@rubicon-caliga/agent-sdk 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +67 -7
  2. package/dist/agent-client.d.ts +4 -0
  3. package/dist/agent-client.d.ts.map +1 -1
  4. package/dist/agent-client.js +6 -0
  5. package/dist/agent-client.js.map +1 -1
  6. package/dist/agent-client.test.d.ts +2 -0
  7. package/dist/agent-client.test.d.ts.map +1 -0
  8. package/dist/agent-client.test.js +110 -0
  9. package/dist/agent-client.test.js.map +1 -0
  10. package/dist/circle-agent-wallet.d.ts +65 -0
  11. package/dist/circle-agent-wallet.d.ts.map +1 -0
  12. package/dist/circle-agent-wallet.js +156 -0
  13. package/dist/circle-agent-wallet.js.map +1 -0
  14. package/dist/circle-agent-wallet.test.d.ts +2 -0
  15. package/dist/circle-agent-wallet.test.d.ts.map +1 -0
  16. package/dist/circle-agent-wallet.test.js +106 -0
  17. package/dist/circle-agent-wallet.test.js.map +1 -0
  18. package/dist/circle-cli-gateway-payment.d.ts +74 -0
  19. package/dist/circle-cli-gateway-payment.d.ts.map +1 -0
  20. package/dist/circle-cli-gateway-payment.js +218 -0
  21. package/dist/circle-cli-gateway-payment.js.map +1 -0
  22. package/dist/circle-cli-gateway-payment.test.d.ts +2 -0
  23. package/dist/circle-cli-gateway-payment.test.d.ts.map +1 -0
  24. package/dist/circle-cli-gateway-payment.test.js +141 -0
  25. package/dist/circle-cli-gateway-payment.test.js.map +1 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/payment-engine.d.ts +0 -14
  31. package/dist/payment-engine.d.ts.map +1 -1
  32. package/dist/payment-engine.js +0 -35
  33. package/dist/payment-engine.js.map +1 -1
  34. package/dist/payment-engine.test.d.ts.map +1 -0
  35. package/package.json +11 -11
  36. package/src/agent-client.test.ts +118 -0
  37. package/src/agent-client.ts +10 -0
  38. package/src/circle-agent-wallet.test.ts +118 -0
  39. package/src/circle-agent-wallet.ts +210 -0
  40. package/src/circle-cli-gateway-payment.test.ts +182 -0
  41. package/src/circle-cli-gateway-payment.ts +284 -0
  42. package/src/index.ts +2 -0
  43. package/src/payment-engine.ts +0 -38
