@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.
- package/README.md +67 -7
- package/dist/agent-client.d.ts +4 -0
- package/dist/agent-client.d.ts.map +1 -1
- package/dist/agent-client.js +6 -0
- package/dist/agent-client.js.map +1 -1
- package/dist/agent-client.test.d.ts +2 -0
- package/dist/agent-client.test.d.ts.map +1 -0
- package/dist/agent-client.test.js +110 -0
- package/dist/agent-client.test.js.map +1 -0
- package/dist/circle-agent-wallet.d.ts +65 -0
- package/dist/circle-agent-wallet.d.ts.map +1 -0
- package/dist/circle-agent-wallet.js +156 -0
- package/dist/circle-agent-wallet.js.map +1 -0
- package/dist/circle-agent-wallet.test.d.ts +2 -0
- package/dist/circle-agent-wallet.test.d.ts.map +1 -0
- package/dist/circle-agent-wallet.test.js +106 -0
- package/dist/circle-agent-wallet.test.js.map +1 -0
- package/dist/circle-cli-gateway-payment.d.ts +74 -0
- package/dist/circle-cli-gateway-payment.d.ts.map +1 -0
- package/dist/circle-cli-gateway-payment.js +218 -0
- package/dist/circle-cli-gateway-payment.js.map +1 -0
- package/dist/circle-cli-gateway-payment.test.d.ts +2 -0
- package/dist/circle-cli-gateway-payment.test.d.ts.map +1 -0
- package/dist/circle-cli-gateway-payment.test.js +141 -0
- package/dist/circle-cli-gateway-payment.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/payment-engine.d.ts +0 -14
- package/dist/payment-engine.d.ts.map +1 -1
- package/dist/payment-engine.js +0 -35
- package/dist/payment-engine.js.map +1 -1
- package/dist/payment-engine.test.d.ts.map +1 -0
- package/package.json +11 -11
- package/src/agent-client.test.ts +118 -0
- package/src/agent-client.ts +10 -0
- package/src/circle-agent-wallet.test.ts +118 -0
- package/src/circle-agent-wallet.ts +210 -0
- package/src/circle-cli-gateway-payment.test.ts +182 -0
- package/src/circle-cli-gateway-payment.ts +284 -0
- package/src/index.ts +2 -0
- package/src/payment-engine.ts +0 -38
package/dist/payment-engine.js
CHANGED
|
@@ -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":"
|
|
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.
|
|
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
|
-
"@
|
|
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
|
+
}
|
package/src/agent-client.ts
CHANGED
|
@@ -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
|
+
}
|