diff --git a/__tests__/proofReservation.test.ts b/__tests__/proofReservation.test.ts index 30f534f1..714ba91a 100644 --- a/__tests__/proofReservation.test.ts +++ b/__tests__/proofReservation.test.ts @@ -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() + }) + }) }) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 874757f7..f1291549 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -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) { diff --git a/src/services/sqlite.ts b/src/services/sqlite.ts index 5f0e8246..29e7ef43 100644 --- a/src/services/sqlite.ts +++ b/src/services/sqlite.ts @@ -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], ]) }