@@ -1,7 +1,3 @@
1
- import { x402Client } from "@x402/core/client";
2
- import { registerBatchScheme } from "@circle-fin/x402-batching/client";
3
- import { ExactEvmScheme } from "@x402/evm/exact/client";
4
- import { privateKeyToAccount } from "viem/accounts";
5
1
  /**
6
2
  * Development engine. Declares the one-word amount without settling real funds,
7
3
  * for use against a dev-mode gateway. NOT for production.
@@ -23,35 +19,4 @@ export class StaticPaymentEngine {
23
19
  };
24
20
  }
25
21
  }
26
- /**
27
- * Circle/x402 engine. Signs the gateway's one-word `paymentRequired` terms.
28
- * Circle may batch settlement internally, but each signed payload corresponds to
29
- * exactly one word.
30
- */
31
- export class CircleGatewayPaymentEngine {
32
- options;
33
- client = new x402Client();
34
- account;
35
- constructor(options) {
36
- this.options = options;
37
- this.account = privateKeyToAccount(this.options.privateKey);
38
- // Recommended buyer integration (Circle x402 buyer how-to): register the
39
- // gasless batched scheme with an `exact` fallback. `registerBatchScheme`
40
- // wires a CompositeEvmScheme that uses Gateway batching when the seller
41
- // supports it and falls back to a standard EIP-3009 `exact` payment
42
- // otherwise — no per-request routing logic needed.
43
- registerBatchScheme(this.client, {
44
- signer: this.account,
45
- fallbackScheme: new ExactEvmScheme(this.account),
46
- });
47
- }
48
- async createWordPayment(session) {
49
- if (!session.paymentRequired) {
50
- throw new Error("Session did not include an x402 one-word payment requirement");
51
- }
52
- return {
53
- paymentPayload: await this.client.createPaymentPayload(session.paymentRequired),
54
- };
55
- }
56
- }
57
22
  //# sourceMappingURL=payment-engine.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"payment-engine.js","sourceRoot":"","sources":["../src/payment-engine.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,mBAAmB,EAA4B,MAAM,kCAAkC,CAAC;AACjG,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAUpD;;;GAGG;AACH,MAAM,OAAO,mBAAmB;IACD;IAA7B,YAA6B,UAAU,gBAAgB;QAA1B,YAAO,GAAP,OAAO,CAAmB;IAAG,CAAC;IAE3D,KAAK,CAAC,iBAAiB,CAAC,OAA6B;QACnD,OAAO;YACL,cAAc,EAAE;gBACd,MAAM,EAAE,oBAAoB;gBAC5B,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,SAAS,EAAE,OAAO,CAAC,SAAS;gBAC5B,YAAY,EAAE,OAAO,CAAC,iBAAiB;gBACvC,YAAY,EAAE,MAAM;aACrB;SACF,CAAC;IACJ,CAAC;CACF;AAID;;;;GAIG;AACH,MAAM,OAAO,0BAA0B;IAIR;IAHZ,MAAM,GAAG,IAAI,UAAU,EAAE,CAAC;IAC1B,OAAO,CAAyC;IAEjE,YAA6B,OAA0C;QAA1C,YAAO,GAAP,OAAO,CAAmC;QACrE,IAAI,CAAC,OAAO,GAAG,mBAAmB,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;QAC5D,yEAAyE;QACzE,yEAAyE;QACzE,wEAAwE;QACxE,oEAAoE;QACpE,mDAAmD;QACnD,mBAAmB,CAAC,IAAI,CAAC,MAAM,EAAE;YAC/B,MAAM,EAAE,IAAI,CAAC,OAAO;YACpB,cAAc,EAAE,IAAI,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC;SACjD,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,iBAAiB,CAAC,OAA6B;QACnD,IAAI,CAAC,OAAO,CAAC,eAAe,EAAE,CAAC;YAC7B,MAAM,IAAI,KAAK,CAAC,8DAA8D,CAAC,CAAC;QAClF,CAAC;QACD,OAAO;YACL,cAAc,EAAE,MAAM,IAAI,CAAC,MAAM,CAAC,oBAAoB,CAAC,OAAO,CAAC,eAAwB,CAAC;SACzF,CAAC;IACJ,CAAC;CACF"}
1
+ {"version":3,"file":"payment-engine.js","sourceRoot":"","sources":["../src/payment-engine.ts"],"names":[],"mappings":"AAUA;;;GAGG;AACH,MAAM,OAAO,mBAAmB;IACD;IAA7B,YAA6B,UAAU,gBAAgB;QAA1B,YAAO,GAAP,OAAO,CAAmB;IAAG,CAAC;IAE3D,KAAK,CAAC,iBAAiB,CAAC,OAA6B;QACnD,OAAO;YACL,cAAc,EAAE;gBACd,MAAM,EAAE,oBAAoB;gBAC5B,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,SAAS,EAAE,OAAO,CAAC,SAAS;gBAC5B,YAAY,EAAE,OAAO,CAAC,iBAAiB;gBACvC,YAAY,EAAE,MAAM;aACrB;SACF,CAAC;IACJ,CAAC;CACF"}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payment-engine.test.d.ts","sourceRoot":"","sources":["../src/payment-engine.test.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubicon-caliga/agent-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Client SDK for autonomous agents consuming per-word article streams via Rubicon x402.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,19 +24,19 @@
24
24
  "publishConfig": {
25
25
  "access": "public"
26
26
  },
