Fix wrong assignement of tId

This commit is contained in:
minibits-cash
2026-05-22 22:38:18 +02:00
parent 25330d3b73
commit 586f5c098d
3 changed files with 219 additions and 36 deletions
+181 -27
View File
@@ -72,7 +72,11 @@ function reservationCount(db: DatabaseSync): number {
// Mirror the exact SQL the production code uses, wrapped in BEGIN/COMMIT to
// match `executeBatch` atomicity.
type LockedProofSnapshot = {secret: string; originalState: 'UNSPENT' | 'PENDING' | 'SPENT'}
type LockedProofSnapshot = {
secret: string
originalState: 'UNSPENT' | 'PENDING' | 'SPENT'
originalTId: number | null
}
function openReservation(
db: DatabaseSync,
@@ -101,11 +105,12 @@ function openReservation(
JSON.stringify(reservation.lockedProofs),
now,
)
// Reassign tId to the new operation alongside the state lock.
const updateProof = db.prepare(
`UPDATE proofs SET state = 'PENDING', updatedAt = ? WHERE secret = ?`,
`UPDATE proofs SET state = 'PENDING', tId = ?, updatedAt = ? WHERE secret = ?`,
)
for (const secret of proofsToLockSecrets) {
updateProof.run(now, secret)
updateProof.run(reservation.transactionId, now, secret)
}
db.exec('COMMIT')
} catch (e) {
@@ -165,11 +170,12 @@ function rollbackReservation(
const now = '2026-05-22T00:00:00.000Z'
db.exec('BEGIN')
try {
// Restore BOTH state and tId from the pre-reserve snapshot.
const restore = db.prepare(
`UPDATE proofs SET state = ?, updatedAt = ? WHERE secret = ?`,
`UPDATE proofs SET state = ?, tId = ?, updatedAt = ? WHERE secret = ?`,
)
for (const snap of lockedProofs) {
restore.run(snap.originalState, now, snap.secret)
restore.run(snap.originalState, snap.originalTId, now, snap.secret)
}
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
db.exec('COMMIT')
@@ -179,6 +185,13 @@ function rollbackReservation(
}
}
function getProofTId(db: DatabaseSync, secret: string): number | null {
const row = db.prepare('SELECT tId FROM proofs WHERE secret = ?').get(secret) as
| {tId: number | null}
| undefined
return row?.tId ?? null
}
function getOpenReservations(db: DatabaseSync): Array<{
id: string
lockedProofs: LockedProofSnapshot[]
@@ -208,8 +221,8 @@ describe('Proof reservations', () => {
unit: 'sat',
operationType: 'send-online',
lockedProofs: [
{secret: 'sA', originalState: 'UNSPENT'},
{secret: 'sB', originalState: 'UNSPENT'},
{secret: 'sA', originalState: 'UNSPENT', originalTId: null},
{secret: 'sB', originalState: 'UNSPENT', originalTId: null},
],
},
['sA', 'sB'],
@@ -237,8 +250,8 @@ describe('Proof reservations', () => {
unit: 'sat',
operationType: 'send-offline',
lockedProofs: [
{secret: 'sA', originalState: 'UNSPENT'},
{secret: 'sB', originalState: 'PENDING'},
{secret: 'sA', originalState: 'UNSPENT', originalTId: null},
{secret: 'sB', originalState: 'PENDING', originalTId: null},
],
},
['sA', 'sB'],
@@ -247,8 +260,8 @@ describe('Proof reservations', () => {
const orphans = getOpenReservations(db)
expect(orphans).toHaveLength(1)
expect(orphans[0].lockedProofs).toEqual([
{secret: 'sA', originalState: 'UNSPENT'},
{secret: 'sB', originalState: 'PENDING'},
{secret: 'sA', originalState: 'UNSPENT', originalTId: null},
{secret: 'sB', originalState: 'PENDING', originalTId: null},
])
db.close()
@@ -271,8 +284,8 @@ describe('Proof reservations', () => {
unit: 'sat',
operationType: 'send-swap',
lockedProofs: [
{secret: 'input1', originalState: 'UNSPENT'},
{secret: 'input2', originalState: 'UNSPENT'},
{secret: 'input1', originalState: 'UNSPENT', originalTId: null},
{secret: 'input2', originalState: 'UNSPENT', originalTId: null},
],
},
['input1', 'input2'],
@@ -308,7 +321,7 @@ describe('Proof reservations', () => {
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'send-offline',
lockedProofs: [{secret: 's1', originalState: 'UNSPENT'}],
lockedProofs: [{secret: 's1', originalState: 'UNSPENT', originalTId: null}],
},
['s1'],
)
@@ -339,8 +352,8 @@ describe('Proof reservations', () => {
unit: 'sat',
operationType: 'send-swap',
lockedProofs: [
{secret: 'sA', originalState: 'UNSPENT'},
{secret: 'sB', originalState: 'UNSPENT'},
{secret: 'sA', originalState: 'UNSPENT', originalTId: null},
{secret: 'sB', originalState: 'UNSPENT', originalTId: null},
],
},
['sA', 'sB'],
@@ -349,8 +362,8 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'sA')).toBe('PENDING')
rollbackReservation(db, 'r5', [
{secret: 'sA', originalState: 'UNSPENT'},
{secret: 'sB', originalState: 'UNSPENT'},
{secret: 'sA', originalState: 'UNSPENT', originalTId: null},
{secret: 'sB', originalState: 'UNSPENT', originalTId: null},
])
expect(getProofState(db, 'sA')).toBe('UNSPENT')
@@ -374,12 +387,12 @@ describe('Proof reservations', () => {
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'send-offline',
lockedProofs: [{secret: 'sA', originalState: 'PENDING'}],
lockedProofs: [{secret: 'sA', originalState: 'PENDING', originalTId: null}],
},
['sA'],
)
rollbackReservation(db, 'r6', [{secret: 'sA', originalState: 'PENDING'}])
rollbackReservation(db, 'r6', [{secret: 'sA', originalState: 'PENDING', originalTId: null}])
// Restored to PENDING (its original locked state), not to UNSPENT
expect(getProofState(db, 'sA')).toBe('PENDING')
@@ -405,8 +418,8 @@ describe('Proof reservations', () => {
unit: 'sat',
operationType: 'send-swap',
lockedProofs: [
{secret: 'orphanA', originalState: 'UNSPENT'},
{secret: 'orphanB', originalState: 'UNSPENT'},
{secret: 'orphanA', originalState: 'UNSPENT', originalTId: null},
{secret: 'orphanB', originalState: 'UNSPENT', originalTId: null},
],
},
['orphanA', 'orphanB'],
@@ -454,8 +467,8 @@ describe('Proof reservations', () => {
unit: 'sat',
operationType: 'send-swap',
lockedProofs: [
{secret: 'a1', originalState: 'UNSPENT'},
{secret: 'a2', originalState: 'UNSPENT'},
{secret: 'a1', originalState: 'UNSPENT', originalTId: null},
{secret: 'a2', originalState: 'UNSPENT', originalTId: null},
],
},
['a1', 'a2'],
@@ -468,7 +481,7 @@ describe('Proof reservations', () => {
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'send-offline',
lockedProofs: [{secret: 'b1', originalState: 'UNSPENT'}],
lockedProofs: [{secret: 'b1', originalState: 'UNSPENT', originalTId: null}],
},
['b1'],
)
@@ -504,7 +517,7 @@ describe('Proof reservations', () => {
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'send',
lockedProofs: [{secret: 's1', originalState: 'UNSPENT'}],
lockedProofs: [{secret: 's1', originalState: 'UNSPENT', originalTId: null}],
},
['s1'],
)
@@ -526,7 +539,7 @@ describe('Proof reservations', () => {
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'send',
lockedProofs: [{secret: 's2', originalState: 'UNSPENT'}],
lockedProofs: [{secret: 's2', originalState: 'UNSPENT', originalTId: null}],
},
['s2'],
),
@@ -540,4 +553,145 @@ describe('Proof reservations', () => {
db.close()
})
})
// ─────────────────────────────────────────────────────────────────────
// Regression: a real bug observed in dev (2026-05-22).
//
// The send went through and the recipient redeemed the token, but the
// SEND transaction in our wallet stayed PENDING forever. Root cause:
// openReservation was writing the proof's OLD tId back to the row
// (the original RECEIVE that minted it) instead of the new operation's
// transactionId. So when sync later saw the proofs SPENT, it grouped
// them under the wrong (already-completed) transaction.
//
// These tests pin the tId-propagation contract: reserving must
// reassign tId to the new operation, and rollback must restore the
// pre-reserve tId.
// ─────────────────────────────────────────────────────────────────────
describe('tId propagation (regression: stuck-PENDING SEND, 2026-05-22)', () => {
test('openReservation reassigns each locked proof tId to the new transactionId', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
// Two proofs received originally by tx 110 and tx 123 respectively
// (simulates the dev log).
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 4, 'inp_a', 'C', 'https://mint.test', 'sat', 110, 'UNSPENT', '2026-01-01')`,
).run()
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, 'UNSPENT', '2026-01-01')`,
).run()
expect(getProofTId(db, 'inp_a')).toBe(110)
expect(getProofTId(db, 'inp_b')).toBe(123)
// New SEND operation as transaction 157
openReservation(
db,
{
id: 'res-157',
transactionId: 157,
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'send-direct',
lockedProofs: [
{secret: 'inp_a', originalState: 'UNSPENT', originalTId: 110},
{secret: 'inp_b', originalState: 'UNSPENT', originalTId: 123},
],
},
['inp_a', 'inp_b'],
)
// After reserve, BOTH proofs must point to the new tx so that any
// sync that later sees them SPENT correctly groups them under 157.
expect(getProofTId(db, 'inp_a')).toBe(157)
expect(getProofTId(db, 'inp_b')).toBe(157)
expect(getProofState(db, 'inp_a')).toBe('PENDING')
expect(getProofState(db, 'inp_b')).toBe('PENDING')
db.close()
})
test('rollback restores each proof to its individual originalTId', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 4, 'inp_a', 'C', 'https://mint.test', 'sat', 110, 'UNSPENT', '2026-01-01')`,
).run()
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, 'UNSPENT', '2026-01-01')`,
).run()
openReservation(
db,
{
id: 'res-fail',
transactionId: 200,
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'send-direct',
lockedProofs: [
{secret: 'inp_a', originalState: 'UNSPENT', originalTId: 110},
{secret: 'inp_b', originalState: 'UNSPENT', originalTId: 123},
],
},
['inp_a', 'inp_b'],
)
// Simulate operation failure.
rollbackReservation(db, 'res-fail', [
{secret: 'inp_a', originalState: 'UNSPENT', originalTId: 110},
{secret: 'inp_b', originalState: 'UNSPENT', originalTId: 123},
])
// Each proof goes back to its OWN prior tId — not the failed
// operation's, and not any uniform value.
expect(getProofTId(db, 'inp_a')).toBe(110)
expect(getProofTId(db, 'inp_b')).toBe(123)
expect(getProofState(db, 'inp_a')).toBe('UNSPENT')
expect(getProofState(db, 'inp_b')).toBe('UNSPENT')
expect(reservationCount(db)).toBe(0)
db.close()
})
test('proofs with no prior tId (null) are reassigned and restored to null', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
// Proof inserted without a tId — simulates an imported/restored
// proof that was never tied to a wallet transaction.
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, state, updatedAt)
VALUES ('keyset1', 1, 'orphan', 'C', 'https://mint.test', 'sat', 'UNSPENT', '2026-01-01')`,
).run()
expect(getProofTId(db, 'orphan')).toBe(null)
openReservation(
db,
{
id: 'res-null',
transactionId: 300,
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'send-direct',
lockedProofs: [
{secret: 'orphan', originalState: 'UNSPENT', originalTId: null},
],
},
['orphan'],
)
expect(getProofTId(db, 'orphan')).toBe(300)
rollbackReservation(db, 'res-null', [
{secret: 'orphan', originalState: 'UNSPENT', originalTId: null},
])
expect(getProofTId(db, 'orphan')).toBe(null)
db.close()
})
})
})
+17 -4
View File
@@ -350,6 +350,7 @@ import {
const lockedProofs = liveProofs.map(p => ({
secret: p.secret,
originalState: opts.rollbackTo === 'preserve' ? p.state : opts.rollbackTo,
originalTId: p.tId ?? null,
}))
// ATOMIC: write reservation row + lock proofs to PENDING in one batch.
@@ -365,10 +366,14 @@ import {
liveProofs,
)
// SQLite is durable — mirror into MST.
// SQLite is durable — mirror into MST. Both state AND tId are
// reassigned: the operation now "owns" these proofs for the
// duration of the reservation, so any sync sweep that sees them
// SPENT will correctly attribute the spend to opts.transactionId.
for (const p of liveProofs) {
if (isAlive(p) && p.state !== 'SPENT') {
p.setProp('state', 'PENDING')
p.setProp('tId', opts.transactionId)
}
}
@@ -504,10 +509,16 @@ import {
rollbackReservation(reservation: ProofReservation): void {
Database.rollbackReservation(reservation.id, reservation.lockedProofs)
// Mirror to MST: restore BOTH state and tId from the pre-reserve
// snapshot so the proof goes back to "owned by its prior tx in its
// prior state" — matches the SQL UPDATE done above.
for (const snap of reservation.lockedProofs) {
const node = self.getBySecret(snap.secret)
if (node && isAlive(node) && node.state !== 'SPENT') {
node.setProp('state', snap.originalState)
if (snap.originalTId !== null) {
node.setProp('tId', snap.originalTId)
}
}
}
@@ -535,13 +546,15 @@ import {
for (const orphan of orphans) {
try {
Database.rollbackReservation(orphan.id, orphan.lockedProofs)
// Mirror into MST (proofs were already reloaded from DB, so
// the SQLite update above already changed their state on the
// next reload; here we update in-place too if the nodes exist).
// Mirror into MST: restore BOTH state and tId from the
// pre-reserve snapshot to match the SQL UPDATE above.
for (const snap of orphan.lockedProofs) {
const node = self.getBySecret(snap.secret)
if (node && isAlive(node) && node.state !== 'SPENT') {
node.setProp('state', snap.originalState)
if (snap.originalTId !== null) {
node.setProp('tId', snap.originalTId)
}
}
}
} catch (e: any) {
+21 -5
View File
@@ -1186,6 +1186,18 @@ const getProofsByTransaction = function (transactionId: number): ProofRecord[] {
export type LockedProofSnapshot = {
secret: string
originalState: ProofState
/**
* The proof's tId AT RESERVE TIME — i.e. the transaction that previously
* owned this proof (typically the original RECEIVE/TOPUP that minted it).
*
* When the reservation opens, the proof's tId is reassigned to the NEW
* operation's transactionId so downstream sync sweeps can correctly group
* spent proofs under the right transaction. On rollback, originalTId is
* restored.
*
* `null` for proofs that had no prior transaction reference.
*/
originalTId: number | null
}
export type ReservationRow = {
@@ -1248,7 +1260,10 @@ const openReservation = function (
proof.dleq ? proof.dleq.s : null,
proof.dleq ? proof.dleq.e : null,
proof.unit,
proof.tId,
// Reassign tId to the new operation. The previous tId (which may
// point to e.g. the original RECEIVE that minted this proof) is
// captured in lockedProofs[i].originalTId for rollback restoration.
reservation.transactionId,
proof.mintUrl,
'PENDING',
now,
@@ -1356,8 +1371,9 @@ const commitReservation = function (
}
/**
* Rollback a reservation: restore each locked proof to its originalState and
* delete the reservation row — all in a single SQLite transaction.
* Rollback a reservation: restore each locked proof to its originalState AND
* originalTId, then delete the reservation row — all in a single SQLite
* transaction.
*/
const rollbackReservation = function (
reservationId: string,
@@ -1369,8 +1385,8 @@ const rollbackReservation = function (
for (const snap of lockedProofs) {
batch.push([
`UPDATE proofs SET state = ?, updatedAt = ? WHERE secret = ?`,
[snap.originalState, now, snap.secret],
`UPDATE proofs SET state = ?, tId = ?, updatedAt = ? WHERE secret = ?`,
[snap.originalState, snap.originalTId, now, snap.secret],
])
}