wip-agent-pay 1.0.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.
@@ -0,0 +1,1440 @@
1
+ // worker/index.js
2
+ // Cloudflare Worker for wip-agent-pay.
3
+ // All payment logic lives here. Local providers just call these routes.
4
+ //
5
+ // Routes:
6
+ // GET / Health check
7
+ // POST /create Mint one-time URL (existing Mode B)
8
+ // GET /:token Redeem one-time URL
9
+ // POST /x402/pay x402 flow via Coinbase CDP (Path 1: self-custody)
10
+ // POST /privy/pay x402 flow via Privy wallet (Path 1: self-custody)
11
+ // POST /pool/pay Pool Mode A: Stripe checkout + x402 from Parker's float
12
+ // POST /pool/confirm Pool Mode A: poll for Stripe confirmation + get content
13
+ // POST /wallet/create Mode C: create Privy wallet by email (over-$25)
14
+ // POST /wallet/pay Mode C: sign x402 from user's Privy wallet
15
+ // POST /stripe/checkout Create Stripe Checkout session (funding)
16
+ // POST /stripe/webhook Stripe payment confirmation
17
+ // GET /stripe/success Post-checkout redirect
18
+ // GET /stripe/cancel Checkout cancelled
19
+ // POST /balance Wallet balance
20
+ // POST /history Transaction history
21
+ // POST /budget View/set spending limits
22
+ //
23
+ // Pool Mode pricing:
24
+ // User pays: x402 price + Stripe fees (2.9% + $0.30) + $0.25 flat fee
25
+ // Parker nets: $0.25 per transaction
26
+ // Max pool transaction: $25. Over $25 redirects to Mode C (user's own wallet).
27
+ //
28
+ // Bindings:
29
+ // KV: PAY_TOKENS, PAY_LEDGER
30
+ // Env: WORKER_SECRET, CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET,
31
+ // CDP_ACCOUNT_ADDRESS, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET,
32
+ // PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_WALLET_ID, PRIVY_WALLET_ADDRESS
33
+
34
+ export default {
35
+ async fetch(request, env) {
36
+ const url = new URL(request.url);
37
+ const path = url.pathname;
38
+ const method = request.method;
39
+
40
+ // --- Health check ---
41
+ if (path === '/' && method === 'GET') {
42
+ return json({ status: 'ok', service: 'wip-agent-pay' });
43
+ }
44
+
45
+ // --- Stripe webhook (no auth ... verified by Stripe signature) ---
46
+ if (path === '/stripe/webhook' && method === 'POST') {
47
+ return handleStripeWebhook(request, env);
48
+ }
49
+
50
+ // --- Stripe success/cancel (no auth ... user redirect) ---
51
+ if (path === '/stripe/success' && method === 'GET') {
52
+ return handleStripeSuccess(request, env);
53
+ }
54
+ if (path === '/stripe/cancel' && method === 'GET') {
55
+ return new Response('<h1>Payment cancelled.</h1><p>You can close this window.</p>', {
56
+ headers: { 'Content-Type': 'text/html' },
57
+ });
58
+ }
59
+
60
+ // --- All other routes require Worker secret ---
61
+ const authHeader = request.headers.get('Authorization') || '';
62
+ const token = authHeader.replace('Bearer ', '');
63
+ if (token !== env.WORKER_SECRET) {
64
+ return json({ error: 'unauthorized' }, 401);
65
+ }
66
+
67
+ // --- Mint one-time URL (existing) ---
68
+ if (path === '/create' && method === 'POST') {
69
+ return handleCreate(request, env, url);
70
+ }
71
+
72
+ // --- x402 pay via CDP ---
73
+ if (path === '/x402/pay' && method === 'POST') {
74
+ return handleX402CDP(request, env);
75
+ }
76
+
77
+ // --- Stripe checkout (funding) ---
78
+ if (path === '/stripe/checkout' && method === 'POST') {
79
+ return handleStripeCheckout(request, env, url);
80
+ }
81
+
82
+ // --- Privy pay ---
83
+ if (path === '/privy/pay' && method === 'POST') {
84
+ return handlePrivyPay(request, env);
85
+ }
86
+
87
+ // --- Balance ---
88
+ if (path === '/balance' && method === 'POST') {
89
+ return handleBalance(request, env);
90
+ }
91
+
92
+ // --- History ---
93
+ if (path === '/history' && method === 'POST') {
94
+ return handleHistory(request, env);
95
+ }
96
+
97
+ // --- Budget ---
98
+ if (path === '/budget' && method === 'POST') {
99
+ return handleBudget(request, env);
100
+ }
101
+
102
+ // --- Pool Mode A: pay from Parker's float ---
103
+ if (path === '/pool/pay' && method === 'POST') {
104
+ return handlePoolPay(request, env, url);
105
+ }
106
+
107
+ // --- Pool Mode A: confirm payment + get content ---
108
+ if (path === '/pool/confirm' && method === 'POST') {
109
+ return handlePoolConfirm(request, env);
110
+ }
111
+
112
+ // --- Mode C: create user wallet ---
113
+ if (path === '/wallet/create' && method === 'POST') {
114
+ return handleWalletCreate(request, env);
115
+ }
116
+
117
+ // --- Mode C: pay from user's wallet ---
118
+ if (path === '/wallet/pay' && method === 'POST') {
119
+ return handleWalletPay(request, env);
120
+ }
121
+
122
+ // --- Redeem one-time URL ---
123
+ const tokenMatch = path.match(/^\/([a-f0-9-]{36})$/);
124
+ if (tokenMatch && method === 'GET') {
125
+ return handleRedeem(tokenMatch[1], env);
126
+ }
127
+
128
+ return json({ error: 'not found' }, 404);
129
+ }
130
+ };
131
+
132
+ // ============================================================
133
+ // Helpers
134
+ // ============================================================
135
+
136
+ function json(data, status = 200) {
137
+ return new Response(JSON.stringify(data), {
138
+ status,
139
+ headers: { 'Content-Type': 'application/json' },
140
+ });
141
+ }
142
+
143
+ function randomToken() {
144
+ return crypto.randomUUID();
145
+ }
146
+
147
+ // ============================================================
148
+ // Route: POST /create (existing one-time URL mint)
149
+ // ============================================================
150
+
151
+ async function handleCreate(request, env, url) {
152
+ const { amount, service, note, expiresMin } = await request.json();
153
+ const token = randomToken();
154
+ const ttl = (expiresMin || 3) * 60;
155
+
156
+ await env.PAY_TOKENS.put(token, JSON.stringify({
157
+ amount, service, note,
158
+ created: Date.now(),
159
+ type: 'one-time-url',
160
+ }), { expirationTtl: ttl });
161
+
162
+ return json({ url: `${url.origin}/${token}` });
163
+ }
164
+
165
+ // ============================================================
166
+ // Route: GET /:token (redeem one-time URL)
167
+ // ============================================================
168
+
169
+ async function handleRedeem(token, env) {
170
+ const data = await env.PAY_TOKENS.get(token);
171
+ if (!data) {
172
+ return json({ error: 'gone', message: 'This payment link has already been used or expired.' }, 410);
173
+ }
174
+
175
+ // Atomic delete ... single use enforced
176
+ await env.PAY_TOKENS.delete(token);
177
+
178
+ const parsed = JSON.parse(data);
179
+ return json({
180
+ status: 'redeemed',
181
+ ...parsed,
182
+ });
183
+ }
184
+
185
+ // ============================================================
186
+ // Route: POST /x402/pay (Coinbase CDP x402 flow)
187
+ // ============================================================
188
+
189
+ async function handleX402CDP(request, env) {
190
+ const { url: targetUrl } = await request.json();
191
+ if (!targetUrl) return json({ error: 'url is required' }, 400);
192
+
193
+ // Step 1: Hit the paywalled URL
194
+ const initialRes = await fetch(targetUrl, { redirect: 'manual' });
195
+
196
+ // If not 402, return whatever we got
197
+ if (initialRes.status !== 402) {
198
+ const body = await initialRes.text();
199
+ return json({
200
+ status: 'no-paywall',
201
+ httpStatus: initialRes.status,
202
+ content: body,
203
+ });
204
+ }
205
+
206
+ // Step 2: Parse 402 payment requirements
207
+ const paymentReqs = await parse402Response(initialRes);
208
+ if (!paymentReqs) {
209
+ return json({ error: 'Could not parse 402 payment requirements' }, 502);
210
+ }
211
+
212
+ // Step 3: Sign EIP-3009 transferWithAuthorization via CDP
213
+ const signature = await signWithCDP(env, paymentReqs);
214
+ if (!signature) {
215
+ return json({ error: 'CDP signing failed' }, 502);
216
+ }
217
+
218
+ // Step 4: Build payment proof and retry
219
+ const paymentProof = buildPaymentProof(paymentReqs, signature, env.CDP_ACCOUNT_ADDRESS);
220
+
221
+ const retryRes = await fetch(targetUrl, {
222
+ headers: {
223
+ 'X-PAYMENT': paymentProof,
224
+ },
225
+ });
226
+
227
+ const content = await retryRes.text();
228
+
229
+ if (!retryRes.ok) {
230
+ return json({
231
+ error: 'Payment submitted but content fetch failed',
232
+ httpStatus: retryRes.status,
233
+ content,
234
+ }, 502);
235
+ }
236
+
237
+ // Step 5: Mint one-time token with content
238
+ const token = randomToken();
239
+ await env.PAY_TOKENS.put(token, JSON.stringify({
240
+ type: 'x402-content',
241
+ content,
242
+ amount: paymentReqs.amount,
243
+ service: new URL(targetUrl).hostname,
244
+ paidAt: Date.now(),
245
+ }), { expirationTtl: 300 }); // 5 min TTL for content tokens
246
+
247
+ return json({
248
+ status: 'paid',
249
+ amount: paymentReqs.amount,
250
+ network: paymentReqs.network,
251
+ service: new URL(targetUrl).hostname,
252
+ token,
253
+ });
254
+ }
255
+
256
+ /**
257
+ * Parse a 402 response to extract payment requirements.
258
+ * Supports x402 protocol headers and JSON body.
259
+ */
260
+ async function parse402Response(res) {
261
+ // Try x402 headers first
262
+ const payTo = res.headers.get('x-payment-address') || res.headers.get('x-pay-to');
263
+ const amount = res.headers.get('x-payment-amount') || res.headers.get('x-amount');
264
+ const network = res.headers.get('x-payment-network') || res.headers.get('x-network');
265
+ const token = res.headers.get('x-payment-token') || res.headers.get('x-token');
266
+ const contract = res.headers.get('x-payment-contract') || res.headers.get('x-token-contract');
267
+
268
+ if (payTo && amount) {
269
+ return {
270
+ payTo,
271
+ amount,
272
+ network: network || 'base',
273
+ token: token || 'USDC',
274
+ contract,
275
+ };
276
+ }
277
+
278
+ // Try JSON body
279
+ try {
280
+ const body = await res.json();
281
+ return {
282
+ payTo: body.payTo || body.address || body.recipient,
283
+ amount: body.amount || body.price,
284
+ network: body.network || body.chain || 'base',
285
+ token: body.token || body.currency || 'USDC',
286
+ contract: body.contract || body.tokenContract,
287
+ };
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Sign EIP-3009 transferWithAuthorization via Coinbase CDP.
295
+ */
296
+ async function signWithCDP(env, paymentReqs) {
297
+ const { apiKeyId, apiKeySecret, walletSecret, accountAddress } =
298
+ getCDPCreds(env);
299
+
300
+ if (!apiKeyId) return null;
301
+
302
+ // Determine chain ID from network
303
+ const chainId = getChainId(paymentReqs.network);
304
+
305
+ // Build EIP-712 typed data for transferWithAuthorization
306
+ const nonce = '0x' + Array.from(crypto.getRandomValues(new Uint8Array(32)))
307
+ .map(b => b.toString(16).padStart(2, '0')).join('');
308
+ const now = Math.floor(Date.now() / 1000);
309
+
310
+ const typedData = {
311
+ domain: {
312
+ name: 'USD Coin',
313
+ version: '2',
314
+ chainId: String(chainId),
315
+ verifyingContract: paymentReqs.contract || getUSDCContract(paymentReqs.network),
316
+ },
317
+ types: {
318
+ TransferWithAuthorization: [
319
+ { name: 'from', type: 'address' },
320
+ { name: 'to', type: 'address' },
321
+ { name: 'value', type: 'uint256' },
322
+ { name: 'validAfter', type: 'uint256' },
323
+ { name: 'validBefore', type: 'uint256' },
324
+ { name: 'nonce', type: 'bytes32' },
325
+ ],
326
+ },
327
+ primaryType: 'TransferWithAuthorization',
328
+ message: {
329
+ from: accountAddress,
330
+ to: paymentReqs.payTo,
331
+ value: toSmallestUnit(paymentReqs.amount),
332
+ validAfter: '0',
333
+ validBefore: String(now + 300),
334
+ nonce,
335
+ },
336
+ };
337
+
338
+ // Call CDP signTypedData
339
+ const cdpUrl = `https://api.cdp.coinbase.com/platform/v2/evm/accounts/${accountAddress}/sign-typed-data`;
340
+ const body = JSON.stringify({ typed_data: typedData });
341
+
342
+ const headers = await createCDPAuthHeaders(
343
+ { apiKeyId, apiKeySecret, walletSecret },
344
+ 'POST', cdpUrl, body
345
+ );
346
+
347
+ const res = await fetch(cdpUrl, {
348
+ method: 'POST',
349
+ headers: {
350
+ 'Content-Type': 'application/json',
351
+ 'Authorization': headers.authorization,
352
+ 'X-Wallet-Auth': headers.walletAuth,
353
+ },
354
+ body,
355
+ });
356
+
357
+ if (!res.ok) {
358
+ console.error('CDP sign failed:', res.status, await res.text());
359
+ return null;
360
+ }
361
+
362
+ const result = await res.json();
363
+ return result.signature;
364
+ }
365
+
366
+ /**
367
+ * Build x402 payment proof header.
368
+ */
369
+ function buildPaymentProof(paymentReqs, signature, fromAddress) {
370
+ const proof = {
371
+ type: 'eip-3009',
372
+ signature,
373
+ from: fromAddress,
374
+ to: paymentReqs.payTo,
375
+ amount: paymentReqs.amount,
376
+ network: paymentReqs.network,
377
+ };
378
+ return btoa(JSON.stringify(proof));
379
+ }
380
+
381
+ function getCDPCreds(env) {
382
+ return {
383
+ apiKeyId: env.CDP_API_KEY_ID || '',
384
+ apiKeySecret: env.CDP_API_KEY_SECRET || '',
385
+ walletSecret: env.CDP_WALLET_SECRET || '',
386
+ accountAddress: env.CDP_ACCOUNT_ADDRESS || '',
387
+ };
388
+ }
389
+
390
+ function getChainId(network) {
391
+ const chains = {
392
+ 'base': 8453,
393
+ 'base-sepolia': 84532,
394
+ 'ethereum': 1,
395
+ 'monad': 10143,
396
+ 'monad-testnet': 10143,
397
+ 'polygon': 137,
398
+ 'arbitrum': 42161,
399
+ 'optimism': 10,
400
+ };
401
+ return chains[network?.toLowerCase()] || 8453;
402
+ }
403
+
404
+ function getUSDCContract(network) {
405
+ const contracts = {
406
+ 'base': '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
407
+ 'ethereum': '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
408
+ 'polygon': '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
409
+ 'arbitrum': '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
410
+ 'optimism': '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85',
411
+ };
412
+ return contracts[network?.toLowerCase()] || contracts['base'];
413
+ }
414
+
415
+ function toSmallestUnit(amount) {
416
+ // USDC has 6 decimals
417
+ return String(Math.round(parseFloat(amount) * 1_000_000));
418
+ }
419
+
420
+ // ============================================================
421
+ // CDP Auth (Ed25519 JWT ... inline for Worker)
422
+ // ============================================================
423
+
424
+ function base64url(buffer) {
425
+ const bytes = new Uint8Array(buffer);
426
+ let str = '';
427
+ for (const b of bytes) str += String.fromCharCode(b);
428
+ return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
429
+ }
430
+
431
+ async function importEd25519Key(base64Secret) {
432
+ const raw = Uint8Array.from(atob(base64Secret), c => c.charCodeAt(0));
433
+ const pkcs8Prefix = new Uint8Array([
434
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
435
+ 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20
436
+ ]);
437
+ const pkcs8 = new Uint8Array(pkcs8Prefix.length + raw.length);
438
+ pkcs8.set(pkcs8Prefix);
439
+ pkcs8.set(raw, pkcs8Prefix.length);
440
+ return crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
441
+ }
442
+
443
+ async function signJWT(payload, key) {
444
+ const header = { alg: 'EdDSA', typ: 'JWT' };
445
+ const enc = new TextEncoder();
446
+ const headerB64 = base64url(enc.encode(JSON.stringify(header)));
447
+ const payloadB64 = base64url(enc.encode(JSON.stringify(payload)));
448
+ const signingInput = `${headerB64}.${payloadB64}`;
449
+ const signature = await crypto.subtle.sign('Ed25519', key, enc.encode(signingInput));
450
+ return `${signingInput}.${base64url(signature)}`;
451
+ }
452
+
453
+ async function createCDPAuthHeaders(creds, method, url, body = '') {
454
+ const now = Math.floor(Date.now() / 1000);
455
+ const parsed = new URL(url);
456
+ const uri = `${method.toUpperCase()} ${parsed.host}${parsed.pathname}`;
457
+
458
+ const apiKey = await importEd25519Key(creds.apiKeySecret);
459
+ const walletKey = await importEd25519Key(creds.walletSecret);
460
+
461
+ const enc = new TextEncoder();
462
+ const bodyHash = await crypto.subtle.digest('SHA-256', enc.encode(body));
463
+
464
+ const [bearer, walletAuth] = await Promise.all([
465
+ signJWT({
466
+ sub: creds.apiKeyId,
467
+ iss: 'cdp',
468
+ aud: ['cdp_service'],
469
+ nbf: now,
470
+ exp: now + 120,
471
+ uris: [uri],
472
+ }, apiKey),
473
+ signJWT({
474
+ iat: now,
475
+ nbf: now,
476
+ exp: now + 120,
477
+ jti: crypto.randomUUID(),
478
+ uris: [uri],
479
+ reqHash: base64url(bodyHash),
480
+ }, walletKey),
481
+ ]);
482
+
483
+ return {
484
+ authorization: `Bearer ${bearer}`,
485
+ walletAuth,
486
+ };
487
+ }
488
+
489
+ // ============================================================
490
+ // Route: POST /stripe/checkout (create funding session)
491
+ // ============================================================
492
+
493
+ async function handleStripeCheckout(request, env, url) {
494
+ const { amount, wallet } = await request.json();
495
+ if (!amount || amount <= 0) return json({ error: 'amount must be positive' }, 400);
496
+
497
+ const fundId = randomToken();
498
+
499
+ // Store pending fund in KV
500
+ await env.PAY_TOKENS.put(`fund:${fundId}`, JSON.stringify({
501
+ type: 'pending-fund',
502
+ amount,
503
+ wallet: wallet || 'cdp',
504
+ status: 'pending',
505
+ created: Date.now(),
506
+ }), { expirationTtl: 3600 }); // 1 hour to complete checkout
507
+
508
+ // Create Stripe Checkout session
509
+ const res = await fetch('https://api.stripe.com/v1/checkout/sessions', {
510
+ method: 'POST',
511
+ headers: {
512
+ 'Authorization': `Bearer ${env.STRIPE_SECRET_KEY}`,
513
+ 'Content-Type': 'application/x-www-form-urlencoded',
514
+ },
515
+ body: new URLSearchParams({
516
+ 'line_items[0][price_data][currency]': 'usd',
517
+ 'line_items[0][price_data][product_data][name]': 'Agent Pay Wallet Funding',
518
+ 'line_items[0][price_data][unit_amount]': String(Math.round(amount * 100)),
519
+ 'line_items[0][quantity]': '1',
520
+ 'mode': 'payment',
521
+ 'success_url': `${url.origin}/stripe/success?fund_id=${fundId}`,
522
+ 'cancel_url': `${url.origin}/stripe/cancel`,
523
+ 'metadata[fund_id]': fundId,
524
+ 'metadata[wallet]': wallet || 'cdp',
525
+ 'payment_method_types[0]': 'card',
526
+ }),
527
+ });
528
+
529
+ if (!res.ok) {
530
+ const err = await res.text();
531
+ return json({ error: `Stripe error: ${err}` }, 502);
532
+ }
533
+
534
+ const session = await res.json();
535
+ return json({ checkoutUrl: session.url, fundId });
536
+ }
537
+
538
+ // ============================================================
539
+ // Route: POST /stripe/webhook (payment confirmation)
540
+ // ============================================================
541
+
542
+ async function handleStripeWebhook(request, env) {
543
+ const body = await request.text();
544
+ const sig = request.headers.get('stripe-signature');
545
+
546
+ if (!sig || !env.STRIPE_WEBHOOK_SECRET) {
547
+ return json({ error: 'missing signature' }, 400);
548
+ }
549
+
550
+ // Verify webhook signature
551
+ const verified = await verifyStripeSignature(body, sig, env.STRIPE_WEBHOOK_SECRET);
552
+ if (!verified) {
553
+ return json({ error: 'invalid signature' }, 400);
554
+ }
555
+
556
+ const event = JSON.parse(body);
557
+
558
+ if (event.type === 'checkout.session.completed') {
559
+ const session = event.data.object;
560
+ const fundId = session.metadata?.fund_id;
561
+ const poolId = session.metadata?.pool_id;
562
+
563
+ if (fundId) {
564
+ // Mark fund as complete
565
+ const fundData = await env.PAY_TOKENS.get(`fund:${fundId}`);
566
+ if (fundData) {
567
+ const parsed = JSON.parse(fundData);
568
+ parsed.status = 'funded';
569
+ parsed.paidAt = Date.now();
570
+ parsed.stripeSessionId = session.id;
571
+ await env.PAY_TOKENS.put(`fund:${fundId}`, JSON.stringify(parsed), {
572
+ expirationTtl: 86400, // 24h
573
+ });
574
+ }
575
+ }
576
+
577
+ if (poolId) {
578
+ // Pool Mode: Stripe payment confirmed. Mark as stripe-paid.
579
+ // The actual x402 signing happens when /pool/confirm is called.
580
+ const poolData = await env.PAY_TOKENS.get(`pool:${poolId}`);
581
+ if (poolData) {
582
+ const parsed = JSON.parse(poolData);
583
+ parsed.stripePaid = true;
584
+ parsed.stripePaidAt = Date.now();
585
+ await env.PAY_TOKENS.put(`pool:${poolId}`, JSON.stringify(parsed), {
586
+ expirationTtl: 600,
587
+ });
588
+ }
589
+ }
590
+ }
591
+
592
+ return json({ received: true });
593
+ }
594
+
595
+ /**
596
+ * Verify Stripe webhook signature using HMAC-SHA256.
597
+ */
598
+ async function verifyStripeSignature(body, sigHeader, secret) {
599
+ try {
600
+ const parts = Object.fromEntries(
601
+ sigHeader.split(',').map(p => {
602
+ const [k, ...v] = p.split('=');
603
+ return [k, v.join('=')];
604
+ })
605
+ );
606
+
607
+ const timestamp = parts.t;
608
+ const expectedSig = parts.v1;
609
+ if (!timestamp || !expectedSig) return false;
610
+
611
+ // Check timestamp freshness (5 min tolerance)
612
+ const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
613
+ if (age > 300) return false;
614
+
615
+ const enc = new TextEncoder();
616
+ const key = await crypto.subtle.importKey(
617
+ 'raw', enc.encode(secret),
618
+ { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
619
+ );
620
+
621
+ const payload = `${timestamp}.${body}`;
622
+ const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload));
623
+ const sigHex = Array.from(new Uint8Array(sig))
624
+ .map(b => b.toString(16).padStart(2, '0')).join('');
625
+
626
+ return sigHex === expectedSig;
627
+ } catch {
628
+ return false;
629
+ }
630
+ }
631
+
632
+ // ============================================================
633
+ // Route: GET /stripe/success (post-checkout redirect)
634
+ // ============================================================
635
+
636
+ async function handleStripeSuccess(request, env) {
637
+ const url = new URL(request.url);
638
+ const fundId = url.searchParams.get('fund_id');
639
+ const poolId = url.searchParams.get('pool_id');
640
+
641
+ const isPool = !!poolId;
642
+ const title = isPool ? 'Payment Complete' : 'Wallet Funded';
643
+ const message = isPool
644
+ ? 'Your agent is fetching the content now. You can close this window.'
645
+ : 'Your agent\'s wallet has been funded. You can close this window.';
646
+ const refId = poolId || fundId;
647
+
648
+ return new Response(`
649
+ <!DOCTYPE html>
650
+ <html>
651
+ <head><title>${title}</title>
652
+ <style>
653
+ body { font-family: -apple-system, sans-serif; display: flex; justify-content: center;
654
+ align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
655
+ .card { background: white; padding: 40px; border-radius: 12px; text-align: center;
656
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 400px; }
657
+ h1 { font-size: 24px; margin: 0 0 8px; }
658
+ p { color: #666; margin: 4px 0; }
659
+ .check { font-size: 48px; margin-bottom: 16px; }
660
+ </style>
661
+ </head>
662
+ <body>
663
+ <div class="card">
664
+ <div class="check">&#10003;</div>
665
+ <h1>${title}</h1>
666
+ <p>${message}</p>
667
+ ${refId ? `<p style="font-size:12px;color:#999;">ID: ${refId}</p>` : ''}
668
+ </div>
669
+ </body>
670
+ </html>
671
+ `, { headers: { 'Content-Type': 'text/html' } });
672
+ }
673
+
674
+ // ============================================================
675
+ // Route: POST /privy/pay (x402 via Privy wallet)
676
+ // ============================================================
677
+
678
+ async function handlePrivyPay(request, env) {
679
+ const { url: targetUrl } = await request.json();
680
+ if (!targetUrl) return json({ error: 'url is required' }, 400);
681
+
682
+ // Step 1: Hit the paywalled URL
683
+ const initialRes = await fetch(targetUrl, { redirect: 'manual' });
684
+
685
+ if (initialRes.status !== 402) {
686
+ const body = await initialRes.text();
687
+ return json({
688
+ status: 'no-paywall',
689
+ httpStatus: initialRes.status,
690
+ content: body,
691
+ });
692
+ }
693
+
694
+ // Step 2: Parse 402 payment requirements
695
+ const paymentReqs = await parse402Response(initialRes);
696
+ if (!paymentReqs) {
697
+ return json({ error: 'Could not parse 402 payment requirements' }, 502);
698
+ }
699
+
700
+ // Step 3: Sign via Privy RPC
701
+ const signature = await signWithPrivy(env, paymentReqs);
702
+ if (!signature) {
703
+ return json({ error: 'Privy signing failed' }, 502);
704
+ }
705
+
706
+ // Step 4: Retry with payment proof
707
+ const paymentProof = buildPaymentProof(paymentReqs, signature, env.PRIVY_WALLET_ADDRESS || '');
708
+
709
+ const retryRes = await fetch(targetUrl, {
710
+ headers: { 'X-PAYMENT': paymentProof },
711
+ });
712
+
713
+ const content = await retryRes.text();
714
+
715
+ if (!retryRes.ok) {
716
+ return json({
717
+ error: 'Payment submitted but content fetch failed',
718
+ httpStatus: retryRes.status,
719
+ content,
720
+ }, 502);
721
+ }
722
+
723
+ // Step 5: Mint token with content
724
+ const token = randomToken();
725
+ await env.PAY_TOKENS.put(token, JSON.stringify({
726
+ type: 'x402-content',
727
+ content,
728
+ amount: paymentReqs.amount,
729
+ service: new URL(targetUrl).hostname,
730
+ paidAt: Date.now(),
731
+ wallet: 'privy',
732
+ }), { expirationTtl: 300 });
733
+
734
+ return json({
735
+ status: 'paid',
736
+ amount: paymentReqs.amount,
737
+ network: paymentReqs.network,
738
+ service: new URL(targetUrl).hostname,
739
+ token,
740
+ wallet: 'privy',
741
+ });
742
+ }
743
+
744
+ /**
745
+ * Sign EIP-712 typed data via Privy RPC.
746
+ */
747
+ async function signWithPrivy(env, paymentReqs) {
748
+ if (!env.PRIVY_APP_ID || !env.PRIVY_APP_SECRET || !env.PRIVY_WALLET_ID) {
749
+ return null;
750
+ }
751
+
752
+ const chainId = getChainId(paymentReqs.network);
753
+ const nonce = '0x' + Array.from(crypto.getRandomValues(new Uint8Array(32)))
754
+ .map(b => b.toString(16).padStart(2, '0')).join('');
755
+ const now = Math.floor(Date.now() / 1000);
756
+
757
+ const typedData = {
758
+ domain: {
759
+ name: 'USD Coin',
760
+ version: '2',
761
+ chainId: String(chainId),
762
+ verifyingContract: paymentReqs.contract || getUSDCContract(paymentReqs.network),
763
+ },
764
+ types: {
765
+ EIP712Domain: [
766
+ { name: 'name', type: 'string' },
767
+ { name: 'version', type: 'string' },
768
+ { name: 'chainId', type: 'uint256' },
769
+ { name: 'verifyingContract', type: 'address' },
770
+ ],
771
+ TransferWithAuthorization: [
772
+ { name: 'from', type: 'address' },
773
+ { name: 'to', type: 'address' },
774
+ { name: 'value', type: 'uint256' },
775
+ { name: 'validAfter', type: 'uint256' },
776
+ { name: 'validBefore', type: 'uint256' },
777
+ { name: 'nonce', type: 'bytes32' },
778
+ ],
779
+ },
780
+ primaryType: 'TransferWithAuthorization',
781
+ message: {
782
+ from: env.PRIVY_WALLET_ADDRESS || '',
783
+ to: paymentReqs.payTo,
784
+ value: toSmallestUnit(paymentReqs.amount),
785
+ validAfter: '0',
786
+ validBefore: String(now + 300),
787
+ nonce,
788
+ },
789
+ };
790
+
791
+ const privyUrl = `https://api.privy.io/v1/wallets/${env.PRIVY_WALLET_ID}/rpc`;
792
+ const authString = btoa(`${env.PRIVY_APP_ID}:${env.PRIVY_APP_SECRET}`);
793
+
794
+ const res = await fetch(privyUrl, {
795
+ method: 'POST',
796
+ headers: {
797
+ 'Content-Type': 'application/json',
798
+ 'Authorization': `Basic ${authString}`,
799
+ 'privy-app-id': env.PRIVY_APP_ID,
800
+ },
801
+ body: JSON.stringify({
802
+ method: 'eth_signTypedData_v4',
803
+ caip2: `eip155:${chainId}`,
804
+ params: { typed_data: typedData },
805
+ }),
806
+ });
807
+
808
+ if (!res.ok) {
809
+ console.error('Privy sign failed:', res.status, await res.text());
810
+ return null;
811
+ }
812
+
813
+ const result = await res.json();
814
+ return result.data?.signature || result.signature;
815
+ }
816
+
817
+ // ============================================================
818
+ // Route: POST /balance (wallet balance)
819
+ // ============================================================
820
+
821
+ async function handleBalance(request, env) {
822
+ const { wallet } = await request.json();
823
+ const w = wallet || 'cdp';
824
+
825
+ if (w === 'cdp') {
826
+ // Query CDP wallet balance
827
+ const creds = getCDPCreds(env);
828
+ if (!creds.apiKeyId || !creds.accountAddress) {
829
+ return json({ balance: '0.00', wallet: w, error: 'CDP wallet not configured' });
830
+ }
831
+
832
+ const balanceUrl = `https://api.cdp.coinbase.com/platform/v2/evm/accounts/${creds.accountAddress}/balances`;
833
+ const headers = await createCDPAuthHeaders(creds, 'GET', balanceUrl);
834
+
835
+ const res = await fetch(balanceUrl, {
836
+ headers: {
837
+ 'Authorization': headers.authorization,
838
+ 'X-Wallet-Auth': headers.walletAuth,
839
+ },
840
+ });
841
+
842
+ if (!res.ok) {
843
+ return json({ balance: '0.00', wallet: w, error: `CDP balance query failed: ${res.status}` });
844
+ }
845
+
846
+ const data = await res.json();
847
+ // Find USDC balance
848
+ const usdc = data.balances?.find(b => b.asset === 'USDC' || b.symbol === 'USDC');
849
+ return json({
850
+ balance: usdc?.amount || '0.00',
851
+ wallet: w,
852
+ address: creds.accountAddress,
853
+ });
854
+ }
855
+
856
+ if (w === 'privy') {
857
+ // Privy balance ... query via RPC
858
+ if (!env.PRIVY_APP_ID || !env.PRIVY_WALLET_ID) {
859
+ return json({ balance: '0.00', wallet: w, error: 'Privy wallet not configured' });
860
+ }
861
+
862
+ return json({
863
+ balance: 'query via on-chain RPC',
864
+ wallet: w,
865
+ address: env.PRIVY_WALLET_ADDRESS || 'not set',
866
+ });
867
+ }
868
+
869
+ return json({ error: `Unknown wallet: ${w}` }, 400);
870
+ }
871
+
872
+ // ============================================================
873
+ // Route: POST /history (transaction history)
874
+ // ============================================================
875
+
876
+ async function handleHistory(request, env) {
877
+ const { wallet, limit } = await request.json();
878
+ const w = wallet || 'cdp';
879
+ const max = Math.min(limit || 20, 100);
880
+
881
+ // Read from PAY_LEDGER KV
882
+ const ledgerKey = `ledger:${w}`;
883
+ const ledgerData = await env.PAY_LEDGER?.get(ledgerKey);
884
+
885
+ if (!ledgerData) {
886
+ return json({ wallet: w, transactions: [] });
887
+ }
888
+
889
+ const ledger = JSON.parse(ledgerData);
890
+ const transactions = ledger.slice(-max).reverse(); // Most recent first
891
+
892
+ return json({ wallet: w, transactions });
893
+ }
894
+
895
+ /**
896
+ * Record a transaction in the ledger.
897
+ * Called internally after successful payments/funding.
898
+ */
899
+ async function recordTransaction(env, wallet, tx) {
900
+ const ledgerKey = `ledger:${wallet}`;
901
+ const existing = await env.PAY_LEDGER?.get(ledgerKey);
902
+ const ledger = existing ? JSON.parse(existing) : [];
903
+
904
+ ledger.push({
905
+ ...tx,
906
+ timestamp: Date.now(),
907
+ id: randomToken(),
908
+ });
909
+
910
+ // Keep last 1000 transactions
911
+ const trimmed = ledger.slice(-1000);
912
+ await env.PAY_LEDGER?.put(ledgerKey, JSON.stringify(trimmed));
913
+ }
914
+
915
+ // ============================================================
916
+ // Route: POST /budget (view/set spending limits)
917
+ // ============================================================
918
+
919
+ async function handleBudget(request, env) {
920
+ const { wallet, daily, perTx, total } = await request.json();
921
+ const w = wallet || 'cdp';
922
+ const budgetKey = `budget:${w}`;
923
+
924
+ // If setting new budget
925
+ if (daily !== undefined || perTx !== undefined || total !== undefined) {
926
+ const existing = await env.PAY_LEDGER?.get(budgetKey);
927
+ const current = existing ? JSON.parse(existing) : {};
928
+
929
+ if (daily !== undefined) current.daily = daily;
930
+ if (perTx !== undefined) current.perTx = perTx;
931
+ if (total !== undefined) current.total = total;
932
+
933
+ await env.PAY_LEDGER?.put(budgetKey, JSON.stringify(current));
934
+ return json({ wallet: w, ...current, status: 'updated' });
935
+ }
936
+
937
+ // View current budget
938
+ const budgetData = await env.PAY_LEDGER?.get(budgetKey);
939
+ const budgetObj = budgetData ? JSON.parse(budgetData) : {};
940
+
941
+ // Calculate spent today
942
+ const ledgerKey = `ledger:${w}`;
943
+ const ledgerData = await env.PAY_LEDGER?.get(ledgerKey);
944
+ let spentToday = 0;
945
+
946
+ if (ledgerData) {
947
+ const ledger = JSON.parse(ledgerData);
948
+ const todayStart = new Date();
949
+ todayStart.setHours(0, 0, 0, 0);
950
+ const todayMs = todayStart.getTime();
951
+
952
+ spentToday = ledger
953
+ .filter(tx => tx.timestamp >= todayMs && tx.type !== 'fund')
954
+ .reduce((sum, tx) => sum + (parseFloat(tx.amount) || 0), 0);
955
+ }
956
+
957
+ const remainingToday = budgetObj.daily
958
+ ? Math.max(0, budgetObj.daily - spentToday).toFixed(2)
959
+ : 'unlimited';
960
+
961
+ return json({
962
+ wallet: w,
963
+ daily: budgetObj.daily || null,
964
+ perTx: budgetObj.perTx || null,
965
+ total: budgetObj.total || null,
966
+ spentToday: spentToday.toFixed(2),
967
+ remainingToday,
968
+ });
969
+ }
970
+
971
+ /**
972
+ * Check if a transaction is within budget.
973
+ * Returns { allowed: true } or { allowed: false, reason: '...' }
974
+ */
975
+ async function checkBudget(env, wallet, amount) {
976
+ const budgetKey = `budget:${wallet}`;
977
+ const budgetData = await env.PAY_LEDGER?.get(budgetKey);
978
+ if (!budgetData) return { allowed: true }; // No budget set
979
+
980
+ const budget = JSON.parse(budgetData);
981
+
982
+ // Per-transaction limit
983
+ if (budget.perTx && parseFloat(amount) > budget.perTx) {
984
+ return { allowed: false, reason: `Exceeds per-transaction limit of $${budget.perTx}` };
985
+ }
986
+
987
+ // Daily limit
988
+ if (budget.daily) {
989
+ const ledgerKey = `ledger:${wallet}`;
990
+ const ledgerData = await env.PAY_LEDGER?.get(ledgerKey);
991
+
992
+ if (ledgerData) {
993
+ const ledger = JSON.parse(ledgerData);
994
+ const todayStart = new Date();
995
+ todayStart.setHours(0, 0, 0, 0);
996
+ const todayMs = todayStart.getTime();
997
+
998
+ const spentToday = ledger
999
+ .filter(tx => tx.timestamp >= todayMs && tx.type !== 'fund')
1000
+ .reduce((sum, tx) => sum + (parseFloat(tx.amount) || 0), 0);
1001
+
1002
+ if (spentToday + parseFloat(amount) > budget.daily) {
1003
+ return { allowed: false, reason: `Would exceed daily limit of $${budget.daily} (spent: $${spentToday.toFixed(2)})` };
1004
+ }
1005
+ }
1006
+ }
1007
+
1008
+ return { allowed: true };
1009
+ }
1010
+
1011
+ // ============================================================
1012
+ // Pool Mode constants
1013
+ // ============================================================
1014
+
1015
+ const POOL_MAX_AMOUNT = 25.00; // Max x402 price for pool mode
1016
+ const POOL_FEE = 0.25; // Parker's flat fee per transaction
1017
+
1018
+ function calculatePoolTotal(x402Amount) {
1019
+ const amount = parseFloat(x402Amount);
1020
+ // Stripe takes 2.9% of the final charge + $0.30. To net the right amount,
1021
+ // we solve: charge = (amount + fee + $0.30) / (1 - 0.029)
1022
+ const subtotal = amount + POOL_FEE + 0.30;
1023
+ const total = subtotal / (1 - 0.029);
1024
+ const stripeFee = total - amount - POOL_FEE;
1025
+ return {
1026
+ x402Amount: amount.toFixed(2),
1027
+ poolFee: POOL_FEE.toFixed(2),
1028
+ stripeFee: stripeFee.toFixed(2),
1029
+ totalCharge: total.toFixed(2),
1030
+ };
1031
+ }
1032
+
1033
+ // ============================================================
1034
+ // Route: POST /pool/pay (Pool Mode A: Stripe + x402 from float)
1035
+ // ============================================================
1036
+
1037
+ async function handlePoolPay(request, env, workerUrl) {
1038
+ const { url: targetUrl } = await request.json();
1039
+ if (!targetUrl) return json({ error: 'url is required' }, 400);
1040
+
1041
+ // Step 1: Hit the paywalled URL
1042
+ const initialRes = await fetch(targetUrl, { redirect: 'manual' });
1043
+
1044
+ // If not 402, return content (it's free)
1045
+ if (initialRes.status !== 402) {
1046
+ const body = await initialRes.text();
1047
+ return json({ status: 'no-paywall', httpStatus: initialRes.status, content: body });
1048
+ }
1049
+
1050
+ // Step 2: Parse 402 payment requirements
1051
+ const paymentReqs = await parse402Response(initialRes);
1052
+ if (!paymentReqs) {
1053
+ return json({ error: 'Could not parse 402 payment requirements' }, 502);
1054
+ }
1055
+
1056
+ const amount = parseFloat(paymentReqs.amount);
1057
+ const service = new URL(targetUrl).hostname;
1058
+
1059
+ // Step 3: Check pool limit
1060
+ if (amount > POOL_MAX_AMOUNT) {
1061
+ return json({
1062
+ status: 'over-pool-limit',
1063
+ error: `Transaction $${amount.toFixed(2)} exceeds pool limit of $${POOL_MAX_AMOUNT}. Use your own wallet (Mode C).`,
1064
+ amount: paymentReqs.amount,
1065
+ service,
1066
+ poolMax: POOL_MAX_AMOUNT,
1067
+ network: paymentReqs.network,
1068
+ }, 400);
1069
+ }
1070
+
1071
+ // Step 4: Calculate pricing
1072
+ const pricing = calculatePoolTotal(paymentReqs.amount);
1073
+
1074
+ // Step 5: Create a payment ID and store pending payment
1075
+ const paymentId = randomToken();
1076
+ await env.PAY_TOKENS.put(`pool:${paymentId}`, JSON.stringify({
1077
+ type: 'pool-pending',
1078
+ targetUrl,
1079
+ amount: paymentReqs.amount,
1080
+ pricing,
1081
+ service,
1082
+ paymentReqs,
1083
+ status: 'pending',
1084
+ created: Date.now(),
1085
+ }), { expirationTtl: 600 }); // 10 min to complete
1086
+
1087
+ // Step 6: Create Stripe Checkout session (payment goes to us)
1088
+ const checkoutParams = new URLSearchParams({
1089
+ 'line_items[0][price_data][currency]': 'usd',
1090
+ 'line_items[0][price_data][product_data][name]': `${service} via Agent Pay`,
1091
+ 'line_items[0][price_data][unit_amount]': String(Math.round(parseFloat(pricing.totalCharge) * 100)),
1092
+ 'line_items[0][quantity]': '1',
1093
+ 'mode': 'payment',
1094
+ 'payment_method_types[0]': 'card',
1095
+ 'success_url': `${workerUrl.origin}/stripe/success?pool_id=${paymentId}`,
1096
+ 'cancel_url': `${workerUrl.origin}/stripe/cancel`,
1097
+ 'metadata[pool_id]': paymentId,
1098
+ 'metadata[type]': 'pool',
1099
+ 'metadata[x402_amount]': paymentReqs.amount,
1100
+ 'metadata[service]': service,
1101
+ });
1102
+
1103
+ const stripeRes = await fetch('https://api.stripe.com/v1/checkout/sessions', {
1104
+ method: 'POST',
1105
+ headers: {
1106
+ 'Authorization': `Bearer ${env.STRIPE_SECRET_KEY}`,
1107
+ 'Content-Type': 'application/x-www-form-urlencoded',
1108
+ },
1109
+ body: checkoutParams,
1110
+ });
1111
+
1112
+ if (!stripeRes.ok) {
1113
+ const err = await stripeRes.text();
1114
+ return json({ error: `Stripe error: ${err}` }, 502);
1115
+ }
1116
+
1117
+ const session = await stripeRes.json();
1118
+
1119
+ // Update payment record with Stripe session
1120
+ const poolData = JSON.parse(await env.PAY_TOKENS.get(`pool:${paymentId}`));
1121
+ poolData.stripeSessionId = session.id;
1122
+ await env.PAY_TOKENS.put(`pool:${paymentId}`, JSON.stringify(poolData), { expirationTtl: 600 });
1123
+
1124
+ return json({
1125
+ status: 'checkout-ready',
1126
+ checkoutUrl: session.url,
1127
+ paymentId,
1128
+ amount: paymentReqs.amount,
1129
+ pricing,
1130
+ service,
1131
+ });
1132
+ }
1133
+
1134
+ // ============================================================
1135
+ // Route: POST /pool/confirm (check Stripe payment + sign x402)
1136
+ // ============================================================
1137
+
1138
+ async function handlePoolConfirm(request, env) {
1139
+ const { paymentId } = await request.json();
1140
+ if (!paymentId) return json({ error: 'paymentId is required' }, 400);
1141
+
1142
+ const poolData = await env.PAY_TOKENS.get(`pool:${paymentId}`);
1143
+ if (!poolData) {
1144
+ return json({ error: 'Payment not found or expired' }, 404);
1145
+ }
1146
+
1147
+ const payment = JSON.parse(poolData);
1148
+
1149
+ // Already paid and content cached
1150
+ if (payment.status === 'paid') {
1151
+ return json({
1152
+ success: true,
1153
+ status: 'paid',
1154
+ content: payment.content,
1155
+ amount: payment.amount,
1156
+ service: payment.service,
1157
+ });
1158
+ }
1159
+
1160
+ // Still pending. Check Stripe.
1161
+ if (payment.status === 'pending') {
1162
+ if (!payment.stripeSessionId) {
1163
+ return json({ success: false, error: 'pending', status: 'pending' });
1164
+ }
1165
+
1166
+ const stripeRes = await fetch(
1167
+ `https://api.stripe.com/v1/checkout/sessions/${payment.stripeSessionId}`,
1168
+ { headers: { 'Authorization': `Bearer ${env.STRIPE_SECRET_KEY}` } }
1169
+ );
1170
+
1171
+ if (!stripeRes.ok) {
1172
+ return json({ success: false, error: 'pending', status: 'pending' });
1173
+ }
1174
+
1175
+ const session = await stripeRes.json();
1176
+ if (session.payment_status !== 'paid') {
1177
+ return json({ success: false, error: 'pending', status: 'pending' });
1178
+ }
1179
+
1180
+ // Stripe confirmed. Now sign x402 from pool wallet and get content.
1181
+ const paymentReqs = payment.paymentReqs;
1182
+
1183
+ // Try CDP first (pool wallet), then Privy fallback
1184
+ let signature = await signWithCDP(env, paymentReqs);
1185
+ let fromAddress = env.CDP_ACCOUNT_ADDRESS;
1186
+
1187
+ if (!signature) {
1188
+ signature = await signWithPrivy(env, paymentReqs);
1189
+ fromAddress = env.PRIVY_WALLET_ADDRESS || '';
1190
+ }
1191
+
1192
+ if (!signature) {
1193
+ return json({
1194
+ error: 'Pool wallet signing failed. Stripe payment collected but x402 not completed. Contact support.',
1195
+ paymentId,
1196
+ }, 502);
1197
+ }
1198
+
1199
+ // Build proof and replay
1200
+ const proof = buildPaymentProof(paymentReqs, signature, fromAddress);
1201
+ const retryRes = await fetch(payment.targetUrl, {
1202
+ headers: { 'X-PAYMENT': proof },
1203
+ });
1204
+
1205
+ const content = await retryRes.text();
1206
+
1207
+ if (retryRes.status === 402) {
1208
+ // Payment proof rejected. This is bad ... Stripe already charged.
1209
+ return json({
1210
+ error: 'x402 payment proof rejected by server. Stripe payment collected. Contact support.',
1211
+ paymentId,
1212
+ httpStatus: retryRes.status,
1213
+ }, 502);
1214
+ }
1215
+
1216
+ // Success. Cache content and record transaction.
1217
+ payment.status = 'paid';
1218
+ payment.paidAt = Date.now();
1219
+ payment.content = content;
1220
+ await env.PAY_TOKENS.put(`pool:${paymentId}`, JSON.stringify(payment), { expirationTtl: 300 });
1221
+
1222
+ await recordTransaction(env, 'pool', {
1223
+ type: 'pay',
1224
+ amount: payment.amount,
1225
+ service: payment.service,
1226
+ pricing: payment.pricing,
1227
+ paymentId,
1228
+ });
1229
+
1230
+ return json({
1231
+ success: true,
1232
+ status: 'paid',
1233
+ content,
1234
+ amount: payment.amount,
1235
+ service: payment.service,
1236
+ });
1237
+ }
1238
+
1239
+ return json({ success: false, error: `Unexpected status: ${payment.status}` });
1240
+ }
1241
+
1242
+ // ============================================================
1243
+ // Route: POST /wallet/create (Mode C: create Privy wallet by email)
1244
+ // ============================================================
1245
+
1246
+ async function handleWalletCreate(request, env) {
1247
+ const { email } = await request.json();
1248
+ if (!email) return json({ error: 'email is required' }, 400);
1249
+
1250
+ if (!env.PRIVY_APP_ID || !env.PRIVY_APP_SECRET) {
1251
+ return json({ error: 'Privy not configured on this Worker' }, 400);
1252
+ }
1253
+
1254
+ const authString = btoa(`${env.PRIVY_APP_ID}:${env.PRIVY_APP_SECRET}`);
1255
+
1256
+ // Create user via Privy API
1257
+ const createRes = await fetch('https://auth.privy.io/api/v1/users', {
1258
+ method: 'POST',
1259
+ headers: {
1260
+ 'Content-Type': 'application/json',
1261
+ 'Authorization': `Basic ${authString}`,
1262
+ 'privy-app-id': env.PRIVY_APP_ID,
1263
+ },
1264
+ body: JSON.stringify({
1265
+ create_ethereum_wallet: true,
1266
+ linked_accounts: [{ type: 'email', address: email }],
1267
+ }),
1268
+ });
1269
+
1270
+ if (!createRes.ok) {
1271
+ const err = await createRes.text();
1272
+ return json({ error: `Privy user creation failed: ${err}` }, 502);
1273
+ }
1274
+
1275
+ const user = await createRes.json();
1276
+
1277
+ // Extract wallet address from linked accounts
1278
+ const wallet = user.linked_accounts?.find(a => a.type === 'wallet');
1279
+ const walletAddress = wallet?.address || null;
1280
+
1281
+ // Store user wallet mapping
1282
+ await env.PAY_LEDGER?.put(`user-wallet:${email}`, JSON.stringify({
1283
+ privyUserId: user.id,
1284
+ walletAddress,
1285
+ email,
1286
+ created: Date.now(),
1287
+ }));
1288
+
1289
+ return json({
1290
+ success: true,
1291
+ email,
1292
+ walletAddress,
1293
+ privyUserId: user.id,
1294
+ fundingInstructions: walletAddress
1295
+ ? `Send USDC to ${walletAddress} on Base or use Coinbase Onramp.`
1296
+ : 'Wallet creation pending. Check back shortly.',
1297
+ });
1298
+ }
1299
+
1300
+ // ============================================================
1301
+ // Route: POST /wallet/pay (Mode C: pay from user's Privy wallet)
1302
+ // ============================================================
1303
+
1304
+ async function handleWalletPay(request, env) {
1305
+ const { url: targetUrl, email } = await request.json();
1306
+ if (!targetUrl) return json({ error: 'url is required' }, 400);
1307
+ if (!email) return json({ error: 'email is required' }, 400);
1308
+
1309
+ // Look up user wallet
1310
+ const userData = await env.PAY_LEDGER?.get(`user-wallet:${email}`);
1311
+ if (!userData) {
1312
+ return json({
1313
+ error: 'No wallet found for this email. Create one first with /wallet/create.',
1314
+ }, 400);
1315
+ }
1316
+
1317
+ const userWallet = JSON.parse(userData);
1318
+ if (!userWallet.walletAddress) {
1319
+ return json({ error: 'Wallet address not available yet.' }, 400);
1320
+ }
1321
+
1322
+ // Hit the paywalled URL
1323
+ const initialRes = await fetch(targetUrl, { redirect: 'manual' });
1324
+
1325
+ if (initialRes.status !== 402) {
1326
+ const body = await initialRes.text();
1327
+ return json({ status: 'no-paywall', httpStatus: initialRes.status, content: body });
1328
+ }
1329
+
1330
+ // Parse 402
1331
+ const paymentReqs = await parse402Response(initialRes);
1332
+ if (!paymentReqs) {
1333
+ return json({ error: 'Could not parse 402 payment requirements' }, 502);
1334
+ }
1335
+
1336
+ // Sign from user's Privy wallet
1337
+ // Note: this requires the user's wallet to be a server wallet we can sign from.
1338
+ // For now, we use the Privy server wallet API with the user's wallet ID.
1339
+ if (!env.PRIVY_APP_ID || !env.PRIVY_APP_SECRET) {
1340
+ return json({ error: 'Privy not configured' }, 400);
1341
+ }
1342
+
1343
+ const chainId = getChainId(paymentReqs.network);
1344
+ const nonce = '0x' + Array.from(crypto.getRandomValues(new Uint8Array(32)))
1345
+ .map(b => b.toString(16).padStart(2, '0')).join('');
1346
+ const now = Math.floor(Date.now() / 1000);
1347
+
1348
+ const typedData = {
1349
+ domain: {
1350
+ name: 'USD Coin',
1351
+ version: '2',
1352
+ chainId: String(chainId),
1353
+ verifyingContract: paymentReqs.contract || getUSDCContract(paymentReqs.network),
1354
+ },
1355
+ types: {
1356
+ EIP712Domain: [
1357
+ { name: 'name', type: 'string' },
1358
+ { name: 'version', type: 'string' },
1359
+ { name: 'chainId', type: 'uint256' },
1360
+ { name: 'verifyingContract', type: 'address' },
1361
+ ],
1362
+ TransferWithAuthorization: [
1363
+ { name: 'from', type: 'address' },
1364
+ { name: 'to', type: 'address' },
1365
+ { name: 'value', type: 'uint256' },
1366
+ { name: 'validAfter', type: 'uint256' },
1367
+ { name: 'validBefore', type: 'uint256' },
1368
+ { name: 'nonce', type: 'bytes32' },
1369
+ ],
1370
+ },
1371
+ primaryType: 'TransferWithAuthorization',
1372
+ message: {
1373
+ from: userWallet.walletAddress,
1374
+ to: paymentReqs.payTo,
1375
+ value: toSmallestUnit(paymentReqs.amount),
1376
+ validAfter: '0',
1377
+ validBefore: String(now + 300),
1378
+ nonce,
1379
+ },
1380
+ };
1381
+
1382
+ // Sign via Privy using the user's wallet
1383
+ const authString = btoa(`${env.PRIVY_APP_ID}:${env.PRIVY_APP_SECRET}`);
1384
+ const privyUrl = `https://api.privy.io/v1/wallets/${userWallet.privyUserId}/rpc`;
1385
+
1386
+ const signRes = await fetch(privyUrl, {
1387
+ method: 'POST',
1388
+ headers: {
1389
+ 'Content-Type': 'application/json',
1390
+ 'Authorization': `Basic ${authString}`,
1391
+ 'privy-app-id': env.PRIVY_APP_ID,
1392
+ },
1393
+ body: JSON.stringify({
1394
+ method: 'eth_signTypedData_v4',
1395
+ caip2: `eip155:${chainId}`,
1396
+ params: { typed_data: typedData },
1397
+ }),
1398
+ });
1399
+
1400
+ if (!signRes.ok) {
1401
+ const err = await signRes.text();
1402
+ return json({ error: `Wallet signing failed: ${err}` }, 502);
1403
+ }
1404
+
1405
+ const signResult = await signRes.json();
1406
+ const signature = signResult.data?.signature || signResult.signature;
1407
+
1408
+ if (!signature) {
1409
+ return json({ error: 'No signature returned from wallet' }, 502);
1410
+ }
1411
+
1412
+ // Replay with proof
1413
+ const proof = buildPaymentProof(paymentReqs, signature, userWallet.walletAddress);
1414
+ const retryRes = await fetch(targetUrl, {
1415
+ headers: { 'X-PAYMENT': proof },
1416
+ });
1417
+
1418
+ const content = await retryRes.text();
1419
+ const service = new URL(targetUrl).hostname;
1420
+
1421
+ if (retryRes.status === 402) {
1422
+ return json({ error: 'Payment proof rejected by server', httpStatus: 402 }, 502);
1423
+ }
1424
+
1425
+ await recordTransaction(env, `user:${email}`, {
1426
+ type: 'pay',
1427
+ amount: paymentReqs.amount,
1428
+ service,
1429
+ wallet: 'privy-user',
1430
+ });
1431
+
1432
+ return json({
1433
+ status: 'paid',
1434
+ amount: paymentReqs.amount,
1435
+ network: paymentReqs.network,
1436
+ service,
1437
+ content,
1438
+ wallet: 'user-privy',
1439
+ });
1440
+ }