@rubicon-caliga/agent-sdk 0.1.0 → 0.1.1
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 +51 -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/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 +31 -0
- package/dist/circle-cli-gateway-payment.d.ts.map +1 -0
- package/dist/circle-cli-gateway-payment.js +185 -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 +32 -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.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 +53 -0
- package/src/circle-cli-gateway-payment.ts +230 -0
- package/src/index.ts +2 -0
- package/src/payment-engine.ts +0 -38
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
parseCircleCliSignature,
|
|
5
|
+
parseCircleCliWalletAddress,
|
|
6
|
+
} from "./circle-cli-gateway-payment.js";
|
|
7
|
+
|
|
8
|
+
test("parses quiet Circle CLI signatures", () => {
|
|
9
|
+
assert.equal(parseCircleCliSignature("0xabc123\n"), "0xabc123");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("parses JSON Circle CLI signatures", () => {
|
|
13
|
+
assert.equal(
|
|
14
|
+
parseCircleCliSignature(JSON.stringify({ data: { signature: "0xdef456" } })),
|
|
15
|
+
"0xdef456",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("rejects non-signature Circle CLI output", () => {
|
|
20
|
+
assert.throws(() => parseCircleCliSignature("signed"), /did not return a hex EIP-712 signature/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("parses a sole wallet address from Circle CLI list output", () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
parseCircleCliWalletAddress(
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
data: {
|
|
28
|
+
wallets: [
|
|
29
|
+
{
|
|
30
|
+
address: "0x1111111111111111111111111111111111111111",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
"0x1111111111111111111111111111111111111111",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("requires explicit wallet address when multiple Agent Wallets are present", () => {
|
|
41
|
+
assert.throws(
|
|
42
|
+
() =>
|
|
43
|
+
parseCircleCliWalletAddress(
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
data: [
|
|
46
|
+
{ address: "0x1111111111111111111111111111111111111111" },
|
|
47
|
+
{ address: "0x2222222222222222222222222222222222222222" },
|
|
48
|
+
],
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
/Multiple Circle Agent Wallets found/,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import type { StartSessionResponse, StreamPaymentRequest } from "@rubicon-caliga/core";
|
|
4
|
+
import { registerBatchScheme } from "@circle-fin/x402-batching/client";
|
|
5
|
+
import { x402Client } from "@x402/core/client";
|
|
6
|
+
import { ExactEvmScheme } from "@x402/evm/exact/client";
|
|
7
|
+
import type { AgentPaymentEngine } from "./payment-engine.js";
|
|
8
|
+
import { serializeTypedData, toEip712Payload } from "./circle-agent-wallet.js";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
export type CircleCliRunner = (command: string, args: string[]) => Promise<string>;
|
|
13
|
+
|
|
14
|
+
export interface CircleCliGatewayPaymentEngineOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Agent Wallet address controlled by Circle CLI. When omitted, the engine
|
|
17
|
+
* resolves the sole agent wallet returned by `circle wallet list`.
|
|
18
|
+
*/
|
|
19
|
+
walletAddress?: `0x${string}`;
|
|
20
|
+
/** Circle CLI chain name. Rubicon real reads settle on Arc Testnet by default. */
|
|
21
|
+
chain?: string;
|
|
22
|
+
/** Circle CLI binary name or path. */
|
|
23
|
+
command?: string;
|
|
24
|
+
/** Command runner injection point for tests or hosted agent sandboxes. */
|
|
25
|
+
runner?: CircleCliRunner;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface TypedDataRequest {
|
|
29
|
+
domain: Record<string, unknown>;
|
|
30
|
+
types: Record<string, unknown>;
|
|
31
|
+
primaryType: string;
|
|
32
|
+
message: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Circle CLI / Agent Wallet payment engine. It creates the one-word x402
|
|
37
|
+
* payment payload for Rubicon's session-first flow and delegates EIP-712
|
|
38
|
+
* signing to `circle wallet sign typed-data`, so agents never need raw private
|
|
39
|
+
* keys or hand-built x402 payloads.
|
|
40
|
+
*/
|
|
41
|
+
export class CircleCliGatewayPaymentEngine implements AgentPaymentEngine {
|
|
42
|
+
private readonly x402 = new x402Client();
|
|
43
|
+
private readonly signer: CircleCliGatewaySigner;
|
|
44
|
+
|
|
45
|
+
constructor(options: CircleCliGatewayPaymentEngineOptions = {}) {
|
|
46
|
+
this.signer = new CircleCliGatewaySigner({
|
|
47
|
+
walletAddress: options.walletAddress,
|
|
48
|
+
chain: options.chain ?? "ARC-TESTNET",
|
|
49
|
+
command: options.command ?? "circle",
|
|
50
|
+
runner: options.runner ?? runCircleCli,
|
|
51
|
+
});
|
|
52
|
+
registerBatchScheme(this.x402, {
|
|
53
|
+
signer: this.signer,
|
|
54
|
+
fallbackScheme: new ExactEvmScheme(this.signer),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async createWordPayment(session: StartSessionResponse): Promise<StreamPaymentRequest> {
|
|
59
|
+
if (!session.paymentRequired) {
|
|
60
|
+
throw new Error("Session did not include an x402 one-word payment requirement");
|
|
61
|
+
}
|
|
62
|
+
await this.signer.ensureAddress();
|
|
63
|
+
return {
|
|
64
|
+
paymentPayload: await this.x402.createPaymentPayload(session.paymentRequired as never),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class CircleCliGatewaySigner {
|
|
70
|
+
address: `0x${string}` = "0x0000000000000000000000000000000000000000";
|
|
71
|
+
private resolved = false;
|
|
72
|
+
private resolving?: Promise<void>;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly options: {
|
|
76
|
+
walletAddress?: `0x${string}`;
|
|
77
|
+
chain: string;
|
|
78
|
+
command: string;
|
|
79
|
+
runner: CircleCliRunner;
|
|
80
|
+
},
|
|
81
|
+
) {
|
|
82
|
+
if (options.walletAddress) {
|
|
83
|
+
this.address = options.walletAddress;
|
|
84
|
+
this.resolved = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async ensureAddress(): Promise<void> {
|
|
89
|
+
if (this.resolved) return;
|
|
90
|
+
if (!this.resolving) {
|
|
91
|
+
this.resolving = this.resolveAddress();
|
|
92
|
+
}
|
|
93
|
+
return this.resolving;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async signTypedData(typed: TypedDataRequest): Promise<`0x${string}`> {
|
|
97
|
+
await this.ensureAddress();
|
|
98
|
+
const signature = await this.options.runner(this.options.command, [
|
|
99
|
+
"wallet",
|
|
100
|
+
"sign",
|
|
101
|
+
"typed-data",
|
|
102
|
+
serializeTypedData(toEip712Payload(typed)),
|
|
103
|
+
"--address",
|
|
104
|
+
this.address,
|
|
105
|
+
"--chain",
|
|
106
|
+
this.options.chain,
|
|
107
|
+
"--quiet",
|
|
108
|
+
]);
|
|
109
|
+
return parseCircleCliSignature(signature);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async resolveAddress(): Promise<void> {
|
|
113
|
+
const output = await this.options.runner(this.options.command, [
|
|
114
|
+
"wallet",
|
|
115
|
+
"list",
|
|
116
|
+
"--chain",
|
|
117
|
+
this.options.chain,
|
|
118
|
+
"--type",
|
|
119
|
+
"agent",
|
|
120
|
+
"--output",
|
|
121
|
+
"json",
|
|
122
|
+
]);
|
|
123
|
+
const address = parseCircleCliWalletAddress(output);
|
|
124
|
+
this.address = address;
|
|
125
|
+
this.resolved = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function runCircleCli(command: string, args: string[]): Promise<string> {
|
|
130
|
+
try {
|
|
131
|
+
const { stdout } = await execFileAsync(command, args, {
|
|
132
|
+
maxBuffer: 1024 * 1024,
|
|
133
|
+
});
|
|
134
|
+
return stdout;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Circle CLI command failed. Ensure Circle CLI is installed, logged in, and has an Agent Wallet on the selected chain. ${message}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function parseCircleCliSignature(output: string): `0x${string}` {
|
|
144
|
+
const trimmed = output.trim();
|
|
145
|
+
if (isHexSignature(trimmed)) {
|
|
146
|
+
return trimmed;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let parsed: unknown;
|
|
150
|
+
try {
|
|
151
|
+
parsed = parseJson(trimmed);
|
|
152
|
+
} catch {
|
|
153
|
+
throw new Error("Circle CLI did not return a hex EIP-712 signature");
|
|
154
|
+
}
|
|
155
|
+
const signature = findString(parsed, ["signature", "signedData", "data.signature"]);
|
|
156
|
+
if (signature && isHexSignature(signature)) {
|
|
157
|
+
return signature;
|
|
158
|
+
}
|
|
159
|
+
throw new Error("Circle CLI did not return a hex EIP-712 signature");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function parseCircleCliWalletAddress(output: string): `0x${string}` {
|
|
163
|
+
const parsed = parseJson(output);
|
|
164
|
+
const wallets = collectWalletCandidates(parsed);
|
|
165
|
+
const addresses = wallets
|
|
166
|
+
.map((wallet) => findString(wallet, ["address", "walletAddress", "blockchainAddress"]))
|
|
167
|
+
.filter((address): address is `0x${string}` => Boolean(address && isAddress(address)));
|
|
168
|
+
|
|
169
|
+
const unique = [...new Set(addresses.map((address) => address.toLowerCase()))];
|
|
170
|
+
if (unique.length === 1) {
|
|
171
|
+
return addresses.find((address) => address.toLowerCase() === unique[0])!;
|
|
172
|
+
}
|
|
173
|
+
if (unique.length === 0) {
|
|
174
|
+
throw new Error("Circle CLI did not return an Agent Wallet address");
|
|
175
|
+
}
|
|
176
|
+
throw new Error("Multiple Circle Agent Wallets found; pass walletAddress explicitly");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function collectWalletCandidates(value: unknown): Record<string, unknown>[] {
|
|
180
|
+
if (Array.isArray(value)) {
|
|
181
|
+
return value.filter(isRecord);
|
|
182
|
+
}
|
|
183
|
+
if (!isRecord(value)) {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
for (const key of ["wallets", "items", "data"]) {
|
|
187
|
+
const nested = value[key];
|
|
188
|
+
if (Array.isArray(nested)) {
|
|
189
|
+
return nested.filter(isRecord);
|
|
190
|
+
}
|
|
191
|
+
if (isRecord(nested)) {
|
|
192
|
+
const deeper = collectWalletCandidates(nested);
|
|
193
|
+
if (deeper.length > 0) return deeper;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return [value];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function findString(value: unknown, keys: string[]): string | undefined {
|
|
200
|
+
for (const key of keys) {
|
|
201
|
+
const found = key.split(".").reduce<unknown>((current, part) => {
|
|
202
|
+
if (!isRecord(current)) return undefined;
|
|
203
|
+
return current[part];
|
|
204
|
+
}, value);
|
|
205
|
+
if (typeof found === "string") {
|
|
206
|
+
return found;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseJson(value: string): unknown {
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(value);
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error("Circle CLI returned non-JSON output");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isHexSignature(value: string): value is `0x${string}` {
|
|
221
|
+
return /^0x[0-9a-fA-F]+$/.test(value);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isAddress(value: string): value is `0x${string}` {
|
|
225
|
+
return /^0x[0-9a-fA-F]{40}$/.test(value);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
229
|
+
return typeof value === "object" && value !== null;
|
|
230
|
+
}
|
package/src/index.ts
CHANGED
package/src/payment-engine.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import type { StartSessionResponse, StreamPaymentRequest } from "@rubicon-caliga/core";
|
|
2
|
-
import { x402Client } from "@x402/core/client";
|
|
3
|
-
import { registerBatchScheme, type GatewayClientConfig } from "@circle-fin/x402-batching/client";
|
|
4
|
-
import { ExactEvmScheme } from "@x402/evm/exact/client";
|
|
5
|
-
import { privateKeyToAccount } from "viem/accounts";
|
|
6
2
|
|
|
7
3
|
/**
|
|
8
4
|
* Produces the payment payload for exactly one word. Called once per word by the
|
|
@@ -31,37 +27,3 @@ export class StaticPaymentEngine implements AgentPaymentEngine {
|
|
|
31
27
|
};
|
|
32
28
|
}
|
|
33
29
|
}
|
|
34
|
-
|
|
35
|
-
export type CircleGatewayPaymentEngineOptions = GatewayClientConfig;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Circle/x402 engine. Signs the gateway's one-word `paymentRequired` terms.
|
|
39
|
-
* Circle may batch settlement internally, but each signed payload corresponds to
|
|
40
|
-
* exactly one word.
|
|
41
|
-
*/
|
|
42
|
-
export class CircleGatewayPaymentEngine implements AgentPaymentEngine {
|
|
43
|
-
private readonly client = new x402Client();
|
|
44
|
-
private readonly account: ReturnType<typeof privateKeyToAccount>;
|
|
45
|
-
|
|
46
|
-
constructor(private readonly options: CircleGatewayPaymentEngineOptions) {
|
|
47
|
-
this.account = privateKeyToAccount(this.options.privateKey);
|
|
48
|
-
// Recommended buyer integration (Circle x402 buyer how-to): register the
|
|
49
|
-
// gasless batched scheme with an `exact` fallback. `registerBatchScheme`
|
|
50
|
-
// wires a CompositeEvmScheme that uses Gateway batching when the seller
|
|
51
|
-
// supports it and falls back to a standard EIP-3009 `exact` payment
|
|
52
|
-
// otherwise — no per-request routing logic needed.
|
|
53
|
-
registerBatchScheme(this.client, {
|
|
54
|
-
signer: this.account,
|
|
55
|
-
fallbackScheme: new ExactEvmScheme(this.account),
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async createWordPayment(session: StartSessionResponse): Promise<StreamPaymentRequest> {
|
|
60
|
-
if (!session.paymentRequired) {
|
|
61
|
-
throw new Error("Session did not include an x402 one-word payment requirement");
|
|
62
|
-
}
|
|
63
|
-
return {
|
|
64
|
-
paymentPayload: await this.client.createPaymentPayload(session.paymentRequired as never),
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
}
|