@obbyx/x402-obyte 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 +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 +222 -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 +350 -0
- package/dist/batch/facilitator.js.map +1 -0
- package/dist/batch/gateway.d.ts +19 -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 +104 -0
- package/dist/batch/store.d.ts.map +1 -0
- package/dist/batch/store.js +192 -0
- package/dist/batch/store.js.map +1 -0
- package/dist/batch/watcher-protocol.d.ts +89 -0
- package/dist/batch/watcher-protocol.d.ts.map +1 -0
- package/dist/batch/watcher-protocol.js +80 -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 +77 -0
- package/dist/exact/facilitator.d.ts.map +1 -0
- package/dist/exact/facilitator.js +331 -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 +133 -0
- package/dist/exact/store.d.ts.map +1 -0
- package/dist/exact/store.js +212 -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 +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -3
- package/src/batch/client-store.ts +167 -0
- package/src/batch/client.ts +314 -0
- package/src/batch/facilitator.ts +469 -0
- package/src/batch/gateway.ts +20 -0
- package/src/batch/requirements.ts +108 -0
- package/src/batch/store.ts +343 -0
- package/src/batch/watcher-protocol.ts +87 -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 +451 -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 +391 -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 +110 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { PaymentPayload, PaymentRequirements } from '@x402/core/types';
|
|
2
|
+
import {
|
|
3
|
+
type ObyteNetwork,
|
|
4
|
+
type Verdict,
|
|
5
|
+
accept,
|
|
6
|
+
getNetwork,
|
|
7
|
+
isAddress,
|
|
8
|
+
isAssetId,
|
|
9
|
+
isObyteNetwork,
|
|
10
|
+
parseAmount,
|
|
11
|
+
reject,
|
|
12
|
+
} from '@obbyx/obyte';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
|
|
15
|
+
export const EXACT_SCHEME = 'exact';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Scheme-specific `extra` of x402 PaymentRequirements for `exact` on Obyte.
|
|
19
|
+
*
|
|
20
|
+
* `binding` is computed by the seller per request (an HMAC over the quote and
|
|
21
|
+
* the request fingerprint, see `@obbyx/sdk`) and must be signed by the client
|
|
22
|
+
* inside the unit's data message. `quoteId`/`validUntil` bound the offer.
|
|
23
|
+
*/
|
|
24
|
+
export const exactExtraSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
binding: z.string().regex(/^[0-9a-f]{64}$/, 'binding must be 64 lowercase hex chars'),
|
|
27
|
+
quoteId: z
|
|
28
|
+
.string()
|
|
29
|
+
.min(8)
|
|
30
|
+
.max(64)
|
|
31
|
+
.regex(/^[A-Za-z0-9_-]+$/),
|
|
32
|
+
validUntil: z.number().int().positive(),
|
|
33
|
+
// x402 §6.1: Obyte supports the `upfront` flow (settle, then serve) and the
|
|
34
|
+
// default `authorization` flow; `escrow` and any asset transfer method
|
|
35
|
+
// (there is none on Obyte: the payer signs the transfer itself) are refused.
|
|
36
|
+
paymentFlow: z.enum(['authorization', 'upfront']).optional(),
|
|
37
|
+
})
|
|
38
|
+
.strict();
|
|
39
|
+
|
|
40
|
+
export type ExactExtra = z.infer<typeof exactExtraSchema>;
|
|
41
|
+
|
|
42
|
+
export interface ParsedExactRequirements {
|
|
43
|
+
readonly network: ObyteNetwork;
|
|
44
|
+
readonly asset: string;
|
|
45
|
+
readonly amount: bigint;
|
|
46
|
+
readonly payTo: string;
|
|
47
|
+
readonly maxTimeoutSeconds: number;
|
|
48
|
+
readonly extra: ExactExtra;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseExactRequirements(req: PaymentRequirements): Verdict<ParsedExactRequirements> {
|
|
52
|
+
if (req.scheme !== EXACT_SCHEME) return reject('unsupported_scheme', 'Scheme is not exact');
|
|
53
|
+
if (!isObyteNetwork(req.network)) return reject('invalid_network', 'Not an Obyte network');
|
|
54
|
+
if (!isAssetId(req.asset)) return reject('invalid_payment_requirements', 'Invalid asset');
|
|
55
|
+
if (!isAddress(req.payTo)) return reject('invalid_payment_requirements', 'Invalid payTo');
|
|
56
|
+
if (!Number.isSafeInteger(req.maxTimeoutSeconds) || req.maxTimeoutSeconds <= 0) {
|
|
57
|
+
return reject('invalid_payment_requirements', 'Invalid maxTimeoutSeconds');
|
|
58
|
+
}
|
|
59
|
+
let amount: bigint;
|
|
60
|
+
try {
|
|
61
|
+
amount = parseAmount(req.amount);
|
|
62
|
+
} catch {
|
|
63
|
+
return reject('invalid_payment_requirements', 'Invalid amount');
|
|
64
|
+
}
|
|
65
|
+
if (amount <= 0n) return reject('invalid_payment_requirements', 'Amount must be positive');
|
|
66
|
+
const extra = exactExtraSchema.safeParse(req.extra);
|
|
67
|
+
if (!extra.success) {
|
|
68
|
+
return reject(
|
|
69
|
+
'invalid_payment_requirements',
|
|
70
|
+
`Invalid extra: ${extra.error.issues[0]?.message}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return accept({
|
|
74
|
+
network: getNetwork(req.network),
|
|
75
|
+
asset: req.asset,
|
|
76
|
+
amount,
|
|
77
|
+
payTo: req.payTo,
|
|
78
|
+
maxTimeoutSeconds: req.maxTimeoutSeconds,
|
|
79
|
+
extra: extra.data,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* x402 core does not compare the client's `accepted` requirements with the
|
|
85
|
+
* server's; a mismatch could let a client pay under different terms.
|
|
86
|
+
*/
|
|
87
|
+
export function acceptedMatches(payload: PaymentPayload, req: PaymentRequirements): boolean {
|
|
88
|
+
const a = payload.accepted;
|
|
89
|
+
return (
|
|
90
|
+
a !== undefined &&
|
|
91
|
+
a.scheme === req.scheme &&
|
|
92
|
+
a.network === req.network &&
|
|
93
|
+
a.asset === req.asset &&
|
|
94
|
+
a.amount === req.amount &&
|
|
95
|
+
a.payTo === req.payTo &&
|
|
96
|
+
a.maxTimeoutSeconds === req.maxTimeoutSeconds &&
|
|
97
|
+
canonical(a.extra) === canonical(req.extra)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function canonical(value: unknown): string {
|
|
102
|
+
return JSON.stringify(value, (_k, v: unknown) =>
|
|
103
|
+
v && typeof v === 'object' && !Array.isArray(v)
|
|
104
|
+
? Object.fromEntries(
|
|
105
|
+
Object.entries(v as Record<string, unknown>).sort(([x], [y]) => x.localeCompare(y)),
|
|
106
|
+
)
|
|
107
|
+
: v,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { ObbyxError } from '@obbyx/obyte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Settlement state machine storage for the `exact` scheme (ADR-0002, ADR-0005).
|
|
6
|
+
*
|
|
7
|
+
* claimed → broadcasting → accepted → stable_good
|
|
8
|
+
* │ └────→ final_bad
|
|
9
|
+
* ├──→ rejected
|
|
10
|
+
* └──→ expired
|
|
11
|
+
*
|
|
12
|
+
* - One row per (network, unit). The row is created (claimed) BEFORE the unit
|
|
13
|
+
* is broadcast (outbox), so a crash between broadcast and the next write is
|
|
14
|
+
* recovered by re-reading non-terminal rows on startup.
|
|
15
|
+
* - Transitions are compare-and-set on the current state inside one
|
|
16
|
+
* SQLite transaction; a lost race returns `false` instead of overwriting.
|
|
17
|
+
* - One facilitator process per database file: the connection takes an
|
|
18
|
+
* exclusive lock at open, so a second process fails fast.
|
|
19
|
+
*/
|
|
20
|
+
export type SettlementState =
|
|
21
|
+
'claimed' | 'broadcasting' | 'accepted' | 'stable_good' | 'final_bad' | 'rejected' | 'expired';
|
|
22
|
+
|
|
23
|
+
export const TERMINAL_STATES: ReadonlySet<SettlementState> = new Set([
|
|
24
|
+
'stable_good',
|
|
25
|
+
'final_bad',
|
|
26
|
+
'rejected',
|
|
27
|
+
'expired',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export interface SettlementRecord {
|
|
31
|
+
readonly key: string;
|
|
32
|
+
readonly network: string;
|
|
33
|
+
readonly unitHash: string;
|
|
34
|
+
readonly binding: string;
|
|
35
|
+
readonly payer: string;
|
|
36
|
+
readonly payTo: string;
|
|
37
|
+
readonly asset: string;
|
|
38
|
+
readonly amount: string;
|
|
39
|
+
readonly state: SettlementState;
|
|
40
|
+
/** Signed unit JSON; deleted (null) after a terminal state and retention. */
|
|
41
|
+
readonly unitJson: string | null;
|
|
42
|
+
/** After this time (unix s) an unpublished unit is never broadcast. */
|
|
43
|
+
readonly publishDeadline: number;
|
|
44
|
+
readonly attempts: number;
|
|
45
|
+
readonly lastError: string | null;
|
|
46
|
+
readonly createdAt: number;
|
|
47
|
+
readonly updatedAt: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NewSettlement {
|
|
51
|
+
readonly key: string;
|
|
52
|
+
readonly network: string;
|
|
53
|
+
readonly unitHash: string;
|
|
54
|
+
readonly binding: string;
|
|
55
|
+
readonly payer: string;
|
|
56
|
+
readonly payTo: string;
|
|
57
|
+
readonly asset: string;
|
|
58
|
+
readonly amount: string;
|
|
59
|
+
readonly unitJson: string;
|
|
60
|
+
readonly publishDeadline: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SettlementEvent {
|
|
64
|
+
readonly key: string;
|
|
65
|
+
readonly kind: string;
|
|
66
|
+
readonly from?: SettlementState | null;
|
|
67
|
+
readonly to?: SettlementState | null;
|
|
68
|
+
readonly data?: Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Port for the `exact` settlement state machine (implemented with node:sqlite
|
|
73
|
+
* here, and with Prisma in apps/facilitator). All implementations must pass
|
|
74
|
+
* the shared contract tests: unique claim, compare-and-set transitions,
|
|
75
|
+
* single process per database.
|
|
76
|
+
*/
|
|
77
|
+
export interface SettlementStore {
|
|
78
|
+
get(key: string): Promise<SettlementRecord | undefined>;
|
|
79
|
+
claim(input: NewSettlement): Promise<{ created: boolean; record: SettlementRecord }>;
|
|
80
|
+
transition(
|
|
81
|
+
key: string,
|
|
82
|
+
from: readonly SettlementState[],
|
|
83
|
+
to: SettlementState,
|
|
84
|
+
patch?: TransitionPatch,
|
|
85
|
+
): Promise<SettlementRecord | undefined>;
|
|
86
|
+
event(event: SettlementEvent): Promise<void>;
|
|
87
|
+
listActive(limit?: number): Promise<SettlementRecord[]>;
|
|
88
|
+
events(
|
|
89
|
+
key: string,
|
|
90
|
+
): Promise<{ kind: string; from: string | null; to: string | null; data: unknown }[]>;
|
|
91
|
+
pruneUnitBodies(olderThan: number): Promise<number>;
|
|
92
|
+
close(): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface TransitionPatch {
|
|
96
|
+
readonly lastError?: string | null;
|
|
97
|
+
readonly incrementAttempts?: boolean;
|
|
98
|
+
readonly data?: Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class StoreLockedError extends ObbyxError {
|
|
102
|
+
constructor(path: string, cause: unknown) {
|
|
103
|
+
super(
|
|
104
|
+
'store_locked',
|
|
105
|
+
`Database ${path} is in use by another facilitator process (one instance per database file)`,
|
|
106
|
+
{ cause, details: { path } },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface Row {
|
|
112
|
+
key: string;
|
|
113
|
+
network: string;
|
|
114
|
+
unit_hash: string;
|
|
115
|
+
binding: string;
|
|
116
|
+
payer: string;
|
|
117
|
+
pay_to: string;
|
|
118
|
+
asset: string;
|
|
119
|
+
amount: string;
|
|
120
|
+
state: SettlementState;
|
|
121
|
+
unit_json: string | null;
|
|
122
|
+
publish_deadline: number;
|
|
123
|
+
attempts: number;
|
|
124
|
+
last_error: string | null;
|
|
125
|
+
created_at: number;
|
|
126
|
+
updated_at: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const SCHEMA = `
|
|
130
|
+
CREATE TABLE IF NOT EXISTS exact_settlements (
|
|
131
|
+
key TEXT PRIMARY KEY,
|
|
132
|
+
network TEXT NOT NULL,
|
|
133
|
+
unit_hash TEXT NOT NULL,
|
|
134
|
+
binding TEXT NOT NULL,
|
|
135
|
+
payer TEXT NOT NULL,
|
|
136
|
+
pay_to TEXT NOT NULL,
|
|
137
|
+
asset TEXT NOT NULL,
|
|
138
|
+
amount TEXT NOT NULL,
|
|
139
|
+
state TEXT NOT NULL,
|
|
140
|
+
unit_json TEXT,
|
|
141
|
+
publish_deadline INTEGER NOT NULL,
|
|
142
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
143
|
+
last_error TEXT,
|
|
144
|
+
created_at INTEGER NOT NULL,
|
|
145
|
+
updated_at INTEGER NOT NULL
|
|
146
|
+
) STRICT;
|
|
147
|
+
CREATE INDEX IF NOT EXISTS exact_settlements_active ON exact_settlements(state)
|
|
148
|
+
WHERE state IN ('claimed', 'broadcasting', 'accepted');
|
|
149
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
150
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
151
|
+
ts INTEGER NOT NULL,
|
|
152
|
+
key TEXT NOT NULL,
|
|
153
|
+
kind TEXT NOT NULL,
|
|
154
|
+
from_state TEXT,
|
|
155
|
+
to_state TEXT,
|
|
156
|
+
data TEXT
|
|
157
|
+
) STRICT;
|
|
158
|
+
CREATE INDEX IF NOT EXISTS events_key ON events(key);
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
export class SqliteSettlementStore implements SettlementStore {
|
|
162
|
+
private readonly db: DatabaseSync;
|
|
163
|
+
private readonly now: () => number;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param path SQLite file, or `:memory:` for tests.
|
|
167
|
+
* @param now Clock in unix seconds (injectable for tests).
|
|
168
|
+
*/
|
|
169
|
+
constructor(path: string, now: () => number = () => Math.floor(Date.now() / 1000)) {
|
|
170
|
+
this.now = now;
|
|
171
|
+
this.db = new DatabaseSync(path);
|
|
172
|
+
try {
|
|
173
|
+
this.db.exec('PRAGMA busy_timeout = 0');
|
|
174
|
+
this.db.exec('PRAGMA journal_mode = WAL');
|
|
175
|
+
this.db.exec('PRAGMA synchronous = FULL');
|
|
176
|
+
// Exclusive lock for the lifetime of the connection: a second process
|
|
177
|
+
// fails on its first write instead of racing us.
|
|
178
|
+
this.db.exec('PRAGMA locking_mode = EXCLUSIVE');
|
|
179
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
180
|
+
this.db.exec(SCHEMA);
|
|
181
|
+
this.db.exec('COMMIT');
|
|
182
|
+
} catch (err) {
|
|
183
|
+
this.db.close();
|
|
184
|
+
if (isBusy(err)) throw new StoreLockedError(path, err);
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async close(): Promise<void> {
|
|
190
|
+
this.db.close();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The exclusive connection, shared with other facilitator stores (e.g. BatchStore). */
|
|
194
|
+
get database(): DatabaseSync {
|
|
195
|
+
return this.db;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async get(key: string): Promise<SettlementRecord | undefined> {
|
|
199
|
+
return this.getSync(key);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private getSync(key: string): SettlementRecord | undefined {
|
|
203
|
+
const row = this.db.prepare('SELECT * FROM exact_settlements WHERE key = ?').get(key) as
|
|
204
|
+
Row | undefined;
|
|
205
|
+
return row === undefined ? undefined : toRecord(row);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Inserts a new settlement in state `claimed` unless the key exists.
|
|
210
|
+
* Returns the stored record and whether this call created it.
|
|
211
|
+
*/
|
|
212
|
+
async claim(input: NewSettlement): Promise<{ created: boolean; record: SettlementRecord }> {
|
|
213
|
+
return this.tx(() => {
|
|
214
|
+
const existing = this.getSync(input.key);
|
|
215
|
+
if (existing !== undefined) return { created: false, record: existing };
|
|
216
|
+
const ts = this.now();
|
|
217
|
+
this.db
|
|
218
|
+
.prepare(
|
|
219
|
+
`INSERT INTO exact_settlements
|
|
220
|
+
(key, network, unit_hash, binding, payer, pay_to, asset, amount, state, unit_json,
|
|
221
|
+
publish_deadline, attempts, created_at, updated_at)
|
|
222
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'claimed', ?, ?, 0, ?, ?)`,
|
|
223
|
+
)
|
|
224
|
+
.run(
|
|
225
|
+
input.key,
|
|
226
|
+
input.network,
|
|
227
|
+
input.unitHash,
|
|
228
|
+
input.binding,
|
|
229
|
+
input.payer,
|
|
230
|
+
input.payTo,
|
|
231
|
+
input.asset,
|
|
232
|
+
input.amount,
|
|
233
|
+
input.unitJson,
|
|
234
|
+
input.publishDeadline,
|
|
235
|
+
ts,
|
|
236
|
+
ts,
|
|
237
|
+
);
|
|
238
|
+
this.insertEvent({ key: input.key, kind: 'claimed', from: null, to: 'claimed' }, ts);
|
|
239
|
+
return { created: true, record: this.mustGet(input.key) };
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Compare-and-set transition. Returns the new record, or `undefined` if the
|
|
245
|
+
* current state is not one of `from` (someone else moved it first).
|
|
246
|
+
*/
|
|
247
|
+
async transition(
|
|
248
|
+
key: string,
|
|
249
|
+
from: readonly SettlementState[],
|
|
250
|
+
to: SettlementState,
|
|
251
|
+
patch: TransitionPatch = {},
|
|
252
|
+
): Promise<SettlementRecord | undefined> {
|
|
253
|
+
return this.tx(() => {
|
|
254
|
+
const current = this.getSync(key);
|
|
255
|
+
if (current === undefined || !from.includes(current.state)) return undefined;
|
|
256
|
+
const ts = this.now();
|
|
257
|
+
this.db
|
|
258
|
+
.prepare(
|
|
259
|
+
`UPDATE exact_settlements
|
|
260
|
+
SET state = ?, last_error = ?, attempts = attempts + ?, updated_at = ?
|
|
261
|
+
WHERE key = ? AND state = ?`,
|
|
262
|
+
)
|
|
263
|
+
.run(
|
|
264
|
+
to,
|
|
265
|
+
patch.lastError === undefined ? current.lastError : patch.lastError,
|
|
266
|
+
patch.incrementAttempts === true ? 1 : 0,
|
|
267
|
+
ts,
|
|
268
|
+
key,
|
|
269
|
+
current.state,
|
|
270
|
+
);
|
|
271
|
+
this.insertEvent(
|
|
272
|
+
{
|
|
273
|
+
key,
|
|
274
|
+
kind: 'transition',
|
|
275
|
+
from: current.state,
|
|
276
|
+
to,
|
|
277
|
+
...(patch.data ? { data: patch.data } : {}),
|
|
278
|
+
},
|
|
279
|
+
ts,
|
|
280
|
+
);
|
|
281
|
+
return this.mustGet(key);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Records a non-transition event (e.g. a retry) in the payment journal. */
|
|
286
|
+
async event(event: SettlementEvent): Promise<void> {
|
|
287
|
+
this.insertEvent(event, this.now());
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Non-terminal settlements, oldest first — resumed by the recovery worker. */
|
|
291
|
+
async listActive(limit = 100): Promise<SettlementRecord[]> {
|
|
292
|
+
const rows = this.db
|
|
293
|
+
.prepare(
|
|
294
|
+
`SELECT * FROM exact_settlements
|
|
295
|
+
WHERE state IN ('claimed', 'broadcasting', 'accepted')
|
|
296
|
+
ORDER BY updated_at ASC LIMIT ?`,
|
|
297
|
+
)
|
|
298
|
+
.all(limit) as unknown as Row[];
|
|
299
|
+
return rows.map(toRecord);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async events(
|
|
303
|
+
key: string,
|
|
304
|
+
): Promise<{ kind: string; from: string | null; to: string | null; data: unknown }[]> {
|
|
305
|
+
const rows = this.db
|
|
306
|
+
.prepare('SELECT kind, from_state, to_state, data FROM events WHERE key = ? ORDER BY id')
|
|
307
|
+
.all(key) as unknown as {
|
|
308
|
+
kind: string;
|
|
309
|
+
from_state: string | null;
|
|
310
|
+
to_state: string | null;
|
|
311
|
+
data: string | null;
|
|
312
|
+
}[];
|
|
313
|
+
return rows.map((r) => ({
|
|
314
|
+
kind: r.kind,
|
|
315
|
+
from: r.from_state,
|
|
316
|
+
to: r.to_state,
|
|
317
|
+
data: r.data === null ? null : (JSON.parse(r.data) as unknown),
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Drops signed unit bodies of terminal settlements older than `olderThan` (unix s). */
|
|
322
|
+
async pruneUnitBodies(olderThan: number): Promise<number> {
|
|
323
|
+
const res = this.db
|
|
324
|
+
.prepare(
|
|
325
|
+
`UPDATE exact_settlements SET unit_json = NULL
|
|
326
|
+
WHERE unit_json IS NOT NULL AND updated_at < ?
|
|
327
|
+
AND state IN ('stable_good', 'final_bad', 'rejected', 'expired')`,
|
|
328
|
+
)
|
|
329
|
+
.run(olderThan);
|
|
330
|
+
return Number(res.changes);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private insertEvent(event: SettlementEvent, ts: number): void {
|
|
334
|
+
this.db
|
|
335
|
+
.prepare(
|
|
336
|
+
'INSERT INTO events (ts, key, kind, from_state, to_state, data) VALUES (?, ?, ?, ?, ?, ?)',
|
|
337
|
+
)
|
|
338
|
+
.run(
|
|
339
|
+
ts,
|
|
340
|
+
event.key,
|
|
341
|
+
event.kind,
|
|
342
|
+
event.from ?? null,
|
|
343
|
+
event.to ?? null,
|
|
344
|
+
event.data === undefined ? null : JSON.stringify(event.data),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private mustGet(key: string): SettlementRecord {
|
|
349
|
+
const record = this.getSync(key);
|
|
350
|
+
if (record === undefined)
|
|
351
|
+
throw new ObbyxError('store_inconsistent', `Missing settlement ${key}`);
|
|
352
|
+
return record;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private tx<T>(fn: () => T): T {
|
|
356
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
357
|
+
try {
|
|
358
|
+
const result = fn();
|
|
359
|
+
this.db.exec('COMMIT');
|
|
360
|
+
return result;
|
|
361
|
+
} catch (err) {
|
|
362
|
+
this.db.exec('ROLLBACK');
|
|
363
|
+
throw err;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function toRecord(row: Row): SettlementRecord {
|
|
369
|
+
return {
|
|
370
|
+
key: row.key,
|
|
371
|
+
network: row.network,
|
|
372
|
+
unitHash: row.unit_hash,
|
|
373
|
+
binding: row.binding,
|
|
374
|
+
payer: row.payer,
|
|
375
|
+
payTo: row.pay_to,
|
|
376
|
+
asset: row.asset,
|
|
377
|
+
amount: row.amount,
|
|
378
|
+
state: row.state,
|
|
379
|
+
unitJson: row.unit_json,
|
|
380
|
+
publishDeadline: row.publish_deadline,
|
|
381
|
+
attempts: row.attempts,
|
|
382
|
+
lastError: row.last_error,
|
|
383
|
+
createdAt: row.created_at,
|
|
384
|
+
updatedAt: row.updated_at,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function isBusy(err: unknown): boolean {
|
|
389
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
390
|
+
return /database is locked|SQLITE_BUSY/i.test(message);
|
|
391
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type obyte from 'obyte';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keeps an obyte.js hub connection alive. obyte.js does not schedule
|
|
5
|
+
* heartbeats itself: the application must call `client.api.heartbeat()`
|
|
6
|
+
* periodically (its WSClient then skips the call if traffic was recent,
|
|
7
|
+
* and closes a connection whose heartbeat goes unanswered, letting
|
|
8
|
+
* `reconnect: true` re-establish it). Hubs drop idle peers without this.
|
|
9
|
+
*
|
|
10
|
+
* Returns a function that stops the timer.
|
|
11
|
+
*/
|
|
12
|
+
export function startHeartbeat(client: obyte.Client, intervalMs = 10_000): () => void {
|
|
13
|
+
const timer = setInterval(() => {
|
|
14
|
+
client.api.heartbeat().catch(() => undefined); // closed/reconnecting: the next tick retries
|
|
15
|
+
}, intervalMs);
|
|
16
|
+
timer.unref();
|
|
17
|
+
return () => clearInterval(timer);
|
|
18
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome of one obyte.js hub request (ADR-0006, invariant I7).
|
|
3
|
+
*
|
|
4
|
+
* obyte.js requests have no timeout. It rejects with the hub's error value
|
|
5
|
+
* (usually a string) for definite hub errors, and with an `Error` for transport
|
|
6
|
+
* failures. A timeout or transport failure is an UNKNOWN outcome — for
|
|
7
|
+
* `postJoint` the unit may have been accepted.
|
|
8
|
+
*/
|
|
9
|
+
export type HubOutcome<T> =
|
|
10
|
+
| { readonly kind: 'ok'; readonly value: T }
|
|
11
|
+
| { readonly kind: 'hub_error'; readonly error: string }
|
|
12
|
+
| { readonly kind: 'unknown'; readonly error: string };
|
|
13
|
+
|
|
14
|
+
export async function hubCall<T>(
|
|
15
|
+
request: () => Promise<T>,
|
|
16
|
+
timeoutMs: number,
|
|
17
|
+
): Promise<HubOutcome<T>> {
|
|
18
|
+
let timer: NodeJS.Timeout | undefined;
|
|
19
|
+
try {
|
|
20
|
+
const value = await Promise.race([
|
|
21
|
+
request(),
|
|
22
|
+
new Promise<never>((_, rejectTimeout) => {
|
|
23
|
+
timer = setTimeout(
|
|
24
|
+
() => rejectTimeout(new Error(`hub request timed out after ${timeoutMs} ms`)),
|
|
25
|
+
timeoutMs,
|
|
26
|
+
);
|
|
27
|
+
}),
|
|
28
|
+
]);
|
|
29
|
+
return { kind: 'ok', value };
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err instanceof Error || err === null || err === undefined) {
|
|
32
|
+
return { kind: 'unknown', error: err instanceof Error ? err.message : 'no error value' };
|
|
33
|
+
}
|
|
34
|
+
return { kind: 'hub_error', error: typeof err === 'string' ? err : safeJson(err) };
|
|
35
|
+
} finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function safeJson(value: unknown): string {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
} catch {
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { HistoryJoint } from '../coins/index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shapes of the hub (light vendor) answers we rely on. The hub is trusted for
|
|
6
|
+
* ledger reads (ADR-0001), but a malformed or unexpected answer must fail
|
|
7
|
+
* closed ("ledger unavailable"), never crash or be half-read.
|
|
8
|
+
*/
|
|
9
|
+
const safeAmount = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
10
|
+
|
|
11
|
+
export const paymentPayloadSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
asset: z.string().optional(),
|
|
14
|
+
inputs: z
|
|
15
|
+
.array(
|
|
16
|
+
z
|
|
17
|
+
.object({
|
|
18
|
+
unit: z.string().optional(),
|
|
19
|
+
message_index: z.number().int().optional(),
|
|
20
|
+
output_index: z.number().int().optional(),
|
|
21
|
+
})
|
|
22
|
+
.loose(),
|
|
23
|
+
)
|
|
24
|
+
.optional(),
|
|
25
|
+
outputs: z.array(z.object({ address: z.string(), amount: safeAmount }).loose()).optional(),
|
|
26
|
+
})
|
|
27
|
+
.loose();
|
|
28
|
+
|
|
29
|
+
/** A message; payment payloads are validated (positions of messages/outputs are kept). */
|
|
30
|
+
const messageSchema = z
|
|
31
|
+
.object({ app: z.string(), payload: z.unknown().optional() })
|
|
32
|
+
.loose()
|
|
33
|
+
.superRefine((m, ctx) => {
|
|
34
|
+
if (m.app === 'payment' && !paymentPayloadSchema.safeParse(m.payload).success) {
|
|
35
|
+
ctx.addIssue({ code: 'custom', message: 'malformed payment payload' });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const ledgerUnitSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
unit: z.string(),
|
|
42
|
+
timestamp: z.number().optional(),
|
|
43
|
+
main_chain_index: z.number().nullable().optional(),
|
|
44
|
+
authors: z.array(z.object({ address: z.string() }).loose()).optional(),
|
|
45
|
+
messages: z.array(messageSchema).default([]),
|
|
46
|
+
})
|
|
47
|
+
.loose();
|
|
48
|
+
|
|
49
|
+
export type LedgerUnit = HistoryJoint['unit'];
|
|
50
|
+
|
|
51
|
+
const historySchema = z
|
|
52
|
+
.object({
|
|
53
|
+
joints: z
|
|
54
|
+
.array(z.object({ unit: ledgerUnitSchema, ball: z.string().optional() }).loose())
|
|
55
|
+
.default([]),
|
|
56
|
+
proofchain_balls: z
|
|
57
|
+
.array(z.object({ unit: z.string(), is_nonserial: z.boolean().optional() }).loose())
|
|
58
|
+
.default([]),
|
|
59
|
+
})
|
|
60
|
+
.loose();
|
|
61
|
+
|
|
62
|
+
/** A unit from `get_joint` (undefined when malformed). */
|
|
63
|
+
export function parseLedgerUnit(raw: unknown): LedgerUnit | undefined {
|
|
64
|
+
const parsed = ledgerUnitSchema.safeParse(raw);
|
|
65
|
+
return parsed.success ? (parsed.data as unknown as LedgerUnit) : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ProofchainBall {
|
|
69
|
+
readonly unit: string;
|
|
70
|
+
readonly is_nonserial?: boolean | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `light/get_history` (undefined when malformed). */
|
|
74
|
+
export function parseHistory(
|
|
75
|
+
raw: unknown,
|
|
76
|
+
): { joints: HistoryJoint[]; proofchainBalls: ProofchainBall[] } | undefined {
|
|
77
|
+
const parsed = historySchema.safeParse(raw);
|
|
78
|
+
if (!parsed.success) return undefined;
|
|
79
|
+
return {
|
|
80
|
+
joints: parsed.data.joints as unknown as HistoryJoint[],
|
|
81
|
+
proofchainBalls: parsed.data.proofchain_balls,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `light/get_aa_state_vars`: a plain record (undefined when malformed). */
|
|
86
|
+
export function parseStateVars(raw: unknown): Record<string, unknown> | undefined {
|
|
87
|
+
const parsed = z.record(z.string(), z.unknown()).safeParse(raw);
|
|
88
|
+
return parsed.success ? parsed.data : undefined;
|
|
89
|
+
}
|