diff --git a/package.json b/package.json index 679e113..4521d8c 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "test:integration": "vitest tests/integration/ --run" }, "dependencies": { - "@trufnetwork/kwil-js": "0.9.11-rc.0", + "@trufnetwork/kwil-js": "0.9.12", "crypto-hash": "^3.1.0", "ethers": "^6.13.5", "lodash": "^4.17.21", diff --git a/src/contracts-api/maaActions.test.ts b/src/contracts-api/maaActions.test.ts new file mode 100644 index 0000000..d9186b3 --- /dev/null +++ b/src/contracts-api/maaActions.test.ts @@ -0,0 +1,127 @@ +import { KwilSigner, NodeKwil, Utils } from "@trufnetwork/kwil-js"; +import { describe, it, expect, vi } from "vitest"; +import { MAAAction } from "./maaActions"; + +/** + * Pure-unit tests for executeAgentAction (the maa_exec submission wrapper). The wire encoding and + * broadcast live in kwil-js (asserted there against the Go golden vector); this layer only normalizes + * the friendly input, forwards a MAAExecBody to kwil.maaExec, and surfaces the tx hash — so the kwil + * client is mocked. The on-chain behaviour (gate, role, allow-list, @caller rewrite) is covered by the + * node integration tests, not reachable over the black-box SDK harness. + */ + +const mockSigner = { signatureType: "secp256k1_ep" } as unknown as KwilSigner; + +/** A successful maaExec response carries the submission tx hash in data.tx_hash. */ +const okResponse = (txHash: string) => ({ status: 200, data: { tx_hash: txHash } }); + +function makeAction(maaExec: ReturnType) { + const mockKwil = { maaExec } as unknown as NodeKwil; + return new MAAAction(mockKwil, mockSigner); +} + +const addr20Hex = "0x" + "11".repeat(20); +const addr20Bytes = new Uint8Array(20).fill(0x11); + +describe("MAAAction.executeAgentAction", () => { + it("forwards a maa_exec body (hex address normalized to bytes) and returns the tx hash", async () => { + const maaExec = vi.fn().mockResolvedValue(okResponse("0xdeadbeef")); + const action = makeAction(maaExec); + + const txHash = await action.executeAgentAction({ + maaAddress: addr20Hex, + action: "ob_place_order", + args: ["0xabc", 42], + }); + + expect(txHash).toBe("0xdeadbeef"); + expect(maaExec).toHaveBeenCalledTimes(1); + expect(maaExec).toHaveBeenCalledWith( + { + maaAddress: addr20Bytes, + namespace: "main", + action: "ob_place_order", + inputs: ["0xabc", 42], + types: undefined, + }, + mockSigner, + ); + }); + + it("accepts a raw 20-byte Uint8Array address unchanged", async () => { + const maaExec = vi.fn().mockResolvedValue(okResponse("0x01")); + const action = makeAction(maaExec); + + await action.executeAgentAction({ maaAddress: addr20Bytes, action: "x" }); + + const body = maaExec.mock.calls[0][0]; + expect(body.maaAddress).toEqual(addr20Bytes); + expect(body.maaAddress.length).toBe(20); + }); + + it("defaults namespace to 'main' and respects an explicit namespace", async () => { + const maaExec = vi.fn().mockResolvedValue(okResponse("0x01")); + const action = makeAction(maaExec); + + await action.executeAgentAction({ maaAddress: addr20Hex, action: "a" }); + expect(maaExec.mock.calls[0][0].namespace).toBe("main"); + + await action.executeAgentAction({ maaAddress: addr20Hex, action: "a", namespace: "custom" }); + expect(maaExec.mock.calls[1][0].namespace).toBe("custom"); + }); + + it("defaults args to an empty array for a no-arg action", async () => { + const maaExec = vi.fn().mockResolvedValue(okResponse("0x01")); + const action = makeAction(maaExec); + + await action.executeAgentAction({ maaAddress: addr20Hex, action: "noop" }); + + expect(maaExec.mock.calls[0][0].inputs).toEqual([]); + }); + + it("forwards positional args and the optional per-arg type overrides", async () => { + const maaExec = vi.fn().mockResolvedValue(okResponse("0x01")); + const action = makeAction(maaExec); + const types = [Utils.DataType.Numeric(78, 0)]; + + await action.executeAgentAction({ + maaAddress: addr20Hex, + action: "a", + args: ["1000"], + types, + }); + + const body = maaExec.mock.calls[0][0]; + expect(body.inputs).toEqual(["1000"]); + expect(body.types).toBe(types); + }); + + it("rejects an address that is not 20 bytes before broadcasting", async () => { + const maaExec = vi.fn(); + const action = makeAction(maaExec); + + await expect( + action.executeAgentAction({ maaAddress: "0x" + "11".repeat(19), action: "a" }), + ).rejects.toThrow("maa_address must be 20 bytes"); + expect(maaExec).not.toHaveBeenCalled(); + }); + + it("rejects an empty action before broadcasting", async () => { + const maaExec = vi.fn(); + const action = makeAction(maaExec); + + await expect( + action.executeAgentAction({ maaAddress: addr20Hex, action: "" }), + ).rejects.toThrow("action must not be empty"); + expect(maaExec).not.toHaveBeenCalled(); + }); + + it("throws when the node returns no tx hash", async () => { + const maaExec = vi.fn().mockResolvedValue({ status: 200, data: {} }); + const action = makeAction(maaExec); + + await expect( + action.executeAgentAction({ maaAddress: addr20Hex, action: "a" }), + ).rejects.toThrow("no transaction hash returned"); + }); +}); diff --git a/src/contracts-api/maaActions.ts b/src/contracts-api/maaActions.ts index a2f6e90..0ab3d3e 100644 --- a/src/contracts-api/maaActions.ts +++ b/src/contracts-api/maaActions.ts @@ -17,6 +17,7 @@ import { MAACreateRuleInput, MAACreateRuleResult, MAAEvent, + MAAExecuteInput, MAAInstance, MAAJoinResult, MAAOwnedWallet, @@ -124,6 +125,42 @@ export class MAAAction extends Action { return { txHash, maaAddress, maaAddressHex: hexlify(maaAddress) }; } + /** + * executeAgentAction runs one allow-listed action AS the agent wallet (a maa_exec transaction). The + * signer is the rule's restricted agent (a delegated action) or the unrestricted owner (e.g. a + * withdrawal); the node rewrites @caller to the wallet after verifying the rule's role and allow-list. + * Returns the submission tx hash (parity with createAgentRule/joinAgentAddress and the Go/Python SDKs). + * + * The network must have activated maa_exec; before activation the node rejects the payload with an + * "unknown payload type" error, surfaced verbatim. + */ + async executeAgentAction(input: MAAExecuteInput): Promise { + const maaAddress = toBytes(input.maaAddress); + if (maaAddress.length !== 20) { + throw new Error(`maa_address must be 20 bytes, got ${maaAddress.length}`); + } + if (!input.action) { + throw new Error("action must not be empty"); + } + + const result = await this.kwilClient.maaExec( + { + maaAddress, + namespace: input.namespace ?? "main", + action: input.action, + inputs: input.args ?? [], + types: input.types, + }, + this.kwilSigner, + ); + + const txHash = result.data?.tx_hash; + if (!txHash) { + throw new Error("executeAgentAction: no transaction hash returned"); + } + return txHash; + } + /** getRule returns a rule's terms (maa_get_rule), or null if no such rule exists. */ async getRule(ruleId: MAABytesLike): Promise { const rows = await this.callRows("maa_get_rule", { $rule_id: toBytes(ruleId) }); diff --git a/src/internal.ts b/src/internal.ts index 6be1f60..5db66cb 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -91,6 +91,7 @@ export type { MAACreateRuleInput, MAACreateRuleResult, MAAJoinResult, + MAAExecuteInput, MAARule, MAAAllowedAction, MAAInstance, diff --git a/src/types/maa.ts b/src/types/maa.ts index f4e490f..ed2d3b5 100644 --- a/src/types/maa.ts +++ b/src/types/maa.ts @@ -8,6 +8,7 @@ * (migration 048): addresses and hashes are 0x-prefixed lowercase hex as returned by the node. */ +import { Types } from "@trufnetwork/kwil-js"; import { MAABytesLike } from "../util/MAAAddress"; /** Parameters for createAgentRule (on-chain maa_create_rule). */ @@ -41,6 +42,30 @@ export interface MAAJoinResult { maaAddressHex: string; } +/** + * Parameters for executeAgentAction (a maa_exec transaction: run an inner action AS the agent wallet). + * + * The signer is either the rule's restricted agent (running a delegated, allow-listed action) or the + * unrestricted owner (e.g. withdrawing); the node rewrites @caller to maaAddress after checking the + * rule's role and allow-list. + */ +export interface MAAExecuteInput { + /** 20-byte agent-wallet (MAA) address to act as, as 0x-hex or raw bytes. */ + maaAddress: MAABytesLike; + /** Inner action name to run as the wallet (must be allow-listed for the wallet's rule). */ + action: string; + /** Positional arguments for the inner action (a single call). Omit for a no-arg action. */ + args?: Types.ValueType[]; + /** Inner action namespace; defaults to "main". */ + namespace?: string; + /** + * Optional per-argument type overrides, parallel to args. A maa_exec payload is not schema-validated + * before broadcast, so NUMERIC/UUID/BYTEA arguments that can't be inferred from a plain JS value need + * an explicit type (e.g. Utils.DataType.Numeric(...)). Omit to let each type be inferred from the value. + */ + types?: Types.MAAExecBody["types"]; +} + /** A rule's terms (maa_get_rule). */ export interface MAARule { ruleId: string;