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.
- package/.license-guard.json +7 -0
- package/CASH.md +75 -0
- package/CHANGELOG.md +18 -0
- package/CLA.md +17 -0
- package/DEPLOY-WORKER.md +161 -0
- package/LICENSE +21 -0
- package/PARTNERS-402.md +75 -0
- package/PARTNERS-STRIPE.md +69 -0
- package/README.md +80 -0
- package/REFERENCE.md +160 -0
- package/SETUP.md +242 -0
- package/SKILL.md +43 -0
- package/SPEC.md +156 -0
- package/WALLET.md +89 -0
- package/cli.js +265 -0
- package/detect.mjs +13 -0
- package/examples/minimal/README.md +17 -0
- package/install.js +6 -0
- package/lib/cdp-auth.js +131 -0
- package/mcp-server.mjs +105 -0
- package/openclaw.mjs +88 -0
- package/openclaw.plugin.json +17 -0
- package/package.json +43 -0
- package/providers/coinbase.js +55 -0
- package/providers/index.js +155 -0
- package/providers/passthrough.js +139 -0
- package/providers/privy.js +50 -0
- package/providers/stripe.js +54 -0
- package/providers/x402.js +50 -0
- package/skills/agent-pay/SKILL.md +30 -0
- package/worker/index.js +1440 -0
- package/worker/wrangler.toml +25 -0
package/worker/index.js
ADDED
|
@@ -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">✓</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
|
+
}
|