@absol-labs/agent 0.6.0 → 0.7.0

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 (47) hide show
  1. package/README.md +21 -5
  2. package/dist/index.d.ts +6 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +6 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/wallet/autonomous-wallet-broker.d.ts +97 -0
  7. package/dist/wallet/autonomous-wallet-broker.d.ts.map +1 -0
  8. package/dist/wallet/autonomous-wallet-broker.js +468 -0
  9. package/dist/wallet/autonomous-wallet-broker.js.map +1 -0
  10. package/dist/wallet/autonomous-wallet-protocol.d.ts +56 -0
  11. package/dist/wallet/autonomous-wallet-protocol.d.ts.map +1 -0
  12. package/dist/wallet/autonomous-wallet-protocol.js +15 -0
  13. package/dist/wallet/autonomous-wallet-protocol.js.map +1 -0
  14. package/dist/wallet/autonomous-wallet.d.ts +106 -0
  15. package/dist/wallet/autonomous-wallet.d.ts.map +1 -0
  16. package/dist/wallet/autonomous-wallet.js +494 -0
  17. package/dist/wallet/autonomous-wallet.js.map +1 -0
  18. package/dist/wallet/privy-broker-server-entry.d.ts +2 -0
  19. package/dist/wallet/privy-broker-server-entry.d.ts.map +1 -0
  20. package/dist/wallet/privy-broker-server-entry.js +8 -0
  21. package/dist/wallet/privy-broker-server-entry.js.map +1 -0
  22. package/dist/wallet/privy-broker-server.d.ts +29 -0
  23. package/dist/wallet/privy-broker-server.d.ts.map +1 -0
  24. package/dist/wallet/privy-broker-server.js +480 -0
  25. package/dist/wallet/privy-broker-server.js.map +1 -0
  26. package/dist/wallet/privy-session-broker.d.ts +109 -0
  27. package/dist/wallet/privy-session-broker.d.ts.map +1 -0
  28. package/dist/wallet/privy-session-broker.js +372 -0
  29. package/dist/wallet/privy-session-broker.js.map +1 -0
  30. package/dist/wallet/privy-session-provider.d.ts +21 -0
  31. package/dist/wallet/privy-session-provider.d.ts.map +1 -0
  32. package/dist/wallet/privy-session-provider.js +94 -0
  33. package/dist/wallet/privy-session-provider.js.map +1 -0
  34. package/dist/wallet/provider.d.ts +51 -2
  35. package/dist/wallet/provider.d.ts.map +1 -1
  36. package/dist/wallet/provider.js +138 -1
  37. package/dist/wallet/provider.js.map +1 -1
  38. package/package.json +3 -1
  39. package/src/index.ts +73 -0
  40. package/src/wallet/autonomous-wallet-broker.ts +764 -0
  41. package/src/wallet/autonomous-wallet-protocol.ts +71 -0
  42. package/src/wallet/autonomous-wallet.ts +779 -0
  43. package/src/wallet/privy-broker-server-entry.ts +9 -0
  44. package/src/wallet/privy-broker-server.ts +573 -0
  45. package/src/wallet/privy-session-broker.ts +634 -0
  46. package/src/wallet/privy-session-provider.ts +129 -0
  47. package/src/wallet/provider.ts +260 -3
