From fa8a902b0aff62a34b0065a6f31527a9a21818b2 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Tue, 23 Jun 2026 12:19:52 -0700 Subject: [PATCH 1/3] Allow internalizing sending transactions --- .../__test/WalletStorageManager.test.ts | 67 +++++++++++++++++++ .../src/storage/methods/internalizeAction.ts | 6 +- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts index 4d2b6d5d3..8d175857e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts @@ -269,6 +269,73 @@ describe('WalletStorageManager tests', () => { expect(result).toBeTruthy() } }) + + test('3_internalize same-wallet payment while transaction is sending', async () => { + for (const { wallet, activeStorage, identityKey, userId } of ctxs) { + const outputSatoshis = 5 + const derivationPrefix = Buffer.from('same-wallet-invoice').toString('base64') + const derivationSuffix = Buffer.from('utxo-0').toString('base64') + const brc29ProtocolID: bsv.WalletProtocol = [2, '3241645161d8'] + const derivedPublicKey = wallet.keyDeriver.derivePublicKey( + brc29ProtocolID, + `${derivationPrefix} ${derivationSuffix}`, + identityKey + ) + + const cr = await wallet.createAction({ + description: 'same-wallet payment pending send', + outputs: [ + { + satoshis: outputSatoshis, + lockingScript: new bsv.P2PKH().lock(derivedPublicKey.toAddress()).toHex(), + outputDescription: 'pay same wallet' + } + ], + options: { + returnTXIDOnly: false, + randomizeOutputs: false, + signAndProcess: true, + noSend: true + } + }) + expect(cr.tx).toBeTruthy() + expect(cr.txid).toBeTruthy() + if (cr.tx == null || cr.txid == null) throw new Error('createAction did not return a signed transaction') + + const existingTx = (await activeStorage.findTransactions({ + partial: { userId, txid: cr.txid } + }))[0] + expect(existingTx).toBeTruthy() + await activeStorage.updateTransaction(existingTx.transactionId, { status: 'sending' }) + + const ir = await activeStorage.internalizeAction( + { userId, identityKey }, + { + tx: cr.tx, + outputs: [ + { + outputIndex: 0, + protocol: 'wallet payment', + paymentRemittance: { + derivationPrefix, + derivationSuffix, + senderIdentityKey: identityKey + } + } + ], + description: 'received same-wallet payment before monitor completion' + } + ) + + expect(ir.accepted).toBe(true) + expect(ir.isMerge).toBe(true) + + const updatedTx = (await activeStorage.findTransactions({ + partial: { transactionId: existingTx.transactionId } + }))[0] + expect(updatedTx.status).toBe('sending') + } + }) }) function logger (s: string) { process.stdout.write(`${s}\n`) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/internalizeAction.ts b/packages/wallet/wallet-toolbox/src/storage/methods/internalizeAction.ts index 6dd80d191..fa4c290bc 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/internalizeAction.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/internalizeAction.ts @@ -142,7 +142,9 @@ export async function restoreInputsToSpendable ( * and merge rules are added to the arguments passed to the storage layer. * * The existing transaction's `status` determines what the merge path does next: - * - `'unproven'` or `'completed'`: outputs are merged into the existing record. The transaction status is left as-is. + * - `'unproven'`, `'completed'`, or `'sending'`: outputs are merged into the existing record. The transaction status is left as-is. + * The `'sending'` case covers a transaction this wallet already signed and handed to broadcast processing, but + * whose proven_tx_req has not yet been advanced by the normal monitor/posting flow. * - `'nosend'`: an ambiguous case. The transaction was created with `noSend: true` and may have been externally * broadcast, may be sitting in a sendWith chain, or may be stuck mid-flight. The merge path treats the * `internalizeAction` call as explicit authorization to advance the lifecycle. Specifically: `transactions.status` @@ -322,7 +324,7 @@ class InternalizeActionContext { partial: { userId: this.userId, txid: this.txid } }) ) - if ((this.etx != null) && this.etx.status !== 'completed' && this.etx.status !== 'unproven' && this.etx.status !== 'nosend') { + if ((this.etx != null) && this.etx.status !== 'completed' && this.etx.status !== 'unproven' && this.etx.status !== 'sending' && this.etx.status !== 'nosend') { throw new WERR_INVALID_PARAMETER( 'tx', `target transaction of internalizeAction has invalid status ${this.etx.status}.` From 241ebb0b46a9ed9fa148b5953bc3ac5c1a389e65 Mon Sep 17 00:00:00 2001 From: Deggen Date: Wed, 24 Jun 2026 08:26:32 -0500 Subject: [PATCH 2/3] fix: mock chaintracker in WalletStorageManager test to avoid live endpoint flakiness; sync JSDoc and comments for 'sending' status support --- .../src/signer/methods/internalizeAction.ts | 4 +++- .../src/storage/__test/WalletStorageManager.test.ts | 10 ++++++++++ .../test/Wallet/action/internalizeAction.a.test.ts | 3 ++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/signer/methods/internalizeAction.ts b/packages/wallet/wallet-toolbox/src/signer/methods/internalizeAction.ts index 9a7b3ae87..3451eb5cb 100644 --- a/packages/wallet/wallet-toolbox/src/signer/methods/internalizeAction.ts +++ b/packages/wallet/wallet-toolbox/src/signer/methods/internalizeAction.ts @@ -18,7 +18,9 @@ import { WERR_INTERNAL, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' * and merge rules are added to the arguments passed to the storage layer. * * The existing transaction's `status` determines what the merge path does next: - * - `'unproven'` or `'completed'`: outputs are merged; status is left as-is. + * - `'unproven'`, `'completed'`, or `'sending'`: outputs are merged; status is left as-is. + * The `'sending'` case covers a transaction this wallet already signed and handed to broadcast processing, but + * whose proven_tx_req has not yet been advanced by the normal monitor/posting flow. * - `'nosend'`: the `internalizeAction` call is treated as explicit authorization to advance the lifecycle. * `transactions.status` is promoted to `'completed'` (BUMP-bearing BEEF) or `'unproven'` (otherwise), and the * `proven_tx_req` is moved out of `'nosend'` so Monitor's proof-fetching flow can finalize it. See storage-layer diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts index 8d175857e..3f99b3f47 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/WalletStorageManager.test.ts @@ -308,6 +308,16 @@ describe('WalletStorageManager tests', () => { expect(existingTx).toBeTruthy() await activeStorage.updateTransaction(existingTx.transactionId, { status: 'sending' }) + // Mock chaintracker responses for AtomicBEEF validation to avoid flakiness from + // live chaintracks endpoints. This test exercises the 'sending' merge path for a + // same-wallet internalize before monitor completion; it is not testing proof validity + // (createAction + other tests cover BEEF construction/validation). + activeStorage.setServices({ + getChainTracker: async () => ({ + isValidRootForHeight: async (_root: string, _height: number) => true + }) + } as any) + const ir = await activeStorage.internalizeAction( { userId, identityKey }, { diff --git a/packages/wallet/wallet-toolbox/test/Wallet/action/internalizeAction.a.test.ts b/packages/wallet/wallet-toolbox/test/Wallet/action/internalizeAction.a.test.ts index 3bb05d8a1..8cd8e5e34 100644 --- a/packages/wallet/wallet-toolbox/test/Wallet/action/internalizeAction.a.test.ts +++ b/packages/wallet/wallet-toolbox/test/Wallet/action/internalizeAction.a.test.ts @@ -23,7 +23,8 @@ describe.skip('internalizeAction tests', () => { } }) - // Check: 'unproven' or 'completed' status. Any other status is an error. + // Check: 'unproven', 'completed', or 'sending' status for merge (status left as-is). 'nosend' advances lifecycle. + // Any other status is an error. // When the transaction already exists, the description is updated. The isOutgoing sense is not changed. test.skip('1_default real wallet data', async () => { From f9e40fcf49fc97e00bd0b8dd6d195054a502b415 Mon Sep 17 00:00:00 2001 From: Deggen Date: Wed, 24 Jun 2026 08:29:02 -0500 Subject: [PATCH 3/3] chore(release): patch version bump for @bsv/wallet-toolbox to 2.2.1 --- packages/wallet/wallet-toolbox/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet/wallet-toolbox/package.json b/packages/wallet/wallet-toolbox/package.json index 0a19355ff..87b87bb08 100644 --- a/packages/wallet/wallet-toolbox/package.json +++ b/packages/wallet/wallet-toolbox/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/wallet-toolbox", - "version": "2.3.0", + "version": "2.3.1", "description": "BRC100 conforming wallet, wallet storage and wallet signer components", "main": "./out/src/index.js", "types": "./out/src/index.d.ts",