@obbyx/sdk 0.0.0-stage → 0.1.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/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 +72 -0
- package/dist/seller/consumed-proofs.d.ts.map +1 -0
- package/dist/seller/consumed-proofs.js +123 -0
- package/dist/seller/consumed-proofs.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 +99 -0
- package/dist/seller/paywall.d.ts.map +1 -0
- package/dist/seller/paywall.js +372 -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 +57 -0
- package/dist/seller/x402-server.d.ts.map +1 -0
- package/dist/seller/x402-server.js +152 -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 +217 -0
- package/src/seller/payment-identifier.ts +104 -0
- package/src/seller/paywall.ts +566 -0
- package/src/seller/quote.ts +82 -0
- package/src/seller/x402-server.ts +224 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Seller-side registry of consumed payment proofs (invariant I3).
|
|
5
|
+
*
|
|
6
|
+
* Keyed by `<caip2>:unit:<hash>`, so it holds even when several APIs or
|
|
7
|
+
* facilitators serve the same payTo. Each proof serves exactly one operation
|
|
8
|
+
* (its binding); a repeat of that operation gets the stored response.
|
|
9
|
+
*
|
|
10
|
+
* upfront: settling → paid_not_served → serving → served
|
|
11
|
+
* └────→ failed └──→ paid_not_served (handler failed)
|
|
12
|
+
* authorization: settling → serving → awaiting_settle → served
|
|
13
|
+
* │ └──────→ failed (handler already ran)
|
|
14
|
+
* └──→ settling (handler failed; nothing settled)
|
|
15
|
+
*
|
|
16
|
+
* serving older than the lease (crash) → paid_not_served | settling (reclaimStale)
|
|
17
|
+
*
|
|
18
|
+
* Transitions are compare-and-set, so concurrent requests with the same proof
|
|
19
|
+
* never run the handler twice.
|
|
20
|
+
*/
|
|
21
|
+
export type ProofState =
|
|
22
|
+
| 'settling'
|
|
23
|
+
| 'paid_not_served'
|
|
24
|
+
| 'serving'
|
|
25
|
+
| 'served'
|
|
26
|
+
| 'failed'
|
|
27
|
+
/** authorization flow: the handler ran, its response waits for the settlement. */
|
|
28
|
+
| 'awaiting_settle';
|
|
29
|
+
|
|
30
|
+
export interface StoredResponse {
|
|
31
|
+
readonly status: number;
|
|
32
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
33
|
+
readonly body: Uint8Array;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ProofRecord {
|
|
37
|
+
readonly key: string;
|
|
38
|
+
readonly binding: string;
|
|
39
|
+
readonly state: ProofState;
|
|
40
|
+
readonly paymentResponse: string | null;
|
|
41
|
+
readonly response: StoredResponse | null;
|
|
42
|
+
readonly updatedAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Row {
|
|
46
|
+
key: string;
|
|
47
|
+
binding: string;
|
|
48
|
+
state: ProofState;
|
|
49
|
+
payment_response: string | null;
|
|
50
|
+
response_status: number | null;
|
|
51
|
+
response_headers: string | null;
|
|
52
|
+
response_body: Uint8Array | null;
|
|
53
|
+
updated_at: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class ConsumedProofsStore {
|
|
57
|
+
private readonly db: DatabaseSync;
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
path: string,
|
|
61
|
+
private readonly now: () => number = () => Math.floor(Date.now() / 1000),
|
|
62
|
+
) {
|
|
63
|
+
this.db = new DatabaseSync(path);
|
|
64
|
+
this.db.exec(`
|
|
65
|
+
PRAGMA journal_mode = WAL;
|
|
66
|
+
PRAGMA synchronous = FULL;
|
|
67
|
+
PRAGMA busy_timeout = 5000;
|
|
68
|
+
CREATE TABLE IF NOT EXISTS consumed_proofs (
|
|
69
|
+
key TEXT PRIMARY KEY,
|
|
70
|
+
binding TEXT NOT NULL,
|
|
71
|
+
state TEXT NOT NULL,
|
|
72
|
+
payment_response TEXT,
|
|
73
|
+
response_status INTEGER,
|
|
74
|
+
response_headers TEXT,
|
|
75
|
+
response_body BLOB,
|
|
76
|
+
created_at INTEGER NOT NULL,
|
|
77
|
+
updated_at INTEGER NOT NULL
|
|
78
|
+
) STRICT;
|
|
79
|
+
CREATE TABLE IF NOT EXISTS payment_ids (
|
|
80
|
+
scope_id TEXT PRIMARY KEY,
|
|
81
|
+
fingerprint TEXT NOT NULL,
|
|
82
|
+
proof_key TEXT NOT NULL,
|
|
83
|
+
created_at INTEGER NOT NULL
|
|
84
|
+
) STRICT;
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* x402 `payment-identifier`: remembers which proof served a client-chosen id
|
|
90
|
+
* for one operation fingerprint. Returns the stored mapping (existing or new).
|
|
91
|
+
*/
|
|
92
|
+
bindPaymentId(
|
|
93
|
+
scopeId: string,
|
|
94
|
+
fingerprint: string,
|
|
95
|
+
proofKey: string,
|
|
96
|
+
): { fingerprint: string; proofKey: string; created: boolean } {
|
|
97
|
+
const res = this.db
|
|
98
|
+
.prepare(
|
|
99
|
+
'INSERT INTO payment_ids (scope_id, fingerprint, proof_key, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(scope_id) DO NOTHING',
|
|
100
|
+
)
|
|
101
|
+
.run(scopeId, fingerprint, proofKey, this.now());
|
|
102
|
+
const row = this.db
|
|
103
|
+
.prepare('SELECT fingerprint, proof_key FROM payment_ids WHERE scope_id = ?')
|
|
104
|
+
.get(scopeId) as {
|
|
105
|
+
fingerprint: string;
|
|
106
|
+
proof_key: string;
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
fingerprint: row.fingerprint,
|
|
110
|
+
proofKey: row.proof_key,
|
|
111
|
+
created: Number(res.changes) === 1,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Compare-and-set: moves the id only if it still points at `previous` (one winner). */
|
|
116
|
+
rebindPaymentId(scopeId: string, previous: string, proofKey: string): boolean {
|
|
117
|
+
const res = this.db
|
|
118
|
+
.prepare('UPDATE payment_ids SET proof_key = ? WHERE scope_id = ? AND proof_key = ?')
|
|
119
|
+
.run(proofKey, scopeId, previous);
|
|
120
|
+
return Number(res.changes) === 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
close(): void {
|
|
124
|
+
this.db.close();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get(key: string): ProofRecord | undefined {
|
|
128
|
+
const row = this.db.prepare('SELECT * FROM consumed_proofs WHERE key = ?').get(key) as
|
|
129
|
+
Row | undefined;
|
|
130
|
+
return row === undefined ? undefined : toRecord(row);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Inserts the proof in `settling` unless it exists; returns the stored record. */
|
|
134
|
+
begin(key: string, binding: string): { created: boolean; record: ProofRecord } {
|
|
135
|
+
const ts = this.now();
|
|
136
|
+
const res = this.db
|
|
137
|
+
.prepare(
|
|
138
|
+
`INSERT INTO consumed_proofs (key, binding, state, created_at, updated_at)
|
|
139
|
+
VALUES (?, ?, 'settling', ?, ?) ON CONFLICT(key) DO NOTHING`,
|
|
140
|
+
)
|
|
141
|
+
.run(key, binding, ts, ts);
|
|
142
|
+
const record = this.get(key);
|
|
143
|
+
if (record === undefined) throw new Error(`consumed proof ${key} vanished`);
|
|
144
|
+
return { created: Number(res.changes) === 1, record };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Compare-and-set; returns false if the proof is not in `from`. */
|
|
148
|
+
transition(
|
|
149
|
+
key: string,
|
|
150
|
+
from: ProofState,
|
|
151
|
+
to: ProofState,
|
|
152
|
+
patch: { paymentResponse?: string; response?: StoredResponse } = {},
|
|
153
|
+
): boolean {
|
|
154
|
+
const res = this.db
|
|
155
|
+
.prepare(
|
|
156
|
+
`UPDATE consumed_proofs
|
|
157
|
+
SET state = ?, updated_at = ?,
|
|
158
|
+
payment_response = COALESCE(?, payment_response),
|
|
159
|
+
response_status = COALESCE(?, response_status),
|
|
160
|
+
response_headers = COALESCE(?, response_headers),
|
|
161
|
+
response_body = COALESCE(?, response_body)
|
|
162
|
+
WHERE key = ? AND state = ?`,
|
|
163
|
+
)
|
|
164
|
+
.run(
|
|
165
|
+
to,
|
|
166
|
+
this.now(),
|
|
167
|
+
patch.paymentResponse ?? null,
|
|
168
|
+
patch.response?.status ?? null,
|
|
169
|
+
patch.response === undefined ? null : JSON.stringify(patch.response.headers),
|
|
170
|
+
patch.response?.body ?? null,
|
|
171
|
+
key,
|
|
172
|
+
from,
|
|
173
|
+
);
|
|
174
|
+
return Number(res.changes) === 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Recovers a proof stuck in `serving` (the process died while the handler
|
|
179
|
+
* ran): after `leaseSeconds` it goes back to `paid_not_served` (already
|
|
180
|
+
* settled) or `settling` (authorization flow, not settled yet), so the SAME
|
|
181
|
+
* payment can complete the operation. Compare-and-set: one winner.
|
|
182
|
+
*/
|
|
183
|
+
reclaimStale(key: string, leaseSeconds: number): boolean {
|
|
184
|
+
const res = this.db
|
|
185
|
+
.prepare(
|
|
186
|
+
`UPDATE consumed_proofs
|
|
187
|
+
SET state = CASE WHEN payment_response IS NULL THEN 'settling' ELSE 'paid_not_served' END,
|
|
188
|
+
updated_at = ?
|
|
189
|
+
WHERE key = ? AND state = 'serving' AND updated_at <= ?`,
|
|
190
|
+
)
|
|
191
|
+
.run(this.now(), key, this.now() - leaseSeconds);
|
|
192
|
+
return Number(res.changes) === 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Removes a proof that never reached the network (e.g. rejected at verification). */
|
|
196
|
+
forget(key: string, state: ProofState): void {
|
|
197
|
+
this.db.prepare('DELETE FROM consumed_proofs WHERE key = ? AND state = ?').run(key, state);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function toRecord(row: Row): ProofRecord {
|
|
202
|
+
return {
|
|
203
|
+
key: row.key,
|
|
204
|
+
binding: row.binding,
|
|
205
|
+
state: row.state,
|
|
206
|
+
paymentResponse: row.payment_response,
|
|
207
|
+
response:
|
|
208
|
+
row.response_status === null
|
|
209
|
+
? null
|
|
210
|
+
: {
|
|
211
|
+
status: row.response_status,
|
|
212
|
+
headers: JSON.parse(row.response_headers ?? '{}') as Record<string, string>,
|
|
213
|
+
body: row.response_body ?? new Uint8Array(),
|
|
214
|
+
},
|
|
215
|
+
updatedAt: row.updated_at,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { PaymentPayload } from '@x402/core/types';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* x402 `payment-identifier` extension (seller side): a client-chosen id makes
|
|
6
|
+
* a repeated operation return the stored response instead of charging again.
|
|
7
|
+
*/
|
|
8
|
+
export const PAYMENT_IDENTIFIER = 'payment-identifier';
|
|
9
|
+
|
|
10
|
+
/** Same constraints as @x402/extensions (16–128 chars of [A-Za-z0-9_-]). */
|
|
11
|
+
export const paymentIdSchema = z.string().regex(/^[a-zA-Z0-9_-]{16,128}$/);
|
|
12
|
+
|
|
13
|
+
const extensionSchema = z
|
|
14
|
+
.object({ info: z.object({ id: z.unknown().optional() }).loose() })
|
|
15
|
+
.loose();
|
|
16
|
+
|
|
17
|
+
/** Same declaration as @x402/extensions `declarePaymentIdentifierExtension`. */
|
|
18
|
+
export function paymentIdentifierDeclaration(required: boolean) {
|
|
19
|
+
return {
|
|
20
|
+
info: { required },
|
|
21
|
+
schema: {
|
|
22
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
required: { type: 'boolean' },
|
|
26
|
+
id: { type: 'string', minLength: 16, maxLength: 128, pattern: '^[a-zA-Z0-9_-]+$' },
|
|
27
|
+
},
|
|
28
|
+
required: ['required'],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The client's id: `undefined` when absent, `'invalid'` when malformed. */
|
|
34
|
+
export function paymentIdOf(payload: PaymentPayload): string | undefined | 'invalid' {
|
|
35
|
+
const raw = payload.extensions?.[PAYMENT_IDENTIFIER];
|
|
36
|
+
if (raw === undefined) return undefined;
|
|
37
|
+
const ext = extensionSchema.safeParse(raw);
|
|
38
|
+
if (!ext.success) return 'invalid';
|
|
39
|
+
const id = ext.data.info.id;
|
|
40
|
+
if (id === undefined) return undefined;
|
|
41
|
+
const parsed = paymentIdSchema.safeParse(id);
|
|
42
|
+
return parsed.success ? parsed.data : 'invalid';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What the store needs for payment-identifier decisions. */
|
|
46
|
+
export interface PaymentIdStore {
|
|
47
|
+
bindPaymentId(
|
|
48
|
+
scopeId: string,
|
|
49
|
+
fingerprint: string,
|
|
50
|
+
proofKey: string,
|
|
51
|
+
): { fingerprint: string; proofKey: string; created: boolean };
|
|
52
|
+
/** Compare-and-set: moves the id to `proofKey` only if it still points at `previous`. */
|
|
53
|
+
rebindPaymentId(scopeId: string, previous: string, proofKey: string): boolean;
|
|
54
|
+
get(
|
|
55
|
+
key: string,
|
|
56
|
+
): { state: string; response: unknown; paymentResponse: string | null } | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type PaymentIdDecision =
|
|
60
|
+
| { kind: 'proceed' }
|
|
61
|
+
| { kind: 'replay'; proofKey: string }
|
|
62
|
+
| { kind: 'reject'; status: 400 | 409; error: string };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One client id = one operation. Contract: the caller has already created the
|
|
66
|
+
* proof row for `proofKey` (so other requests see it as in progress).
|
|
67
|
+
* Concurrent retries with DIFFERENT payments
|
|
68
|
+
* for the same id: exactly one wins the compare-and-set; the others get 409,
|
|
69
|
+
* so an id can never be charged twice.
|
|
70
|
+
*/
|
|
71
|
+
export function decidePaymentId(
|
|
72
|
+
store: PaymentIdStore,
|
|
73
|
+
input: {
|
|
74
|
+
paymentId: string | undefined | 'invalid';
|
|
75
|
+
required: boolean;
|
|
76
|
+
scopeId: (id: string) => string;
|
|
77
|
+
fingerprint: string;
|
|
78
|
+
proofKey: string;
|
|
79
|
+
},
|
|
80
|
+
): PaymentIdDecision {
|
|
81
|
+
const { paymentId } = input;
|
|
82
|
+
if (paymentId === 'invalid')
|
|
83
|
+
return { kind: 'reject', status: 400, error: 'invalid_payment_identifier' };
|
|
84
|
+
if (paymentId === undefined) {
|
|
85
|
+
return input.required
|
|
86
|
+
? { kind: 'reject', status: 400, error: 'payment_identifier_required' }
|
|
87
|
+
: { kind: 'proceed' };
|
|
88
|
+
}
|
|
89
|
+
const scopeId = input.scopeId(paymentId);
|
|
90
|
+
const mapping = store.bindPaymentId(scopeId, input.fingerprint, input.proofKey);
|
|
91
|
+
if (mapping.fingerprint !== input.fingerprint) {
|
|
92
|
+
return { kind: 'reject', status: 409, error: 'payment_identifier_conflict' };
|
|
93
|
+
}
|
|
94
|
+
if (mapping.proofKey === input.proofKey) return { kind: 'proceed' };
|
|
95
|
+
const original = store.get(mapping.proofKey);
|
|
96
|
+
if (original?.state === 'served') return { kind: 'replay', proofKey: mapping.proofKey };
|
|
97
|
+
if (original !== undefined && original.state !== 'failed') {
|
|
98
|
+
return { kind: 'reject', status: 409, error: 'payment_in_progress' };
|
|
99
|
+
}
|
|
100
|
+
// The earlier attempt definitely failed (or never consumed its proof): move the id.
|
|
101
|
+
return store.rebindPaymentId(scopeId, mapping.proofKey, input.proofKey)
|
|
102
|
+
? { kind: 'proceed' }
|
|
103
|
+
: { kind: 'reject', status: 409, error: 'payment_in_progress' };
|
|
104
|
+
}
|