Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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<typeof ComputeActionRefSchema>): 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<typeof VerifyAnchorSchema>): Promise<string> {
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();
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
base: "https://mainnet.base.org",
arbitrum: "https://arb1.arbitrum.io/rpc",
ink: "https://rpc-gel.inkonchain.com",
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./actionRefVerifyActionProvider";
Original file line number Diff line number Diff line change
@@ -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();
Loading
Loading