@dvmkit/sdk 0.10.1 → 0.12.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-STJBBVNL.js → chunk-2VGMZ7VF.js} +487 -71
- package/dist/{chunk-3Q6FTZDB.js → chunk-3YFFZLW7.js} +2 -2
- 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-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-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-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-YOSZIWC3.js → chunk-POYUX2NN.js} +149 -11
- package/dist/{chunk-VUGEEMLT.js → chunk-RPUFWIT4.js} +11 -89
- package/dist/{chunk-SYBFMHXQ.js → chunk-S7PIFHE4.js} +590 -67
- package/dist/{chunk-2OGBZPPJ.js → chunk-TWKQFIOW.js} +21 -3
- 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-a1yRdmhI.d.ts} +1 -1
- package/dist/{credit-ledger-4PZNAMDT.js → credit-ledger-MF23UKRJ.js} +6 -3
- package/dist/{credit-menu-BWsVqMh-.d.ts → credit-menu-B2T-O9oY.d.ts} +11 -4
- package/dist/{fx-BM5h4o5S.d.ts → fx-DLhscRUh.d.ts} +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +6 -5
- package/dist/internal/caller.d.ts +8 -6
- package/dist/internal/caller.js +27 -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-B8Ijr5V6.d.ts} +3 -1
- package/dist/{lightning-backend-9oky_wJi.d.ts → lightning-backend-DmwMSgaM.d.ts} +1 -1
- package/dist/memory-credit-ledger-ERD764PS.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-ZVMAN6L2.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 +30 -12
- package/dist/server/index.js +32 -30
- package/dist/{step-cache--7yA5Ihr.d.ts → step-cache-DQqpwXa8.d.ts} +206 -47
- 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-D5kk2nIT.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),
|
|
@@ -1973,7 +2029,8 @@ var CreditLedger = class {
|
|
|
1973
2029
|
/**
|
|
1974
2030
|
* Grow an accepted job’s pending hold while preserving its original ledger_seq. The balance
|
|
1975
2031
|
* tuple is updated at that sequence, so interleaved draws need not form a monotonic balance
|
|
1976
|
-
* trajectory.
|
|
2032
|
+
* trajectory. Existing rail top-ups retain accepted-job semantics; spending existing balance
|
|
2033
|
+
* opts into fresh-spend expiry and channel gates.
|
|
1977
2034
|
*
|
|
1978
2035
|
* Available balance excludes every pending hold, including this one. An earmark allocates
|
|
1979
2036
|
* the incoming funding’s rail value; absent one, growth uses the pool.
|
|
@@ -1985,7 +2042,7 @@ var CreditLedger = class {
|
|
|
1985
2042
|
*/
|
|
1986
2043
|
async growDraw(args) {
|
|
1987
2044
|
assertAmount(args.addAmountMicro, { min: 1 });
|
|
1988
|
-
const run = async (client, credit) => {
|
|
2045
|
+
const run = async (client, credit, tempoLifecycle) => {
|
|
1989
2046
|
const existing = await this.readDraw(client, args.creditId, args.drawId);
|
|
1990
2047
|
if (!existing) {
|
|
1991
2048
|
throw new CreditLedgerError(
|
|
@@ -2008,6 +2065,14 @@ var CreditLedger = class {
|
|
|
2008
2065
|
{ expectedJobId: existing.job_id ?? null }
|
|
2009
2066
|
);
|
|
2010
2067
|
}
|
|
2068
|
+
if (args.spendExistingBalance && toSafeInt(credit.expiry_ms) <= (args.nowMs ?? Date.now())) {
|
|
2069
|
+
throw new CreditLedgerError("credit_expired", "Credit has expired");
|
|
2070
|
+
}
|
|
2071
|
+
if (args.spendExistingBalance && tempoLifecycle && (tempoLifecycle.finalized || tempoLifecycle.closeRequestedAt !== 0n)) {
|
|
2072
|
+
throw new CreditLedgerError("tempo_channel_closing", "Credit channel is closing");
|
|
2073
|
+
}
|
|
2074
|
+
const refund = args.spendExistingBalance ? await this.blockingX402Refund(credit.x402_channel_id, X402_SPEND_BLOCKING_SETTLEMENT_STATUSES) : void 0;
|
|
2075
|
+
if (refund) throw x402SettlementPending(args.creditId, refund);
|
|
2011
2076
|
const priorGrown = toSafeInt(existing.grown_micro);
|
|
2012
2077
|
const headroom = args.capMicro === void 0 ? args.addAmountMicro : Math.max(0, args.capMicro - priorGrown);
|
|
2013
2078
|
const grantMicro = Math.min(args.addAmountMicro, headroom);
|
|
@@ -2055,6 +2120,12 @@ var CreditLedger = class {
|
|
|
2055
2120
|
const balanceAfterMicro = available - grantMicro;
|
|
2056
2121
|
const drawMsats = priorMsats + added.drawMsats;
|
|
2057
2122
|
const drawNative = added.drawNative === null ? priorNative : (priorNative ?? 0) + added.drawNative;
|
|
2123
|
+
if (args.spendExistingBalance) {
|
|
2124
|
+
await this.hostingCleanupFence?.assertNewIntent(client, {
|
|
2125
|
+
jobId: args.jobId,
|
|
2126
|
+
paymentSeq: args.hostingPaymentSeq
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2058
2129
|
await client.query(
|
|
2059
2130
|
`UPDATE credit_draws
|
|
2060
2131
|
SET amount_micro = $3, balance_after_micro = $4, draw_msats = $5, draw_native = $6,
|
|
@@ -2085,12 +2156,198 @@ var CreditLedger = class {
|
|
|
2085
2156
|
cappedMicro: args.addAmountMicro - grantMicro
|
|
2086
2157
|
};
|
|
2087
2158
|
};
|
|
2159
|
+
const query = args.tx ?? this.pool;
|
|
2160
|
+
const { rows } = await query.query(
|
|
2161
|
+
"SELECT tempo_channel_id FROM credits WHERE credit_id = $1",
|
|
2162
|
+
[args.creditId]
|
|
2163
|
+
);
|
|
2164
|
+
const channelId = rows[0]?.tempo_channel_id;
|
|
2165
|
+
if (args.spendExistingBalance && channelId && this.tempoSessionStore) {
|
|
2166
|
+
return this.tempoSessionStore.withDrawPlacement(channelId, async (tx, lifecycle) => {
|
|
2167
|
+
const credit = await this.lockCreditRow(tx, args.creditId);
|
|
2168
|
+
return run(tx, credit, lifecycle);
|
|
2169
|
+
}, args.tx);
|
|
2170
|
+
}
|
|
2088
2171
|
if (args.tx) {
|
|
2089
2172
|
const credit = await this.lockCreditRow(args.tx, args.creditId);
|
|
2090
2173
|
return run(args.tx, credit);
|
|
2091
2174
|
}
|
|
2092
2175
|
return this.withCreditLock(args.creditId, run);
|
|
2093
2176
|
}
|
|
2177
|
+
/** Restore a completed draw and its report under one durable transaction. */
|
|
2178
|
+
async refundDraw(args) {
|
|
2179
|
+
const request = refundRequest(args);
|
|
2180
|
+
const client = await this.pool.connect();
|
|
2181
|
+
try {
|
|
2182
|
+
await client.query("BEGIN");
|
|
2183
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [args.refundId]);
|
|
2184
|
+
const prior = await client.query(
|
|
2185
|
+
"SELECT request, result FROM credit_draw_refunds WHERE refund_id = $1",
|
|
2186
|
+
[args.refundId]
|
|
2187
|
+
);
|
|
2188
|
+
if (prior.rows[0]) {
|
|
2189
|
+
if (prior.rows[0].request !== request) {
|
|
2190
|
+
throw new CreditLedgerError("refund_conflict", "This refundId was recorded with different arguments.");
|
|
2191
|
+
}
|
|
2192
|
+
await client.query("COMMIT");
|
|
2193
|
+
return { ...prior.rows[0].result, replayed: true };
|
|
2194
|
+
}
|
|
2195
|
+
let creditId = args.creditId;
|
|
2196
|
+
let drawId = args.drawId;
|
|
2197
|
+
if (args.jobId !== void 0) {
|
|
2198
|
+
const matches = await client.query(
|
|
2199
|
+
"SELECT credit_id, draw_id FROM credit_draws WHERE job_id = $1 LIMIT 2",
|
|
2200
|
+
[args.jobId]
|
|
2201
|
+
);
|
|
2202
|
+
if (matches.rows.length === 1) {
|
|
2203
|
+
creditId = matches.rows[0].credit_id;
|
|
2204
|
+
drawId = matches.rows[0].draw_id;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
if (creditId === void 0 || drawId === void 0) {
|
|
2208
|
+
throw new CreditLedgerError("draw_not_found", "No unique credit draw matches this job.");
|
|
2209
|
+
}
|
|
2210
|
+
let credit;
|
|
2211
|
+
try {
|
|
2212
|
+
credit = await this.lockCreditRow(client, creditId);
|
|
2213
|
+
} catch (error) {
|
|
2214
|
+
if (error instanceof CreditLedgerError && error.code === "credit_not_found") {
|
|
2215
|
+
throw new CreditLedgerError("draw_not_found", "No draw matches this credit.");
|
|
2216
|
+
}
|
|
2217
|
+
throw error;
|
|
2218
|
+
}
|
|
2219
|
+
const draw = await this.readDraw(client, creditId, drawId);
|
|
2220
|
+
if (!credit || !draw || !draw.job_id || args.jobId !== void 0 && draw.job_id !== args.jobId) {
|
|
2221
|
+
throw new CreditLedgerError("draw_not_found", "No job-bound draw matches this selector.");
|
|
2222
|
+
}
|
|
2223
|
+
const owners = new Set(draw.refund_dvm_id ? [draw.refund_dvm_id] : []);
|
|
2224
|
+
const sources = await client.query(
|
|
2225
|
+
"SELECT to_regclass('jobs')::text AS jobs, to_regclass('reported_job_terminals')::text AS terminals"
|
|
2226
|
+
);
|
|
2227
|
+
if (sources.rows[0].jobs) {
|
|
2228
|
+
const jobs = await client.query(
|
|
2229
|
+
"SELECT dvm_id, receipt FROM jobs WHERE id = $1",
|
|
2230
|
+
[draw.job_id]
|
|
2231
|
+
);
|
|
2232
|
+
const job = jobs.rows[0];
|
|
2233
|
+
if (job?.dvm_id) owners.add(job.dvm_id);
|
|
2234
|
+
const receipt = job?.receipt;
|
|
2235
|
+
if (args.trustedReceiptPubkey !== void 0 && isSignedJobReceipt(receipt) && receipt.receipt_pubkey === args.trustedReceiptPubkey && receipt.job_id === draw.job_id && verifyReceipt(receipt)) {
|
|
2236
|
+
owners.add(receipt.dvm_id);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
if (sources.rows[0].terminals) {
|
|
2240
|
+
const terminals = await client.query(
|
|
2241
|
+
"SELECT dvm_id FROM reported_job_terminals WHERE job_id = $1",
|
|
2242
|
+
[draw.job_id]
|
|
2243
|
+
);
|
|
2244
|
+
for (const terminal of terminals.rows) owners.add(terminal.dvm_id);
|
|
2245
|
+
}
|
|
2246
|
+
if (owners.size !== 1 || !owners.has(args.outbox.dvmId)) {
|
|
2247
|
+
throw new CreditLedgerError("draw_not_found", "No draw with proven ownership matches this DVM.");
|
|
2248
|
+
}
|
|
2249
|
+
if (draw.status !== "settled") {
|
|
2250
|
+
throw new CreditLedgerError("draw_not_completed", "Only a settled completed draw can be refunded.");
|
|
2251
|
+
}
|
|
2252
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
2253
|
+
const drained = await client.query(`SELECT 1 FROM credit_drains d
|
|
2254
|
+
WHERE d.credit_id = $1 AND d.status <> 'released' AND d.balance_after_micro = 0
|
|
2255
|
+
AND (SELECT MAX(ledger_seq) FROM credit_drains WHERE credit_id = $1 AND status <> 'released') = d.ledger_seq
|
|
2256
|
+
AND ($2::bigint IS NULL OR $2::bigint < d.ledger_seq)
|
|
2257
|
+
AND NOT EXISTS (SELECT 1 FROM credit_draws later WHERE later.credit_id = $1
|
|
2258
|
+
AND later.ledger_seq > d.ledger_seq AND later.amount_micro > 0)
|
|
2259
|
+
AND NOT EXISTS (SELECT 1 FROM credit_fundings f WHERE f.credit_id = $1
|
|
2260
|
+
AND f.ledger_seq >= d.ledger_seq AND f.amount_micro > 0)
|
|
2261
|
+
LIMIT 1`, [creditId, credit.last_funding_ledger_seq]);
|
|
2262
|
+
if (credit.status !== "active" || toSafeInt(credit.expiry_ms) <= nowMs) {
|
|
2263
|
+
throw new CreditLedgerError("credit_not_open", "The original credit is no longer open for refunds.");
|
|
2264
|
+
}
|
|
2265
|
+
if (credit.tempo_channel_id || credit.x402_channel_id) {
|
|
2266
|
+
throw new CreditLedgerError("credit_refund_channel_unsupported", "Channel collateral cannot back a refund of settled spending.");
|
|
2267
|
+
}
|
|
2268
|
+
if (toSafeInt(credit.balance_micro) === 0 && drained.rows.length > 0) {
|
|
2269
|
+
if (credit.last_funding_ledger_seq === null) {
|
|
2270
|
+
throw new CreditLedgerError("credit_refund_state_unavailable", "The original credit's historical funding and drain order cannot be established.");
|
|
2271
|
+
}
|
|
2272
|
+
throw new CreditLedgerError("credit_not_open", "The original credit is no longer open for refunds.");
|
|
2273
|
+
}
|
|
2274
|
+
const totals = await client.query(`SELECT COALESCE(SUM(amount_micro), 0) AS refunded
|
|
2275
|
+
FROM credit_draw_refunds WHERE credit_id = $1 AND draw_id = $2`, [creditId, drawId]);
|
|
2276
|
+
const before = toSafeInt(totals.rows[0].refunded);
|
|
2277
|
+
const originalMicro = toSafeInt(draw.amount_micro);
|
|
2278
|
+
const amountMicro = args.amountMicro ?? originalMicro - before;
|
|
2279
|
+
if (amountMicro <= 0 || amountMicro > originalMicro - before) {
|
|
2280
|
+
throw new CreditLedgerError("refund_exceeds_draw", "The refund exceeds the draw's remaining unrefunded charge.");
|
|
2281
|
+
}
|
|
2282
|
+
const after = before + amountMicro;
|
|
2283
|
+
if (!isFundingRail(credit.rail)) {
|
|
2284
|
+
throw new CreditLedgerError("credit_refund_basis_unavailable", "The original funding rail is not recorded on this older credit.");
|
|
2285
|
+
}
|
|
2286
|
+
const bitcoin = isNonChannelBitcoinRail(credit.rail);
|
|
2287
|
+
const originalSats = draw.settled_sats === null ? null : toSafeInt(draw.settled_sats);
|
|
2288
|
+
if (bitcoin && originalSats === null) {
|
|
2289
|
+
throw new CreditLedgerError("credit_refund_basis_unavailable", "The original Bitcoin value is not provable from this older draw.");
|
|
2290
|
+
}
|
|
2291
|
+
const restoredSats = bitcoin ? refundAssetDelta(originalSats, originalMicro, before, after) : null;
|
|
2292
|
+
const restoredMsats = restoredSats === null ? refundAssetDelta(toSafeInt(draw.draw_msats), originalMicro, before, after) : restoredSats * 1e3;
|
|
2293
|
+
const restoredNative = draw.draw_native === null ? null : refundAssetDelta(toSafeInt(draw.draw_native), originalMicro, before, after);
|
|
2294
|
+
const balanceAfterMicro = toSafeInt(toSafeInt(credit.balance_micro) + amountMicro);
|
|
2295
|
+
const ledgerSeq = toSafeInt(toSafeInt(credit.last_ledger_seq) + 1);
|
|
2296
|
+
toSafeInt(toSafeInt(credit.msats_remaining) + restoredMsats);
|
|
2297
|
+
if (credit.native_remaining !== null) toSafeInt(toSafeInt(credit.native_remaining) + (restoredNative ?? 0));
|
|
2298
|
+
await client.query(`UPDATE credit_draws SET refund_dvm_id = $3
|
|
2299
|
+
WHERE credit_id = $1 AND draw_id = $2`, [creditId, drawId, args.outbox.dvmId]);
|
|
2300
|
+
await client.query(`UPDATE credits SET balance_micro = $2, last_ledger_seq = $3,
|
|
2301
|
+
msats_remaining = msats_remaining + $4,
|
|
2302
|
+
native_remaining = CASE WHEN native_remaining IS NULL THEN NULL ELSE native_remaining + $5 END
|
|
2303
|
+
WHERE credit_id = $1`, [creditId, balanceAfterMicro, ledgerSeq, restoredMsats, restoredNative ?? 0]);
|
|
2304
|
+
if (bitcoin) {
|
|
2305
|
+
await client.query(
|
|
2306
|
+
`INSERT INTO credit_funding_lots
|
|
2307
|
+
(lot_id, credit_id, rail, sats_funded, credited_micro, remaining_micro, funding_ref, created_at, sats_basis_known)
|
|
2308
|
+
VALUES ($1, $2, $3, $4, $5, $5, $6, $7, true)`,
|
|
2309
|
+
[`lot:refund:${randomUUID()}`, creditId, credit.rail, restoredSats, amountMicro, args.refundId, nowMs]
|
|
2310
|
+
);
|
|
2311
|
+
}
|
|
2312
|
+
const result = {
|
|
2313
|
+
refundId: args.refundId,
|
|
2314
|
+
creditId,
|
|
2315
|
+
drawId,
|
|
2316
|
+
jobId: draw.job_id,
|
|
2317
|
+
amountMicro,
|
|
2318
|
+
currency: credit.currency,
|
|
2319
|
+
ledgerSeq,
|
|
2320
|
+
refundedAt: nowMs,
|
|
2321
|
+
balanceAfterMicro,
|
|
2322
|
+
replayed: false
|
|
2323
|
+
};
|
|
2324
|
+
await client.query(
|
|
2325
|
+
`INSERT INTO credit_draw_refunds
|
|
2326
|
+
(refund_id, credit_id, draw_id, request, amount_micro, restored_sats, result)
|
|
2327
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
2328
|
+
[args.refundId, creditId, drawId, request, amountMicro, restoredSats, JSON.stringify(result)]
|
|
2329
|
+
);
|
|
2330
|
+
await args.outbox.enqueue(client, {
|
|
2331
|
+
dvmId: args.outbox.dvmId,
|
|
2332
|
+
creditId,
|
|
2333
|
+
drawId,
|
|
2334
|
+
jobId: draw.job_id,
|
|
2335
|
+
refundId: args.refundId,
|
|
2336
|
+
amountMicro,
|
|
2337
|
+
creditCurrency: credit.currency,
|
|
2338
|
+
ledgerSeq,
|
|
2339
|
+
refundedAt: nowMs,
|
|
2340
|
+
...args.reason === void 0 ? {} : { reason: args.reason }
|
|
2341
|
+
});
|
|
2342
|
+
await client.query("COMMIT");
|
|
2343
|
+
return result;
|
|
2344
|
+
} catch (error) {
|
|
2345
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
2346
|
+
throw error;
|
|
2347
|
+
} finally {
|
|
2348
|
+
client.release();
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2094
2351
|
/**
|
|
2095
2352
|
* Finalize a pending draw: the hold becomes a real debit
|
|
2096
2353
|
* (`balance_micro −= amount`). Available balance is unchanged — the amount
|
|
@@ -2779,6 +3036,15 @@ var CreditLedger = class {
|
|
|
2779
3036
|
}
|
|
2780
3037
|
}
|
|
2781
3038
|
async terminalizeTempoCreditsLocked(q, evidence, channelId, observedAt) {
|
|
3039
|
+
const refunded = evidence.refundedToPayerNative ?? 0n;
|
|
3040
|
+
if (refunded < 0n) throw new Error("Tempo close refund must be nonnegative");
|
|
3041
|
+
const payerExit = evidence.closeKind === "payer_exit";
|
|
3042
|
+
if (payerExit && evidence.refundedToPayerNative !== void 0 || evidence.closeKind === "cooperative_close" && evidence.refundedToPayerNative === void 0) {
|
|
3043
|
+
throw new Error("Tempo close kind requires matching exact cooperative refund evidence");
|
|
3044
|
+
}
|
|
3045
|
+
const lossNative = (payerExit ? evidence.consumedNative : evidence.highestVoucherNative) - evidence.settledOnChainNative - (payerExit ? 0n : refunded);
|
|
3046
|
+
const lossBasis = payerExit ? "payer_exit" : evidence.refundedToPayerNative === void 0 ? "caller_asserted" : "cooperative_close";
|
|
3047
|
+
if (lossNative <= 0n) return this.readTempoCreditLosses(q, { channelId });
|
|
2782
3048
|
const { rows: credits } = await q.query(
|
|
2783
3049
|
`SELECT * FROM credits
|
|
2784
3050
|
WHERE tempo_channel_id = $1
|
|
@@ -2799,8 +3065,8 @@ var CreditLedger = class {
|
|
|
2799
3065
|
`INSERT INTO tempo_credit_losses
|
|
2800
3066
|
(credit_id, channel_id, caller_pubkey, currency, former_balance_micro,
|
|
2801
3067
|
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)
|
|
3068
|
+
consumed_native, observed_at, refunded_to_payer_native, loss_native, loss_basis)
|
|
3069
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
2804
3070
|
ON CONFLICT (credit_id) DO NOTHING`,
|
|
2805
3071
|
[
|
|
2806
3072
|
credit.credit_id,
|
|
@@ -2812,7 +3078,10 @@ var CreditLedger = class {
|
|
|
2812
3078
|
evidence.highestVoucherNative.toString(),
|
|
2813
3079
|
consumed[0]?.amount ?? "0",
|
|
2814
3080
|
evidence.consumedNative.toString(),
|
|
2815
|
-
observedAt
|
|
3081
|
+
observedAt,
|
|
3082
|
+
evidence.refundedToPayerNative?.toString() ?? null,
|
|
3083
|
+
lossNative.toString(),
|
|
3084
|
+
lossBasis
|
|
2816
3085
|
]
|
|
2817
3086
|
);
|
|
2818
3087
|
await q.query(`UPDATE credits SET status = 'unbacked' WHERE credit_id = $1`, [
|
|
@@ -2821,8 +3090,8 @@ var CreditLedger = class {
|
|
|
2821
3090
|
}
|
|
2822
3091
|
return this.readTempoCreditLosses(q, { channelId });
|
|
2823
3092
|
}
|
|
2824
|
-
async listTempoCreditLosses(args) {
|
|
2825
|
-
return this.readTempoCreditLosses(this.pool, { limit: args?.limit ?? 50 });
|
|
3093
|
+
async listTempoCreditLosses(args, tx) {
|
|
3094
|
+
return this.readTempoCreditLosses(tx ?? this.pool, { limit: args?.limit ?? 50, channelId: args?.channelId?.toLowerCase() });
|
|
2826
3095
|
}
|
|
2827
3096
|
/**
|
|
2828
3097
|
* Reconcile the credit bound to a chain-proven empty x402 channel.
|
|
@@ -3158,26 +3427,27 @@ var CreditLedger = class {
|
|
|
3158
3427
|
return rows.map(drainRecordFromJoinRow);
|
|
3159
3428
|
}
|
|
3160
3429
|
/**
|
|
3161
|
-
* Page
|
|
3162
|
-
* age-bounded
|
|
3430
|
+
* Page held Tempo-channel drains by (created_at, credit_id, drain_id), optionally
|
|
3431
|
+
* age-bounded. Audit reads also include terminal operator decisions. Accept the legacy mpp stored method as well as
|
|
3163
3432
|
* tempo so pre-migration drains remain repairable. Use the compound key for both ordering
|
|
3164
3433
|
* and continuation.
|
|
3165
3434
|
*/
|
|
3166
3435
|
async listChannelDrains(args) {
|
|
3167
|
-
const params = [args.createdBeforeMs, args.limit];
|
|
3436
|
+
const params = [args.createdBeforeMs, args.limit, args.includeResolved ?? false, args.includeWrittenOff || args.includeResolved || false];
|
|
3168
3437
|
let keyset = "";
|
|
3169
3438
|
if (args.after) {
|
|
3170
3439
|
params.push(args.after.createdAt, args.after.creditId, args.after.drainId);
|
|
3171
|
-
keyset = ` AND (d.created_at, d.credit_id, d.drain_id) > ($
|
|
3440
|
+
keyset = ` AND (d.created_at, d.credit_id, d.drain_id) > ($5, $6, $7)`;
|
|
3172
3441
|
}
|
|
3173
3442
|
const { rows } = await this.pool.query(
|
|
3174
3443
|
`SELECT d.*, ${DRAIN_CREDIT_COLUMNS} FROM credit_drains d
|
|
3175
3444
|
JOIN credits c ON c.credit_id = d.credit_id
|
|
3176
|
-
WHERE d.status IN ('pending','exit_required')
|
|
3445
|
+
WHERE (d.status IN ('pending','exit_required')
|
|
3446
|
+
OR ($3::boolean AND d.operator_decision IS NOT NULL))
|
|
3177
3447
|
AND d.method = 'tempo'
|
|
3178
3448
|
AND c.tempo_channel_id IS NOT NULL
|
|
3179
3449
|
AND d.created_at <= $1
|
|
3180
|
-
$
|
|
3450
|
+
AND ($4::boolean OR d.written_off_at IS NULL)
|
|
3181
3451
|
${keyset}
|
|
3182
3452
|
ORDER BY d.created_at, d.credit_id, d.drain_id
|
|
3183
3453
|
LIMIT $2`,
|
|
@@ -3241,49 +3511,182 @@ var CreditLedger = class {
|
|
|
3241
3511
|
verb: "require timed exit"
|
|
3242
3512
|
});
|
|
3243
3513
|
}
|
|
3514
|
+
/** Retain an exact receipt-derived refund; a later missing/zero read cannot erase payment. */
|
|
3515
|
+
async recordTempoDrainRefund(args) {
|
|
3516
|
+
const amount = String(args.refundedNative);
|
|
3517
|
+
if (!/^\d{1,78}$/.test(amount)) throw new CreditLedgerError("invalid_basis", "Tempo refund evidence must be a bounded non-negative integer.");
|
|
3518
|
+
await this.recordTempoDrainRefundEvidence(args, amount);
|
|
3519
|
+
}
|
|
3520
|
+
/** Retain trusted proof that some refund was paid, without claiming an exact amount. */
|
|
3521
|
+
async recordTempoDrainPayment(args) {
|
|
3522
|
+
await this.recordTempoDrainRefundEvidence(args, null);
|
|
3523
|
+
}
|
|
3524
|
+
async recordTempoDrainRefundEvidence(args, amount) {
|
|
3525
|
+
const { rows } = await this.pool.query(
|
|
3526
|
+
"SELECT tempo_channel_id FROM credits WHERE credit_id=$1",
|
|
3527
|
+
[args.creditId]
|
|
3528
|
+
);
|
|
3529
|
+
const record = async (tx) => {
|
|
3530
|
+
await tx.query(
|
|
3531
|
+
`UPDATE credit_drains SET proven_refund_native=
|
|
3532
|
+
GREATEST(proven_refund_native, $3::numeric),
|
|
3533
|
+
refund_payment_proven=refund_payment_proven OR COALESCE($3::numeric > 0, TRUE)
|
|
3534
|
+
WHERE credit_id=$1 AND drain_id=$2 AND method='tempo'
|
|
3535
|
+
AND status IN ('pending','exit_required') AND operator_decision IS NULL`,
|
|
3536
|
+
[args.creditId, args.drainId, amount]
|
|
3537
|
+
);
|
|
3538
|
+
};
|
|
3539
|
+
const channelId = rows[0]?.tempo_channel_id;
|
|
3540
|
+
if (channelId && this.tempoSessionStore) {
|
|
3541
|
+
await this.tempoSessionStore.withDrawPlacement(channelId, record);
|
|
3542
|
+
} else {
|
|
3543
|
+
await record(this.pool);
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
/** Record the latest established proof refusal without changing the held debit. */
|
|
3547
|
+
async recordTempoDrainHold(args) {
|
|
3548
|
+
await this.pool.query(
|
|
3549
|
+
`UPDATE credit_drains SET hold_reason=
|
|
3550
|
+
CASE WHEN hold_reason='refund_short' AND proven_refund_native IS NULL THEN hold_reason ELSE $3 END
|
|
3551
|
+
WHERE credit_id=$1 AND drain_id=$2 AND method='tempo'
|
|
3552
|
+
AND status IN ('pending','exit_required') AND operator_decision IS NULL`,
|
|
3553
|
+
[args.creditId, args.drainId, args.reason]
|
|
3554
|
+
);
|
|
3555
|
+
}
|
|
3244
3556
|
/**
|
|
3245
|
-
*
|
|
3246
|
-
*
|
|
3247
|
-
*
|
|
3248
|
-
*
|
|
3557
|
+
* An operator attests a full off-path refund or restores the exact unpaid debit. The
|
|
3558
|
+
* channel lock precedes the credit and drain locks; automatic booking shares the drain
|
|
3559
|
+
* CAS, so only one terminal outcome can commit. No channel lifecycle state is reopened.
|
|
3560
|
+
* Direct callers own reporting: hosted booking must join the caller's drain outbox transaction.
|
|
3249
3561
|
*/
|
|
3250
|
-
async
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3562
|
+
async decideTempoDrain(args) {
|
|
3563
|
+
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)) {
|
|
3564
|
+
throw new CreditLedgerError("invalid_drain_decision", "A decision requires a reason of 1\u20132000 characters and optional evidence of 1\u20134000 characters.");
|
|
3565
|
+
}
|
|
3566
|
+
const query = args.tx ?? this.pool;
|
|
3567
|
+
const { rows } = await query.query(
|
|
3568
|
+
`SELECT tempo_channel_id FROM credits WHERE credit_id=$1`,
|
|
3569
|
+
[args.creditId]
|
|
3570
|
+
);
|
|
3571
|
+
if (!rows[0]) throw new CreditLedgerError("credit_not_found", "The credit was not found.");
|
|
3572
|
+
const channelId = rows[0].tempo_channel_id;
|
|
3573
|
+
if (!channelId || !this.tempoSessionStore) {
|
|
3574
|
+
throw new CreditLedgerError("drain_not_repairable", "A durable Tempo session channel is required for an operator decision.");
|
|
3575
|
+
}
|
|
3576
|
+
return this.tempoSessionStore.withDrawPlacement(channelId, async (tx, lifecycle) => {
|
|
3577
|
+
const intent = await tx.query(
|
|
3578
|
+
`SELECT reference, settled_before_close, forecast_pending FROM mpp_tempo_close_intents
|
|
3579
|
+
WHERE channel_id=$1 AND drain_id=$2 FOR UPDATE`,
|
|
3580
|
+
[channelId, args.drainId]
|
|
3581
|
+
);
|
|
3582
|
+
const credit = await this.lockCreditRow(tx, args.creditId);
|
|
3583
|
+
const { rows: rows2 } = await tx.query(
|
|
3584
|
+
`SELECT * FROM credit_drains WHERE credit_id=$1 AND drain_id=$2 FOR UPDATE`,
|
|
3585
|
+
[args.creditId, args.drainId]
|
|
3586
|
+
);
|
|
3587
|
+
const row = rows2[0];
|
|
3588
|
+
if (!row) throw new CreditLedgerError("drain_not_found", "The drain was not found.");
|
|
3589
|
+
if (row.method !== "tempo") throw new CreditLedgerError("drain_not_repairable", "Only a held Tempo session drain accepts an operator decision.");
|
|
3590
|
+
const reason = args.reason.trim();
|
|
3591
|
+
const evidence = args.evidence?.trim();
|
|
3592
|
+
let result;
|
|
3593
|
+
if (row.operator_decision) {
|
|
3594
|
+
const prior = row.operator_decision;
|
|
3595
|
+
if (prior.decision !== args.decision || prior.reason !== reason || prior.evidence !== evidence) {
|
|
3596
|
+
throw new CreditLedgerError("drain_decision_conflict", "This drain already has a different immutable operator decision.");
|
|
3597
|
+
}
|
|
3598
|
+
result = {
|
|
3262
3599
|
drain: drainRecordFromRow(row, credit),
|
|
3263
|
-
credit: await this.snapshotFromRow(credit,
|
|
3600
|
+
credit: await this.snapshotFromRow(credit, tx, args.nowMs ?? Date.now()),
|
|
3264
3601
|
replayed: true
|
|
3265
3602
|
};
|
|
3603
|
+
} else {
|
|
3604
|
+
if (row.status !== "pending" && row.status !== "exit_required") {
|
|
3605
|
+
throw new CreditLedgerError("drain_decision_conflict", "This drain has already reached another terminal outcome.");
|
|
3606
|
+
}
|
|
3607
|
+
if (args.decision === "release") {
|
|
3608
|
+
const closeIntent = intent.rows[0];
|
|
3609
|
+
const reference = closeIntent?.reference;
|
|
3610
|
+
if (!lifecycle.finalized && closeIntent && (closeIntent.forecast_pending !== true || closeIntent.settled_before_close != null || reference && /^0x[0-9a-fA-F]{64}$/.test(reference))) {
|
|
3611
|
+
throw new CreditLedgerError("tempo_drain_close_pending", "The close may still pay. Reconcile its final outcome before releasing this drain.");
|
|
3612
|
+
}
|
|
3613
|
+
const channel = await tx.query(
|
|
3614
|
+
"SELECT state_json FROM mpp_tempo_channels WHERE channel_key=$1",
|
|
3615
|
+
[channelId]
|
|
3616
|
+
);
|
|
3617
|
+
const state = channel.rows[0] && parseStoreValue(channel.rows[0].state_json);
|
|
3618
|
+
const observation = state?.tempoDrainPaymentObservation;
|
|
3619
|
+
const paymentObserved = observation?.creditId === args.creditId && observation.drainId === args.drainId;
|
|
3620
|
+
if (paymentObserved || row.refund_payment_proven || row.proven_refund_native !== null && BigInt(row.proven_refund_native) > 0n) {
|
|
3621
|
+
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.");
|
|
3622
|
+
}
|
|
3623
|
+
if (row.hold_reason === "refund_short" && row.proven_refund_native === null) {
|
|
3624
|
+
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.");
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
const decision = {
|
|
3628
|
+
source: "operator",
|
|
3629
|
+
decision: args.decision,
|
|
3630
|
+
reason,
|
|
3631
|
+
...evidence === void 0 ? {} : { evidence },
|
|
3632
|
+
decidedAt: args.nowMs ?? Date.now()
|
|
3633
|
+
};
|
|
3634
|
+
await tx.query(`UPDATE credit_drains SET operator_decision=$3
|
|
3635
|
+
WHERE credit_id=$1 AND drain_id=$2`, [args.creditId, args.drainId, JSON.stringify(decision)]);
|
|
3636
|
+
row.operator_decision = decision;
|
|
3637
|
+
if (args.decision === "release") {
|
|
3638
|
+
result = await this.releaseDrainLocked(tx, row, args, decision.decidedAt);
|
|
3639
|
+
} else {
|
|
3640
|
+
await this.clearDrainWriteOff({ creditId: args.creditId, drainId: args.drainId, tx });
|
|
3641
|
+
const booked = await this.markDrainSent({
|
|
3642
|
+
creditId: args.creditId,
|
|
3643
|
+
drainId: args.drainId,
|
|
3644
|
+
sentRef: { source: "operator", channel_id: channelId },
|
|
3645
|
+
tx,
|
|
3646
|
+
nowMs: decision.decidedAt
|
|
3647
|
+
});
|
|
3648
|
+
result = { ...booked, credit: await this.snapshotFromRow(credit, tx, decision.decidedAt) };
|
|
3649
|
+
}
|
|
3266
3650
|
}
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3651
|
+
const spendableMicro = lifecycle.finalized || lifecycle.closeRequestedAt !== 0n || result.credit.expired ? 0 : result.credit.availableMicro;
|
|
3652
|
+
return { ...result, spendableMicro };
|
|
3653
|
+
}, args.tx);
|
|
3654
|
+
}
|
|
3655
|
+
/** Restore an unpaid pending drain; operator decisions also admit exit_required. */
|
|
3656
|
+
async releaseDrain(args) {
|
|
3657
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
3658
|
+
const run = async (client, credit) => {
|
|
3659
|
+
const row = await this.readDrain(client, args.creditId, args.drainId);
|
|
3660
|
+
if (!row) throw new CreditLedgerError("drain_not_found", `drain ${args.drainId} on credit ${args.creditId} not found`);
|
|
3661
|
+
if (row.status === "released") return {
|
|
3662
|
+
drain: drainRecordFromRow(row, credit),
|
|
3663
|
+
credit: await this.snapshotFromRow(credit, client, nowMs),
|
|
3664
|
+
replayed: true
|
|
3665
|
+
};
|
|
3666
|
+
if (row.status !== "pending") throw new CreditLedgerError(
|
|
3667
|
+
"invalid_drain_state",
|
|
3668
|
+
`cannot release ${row.method} drain ${args.drainId} from status ${row.status}`
|
|
3669
|
+
);
|
|
3670
|
+
return this.releaseDrainLocked(client, row, args, nowMs);
|
|
3671
|
+
};
|
|
3672
|
+
if (args.tx) return run(args.tx, await this.lockCreditRow(args.tx, args.creditId));
|
|
3673
|
+
return this.withCreditLock(args.creditId, run);
|
|
3674
|
+
}
|
|
3675
|
+
async releaseDrainLocked(client, row, args, nowMs) {
|
|
3676
|
+
const { rows } = await client.query(
|
|
3677
|
+
`UPDATE credit_drains SET status = 'released', released_at = $3
|
|
3678
|
+
WHERE credit_id = $1 AND drain_id = $2 AND status = $4
|
|
3276
3679
|
RETURNING *`,
|
|
3277
|
-
|
|
3680
|
+
[args.creditId, args.drainId, nowMs, row.status]
|
|
3681
|
+
);
|
|
3682
|
+
if (!rows[0]) {
|
|
3683
|
+
throw new CreditLedgerError(
|
|
3684
|
+
"invalid_drain_state",
|
|
3685
|
+
`drain ${args.drainId} changed while it was being released`
|
|
3278
3686
|
);
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
`drain ${args.drainId} changed while it was being released`
|
|
3283
|
-
);
|
|
3284
|
-
}
|
|
3285
|
-
await client.query(
|
|
3286
|
-
`UPDATE credits
|
|
3687
|
+
}
|
|
3688
|
+
await client.query(
|
|
3689
|
+
`UPDATE credits
|
|
3287
3690
|
SET balance_micro = balance_micro + $2,
|
|
3288
3691
|
msats_remaining = msats_remaining + $3,
|
|
3289
3692
|
native_remaining = CASE
|
|
@@ -3291,18 +3694,15 @@ var CreditLedger = class {
|
|
|
3291
3694
|
ELSE native_remaining + $4
|
|
3292
3695
|
END
|
|
3293
3696
|
WHERE credit_id = $1`,
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
};
|
|
3697
|
+
[args.creditId, row.amount_micro, row.drained_msats, row.drained_native]
|
|
3698
|
+
);
|
|
3699
|
+
await this.applyLotDebits(client, row.lot_debits ?? [], 1);
|
|
3700
|
+
const restored = await this.lockCreditRow(client, args.creditId);
|
|
3701
|
+
return {
|
|
3702
|
+
drain: drainRecordFromRow(rows[0], restored),
|
|
3703
|
+
credit: await this.snapshotFromRow(restored, client, nowMs),
|
|
3704
|
+
replayed: false
|
|
3303
3705
|
};
|
|
3304
|
-
if (args.tx) return run(args.tx, await this.lockCreditRow(args.tx, args.creditId));
|
|
3305
|
-
return this.withCreditLock(args.creditId, run);
|
|
3306
3706
|
}
|
|
3307
3707
|
/**
|
|
3308
3708
|
* Attach the parked Cashu token to a pending drain (`pending → parked`).
|
|
@@ -3525,7 +3925,13 @@ var CreditLedger = class {
|
|
|
3525
3925
|
let settledMsats = forecastMsats;
|
|
3526
3926
|
if (isNonChannelBitcoinRail(credit.rail)) {
|
|
3527
3927
|
const depletion = depleteLots(await this.openLots(client, args.creditId), amountMicro);
|
|
3528
|
-
|
|
3928
|
+
const inKindMsats = inKindDrawMsats(depletion, amountMicro);
|
|
3929
|
+
settledMsats = inKindMsats ?? forecastMsats;
|
|
3930
|
+
await client.query(
|
|
3931
|
+
`UPDATE credit_draws SET settled_sats = $3
|
|
3932
|
+
WHERE credit_id = $1 AND draw_id = $2`,
|
|
3933
|
+
[args.creditId, args.drawId, inKindMsats === null ? null : inKindMsats / 1e3]
|
|
3934
|
+
);
|
|
3529
3935
|
await this.applyLotDebits(client, depletion.debits, -1);
|
|
3530
3936
|
}
|
|
3531
3937
|
if (settledMsats !== forecastMsats) {
|
|
@@ -3771,8 +4177,8 @@ var BITCOIN_CREDIT_PREDICATE = `c.rail IN ('cashu', 'lightning')
|
|
|
3771
4177
|
AND c.balance_micro > 0`;
|
|
3772
4178
|
var LOT_COVERAGE_LATERAL = `SELECT
|
|
3773
4179
|
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
|
|
4180
|
+
COALESCE(SUM(l.remaining_micro) FILTER (WHERE l.sats_funded = 0 AND NOT l.sats_basis_known), 0) AS basisless_micro,
|
|
4181
|
+
COALESCE(SUM(FLOOR(l.sats_funded::numeric * l.remaining_micro / NULLIF(l.credited_micro, 0))), 0) AS owed_sats
|
|
3776
4182
|
FROM credit_funding_lots l
|
|
3777
4183
|
WHERE l.credit_id = c.credit_id AND l.remaining_micro > 0`;
|
|
3778
4184
|
var IN_KIND_PREDICATE = `cov.covered_micro >= c.balance_micro AND cov.basisless_micro = 0`;
|
|
@@ -3840,6 +4246,9 @@ function tempoCreditLossFromRow(row) {
|
|
|
3840
4246
|
highestVoucherNative: BigInt(row.highest_voucher_native),
|
|
3841
4247
|
consumedServiceMicro: toSafeInt(row.consumed_service_micro),
|
|
3842
4248
|
consumedNative: BigInt(row.consumed_native),
|
|
4249
|
+
refundedToPayerNative: row.refunded_to_payer_native === null ? null : BigInt(row.refunded_to_payer_native),
|
|
4250
|
+
lossNative: row.loss_native === null ? null : BigInt(row.loss_native),
|
|
4251
|
+
lossBasis: row.loss_basis === "cooperative_close" || row.loss_basis === "payer_exit" || row.loss_basis === "caller_asserted" ? row.loss_basis : null,
|
|
3843
4252
|
observedAt: toSafeInt(row.observed_at)
|
|
3844
4253
|
};
|
|
3845
4254
|
}
|
|
@@ -3883,7 +4292,11 @@ function drainRecordFromRow(row, credit) {
|
|
|
3883
4292
|
sentAt: row.sent_at === null ? null : toSafeInt(row.sent_at),
|
|
3884
4293
|
releasedAt: row.released_at === null ? null : toSafeInt(row.released_at),
|
|
3885
4294
|
writeOffNote: row.write_off_note,
|
|
3886
|
-
writtenOffAt: row.written_off_at === null ? null : toSafeInt(row.written_off_at)
|
|
4295
|
+
writtenOffAt: row.written_off_at === null ? null : toSafeInt(row.written_off_at),
|
|
4296
|
+
operatorDecision: row.operator_decision ?? null,
|
|
4297
|
+
holdReason: row.hold_reason ?? null,
|
|
4298
|
+
provenRefundNative: row.proven_refund_native ?? null,
|
|
4299
|
+
refundPaymentProven: row.refund_payment_proven ?? false
|
|
3887
4300
|
};
|
|
3888
4301
|
}
|
|
3889
4302
|
function drainRecordFromJoinRow(row) {
|
|
@@ -3900,6 +4313,7 @@ function fundingLotFromRow(row) {
|
|
|
3900
4313
|
creditId: row.credit_id,
|
|
3901
4314
|
rail: row.rail,
|
|
3902
4315
|
satsFunded: toSafeInt(row.sats_funded),
|
|
4316
|
+
satsBasisKnown: row.sats_basis_known,
|
|
3903
4317
|
creditedMicro: toSafeInt(row.credited_micro),
|
|
3904
4318
|
remainingMicro: toSafeInt(row.remaining_micro),
|
|
3905
4319
|
fundingRef: row.funding_ref,
|
|
@@ -3983,6 +4397,8 @@ export {
|
|
|
3983
4397
|
netOwedSats,
|
|
3984
4398
|
fifoOrder,
|
|
3985
4399
|
isInKindDepletion,
|
|
4400
|
+
refundRequest,
|
|
4401
|
+
refundAssetDelta,
|
|
3986
4402
|
X402SettlementRaceError,
|
|
3987
4403
|
X402RelaySubmissionLockError,
|
|
3988
4404
|
X402_WEDGED_SETTLEMENT_STATUSES,
|