@elizaos/plugin-x402 2.0.0-alpha.6 → 2.0.0-beta.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.
Files changed (39) hide show
  1. package/dist/index.d.ts +57 -2
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +30919 -1913
  4. package/dist/index.js.map +114 -21
  5. package/dist/payment-config.d.ts +256 -0
  6. package/dist/payment-config.d.ts.map +1 -0
  7. package/dist/payment-wrapper.d.ts +42 -0
  8. package/dist/payment-wrapper.d.ts.map +1 -0
  9. package/dist/startup-validator.d.ts +28 -0
  10. package/dist/startup-validator.d.ts.map +1 -0
  11. package/dist/types.d.ts +158 -0
  12. package/dist/types.d.ts.map +1 -0
  13. package/dist/x402-facilitator-binding.d.ts +9 -0
  14. package/dist/x402-facilitator-binding.d.ts.map +1 -0
  15. package/dist/x402-replay-durable.d.ts +30 -0
  16. package/dist/x402-replay-durable.d.ts.map +1 -0
  17. package/dist/x402-replay-guard.d.ts +28 -0
  18. package/dist/x402-replay-guard.d.ts.map +1 -0
  19. package/dist/x402-replay-keys.d.ts +21 -0
  20. package/dist/x402-replay-keys.d.ts.map +1 -0
  21. package/dist/x402-resolve.d.ts +6 -0
  22. package/dist/x402-resolve.d.ts.map +1 -0
  23. package/dist/x402-standard-payment.d.ts +130 -0
  24. package/dist/x402-standard-payment.d.ts.map +1 -0
  25. package/dist/x402-types.d.ts +130 -0
  26. package/dist/x402-types.d.ts.map +1 -0
  27. package/package.json +43 -94
  28. package/src/index.ts +113 -0
  29. package/src/payment-config.ts +737 -0
  30. package/src/payment-wrapper.ts +1991 -0
  31. package/src/startup-validator.ts +349 -0
  32. package/src/types.ts +177 -0
  33. package/src/x402-facilitator-binding.ts +104 -0
  34. package/src/x402-replay-durable.ts +320 -0
  35. package/src/x402-replay-guard.ts +165 -0
  36. package/src/x402-replay-keys.ts +151 -0
  37. package/src/x402-resolve.ts +43 -0
  38. package/src/x402-standard-payment.ts +519 -0
  39. package/src/x402-types.ts +376 -0
