@dvmkit/sdk 0.10.1 → 0.11.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/dist/{chunk-3Q6FTZDB.js → chunk-3YFFZLW7.js} +2 -2
- package/dist/{chunk-YOSZIWC3.js → chunk-55DVLBCD.js} +135 -8
- package/dist/{chunk-VMHYGKWT.js → chunk-566CFXGU.js} +9 -1
- package/dist/chunk-5H7P3ORL.js +92 -0
- package/dist/{chunk-3R7NQA3Z.js → chunk-7KQLPVV7.js} +1 -1
- package/dist/{chunk-SYBFMHXQ.js → chunk-AIQGF52I.js} +242 -38
- package/dist/{chunk-JNECYURT.js → chunk-D4FFS3IS.js} +1 -1
- package/dist/{chunk-VTHYQCDB.js → chunk-E7ZBCT5U.js} +1 -1
- package/dist/{chunk-MMGSYMIC.js → chunk-EP7OVWI5.js} +1 -1
- package/dist/{chunk-LDIPVPKI.js → chunk-FFRTAQUW.js} +1 -1
- package/dist/chunk-H3GGFEE5.js +57 -0
- package/dist/{chunk-M3T5JYDJ.js → chunk-HNXMWHLU.js} +1 -1
- package/dist/{chunk-2OGBZPPJ.js → chunk-HQPQ4PFI.js} +1 -1
- package/dist/{chunk-UKD3R2KU.js → chunk-HW2UCOOC.js} +4 -55
- package/dist/{chunk-FFXXGCVG.js → chunk-ITRSITFP.js} +1 -1
- package/dist/{chunk-MU3IE434.js → chunk-JLT2YIBS.js} +267 -52
- package/dist/{chunk-STJBBVNL.js → chunk-K2ZNIQZP.js} +458 -69
- package/dist/{chunk-KFCEXCZM.js → chunk-KJZVTWMX.js} +1 -1
- package/dist/{chunk-W3VGCNG6.js → chunk-LDKV4UHI.js} +1 -1
- package/dist/{chunk-AJ6UHXUA.js → chunk-M4NFYEEY.js} +1 -1
- package/dist/{chunk-MB6R4XPZ.js → chunk-NS2OMIU7.js} +15 -3
- package/dist/{chunk-VUGEEMLT.js → chunk-RPUFWIT4.js} +11 -89
- package/dist/{chunk-EWZFANHJ.js → chunk-VWIMQ5H5.js} +1 -1
- package/dist/{chunk-2XQLL6N6.js → chunk-WFCVS6QB.js} +2 -2
- package/dist/{chunk-QGNXYVBO.js → chunk-Z4FOPROB.js} +17 -0
- package/dist/{content-retention-CrILCNxk.d.ts → content-retention-ySTD1wwZ.d.ts} +1 -1
- package/dist/{credit-ledger-4PZNAMDT.js → credit-ledger-4CBYLSLY.js} +6 -3
- package/dist/{credit-menu-BWsVqMh-.d.ts → credit-menu-BYySnZBk.d.ts} +4 -4
- package/dist/{fx-BM5h4o5S.d.ts → fx-DVTNs9q1.d.ts} +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +6 -5
- package/dist/internal/caller.d.ts +6 -6
- package/dist/internal/caller.js +26 -22
- package/dist/internal/server.d.ts +7 -7
- package/dist/internal/server.js +24 -22
- package/dist/{job-store-92v8e1LQ.d.ts → job-store-DRwXCJDI.d.ts} +1 -1
- package/dist/{lightning-backend-9oky_wJi.d.ts → lightning-backend-BX5YvjnM.d.ts} +1 -1
- package/dist/memory-credit-ledger-DKCN7FY2.js +14 -0
- package/dist/{mpp-setup-FURD45LI.js → mpp-setup-JU3MT7QX.js} +3 -3
- package/dist/{payout-reporter-HVJ6WUF3.js → payout-reporter-NXWQVKXW.js} +2 -2
- package/dist/{postgres-consumed-credential-store-WO6OCFXZ.js → postgres-consumed-credential-store-WXJLM6J7.js} +1 -1
- package/dist/{postgres-job-store-H45PRBLA.js → postgres-job-store-WIIVRZPD.js} +2 -2
- package/dist/{postgres-replay-store-XXJSA457.js → postgres-replay-store-QJ4FG6AR.js} +2 -2
- package/dist/{pricing-X4XU5HXY.js → pricing-KCLENT2C.js} +3 -3
- package/dist/{revenue-reporter-EV3K5L6B.js → revenue-reporter-HD4GMXGD.js} +2 -2
- package/dist/server/index.d.ts +28 -12
- package/dist/server/index.js +31 -29
- package/dist/{step-cache--7yA5Ihr.d.ts → step-cache-d-VKen4K.d.ts} +147 -13
- package/dist/{tempo-charge-store-5JOIYUIH.js → tempo-charge-store-7LTUAQHQ.js} +2 -2
- package/dist/{tempo-session-store-S6QMLQUA.js → tempo-session-store-LYAIDR2B.js} +3 -3
- package/dist/testing/index.d.ts +3 -3
- package/dist/testing/index.js +6 -6
- package/dist/{usd-C7lvLvLk.d.ts → usd-DSroMJ--.d.ts} +1 -1
- package/dist/{x402-EN5LSOVM.js → x402-RUJ4LUKF.js} +3 -3
- package/package.json +1 -1
- package/recipes/postgres/financial-export-v1.sql +19 -5
- package/dist/memory-credit-ledger-RP7DT5IY.js +0 -11
|
@@ -1,15 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseStoreValue
|
|
3
|
+
} from "./chunk-YG7G4DPZ.js";
|
|
1
4
|
import {
|
|
2
5
|
withSdkInitLock
|
|
3
6
|
} from "./chunk-COYKJRMM.js";
|
|
7
|
+
import {
|
|
8
|
+
isSignedJobReceipt,
|
|
9
|
+
verifyReceipt
|
|
10
|
+
} from "./chunk-5H7P3ORL.js";
|
|
4
11
|
import {
|
|
5
12
|
applyRetryToPool
|
|
6
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-EP7OVWI5.js";
|
|
7
14
|
import {
|
|
8
15
|
x402NetworkByCaip2
|
|
9
16
|
} from "./chunk-BPYMEMQX.js";
|
|
10
17
|
import {
|
|
11
18
|
diagnosticConsole
|
|
12
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-Z4FOPROB.js";
|
|
13
20
|
|
|
14
21
|
// src/sdk/server/credit-ledger.ts
|
|
15
22
|
import { randomUUID } from "crypto";
|
|
@@ -35,7 +42,7 @@ function depleteLots(lots, amountMicro) {
|
|
|
35
42
|
const sats = lotSats(lot, available) - lotSats(lot, available - micro);
|
|
36
43
|
debits.push({ lotId: lot.lotId, micro, sats });
|
|
37
44
|
satsOwed += sats;
|
|
38
|
-
if (lot.satsFunded > 0) backedMicro += micro;
|
|
45
|
+
if (lot.satsFunded > 0 || lot.satsBasisKnown) backedMicro += micro;
|
|
39
46
|
left -= micro;
|
|
40
47
|
}
|
|
41
48
|
return { debits, satsOwed, uncoveredMicro: left, backedMicro };
|
|
@@ -59,7 +66,28 @@ function isInKindDepletion(depletion, amountMicro) {
|
|
|
59
66
|
function lotSats(lot, micro) {
|
|
60
67
|
if (lot.satsFunded <= 0 || lot.creditedMicro <= 0 || micro <= 0) return 0;
|
|
61
68
|
const taken = Math.min(micro, lot.creditedMicro);
|
|
62
|
-
return
|
|
69
|
+
return Number(BigInt(lot.satsFunded) * BigInt(taken) / BigInt(lot.creditedMicro));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/sdk/server/credit-draw-refund.ts
|
|
73
|
+
function refundRequest(args) {
|
|
74
|
+
const id = (value) => typeof value === "string" && value.length > 0;
|
|
75
|
+
if (!id(args.refundId) || !(id(args.jobId) && args.creditId === void 0 && args.drawId === void 0 || args.jobId === void 0 && id(args.creditId) && id(args.drawId)) || args.amountMicro !== void 0 && (!Number.isSafeInteger(args.amountMicro) || args.amountMicro <= 0) || args.reason !== void 0 && (typeof args.reason !== "string" || args.reason.length > 280)) {
|
|
76
|
+
throw new CreditLedgerError("invalid_refund", "Provide one draw selector, a refundId, a positive integer amountMicro if specified, and a reason of at most 280 characters.");
|
|
77
|
+
}
|
|
78
|
+
return JSON.stringify({
|
|
79
|
+
dvmId: args.outbox.dvmId,
|
|
80
|
+
jobId: args.jobId ?? null,
|
|
81
|
+
creditId: args.creditId ?? null,
|
|
82
|
+
drawId: args.drawId ?? null,
|
|
83
|
+
amountMicro: args.amountMicro ?? null,
|
|
84
|
+
reason: args.reason ?? null
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function refundAssetDelta(original, amountMicro, before, after) {
|
|
88
|
+
const value = BigInt(original);
|
|
89
|
+
const denominator = BigInt(amountMicro);
|
|
90
|
+
return Number(value * BigInt(after) / denominator - value * BigInt(before) / denominator);
|
|
63
91
|
}
|
|
64
92
|
|
|
65
93
|
// src/sdk/server/x402-channel-store.ts
|
|
@@ -1140,6 +1168,7 @@ var CreditLedger = class {
|
|
|
1140
1168
|
* fleet, so this DVM may advertise credit.
|
|
1141
1169
|
*/
|
|
1142
1170
|
durable = true;
|
|
1171
|
+
tempoCreditLossEvidenceVersion = 1;
|
|
1143
1172
|
x402Settlements;
|
|
1144
1173
|
expiryReleaseOutbox;
|
|
1145
1174
|
hostingCleanupFence;
|
|
@@ -1200,6 +1229,10 @@ var CreditLedger = class {
|
|
|
1200
1229
|
observed_at BIGINT NOT NULL
|
|
1201
1230
|
);
|
|
1202
1231
|
`);
|
|
1232
|
+
await this.pool.query(`ALTER TABLE tempo_credit_losses
|
|
1233
|
+
ADD COLUMN IF NOT EXISTS refunded_to_payer_native BIGINT,
|
|
1234
|
+
ADD COLUMN IF NOT EXISTS loss_native BIGINT,
|
|
1235
|
+
ADD COLUMN IF NOT EXISTS loss_basis TEXT`);
|
|
1203
1236
|
await this.pool.query(
|
|
1204
1237
|
`CREATE INDEX IF NOT EXISTS idx_tempo_credit_losses_observed
|
|
1205
1238
|
ON tempo_credit_losses(observed_at, credit_id);`
|
|
@@ -1229,7 +1262,8 @@ var CreditLedger = class {
|
|
|
1229
1262
|
"tempo_channel_id TEXT",
|
|
1230
1263
|
"msats_remaining BIGINT NOT NULL DEFAULT 0",
|
|
1231
1264
|
"native_remaining BIGINT",
|
|
1232
|
-
"x402_channel_id TEXT"
|
|
1265
|
+
"x402_channel_id TEXT",
|
|
1266
|
+
"last_funding_ledger_seq BIGINT"
|
|
1233
1267
|
]) {
|
|
1234
1268
|
await this.pool.query(`ALTER TABLE credits ADD COLUMN IF NOT EXISTS ${column}`);
|
|
1235
1269
|
}
|
|
@@ -1261,10 +1295,25 @@ var CreditLedger = class {
|
|
|
1261
1295
|
"draw_msats BIGINT NOT NULL DEFAULT 0",
|
|
1262
1296
|
"draw_native BIGINT",
|
|
1263
1297
|
"grown_micro BIGINT NOT NULL DEFAULT 0",
|
|
1264
|
-
"allocated_msats BIGINT"
|
|
1298
|
+
"allocated_msats BIGINT",
|
|
1299
|
+
"settled_sats BIGINT",
|
|
1300
|
+
"refund_dvm_id TEXT"
|
|
1265
1301
|
]) {
|
|
1266
1302
|
await this.pool.query(`ALTER TABLE credit_draws ADD COLUMN IF NOT EXISTS ${column}`);
|
|
1267
1303
|
}
|
|
1304
|
+
await this.pool.query(`
|
|
1305
|
+
CREATE TABLE IF NOT EXISTS credit_draw_refunds (
|
|
1306
|
+
refund_id TEXT PRIMARY KEY,
|
|
1307
|
+
credit_id TEXT NOT NULL,
|
|
1308
|
+
draw_id TEXT NOT NULL,
|
|
1309
|
+
request TEXT NOT NULL,
|
|
1310
|
+
amount_micro BIGINT NOT NULL CHECK (amount_micro > 0),
|
|
1311
|
+
restored_sats BIGINT,
|
|
1312
|
+
result JSONB NOT NULL
|
|
1313
|
+
);
|
|
1314
|
+
`);
|
|
1315
|
+
await this.pool.query(`CREATE INDEX IF NOT EXISTS credit_draw_refunds_draw_idx
|
|
1316
|
+
ON credit_draw_refunds(credit_id, draw_id)`);
|
|
1268
1317
|
await this.pool.query(
|
|
1269
1318
|
`CREATE UNIQUE INDEX IF NOT EXISTS credit_draws_seq_uidx
|
|
1270
1319
|
ON credit_draws(credit_id, ledger_seq);`
|
|
@@ -1312,6 +1361,8 @@ var CreditLedger = class {
|
|
|
1312
1361
|
created_at BIGINT NOT NULL
|
|
1313
1362
|
);
|
|
1314
1363
|
`);
|
|
1364
|
+
await this.pool.query(`ALTER TABLE credit_funding_lots
|
|
1365
|
+
ADD COLUMN IF NOT EXISTS sats_basis_known BOOLEAN NOT NULL DEFAULT false`);
|
|
1315
1366
|
await this.pool.query(
|
|
1316
1367
|
`CREATE INDEX IF NOT EXISTS idx_credit_funding_lots_open
|
|
1317
1368
|
ON credit_funding_lots(credit_id, created_at, lot_id) WHERE remaining_micro > 0;`
|
|
@@ -1354,6 +1405,10 @@ var CreditLedger = class {
|
|
|
1354
1405
|
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS owed_sats BIGINT`);
|
|
1355
1406
|
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS lot_debits JSONB`);
|
|
1356
1407
|
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS released_at BIGINT`);
|
|
1408
|
+
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS operator_decision JSONB`);
|
|
1409
|
+
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS hold_reason TEXT`);
|
|
1410
|
+
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS proven_refund_native NUMERIC(78,0)`);
|
|
1411
|
+
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS refund_payment_proven BOOLEAN NOT NULL DEFAULT FALSE`);
|
|
1357
1412
|
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS fulfilment JSONB`);
|
|
1358
1413
|
await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS write_off_note TEXT`);
|
|
1359
1414
|
await this.pool.query(
|
|
@@ -1556,10 +1611,11 @@ var CreditLedger = class {
|
|
|
1556
1611
|
({ rows } = await q.query(
|
|
1557
1612
|
`INSERT INTO credits (credit_id, caller_pubkey, currency, balance_micro, expiry_ms, status, created_at,
|
|
1558
1613
|
rail, native_asset, mint, funding_ref, msats_remaining, native_remaining,
|
|
1559
|
-
tempo_channel_id, x402_channel_id)
|
|
1560
|
-
VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
1614
|
+
tempo_channel_id, x402_channel_id, last_funding_ledger_seq)
|
|
1615
|
+
VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8, $9, $10, $11, $12, $13, $14, 0)
|
|
1561
1616
|
ON CONFLICT (credit_id) DO UPDATE
|
|
1562
1617
|
SET balance_micro = credits.balance_micro + EXCLUDED.balance_micro,
|
|
1618
|
+
last_funding_ledger_seq = credits.last_ledger_seq,
|
|
1563
1619
|
expiry_ms = EXCLUDED.expiry_ms,
|
|
1564
1620
|
rail = COALESCE(credits.rail, EXCLUDED.rail),
|
|
1565
1621
|
native_asset = COALESCE(EXCLUDED.native_asset, credits.native_asset),
|
|
@@ -2091,6 +2147,180 @@ var CreditLedger = class {
|
|
|
2091
2147
|
}
|
|
2092
2148
|
return this.withCreditLock(args.creditId, run);
|
|
2093
2149
|
}
|
|
2150
|
+
/** Restore a completed draw and its report under one durable transaction. */
|
|
2151
|
+
async refundDraw(args) {
|
|
2152
|
+
const request = refundRequest(args);
|
|
2153
|
+
const client = await this.pool.connect();
|
|
2154
|
+
try {
|
|
2155
|
+
await client.query("BEGIN");
|
|
2156
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [args.refundId]);
|
|
2157
|
+
const prior = await client.query(
|
|
2158
|
+
"SELECT request, result FROM credit_draw_refunds WHERE refund_id = $1",
|
|
2159
|
+
[args.refundId]
|
|
2160
|
+
);
|
|
2161
|
+
if (prior.rows[0]) {
|
|
2162
|
+
if (prior.rows[0].request !== request) {
|
|
2163
|
+
throw new CreditLedgerError("refund_conflict", "This refundId was recorded with different arguments.");
|
|
2164
|
+
}
|
|
2165
|
+
await client.query("COMMIT");
|
|
2166
|
+
return { ...prior.rows[0].result, replayed: true };
|
|
2167
|
+
}
|
|
2168
|
+
let creditId = args.creditId;
|
|
2169
|
+
let drawId = args.drawId;
|
|
2170
|
+
if (args.jobId !== void 0) {
|
|
2171
|
+
const matches = await client.query(
|
|
2172
|
+
"SELECT credit_id, draw_id FROM credit_draws WHERE job_id = $1 LIMIT 2",
|
|
2173
|
+
[args.jobId]
|
|
2174
|
+
);
|
|
2175
|
+
if (matches.rows.length === 1) {
|
|
2176
|
+
creditId = matches.rows[0].credit_id;
|
|
2177
|
+
drawId = matches.rows[0].draw_id;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
if (creditId === void 0 || drawId === void 0) {
|
|
2181
|
+
throw new CreditLedgerError("draw_not_found", "No unique credit draw matches this job.");
|
|
2182
|
+
}
|
|
2183
|
+
let credit;
|
|
2184
|
+
try {
|
|
2185
|
+
credit = await this.lockCreditRow(client, creditId);
|
|
2186
|
+
} catch (error) {
|
|
2187
|
+
if (error instanceof CreditLedgerError && error.code === "credit_not_found") {
|
|
2188
|
+
throw new CreditLedgerError("draw_not_found", "No draw matches this credit.");
|
|
2189
|
+
}
|
|
2190
|
+
throw error;
|
|
2191
|
+
}
|
|
2192
|
+
const draw = await this.readDraw(client, creditId, drawId);
|
|
2193
|
+
if (!credit || !draw || !draw.job_id || args.jobId !== void 0 && draw.job_id !== args.jobId) {
|
|
2194
|
+
throw new CreditLedgerError("draw_not_found", "No job-bound draw matches this selector.");
|
|
2195
|
+
}
|
|
2196
|
+
const owners = new Set(draw.refund_dvm_id ? [draw.refund_dvm_id] : []);
|
|
2197
|
+
const sources = await client.query(
|
|
2198
|
+
"SELECT to_regclass('jobs')::text AS jobs, to_regclass('reported_job_terminals')::text AS terminals"
|
|
2199
|
+
);
|
|
2200
|
+
if (sources.rows[0].jobs) {
|
|
2201
|
+
const jobs = await client.query(
|
|
2202
|
+
"SELECT dvm_id, receipt FROM jobs WHERE id = $1",
|
|
2203
|
+
[draw.job_id]
|
|
2204
|
+
);
|
|
2205
|
+
const job = jobs.rows[0];
|
|
2206
|
+
if (job?.dvm_id) owners.add(job.dvm_id);
|
|
2207
|
+
const receipt = job?.receipt;
|
|
2208
|
+
if (args.trustedReceiptPubkey !== void 0 && isSignedJobReceipt(receipt) && receipt.receipt_pubkey === args.trustedReceiptPubkey && receipt.job_id === draw.job_id && verifyReceipt(receipt)) {
|
|
2209
|
+
owners.add(receipt.dvm_id);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
if (sources.rows[0].terminals) {
|
|
2213
|
+
const terminals = await client.query(
|
|
2214
|
+
"SELECT dvm_id FROM reported_job_terminals WHERE job_id = $1",
|
|
2215
|
+
[draw.job_id]
|
|
2216
|
+
);
|
|
2217
|
+
for (const terminal of terminals.rows) owners.add(terminal.dvm_id);
|
|
2218
|
+
}
|
|
2219
|
+
if (owners.size !== 1 || !owners.has(args.outbox.dvmId)) {
|
|
2220
|
+
throw new CreditLedgerError("draw_not_found", "No draw with proven ownership matches this DVM.");
|
|
2221
|
+
}
|
|
2222
|
+
if (draw.status !== "settled") {
|
|
2223
|
+
throw new CreditLedgerError("draw_not_completed", "Only a settled completed draw can be refunded.");
|
|
2224
|
+
}
|
|
2225
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
2226
|
+
const drained = await client.query(`SELECT 1 FROM credit_drains d
|
|
2227
|
+
WHERE d.credit_id = $1 AND d.status <> 'released' AND d.balance_after_micro = 0
|
|
2228
|
+
AND (SELECT MAX(ledger_seq) FROM credit_drains WHERE credit_id = $1 AND status <> 'released') = d.ledger_seq
|
|
2229
|
+
AND ($2::bigint IS NULL OR $2::bigint < d.ledger_seq)
|
|
2230
|
+
AND NOT EXISTS (SELECT 1 FROM credit_draws later WHERE later.credit_id = $1
|
|
2231
|
+
AND later.ledger_seq > d.ledger_seq AND later.amount_micro > 0)
|
|
2232
|
+
AND NOT EXISTS (SELECT 1 FROM credit_fundings f WHERE f.credit_id = $1
|
|
2233
|
+
AND f.ledger_seq >= d.ledger_seq AND f.amount_micro > 0)
|
|
2234
|
+
LIMIT 1`, [creditId, credit.last_funding_ledger_seq]);
|
|
2235
|
+
if (credit.status !== "active" || toSafeInt(credit.expiry_ms) <= nowMs) {
|
|
2236
|
+
throw new CreditLedgerError("credit_not_open", "The original credit is no longer open for refunds.");
|
|
2237
|
+
}
|
|
2238
|
+
if (credit.tempo_channel_id || credit.x402_channel_id) {
|
|
2239
|
+
throw new CreditLedgerError("credit_refund_channel_unsupported", "Channel collateral cannot back a refund of settled spending.");
|
|
2240
|
+
}
|
|
2241
|
+
if (toSafeInt(credit.balance_micro) === 0 && drained.rows.length > 0) {
|
|
2242
|
+
if (credit.last_funding_ledger_seq === null) {
|
|
2243
|
+
throw new CreditLedgerError("credit_refund_state_unavailable", "The original credit's historical funding and drain order cannot be established.");
|
|
2244
|
+
}
|
|
2245
|
+
throw new CreditLedgerError("credit_not_open", "The original credit is no longer open for refunds.");
|
|
2246
|
+
}
|
|
2247
|
+
const totals = await client.query(`SELECT COALESCE(SUM(amount_micro), 0) AS refunded
|
|
2248
|
+
FROM credit_draw_refunds WHERE credit_id = $1 AND draw_id = $2`, [creditId, drawId]);
|
|
2249
|
+
const before = toSafeInt(totals.rows[0].refunded);
|
|
2250
|
+
const originalMicro = toSafeInt(draw.amount_micro);
|
|
2251
|
+
const amountMicro = args.amountMicro ?? originalMicro - before;
|
|
2252
|
+
if (amountMicro <= 0 || amountMicro > originalMicro - before) {
|
|
2253
|
+
throw new CreditLedgerError("refund_exceeds_draw", "The refund exceeds the draw's remaining unrefunded charge.");
|
|
2254
|
+
}
|
|
2255
|
+
const after = before + amountMicro;
|
|
2256
|
+
if (!isFundingRail(credit.rail)) {
|
|
2257
|
+
throw new CreditLedgerError("credit_refund_basis_unavailable", "The original funding rail is not recorded on this older credit.");
|
|
2258
|
+
}
|
|
2259
|
+
const bitcoin = isNonChannelBitcoinRail(credit.rail);
|
|
2260
|
+
const originalSats = draw.settled_sats === null ? null : toSafeInt(draw.settled_sats);
|
|
2261
|
+
if (bitcoin && originalSats === null) {
|
|
2262
|
+
throw new CreditLedgerError("credit_refund_basis_unavailable", "The original Bitcoin value is not provable from this older draw.");
|
|
2263
|
+
}
|
|
2264
|
+
const restoredSats = bitcoin ? refundAssetDelta(originalSats, originalMicro, before, after) : null;
|
|
2265
|
+
const restoredMsats = restoredSats === null ? refundAssetDelta(toSafeInt(draw.draw_msats), originalMicro, before, after) : restoredSats * 1e3;
|
|
2266
|
+
const restoredNative = draw.draw_native === null ? null : refundAssetDelta(toSafeInt(draw.draw_native), originalMicro, before, after);
|
|
2267
|
+
const balanceAfterMicro = toSafeInt(toSafeInt(credit.balance_micro) + amountMicro);
|
|
2268
|
+
const ledgerSeq = toSafeInt(toSafeInt(credit.last_ledger_seq) + 1);
|
|
2269
|
+
toSafeInt(toSafeInt(credit.msats_remaining) + restoredMsats);
|
|
2270
|
+
if (credit.native_remaining !== null) toSafeInt(toSafeInt(credit.native_remaining) + (restoredNative ?? 0));
|
|
2271
|
+
await client.query(`UPDATE credit_draws SET refund_dvm_id = $3
|
|
2272
|
+
WHERE credit_id = $1 AND draw_id = $2`, [creditId, drawId, args.outbox.dvmId]);
|
|
2273
|
+
await client.query(`UPDATE credits SET balance_micro = $2, last_ledger_seq = $3,
|
|
2274
|
+
msats_remaining = msats_remaining + $4,
|
|
2275
|
+
native_remaining = CASE WHEN native_remaining IS NULL THEN NULL ELSE native_remaining + $5 END
|
|
2276
|
+
WHERE credit_id = $1`, [creditId, balanceAfterMicro, ledgerSeq, restoredMsats, restoredNative ?? 0]);
|
|
2277
|
+
if (bitcoin) {
|
|
2278
|
+
await client.query(
|
|
2279
|
+
`INSERT INTO credit_funding_lots
|
|
2280
|
+
(lot_id, credit_id, rail, sats_funded, credited_micro, remaining_micro, funding_ref, created_at, sats_basis_known)
|
|
2281
|
+
VALUES ($1, $2, $3, $4, $5, $5, $6, $7, true)`,
|
|
2282
|
+
[`lot:refund:${randomUUID()}`, creditId, credit.rail, restoredSats, amountMicro, args.refundId, nowMs]
|
|
2283
|
+
);
|
|
2284
|
+
}
|
|
2285
|
+
const result = {
|
|
2286
|
+
refundId: args.refundId,
|
|
2287
|
+
creditId,
|
|
2288
|
+
drawId,
|
|
2289
|
+
jobId: draw.job_id,
|
|
2290
|
+
amountMicro,
|
|
2291
|
+
currency: credit.currency,
|
|
2292
|
+
ledgerSeq,
|
|
2293
|
+
refundedAt: nowMs,
|
|
2294
|
+
balanceAfterMicro,
|
|
2295
|
+
replayed: false
|
|
2296
|
+
};
|
|
2297
|
+
await client.query(
|
|
2298
|
+
`INSERT INTO credit_draw_refunds
|
|
2299
|
+
(refund_id, credit_id, draw_id, request, amount_micro, restored_sats, result)
|
|
2300
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
2301
|
+
[args.refundId, creditId, drawId, request, amountMicro, restoredSats, JSON.stringify(result)]
|
|
2302
|
+
);
|
|
2303
|
+
await args.outbox.enqueue(client, {
|
|
2304
|
+
dvmId: args.outbox.dvmId,
|
|
2305
|
+
creditId,
|
|
2306
|
+
drawId,
|
|
2307
|
+
jobId: draw.job_id,
|
|
2308
|
+
refundId: args.refundId,
|
|
2309
|
+
amountMicro,
|
|
2310
|
+
creditCurrency: credit.currency,
|
|
2311
|
+
ledgerSeq,
|
|
2312
|
+
refundedAt: nowMs,
|
|
2313
|
+
...args.reason === void 0 ? {} : { reason: args.reason }
|
|
2314
|
+
});
|
|
2315
|
+
await client.query("COMMIT");
|
|
2316
|
+
return result;
|
|
2317
|
+
} catch (error) {
|
|
2318
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
2319
|
+
throw error;
|
|
2320
|
+
} finally {
|
|
2321
|
+
client.release();
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2094
2324
|
/**
|
|
2095
2325
|
* Finalize a pending draw: the hold becomes a real debit
|
|
2096
2326
|
* (`balance_micro −= amount`). Available balance is unchanged — the amount
|
|
@@ -2779,6 +3009,15 @@ var CreditLedger = class {
|
|
|
2779
3009
|
}
|
|
2780
3010
|
}
|
|
2781
3011
|
async terminalizeTempoCreditsLocked(q, evidence, channelId, observedAt) {
|
|
3012
|
+
const refunded = evidence.refundedToPayerNative ?? 0n;
|
|
3013
|
+
if (refunded < 0n) throw new Error("Tempo close refund must be nonnegative");
|
|
3014
|
+
const payerExit = evidence.closeKind === "payer_exit";
|
|
3015
|
+
if (payerExit && evidence.refundedToPayerNative !== void 0 || evidence.closeKind === "cooperative_close" && evidence.refundedToPayerNative === void 0) {
|
|
3016
|
+
throw new Error("Tempo close kind requires matching exact cooperative refund evidence");
|
|
3017
|
+
}
|
|
3018
|
+
const lossNative = (payerExit ? evidence.consumedNative : evidence.highestVoucherNative) - evidence.settledOnChainNative - (payerExit ? 0n : refunded);
|
|
3019
|
+
const lossBasis = payerExit ? "payer_exit" : evidence.refundedToPayerNative === void 0 ? "caller_asserted" : "cooperative_close";
|
|
3020
|
+
if (lossNative <= 0n) return this.readTempoCreditLosses(q, { channelId });
|
|
2782
3021
|
const { rows: credits } = await q.query(
|
|
2783
3022
|
`SELECT * FROM credits
|
|
2784
3023
|
WHERE tempo_channel_id = $1
|
|
@@ -2799,8 +3038,8 @@ var CreditLedger = class {
|
|
|
2799
3038
|
`INSERT INTO tempo_credit_losses
|
|
2800
3039
|
(credit_id, channel_id, caller_pubkey, currency, former_balance_micro,
|
|
2801
3040
|
settled_on_chain_native, highest_voucher_native, consumed_service_micro,
|
|
2802
|
-
consumed_native, observed_at)
|
|
2803
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
3041
|
+
consumed_native, observed_at, refunded_to_payer_native, loss_native, loss_basis)
|
|
3042
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
2804
3043
|
ON CONFLICT (credit_id) DO NOTHING`,
|
|
2805
3044
|
[
|
|
2806
3045
|
credit.credit_id,
|
|
@@ -2812,7 +3051,10 @@ var CreditLedger = class {
|
|
|
2812
3051
|
evidence.highestVoucherNative.toString(),
|
|
2813
3052
|
consumed[0]?.amount ?? "0",
|
|
2814
3053
|
evidence.consumedNative.toString(),
|
|
2815
|
-
observedAt
|
|
3054
|
+
observedAt,
|
|
3055
|
+
evidence.refundedToPayerNative?.toString() ?? null,
|
|
3056
|
+
lossNative.toString(),
|
|
3057
|
+
lossBasis
|
|
2816
3058
|
]
|
|
2817
3059
|
);
|
|
2818
3060
|
await q.query(`UPDATE credits SET status = 'unbacked' WHERE credit_id = $1`, [
|
|
@@ -2821,8 +3063,8 @@ var CreditLedger = class {
|
|
|
2821
3063
|
}
|
|
2822
3064
|
return this.readTempoCreditLosses(q, { channelId });
|
|
2823
3065
|
}
|
|
2824
|
-
async listTempoCreditLosses(args) {
|
|
2825
|
-
return this.readTempoCreditLosses(this.pool, { limit: args?.limit ?? 50 });
|
|
3066
|
+
async listTempoCreditLosses(args, tx) {
|
|
3067
|
+
return this.readTempoCreditLosses(tx ?? this.pool, { limit: args?.limit ?? 50, channelId: args?.channelId?.toLowerCase() });
|
|
2826
3068
|
}
|
|
2827
3069
|
/**
|
|
2828
3070
|
* Reconcile the credit bound to a chain-proven empty x402 channel.
|
|
@@ -3158,26 +3400,27 @@ var CreditLedger = class {
|
|
|
3158
3400
|
return rows.map(drainRecordFromJoinRow);
|
|
3159
3401
|
}
|
|
3160
3402
|
/**
|
|
3161
|
-
* Page
|
|
3162
|
-
* age-bounded
|
|
3403
|
+
* Page held Tempo-channel drains by (created_at, credit_id, drain_id), optionally
|
|
3404
|
+
* age-bounded. Audit reads also include terminal operator decisions. Accept the legacy mpp stored method as well as
|
|
3163
3405
|
* tempo so pre-migration drains remain repairable. Use the compound key for both ordering
|
|
3164
3406
|
* and continuation.
|
|
3165
3407
|
*/
|
|
3166
3408
|
async listChannelDrains(args) {
|
|
3167
|
-
const params = [args.createdBeforeMs, args.limit];
|
|
3409
|
+
const params = [args.createdBeforeMs, args.limit, args.includeResolved ?? false, args.includeWrittenOff || args.includeResolved || false];
|
|
3168
3410
|
let keyset = "";
|
|
3169
3411
|
if (args.after) {
|
|
3170
3412
|
params.push(args.after.createdAt, args.after.creditId, args.after.drainId);
|
|
3171
|
-
keyset = ` AND (d.created_at, d.credit_id, d.drain_id) > ($
|
|
3413
|
+
keyset = ` AND (d.created_at, d.credit_id, d.drain_id) > ($5, $6, $7)`;
|
|
3172
3414
|
}
|
|
3173
3415
|
const { rows } = await this.pool.query(
|
|
3174
3416
|
`SELECT d.*, ${DRAIN_CREDIT_COLUMNS} FROM credit_drains d
|
|
3175
3417
|
JOIN credits c ON c.credit_id = d.credit_id
|
|
3176
|
-
WHERE d.status IN ('pending','exit_required')
|
|
3418
|
+
WHERE (d.status IN ('pending','exit_required')
|
|
3419
|
+
OR ($3::boolean AND d.operator_decision IS NOT NULL))
|
|
3177
3420
|
AND d.method = 'tempo'
|
|
3178
3421
|
AND c.tempo_channel_id IS NOT NULL
|
|
3179
3422
|
AND d.created_at <= $1
|
|
3180
|
-
$
|
|
3423
|
+
AND ($4::boolean OR d.written_off_at IS NULL)
|
|
3181
3424
|
${keyset}
|
|
3182
3425
|
ORDER BY d.created_at, d.credit_id, d.drain_id
|
|
3183
3426
|
LIMIT $2`,
|
|
@@ -3241,49 +3484,182 @@ var CreditLedger = class {
|
|
|
3241
3484
|
verb: "require timed exit"
|
|
3242
3485
|
});
|
|
3243
3486
|
}
|
|
3487
|
+
/** Retain an exact receipt-derived refund; a later missing/zero read cannot erase payment. */
|
|
3488
|
+
async recordTempoDrainRefund(args) {
|
|
3489
|
+
const amount = String(args.refundedNative);
|
|
3490
|
+
if (!/^\d{1,78}$/.test(amount)) throw new CreditLedgerError("invalid_basis", "Tempo refund evidence must be a bounded non-negative integer.");
|
|
3491
|
+
await this.recordTempoDrainRefundEvidence(args, amount);
|
|
3492
|
+
}
|
|
3493
|
+
/** Retain trusted proof that some refund was paid, without claiming an exact amount. */
|
|
3494
|
+
async recordTempoDrainPayment(args) {
|
|
3495
|
+
await this.recordTempoDrainRefundEvidence(args, null);
|
|
3496
|
+
}
|
|
3497
|
+
async recordTempoDrainRefundEvidence(args, amount) {
|
|
3498
|
+
const { rows } = await this.pool.query(
|
|
3499
|
+
"SELECT tempo_channel_id FROM credits WHERE credit_id=$1",
|
|
3500
|
+
[args.creditId]
|
|
3501
|
+
);
|
|
3502
|
+
const record = async (tx) => {
|
|
3503
|
+
await tx.query(
|
|
3504
|
+
`UPDATE credit_drains SET proven_refund_native=
|
|
3505
|
+
GREATEST(proven_refund_native, $3::numeric),
|
|
3506
|
+
refund_payment_proven=refund_payment_proven OR COALESCE($3::numeric > 0, TRUE)
|
|
3507
|
+
WHERE credit_id=$1 AND drain_id=$2 AND method='tempo'
|
|
3508
|
+
AND status IN ('pending','exit_required') AND operator_decision IS NULL`,
|
|
3509
|
+
[args.creditId, args.drainId, amount]
|
|
3510
|
+
);
|
|
3511
|
+
};
|
|
3512
|
+
const channelId = rows[0]?.tempo_channel_id;
|
|
3513
|
+
if (channelId && this.tempoSessionStore) {
|
|
3514
|
+
await this.tempoSessionStore.withDrawPlacement(channelId, record);
|
|
3515
|
+
} else {
|
|
3516
|
+
await record(this.pool);
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
/** Record the latest established proof refusal without changing the held debit. */
|
|
3520
|
+
async recordTempoDrainHold(args) {
|
|
3521
|
+
await this.pool.query(
|
|
3522
|
+
`UPDATE credit_drains SET hold_reason=
|
|
3523
|
+
CASE WHEN hold_reason='refund_short' AND proven_refund_native IS NULL THEN hold_reason ELSE $3 END
|
|
3524
|
+
WHERE credit_id=$1 AND drain_id=$2 AND method='tempo'
|
|
3525
|
+
AND status IN ('pending','exit_required') AND operator_decision IS NULL`,
|
|
3526
|
+
[args.creditId, args.drainId, args.reason]
|
|
3527
|
+
);
|
|
3528
|
+
}
|
|
3244
3529
|
/**
|
|
3245
|
-
*
|
|
3246
|
-
*
|
|
3247
|
-
*
|
|
3248
|
-
*
|
|
3530
|
+
* An operator attests a full off-path refund or restores the exact unpaid debit. The
|
|
3531
|
+
* channel lock precedes the credit and drain locks; automatic booking shares the drain
|
|
3532
|
+
* CAS, so only one terminal outcome can commit. No channel lifecycle state is reopened.
|
|
3533
|
+
* Direct callers own reporting: hosted booking must join the caller's drain outbox transaction.
|
|
3249
3534
|
*/
|
|
3250
|
-
async
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3535
|
+
async decideTempoDrain(args) {
|
|
3536
|
+
if (!["book_refunded", "release"].includes(args.decision) || typeof args.reason !== "string" || !args.reason.trim() || args.reason.trim().length > 2e3 || args.evidence !== void 0 && (typeof args.evidence !== "string" || !args.evidence.trim() || args.evidence.trim().length > 4e3)) {
|
|
3537
|
+
throw new CreditLedgerError("invalid_drain_decision", "A decision requires a reason of 1\u20132000 characters and optional evidence of 1\u20134000 characters.");
|
|
3538
|
+
}
|
|
3539
|
+
const query = args.tx ?? this.pool;
|
|
3540
|
+
const { rows } = await query.query(
|
|
3541
|
+
`SELECT tempo_channel_id FROM credits WHERE credit_id=$1`,
|
|
3542
|
+
[args.creditId]
|
|
3543
|
+
);
|
|
3544
|
+
if (!rows[0]) throw new CreditLedgerError("credit_not_found", "The credit was not found.");
|
|
3545
|
+
const channelId = rows[0].tempo_channel_id;
|
|
3546
|
+
if (!channelId || !this.tempoSessionStore) {
|
|
3547
|
+
throw new CreditLedgerError("drain_not_repairable", "A durable Tempo session channel is required for an operator decision.");
|
|
3548
|
+
}
|
|
3549
|
+
return this.tempoSessionStore.withDrawPlacement(channelId, async (tx, lifecycle) => {
|
|
3550
|
+
const intent = await tx.query(
|
|
3551
|
+
`SELECT reference, settled_before_close, forecast_pending FROM mpp_tempo_close_intents
|
|
3552
|
+
WHERE channel_id=$1 AND drain_id=$2 FOR UPDATE`,
|
|
3553
|
+
[channelId, args.drainId]
|
|
3554
|
+
);
|
|
3555
|
+
const credit = await this.lockCreditRow(tx, args.creditId);
|
|
3556
|
+
const { rows: rows2 } = await tx.query(
|
|
3557
|
+
`SELECT * FROM credit_drains WHERE credit_id=$1 AND drain_id=$2 FOR UPDATE`,
|
|
3558
|
+
[args.creditId, args.drainId]
|
|
3559
|
+
);
|
|
3560
|
+
const row = rows2[0];
|
|
3561
|
+
if (!row) throw new CreditLedgerError("drain_not_found", "The drain was not found.");
|
|
3562
|
+
if (row.method !== "tempo") throw new CreditLedgerError("drain_not_repairable", "Only a held Tempo session drain accepts an operator decision.");
|
|
3563
|
+
const reason = args.reason.trim();
|
|
3564
|
+
const evidence = args.evidence?.trim();
|
|
3565
|
+
let result;
|
|
3566
|
+
if (row.operator_decision) {
|
|
3567
|
+
const prior = row.operator_decision;
|
|
3568
|
+
if (prior.decision !== args.decision || prior.reason !== reason || prior.evidence !== evidence) {
|
|
3569
|
+
throw new CreditLedgerError("drain_decision_conflict", "This drain already has a different immutable operator decision.");
|
|
3570
|
+
}
|
|
3571
|
+
result = {
|
|
3262
3572
|
drain: drainRecordFromRow(row, credit),
|
|
3263
|
-
credit: await this.snapshotFromRow(credit,
|
|
3573
|
+
credit: await this.snapshotFromRow(credit, tx, args.nowMs ?? Date.now()),
|
|
3264
3574
|
replayed: true
|
|
3265
3575
|
};
|
|
3576
|
+
} else {
|
|
3577
|
+
if (row.status !== "pending" && row.status !== "exit_required") {
|
|
3578
|
+
throw new CreditLedgerError("drain_decision_conflict", "This drain has already reached another terminal outcome.");
|
|
3579
|
+
}
|
|
3580
|
+
if (args.decision === "release") {
|
|
3581
|
+
const closeIntent = intent.rows[0];
|
|
3582
|
+
const reference = closeIntent?.reference;
|
|
3583
|
+
if (!lifecycle.finalized && closeIntent && (closeIntent.forecast_pending !== true || closeIntent.settled_before_close != null || reference && /^0x[0-9a-fA-F]{64}$/.test(reference))) {
|
|
3584
|
+
throw new CreditLedgerError("tempo_drain_close_pending", "The close may still pay. Reconcile its final outcome before releasing this drain.");
|
|
3585
|
+
}
|
|
3586
|
+
const channel = await tx.query(
|
|
3587
|
+
"SELECT state_json FROM mpp_tempo_channels WHERE channel_key=$1",
|
|
3588
|
+
[channelId]
|
|
3589
|
+
);
|
|
3590
|
+
const state = channel.rows[0] && parseStoreValue(channel.rows[0].state_json);
|
|
3591
|
+
const observation = state?.tempoDrainPaymentObservation;
|
|
3592
|
+
const paymentObserved = observation?.creditId === args.creditId && observation.drainId === args.drainId;
|
|
3593
|
+
if (paymentObserved || row.refund_payment_proven || row.proven_refund_native !== null && BigInt(row.proven_refund_native) > 0n) {
|
|
3594
|
+
throw new CreditLedgerError("tempo_drain_refund_paid", "A positive refund is already proven. Verify return of the full held amount, including any remainder, then book it as refunded; never release it as unpaid.");
|
|
3595
|
+
}
|
|
3596
|
+
if (row.hold_reason === "refund_short" && row.proven_refund_native === null) {
|
|
3597
|
+
throw new CreditLedgerError("tempo_drain_refund_unknown", "This short-refund history has no retained exact amount. Recover its refund evidence before releasing it as unpaid.");
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
const decision = {
|
|
3601
|
+
source: "operator",
|
|
3602
|
+
decision: args.decision,
|
|
3603
|
+
reason,
|
|
3604
|
+
...evidence === void 0 ? {} : { evidence },
|
|
3605
|
+
decidedAt: args.nowMs ?? Date.now()
|
|
3606
|
+
};
|
|
3607
|
+
await tx.query(`UPDATE credit_drains SET operator_decision=$3
|
|
3608
|
+
WHERE credit_id=$1 AND drain_id=$2`, [args.creditId, args.drainId, JSON.stringify(decision)]);
|
|
3609
|
+
row.operator_decision = decision;
|
|
3610
|
+
if (args.decision === "release") {
|
|
3611
|
+
result = await this.releaseDrainLocked(tx, row, args, decision.decidedAt);
|
|
3612
|
+
} else {
|
|
3613
|
+
await this.clearDrainWriteOff({ creditId: args.creditId, drainId: args.drainId, tx });
|
|
3614
|
+
const booked = await this.markDrainSent({
|
|
3615
|
+
creditId: args.creditId,
|
|
3616
|
+
drainId: args.drainId,
|
|
3617
|
+
sentRef: { source: "operator", channel_id: channelId },
|
|
3618
|
+
tx,
|
|
3619
|
+
nowMs: decision.decidedAt
|
|
3620
|
+
});
|
|
3621
|
+
result = { ...booked, credit: await this.snapshotFromRow(credit, tx, decision.decidedAt) };
|
|
3622
|
+
}
|
|
3266
3623
|
}
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3624
|
+
const spendableMicro = lifecycle.finalized || lifecycle.closeRequestedAt !== 0n || result.credit.expired ? 0 : result.credit.availableMicro;
|
|
3625
|
+
return { ...result, spendableMicro };
|
|
3626
|
+
}, args.tx);
|
|
3627
|
+
}
|
|
3628
|
+
/** Restore an unpaid pending drain; operator decisions also admit exit_required. */
|
|
3629
|
+
async releaseDrain(args) {
|
|
3630
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
3631
|
+
const run = async (client, credit) => {
|
|
3632
|
+
const row = await this.readDrain(client, args.creditId, args.drainId);
|
|
3633
|
+
if (!row) throw new CreditLedgerError("drain_not_found", `drain ${args.drainId} on credit ${args.creditId} not found`);
|
|
3634
|
+
if (row.status === "released") return {
|
|
3635
|
+
drain: drainRecordFromRow(row, credit),
|
|
3636
|
+
credit: await this.snapshotFromRow(credit, client, nowMs),
|
|
3637
|
+
replayed: true
|
|
3638
|
+
};
|
|
3639
|
+
if (row.status !== "pending") throw new CreditLedgerError(
|
|
3640
|
+
"invalid_drain_state",
|
|
3641
|
+
`cannot release ${row.method} drain ${args.drainId} from status ${row.status}`
|
|
3642
|
+
);
|
|
3643
|
+
return this.releaseDrainLocked(client, row, args, nowMs);
|
|
3644
|
+
};
|
|
3645
|
+
if (args.tx) return run(args.tx, await this.lockCreditRow(args.tx, args.creditId));
|
|
3646
|
+
return this.withCreditLock(args.creditId, run);
|
|
3647
|
+
}
|
|
3648
|
+
async releaseDrainLocked(client, row, args, nowMs) {
|
|
3649
|
+
const { rows } = await client.query(
|
|
3650
|
+
`UPDATE credit_drains SET status = 'released', released_at = $3
|
|
3651
|
+
WHERE credit_id = $1 AND drain_id = $2 AND status = $4
|
|
3276
3652
|
RETURNING *`,
|
|
3277
|
-
|
|
3653
|
+
[args.creditId, args.drainId, nowMs, row.status]
|
|
3654
|
+
);
|
|
3655
|
+
if (!rows[0]) {
|
|
3656
|
+
throw new CreditLedgerError(
|
|
3657
|
+
"invalid_drain_state",
|
|
3658
|
+
`drain ${args.drainId} changed while it was being released`
|
|
3278
3659
|
);
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
`drain ${args.drainId} changed while it was being released`
|
|
3283
|
-
);
|
|
3284
|
-
}
|
|
3285
|
-
await client.query(
|
|
3286
|
-
`UPDATE credits
|
|
3660
|
+
}
|
|
3661
|
+
await client.query(
|
|
3662
|
+
`UPDATE credits
|
|
3287
3663
|
SET balance_micro = balance_micro + $2,
|
|
3288
3664
|
msats_remaining = msats_remaining + $3,
|
|
3289
3665
|
native_remaining = CASE
|
|
@@ -3291,18 +3667,15 @@ var CreditLedger = class {
|
|
|
3291
3667
|
ELSE native_remaining + $4
|
|
3292
3668
|
END
|
|
3293
3669
|
WHERE credit_id = $1`,
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
};
|
|
3670
|
+
[args.creditId, row.amount_micro, row.drained_msats, row.drained_native]
|
|
3671
|
+
);
|
|
3672
|
+
await this.applyLotDebits(client, row.lot_debits ?? [], 1);
|
|
3673
|
+
const restored = await this.lockCreditRow(client, args.creditId);
|
|
3674
|
+
return {
|
|
3675
|
+
drain: drainRecordFromRow(rows[0], restored),
|
|
3676
|
+
credit: await this.snapshotFromRow(restored, client, nowMs),
|
|
3677
|
+
replayed: false
|
|
3303
3678
|
};
|
|
3304
|
-
if (args.tx) return run(args.tx, await this.lockCreditRow(args.tx, args.creditId));
|
|
3305
|
-
return this.withCreditLock(args.creditId, run);
|
|
3306
3679
|
}
|
|
3307
3680
|
/**
|
|
3308
3681
|
* Attach the parked Cashu token to a pending drain (`pending → parked`).
|
|
@@ -3525,7 +3898,13 @@ var CreditLedger = class {
|
|
|
3525
3898
|
let settledMsats = forecastMsats;
|
|
3526
3899
|
if (isNonChannelBitcoinRail(credit.rail)) {
|
|
3527
3900
|
const depletion = depleteLots(await this.openLots(client, args.creditId), amountMicro);
|
|
3528
|
-
|
|
3901
|
+
const inKindMsats = inKindDrawMsats(depletion, amountMicro);
|
|
3902
|
+
settledMsats = inKindMsats ?? forecastMsats;
|
|
3903
|
+
await client.query(
|
|
3904
|
+
`UPDATE credit_draws SET settled_sats = $3
|
|
3905
|
+
WHERE credit_id = $1 AND draw_id = $2`,
|
|
3906
|
+
[args.creditId, args.drawId, inKindMsats === null ? null : inKindMsats / 1e3]
|
|
3907
|
+
);
|
|
3529
3908
|
await this.applyLotDebits(client, depletion.debits, -1);
|
|
3530
3909
|
}
|
|
3531
3910
|
if (settledMsats !== forecastMsats) {
|
|
@@ -3771,8 +4150,8 @@ var BITCOIN_CREDIT_PREDICATE = `c.rail IN ('cashu', 'lightning')
|
|
|
3771
4150
|
AND c.balance_micro > 0`;
|
|
3772
4151
|
var LOT_COVERAGE_LATERAL = `SELECT
|
|
3773
4152
|
COALESCE(SUM(l.remaining_micro), 0) AS covered_micro,
|
|
3774
|
-
COALESCE(SUM(l.remaining_micro) FILTER (WHERE l.sats_funded = 0), 0) AS basisless_micro,
|
|
3775
|
-
COALESCE(SUM(l.sats_funded * l.remaining_micro / NULLIF(l.credited_micro, 0)), 0) AS owed_sats
|
|
4153
|
+
COALESCE(SUM(l.remaining_micro) FILTER (WHERE l.sats_funded = 0 AND NOT l.sats_basis_known), 0) AS basisless_micro,
|
|
4154
|
+
COALESCE(SUM(FLOOR(l.sats_funded::numeric * l.remaining_micro / NULLIF(l.credited_micro, 0))), 0) AS owed_sats
|
|
3776
4155
|
FROM credit_funding_lots l
|
|
3777
4156
|
WHERE l.credit_id = c.credit_id AND l.remaining_micro > 0`;
|
|
3778
4157
|
var IN_KIND_PREDICATE = `cov.covered_micro >= c.balance_micro AND cov.basisless_micro = 0`;
|
|
@@ -3840,6 +4219,9 @@ function tempoCreditLossFromRow(row) {
|
|
|
3840
4219
|
highestVoucherNative: BigInt(row.highest_voucher_native),
|
|
3841
4220
|
consumedServiceMicro: toSafeInt(row.consumed_service_micro),
|
|
3842
4221
|
consumedNative: BigInt(row.consumed_native),
|
|
4222
|
+
refundedToPayerNative: row.refunded_to_payer_native === null ? null : BigInt(row.refunded_to_payer_native),
|
|
4223
|
+
lossNative: row.loss_native === null ? null : BigInt(row.loss_native),
|
|
4224
|
+
lossBasis: row.loss_basis === "cooperative_close" || row.loss_basis === "payer_exit" || row.loss_basis === "caller_asserted" ? row.loss_basis : null,
|
|
3843
4225
|
observedAt: toSafeInt(row.observed_at)
|
|
3844
4226
|
};
|
|
3845
4227
|
}
|
|
@@ -3883,7 +4265,11 @@ function drainRecordFromRow(row, credit) {
|
|
|
3883
4265
|
sentAt: row.sent_at === null ? null : toSafeInt(row.sent_at),
|
|
3884
4266
|
releasedAt: row.released_at === null ? null : toSafeInt(row.released_at),
|
|
3885
4267
|
writeOffNote: row.write_off_note,
|
|
3886
|
-
writtenOffAt: row.written_off_at === null ? null : toSafeInt(row.written_off_at)
|
|
4268
|
+
writtenOffAt: row.written_off_at === null ? null : toSafeInt(row.written_off_at),
|
|
4269
|
+
operatorDecision: row.operator_decision ?? null,
|
|
4270
|
+
holdReason: row.hold_reason ?? null,
|
|
4271
|
+
provenRefundNative: row.proven_refund_native ?? null,
|
|
4272
|
+
refundPaymentProven: row.refund_payment_proven ?? false
|
|
3887
4273
|
};
|
|
3888
4274
|
}
|
|
3889
4275
|
function drainRecordFromJoinRow(row) {
|
|
@@ -3900,6 +4286,7 @@ function fundingLotFromRow(row) {
|
|
|
3900
4286
|
creditId: row.credit_id,
|
|
3901
4287
|
rail: row.rail,
|
|
3902
4288
|
satsFunded: toSafeInt(row.sats_funded),
|
|
4289
|
+
satsBasisKnown: row.sats_basis_known,
|
|
3903
4290
|
creditedMicro: toSafeInt(row.credited_micro),
|
|
3904
4291
|
remainingMicro: toSafeInt(row.remaining_micro),
|
|
3905
4292
|
fundingRef: row.funding_ref,
|
|
@@ -3983,6 +4370,8 @@ export {
|
|
|
3983
4370
|
netOwedSats,
|
|
3984
4371
|
fifoOrder,
|
|
3985
4372
|
isInKindDepletion,
|
|
4373
|
+
refundRequest,
|
|
4374
|
+
refundAssetDelta,
|
|
3986
4375
|
X402SettlementRaceError,
|
|
3987
4376
|
X402RelaySubmissionLockError,
|
|
3988
4377
|
X402_WEDGED_SETTLEMENT_STATUSES,
|