@dvmkit/sdk 0.0.0 → 0.1.0-rc.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/NOTICE +2 -0
- package/README.md +38 -2
- package/dist/chunk-27V2ILSR.js +291 -0
- package/dist/chunk-5GFED3GJ.js +955 -0
- package/dist/chunk-6JZIX5WW.js +1155 -0
- package/dist/chunk-7IH5SG2A.js +1038 -0
- package/dist/chunk-AT6V3SY7.js +102 -0
- package/dist/chunk-DCNT4PJS.js +733 -0
- package/dist/chunk-DMNLFNTW.js +135 -0
- package/dist/chunk-FROTD5XQ.js +70 -0
- package/dist/chunk-H25M54MI.js +149 -0
- package/dist/chunk-KQAJVVZT.js +712 -0
- package/dist/chunk-KXWROQGK.js +74 -0
- package/dist/chunk-L4OYF4DQ.js +67 -0
- package/dist/chunk-NTK5DJ6R.js +1256 -0
- package/dist/chunk-RPXHKMYE.js +3808 -0
- package/dist/chunk-S3XAHZQY.js +63 -0
- package/dist/chunk-YG7G4DPZ.js +25 -0
- package/dist/credit-ledger-EDMEZSA2.js +28 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.js +303 -0
- package/dist/job-store-C5n6bhap.d.ts +5090 -0
- package/dist/memory-credit-ledger-7TTZDSRS.js +9 -0
- package/dist/mpp-secret-state-WNAQQ6K4.js +127 -0
- package/dist/mpp-setup-MOBWGTWJ.js +30 -0
- package/dist/postgres-consumed-credential-store-VHBT4KEA.js +72 -0
- package/dist/postgres-job-store-J5F4GUWU.js +7 -0
- package/dist/postgres-kv-store-JFBDP5IP.js +7 -0
- package/dist/postgres-replay-store-UJXRT6VO.js +7 -0
- package/dist/pricing-4CEB34RM.js +48 -0
- package/dist/processed-payment-store-HAA4SFNK.js +11 -0
- package/dist/revenue-reporter-M35KP6V7.js +435 -0
- package/dist/server/index.d.ts +4108 -0
- package/dist/server/index.js +22538 -0
- package/dist/ssrf-BdHsrrIb.d.ts +325 -0
- package/dist/tempo-charge-store-6GJEMNUU.js +130 -0
- package/dist/tempo-session-store-FTEEGZXA.js +467 -0
- package/dist/testing/index.d.ts +135 -0
- package/dist/testing/index.js +151 -0
- package/dist/x402-35VLYFKZ.js +1272 -0
- package/package.json +89 -6
|
@@ -0,0 +1,3808 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyRetryToPool
|
|
3
|
+
} from "./chunk-H25M54MI.js";
|
|
4
|
+
import {
|
|
5
|
+
withSdkInitLock
|
|
6
|
+
} from "./chunk-S3XAHZQY.js";
|
|
7
|
+
|
|
8
|
+
// src/sdk/server/credit-ledger.ts
|
|
9
|
+
import { randomUUID } from "crypto";
|
|
10
|
+
|
|
11
|
+
// src/sdk/server/credit-lots.ts
|
|
12
|
+
var NON_CHANNEL_BITCOIN_RAILS = [
|
|
13
|
+
"cashu",
|
|
14
|
+
"lightning"
|
|
15
|
+
];
|
|
16
|
+
function isNonChannelBitcoinRail(rail) {
|
|
17
|
+
return typeof rail === "string" && NON_CHANNEL_BITCOIN_RAILS.includes(rail);
|
|
18
|
+
}
|
|
19
|
+
function depleteLots(lots, amountMicro) {
|
|
20
|
+
const debits = [];
|
|
21
|
+
let left = Math.max(0, amountMicro);
|
|
22
|
+
let satsOwed = 0;
|
|
23
|
+
let backedMicro = 0;
|
|
24
|
+
for (const lot of lots) {
|
|
25
|
+
if (left <= 0) break;
|
|
26
|
+
const available = Math.max(0, lot.remainingMicro);
|
|
27
|
+
if (available === 0) continue;
|
|
28
|
+
const micro = Math.min(available, left);
|
|
29
|
+
const sats = lotSats(lot, available) - lotSats(lot, available - micro);
|
|
30
|
+
debits.push({ lotId: lot.lotId, micro, sats });
|
|
31
|
+
satsOwed += sats;
|
|
32
|
+
if (lot.satsFunded > 0) backedMicro += micro;
|
|
33
|
+
left -= micro;
|
|
34
|
+
}
|
|
35
|
+
return { debits, satsOwed, uncoveredMicro: left, backedMicro };
|
|
36
|
+
}
|
|
37
|
+
function inKindDrawMsats(depletion, amountMicro) {
|
|
38
|
+
return isInKindDepletion(depletion, amountMicro) ? depletion.satsOwed * 1e3 : null;
|
|
39
|
+
}
|
|
40
|
+
function lotOwedSats(lots) {
|
|
41
|
+
return lots.reduce((sum, lot) => sum + lotSats(lot, lot.remainingMicro), 0);
|
|
42
|
+
}
|
|
43
|
+
var DRAIN_DELIVERY_RESERVE_SATS = 8;
|
|
44
|
+
function netOwedSats(grossSats) {
|
|
45
|
+
return Math.max(0, grossSats - DRAIN_DELIVERY_RESERVE_SATS);
|
|
46
|
+
}
|
|
47
|
+
function fifoOrder(left, right) {
|
|
48
|
+
return left.createdAt - right.createdAt || left.lotId.localeCompare(right.lotId);
|
|
49
|
+
}
|
|
50
|
+
function isInKindDepletion(depletion, amountMicro) {
|
|
51
|
+
return depletion.uncoveredMicro === 0 && depletion.backedMicro === amountMicro && amountMicro > 0;
|
|
52
|
+
}
|
|
53
|
+
function lotSats(lot, micro) {
|
|
54
|
+
if (lot.satsFunded <= 0 || lot.creditedMicro <= 0 || micro <= 0) return 0;
|
|
55
|
+
const taken = Math.min(micro, lot.creditedMicro);
|
|
56
|
+
return Math.floor(lot.satsFunded * taken / lot.creditedMicro);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/sdk/server/x402-channel-store.ts
|
|
60
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
61
|
+
import { isDeepStrictEqual } from "util";
|
|
62
|
+
var X402SettlementRaceError = class extends Error {
|
|
63
|
+
};
|
|
64
|
+
var X402RelaySubmissionLockError = class extends Error {
|
|
65
|
+
constructor(lockKey, timeoutMs, holder) {
|
|
66
|
+
super(
|
|
67
|
+
`x402 self-relay submission lock '${lockKey}' still held after ${String(timeoutMs)}ms${holder ? ` (held by ${describeHolder(holder)})` : ""}`
|
|
68
|
+
);
|
|
69
|
+
this.lockKey = lockKey;
|
|
70
|
+
this.timeoutMs = timeoutMs;
|
|
71
|
+
this.holder = holder;
|
|
72
|
+
this.name = "X402RelaySubmissionLockError";
|
|
73
|
+
}
|
|
74
|
+
lockKey;
|
|
75
|
+
timeoutMs;
|
|
76
|
+
holder;
|
|
77
|
+
code = "x402_relay_submission_lock_timeout";
|
|
78
|
+
retryable = true;
|
|
79
|
+
};
|
|
80
|
+
var X402_WEDGED_SETTLEMENT_STATUSES = ["submitting", "settled"];
|
|
81
|
+
var X402_RESOLVED_SETTLEMENT_STATUSES = [
|
|
82
|
+
...X402_WEDGED_SETTLEMENT_STATUSES,
|
|
83
|
+
"written_off"
|
|
84
|
+
];
|
|
85
|
+
var X402_SPEND_BLOCKING_SETTLEMENT_STATUSES = [
|
|
86
|
+
...X402_WEDGED_SETTLEMENT_STATUSES,
|
|
87
|
+
"written_off"
|
|
88
|
+
];
|
|
89
|
+
var SETTLEMENT_POOL_MAX = 5;
|
|
90
|
+
var SETTLEMENT_CONNECT_TIMEOUT_MS = 1e4;
|
|
91
|
+
var FLEET_LOCK_TIMEOUT_MS = 3e4;
|
|
92
|
+
var RELAY_SUBMISSION_LOCK_TIMEOUT_MS = 3e4;
|
|
93
|
+
var LOCK_NOT_AVAILABLE = "55P03";
|
|
94
|
+
var PostgresX402ChannelStorage = class {
|
|
95
|
+
constructor(pool, opts) {
|
|
96
|
+
this.pool = pool;
|
|
97
|
+
this.relayLockTimeoutMs = opts?.relaySubmissionLockTimeoutMs ?? RELAY_SUBMISSION_LOCK_TIMEOUT_MS;
|
|
98
|
+
this.terminalizeUnbackedCredit = opts?.terminalizeUnbackedCredit;
|
|
99
|
+
if (!Number.isInteger(this.relayLockTimeoutMs) || this.relayLockTimeoutMs <= 0) {
|
|
100
|
+
throw new Error("relaySubmissionLockTimeoutMs must be a positive integer number of ms");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
pool;
|
|
104
|
+
transaction = new AsyncLocalStorage();
|
|
105
|
+
refundBase = new AsyncLocalStorage();
|
|
106
|
+
settlementPool;
|
|
107
|
+
fleetLock = Promise.resolve();
|
|
108
|
+
fleetLockQueueDepth = 0;
|
|
109
|
+
relayLockTimeoutMs;
|
|
110
|
+
terminalizeUnbackedCredit;
|
|
111
|
+
/** Create the durable channel table under the SDK-wide DDL lock. */
|
|
112
|
+
async init() {
|
|
113
|
+
this.settlementPool ??= createSettlementPool(this.pool);
|
|
114
|
+
await withSdkInitLock(this.pool, async () => {
|
|
115
|
+
await this.pool.query(`
|
|
116
|
+
CREATE TABLE IF NOT EXISTS x402_batch_channels (
|
|
117
|
+
channel_id TEXT PRIMARY KEY,
|
|
118
|
+
channel_config JSONB NOT NULL,
|
|
119
|
+
charged_cumulative_amount NUMERIC(78, 0) NOT NULL,
|
|
120
|
+
signed_max_claimable NUMERIC(78, 0) NOT NULL,
|
|
121
|
+
signature TEXT NOT NULL,
|
|
122
|
+
balance NUMERIC(78, 0) NOT NULL,
|
|
123
|
+
total_claimed NUMERIC(78, 0) NOT NULL,
|
|
124
|
+
withdraw_requested_at BIGINT NOT NULL,
|
|
125
|
+
refund_nonce BIGINT NOT NULL,
|
|
126
|
+
onchain_synced_at BIGINT,
|
|
127
|
+
last_request_timestamp BIGINT NOT NULL,
|
|
128
|
+
pending_request JSONB
|
|
129
|
+
)
|
|
130
|
+
`);
|
|
131
|
+
await this.pool.query(
|
|
132
|
+
`CREATE INDEX IF NOT EXISTS idx_x402_batch_channels_withdrawal
|
|
133
|
+
ON x402_batch_channels(withdraw_requested_at)
|
|
134
|
+
WHERE withdraw_requested_at <> 0`
|
|
135
|
+
);
|
|
136
|
+
await this.pool.query(`
|
|
137
|
+
CREATE TABLE IF NOT EXISTS x402_batch_settlements (
|
|
138
|
+
settlement_id TEXT PRIMARY KEY,
|
|
139
|
+
channel_id TEXT NOT NULL,
|
|
140
|
+
operation TEXT NOT NULL CHECK (operation IN ('fund', 'refund')),
|
|
141
|
+
effect_id TEXT NOT NULL,
|
|
142
|
+
payment_id TEXT NOT NULL,
|
|
143
|
+
payment_payload JSONB NOT NULL,
|
|
144
|
+
payment_requirements JSONB NOT NULL,
|
|
145
|
+
channel_before JSONB,
|
|
146
|
+
submission_block NUMERIC(78, 0),
|
|
147
|
+
pending_id TEXT,
|
|
148
|
+
status TEXT NOT NULL,
|
|
149
|
+
settle_response JSONB,
|
|
150
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
151
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
152
|
+
UNIQUE (operation, effect_id),
|
|
153
|
+
UNIQUE (operation, payment_id)
|
|
154
|
+
)
|
|
155
|
+
`);
|
|
156
|
+
await this.pool.query(
|
|
157
|
+
`ALTER TABLE x402_batch_settlements ADD COLUMN IF NOT EXISTS channel_before JSONB`
|
|
158
|
+
);
|
|
159
|
+
await this.pool.query(
|
|
160
|
+
`ALTER TABLE x402_batch_settlements ADD COLUMN IF NOT EXISTS submission_block NUMERIC(78, 0)`
|
|
161
|
+
);
|
|
162
|
+
await this.pool.query(
|
|
163
|
+
`ALTER TABLE x402_batch_settlements ADD COLUMN IF NOT EXISTS channel_at_submission JSONB`
|
|
164
|
+
);
|
|
165
|
+
await this.pool.query(
|
|
166
|
+
`ALTER TABLE x402_batch_settlements ADD COLUMN IF NOT EXISTS write_off_note TEXT`
|
|
167
|
+
);
|
|
168
|
+
await this.pool.query(
|
|
169
|
+
`ALTER TABLE x402_batch_settlements ADD COLUMN IF NOT EXISTS resolved_at BIGINT`
|
|
170
|
+
);
|
|
171
|
+
await this.pool.query(
|
|
172
|
+
`ALTER TABLE x402_batch_settlements
|
|
173
|
+
DROP CONSTRAINT IF EXISTS x402_batch_settlements_status_check`
|
|
174
|
+
);
|
|
175
|
+
await this.pool.query(
|
|
176
|
+
`ALTER TABLE x402_batch_settlements
|
|
177
|
+
ADD CONSTRAINT x402_batch_settlements_status_check
|
|
178
|
+
CHECK (status IN ('prepared', 'submitting', 'settled', 'complete', 'written_off'))`
|
|
179
|
+
);
|
|
180
|
+
await this.pool.query(`DROP INDEX IF EXISTS idx_x402_batch_settlements_repair`);
|
|
181
|
+
await this.pool.query(
|
|
182
|
+
`CREATE INDEX IF NOT EXISTS idx_x402_batch_settlements_repair_created
|
|
183
|
+
ON x402_batch_settlements(created_at, settlement_id)
|
|
184
|
+
WHERE status IN ('submitting', 'settled', 'written_off')`
|
|
185
|
+
);
|
|
186
|
+
await this.pool.query(
|
|
187
|
+
`CREATE INDEX IF NOT EXISTS idx_x402_batch_settlements_channel_open
|
|
188
|
+
ON x402_batch_settlements(channel_id, created_at)
|
|
189
|
+
WHERE operation = 'refund'
|
|
190
|
+
AND status IN ('submitting', 'settled', 'written_off')`
|
|
191
|
+
);
|
|
192
|
+
await this.pool.query(`
|
|
193
|
+
CREATE TABLE IF NOT EXISTS x402_batch_settlement_state (
|
|
194
|
+
scope TEXT PRIMARY KEY,
|
|
195
|
+
pending_settle BOOLEAN NOT NULL,
|
|
196
|
+
updated_at BIGINT NOT NULL
|
|
197
|
+
)
|
|
198
|
+
`);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Read the fleet's durable "claimed funds are awaiting transfer" marker.
|
|
203
|
+
*
|
|
204
|
+
* Upstream keeps this in process memory, which a multi-machine fleet cannot
|
|
205
|
+
* share: the machine that won a claim tick is rarely the one that wins the
|
|
206
|
+
* next settle tick. An unset scope reads false, so a receiver that has never
|
|
207
|
+
* claimed never submits a settlement.
|
|
208
|
+
*
|
|
209
|
+
* Both accessors run on the settlement pool: every caller reads or writes the
|
|
210
|
+
* marker while {@link withFleetSettlementLock} holds a host-pool client, which
|
|
211
|
+
* is the nesting the internal-review split exists to keep off a non-terminal pool.
|
|
212
|
+
*/
|
|
213
|
+
async isSettlePending(scope) {
|
|
214
|
+
const reader = await this.settlementWriter();
|
|
215
|
+
const { rows } = await reader.query(
|
|
216
|
+
`SELECT pending_settle FROM x402_batch_settlement_state WHERE scope = $1`,
|
|
217
|
+
[scope]
|
|
218
|
+
);
|
|
219
|
+
return rows[0]?.pending_settle ?? false;
|
|
220
|
+
}
|
|
221
|
+
/** Record whether a claim's funds still need a `settle(receiver, token)` transfer. */
|
|
222
|
+
async setSettlePending(scope, pending) {
|
|
223
|
+
const writer = await this.settlementWriter();
|
|
224
|
+
await writer.query(
|
|
225
|
+
`INSERT INTO x402_batch_settlement_state (scope, pending_settle, updated_at)
|
|
226
|
+
VALUES ($1, $2, $3)
|
|
227
|
+
ON CONFLICT (scope) DO UPDATE
|
|
228
|
+
SET pending_settle = EXCLUDED.pending_settle,
|
|
229
|
+
updated_at = EXCLUDED.updated_at`,
|
|
230
|
+
[scope, pending, Date.now()]
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Look up the durable settlement state for one credit-ledger effect.
|
|
235
|
+
*
|
|
236
|
+
* On the dedicated settlement pool like every other marker, and for a sharper
|
|
237
|
+
* reason than they had: this and {@link prepareSettlement} now run *inside*
|
|
238
|
+
* the channel transaction (internal-review), which holds a host-pool client for its
|
|
239
|
+
* whole duration. Taking a second client from that same pool is the
|
|
240
|
+
* self-deadlock internal-review fixed — at pool max every client is a holder and a
|
|
241
|
+
* waiter — and moving these calls under the transaction is exactly what would
|
|
242
|
+
* reintroduce it.
|
|
243
|
+
*/
|
|
244
|
+
async getSettlement(operation, effectId) {
|
|
245
|
+
const { rows } = await (await this.settlementWriter()).query(
|
|
246
|
+
`SELECT * FROM x402_batch_settlements WHERE operation = $1 AND effect_id = $2`,
|
|
247
|
+
[operation, effectId]
|
|
248
|
+
);
|
|
249
|
+
return rows[0] ? toSettlementIntent(rows[0]) : void 0;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The refund on this channel that is neither finished nor abandoned, if there
|
|
253
|
+
* is one — the credit ledger's spend gate (internal-review).
|
|
254
|
+
*
|
|
255
|
+
* `statuses` is the caller's, because the two gates disagree by design: a
|
|
256
|
+
* draw is blocked by {@link X402_SPEND_BLOCKING_SETTLEMENT_STATUSES}, a fresh
|
|
257
|
+
* drain only by {@link X402_WEDGED_SETTLEMENT_STATUSES}.
|
|
258
|
+
*
|
|
259
|
+
* On the dedicated settlement pool, and that is what makes the gate callable
|
|
260
|
+
* at all: a draw routinely runs inside an open channel transaction holding a
|
|
261
|
+
* host-pool client, and taking a second one from that pool is the internal-review
|
|
262
|
+
* self-deadlock. The oldest blocking row wins, so a channel that somehow
|
|
263
|
+
* accumulated two names the one an operator should reach for first.
|
|
264
|
+
*
|
|
265
|
+
* Projects three columns rather than decoding a whole {@link
|
|
266
|
+
* X402SettlementIntent}: the decoder refuses a pre-internal-review row that carries
|
|
267
|
+
* no reconciliation snapshot, and a gate that throws on one would take every
|
|
268
|
+
* draw on that channel down with it — when the row is exactly the kind that
|
|
269
|
+
* ought to block them.
|
|
270
|
+
*/
|
|
271
|
+
async pendingRefundSettlement(channelId, statuses) {
|
|
272
|
+
const { rows } = await (await this.settlementWriter()).query(
|
|
273
|
+
`SELECT settlement_id, effect_id, status FROM x402_batch_settlements
|
|
274
|
+
WHERE channel_id = $1 AND operation = 'refund' AND status = ANY($2::text[])
|
|
275
|
+
ORDER BY created_at, settlement_id
|
|
276
|
+
LIMIT 1`,
|
|
277
|
+
[normalizeChannelId(channelId), statuses]
|
|
278
|
+
);
|
|
279
|
+
if (rows.length === 0) return void 0;
|
|
280
|
+
const row = rows[0];
|
|
281
|
+
return { settlementId: row.settlement_id, effectId: row.effect_id, status: row.status };
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Read one settlement by its own identifier — the handle an operator holds
|
|
285
|
+
* (internal-review). Same dedicated pool as {@link getSettlement}, for the same
|
|
286
|
+
* reason: the repair reads this while a channel transaction may be open.
|
|
287
|
+
*/
|
|
288
|
+
async getSettlementById(settlementId) {
|
|
289
|
+
const { rows } = await (await this.settlementWriter()).query(`SELECT * FROM x402_batch_settlements WHERE settlement_id = $1`, [
|
|
290
|
+
settlementId
|
|
291
|
+
]);
|
|
292
|
+
return rows[0] ? toSettlementIntent(rows[0]) : void 0;
|
|
293
|
+
}
|
|
294
|
+
/** Lazily move a legacy settlement row onto its authorization/effect-scoped replay key. */
|
|
295
|
+
async normalizeLegacyPaymentId(settlementId, operation, legacyPaymentId, paymentId) {
|
|
296
|
+
const writer = await this.settlementWriter();
|
|
297
|
+
const updated = await writer.query(
|
|
298
|
+
`UPDATE x402_batch_settlements
|
|
299
|
+
SET payment_id = $3
|
|
300
|
+
WHERE settlement_id = $1 AND operation = $4 AND payment_id = $2
|
|
301
|
+
RETURNING *`,
|
|
302
|
+
[settlementId, legacyPaymentId, paymentId, operation]
|
|
303
|
+
);
|
|
304
|
+
if (updated.rows[0]) return toSettlementIntent(updated.rows[0]);
|
|
305
|
+
const existing = await this.getSettlementById(settlementId);
|
|
306
|
+
if (existing?.operation === operation && existing.paymentId === paymentId) return existing;
|
|
307
|
+
throw new Error(`x402 settlement ${settlementId} legacy payment identity changed`);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The operator's repair queue: settlements stuck between "chain paid" and
|
|
311
|
+
* "ledger booked", oldest write first (internal-review).
|
|
312
|
+
*
|
|
313
|
+
* Keyset-paginated on `(created_at, settlement_id)` for the reason the
|
|
314
|
+
* blocked-invoice queue is: a wedged row never self-clears, so a fixed first
|
|
315
|
+
* page of rows the operator has declined to act on would starve everything
|
|
316
|
+
* behind it on every run, forever.
|
|
317
|
+
*
|
|
318
|
+
* The keyset is on `created_at`, which never moves, while `updatedBeforeMs`
|
|
319
|
+
* filters on `updated_at`, which does — so a row an operator touches mid-walk
|
|
320
|
+
* can leave the queue but can never jump the cursor and be skipped. The cursor
|
|
321
|
+
* itself is Postgres's text rendering of that timestamp, handed back verbatim;
|
|
322
|
+
* see {@link X402SettlementCursor} for why a `Date` cannot carry it.
|
|
323
|
+
*
|
|
324
|
+
* `updatedBeforeMs` is the staleness floor. A healthy settlement passes
|
|
325
|
+
* through `submitting` and `settled` inside one request, so anything younger
|
|
326
|
+
* than the floor is in flight rather than wedged and must not be offered as
|
|
327
|
+
* repairable work.
|
|
328
|
+
*/
|
|
329
|
+
async listSettlements(args) {
|
|
330
|
+
const { rows } = await (await this.settlementWriter()).query(
|
|
331
|
+
`SELECT *, created_at::text AS created_at_key FROM x402_batch_settlements
|
|
332
|
+
WHERE status = ANY($1::text[])
|
|
333
|
+
AND updated_at <= to_timestamp($2::double precision / 1000)
|
|
334
|
+
AND ($3::text IS NULL
|
|
335
|
+
OR (created_at, settlement_id) > ($3::timestamptz, $4::text))
|
|
336
|
+
ORDER BY created_at, settlement_id
|
|
337
|
+
LIMIT $5`,
|
|
338
|
+
[
|
|
339
|
+
args.statuses,
|
|
340
|
+
args.updatedBeforeMs,
|
|
341
|
+
args.after?.createdAt ?? null,
|
|
342
|
+
args.after?.settlementId ?? null,
|
|
343
|
+
args.limit
|
|
344
|
+
]
|
|
345
|
+
);
|
|
346
|
+
const last = rows.length === args.limit ? rows[rows.length - 1] : void 0;
|
|
347
|
+
return {
|
|
348
|
+
settlements: rows.map(toSettlementIntent),
|
|
349
|
+
...last ? { nextAfter: { createdAt: last.created_at_key, settlementId: last.settlement_id } } : {}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Close a wedged settlement without booking anything (internal-review).
|
|
354
|
+
*
|
|
355
|
+
* A single-statement CAS on the wedged statuses, on the settlement pool —
|
|
356
|
+
* there is no ledger effect to be atomic with, which is the whole point:
|
|
357
|
+
* the write-off records that an operator reviewed this settlement and is not
|
|
358
|
+
* completing it, and no dvmkit table moves. Idempotent, and the first note
|
|
359
|
+
* wins; `written` is the transition bit the caller logs on, so the one place
|
|
360
|
+
* the amount is ever recorded gets exactly one entry.
|
|
361
|
+
*
|
|
362
|
+
* Reversible by construction: a later reconcile calls
|
|
363
|
+
* {@link recordSettlementResponse}, whose `status <> 'complete'` CAS lifts a
|
|
364
|
+
* written-off row back to `settled`.
|
|
365
|
+
*/
|
|
366
|
+
async writeOffSettlement(settlementId, note, nowMs) {
|
|
367
|
+
const writer = await this.settlementWriter();
|
|
368
|
+
const updated = await writer.query(
|
|
369
|
+
`UPDATE x402_batch_settlements
|
|
370
|
+
SET status = 'written_off', write_off_note = $2, resolved_at = $3, updated_at = NOW()
|
|
371
|
+
WHERE settlement_id = $1 AND status = ANY($4::text[])
|
|
372
|
+
RETURNING *`,
|
|
373
|
+
[settlementId, note ?? null, nowMs, X402_WEDGED_SETTLEMENT_STATUSES]
|
|
374
|
+
);
|
|
375
|
+
if (updated.rows[0]) return { intent: toSettlementIntent(updated.rows[0]), written: true };
|
|
376
|
+
const existing = await this.getSettlementById(settlementId);
|
|
377
|
+
return existing ? { intent: existing, written: false } : void 0;
|
|
378
|
+
}
|
|
379
|
+
/** Persist the reconciliation identity, refreshing a prepared retry's transient reservation. */
|
|
380
|
+
async prepareSettlement(intent) {
|
|
381
|
+
const settlements = await this.settlementWriter();
|
|
382
|
+
await settlements.query(
|
|
383
|
+
`INSERT INTO x402_batch_settlements (
|
|
384
|
+
settlement_id, channel_id, operation, effect_id, payment_id,
|
|
385
|
+
payment_payload, payment_requirements, channel_before, pending_id, status
|
|
386
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'prepared')
|
|
387
|
+
ON CONFLICT DO NOTHING`,
|
|
388
|
+
[
|
|
389
|
+
intent.settlementId,
|
|
390
|
+
normalizeChannelId(intent.channelId),
|
|
391
|
+
intent.operation,
|
|
392
|
+
intent.effectId,
|
|
393
|
+
intent.paymentId,
|
|
394
|
+
intent.payload,
|
|
395
|
+
intent.requirements,
|
|
396
|
+
intent.channelBefore,
|
|
397
|
+
intent.pendingId ?? null
|
|
398
|
+
]
|
|
399
|
+
);
|
|
400
|
+
const { rows } = await settlements.query(
|
|
401
|
+
`SELECT * FROM x402_batch_settlements
|
|
402
|
+
WHERE settlement_id = $1
|
|
403
|
+
OR (operation = $2 AND effect_id = $3)
|
|
404
|
+
OR (operation = $2 AND payment_id = $4)
|
|
405
|
+
ORDER BY settlement_id = $1 DESC
|
|
406
|
+
LIMIT 1`,
|
|
407
|
+
[intent.settlementId, intent.operation, intent.effectId, intent.paymentId]
|
|
408
|
+
);
|
|
409
|
+
let stored = rows[0] ? toSettlementIntent(rows[0]) : void 0;
|
|
410
|
+
if (stored?.settlementId !== intent.settlementId || stored.channelId !== normalizeChannelId(intent.channelId) || stored.operation !== intent.operation || stored.effectId !== intent.effectId) {
|
|
411
|
+
throw new Error("x402 settlement identity conflicts with a prior credit effect");
|
|
412
|
+
}
|
|
413
|
+
if (stored.paymentId !== intent.paymentId || !isDeepStrictEqual(stored.payload, intent.payload) || !isDeepStrictEqual(stored.requirements, intent.requirements)) {
|
|
414
|
+
if (!canRefreshPreparedDepositAuthorization(stored, intent, Date.now())) {
|
|
415
|
+
throw new Error("x402 settlement identity conflicts with a prior credit effect");
|
|
416
|
+
}
|
|
417
|
+
const refreshed = await settlements.query(
|
|
418
|
+
`UPDATE x402_batch_settlements
|
|
419
|
+
SET payment_id = $2, payment_payload = $3, channel_before = $4,
|
|
420
|
+
pending_id = $5, updated_at = NOW()
|
|
421
|
+
WHERE settlement_id = $1 AND status = 'prepared'
|
|
422
|
+
AND payment_id = $6 AND payment_payload = $7
|
|
423
|
+
RETURNING *`,
|
|
424
|
+
[
|
|
425
|
+
intent.settlementId,
|
|
426
|
+
intent.paymentId,
|
|
427
|
+
intent.payload,
|
|
428
|
+
intent.channelBefore,
|
|
429
|
+
intent.pendingId ?? null,
|
|
430
|
+
stored.paymentId,
|
|
431
|
+
stored.payload
|
|
432
|
+
]
|
|
433
|
+
);
|
|
434
|
+
if (refreshed.rows[0]) return toSettlementIntent(refreshed.rows[0]);
|
|
435
|
+
const raced = await this.getSettlementById(intent.settlementId);
|
|
436
|
+
if (raced?.paymentId !== intent.paymentId || !isDeepStrictEqual(raced.payload, intent.payload) || !isDeepStrictEqual(raced.requirements, intent.requirements)) {
|
|
437
|
+
throw new Error("x402 settlement identity conflicts with a prior credit effect");
|
|
438
|
+
}
|
|
439
|
+
stored = raced;
|
|
440
|
+
}
|
|
441
|
+
if (stored.status === "prepared" && (!isDeepStrictEqual(stored.channelBefore, intent.channelBefore) || stored.pendingId !== intent.pendingId)) {
|
|
442
|
+
const refreshed = await settlements.query(
|
|
443
|
+
`UPDATE x402_batch_settlements
|
|
444
|
+
SET channel_before = $2, pending_id = $3, updated_at = NOW()
|
|
445
|
+
WHERE settlement_id = $1 AND status = 'prepared'
|
|
446
|
+
RETURNING *`,
|
|
447
|
+
[intent.settlementId, intent.channelBefore, intent.pendingId ?? null]
|
|
448
|
+
);
|
|
449
|
+
if (refreshed.rowCount !== 1) {
|
|
450
|
+
throw new Error(`x402 settlement ${intent.settlementId} is no longer prepared for retry`);
|
|
451
|
+
}
|
|
452
|
+
return toSettlementIntent(refreshed.rows[0]);
|
|
453
|
+
}
|
|
454
|
+
if (!isDeepStrictEqual(stored.channelBefore, intent.channelBefore)) {
|
|
455
|
+
throw new Error("x402 settlement identity conflicts with a prior credit effect");
|
|
456
|
+
}
|
|
457
|
+
return stored;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Mark that the next operation is the ambiguous external-call boundary.
|
|
461
|
+
*
|
|
462
|
+
* `channelAtSubmission` is the live channel the facilitator is about to size
|
|
463
|
+
* and sign the settlement against (internal-review) — bookmarked here, with the
|
|
464
|
+
* block height, because these two are the only description of that instant
|
|
465
|
+
* that survives the call.
|
|
466
|
+
*/
|
|
467
|
+
async markSettlementSubmitting(settlementId, submissionBlock, channelAtSubmission) {
|
|
468
|
+
if (!/^\d+$/.test(submissionBlock)) {
|
|
469
|
+
throw new Error(`x402 settlement ${settlementId} has an invalid submission block`);
|
|
470
|
+
}
|
|
471
|
+
const writer = await this.settlementWriter();
|
|
472
|
+
const result = await writer.query(
|
|
473
|
+
`UPDATE x402_batch_settlements
|
|
474
|
+
SET status = 'submitting', submission_block = $2,
|
|
475
|
+
channel_at_submission = $3, updated_at = NOW()
|
|
476
|
+
WHERE settlement_id = $1 AND status = 'prepared'`,
|
|
477
|
+
[settlementId, submissionBlock, channelAtSubmission ?? null]
|
|
478
|
+
);
|
|
479
|
+
if (result.rowCount !== 1) {
|
|
480
|
+
throw new Error(`x402 settlement ${settlementId} is not prepared for submission`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** Return an explicitly failed facilitator submission to the safe retry phase. */
|
|
484
|
+
async markSettlementRetryable(settlementId) {
|
|
485
|
+
const writer = await this.settlementWriter();
|
|
486
|
+
const result = await writer.query(
|
|
487
|
+
`UPDATE x402_batch_settlements
|
|
488
|
+
SET status = 'prepared', submission_block = NULL,
|
|
489
|
+
channel_at_submission = NULL, updated_at = NOW()
|
|
490
|
+
WHERE settlement_id = $1 AND status = 'submitting'`,
|
|
491
|
+
[settlementId]
|
|
492
|
+
);
|
|
493
|
+
if (result.rowCount !== 1) {
|
|
494
|
+
throw new Error(`x402 settlement ${settlementId} is not awaiting a submission result`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Record a successful facilitator response independently of the enclosing
|
|
499
|
+
* ledger transaction.
|
|
500
|
+
*
|
|
501
|
+
* The write-off columns are cleared, not carried: this CAS is what lifts a
|
|
502
|
+
* `written_off` row back to `settled` (which is what makes the write-off
|
|
503
|
+
* reversible), and a row that is no longer written off must not still be
|
|
504
|
+
* carrying the note and resolution timestamp of a decision that has been
|
|
505
|
+
* reversed. On the live path the columns are already `NULL` and this is a
|
|
506
|
+
* no-op.
|
|
507
|
+
*/
|
|
508
|
+
async recordSettlementResponse(settlementId, response) {
|
|
509
|
+
const writer = await this.settlementWriter();
|
|
510
|
+
const result = await writer.query(
|
|
511
|
+
`UPDATE x402_batch_settlements
|
|
512
|
+
SET status = 'settled', settle_response = $2,
|
|
513
|
+
write_off_note = NULL, resolved_at = NULL, updated_at = NOW()
|
|
514
|
+
WHERE settlement_id = $1 AND status <> 'complete'`,
|
|
515
|
+
[settlementId, response]
|
|
516
|
+
);
|
|
517
|
+
if (result.rowCount !== 1) {
|
|
518
|
+
throw new X402SettlementRaceError(`x402 settlement ${settlementId} is not recoverable`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Mark the external settlement and ledger effect complete in the active transaction.
|
|
523
|
+
*
|
|
524
|
+
* `resolvedAtMs` is set only by the operator repair (internal-review), so an audit
|
|
525
|
+
* read can tell a settlement that completed on its own from one a human had
|
|
526
|
+
* to finish.
|
|
527
|
+
*/
|
|
528
|
+
async completeSettlement(settlementId, resolvedAtMs) {
|
|
529
|
+
const q = this.transaction.getStore() ?? this.pool;
|
|
530
|
+
const result = await q.query(
|
|
531
|
+
`UPDATE x402_batch_settlements
|
|
532
|
+
SET status = 'complete',
|
|
533
|
+
resolved_at = COALESCE($2::bigint, resolved_at),
|
|
534
|
+
updated_at = NOW()
|
|
535
|
+
WHERE settlement_id = $1 AND status = 'settled'`,
|
|
536
|
+
[settlementId, resolvedAtMs ?? null]
|
|
537
|
+
);
|
|
538
|
+
if (result.rowCount !== 1) {
|
|
539
|
+
throw new X402SettlementRaceError(
|
|
540
|
+
`x402 settlement ${settlementId} has no recorded facilitator response`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/** Load one channel snapshot. */
|
|
545
|
+
async get(channelId) {
|
|
546
|
+
const q = this.transaction.getStore() ?? this.pool;
|
|
547
|
+
const { rows } = await q.query(
|
|
548
|
+
`SELECT * FROM x402_batch_channels WHERE channel_id = $1`,
|
|
549
|
+
[normalizeChannelId(channelId)]
|
|
550
|
+
);
|
|
551
|
+
return rows[0] ? toChannel(rows[0]) : void 0;
|
|
552
|
+
}
|
|
553
|
+
/** List all channel snapshots in stable channel-id order. */
|
|
554
|
+
async list() {
|
|
555
|
+
const q = this.transaction.getStore() ?? this.pool;
|
|
556
|
+
const { rows } = await q.query(
|
|
557
|
+
`SELECT * FROM x402_batch_channels ORDER BY channel_id`
|
|
558
|
+
);
|
|
559
|
+
return rows.map(toChannel);
|
|
560
|
+
}
|
|
561
|
+
/** Atomic backend-level read/modify/write for one channel. */
|
|
562
|
+
async updateChannel(channelId, update) {
|
|
563
|
+
const active = this.transaction.getStore();
|
|
564
|
+
if (active) return this.updateInTransaction(active, channelId, update, true);
|
|
565
|
+
const client = await this.pool.connect();
|
|
566
|
+
try {
|
|
567
|
+
await client.query("BEGIN");
|
|
568
|
+
const result = await this.updateInTransaction(client, channelId, update, false);
|
|
569
|
+
await client.query("COMMIT");
|
|
570
|
+
return result;
|
|
571
|
+
} catch (error) {
|
|
572
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
573
|
+
throw error;
|
|
574
|
+
} finally {
|
|
575
|
+
client.release();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Run channel state and its ledger effect under the channel lock.
|
|
580
|
+
*
|
|
581
|
+
* Upstream's voucher-only settle updates `chargedCumulativeAmount` through
|
|
582
|
+
* `updateChannel`. Keeping that update and `commitMarkedFunding` in this one
|
|
583
|
+
* transaction means a ledger refusal rolls the voucher state back. External
|
|
584
|
+
* deposit/refund responses are recorded separately before this transaction
|
|
585
|
+
* commits, leaving a durable reconciliation row if COMMIT fails.
|
|
586
|
+
*/
|
|
587
|
+
/**
|
|
588
|
+
* Sanction one cooperative-refund base for the updates inside `operation`.
|
|
589
|
+
*
|
|
590
|
+
* The store cannot derive the base — it comes from the credit ledger — so
|
|
591
|
+
* this is how the settlement layer says "this exact decrease is the one the
|
|
592
|
+
* payer's verified refund voucher authorises". Outside this scope a decrease
|
|
593
|
+
* of `chargedCumulativeAmount` is refused flat, which is what keeps the
|
|
594
|
+
* arbitrary-decrease guard meaningful (internal-review).
|
|
595
|
+
*/
|
|
596
|
+
withSanctionedRefundBase(base, operation) {
|
|
597
|
+
return this.refundBase.run(base, operation);
|
|
598
|
+
}
|
|
599
|
+
async withChannelTransaction(channelId, operation) {
|
|
600
|
+
const client = await this.pool.connect();
|
|
601
|
+
try {
|
|
602
|
+
await client.query("BEGIN");
|
|
603
|
+
await lockChannel(client, channelId);
|
|
604
|
+
const result = await this.transaction.run(client, () => operation(client));
|
|
605
|
+
await client.query("COMMIT");
|
|
606
|
+
return result;
|
|
607
|
+
} catch (error) {
|
|
608
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
609
|
+
throw error;
|
|
610
|
+
} finally {
|
|
611
|
+
client.release();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Run one fleet-wide settlement operation, optionally waiting for the current owner.
|
|
616
|
+
*
|
|
617
|
+
* The lock is session-scoped, so its client stays checked out for the whole
|
|
618
|
+
* operation — and `operation()` (upstream's claim/settle) re-enters the pool
|
|
619
|
+
* through {@link updateChannel}. Two bounds keep that from wedging the pool
|
|
620
|
+
* (internal-review): local callers are serialized in-process, so this holds at most
|
|
621
|
+
* one client per process no matter how many requests arrive, and a waiting
|
|
622
|
+
* acquire carries a `lock_timeout` so a wedged fleet-mate can't pin that
|
|
623
|
+
* client — and the caller's request — indefinitely. A timeout reports the
|
|
624
|
+
* lock as not acquired, which is what every caller already handles.
|
|
625
|
+
*
|
|
626
|
+
* The queue depth is reserved synchronously, before the promise chain can
|
|
627
|
+
* yield. That makes the non-waiting form a true local try-lock even while the
|
|
628
|
+
* first caller is still acquiring its connection: an overlapping request
|
|
629
|
+
* returns `false` instead of queuing behind the facilitator operation.
|
|
630
|
+
*/
|
|
631
|
+
async withFleetSettlementLock(operation, wait = false) {
|
|
632
|
+
if (!wait && this.fleetLockQueueDepth > 0) return false;
|
|
633
|
+
this.fleetLockQueueDepth += 1;
|
|
634
|
+
const turn = this.fleetLock.then(() => this.runUnderFleetLock(operation, wait));
|
|
635
|
+
this.fleetLock = turn.catch(() => void 0);
|
|
636
|
+
try {
|
|
637
|
+
return await turn;
|
|
638
|
+
} finally {
|
|
639
|
+
this.fleetLockQueueDepth -= 1;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Hold the fleet-wide self-relay submission lock for one chain submission (internal-review).
|
|
644
|
+
*
|
|
645
|
+
* The session lock keeps one per-DVM gas EOA to one in-flight submission
|
|
646
|
+
* across the whole fleet — two machines submitting at once collide on the
|
|
647
|
+
* nonce. It is reached from `resource.settlePayment` inside
|
|
648
|
+
* {@link withChannelTransaction} and from upstream's claim/settle inside
|
|
649
|
+
* {@link withFleetSettlementLock}, so on both paths a host-pool client is
|
|
650
|
+
* already checked out: it therefore acquires from the terminal settlement
|
|
651
|
+
* pool, which is the rule in this class's doc, and never from the host pool
|
|
652
|
+
* the caller is holding.
|
|
653
|
+
*
|
|
654
|
+
* The wait is bounded by `lock_timeout` and a timeout **refuses** rather than
|
|
655
|
+
* proceeding unlocked, which is where this differs from
|
|
656
|
+
* {@link withFleetSettlementLock}: a fleet lock nobody got means the claim is
|
|
657
|
+
* someone else's turn, while a submission lock nobody got means the caller
|
|
658
|
+
* must not submit at all. The refusal is typed and retryable, and names the
|
|
659
|
+
* holder when Postgres will tell us who it is.
|
|
660
|
+
*
|
|
661
|
+
* The relay's own in-process tail serialises callers before they reach here,
|
|
662
|
+
* so one process pins at most one settlement-pool client no matter how many
|
|
663
|
+
* deposits arrive.
|
|
664
|
+
*/
|
|
665
|
+
async withRelaySubmissionLock(key, operation) {
|
|
666
|
+
const pool = await this.settlementPoolRef();
|
|
667
|
+
const client = await pool.connect();
|
|
668
|
+
let locked = false;
|
|
669
|
+
let destroyClient = false;
|
|
670
|
+
try {
|
|
671
|
+
await client.query(`SET lock_timeout = ${String(this.relayLockTimeoutMs)}`);
|
|
672
|
+
try {
|
|
673
|
+
await client.query(RELAY_LOCK_SQL, [key]);
|
|
674
|
+
locked = true;
|
|
675
|
+
} catch (error) {
|
|
676
|
+
if (!isLockTimeout(error)) throw error;
|
|
677
|
+
throw new X402RelaySubmissionLockError(
|
|
678
|
+
key,
|
|
679
|
+
this.relayLockTimeoutMs,
|
|
680
|
+
await relayLockHolder(client, key)
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
return await operation();
|
|
684
|
+
} finally {
|
|
685
|
+
if (locked) {
|
|
686
|
+
await client.query(RELAY_UNLOCK_SQL, [key]).catch(() => {
|
|
687
|
+
destroyClient = true;
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
await client.query(`RESET lock_timeout`).catch(() => {
|
|
691
|
+
destroyClient = true;
|
|
692
|
+
});
|
|
693
|
+
client.release(destroyClient);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/** Release the dedicated settlement pool; the host-owned pool is left alone. */
|
|
697
|
+
async close() {
|
|
698
|
+
const settlement = this.settlementPool;
|
|
699
|
+
this.settlementPool = void 0;
|
|
700
|
+
if (settlement) await (await settlement).end();
|
|
701
|
+
}
|
|
702
|
+
async settlementWriter() {
|
|
703
|
+
return this.settlementPoolRef();
|
|
704
|
+
}
|
|
705
|
+
async settlementPoolRef() {
|
|
706
|
+
this.settlementPool ??= createSettlementPool(this.pool);
|
|
707
|
+
return this.settlementPool;
|
|
708
|
+
}
|
|
709
|
+
async runUnderFleetLock(operation, wait) {
|
|
710
|
+
const client = await this.pool.connect();
|
|
711
|
+
let locked = false;
|
|
712
|
+
let timeoutSet = false;
|
|
713
|
+
try {
|
|
714
|
+
if (wait) {
|
|
715
|
+
await client.query(`SET lock_timeout = ${FLEET_LOCK_TIMEOUT_MS}`);
|
|
716
|
+
timeoutSet = true;
|
|
717
|
+
try {
|
|
718
|
+
await client.query(
|
|
719
|
+
`SELECT pg_advisory_lock(hashtextextended('x402-batch-settlement', 1762))`
|
|
720
|
+
);
|
|
721
|
+
locked = true;
|
|
722
|
+
} catch (error) {
|
|
723
|
+
if (!isLockTimeout(error)) throw error;
|
|
724
|
+
}
|
|
725
|
+
} else {
|
|
726
|
+
const { rows } = await client.query(
|
|
727
|
+
`SELECT pg_try_advisory_lock(hashtextextended('x402-batch-settlement', 1762)) AS locked`
|
|
728
|
+
);
|
|
729
|
+
locked = rows[0]?.locked ?? false;
|
|
730
|
+
}
|
|
731
|
+
if (!locked) return false;
|
|
732
|
+
await operation();
|
|
733
|
+
return true;
|
|
734
|
+
} finally {
|
|
735
|
+
let destroyClient = false;
|
|
736
|
+
if (locked) {
|
|
737
|
+
await client.query(`SELECT pg_advisory_unlock(hashtextextended('x402-batch-settlement', 1762))`).catch(() => {
|
|
738
|
+
destroyClient = true;
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
if (timeoutSet) {
|
|
742
|
+
await client.query(`RESET lock_timeout`).catch(() => {
|
|
743
|
+
destroyClient = true;
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
client.release(destroyClient);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
async updateInTransaction(client, channelId, update, alreadyLocked) {
|
|
750
|
+
const normalized = normalizeChannelId(channelId);
|
|
751
|
+
if (!alreadyLocked) await lockChannel(client, normalized);
|
|
752
|
+
const { rows } = await client.query(
|
|
753
|
+
`SELECT * FROM x402_batch_channels WHERE channel_id = $1 FOR UPDATE`,
|
|
754
|
+
[normalized]
|
|
755
|
+
);
|
|
756
|
+
const current = rows[0] ? toChannel(rows[0]) : void 0;
|
|
757
|
+
const next = update(current);
|
|
758
|
+
if (next === void 0) {
|
|
759
|
+
if (!current) return { channel: void 0, status: "unchanged" };
|
|
760
|
+
await client.query(`DELETE FROM x402_batch_channels WHERE channel_id = $1`, [normalized]);
|
|
761
|
+
return { channel: void 0, status: "deleted" };
|
|
762
|
+
}
|
|
763
|
+
assertChannelTransition(current, next, normalized, this.refundBase.getStore());
|
|
764
|
+
if (this.terminalizeUnbackedCredit && next.onchainSyncedAt !== void 0 && next.onchainSyncedAt !== current?.onchainSyncedAt && next.withdrawRequestedAt === 0 && BigInt(next.balance) === BigInt(next.totalClaimed)) {
|
|
765
|
+
await this.terminalizeUnbackedCredit(
|
|
766
|
+
{
|
|
767
|
+
channelId: normalized,
|
|
768
|
+
channelBalanceNative: BigInt(next.balance),
|
|
769
|
+
totalClaimedNative: BigInt(next.totalClaimed),
|
|
770
|
+
observedAt: next.onchainSyncedAt
|
|
771
|
+
},
|
|
772
|
+
client
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
if (current && sameChannel(current, next)) {
|
|
776
|
+
return { channel: current, status: "unchanged" };
|
|
777
|
+
}
|
|
778
|
+
await writeChannel(client, normalized, next);
|
|
779
|
+
return { channel: cloneChannel(next), status: "updated" };
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
async function createSettlementPool(pool) {
|
|
783
|
+
const pg = await import("pg");
|
|
784
|
+
const options = pool.options ?? {};
|
|
785
|
+
const settlement = new pg.default.Pool({
|
|
786
|
+
...options,
|
|
787
|
+
// pg defines `password` non-enumerably on `pool.options`, so a spread drops
|
|
788
|
+
// it — read it through explicitly or every connection fails to authenticate.
|
|
789
|
+
...options.password !== void 0 ? { password: options.password } : {},
|
|
790
|
+
max: SETTLEMENT_POOL_MAX,
|
|
791
|
+
connectionTimeoutMillis: SETTLEMENT_CONNECT_TIMEOUT_MS
|
|
792
|
+
});
|
|
793
|
+
applyRetryToPool(settlement, "x402-settlement");
|
|
794
|
+
return settlement;
|
|
795
|
+
}
|
|
796
|
+
var RELAY_LOCK_SQL = `SELECT pg_advisory_lock(hashtextextended($1, 1759))`;
|
|
797
|
+
var RELAY_UNLOCK_SQL = `SELECT pg_advisory_unlock(hashtextextended($1, 1759))`;
|
|
798
|
+
async function relayLockHolder(client, key) {
|
|
799
|
+
try {
|
|
800
|
+
const { rows } = await client.query(
|
|
801
|
+
`SELECT a.pid,
|
|
802
|
+
a.application_name,
|
|
803
|
+
a.client_addr::text AS client_addr,
|
|
804
|
+
a.state,
|
|
805
|
+
EXTRACT(EPOCH FROM (NOW() - a.query_start))::float8 AS held_seconds
|
|
806
|
+
FROM pg_locks l
|
|
807
|
+
JOIN pg_stat_activity a ON a.pid = l.pid
|
|
808
|
+
WHERE l.locktype = 'advisory'
|
|
809
|
+
AND l.granted
|
|
810
|
+
AND l.classid = ((hashtextextended($1, 1759) >> 32) & 4294967295)::oid
|
|
811
|
+
AND l.objid = (hashtextextended($1, 1759) & 4294967295)::oid
|
|
812
|
+
LIMIT 1`,
|
|
813
|
+
[key]
|
|
814
|
+
);
|
|
815
|
+
if (rows.length === 0) return void 0;
|
|
816
|
+
const row = rows[0];
|
|
817
|
+
return {
|
|
818
|
+
pid: row.pid,
|
|
819
|
+
...row.application_name ? { applicationName: row.application_name } : {},
|
|
820
|
+
...row.client_addr ? { clientAddr: row.client_addr } : {},
|
|
821
|
+
...row.state ? { state: row.state } : {},
|
|
822
|
+
...row.held_seconds !== null ? { heldSeconds: Math.round(row.held_seconds) } : {}
|
|
823
|
+
};
|
|
824
|
+
} catch {
|
|
825
|
+
return void 0;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
function describeHolder(holder) {
|
|
829
|
+
const parts = [`pid ${String(holder.pid)}`];
|
|
830
|
+
if (holder.applicationName) parts.push(holder.applicationName);
|
|
831
|
+
if (holder.clientAddr) parts.push(holder.clientAddr);
|
|
832
|
+
if (holder.state) parts.push(holder.state);
|
|
833
|
+
if (holder.heldSeconds !== void 0) parts.push(`${String(holder.heldSeconds)}s in`);
|
|
834
|
+
return parts.join(", ");
|
|
835
|
+
}
|
|
836
|
+
function isLockTimeout(error) {
|
|
837
|
+
return typeof error === "object" && error !== null && error.code === LOCK_NOT_AVAILABLE;
|
|
838
|
+
}
|
|
839
|
+
function toSettlementIntent(row) {
|
|
840
|
+
if (!row.channel_before) {
|
|
841
|
+
throw new Error(`x402 settlement ${row.settlement_id} has no reconciliation snapshot`);
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
settlementId: row.settlement_id,
|
|
845
|
+
channelId: row.channel_id,
|
|
846
|
+
operation: row.operation,
|
|
847
|
+
effectId: row.effect_id,
|
|
848
|
+
paymentId: row.payment_id,
|
|
849
|
+
payload: row.payment_payload,
|
|
850
|
+
requirements: row.payment_requirements,
|
|
851
|
+
channelBefore: row.channel_before,
|
|
852
|
+
...row.channel_at_submission ? { channelAtSubmission: row.channel_at_submission } : {},
|
|
853
|
+
...row.submission_block !== null ? { submissionBlock: row.submission_block } : {},
|
|
854
|
+
...row.pending_id ? { pendingId: row.pending_id } : {},
|
|
855
|
+
status: row.status,
|
|
856
|
+
...row.settle_response ? { response: row.settle_response } : {},
|
|
857
|
+
createdAt: row.created_at.getTime(),
|
|
858
|
+
updatedAt: row.updated_at.getTime(),
|
|
859
|
+
...row.write_off_note !== null ? { writeOffNote: row.write_off_note } : {},
|
|
860
|
+
...row.resolved_at !== null ? { resolvedAt: Number(row.resolved_at) } : {}
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
function canRefreshPreparedDepositAuthorization(stored, intent, nowMs) {
|
|
864
|
+
if (stored.status !== "prepared" || stored.operation !== "fund" || !isDeepStrictEqual(stored.requirements, intent.requirements))
|
|
865
|
+
return false;
|
|
866
|
+
const before = refreshableDepositAuthorization(stored.payload);
|
|
867
|
+
const after = refreshableDepositAuthorization(intent.payload);
|
|
868
|
+
if (!before || !after || !isDeepStrictEqual(before.shape, after.shape)) return false;
|
|
869
|
+
const nowSeconds = BigInt(Math.floor(nowMs / 1e3));
|
|
870
|
+
return before.validBefore < nowSeconds && after.validBefore > nowSeconds;
|
|
871
|
+
}
|
|
872
|
+
function refreshableDepositAuthorization(payload) {
|
|
873
|
+
const shape = structuredClone(payload);
|
|
874
|
+
const body = objectRecord(shape.payload);
|
|
875
|
+
const deposit = objectRecord(body?.deposit);
|
|
876
|
+
const wrapper = objectRecord(deposit?.authorization);
|
|
877
|
+
const authorization = objectRecord(wrapper?.erc3009Authorization);
|
|
878
|
+
const validBefore = authorization?.validBefore;
|
|
879
|
+
const signature = authorization?.signature;
|
|
880
|
+
if (!authorization || body?.type !== "deposit" || typeof validBefore !== "string" || !/^\d+$/.test(validBefore) || typeof signature !== "string" || signature.length === 0)
|
|
881
|
+
return void 0;
|
|
882
|
+
delete authorization.validBefore;
|
|
883
|
+
delete authorization.salt;
|
|
884
|
+
delete authorization.signature;
|
|
885
|
+
return { validBefore: BigInt(validBefore), shape };
|
|
886
|
+
}
|
|
887
|
+
function objectRecord(value) {
|
|
888
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
889
|
+
}
|
|
890
|
+
async function lockChannel(client, channelId) {
|
|
891
|
+
await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 1762))`, [
|
|
892
|
+
normalizeChannelId(channelId)
|
|
893
|
+
]);
|
|
894
|
+
}
|
|
895
|
+
async function writeChannel(client, channelId, channel) {
|
|
896
|
+
await client.query(
|
|
897
|
+
`INSERT INTO x402_batch_channels (
|
|
898
|
+
channel_id, channel_config, charged_cumulative_amount, signed_max_claimable,
|
|
899
|
+
signature, balance, total_claimed, withdraw_requested_at, refund_nonce,
|
|
900
|
+
onchain_synced_at, last_request_timestamp, pending_request
|
|
901
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
902
|
+
ON CONFLICT (channel_id) DO UPDATE SET
|
|
903
|
+
channel_config = EXCLUDED.channel_config,
|
|
904
|
+
charged_cumulative_amount = EXCLUDED.charged_cumulative_amount,
|
|
905
|
+
signed_max_claimable = EXCLUDED.signed_max_claimable,
|
|
906
|
+
signature = EXCLUDED.signature,
|
|
907
|
+
balance = EXCLUDED.balance,
|
|
908
|
+
total_claimed = EXCLUDED.total_claimed,
|
|
909
|
+
withdraw_requested_at = EXCLUDED.withdraw_requested_at,
|
|
910
|
+
refund_nonce = EXCLUDED.refund_nonce,
|
|
911
|
+
onchain_synced_at = EXCLUDED.onchain_synced_at,
|
|
912
|
+
last_request_timestamp = EXCLUDED.last_request_timestamp,
|
|
913
|
+
pending_request = EXCLUDED.pending_request`,
|
|
914
|
+
[
|
|
915
|
+
channelId,
|
|
916
|
+
channel.channelConfig,
|
|
917
|
+
channel.chargedCumulativeAmount,
|
|
918
|
+
channel.signedMaxClaimable,
|
|
919
|
+
channel.signature,
|
|
920
|
+
channel.balance,
|
|
921
|
+
channel.totalClaimed,
|
|
922
|
+
channel.withdrawRequestedAt,
|
|
923
|
+
channel.refundNonce,
|
|
924
|
+
channel.onchainSyncedAt ?? null,
|
|
925
|
+
channel.lastRequestTimestamp,
|
|
926
|
+
channel.pendingRequest ?? null
|
|
927
|
+
]
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
function toChannel(row) {
|
|
931
|
+
return {
|
|
932
|
+
channelId: row.channel_id,
|
|
933
|
+
channelConfig: row.channel_config,
|
|
934
|
+
chargedCumulativeAmount: row.charged_cumulative_amount,
|
|
935
|
+
signedMaxClaimable: row.signed_max_claimable,
|
|
936
|
+
signature: row.signature,
|
|
937
|
+
balance: row.balance,
|
|
938
|
+
totalClaimed: row.total_claimed,
|
|
939
|
+
withdrawRequestedAt: safeInteger(row.withdraw_requested_at, "withdraw_requested_at"),
|
|
940
|
+
refundNonce: safeInteger(row.refund_nonce, "refund_nonce"),
|
|
941
|
+
...row.onchain_synced_at !== null ? { onchainSyncedAt: safeInteger(row.onchain_synced_at, "onchain_synced_at") } : {},
|
|
942
|
+
lastRequestTimestamp: safeInteger(row.last_request_timestamp, "last_request_timestamp"),
|
|
943
|
+
...row.pending_request ? { pendingRequest: row.pending_request } : {}
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
function assertChannelTransition(current, next, expectedId, sanctionedRefundBase) {
|
|
947
|
+
if (normalizeChannelId(next.channelId) !== expectedId) {
|
|
948
|
+
throw new Error(`x402 channel update changed channelId ${expectedId}`);
|
|
949
|
+
}
|
|
950
|
+
if (!current) return;
|
|
951
|
+
if (!isDeepStrictEqual(current.channelConfig, next.channelConfig)) {
|
|
952
|
+
throw new Error(`x402 channel ${expectedId} attempted to mutate immutable channelConfig`);
|
|
953
|
+
}
|
|
954
|
+
const revokesUnclaimedAuthority = sanctionedRefundBase !== void 0 && BigInt(next.chargedCumulativeAmount) < BigInt(current.chargedCumulativeAmount) && BigInt(next.chargedCumulativeAmount) === BigInt(sanctionedRefundBase) && BigInt(next.chargedCumulativeAmount) >= BigInt(next.totalClaimed) && next.signedMaxClaimable === current.signedMaxClaimable && next.signature === current.signature && current.pendingRequest === void 0 && next.pendingRequest === void 0;
|
|
955
|
+
if (BigInt(next.chargedCumulativeAmount) < BigInt(current.chargedCumulativeAmount) && !revokesUnclaimedAuthority) {
|
|
956
|
+
throw new Error(`x402 channel ${expectedId} attempted to decrease chargedCumulativeAmount`);
|
|
957
|
+
}
|
|
958
|
+
if (BigInt(next.totalClaimed) < BigInt(current.totalClaimed)) {
|
|
959
|
+
throw new Error(`x402 channel ${expectedId} attempted to decrease totalClaimed`);
|
|
960
|
+
}
|
|
961
|
+
const reservesRevokedVoucher = BigInt(next.signedMaxClaimable) < BigInt(current.signedMaxClaimable) && next.chargedCumulativeAmount === current.chargedCumulativeAmount && next.signedMaxClaimable === next.chargedCumulativeAmount && current.pendingRequest === void 0 && next.pendingRequest !== void 0;
|
|
962
|
+
if (BigInt(next.signedMaxClaimable) < BigInt(current.signedMaxClaimable) && !reservesRevokedVoucher && (!current.pendingRequest || next.pendingRequest || BigInt(next.signedMaxClaimable) < BigInt(next.chargedCumulativeAmount))) {
|
|
963
|
+
throw new Error(`x402 channel ${expectedId} attempted to decrease signedMaxClaimable`);
|
|
964
|
+
}
|
|
965
|
+
if (next.refundNonce < current.refundNonce) {
|
|
966
|
+
throw new Error(`x402 channel ${expectedId} attempted to decrease refundNonce`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
function sameChannel(left, right) {
|
|
970
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
971
|
+
}
|
|
972
|
+
function cloneChannel(channel) {
|
|
973
|
+
return structuredClone(channel);
|
|
974
|
+
}
|
|
975
|
+
function normalizeChannelId(channelId) {
|
|
976
|
+
const normalized = channelId.toLowerCase();
|
|
977
|
+
if (!/^0x[0-9a-f]{64}$/.test(normalized)) {
|
|
978
|
+
throw new Error(`x402 channelId must be a canonical bytes32 value; got ${channelId}`);
|
|
979
|
+
}
|
|
980
|
+
return normalized;
|
|
981
|
+
}
|
|
982
|
+
function safeInteger(value, field) {
|
|
983
|
+
const parsed = Number(value);
|
|
984
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
985
|
+
throw new Error(`x402 channel ${field} exceeds JavaScript's safe integer range`);
|
|
986
|
+
}
|
|
987
|
+
return parsed;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// src/sdk/server/credit-ledger.ts
|
|
991
|
+
var CreditLedger = class {
|
|
992
|
+
constructor(pool, tempoSessionStore) {
|
|
993
|
+
this.pool = pool;
|
|
994
|
+
this.tempoSessionStore = tempoSessionStore;
|
|
995
|
+
}
|
|
996
|
+
pool;
|
|
997
|
+
tempoSessionStore;
|
|
998
|
+
/**
|
|
999
|
+
* Postgres-backed: balances survive a restart and are shared across the
|
|
1000
|
+
* fleet, so this DVM may advertise credit (internal-review).
|
|
1001
|
+
*/
|
|
1002
|
+
durable = true;
|
|
1003
|
+
x402Settlements;
|
|
1004
|
+
/**
|
|
1005
|
+
* Bind the durable x402 settlement state this ledger gates spending on
|
|
1006
|
+
* (internal-review).
|
|
1007
|
+
*
|
|
1008
|
+
* Late, because the settlement store is built with the batch-settlement
|
|
1009
|
+
* server, which is built *after* the ledger it reads earned draws from. An
|
|
1010
|
+
* unbound ledger simply does not gate — correct for a DVM with no x402
|
|
1011
|
+
* channel storage, where no settlement row can exist to wedge.
|
|
1012
|
+
*/
|
|
1013
|
+
useX402SettlementGate(gate) {
|
|
1014
|
+
this.x402Settlements = gate;
|
|
1015
|
+
}
|
|
1016
|
+
/** Create the `credits` / `credit_draws` tables if absent. Call once at SDK boot. */
|
|
1017
|
+
async init() {
|
|
1018
|
+
await withSdkInitLock(this.pool, () => this.createTables());
|
|
1019
|
+
}
|
|
1020
|
+
/** The boot DDL itself — always runs under {@link withSdkInitLock} (internal-review). */
|
|
1021
|
+
async createTables() {
|
|
1022
|
+
await this.pool.query(`
|
|
1023
|
+
CREATE TABLE IF NOT EXISTS credits (
|
|
1024
|
+
credit_id TEXT PRIMARY KEY,
|
|
1025
|
+
caller_pubkey TEXT NOT NULL,
|
|
1026
|
+
currency TEXT NOT NULL,
|
|
1027
|
+
balance_micro BIGINT NOT NULL,
|
|
1028
|
+
expiry_ms BIGINT NOT NULL,
|
|
1029
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
1030
|
+
last_ledger_seq BIGINT NOT NULL DEFAULT 0,
|
|
1031
|
+
created_at BIGINT NOT NULL,
|
|
1032
|
+
rail TEXT,
|
|
1033
|
+
native_asset TEXT,
|
|
1034
|
+
mint TEXT,
|
|
1035
|
+
funding_ref TEXT,
|
|
1036
|
+
tempo_channel_id TEXT,
|
|
1037
|
+
msats_remaining BIGINT NOT NULL DEFAULT 0,
|
|
1038
|
+
native_remaining BIGINT,
|
|
1039
|
+
x402_channel_id TEXT
|
|
1040
|
+
);
|
|
1041
|
+
`);
|
|
1042
|
+
await this.pool.query(`
|
|
1043
|
+
CREATE TABLE IF NOT EXISTS tempo_credit_losses (
|
|
1044
|
+
credit_id TEXT PRIMARY KEY,
|
|
1045
|
+
channel_id TEXT NOT NULL,
|
|
1046
|
+
caller_pubkey TEXT NOT NULL,
|
|
1047
|
+
currency TEXT NOT NULL,
|
|
1048
|
+
former_balance_micro BIGINT NOT NULL,
|
|
1049
|
+
settled_on_chain_native BIGINT NOT NULL,
|
|
1050
|
+
highest_voucher_native BIGINT NOT NULL,
|
|
1051
|
+
consumed_service_micro BIGINT NOT NULL,
|
|
1052
|
+
consumed_native BIGINT NOT NULL,
|
|
1053
|
+
observed_at BIGINT NOT NULL
|
|
1054
|
+
);
|
|
1055
|
+
`);
|
|
1056
|
+
await this.pool.query(
|
|
1057
|
+
`CREATE INDEX IF NOT EXISTS idx_tempo_credit_losses_observed
|
|
1058
|
+
ON tempo_credit_losses(observed_at, credit_id);`
|
|
1059
|
+
);
|
|
1060
|
+
await this.pool.query(`
|
|
1061
|
+
CREATE TABLE IF NOT EXISTS x402_credit_losses (
|
|
1062
|
+
credit_id TEXT PRIMARY KEY,
|
|
1063
|
+
channel_id TEXT NOT NULL,
|
|
1064
|
+
caller_pubkey TEXT NOT NULL,
|
|
1065
|
+
currency TEXT NOT NULL,
|
|
1066
|
+
former_balance_micro BIGINT NOT NULL,
|
|
1067
|
+
channel_balance_native BIGINT NOT NULL,
|
|
1068
|
+
total_claimed_native BIGINT NOT NULL,
|
|
1069
|
+
consumed_service_micro BIGINT NOT NULL,
|
|
1070
|
+
observed_at BIGINT NOT NULL
|
|
1071
|
+
);
|
|
1072
|
+
`);
|
|
1073
|
+
await this.pool.query(
|
|
1074
|
+
`CREATE INDEX IF NOT EXISTS idx_x402_credit_losses_observed
|
|
1075
|
+
ON x402_credit_losses(observed_at, credit_id);`
|
|
1076
|
+
);
|
|
1077
|
+
for (const column of [
|
|
1078
|
+
"rail TEXT",
|
|
1079
|
+
"native_asset TEXT",
|
|
1080
|
+
"mint TEXT",
|
|
1081
|
+
"funding_ref TEXT",
|
|
1082
|
+
"tempo_channel_id TEXT",
|
|
1083
|
+
"msats_remaining BIGINT NOT NULL DEFAULT 0",
|
|
1084
|
+
"native_remaining BIGINT",
|
|
1085
|
+
"x402_channel_id TEXT"
|
|
1086
|
+
]) {
|
|
1087
|
+
await this.pool.query(`ALTER TABLE credits ADD COLUMN IF NOT EXISTS ${column}`);
|
|
1088
|
+
}
|
|
1089
|
+
await this.pool.query(
|
|
1090
|
+
`CREATE INDEX IF NOT EXISTS idx_credits_caller ON credits(caller_pubkey);`
|
|
1091
|
+
);
|
|
1092
|
+
await this.pool.query(
|
|
1093
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS credits_x402_channel_uidx
|
|
1094
|
+
ON credits(x402_channel_id) WHERE x402_channel_id IS NOT NULL;`
|
|
1095
|
+
);
|
|
1096
|
+
await this.pool.query(`
|
|
1097
|
+
CREATE TABLE IF NOT EXISTS credit_draws (
|
|
1098
|
+
credit_id TEXT NOT NULL,
|
|
1099
|
+
draw_id TEXT NOT NULL,
|
|
1100
|
+
amount_micro BIGINT NOT NULL,
|
|
1101
|
+
job_id TEXT,
|
|
1102
|
+
ledger_seq BIGINT NOT NULL,
|
|
1103
|
+
status TEXT NOT NULL,
|
|
1104
|
+
balance_after_micro BIGINT NOT NULL,
|
|
1105
|
+
created_at BIGINT NOT NULL,
|
|
1106
|
+
resolved_at BIGINT,
|
|
1107
|
+
draw_msats BIGINT NOT NULL DEFAULT 0,
|
|
1108
|
+
draw_native BIGINT,
|
|
1109
|
+
grown_micro BIGINT NOT NULL DEFAULT 0,
|
|
1110
|
+
PRIMARY KEY (credit_id, draw_id)
|
|
1111
|
+
);
|
|
1112
|
+
`);
|
|
1113
|
+
for (const column of [
|
|
1114
|
+
"draw_msats BIGINT NOT NULL DEFAULT 0",
|
|
1115
|
+
"draw_native BIGINT",
|
|
1116
|
+
"grown_micro BIGINT NOT NULL DEFAULT 0",
|
|
1117
|
+
"allocated_msats BIGINT"
|
|
1118
|
+
]) {
|
|
1119
|
+
await this.pool.query(`ALTER TABLE credit_draws ADD COLUMN IF NOT EXISTS ${column}`);
|
|
1120
|
+
}
|
|
1121
|
+
await this.pool.query(
|
|
1122
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS credit_draws_seq_uidx
|
|
1123
|
+
ON credit_draws(credit_id, ledger_seq);`
|
|
1124
|
+
);
|
|
1125
|
+
await this.pool.query(
|
|
1126
|
+
`CREATE INDEX IF NOT EXISTS idx_credit_draws_pending
|
|
1127
|
+
ON credit_draws(credit_id) WHERE status = 'pending';`
|
|
1128
|
+
);
|
|
1129
|
+
await this.pool.query(
|
|
1130
|
+
`CREATE INDEX IF NOT EXISTS idx_credit_draws_pending_age
|
|
1131
|
+
ON credit_draws(created_at, credit_id, ledger_seq) WHERE status = 'pending';`
|
|
1132
|
+
);
|
|
1133
|
+
await this.pool.query(
|
|
1134
|
+
`CREATE INDEX IF NOT EXISTS idx_credit_draws_job_id
|
|
1135
|
+
ON credit_draws(job_id) WHERE job_id IS NOT NULL;`
|
|
1136
|
+
);
|
|
1137
|
+
await this.pool.query(`
|
|
1138
|
+
CREATE TABLE IF NOT EXISTS credit_fundings (
|
|
1139
|
+
credit_id TEXT NOT NULL,
|
|
1140
|
+
fund_id TEXT NOT NULL,
|
|
1141
|
+
amount_micro BIGINT NOT NULL,
|
|
1142
|
+
rail TEXT,
|
|
1143
|
+
created_at BIGINT NOT NULL,
|
|
1144
|
+
PRIMARY KEY (credit_id, fund_id)
|
|
1145
|
+
);
|
|
1146
|
+
`);
|
|
1147
|
+
await this.pool.query(`ALTER TABLE credit_fundings ADD COLUMN IF NOT EXISTS rail TEXT`);
|
|
1148
|
+
for (const column of [
|
|
1149
|
+
"caller_pubkey TEXT",
|
|
1150
|
+
"balance_after_micro BIGINT",
|
|
1151
|
+
"ledger_seq BIGINT",
|
|
1152
|
+
"receipt JSONB"
|
|
1153
|
+
]) {
|
|
1154
|
+
await this.pool.query(`ALTER TABLE credit_fundings ADD COLUMN IF NOT EXISTS ${column}`);
|
|
1155
|
+
}
|
|
1156
|
+
await this.pool.query(`
|
|
1157
|
+
CREATE TABLE IF NOT EXISTS credit_funding_lots (
|
|
1158
|
+
lot_id TEXT PRIMARY KEY,
|
|
1159
|
+
credit_id TEXT NOT NULL,
|
|
1160
|
+
rail TEXT NOT NULL,
|
|
1161
|
+
sats_funded BIGINT NOT NULL,
|
|
1162
|
+
credited_micro BIGINT NOT NULL,
|
|
1163
|
+
remaining_micro BIGINT NOT NULL,
|
|
1164
|
+
funding_ref TEXT,
|
|
1165
|
+
created_at BIGINT NOT NULL
|
|
1166
|
+
);
|
|
1167
|
+
`);
|
|
1168
|
+
await this.pool.query(
|
|
1169
|
+
`CREATE INDEX IF NOT EXISTS idx_credit_funding_lots_open
|
|
1170
|
+
ON credit_funding_lots(credit_id, created_at, lot_id) WHERE remaining_micro > 0;`
|
|
1171
|
+
);
|
|
1172
|
+
await this.backfillFundingLots();
|
|
1173
|
+
await this.pool.query(`
|
|
1174
|
+
CREATE TABLE IF NOT EXISTS credit_drains (
|
|
1175
|
+
credit_id TEXT NOT NULL,
|
|
1176
|
+
drain_id TEXT NOT NULL,
|
|
1177
|
+
method TEXT NOT NULL,
|
|
1178
|
+
payout JSONB NOT NULL,
|
|
1179
|
+
amount_micro BIGINT NOT NULL,
|
|
1180
|
+
balance_after_micro BIGINT NOT NULL,
|
|
1181
|
+
drained_msats BIGINT NOT NULL DEFAULT 0,
|
|
1182
|
+
drained_native BIGINT,
|
|
1183
|
+
owed_sats BIGINT,
|
|
1184
|
+
lot_debits JSONB,
|
|
1185
|
+
ledger_seq BIGINT NOT NULL,
|
|
1186
|
+
status TEXT NOT NULL,
|
|
1187
|
+
token TEXT,
|
|
1188
|
+
sent_ref JSONB,
|
|
1189
|
+
receipts JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
1190
|
+
created_at BIGINT NOT NULL,
|
|
1191
|
+
parked_at BIGINT,
|
|
1192
|
+
picked_up_at BIGINT,
|
|
1193
|
+
sent_at BIGINT,
|
|
1194
|
+
released_at BIGINT,
|
|
1195
|
+
PRIMARY KEY (credit_id, drain_id)
|
|
1196
|
+
);
|
|
1197
|
+
`);
|
|
1198
|
+
await this.pool.query(
|
|
1199
|
+
`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS balance_after_micro BIGINT NOT NULL DEFAULT 0`
|
|
1200
|
+
);
|
|
1201
|
+
await this.pool.query(
|
|
1202
|
+
`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS drained_msats BIGINT NOT NULL DEFAULT 0`
|
|
1203
|
+
);
|
|
1204
|
+
await this.pool.query(
|
|
1205
|
+
`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS drained_native BIGINT`
|
|
1206
|
+
);
|
|
1207
|
+
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS owed_sats BIGINT`);
|
|
1208
|
+
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS lot_debits JSONB`);
|
|
1209
|
+
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS released_at BIGINT`);
|
|
1210
|
+
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS fulfilment JSONB`);
|
|
1211
|
+
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS write_off_note TEXT`);
|
|
1212
|
+
await this.pool.query(
|
|
1213
|
+
`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS written_off_at BIGINT`
|
|
1214
|
+
);
|
|
1215
|
+
await this.pool.query(
|
|
1216
|
+
`UPDATE credit_drains d SET drained_native = d.amount_micro
|
|
1217
|
+
FROM credits c
|
|
1218
|
+
WHERE d.credit_id = c.credit_id
|
|
1219
|
+
AND c.tempo_channel_id IS NOT NULL
|
|
1220
|
+
AND d.drained_native IS NULL`
|
|
1221
|
+
);
|
|
1222
|
+
await this.pool.query(`UPDATE credits SET rail = 'tempo' WHERE rail = 'mpp'`);
|
|
1223
|
+
await this.pool.query(`UPDATE credit_fundings SET rail = 'tempo' WHERE rail = 'mpp'`);
|
|
1224
|
+
await this.pool.query(`UPDATE credit_drains SET method = 'tempo' WHERE method = 'mpp'`);
|
|
1225
|
+
await this.pool.query(
|
|
1226
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS credit_drains_seq_uidx
|
|
1227
|
+
ON credit_drains(credit_id, ledger_seq);`
|
|
1228
|
+
);
|
|
1229
|
+
await this.pool.query(
|
|
1230
|
+
`CREATE INDEX IF NOT EXISTS idx_credit_drains_pending
|
|
1231
|
+
ON credit_drains(status) WHERE status = 'pending';`
|
|
1232
|
+
);
|
|
1233
|
+
await this.pool.query(`
|
|
1234
|
+
CREATE TABLE IF NOT EXISTS credit_invoices (
|
|
1235
|
+
credit_id TEXT NOT NULL,
|
|
1236
|
+
fund_id TEXT NOT NULL,
|
|
1237
|
+
caller_pubkey TEXT NOT NULL,
|
|
1238
|
+
currency TEXT NOT NULL,
|
|
1239
|
+
amount_micro BIGINT NOT NULL,
|
|
1240
|
+
amount_msats BIGINT NOT NULL,
|
|
1241
|
+
payment_hash TEXT NOT NULL,
|
|
1242
|
+
bolt11 TEXT NOT NULL,
|
|
1243
|
+
status TEXT NOT NULL,
|
|
1244
|
+
blocked_reason TEXT,
|
|
1245
|
+
expires_at BIGINT NOT NULL,
|
|
1246
|
+
created_at BIGINT NOT NULL,
|
|
1247
|
+
settled_at BIGINT,
|
|
1248
|
+
PRIMARY KEY (credit_id, fund_id)
|
|
1249
|
+
);
|
|
1250
|
+
`);
|
|
1251
|
+
await this.pool.query(
|
|
1252
|
+
`ALTER TABLE credit_invoices ADD COLUMN IF NOT EXISTS blocked_reason TEXT`
|
|
1253
|
+
);
|
|
1254
|
+
await this.pool.query(
|
|
1255
|
+
`ALTER TABLE credit_invoices ADD COLUMN IF NOT EXISTS reconciled_credit_id TEXT`
|
|
1256
|
+
);
|
|
1257
|
+
await this.pool.query(
|
|
1258
|
+
`ALTER TABLE credit_invoices ADD COLUMN IF NOT EXISTS reconciled_fund_id TEXT`
|
|
1259
|
+
);
|
|
1260
|
+
await this.pool.query(
|
|
1261
|
+
`ALTER TABLE credit_invoices ADD COLUMN IF NOT EXISTS resolved_at BIGINT`
|
|
1262
|
+
);
|
|
1263
|
+
await this.pool.query(
|
|
1264
|
+
`ALTER TABLE credit_invoices ADD COLUMN IF NOT EXISTS write_off_note TEXT`
|
|
1265
|
+
);
|
|
1266
|
+
await this.pool.query(
|
|
1267
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS credit_invoices_hash_uidx
|
|
1268
|
+
ON credit_invoices(payment_hash);`
|
|
1269
|
+
);
|
|
1270
|
+
await this.pool.query(
|
|
1271
|
+
`CREATE INDEX IF NOT EXISTS idx_credit_invoices_pending
|
|
1272
|
+
ON credit_invoices(caller_pubkey, created_at) WHERE status = 'pending';`
|
|
1273
|
+
);
|
|
1274
|
+
await this.pool.query(
|
|
1275
|
+
`CREATE INDEX IF NOT EXISTS idx_credit_invoices_blocked
|
|
1276
|
+
ON credit_invoices(created_at, credit_id, fund_id) WHERE status = 'blocked';`
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Give every open non-channel Bitcoin credit a funding lot (internal-review).
|
|
1281
|
+
*
|
|
1282
|
+
* Runs inside the boot DDL, is guarded per credit by `NOT EXISTS`, and is
|
|
1283
|
+
* therefore both the one-time migration and a standing self-heal for a
|
|
1284
|
+
* credit that somehow reaches a positive balance with no lot behind it.
|
|
1285
|
+
*
|
|
1286
|
+
* The lot is derived from `credits.msats_remaining`, not replayed out of
|
|
1287
|
+
* `credit_fundings`. That is not a shortcut: `credit_fundings` records only
|
|
1288
|
+
* `{amount_micro, rail}` — no instrument amount at all — and is not written
|
|
1289
|
+
* on every funding path, whereas `msats_remaining` **is** the credit's
|
|
1290
|
+
* current unspent rail value, maintained pro rata on every draw since
|
|
1291
|
+
* internal-review. One synthetic lot pairing it with `balance_micro` therefore
|
|
1292
|
+
* reproduces today's in-kind position exactly, and FIFO over a single lot is
|
|
1293
|
+
* trivially correct. Historic per-funding rates are unrecoverable and would
|
|
1294
|
+
* change nothing: only the remaining position is owed.
|
|
1295
|
+
*
|
|
1296
|
+
* The semantics change is deliberately retroactive (internal-review) — every
|
|
1297
|
+
* current holder is first-party, so there is one regime and no legacy
|
|
1298
|
+
* branch.
|
|
1299
|
+
*/
|
|
1300
|
+
async backfillFundingLots() {
|
|
1301
|
+
const { rowCount } = await this.pool.query(
|
|
1302
|
+
`INSERT INTO credit_funding_lots
|
|
1303
|
+
(lot_id, credit_id, rail, sats_funded, credited_micro, remaining_micro, funding_ref, created_at)
|
|
1304
|
+
SELECT 'lot:backfill:' || c.credit_id, c.credit_id, c.rail,
|
|
1305
|
+
c.msats_remaining / 1000, c.balance_micro, c.balance_micro, c.funding_ref, c.created_at
|
|
1306
|
+
FROM credits c
|
|
1307
|
+
WHERE ${BITCOIN_CREDIT_PREDICATE}
|
|
1308
|
+
AND NOT EXISTS (SELECT 1 FROM credit_funding_lots l WHERE l.credit_id = c.credit_id)
|
|
1309
|
+
ON CONFLICT (lot_id) DO NOTHING`
|
|
1310
|
+
);
|
|
1311
|
+
const { rows } = await this.pool.query(
|
|
1312
|
+
`SELECT COUNT(*) FILTER (WHERE cov.covered_micro < c.balance_micro) AS uncovered,
|
|
1313
|
+
COUNT(*) FILTER (WHERE cov.covered_micro >= c.balance_micro AND cov.basisless_micro > 0)
|
|
1314
|
+
AS unbacked
|
|
1315
|
+
FROM credits c
|
|
1316
|
+
CROSS JOIN LATERAL (${LOT_COVERAGE_LATERAL}) cov
|
|
1317
|
+
WHERE ${BITCOIN_CREDIT_PREDICATE}`
|
|
1318
|
+
);
|
|
1319
|
+
const uncovered = Number(rows[0]?.uncovered ?? 0);
|
|
1320
|
+
const unbacked = Number(rows[0]?.unbacked ?? 0);
|
|
1321
|
+
if (rowCount === 0 && uncovered === 0 && unbacked === 0) return;
|
|
1322
|
+
console.warn(
|
|
1323
|
+
JSON.stringify({
|
|
1324
|
+
level: uncovered > 0 ? "warn" : "info",
|
|
1325
|
+
event: "credit_funding_lot_backfill",
|
|
1326
|
+
backfilled: rowCount ?? 0,
|
|
1327
|
+
uncovered_credits: uncovered,
|
|
1328
|
+
unbacked_credits: unbacked,
|
|
1329
|
+
message: uncovered > 0 ? "Some open Bitcoin credits hold more balance than their funding lots cover. Those reclaims are priced off a live rate rather than in kind until the gap is repaired." : "Funding lots are in place for every open Bitcoin credit."
|
|
1330
|
+
})
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Create a credit, or top up an existing one (same `creditId`). Top-ups add
|
|
1335
|
+
* to the balance and overwrite `expiry_ms` with the provided value — funding
|
|
1336
|
+
* an expired credit revives it (spec §5 carry-forward: the credit is a
|
|
1337
|
+
* rolling buffer, not per-job escrow). The existing row's `caller_pubkey`
|
|
1338
|
+
* and `currency` must match or the fund is refused with a typed error —
|
|
1339
|
+
* without that check a top-up against someone else's `credit_id` would
|
|
1340
|
+
* silently merge two callers' money.
|
|
1341
|
+
*
|
|
1342
|
+
* Pass `tx` (a client inside a caller-owned `BEGIN`) to commit the rail
|
|
1343
|
+
* receive and the ledger credit atomically (spec condition 3 — the internal-review
|
|
1344
|
+
* verifier does this). The ledger issues **no** transaction control on `tx`;
|
|
1345
|
+
* the upsert is a single statement, so without `tx` it is equally atomic on
|
|
1346
|
+
* the pool.
|
|
1347
|
+
*
|
|
1348
|
+
* `basis` records the rail value behind the fiat (internal-review) so each draw can
|
|
1349
|
+
* be allocated its share of the rail-native amount actually received. A
|
|
1350
|
+
* top-up on a different rail than the credit's is refused with
|
|
1351
|
+
* `rail_mismatch`: sats and USDC microunits aren't summable, so a blended
|
|
1352
|
+
* credit would have no coherent native basis to allocate from.
|
|
1353
|
+
*
|
|
1354
|
+
* It is **required** (internal-review), in the type and again at runtime via
|
|
1355
|
+
* {@link assertFundingBasis}. A basis-less fund wrote `rail = NULL`, and a
|
|
1356
|
+
* draw against such a credit settles — a real debit — while
|
|
1357
|
+
* `railAsFundingMethod` correctly declines to guess a rail, so `bookRevenue`
|
|
1358
|
+
* books nothing and outstanding liability (`deposits − draw revenue`) stays
|
|
1359
|
+
* overstated by the drawn amount forever. `credits.rail` stays nullable
|
|
1360
|
+
* because rows written before this change still exist in live databases:
|
|
1361
|
+
* **write is closed, read is not.** A legacy row adopts a rail from its first
|
|
1362
|
+
* typed top-up via the `COALESCE` below.
|
|
1363
|
+
*
|
|
1364
|
+
* The rail-native **instrument** is pinned at first funding too, and both
|
|
1365
|
+
* channel guards are symmetric for the same reason (internal-review). A reusable
|
|
1366
|
+
* channel — Tempo session or x402 batch-settlement — may only ever top up
|
|
1367
|
+
* the credit it opened, and a credit opened by a one-shot (a Tempo charge, an
|
|
1368
|
+
* x402 exact authorization) may never adopt one. The x402 half used to admit
|
|
1369
|
+
* `credits.x402_channel_id IS NULL`, so a channel deposit bound itself to a
|
|
1370
|
+
* credit already holding exact-funded value: `earnedNativeForX402Channel`
|
|
1371
|
+
* then read that credit's whole settled-draw history as the channel's
|
|
1372
|
+
* earnings, and the first claim tick swept a deposit nobody had consumed,
|
|
1373
|
+
* while the unspent balance lost every exit (the drain's refund base goes
|
|
1374
|
+
* below `totalClaimed`, and a channel-bound credit refuses every non-x402
|
|
1375
|
+
* drain). `credits_x402_channel_uidx` does not cover this: it guards
|
|
1376
|
+
* one-credit-per-channel, not one-source-per-credit.
|
|
1377
|
+
*
|
|
1378
|
+
* An x402 channel deposit is additionally refused `settlement_pending` while
|
|
1379
|
+
* that channel carries an unresolved refund (internal-review) — see the gate read
|
|
1380
|
+
* below. This method is the authority on that rule, as it is on the binding
|
|
1381
|
+
* rules above; every door preflights it where a refusal is still free.
|
|
1382
|
+
*/
|
|
1383
|
+
async fund(args) {
|
|
1384
|
+
assertAmount(args.amountMicro, { min: 1 });
|
|
1385
|
+
if (!Number.isSafeInteger(args.expiryMs) || args.expiryMs <= 0) {
|
|
1386
|
+
throw new CreditLedgerError(
|
|
1387
|
+
"invalid_expiry",
|
|
1388
|
+
`expiryMs must be a positive safe integer (got ${args.expiryMs})`
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
const b = assertFundingBasis(args.basis);
|
|
1392
|
+
const q = args.tx ?? this.pool;
|
|
1393
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
1394
|
+
const creditId = args.creditId ?? randomUUID();
|
|
1395
|
+
const wedged = await this.blockingX402Refund(
|
|
1396
|
+
b.x402Channel?.channelId,
|
|
1397
|
+
X402_SPEND_BLOCKING_SETTLEMENT_STATUSES
|
|
1398
|
+
);
|
|
1399
|
+
if (wedged) throw x402SettlementPending(creditId, wedged);
|
|
1400
|
+
let rows;
|
|
1401
|
+
try {
|
|
1402
|
+
({ rows } = await q.query(
|
|
1403
|
+
`INSERT INTO credits (credit_id, caller_pubkey, currency, balance_micro, expiry_ms, status, created_at,
|
|
1404
|
+
rail, native_asset, mint, funding_ref, msats_remaining, native_remaining,
|
|
1405
|
+
tempo_channel_id, x402_channel_id)
|
|
1406
|
+
VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
1407
|
+
ON CONFLICT (credit_id) DO UPDATE
|
|
1408
|
+
SET balance_micro = credits.balance_micro + EXCLUDED.balance_micro,
|
|
1409
|
+
expiry_ms = EXCLUDED.expiry_ms,
|
|
1410
|
+
rail = COALESCE(credits.rail, EXCLUDED.rail),
|
|
1411
|
+
native_asset = COALESCE(EXCLUDED.native_asset, credits.native_asset),
|
|
1412
|
+
mint = COALESCE(EXCLUDED.mint, credits.mint),
|
|
1413
|
+
funding_ref = COALESCE(EXCLUDED.funding_ref, credits.funding_ref),
|
|
1414
|
+
tempo_channel_id = COALESCE(credits.tempo_channel_id, EXCLUDED.tempo_channel_id),
|
|
1415
|
+
msats_remaining = credits.msats_remaining + EXCLUDED.msats_remaining,
|
|
1416
|
+
native_remaining = CASE
|
|
1417
|
+
WHEN EXCLUDED.native_remaining IS NULL THEN credits.native_remaining
|
|
1418
|
+
ELSE COALESCE(credits.native_remaining, 0) + EXCLUDED.native_remaining
|
|
1419
|
+
END,
|
|
1420
|
+
x402_channel_id = COALESCE(credits.x402_channel_id, EXCLUDED.x402_channel_id)
|
|
1421
|
+
WHERE credits.caller_pubkey = EXCLUDED.caller_pubkey
|
|
1422
|
+
AND credits.status = 'active'
|
|
1423
|
+
AND credits.currency = EXCLUDED.currency
|
|
1424
|
+
AND (credits.rail IS NULL OR credits.rail = EXCLUDED.rail)
|
|
1425
|
+
AND ((credits.tempo_channel_id IS NULL AND EXCLUDED.tempo_channel_id IS NULL)
|
|
1426
|
+
OR credits.tempo_channel_id = EXCLUDED.tempo_channel_id)
|
|
1427
|
+
AND ((credits.x402_channel_id IS NULL AND EXCLUDED.x402_channel_id IS NULL)
|
|
1428
|
+
OR credits.x402_channel_id = EXCLUDED.x402_channel_id)
|
|
1429
|
+
RETURNING *`,
|
|
1430
|
+
[
|
|
1431
|
+
creditId,
|
|
1432
|
+
args.callerPubkey,
|
|
1433
|
+
args.currency,
|
|
1434
|
+
args.amountMicro,
|
|
1435
|
+
args.expiryMs,
|
|
1436
|
+
nowMs,
|
|
1437
|
+
b.rail,
|
|
1438
|
+
b.nativeAsset ?? null,
|
|
1439
|
+
b.mint ?? null,
|
|
1440
|
+
b.fundingRef ?? null,
|
|
1441
|
+
b.paidMsats,
|
|
1442
|
+
b.nativeAmount ?? null,
|
|
1443
|
+
b.tempoSession?.channelId.toLowerCase() ?? null,
|
|
1444
|
+
b.x402Channel?.channelId ?? null
|
|
1445
|
+
]
|
|
1446
|
+
));
|
|
1447
|
+
} catch (err) {
|
|
1448
|
+
if (b.x402Channel && isUniqueViolation(err)) {
|
|
1449
|
+
const channelId = b.x402Channel.channelId;
|
|
1450
|
+
const { rows: bound } = await this.pool.query(
|
|
1451
|
+
`SELECT credit_id, rail, x402_channel_id
|
|
1452
|
+
FROM credits
|
|
1453
|
+
WHERE x402_channel_id = $1`,
|
|
1454
|
+
[channelId]
|
|
1455
|
+
);
|
|
1456
|
+
throw new CreditLedgerError(
|
|
1457
|
+
"rail_mismatch",
|
|
1458
|
+
bound[0] ? `x402 channel ${channelId} is already bound to credit ${bound[0].credit_id}` : `x402 channel ${channelId} is already bound to another credit`,
|
|
1459
|
+
{
|
|
1460
|
+
expectedRail: bound[0]?.rail ?? "x402",
|
|
1461
|
+
expectedInstrument: "x402_channel",
|
|
1462
|
+
expectedChannelId: channelId,
|
|
1463
|
+
...bound[0] ? { boundCreditId: bound[0].credit_id } : {}
|
|
1464
|
+
},
|
|
1465
|
+
{ cause: err }
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
throw err;
|
|
1469
|
+
}
|
|
1470
|
+
if (rows.length === 0) {
|
|
1471
|
+
const { rows: existing } = await q.query(
|
|
1472
|
+
`SELECT caller_pubkey, currency, status, rail, tempo_channel_id, x402_channel_id
|
|
1473
|
+
FROM credits WHERE credit_id = $1`,
|
|
1474
|
+
[creditId]
|
|
1475
|
+
);
|
|
1476
|
+
const row = existing[0];
|
|
1477
|
+
if (row.caller_pubkey !== args.callerPubkey) {
|
|
1478
|
+
throw new CreditLedgerError(
|
|
1479
|
+
"caller_mismatch",
|
|
1480
|
+
`credit ${creditId} belongs to a different caller pubkey`
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
if (row.status !== "active") throw terminalCreditError(creditId, row.status);
|
|
1484
|
+
if (row.currency !== args.currency) {
|
|
1485
|
+
throw new CreditLedgerError(
|
|
1486
|
+
"currency_mismatch",
|
|
1487
|
+
`credit ${creditId} is denominated in ${row.currency}, not ${args.currency}`
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
if (row.rail !== null && row.rail !== b.rail) {
|
|
1491
|
+
throw new CreditLedgerError(
|
|
1492
|
+
"rail_mismatch",
|
|
1493
|
+
`credit ${creditId} was funded on the ${row.rail} rail, not ${b.rail}`,
|
|
1494
|
+
{ expectedRail: row.rail }
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
const incomingTempoChannel = b.tempoSession?.channelId.toLowerCase() ?? null;
|
|
1498
|
+
if (row.tempo_channel_id !== incomingTempoChannel) {
|
|
1499
|
+
throw new CreditLedgerError(
|
|
1500
|
+
"rail_mismatch",
|
|
1501
|
+
row.tempo_channel_id ? `credit ${creditId} belongs to Tempo session channel ${row.tempo_channel_id}` : row.rail === "tempo" ? `credit ${creditId} was funded by a one-shot Tempo charge and cannot accept a Tempo session voucher` : `credit ${creditId} is not bound to a Tempo session channel and cannot adopt one`,
|
|
1502
|
+
{
|
|
1503
|
+
expectedRail: row.rail,
|
|
1504
|
+
...row.tempo_channel_id || row.rail === "tempo" ? { expectedInstrument: row.tempo_channel_id ? "tempo_session" : "tempo_charge" } : {},
|
|
1505
|
+
expectedChannelId: row.tempo_channel_id
|
|
1506
|
+
}
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
if (row.x402_channel_id !== (b.x402Channel?.channelId ?? null)) {
|
|
1510
|
+
throw new CreditLedgerError(
|
|
1511
|
+
"rail_mismatch",
|
|
1512
|
+
row.x402_channel_id ? `credit ${creditId} is bound to x402 settlement channel ${row.x402_channel_id}` : row.rail === "x402" ? `credit ${creditId} was funded by a one-shot x402 exact payment and cannot accept an x402 batch-settlement channel deposit` : `credit ${creditId} is not bound to an x402 batch-settlement channel and cannot adopt one`,
|
|
1513
|
+
{
|
|
1514
|
+
expectedRail: row.rail,
|
|
1515
|
+
...row.x402_channel_id || row.rail === "x402" ? { expectedInstrument: row.x402_channel_id ? "x402_channel" : "x402_exact" } : {},
|
|
1516
|
+
expectedChannelId: row.x402_channel_id
|
|
1517
|
+
}
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
throw new CreditLedgerError(
|
|
1521
|
+
"rail_mismatch",
|
|
1522
|
+
`credit ${creditId} was funded on the ${row.rail} rail, not ${b.rail}`,
|
|
1523
|
+
{ expectedRail: row.rail }
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
await this.recordFundingLot(q, creditId, args.amountMicro, b, nowMs);
|
|
1527
|
+
return this.snapshotFromRow(rows[0], q, nowMs);
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Record this funding's in-kind basis as a lot (internal-review).
|
|
1531
|
+
*
|
|
1532
|
+
* Called from inside {@link fund}, on the same handle, so the lot shares
|
|
1533
|
+
* whatever transaction the rail opened — `withCommitTx` for Cashu,
|
|
1534
|
+
* `ProcessedPaymentStore.withTx` for x402/Tempo, `settleInvoice`'s own
|
|
1535
|
+
* `BEGIN` for Lightning. The funding-atomicity invariant therefore extends
|
|
1536
|
+
* to it structurally rather than by every call site remembering: a lot
|
|
1537
|
+
* cannot exist without its ledger credit, or the credit without its lot.
|
|
1538
|
+
*
|
|
1539
|
+
* Only a **channel-backed** credit writes no lot: it is in-kind already —
|
|
1540
|
+
* the escrow itself retains the unused value — so a lot on one would be
|
|
1541
|
+
* counted as Bitcoin deposit liability the hub does not hold.
|
|
1542
|
+
*
|
|
1543
|
+
* Every other non-channel Bitcoin funding writes one, the implicit N=1
|
|
1544
|
+
* per-call payment included. That one funds and draws the same amount in
|
|
1545
|
+
* this transaction, so it looks like a lot born depleted — but the draw is a
|
|
1546
|
+
* *pending hold*, not a debit, and only a `completed` job settles it
|
|
1547
|
+
* (`JobManager.settlesDraw`). A failed, cancelled or stale-swept job
|
|
1548
|
+
* releases the hold and the whole funded amount becomes reclaimable balance,
|
|
1549
|
+
* so suppressing the lot here would leave routine traffic reclaiming off a
|
|
1550
|
+
* live rate and missing from the sweep floor. One small insert on the hot
|
|
1551
|
+
* path buys the invariant the liability read depends on: open lots cover
|
|
1552
|
+
* `balance_micro` for every Bitcoin credit, always.
|
|
1553
|
+
*
|
|
1554
|
+
* A zero-sats basis still writes a lot. It is honest — the credit exists and
|
|
1555
|
+
* we know its rail — and it is what makes "covered but unbacked" a
|
|
1556
|
+
* distinguishable state from "no lot at all" for the reclaim and the
|
|
1557
|
+
* liability rollup, instead of both reading as a gap.
|
|
1558
|
+
*/
|
|
1559
|
+
async recordFundingLot(q, creditId, amountMicro, basis, nowMs) {
|
|
1560
|
+
if (!isNonChannelBitcoinRail(basis.rail)) return;
|
|
1561
|
+
if (basis.tempoSession || basis.x402Channel) return;
|
|
1562
|
+
await q.query(
|
|
1563
|
+
`INSERT INTO credit_funding_lots
|
|
1564
|
+
(lot_id, credit_id, rail, sats_funded, credited_micro, remaining_micro, funding_ref, created_at)
|
|
1565
|
+
VALUES ($1, $2, $3, $4, $5, $5, $6, $7)`,
|
|
1566
|
+
[
|
|
1567
|
+
`lot:${randomUUID()}`,
|
|
1568
|
+
creditId,
|
|
1569
|
+
basis.rail,
|
|
1570
|
+
Math.floor(basis.paidMsats / 1e3),
|
|
1571
|
+
amountMicro,
|
|
1572
|
+
basis.fundingRef ?? null,
|
|
1573
|
+
nowMs
|
|
1574
|
+
]
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Place a two-phase `pending` hold of `amountMicro` against the credit and
|
|
1579
|
+
* assign the next `ledger_seq`. Idempotent on `(creditId, drawId)`: a replay
|
|
1580
|
+
* returns the original `{balanceAfterMicro, ledgerSeq}` with
|
|
1581
|
+
* `replayed: true` and places no second hold — this is how a caller recovers
|
|
1582
|
+
* a lost response (spec condition 1). A replay whose `amountMicro` or
|
|
1583
|
+
* `jobId` differs from the recorded draw is not a replay but a money bug in
|
|
1584
|
+
* the caller, refused with `draw_conflict`.
|
|
1585
|
+
*
|
|
1586
|
+
* The replay lookup runs **before** the expiry check, deliberately: a draw
|
|
1587
|
+
* placed pre-expiry must stay recoverable after the credit expires, or the
|
|
1588
|
+
* DVM holds a debit the caller can never reconcile. Fresh draws on an
|
|
1589
|
+
* expired credit are refused with `credit_expired`; the balance stays
|
|
1590
|
+
* intact and readable (spec §5: expiry ends spending, never ownership).
|
|
1591
|
+
*
|
|
1592
|
+
* Pass `tx` (a client inside a caller-owned `BEGIN`) to place the hold in
|
|
1593
|
+
* the same transaction as the rail commit and the `fund` upsert (spec
|
|
1594
|
+
* condition 3 — the internal-review implicit N=1 path). The ledger issues no
|
|
1595
|
+
* transaction control on `tx`; the `SELECT … FOR UPDATE` row lock is still
|
|
1596
|
+
* taken on the caller's transaction, so the locking invariant holds.
|
|
1597
|
+
*/
|
|
1598
|
+
async draw(args) {
|
|
1599
|
+
assertAmount(args.amountMicro, { min: 0 });
|
|
1600
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
1601
|
+
const jobId = args.jobId ?? null;
|
|
1602
|
+
const run = async (client, credit, tempoLifecycle) => {
|
|
1603
|
+
const existing = await this.readDraw(client, args.creditId, args.drawId);
|
|
1604
|
+
if (existing) {
|
|
1605
|
+
const recordedJobId = existing.job_id ?? null;
|
|
1606
|
+
if (toSafeInt(existing.amount_micro) !== args.amountMicro || recordedJobId !== jobId) {
|
|
1607
|
+
throw new CreditLedgerError(
|
|
1608
|
+
"draw_conflict",
|
|
1609
|
+
`draw ${args.drawId} on credit ${args.creditId} was recorded with different parameters`,
|
|
1610
|
+
{
|
|
1611
|
+
expectedAmountMicro: toSafeInt(existing.amount_micro),
|
|
1612
|
+
expectedJobId: recordedJobId
|
|
1613
|
+
}
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
return { ...drawResultFromRow(existing, credit), replayed: true };
|
|
1617
|
+
}
|
|
1618
|
+
if (credit.status !== "active") throw terminalCreditError(args.creditId, credit.status);
|
|
1619
|
+
if (tempoLifecycle && (tempoLifecycle.finalized || tempoLifecycle.closeRequestedAt !== 0n)) {
|
|
1620
|
+
throw new CreditLedgerError(
|
|
1621
|
+
"tempo_channel_closing",
|
|
1622
|
+
`credit ${args.creditId} is bound to a Tempo channel that is closing or finalized`
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
const x402Refund = await this.blockingX402Refund(
|
|
1626
|
+
credit.x402_channel_id,
|
|
1627
|
+
X402_SPEND_BLOCKING_SETTLEMENT_STATUSES
|
|
1628
|
+
);
|
|
1629
|
+
if (x402Refund) throw x402SettlementPending(args.creditId, x402Refund);
|
|
1630
|
+
const balanceMicro = toSafeInt(credit.balance_micro);
|
|
1631
|
+
const expiryMs = toSafeInt(credit.expiry_ms);
|
|
1632
|
+
if (expiryMs <= nowMs) {
|
|
1633
|
+
throw new CreditLedgerError("credit_expired", `credit ${args.creditId} expired`, {
|
|
1634
|
+
expiryMs,
|
|
1635
|
+
balanceMicro
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
const pending = await this.pendingSums(client, args.creditId);
|
|
1639
|
+
const available = balanceMicro - pending.micro;
|
|
1640
|
+
if (args.amountMicro > available) {
|
|
1641
|
+
throw new CreditLedgerError(
|
|
1642
|
+
"insufficient_credit",
|
|
1643
|
+
`draw of ${args.amountMicro} exceeds available balance ${available}`,
|
|
1644
|
+
{ availableMicro: available, balanceMicro, requestedMicro: args.amountMicro }
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
const nativeRemaining = credit.native_remaining === null ? null : toSafeInt(credit.native_remaining);
|
|
1648
|
+
const railValue = allocateDrawValue({
|
|
1649
|
+
amountMicro: args.amountMicro,
|
|
1650
|
+
availableMicro: available,
|
|
1651
|
+
availableMsats: Math.max(0, toSafeInt(credit.msats_remaining) - pending.msats),
|
|
1652
|
+
availableNative: nativeRemaining === null ? null : Math.max(0, nativeRemaining - pending.native)
|
|
1653
|
+
});
|
|
1654
|
+
const ledgerSeq = toSafeInt(credit.last_ledger_seq) + 1;
|
|
1655
|
+
const balanceAfterMicro = available - args.amountMicro;
|
|
1656
|
+
await client.query(`UPDATE credits SET last_ledger_seq = $2 WHERE credit_id = $1`, [
|
|
1657
|
+
args.creditId,
|
|
1658
|
+
ledgerSeq
|
|
1659
|
+
]);
|
|
1660
|
+
await client.query(
|
|
1661
|
+
`INSERT INTO credit_draws
|
|
1662
|
+
(credit_id, draw_id, amount_micro, job_id, ledger_seq, status, balance_after_micro, created_at,
|
|
1663
|
+
draw_msats, draw_native)
|
|
1664
|
+
VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8, $9)`,
|
|
1665
|
+
[
|
|
1666
|
+
args.creditId,
|
|
1667
|
+
args.drawId,
|
|
1668
|
+
args.amountMicro,
|
|
1669
|
+
jobId,
|
|
1670
|
+
ledgerSeq,
|
|
1671
|
+
balanceAfterMicro,
|
|
1672
|
+
nowMs,
|
|
1673
|
+
railValue.drawMsats,
|
|
1674
|
+
railValue.drawNative
|
|
1675
|
+
]
|
|
1676
|
+
);
|
|
1677
|
+
return {
|
|
1678
|
+
creditId: args.creditId,
|
|
1679
|
+
drawId: args.drawId,
|
|
1680
|
+
amountMicro: args.amountMicro,
|
|
1681
|
+
balanceAfterMicro,
|
|
1682
|
+
ledgerSeq,
|
|
1683
|
+
status: "pending",
|
|
1684
|
+
replayed: false,
|
|
1685
|
+
railValue: { ...railValue, ...creditBasisFromRow(credit) },
|
|
1686
|
+
resolvedAt: null
|
|
1687
|
+
};
|
|
1688
|
+
};
|
|
1689
|
+
const query = args.tx ?? this.pool;
|
|
1690
|
+
const { rows } = await query.query(
|
|
1691
|
+
`SELECT tempo_channel_id FROM credits WHERE credit_id = $1`,
|
|
1692
|
+
[args.creditId]
|
|
1693
|
+
);
|
|
1694
|
+
const channelId = rows[0]?.tempo_channel_id;
|
|
1695
|
+
if (channelId && this.tempoSessionStore) {
|
|
1696
|
+
return this.tempoSessionStore.withDrawPlacement(
|
|
1697
|
+
channelId,
|
|
1698
|
+
async (tx, lifecycle) => {
|
|
1699
|
+
const credit = await this.lockCreditRow(tx, args.creditId);
|
|
1700
|
+
return run(tx, credit, lifecycle);
|
|
1701
|
+
},
|
|
1702
|
+
args.tx
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
if (args.tx) {
|
|
1706
|
+
const credit = await this.lockCreditRow(args.tx, args.creditId);
|
|
1707
|
+
return run(args.tx, credit);
|
|
1708
|
+
}
|
|
1709
|
+
return this.withCreditLock(args.creditId, (client, credit) => run(client, credit));
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* The unresolved refund on `channelId` that must stop this ledger effect, if
|
|
1713
|
+
* any (internal-review).
|
|
1714
|
+
*
|
|
1715
|
+
* `undefined` whenever there is nothing to ask — an unbound credit, or a DVM
|
|
1716
|
+
* with no durable settlement store, where a settlement row cannot exist.
|
|
1717
|
+
*
|
|
1718
|
+
* Public since internal-review, so `/v1/credit`'s pre-payment preflight can ask the
|
|
1719
|
+
* same question `fund` will ask from inside the rail transaction — one read,
|
|
1720
|
+
* one answer, rather than a second copy of the rule in the routes.
|
|
1721
|
+
*/
|
|
1722
|
+
async blockingX402Refund(channelId, statuses) {
|
|
1723
|
+
if (!channelId || !this.x402Settlements) return void 0;
|
|
1724
|
+
return this.x402Settlements.pendingRefundSettlement(channelId, statuses);
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Grow a pending draw by `addAmountMicro` (internal-review) — the ledger half of a
|
|
1728
|
+
* mid-job `requestPayment` top-up. One job keeps **one** draw: the mid-job
|
|
1729
|
+
* money funds the same credit and enlarges the hold the upfront leg placed,
|
|
1730
|
+
* so the receipt's `ReceiptCredit` block countersigns the job's full cost
|
|
1731
|
+
* and `revenue_events` still books exactly one row per job.
|
|
1732
|
+
*
|
|
1733
|
+
* `draw()` cannot express this: a same-`draw_id` call with a different
|
|
1734
|
+
* amount is `draw_conflict` by design (a replayed draw must never re-price).
|
|
1735
|
+
*
|
|
1736
|
+
* The arithmetic is `draw()`'s, unchanged: `addAmountMicro` is checked
|
|
1737
|
+
* against — and `balance_after_micro` recomputed from — the balance net of
|
|
1738
|
+
* **every** pending hold, this draw's own included. That is deliberately the
|
|
1739
|
+
* conservative reading (a fresh draw of `addAmountMicro` would see exactly
|
|
1740
|
+
* the same figure), and it is what keeps the total held after growth inside
|
|
1741
|
+
* the credit's balance.
|
|
1742
|
+
*
|
|
1743
|
+
* The increment's rail value comes from {@link growthRailValue}: earmarked
|
|
1744
|
+
* to the funding that backs it when the caller names one (the mid-job case),
|
|
1745
|
+
* pro-rata otherwise. Either way the draws of a credit keep summing to
|
|
1746
|
+
* precisely what the rails paid (internal-review).
|
|
1747
|
+
*
|
|
1748
|
+
* **`ledger_seq` is not re-taken.** The per-credit sequence is gap-free
|
|
1749
|
+
* (`credit_draws_seq_uidx`), so moving this draw forward would strand its
|
|
1750
|
+
* original number. The consequence is deliberate and worth knowing: the
|
|
1751
|
+
* recorded `balance_after_micro` is the post-growth figure stamped at the
|
|
1752
|
+
* *original* seq, so a credit with interleaved sibling activity has a
|
|
1753
|
+
* trajectory that is truthful per draw rather than monotonic across seqs.
|
|
1754
|
+
*
|
|
1755
|
+
* **No expiry check**, matching `settle`/`release`: the job was accepted
|
|
1756
|
+
* pre-expiry and is still running. Refusing here would strand the handler
|
|
1757
|
+
* mid-flight over a clock the caller can't influence.
|
|
1758
|
+
*
|
|
1759
|
+
* **Not idempotent by itself.** Growth carries no key of its own — it
|
|
1760
|
+
* inherits the rail's (the accumulator's `(dvm_id, request_id)` UNIQUE rolls
|
|
1761
|
+
* the whole transaction back on replay; x402/mpp collide on the
|
|
1762
|
+
* `processed_payments` marker). Never call it outside a rail commit.
|
|
1763
|
+
*
|
|
1764
|
+
* **`capMicro` bounds cumulative growth at what the job cumulatively asked
|
|
1765
|
+
* (internal-review)**, and it is the only ceiling that can refuse a top-up: the
|
|
1766
|
+
* available-balance check cannot, because the `fund` a moment earlier in
|
|
1767
|
+
* this same transaction raised the balance by exactly the amount being
|
|
1768
|
+
* drawn. Excess is **granted partially or not at all rather than thrown** —
|
|
1769
|
+
* this runs inside the rail commit, so a throw would roll back cashu proofs
|
|
1770
|
+
* the caller already sent. The refused remainder stays funded balance the
|
|
1771
|
+
* caller owns and can drain.
|
|
1772
|
+
*/
|
|
1773
|
+
async growDraw(args) {
|
|
1774
|
+
assertAmount(args.addAmountMicro, { min: 1 });
|
|
1775
|
+
const run = async (client, credit) => {
|
|
1776
|
+
const existing = await this.readDraw(client, args.creditId, args.drawId);
|
|
1777
|
+
if (!existing) {
|
|
1778
|
+
throw new CreditLedgerError(
|
|
1779
|
+
"draw_not_found",
|
|
1780
|
+
`draw ${args.drawId} not found on credit ${args.creditId}`
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1783
|
+
if (existing.status !== "pending") {
|
|
1784
|
+
throw new CreditLedgerError(
|
|
1785
|
+
"invalid_draw_state",
|
|
1786
|
+
`cannot grow draw ${args.drawId}: status is ${existing.status}`,
|
|
1787
|
+
{ currentStatus: existing.status }
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
if (credit.status !== "active") throw terminalCreditError(args.creditId, credit.status);
|
|
1791
|
+
if (args.jobId !== void 0 && (existing.job_id ?? null) !== args.jobId) {
|
|
1792
|
+
throw new CreditLedgerError(
|
|
1793
|
+
"draw_conflict",
|
|
1794
|
+
`draw ${args.drawId} on credit ${args.creditId} belongs to a different job`,
|
|
1795
|
+
{ expectedJobId: existing.job_id ?? null }
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
const priorGrown = toSafeInt(existing.grown_micro);
|
|
1799
|
+
const headroom = args.capMicro === void 0 ? args.addAmountMicro : Math.max(0, args.capMicro - priorGrown);
|
|
1800
|
+
const grantMicro = Math.min(args.addAmountMicro, headroom);
|
|
1801
|
+
const balanceMicro = toSafeInt(credit.balance_micro);
|
|
1802
|
+
const pending = await this.pendingSums(client, args.creditId);
|
|
1803
|
+
const available = balanceMicro - pending.micro;
|
|
1804
|
+
if (grantMicro <= 0) {
|
|
1805
|
+
return {
|
|
1806
|
+
creditId: args.creditId,
|
|
1807
|
+
drawId: args.drawId,
|
|
1808
|
+
amountMicro: toSafeInt(existing.amount_micro),
|
|
1809
|
+
balanceAfterMicro: toSafeInt(existing.balance_after_micro),
|
|
1810
|
+
ledgerSeq: toSafeInt(existing.ledger_seq),
|
|
1811
|
+
status: "pending",
|
|
1812
|
+
replayed: false,
|
|
1813
|
+
railValue: {
|
|
1814
|
+
drawMsats: toSafeInt(existing.draw_msats),
|
|
1815
|
+
drawNative: existing.draw_native === null ? null : toSafeInt(existing.draw_native),
|
|
1816
|
+
...creditBasisFromRow(credit)
|
|
1817
|
+
},
|
|
1818
|
+
resolvedAt: null,
|
|
1819
|
+
addedRailValue: { drawMsats: 0, drawNative: null },
|
|
1820
|
+
addedAmountMicro: 0,
|
|
1821
|
+
cappedMicro: args.addAmountMicro
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
if (grantMicro > available) {
|
|
1825
|
+
throw new CreditLedgerError(
|
|
1826
|
+
"insufficient_credit",
|
|
1827
|
+
`top-up draw of ${grantMicro} exceeds available balance ${available}`,
|
|
1828
|
+
{ availableMicro: available, balanceMicro, requestedMicro: grantMicro }
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
const priorMsats = toSafeInt(existing.draw_msats);
|
|
1832
|
+
const priorNative = existing.draw_native === null ? null : toSafeInt(existing.draw_native);
|
|
1833
|
+
const nativeRemaining = credit.native_remaining === null ? null : toSafeInt(credit.native_remaining);
|
|
1834
|
+
const added = growthRailValue({
|
|
1835
|
+
earmark: clipEarmark(args.addRailValue, grantMicro, args.addAmountMicro),
|
|
1836
|
+
amountMicro: grantMicro,
|
|
1837
|
+
availableMicro: available,
|
|
1838
|
+
availableMsats: Math.max(0, toSafeInt(credit.msats_remaining) - pending.msats),
|
|
1839
|
+
availableNative: nativeRemaining === null ? null : Math.max(0, nativeRemaining - pending.native)
|
|
1840
|
+
});
|
|
1841
|
+
const amountMicro = toSafeInt(existing.amount_micro) + grantMicro;
|
|
1842
|
+
const balanceAfterMicro = available - grantMicro;
|
|
1843
|
+
const drawMsats = priorMsats + added.drawMsats;
|
|
1844
|
+
const drawNative = added.drawNative === null ? priorNative : (priorNative ?? 0) + added.drawNative;
|
|
1845
|
+
await client.query(
|
|
1846
|
+
`UPDATE credit_draws
|
|
1847
|
+
SET amount_micro = $3, balance_after_micro = $4, draw_msats = $5, draw_native = $6,
|
|
1848
|
+
grown_micro = $7
|
|
1849
|
+
WHERE credit_id = $1 AND draw_id = $2`,
|
|
1850
|
+
[
|
|
1851
|
+
args.creditId,
|
|
1852
|
+
args.drawId,
|
|
1853
|
+
amountMicro,
|
|
1854
|
+
balanceAfterMicro,
|
|
1855
|
+
drawMsats,
|
|
1856
|
+
drawNative,
|
|
1857
|
+
priorGrown + grantMicro
|
|
1858
|
+
]
|
|
1859
|
+
);
|
|
1860
|
+
return {
|
|
1861
|
+
creditId: args.creditId,
|
|
1862
|
+
drawId: args.drawId,
|
|
1863
|
+
amountMicro,
|
|
1864
|
+
balanceAfterMicro,
|
|
1865
|
+
ledgerSeq: toSafeInt(existing.ledger_seq),
|
|
1866
|
+
status: "pending",
|
|
1867
|
+
replayed: false,
|
|
1868
|
+
railValue: { drawMsats, drawNative, ...creditBasisFromRow(credit) },
|
|
1869
|
+
resolvedAt: null,
|
|
1870
|
+
addedRailValue: added,
|
|
1871
|
+
addedAmountMicro: grantMicro,
|
|
1872
|
+
cappedMicro: args.addAmountMicro - grantMicro
|
|
1873
|
+
};
|
|
1874
|
+
};
|
|
1875
|
+
if (args.tx) {
|
|
1876
|
+
const credit = await this.lockCreditRow(args.tx, args.creditId);
|
|
1877
|
+
return run(args.tx, credit);
|
|
1878
|
+
}
|
|
1879
|
+
return this.withCreditLock(args.creditId, run);
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Finalize a pending draw: the hold becomes a real debit
|
|
1883
|
+
* (`balance_micro −= amount`). Available balance is unchanged — the amount
|
|
1884
|
+
* moves from "held" to "spent" — so the `balanceAfterMicro` trajectory
|
|
1885
|
+
* recorded at draw time stays truthful for settled draws, which is what the
|
|
1886
|
+
* receipt chain countersigns. Idempotent: settling a settled draw returns
|
|
1887
|
+
* the original tuple. A released draw cannot be settled
|
|
1888
|
+
* (`invalid_draw_state`) — release-then-settle would debit money the caller
|
|
1889
|
+
* was already told is theirs again.
|
|
1890
|
+
*
|
|
1891
|
+
* Deliberately **no expiry check** here (nor on `release`): a job accepted
|
|
1892
|
+
* pre-expiry routinely reaches its terminal post-expiry, and refusing would
|
|
1893
|
+
* leak the hold forever. Expiry gates new draws only.
|
|
1894
|
+
*/
|
|
1895
|
+
async settle(args) {
|
|
1896
|
+
if (!this.tempoSessionStore) return this.resolveDraw({ ...args, to: "settled" });
|
|
1897
|
+
const { rows } = await this.pool.query(
|
|
1898
|
+
`SELECT tempo_channel_id FROM credits WHERE credit_id = $1`,
|
|
1899
|
+
[args.creditId]
|
|
1900
|
+
);
|
|
1901
|
+
const channelId = rows[0]?.tempo_channel_id;
|
|
1902
|
+
if (!channelId) return this.resolveDraw({ ...args, to: "settled" });
|
|
1903
|
+
return this.tempoSessionStore.withTerminalConsumption(channelId, async (tx, lifecycle) => {
|
|
1904
|
+
const credit = await this.lockCreditRow(tx, args.creditId);
|
|
1905
|
+
const draw = await this.readDraw(tx, args.creditId, args.drawId);
|
|
1906
|
+
if (!draw) {
|
|
1907
|
+
throw new CreditLedgerError(
|
|
1908
|
+
"draw_not_found",
|
|
1909
|
+
`draw ${args.drawId} not found on credit ${args.creditId}`
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
const drawNative = draw.draw_native === null ? null : BigInt(draw.draw_native);
|
|
1913
|
+
if (drawNative === null) {
|
|
1914
|
+
throw new CreditLedgerError(
|
|
1915
|
+
"invalid_basis",
|
|
1916
|
+
`Tempo session draw ${args.drawId} has no native value to consume`
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
if (draw.status === "settled") {
|
|
1920
|
+
const value2 = await this.resolveDrawLocked(tx, credit, { ...args, to: "settled" });
|
|
1921
|
+
return { value: value2, amount: drawNative, consume: false };
|
|
1922
|
+
}
|
|
1923
|
+
if (credit.status === "unbacked") {
|
|
1924
|
+
const value2 = await this.resolveDrawLocked(tx, credit, {
|
|
1925
|
+
...args,
|
|
1926
|
+
to: "released"
|
|
1927
|
+
});
|
|
1928
|
+
return { value: value2, amount: 0n, consume: false };
|
|
1929
|
+
}
|
|
1930
|
+
const closing = lifecycle.finalized || lifecycle.closeRequestedAt !== 0n;
|
|
1931
|
+
const covered = lifecycle.settledOnChain >= lifecycle.highestVoucherAmount;
|
|
1932
|
+
if (closing && !covered) {
|
|
1933
|
+
if (!lifecycle.finalized) {
|
|
1934
|
+
throw new CreditLedgerError(
|
|
1935
|
+
"tempo_settlement_pending",
|
|
1936
|
+
`Tempo channel settlement has not yet covered draw ${args.drawId}`
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
const value2 = await this.resolveDrawLocked(tx, credit, {
|
|
1940
|
+
...args,
|
|
1941
|
+
to: "released"
|
|
1942
|
+
});
|
|
1943
|
+
return { value: value2, amount: 0n, consume: false };
|
|
1944
|
+
}
|
|
1945
|
+
const value = await this.resolveDrawLocked(tx, credit, { ...args, to: "settled" });
|
|
1946
|
+
return {
|
|
1947
|
+
value,
|
|
1948
|
+
amount: drawNative,
|
|
1949
|
+
consume: !closing && !value.replayed
|
|
1950
|
+
};
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Release a pending draw: the hold evaporates, the balance is untouched —
|
|
1955
|
+
* this is how "no debit on job failure" is mechanically real (spec §1).
|
|
1956
|
+
* Idempotent: releasing a released draw returns the original tuple. A
|
|
1957
|
+
* settled draw cannot be released (`invalid_draw_state`) — un-settling
|
|
1958
|
+
* booked revenue is a reconciliation problem, not a ledger verb.
|
|
1959
|
+
*/
|
|
1960
|
+
async release(args) {
|
|
1961
|
+
return this.resolveDraw({ ...args, to: "released" });
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Record a fund-only top-up under its client-generated `fundId` (internal-review).
|
|
1965
|
+
* The deposit half of the evidence chain, and the top-up path's idempotency
|
|
1966
|
+
* key: `PRIMARY KEY (credit_id, fund_id)` means a concurrent duplicate
|
|
1967
|
+
* loses with a typed `funding_replayed` rather than crediting twice.
|
|
1968
|
+
*
|
|
1969
|
+
* Call it inside the same `tx` as {@link fund} — the rail commit, the
|
|
1970
|
+
* funding record, and the balance increment must land together or not at
|
|
1971
|
+
* all (spec §2 condition 3). Like `fund`, this issues no transaction
|
|
1972
|
+
* control on `tx`; it is a single statement.
|
|
1973
|
+
*
|
|
1974
|
+
* This does **not** move money on its own. `fund` still does the crediting;
|
|
1975
|
+
* this row is what lets a retry be answered with the original outcome.
|
|
1976
|
+
*/
|
|
1977
|
+
async recordFunding(args) {
|
|
1978
|
+
assertAmount(args.amountMicro, { min: 1 });
|
|
1979
|
+
const q = args.tx ?? this.pool;
|
|
1980
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
1981
|
+
const { rows } = await q.query(
|
|
1982
|
+
`INSERT INTO credit_fundings (credit_id, fund_id, amount_micro, rail, created_at)
|
|
1983
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
1984
|
+
ON CONFLICT (credit_id, fund_id) DO NOTHING
|
|
1985
|
+
RETURNING *`,
|
|
1986
|
+
[args.creditId, args.fundId, args.amountMicro, args.rail ?? null, nowMs]
|
|
1987
|
+
);
|
|
1988
|
+
if (rows.length === 0) {
|
|
1989
|
+
throw new CreditLedgerError(
|
|
1990
|
+
"funding_replayed",
|
|
1991
|
+
`funding ${args.fundId} on credit ${args.creditId} was already recorded`
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1994
|
+
return fundingRecordFromRow(rows[0]);
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* Read one funding record (no lock). The `/v1/credit` fund path checks this
|
|
1998
|
+
* **before** touching a rail, so a retried top-up costs nothing and returns
|
|
1999
|
+
* the original outcome instead of presenting an artifact the rail would
|
|
2000
|
+
* refuse as spent.
|
|
2001
|
+
*/
|
|
2002
|
+
async getFunding(args) {
|
|
2003
|
+
const { rows } = await this.pool.query(
|
|
2004
|
+
`SELECT * FROM credit_fundings WHERE credit_id = $1 AND fund_id = $2`,
|
|
2005
|
+
[args.creditId, args.fundId]
|
|
2006
|
+
);
|
|
2007
|
+
return rows.length > 0 ? fundingRecordFromRow(rows[0]) : void 0;
|
|
2008
|
+
}
|
|
2009
|
+
/** Fix the funding-time receipt tuple inside the rail's open transaction. */
|
|
2010
|
+
async completeFunding(args) {
|
|
2011
|
+
const q = args.tx ?? this.pool;
|
|
2012
|
+
const { rows } = await q.query(
|
|
2013
|
+
`UPDATE credit_fundings
|
|
2014
|
+
SET caller_pubkey = COALESCE(caller_pubkey, $3),
|
|
2015
|
+
balance_after_micro = COALESCE(balance_after_micro, $4),
|
|
2016
|
+
ledger_seq = COALESCE(ledger_seq, $5)
|
|
2017
|
+
WHERE credit_id = $1 AND fund_id = $2
|
|
2018
|
+
RETURNING *`,
|
|
2019
|
+
[args.creditId, args.fundId, args.callerPubkey, args.balanceAfterMicro, args.ledgerSeq]
|
|
2020
|
+
);
|
|
2021
|
+
if (rows.length === 0) {
|
|
2022
|
+
throw new CreditLedgerError(
|
|
2023
|
+
"credit_not_found",
|
|
2024
|
+
`funding ${args.fundId} on credit ${args.creditId} was not recorded`
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
return fundingRecordFromRow(rows[0]);
|
|
2028
|
+
}
|
|
2029
|
+
/** Persist the first signature for a funding; concurrent issuers read back the winner. */
|
|
2030
|
+
async saveFundingReceipt(args) {
|
|
2031
|
+
const { rows } = await this.pool.query(
|
|
2032
|
+
`UPDATE credit_fundings SET receipt = COALESCE(receipt, $3::jsonb)
|
|
2033
|
+
WHERE credit_id = $1 AND fund_id = $2
|
|
2034
|
+
RETURNING *`,
|
|
2035
|
+
[args.creditId, args.fundId, JSON.stringify(args.receipt)]
|
|
2036
|
+
);
|
|
2037
|
+
if (!rows[0]?.receipt) {
|
|
2038
|
+
throw new CreditLedgerError(
|
|
2039
|
+
"credit_not_found",
|
|
2040
|
+
`funding ${args.fundId} on credit ${args.creditId} was not recorded`
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
return rows[0].receipt;
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Record the bolt11 issued for a `(creditId, fundId)` top-up (internal-review), or
|
|
2047
|
+
* return the one already issued for it.
|
|
2048
|
+
*
|
|
2049
|
+
* **Returning the existing row is the point.** A caller re-polling an unpaid
|
|
2050
|
+
* top-up must get the invoice they were handed the first time; minting a
|
|
2051
|
+
* fresh bolt11 per attempt is the double-charge bug — they pay the second
|
|
2052
|
+
* one, the first stays payable, and both settle onto one `fund_id` that can
|
|
2053
|
+
* only be credited once.
|
|
2054
|
+
*/
|
|
2055
|
+
async recordInvoice(args) {
|
|
2056
|
+
assertAmount(args.amountMicro, { min: 1 });
|
|
2057
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
2058
|
+
let rows;
|
|
2059
|
+
try {
|
|
2060
|
+
({ rows } = await this.pool.query(
|
|
2061
|
+
`INSERT INTO credit_invoices (credit_id, fund_id, caller_pubkey, currency, amount_micro,
|
|
2062
|
+
amount_msats, payment_hash, bolt11, status, expires_at, created_at)
|
|
2063
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, $10)
|
|
2064
|
+
ON CONFLICT (credit_id, fund_id) DO NOTHING
|
|
2065
|
+
RETURNING *`,
|
|
2066
|
+
[
|
|
2067
|
+
args.creditId,
|
|
2068
|
+
args.fundId,
|
|
2069
|
+
args.callerPubkey,
|
|
2070
|
+
args.currency,
|
|
2071
|
+
args.amountMicro,
|
|
2072
|
+
args.amountMsats,
|
|
2073
|
+
args.paymentHash,
|
|
2074
|
+
args.bolt11,
|
|
2075
|
+
args.expiresAt,
|
|
2076
|
+
nowMs
|
|
2077
|
+
]
|
|
2078
|
+
));
|
|
2079
|
+
} catch (err) {
|
|
2080
|
+
if (isUniqueViolation(err)) {
|
|
2081
|
+
throw new CreditLedgerError(
|
|
2082
|
+
"invoice_conflict",
|
|
2083
|
+
`payment hash ${args.paymentHash} is already bound to another funding`,
|
|
2084
|
+
{},
|
|
2085
|
+
{ cause: err }
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
throw err;
|
|
2089
|
+
}
|
|
2090
|
+
if (rows.length > 0) return invoiceRecordFromRow(rows[0]);
|
|
2091
|
+
const existing = await this.getInvoice({ creditId: args.creditId, fundId: args.fundId });
|
|
2092
|
+
if (!existing) {
|
|
2093
|
+
throw new CreditLedgerError(
|
|
2094
|
+
"invoice_not_found",
|
|
2095
|
+
`invoice for funding ${args.fundId} on credit ${args.creditId} vanished between write and read`
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
return existing;
|
|
2099
|
+
}
|
|
2100
|
+
/** Read one invoice (no lock). */
|
|
2101
|
+
async getInvoice(args) {
|
|
2102
|
+
const { rows } = await this.pool.query(
|
|
2103
|
+
`SELECT * FROM credit_invoices WHERE credit_id = $1 AND fund_id = $2`,
|
|
2104
|
+
[args.creditId, args.fundId]
|
|
2105
|
+
);
|
|
2106
|
+
return rows.length > 0 ? invoiceRecordFromRow(rows[0]) : void 0;
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* A caller's outstanding invoices, oldest first — the settlement sweep's
|
|
2110
|
+
* read. Local and indexed, so the wallet is only consulted when something is
|
|
2111
|
+
* genuinely outstanding.
|
|
2112
|
+
*/
|
|
2113
|
+
async listPendingInvoices(args) {
|
|
2114
|
+
const { rows } = await this.pool.query(
|
|
2115
|
+
`SELECT * FROM credit_invoices
|
|
2116
|
+
WHERE caller_pubkey = $1 AND status = 'pending'
|
|
2117
|
+
AND ($2::text IS NULL OR credit_id = $2)
|
|
2118
|
+
ORDER BY created_at, fund_id
|
|
2119
|
+
LIMIT $3`,
|
|
2120
|
+
[args.callerPubkey, args.creditId ?? null, args.limit]
|
|
2121
|
+
);
|
|
2122
|
+
return rows.map(invoiceRecordFromRow);
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* Apply an observed Lightning settlement to the ledger — **the exactly-once
|
|
2126
|
+
* boundary** (internal-review; spec §2 condition 3 for a rail whose commit happens
|
|
2127
|
+
* off-box).
|
|
2128
|
+
*
|
|
2129
|
+
* One transaction covers the funding record, the balance, and the invoice's
|
|
2130
|
+
* own status flip, so the three can never disagree. A crash anywhere inside
|
|
2131
|
+
* leaves the invoice `pending` and the balance untouched: the next
|
|
2132
|
+
* `lookup_invoice` observes the same settled payment and credits it, once.
|
|
2133
|
+
* A replayed check after the commit sees `settled` and moves nothing.
|
|
2134
|
+
*
|
|
2135
|
+
* The lock is on the **invoice** row, not the credit row: the first
|
|
2136
|
+
* Lightning top-up is what opens the credit, so there is frequently no
|
|
2137
|
+
* `credits` row to take `FOR UPDATE` yet. That does not weaken the ledger's
|
|
2138
|
+
* concurrency contract — no `credit_draws` write happens here, and `fund`'s
|
|
2139
|
+
* upsert is a single statement — while still serialising two settlement
|
|
2140
|
+
* checks racing on the same invoice.
|
|
2141
|
+
*/
|
|
2142
|
+
async settleInvoice(args) {
|
|
2143
|
+
assertFundingBasis(args.basis);
|
|
2144
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
2145
|
+
const settledAt = args.settledAtMs ?? nowMs;
|
|
2146
|
+
const client = await this.pool.connect();
|
|
2147
|
+
try {
|
|
2148
|
+
await client.query("BEGIN");
|
|
2149
|
+
const { rows } = await client.query(
|
|
2150
|
+
`SELECT * FROM credit_invoices WHERE credit_id = $1 AND fund_id = $2 FOR UPDATE`,
|
|
2151
|
+
[args.creditId, args.fundId]
|
|
2152
|
+
);
|
|
2153
|
+
if (rows.length === 0) {
|
|
2154
|
+
throw new CreditLedgerError(
|
|
2155
|
+
"invoice_not_found",
|
|
2156
|
+
`no invoice for funding ${args.fundId} on credit ${args.creditId}`
|
|
2157
|
+
);
|
|
2158
|
+
}
|
|
2159
|
+
const invoice = invoiceRecordFromRow(rows[0]);
|
|
2160
|
+
if (invoice.status === "settled") {
|
|
2161
|
+
await client.query("COMMIT");
|
|
2162
|
+
const [funding2, credit2] = await Promise.all([
|
|
2163
|
+
this.getFunding({ creditId: args.creditId, fundId: args.fundId }),
|
|
2164
|
+
this.get(args.creditId, { nowMs })
|
|
2165
|
+
]);
|
|
2166
|
+
return { invoice, funding: funding2, credit: credit2, replayed: true };
|
|
2167
|
+
}
|
|
2168
|
+
const funding = await this.recordFunding({
|
|
2169
|
+
creditId: invoice.creditId,
|
|
2170
|
+
fundId: invoice.fundId,
|
|
2171
|
+
amountMicro: invoice.amountMicro,
|
|
2172
|
+
rail: "lightning",
|
|
2173
|
+
tx: client,
|
|
2174
|
+
nowMs
|
|
2175
|
+
});
|
|
2176
|
+
const credit = await this.fund({
|
|
2177
|
+
creditId: invoice.creditId,
|
|
2178
|
+
callerPubkey: invoice.callerPubkey,
|
|
2179
|
+
currency: invoice.currency,
|
|
2180
|
+
amountMicro: invoice.amountMicro,
|
|
2181
|
+
expiryMs: args.expiryMs,
|
|
2182
|
+
basis: args.basis,
|
|
2183
|
+
tx: client,
|
|
2184
|
+
nowMs
|
|
2185
|
+
});
|
|
2186
|
+
await this.completeFunding({
|
|
2187
|
+
creditId: invoice.creditId,
|
|
2188
|
+
fundId: invoice.fundId,
|
|
2189
|
+
callerPubkey: invoice.callerPubkey,
|
|
2190
|
+
balanceAfterMicro: credit.availableMicro,
|
|
2191
|
+
ledgerSeq: credit.lastLedgerSeq,
|
|
2192
|
+
tx: client
|
|
2193
|
+
});
|
|
2194
|
+
const settled = await client.query(
|
|
2195
|
+
`UPDATE credit_invoices SET status = 'settled', settled_at = $3
|
|
2196
|
+
WHERE credit_id = $1 AND fund_id = $2
|
|
2197
|
+
RETURNING *`,
|
|
2198
|
+
[args.creditId, args.fundId, settledAt]
|
|
2199
|
+
);
|
|
2200
|
+
if (args.depositOutbox) {
|
|
2201
|
+
await args.depositOutbox.enqueue(client, args.depositOutbox.payload);
|
|
2202
|
+
}
|
|
2203
|
+
await client.query("COMMIT");
|
|
2204
|
+
return {
|
|
2205
|
+
invoice: invoiceRecordFromRow(settled.rows[0]),
|
|
2206
|
+
funding,
|
|
2207
|
+
credit,
|
|
2208
|
+
replayed: false
|
|
2209
|
+
};
|
|
2210
|
+
} catch (err) {
|
|
2211
|
+
try {
|
|
2212
|
+
await client.query("ROLLBACK");
|
|
2213
|
+
} catch {
|
|
2214
|
+
}
|
|
2215
|
+
throw err;
|
|
2216
|
+
} finally {
|
|
2217
|
+
client.release();
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* Retire an invoice that expired without being paid — bookkeeping only, no
|
|
2222
|
+
* ledger effect. A CAS on `pending` so it can never overwrite a settlement
|
|
2223
|
+
* observed concurrently.
|
|
2224
|
+
*/
|
|
2225
|
+
async markInvoiceExpired(args) {
|
|
2226
|
+
await this.pool.query(
|
|
2227
|
+
`UPDATE credit_invoices SET status = 'expired'
|
|
2228
|
+
WHERE credit_id = $1 AND fund_id = $2 AND status = 'pending'`,
|
|
2229
|
+
[args.creditId, args.fundId]
|
|
2230
|
+
);
|
|
2231
|
+
return this.getInvoice(args);
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Retire an invoice that **was paid** and can never be credited (internal-review).
|
|
2235
|
+
*
|
|
2236
|
+
* Distinct from `expired` because the money is the opposite way round: an
|
|
2237
|
+
* expired invoice was never paid and owes nobody anything, whereas a blocked
|
|
2238
|
+
* one has sats sitting in the builder's wallet with no ledger row to put them
|
|
2239
|
+
* on. Only an operator can resolve that, so the row records `reason` and
|
|
2240
|
+
* stops being swept — leaving it `pending` would spend one of the sweep's few
|
|
2241
|
+
* slots re-deriving the same verdict on every request the caller makes, and
|
|
2242
|
+
* three of them would wedge that caller's sweep so a genuinely payable
|
|
2243
|
+
* invoice behind them never credits.
|
|
2244
|
+
*
|
|
2245
|
+
* A CAS on `pending`, like the expiry retirement: a settlement observed
|
|
2246
|
+
* concurrently must win.
|
|
2247
|
+
*/
|
|
2248
|
+
async markInvoiceBlocked(args) {
|
|
2249
|
+
await this.pool.query(
|
|
2250
|
+
`UPDATE credit_invoices SET status = 'blocked', blocked_reason = $3
|
|
2251
|
+
WHERE credit_id = $1 AND fund_id = $2 AND status = 'pending'`,
|
|
2252
|
+
[args.creditId, args.fundId, args.reason]
|
|
2253
|
+
);
|
|
2254
|
+
return this.getInvoice(args);
|
|
2255
|
+
}
|
|
2256
|
+
/** Read one invoice by the wallet's own identifier for the payment (no lock). */
|
|
2257
|
+
async getInvoiceByPaymentHash(paymentHash) {
|
|
2258
|
+
const { rows } = await this.pool.query(
|
|
2259
|
+
`SELECT * FROM credit_invoices WHERE payment_hash = $1`,
|
|
2260
|
+
[paymentHash]
|
|
2261
|
+
);
|
|
2262
|
+
return rows.length > 0 ? invoiceRecordFromRow(rows[0]) : void 0;
|
|
2263
|
+
}
|
|
2264
|
+
/**
|
|
2265
|
+
* The operator's queue: invoices the sweep gave up on (internal-review).
|
|
2266
|
+
*
|
|
2267
|
+
* **Keyset-paginated, not offset.** Blocked rows are terminal and never
|
|
2268
|
+
* self-clear, so a row the operator declines to act on sits at the head of
|
|
2269
|
+
* the age ordering forever; a fixed first page would starve everything
|
|
2270
|
+
* behind it on every run, which is the internal-review shape one page up.
|
|
2271
|
+
*
|
|
2272
|
+
* `includeResolved` widens to the operator-resolved statuses so a run can be
|
|
2273
|
+
* audited after the fact — the acceptance criterion this verb exists for.
|
|
2274
|
+
* Host-wide, like `listPendingDrains`: the ledger has no `dvm_id`, so on a
|
|
2275
|
+
* multi-mount host one builder's admin credential reads every mount's rows.
|
|
2276
|
+
*/
|
|
2277
|
+
async listBlockedInvoices(args) {
|
|
2278
|
+
const statuses = args.includeResolved ? ["blocked", "reconciled", "written_off"] : ["blocked"];
|
|
2279
|
+
const { rows } = await this.pool.query(
|
|
2280
|
+
`SELECT * FROM credit_invoices
|
|
2281
|
+
WHERE status = ANY($1::text[])
|
|
2282
|
+
AND ($2::bigint IS NULL
|
|
2283
|
+
OR (created_at, credit_id, fund_id) > ($2::bigint, $3::text, $4::text))
|
|
2284
|
+
ORDER BY created_at, credit_id, fund_id
|
|
2285
|
+
LIMIT $5`,
|
|
2286
|
+
[
|
|
2287
|
+
statuses,
|
|
2288
|
+
args.after?.createdAt ?? null,
|
|
2289
|
+
args.after?.creditId ?? null,
|
|
2290
|
+
args.after?.fundId ?? null,
|
|
2291
|
+
args.limit
|
|
2292
|
+
]
|
|
2293
|
+
);
|
|
2294
|
+
return rows.map(invoiceRecordFromRow);
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* Apply a blocked invoice's payment to a credit an operator named — the
|
|
2298
|
+
* repair for the one Lightning outcome the DVM cannot fix itself (internal-review).
|
|
2299
|
+
*
|
|
2300
|
+
* Shaped statement-for-statement on {@link settleInvoice}, because it is the
|
|
2301
|
+
* same money doing the same thing a different way: one transaction covering
|
|
2302
|
+
* the funding record, the balance, the invoice's status flip and the deposit
|
|
2303
|
+
* outbox row, so the four can never disagree. Three deliberate differences:
|
|
2304
|
+
*
|
|
2305
|
+
* - **The lock is taken on `payment_hash`**, which is UNIQUE and is the
|
|
2306
|
+
* identifier the operator actually holds (it is what the
|
|
2307
|
+
* `lightning_settlement_blocked` log carries and what names the payment in
|
|
2308
|
+
* their wallet). Invoice row first, credit row second via `fund`'s upsert —
|
|
2309
|
+
* the same order `settleInvoice` takes, which is what keeps a reconcile and
|
|
2310
|
+
* a concurrent settlement check from deadlocking against each other.
|
|
2311
|
+
* - **The funding lands at `(targetCreditId, paymentHash)`, not the invoice's
|
|
2312
|
+
* own `(credit_id, fund_id)`.** That key is frequently the reason the row
|
|
2313
|
+
* is blocked at all — `funding_replayed_on_cashu` means something else
|
|
2314
|
+
* already holds it — and the operator's most natural target is that very
|
|
2315
|
+
* credit. The payment hash cannot collide with it, and it makes all three
|
|
2316
|
+
* references to this payment agree: `basis.fundingRef`, the deposit's
|
|
2317
|
+
* `funding_id`, and the funding row's `fund_id`.
|
|
2318
|
+
* - **`written_off` is an accepted input status.** A write-off unwound
|
|
2319
|
+
* nothing, so an operator who closed a row by mistake must not need raw SQL
|
|
2320
|
+
* against a money table to reopen it.
|
|
2321
|
+
*
|
|
2322
|
+
* The invoice row's status — never the funding row — is the idempotency
|
|
2323
|
+
* source of truth. `fund_id` is caller-chosen and the payment hash is
|
|
2324
|
+
* disclosed to them, so a caller *can* squat `(theirCredit, thatHash)` with a
|
|
2325
|
+
* top-up on another rail; when they have, `recordFunding` throws
|
|
2326
|
+
* `funding_replayed` and that surfaces as its own loud failure rather than
|
|
2327
|
+
* being absorbed as "already reconciled".
|
|
2328
|
+
*/
|
|
2329
|
+
async reconcileBlockedInvoice(args) {
|
|
2330
|
+
assertFundingBasis(args.basis);
|
|
2331
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
2332
|
+
const client = await this.pool.connect();
|
|
2333
|
+
try {
|
|
2334
|
+
await client.query("BEGIN");
|
|
2335
|
+
const { rows } = await client.query(
|
|
2336
|
+
`SELECT * FROM credit_invoices WHERE payment_hash = $1 FOR UPDATE`,
|
|
2337
|
+
[args.paymentHash]
|
|
2338
|
+
);
|
|
2339
|
+
if (rows.length === 0) {
|
|
2340
|
+
throw new CreditLedgerError(
|
|
2341
|
+
"invoice_not_found",
|
|
2342
|
+
`no invoice for payment hash ${args.paymentHash}`
|
|
2343
|
+
);
|
|
2344
|
+
}
|
|
2345
|
+
const invoice = invoiceRecordFromRow(rows[0]);
|
|
2346
|
+
if (invoice.status === "reconciled") {
|
|
2347
|
+
await client.query("COMMIT");
|
|
2348
|
+
const [funding2, credit2] = await Promise.all([
|
|
2349
|
+
invoice.reconciledCreditId && invoice.reconciledFundId ? this.getFunding({
|
|
2350
|
+
creditId: invoice.reconciledCreditId,
|
|
2351
|
+
fundId: invoice.reconciledFundId
|
|
2352
|
+
}) : Promise.resolve(void 0),
|
|
2353
|
+
invoice.reconciledCreditId ? this.get(invoice.reconciledCreditId, { nowMs }) : Promise.resolve(void 0)
|
|
2354
|
+
]);
|
|
2355
|
+
return { invoice, funding: funding2, credit: credit2, replayed: true };
|
|
2356
|
+
}
|
|
2357
|
+
if (invoice.status !== "blocked" && invoice.status !== "written_off") {
|
|
2358
|
+
throw new CreditLedgerError(
|
|
2359
|
+
"invoice_not_blocked",
|
|
2360
|
+
`invoice ${args.paymentHash} is ${invoice.status}; only a blocked or written-off invoice can be reconciled`
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
2363
|
+
const funding = await this.recordFunding({
|
|
2364
|
+
creditId: args.targetCreditId,
|
|
2365
|
+
fundId: invoice.paymentHash,
|
|
2366
|
+
amountMicro: invoice.amountMicro,
|
|
2367
|
+
rail: "lightning",
|
|
2368
|
+
tx: client,
|
|
2369
|
+
nowMs
|
|
2370
|
+
});
|
|
2371
|
+
const credit = await this.fund({
|
|
2372
|
+
creditId: args.targetCreditId,
|
|
2373
|
+
callerPubkey: invoice.callerPubkey,
|
|
2374
|
+
currency: invoice.currency,
|
|
2375
|
+
amountMicro: invoice.amountMicro,
|
|
2376
|
+
expiryMs: args.expiryMs,
|
|
2377
|
+
basis: args.basis,
|
|
2378
|
+
tx: client,
|
|
2379
|
+
nowMs
|
|
2380
|
+
});
|
|
2381
|
+
await this.completeFunding({
|
|
2382
|
+
creditId: args.targetCreditId,
|
|
2383
|
+
fundId: invoice.paymentHash,
|
|
2384
|
+
callerPubkey: invoice.callerPubkey,
|
|
2385
|
+
balanceAfterMicro: credit.availableMicro,
|
|
2386
|
+
ledgerSeq: credit.lastLedgerSeq,
|
|
2387
|
+
tx: client
|
|
2388
|
+
});
|
|
2389
|
+
const reconciled = await client.query(
|
|
2390
|
+
`UPDATE credit_invoices
|
|
2391
|
+
SET status = 'reconciled',
|
|
2392
|
+
reconciled_credit_id = $2,
|
|
2393
|
+
reconciled_fund_id = $3,
|
|
2394
|
+
resolved_at = $4,
|
|
2395
|
+
settled_at = COALESCE($5, settled_at)
|
|
2396
|
+
WHERE payment_hash = $1
|
|
2397
|
+
RETURNING *`,
|
|
2398
|
+
[
|
|
2399
|
+
args.paymentHash,
|
|
2400
|
+
args.targetCreditId,
|
|
2401
|
+
invoice.paymentHash,
|
|
2402
|
+
nowMs,
|
|
2403
|
+
args.settledAtMs ?? null
|
|
2404
|
+
]
|
|
2405
|
+
);
|
|
2406
|
+
if (args.depositOutbox) {
|
|
2407
|
+
await args.depositOutbox.enqueue(client, args.depositOutbox.payload);
|
|
2408
|
+
}
|
|
2409
|
+
await client.query("COMMIT");
|
|
2410
|
+
return {
|
|
2411
|
+
invoice: invoiceRecordFromRow(reconciled.rows[0]),
|
|
2412
|
+
funding,
|
|
2413
|
+
credit,
|
|
2414
|
+
replayed: false
|
|
2415
|
+
};
|
|
2416
|
+
} catch (err) {
|
|
2417
|
+
try {
|
|
2418
|
+
await client.query("ROLLBACK");
|
|
2419
|
+
} catch {
|
|
2420
|
+
}
|
|
2421
|
+
throw err;
|
|
2422
|
+
} finally {
|
|
2423
|
+
client.release();
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Record that an operator reviewed a blocked invoice and chose not to credit
|
|
2428
|
+
* it (internal-review) — no ledger effect, purely a queue transition.
|
|
2429
|
+
*
|
|
2430
|
+
* A CAS on `blocked`, and idempotent: re-running returns the recorded row
|
|
2431
|
+
* rather than overwriting the first note. It cannot capture a `reconciled`
|
|
2432
|
+
* row, because that one moved money.
|
|
2433
|
+
*
|
|
2434
|
+
* `written` reports whether *this* call was the transition. The caller has
|
|
2435
|
+
* no other way to tell — the row reads identically either way — and the
|
|
2436
|
+
* write-off's audit log is the only record of the amount, so a re-emitted
|
|
2437
|
+
* one reads as a second decision about the same sats.
|
|
2438
|
+
*/
|
|
2439
|
+
async writeOffBlockedInvoice(args) {
|
|
2440
|
+
const { rowCount } = await this.pool.query(
|
|
2441
|
+
`UPDATE credit_invoices SET status = 'written_off', write_off_note = $2, resolved_at = $3
|
|
2442
|
+
WHERE payment_hash = $1 AND status = 'blocked'`,
|
|
2443
|
+
[args.paymentHash, args.note ?? null, args.nowMs ?? Date.now()]
|
|
2444
|
+
);
|
|
2445
|
+
const invoice = await this.getInvoiceByPaymentHash(args.paymentHash);
|
|
2446
|
+
if (!invoice) return void 0;
|
|
2447
|
+
return { invoice, written: (rowCount ?? 0) > 0 };
|
|
2448
|
+
}
|
|
2449
|
+
/** Read one credit (no lock). Expired credits stay fully readable. */
|
|
2450
|
+
async get(creditId, opts) {
|
|
2451
|
+
const { rows } = await this.pool.query(
|
|
2452
|
+
`SELECT * FROM credits WHERE credit_id = $1`,
|
|
2453
|
+
[creditId]
|
|
2454
|
+
);
|
|
2455
|
+
if (rows.length === 0) return void 0;
|
|
2456
|
+
return this.snapshotFromRow(rows[0], this.pool, opts?.nowMs ?? Date.now());
|
|
2457
|
+
}
|
|
2458
|
+
/** Read all credits held by a caller pubkey, oldest first (no lock). */
|
|
2459
|
+
async getForCaller(callerPubkey, opts) {
|
|
2460
|
+
const { rows } = await this.pool.query(
|
|
2461
|
+
`SELECT * FROM credits WHERE caller_pubkey = $1 ORDER BY created_at, credit_id`,
|
|
2462
|
+
[callerPubkey]
|
|
2463
|
+
);
|
|
2464
|
+
const nowMs = opts?.nowMs ?? Date.now();
|
|
2465
|
+
const out = [];
|
|
2466
|
+
for (const row of rows) {
|
|
2467
|
+
out.push(await this.snapshotFromRow(row, this.pool, nowMs));
|
|
2468
|
+
}
|
|
2469
|
+
return out;
|
|
2470
|
+
}
|
|
2471
|
+
/** Read one draw (no lock), with its credit's rail basis joined in. */
|
|
2472
|
+
async getDraw(args) {
|
|
2473
|
+
const { rows } = await this.pool.query(
|
|
2474
|
+
`${DRAW_WITH_BASIS_SELECT} WHERE d.credit_id = $1 AND d.draw_id = $2`,
|
|
2475
|
+
[args.creditId, args.drawId]
|
|
2476
|
+
);
|
|
2477
|
+
return rows[0] ? drawRecordFromRow(rows[0], rows[0]) : void 0;
|
|
2478
|
+
}
|
|
2479
|
+
/** Read the sole draw bound to a pre-allocated job id; ambiguity fails closed. */
|
|
2480
|
+
async getDrawByJobId(jobId) {
|
|
2481
|
+
const { rows } = await this.pool.query(
|
|
2482
|
+
`${DRAW_WITH_BASIS_SELECT} WHERE d.job_id = $1 ORDER BY d.created_at LIMIT 2`,
|
|
2483
|
+
[jobId]
|
|
2484
|
+
);
|
|
2485
|
+
return rows.length === 1 ? drawRecordFromRow(rows[0], rows[0]) : void 0;
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* All pending holds on a credit, in `ledger_seq` order (no lock). Read
|
|
2489
|
+
* surface for the internal-review sweeper wiring — a worker that dies mid-job
|
|
2490
|
+
* leaves its hold `pending` until something releases it.
|
|
2491
|
+
*/
|
|
2492
|
+
async listPendingDraws(creditId) {
|
|
2493
|
+
const { rows } = await this.pool.query(
|
|
2494
|
+
`${DRAW_WITH_BASIS_SELECT} WHERE d.credit_id = $1 AND d.status = 'pending' ORDER BY d.ledger_seq`,
|
|
2495
|
+
[creditId]
|
|
2496
|
+
);
|
|
2497
|
+
return rows.map((row) => drawRecordFromRow(row, row));
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* One page of pending holds host-wide placed before `createdBeforeMs`,
|
|
2501
|
+
* oldest first (no lock). The orphan sweeper's read surface (internal-review): a
|
|
2502
|
+
* draw commits with its `job_id` before the job row is persisted, so a crash
|
|
2503
|
+
* or a fail-closed refusal in that window strands a hold nothing can ever
|
|
2504
|
+
* resolve — the only release path keys off the `credit_id`/`draw_id` written
|
|
2505
|
+
* on the job row.
|
|
2506
|
+
*
|
|
2507
|
+
* Host-wide like {@link listPendingDrains}, not per-credit: the sweeper has
|
|
2508
|
+
* no candidate credit to start from. Age-bounded so it never sees a draw
|
|
2509
|
+
* whose job row is merely still in flight.
|
|
2510
|
+
*
|
|
2511
|
+
* `limit` is required and `after` keyset-paginates: an aged hold whose job
|
|
2512
|
+
* row *does* exist is legitimately un-sweepable and stays `pending`
|
|
2513
|
+
* indefinitely, so a caller that re-issues the same first page would never
|
|
2514
|
+
* see past a `limit`-sized wall of them. Pass the last row's
|
|
2515
|
+
* {@link StalePendingDrawCursor} to advance.
|
|
2516
|
+
*/
|
|
2517
|
+
async listStalePendingDraws(args) {
|
|
2518
|
+
const after = args.after;
|
|
2519
|
+
const { rows } = await this.pool.query(
|
|
2520
|
+
`${DRAW_WITH_BASIS_SELECT}
|
|
2521
|
+
WHERE d.status = 'pending' AND d.created_at < $1
|
|
2522
|
+
AND ($3::bigint IS NULL
|
|
2523
|
+
OR (d.created_at, d.credit_id, d.ledger_seq) > ($3::bigint, $4::text, $5::bigint))
|
|
2524
|
+
ORDER BY d.created_at, d.credit_id, d.ledger_seq
|
|
2525
|
+
LIMIT $2`,
|
|
2526
|
+
[
|
|
2527
|
+
args.createdBeforeMs,
|
|
2528
|
+
args.limit,
|
|
2529
|
+
after?.createdAt ?? null,
|
|
2530
|
+
after?.creditId ?? null,
|
|
2531
|
+
after?.ledgerSeq ?? null
|
|
2532
|
+
]
|
|
2533
|
+
);
|
|
2534
|
+
return rows.map((row) => drawRecordFromRow(row, row));
|
|
2535
|
+
}
|
|
2536
|
+
/**
|
|
2537
|
+
* Rail-native value the credit an x402 settlement channel funded has
|
|
2538
|
+
* actually earned — the sum of its **settled** draws, in the credit's native
|
|
2539
|
+
* atomic units (USDC micro on this rail). The batch-settlement claim job's
|
|
2540
|
+
* ceiling (internal-review): a channel is claimable up to what its credit's draws
|
|
2541
|
+
* have earned, never up to the deposit that funded it.
|
|
2542
|
+
*
|
|
2543
|
+
* `undefined` when no credit is bound to the channel, which the claim job
|
|
2544
|
+
* treats as "cannot be resolved" and claims nothing for — distinct from a
|
|
2545
|
+
* resolved credit that has earned `0`.
|
|
2546
|
+
*
|
|
2547
|
+
* Deliberately a sum over settled draws rather than `funded − remaining`:
|
|
2548
|
+
* a drain lowers `native_remaining` without earning anything, so the
|
|
2549
|
+
* subtraction would read the caller's own reclaim as revenue. Pending holds
|
|
2550
|
+
* are excluded for the mirror reason — a hold is work in flight, and
|
|
2551
|
+
* claiming against it would take money a release still owes back.
|
|
2552
|
+
*/
|
|
2553
|
+
async earnedNativeForX402Channel(channelId) {
|
|
2554
|
+
const { rows } = await this.pool.query(
|
|
2555
|
+
`SELECT COALESCE(
|
|
2556
|
+
(SELECT SUM(d.draw_native)
|
|
2557
|
+
FROM credit_draws d
|
|
2558
|
+
WHERE d.credit_id = c.credit_id
|
|
2559
|
+
AND d.status = 'settled'
|
|
2560
|
+
AND d.draw_native IS NOT NULL),
|
|
2561
|
+
0
|
|
2562
|
+
)::text AS earned
|
|
2563
|
+
FROM credits c
|
|
2564
|
+
WHERE c.x402_channel_id = $1`,
|
|
2565
|
+
[channelId]
|
|
2566
|
+
);
|
|
2567
|
+
if (rows.length === 0) return void 0;
|
|
2568
|
+
return toSafeInt(rows[0].earned);
|
|
2569
|
+
}
|
|
2570
|
+
/**
|
|
2571
|
+
* The credit an x402 settlement channel funded, if one is bound to it — the
|
|
2572
|
+
* binding is UNIQUE, so at most one row can answer (internal-review).
|
|
2573
|
+
*
|
|
2574
|
+
* The operator repair's entry point: a wedged settlement row carries the
|
|
2575
|
+
* channel and an `effect_id`, and this is what turns them back into the
|
|
2576
|
+
* credit id, the caller pubkey, and the drain id the effect encodes. Deriving
|
|
2577
|
+
* the credit id by splitting `effect_id` instead would be wrong — credit ids
|
|
2578
|
+
* carry their own colons (`imp:`, `fnd:` and the rail segment inside them).
|
|
2579
|
+
*/
|
|
2580
|
+
async getCreditByX402Channel(channelId) {
|
|
2581
|
+
const { rows } = await this.pool.query(
|
|
2582
|
+
`SELECT * FROM credits WHERE x402_channel_id = $1`,
|
|
2583
|
+
[channelId]
|
|
2584
|
+
);
|
|
2585
|
+
return rows[0] ? this.snapshotFromRow(rows[0], this.pool, Date.now()) : void 0;
|
|
2586
|
+
}
|
|
2587
|
+
/** Retire every active credit backed by a chain-proven lost Tempo channel. */
|
|
2588
|
+
async terminalizeTempoCredits(evidence, tx) {
|
|
2589
|
+
const channelId = evidence.channelId.toLowerCase();
|
|
2590
|
+
const observedAt = evidence.observedAt ?? Date.now();
|
|
2591
|
+
if (tx) return this.terminalizeTempoCreditsLocked(tx, evidence, channelId, observedAt);
|
|
2592
|
+
const client = await this.pool.connect();
|
|
2593
|
+
try {
|
|
2594
|
+
await client.query("BEGIN");
|
|
2595
|
+
const losses = await this.terminalizeTempoCreditsLocked(
|
|
2596
|
+
client,
|
|
2597
|
+
evidence,
|
|
2598
|
+
channelId,
|
|
2599
|
+
observedAt
|
|
2600
|
+
);
|
|
2601
|
+
await client.query("COMMIT");
|
|
2602
|
+
return losses;
|
|
2603
|
+
} catch (error) {
|
|
2604
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
2605
|
+
throw error;
|
|
2606
|
+
} finally {
|
|
2607
|
+
client.release();
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
async terminalizeTempoCreditsLocked(q, evidence, channelId, observedAt) {
|
|
2611
|
+
const { rows: credits } = await q.query(
|
|
2612
|
+
`SELECT * FROM credits
|
|
2613
|
+
WHERE tempo_channel_id = $1
|
|
2614
|
+
ORDER BY credit_id
|
|
2615
|
+
FOR UPDATE`,
|
|
2616
|
+
[channelId]
|
|
2617
|
+
);
|
|
2618
|
+
for (const credit of credits) {
|
|
2619
|
+
if (credit.status === "unbacked") continue;
|
|
2620
|
+
if (credit.status !== "active") throw terminalCreditError(credit.credit_id, credit.status);
|
|
2621
|
+
const { rows: consumed } = await q.query(
|
|
2622
|
+
`SELECT COALESCE(SUM(amount_micro), 0)::text AS amount
|
|
2623
|
+
FROM credit_draws
|
|
2624
|
+
WHERE credit_id = $1 AND status = 'settled'`,
|
|
2625
|
+
[credit.credit_id]
|
|
2626
|
+
);
|
|
2627
|
+
await q.query(
|
|
2628
|
+
`INSERT INTO tempo_credit_losses
|
|
2629
|
+
(credit_id, channel_id, caller_pubkey, currency, former_balance_micro,
|
|
2630
|
+
settled_on_chain_native, highest_voucher_native, consumed_service_micro,
|
|
2631
|
+
consumed_native, observed_at)
|
|
2632
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
2633
|
+
ON CONFLICT (credit_id) DO NOTHING`,
|
|
2634
|
+
[
|
|
2635
|
+
credit.credit_id,
|
|
2636
|
+
channelId,
|
|
2637
|
+
credit.caller_pubkey,
|
|
2638
|
+
credit.currency,
|
|
2639
|
+
credit.balance_micro,
|
|
2640
|
+
evidence.settledOnChainNative.toString(),
|
|
2641
|
+
evidence.highestVoucherNative.toString(),
|
|
2642
|
+
consumed[0]?.amount ?? "0",
|
|
2643
|
+
evidence.consumedNative.toString(),
|
|
2644
|
+
observedAt
|
|
2645
|
+
]
|
|
2646
|
+
);
|
|
2647
|
+
await q.query(`UPDATE credits SET status = 'unbacked' WHERE credit_id = $1`, [
|
|
2648
|
+
credit.credit_id
|
|
2649
|
+
]);
|
|
2650
|
+
}
|
|
2651
|
+
return this.readTempoCreditLosses(q, { channelId });
|
|
2652
|
+
}
|
|
2653
|
+
/** List terminal Tempo credit losses, newest observation first. */
|
|
2654
|
+
async listTempoCreditLosses(args) {
|
|
2655
|
+
return this.readTempoCreditLosses(this.pool, { limit: args?.limit ?? 50 });
|
|
2656
|
+
}
|
|
2657
|
+
/** Retire the active credit bound to a chain-proven empty x402 channel. */
|
|
2658
|
+
async terminalizeX402Credit(evidence, tx) {
|
|
2659
|
+
const channelId = evidence.channelId.toLowerCase();
|
|
2660
|
+
const observedAt = evidence.observedAt ?? Date.now();
|
|
2661
|
+
if (tx) return this.terminalizeX402CreditLocked(tx, evidence, channelId, observedAt);
|
|
2662
|
+
const client = await this.pool.connect();
|
|
2663
|
+
try {
|
|
2664
|
+
await client.query("BEGIN");
|
|
2665
|
+
const losses = await this.terminalizeX402CreditLocked(
|
|
2666
|
+
client,
|
|
2667
|
+
evidence,
|
|
2668
|
+
channelId,
|
|
2669
|
+
observedAt
|
|
2670
|
+
);
|
|
2671
|
+
await client.query("COMMIT");
|
|
2672
|
+
return losses;
|
|
2673
|
+
} catch (error) {
|
|
2674
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
2675
|
+
throw error;
|
|
2676
|
+
} finally {
|
|
2677
|
+
client.release();
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
async terminalizeX402CreditLocked(q, evidence, channelId, observedAt) {
|
|
2681
|
+
const { rows: credits } = await q.query(
|
|
2682
|
+
`SELECT * FROM credits WHERE x402_channel_id = $1 FOR UPDATE`,
|
|
2683
|
+
[channelId]
|
|
2684
|
+
);
|
|
2685
|
+
if (credits.length > 0 && credits[0].status !== "unbacked") {
|
|
2686
|
+
const credit = credits[0];
|
|
2687
|
+
if (credit.status !== "active") throw terminalCreditError(credit.credit_id, credit.status);
|
|
2688
|
+
const { rows: consumed } = await q.query(
|
|
2689
|
+
`SELECT COALESCE(SUM(amount_micro), 0)::text AS amount
|
|
2690
|
+
FROM credit_draws
|
|
2691
|
+
WHERE credit_id = $1 AND status = 'settled'`,
|
|
2692
|
+
[credit.credit_id]
|
|
2693
|
+
);
|
|
2694
|
+
await q.query(
|
|
2695
|
+
`INSERT INTO x402_credit_losses
|
|
2696
|
+
(credit_id, channel_id, caller_pubkey, currency, former_balance_micro,
|
|
2697
|
+
channel_balance_native, total_claimed_native, consumed_service_micro, observed_at)
|
|
2698
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
2699
|
+
ON CONFLICT (credit_id) DO NOTHING`,
|
|
2700
|
+
[
|
|
2701
|
+
credit.credit_id,
|
|
2702
|
+
channelId,
|
|
2703
|
+
credit.caller_pubkey,
|
|
2704
|
+
credit.currency,
|
|
2705
|
+
credit.balance_micro,
|
|
2706
|
+
evidence.channelBalanceNative.toString(),
|
|
2707
|
+
evidence.totalClaimedNative.toString(),
|
|
2708
|
+
consumed[0]?.amount ?? "0",
|
|
2709
|
+
observedAt
|
|
2710
|
+
]
|
|
2711
|
+
);
|
|
2712
|
+
await q.query(`UPDATE credits SET status = 'unbacked' WHERE credit_id = $1`, [
|
|
2713
|
+
credit.credit_id
|
|
2714
|
+
]);
|
|
2715
|
+
}
|
|
2716
|
+
return this.readX402CreditLosses(q, { channelId });
|
|
2717
|
+
}
|
|
2718
|
+
/** List terminal x402 credit losses, newest observation first. */
|
|
2719
|
+
async listX402CreditLosses(args) {
|
|
2720
|
+
return this.readX402CreditLosses(this.pool, { limit: args?.limit ?? 50 });
|
|
2721
|
+
}
|
|
2722
|
+
// ── Drains (internal-review) ─────────────────────────────────────────────────
|
|
2723
|
+
/**
|
|
2724
|
+
* Debit the caller's entire available balance into a drain liability
|
|
2725
|
+
* (spec §5: expiry ends spending, never ownership — this is the
|
|
2726
|
+
* builder-honored reclaim floor). Runs under the credit row lock: the
|
|
2727
|
+
* amount is `balance − pending holds` read under `FOR UPDATE`, the balance
|
|
2728
|
+
* is decremented in the same transaction, and the drain takes the next
|
|
2729
|
+
* gap-free `ledger_seq` — so the balance zeroes exactly once however many
|
|
2730
|
+
* machines race the request.
|
|
2731
|
+
*
|
|
2732
|
+
* Idempotent on `(creditId, drainId)`: a replay returns the recorded drain
|
|
2733
|
+
* (this is also the poll/pickup read). A replay whose `method` or `payout`
|
|
2734
|
+
* differs from the recorded drain is refused with `drain_conflict` —
|
|
2735
|
+
* silently honouring a changed payout would ship the caller's money to a
|
|
2736
|
+
* destination they never signed alongside this `drain_id`.
|
|
2737
|
+
*
|
|
2738
|
+
* Pending holds are NOT drained — in-flight jobs keep their money until
|
|
2739
|
+
* they settle or release. A release after a drain restores available
|
|
2740
|
+
* balance the caller reclaims with a **new** `drain_id`; drains are ledger
|
|
2741
|
+
* arithmetic, not a terminal credit state, so `status` stays `active`.
|
|
2742
|
+
*
|
|
2743
|
+
* Deliberately **no expiry check**: draining works before and after
|
|
2744
|
+
* expiry. The one thing expiry gates is new draws.
|
|
2745
|
+
*/
|
|
2746
|
+
async requestDrain(args) {
|
|
2747
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
2748
|
+
const run = async (client, credit) => {
|
|
2749
|
+
if (credit.caller_pubkey !== args.callerPubkey) {
|
|
2750
|
+
throw new CreditLedgerError(
|
|
2751
|
+
"caller_mismatch",
|
|
2752
|
+
`credit ${args.creditId} belongs to a different caller pubkey`
|
|
2753
|
+
);
|
|
2754
|
+
}
|
|
2755
|
+
const existing = await this.readDrain(client, args.creditId, args.drainId);
|
|
2756
|
+
if (existing) {
|
|
2757
|
+
if (existing.method !== args.method || JSON.stringify(existing.payout) !== JSON.stringify(args.payout)) {
|
|
2758
|
+
throw new CreditLedgerError(
|
|
2759
|
+
"drain_conflict",
|
|
2760
|
+
`drain ${args.drainId} on credit ${args.creditId} was recorded with a different method or payout`
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2763
|
+
return {
|
|
2764
|
+
drain: drainRecordFromRow(existing, credit),
|
|
2765
|
+
credit: await this.snapshotFromRow(credit, client, nowMs),
|
|
2766
|
+
replayed: true
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
if (credit.status !== "active") throw terminalCreditError(args.creditId, credit.status);
|
|
2770
|
+
const blocking = await this.blockingX402Refund(
|
|
2771
|
+
credit.x402_channel_id,
|
|
2772
|
+
X402_WEDGED_SETTLEMENT_STATUSES
|
|
2773
|
+
);
|
|
2774
|
+
if (blocking && blocking.effectId !== `${args.creditId}:${args.drainId}`) {
|
|
2775
|
+
throw x402SettlementPending(args.creditId, blocking);
|
|
2776
|
+
}
|
|
2777
|
+
const balanceMicro = toSafeInt(credit.balance_micro);
|
|
2778
|
+
const pending = await this.pendingSums(client, args.creditId);
|
|
2779
|
+
const availableMicro = balanceMicro - pending.micro;
|
|
2780
|
+
if (args.requireNoPending && pending.micro > 0) {
|
|
2781
|
+
throw new CreditLedgerError(
|
|
2782
|
+
"drain_conflict",
|
|
2783
|
+
`credit ${args.creditId} has pending draws that must finish before it can drain`,
|
|
2784
|
+
{ drainConflict: "pending_draws" }
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
2787
|
+
if (availableMicro <= 0) {
|
|
2788
|
+
throw new CreditLedgerError(
|
|
2789
|
+
"nothing_to_drain",
|
|
2790
|
+
`credit ${args.creditId} has no available balance to drain`,
|
|
2791
|
+
{ availableMicro, balanceMicro }
|
|
2792
|
+
);
|
|
2793
|
+
}
|
|
2794
|
+
const inKind = isNonChannelBitcoinRail(credit.rail) ? await this.reclaimInKind(client, args.creditId, availableMicro) : void 0;
|
|
2795
|
+
if (inKind?.owedSats === 0) {
|
|
2796
|
+
throw new CreditLedgerError(
|
|
2797
|
+
"drain_below_dust",
|
|
2798
|
+
`credit ${args.creditId} holds ${availableMicro} micro, worth ${inKind.grossSats ?? 0} sats at the rate it was funded \u2014 no more than the ${DRAIN_DELIVERY_RESERVE_SATS} sats it costs to hand back`,
|
|
2799
|
+
{ availableMicro, balanceMicro }
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
const ledgerSeq = toSafeInt(credit.last_ledger_seq) + 1;
|
|
2803
|
+
const balanceAfter = balanceMicro - availableMicro;
|
|
2804
|
+
const drainedMsats = Math.max(0, toSafeInt(credit.msats_remaining) - pending.msats);
|
|
2805
|
+
const drainedNative = credit.native_remaining === null ? null : Math.max(0, toSafeInt(credit.native_remaining) - pending.native);
|
|
2806
|
+
await client.query(
|
|
2807
|
+
`UPDATE credits
|
|
2808
|
+
SET balance_micro = $2,
|
|
2809
|
+
last_ledger_seq = $3,
|
|
2810
|
+
msats_remaining = LEAST(msats_remaining, $4),
|
|
2811
|
+
native_remaining = CASE WHEN native_remaining IS NULL THEN NULL
|
|
2812
|
+
ELSE LEAST(native_remaining, $5) END
|
|
2813
|
+
WHERE credit_id = $1`,
|
|
2814
|
+
[args.creditId, balanceAfter, ledgerSeq, pending.msats, pending.native]
|
|
2815
|
+
);
|
|
2816
|
+
const { rows } = await client.query(
|
|
2817
|
+
`INSERT INTO credit_drains
|
|
2818
|
+
(credit_id, drain_id, method, payout, amount_micro, balance_after_micro,
|
|
2819
|
+
drained_msats, drained_native, owed_sats, lot_debits, ledger_seq, status, created_at)
|
|
2820
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'pending', $12)
|
|
2821
|
+
RETURNING *`,
|
|
2822
|
+
[
|
|
2823
|
+
args.creditId,
|
|
2824
|
+
args.drainId,
|
|
2825
|
+
args.method,
|
|
2826
|
+
JSON.stringify(args.payout),
|
|
2827
|
+
availableMicro,
|
|
2828
|
+
balanceAfter,
|
|
2829
|
+
drainedMsats,
|
|
2830
|
+
drainedNative,
|
|
2831
|
+
inKind?.owedSats ?? null,
|
|
2832
|
+
inKind ? JSON.stringify(inKind.debits) : null,
|
|
2833
|
+
ledgerSeq,
|
|
2834
|
+
nowMs
|
|
2835
|
+
]
|
|
2836
|
+
);
|
|
2837
|
+
if (inKind) await this.applyLotDebits(client, inKind.debits, -1);
|
|
2838
|
+
const updated = {
|
|
2839
|
+
...credit,
|
|
2840
|
+
balance_micro: String(balanceAfter),
|
|
2841
|
+
last_ledger_seq: String(ledgerSeq)
|
|
2842
|
+
};
|
|
2843
|
+
return {
|
|
2844
|
+
drain: drainRecordFromRow(rows[0], credit),
|
|
2845
|
+
credit: await this.snapshotFromRow(updated, client, nowMs),
|
|
2846
|
+
replayed: false
|
|
2847
|
+
};
|
|
2848
|
+
};
|
|
2849
|
+
if (args.tx) {
|
|
2850
|
+
return run(args.tx, await this.lockCreditRow(args.tx, args.creditId));
|
|
2851
|
+
}
|
|
2852
|
+
return this.withCreditLock(args.creditId, run);
|
|
2853
|
+
}
|
|
2854
|
+
/** Read one drain (no lock), joined with its credit for currency + owner. */
|
|
2855
|
+
async getDrain(args) {
|
|
2856
|
+
const { rows } = await this.pool.query(
|
|
2857
|
+
`SELECT d.*, ${DRAIN_CREDIT_COLUMNS} FROM credit_drains d
|
|
2858
|
+
JOIN credits c ON c.credit_id = d.credit_id
|
|
2859
|
+
WHERE d.credit_id = $1 AND d.drain_id = $2`,
|
|
2860
|
+
[args.creditId, args.drainId]
|
|
2861
|
+
);
|
|
2862
|
+
return rows.length > 0 ? drainRecordFromJoinRow(rows[0]) : void 0;
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* One credit's undepleted funding lots, oldest first (internal-review) — the
|
|
2866
|
+
* public read behind the reclaim's own arithmetic, for anything that needs
|
|
2867
|
+
* to show its work.
|
|
2868
|
+
*/
|
|
2869
|
+
async listFundingLots(creditId) {
|
|
2870
|
+
return this.openLots(this.pool, creditId);
|
|
2871
|
+
}
|
|
2872
|
+
/**
|
|
2873
|
+
* What this DVM owes back in satoshis if every open non-channel Bitcoin
|
|
2874
|
+
* credit reclaimed right now (internal-review) — the deposit half of the hub
|
|
2875
|
+
* balance, and the floor a payout sweep must never go below.
|
|
2876
|
+
*
|
|
2877
|
+
* Read entirely off the funding lots, at their own funding rates, with no
|
|
2878
|
+
* exchange-rate lookup anywhere: the obligation is in kind, so a rate has
|
|
2879
|
+
* nothing to say about it. Pending job holds are deliberately **not**
|
|
2880
|
+
* subtracted — a hold either settles into revenue or releases back to the
|
|
2881
|
+
* caller, and until it does, treating it as already earned would understate
|
|
2882
|
+
* the floor. The figure is conservative by exactly that amount.
|
|
2883
|
+
*
|
|
2884
|
+
* The two coverage holes ride along rather than being folded in silently, so
|
|
2885
|
+
* a gap reads as a gap: `uncovered` is a credit whose lots fall short of its
|
|
2886
|
+
* balance, `unbacked` is one holding any open micro with no sats basis. A
|
|
2887
|
+
* reclaim on either prices the **whole** balance off a live rate — the
|
|
2888
|
+
* depletion refuses a mixture rather than blending two bases — so both are
|
|
2889
|
+
* reported as the whole balance, and neither contributes to `depositSats`.
|
|
2890
|
+
* A figure that counted the backed half of a mixed credit would be a floor
|
|
2891
|
+
* the payout never matches.
|
|
2892
|
+
*/
|
|
2893
|
+
async bitcoinDepositLiability() {
|
|
2894
|
+
const { rows } = await this.pool.query(
|
|
2895
|
+
`SELECT c.rail, c.currency,
|
|
2896
|
+
COUNT(*) AS credits,
|
|
2897
|
+
COALESCE(SUM(cov.owed_sats) FILTER (WHERE ${IN_KIND_PREDICATE}), 0) AS owed_sats,
|
|
2898
|
+
COALESCE(SUM(c.balance_micro), 0) AS balance_micro,
|
|
2899
|
+
COUNT(*) FILTER (WHERE cov.covered_micro < c.balance_micro) AS uncovered_credits,
|
|
2900
|
+
COALESCE(SUM(c.balance_micro) FILTER (WHERE cov.covered_micro < c.balance_micro), 0)
|
|
2901
|
+
AS uncovered_micro,
|
|
2902
|
+
COUNT(*) FILTER (WHERE cov.covered_micro >= c.balance_micro AND cov.basisless_micro > 0)
|
|
2903
|
+
AS unbacked_credits,
|
|
2904
|
+
COALESCE(SUM(c.balance_micro)
|
|
2905
|
+
FILTER (WHERE cov.covered_micro >= c.balance_micro AND cov.basisless_micro > 0), 0)
|
|
2906
|
+
AS unbacked_micro
|
|
2907
|
+
FROM credits c
|
|
2908
|
+
CROSS JOIN LATERAL (${LOT_COVERAGE_LATERAL}) cov
|
|
2909
|
+
WHERE ${BITCOIN_CREDIT_PREDICATE}
|
|
2910
|
+
GROUP BY c.rail, c.currency
|
|
2911
|
+
ORDER BY c.rail, c.currency`
|
|
2912
|
+
);
|
|
2913
|
+
const liability = {
|
|
2914
|
+
depositSats: 0,
|
|
2915
|
+
credits: 0,
|
|
2916
|
+
byRail: [],
|
|
2917
|
+
byCurrency: [],
|
|
2918
|
+
uncoveredCredits: 0,
|
|
2919
|
+
uncoveredMicro: 0,
|
|
2920
|
+
unbackedCredits: 0,
|
|
2921
|
+
unbackedMicro: 0
|
|
2922
|
+
};
|
|
2923
|
+
const byRail = /* @__PURE__ */ new Map();
|
|
2924
|
+
const byCurrency = /* @__PURE__ */ new Map();
|
|
2925
|
+
for (const row of rows) {
|
|
2926
|
+
const sats = toSafeInt(row.owed_sats);
|
|
2927
|
+
const credits = toSafeInt(row.credits);
|
|
2928
|
+
liability.depositSats += sats;
|
|
2929
|
+
liability.credits += credits;
|
|
2930
|
+
liability.uncoveredCredits += toSafeInt(row.uncovered_credits);
|
|
2931
|
+
liability.uncoveredMicro += toSafeInt(row.uncovered_micro);
|
|
2932
|
+
liability.unbackedCredits += toSafeInt(row.unbacked_credits);
|
|
2933
|
+
liability.unbackedMicro += toSafeInt(row.unbacked_micro);
|
|
2934
|
+
const rail = byRail.get(row.rail) ?? { rail: row.rail, sats: 0, credits: 0 };
|
|
2935
|
+
rail.sats += sats;
|
|
2936
|
+
rail.credits += credits;
|
|
2937
|
+
byRail.set(row.rail, rail);
|
|
2938
|
+
const currency = byCurrency.get(row.currency) ?? {
|
|
2939
|
+
currency: row.currency,
|
|
2940
|
+
micro: 0,
|
|
2941
|
+
credits: 0
|
|
2942
|
+
};
|
|
2943
|
+
currency.micro += toSafeInt(row.balance_micro);
|
|
2944
|
+
currency.credits += credits;
|
|
2945
|
+
byCurrency.set(row.currency, currency);
|
|
2946
|
+
}
|
|
2947
|
+
liability.byRail = [...byRail.values()];
|
|
2948
|
+
liability.byCurrency = [...byCurrency.values()];
|
|
2949
|
+
return liability;
|
|
2950
|
+
}
|
|
2951
|
+
/**
|
|
2952
|
+
* Every unfulfilled drain, oldest first (no lock) — the builder batch
|
|
2953
|
+
* job's work list and the outstanding-refund-liability read the
|
|
2954
|
+
* scoped-budget assertion sums over. `pending` means un-parked (Cashu) or
|
|
2955
|
+
* un-sent (Lightning/x402/mpp); `parked` value is already committed to a
|
|
2956
|
+
* caller-locked token, so it is out of the liability the LN send budget
|
|
2957
|
+
* must cover.
|
|
2958
|
+
*/
|
|
2959
|
+
async listPendingDrains() {
|
|
2960
|
+
const { rows } = await this.pool.query(
|
|
2961
|
+
`SELECT d.*, ${DRAIN_CREDIT_COLUMNS} FROM credit_drains d
|
|
2962
|
+
JOIN credits c ON c.credit_id = d.credit_id
|
|
2963
|
+
WHERE d.status = 'pending' ORDER BY d.created_at, d.credit_id, d.drain_id`
|
|
2964
|
+
);
|
|
2965
|
+
return rows.map(drainRecordFromJoinRow);
|
|
2966
|
+
}
|
|
2967
|
+
/**
|
|
2968
|
+
* One page of still-`pending` drains on a channel-backed credit, oldest
|
|
2969
|
+
* first (no lock) — the operator's repair queue (internal-review).
|
|
2970
|
+
*
|
|
2971
|
+
* Keyset-paginated for the x402 settlement queue's reason: a wedged row
|
|
2972
|
+
* never self-clears, so a caller that re-issues the same first page would
|
|
2973
|
+
* never see past a `limit`-sized wall of them. The keyset is
|
|
2974
|
+
* `(created_at, credit_id, drain_id)` — `created_at` is immutable, so no
|
|
2975
|
+
* concurrent write can push an unexamined row past the cursor. It is a
|
|
2976
|
+
* BIGINT of epoch milliseconds rather than a `TIMESTAMPTZ`, so the
|
|
2977
|
+
* microsecond-vs-millisecond cursor trap that bit `listSettlements` cannot
|
|
2978
|
+
* arise here: the value round-trips as the integer it already is.
|
|
2979
|
+
*
|
|
2980
|
+
* `createdBeforeMs` is the staleness floor. A healthy cooperative close
|
|
2981
|
+
* moves the drain to `sent` inside the request that opened it, so anything
|
|
2982
|
+
* younger is in flight rather than stuck.
|
|
2983
|
+
*
|
|
2984
|
+
* The method filter is `'tempo'`, and it is the one line here worth a second
|
|
2985
|
+
* look: `init()` migrates the pre-internal-review `'mpp'` spelling away and
|
|
2986
|
+
* `DrainMethod` no longer carries it, so a query naming it matches nothing a
|
|
2987
|
+
* DVM has ever written. It named it anyway until internal-review's rename sweep and
|
|
2988
|
+
* internal-review's live rung caught it independently — the whole operator queue
|
|
2989
|
+
* read empty in production while every route test stayed green, because
|
|
2990
|
+
* those run on `MemoryCreditLedger`, which filters on the typed value. The
|
|
2991
|
+
* two implementations of this one predicate must be read together.
|
|
2992
|
+
*
|
|
2993
|
+
* A written-off row is excluded unless `includeResolved`: the operator has
|
|
2994
|
+
* already decided about it, and leaving it on the live queue would re-serve
|
|
2995
|
+
* a closed decision on every walk.
|
|
2996
|
+
*/
|
|
2997
|
+
async listChannelDrains(args) {
|
|
2998
|
+
const params = [args.createdBeforeMs, args.limit];
|
|
2999
|
+
let keyset = "";
|
|
3000
|
+
if (args.after) {
|
|
3001
|
+
params.push(args.after.createdAt, args.after.creditId, args.after.drainId);
|
|
3002
|
+
keyset = ` AND (d.created_at, d.credit_id, d.drain_id) > ($3, $4, $5)`;
|
|
3003
|
+
}
|
|
3004
|
+
const { rows } = await this.pool.query(
|
|
3005
|
+
`SELECT d.*, ${DRAIN_CREDIT_COLUMNS} FROM credit_drains d
|
|
3006
|
+
JOIN credits c ON c.credit_id = d.credit_id
|
|
3007
|
+
WHERE d.status = 'pending'
|
|
3008
|
+
AND d.method = 'tempo'
|
|
3009
|
+
AND c.tempo_channel_id IS NOT NULL
|
|
3010
|
+
AND d.created_at <= $1
|
|
3011
|
+
${args.includeResolved ? "" : "AND d.written_off_at IS NULL"}
|
|
3012
|
+
${keyset}
|
|
3013
|
+
ORDER BY d.created_at, d.credit_id, d.drain_id
|
|
3014
|
+
LIMIT $2`,
|
|
3015
|
+
params
|
|
3016
|
+
);
|
|
3017
|
+
return rows.map(drainRecordFromJoinRow);
|
|
3018
|
+
}
|
|
3019
|
+
/**
|
|
3020
|
+
* Record that an operator reviewed a floor-refused channel drain and is not
|
|
3021
|
+
* booking it (internal-review). Books nothing anywhere — no status change, no
|
|
3022
|
+
* balance movement, no platform report.
|
|
3023
|
+
*
|
|
3024
|
+
* A single-statement CAS on `written_off_at IS NULL`, so the first note on
|
|
3025
|
+
* file wins: a second operator asking for the same decision finds one
|
|
3026
|
+
* already recorded and is told so (`written: false`) rather than writing
|
|
3027
|
+
* over a colleague's reason. Only a `pending` drain may be written off — a
|
|
3028
|
+
* terminal one has an answer already.
|
|
3029
|
+
*/
|
|
3030
|
+
async writeOffDrain(args) {
|
|
3031
|
+
const { rowCount } = await this.pool.query(
|
|
3032
|
+
`UPDATE credit_drains SET write_off_note = $3, written_off_at = $4
|
|
3033
|
+
WHERE credit_id = $1 AND drain_id = $2
|
|
3034
|
+
AND status = 'pending' AND written_off_at IS NULL`,
|
|
3035
|
+
[args.creditId, args.drainId, args.note ?? null, args.nowMs ?? Date.now()]
|
|
3036
|
+
);
|
|
3037
|
+
const drain = await this.getDrain({ creditId: args.creditId, drainId: args.drainId });
|
|
3038
|
+
if (!drain) return void 0;
|
|
3039
|
+
return { drain, written: (rowCount ?? 0) > 0 };
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Drop a recorded write-off, inside the transaction that books the drain.
|
|
3043
|
+
*
|
|
3044
|
+
* This is the reversibility half, and its placement is the whole guarantee:
|
|
3045
|
+
* a reconcile clears the note in the same transaction as the `pending →
|
|
3046
|
+
* sent` CAS, so a ledger leg that rolls back leaves the operator's decision
|
|
3047
|
+
* exactly as it found it. The x402 repair needs an explicit compensating
|
|
3048
|
+
* restore for this because its settlement row lives outside the ledger's
|
|
3049
|
+
* transaction; here the two are one row.
|
|
3050
|
+
*/
|
|
3051
|
+
async clearDrainWriteOff(args) {
|
|
3052
|
+
const q = args.tx ?? this.pool;
|
|
3053
|
+
await q.query(
|
|
3054
|
+
`UPDATE credit_drains SET write_off_note = NULL, written_off_at = NULL
|
|
3055
|
+
WHERE credit_id = $1 AND drain_id = $2`,
|
|
3056
|
+
[args.creditId, args.drainId]
|
|
3057
|
+
);
|
|
3058
|
+
}
|
|
3059
|
+
/**
|
|
3060
|
+
* Cancel an unfulfilled Tempo cooperative drain and restore the exact
|
|
3061
|
+
* balance plus rail basis it removed (`pending -> released`). This is only
|
|
3062
|
+
* for a close the channel reconciliation proved cannot pay; ordinary payout
|
|
3063
|
+
* rails never re-credit a liability after it has been handed to a worker.
|
|
3064
|
+
*/
|
|
3065
|
+
async releaseDrain(args) {
|
|
3066
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
3067
|
+
const run = async (client, credit) => {
|
|
3068
|
+
const row = await this.readDrain(client, args.creditId, args.drainId);
|
|
3069
|
+
if (!row) {
|
|
3070
|
+
throw new CreditLedgerError(
|
|
3071
|
+
"drain_not_found",
|
|
3072
|
+
`drain ${args.drainId} on credit ${args.creditId} not found`
|
|
3073
|
+
);
|
|
3074
|
+
}
|
|
3075
|
+
if (row.status === "released") {
|
|
3076
|
+
return {
|
|
3077
|
+
drain: drainRecordFromRow(row, credit),
|
|
3078
|
+
credit: await this.snapshotFromRow(credit, client, nowMs),
|
|
3079
|
+
replayed: true
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
if (row.status !== "pending") {
|
|
3083
|
+
throw new CreditLedgerError(
|
|
3084
|
+
"invalid_drain_state",
|
|
3085
|
+
`cannot release ${row.method} drain ${args.drainId} from status ${row.status}`
|
|
3086
|
+
);
|
|
3087
|
+
}
|
|
3088
|
+
const { rows } = await client.query(
|
|
3089
|
+
`UPDATE credit_drains SET status = 'released', released_at = $3
|
|
3090
|
+
WHERE credit_id = $1 AND drain_id = $2 AND status = 'pending'
|
|
3091
|
+
RETURNING *`,
|
|
3092
|
+
[args.creditId, args.drainId, nowMs]
|
|
3093
|
+
);
|
|
3094
|
+
if (!rows[0]) {
|
|
3095
|
+
throw new CreditLedgerError(
|
|
3096
|
+
"invalid_drain_state",
|
|
3097
|
+
`drain ${args.drainId} changed while it was being released`
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
3100
|
+
await client.query(
|
|
3101
|
+
`UPDATE credits
|
|
3102
|
+
SET balance_micro = balance_micro + $2,
|
|
3103
|
+
msats_remaining = msats_remaining + $3,
|
|
3104
|
+
native_remaining = CASE
|
|
3105
|
+
WHEN native_remaining IS NULL OR $4::BIGINT IS NULL THEN native_remaining
|
|
3106
|
+
ELSE native_remaining + $4
|
|
3107
|
+
END
|
|
3108
|
+
WHERE credit_id = $1`,
|
|
3109
|
+
[args.creditId, row.amount_micro, row.drained_msats, row.drained_native]
|
|
3110
|
+
);
|
|
3111
|
+
await this.applyLotDebits(client, row.lot_debits ?? [], 1);
|
|
3112
|
+
const restored = await this.lockCreditRow(client, args.creditId);
|
|
3113
|
+
return {
|
|
3114
|
+
drain: drainRecordFromRow(rows[0], restored),
|
|
3115
|
+
credit: await this.snapshotFromRow(restored, client, nowMs),
|
|
3116
|
+
replayed: false
|
|
3117
|
+
};
|
|
3118
|
+
};
|
|
3119
|
+
if (args.tx) return run(args.tx, await this.lockCreditRow(args.tx, args.creditId));
|
|
3120
|
+
return this.withCreditLock(args.creditId, run);
|
|
3121
|
+
}
|
|
3122
|
+
/**
|
|
3123
|
+
* Attach the parked Cashu token to a pending drain (`pending → parked`).
|
|
3124
|
+
* A single-statement CAS on `status = 'pending'` — no credit lock needed;
|
|
3125
|
+
* the money already left the balance at request time. Idempotent: parking
|
|
3126
|
+
* an already-parked/picked-up drain with the same token returns the
|
|
3127
|
+
* recorded row (the builder's park retry after a lost response); a
|
|
3128
|
+
* different token refuses with `drain_conflict` rather than silently
|
|
3129
|
+
* replacing notes the caller may already hold.
|
|
3130
|
+
*
|
|
3131
|
+
* Pass `tx` to commit the park atomically with the accumulator row swap
|
|
3132
|
+
* the admin route performs alongside it.
|
|
3133
|
+
*/
|
|
3134
|
+
async parkDrain(args) {
|
|
3135
|
+
const q = args.tx ?? this.pool;
|
|
3136
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
3137
|
+
const { rows } = await q.query(
|
|
3138
|
+
`UPDATE credit_drains
|
|
3139
|
+
SET status = 'parked', token = $3, parked_at = $4,
|
|
3140
|
+
fulfilment = COALESCE($5::jsonb, fulfilment)
|
|
3141
|
+
WHERE credit_id = $1 AND drain_id = $2 AND status = 'pending' AND method = 'cashu'
|
|
3142
|
+
RETURNING *`,
|
|
3143
|
+
[
|
|
3144
|
+
args.creditId,
|
|
3145
|
+
args.drainId,
|
|
3146
|
+
args.token,
|
|
3147
|
+
nowMs,
|
|
3148
|
+
args.fulfilment ? JSON.stringify(args.fulfilment) : null
|
|
3149
|
+
]
|
|
3150
|
+
);
|
|
3151
|
+
if (rows.length > 0) return this.rejoin(q, rows[0]);
|
|
3152
|
+
const refusal = await this.refuseTransition(q, args.creditId, args.drainId, {
|
|
3153
|
+
idempotentWhen: (row) => (row.status === "parked" || row.status === "picked_up") && row.token === args.token,
|
|
3154
|
+
verb: "park"
|
|
3155
|
+
});
|
|
3156
|
+
return refusal.drain;
|
|
3157
|
+
}
|
|
3158
|
+
/**
|
|
3159
|
+
* Record that the caller collected the parked token (`parked →
|
|
3160
|
+
* picked_up`). Idempotent on `picked_up` — pickup must survive response
|
|
3161
|
+
* loss, so the caller's re-poll keeps returning the same token either way.
|
|
3162
|
+
*
|
|
3163
|
+
* Returns `replayed` because this is a **terminal** transition and the money
|
|
3164
|
+
* has left at exactly one of these calls (internal-review): the platform drain
|
|
3165
|
+
* report must be emitted by that caller and no other. Pass `tx` to write the
|
|
3166
|
+
* report through the same transaction as the CAS.
|
|
3167
|
+
*/
|
|
3168
|
+
async markDrainPickedUp(args) {
|
|
3169
|
+
const q = args.tx ?? this.pool;
|
|
3170
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
3171
|
+
const { rows } = await q.query(
|
|
3172
|
+
`UPDATE credit_drains SET status = 'picked_up', picked_up_at = $3
|
|
3173
|
+
WHERE credit_id = $1 AND drain_id = $2 AND status = 'parked'
|
|
3174
|
+
RETURNING *`,
|
|
3175
|
+
[args.creditId, args.drainId, nowMs]
|
|
3176
|
+
);
|
|
3177
|
+
if (rows.length > 0) return { drain: await this.rejoin(q, rows[0]), replayed: false };
|
|
3178
|
+
return this.refuseTransition(q, args.creditId, args.drainId, {
|
|
3179
|
+
idempotentWhen: (row) => row.status === "picked_up",
|
|
3180
|
+
verb: "mark picked up"
|
|
3181
|
+
});
|
|
3182
|
+
}
|
|
3183
|
+
/**
|
|
3184
|
+
* Record a completed send-back (`pending → sent`) for the non-parked
|
|
3185
|
+
* methods — the builder's batch job paid the Lightning invoice or shipped
|
|
3186
|
+
* the on-chain transfer and posts the settlement reference. Idempotent on
|
|
3187
|
+
* `sent` (the recorded `sent_ref` wins; a retry after a lost response
|
|
3188
|
+
* carries the same reference or none at all).
|
|
3189
|
+
*
|
|
3190
|
+
* `replayed` and `tx` carry the same meaning as on {@link markDrainPickedUp},
|
|
3191
|
+
* and matter more here: the builder's batch job re-POSTs `mark-drain-sent`
|
|
3192
|
+
* on every recovery pass, so the replay is the routine case rather than the
|
|
3193
|
+
* exception.
|
|
3194
|
+
*/
|
|
3195
|
+
async markDrainSent(args) {
|
|
3196
|
+
const q = args.tx ?? this.pool;
|
|
3197
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
3198
|
+
const { rows } = await q.query(
|
|
3199
|
+
`UPDATE credit_drains SET status = 'sent', sent_ref = $3, sent_at = $4
|
|
3200
|
+
WHERE credit_id = $1 AND drain_id = $2 AND status = 'pending' AND method <> 'cashu'
|
|
3201
|
+
RETURNING *`,
|
|
3202
|
+
[args.creditId, args.drainId, JSON.stringify(args.sentRef), nowMs]
|
|
3203
|
+
);
|
|
3204
|
+
if (rows.length > 0) return { drain: await this.rejoin(q, rows[0]), replayed: false };
|
|
3205
|
+
return this.refuseTransition(q, args.creditId, args.drainId, {
|
|
3206
|
+
idempotentWhen: (row) => row.status === "sent",
|
|
3207
|
+
verb: "mark sent"
|
|
3208
|
+
});
|
|
3209
|
+
}
|
|
3210
|
+
/**
|
|
3211
|
+
* Append a countersigned reclaim-event receipt to the drain's evidence
|
|
3212
|
+
* trail. A single-statement JSONB append — receipts are emitted after the
|
|
3213
|
+
* state transition they attest, and losing one never loses money (the
|
|
3214
|
+
* transition is the source of truth; the receipt is the proof the caller
|
|
3215
|
+
* can carry away).
|
|
3216
|
+
*/
|
|
3217
|
+
async appendDrainReceipt(args) {
|
|
3218
|
+
const q = args.tx ?? this.pool;
|
|
3219
|
+
await q.query(
|
|
3220
|
+
`UPDATE credit_drains SET receipts = receipts || $3::jsonb
|
|
3221
|
+
WHERE credit_id = $1 AND drain_id = $2`,
|
|
3222
|
+
[args.creditId, args.drainId, JSON.stringify([args.receipt])]
|
|
3223
|
+
);
|
|
3224
|
+
}
|
|
3225
|
+
// ── Internals ─────────────────────────────────────────────────────────
|
|
3226
|
+
async readDrain(q, creditId, drainId) {
|
|
3227
|
+
const { rows } = await q.query(
|
|
3228
|
+
`SELECT * FROM credit_drains WHERE credit_id = $1 AND drain_id = $2`,
|
|
3229
|
+
[creditId, drainId]
|
|
3230
|
+
);
|
|
3231
|
+
return rows[0];
|
|
3232
|
+
}
|
|
3233
|
+
/** Re-read the credit join fields for a freshly-updated drain row. */
|
|
3234
|
+
async rejoin(q, row) {
|
|
3235
|
+
const { rows } = await q.query(
|
|
3236
|
+
`SELECT currency, caller_pubkey, tempo_channel_id, x402_channel_id
|
|
3237
|
+
FROM credits WHERE credit_id = $1`,
|
|
3238
|
+
[row.credit_id]
|
|
3239
|
+
);
|
|
3240
|
+
return drainRecordFromRow(row, rows[0]);
|
|
3241
|
+
}
|
|
3242
|
+
/** Shared refusal tail for the CAS transitions: idempotent replay or typed error. */
|
|
3243
|
+
async refuseTransition(q, creditId, drainId, opts) {
|
|
3244
|
+
const row = await this.readDrain(q, creditId, drainId);
|
|
3245
|
+
if (!row) {
|
|
3246
|
+
throw new CreditLedgerError(
|
|
3247
|
+
"drain_not_found",
|
|
3248
|
+
`drain ${drainId} not found on credit ${creditId}`
|
|
3249
|
+
);
|
|
3250
|
+
}
|
|
3251
|
+
if (opts.idempotentWhen(row)) return { drain: await this.rejoin(q, row), replayed: true };
|
|
3252
|
+
throw new CreditLedgerError(
|
|
3253
|
+
row.status === "parked" || row.status === "picked_up" || row.status === "sent" ? "drain_conflict" : "invalid_drain_state",
|
|
3254
|
+
`cannot ${opts.verb} drain ${drainId}: status is ${row.status} (method ${row.method})`
|
|
3255
|
+
);
|
|
3256
|
+
}
|
|
3257
|
+
/**
|
|
3258
|
+
* Price a reclaim in kind and say which lots it takes (internal-review).
|
|
3259
|
+
*
|
|
3260
|
+
* The debits are returned whether or not the reclaim can be priced, and the
|
|
3261
|
+
* caller applies them either way: the money is leaving the credit, so the
|
|
3262
|
+
* lots have to shrink with it or the deposit-liability rollup would keep
|
|
3263
|
+
* counting a balance that is gone.
|
|
3264
|
+
*
|
|
3265
|
+
* `owedSats` is `null` where {@link isInKindDepletion} refuses — the lots
|
|
3266
|
+
* are short of the balance, or the covering lots carry no sats basis (a
|
|
3267
|
+
* credit funded before internal-review). That row is priced off a live rate by the
|
|
3268
|
+
* admin surface instead, which is what *every* row did before this change,
|
|
3269
|
+
* so the fallback is the old behaviour rather than a new failure mode.
|
|
3270
|
+
*
|
|
3271
|
+
* What it publishes is **net of the delivery reserve** (internal-review): handing a
|
|
3272
|
+
* refund over costs a mint fee and, when the notes have to be minted just in
|
|
3273
|
+
* time, a Lightning hop. The caller carries that cost per internal-review, and a
|
|
3274
|
+
* flat reserve is how they carry it without the figure moving — an
|
|
3275
|
+
* actual-fee true-up could only be applied after the promise was made. The
|
|
3276
|
+
* debits stay gross because they are denominated in micro and are what
|
|
3277
|
+
* `releaseDrain` restores; only the sats figure is netted.
|
|
3278
|
+
*/
|
|
3279
|
+
async reclaimInKind(q, creditId, amountMicro) {
|
|
3280
|
+
const depletion = depleteLots(await this.openLots(q, creditId), amountMicro);
|
|
3281
|
+
const inKind = isInKindDepletion(depletion, amountMicro);
|
|
3282
|
+
return {
|
|
3283
|
+
owedSats: inKind ? netOwedSats(depletion.satsOwed) : null,
|
|
3284
|
+
grossSats: inKind ? depletion.satsOwed : null,
|
|
3285
|
+
debits: depletion.debits
|
|
3286
|
+
};
|
|
3287
|
+
}
|
|
3288
|
+
/**
|
|
3289
|
+
* This credit's undepleted funding lots, oldest first (internal-review).
|
|
3290
|
+
*
|
|
3291
|
+
* Ordered in SQL on the same `(created_at, lot_id)` key the partial index
|
|
3292
|
+
* carries, so FIFO is the index's own order rather than something a reader
|
|
3293
|
+
* re-establishes. Depleted lots stay on the table as the deposit's audit
|
|
3294
|
+
* trail and are excluded here — they price nothing.
|
|
3295
|
+
*/
|
|
3296
|
+
async openLots(q, creditId) {
|
|
3297
|
+
const { rows } = await q.query(
|
|
3298
|
+
`SELECT * FROM credit_funding_lots
|
|
3299
|
+
WHERE credit_id = $1 AND remaining_micro > 0
|
|
3300
|
+
ORDER BY created_at, lot_id`,
|
|
3301
|
+
[creditId]
|
|
3302
|
+
);
|
|
3303
|
+
return rows.map(fundingLotFromRow);
|
|
3304
|
+
}
|
|
3305
|
+
/**
|
|
3306
|
+
* Move `debits` out of (`sign: -1`) or back into (`sign: 1`) their lots.
|
|
3307
|
+
*
|
|
3308
|
+
* Always runs under the credit row lock the caller already holds, on that
|
|
3309
|
+
* caller's handle, so the read-then-write is serialized per credit exactly
|
|
3310
|
+
* as every other balance move is.
|
|
3311
|
+
*/
|
|
3312
|
+
async applyLotDebits(q, debits, sign) {
|
|
3313
|
+
for (const debit of debits) {
|
|
3314
|
+
await q.query(
|
|
3315
|
+
`UPDATE credit_funding_lots
|
|
3316
|
+
SET remaining_micro = GREATEST(LEAST(remaining_micro + $2, credited_micro), 0)
|
|
3317
|
+
WHERE lot_id = $1`,
|
|
3318
|
+
[debit.lotId, sign * debit.micro]
|
|
3319
|
+
);
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
/** Shared settle/release state machine — see the public JSDoc on each verb. */
|
|
3323
|
+
async resolveDraw(args) {
|
|
3324
|
+
if (args.tx) {
|
|
3325
|
+
const credit = await this.lockCreditRow(args.tx, args.creditId);
|
|
3326
|
+
return this.resolveDrawLocked(args.tx, credit, args);
|
|
3327
|
+
}
|
|
3328
|
+
return this.withCreditLock(
|
|
3329
|
+
args.creditId,
|
|
3330
|
+
(client, credit) => this.resolveDrawLocked(client, credit, args)
|
|
3331
|
+
);
|
|
3332
|
+
}
|
|
3333
|
+
async resolveDrawLocked(client, credit, args) {
|
|
3334
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
3335
|
+
const draw = await this.readDraw(client, args.creditId, args.drawId);
|
|
3336
|
+
if (!draw) {
|
|
3337
|
+
throw new CreditLedgerError(
|
|
3338
|
+
"draw_not_found",
|
|
3339
|
+
`draw ${args.drawId} not found on credit ${args.creditId}`
|
|
3340
|
+
);
|
|
3341
|
+
}
|
|
3342
|
+
let balanceMicro = toSafeInt(credit.balance_micro);
|
|
3343
|
+
let replayed = false;
|
|
3344
|
+
if (draw.status === args.to) {
|
|
3345
|
+
replayed = true;
|
|
3346
|
+
} else if (draw.status !== "pending") {
|
|
3347
|
+
throw new CreditLedgerError(
|
|
3348
|
+
"invalid_draw_state",
|
|
3349
|
+
`cannot ${args.to === "settled" ? "settle" : "release"} draw ${args.drawId}: status is ${draw.status}`,
|
|
3350
|
+
{ currentStatus: draw.status }
|
|
3351
|
+
);
|
|
3352
|
+
} else {
|
|
3353
|
+
await client.query(
|
|
3354
|
+
`UPDATE credit_draws SET status = $3, resolved_at = $4
|
|
3355
|
+
WHERE credit_id = $1 AND draw_id = $2`,
|
|
3356
|
+
[args.creditId, args.drawId, args.to, nowMs]
|
|
3357
|
+
);
|
|
3358
|
+
if (args.to === "settled") {
|
|
3359
|
+
const amountMicro = toSafeInt(draw.amount_micro);
|
|
3360
|
+
balanceMicro -= amountMicro;
|
|
3361
|
+
const forecastMsats = toSafeInt(draw.draw_msats);
|
|
3362
|
+
let settledMsats = forecastMsats;
|
|
3363
|
+
if (isNonChannelBitcoinRail(credit.rail)) {
|
|
3364
|
+
const depletion = depleteLots(await this.openLots(client, args.creditId), amountMicro);
|
|
3365
|
+
settledMsats = inKindDrawMsats(depletion, amountMicro) ?? forecastMsats;
|
|
3366
|
+
await this.applyLotDebits(client, depletion.debits, -1);
|
|
3367
|
+
}
|
|
3368
|
+
if (settledMsats !== forecastMsats) {
|
|
3369
|
+
await client.query(
|
|
3370
|
+
`UPDATE credit_draws SET draw_msats = $3, allocated_msats = $4
|
|
3371
|
+
WHERE credit_id = $1 AND draw_id = $2`,
|
|
3372
|
+
[args.creditId, args.drawId, settledMsats, forecastMsats]
|
|
3373
|
+
);
|
|
3374
|
+
draw.draw_msats = String(settledMsats);
|
|
3375
|
+
draw.allocated_msats = String(forecastMsats);
|
|
3376
|
+
}
|
|
3377
|
+
await client.query(
|
|
3378
|
+
`UPDATE credits
|
|
3379
|
+
SET balance_micro = $2,
|
|
3380
|
+
msats_remaining = GREATEST(msats_remaining - $3, 0),
|
|
3381
|
+
native_remaining = CASE WHEN native_remaining IS NULL THEN NULL
|
|
3382
|
+
ELSE GREATEST(native_remaining - $4, 0) END
|
|
3383
|
+
WHERE credit_id = $1`,
|
|
3384
|
+
[args.creditId, balanceMicro, settledMsats, Number(draw.draw_native ?? 0)]
|
|
3385
|
+
);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
const availableMicro = credit.status === "unbacked" ? 0 : balanceMicro - (await this.pendingSums(client, args.creditId)).micro;
|
|
3389
|
+
const resolution = {
|
|
3390
|
+
...drawResultFromRow(draw, credit),
|
|
3391
|
+
status: args.to,
|
|
3392
|
+
replayed,
|
|
3393
|
+
availableMicro,
|
|
3394
|
+
resolvedAt: draw.status === "pending" ? nowMs : Number(draw.resolved_at ?? nowMs)
|
|
3395
|
+
};
|
|
3396
|
+
if (!replayed && args.to === "released" && resolution.amountMicro > 0 && args.releaseOutbox) {
|
|
3397
|
+
await args.releaseOutbox.enqueue(client, {
|
|
3398
|
+
dvmId: args.releaseOutbox.dvmId,
|
|
3399
|
+
creditId: resolution.creditId,
|
|
3400
|
+
drawId: resolution.drawId,
|
|
3401
|
+
amountMicro: resolution.amountMicro,
|
|
3402
|
+
creditCurrency: credit.currency,
|
|
3403
|
+
ledgerSeq: resolution.ledgerSeq,
|
|
3404
|
+
releasedAt: resolution.resolvedAt
|
|
3405
|
+
});
|
|
3406
|
+
}
|
|
3407
|
+
return resolution;
|
|
3408
|
+
}
|
|
3409
|
+
/**
|
|
3410
|
+
* Run `fn` inside a transaction holding `SELECT … FOR UPDATE` on the credit
|
|
3411
|
+
* row — the lock every `credit_draws` write must be under. Commits on
|
|
3412
|
+
* success (harmless for read-only replay paths), rolls back on throw.
|
|
3413
|
+
*/
|
|
3414
|
+
async withCreditLock(creditId, fn) {
|
|
3415
|
+
const client = await this.pool.connect();
|
|
3416
|
+
try {
|
|
3417
|
+
await client.query("BEGIN");
|
|
3418
|
+
const credit = await this.lockCreditRow(client, creditId);
|
|
3419
|
+
const result = await fn(client, credit);
|
|
3420
|
+
await client.query("COMMIT");
|
|
3421
|
+
return result;
|
|
3422
|
+
} catch (err) {
|
|
3423
|
+
try {
|
|
3424
|
+
await client.query("ROLLBACK");
|
|
3425
|
+
} catch {
|
|
3426
|
+
}
|
|
3427
|
+
throw err;
|
|
3428
|
+
} finally {
|
|
3429
|
+
client.release();
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
/** `SELECT … FOR UPDATE` the credit row on `q` (which must be inside a transaction). */
|
|
3433
|
+
async lockCreditRow(q, creditId) {
|
|
3434
|
+
const { rows } = await q.query(
|
|
3435
|
+
`SELECT * FROM credits WHERE credit_id = $1 FOR UPDATE`,
|
|
3436
|
+
[creditId]
|
|
3437
|
+
);
|
|
3438
|
+
if (rows.length === 0) {
|
|
3439
|
+
throw new CreditLedgerError("credit_not_found", `credit ${creditId} not found`);
|
|
3440
|
+
}
|
|
3441
|
+
return rows[0];
|
|
3442
|
+
}
|
|
3443
|
+
async readDraw(q, creditId, drawId) {
|
|
3444
|
+
const { rows } = await q.query(
|
|
3445
|
+
`SELECT * FROM credit_draws WHERE credit_id = $1 AND draw_id = $2`,
|
|
3446
|
+
[creditId, drawId]
|
|
3447
|
+
);
|
|
3448
|
+
return rows[0];
|
|
3449
|
+
}
|
|
3450
|
+
/**
|
|
3451
|
+
* Held-but-unresolved totals for a credit, in all three units at once — the
|
|
3452
|
+
* fiat micro that defines available balance plus the rail-value remainders
|
|
3453
|
+
* a new draw allocates against (internal-review). One round-trip, since every
|
|
3454
|
+
* caller needs the micro sum anyway.
|
|
3455
|
+
*/
|
|
3456
|
+
async pendingSums(q, creditId) {
|
|
3457
|
+
const { rows } = await q.query(
|
|
3458
|
+
`SELECT COALESCE(SUM(amount_micro), 0) AS micro,
|
|
3459
|
+
COALESCE(SUM(draw_msats), 0) AS msats,
|
|
3460
|
+
COALESCE(SUM(draw_native), 0) AS native
|
|
3461
|
+
FROM credit_draws WHERE credit_id = $1 AND status = 'pending'`,
|
|
3462
|
+
[creditId]
|
|
3463
|
+
);
|
|
3464
|
+
return {
|
|
3465
|
+
micro: toSafeInt(rows[0].micro),
|
|
3466
|
+
msats: toSafeInt(rows[0].msats),
|
|
3467
|
+
native: toSafeInt(rows[0].native)
|
|
3468
|
+
};
|
|
3469
|
+
}
|
|
3470
|
+
async snapshotFromRow(row, q, nowMs) {
|
|
3471
|
+
const balanceMicro = toSafeInt(row.balance_micro);
|
|
3472
|
+
const expiryMs = toSafeInt(row.expiry_ms);
|
|
3473
|
+
const pending = await this.pendingSums(q, row.credit_id);
|
|
3474
|
+
const live = row.status === "active";
|
|
3475
|
+
return {
|
|
3476
|
+
creditId: row.credit_id,
|
|
3477
|
+
callerPubkey: row.caller_pubkey,
|
|
3478
|
+
currency: row.currency,
|
|
3479
|
+
balanceMicro,
|
|
3480
|
+
availableMicro: live ? balanceMicro - pending.micro : 0,
|
|
3481
|
+
nativeAvailable: row.native_remaining === null ? null : live ? Math.max(0, toSafeInt(row.native_remaining) - pending.native) : 0,
|
|
3482
|
+
expiryMs,
|
|
3483
|
+
expired: expiryMs <= nowMs,
|
|
3484
|
+
status: row.status === "unbacked" ? "unbacked" : "active",
|
|
3485
|
+
lastLedgerSeq: toSafeInt(row.last_ledger_seq),
|
|
3486
|
+
createdAt: toSafeInt(row.created_at),
|
|
3487
|
+
rail: row.rail,
|
|
3488
|
+
tempoChannelId: row.tempo_channel_id ?? null,
|
|
3489
|
+
x402ChannelId: row.x402_channel_id
|
|
3490
|
+
};
|
|
3491
|
+
}
|
|
3492
|
+
async readTempoCreditLosses(q, args) {
|
|
3493
|
+
const { rows } = await q.query(
|
|
3494
|
+
`SELECT * FROM tempo_credit_losses
|
|
3495
|
+
WHERE ($1::text IS NULL OR channel_id = $1)
|
|
3496
|
+
ORDER BY observed_at DESC, credit_id
|
|
3497
|
+
LIMIT $2`,
|
|
3498
|
+
[args.channelId ?? null, args.limit ?? 1e3]
|
|
3499
|
+
);
|
|
3500
|
+
return rows.map(tempoCreditLossFromRow);
|
|
3501
|
+
}
|
|
3502
|
+
async readX402CreditLosses(q, args) {
|
|
3503
|
+
const { rows } = await q.query(
|
|
3504
|
+
`SELECT * FROM x402_credit_losses
|
|
3505
|
+
WHERE ($1::text IS NULL OR channel_id = $1)
|
|
3506
|
+
ORDER BY observed_at DESC, credit_id
|
|
3507
|
+
LIMIT $2`,
|
|
3508
|
+
[args.channelId ?? null, args.limit ?? 1e3]
|
|
3509
|
+
);
|
|
3510
|
+
return rows.map(x402CreditLossFromRow);
|
|
3511
|
+
}
|
|
3512
|
+
};
|
|
3513
|
+
var DRAIN_METHODS = ["cashu", "x402", "tempo"];
|
|
3514
|
+
function isDrainMethod(value) {
|
|
3515
|
+
return DRAIN_METHODS.includes(value);
|
|
3516
|
+
}
|
|
3517
|
+
var FUNDING_RAILS = {
|
|
3518
|
+
cashu: true,
|
|
3519
|
+
x402: true,
|
|
3520
|
+
tempo: true,
|
|
3521
|
+
lightning: true
|
|
3522
|
+
};
|
|
3523
|
+
function isFundingRail(rail) {
|
|
3524
|
+
return typeof rail === "string" && Object.hasOwn(FUNDING_RAILS, rail);
|
|
3525
|
+
}
|
|
3526
|
+
function assertFundingBasis(basis) {
|
|
3527
|
+
if (!basis) {
|
|
3528
|
+
throw new CreditLedgerError("invalid_basis", "basis is required (internal-review)");
|
|
3529
|
+
}
|
|
3530
|
+
if (!isFundingRail(basis.rail)) {
|
|
3531
|
+
throw new CreditLedgerError(
|
|
3532
|
+
"invalid_basis",
|
|
3533
|
+
`basis.rail must be one of ${Object.keys(FUNDING_RAILS).join(", ")} (got ${String(basis.rail)})`
|
|
3534
|
+
);
|
|
3535
|
+
}
|
|
3536
|
+
if (!Number.isSafeInteger(basis.paidMsats) || basis.paidMsats < 0) {
|
|
3537
|
+
throw new CreditLedgerError(
|
|
3538
|
+
"invalid_basis",
|
|
3539
|
+
`basis.paidMsats must be a non-negative safe integer (got ${String(basis.paidMsats)})`
|
|
3540
|
+
);
|
|
3541
|
+
}
|
|
3542
|
+
return basis;
|
|
3543
|
+
}
|
|
3544
|
+
function allocateDrawValue(args) {
|
|
3545
|
+
const share = (remaining) => {
|
|
3546
|
+
if (args.availableMicro <= 0 || args.amountMicro <= 0 || remaining <= 0) return 0;
|
|
3547
|
+
return Math.min(remaining, Math.round(remaining * args.amountMicro / args.availableMicro));
|
|
3548
|
+
};
|
|
3549
|
+
return {
|
|
3550
|
+
drawMsats: share(args.availableMsats),
|
|
3551
|
+
drawNative: args.availableNative === null ? null : share(args.availableNative)
|
|
3552
|
+
};
|
|
3553
|
+
}
|
|
3554
|
+
function growthRailValue(args) {
|
|
3555
|
+
const { earmark } = args;
|
|
3556
|
+
if (!earmark) return allocateDrawValue(args);
|
|
3557
|
+
return {
|
|
3558
|
+
drawMsats: Math.min(Math.max(0, earmark.drawMsats), args.availableMsats),
|
|
3559
|
+
drawNative: args.availableNative === null ? null : Math.min(Math.max(0, earmark.drawNative ?? 0), args.availableNative)
|
|
3560
|
+
};
|
|
3561
|
+
}
|
|
3562
|
+
function clipEarmark(earmark, grantMicro, addAmountMicro) {
|
|
3563
|
+
if (!earmark || grantMicro >= addAmountMicro || addAmountMicro <= 0) return earmark;
|
|
3564
|
+
const scale = (value) => Math.min(value, Math.ceil(value * grantMicro / addAmountMicro));
|
|
3565
|
+
return {
|
|
3566
|
+
drawMsats: scale(earmark.drawMsats),
|
|
3567
|
+
drawNative: earmark.drawNative === null ? null : scale(earmark.drawNative)
|
|
3568
|
+
};
|
|
3569
|
+
}
|
|
3570
|
+
var CreditLedgerError = class extends Error {
|
|
3571
|
+
code;
|
|
3572
|
+
details;
|
|
3573
|
+
constructor(code, message, details = {}, opts) {
|
|
3574
|
+
super(message, opts);
|
|
3575
|
+
this.name = "CreditLedgerError";
|
|
3576
|
+
this.code = code;
|
|
3577
|
+
this.details = details;
|
|
3578
|
+
}
|
|
3579
|
+
};
|
|
3580
|
+
function x402SettlementPending(creditId, refund) {
|
|
3581
|
+
const drainId = X402_WEDGED_SETTLEMENT_STATUSES.includes(refund.status) && refund.effectId.startsWith(`${creditId}:`) ? refund.effectId.slice(creditId.length + 1) : "";
|
|
3582
|
+
return new CreditLedgerError(
|
|
3583
|
+
"settlement_pending",
|
|
3584
|
+
`credit ${creditId} is bound to an x402 channel whose refund ${refund.settlementId} is ${refund.status}`,
|
|
3585
|
+
{
|
|
3586
|
+
settlementId: refund.settlementId,
|
|
3587
|
+
settlementStatus: refund.status,
|
|
3588
|
+
...drainId ? { blockingDrainId: drainId } : {}
|
|
3589
|
+
}
|
|
3590
|
+
);
|
|
3591
|
+
}
|
|
3592
|
+
function terminalCreditError(creditId, status) {
|
|
3593
|
+
return new CreditLedgerError(
|
|
3594
|
+
"credit_unbacked",
|
|
3595
|
+
`credit ${creditId} is terminal (${status}); its Tempo backing finalized before the DVM was protected`
|
|
3596
|
+
);
|
|
3597
|
+
}
|
|
3598
|
+
function isUniqueViolation(err) {
|
|
3599
|
+
return typeof err === "object" && err !== null && err.code === "23505";
|
|
3600
|
+
}
|
|
3601
|
+
var DRAW_WITH_BASIS_SELECT = `
|
|
3602
|
+
SELECT d.*, c.rail, c.native_asset, c.mint, c.funding_ref, c.currency
|
|
3603
|
+
FROM credit_draws d
|
|
3604
|
+
LEFT JOIN credits c ON c.credit_id = d.credit_id`;
|
|
3605
|
+
var DRAIN_CREDIT_COLUMNS = "c.currency, c.caller_pubkey, c.tempo_channel_id, c.x402_channel_id";
|
|
3606
|
+
var BITCOIN_CREDIT_PREDICATE = `c.rail IN ('cashu', 'lightning')
|
|
3607
|
+
AND c.tempo_channel_id IS NULL
|
|
3608
|
+
AND c.x402_channel_id IS NULL
|
|
3609
|
+
AND c.balance_micro > 0`;
|
|
3610
|
+
var LOT_COVERAGE_LATERAL = `SELECT
|
|
3611
|
+
COALESCE(SUM(l.remaining_micro), 0) AS covered_micro,
|
|
3612
|
+
COALESCE(SUM(l.remaining_micro) FILTER (WHERE l.sats_funded = 0), 0) AS basisless_micro,
|
|
3613
|
+
COALESCE(SUM(l.sats_funded * l.remaining_micro / NULLIF(l.credited_micro, 0)), 0) AS owed_sats
|
|
3614
|
+
FROM credit_funding_lots l
|
|
3615
|
+
WHERE l.credit_id = c.credit_id AND l.remaining_micro > 0`;
|
|
3616
|
+
var IN_KIND_PREDICATE = `cov.covered_micro >= c.balance_micro AND cov.basisless_micro = 0`;
|
|
3617
|
+
function toSafeInt(value) {
|
|
3618
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
3619
|
+
if (!Number.isSafeInteger(n)) {
|
|
3620
|
+
throw new Error(`credit ledger: BIGINT value ${value} outside safe integer range`);
|
|
3621
|
+
}
|
|
3622
|
+
return n;
|
|
3623
|
+
}
|
|
3624
|
+
function assertAmount(amountMicro, opts) {
|
|
3625
|
+
if (!Number.isSafeInteger(amountMicro) || amountMicro < opts.min) {
|
|
3626
|
+
throw new CreditLedgerError(
|
|
3627
|
+
"invalid_amount",
|
|
3628
|
+
`amountMicro must be a safe integer \u2265 ${opts.min} (got ${amountMicro})`
|
|
3629
|
+
);
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
function creditBasisFromRow(row) {
|
|
3633
|
+
return {
|
|
3634
|
+
rail: row.rail,
|
|
3635
|
+
nativeAsset: row.native_asset,
|
|
3636
|
+
mint: row.mint,
|
|
3637
|
+
fundingRef: row.funding_ref,
|
|
3638
|
+
creditCurrency: row.currency
|
|
3639
|
+
};
|
|
3640
|
+
}
|
|
3641
|
+
function railValueFromRow(row, basis) {
|
|
3642
|
+
return {
|
|
3643
|
+
drawMsats: toSafeInt(row.draw_msats),
|
|
3644
|
+
drawNative: row.draw_native === null ? null : toSafeInt(row.draw_native),
|
|
3645
|
+
...creditBasisFromRow(basis)
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
function drawResultFromRow(row, basis) {
|
|
3649
|
+
return {
|
|
3650
|
+
creditId: row.credit_id,
|
|
3651
|
+
drawId: row.draw_id,
|
|
3652
|
+
amountMicro: toSafeInt(row.amount_micro),
|
|
3653
|
+
balanceAfterMicro: toSafeInt(row.balance_after_micro),
|
|
3654
|
+
ledgerSeq: toSafeInt(row.ledger_seq),
|
|
3655
|
+
status: row.status,
|
|
3656
|
+
replayed: false,
|
|
3657
|
+
railValue: railValueFromRow(row, basis),
|
|
3658
|
+
resolvedAt: row.resolved_at === null ? null : toSafeInt(row.resolved_at)
|
|
3659
|
+
};
|
|
3660
|
+
}
|
|
3661
|
+
function drawRecordFromRow(row, basis) {
|
|
3662
|
+
const { replayed: _replayed, ...rest } = drawResultFromRow(row, basis);
|
|
3663
|
+
return {
|
|
3664
|
+
...rest,
|
|
3665
|
+
jobId: row.job_id,
|
|
3666
|
+
createdAt: toSafeInt(row.created_at),
|
|
3667
|
+
allocatedMsats: row.allocated_msats === null ? rest.railValue.drawMsats : toSafeInt(row.allocated_msats)
|
|
3668
|
+
};
|
|
3669
|
+
}
|
|
3670
|
+
function tempoCreditLossFromRow(row) {
|
|
3671
|
+
return {
|
|
3672
|
+
creditId: row.credit_id,
|
|
3673
|
+
channelId: row.channel_id,
|
|
3674
|
+
callerPubkey: row.caller_pubkey,
|
|
3675
|
+
currency: row.currency,
|
|
3676
|
+
formerBalanceMicro: toSafeInt(row.former_balance_micro),
|
|
3677
|
+
settledOnChainNative: BigInt(row.settled_on_chain_native),
|
|
3678
|
+
highestVoucherNative: BigInt(row.highest_voucher_native),
|
|
3679
|
+
consumedServiceMicro: toSafeInt(row.consumed_service_micro),
|
|
3680
|
+
consumedNative: BigInt(row.consumed_native),
|
|
3681
|
+
observedAt: toSafeInt(row.observed_at)
|
|
3682
|
+
};
|
|
3683
|
+
}
|
|
3684
|
+
function x402CreditLossFromRow(row) {
|
|
3685
|
+
return {
|
|
3686
|
+
creditId: row.credit_id,
|
|
3687
|
+
channelId: row.channel_id,
|
|
3688
|
+
callerPubkey: row.caller_pubkey,
|
|
3689
|
+
currency: row.currency,
|
|
3690
|
+
formerBalanceMicro: toSafeInt(row.former_balance_micro),
|
|
3691
|
+
channelBalanceNative: BigInt(row.channel_balance_native),
|
|
3692
|
+
totalClaimedNative: BigInt(row.total_claimed_native),
|
|
3693
|
+
consumedServiceMicro: toSafeInt(row.consumed_service_micro),
|
|
3694
|
+
observedAt: toSafeInt(row.observed_at)
|
|
3695
|
+
};
|
|
3696
|
+
}
|
|
3697
|
+
function drainRecordFromRow(row, credit) {
|
|
3698
|
+
return {
|
|
3699
|
+
creditId: row.credit_id,
|
|
3700
|
+
drainId: row.drain_id,
|
|
3701
|
+
callerPubkey: credit.caller_pubkey,
|
|
3702
|
+
currency: credit.currency,
|
|
3703
|
+
method: row.method,
|
|
3704
|
+
// One column or the other, never both: a credit binds to at most one
|
|
3705
|
+
// channel, and the two rails' ids never coexist on a row.
|
|
3706
|
+
channelId: credit.tempo_channel_id ?? credit.x402_channel_id,
|
|
3707
|
+
payout: row.payout,
|
|
3708
|
+
amountMicro: toSafeInt(row.amount_micro),
|
|
3709
|
+
drainedNative: row.drained_native === null ? null : toSafeInt(row.drained_native),
|
|
3710
|
+
owedSats: row.owed_sats === null ? null : toSafeInt(row.owed_sats),
|
|
3711
|
+
balanceAfterMicro: toSafeInt(row.balance_after_micro),
|
|
3712
|
+
ledgerSeq: toSafeInt(row.ledger_seq),
|
|
3713
|
+
status: row.status,
|
|
3714
|
+
token: row.token,
|
|
3715
|
+
sentRef: row.sent_ref,
|
|
3716
|
+
fulfilment: row.fulfilment,
|
|
3717
|
+
receipts: row.receipts,
|
|
3718
|
+
createdAt: toSafeInt(row.created_at),
|
|
3719
|
+
parkedAt: row.parked_at === null ? null : toSafeInt(row.parked_at),
|
|
3720
|
+
pickedUpAt: row.picked_up_at === null ? null : toSafeInt(row.picked_up_at),
|
|
3721
|
+
sentAt: row.sent_at === null ? null : toSafeInt(row.sent_at),
|
|
3722
|
+
releasedAt: row.released_at === null ? null : toSafeInt(row.released_at),
|
|
3723
|
+
writeOffNote: row.write_off_note,
|
|
3724
|
+
writtenOffAt: row.written_off_at === null ? null : toSafeInt(row.written_off_at)
|
|
3725
|
+
};
|
|
3726
|
+
}
|
|
3727
|
+
function drainRecordFromJoinRow(row) {
|
|
3728
|
+
return drainRecordFromRow(row, {
|
|
3729
|
+
currency: row.currency,
|
|
3730
|
+
caller_pubkey: row.caller_pubkey,
|
|
3731
|
+
tempo_channel_id: row.tempo_channel_id,
|
|
3732
|
+
x402_channel_id: row.x402_channel_id
|
|
3733
|
+
});
|
|
3734
|
+
}
|
|
3735
|
+
function fundingLotFromRow(row) {
|
|
3736
|
+
return {
|
|
3737
|
+
lotId: row.lot_id,
|
|
3738
|
+
creditId: row.credit_id,
|
|
3739
|
+
rail: row.rail,
|
|
3740
|
+
satsFunded: toSafeInt(row.sats_funded),
|
|
3741
|
+
creditedMicro: toSafeInt(row.credited_micro),
|
|
3742
|
+
remainingMicro: toSafeInt(row.remaining_micro),
|
|
3743
|
+
fundingRef: row.funding_ref,
|
|
3744
|
+
createdAt: toSafeInt(row.created_at)
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3747
|
+
function fundingRecordFromRow(row) {
|
|
3748
|
+
return {
|
|
3749
|
+
creditId: row.credit_id,
|
|
3750
|
+
fundId: row.fund_id,
|
|
3751
|
+
amountMicro: toSafeInt(row.amount_micro),
|
|
3752
|
+
rail: row.rail,
|
|
3753
|
+
createdAt: toSafeInt(row.created_at),
|
|
3754
|
+
callerPubkey: row.caller_pubkey,
|
|
3755
|
+
balanceAfterMicro: row.balance_after_micro === null ? null : toSafeInt(row.balance_after_micro),
|
|
3756
|
+
ledgerSeq: row.ledger_seq === null ? null : toSafeInt(row.ledger_seq),
|
|
3757
|
+
receipt: row.receipt
|
|
3758
|
+
};
|
|
3759
|
+
}
|
|
3760
|
+
function invoiceRecordFromRow(row) {
|
|
3761
|
+
return {
|
|
3762
|
+
creditId: row.credit_id,
|
|
3763
|
+
fundId: row.fund_id,
|
|
3764
|
+
callerPubkey: row.caller_pubkey,
|
|
3765
|
+
currency: row.currency,
|
|
3766
|
+
amountMicro: toSafeInt(row.amount_micro),
|
|
3767
|
+
amountMsats: toSafeInt(row.amount_msats),
|
|
3768
|
+
paymentHash: row.payment_hash,
|
|
3769
|
+
bolt11: row.bolt11,
|
|
3770
|
+
status: row.status,
|
|
3771
|
+
blockedReason: row.blocked_reason,
|
|
3772
|
+
reconciledCreditId: row.reconciled_credit_id,
|
|
3773
|
+
reconciledFundId: row.reconciled_fund_id,
|
|
3774
|
+
resolvedAt: row.resolved_at === null ? null : toSafeInt(row.resolved_at),
|
|
3775
|
+
writeOffNote: row.write_off_note,
|
|
3776
|
+
expiresAt: toSafeInt(row.expires_at),
|
|
3777
|
+
createdAt: toSafeInt(row.created_at),
|
|
3778
|
+
settledAt: row.settled_at === null ? null : toSafeInt(row.settled_at)
|
|
3779
|
+
};
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3782
|
+
export {
|
|
3783
|
+
isNonChannelBitcoinRail,
|
|
3784
|
+
depleteLots,
|
|
3785
|
+
inKindDrawMsats,
|
|
3786
|
+
lotOwedSats,
|
|
3787
|
+
DRAIN_DELIVERY_RESERVE_SATS,
|
|
3788
|
+
netOwedSats,
|
|
3789
|
+
fifoOrder,
|
|
3790
|
+
isInKindDepletion,
|
|
3791
|
+
X402SettlementRaceError,
|
|
3792
|
+
X402RelaySubmissionLockError,
|
|
3793
|
+
X402_WEDGED_SETTLEMENT_STATUSES,
|
|
3794
|
+
X402_RESOLVED_SETTLEMENT_STATUSES,
|
|
3795
|
+
X402_SPEND_BLOCKING_SETTLEMENT_STATUSES,
|
|
3796
|
+
PostgresX402ChannelStorage,
|
|
3797
|
+
CreditLedger,
|
|
3798
|
+
DRAIN_METHODS,
|
|
3799
|
+
isDrainMethod,
|
|
3800
|
+
FUNDING_RAILS,
|
|
3801
|
+
isFundingRail,
|
|
3802
|
+
assertFundingBasis,
|
|
3803
|
+
allocateDrawValue,
|
|
3804
|
+
growthRailValue,
|
|
3805
|
+
clipEarmark,
|
|
3806
|
+
CreditLedgerError,
|
|
3807
|
+
x402SettlementPending
|
|
3808
|
+
};
|