@obbyx/sdk 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +89 -2
- package/dist/client/budget.d.ts +43 -0
- package/dist/client/budget.d.ts.map +1 -0
- package/dist/client/budget.js +56 -0
- package/dist/client/budget.js.map +1 -0
- package/dist/client/paying-fetch.d.ts +46 -0
- package/dist/client/paying-fetch.d.ts.map +1 -0
- package/dist/client/paying-fetch.js +186 -0
- package/dist/client/paying-fetch.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/seller/binding.d.ts +2 -0
- package/dist/seller/binding.d.ts.map +1 -0
- package/dist/seller/binding.js +3 -0
- package/dist/seller/binding.js.map +1 -0
- package/dist/seller/consumed-proofs.d.ts +78 -0
- package/dist/seller/consumed-proofs.d.ts.map +1 -0
- package/dist/seller/consumed-proofs.js +150 -0
- package/dist/seller/consumed-proofs.js.map +1 -0
- package/dist/seller/defaults.d.ts +7 -0
- package/dist/seller/defaults.d.ts.map +1 -0
- package/dist/seller/defaults.js +7 -0
- package/dist/seller/defaults.js.map +1 -0
- package/dist/seller/payment-identifier.d.ts +73 -0
- package/dist/seller/payment-identifier.d.ts.map +1 -0
- package/dist/seller/payment-identifier.js +75 -0
- package/dist/seller/payment-identifier.js.map +1 -0
- package/dist/seller/paywall.d.ts +101 -0
- package/dist/seller/paywall.d.ts.map +1 -0
- package/dist/seller/paywall.js +397 -0
- package/dist/seller/paywall.js.map +1 -0
- package/dist/seller/quote.d.ts +38 -0
- package/dist/seller/quote.d.ts.map +1 -0
- package/dist/seller/quote.js +39 -0
- package/dist/seller/quote.js.map +1 -0
- package/dist/seller/x402-server.d.ts +59 -0
- package/dist/seller/x402-server.d.ts.map +1 -0
- package/dist/seller/x402-server.js +173 -0
- package/dist/seller/x402-server.js.map +1 -0
- package/package.json +68 -3
- package/src/client/budget.ts +88 -0
- package/src/client/paying-fetch.ts +256 -0
- package/src/index.ts +35 -0
- package/src/seller/binding.ts +8 -0
- package/src/seller/consumed-proofs.ts +245 -0
- package/src/seller/defaults.ts +6 -0
- package/src/seller/payment-identifier.ts +104 -0
- package/src/seller/paywall.ts +595 -0
- package/src/seller/quote.ts +82 -0
- package/src/seller/x402-server.ts +251 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { decodePaymentSignatureHeader } from '@x402/core/http';
|
|
2
|
+
import { ObbyxError, fromDisplay, getNetwork, isAssetId, networkKey, parseAmount, } from '@obbyx/obyte';
|
|
3
|
+
import { moduleLogger } from '@obbyx/obyte/logger';
|
|
4
|
+
import { EXACT_SCHEME, bindingsEqual, exactExtraSchema } from '@obbyx/x402-obyte';
|
|
5
|
+
import { DEFAULT_QUOTE_TTL_SECONDS, DEFAULT_SERVING_LEASE_SECONDS } from './defaults.js';
|
|
6
|
+
import { bodyHashOf, newQuote, offerBinding } from './quote.js';
|
|
7
|
+
function requestOf(transportContext) {
|
|
8
|
+
const request = transportContext?.request;
|
|
9
|
+
const adapter = request?.['adapter'];
|
|
10
|
+
if (adapter === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
const url = new URL(adapter.getUrl(), 'http://localhost');
|
|
13
|
+
return {
|
|
14
|
+
method: adapter.getMethod(),
|
|
15
|
+
url: url.pathname + url.search,
|
|
16
|
+
body: adapter.getBody?.(),
|
|
17
|
+
paymentHeader: typeof request?.['paymentHeader'] === 'string' ? request['paymentHeader'] : undefined,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export class ObyteExactServer {
|
|
21
|
+
options;
|
|
22
|
+
scheme = EXACT_SCHEME;
|
|
23
|
+
defaultAssetTransferMethod = 'default';
|
|
24
|
+
paymentFlows = {
|
|
25
|
+
default: { supported: ['upfront'], default: 'upfront' },
|
|
26
|
+
};
|
|
27
|
+
dynamicExtraFields = ['binding', 'quoteId', 'validUntil'];
|
|
28
|
+
schemeHooks;
|
|
29
|
+
log;
|
|
30
|
+
now;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
this.log = moduleLogger(options.logger, 'sdk.x402-server', { scheme: EXACT_SCHEME });
|
|
34
|
+
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
35
|
+
this.schemeHooks = {
|
|
36
|
+
onBeforeVerify: async (ctx) => {
|
|
37
|
+
const unit = ctx.paymentPayload.payload.unit?.unit;
|
|
38
|
+
const binding = ctx.requirements.extra.binding;
|
|
39
|
+
if (typeof unit !== 'string' || typeof binding !== 'string') {
|
|
40
|
+
return { abort: true, reason: 'invalid_payload' };
|
|
41
|
+
}
|
|
42
|
+
const { record } = this.options.proofs.begin(this.key(ctx.requirements.network, unit), binding);
|
|
43
|
+
if (!bindingsEqual(record.binding, binding))
|
|
44
|
+
return { abort: true, reason: 'invalid_payment_reused' };
|
|
45
|
+
if (record.state === 'served') {
|
|
46
|
+
this.log.warn({ proofId: unit }, 'payment already served a request; refused');
|
|
47
|
+
return { abort: true, reason: 'payment_already_used' };
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
},
|
|
51
|
+
// Claim the proof atomically (settling → serving) before settling, so two
|
|
52
|
+
// concurrent requests with the same payment can never both reach the
|
|
53
|
+
// handler: the official middleware runs the handler after a successful
|
|
54
|
+
// settle, and the facilitator answers success for both (idempotent).
|
|
55
|
+
onBeforeSettle: async (ctx) => {
|
|
56
|
+
const key = this.keyOf(ctx);
|
|
57
|
+
if (key === undefined)
|
|
58
|
+
return { abort: true, reason: 'invalid_payload' };
|
|
59
|
+
const proofs = this.options.proofs;
|
|
60
|
+
// A claim left behind by a crashed process is taken over after the lease.
|
|
61
|
+
proofs.reclaimStale(key, this.options.servingLeaseSeconds ?? DEFAULT_SERVING_LEASE_SECONDS);
|
|
62
|
+
if (proofs.transition(key, 'settling', 'serving') ||
|
|
63
|
+
// settled earlier, but the handler failed: serve it now (settle is idempotent)
|
|
64
|
+
proofs.transition(key, 'paid_not_served', 'serving')) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const state = this.options.proofs.get(key)?.state;
|
|
68
|
+
this.log.warn({ state }, 'payment already in use by another request; refused');
|
|
69
|
+
return {
|
|
70
|
+
abort: true,
|
|
71
|
+
reason: state === 'served' ? 'payment_already_used' : 'payment_in_progress',
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
onAfterSettle: async (ctx) => {
|
|
75
|
+
const key = this.keyOf(ctx);
|
|
76
|
+
if (key === undefined)
|
|
77
|
+
return;
|
|
78
|
+
// Success: one operation consumed the proof. Otherwise (pending, failed):
|
|
79
|
+
// release the claim so the SAME payment can be retried.
|
|
80
|
+
if (ctx.result.success)
|
|
81
|
+
this.options.proofs.transition(key, 'serving', 'served');
|
|
82
|
+
else
|
|
83
|
+
this.options.proofs.transition(key, 'serving', 'settling');
|
|
84
|
+
},
|
|
85
|
+
// The handler failed or threw after the payment was settled (upfront):
|
|
86
|
+
// the payment stays usable for the SAME operation instead of "already used".
|
|
87
|
+
onVerifiedPaymentCanceled: async (ctx) => {
|
|
88
|
+
const key = this.keyOf(ctx);
|
|
89
|
+
if (key === undefined)
|
|
90
|
+
return;
|
|
91
|
+
if (ctx.settledPhases.includes('before-handler')) {
|
|
92
|
+
this.options.proofs.transition(key, 'served', 'paid_not_served');
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.options.proofs.transition(key, 'serving', 'settling');
|
|
96
|
+
}
|
|
97
|
+
this.log.warn({ reason: ctx.reason }, 'paid request not served; the same payment can retry');
|
|
98
|
+
},
|
|
99
|
+
onSettleFailure: async (ctx) => {
|
|
100
|
+
const key = this.keyOf(ctx);
|
|
101
|
+
if (key !== undefined)
|
|
102
|
+
this.options.proofs.transition(key, 'serving', 'settling');
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** `{asset, amount}` in atomic units, or "20000 bytes" / "0.00002 GBYTE". */
|
|
107
|
+
async parsePrice(price, network) {
|
|
108
|
+
getNetwork(network);
|
|
109
|
+
if (typeof price === 'object') {
|
|
110
|
+
if (!isAssetId(price.asset))
|
|
111
|
+
throw new ObbyxError('invalid_price', `Invalid asset ${price.asset}`);
|
|
112
|
+
return { asset: price.asset, amount: parseAmount(price.amount).toString() };
|
|
113
|
+
}
|
|
114
|
+
const [, value, unit] = /^\s*([0-9]+(?:\.[0-9]+)?)\s*(bytes|gbyte)\s*$/i.exec(String(price)) ?? [];
|
|
115
|
+
if (value === undefined || unit === undefined) {
|
|
116
|
+
throw new ObbyxError('invalid_price', 'Use {asset, amount} or "<n> bytes" / "<x> GBYTE" (no USD default asset on Obyte yet)');
|
|
117
|
+
}
|
|
118
|
+
const amount = unit.toLowerCase() === 'bytes' ? parseAmount(value) : fromDisplay(value, 9);
|
|
119
|
+
return { asset: 'base', amount: amount.toString() };
|
|
120
|
+
}
|
|
121
|
+
getAssetDecimals(asset) {
|
|
122
|
+
return asset === 'base' ? 9 : undefined;
|
|
123
|
+
}
|
|
124
|
+
/** Nothing to add from /supported for exact; the per-request fields come from the enrich hook. */
|
|
125
|
+
async enhancePaymentRequirements(requirements) {
|
|
126
|
+
return requirements;
|
|
127
|
+
}
|
|
128
|
+
/** Adds (or, for a valid paid request, re-confirms) the per-request binding and quote. */
|
|
129
|
+
enrichPaymentRequiredResponse = async (ctx) => {
|
|
130
|
+
const request = requestOf(ctx.transportContext);
|
|
131
|
+
if (request === undefined)
|
|
132
|
+
throw new ObbyxError('no_request', 'Obyte exact needs the HTTP request context');
|
|
133
|
+
const accepted = this.acceptedQuote(request);
|
|
134
|
+
return ctx.requirements.map((req) => {
|
|
135
|
+
if (req.scheme !== EXACT_SCHEME)
|
|
136
|
+
return req;
|
|
137
|
+
const quote = accepted !== undefined &&
|
|
138
|
+
bindingsEqual(this.bindingFor(req, request, accepted), accepted.binding)
|
|
139
|
+
? accepted
|
|
140
|
+
: undefined;
|
|
141
|
+
if (quote !== undefined)
|
|
142
|
+
return { ...req, extra: { ...req.extra, ...quote } };
|
|
143
|
+
const fresh = newQuote(this.now(), this.options.quoteTtlSeconds ?? DEFAULT_QUOTE_TTL_SECONDS);
|
|
144
|
+
return {
|
|
145
|
+
...req,
|
|
146
|
+
extra: { ...req.extra, binding: this.bindingFor(req, request, fresh), ...fresh },
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
acceptedQuote(request) {
|
|
151
|
+
if (request.paymentHeader === undefined)
|
|
152
|
+
return undefined;
|
|
153
|
+
try {
|
|
154
|
+
const extra = exactExtraSchema.safeParse(decodePaymentSignatureHeader(request.paymentHeader).accepted?.extra);
|
|
155
|
+
return extra.success ? extra.data : undefined;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
bindingFor(req, request, quote) {
|
|
162
|
+
return offerBinding(this.options, EXACT_SCHEME, quote, { method: request.method, url: request.url, bodyHash: bodyHashOf(request.body) }, { network: req.network, asset: req.asset, amount: req.amount, payTo: req.payTo });
|
|
163
|
+
}
|
|
164
|
+
/** Proof key of the payment in a hook context (undefined when malformed). */
|
|
165
|
+
keyOf(ctx) {
|
|
166
|
+
const unit = ctx.paymentPayload.payload.unit?.unit;
|
|
167
|
+
return typeof unit === 'string' ? this.key(ctx.requirements.network, unit) : undefined;
|
|
168
|
+
}
|
|
169
|
+
key(network, unit) {
|
|
170
|
+
return networkKey(getNetwork(network), 'unit', unit);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=x402-server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"x402-server.js","sourceRoot":"","sources":["../../src/seller/x402-server.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obbyx/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Accept and make x402 payments on Obyte: Express paywall, adapter for the official @x402 servers, paying fetch with spending budgets",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"x402",
|
|
7
|
+
"obyte",
|
|
8
|
+
"paywall",
|
|
9
|
+
"payments",
|
|
10
|
+
"express",
|
|
11
|
+
"agents",
|
|
12
|
+
"http-402"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Alexander Ponomarev",
|
|
16
|
+
"homepage": "https://github.com/Taump/obbyx/tree/master/packages/sdk#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/Taump/obbyx.git",
|
|
20
|
+
"directory": "packages/sdk"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/Taump/obbyx/issues"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=24"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@obbyx/obyte": "^0.1.0",
|
|
47
|
+
"@obbyx/x402-obyte": "^0.2.0",
|
|
48
|
+
"@x402/core": "^2.29.0",
|
|
49
|
+
"obyte": "0.2.1",
|
|
50
|
+
"zod": "^4.6.7"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"express": "^5.0.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"express": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/express": "^5.0.6",
|
|
62
|
+
"@x402/express": "2.29.0",
|
|
63
|
+
"@x402/extensions": "2.29.0",
|
|
64
|
+
"@x402/fetch": "2.29.0",
|
|
65
|
+
"express": "^5.3.0"
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"build": "tsc -p tsconfig.build.json",
|
|
69
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
70
|
+
}
|
|
6
71
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ObbyxError } from '@obbyx/obyte';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spending limits for a paying client (agents, services).
|
|
5
|
+
*
|
|
6
|
+
* - `perCall`: the most one request may cost, per asset.
|
|
7
|
+
* - `total`: the most all requests may cost together, per asset.
|
|
8
|
+
*
|
|
9
|
+
* Amounts are RESERVED before a payment is signed (atomically: the event loop
|
|
10
|
+
* runs reserve() to completion), so concurrent calls can never overshoot the
|
|
11
|
+
* total. A reservation is then:
|
|
12
|
+
* - `spent` when the seller confirmed the payment,
|
|
13
|
+
* - `released` when the payment was definitely not made,
|
|
14
|
+
* - left in place when the outcome is unknown (invariant I7): the budget never
|
|
15
|
+
* under-counts a payment that may still settle.
|
|
16
|
+
*/
|
|
17
|
+
export interface BudgetLimits {
|
|
18
|
+
readonly perCall?: ReadonlyMap<string, bigint>;
|
|
19
|
+
readonly total?: ReadonlyMap<string, bigint>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BudgetReservation {
|
|
23
|
+
readonly id: number;
|
|
24
|
+
readonly asset: string;
|
|
25
|
+
readonly amount: bigint;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class BudgetExceededError extends ObbyxError {
|
|
29
|
+
constructor(message: string, details: Record<string, unknown>) {
|
|
30
|
+
super('budget_exceeded', message, { details });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class SpendingBudget {
|
|
35
|
+
private readonly spent = new Map<string, bigint>();
|
|
36
|
+
private readonly reserved = new Map<number, BudgetReservation>();
|
|
37
|
+
private nextId = 1;
|
|
38
|
+
|
|
39
|
+
constructor(private readonly limits: BudgetLimits) {}
|
|
40
|
+
|
|
41
|
+
reserve(asset: string, amount: bigint): BudgetReservation {
|
|
42
|
+
const perCall = this.limits.perCall?.get(asset);
|
|
43
|
+
if (perCall !== undefined && amount > perCall) {
|
|
44
|
+
throw new BudgetExceededError(
|
|
45
|
+
`Price ${amount} exceeds the per-call limit ${perCall} (${asset})`,
|
|
46
|
+
{
|
|
47
|
+
asset,
|
|
48
|
+
amount: amount.toString(),
|
|
49
|
+
perCall: perCall.toString(),
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const total = this.limits.total?.get(asset);
|
|
54
|
+
const committed = this.used(asset);
|
|
55
|
+
if (total !== undefined && committed + amount > total) {
|
|
56
|
+
throw new BudgetExceededError(
|
|
57
|
+
`Paying ${amount} would exceed the total budget ${total} (${asset}); already used or reserved: ${committed}`,
|
|
58
|
+
{ asset, amount: amount.toString(), total: total.toString(), used: committed.toString() },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const reservation = { id: this.nextId++, asset, amount };
|
|
62
|
+
this.reserved.set(reservation.id, reservation);
|
|
63
|
+
return reservation;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The payment settled: the amount is spent. */
|
|
67
|
+
spend(r: BudgetReservation): void {
|
|
68
|
+
if (!this.reserved.delete(r.id)) return;
|
|
69
|
+
this.spent.set(r.asset, (this.spent.get(r.asset) ?? 0n) + r.amount);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The payment was definitely not made: give the amount back. */
|
|
73
|
+
release(r: BudgetReservation): void {
|
|
74
|
+
this.reserved.delete(r.id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Spent + reserved (unknown outcomes included) for an asset. */
|
|
78
|
+
used(asset: string): bigint {
|
|
79
|
+
let sum = this.spent.get(asset) ?? 0n;
|
|
80
|
+
for (const r of this.reserved.values()) if (r.asset === asset) sum += r.amount;
|
|
81
|
+
return sum;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
remaining(asset: string): bigint | undefined {
|
|
85
|
+
const total = this.limits.total?.get(asset);
|
|
86
|
+
return total === undefined ? undefined : total - this.used(asset);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
decodePaymentRequiredHeader,
|
|
4
|
+
decodePaymentResponseHeader,
|
|
5
|
+
decodePaymentSignatureHeader,
|
|
6
|
+
encodePaymentSignatureHeader,
|
|
7
|
+
} from '@x402/core/http';
|
|
8
|
+
import type { PaymentPayload, PaymentRequirements, SettleResponse } from '@x402/core/types';
|
|
9
|
+
import { ObbyxError } from '@obbyx/obyte';
|
|
10
|
+
import { type Logger, moduleLogger } from '@obbyx/obyte/logger';
|
|
11
|
+
import type { BudgetReservation, SpendingBudget } from './budget.js';
|
|
12
|
+
import {
|
|
13
|
+
BATCH_SCHEME,
|
|
14
|
+
type BatchObyteClient,
|
|
15
|
+
EXACT_SCHEME,
|
|
16
|
+
type ExactObyteClient,
|
|
17
|
+
} from '@obbyx/x402-obyte';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* fetch() that pays x402 `exact` Obyte payments (buyer side).
|
|
21
|
+
*
|
|
22
|
+
* - Pays only offers within `maxAmount` (atomic units per asset).
|
|
23
|
+
* - Redirects are not followed automatically: a payment is never re-sent to
|
|
24
|
+
* another origin.
|
|
25
|
+
* - `settlement_pending` is retried with the SAME payment (never a new one)
|
|
26
|
+
* until `pendingTimeoutMs`; if still unknown, it throws `PaymentPendingError`
|
|
27
|
+
* carrying the payment header so the caller can retry later (I7).
|
|
28
|
+
*/
|
|
29
|
+
export interface PayingFetchOptions {
|
|
30
|
+
/** Payment mechanisms; a channel (batch) is preferred when one exists to the seller. */
|
|
31
|
+
readonly mechanisms: { readonly exact?: ExactObyteClient; readonly batch?: BatchObyteClient };
|
|
32
|
+
readonly network: string;
|
|
33
|
+
readonly maxAmount: ReadonlyMap<string, bigint>;
|
|
34
|
+
readonly logger: Logger;
|
|
35
|
+
readonly pendingTimeoutMs?: number;
|
|
36
|
+
/** Shared spending budget (per-call and total limits, reserved atomically). */
|
|
37
|
+
readonly budget?: SpendingBudget;
|
|
38
|
+
readonly fetch?: typeof fetch;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PaidResponse {
|
|
42
|
+
readonly response: Response;
|
|
43
|
+
readonly payment?: SettleResponse;
|
|
44
|
+
/** Base64 PAYMENT-SIGNATURE that was sent, for retries of the same operation. */
|
|
45
|
+
readonly paymentHeader?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class PaymentPendingError extends ObbyxError {
|
|
49
|
+
constructor(
|
|
50
|
+
readonly paymentHeader: string,
|
|
51
|
+
readonly transaction: string,
|
|
52
|
+
) {
|
|
53
|
+
super(
|
|
54
|
+
'settlement_pending',
|
|
55
|
+
`Payment ${transaction} is not final yet; retry the same request later with the same payment`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function payingFetch(
|
|
61
|
+
url: string,
|
|
62
|
+
init: RequestInit & {
|
|
63
|
+
paymentHeader?: string;
|
|
64
|
+
/** x402 payment-identifier for this operation (reuse it when retrying after a lost response). */
|
|
65
|
+
paymentId?: string;
|
|
66
|
+
},
|
|
67
|
+
options: PayingFetchOptions,
|
|
68
|
+
): Promise<PaidResponse> {
|
|
69
|
+
const log = moduleLogger(options.logger, 'sdk.fetch');
|
|
70
|
+
const doFetch = options.fetch ?? fetch;
|
|
71
|
+
const request = (header?: string) =>
|
|
72
|
+
doFetch(url, {
|
|
73
|
+
...init,
|
|
74
|
+
redirect: 'manual',
|
|
75
|
+
headers: { ...headersOf(init.headers), ...(header ? { 'PAYMENT-SIGNATURE': header } : {}) },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let header = init.paymentHeader;
|
|
79
|
+
let reservation: BudgetReservation | undefined;
|
|
80
|
+
let response = await request(header);
|
|
81
|
+
if (response.status !== 402 && header === undefined) return { response };
|
|
82
|
+
|
|
83
|
+
if (header === undefined) {
|
|
84
|
+
const required = response.headers.get('PAYMENT-REQUIRED');
|
|
85
|
+
if (required === null)
|
|
86
|
+
throw new ObbyxError('invalid_402', '402 without PAYMENT-REQUIRED header');
|
|
87
|
+
const offer = decodePaymentRequiredHeader(required);
|
|
88
|
+
// Offers in preference order (channel first). A channel that turns out to
|
|
89
|
+
// be unusable before anything is signed (not funded, closing, exhausted,
|
|
90
|
+
// busy) falls through to the next offer, normally `exact`.
|
|
91
|
+
let accepted: PaymentRequirements | undefined;
|
|
92
|
+
let result: Awaited<ReturnType<ExactObyteClient['createPaymentPayload']>> | undefined;
|
|
93
|
+
let lastChannelError: ObbyxError | undefined;
|
|
94
|
+
for (const candidate of chooseOffers(offer.accepts, options)) {
|
|
95
|
+
const mechanism =
|
|
96
|
+
candidate.scheme === BATCH_SCHEME ? options.mechanisms.batch : options.mechanisms.exact;
|
|
97
|
+
if (mechanism === undefined) continue;
|
|
98
|
+
log.info(
|
|
99
|
+
{
|
|
100
|
+
url,
|
|
101
|
+
scheme: candidate.scheme,
|
|
102
|
+
payTo: candidate.payTo,
|
|
103
|
+
asset: candidate.asset,
|
|
104
|
+
amount: candidate.amount,
|
|
105
|
+
},
|
|
106
|
+
'paying for request',
|
|
107
|
+
);
|
|
108
|
+
// Reserve the price BEFORE signing: concurrent calls cannot overshoot the budget.
|
|
109
|
+
reservation = options.budget?.reserve(candidate.asset, BigInt(candidate.amount));
|
|
110
|
+
try {
|
|
111
|
+
result = await mechanism.createPaymentPayload(2, candidate);
|
|
112
|
+
accepted = candidate;
|
|
113
|
+
break;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (reservation) options.budget?.release(reservation); // nothing was signed or sent
|
|
116
|
+
reservation = undefined;
|
|
117
|
+
if (err instanceof ObbyxError && CHANNEL_UNUSABLE.has(err.code)) {
|
|
118
|
+
lastChannelError = err;
|
|
119
|
+
log.info(
|
|
120
|
+
{ reason: err.code, detail: err.message },
|
|
121
|
+
'channel unusable; trying the next offer',
|
|
122
|
+
);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (accepted === undefined || result === undefined) {
|
|
129
|
+
// Nothing else to pay with: report why the channel could not (e.g. payment_pending).
|
|
130
|
+
throw lastChannelError ?? new ObbyxError('no_acceptable_offer', 'No offer could be paid');
|
|
131
|
+
}
|
|
132
|
+
const payload: PaymentPayload = {
|
|
133
|
+
x402Version: 2,
|
|
134
|
+
resource: offer.resource,
|
|
135
|
+
accepted,
|
|
136
|
+
payload: result.payload,
|
|
137
|
+
...(offer.extensions ? { extensions: withPaymentId(offer.extensions, init.paymentId) } : {}),
|
|
138
|
+
};
|
|
139
|
+
header = encodePaymentSignatureHeader(payload);
|
|
140
|
+
response = await request(header);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const deadline = Date.now() + (options.pendingTimeoutMs ?? 120_000);
|
|
144
|
+
for (;;) {
|
|
145
|
+
const settle = readSettle(response);
|
|
146
|
+
if (response.status !== 402 || settle?.errorReason !== 'settlement_pending') {
|
|
147
|
+
const accepted = acceptedOf(header);
|
|
148
|
+
if (accepted?.scheme === BATCH_SCHEME) options.mechanisms.batch?.onSettled(accepted, settle);
|
|
149
|
+
if (reservation !== undefined && options.budget !== undefined) {
|
|
150
|
+
// I7: spend on success; release ONLY on a definite refusal (402: the
|
|
151
|
+
// payment was not used). Anything else (5xx, 409, no settlement info)
|
|
152
|
+
// is an unknown outcome: the reservation stays counted.
|
|
153
|
+
if (settle?.success === true || (response.ok && settle === undefined))
|
|
154
|
+
options.budget.spend(reservation);
|
|
155
|
+
else if (response.status === 402) options.budget.release(reservation);
|
|
156
|
+
}
|
|
157
|
+
return { response, ...(settle ? { payment: settle } : {}), paymentHeader: header };
|
|
158
|
+
}
|
|
159
|
+
if (Date.now() > deadline) throw new PaymentPendingError(header, settle.transaction);
|
|
160
|
+
const retryAfterMs = Math.min(15, Number(response.headers.get('Retry-After') ?? 5)) * 1000;
|
|
161
|
+
const wait = Math.max(0, Math.min(retryAfterMs, deadline - Date.now()));
|
|
162
|
+
log.info(
|
|
163
|
+
{ transaction: settle.transaction, waitMs: wait },
|
|
164
|
+
'settlement pending; retrying with the same payment',
|
|
165
|
+
);
|
|
166
|
+
await response.body?.cancel();
|
|
167
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
168
|
+
response = await request(header);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** An offer from an untrusted seller: integer amount in smallest units, strings elsewhere. */
|
|
173
|
+
const offerShape = z
|
|
174
|
+
.object({
|
|
175
|
+
scheme: z.string(),
|
|
176
|
+
network: z.string(),
|
|
177
|
+
asset: z.string(),
|
|
178
|
+
amount: z.string().regex(/^\d{1,30}$/),
|
|
179
|
+
payTo: z.string(),
|
|
180
|
+
maxTimeoutSeconds: z.number().int().positive(),
|
|
181
|
+
extra: z.record(z.string(), z.unknown()),
|
|
182
|
+
})
|
|
183
|
+
.loose();
|
|
184
|
+
|
|
185
|
+
/** Channel states in which the client signs nothing and can pay another way. */
|
|
186
|
+
const CHANNEL_UNUSABLE = new Set([
|
|
187
|
+
'channel_not_funded',
|
|
188
|
+
'channel_closing',
|
|
189
|
+
'channel_exhausted',
|
|
190
|
+
'payment_pending',
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
/** Usable offers within the spending limit, a channel first (instant, no per-call fee). */
|
|
194
|
+
function chooseOffers(
|
|
195
|
+
accepts: PaymentRequirements[],
|
|
196
|
+
options: PayingFetchOptions,
|
|
197
|
+
): PaymentRequirements[] {
|
|
198
|
+
const usable = (a: PaymentRequirements) =>
|
|
199
|
+
offerShape.safeParse(a).success &&
|
|
200
|
+
a.network === options.network &&
|
|
201
|
+
((a.scheme === BATCH_SCHEME && options.mechanisms.batch?.hasChannel(a) === true) ||
|
|
202
|
+
(a.scheme === EXACT_SCHEME && options.mechanisms.exact !== undefined));
|
|
203
|
+
const candidates = [
|
|
204
|
+
...accepts.filter((a) => a.scheme === BATCH_SCHEME),
|
|
205
|
+
...accepts.filter((a) => a.scheme !== BATCH_SCHEME),
|
|
206
|
+
].filter(usable);
|
|
207
|
+
if (candidates.length === 0) {
|
|
208
|
+
throw new ObbyxError('no_acceptable_offer', `No usable offer on ${options.network}`);
|
|
209
|
+
}
|
|
210
|
+
const affordable = candidates.filter((offer) => {
|
|
211
|
+
const max = options.maxAmount.get(offer.asset);
|
|
212
|
+
return max !== undefined && BigInt(offer.amount) <= max;
|
|
213
|
+
});
|
|
214
|
+
if (affordable.length === 0) {
|
|
215
|
+
const prices = candidates.map((c) => `${c.amount} ${c.asset}`).join(', ');
|
|
216
|
+
throw new ObbyxError('amount_over_limit', `Price (${prices}) exceeds your spending limit`);
|
|
217
|
+
}
|
|
218
|
+
return affordable;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Echoes the seller's extensions; fills the payment-identifier id if declared. */
|
|
222
|
+
function withPaymentId(
|
|
223
|
+
extensions: Record<string, unknown>,
|
|
224
|
+
id: string | undefined,
|
|
225
|
+
): Record<string, unknown> {
|
|
226
|
+
const declared = extensions['payment-identifier'] as
|
|
227
|
+
{ info?: Record<string, unknown> } | undefined;
|
|
228
|
+
if (declared === undefined) return extensions;
|
|
229
|
+
const paymentId = id ?? `pay_${globalThis.crypto.randomUUID().replaceAll('-', '')}`;
|
|
230
|
+
return {
|
|
231
|
+
...extensions,
|
|
232
|
+
'payment-identifier': { ...declared, info: { ...declared.info, id: paymentId } },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function acceptedOf(header: string): PaymentRequirements | undefined {
|
|
237
|
+
try {
|
|
238
|
+
return decodePaymentSignatureHeader(header).accepted;
|
|
239
|
+
} catch {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readSettle(response: Response): SettleResponse | undefined {
|
|
245
|
+
const header = response.headers.get('PAYMENT-RESPONSE');
|
|
246
|
+
if (header === null) return undefined;
|
|
247
|
+
try {
|
|
248
|
+
return decodePaymentResponseHeader(header);
|
|
249
|
+
} catch {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function headersOf(headers: RequestInit['headers']): Record<string, string> {
|
|
255
|
+
return Object.fromEntries(new Headers(headers).entries());
|
|
256
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type BindingInput,
|
|
3
|
+
bindingsEqual,
|
|
4
|
+
computeBinding,
|
|
5
|
+
newQuoteId,
|
|
6
|
+
sha256Hex,
|
|
7
|
+
} from './seller/binding.js';
|
|
8
|
+
export {
|
|
9
|
+
ConsumedProofsStore,
|
|
10
|
+
type ProofRecord,
|
|
11
|
+
type ProofState,
|
|
12
|
+
type StoredResponse,
|
|
13
|
+
} from './seller/consumed-proofs.js';
|
|
14
|
+
export {
|
|
15
|
+
ObytePaywall,
|
|
16
|
+
type ObytePaywallOptions,
|
|
17
|
+
type PaidHandler,
|
|
18
|
+
type PaidResult,
|
|
19
|
+
type PaidRouteOptions,
|
|
20
|
+
type PaywallScheme,
|
|
21
|
+
type RoutePrice,
|
|
22
|
+
} from './seller/paywall.js';
|
|
23
|
+
export {
|
|
24
|
+
type PaidResponse,
|
|
25
|
+
PaymentPendingError,
|
|
26
|
+
type PayingFetchOptions,
|
|
27
|
+
payingFetch,
|
|
28
|
+
} from './client/paying-fetch.js';
|
|
29
|
+
export {
|
|
30
|
+
type BudgetLimits,
|
|
31
|
+
BudgetExceededError,
|
|
32
|
+
type BudgetReservation,
|
|
33
|
+
SpendingBudget,
|
|
34
|
+
} from './client/budget.js';
|
|
35
|
+
export { ObyteExactServer, type ObyteExactServerOptions } from './seller/x402-server.js';
|