Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
127 changes: 127 additions & 0 deletions src/contracts-api/maaActions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>) {
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");
});
});
37 changes: 37 additions & 0 deletions src/contracts-api/maaActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
MAACreateRuleInput,
MAACreateRuleResult,
MAAEvent,
MAAExecuteInput,
MAAInstance,
MAAJoinResult,
MAAOwnedWallet,
Expand Down Expand Up @@ -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<string> {
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<MAARule | null> {
const rows = await this.callRows("maa_get_rule", { $rule_id: toBytes(ruleId) });
Expand Down
1 change: 1 addition & 0 deletions src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export type {
MAACreateRuleInput,
MAACreateRuleResult,
MAAJoinResult,
MAAExecuteInput,
MAARule,
MAAAllowedAction,
MAAInstance,
Expand Down
25 changes: 25 additions & 0 deletions src/types/maa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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;
Expand Down
Loading