wayleave 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/index.d.ts +14 -1
- package/index.js +116 -13
- package/package.json +8 -2
- package/x402.d.ts +41 -0
- package/x402.js +181 -0
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ Only the first lane is cryptographic. The other three are read off what the clie
|
|
|
27
27
|
|
|
28
28
|
```js
|
|
29
29
|
import Wayleave from 'wayleave';
|
|
30
|
+
import { coinbaseFacilitator } from 'wayleave/x402';
|
|
30
31
|
|
|
31
32
|
const gate = new Wayleave({
|
|
32
33
|
directories: {
|
|
@@ -38,14 +39,22 @@ const gate = new Wayleave({
|
|
|
38
39
|
},
|
|
39
40
|
rateLimits: { declared_agent: 10 },
|
|
40
41
|
pricedPaths: { '/api/premium': 0.05 }, // agents pay 5¢/call, humans free
|
|
41
|
-
verifyPayment: (
|
|
42
|
-
|
|
42
|
+
verifyPayment: coinbaseFacilitator({ // REQUIRED to sell
|
|
43
|
+
apiKeyId: process.env.CDP_API_KEY_ID,
|
|
44
|
+
apiKeySecret: process.env.CDP_API_KEY_SECRET,
|
|
45
|
+
receivingAddress: process.env.WAYLEAVE_RECEIVING_ADDRESS,
|
|
46
|
+
}),
|
|
43
47
|
onEvent: e => queueForBilling(e), // metering hook; never blocks serving
|
|
44
48
|
});
|
|
45
49
|
|
|
46
|
-
app.use(gate.express());
|
|
50
|
+
app.use(gate.express()); // async: it awaits your verifier
|
|
47
51
|
```
|
|
48
52
|
|
|
53
|
+
**If you sell anything, use `gate.express()` or `gate.handleAsync()`.**
|
|
54
|
+
Confirming settlement is a network call, so a real `verifyPayment` returns a
|
|
55
|
+
Promise. The synchronous `handle()` cannot await one and will tell you so
|
|
56
|
+
rather than quietly denying every payment.
|
|
57
|
+
|
|
49
58
|
## Guarantees, honestly stated
|
|
50
59
|
|
|
51
60
|
- Signature verification is real Ed25519 over an RFC 9421 signature base — forged keys, tampered requests, expired signatures, and replay-farming windows are all rejected. Tested adversarially, and against other implementations' wire formats rather than only its own.
|
|
@@ -59,7 +68,7 @@ app.use(gate.express());
|
|
|
59
68
|
|
|
60
69
|
## Status
|
|
61
70
|
|
|
62
|
-
v0.
|
|
71
|
+
v0.3.0 — verification, lanes, policy, pricing, and the metering hook, all under test (101 scenarios, including forgery, tampering, replay, guessed payment proofs, spoofed rate-limit identities, spoofed browser headers, spoofed operator identity, and cross-implementation wire formats). Payment settlement network integration is in progress; the 402 challenge is built in and settlement confirmation is a function you supply.
|
|
63
72
|
|
|
64
73
|
0.1.5 closes a hole worth naming: before it, a priced route accepted a payment proof that any agent could derive from the 402 challenge it had just been sent — free passage, recorded as revenue. Settlement confirmation is now yours to supply and denies by default. If you shipped 0.1.4 or earlier on a priced route, upgrade.
|
|
65
74
|
|
package/index.d.ts
CHANGED
|
@@ -116,7 +116,8 @@ export interface PaymentContext {
|
|
|
116
116
|
export type PaymentVerifier = (
|
|
117
117
|
proof: string,
|
|
118
118
|
ctx: PaymentContext
|
|
119
|
-
) => boolean | { ok: boolean; ref?: string; reason?: string }
|
|
119
|
+
) => boolean | { ok: boolean; ref?: string; reason?: string }
|
|
120
|
+
| Promise<boolean | { ok: boolean; ref?: string; reason?: string }>;
|
|
120
121
|
|
|
121
122
|
/** x402-shaped payment challenge returned with a 402. */
|
|
122
123
|
export interface PaymentChallenge {
|
|
@@ -315,6 +316,12 @@ export interface WayleaveOptions {
|
|
|
315
316
|
* billing must never break serving.
|
|
316
317
|
*/
|
|
317
318
|
onEvent?: (event: MeterEvent) => void;
|
|
319
|
+
/**
|
|
320
|
+
* Shorthand for wiring a `MeterSink` yourself: `meter: { apiKey }` builds
|
|
321
|
+
* one pointed at meter.wayleave.dev. Pass `sink` directly for full control;
|
|
322
|
+
* an explicit `sink` always wins.
|
|
323
|
+
*/
|
|
324
|
+
meter?: { apiKey: string; endpoint?: string; [k: string]: unknown };
|
|
318
325
|
}
|
|
319
326
|
|
|
320
327
|
/** Build the `@signature-params` value for the Web Bot Auth profile. */
|
|
@@ -364,6 +371,12 @@ export function classify(
|
|
|
364
371
|
|
|
365
372
|
export class Wayleave {
|
|
366
373
|
constructor(opts?: WayleaveOptions);
|
|
374
|
+
/**
|
|
375
|
+
* Async twin of `handle`. Required if `verifyPayment` returns a Promise —
|
|
376
|
+
* which any real facilitator does, since confirming settlement is a network
|
|
377
|
+
* call. `express()` uses this.
|
|
378
|
+
*/
|
|
379
|
+
handleAsync(req: WayleaveRequest, now?: number, paymentProof?: string): Promise<Decision>;
|
|
367
380
|
/** Classify, apply policy, price, and emit the metering event. */
|
|
368
381
|
handle(
|
|
369
382
|
req: WayleaveRequest,
|
package/index.js
CHANGED
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
|
|
20
20
|
import { createPublicKey, verify as edVerify, sign as edSign,
|
|
21
21
|
createHash, randomUUID } from 'node:crypto';
|
|
22
|
+
// Same package, zero dependencies, ~9KB. Importing it unconditionally costs
|
|
23
|
+
// nothing measurable and makes `meter: { apiKey }` work without ceremony.
|
|
24
|
+
import { MeterSink } from './meter.js';
|
|
22
25
|
|
|
23
26
|
export const LANES = Object.freeze({
|
|
24
27
|
VERIFIED: 'verified_agent',
|
|
@@ -351,6 +354,24 @@ export class DirectSink {
|
|
|
351
354
|
}
|
|
352
355
|
}
|
|
353
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Turn whatever a verifier returned into a verdict.
|
|
359
|
+
*
|
|
360
|
+
* A Promise reaching here means an async verifier was used on the synchronous
|
|
361
|
+
* path. Reading `.ok` off a Promise yields undefined and denies silently —
|
|
362
|
+
* which is precisely how the documented facilitator example failed from 0.1.5
|
|
363
|
+
* to 0.2.1. It now says so instead.
|
|
364
|
+
*/
|
|
365
|
+
function normalizeVerdict(r) {
|
|
366
|
+
if (r && typeof r.then === 'function')
|
|
367
|
+
return { ok: false, reason:
|
|
368
|
+
'verifyPayment returned a Promise — use gate.handleAsync() or gate.express(), ' +
|
|
369
|
+
'which await it. The synchronous handle() cannot.' };
|
|
370
|
+
if (r === true) return { ok: true, ref: null };
|
|
371
|
+
if (r && r.ok === true) return { ok: true, ref: r.ref || null };
|
|
372
|
+
return { ok: false, reason: (r && r.reason) || 'not settled' };
|
|
373
|
+
}
|
|
374
|
+
|
|
354
375
|
// ── the gate ────────────────────────────────────────────────────────────
|
|
355
376
|
|
|
356
377
|
export class Wayleave {
|
|
@@ -408,7 +429,19 @@ export class Wayleave {
|
|
|
408
429
|
// State and metering are interfaces, not implementations. Swap either for
|
|
409
430
|
// a shared or durable one without the gate noticing. See MemoryStore.
|
|
410
431
|
this.store = opts.store || new MemoryStore({ maxTracked: this.maxTracked });
|
|
411
|
-
|
|
432
|
+
|
|
433
|
+
// `meter: { apiKey }` is shorthand for wiring a MeterSink yourself. It
|
|
434
|
+
// exists because the two-line version of this was the most common thing
|
|
435
|
+
// people got wrong: forgetting to drain on shutdown, or handing the sink
|
|
436
|
+
// a callback that throws. Pass `sink` directly if you want control.
|
|
437
|
+
this.sink = opts.sink
|
|
438
|
+
|| (opts.meter?.apiKey
|
|
439
|
+
? new MeterSink({
|
|
440
|
+
endpoint: 'https://meter.wayleave.dev',
|
|
441
|
+
...opts.meter,
|
|
442
|
+
})
|
|
443
|
+
: null)
|
|
444
|
+
|| new DirectSink(this.onEvent);
|
|
412
445
|
|
|
413
446
|
// Identifies events from this process so a retrying sink can dedup. A
|
|
414
447
|
// counter alone would collide across instances; a timestamp alone would
|
|
@@ -437,6 +470,28 @@ export class Wayleave {
|
|
|
437
470
|
return { lane, identity: ident, ...d, evidence };
|
|
438
471
|
}
|
|
439
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Async twin of handle(), for verifiers that talk to a network.
|
|
475
|
+
*
|
|
476
|
+
* Identical in every respect except that it can await your verifyPayment.
|
|
477
|
+
* Use this whenever you actually sell something; the Express adapter does.
|
|
478
|
+
*/
|
|
479
|
+
async handleAsync(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
|
|
480
|
+
const c = classify(req, this.directories, now,
|
|
481
|
+
{ verifyAgentIP: this.verifyAgentIP, ip: this._client(req) });
|
|
482
|
+
const { lane, agentId, evidence } = c;
|
|
483
|
+
const ident = agentId || `${lane}:${this._client(req)}`;
|
|
484
|
+
const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
|
|
485
|
+
const d = await this._decideAsync(req, lane, ident, now, proof, c);
|
|
486
|
+
const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
|
|
487
|
+
t: now, path: req.path, lane, identity: ident,
|
|
488
|
+
status: d.status, why: d.why, evidence,
|
|
489
|
+
billedUsd: d.billed || 0 };
|
|
490
|
+
if (d.paymentRef) entry.paymentRef = d.paymentRef;
|
|
491
|
+
this.sink.emit(entry);
|
|
492
|
+
return { lane, identity: ident, ...d, evidence };
|
|
493
|
+
}
|
|
494
|
+
|
|
440
495
|
/**
|
|
441
496
|
* Who to rate-limit. A cryptographic identity is authoritative; a network
|
|
442
497
|
* address is at least expensive to rotate. A header is neither.
|
|
@@ -449,6 +504,7 @@ export class Wayleave {
|
|
|
449
504
|
return req.ip || 'unknown';
|
|
450
505
|
}
|
|
451
506
|
|
|
507
|
+
|
|
452
508
|
/**
|
|
453
509
|
* Positive evidence that a person is present, which only your application
|
|
454
510
|
* holds — a signed-in session, a cookie you set, a challenge you passed.
|
|
@@ -470,7 +526,17 @@ export class Wayleave {
|
|
|
470
526
|
return this.store.hit(ident, this.rateWindow, now);
|
|
471
527
|
}
|
|
472
528
|
|
|
473
|
-
|
|
529
|
+
/**
|
|
530
|
+
* Everything up to the payment question.
|
|
531
|
+
*
|
|
532
|
+
* Split out so the sync and async paths share one copy of the policy,
|
|
533
|
+
* replay, and rate-limit logic. Two copies of this would drift, and the
|
|
534
|
+
* half that drifted would be the one nobody tested.
|
|
535
|
+
*
|
|
536
|
+
* Returns either a final decision, or a marker saying a priced route was
|
|
537
|
+
* matched and a payment verdict is needed.
|
|
538
|
+
*/
|
|
539
|
+
_preDecide(req, lane, ident, now, paymentProof, c) {
|
|
474
540
|
for (const [prefix, allow] of this.rules[lane] || []) {
|
|
475
541
|
if (req.path.startsWith(prefix)) {
|
|
476
542
|
if (!allow) return { status: 403, why: `${lane} denied on ${prefix}` };
|
|
@@ -508,12 +574,10 @@ export class Wayleave {
|
|
|
508
574
|
: 'payment required for agent access';
|
|
509
575
|
return { status: 402, why, challenge };
|
|
510
576
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
why: `settled $${price}` };
|
|
516
|
-
return { status: 402, why: `payment proof rejected: ${v.reason}`, challenge };
|
|
577
|
+
// Hand back to the caller, which knows whether it can await.
|
|
578
|
+
return { needsPayment: true, challenge, price,
|
|
579
|
+
ctx: { price, resource: prefix, identity: ident,
|
|
580
|
+
path: req.path, now } };
|
|
517
581
|
}
|
|
518
582
|
}
|
|
519
583
|
}
|
|
@@ -522,6 +586,28 @@ export class Wayleave {
|
|
|
522
586
|
return { status: 200, why: `${lane} allowed` };
|
|
523
587
|
}
|
|
524
588
|
|
|
589
|
+
/** Turn a payment verdict into the final decision. Shared by both paths. */
|
|
590
|
+
_settleDecision(v, pre) {
|
|
591
|
+
if (v.ok)
|
|
592
|
+
return { status: 200, billed: pre.price, paymentRef: v.ref,
|
|
593
|
+
why: `settled $${pre.price}` };
|
|
594
|
+
return { status: 402, why: `payment proof rejected: ${v.reason}`,
|
|
595
|
+
challenge: pre.challenge };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
_decide(req, lane, ident, now, paymentProof, c) {
|
|
599
|
+
const pre = this._preDecide(req, lane, ident, now, paymentProof, c);
|
|
600
|
+
if (!pre.needsPayment) return pre;
|
|
601
|
+
return this._settleDecision(this._checkPayment(paymentProof, pre.ctx), pre);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async _decideAsync(req, lane, ident, now, paymentProof, c) {
|
|
605
|
+
const pre = this._preDecide(req, lane, ident, now, paymentProof, c);
|
|
606
|
+
if (!pre.needsPayment) return pre;
|
|
607
|
+
return this._settleDecision(
|
|
608
|
+
await this._checkPaymentAsync(paymentProof, pre.ctx), pre);
|
|
609
|
+
}
|
|
610
|
+
|
|
525
611
|
/**
|
|
526
612
|
* Settlement is somebody else's job — a facilitator that actually moved the
|
|
527
613
|
* money. This asks yours and believes nothing else. A verifier that throws
|
|
@@ -533,15 +619,32 @@ export class Wayleave {
|
|
|
533
619
|
let r;
|
|
534
620
|
try { r = this.verifyPayment(proof, ctx); }
|
|
535
621
|
catch (e) { return { ok: false, reason: `verifier threw: ${e.message}` }; }
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
622
|
+
return normalizeVerdict(r);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* The async twin of _checkPayment.
|
|
627
|
+
*
|
|
628
|
+
* Every real facilitator is a network call, so a useful verifier returns a
|
|
629
|
+
* Promise. The synchronous path cannot await one and refuses it loudly
|
|
630
|
+
* rather than reading `.ok` off a Promise and silently denying — which is
|
|
631
|
+
* what shipped from 0.1.5 to 0.2.1, and meant the documented way to sell
|
|
632
|
+
* anything never worked.
|
|
633
|
+
*/
|
|
634
|
+
async _checkPaymentAsync(proof, ctx) {
|
|
635
|
+
if (!this.verifyPayment)
|
|
636
|
+
return { ok: false, reason: 'no verifyPayment configured' };
|
|
637
|
+
try {
|
|
638
|
+
return normalizeVerdict(await this.verifyPayment(proof, ctx));
|
|
639
|
+
} catch (e) {
|
|
640
|
+
return { ok: false, reason: `verifier threw: ${e.message}` };
|
|
641
|
+
}
|
|
539
642
|
}
|
|
540
643
|
|
|
541
644
|
/** Express/Connect adapter: app.use(wayleave.express()) */
|
|
542
645
|
express() {
|
|
543
|
-
return (req, res, next) => {
|
|
544
|
-
const r = this.
|
|
646
|
+
return async (req, res, next) => {
|
|
647
|
+
const r = await this.handleAsync({
|
|
545
648
|
method: req.method, path: req.path || req.url,
|
|
546
649
|
authority: req.headers.host || '',
|
|
547
650
|
headers: req.headers,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wayleave",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Charge AI agents for passage across your app. Verify agent signatures (Web Bot Auth / Ed25519), classify every request, and return 402 with a price on routes you choose. Humans browse free.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"./meter": {
|
|
14
14
|
"types": "./meter.d.ts",
|
|
15
15
|
"default": "./meter.js"
|
|
16
|
+
},
|
|
17
|
+
"./x402": {
|
|
18
|
+
"types": "./x402.d.ts",
|
|
19
|
+
"default": "./x402.js"
|
|
16
20
|
}
|
|
17
21
|
},
|
|
18
22
|
"files": [
|
|
@@ -21,7 +25,9 @@
|
|
|
21
25
|
"index.d.ts",
|
|
22
26
|
"index.js",
|
|
23
27
|
"meter.d.ts",
|
|
24
|
-
"meter.js"
|
|
28
|
+
"meter.js",
|
|
29
|
+
"x402.d.ts",
|
|
30
|
+
"x402.js"
|
|
25
31
|
],
|
|
26
32
|
"scripts": {
|
|
27
33
|
"test": "node --test *.test.js"
|
package/x402.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PaymentVerifier } from './index.js';
|
|
2
|
+
|
|
3
|
+
export interface CoinbaseFacilitatorOptions {
|
|
4
|
+
/** CDP API key id. Yours. */
|
|
5
|
+
apiKeyId: string;
|
|
6
|
+
/** CDP API key secret. Yours. */
|
|
7
|
+
apiKeySecret: string;
|
|
8
|
+
/**
|
|
9
|
+
* The address YOU get paid at. Money moves agent → facilitator → here.
|
|
10
|
+
* Wayleave is never a hop and holds nothing.
|
|
11
|
+
*/
|
|
12
|
+
receivingAddress: string;
|
|
13
|
+
/** Default 'base'. */
|
|
14
|
+
network?: string;
|
|
15
|
+
/** Token contract address; omit for the network default (USDC). */
|
|
16
|
+
asset?: string;
|
|
17
|
+
/** Asset decimals. 6 for USDC. */
|
|
18
|
+
decimals?: number;
|
|
19
|
+
/**
|
|
20
|
+
* true (default) submits the payment on-chain. false only verifies it,
|
|
21
|
+
* which is useful for a dry run but pays nobody.
|
|
22
|
+
*/
|
|
23
|
+
settle?: boolean;
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
onError?: (err: Error, info: Record<string, unknown>) => void;
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A ready-made `verifyPayment` for the Coinbase x402 rail.
|
|
31
|
+
*
|
|
32
|
+
* Returns a Promise, so use it with `gate.express()` or `gate.handleAsync()`.
|
|
33
|
+
* The synchronous `handle()` cannot await it and will say so rather than
|
|
34
|
+
* silently deny.
|
|
35
|
+
*/
|
|
36
|
+
export function coinbaseFacilitator(opts: CoinbaseFacilitatorOptions): PaymentVerifier;
|
|
37
|
+
|
|
38
|
+
/** Dollars → atomic units as a decimal string. Throws on excess precision. */
|
|
39
|
+
export function toAtomicUnits(usd: number | string, decimals?: number): string;
|
|
40
|
+
|
|
41
|
+
export default coinbaseFacilitator;
|
package/x402.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wayleave/x402 — a ready-made `verifyPayment` for the Coinbase x402 rail.
|
|
3
|
+
*
|
|
4
|
+
* Before this existed, selling anything meant writing your own integration
|
|
5
|
+
* against the facilitator API: two endpoints, a request body shaped
|
|
6
|
+
* { x402Version, paymentPayload, paymentRequirements }, atomic-unit
|
|
7
|
+
* conversion, and a response that returns HTTP 200 even when the payment is
|
|
8
|
+
* bad. That is a lot of surface to get subtly wrong in the one place where
|
|
9
|
+
* being wrong means giving away data for free or billing for money that never
|
|
10
|
+
* moved.
|
|
11
|
+
*
|
|
12
|
+
* So it lives here instead, tested, and you configure it:
|
|
13
|
+
*
|
|
14
|
+
* import Wayleave from 'wayleave';
|
|
15
|
+
* import { coinbaseFacilitator } from 'wayleave/x402';
|
|
16
|
+
*
|
|
17
|
+
* const gate = new Wayleave({
|
|
18
|
+
* pricedPaths: { '/api/premium': 0.05 },
|
|
19
|
+
* strictPricedPaths: true,
|
|
20
|
+
* confirmHuman: req => Boolean(req.session?.userId),
|
|
21
|
+
* verifyPayment: coinbaseFacilitator({
|
|
22
|
+
* apiKeyId: process.env.CDP_API_KEY_ID,
|
|
23
|
+
* apiKeySecret: process.env.CDP_API_KEY_SECRET,
|
|
24
|
+
* receivingAddress: process.env.WAYLEAVE_RECEIVING_ADDRESS,
|
|
25
|
+
* }),
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* YOUR credentials, YOUR receiving address. Money moves agent -> facilitator
|
|
29
|
+
* -> you. Wayleave is not a hop, does not hold funds, and this module has no
|
|
30
|
+
* method that moves value — there is a test asserting that, because the day it
|
|
31
|
+
* gains one this stops being metering and becomes money transmission.
|
|
32
|
+
*
|
|
33
|
+
* Zero dependencies: fetch is global from Node 18.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const DEFAULT_BASE = 'https://api.cdp.coinbase.com/platform';
|
|
37
|
+
const X402_VERSION = 1;
|
|
38
|
+
const DEFAULT_DECIMALS = 6; // USDC and most stablecoins
|
|
39
|
+
const TIMEOUT_MS = 10000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Dollars -> atomic units, as a decimal string.
|
|
43
|
+
*
|
|
44
|
+
* String arithmetic, not `usd * 10 ** decimals`, because floating point
|
|
45
|
+
* mangles money: 0.07 * 1e6 is 70000.00000000001 in JavaScript, and rounding
|
|
46
|
+
* that is how a ledger acquires a cent of drift per transaction.
|
|
47
|
+
*/
|
|
48
|
+
export function toAtomicUnits(usd, decimals = DEFAULT_DECIMALS) {
|
|
49
|
+
const s = String(usd);
|
|
50
|
+
if (!/^\d+(\.\d+)?$/.test(s))
|
|
51
|
+
throw new Error(`amount must be a non-negative decimal, got "${usd}"`);
|
|
52
|
+
const [whole, frac = ''] = s.split('.');
|
|
53
|
+
if (frac.length > decimals)
|
|
54
|
+
throw new Error(`amount ${usd} is finer than the asset's ${decimals} decimals`);
|
|
55
|
+
return (whole + frac.padEnd(decimals, '0')).replace(/^0+(?=\d)/, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build a `verifyPayment(proof, ctx)` for the Coinbase x402 facilitator.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} opts
|
|
62
|
+
* @param {string} opts.apiKeyId CDP API key id
|
|
63
|
+
* @param {string} opts.apiKeySecret CDP API key secret
|
|
64
|
+
* @param {string} opts.receivingAddress YOUR address. Never ours.
|
|
65
|
+
* @param {string} [opts.network] 'base' (default) | 'base-sepolia' | ...
|
|
66
|
+
* @param {string} [opts.asset] token contract; omit for the network default
|
|
67
|
+
* @param {number} [opts.decimals] asset decimals, 6 for USDC
|
|
68
|
+
* @param {boolean} [opts.settle] true (default) submits on-chain.
|
|
69
|
+
* false only verifies — useful for a
|
|
70
|
+
* dry run, but nobody gets paid.
|
|
71
|
+
* @param {(err:Error, info:object)=>void} [opts.onError]
|
|
72
|
+
* @returns {(proof:string, ctx:object)=> Promise<{ok:boolean, ref?:string, reason?:string}>}
|
|
73
|
+
*/
|
|
74
|
+
export function coinbaseFacilitator({
|
|
75
|
+
apiKeyId, apiKeySecret, receivingAddress,
|
|
76
|
+
network = 'base', asset = null, decimals = DEFAULT_DECIMALS,
|
|
77
|
+
settle = true, baseUrl = DEFAULT_BASE, onError = () => {}, fetch: f = fetch,
|
|
78
|
+
} = {}) {
|
|
79
|
+
if (!apiKeyId || !apiKeySecret)
|
|
80
|
+
throw new Error('coinbaseFacilitator requires apiKeyId and apiKeySecret');
|
|
81
|
+
if (!receivingAddress)
|
|
82
|
+
throw new Error('coinbaseFacilitator requires receivingAddress — the address YOU get paid at');
|
|
83
|
+
|
|
84
|
+
const base = baseUrl.replace(/\/$/, '');
|
|
85
|
+
|
|
86
|
+
return async function verifyPayment(proof, ctx) {
|
|
87
|
+
// The middleware hands us the proof as a string; agents send the signed
|
|
88
|
+
// payload as JSON. Accept either so a customer does not have to care.
|
|
89
|
+
let paymentPayload = proof;
|
|
90
|
+
if (typeof proof === 'string') {
|
|
91
|
+
try { paymentPayload = JSON.parse(proof); }
|
|
92
|
+
catch { return deny('payment proof is not a valid x402 payload'); }
|
|
93
|
+
}
|
|
94
|
+
if (!paymentPayload || typeof paymentPayload !== 'object')
|
|
95
|
+
return deny('no payment payload supplied');
|
|
96
|
+
|
|
97
|
+
let requirements;
|
|
98
|
+
try {
|
|
99
|
+
requirements = {
|
|
100
|
+
scheme: 'exact',
|
|
101
|
+
network,
|
|
102
|
+
// We assert what was required; the facilitator checks the payment
|
|
103
|
+
// matches. Under the exact scheme the transfer MUST equal this and
|
|
104
|
+
// MUST land at payTo, so a wrong amount or wrong address cannot come
|
|
105
|
+
// back valid. That is stronger than trusting a number the payer sent.
|
|
106
|
+
maxAmountRequired: toAtomicUnits(ctx.price, decimals),
|
|
107
|
+
resource: ctx.resource ?? ctx.path ?? '',
|
|
108
|
+
description: '',
|
|
109
|
+
mimeType: 'application/json',
|
|
110
|
+
payTo: receivingAddress,
|
|
111
|
+
maxTimeoutSeconds: 60,
|
|
112
|
+
...(asset ? { asset } : {}),
|
|
113
|
+
};
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return deny(`cannot build payment requirements: ${err.message}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const body = { x402Version: X402_VERSION, paymentPayload, paymentRequirements: requirements };
|
|
119
|
+
const path = settle ? '/v2/x402/settle' : '/v2/x402/verify';
|
|
120
|
+
|
|
121
|
+
let res;
|
|
122
|
+
try {
|
|
123
|
+
res = await post(f, `${base}${path}`, body, apiKeyId, apiKeySecret);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Unreachable facilitator denies. An outage must degrade into unpaid,
|
|
126
|
+
// never into free passage recorded as revenue.
|
|
127
|
+
onError(err, { phase: 'facilitator', path });
|
|
128
|
+
return deny(`facilitator unreachable: ${err.message}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
onError(new Error(`facilitator ${res.status}`), { phase: 'facilitator', path });
|
|
133
|
+
return deny(`facilitator returned ${res.status}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const d = res.data ?? {};
|
|
137
|
+
|
|
138
|
+
// CRITICAL: the facilitator answers HTTP 200 even when the payment is
|
|
139
|
+
// bad. Reading the status code alone books every rejected payment as
|
|
140
|
+
// revenue — which is exactly the bug 0.1.5 fixed, one layer down.
|
|
141
|
+
if (settle) {
|
|
142
|
+
if (d.success !== true)
|
|
143
|
+
return deny(`settlement failed: ${d.errorReason ?? 'no reason given'}`);
|
|
144
|
+
if (!d.transaction)
|
|
145
|
+
return deny('settlement reported success with nothing to reference');
|
|
146
|
+
return { ok: true, ref: String(d.transaction) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (d.isValid !== true)
|
|
150
|
+
return deny(`payment not valid: ${d.invalidReason ?? 'no reason given'}`);
|
|
151
|
+
return { ok: true, ref: d.payer ? `payer:${d.payer}` : null };
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function post(f, url, body, keyId, keySecret) {
|
|
156
|
+
const ctrl = new AbortController();
|
|
157
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
158
|
+
try {
|
|
159
|
+
const r = await f(url, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: {
|
|
162
|
+
'content-type': 'application/json',
|
|
163
|
+
'cb-access-key': keyId,
|
|
164
|
+
'cb-access-secret': keySecret,
|
|
165
|
+
},
|
|
166
|
+
body: JSON.stringify(body),
|
|
167
|
+
signal: ctrl.signal,
|
|
168
|
+
});
|
|
169
|
+
let data = null;
|
|
170
|
+
try { data = await r.json(); } catch { /* non-JSON body stays null */ }
|
|
171
|
+
return { ok: r.ok, status: r.status, data };
|
|
172
|
+
} finally {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function deny(reason) {
|
|
178
|
+
return { ok: false, reason };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export default coinbaseFacilitator;
|