From 69631fa3c1fbe7cd86f7fecf6b16de873e11f5c9 Mon Sep 17 00:00:00 2001 From: giskard09 Date: Thu, 20 Aug 2026 23:01:27 -0300 Subject: [PATCH] feat(action-providers): add ActionRefVerify provider Read-only, no wallet, no transactions. Derives action_ref (action-ref-v1: SHA-256 of RFC 8785 JCS over agent_id/action_type/scope/timestamp) for a declared action, and independently checks the public chain for a matching Anchored event against the permissionless AnchorRegistry contract. Modeled on X402ActionProvider.retryWithX402's real request/response shape. Full worked example (incl. a real on-chain anchor) at github.com/giskard09/coinbase-x402-action-ref-anchor. Tests: 6/6 passing. Lint, prettier, tsc --noEmit all clean. --- .../actionRefVerify/README.md | 50 +++++ .../actionRefVerifyActionProvider.test.ts | 106 ++++++++++ .../actionRefVerifyActionProvider.ts | 183 ++++++++++++++++++ .../actionRefVerify/constants.ts | 14 ++ .../action-providers/actionRefVerify/index.ts | 1 + .../actionRefVerify/schemas.ts | 36 ++++ .../action-providers/actionRefVerify/utils.ts | 53 +++++ .../agentkit/src/action-providers/index.ts | 1 + 8 files changed, 444 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/actionRefVerify/README.md create mode 100644 typescript/agentkit/src/action-providers/actionRefVerify/actionRefVerifyActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/actionRefVerify/actionRefVerifyActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/actionRefVerify/constants.ts create mode 100644 typescript/agentkit/src/action-providers/actionRefVerify/index.ts create mode 100644 typescript/agentkit/src/action-providers/actionRefVerify/schemas.ts create mode 100644 typescript/agentkit/src/action-providers/actionRefVerify/utils.ts diff --git a/typescript/agentkit/src/action-providers/actionRefVerify/README.md b/typescript/agentkit/src/action-providers/actionRefVerify/README.md new file mode 100644 index 000000000..25df7855a --- /dev/null +++ b/typescript/agentkit/src/action-providers/actionRefVerify/README.md @@ -0,0 +1,50 @@ +# Action Ref Verify Action Provider + +This directory contains the `ActionRefVerifyActionProvider` implementation, which provides actions to derive and independently check content-addressed references for agent actions (`action-ref-v1` spec). + +## Overview + +`ActionRefVerifyActionProvider` is a read-only, no-wallet action provider that: + +1. Derives `action_ref` — a deterministic, content-addressed identifier for a declared agent action (`SHA-256` of RFC 8785 JCS over `{agent_id, action_type, scope, timestamp}`). +2. Checks whether a given `action_ref` has been anchored on-chain, via a permissionless `AnchorRegistry.anchor(bytes32)` call (same CREATE2 address on Base, Arbitrum One, and Ink). + +It makes no transactions and requires no wallet — the anchor itself, if any, is written by whoever chooses to (the agent's own operator, or any third party), independently of this provider. + +A full worked example, including a real on-chain anchor, is published at [`giskard09/coinbase-x402-action-ref-anchor`](https://github.com/giskard09/coinbase-x402-action-ref-anchor) — modeled on this repo's own `X402ActionProvider.retryWithX402`. + +## Directory Structure + +``` +actionRefVerify/ +├── actionRefVerifyActionProvider.ts # Main provider with compute/verify actions +├── actionRefVerifyActionProvider.test.ts # Test file for the provider +├── schemas.ts # Action schemas +├── constants.ts # AnchorRegistry address, RPC endpoints, event topic +├── utils.ts # JCS canonicalization + SHA-256 helpers +├── index.ts # Main exports +└── README.md # This file +``` + +## Actions + +### `compute_action_ref` + +Computes `action_ref` from four declared fields, per `action-ref-v1`. Pure derivation — no network calls. + +### `verify_action_ref_anchor` + +Queries the given chain's public RPC directly for an `Anchored(bytes32,address,uint256)` event matching a given `action_ref`. Does not trust any off-chain report of anchor status — it reads the chain. + +## What this does NOT do + +- Does not verify that a declared action actually happened as described. +- Does not evaluate whether an action was safe or advisable. +- Does not sign, dispatch, or modify any transaction, payment, or other agent action. +- Does not require any change to any other provider's API — it composes with any action's declared inputs/outputs after the fact. + +## Notes + +For more information on the `action-ref-v1` spec, see [argentum-core/docs/spec/action-ref.md](https://github.com/giskard09/argentum-core/blob/master/docs/spec/action-ref.md). + +For more information on all the available action providers & wallet providers, see the [README.md](../../../README.md) file. diff --git a/typescript/agentkit/src/action-providers/actionRefVerify/actionRefVerifyActionProvider.test.ts b/typescript/agentkit/src/action-providers/actionRefVerify/actionRefVerifyActionProvider.test.ts new file mode 100644 index 000000000..3e94fbdca --- /dev/null +++ b/typescript/agentkit/src/action-providers/actionRefVerify/actionRefVerifyActionProvider.test.ts @@ -0,0 +1,106 @@ +import { actionRefVerifyActionProvider } from "./actionRefVerifyActionProvider"; + +describe("ActionRefVerifyActionProvider", () => { + const provider = actionRefVerifyActionProvider(); + const fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("computeActionRefAction", () => { + it("derives action_ref matching the action-ref-v1 reference implementation", () => { + // Cross-checked against argentum-core's reference implementation + // (plugins/agt_evidence_anchor/action_ref.py) and against the + // published worked example (giskard09/coinbase-x402-action-ref-anchor). + const result = provider.computeActionRefAction({ + agentId: "worked-example.coinbase-x402-action-ref-anchor", + actionType: "agentkit.x402.retry_http_request_with_x402", + scope: "base:usdc:pay-and-fetch", + timestamp: "2026-08-20T22:00:00.000Z", + }); + const parsed = JSON.parse(result); + expect(parsed.actionRef).toBe( + "3a8b0736b88af42b32160233fb54d9dc85bef257537d83fd604225e765d2401b", + ); + expect(parsed.anchorRefBytes32).toBe( + "0x3a8b0736b88af42b32160233fb54d9dc85bef257537d83fd604225e765d2401b", + ); + }); + + it("is deterministic — same input always yields the same ref", () => { + const args = { + agentId: "agent-1", + actionType: "test.action", + scope: "test:scope", + timestamp: "2026-01-01T00:00:00.000Z", + }; + const first = provider.computeActionRefAction(args); + const second = provider.computeActionRefAction(args); + expect(first).toBe(second); + }); + }); + + describe("verifyActionRefAnchor", () => { + it("returns anchored:true when a matching Anchored event is found", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + result: [ + { + transactionHash: "0xabc123", + blockNumber: "0x2fda6f8", + topics: [ + "0xfe2289542f7a0110ac112c3a4d712afdcaaf2900a1326f4e6f340b563a0e8734", + "0x" + "aa".repeat(32), + "0x000000000000000000000000dcc84e9798e8eb1b1b48a31b8f35e5aa7b83dbf4", + ], + data: "0x0000000000000000000000000000000000000000000000000000000068a5f3a3", + }, + ], + }), + }); + + const result = await provider.verifyActionRefAnchor({ + actionRef: "aa".repeat(32), + chain: "base", + }); + const parsed = JSON.parse(result); + expect(parsed.anchored).toBe(true); + expect(parsed.txHash).toBe("0xabc123"); + }); + + it("returns anchored:false when no event is found", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ result: [] }), + }); + + const result = await provider.verifyActionRefAnchor({ + actionRef: "bb".repeat(32), + chain: "base", + }); + const parsed = JSON.parse(result); + expect(parsed.anchored).toBe(false); + }); + + it("handles RPC errors gracefully", async () => { + fetchMock.mockRejectedValue(new Error("RPC unreachable")); + + const result = await provider.verifyActionRefAnchor({ + actionRef: "cc".repeat(32), + chain: "base", + }); + const parsed = JSON.parse(result); + expect(parsed.error).toBe(true); + expect(parsed.details).toContain("RPC unreachable"); + }); + }); + + describe("supportsNetwork", () => { + it("returns true for any network", () => { + expect(provider.supportsNetwork()).toBe(true); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/actionRefVerify/actionRefVerifyActionProvider.ts b/typescript/agentkit/src/action-providers/actionRefVerify/actionRefVerifyActionProvider.ts new file mode 100644 index 000000000..c13961477 --- /dev/null +++ b/typescript/agentkit/src/action-providers/actionRefVerify/actionRefVerifyActionProvider.ts @@ -0,0 +1,183 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { ComputeActionRefSchema, VerifyAnchorSchema } from "./schemas"; +import { ANCHOR_REGISTRY_ADDRESS, ANCHORED_TOPIC0, RPC_ENDPOINTS } from "./constants"; +import { computeActionRef } from "./utils"; + +/** + * ActionRefVerifyActionProvider — read-only, no wallet, no transactions. + * + * Derives `action_ref` (action-ref-v1: SHA-256 of RFC 8785 JCS over + * {agent_id, action_type, scope, timestamp}) for a declared action, and + * checks whether it has been anchored against the permissionless + * AnchorRegistry contract (`anchor(bytes32)`, same CREATE2 address on Base, + * Arbitrum One, and Ink). + * + * This does not verify that the declared action actually happened as + * described, or evaluate whether it was safe or advisable — it only + * confirms that a specific content-addressed reference for it exists, + * independently, on a public chain. See + * https://github.com/giskard09/coinbase-x402-action-ref-anchor for a full + * worked example. + */ +export class ActionRefVerifyActionProvider extends ActionProvider { + /** + * Constructor for the ActionRefVerifyActionProvider class. + */ + constructor() { + super("action-ref-verify", []); + } + + /** + * Computes an action_ref from four declared fields, per action-ref-v1. + * + * @param args - The four preimage fields + * @returns A JSON string with the derived action_ref + */ + @CreateAction({ + name: "compute_action_ref", + description: `Computes action_ref — a deterministic, content-addressed identifier for a +declared agent action (action-ref-v1 spec: SHA-256 of RFC 8785 JCS over +{agent_id, action_type, scope, timestamp}). + +This does not record, sign, or dispatch anything. It only derives a +reference any third party can independently recompute from the same four +declared fields, so it can later be checked against an on-chain anchor with +verify_action_ref_anchor.`, + schema: ComputeActionRefSchema, + }) + computeActionRefAction(args: z.infer): string { + const actionRef = computeActionRef(args.agentId, args.actionType, args.scope, args.timestamp); + return JSON.stringify( + { + actionRef, + anchorRefBytes32: `0x${actionRef}`, + anchorRegistry: ANCHOR_REGISTRY_ADDRESS, + note: "Anchor this ref with a permissionless anchor(bytes32) call, then use verify_action_ref_anchor to confirm it independently.", + }, + null, + 2, + ); + } + + /** + * Checks whether a given action_ref has been anchored on-chain. + * + * @param args - The action_ref and target chain + * @returns A JSON string with the anchor status + */ + @CreateAction({ + name: "verify_action_ref_anchor", + description: `Checks whether a given action_ref has been anchored on-chain via +AnchorRegistry.anchor(bytes32) (Base, Arbitrum One, or Ink — same address on +all three). Queries the public RPC directly for the Anchored event; does +not trust any off-chain report of anchor status. + +Returns anchored:true with the tx hash, block, and the address that +anchored it if found; anchored:false otherwise. Absence of an anchor does +not mean the underlying action didn't happen — it means nobody chose to +anchor a reference to it, which is a separate, opt-in step.`, + schema: VerifyAnchorSchema, + }) + async verifyActionRefAnchor(args: z.infer): Promise { + const ref = args.actionRef.startsWith("0x") ? args.actionRef : `0x${args.actionRef}`; + const rpcUrl = RPC_ENDPOINTS[args.chain]; + + try { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "eth_getLogs", + params: [ + { + address: ANCHOR_REGISTRY_ADDRESS, + fromBlock: "earliest", + toBlock: "latest", + topics: [ANCHORED_TOPIC0, ref], + }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`RPC HTTP error: ${response.status}`); + } + + const body = await response.json(); + if (body.error) { + throw new Error(`RPC error: ${JSON.stringify(body.error)}`); + } + + const logs = body.result as Array<{ + transactionHash: string; + blockNumber: string; + topics: string[]; + data: string; + }>; + + if (!logs || logs.length === 0) { + return JSON.stringify( + { + anchored: false, + actionRef: ref, + chain: args.chain, + note: "No Anchored event found for this ref on this chain. Absence does not mean the action didn't happen — anchoring is a separate, opt-in step.", + }, + null, + 2, + ); + } + + const log = logs[0]; + const anchoredBy = `0x${log.topics[2].slice(-40)}`; + const blockTimestamp = parseInt(log.data, 16); + + return JSON.stringify( + { + anchored: true, + actionRef: ref, + chain: args.chain, + txHash: log.transactionHash, + block: parseInt(log.blockNumber, 16), + anchoredBy, + blockTimestamp, + }, + null, + 2, + ); + } catch (error: unknown) { + return JSON.stringify( + { + error: true, + message: "Failed to query AnchorRegistry", + details: error instanceof Error ? error.message : String(error), + }, + null, + 2, + ); + } + } + + /** + * Checks if the action provider supports the given network. + * Read-only and RPC-agnostic — supports any network, since the checked + * chain is chosen explicitly per call via the `chain` argument, not + * derived from the agent's own wallet network. + * + * @returns True on all networks. + */ + supportsNetwork(): boolean { + return true; + } +} + +/** + * Creates a new instance of the ActionRefVerifyActionProvider. + * + * @returns A new ActionRefVerifyActionProvider instance + */ +export const actionRefVerifyActionProvider = () => new ActionRefVerifyActionProvider(); diff --git a/typescript/agentkit/src/action-providers/actionRefVerify/constants.ts b/typescript/agentkit/src/action-providers/actionRefVerify/constants.ts new file mode 100644 index 000000000..9e1ee3f9c --- /dev/null +++ b/typescript/agentkit/src/action-providers/actionRefVerify/constants.ts @@ -0,0 +1,14 @@ +/** + * Same CREATE2 AnchorRegistry address across Base, Arbitrum One, and Ink — + * see https://github.com/giskard09/argentum-core/blob/master/docs/spec/counterparty-ref.md + */ +export const ANCHOR_REGISTRY_ADDRESS = "0x49fEcA52bC634a9Ab773226D16619deC547794aa"; + +/** keccak256("Anchored(bytes32,address,uint256)") */ +export const ANCHORED_TOPIC0 = "0xfe2289542f7a0110ac112c3a4d712afdcaaf2900a1326f4e6f340b563a0e8734"; + +export const RPC_ENDPOINTS: Record = { + base: "https://mainnet.base.org", + arbitrum: "https://arb1.arbitrum.io/rpc", + ink: "https://rpc-gel.inkonchain.com", +}; diff --git a/typescript/agentkit/src/action-providers/actionRefVerify/index.ts b/typescript/agentkit/src/action-providers/actionRefVerify/index.ts new file mode 100644 index 000000000..89381c842 --- /dev/null +++ b/typescript/agentkit/src/action-providers/actionRefVerify/index.ts @@ -0,0 +1 @@ +export * from "./actionRefVerifyActionProvider"; diff --git a/typescript/agentkit/src/action-providers/actionRefVerify/schemas.ts b/typescript/agentkit/src/action-providers/actionRefVerify/schemas.ts new file mode 100644 index 000000000..c6f4d0984 --- /dev/null +++ b/typescript/agentkit/src/action-providers/actionRefVerify/schemas.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +/** + * Input schema for computing an action_ref (action-ref-v1: SHA-256 of RFC + * 8785 JCS over {agent_id, action_type, scope, timestamp}). + */ +export const ComputeActionRefSchema = z + .object({ + agentId: z.string().describe("The agent_id preimage field"), + actionType: z + .string() + .describe( + "The action_type preimage field, e.g. 'agentkit.x402.retry_http_request_with_x402'", + ), + scope: z.string().describe("The scope preimage field, e.g. 'base:usdc:pay-and-fetch'"), + timestamp: z + .string() + .describe("RFC 3339 UTC with 3-digit ms precision, e.g. '2026-08-20T22:00:00.000Z'"), + }) + .strict(); + +/** + * Input schema for verifying that an action_ref was anchored on-chain. + */ +export const VerifyAnchorSchema = z + .object({ + actionRef: z + .string() + .regex(/^(0x)?[0-9a-fA-F]{64}$/, "actionRef must be a 32-byte hex string") + .describe("The action_ref (bytes32 hex, with or without 0x prefix) to check"), + chain: z + .enum(["base", "arbitrum", "ink"]) + .default("base") + .describe("Which deployment of AnchorRegistry to query — same CREATE2 address on all three"), + }) + .strict(); diff --git a/typescript/agentkit/src/action-providers/actionRefVerify/utils.ts b/typescript/agentkit/src/action-providers/actionRefVerify/utils.ts new file mode 100644 index 000000000..df1e40a00 --- /dev/null +++ b/typescript/agentkit/src/action-providers/actionRefVerify/utils.ts @@ -0,0 +1,53 @@ +import { createHash } from "crypto"; + +/** + * RFC 8785 JCS encoding for a flat object of string values: sorted keys, + * no extra whitespace, UTF-8. For string-only values this is equivalent to + * JSON.stringify with sorted keys — same approach used by + * argentum-core/plugins/agt_evidence_anchor/action_ref.py (_jcs_encode). + * + * @param obj - A flat object with string values only. + * @returns The canonicalized JSON string. + */ +export function jcsEncode(obj: Record): string { + const sorted: Record = {}; + for (const key of Object.keys(obj).sort()) { + sorted[key] = obj[key]; + } + return JSON.stringify(sorted); +} + +/** + * Computes the SHA-256 hex digest of a UTF-8 string. + * + * @param input - The string to hash. + * @returns The lowercase hex-encoded digest. + */ +export function sha256Hex(input: string): string { + return createHash("sha256").update(input, "utf8").digest("hex"); +} + +/** + * action-ref-v1: SHA-256(JCS({agent_id, action_type, scope, timestamp})). + * See https://github.com/giskard09/argentum-core/blob/master/docs/spec/action-ref.md + * + * @param agentId - The agent_id preimage field. + * @param actionType - The action_type preimage field. + * @param scope - The scope preimage field. + * @param timestamp - RFC 3339 UTC timestamp with 3-digit ms precision. + * @returns The 64-character lowercase hex action_ref. + */ +export function computeActionRef( + agentId: string, + actionType: string, + scope: string, + timestamp: string, +): string { + const payload = { + agent_id: agentId, + action_type: actionType, + scope, + timestamp, + }; + return sha256Hex(jcsEncode(payload)); +} diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..e36bfb438 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -4,6 +4,7 @@ export * from "./actionProvider"; export * from "./customActionProvider"; export * from "./across"; +export * from "./actionRefVerify"; export * from "./alchemy"; export * from "./baseAccount"; export * from "./basename";