@@ -0,0 +1,320 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { type AgentRuntime, logger } from "@elizaos/core";
3
+ import { sql } from "drizzle-orm";
4
+
5
+ function sha256Utf8(s: string): string {
6
+ return createHash("sha256").update(s, "utf8").digest("hex");
7
+ }
8
+
9
+ /** Stored in the runtime cache while verification owns a durable reservation. */
10
+ export type X402ReplayInflightPayload = {
11
+ state: "inflight";
12
+ owner: string;
13
+ reservedAt: number;
14
+ expiresAt: number;
15
+ };
16
+
17
+ /** Stored in the runtime cache after a successful payment verification. */
18
+ export type X402ReplayConsumedPayload = {
19
+ state: "consumed";
20
+ consumedAt: number;
21
+ };
22
+
23
+ export type X402ReplayPayload =
24
+ | X402ReplayInflightPayload
25
+ | X402ReplayConsumedPayload;
26
+
27
+ export type DurableReplayReservation =
28
+ | { ok: true; owner: string; atomic: boolean }
29
+ | { ok: false };
30
+
31
+ type QueryableDb = {
32
+ execute: (query: unknown) => Promise<unknown>;
33
+ };
34
+
35
+ /**
36
+ * Stable cache key for a replay credential, scoped by agent so two agents never
37
+ * share the same cache row.
38
+ */
39
+ export function durableReplayCacheKey(
40
+ agentId: string | undefined,
41
+ replayKey: string,
42
+ ): string {
43
+ const agent = agentId && agentId.trim().length > 0 ? agentId.trim() : "_";
44
+ return `x402:replay:v1:${sha256Utf8(`${agent}::${replayKey}`)}`;
45
+ }
46
+
47
+ function replayReservationTtlMs(): number {
48
+ const raw = process.env.X402_REPLAY_RESERVATION_TTL_MS;
49
+ const n = Number.parseInt(raw ?? "120000", 10);
50
+ return Number.isFinite(n) && n > 0 ? n : 120_000;
51
+ }
52
+
53
+ function resultHadRows(result: unknown): boolean {
54
+ if (Array.isArray(result)) return result.length > 0;
55
+ if (typeof result === "object" && result !== null) {
56
+ const rows = (result as { rows?: unknown }).rows;
57
+ if (Array.isArray(rows)) return rows.length > 0;
58
+ const rowCount = (result as { rowCount?: unknown }).rowCount;
59
+ if (typeof rowCount === "number") return rowCount > 0;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ async function getSqlDb(runtime: AgentRuntime): Promise<QueryableDb | null> {
65
+ const db = await runtime.adapter?.getConnection?.();
66
+ if (
67
+ db &&
68
+ typeof db === "object" &&
69
+ typeof (db as { execute?: unknown }).execute === "function"
70
+ ) {
71
+ return db as QueryableDb;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ async function insertInflightReservation(
77
+ db: QueryableDb,
78
+ agentId: string,
79
+ cacheKey: string,
80
+ payload: X402ReplayInflightPayload,
81
+ ): Promise<boolean> {
82
+ const result = await db.execute(sql`
83
+ INSERT INTO cache (key, agent_id, value)
84
+ VALUES (${cacheKey}, ${agentId}, ${JSON.stringify(payload)}::jsonb)
85
+ ON CONFLICT (key, agent_id) DO NOTHING
86
+ RETURNING key
87
+ `);
88
+ return resultHadRows(result);
89
+ }
90
+
91
+ async function stealExpiredInflightReservation(
92
+ db: QueryableDb,
93
+ agentId: string,
94
+ cacheKey: string,
95
+ payload: X402ReplayInflightPayload,
96
+ now: number,
97
+ ): Promise<boolean> {
98
+ const result = await db.execute(sql`
99
+ UPDATE cache
100
+ SET value = ${JSON.stringify(payload)}::jsonb
101
+ WHERE key = ${cacheKey}
102
+ AND agent_id = ${agentId}
103
+ AND value->>'state' = 'inflight'
104
+ AND COALESCE((value->>'expiresAt')::bigint, 0) <= ${now}
105
+ RETURNING key
106
+ `);
107
+ return resultHadRows(result);
108
+ }
109
+
110
+ async function releaseSqlReservations(
111
+ db: QueryableDb,
112
+ agentId: string,
113
+ cacheKeys: string[],
114
+ owner: string,
115
+ ): Promise<void> {
116
+ for (const cacheKey of cacheKeys) {
117
+ await db.execute(sql`
118
+ DELETE FROM cache
119
+ WHERE key = ${cacheKey}
120
+ AND agent_id = ${agentId}
121
+ AND value->>'state' = 'inflight'
122
+ AND value->>'owner' = ${owner}
123
+ `);
124
+ }
125
+ }
126
+
127
+ async function commitSqlReservations(
128
+ db: QueryableDb,
129
+ agentId: string,
130
+ cacheKeys: string[],
131
+ owner: string,
132
+ ): Promise<void> {
133
+ const payload: X402ReplayConsumedPayload = {
134
+ state: "consumed",
135
+ consumedAt: Date.now(),
136
+ };
137
+ for (const cacheKey of cacheKeys) {
138
+ const result = await db.execute(sql`
139
+ UPDATE cache
140
+ SET value = ${JSON.stringify(payload)}::jsonb
141
+ WHERE key = ${cacheKey}
142
+ AND agent_id = ${agentId}
143
+ AND value->>'state' = 'inflight'
144
+ AND value->>'owner' = ${owner}
145
+ RETURNING key
146
+ `);
147
+ if (!resultHadRows(result)) {
148
+ logger.error(
149
+ `[x402] durable replay: failed to commit reserved replay key ${cacheKey}`,
150
+ );
151
+ }
152
+ }
153
+ }
154
+
155
+ function isConsumed(value: X402ReplayPayload | undefined): boolean {
156
+ if (!value) return false;
157
+ return value.state === "consumed" || "consumedAt" in value;
158
+ }
159
+
160
+ export async function durableReplayTryReserve(
161
+ runtime: AgentRuntime,
162
+ agentId: string | undefined,
163
+ keys: string[],
164
+ ): Promise<DurableReplayReservation> {
165
+ if (keys.length === 0) return { ok: true, owner: randomUUID(), atomic: true };
166
+
167
+ const db = await getSqlDb(runtime);
168
+ const resolvedAgentId =
169
+ agentId && agentId.trim().length > 0
170
+ ? agentId.trim()
171
+ : runtime.agentId
172
+ ? String(runtime.agentId)
173
+ : undefined;
174
+
175
+ if (db && resolvedAgentId) {
176
+ const owner = randomUUID();
177
+ const now = Date.now();
178
+ const payload: X402ReplayInflightPayload = {
179
+ state: "inflight",
180
+ owner,
181
+ reservedAt: now,
182
+ expiresAt: now + replayReservationTtlMs(),
183
+ };
184
+ const acquired: string[] = [];
185
+ try {
186
+ for (const replayKey of keys) {
187
+ const cacheKey = durableReplayCacheKey(resolvedAgentId, replayKey);
188
+ if (
189
+ (await insertInflightReservation(
190
+ db,
191
+ resolvedAgentId,
192
+ cacheKey,
193
+ payload,
194
+ )) ||
195
+ (await stealExpiredInflightReservation(
196
+ db,
197
+ resolvedAgentId,
198
+ cacheKey,
199
+ payload,
200
+ now,
201
+ ))
202
+ ) {
203
+ acquired.push(cacheKey);
204
+ continue;
205
+ }
206
+
207
+ await releaseSqlReservations(db, resolvedAgentId, acquired, owner);
208
+ return { ok: false };
209
+ }
210
+ return { ok: true, owner, atomic: true };
211
+ } catch (err) {
212
+ await releaseSqlReservations(db, resolvedAgentId, acquired, owner).catch(
213
+ () => {},
214
+ );
215
+ logger.error(
216
+ `[x402] durable replay: atomic reservation failed: ${
217
+ err instanceof Error ? err.message : String(err)
218
+ }`,
219
+ );
220
+ return { ok: false };
221
+ }
222
+ }
223
+
224
+ const owner = randomUUID();
225
+ for (const replayKey of keys) {
226
+ const cacheKey = durableReplayCacheKey(agentId, replayKey);
227
+ const v = await runtime.getCache<X402ReplayPayload>(cacheKey);
228
+ if (isConsumed(v)) return { ok: false };
229
+ if (v?.state === "inflight" && v.expiresAt > Date.now()) {
230
+ return { ok: false };
231
+ }
232
+ }
233
+ const now = Date.now();
234
+ const payload: X402ReplayInflightPayload = {
235
+ state: "inflight",
236
+ owner,
237
+ reservedAt: now,
238
+ expiresAt: now + replayReservationTtlMs(),
239
+ };
240
+ for (const replayKey of keys) {
241
+ await runtime.setCache(durableReplayCacheKey(agentId, replayKey), payload);
242
+ }
243
+ return { ok: true, owner, atomic: false };
244
+ }
245
+
246
+ export async function durableReplayAbortReservation(
247
+ runtime: AgentRuntime,
248
+ agentId: string | undefined,
249
+ keys: string[],
250
+ owner?: string,
251
+ ): Promise<void> {
252
+ if (!owner || keys.length === 0) return;
253
+ const db = await getSqlDb(runtime);
254
+ const resolvedAgentId =
255
+ agentId && agentId.trim().length > 0
256
+ ? agentId.trim()
257
+ : runtime.agentId
258
+ ? String(runtime.agentId)
259
+ : undefined;
260
+ if (db && resolvedAgentId) {
261
+ await releaseSqlReservations(
262
+ db,
263
+ resolvedAgentId,
264
+ keys.map((k) => durableReplayCacheKey(resolvedAgentId, k)),
265
+ owner,
266
+ );
267
+ return;
268
+ }
269
+ for (const replayKey of keys) {
270
+ const cacheKey = durableReplayCacheKey(agentId, replayKey);
271
+ const v = await runtime.getCache<X402ReplayPayload>(cacheKey);
272
+ if (v?.state === "inflight" && v.owner === owner) {
273
+ await runtime.deleteCache(cacheKey);
274
+ }
275
+ }
276
+ }
277
+
278
+ export async function durableReplayCommitReservation(
279
+ runtime: AgentRuntime,
280
+ agentId: string | undefined,
281
+ keys: string[],
282
+ owner?: string,
283
+ ): Promise<void> {
284
+ if (keys.length === 0) return;
285
+ const db = await getSqlDb(runtime);
286
+ const resolvedAgentId =
287
+ agentId && agentId.trim().length > 0
288
+ ? agentId.trim()
289
+ : runtime.agentId
290
+ ? String(runtime.agentId)
291
+ : undefined;
292
+ if (db && resolvedAgentId && owner) {
293
+ await commitSqlReservations(
294
+ db,
295
+ resolvedAgentId,
296
+ keys.map((k) => durableReplayCacheKey(resolvedAgentId, k)),
297
+ owner,
298
+ );
299
+ return;
300
+ }
301
+
302
+ const payload: X402ReplayConsumedPayload = {
303
+ state: "consumed",
304
+ consumedAt: Date.now(),
305
+ };
306
+ for (const replayKey of keys) {
307
+ const ok = await runtime.setCache(
308
+ durableReplayCacheKey(agentId, replayKey),
309
+ payload,
310
+ );
311
+ if (!ok) {
312
+ logger.error(
313
+ `[x402] durable replay: setCache failed for replay key ${replayKey.slice(
314
+ 0,
315
+ 80,
316
+ )} (payment may be retryable if this persists)`,
317
+ );
318
+ }
319
+ }
320
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Replay + in-flight guard for x402 verification.
3
+ *
4
+ * - **In-flight (`inflight`)**: in-memory only; prevents concurrent duplicate
5
+ * verification in the same process (TOCTOU between check and verify).
6
+ * - **Consumed**: when `X402_REPLAY_DURABLE` is not disabled and `runtime` is
7
+ * passed, credentials are recorded via `runtime.setCache` / `getCache` so they
8
+ * survive restarts and work across multiple server processes sharing the DB.
9
+ * Entries do not expire (same tx hash / payment id must not unlock paid routes twice).
10
+ * - **Fallback**: set `X402_REPLAY_DURABLE=0` (or `false` / `off`) to use only an
11
+ * in-memory TTL map (`X402_REPLAY_WINDOW_MS` / `X402_REPLAY_TTL_MS`, default 10m).
12
+ * If `runtime` is omitted (e.g. isolated unit tests), that same in-memory path is used.
13
+ */
14
+
15
+ import { type AgentRuntime, logger } from "@elizaos/core";
16
+
17
+ import {
18
+ durableReplayAbortReservation,
19
+ durableReplayCommitReservation,
20
+ durableReplayTryReserve,
21
+ } from "./x402-replay-durable.js";
22
+
23
+ const inflight = new Set<string>();
24
+ const consumedMemory = new Map<string, number>();
25
+ const durableReservationOwners = new Map<string, string>();
26
+
27
+ function replayWindowMs(): number {
28
+ const raw =
29
+ process.env.X402_REPLAY_WINDOW_MS ?? process.env.X402_REPLAY_TTL_MS;
30
+ const n = Number.parseInt(raw ?? "600000", 10);
31
+ return Number.isFinite(n) && n > 0 ? n : 600_000;
32
+ }
33
+
34
+ function pruneConsumedMemory(now: number): void {
35
+ for (const [k, exp] of consumedMemory) {
36
+ if (exp <= now) consumedMemory.delete(k);
37
+ }
38
+ }
39
+
40
+ /** When true (default), use runtime cache for consumed credentials. */
41
+ export function isDurableReplayEnabled(): boolean {
42
+ const v = process.env.X402_REPLAY_DURABLE?.trim().toLowerCase();
43
+ if (v === "0" || v === "false" || v === "off") return false;
44
+ return true;
45
+ }
46
+
47
+ /**
48
+ * Reserve canonical replay keys for the duration of verification.
49
+ * Returns false if any key is already consumed, or already in-flight in this process.
50
+ */
51
+ export async function replayGuardTryBegin(
52
+ keys: string[],
53
+ runtime?: AgentRuntime,
54
+ agentId?: string,
55
+ ): Promise<boolean> {
56
+ if (keys.length === 0) return true;
57
+ const now = Date.now();
58
+ const useDurable = isDurableReplayEnabled() && runtime != null;
59
+
60
+ try {
61
+ let durableOwner: string | null = null;
62
+ if (useDurable) {
63
+ const reservation = await durableReplayTryReserve(runtime, agentId, keys);
64
+ if (!reservation.ok) return false;
65
+ durableOwner = reservation.owner;
66
+ } else {
67
+ pruneConsumedMemory(now);
68
+ for (const k of keys) {
69
+ const exp = consumedMemory.get(k);
70
+ if (exp != null && exp > now) return false;
71
+ }
72
+ }
73
+
74
+ for (const k of keys) {
75
+ if (inflight.has(k)) {
76
+ // Already in-flight in this process — release the durable reservation
77
+ // we just took so it does not linger until TTL expiry and block
78
+ // subsequent legitimate attempts for the same credential.
79
+ if (durableOwner && runtime) {
80
+ await durableReplayAbortReservation(
81
+ runtime,
82
+ agentId,
83
+ keys,
84
+ durableOwner,
85
+ );
86
+ }
87
+ return false;
88
+ }
89
+ }
90
+ if (durableOwner) {
91
+ for (const k of keys) durableReservationOwners.set(k, durableOwner);
92
+ }
93
+ for (const k of keys) inflight.add(k);
94
+ return true;
95
+ } catch (err) {
96
+ logger.error(
97
+ `[x402] replayGuardTryBegin failed: ${
98
+ err instanceof Error ? err.message : String(err)
99
+ }`,
100
+ );
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /** Release reservation after a failed or abandoned verification attempt. */
106
+ export function replayGuardAbort(keys: string[]): void {
107
+ for (const k of keys) {
108
+ inflight.delete(k);
109
+ durableReservationOwners.delete(k);
110
+ }
111
+ }
112
+
113
+ /** Release a durable reservation after a failed or abandoned verification attempt. */
114
+ export async function replayGuardAbortAsync(
115
+ keys: string[],
116
+ runtime?: AgentRuntime,
117
+ agentId?: string,
118
+ ): Promise<void> {
119
+ const owner = keys
120
+ .map((k) => durableReservationOwners.get(k))
121
+ .find((x): x is string => typeof x === "string");
122
+ replayGuardAbort(keys);
123
+ if (owner && runtime) {
124
+ await durableReplayAbortReservation(runtime, agentId, keys, owner);
125
+ }
126
+ }
127
+
128
+ /** Mark keys consumed after a successful verification (clears in-flight). */
129
+ export async function replayGuardCommit(
130
+ keys: string[],
131
+ runtime?: AgentRuntime,
132
+ agentId?: string,
133
+ ): Promise<void> {
134
+ const useDurable = isDurableReplayEnabled() && runtime != null;
135
+ const exp = Date.now() + replayWindowMs();
136
+ // Require all keys to map to the same owner. If owners diverge, the
137
+ // in-process map raced with another request — drop the owner so the durable
138
+ // layer can no-op the owner-bound path instead of recording wrong lineage.
139
+ let owner: string | undefined;
140
+ let ownerConsistent = true;
141
+ for (const k of keys) {
142
+ const o = durableReservationOwners.get(k);
143
+ if (typeof o !== "string") continue;
144
+ if (owner === undefined) {
145
+ owner = o;
146
+ } else if (owner !== o) {
147
+ ownerConsistent = false;
148
+ break;
149
+ }
150
+ }
151
+ for (const k of keys) {
152
+ inflight.delete(k);
153
+ durableReservationOwners.delete(k);
154
+ }
155
+ if (useDurable && keys.length > 0 && runtime) {
156
+ await durableReplayCommitReservation(
157
+ runtime,
158
+ agentId,
159
+ keys,
160
+ ownerConsistent ? owner : undefined,
161
+ );
162
+ } else if (!useDurable) {
163
+ for (const k of keys) consumedMemory.set(k, exp);
164
+ }
165
+ }
@@ -0,0 +1,151 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ function sha256Utf8(s: string): string {
4
+ return createHash("sha256").update(s, "utf8").digest("hex");
5
+ }
6
+
7
+ /**
8
+ * Replay key for facilitator payment IDs (sanitized id → stable hash).
9
+ */
10
+ export function paymentIdReplayKey(paymentId: string): string | null {
11
+ const cleaned = paymentId.trim();
12
+ if (!/^[a-zA-Z0-9_-]+$/.test(cleaned) || cleaned.length > 128) return null;
13
+ return `fac:${sha256Utf8(cleaned)}`;
14
+ }
15
+
16
+ function looksMostlyPrintableAscii(s: string): boolean {
17
+ if (!s || s.length > 100_000) return false;
18
+ let ok = 0;
19
+ for (let i = 0; i < s.length; i++) {
20
+ const code = s.charCodeAt(i);
21
+ if (
22
+ code === 9 ||
23
+ code === 10 ||
24
+ code === 13 ||
25
+ (code >= 32 && code < 127)
26
+ ) {
27
+ ok++;
28
+ }
29
+ }
30
+ return ok / s.length > 0.85;
31
+ }
32
+
33
+ function tryBase64Utf8(proof: string): string | null {
34
+ const t = proof.trim();
35
+ if (t.length < 8 || !/^[A-Za-z0-9+/=_-]+$/.test(t.replace(/\s/g, ""))) {
36
+ return null;
37
+ }
38
+ const buf = Buffer.from(t, "base64");
39
+ if (buf.length === 0) return null;
40
+ const decoded = buf.toString("utf8");
41
+ if (!decoded || decoded.includes("\0")) return null;
42
+ if (!looksMostlyPrintableAscii(decoded)) return null;
43
+ return decoded;
44
+ }
45
+
46
+ /**
47
+ * For route handlers: only treat the proof as base64-wrapped UTF-8 when it passes
48
+ * the same heuristics as replay-key extraction. Raw `0x…` tx hashes and colon proofs
49
+ * stay intact (unlike unconditional `Buffer.from(s, "base64")`).
50
+ */
51
+ export function decodePaymentProofForParsing(proof: string): string {
52
+ return tryBase64Utf8(proof) ?? proof;
53
+ }
54
+
55
+ function addEvmTxHashes(s: string, into: Set<string>): void {
56
+ const matches = s.match(/0x[a-fA-F0-9]{64}/g);
57
+ if (!matches) return;
58
+ for (const h of matches) into.add(`evm-tx:${h.toLowerCase()}`);
59
+ }
60
+
61
+ function addSolanaTxSignatures(s: string, into: Set<string>): void {
62
+ const parts = s.split(":");
63
+ if (parts.length >= 3 && parts[0]?.toUpperCase() === "SOLANA") {
64
+ const sig = parts[2]?.trim();
65
+ if (sig && /^[1-9A-HJ-NP-Za-km-z]{87,88}$/.test(sig)) {
66
+ into.add(`sol-tx:${sig}`);
67
+ }
68
+ }
69
+ const trimmed = s.trim().split(/\s+/)[0] ?? "";
70
+ if (/^[1-9A-HJ-NP-Za-km-z]{87,88}$/.test(trimmed)) {
71
+ into.add(`sol-tx:${trimmed}`);
72
+ }
73
+ }
74
+
75
+ function addEip712StableKey(s: string, into: Set<string>): void {
76
+ let obj: unknown;
77
+ try {
78
+ obj = JSON.parse(s);
79
+ } catch {
80
+ return;
81
+ }
82
+ if (typeof obj !== "object" || obj === null) return;
83
+ const root = obj as Record<string, unknown>;
84
+ const payload = root.payload as Record<string, unknown> | undefined;
85
+ const auth = (payload?.authorization ?? root.authorization) as
86
+ | Record<string, unknown>
87
+ | undefined;
88
+ if (!auth || typeof auth !== "object") return;
89
+ const domain = (payload?.domain ?? root.domain) as
90
+ | Record<string, unknown>
91
+ | undefined;
92
+ if (
93
+ typeof auth.from !== "string" ||
94
+ typeof auth.to !== "string" ||
95
+ typeof auth.value !== "string" ||
96
+ typeof auth.nonce !== "string"
97
+ ) {
98
+ return;
99
+ }
100
+ const contract =
101
+ domain && typeof domain.verifyingContract === "string"
102
+ ? domain.verifyingContract.toLowerCase()
103
+ : "";
104
+ const chainId =
105
+ domain && typeof domain.chainId === "number" ? domain.chainId : -1;
106
+ const stable = JSON.stringify({
107
+ c: contract,
108
+ ch: chainId,
109
+ f: auth.from.toLowerCase(),
110
+ t: auth.to.toLowerCase(),
111
+ v: auth.value,
112
+ n: auth.nonce,
113
+ });
114
+ into.add(`eip712:${sha256Utf8(stable)}`);
115
+ }
116
+
117
+ /**
118
+ * Canonical replay keys derivable from a payment proof string (raw or base64-wrapped).
119
+ * Same on-chain tx / Solana signature / EIP-712 intent maps to the same key regardless
120
+ * of outer encoding (e.g. base64 vs plain).
121
+ */
122
+ export function replayKeysFromProofString(proof: string): string[] {
123
+ const keys = new Set<string>();
124
+ const variants = new Set<string>([proof]);
125
+ const decoded = tryBase64Utf8(proof);
126
+ if (decoded) variants.add(decoded);
127
+ for (const v of variants) {
128
+ addEvmTxHashes(v, keys);
129
+ addSolanaTxSignatures(v, keys);
130
+ addEip712StableKey(v, keys);
131
+ }
132
+ return [...keys];
133
+ }
134
+
135
+ /**
136
+ * All replay keys to consult before verification and to mark after a successful one.
137
+ */
138
+ export function collectReplayKeysToCheck(
139
+ paymentProof?: string,
140
+ paymentId?: string,
141
+ ): string[] {
142
+ const keys = new Set<string>();
143
+ if (paymentId) {
144
+ const pk = paymentIdReplayKey(paymentId);
145
+ if (pk) keys.add(pk);
146
+ }
147
+ if (paymentProof) {
148
+ for (const k of replayKeysFromProofString(paymentProof)) keys.add(k);
149
+ }
150
+ return [...keys];
151
+ }
@@ -0,0 +1,43 @@
1
+ import type {
2
+ AgentRuntime,
3
+ Character,
4
+ CharacterX402Settings,
5
+ PaymentEnabledRoute,
6
+ X402Config,
7
+ } from "@elizaos/core";
8
+
9
+ export const X402_EVENT_PAYMENT_VERIFIED = "PAYMENT_VERIFIED";
10
+ export const X402_EVENT_PAYMENT_REQUIRED = "PAYMENT_REQUIRED";
11
+
12
+ function readCharacterX402(
13
+ settings: Character["settings"] | undefined,
14
+ ): CharacterX402Settings | undefined {
15
+ if (!settings || typeof settings !== "object") return undefined;
16
+ const raw = (settings as Record<string, unknown>).x402;
17
+ if (!raw || typeof raw !== "object") return undefined;
18
+ return raw as CharacterX402Settings;
19
+ }
20
+
21
+ /** Resolves `x402: true` / partial route config using `character.settings.x402`. */
22
+ export function resolveEffectiveX402(
23
+ route: PaymentEnabledRoute,
24
+ runtime: AgentRuntime,
25
+ ): X402Config | null {
26
+ const cx = readCharacterX402(runtime.character?.settings);
27
+ const raw = route.x402;
28
+ if (raw === true) {
29
+ if (cx?.defaultPriceInCents == null || !cx.defaultPaymentConfigs?.length)
30
+ return null;
31
+ return {
32
+ priceInCents: cx.defaultPriceInCents,
33
+ paymentConfigs: [...cx.defaultPaymentConfigs],
34
+ };
35
+ }
36
+ if (raw && typeof raw === "object") {
37
+ const price = raw.priceInCents ?? cx?.defaultPriceInCents;
38
+ const configs = raw.paymentConfigs ?? cx?.defaultPaymentConfigs;
39
+ if (price == null || !configs?.length) return null;
40
+ return { priceInCents: price, paymentConfigs: [...configs] };
41
+ }
42
+ return null;
43
+ }