From dab934a017370f4728607c0d05d53a469854ce5c Mon Sep 17 00:00:00 2001 From: GenericCPU Date: Sat, 17 Oct 2026 00:06:26 -0300 Subject: [PATCH 1/4] fix(wallet-toolbox): keep the locking script of a funding output chosen from a noScript scan Change candidates are read with noScript: true, which clears every locking script. validateOutputScript reloads only a script offloaded into the raw transaction, so an output whose script is stored inline on its row left StorageIdb.allocateChangeInput, and makeFundingResult for action batches, with no locking script. The signer then received a funding input it cannot sign. Add StorageProvider.restoreOutputScript, which falls back to rereading the row, and use it in both funding paths. Co-authored-by: Cursor --- .../wallet-toolbox/src/storage/StorageIdb.ts | 7 +- .../src/storage/StorageProvider.ts | 15 +++ .../__test/allocateChangeInputScript.test.ts | 100 ++++++++++++++++++ .../src/storage/methods/actionBatch.ts | 3 +- 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts index 9bb31ff40b..3e46a905a1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts @@ -347,10 +347,9 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider if (output != null) { // mark output as spent by transactionId await this.updateOutput(output.outputId, { spendable: false, spentBy: transactionId }, dbTrx) - // Hydrate the locking script for the chosen output. Identical to Knex canon at - // StorageKnex.allocateChangeInput: required when the script was offloaded into - // rawTx storage due to exceeding maxOutputScript. - await this.validateOutputScript(output, dbTrx) + // The candidate scan cleared every script; restore the chosen one, whether + // it was offloaded into rawTx storage or stored inline on the row. + await this.restoreOutputScript(output, dbTrx) } return output } finally { diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts index 400ac4fc33..2ee5208a6e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts @@ -1909,6 +1909,21 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal if (script == null) return o.lockingScript = script } + + /** + * Restore the locking script of an output read with `noScript: true`. + * + * `validateOutputScript` only reloads a script offloaded into the raw + * transaction. A script stored inline on the row is left cleared, so a + * funding output chosen from a `noScript` scan reaches the signer without + * one. Reread the row in that case. + */ + async restoreOutputScript(o: TableOutput, trx?: TrxToken): Promise { + await this.validateOutputScript(o, trx) + if (o.lockingScript != null && o.lockingScript.length > 0) return + const stored = await this.findOutputById(o.outputId, trx) + if (stored?.lockingScript != null && stored.lockingScript.length > 0) o.lockingScript = stored.lockingScript + } } export interface StorageProviderOptions extends StorageReaderWriterOptions { diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts new file mode 100644 index 0000000000..dab11f9742 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts @@ -0,0 +1,100 @@ +import { TableOutput, TableOutputBasket } from '../schema/tables' +import { _tu, TestWalletNoSetup, TestWalletProviderNoSetup } from '../../../test/utils/TestUtilsWalletStorage' +import 'fake-indexeddb/auto' +import { managedChangeOutputFields } from '../methods/managedChange' + +/** + * Change candidates are scanned with `noScript: true`, which clears every + * locking script. `validateOutputScript` reloads only a script offloaded into + * the raw transaction, so a script stored inline on the row stayed cleared and + * the signer received a funding input with no locking script. + */ +describe('funding outputs keep their locking script', () => { + jest.setTimeout(60_000) + type Ctx = TestWalletNoSetup | TestWalletProviderNoSetup + const ctxs: Ctx[] = [] + + beforeAll(async () => { + ctxs.push(await _tu.createLegacyWalletSQLiteCopy('allocateChangeInputScript')) + ctxs.push(await _tu.createIdbLegacyWalletCopy('allocateChangeInputScriptIdb')) + }) + afterAll(async () => { + for (const ctx of ctxs) await ctx.storage.destroy() + }) + + const p2pkhScript = [0x76, 0xa9, 0x14, ...Array.from({ length: 20 }, () => 0x22), 0x88, 0xac] + + async function inlineChange( + ctx: Ctx, + name: string, + satoshis: number, + vout: number + ): Promise<{ basketId: number; transactionId: number; outputId: number }> { + const storage = ctx.activeStorage + const basket: TableOutputBasket = { + created_at: new Date(), + updated_at: new Date(), + basketId: 0, + userId: ctx.userId, + name, + numberOfDesiredUTXOs: 0, + minimumDesiredUTXOValue: 0, + isDeleted: false + } + const basketId = await storage.insertOutputBasket(basket) + const tx = (await storage.findTransactions({ partial: { userId: ctx.userId, status: 'completed' } }))[0] + const outputId = await storage.insertOutput({ + outputId: 0, + created_at: new Date(), + updated_at: new Date(), + userId: ctx.userId, + transactionId: tx.transactionId, + basketId, + spendable: true, + satoshis, + outputDescription: 'inline script change', + vout, + txid: 'allocateChangeInputScriptTxid', + derivationPrefix: 'AAAAAAAAAAA=', + derivationSuffix: 'AAAAAAAAAAA=', + lockingScript: p2pkhScript, + scriptLength: p2pkhScript.length, + // Inline: the script is not offloaded into rawTx storage. + scriptOffset: 0, + ...managedChangeOutputFields + } as TableOutput) + return { basketId, transactionId: tx.transactionId, outputId } + } + + test('allocateChangeInput returns the chosen output with its inline script', async () => { + for (const [backend, ctx] of ctxs.entries()) { + const value = 616161 + const { basketId, transactionId, outputId } = await inlineChange(ctx, `inline-allocate-${backend}`, value, 950) + const picked = await ctx.activeStorage.allocateChangeInput( + ctx.userId, + basketId, + value, + value, + false, + transactionId + ) + expect(picked?.outputId).toBe(outputId) + expect(Array.from(picked?.lockingScript ?? [])).toEqual(p2pkhScript) + } + }) + + test('restoreOutputScript rereads an inline script cleared by a noScript scan', async () => { + for (const [backend, ctx] of ctxs.entries()) { + const { basketId } = await inlineChange(ctx, `inline-restore-${backend}`, 717171, 951) + const [scanned] = await ctx.activeStorage.findOutputs({ + partial: { userId: ctx.userId, basketId }, + noScript: true + }) + expect(scanned.lockingScript).toBeUndefined() + await ctx.activeStorage.validateOutputScript(scanned) + expect(scanned.lockingScript).toBeUndefined() + await ctx.activeStorage.restoreOutputScript(scanned) + expect(Array.from(scanned.lockingScript ?? [])).toEqual(p2pkhScript) + } + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts b/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts index bd96e3b08d..9f6d368ae6 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts @@ -370,7 +370,8 @@ async function makeFundingResult( const beef = new Beef() const result: ActionBatchFundingOutput[] = [] for (const output of outputs) { - await storage.validateOutputScript(output) + // Funding candidates come from a `noScript` scan. + await storage.restoreOutputScript(output) const copy: ActionBatchFundingOutput = { ...output } if (args.isSignAction && args.includeAllSourceTransactions) { copy.sourceTransaction = await storage.getRawTxOfKnownValidTransaction(output.txid) From f3554061f59b5384b0628f22882f5cb6fe637dfa Mon Sep 17 00:00:00 2001 From: GenericCPU Date: Sat, 17 Oct 2026 00:12:44 -0300 Subject: [PATCH 2/4] test(wallet-toolbox): cover restored and raw-less funding rows Run allocateChangeInput over every row shape validateOutputScript cannot fill: an inline script, a row restored or synced without txid/scriptOffset/ scriptLength, and an offloaded script whose raw transaction storage no longer holds. Guard that a script offloaded into a stored raw transaction is still rebuilt from it. Co-authored-by: Cursor --- .../__test/allocateChangeInputScript.test.ts | 80 ++++++++++++++----- 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts index dab11f9742..1ce6fe122d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts @@ -6,8 +6,9 @@ import { managedChangeOutputFields } from '../methods/managedChange' /** * Change candidates are scanned with `noScript: true`, which clears every * locking script. `validateOutputScript` reloads only a script offloaded into - * the raw transaction, so a script stored inline on the row stayed cleared and - * the signer received a funding input with no locking script. + * a raw transaction storage still holds, so a script kept on the row stayed + * cleared and the signer received a funding input with no locking script. + * Rows restored or synced without outpoint metadata are the common case. */ describe('funding outputs keep their locking script', () => { jest.setTimeout(60_000) @@ -24,11 +25,22 @@ describe('funding outputs keep their locking script', () => { const p2pkhScript = [0x76, 0xa9, 0x14, ...Array.from({ length: 20 }, () => 0x22), 0x88, 0xac] + /** Row shapes `validateOutputScript` cannot fill, each with the script kept on the row. */ + const shapes: Array<[string, Partial]> = [ + ['inline', { txid: 'allocateChangeInputScriptTxid', scriptLength: p2pkhScript.length, scriptOffset: 0 }], + ['restored without outpoint metadata', { txid: undefined, scriptLength: undefined, scriptOffset: undefined }], + [ + 'offloaded with no stored raw transaction', + { txid: 'ab'.repeat(32), scriptLength: p2pkhScript.length, scriptOffset: 1 } + ] + ] + async function inlineChange( ctx: Ctx, name: string, satoshis: number, - vout: number + vout: number, + shape: Partial = shapes[0][1] ): Promise<{ basketId: number; transactionId: number; outputId: number }> { const storage = ctx.activeStorage const basket: TableOutputBasket = { @@ -58,34 +70,58 @@ describe('funding outputs keep their locking script', () => { derivationPrefix: 'AAAAAAAAAAA=', derivationSuffix: 'AAAAAAAAAAA=', lockingScript: p2pkhScript, - scriptLength: p2pkhScript.length, - // Inline: the script is not offloaded into rawTx storage. - scriptOffset: 0, - ...managedChangeOutputFields + ...managedChangeOutputFields, + ...shape } as TableOutput) return { basketId, transactionId: tx.transactionId, outputId } } - test('allocateChangeInput returns the chosen output with its inline script', async () => { - for (const [backend, ctx] of ctxs.entries()) { - const value = 616161 - const { basketId, transactionId, outputId } = await inlineChange(ctx, `inline-allocate-${backend}`, value, 950) - const picked = await ctx.activeStorage.allocateChangeInput( - ctx.userId, - basketId, - value, - value, - false, - transactionId - ) - expect(picked?.outputId).toBe(outputId) - expect(Array.from(picked?.lockingScript ?? [])).toEqual(p2pkhScript) + test.each(shapes.map(([label, shape], i) => [label, shape, i] as const))( + 'allocateChangeInput returns the chosen %s output with its script', + async (_label, shape, i) => { + for (const [backend, ctx] of ctxs.entries()) { + const value = 616161 + i + const { basketId, transactionId, outputId } = await inlineChange( + ctx, + `script-allocate-${i}-${backend}`, + value, + 950 + i, + shape + ) + const picked = await ctx.activeStorage.allocateChangeInput( + ctx.userId, + basketId, + value, + value, + false, + transactionId + ) + expect(picked?.outputId).toBe(outputId) + expect(Array.from(picked?.lockingScript ?? [])).toEqual(p2pkhScript) + } + } + ) + + test('restoreOutputScript still rebuilds a script offloaded into a stored raw transaction', async () => { + for (const ctx of ctxs) { + const outputs = await ctx.activeStorage.findOutputs({ partial: { userId: ctx.userId }, noScript: true }) + let checked = 0 + for (const scanned of outputs) { + if (scanned.scriptOffset == null || scanned.scriptOffset === 0 || scanned.txid == null) continue + const raw = await ctx.activeStorage.getRawTxOfKnownValidTransaction(scanned.txid) + if (raw == null) continue + const expected = raw.slice(scanned.scriptOffset, scanned.scriptOffset + (scanned.scriptLength ?? 0)) + await ctx.activeStorage.restoreOutputScript(scanned) + expect(Array.from(scanned.lockingScript ?? [])).toEqual(Array.from(expected)) + if (++checked === 3) break + } + expect(checked).toBeGreaterThan(0) } }) test('restoreOutputScript rereads an inline script cleared by a noScript scan', async () => { for (const [backend, ctx] of ctxs.entries()) { - const { basketId } = await inlineChange(ctx, `inline-restore-${backend}`, 717171, 951) + const { basketId } = await inlineChange(ctx, `inline-restore-${backend}`, 717171, 970) const [scanned] = await ctx.activeStorage.findOutputs({ partial: { userId: ctx.userId, basketId }, noScript: true From 12142e0b15b622fe55a8c41d0331a3fd300b47ad Mon Sep 17 00:00:00 2001 From: GenericCPU Date: Sat, 17 Oct 2026 00:20:04 -0300 Subject: [PATCH 3/4] refactor(wallet-toolbox): keep restoreOutputScript off the public StorageProvider API Move the helper into storage/methods, which the package does not export, so the fix adds no public method a subclass could collide with. Co-authored-by: Cursor --- .../wallet-toolbox/src/storage/StorageIdb.ts | 3 ++- .../src/storage/StorageProvider.ts | 15 --------------- .../__test/allocateChangeInputScript.test.ts | 5 +++-- .../src/storage/methods/actionBatch.ts | 3 ++- .../src/storage/methods/restoreOutputScript.ts | 18 ++++++++++++++++++ 5 files changed, 25 insertions(+), 19 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/storage/methods/restoreOutputScript.ts diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts index 3e46a905a1..ae11d0939a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageIdb.ts @@ -75,6 +75,7 @@ import { WERR_INTERNAL, WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER, WERR_UNA import { EntityTimeStamp, TransactionStatus } from '../sdk/types' import { isAutoSpendableChangeOutput, managedChangeOutputFields } from './methods/managedChange' import { selectCanonicalChange } from './methods/actionPlanning' +import { restoreOutputScript } from './methods/restoreOutputScript' import { DEFAULT_MANAGED_CHANGE_MINIMUM_SATOSHIS, isLegacyManagedChangeBasketDefault @@ -349,7 +350,7 @@ export class StorageIdb extends StorageProvider implements WalletStorageProvider await this.updateOutput(output.outputId, { spendable: false, spentBy: transactionId }, dbTrx) // The candidate scan cleared every script; restore the chosen one, whether // it was offloaded into rawTx storage or stored inline on the row. - await this.restoreOutputScript(output, dbTrx) + await restoreOutputScript(this, output, dbTrx) } return output } finally { diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts index 2ee5208a6e..400ac4fc33 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageProvider.ts @@ -1909,21 +1909,6 @@ export abstract class StorageProvider extends StorageReaderWriter implements Wal if (script == null) return o.lockingScript = script } - - /** - * Restore the locking script of an output read with `noScript: true`. - * - * `validateOutputScript` only reloads a script offloaded into the raw - * transaction. A script stored inline on the row is left cleared, so a - * funding output chosen from a `noScript` scan reaches the signer without - * one. Reread the row in that case. - */ - async restoreOutputScript(o: TableOutput, trx?: TrxToken): Promise { - await this.validateOutputScript(o, trx) - if (o.lockingScript != null && o.lockingScript.length > 0) return - const stored = await this.findOutputById(o.outputId, trx) - if (stored?.lockingScript != null && stored.lockingScript.length > 0) o.lockingScript = stored.lockingScript - } } export interface StorageProviderOptions extends StorageReaderWriterOptions { diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts index 1ce6fe122d..fe64d58daf 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/allocateChangeInputScript.test.ts @@ -2,6 +2,7 @@ import { TableOutput, TableOutputBasket } from '../schema/tables' import { _tu, TestWalletNoSetup, TestWalletProviderNoSetup } from '../../../test/utils/TestUtilsWalletStorage' import 'fake-indexeddb/auto' import { managedChangeOutputFields } from '../methods/managedChange' +import { restoreOutputScript } from '../methods/restoreOutputScript' /** * Change candidates are scanned with `noScript: true`, which clears every @@ -111,7 +112,7 @@ describe('funding outputs keep their locking script', () => { const raw = await ctx.activeStorage.getRawTxOfKnownValidTransaction(scanned.txid) if (raw == null) continue const expected = raw.slice(scanned.scriptOffset, scanned.scriptOffset + (scanned.scriptLength ?? 0)) - await ctx.activeStorage.restoreOutputScript(scanned) + await restoreOutputScript(ctx.activeStorage, scanned) expect(Array.from(scanned.lockingScript ?? [])).toEqual(Array.from(expected)) if (++checked === 3) break } @@ -129,7 +130,7 @@ describe('funding outputs keep their locking script', () => { expect(scanned.lockingScript).toBeUndefined() await ctx.activeStorage.validateOutputScript(scanned) expect(scanned.lockingScript).toBeUndefined() - await ctx.activeStorage.restoreOutputScript(scanned) + await restoreOutputScript(ctx.activeStorage, scanned) expect(Array.from(scanned.lockingScript ?? [])).toEqual(p2pkhScript) } }) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts b/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts index 9f6d368ae6..9992cdb8c1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts @@ -55,6 +55,7 @@ import { validateCompactManifest } from './actionBatchBlobs' import { validateManifestActions } from './actionBatchValidation' +import { restoreOutputScript } from './restoreOutputScript' import type { ValidatedBatchAction } from './actionBatchValidation' export const ACTION_BATCH_LEASE_MS = 15 * 60 * 1000 @@ -371,7 +372,7 @@ async function makeFundingResult( const result: ActionBatchFundingOutput[] = [] for (const output of outputs) { // Funding candidates come from a `noScript` scan. - await storage.restoreOutputScript(output) + await restoreOutputScript(storage, output) const copy: ActionBatchFundingOutput = { ...output } if (args.isSignAction && args.includeAllSourceTransactions) { copy.sourceTransaction = await storage.getRawTxOfKnownValidTransaction(output.txid) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/restoreOutputScript.ts b/packages/wallet/wallet-toolbox/src/storage/methods/restoreOutputScript.ts new file mode 100644 index 0000000000..337a14ca24 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/methods/restoreOutputScript.ts @@ -0,0 +1,18 @@ +import type { TrxToken } from '../../sdk/WalletStorage.interfaces' +import type { TableOutput } from '../schema/tables' +import type { StorageProvider } from '../StorageProvider' + +/** + * Restore the locking script of an output read with `noScript: true`. + * + * `validateOutputScript` only reloads a script offloaded into a raw + * transaction storage still holds. A script kept on the row is left cleared, + * so a funding output chosen from a `noScript` scan would reach the signer + * without one. Reread the row in that case. + */ +export async function restoreOutputScript(storage: StorageProvider, o: TableOutput, trx?: TrxToken): Promise { + await storage.validateOutputScript(o, trx) + if (o.lockingScript != null && o.lockingScript.length > 0) return + const stored = await storage.findOutputById(o.outputId, trx) + if (stored?.lockingScript != null && stored.lockingScript.length > 0) o.lockingScript = stored.lockingScript +} From 517b4b0c989abe1cc95a56f4bdd0595656048000 Mon Sep 17 00:00:00 2001 From: GenericCPU Date: Sat, 17 Oct 2026 00:25:25 -0300 Subject: [PATCH 4/4] refactor(wallet-toolbox): restore funding scripts before building the funding result Co-authored-by: Cursor --- .../wallet/wallet-toolbox/src/storage/methods/actionBatch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts b/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts index 9992cdb8c1..5fe15a8ab5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/actionBatch.ts @@ -370,9 +370,9 @@ async function makeFundingResult( ): Promise<{ outputs: ActionBatchFundingOutput[]; beef?: Uint8Array }> { const beef = new Beef() const result: ActionBatchFundingOutput[] = [] + // Funding candidates come from a `noScript` scan. + await Promise.all(outputs.map(async output => await restoreOutputScript(storage, output))) for (const output of outputs) { - // Funding candidates come from a `noScript` scan. - await restoreOutputScript(storage, output) const copy: ActionBatchFundingOutput = { ...output } if (args.isSignAction && args.includeAllSourceTransactions) { copy.sourceTransaction = await storage.getRawTxOfKnownValidTransaction(output.txid)