@@ -0,0 +1,764 @@
1
+ import { createPublicKey, randomUUID, verify } from "node:crypto";
2
+
3
+ import {
4
+ formatRequestForAuthorizationSignature,
5
+ type PrivyClient,
6
+ } from "@privy-io/node";
7
+ import { streamEscrowV2Abi } from "@absol-labs/shared";
8
+ import {
9
+ decodeFunctionData,
10
+ getAddress,
11
+ isAddress,
12
+ type Address,
13
+ type Hex,
14
+ } from "viem";
15
+ import { z } from "zod";
16
+
17
+ import {
18
+ autonomousProofBytes,
19
+ AUTONOMOUS_WALLET_CHAIN_ID,
20
+ AUTONOMOUS_WALLET_VERSION,
21
+ type AutonomousWalletBroker as AutonomousWalletClientBroker,
22
+ type AutonomousWalletProof,
23
+ type PreparedPrivyRequest,
24
+ } from "./autonomous-wallet-protocol.js";
25
+ import { invocationCapabilitySchema } from "../capability/invocation-capability.js";
26
+ import {
27
+ invocationCapabilityDomainName,
28
+ invocationCapabilityDomainVersion,
29
+ } from "../capability/invocation-capability.js";
30
+ import {
31
+ PRIVY_POLICY_MAX_DURATION_SECONDS,
32
+ PRIVY_POLICY_MAX_RATE_USDC,
33
+ PRIVY_POLICY_MAX_TOTAL_USDC,
34
+ PRIVY_BROKER_ESCROW,
35
+ PRIVY_BROKER_USDC,
36
+ type BrokerStreamReader,
37
+ } from "./privy-session-broker.js";
38
+
39
+ const transactionSchema = z.object({
40
+ from: z.string().refine(isAddress),
41
+ to: z.string().refine(isAddress),
42
+ data: z.string().regex(/^0x[0-9a-fA-F]*$/),
43
+ value: z.union([z.string(), z.number(), z.bigint()]).optional(),
44
+ chainId: z.union([z.string(), z.number()]).optional(),
45
+ });
46
+
47
+ const typedDataSchema = z.object({
48
+ domain: z.object({
49
+ name: z.string(),
50
+ version: z.string(),
51
+ chainId: z.union([z.string(), z.number()]),
52
+ verifyingContract: z.string().refine(isAddress),
53
+ }),
54
+ primaryType: z.literal("InvocationCapability"),
55
+ types: z.record(z.array(z.object({ name: z.string(), type: z.string() }))),
56
+ message: z.record(z.unknown()),
57
+ });
58
+
59
+ export interface AutonomousWalletBrokerStore {
60
+ findProvisionByPublicKey(
61
+ publicKey: string,
62
+ ): Promise<AutonomousWalletProvision | null>;
63
+ findProvisionByNonce(
64
+ nonce: string,
65
+ ): Promise<AutonomousWalletProvision | null>;
66
+ putProvision(provision: AutonomousWalletProvision): Promise<void>;
67
+ getPrepared(requestId: string): Promise<AutonomousPreparedRequest | null>;
68
+ putPrepared(request: AutonomousPreparedRequest): Promise<void>;
69
+ completePrepared(
70
+ requestId: string,
71
+ result: Hex,
72
+ consumedAt: number,
73
+ ): Promise<AutonomousPreparedRequest | null>;
74
+ }
75
+
76
+ export interface AutonomousWalletProvision {
77
+ readonly publicKey: string;
78
+ readonly nonce: string;
79
+ readonly walletId: string;
80
+ readonly address: Address;
81
+ readonly policyId: string;
82
+ }
83
+
84
+ export interface AutonomousPreparedRequest extends PreparedPrivyRequest {
85
+ readonly publicKey: string;
86
+ readonly idempotencyKey: string;
87
+ readonly requestExpiryMs: number;
88
+ readonly body: Record<string, unknown>;
89
+ readonly consumedAt?: number;
90
+ readonly result?: Hex;
91
+ }
92
+
93
+ export interface AutonomousPrivyProvider {
94
+ createWallet(input: {
95
+ readonly publicKey: string;
96
+ readonly policyId: string;
97
+ readonly idempotencyKey: string;
98
+ }): Promise<{
99
+ readonly walletId: string;
100
+ readonly address: Address;
101
+ readonly policyId: string;
102
+ }>;
103
+ executeRpc(input: {
104
+ readonly walletId: string;
105
+ readonly body: Record<string, unknown>;
106
+ readonly idempotencyKey: string;
107
+ readonly requestExpiryMs: number;
108
+ readonly signature: string;
109
+ }): Promise<Hex>;
110
+ }
111
+
112
+ export class AutonomousWalletBrokerError extends Error {
113
+ constructor(
114
+ readonly code:
115
+ | "invalid-request"
116
+ | "unauthorized"
117
+ | "replay"
118
+ | "policy-denied"
119
+ | "provider-error",
120
+ message: string,
121
+ options?: { readonly cause?: unknown },
122
+ ) {
123
+ super(message, options);
124
+ this.name = "AutonomousWalletBrokerError";
125
+ }
126
+ }
127
+
128
+ export interface AutonomousWalletBrokerOptions {
129
+ readonly appId: string;
130
+ /** The one policy that is approved for Metrik autonomous wallets. */
131
+ readonly policyId: string;
132
+ readonly provider: AutonomousPrivyProvider;
133
+ readonly store: AutonomousWalletBrokerStore;
134
+ readonly streamReader: BrokerStreamReader;
135
+ readonly nowSeconds?: () => number;
136
+ readonly proofMaxAgeSeconds?: number;
137
+ readonly requestTtlSeconds?: number;
138
+ /** Must match the base URL used by the Privy client, without a trailing slash. */
139
+ readonly privyApiBaseUrl?: string;
140
+ }
141
+
142
+ /**
143
+ * Broker for the agent-owned path. The server knows the wallet ID and Privy
144
+ * app credentials, but never receives or stores the agent's private key. Every
145
+ * RPC is frozen into a short-lived request; the agent signs those exact bytes,
146
+ * then the broker forwards that signature as Privy's authorization context.
147
+ */
148
+ export class AutonomousWalletBroker implements AutonomousWalletClientBroker {
149
+ private readonly locks = new Map<string, Promise<void>>();
150
+ private readonly nowSeconds: () => number;
151
+ private readonly proofMaxAge: number;
152
+ private readonly requestTtl: number;
153
+ private readonly privyApiBaseUrl: string;
154
+
155
+ constructor(private readonly options: AutonomousWalletBrokerOptions) {
156
+ if (options.appId.trim() === "" || options.policyId.trim() === "") {
157
+ throw new AutonomousWalletBrokerError(
158
+ "invalid-request",
159
+ "Privy app id and fixed policy id are required",
160
+ );
161
+ }
162
+ this.nowSeconds =
163
+ options.nowSeconds ?? (() => Math.floor(Date.now() / 1_000));
164
+ this.proofMaxAge = options.proofMaxAgeSeconds ?? 300;
165
+ this.requestTtl = options.requestTtlSeconds ?? 60;
166
+ if (
167
+ !Number.isInteger(this.proofMaxAge) ||
168
+ this.proofMaxAge < 1 ||
169
+ !Number.isInteger(this.requestTtl) ||
170
+ this.requestTtl < 1
171
+ ) {
172
+ throw new AutonomousWalletBrokerError(
173
+ "invalid-request",
174
+ "proof and request TTLs must be positive integers",
175
+ );
176
+ }
177
+ const base = options.privyApiBaseUrl ?? "https://api.privy.io";
178
+ const url = new URL(base);
179
+ if (url.protocol !== "https:")
180
+ throw new AutonomousWalletBrokerError(
181
+ "invalid-request",
182
+ "Privy API URL must use HTTPS",
183
+ );
184
+ url.search = "";
185
+ url.hash = "";
186
+ this.privyApiBaseUrl = url.toString().replace(/\/$/, "");
187
+ }
188
+
189
+ async provision(
190
+ input: Parameters<AutonomousWalletClientBroker["provision"]>[0],
191
+ ) {
192
+ // A single provisioning lock closes both the public-key and nonce races.
193
+ // Provisioning is rare and Privy is the dominant latency, so serializing it
194
+ // is preferable to creating two wallets for concurrent conflicting proofs.
195
+ return this.withLock("provision", () => this.provisionUnlocked(input));
196
+ }
197
+
198
+ private async provisionUnlocked(
199
+ input: Parameters<AutonomousWalletClientBroker["provision"]>[0],
200
+ ) {
201
+ if (
202
+ input.appId !== this.options.appId ||
203
+ input.chainId !== AUTONOMOUS_WALLET_CHAIN_ID
204
+ ) {
205
+ throw new AutonomousWalletBrokerError(
206
+ "invalid-request",
207
+ "autonomous wallet request is for an unsupported app or chain",
208
+ );
209
+ }
210
+ if (input.proof.nonce !== input.idempotencyKey) {
211
+ throw new AutonomousWalletBrokerError(
212
+ "unauthorized",
213
+ "proof nonce does not match idempotency key",
214
+ );
215
+ }
216
+ verifyProof(
217
+ input.proof,
218
+ input.publicKey,
219
+ this.options.appId,
220
+ this.nowSeconds(),
221
+ this.proofMaxAge,
222
+ );
223
+ const byNonce = await this.options.store.findProvisionByNonce(
224
+ input.idempotencyKey,
225
+ );
226
+ if (byNonce !== null) {
227
+ if (byNonce.publicKey !== input.publicKey)
228
+ throw new AutonomousWalletBrokerError(
229
+ "replay",
230
+ "provisioning nonce is already bound to another key",
231
+ );
232
+ return byNonce;
233
+ }
234
+ const existing = await this.options.store.findProvisionByPublicKey(
235
+ input.publicKey,
236
+ );
237
+ if (existing !== null) {
238
+ if (existing.nonce !== input.idempotencyKey)
239
+ throw new AutonomousWalletBrokerError(
240
+ "replay",
241
+ "public key is already bound to another provisioning nonce",
242
+ );
243
+ return existing;
244
+ }
245
+ let created: Awaited<ReturnType<AutonomousPrivyProvider["createWallet"]>>;
246
+ try {
247
+ created = await this.options.provider.createWallet({
248
+ publicKey: input.publicKey,
249
+ policyId: this.options.policyId,
250
+ idempotencyKey: input.idempotencyKey,
251
+ });
252
+ } catch (error) {
253
+ throw new AutonomousWalletBrokerError(
254
+ "provider-error",
255
+ "Privy rejected autonomous wallet creation",
256
+ { cause: error },
257
+ );
258
+ }
259
+ if (created.policyId !== this.options.policyId) {
260
+ throw new AutonomousWalletBrokerError(
261
+ "provider-error",
262
+ "Privy returned a wallet without the approved Metrik policy",
263
+ );
264
+ }
265
+ const result: AutonomousWalletProvision = {
266
+ ...created,
267
+ publicKey: input.publicKey,
268
+ nonce: input.idempotencyKey,
269
+ };
270
+ await this.options.store.putProvision(result);
271
+ return result;
272
+ }
273
+
274
+ async prepare(
275
+ input: Parameters<AutonomousWalletClientBroker["prepare"]>[0],
276
+ ): Promise<PreparedPrivyRequest> {
277
+ const provision = await this.options.store.findProvisionByPublicKey(
278
+ input.publicKey,
279
+ );
280
+ if (provision === null || provision.walletId !== input.walletId)
281
+ throw new AutonomousWalletBrokerError(
282
+ "unauthorized",
283
+ "wallet is not bound to this public key",
284
+ );
285
+ const body = await buildRpcBody(
286
+ input.method,
287
+ input.params,
288
+ provision.address,
289
+ this.options.streamReader,
290
+ this.nowSeconds(),
291
+ );
292
+ const requestExpiryMs = (this.nowSeconds() + this.requestTtl) * 1_000;
293
+ const idempotencyKey = randomUUID();
294
+ const requestId = randomUUID();
295
+ const payload = formatRequestForAuthorizationSignature({
296
+ version: 1,
297
+ method: "POST",
298
+ url: `${this.privyApiBaseUrl}/v1/wallets/${input.walletId}/rpc`,
299
+ body,
300
+ headers: {
301
+ "privy-app-id": this.options.appId,
302
+ "privy-idempotency-key": idempotencyKey,
303
+ "privy-request-expiry": String(requestExpiryMs),
304
+ },
305
+ });
306
+ const prepared: AutonomousPreparedRequest = {
307
+ requestId,
308
+ walletId: input.walletId,
309
+ publicKey: input.publicKey,
310
+ method: input.method,
311
+ payload: Buffer.from(payload).toString("base64"),
312
+ expiresAt: Math.floor(requestExpiryMs / 1_000),
313
+ requestExpiryMs,
314
+ idempotencyKey,
315
+ body,
316
+ };
317
+ await this.options.store.putPrepared(prepared);
318
+ return prepared;
319
+ }
320
+
321
+ async execute(
322
+ input: Parameters<AutonomousWalletClientBroker["execute"]>[0],
323
+ ): Promise<Hex> {
324
+ return this.withLock(input.requestId, async () => {
325
+ const prepared = await this.options.store.getPrepared(input.requestId);
326
+ if (prepared === null)
327
+ throw new AutonomousWalletBrokerError(
328
+ "replay",
329
+ "prepared request was already consumed or does not exist",
330
+ );
331
+ if (prepared.publicKey !== input.publicKey)
332
+ throw new AutonomousWalletBrokerError(
333
+ "unauthorized",
334
+ "request signer does not match the prepared public key",
335
+ );
336
+ if (prepared.result !== undefined) return prepared.result;
337
+ if (prepared.consumedAt !== undefined)
338
+ throw new AutonomousWalletBrokerError(
339
+ "replay",
340
+ "prepared request was already consumed",
341
+ );
342
+ if (this.nowSeconds() >= prepared.expiresAt)
343
+ throw new AutonomousWalletBrokerError(
344
+ "replay",
345
+ "prepared request has expired",
346
+ );
347
+ verifyP256Signature(
348
+ prepared.publicKey,
349
+ Buffer.from(prepared.payload, "base64"),
350
+ input.signature,
351
+ );
352
+ let result: Hex;
353
+ try {
354
+ result = await this.options.provider.executeRpc({
355
+ walletId: prepared.walletId,
356
+ body: prepared.body,
357
+ idempotencyKey: prepared.idempotencyKey,
358
+ requestExpiryMs: prepared.requestExpiryMs,
359
+ signature: input.signature,
360
+ });
361
+ } catch (error) {
362
+ throw new AutonomousWalletBrokerError(
363
+ "provider-error",
364
+ "Privy rejected the agent authorization signature",
365
+ { cause: error },
366
+ );
367
+ }
368
+ const completed = await this.options.store.completePrepared(
369
+ input.requestId,
370
+ result,
371
+ this.nowSeconds(),
372
+ );
373
+ if (completed === null)
374
+ throw new AutonomousWalletBrokerError(
375
+ "replay",
376
+ "prepared request disappeared before completion",
377
+ );
378
+ return result;
379
+ });
380
+ }
381
+
382
+ private async withLock<T>(
383
+ id: string,
384
+ operation: () => Promise<T>,
385
+ ): Promise<T> {
386
+ const previous = this.locks.get(id) ?? Promise.resolve();
387
+ let release!: () => void;
388
+ const next = new Promise<void>((resolve) => {
389
+ release = resolve;
390
+ });
391
+ const queued = previous.then(() => next);
392
+ this.locks.set(id, queued);
393
+ await previous;
394
+ try {
395
+ return await operation();
396
+ } finally {
397
+ release();
398
+ if (this.locks.get(id) === queued) this.locks.delete(id);
399
+ }
400
+ }
401
+ }
402
+
403
+ /** Adapts the installed @privy-io/node client without giving it an agent key. */
404
+ export function createPrivyAutonomousProvider(
405
+ client: PrivyClient,
406
+ ): AutonomousPrivyProvider {
407
+ return {
408
+ async createWallet(input) {
409
+ const wallet = await client.wallets().create({
410
+ chain_type: "ethereum",
411
+ owner: { public_key: input.publicKey },
412
+ policy_ids: [input.policyId],
413
+ idempotency_key: input.idempotencyKey,
414
+ });
415
+ return {
416
+ walletId: wallet.id,
417
+ address: getAddress(wallet.address),
418
+ policyId: wallet.policy_ids[0] ?? "",
419
+ };
420
+ },
421
+ async executeRpc(input) {
422
+ const response: unknown = await client.wallets().rpc(input.walletId, {
423
+ ...input.body,
424
+ authorization_context: { signatures: [input.signature] },
425
+ idempotency_key: input.idempotencyKey,
426
+ request_expiry: input.requestExpiryMs,
427
+ } as never);
428
+ const data = (response as { readonly data?: unknown }).data;
429
+ if (data === null || typeof data !== "object")
430
+ throw new Error("Privy returned an invalid RPC response");
431
+ const record = data as Record<string, unknown>;
432
+ const result = record.hash ?? record.signature;
433
+ if (typeof result !== "string" || !/^0x[0-9a-fA-F]+$/.test(result))
434
+ throw new Error("Privy returned no transaction/signature result");
435
+ return result as Hex;
436
+ },
437
+ };
438
+ }
439
+
440
+ export class InMemoryAutonomousWalletBrokerStore implements AutonomousWalletBrokerStore {
441
+ private readonly provisions = new Map<string, AutonomousWalletProvision>();
442
+ private readonly prepared = new Map<string, AutonomousPreparedRequest>();
443
+ async findProvisionByPublicKey(key: string) {
444
+ return this.provisions.get(key) ?? null;
445
+ }
446
+ async findProvisionByNonce(nonce: string) {
447
+ return [...this.provisions.values()].find((p) => p.nonce === nonce) ?? null;
448
+ }
449
+ async putProvision(value: AutonomousWalletProvision) {
450
+ this.provisions.set(value.publicKey, value);
451
+ }
452
+ async getPrepared(id: string) {
453
+ return this.prepared.get(id) ?? null;
454
+ }
455
+ async putPrepared(value: AutonomousPreparedRequest) {
456
+ this.prepared.set(value.requestId, value);
457
+ }
458
+ async completePrepared(id: string, result: Hex, consumedAt: number) {
459
+ const value = this.prepared.get(id);
460
+ if (value === undefined) return null;
461
+ if (value.result !== undefined) return value;
462
+ const consumed = { ...value, consumedAt, result };
463
+ this.prepared.set(id, consumed);
464
+ return consumed;
465
+ }
466
+ }
467
+
468
+ function verifyProof(
469
+ proof: AutonomousWalletProof,
470
+ publicKey: string,
471
+ appId: string,
472
+ now: number,
473
+ maxAge: number,
474
+ ): void {
475
+ if (
476
+ proof.version !== AUTONOMOUS_WALLET_VERSION ||
477
+ proof.appId !== appId ||
478
+ proof.chainId !== AUTONOMOUS_WALLET_CHAIN_ID ||
479
+ proof.publicKey !== publicKey ||
480
+ !Number.isSafeInteger(proof.issuedAt) ||
481
+ proof.issuedAt > now + 30 ||
482
+ proof.issuedAt < now - maxAge ||
483
+ !/^[A-Za-z0-9+/]+={0,2}$/.test(proof.signature)
484
+ )
485
+ throw new AutonomousWalletBrokerError(
486
+ "unauthorized",
487
+ "invalid autonomous wallet proof",
488
+ );
489
+ try {
490
+ const key = parseP256PublicKey(publicKey);
491
+ if (
492
+ !verify(
493
+ "sha256",
494
+ autonomousProofBytes(proof),
495
+ key,
496
+ Buffer.from(proof.signature, "base64"),
497
+ )
498
+ )
499
+ throw new Error("signature mismatch");
500
+ } catch (error) {
501
+ throw new AutonomousWalletBrokerError(
502
+ "unauthorized",
503
+ "invalid autonomous wallet proof signature",
504
+ { cause: error },
505
+ );
506
+ }
507
+ }
508
+
509
+ function verifyP256Signature(
510
+ publicKey: string,
511
+ payload: Uint8Array,
512
+ signature: string,
513
+ ): void {
514
+ try {
515
+ const key = parseP256PublicKey(publicKey);
516
+ if (
517
+ !isStrictBase64(signature) ||
518
+ !verify("sha256", payload, key, Buffer.from(signature, "base64"))
519
+ ) {
520
+ throw new Error("signature mismatch");
521
+ }
522
+ } catch (error) {
523
+ throw new AutonomousWalletBrokerError(
524
+ "unauthorized",
525
+ "invalid prepared-request signature",
526
+ { cause: error },
527
+ );
528
+ }
529
+ }
530
+
531
+ function parseP256PublicKey(value: string) {
532
+ if (!isStrictBase64(value))
533
+ throw new Error("public key is not strict base64");
534
+ const key = createPublicKey({
535
+ key: Buffer.from(value, "base64"),
536
+ format: "der",
537
+ type: "spki",
538
+ });
539
+ if (
540
+ key.asymmetricKeyType !== "ec" ||
541
+ key.asymmetricKeyDetails?.namedCurve !== "prime256v1"
542
+ ) {
543
+ throw new Error("public key is not a P-256 key");
544
+ }
545
+ return key;
546
+ }
547
+
548
+ function isStrictBase64(value: string): boolean {
549
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 === 1)
550
+ return false;
551
+ const decoded = Buffer.from(value, "base64");
552
+ return decoded.length > 0 && decoded.toString("base64") === value;
553
+ }
554
+
555
+ async function buildRpcBody(
556
+ method: "eth_sendTransaction" | "eth_signTypedData_v4",
557
+ params: readonly unknown[],
558
+ address: Address,
559
+ streamReader: BrokerStreamReader,
560
+ now: number,
561
+ ): Promise<Record<string, unknown>> {
562
+ if (method === "eth_sendTransaction") {
563
+ const tx = transactionSchema.parse(params[0]);
564
+ if (tx.from.toLowerCase() !== address.toLowerCase())
565
+ throw new AutonomousWalletBrokerError(
566
+ "policy-denied",
567
+ "transaction sender does not match wallet",
568
+ );
569
+ assertChain(tx.chainId);
570
+ assertZeroValue(tx.value);
571
+ const target = getAddress(tx.to).toLowerCase();
572
+ if (target === PRIVY_BROKER_USDC.toLowerCase()) {
573
+ assertUsdcApproval(tx.data as Hex);
574
+ } else if (target === PRIVY_BROKER_ESCROW.toLowerCase()) {
575
+ await assertEscrowMethod(tx.data as Hex, address, streamReader);
576
+ } else {
577
+ throw new AutonomousWalletBrokerError(
578
+ "policy-denied",
579
+ "transaction target is not allowlisted",
580
+ );
581
+ }
582
+ const { chainId: _chainId, ...transaction } = tx;
583
+ return {
584
+ method,
585
+ chain_type: "ethereum",
586
+ caip2: "eip155:84532",
587
+ params: { transaction: normalizeJson(transaction) },
588
+ };
589
+ }
590
+ const signer = params[0];
591
+ const typed =
592
+ typeof params[1] === "string"
593
+ ? (JSON.parse(params[1]) as unknown)
594
+ : params[1];
595
+ if (
596
+ typeof signer !== "string" ||
597
+ signer.toLowerCase() !== address.toLowerCase()
598
+ )
599
+ throw new AutonomousWalletBrokerError(
600
+ "policy-denied",
601
+ "typed-data signer does not match wallet",
602
+ );
603
+ const parsed = typedDataSchema.parse(typed);
604
+ if (
605
+ parsed.domain.chainId !== AUTONOMOUS_WALLET_CHAIN_ID &&
606
+ Number(parsed.domain.chainId) !== AUTONOMOUS_WALLET_CHAIN_ID
607
+ )
608
+ throw new AutonomousWalletBrokerError(
609
+ "policy-denied",
610
+ "typed data is not for Base Sepolia",
611
+ );
612
+ if (
613
+ parsed.domain.name !== invocationCapabilityDomainName ||
614
+ parsed.domain.version !== invocationCapabilityDomainVersion ||
615
+ parsed.domain.verifyingContract.toLowerCase() !==
616
+ PRIVY_BROKER_ESCROW.toLowerCase()
617
+ )
618
+ throw new AutonomousWalletBrokerError(
619
+ "policy-denied",
620
+ "only Metrik invocation capabilities may be signed",
621
+ );
622
+ const capability = invocationCapabilitySchema.parse({
623
+ ...parsed.message,
624
+ expiry:
625
+ typeof parsed.message.expiry === "string"
626
+ ? Number(parsed.message.expiry)
627
+ : parsed.message.expiry,
628
+ });
629
+ if (
630
+ capability.buyer.toLowerCase() !== address.toLowerCase() ||
631
+ capability.expiry <= now ||
632
+ capability.expiry > now + 120
633
+ ) {
634
+ throw new AutonomousWalletBrokerError(
635
+ "policy-denied",
636
+ "capability buyer or expiry is outside the autonomous wallet policy",
637
+ );
638
+ }
639
+ const stream = await streamReader.getStream(capability.streamId as Hex);
640
+ if (
641
+ stream.status !== "active" ||
642
+ stream.buyer.toLowerCase() !== address.toLowerCase() ||
643
+ stream.serviceRef.toLowerCase() !== capability.serviceRef.toLowerCase()
644
+ ) {
645
+ throw new AutonomousWalletBrokerError(
646
+ "policy-denied",
647
+ "capability does not match an active buyer stream",
648
+ );
649
+ }
650
+ return {
651
+ method,
652
+ chain_type: "ethereum",
653
+ caip2: "eip155:84532",
654
+ params: { typed_data: normalizeJson(parsed) },
655
+ };
656
+ }
657
+
658
+ function normalizeJson(value: unknown): unknown {
659
+ if (typeof value === "bigint") return `0x${value.toString(16)}`;
660
+ if (Array.isArray(value)) return value.map(normalizeJson);
661
+ if (value !== null && typeof value === "object") {
662
+ return Object.fromEntries(
663
+ Object.entries(value).map(([key, item]) => [key, normalizeJson(item)]),
664
+ );
665
+ }
666
+ return value;
667
+ }
668
+
669
+ function assertChain(value: string | number | undefined): void {
670
+ if (value === undefined) return;
671
+ const parsed =
672
+ typeof value === "number"
673
+ ? value
674
+ : value.startsWith("0x")
675
+ ? Number.parseInt(value.slice(2), 16)
676
+ : Number(value);
677
+ if (parsed !== AUTONOMOUS_WALLET_CHAIN_ID)
678
+ throw new AutonomousWalletBrokerError(
679
+ "policy-denied",
680
+ "transaction is not for Base Sepolia",
681
+ );
682
+ }
683
+
684
+ function assertZeroValue(value: string | number | bigint | undefined): void {
685
+ if (value === undefined) return;
686
+ try {
687
+ if (BigInt(value) !== 0n) throw new Error("nonzero");
688
+ } catch (error) {
689
+ throw new AutonomousWalletBrokerError(
690
+ "policy-denied",
691
+ "native-token transfers are not allowed",
692
+ { cause: error },
693
+ );
694
+ }
695
+ }
696
+
697
+ function assertUsdcApproval(data: Hex): void {
698
+ try {
699
+ const decoded = decodeFunctionData({
700
+ abi: [
701
+ {
702
+ type: "function",
703
+ name: "approve",
704
+ stateMutability: "nonpayable",
705
+ inputs: [
706
+ { name: "spender", type: "address" },
707
+ { name: "amount", type: "uint256" },
708
+ ],
709
+ outputs: [{ name: "", type: "bool" }],
710
+ },
711
+ ] as const,
712
+ data,
713
+ });
714
+ const [spender, amount] = decoded.args;
715
+ if (
716
+ spender.toLowerCase() !== PRIVY_BROKER_ESCROW.toLowerCase() ||
717
+ amount > 1_000_000n
718
+ )
719
+ throw new Error("approval outside Metrik policy");
720
+ } catch (error) {
721
+ throw new AutonomousWalletBrokerError(
722
+ "policy-denied",
723
+ "USDC approval is not an allowlisted escrow approval",
724
+ { cause: error },
725
+ );
726
+ }
727
+ }
728
+
729
+ async function assertEscrowMethod(
730
+ data: Hex,
731
+ address: Address,
732
+ streamReader: BrokerStreamReader,
733
+ ): Promise<void> {
734
+ try {
735
+ const decoded = decodeFunctionData({ abi: streamEscrowV2Abi, data });
736
+ if (decoded.functionName === "openStream") {
737
+ const [, , deposit, rate, duration] = decoded.args;
738
+ if (
739
+ deposit > PRIVY_POLICY_MAX_TOTAL_USDC ||
740
+ rate > PRIVY_POLICY_MAX_RATE_USDC ||
741
+ duration > BigInt(PRIVY_POLICY_MAX_DURATION_SECONDS)
742
+ ) {
743
+ throw new Error("openStream exceeds the fixed Metrik policy");
744
+ }
745
+ return;
746
+ }
747
+ if (
748
+ decoded.functionName !== "close" &&
749
+ decoded.functionName !== "reclaim" &&
750
+ decoded.functionName !== "reclaimUnverified"
751
+ ) {
752
+ throw new Error("escrow method is not allowlisted");
753
+ }
754
+ const stream = await streamReader.getStream(decoded.args[0]);
755
+ if (stream.buyer.toLowerCase() !== address.toLowerCase())
756
+ throw new Error("autonomous wallet is not the stream buyer");
757
+ } catch (error) {
758
+ throw new AutonomousWalletBrokerError(
759
+ "policy-denied",
760
+ "escrow call is outside the fixed Metrik policy",
761
+ { cause: error },
762
+ );
763
+ }
764
+ }