27
- "scripts": {
28
- "build": "tsc -p tsconfig.json",
29
- "lint": "tsc -p tsconfig.json --noEmit",
30
- "typecheck": "tsc -p tsconfig.json --noEmit",
31
- "test": "node --test dist/**/*.test.js",
32
- "prepublishOnly": "pnpm run build"
33
- },
34
27
  "dependencies": {
35
- "@rubicon-caliga/core": "^0.1.0",
28
+ "@circle-fin/developer-controlled-wallets": "^10.6.0",
36
29
  "@circle-fin/x402-batching": "^3.1.2",
37
30
  "@x402/core": "^2.15.0",
38
31
  "@x402/evm": "^2.15.0",
39
32
  "eventsource": "^4.0.0",
40
- "viem": "^2.52.2"
33
+ "viem": "^2.52.2",
34
+ "@rubicon-caliga/core": "0.1.0"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "lint": "tsc -p tsconfig.json --noEmit",
39
+ "typecheck": "tsc -p tsconfig.json --noEmit",
40
+ "test": "node --test dist/**/*.test.js"
41
41
  }
42
- }
42
+ }
@@ -0,0 +1,118 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { RubiconClient } from "./agent-client.js";
4
+ import type { AgentPaymentEngine } from "./payment-engine.js";
5
+
6
+ const paymentEngine: AgentPaymentEngine = {
7
+ async createWordPayment() {
8
+ return { paymentPayload: { ok: true } };
9
+ },
10
+ };
11
+
12
+ test("run receipt preserves Gateway settlement receipt fields", async () => {
13
+ const fetcher = (async (input: Parameters<typeof fetch>[0]) => {
14
+ const url = String(input);
15
+ if (url.endsWith("/v1/sessions")) {
16
+ return jsonResponse({
17
+ sessionId: "session_1",
18
+ state: "active",
19
+ article: article(),
20
+ navigation: navigation(),
21
+ pricePerWordAtomic: "1",
22
+ maxArticlePriceAtomic: "10",
23
+ conversationId: "conversation_1",
24
+ wordPaymentAtomic: "1",
25
+ gatewayFeeBps: 0,
26
+ paymentRequired: { scheme: "exact" },
27
+ expiresAt: "2026-06-18T12:00:00.000Z",
28
+ wordsPaid: 0,
29
+ wordsDelivered: 0,
30
+ paidAtomic: "0",
31
+ });
32
+ }
33
+ if (url.endsWith("/v1/sessions/session_1/payments")) {
34
+ return jsonResponse({
35
+ accepted: true,
36
+ sequence: 0,
37
+ word: "Rubicon",
38
+ priceAtomic: "1",
39
+ wordsPaid: 1,
40
+ wordsDelivered: 1,
41
+ paidAtomic: "1",
42
+ completed: true,
43
+ transactionHashes: [],
44
+ settlementIds: ["settlement_1"],
45
+ payment: {
46
+ paymentId: "payment_1",
47
+ sessionId: "session_1",
48
+ articleId: "article_1",
49
+ sequence: 0,
50
+ meteringUnit: "word",
51
+ amountAtomic: "1",
52
+ currency: "USDC",
53
+ network: "eip155:5042002",
54
+ payTo: "0x3333333333333333333333333333333333333333",
55
+ transactionHashes: [],
56
+ settlementIds: ["settlement_1"],
57
+ buyerWalletAddress: "0x2222222222222222222222222222222222222222",
58
+ settledAt: "2026-06-18T12:00:00.000Z",
59
+ },
60
+ });
61
+ }
62
+ throw new Error(`Unexpected fetch: ${url}`);
63
+ }) as typeof fetch;
64
+
65
+ const client = new RubiconClient({
66
+ baseUrl: "http://rubicon.test",
67
+ paymentEngine,
68
+ fetch: fetcher,
69
+ });
70
+
71
+ const receipt = await client.run({
72
+ articleId: "article_1",
73
+ maxSpendAtomic: "10",
74
+ });
75
+
76
+ assert.deepEqual(receipt.transactionHashes, []);
77
+ assert.deepEqual(receipt.settlementIds, ["settlement_1"]);
78
+ assert.equal(receipt.buyerWalletAddress, "0x2222222222222222222222222222222222222222");
79
+ assert.equal(receipt.sellerPayTo, "0x3333333333333333333333333333333333333333");
80
+ assert.equal(receipt.network, "eip155:5042002");
81
+ });
82
+
83
+ function jsonResponse(body: unknown): Response {
84
+ return new Response(JSON.stringify(body), {
85
+ status: 200,
86
+ headers: { "content-type": "application/json" },
87
+ });
88
+ }
89
+
90
+ function article() {
91
+ return {
92
+ articleId: "article_1",
93
+ creatorId: "creator_1",
94
+ creatorUsername: "creator",
95
+ title: "Title",
96
+ author: "Author",
97
+ state: "published",
98
+ totalWords: 1,
99
+ pricePerWordAtomic: "1",
100
+ maxArticlePriceAtomic: "1",
101
+ sections: [],
102
+ };
103
+ }
104
+
105
+ function navigation() {
106
+ return {
107
+ articleId: "article_1",
108
+ sections: [],
109
+ sellerAgent: {
110
+ recommendedSectionId: "intro",
111
+ alternativeSectionIds: [],
112
+ rationale: "",
113
+ safeHints: [],
114
+ withheld: [],
115
+ },
116
+ stopConditions: [],
117
+ };
118
+ }
@@ -39,6 +39,10 @@ export interface ReadReceipt {
39
39
  amountPaidAtomic: `${bigint}`;
40
40
  payments: WordPaymentReceipt[];
41
41
  transactionHashes: string[];
42
+ settlementIds: string[];
43
+ buyerWalletAddress?: `0x${string}`;
44
+ sellerPayTo?: `0x${string}`;
45
+ network?: string;
42
46
  text: string;
43
47
  completed: boolean;
44
48
  stopReason: "article_completed" | "stop_condition" | "budget_reached" | "max_words" | "aborted";
@@ -268,6 +272,7 @@ export class RubiconClient {
268
272
  let wordsRead = 0;
269
273
  let amountPaid = 0n;
270
274
  const transactionHashes: string[] = [];
275
+ const settlementIds: string[] = [];
271
276
  const payments: WordPaymentReceipt[] = [];
272
277
  let stopReason: ReadReceipt["stopReason"] = "article_completed";
273
278
  let completed = false;
@@ -280,6 +285,10 @@ export class RubiconClient {
280
285
  amountPaidAtomic: `${amountPaid}`,
281
286
  payments: [...payments],
282
287
  transactionHashes: [...transactionHashes],
288
+ settlementIds: [...settlementIds],
289
+ buyerWalletAddress: [...payments].reverse().find((payment) => payment.buyerWalletAddress)?.buyerWalletAddress,
290
+ sellerPayTo: [...payments].reverse().find((payment) => payment.payTo)?.payTo,
291
+ network: [...payments].reverse().find((payment) => payment.network)?.network,
283
292
  text,
284
293
  completed,
285
294
  stopReason,
@@ -326,6 +335,7 @@ export class RubiconClient {
326
335
  payments.push(result.payment);
327
336
  }
328
337
  transactionHashes.push(...(result.transactionHashes ?? (result.transactionHash ? [result.transactionHash] : [])));
338
+ settlementIds.push(...(result.settlementIds ?? (result.settlementId ? [result.settlementId] : [])));
329
339
  text = text ? `${text} ${result.word}` : result.word;
330
340
 
331
341
  yield {
@@ -0,0 +1,118 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { serializeTypedData, toEip712Payload } from "./circle-agent-wallet.js";
4
+
5
+ // The x402 schemes hand the signer viem-style typed data with no EIP712Domain
6
+ // entry; Circle's API needs the complete document. These pin the bridging.
7
+
8
+ const TRANSFER_TYPES = {
9
+ TransferWithAuthorization: [
10
+ { name: "from", type: "address" },
11
+ { name: "to", type: "address" },
12
+ { name: "value", type: "uint256" },
13
+ ],
14
+ };
15
+
16
+ const EIP3009_TRANSFER_TYPES = {
17
+ TransferWithAuthorization: [
18
+ { name: "from", type: "address" },
19
+ { name: "to", type: "address" },
20
+ { name: "value", type: "uint256" },
21
+ { name: "validAfter", type: "uint256" },
22
+ { name: "validBefore", type: "uint256" },
23
+ { name: "nonce", type: "bytes32" },
24
+ ],
25
+ };
26
+
27
+ test("injects EIP712Domain derived from the domain fields present", () => {
28
+ const payload = toEip712Payload({
29
+ domain: {
30
+ name: "USD Coin",
31
+ version: "2",
32
+ chainId: 5042002,
33
+ verifyingContract: "0xabc",
34
+ },
35
+ types: TRANSFER_TYPES,
36
+ primaryType: "TransferWithAuthorization",
37
+ message: { from: "0x1", to: "0x2", value: "5" },
38
+ });
39
+
40
+ assert.deepEqual(payload.types.EIP712Domain, [
41
+ { name: "name", type: "string" },
42
+ { name: "version", type: "string" },
43
+ { name: "chainId", type: "uint256" },
44
+ { name: "verifyingContract", type: "address" },
45
+ ]);
46
+ // Original typed-data entries are preserved alongside the injected domain.
47
+ assert.deepEqual(payload.types.TransferWithAuthorization, TRANSFER_TYPES.TransferWithAuthorization);
48
+ assert.equal(payload.primaryType, "TransferWithAuthorization");
49
+ });
50
+
51
+ test("only includes domain fields that are actually present", () => {
52
+ const payload = toEip712Payload({
53
+ domain: { name: "Test", chainId: 1337 },
54
+ types: TRANSFER_TYPES,
55
+ primaryType: "TransferWithAuthorization",
56
+ message: {},
57
+ });
58
+
59
+ assert.deepEqual(payload.types.EIP712Domain, [
60
+ { name: "name", type: "string" },
61
+ { name: "chainId", type: "uint256" },
62
+ ]);
63
+ });
64
+
65
+ test("does not overwrite an EIP712Domain the caller already supplied", () => {
66
+ const provided = [{ name: "name", type: "string" }];
67
+ const payload = toEip712Payload({
68
+ domain: { name: "Test", chainId: 1337 },
69
+ types: { ...TRANSFER_TYPES, EIP712Domain: provided },
70
+ primaryType: "TransferWithAuthorization",
71
+ message: {},
72
+ });
73
+
74
+ assert.deepEqual(payload.types.EIP712Domain, provided);
75
+ });
76
+
77
+ test("serializes bigint authorization fields (exact fallback) as decimal strings", () => {
78
+ // The `exact` scheme hands the signer bigint value/validAfter/validBefore;
79
+ // plain JSON.stringify would throw, so they must be coerced to strings.
80
+ const payload = toEip712Payload({
81
+ domain: { name: "USD Coin", chainId: 5042002 },
82
+ types: EIP3009_TRANSFER_TYPES,
83
+ primaryType: "TransferWithAuthorization",
84
+ message: {
85
+ from: "0x1",
86
+ to: "0x2",
87
+ value: 5n,
88
+ validAfter: 0n,
89
+ validBefore: 1893456000n,
90
+ nonce: "0xabc",
91
+ },
92
+ });
93
+
94
+ const json = serializeTypedData(payload);
95
+ const parsed = JSON.parse(json);
96
+ assert.equal(parsed.message.value, "5");
97
+ assert.equal(parsed.message.validAfter, "0");
98
+ assert.equal(parsed.message.validBefore, "1893456000");
99
+ });
100
+
101
+ test("removes message fields that are not declared by the primary type", () => {
102
+ const payload = toEip712Payload({
103
+ domain: { name: "USD Coin", chainId: 5042002 },
104
+ types: TRANSFER_TYPES,
105
+ primaryType: "TransferWithAuthorization",
106
+ message: {
107
+ from: "0x1",
108
+ to: "0x2",
109
+ value: "5",
110
+ validAfter: "0",
111
+ validBefore: "1893456000",
112
+ nonce: "0xabc",
113
+ authorization: { unexpected: true },
114
+ },
115
+ });
116
+
117
+ assert.deepEqual(payload.message, { from: "0x1", to: "0x2", value: "5" });
118
+ });
@@ -0,0 +1,210 @@
1
+ import type { StartSessionResponse, StreamPaymentRequest } from "@rubicon-caliga/core";
2
+ import { x402Client } from "@x402/core/client";
3
+ import { registerBatchScheme } from "@circle-fin/x402-batching/client";
4
+ import { ExactEvmScheme } from "@x402/evm/exact/client";
5
+ import {
6
+ initiateDeveloperControlledWalletsClient,
7
+ type CircleDeveloperControlledWalletsClient,
8
+ } from "@circle-fin/developer-controlled-wallets";
9
+ import type { AgentPaymentEngine } from "./payment-engine.js";
10
+
11
+ export interface CircleAgentWalletEngineOptions {
12
+ /** Circle API key that controls the Agent Wallet. */
13
+ apiKey: string;
14
+ /** Entity secret registered for the Circle developer account. */
15
+ entitySecret: string;
16
+ /** The Agent Wallet that holds USDC and signs each one-word payment. */
17
+ walletId: string;
18
+ /**
19
+ * The wallet's on-chain address. Optional — when omitted it is resolved once
20
+ * from the Circle API via `getWallet` before the first payment is signed.
21
+ */
22
+ walletAddress?: `0x${string}`;
23
+ /** Override the Circle API base URL (e.g. sandbox vs. production). */
24
+ baseUrl?: string;
25
+ /** Pre-built Circle client. Mainly an injection point for tests. */
26
+ client?: CircleDeveloperControlledWalletsClient;
27
+ }
28
+
29
+ /** The minimal EIP-712 signing request the x402 client hands to a signer. */
30
+ interface TypedDataRequest {
31
+ domain: Record<string, unknown>;
32
+ types: Record<string, unknown>;
33
+ primaryType: string;
34
+ message: Record<string, unknown>;
35
+ }
36
+
37
+ /**
38
+ * viem-shaped signer (`{ address, signTypedData }`) that delegates EIP-712
39
+ * signing to a Circle Agent Wallet over the API instead of holding a raw
40
+ * private key. Satisfies both the batch (`BatchEvmSigner`) and exact
41
+ * (`ClientEvmSigner`) signer contracts used by the Circle x402 schemes.
42
+ */
43
+ class CircleAgentWalletSigner {
44
+ // Populated before the first signature — either from options or via getWallet.
45
+ address: `0x${string}` = "0x0000000000000000000000000000000000000000";
46
+ private resolved = false;
47
+ private resolving?: Promise<void>;
48
+
49
+ constructor(
50
+ private readonly client: CircleDeveloperControlledWalletsClient,
51
+ private readonly walletId: string,
52
+ address?: `0x${string}`,
53
+ ) {
54
+ if (address) {
55
+ this.address = address;
56
+ this.resolved = true;
57
+ }
58
+ }
59
+
60
+ /** Resolves and caches the wallet's on-chain address (idempotent). */
61
+ async ensureAddress(): Promise<void> {
62
+ if (this.resolved) return;
63
+ if (!this.resolving) {
64
+ this.resolving = this.client.getWallet({ id: this.walletId }).then((res) => {
65
+ const address = res.data?.wallet.address;
66
+ if (!address) {
67
+ throw new Error(`Circle wallet ${this.walletId} did not return an on-chain address`);
68
+ }
69
+ this.address = address as `0x${string}`;
70
+ this.resolved = true;
71
+ });
72
+ }
73
+ return this.resolving;
74
+ }
75
+
76
+ async signTypedData(typed: TypedDataRequest): Promise<`0x${string}`> {
77
+ // The schemes read `address` synchronously while building the payload, so
78
+ // make sure it is resolved before we sign.
79
+ await this.ensureAddress();
80
+ const res = await this.client.signTypedData({
81
+ walletId: this.walletId,
82
+ data: serializeTypedData(toEip712Payload(typed)),
83
+ memo: "Rubicon one-word payment",
84
+ });
85
+ const signature = res.data?.signature;
86
+ if (!signature) {
87
+ throw new Error("Circle Agent Wallet did not return a signature for the x402 payment");
88
+ }
89
+ return signature as `0x${string}`;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Circle Agent Wallet engine. Signs the gateway's one-word x402 terms with a
95
+ * custodial Circle Agent Wallet — the recommended buyer setup — so the agent
96
+ * never handles a local signing key. Settlement may be batched by Circle, but
97
+ * each signed payload still corresponds to exactly one word.
98
+ */
99
+ export class CircleAgentWalletEngine implements AgentPaymentEngine {
100
+ private readonly x402 = new x402Client();
101
+ private readonly signer: CircleAgentWalletSigner;
102
+
103
+ constructor(options: CircleAgentWalletEngineOptions) {
104
+ const client =
105
+ options.client ??
106
+ initiateDeveloperControlledWalletsClient({
107
+ apiKey: options.apiKey,
108
+ entitySecret: options.entitySecret,
109
+ ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}),
110
+ });
111
+ this.signer = new CircleAgentWalletSigner(client, options.walletId, options.walletAddress);
112
+ // Gasless Gateway batching with an `exact` EIP-3009 fallback. The signer is
113
+ // a custodial Circle Agent Wallet, not a local private key.
114
+ registerBatchScheme(this.x402, {
115
+ signer: this.signer,
116
+ fallbackScheme: new ExactEvmScheme(this.signer),
117
+ });
118
+ }
119
+
120
+ async createWordPayment(session: StartSessionResponse): Promise<StreamPaymentRequest> {
121
+ if (!session.paymentRequired) {
122
+ throw new Error("Session did not include an x402 one-word payment requirement");
123
+ }
124
+ // Resolve the wallet address up front so the synchronous `address` read
125
+ // inside createPaymentPayload sees a real value.
126
+ await this.signer.ensureAddress();
127
+ return {
128
+ paymentPayload: await this.x402.createPaymentPayload(session.paymentRequired as never),
129
+ };
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Circle's signTypedData API expects a complete EIP-712 document as a JSON
135
+ * string. The x402 schemes pass viem-style typed data, which omits the implicit
136
+ * `EIP712Domain` type, so add it back from whichever domain fields are present.
137
+ *
138
+ * Exported for unit testing — application code never calls this directly.
139
+ */
140
+ /**
141
+ * Serialize an EIP-712 document to the JSON string Circle's API expects. The
142
+ * `exact` fallback scheme passes the authorization's `value`/`validAfter`/
143
+ * `validBefore` as bigints, which `JSON.stringify` cannot encode — emit them as
144
+ * decimal strings (the EIP-712 JSON convention) instead of throwing.
145
+ *
146
+ * Exported for unit testing — application code never calls this directly.
147
+ */
148
+ export function serializeTypedData(typed: ReturnType<typeof toEip712Payload>): string {
149
+ return JSON.stringify(typed, (_key, value) =>
150
+ typeof value === "bigint" ? value.toString() : value,
151
+ );
152
+ }
153
+
154
+ export function toEip712Payload(typed: TypedDataRequest) {
155
+ const domain = typed.domain ?? {};
156
+ const types = { ...(typed.types as Record<string, unknown>) };
157
+ if (!types.EIP712Domain) {
158
+ types.EIP712Domain = eip712DomainFields(domain);
159
+ }
160
+ return {
161
+ domain,
162
+ types,
163
+ primaryType: typed.primaryType,
164
+ message: eip712PrimaryMessage(typed.message, types, typed.primaryType),
165
+ };
166
+ }
167
+
168
+ function eip712DomainFields(domain: Record<string, unknown>): Array<{ name: string; type: string }> {
169
+ const candidates: Array<[string, string]> = [
170
+ ["name", "string"],
171
+ ["version", "string"],
172
+ ["chainId", "uint256"],
173
+ ["verifyingContract", "address"],
174
+ ["salt", "bytes32"],
175
+ ];
176
+ return candidates
177
+ .filter(([field]) => domain[field] !== undefined)
178
+ .map(([name, type]) => ({ name, type }));
179
+ }
180
+
181
+ function eip712PrimaryMessage(
182
+ message: Record<string, unknown>,
183
+ types: Record<string, unknown>,
184
+ primaryType: string,
185
+ ): Record<string, unknown> {
186
+ const fields = types[primaryType];
187
+ if (!Array.isArray(fields)) {
188
+ return message;
189
+ }
190
+
191
+ const allowed = new Set(
192
+ fields
193
+ .map((field) => (isEip712Field(field) ? field.name : undefined))
194
+ .filter((name): name is string => Boolean(name)),
195
+ );
196
+ if (allowed.size === 0) {
197
+ return message;
198
+ }
199
+
200
+ return Object.fromEntries(Object.entries(message).filter(([key]) => allowed.has(key)));
201
+ }
202
+
203
+ function isEip712Field(field: unknown): field is { name: string; type: string } {
204
+ return (
205
+ typeof field === "object" &&
206
+ field !== null &&
207
+ typeof (field as { name?: unknown }).name === "string" &&
208
+ typeof (field as { type?: unknown }).type === "string"
209
+ );
210
+ }