@obbyx/x402-obyte 0.0.0-stage → 0.2.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 +65 -2
- package/dist/batch/client-store.d.ts +50 -0
- package/dist/batch/client-store.d.ts.map +1 -0
- package/dist/batch/client-store.js +93 -0
- package/dist/batch/client-store.js.map +1 -0
- package/dist/batch/client.d.ts +63 -0
- package/dist/batch/client.d.ts.map +1 -0
- package/dist/batch/client.js +226 -0
- package/dist/batch/client.js.map +1 -0
- package/dist/batch/facilitator.d.ts +73 -0
- package/dist/batch/facilitator.d.ts.map +1 -0
- package/dist/batch/facilitator.js +341 -0
- package/dist/batch/facilitator.js.map +1 -0
- package/dist/batch/gateway.d.ts +21 -0
- package/dist/batch/gateway.d.ts.map +1 -0
- package/dist/batch/gateway.js +2 -0
- package/dist/batch/gateway.js.map +1 -0
- package/dist/batch/requirements.d.ts +52 -0
- package/dist/batch/requirements.d.ts.map +1 -0
- package/dist/batch/requirements.js +80 -0
- package/dist/batch/requirements.js.map +1 -0
- package/dist/batch/store.d.ts +112 -0
- package/dist/batch/store.d.ts.map +1 -0
- package/dist/batch/store.js +198 -0
- package/dist/batch/store.js.map +1 -0
- package/dist/batch/watcher-protocol.d.ts +91 -0
- package/dist/batch/watcher-protocol.d.ts.map +1 -0
- package/dist/batch/watcher-protocol.js +82 -0
- package/dist/batch/watcher-protocol.js.map +1 -0
- package/dist/binding/binding.d.ts +32 -0
- package/dist/binding/binding.d.ts.map +1 -0
- package/dist/binding/binding.js +19 -0
- package/dist/binding/binding.js.map +1 -0
- package/dist/coins/composer.d.ts +58 -0
- package/dist/coins/composer.d.ts.map +1 -0
- package/dist/coins/composer.js +102 -0
- package/dist/coins/composer.js.map +1 -0
- package/dist/coins/index.d.ts +4 -0
- package/dist/coins/index.d.ts.map +1 -0
- package/dist/coins/index.js +4 -0
- package/dist/coins/index.js.map +1 -0
- package/dist/coins/reservations.d.ts +37 -0
- package/dist/coins/reservations.d.ts.map +1 -0
- package/dist/coins/reservations.js +102 -0
- package/dist/coins/reservations.js.map +1 -0
- package/dist/coins/selection.d.ts +42 -0
- package/dist/coins/selection.d.ts.map +1 -0
- package/dist/coins/selection.js +60 -0
- package/dist/coins/selection.js.map +1 -0
- package/dist/exact/client.d.ts +33 -0
- package/dist/exact/client.d.ts.map +1 -0
- package/dist/exact/client.js +93 -0
- package/dist/exact/client.js.map +1 -0
- package/dist/exact/facilitator-v1.d.ts +48 -0
- package/dist/exact/facilitator-v1.d.ts.map +1 -0
- package/dist/exact/facilitator-v1.js +81 -0
- package/dist/exact/facilitator-v1.js.map +1 -0
- package/dist/exact/facilitator.d.ts +94 -0
- package/dist/exact/facilitator.d.ts.map +1 -0
- package/dist/exact/facilitator.js +361 -0
- package/dist/exact/facilitator.js.map +1 -0
- package/dist/exact/finality.d.ts +18 -0
- package/dist/exact/finality.d.ts.map +1 -0
- package/dist/exact/finality.js +45 -0
- package/dist/exact/finality.js.map +1 -0
- package/dist/exact/ledger-checks.d.ts +15 -0
- package/dist/exact/ledger-checks.d.ts.map +1 -0
- package/dist/exact/ledger-checks.js +85 -0
- package/dist/exact/ledger-checks.js.map +1 -0
- package/dist/exact/reasons.d.ts +29 -0
- package/dist/exact/reasons.d.ts.map +1 -0
- package/dist/exact/reasons.js +21 -0
- package/dist/exact/reasons.js.map +1 -0
- package/dist/exact/requirements.d.ts +37 -0
- package/dist/exact/requirements.d.ts.map +1 -0
- package/dist/exact/requirements.js +80 -0
- package/dist/exact/requirements.js.map +1 -0
- package/dist/exact/store.d.ts +136 -0
- package/dist/exact/store.d.ts.map +1 -0
- package/dist/exact/store.js +217 -0
- package/dist/exact/store.js.map +1 -0
- package/dist/hub/heartbeat.d.ts +12 -0
- package/dist/hub/heartbeat.d.ts.map +1 -0
- package/dist/hub/heartbeat.js +17 -0
- package/dist/hub/heartbeat.js.map +1 -0
- package/dist/hub/hub-call.d.ts +20 -0
- package/dist/hub/hub-call.d.ts.map +1 -0
- package/dist/hub/hub-call.js +30 -0
- package/dist/hub/hub-call.js.map +1 -0
- package/dist/hub/schemas.d.ts +41 -0
- package/dist/hub/schemas.d.ts.map +1 -0
- package/dist/hub/schemas.js +71 -0
- package/dist/hub/schemas.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/util/keyed-queue.d.ts +13 -0
- package/dist/util/keyed-queue.d.ts.map +1 -0
- package/dist/util/keyed-queue.js +25 -0
- package/dist/util/keyed-queue.js.map +1 -0
- package/package.json +51 -3
- package/src/batch/client-store.ts +167 -0
- package/src/batch/client.ts +317 -0
- package/src/batch/facilitator.ts +461 -0
- package/src/batch/gateway.ts +22 -0
- package/src/batch/requirements.ts +108 -0
- package/src/batch/store.ts +359 -0
- package/src/batch/watcher-protocol.ts +89 -0
- package/src/binding/binding.ts +50 -0
- package/src/coins/composer.ts +159 -0
- package/src/coins/index.ts +9 -0
- package/src/coins/reservations.ts +139 -0
- package/src/coins/selection.ts +93 -0
- package/src/exact/client.ts +144 -0
- package/src/exact/facilitator-v1.ts +137 -0
- package/src/exact/facilitator.ts +496 -0
- package/src/exact/finality.ts +56 -0
- package/src/exact/ledger-checks.ts +113 -0
- package/src/exact/reasons.ts +25 -0
- package/src/exact/requirements.ts +109 -0
- package/src/exact/store.ts +399 -0
- package/src/hub/heartbeat.ts +18 -0
- package/src/hub/hub-call.ts +46 -0
- package/src/hub/schemas.ts +89 -0
- package/src/index.ts +111 -0
- package/src/util/keyed-queue.ts +25 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { ObbyxError } from '@obbyx/obyte';
|
|
3
|
+
import { type Utxo, inputKey } from './selection.js';
|
|
4
|
+
|
|
5
|
+
/** Durable (SQLite) reservations of the wallet's coins, bound to the unit that spends them. */
|
|
6
|
+
export interface ReservationRow {
|
|
7
|
+
input_key: string;
|
|
8
|
+
network: string;
|
|
9
|
+
payment_unit: string | null;
|
|
10
|
+
reserved_at: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class CoinReservations {
|
|
14
|
+
private readonly db: DatabaseSync;
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
path: string,
|
|
18
|
+
private readonly now: () => number = () => Math.floor(Date.now() / 1000),
|
|
19
|
+
) {
|
|
20
|
+
this.db = new DatabaseSync(path);
|
|
21
|
+
this.db.exec(`
|
|
22
|
+
PRAGMA journal_mode = WAL;
|
|
23
|
+
PRAGMA synchronous = FULL;
|
|
24
|
+
PRAGMA busy_timeout = 5000;
|
|
25
|
+
CREATE TABLE IF NOT EXISTS coin_reservations (
|
|
26
|
+
input_key TEXT PRIMARY KEY,
|
|
27
|
+
network TEXT NOT NULL,
|
|
28
|
+
address TEXT NOT NULL,
|
|
29
|
+
payment_unit TEXT,
|
|
30
|
+
reserved_at INTEGER NOT NULL,
|
|
31
|
+
hold_until INTEGER
|
|
32
|
+
) STRICT;
|
|
33
|
+
`);
|
|
34
|
+
// Databases created before hold_until existed.
|
|
35
|
+
const columns = this.db.prepare('PRAGMA table_info(coin_reservations)').all() as {
|
|
36
|
+
name: string;
|
|
37
|
+
}[];
|
|
38
|
+
if (!columns.some((c) => c.name === 'hold_until')) {
|
|
39
|
+
this.db.exec('ALTER TABLE coin_reservations ADD COLUMN hold_until INTEGER');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
close(): void {
|
|
44
|
+
this.db.close();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
reservedKeys(network: string): Set<string> {
|
|
48
|
+
const rows = this.db
|
|
49
|
+
.prepare('SELECT input_key FROM coin_reservations WHERE network = ?')
|
|
50
|
+
.all(network) as unknown as {
|
|
51
|
+
input_key: string;
|
|
52
|
+
}[];
|
|
53
|
+
return new Set(rows.map((r) => r.input_key));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Reserves all inputs atomically; fails if any is already reserved. */
|
|
57
|
+
reserve(network: string, address: string, inputs: readonly Utxo[]): void {
|
|
58
|
+
const ts = this.now();
|
|
59
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
60
|
+
try {
|
|
61
|
+
const insert = this.db.prepare(
|
|
62
|
+
'INSERT INTO coin_reservations (input_key, network, address, payment_unit, reserved_at) VALUES (?, ?, ?, NULL, ?)',
|
|
63
|
+
);
|
|
64
|
+
for (const u of inputs) insert.run(inputKey(network, u), network, address, ts);
|
|
65
|
+
this.db.exec('COMMIT');
|
|
66
|
+
} catch (err) {
|
|
67
|
+
this.db.exec('ROLLBACK');
|
|
68
|
+
throw new ObbyxError('coins_reserved', 'An input is already reserved by another payment', {
|
|
69
|
+
cause: err,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Binds the inputs reserved for a compose to the resulting unit hash.
|
|
76
|
+
* `holdUntil`: the unit may still be published until then (offer validity +
|
|
77
|
+
* the facilitator's publication window), so its inputs are never released earlier.
|
|
78
|
+
*/
|
|
79
|
+
bind(network: string, inputs: readonly Utxo[], unit: string, holdUntil?: number): void {
|
|
80
|
+
const update = this.db.prepare(
|
|
81
|
+
'UPDATE coin_reservations SET payment_unit = ?, hold_until = ? WHERE input_key = ? AND payment_unit IS NULL',
|
|
82
|
+
);
|
|
83
|
+
for (const u of inputs) update.run(unit, holdUntil ?? null, inputKey(network, u));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Releases inputs whose compose failed (no unit was produced). */
|
|
87
|
+
releaseUnbound(network: string, inputs: readonly Utxo[]): void {
|
|
88
|
+
const del = this.db.prepare(
|
|
89
|
+
'DELETE FROM coin_reservations WHERE input_key = ? AND payment_unit IS NULL',
|
|
90
|
+
);
|
|
91
|
+
for (const u of inputs) del.run(inputKey(network, u));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Units with live reservations, oldest first. */
|
|
95
|
+
boundUnits(network: string): { unit: string; reservedAt: number; holdUntil: number | null }[] {
|
|
96
|
+
const rows = this.db
|
|
97
|
+
.prepare(
|
|
98
|
+
`SELECT payment_unit, MIN(reserved_at) AS reserved_at, MAX(hold_until) AS hold_until
|
|
99
|
+
FROM coin_reservations
|
|
100
|
+
WHERE network = ? AND payment_unit IS NOT NULL GROUP BY payment_unit ORDER BY reserved_at`,
|
|
101
|
+
)
|
|
102
|
+
.all(network) as unknown as {
|
|
103
|
+
payment_unit: string;
|
|
104
|
+
reserved_at: number;
|
|
105
|
+
hold_until: number | null;
|
|
106
|
+
}[];
|
|
107
|
+
return rows.map((r) => ({
|
|
108
|
+
unit: r.payment_unit,
|
|
109
|
+
reservedAt: r.reserved_at,
|
|
110
|
+
holdUntil: r.hold_until,
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Ends all reservations of a unit (seen on the ledger, or provably never published). */
|
|
115
|
+
endUnit(network: string, unit: string): number {
|
|
116
|
+
return Number(
|
|
117
|
+
this.db
|
|
118
|
+
.prepare('DELETE FROM coin_reservations WHERE network = ? AND payment_unit = ?')
|
|
119
|
+
.run(network, unit).changes,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Drops reservations of composes that crashed before producing a unit. */
|
|
124
|
+
dropStaleUnbound(network: string, olderThan: number): number {
|
|
125
|
+
return Number(
|
|
126
|
+
this.db
|
|
127
|
+
.prepare(
|
|
128
|
+
'DELETE FROM coin_reservations WHERE network = ? AND payment_unit IS NULL AND reserved_at < ?',
|
|
129
|
+
)
|
|
130
|
+
.run(network, olderThan).changes,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
rows(): ReservationRow[] {
|
|
135
|
+
return this.db
|
|
136
|
+
.prepare('SELECT input_key, network, payment_unit, reserved_at FROM coin_reservations')
|
|
137
|
+
.all() as unknown as ReservationRow[];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Coin selection from a light history (pure functions). */
|
|
2
|
+
export interface Utxo {
|
|
3
|
+
readonly unit: string;
|
|
4
|
+
readonly messageIndex: number;
|
|
5
|
+
readonly outputIndex: number;
|
|
6
|
+
readonly asset: string;
|
|
7
|
+
readonly amount: bigint;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function inputKey(
|
|
11
|
+
network: string,
|
|
12
|
+
u: Pick<Utxo, 'unit' | 'messageIndex' | 'outputIndex'>,
|
|
13
|
+
): string {
|
|
14
|
+
return `${network}:${u.unit}:${u.messageIndex}:${u.outputIndex}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface HistoryJoint {
|
|
18
|
+
unit: {
|
|
19
|
+
unit: string;
|
|
20
|
+
main_chain_index?: number | null;
|
|
21
|
+
authors?: { address: string }[];
|
|
22
|
+
messages?: {
|
|
23
|
+
app: string;
|
|
24
|
+
payload?: {
|
|
25
|
+
asset?: string;
|
|
26
|
+
inputs?: { unit?: string; message_index?: number; output_index?: number }[];
|
|
27
|
+
outputs?: { address: string; amount: number }[];
|
|
28
|
+
};
|
|
29
|
+
}[];
|
|
30
|
+
};
|
|
31
|
+
ball?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Unspent outputs of `address` from a `light/get_history` response.
|
|
36
|
+
* Others' outputs count only if stable and at or before `lastBallMci`.
|
|
37
|
+
*/
|
|
38
|
+
export function unspentOutputs(
|
|
39
|
+
joints: readonly HistoryJoint[],
|
|
40
|
+
address: string,
|
|
41
|
+
lastBallMci: number,
|
|
42
|
+
): Utxo[] {
|
|
43
|
+
const spent = new Set<string>();
|
|
44
|
+
for (const j of joints) {
|
|
45
|
+
for (const m of j.unit.messages ?? []) {
|
|
46
|
+
if (m.app !== 'payment') continue;
|
|
47
|
+
for (const i of m.payload?.inputs ?? []) {
|
|
48
|
+
if (i.unit !== undefined) spent.add(`${i.unit}:${i.message_index}:${i.output_index}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const utxos: Utxo[] = [];
|
|
53
|
+
for (const j of joints) {
|
|
54
|
+
const own = j.unit.authors?.some((a) => a.address === address) === true;
|
|
55
|
+
const stable = j.ball !== undefined;
|
|
56
|
+
const mci = j.unit.main_chain_index;
|
|
57
|
+
if (!own && !(stable && typeof mci === 'number' && mci <= lastBallMci)) continue;
|
|
58
|
+
(j.unit.messages ?? []).forEach((m, messageIndex) => {
|
|
59
|
+
if (m.app !== 'payment') return;
|
|
60
|
+
(m.payload?.outputs ?? []).forEach((o, outputIndex) => {
|
|
61
|
+
if (o.address !== address || spent.has(`${j.unit.unit}:${messageIndex}:${outputIndex}`))
|
|
62
|
+
return;
|
|
63
|
+
utxos.push({
|
|
64
|
+
unit: j.unit.unit,
|
|
65
|
+
messageIndex,
|
|
66
|
+
outputIndex,
|
|
67
|
+
asset: m.payload?.asset ?? 'base',
|
|
68
|
+
amount: BigInt(o.amount),
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return utxos;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Largest-first selection until the target is covered. */
|
|
77
|
+
export function selectCoins(
|
|
78
|
+
utxos: readonly Utxo[],
|
|
79
|
+
asset: string,
|
|
80
|
+
target: bigint,
|
|
81
|
+
): Utxo[] | undefined {
|
|
82
|
+
const candidates = utxos
|
|
83
|
+
.filter((u) => u.asset === asset)
|
|
84
|
+
.sort((a, b) => (a.amount > b.amount ? -1 : a.amount < b.amount ? 1 : 0));
|
|
85
|
+
const picked: Utxo[] = [];
|
|
86
|
+
let total = 0n;
|
|
87
|
+
for (const u of candidates) {
|
|
88
|
+
if (total >= target) break;
|
|
89
|
+
picked.push(u);
|
|
90
|
+
total += u.amount;
|
|
91
|
+
}
|
|
92
|
+
return total >= target ? picked : undefined;
|
|
93
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PaymentPayloadContext,
|
|
3
|
+
PaymentPayloadResult,
|
|
4
|
+
PaymentRequirements,
|
|
5
|
+
SchemeNetworkClient,
|
|
6
|
+
} from '@x402/core/types';
|
|
7
|
+
import { ObbyxError, toSafeNumber } from '@obbyx/obyte';
|
|
8
|
+
import { type Logger, moduleLogger } from '@obbyx/obyte/logger';
|
|
9
|
+
import type obyte from 'obyte';
|
|
10
|
+
import type { ReservingComposer } from '../coins/index.js';
|
|
11
|
+
import { type PaymentRequirementsV1, requirementsV1ToV2 } from './facilitator-v1.js';
|
|
12
|
+
import { EXACT_SCHEME, parseExactRequirements } from './requirements.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Client side of `exact` on Obyte: composes and signs (but does NOT
|
|
16
|
+
* broadcast) a unit paying `payTo` with the seller's binding in a data
|
|
17
|
+
* message — obyte.js `client.compose.message` used directly.
|
|
18
|
+
*
|
|
19
|
+
* With a `composer` (ReservingComposer) inputs are reserved per payment and
|
|
20
|
+
* composes are serialized, so concurrent payments never pick the same coins.
|
|
21
|
+
*/
|
|
22
|
+
export interface ExactObyteClientOptions {
|
|
23
|
+
/** obyte.js client connected to a hub of `network`. */
|
|
24
|
+
readonly client: obyte.Client;
|
|
25
|
+
readonly network: string;
|
|
26
|
+
readonly privateKey: Uint8Array;
|
|
27
|
+
readonly logger: Logger;
|
|
28
|
+
/** Refuse requirements above this amount (atomic units) per asset. */
|
|
29
|
+
readonly maxAmount?: ReadonlyMap<string, bigint>;
|
|
30
|
+
/** Reserves inputs and serializes composes (strongly recommended; see coins.ts). */
|
|
31
|
+
readonly composer?: ReservingComposer;
|
|
32
|
+
/** Refuse units whose network fees (headers + payload + tps) exceed this (bytes). */
|
|
33
|
+
readonly maxFee?: bigint;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Inputs of a signed unit stay reserved at least until the offer expires plus
|
|
38
|
+
* this margin (covers the facilitator's default publication window). An Obyte
|
|
39
|
+
* unit never expires, so any release is a heuristic; releasing too early can
|
|
40
|
+
* only make a LATER payment of ours fail (one of two units spending the same
|
|
41
|
+
* input becomes final-bad), it cannot pay twice.
|
|
42
|
+
*/
|
|
43
|
+
const PUBLICATION_MARGIN_SECONDS = 900;
|
|
44
|
+
|
|
45
|
+
export class ExactObyteClient implements SchemeNetworkClient {
|
|
46
|
+
readonly scheme = EXACT_SCHEME;
|
|
47
|
+
private readonly log: Logger;
|
|
48
|
+
|
|
49
|
+
constructor(private readonly options: ExactObyteClientOptions) {
|
|
50
|
+
this.log = moduleLogger(options.logger, 'x402.exact.client');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async createPaymentPayload(
|
|
54
|
+
x402Version: number,
|
|
55
|
+
requirements: PaymentRequirements,
|
|
56
|
+
context?: PaymentPayloadContext,
|
|
57
|
+
): Promise<PaymentPayloadResult> {
|
|
58
|
+
if (x402Version !== 1 && x402Version !== 2) {
|
|
59
|
+
throw new ObbyxError('invalid_x402_version', 'Only x402 v1 and v2 are supported');
|
|
60
|
+
}
|
|
61
|
+
// v1 requirements (maxAmountRequired, plain network name) → v2 shape.
|
|
62
|
+
const v2 =
|
|
63
|
+
x402Version === 1
|
|
64
|
+
? requirementsV1ToV2(requirements as unknown as PaymentRequirementsV1)
|
|
65
|
+
: requirements;
|
|
66
|
+
const parsed = parseExactRequirements(v2);
|
|
67
|
+
if (!parsed.ok) throw new ObbyxError(parsed.reason, parsed.message);
|
|
68
|
+
const req = parsed.value;
|
|
69
|
+
if (req.network.caip2 !== this.options.network) {
|
|
70
|
+
throw new ObbyxError('invalid_network', `This client pays on ${this.options.network}`);
|
|
71
|
+
}
|
|
72
|
+
const cap = this.options.maxAmount?.get(req.asset);
|
|
73
|
+
if (cap !== undefined && req.amount > cap) {
|
|
74
|
+
throw new ObbyxError(
|
|
75
|
+
'amount_over_limit',
|
|
76
|
+
`Price ${req.amount} exceeds the limit ${cap} for ${req.asset}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
context?.maxAmountPerPayment !== undefined &&
|
|
81
|
+
req.amount > BigInt(context.maxAmountPerPayment)
|
|
82
|
+
) {
|
|
83
|
+
throw new ObbyxError('amount_over_limit', 'Price exceeds maxAmountPerPayment');
|
|
84
|
+
}
|
|
85
|
+
if (req.extra.validUntil < Math.floor(Date.now() / 1000)) {
|
|
86
|
+
throw new ObbyxError('quote_expired', 'The payment offer has expired');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const output = { address: req.payTo, amount: toSafeNumber(req.amount) };
|
|
90
|
+
const payment =
|
|
91
|
+
req.asset === 'base'
|
|
92
|
+
? { app: 'payment', payload: { outputs: [output] } }
|
|
93
|
+
: { app: 'payment', payload: { asset: req.asset, outputs: [output] } };
|
|
94
|
+
const data = {
|
|
95
|
+
app: 'data',
|
|
96
|
+
payload: { x402: { binding: req.extra.binding, quoteId: req.extra.quoteId } },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const compose = () =>
|
|
100
|
+
this.options.client.compose.message('multi', [payment, data] as never, {
|
|
101
|
+
privateKey: this.options.privateKey,
|
|
102
|
+
testnet: req.network.testnet,
|
|
103
|
+
}) as unknown as Promise<Record<string, unknown>>;
|
|
104
|
+
// The signed unit stays publishable until the offer expires plus the
|
|
105
|
+
// facilitator's publication window: keep its inputs reserved that long.
|
|
106
|
+
const holdUntil = req.extra.validUntil + PUBLICATION_MARGIN_SECONDS;
|
|
107
|
+
const unit = await (this.options.composer
|
|
108
|
+
? this.options.composer.compose(compose, { holdUntil })
|
|
109
|
+
: compose());
|
|
110
|
+
const fee =
|
|
111
|
+
BigInt(Number(unit['headers_commission'] ?? 0)) +
|
|
112
|
+
BigInt(Number(unit['payload_commission'] ?? 0)) +
|
|
113
|
+
BigInt(Number(unit['tps_fee'] ?? 0)) +
|
|
114
|
+
BigInt(Number(unit['burn_fee'] ?? 0)); // same total the facilitator checks
|
|
115
|
+
if (this.options.maxFee !== undefined && fee > this.options.maxFee) {
|
|
116
|
+
// The unit was never sent anywhere: its inputs can be released at once.
|
|
117
|
+
this.options.composer?.discard(String(unit['unit']));
|
|
118
|
+
throw new ObbyxError(
|
|
119
|
+
'fee_over_limit',
|
|
120
|
+
`Network fee ${fee} exceeds the limit ${this.options.maxFee} bytes`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
this.log.info(
|
|
124
|
+
{
|
|
125
|
+
proofId: unit['unit'],
|
|
126
|
+
payTo: req.payTo,
|
|
127
|
+
asset: req.asset,
|
|
128
|
+
amount: req.amount.toString(),
|
|
129
|
+
binding: req.extra.binding,
|
|
130
|
+
},
|
|
131
|
+
'payment unit signed (not broadcast)',
|
|
132
|
+
);
|
|
133
|
+
// v1: the mechanism returns the complete payload (core passes it through as-is).
|
|
134
|
+
if (x402Version === 1) {
|
|
135
|
+
return {
|
|
136
|
+
x402Version: 1,
|
|
137
|
+
scheme: EXACT_SCHEME,
|
|
138
|
+
network: requirements.network,
|
|
139
|
+
payload: { unit },
|
|
140
|
+
} as unknown as PaymentPayloadResult;
|
|
141
|
+
}
|
|
142
|
+
return { x402Version, payload: { unit } };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Network,
|
|
3
|
+
PaymentPayload,
|
|
4
|
+
PaymentRequirements,
|
|
5
|
+
SchemeNetworkFacilitator,
|
|
6
|
+
SettleResponse,
|
|
7
|
+
VerifyResponse,
|
|
8
|
+
} from '@x402/core/types';
|
|
9
|
+
import { getNetwork, isObyteNetwork } from '@obbyx/obyte';
|
|
10
|
+
import type { ExactObyteFacilitator } from './facilitator.js';
|
|
11
|
+
import { EXACT_SCHEME } from './requirements.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* x402 **v1** for the Obyte `exact` scheme.
|
|
15
|
+
*
|
|
16
|
+
* v1 differs in shape only: plain network names (`obyte-testnet`),
|
|
17
|
+
* `maxAmountRequired`, `resource`/`mimeType` inside the requirements, and a
|
|
18
|
+
* payload without `accepted`. This adapter converts to the v2 shapes and runs
|
|
19
|
+
* the SAME v2 mechanism with the SAME store: settlement keys use the CAIP-2 id,
|
|
20
|
+
* so a proof spent through v1 can never be spent again through v2 (and vice versa).
|
|
21
|
+
*/
|
|
22
|
+
export interface PaymentRequirementsV1 {
|
|
23
|
+
readonly scheme: string;
|
|
24
|
+
readonly network: string;
|
|
25
|
+
readonly maxAmountRequired: string;
|
|
26
|
+
readonly resource: string;
|
|
27
|
+
readonly description: string;
|
|
28
|
+
readonly mimeType: string;
|
|
29
|
+
readonly outputSchema?: Record<string, unknown>;
|
|
30
|
+
readonly payTo: string;
|
|
31
|
+
readonly maxTimeoutSeconds: number;
|
|
32
|
+
readonly asset: string;
|
|
33
|
+
readonly extra: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PaymentPayloadV1 {
|
|
37
|
+
readonly x402Version: 1;
|
|
38
|
+
readonly scheme: string;
|
|
39
|
+
readonly network: string;
|
|
40
|
+
readonly payload: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function requirementsV1ToV2(req: PaymentRequirementsV1): PaymentRequirements {
|
|
44
|
+
return {
|
|
45
|
+
scheme: req.scheme,
|
|
46
|
+
network: getNetwork(req.network).caip2,
|
|
47
|
+
asset: req.asset,
|
|
48
|
+
amount: req.maxAmountRequired,
|
|
49
|
+
payTo: req.payTo,
|
|
50
|
+
maxTimeoutSeconds: req.maxTimeoutSeconds,
|
|
51
|
+
extra: req.extra ?? {},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function requirementsV2ToV1(
|
|
56
|
+
req: PaymentRequirements,
|
|
57
|
+
resource: { url: string; description?: string; mimeType?: string },
|
|
58
|
+
): PaymentRequirementsV1 {
|
|
59
|
+
return {
|
|
60
|
+
scheme: req.scheme,
|
|
61
|
+
network: getNetwork(req.network).v1Name,
|
|
62
|
+
maxAmountRequired: req.amount,
|
|
63
|
+
resource: resource.url,
|
|
64
|
+
description: resource.description ?? '',
|
|
65
|
+
mimeType: resource.mimeType ?? '',
|
|
66
|
+
outputSchema: {},
|
|
67
|
+
payTo: req.payTo,
|
|
68
|
+
maxTimeoutSeconds: req.maxTimeoutSeconds,
|
|
69
|
+
asset: req.asset,
|
|
70
|
+
extra: req.extra,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class ExactObyteFacilitatorV1 implements SchemeNetworkFacilitator {
|
|
75
|
+
readonly scheme = EXACT_SCHEME;
|
|
76
|
+
readonly caipFamily = 'obyte-*';
|
|
77
|
+
|
|
78
|
+
constructor(private readonly v2: ExactObyteFacilitator) {}
|
|
79
|
+
|
|
80
|
+
getExtra(network: Network): Record<string, unknown> | undefined {
|
|
81
|
+
return this.v2.getExtra(getNetwork(network).caip2);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getSigners(): string[] {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async verify(
|
|
89
|
+
payload: PaymentPayload,
|
|
90
|
+
requirements: PaymentRequirements,
|
|
91
|
+
): Promise<VerifyResponse> {
|
|
92
|
+
const converted = this.convert(payload, requirements);
|
|
93
|
+
if (!converted.ok) return { isValid: false, invalidReason: converted.reason };
|
|
94
|
+
return this.v2.verify(converted.payload, converted.requirements);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async settle(
|
|
98
|
+
payload: PaymentPayload,
|
|
99
|
+
requirements: PaymentRequirements,
|
|
100
|
+
): Promise<SettleResponse> {
|
|
101
|
+
const converted = this.convert(payload, requirements);
|
|
102
|
+
const v1Network = (requirements as unknown as PaymentRequirementsV1).network as Network;
|
|
103
|
+
if (!converted.ok)
|
|
104
|
+
return { success: false, errorReason: converted.reason, transaction: '', network: v1Network };
|
|
105
|
+
const res = await this.v2.settle(converted.payload, converted.requirements);
|
|
106
|
+
return { ...res, network: v1Network };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private convert(
|
|
110
|
+
payload: PaymentPayload,
|
|
111
|
+
requirements: PaymentRequirements,
|
|
112
|
+
):
|
|
113
|
+
| { ok: true; payload: PaymentPayload; requirements: PaymentRequirements }
|
|
114
|
+
| { ok: false; reason: string } {
|
|
115
|
+
const p = payload as unknown as PaymentPayloadV1;
|
|
116
|
+
const r = requirements as unknown as PaymentRequirementsV1;
|
|
117
|
+
if (p.x402Version !== 1) return { ok: false, reason: 'invalid_x402_version' };
|
|
118
|
+
if (p.scheme !== EXACT_SCHEME || r.scheme !== EXACT_SCHEME)
|
|
119
|
+
return { ok: false, reason: 'unsupported_scheme' };
|
|
120
|
+
if (!isObyteNetwork(r.network) || p.network !== r.network)
|
|
121
|
+
return { ok: false, reason: 'invalid_network' };
|
|
122
|
+
if (typeof r.maxAmountRequired !== 'string')
|
|
123
|
+
return { ok: false, reason: 'invalid_payment_requirements' };
|
|
124
|
+
const v2req = requirementsV1ToV2(r);
|
|
125
|
+
// v1 has no `accepted`: the client paid the requirements the server sent.
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
requirements: v2req,
|
|
129
|
+
payload: {
|
|
130
|
+
x402Version: 2,
|
|
131
|
+
resource: { url: r.resource, description: r.description, mimeType: r.mimeType },
|
|
132
|
+
accepted: v2req,
|
|
133
|
+
payload: p.payload,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|