diff --git a/contracts-c/firewall/firewall.c b/contracts-c/firewall/firewall.c new file mode 100644 index 0000000..2adec1f --- /dev/null +++ b/contracts-c/firewall/firewall.c @@ -0,0 +1,182 @@ +//------------------------------------------------------------------------------ +/* + +Operations: + +// invoke ops are: + // C - create (user) + // U - update (verified) + // A - add (verified) + // R - remove (verified) + +*/ +//============================================================================== + +#include "hookapi.h" + +#define SVAR(x) &(x), sizeof(x) + +#define DONE(x)\ + return accept(SBUF(x), __LINE__) + +#define NOPE(x)\ + return rollback(SBUF(x), __LINE__) + +#define FLIP_ENDIAN_64(n) ((uint64_t)(((n & 0xFFULL) << 56ULL) | \ + ((n & 0xFF00ULL) << 40ULL) | \ + ((n & 0xFF0000ULL) << 24ULL) | \ + ((n & 0xFF000000ULL) << 8ULL) | \ + ((n & 0xFF00000000ULL) >> 8ULL) | \ + ((n & 0xFF0000000000ULL) >> 24ULL) | \ + ((n & 0xFF000000000000ULL) >> 40ULL) | \ + ((n & 0xFF00000000000000ULL) >> 56ULL))) + +#define ttSET_HOOK 22 + +#define FIREWALL_MODEL 77 +#define BACKUP_OFFSET 33 +#define AMOUNT_OFFSET 61 + +uint8_t nonce_ns[32] = { + 0x21, 0x2C, 0xCE, 0x06, 0x94, 0xF0, 0x63, 0xCC, + 0x1D, 0xB8, 0xCF, 0xA3, 0x46, 0xE2, 0x96, 0xF3, + 0xE1, 0xF9, 0xE1, 0xF0, 0xFE, 0x93, 0x12, 0xF6, + 0x87, 0x58, 0x00, 0xBA, 0x70, 0x57, 0x8F, 0xFE +}; + +int64_t hook(uint32_t r) +{ + _g(1,1); + + uint8_t txn_id[32]; + otxn_id(txn_id, 32, 0); + + uint8_t hook_accid[32]; + hook_account(hook_accid + 12, 20); + + uint8_t otxn_accid[32]; + otxn_field(otxn_accid + 12, 20, sfAccount); + + uint8_t otxn_dstid[32]; + otxn_field(otxn_dstid + 12, 20, sfDestination); + + // Incoming + if (!BUFFER_EQUAL_20(hook_accid + 12, otxn_accid + 12)) + DONE("Firewall.c: Accepting Incoming Txn."); + + int64_t tt = otxn_type(); + + // Operation + uint8_t op; + if (otxn_param(&op, 1, "OP", 2) != 1 && tt != ttPAYMENT) + NOPE("Firewall.c: Missing OP parameter on Invoke."); + + uint8_t firewall_model[FIREWALL_MODEL]; + int64_t firewall_state = state(SBUF(firewall_model), hook_accid + 12, 20); + if ((op == 'U' || op == 'A' || op == 'R') && firewall_state == DOESNT_EXIST) + NOPE("Firewall.c: Firewall does not exist."); + + if (tt != ttINVOKE && tt != ttPAYMENT) + NOPE("Firewall.c: Rejecting Outgoing Txn. (TT)"); + + // Validate the incoming txn + if (tt == ttINVOKE && op != 'C' && op != 'U' && op != 'A' && op != 'R') + NOPE("Firewall.c: Rejecting Outgoing Txn. (OP)"); + + uint32_t nonce_seq; + if (tt == ttINVOKE && (op == 'U' || op == 'A' || op == 'R')) + { + state_foreign(SVAR(nonce_seq), hook_accid + 12, 20, SBUF(nonce_ns), hook_accid + 12, 20); + uint8_t sig_buf[256]; + int64_t sig_len = otxn_param(SBUF(sig_buf), "SIG", 3); + uint8_t nonce_buf[4]; + UINT32_TO_BUF(nonce_buf, nonce_seq) + if (util_verify(nonce_buf, 4, sig_buf, sig_len, firewall_model + 0u, 33) != 1) + NOPE("Firewall.c: Signature verification failed."); + } + + if (tt == ttPAYMENT) + { + otxn_slot(1); + slot_subfield(1, sfAmount, 2); + uint8_t amt[48]; + if (slot_size(2) == 48) + DONE("Firewall.c: Allowing Outgoing Txn. (IOU)"); + + int64_t amt_xfl = slot_float(2); + int64_t firewall_xfl = FLIP_ENDIAN_64(UINT64_FROM_BUF(firewall_model + AMOUNT_OFFSET)); + + if (amt_xfl <= 0 || float_compare(amt_xfl, firewall_xfl, COMPARE_LESS | COMPARE_EQUAL)) + DONE("Firewall.c: Allowing Outgoing Txn. (Amount)"); + + if (BUFFER_EQUAL_20(firewall_model + BACKUP_OFFSET, otxn_dstid + 12) == 1) + DONE("Firewall.c: Allowing Outgoing Txn. (Backup)"); + + uint8_t whitelist_accid[32]; + if (state(SBUF(whitelist_accid), otxn_dstid + 12, 20) != DOESNT_EXIST) + DONE("Firewall.c: Allowing Outgoing Txn. (Whitelisted)"); + + NOPE("Firewall.c: Rejecting Outgoing Txn."); + } + + // action + switch (op) + { + // create + case 'C': + { + uint8_t firewall_model[FIREWALL_MODEL]; + if (state(SBUF(firewall_model), hook_accid + 12, 20) != DOESNT_EXIST) + NOPE("Firewall.c: Firewall already exists."); + + otxn_param(SBUF(firewall_model), "FM", 2); + + state_set(SBUF(firewall_model), hook_accid + 12, 20); + DONE("Firewall.c: Set Firewall."); + } + + // update + case 'U': + { + // TODO: Validate Which Fields Are Allowed To Be Updated + uint8_t firewall_model[FIREWALL_MODEL]; + otxn_param(SBUF(firewall_model), "FM", 2); + state_set(SBUF(firewall_model), hook_accid + 12, 20); + DONE("Firewall.c: Update Firewall."); + break; + } + + // add whitelist + case 'A': + { + uint8_t whitelist_accid[20]; + otxn_param(SBUF(whitelist_accid), "ACC", 3); + state_set(SBUF(txn_id), whitelist_accid, 20); + nonce_seq++; + if (state_foreign_set(SVAR(nonce_seq), hook_accid + 12, 20, SBUF(nonce_ns), hook_accid + 12, 20) != 4) + NOPE("Firewall.c: Failed to set state."); + + DONE("Firewall.c: Add Whitelist Account."); + } + + // add whitelist + case 'R': + { + uint8_t whitelist_accid[20]; + otxn_param(SBUF(whitelist_accid), "ACC", 3); + state_set(0, 0, whitelist_accid, 20); + nonce_seq++; + if (state_foreign_set(SVAR(nonce_seq), hook_accid + 12, 20, SBUF(nonce_ns), hook_accid + 12, 20) != 4) + NOPE("Firewall.c: Failed to set state."); + + DONE("Firewall.c: Remove Whitelist Account."); + } + + default: + { + DONE("Firewall.c: Unknown operation."); + } + } + + return 0; +} \ No newline at end of file diff --git a/test/integration-c/firewall/firewall.test.ts b/test/integration-c/firewall/firewall.test.ts new file mode 100644 index 0000000..8612e82 --- /dev/null +++ b/test/integration-c/firewall/firewall.test.ts @@ -0,0 +1,293 @@ +// xrpl +import { + Client, + Wallet, + Invoke, + SetHookFlags, + TransactionMetadata, + xrpToDrops, + Payment, +} from '@transia/xrpl' +// xrpl-helpers +import { + XrplIntegrationTestContext, + setupClient, + teardownClient, + serverUrl, +} from '@transia/hooks-toolkit/dist/npm/src/libs/xrpl-helpers' +// src +import { + Xrpld, + SetHookParams, + setHooksV3, + createHookPayload, + iHookParamEntry, + iHookParamName, + iHookParamValue, + ExecutionUtility, + StateUtility, + padHexString, + xrpAddressToHex, + hexNamespace, + decodeModel, + uint32ToHex, + hexToUInt32, + flipHex, + calculateHookOff, +} from '@transia/hooks-toolkit/dist/npm/src' +import { FirewallModel } from './models/FirewallModel' +import { sign, verify } from '@transia/ripple-keypairs' + +export async function getNextNonce( + client: Client, + hookAccount: string, + nonceAccount: string, + namespace: string +): Promise { + try { + const state = await StateUtility.getHookState( + client, + hookAccount, + padHexString(xrpAddressToHex(nonceAccount)), + hexNamespace(namespace) + ) + return Number(hexToUInt32(flipHex(state.HookStateData))) + } catch (error: any) { + console.log(error.message) + return 0 + } +} + +export async function getFirewall(testContext: XrplIntegrationTestContext) { + try { + const state = await StateUtility.getHookState( + testContext.client, + testContext.hook1.classicAddress, + padHexString(xrpAddressToHex(testContext.hook1.classicAddress)), + hexNamespace('firewall') + ) + console.log(state) + + const decoded = decodeModel(state.HookStateData, FirewallModel) + console.log(decoded) + return true + } catch (error) { + console.log(error) + + return false + } +} + +export async function setFirewall( + testContext: XrplIntegrationTestContext, + firewallModel: FirewallModel +) { + const otxn1param1 = new iHookParamEntry( + new iHookParamName('OP'), + new iHookParamValue('C') + ) + + console.log(firewallModel.encode().toUpperCase()) + console.log(firewallModel.encode().length / 2) + + const otxn1param2 = new iHookParamEntry( + new iHookParamName('FM'), + new iHookParamValue(firewallModel.encode().toUpperCase(), true) + ) + const builtTx: Invoke = { + TransactionType: 'Invoke', + Account: testContext.hook1.classicAddress, + HookParameters: [otxn1param1.toXrpl(), otxn1param2.toXrpl()], + } + + const result = await Xrpld.submit(testContext.client, { + wallet: testContext.hook1, + tx: builtTx, + }) + const hookExecutions = await ExecutionUtility.getHookExecutionsFromMeta( + testContext.client, + result.meta as TransactionMetadata + ) + + expect(hookExecutions.executions[0].HookReturnString).toMatch( + 'Firewall.c: Set Firewall.' + ) +} + +export async function updateWhiteList( + testContext: XrplIntegrationTestContext, + op: string, + kpWallet: Wallet, + account: string, + hookReturn: string +) { + const otxn1param1 = new iHookParamEntry( + new iHookParamName('OP'), + new iHookParamValue(op) + ) + const otxn1param2 = new iHookParamEntry( + new iHookParamName('ACC'), + new iHookParamValue(xrpAddressToHex(account), true) + ) + const nonce = await getNextNonce( + testContext.client, + testContext.hook1.classicAddress, + testContext.hook1.classicAddress, + 'nonces' + ) + const hex = uint32ToHex(nonce) + const signature = sign(hex, kpWallet.privateKey) + expect(verify(hex, signature, kpWallet.publicKey)).toBe(true) + const otxn1param3 = new iHookParamEntry( + new iHookParamName('SIG'), + new iHookParamValue(signature, true) + ) + const builtTx: Invoke = { + TransactionType: 'Invoke', + Account: testContext.hook1.classicAddress, + HookParameters: [ + otxn1param1.toXrpl(), + otxn1param2.toXrpl(), + otxn1param3.toXrpl(), + ], + } + + const result = await Xrpld.submit(testContext.client, { + wallet: testContext.hook1, + tx: builtTx, + }) + const hookExecutions = await ExecutionUtility.getHookExecutionsFromMeta( + testContext.client, + result.meta as TransactionMetadata + ) + + expect(hookExecutions.executions[0].HookReturnString).toMatch(hookReturn) +} + +export async function submitTxn( + client: Client, + account: Wallet, + destination: string, + amount: string, + hookReturn: string +) { + // Payment + const builtTx: Payment = { + TransactionType: 'Payment', + Account: account.classicAddress, + Destination: destination, + Amount: amount, + } + try { + const result = await Xrpld.submit(client, { + wallet: account, + tx: builtTx, + }) + const hookExecutions = await ExecutionUtility.getHookExecutionsFromMeta( + client, + result.meta as TransactionMetadata + ) + + expect(hookExecutions.executions[0].HookReturnString).toMatch(hookReturn) + } catch (error: any) { + expect(error.message).toMatch(hookReturn) + } +} + +describe('firewall', () => { + let testContext: XrplIntegrationTestContext + beforeAll(async () => { + testContext = await setupClient(serverUrl) + const hook1 = createHookPayload({ + version: 0, + createFile: 'firewall', + namespace: 'firewall', + flags: SetHookFlags.hsfOverride, + hookOnArray: [], + }) + hook1.HookOn = calculateHookOff([]) + await setHooksV3({ + client: testContext.client, + seed: testContext.hook1.seed, + hooks: [{ Hook: hook1 }], + } as SetHookParams) + }) + afterAll(async () => { + teardownClient(testContext) + }) + + it('firewall - success', async () => { + const kpWallet = Wallet.fromSeed('sEdSkmmthsWRyiEbr6qsvXbUwsHGjjZ') + if (!(await getFirewall(testContext))) { + const firewallModel = new FirewallModel( + kpWallet.publicKey, + testContext.hook2.classicAddress, + 86400, + 0, + 1000, + 0 + ) + await setFirewall(testContext, firewallModel) + // Add whitelist + await updateWhiteList( + testContext, + 'A', + kpWallet, + testContext.alice.classicAddress, + 'Firewall.c: Add Whitelist Account.' + ) + } + + // Pass Amount + await submitTxn( + testContext.client, + testContext.hook1, + testContext.bob.classicAddress, + xrpToDrops(1000), + 'Firewall.c: Allowing Outgoing Txn. (Amount)' + ) + + // Fail Amount + await submitTxn( + testContext.client, + testContext.hook1, + testContext.bob.classicAddress, + xrpToDrops(1001), + 'Firewall.c: Rejecting Outgoing Txn.' + ) + + // Pass Backup Account + await submitTxn( + testContext.client, + testContext.hook1, + testContext.hook2.classicAddress, + xrpToDrops(1001), + 'Firewall.c: Allowing Outgoing Txn. (Backup)' + ) + + // Pass Whitelist Account + await submitTxn( + testContext.client, + testContext.hook1, + testContext.alice.classicAddress, + xrpToDrops(1001), + 'Firewall.c: Allowing Outgoing Txn. (Whitelisted)' + ) + + await updateWhiteList( + testContext, + 'R', + kpWallet, + testContext.alice.classicAddress, + 'Firewall.c: Remove Whitelist Account.' + ) + + await submitTxn( + testContext.client, + testContext.hook1, + testContext.alice.classicAddress, + xrpToDrops(1001), + 'Firewall.c: Rejecting Outgoing Txn.' + ) + }) +}) diff --git a/test/integration-c/firewall/models/FirewallModel.ts b/test/integration-c/firewall/models/FirewallModel.ts new file mode 100644 index 0000000..389bcc8 --- /dev/null +++ b/test/integration-c/firewall/models/FirewallModel.ts @@ -0,0 +1,57 @@ +import { + BaseModel, + Metadata, + XFL, + XRPAddress, + UInt32, + PublicKey, +} from '@transia/hooks-toolkit/dist/npm/src/libs/binary-models' + +export class FirewallModel extends BaseModel { + publicKey: PublicKey + backupAccount: XRPAddress + timePeriod: UInt32 + startTime: UInt32 + amount: XFL + totalOut: XFL + + // 77 bytes + constructor( + publicKey: PublicKey, // 33 bytes / 0 + backupAccount: XRPAddress, // 20 bytes / 33 + timePeriod: UInt32, // 4 bytes / 53 + startTime: UInt32, // 4 bytes / 57 + amount: XFL, // 8 bytes / 61 + totalOut: XFL // 8 bytes / 69 + ) { + super() + this.publicKey = publicKey + this.backupAccount = backupAccount + this.timePeriod = timePeriod + this.startTime = startTime + this.amount = amount + this.totalOut = totalOut + } + + getMetadata(): Metadata { + return [ + { field: 'publicKey', type: 'publicKey' }, + { field: 'backupAccount', type: 'xrpAddress' }, + { field: 'timePeriod', type: 'uint32' }, + { field: 'startTime', type: 'uint32' }, + { field: 'amount', type: 'xfl' }, + { field: 'totalOut', type: 'xfl' }, + ] + } + + toJSON() { + return { + publicKey: this.publicKey, + backupAccount: this.backupAccount, + timePeriod: this.timePeriod, + startTime: this.startTime, + amount: this.amount, + totalOut: this.totalOut, + } + } +}