From 98b41f689fa815edc09a6caa8f5aacde26c5003c Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 17 May 2026 11:45:17 -0500 Subject: [PATCH 1/6] Validate redemption wallet UTXOs --- .../redemptions/redemptions-service.ts | 75 +++++++++++++- typescript/test/services/redemptions.test.ts | 97 ++++++++++++++++++- 2 files changed, 164 insertions(+), 8 deletions(-) diff --git a/typescript/src/services/redemptions/redemptions-service.ts b/typescript/src/services/redemptions/redemptions-service.ts index 8c59a3ea2..4c7719003 100644 --- a/typescript/src/services/redemptions/redemptions-service.ts +++ b/typescript/src/services/redemptions/redemptions-service.ts @@ -9,6 +9,7 @@ import { import { BitcoinAddressConverter, BitcoinClient, + BitcoinHashUtils, BitcoinNetwork, BitcoinScriptUtils, BitcoinTxHash, @@ -371,18 +372,38 @@ export class RedemptionsService { ) } + const bitcoinNetwork = await this.bitcoinClient.getNetwork() + for (let index = 0; index < potentialCandidateWallets.length; index++) { const serializableWallet = potentialCandidateWallets[index] const { - walletBTCBalance: candidateBTCBalance, walletPublicKey: candidatePublicKey, mainUtxo: candidateMainUtxo, } = this.fromSerializableWallet(serializableWallet) - if (candidateBTCBalance.lt(amount)) { + const walletPublicKeyHash = + BitcoinHashUtils.computeHash160(candidatePublicKey) + const currentWallet = await this.tbtcContracts.bridge.wallets( + walletPublicKeyHash + ) + + if (!currentWallet || currentWallet.state !== WalletState.Live) { console.debug( - `The wallet (${candidatePublicKey.toString()})` + - `cannot handle the redemption request. ` + + `Wallet is not in Live state ` + + `(wallet public key hash: ${walletPublicKeyHash.toString()}). ` + + `Continue the loop execution to the next wallet...` + ) + continue + } + + if ( + currentWallet.walletPublicKey && + !currentWallet.walletPublicKey.equals(candidatePublicKey) + ) { + console.debug( + `The wallet public key returned by the redemption wallet API ` + + `does not match the on-chain wallet public key ` + + `(wallet public key hash: ${walletPublicKeyHash.toString()}). ` + `Continue the loop execution to the next wallet...` ) continue @@ -407,8 +428,52 @@ export class RedemptionsService { continue } } + + let currentMainUtxo = candidateMainUtxo + const candidateMainUtxoHash = + this.tbtcContracts.bridge.buildUtxoHash(candidateMainUtxo) + + if (!currentWallet.mainUtxoHash.equals(candidateMainUtxoHash)) { + console.debug( + `The wallet main UTXO returned by the redemption wallet API is stale ` + + `(wallet public key hash: ${walletPublicKeyHash.toString()}). ` + + `Trying to resolve the current wallet main UTXO...` + ) + + const resolvedMainUtxo = await this.determineWalletMainUtxo( + walletPublicKeyHash, + bitcoinNetwork + ) + + if (!resolvedMainUtxo) { + console.debug( + `Could not resolve current main UTXO for wallet ` + + `${walletPublicKeyHash.toString()}. ` + + `Continue the loop execution to the next wallet...` + ) + continue + } + + currentMainUtxo = resolvedMainUtxo + } + + const candidateBTCBalance = currentMainUtxo.value.gt( + currentWallet.pendingRedemptionsValue + ) + ? currentMainUtxo.value.sub(currentWallet.pendingRedemptionsValue) + : BigNumber.from(0) + + if (candidateBTCBalance.lt(amount)) { + console.debug( + `The wallet (${candidatePublicKey.toString()})` + + `cannot handle the redemption request. ` + + `Continue the loop execution to the next wallet...` + ) + continue + } + walletPublicKey = candidatePublicKey - mainUtxo = candidateMainUtxo + mainUtxo = currentMainUtxo console.debug( `The wallet (${walletPublicKey.toString()})` + diff --git a/typescript/test/services/redemptions.test.ts b/typescript/test/services/redemptions.test.ts index 1ac06bca9..971bf2592 100644 --- a/typescript/test/services/redemptions.test.ts +++ b/typescript/test/services/redemptions.test.ts @@ -794,18 +794,18 @@ describe("Redemptions", () => { // Creates a basic L1 Bitcoin redeemer mock that returns a fixed tx hash. // Optionally captures the wallet public key passed to requestRedemption. function createMockL1BitcoinRedeemer( - onRequestRedemption?: (walletPubKey: Hex) => void + onRequestRedemption?: (walletPubKey: Hex, mainUtxo: BitcoinUtxo) => void ) { return { getChainIdentifier: () => EthereumAddress.from("0x1234567890123456789012345678901234567890"), requestRedemption: async ( walletPubKey: Hex, - _mainUtxo: BitcoinUtxo, + mainUtxo: BitcoinUtxo, _encodedVm: BytesLike ) => { if (onRequestRedemption) { - onRequestRedemption(walletPubKey) + onRequestRedemption(walletPubKey, mainUtxo) } return mockTxHash }, @@ -1140,6 +1140,19 @@ describe("Redemptions", () => { const tbtcContracts = new MockTBTCContracts() const bitcoinClient = new MockBitcoinClient() bitcoinClient.network = BitcoinNetwork.Mainnet + const wallet = findWalletForRedemptionData.liveWallet + + tbtcContracts.bridge.setWallet( + wallet.event.walletPublicKeyHash.toPrefixedString(), + { + state: WalletState.Live, + walletPublicKey: wallet.data.walletPublicKey, + pendingRedemptionsValue: BigNumber.from(0), + mainUtxoHash: tbtcContracts.bridge.buildUtxoHash( + wallet.data.mainUtxo + ), + } as Wallet + ) const redemptionsService = new ApiSuccessGuardService( tbtcContracts, @@ -1156,6 +1169,84 @@ describe("Redemptions", () => { expect(result).to.have.property("targetChainTxHash") }) + + it("should re-resolve stale API main UTXO before requesting redemption", async () => { + const tbtcContracts = new MockTBTCContracts() + const bitcoinClient = new MockBitcoinClient() + bitcoinClient.network = BitcoinNetwork.Mainnet + const wallet = findWalletForRedemptionData.liveWallet + const staleMainUtxo: BitcoinUtxo = { + transactionHash: Hex.from( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ), + outputIndex: 0, + value: wallet.data.mainUtxo.value, + } + + tbtcContracts.bridge.setWallet( + wallet.event.walletPublicKeyHash.toPrefixedString(), + { + state: WalletState.Live, + walletPublicKey: wallet.data.walletPublicKey, + pendingRedemptionsValue: BigNumber.from(0), + mainUtxoHash: tbtcContracts.bridge.buildUtxoHash( + wallet.data.mainUtxo + ), + } as Wallet + ) + + const walletTransactions = new Map() + wallet.data.transactions.forEach((tx) => { + walletTransactions.set(tx.transactionHash.toString(), tx) + }) + bitcoinClient.transactions = walletTransactions + + const walletTransactionHashes = new Map() + walletTransactionHashes.set( + wallet.event.walletPublicKeyHash.toString(), + wallet.data.transactions.map((tx) => tx.transactionHash) + ) + bitcoinClient.transactionHashes = walletTransactionHashes + + class StaleApiSuccessGuardService extends ApiSuccessGuardService { + protected async fetchWalletsForRedemption(): Promise { + return [ + { + index: 0, + walletPublicKey: wallet.data.walletPublicKey.toString(), + mainUtxo: { + transactionHash: staleMainUtxo.transactionHash.toString(), + outputIndex: staleMainUtxo.outputIndex, + value: staleMainUtxo.value.toString(), + }, + walletBTCBalance: staleMainUtxo.value.toString(), + }, + ] + } + } + + let capturedMainUtxo: BitcoinUtxo | undefined + + const redemptionsService = new StaleApiSuccessGuardService( + tbtcContracts, + bitcoinClient, + createCrossChainResolver( + createMockL1BitcoinRedeemer((_, mainUtxo) => { + capturedMainUtxo = mainUtxo + }) + ) + ) + + const result = await redemptionsService.relayRedemptionRequestToL1( + testAmount, + testEncodedVm, + testL2ChainName, + testRedeemerOutputScript + ) + + expect(result).to.have.property("targetChainTxHash") + expect(capturedMainUtxo).to.deep.equal(wallet.data.mainUtxo) + }) } ) From d336050123c61b49c89656ac259f31e8c93bbbc8 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 17 May 2026 11:58:38 -0500 Subject: [PATCH 2/6] Preserve API redemption wallet reservations --- .../redemptions/redemptions-service.ts | 8 ++- typescript/test/services/redemptions.test.ts | 62 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/typescript/src/services/redemptions/redemptions-service.ts b/typescript/src/services/redemptions/redemptions-service.ts index 4c7719003..037877045 100644 --- a/typescript/src/services/redemptions/redemptions-service.ts +++ b/typescript/src/services/redemptions/redemptions-service.ts @@ -377,6 +377,7 @@ export class RedemptionsService { for (let index = 0; index < potentialCandidateWallets.length; index++) { const serializableWallet = potentialCandidateWallets[index] const { + walletBTCBalance: apiCandidateBTCBalance, walletPublicKey: candidatePublicKey, mainUtxo: candidateMainUtxo, } = this.fromSerializableWallet(serializableWallet) @@ -457,11 +458,16 @@ export class RedemptionsService { currentMainUtxo = resolvedMainUtxo } - const candidateBTCBalance = currentMainUtxo.value.gt( + const onChainCandidateBTCBalance = currentMainUtxo.value.gt( currentWallet.pendingRedemptionsValue ) ? currentMainUtxo.value.sub(currentWallet.pendingRedemptionsValue) : BigNumber.from(0) + const candidateBTCBalance = onChainCandidateBTCBalance.lt( + apiCandidateBTCBalance + ) + ? onChainCandidateBTCBalance + : apiCandidateBTCBalance if (candidateBTCBalance.lt(amount)) { console.debug( diff --git a/typescript/test/services/redemptions.test.ts b/typescript/test/services/redemptions.test.ts index 971bf2592..596b8192d 100644 --- a/typescript/test/services/redemptions.test.ts +++ b/typescript/test/services/redemptions.test.ts @@ -1247,6 +1247,68 @@ describe("Redemptions", () => { expect(result).to.have.property("targetChainTxHash") expect(capturedMainUtxo).to.deep.equal(wallet.data.mainUtxo) }) + + it("should preserve API-reserved balance when validating against Bridge state", async () => { + const tbtcContracts = new MockTBTCContracts() + const bitcoinClient = new MockBitcoinClient() + bitcoinClient.network = BitcoinNetwork.Mainnet + const wallet = findWalletForRedemptionData.liveWallet + const amountInSatoshi = testAmount.div( + BigNumber.from("10000000000") + ) + + tbtcContracts.bridge.setWallet( + wallet.event.walletPublicKeyHash.toPrefixedString(), + { + state: WalletState.Live, + walletPublicKey: wallet.data.walletPublicKey, + pendingRedemptionsValue: BigNumber.from(0), + mainUtxoHash: tbtcContracts.bridge.buildUtxoHash( + wallet.data.mainUtxo + ), + } as Wallet + ) + + class ApiBalanceGuardService extends RedemptionsService { + public async determineValidRedemptionWallet( + amount: BigNumber, + potentialCandidateWallets: Array, + redeemerAddressOrScript?: string + ): Promise { + return super.determineValidRedemptionWallet( + amount, + potentialCandidateWallets, + redeemerAddressOrScript + ) + } + } + + const redemptionsService = new ApiBalanceGuardService( + tbtcContracts, + bitcoinClient, + createCrossChainResolver(createMockL1BitcoinRedeemer()) + ) + + await expect( + redemptionsService.determineValidRedemptionWallet( + amountInSatoshi, + [ + { + index: 0, + walletPublicKey: wallet.data.walletPublicKey.toString(), + mainUtxo: { + transactionHash: + wallet.data.mainUtxo.transactionHash.toString(), + outputIndex: wallet.data.mainUtxo.outputIndex, + value: wallet.data.mainUtxo.value.toString(), + }, + walletBTCBalance: amountInSatoshi.sub(1).toString(), + }, + ], + testRedeemerOutputScript + ) + ).to.be.rejectedWith("Could not find a wallet with enough funds.") + }) } ) From 48975985b9450e926bc94084bb7a25a42b4fe77b Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 17 May 2026 17:25:02 -0500 Subject: [PATCH 3/6] Update TypeScript API docs --- .../classes/RedemptionsService.md | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/typescript/api-reference/classes/RedemptionsService.md b/typescript/api-reference/classes/RedemptionsService.md index c74080962..290ee207e 100644 --- a/typescript/api-reference/classes/RedemptionsService.md +++ b/typescript/api-reference/classes/RedemptionsService.md @@ -52,7 +52,7 @@ Service exposing features related to tBTC v2 redemptions. #### Defined in -[services/redemptions/redemptions-service.ts:47](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L47) +[services/redemptions/redemptions-service.ts:48](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L48) ## Properties @@ -78,7 +78,7 @@ Gets cross-chain contracts for the given supported L2 chain. #### Defined in -[services/redemptions/redemptions-service.ts:45](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L45) +[services/redemptions/redemptions-service.ts:46](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L46) ___ @@ -90,7 +90,7 @@ Bitcoin client handle. #### Defined in -[services/redemptions/redemptions-service.ts:38](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L38) +[services/redemptions/redemptions-service.ts:39](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L39) ___ @@ -102,7 +102,7 @@ Handle to tBTC contracts. #### Defined in -[services/redemptions/redemptions-service.ts:34](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L34) +[services/redemptions/redemptions-service.ts:35](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L35) ## Methods @@ -133,7 +133,7 @@ An array of subarrays, where each subarray has a maximum length of `chunkSize`. #### Defined in -[services/redemptions/redemptions-service.ts:580](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L580) +[services/redemptions/redemptions-service.ts:651](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L651) ___ @@ -160,7 +160,7 @@ Object containing: #### Defined in -[services/redemptions/redemptions-service.ts:315](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L315) +[services/redemptions/redemptions-service.ts:316](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L316) ___ @@ -195,7 +195,7 @@ Throws an error if no valid redemption wallet exists for the given #### Defined in -[services/redemptions/redemptions-service.ts:359](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L359) +[services/redemptions/redemptions-service.ts:360](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L360) ___ @@ -222,7 +222,7 @@ Promise holding the wallet main UTXO or undefined value. #### Defined in -[services/redemptions/redemptions-service.ts:599](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L599) +[services/redemptions/redemptions-service.ts:670](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L670) ___ @@ -241,7 +241,7 @@ Array of wallet events. #### Defined in -[services/redemptions/redemptions-service.ts:753](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L753) +[services/redemptions/redemptions-service.ts:824](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L824) ___ @@ -268,7 +268,7 @@ Promise with the wallet details needed to request a redemption. #### Defined in -[services/redemptions/redemptions-service.ts:439](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L439) +[services/redemptions/redemptions-service.ts:510](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L510) ___ @@ -288,7 +288,7 @@ ___ #### Defined in -[services/redemptions/redemptions-service.ts:840](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L840) +[services/redemptions/redemptions-service.ts:911](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L911) ___ @@ -312,7 +312,7 @@ The output script of the given Bitcoin address. #### Defined in -[services/redemptions/redemptions-service.ts:778](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L778) +[services/redemptions/redemptions-service.ts:849](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L849) ___ @@ -343,7 +343,7 @@ Throws an error if no redemption request exists for the given #### Defined in -[services/redemptions/redemptions-service.ts:711](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L711) +[services/redemptions/redemptions-service.ts:782](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L782) ___ @@ -379,7 +379,7 @@ Throws an error if no wallet with sufficient funds can be found. #### Defined in -[services/redemptions/redemptions-service.ts:244](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L244) +[services/redemptions/redemptions-service.ts:245](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L245) ___ @@ -409,7 +409,7 @@ Object containing: #### Defined in -[services/redemptions/redemptions-service.ts:200](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L200) +[services/redemptions/redemptions-service.ts:201](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L201) ___ @@ -438,7 +438,7 @@ Object containing: #### Defined in -[services/redemptions/redemptions-service.ts:84](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L84) +[services/redemptions/redemptions-service.ts:85](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L85) ___ @@ -470,7 +470,7 @@ Object containing: #### Defined in -[services/redemptions/redemptions-service.ts:156](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L156) +[services/redemptions/redemptions-service.ts:157](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L157) ___ @@ -496,7 +496,7 @@ The resolved output script as a Hex object. #### Defined in -[services/redemptions/redemptions-service.ts:808](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L808) +[services/redemptions/redemptions-service.ts:879](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L879) ___ @@ -520,4 +520,4 @@ once the loader is ready. #### Defined in -[services/redemptions/redemptions-service.ts:65](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L65) +[services/redemptions/redemptions-service.ts:66](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L66) From 36f6a117d222d2b6604cef0de7d71749f0e0822f Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 17 May 2026 17:34:23 -0500 Subject: [PATCH 4/6] Address redemption wallet validation review --- .../classes/RedemptionsService.md | 27 +-- .../redemptions/redemptions-service.ts | 17 +- typescript/test/services/redemptions.test.ts | 159 ++++++++++++++++++ 3 files changed, 189 insertions(+), 14 deletions(-) diff --git a/typescript/api-reference/classes/RedemptionsService.md b/typescript/api-reference/classes/RedemptionsService.md index 290ee207e..9feb325ae 100644 --- a/typescript/api-reference/classes/RedemptionsService.md +++ b/typescript/api-reference/classes/RedemptionsService.md @@ -133,7 +133,7 @@ An array of subarrays, where each subarray has a maximum length of `chunkSize`. #### Defined in -[services/redemptions/redemptions-service.ts:651](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L651) +[services/redemptions/redemptions-service.ts:660](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L660) ___ @@ -169,13 +169,20 @@ ___ ▸ **determineValidRedemptionWallet**(`amount`, `potentialCandidateWallets`, `redeemerAddressOrScript?`): `Promise`\<[`RedemptionWallet`](../interfaces/RedemptionWallet.md)\> Determines a valid wallet that can handle a redemption request. +Cross-checks API-provided wallet candidates against the current Bridge +state before accepting them. A candidate is accepted only if the on-chain +wallet is Live, its on-chain public key does not disagree with the API +public key, and its main UTXO matches the Bridge's main UTXO hash or can be +re-resolved from Bitcoin history. The spendable balance is capped to the +lower of the API balance and the on-chain main UTXO value minus pending +Bridge redemptions. #### Parameters | Name | Type | Description | | :------ | :------ | :------ | | `amount` | `BigNumber` | The amount to be redeemed in satoshi precision (1e8). | -| `potentialCandidateWallets` | [`SerializableWallet`](../interfaces/SerializableWallet.md)[] | Array of wallets that can handle the redemption request. The wallets must be in the Live state. | +| `potentialCandidateWallets` | [`SerializableWallet`](../interfaces/SerializableWallet.md)[] | Array of wallets that can handle the redemption request. | | `redeemerAddressOrScript?` | `string` | Optional. Either a Bitcoin address (P2PKH, P2WPKH, P2SH, P2WSH) or a raw hex output script (with or without 0x prefix). When provided, the function checks for pending redemptions to avoid wallet collisions. - If the input matches /^(0x)?[0-9a-fA-F]+$/, it's treated as a raw hex output script and used directly. - Otherwise, it's treated as a Bitcoin address and converted to an output script. | #### Returns @@ -195,7 +202,7 @@ Throws an error if no valid redemption wallet exists for the given #### Defined in -[services/redemptions/redemptions-service.ts:360](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L360) +[services/redemptions/redemptions-service.ts:367](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L367) ___ @@ -222,7 +229,7 @@ Promise holding the wallet main UTXO or undefined value. #### Defined in -[services/redemptions/redemptions-service.ts:670](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L670) +[services/redemptions/redemptions-service.ts:679](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L679) ___ @@ -241,7 +248,7 @@ Array of wallet events. #### Defined in -[services/redemptions/redemptions-service.ts:824](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L824) +[services/redemptions/redemptions-service.ts:833](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L833) ___ @@ -268,7 +275,7 @@ Promise with the wallet details needed to request a redemption. #### Defined in -[services/redemptions/redemptions-service.ts:510](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L510) +[services/redemptions/redemptions-service.ts:519](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L519) ___ @@ -288,7 +295,7 @@ ___ #### Defined in -[services/redemptions/redemptions-service.ts:911](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L911) +[services/redemptions/redemptions-service.ts:920](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L920) ___ @@ -312,7 +319,7 @@ The output script of the given Bitcoin address. #### Defined in -[services/redemptions/redemptions-service.ts:849](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L849) +[services/redemptions/redemptions-service.ts:858](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L858) ___ @@ -343,7 +350,7 @@ Throws an error if no redemption request exists for the given #### Defined in -[services/redemptions/redemptions-service.ts:782](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L782) +[services/redemptions/redemptions-service.ts:791](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L791) ___ @@ -496,7 +503,7 @@ The resolved output script as a Hex object. #### Defined in -[services/redemptions/redemptions-service.ts:879](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L879) +[services/redemptions/redemptions-service.ts:888](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/services/redemptions/redemptions-service.ts#L888) ___ diff --git a/typescript/src/services/redemptions/redemptions-service.ts b/typescript/src/services/redemptions/redemptions-service.ts index 037877045..59a478654 100644 --- a/typescript/src/services/redemptions/redemptions-service.ts +++ b/typescript/src/services/redemptions/redemptions-service.ts @@ -338,9 +338,16 @@ export class RedemptionsService { /** * Determines a valid wallet that can handle a redemption request. + * Cross-checks API-provided wallet candidates against the current Bridge + * state before accepting them. A candidate is accepted only if the on-chain + * wallet is Live, its on-chain public key does not disagree with the API + * public key, and its main UTXO matches the Bridge's main UTXO hash or can be + * re-resolved from Bitcoin history. The spendable balance is capped to the + * lower of the API balance and the on-chain main UTXO value minus pending + * Bridge redemptions. * @param amount The amount to be redeemed in satoshi precision (1e8). * @param potentialCandidateWallets Array of wallets that can handle the - * redemption request. The wallets must be in the Live state. + * redemption request. * @param redeemerAddressOrScript Optional. Either a Bitcoin address (P2PKH, * P2WPKH, P2SH, P2WSH) or a raw hex output script (with or without * 0x prefix). When provided, the function checks for pending @@ -397,6 +404,8 @@ export class RedemptionsService { continue } + // Missing on-chain public key is tolerated to avoid turning a registry + // data issue into a candidate rejection. A disagreement is unsafe. if ( currentWallet.walletPublicKey && !currentWallet.walletPublicKey.equals(candidatePublicKey) @@ -471,7 +480,7 @@ export class RedemptionsService { if (candidateBTCBalance.lt(amount)) { console.debug( - `The wallet (${candidatePublicKey.toString()})` + + `The wallet (${candidatePublicKey.toString()}) ` + `cannot handle the redemption request. ` + `Continue the loop execution to the next wallet...` ) @@ -482,7 +491,7 @@ export class RedemptionsService { mainUtxo = currentMainUtxo console.debug( - `The wallet (${walletPublicKey.toString()})` + + `The wallet (${walletPublicKey.toString()}) ` + `can handle the redemption request. ` + `Stop the loop execution and proceed with the redemption...` ) @@ -599,7 +608,7 @@ export class RedemptionsService { }) } else { console.debug( - `The wallet (${walletPublicKeyHash.toString()})` + + `The wallet (${walletPublicKeyHash.toString()}) ` + `cannot handle the redemption request. ` + `Continue the loop execution to the next wallet...` ) diff --git a/typescript/test/services/redemptions.test.ts b/typescript/test/services/redemptions.test.ts index 596b8192d..f2337bbcd 100644 --- a/typescript/test/services/redemptions.test.ts +++ b/typescript/test/services/redemptions.test.ts @@ -1136,6 +1136,37 @@ describe("Redemptions", () => { } } + class WalletValidationGuardService extends RedemptionsService { + public async determineValidRedemptionWallet( + amount: BigNumber, + potentialCandidateWallets: Array, + redeemerAddressOrScript?: string + ): Promise { + return super.determineValidRedemptionWallet( + amount, + potentialCandidateWallets, + redeemerAddressOrScript + ) + } + } + + const serializeRedemptionWalletCandidate = ( + walletPublicKey: Hex, + mainUtxo: BitcoinUtxo, + walletBTCBalance = mainUtxo.value + ) => { + return { + index: 0, + walletPublicKey: walletPublicKey.toString(), + mainUtxo: { + transactionHash: mainUtxo.transactionHash.toString(), + outputIndex: mainUtxo.outputIndex, + value: mainUtxo.value.toString(), + }, + walletBTCBalance: walletBTCBalance.toString(), + } + } + it("should complete without triggering on-chain fallback", async () => { const tbtcContracts = new MockTBTCContracts() const bitcoinClient = new MockBitcoinClient() @@ -1309,6 +1340,134 @@ describe("Redemptions", () => { ) ).to.be.rejectedWith("Could not find a wallet with enough funds.") }) + + it("should skip API candidates that are not Live on-chain", async () => { + const tbtcContracts = new MockTBTCContracts() + const bitcoinClient = new MockBitcoinClient() + bitcoinClient.network = BitcoinNetwork.Mainnet + const nonLiveWallet = findWalletForRedemptionData.nonLiveWallet + const liveWallet = findWalletForRedemptionData.liveWallet + const amountInSatoshi = testAmount.div( + BigNumber.from("10000000000") + ) + const nonLiveWalletPublicKeyHash = BitcoinHashUtils.computeHash160( + nonLiveWallet.data.walletPublicKey + ) + + tbtcContracts.bridge.setWallet( + nonLiveWalletPublicKeyHash.toPrefixedString(), + { + state: WalletState.Unknown, + walletPublicKey: nonLiveWallet.data.walletPublicKey, + pendingRedemptionsValue: BigNumber.from(0), + mainUtxoHash: tbtcContracts.bridge.buildUtxoHash( + liveWallet.data.mainUtxo + ), + } as Wallet + ) + tbtcContracts.bridge.setWallet( + liveWallet.event.walletPublicKeyHash.toPrefixedString(), + { + state: WalletState.Live, + walletPublicKey: liveWallet.data.walletPublicKey, + pendingRedemptionsValue: BigNumber.from(0), + mainUtxoHash: tbtcContracts.bridge.buildUtxoHash( + liveWallet.data.mainUtxo + ), + } as Wallet + ) + + const redemptionsService = new WalletValidationGuardService( + tbtcContracts, + bitcoinClient, + createCrossChainResolver(createMockL1BitcoinRedeemer()) + ) + + const result = + await redemptionsService.determineValidRedemptionWallet( + amountInSatoshi, + [ + serializeRedemptionWalletCandidate( + nonLiveWallet.data.walletPublicKey, + liveWallet.data.mainUtxo + ), + serializeRedemptionWalletCandidate( + liveWallet.data.walletPublicKey, + liveWallet.data.mainUtxo + ), + ], + testRedeemerOutputScript + ) + + expect(result.walletPublicKey.toString()).to.equal( + liveWallet.data.walletPublicKey.toString() + ) + }) + + it("should skip API candidates with an on-chain public key mismatch", async () => { + const tbtcContracts = new MockTBTCContracts() + const bitcoinClient = new MockBitcoinClient() + bitcoinClient.network = BitcoinNetwork.Mainnet + const mismatchedWallet = + findWalletForRedemptionData.walletWithPendingRedemption + const liveWallet = findWalletForRedemptionData.liveWallet + const amountInSatoshi = testAmount.div( + BigNumber.from("10000000000") + ) + const mismatchedWalletPublicKeyHash = + BitcoinHashUtils.computeHash160( + mismatchedWallet.data.walletPublicKey + ) + + tbtcContracts.bridge.setWallet( + mismatchedWalletPublicKeyHash.toPrefixedString(), + { + state: WalletState.Live, + walletPublicKey: liveWallet.data.walletPublicKey, + pendingRedemptionsValue: BigNumber.from(0), + mainUtxoHash: tbtcContracts.bridge.buildUtxoHash( + liveWallet.data.mainUtxo + ), + } as Wallet + ) + tbtcContracts.bridge.setWallet( + liveWallet.event.walletPublicKeyHash.toPrefixedString(), + { + state: WalletState.Live, + walletPublicKey: liveWallet.data.walletPublicKey, + pendingRedemptionsValue: BigNumber.from(0), + mainUtxoHash: tbtcContracts.bridge.buildUtxoHash( + liveWallet.data.mainUtxo + ), + } as Wallet + ) + + const redemptionsService = new WalletValidationGuardService( + tbtcContracts, + bitcoinClient, + createCrossChainResolver(createMockL1BitcoinRedeemer()) + ) + + const result = + await redemptionsService.determineValidRedemptionWallet( + amountInSatoshi, + [ + serializeRedemptionWalletCandidate( + mismatchedWallet.data.walletPublicKey, + liveWallet.data.mainUtxo + ), + serializeRedemptionWalletCandidate( + liveWallet.data.walletPublicKey, + liveWallet.data.mainUtxo + ), + ], + testRedeemerOutputScript + ) + + expect(result.walletPublicKey.toString()).to.equal( + liveWallet.data.walletPublicKey.toString() + ) + }) } ) From f43848ba1b902448b4ab1180c9f5f1ffbc04f98a Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 17 May 2026 17:51:27 -0500 Subject: [PATCH 5/6] Cover redemption wallet edge cases --- typescript/test/services/redemptions.test.ts | 136 +++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/typescript/test/services/redemptions.test.ts b/typescript/test/services/redemptions.test.ts index f2337bbcd..9f6461eac 100644 --- a/typescript/test/services/redemptions.test.ts +++ b/typescript/test/services/redemptions.test.ts @@ -1341,6 +1341,142 @@ describe("Redemptions", () => { ).to.be.rejectedWith("Could not find a wallet with enough funds.") }) + it("should skip API candidates if stale main UTXO cannot be resolved", async () => { + const tbtcContracts = new MockTBTCContracts() + const bitcoinClient = new MockBitcoinClient() + bitcoinClient.network = BitcoinNetwork.Mainnet + const staleWallet = + findWalletForRedemptionData.walletWithPendingRedemption + const liveWallet = findWalletForRedemptionData.liveWallet + const amountInSatoshi = testAmount.div( + BigNumber.from("10000000000") + ) + const staleWalletPublicKeyHash = BitcoinHashUtils.computeHash160( + staleWallet.data.walletPublicKey + ) + const staleMainUtxo: BitcoinUtxo = { + transactionHash: Hex.from( + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ), + outputIndex: 0, + value: liveWallet.data.mainUtxo.value, + } + + tbtcContracts.bridge.setWallet( + staleWalletPublicKeyHash.toPrefixedString(), + { + state: WalletState.Live, + walletPublicKey: staleWallet.data.walletPublicKey, + pendingRedemptionsValue: BigNumber.from(0), + mainUtxoHash: tbtcContracts.bridge.buildUtxoHash( + staleWallet.data.mainUtxo + ), + } as Wallet + ) + tbtcContracts.bridge.setWallet( + liveWallet.event.walletPublicKeyHash.toPrefixedString(), + { + state: WalletState.Live, + walletPublicKey: liveWallet.data.walletPublicKey, + pendingRedemptionsValue: BigNumber.from(0), + mainUtxoHash: tbtcContracts.bridge.buildUtxoHash( + liveWallet.data.mainUtxo + ), + } as Wallet + ) + + const redemptionsService = new WalletValidationGuardService( + tbtcContracts, + bitcoinClient, + createCrossChainResolver(createMockL1BitcoinRedeemer()) + ) + + const result = + await redemptionsService.determineValidRedemptionWallet( + amountInSatoshi, + [ + serializeRedemptionWalletCandidate( + staleWallet.data.walletPublicKey, + staleMainUtxo + ), + serializeRedemptionWalletCandidate( + liveWallet.data.walletPublicKey, + liveWallet.data.mainUtxo + ), + ], + testRedeemerOutputScript + ) + + expect(result.walletPublicKey.toString()).to.equal( + liveWallet.data.walletPublicKey.toString() + ) + }) + + it("should skip API candidates if on-chain pending redemptions exceed main UTXO value", async () => { + const tbtcContracts = new MockTBTCContracts() + const bitcoinClient = new MockBitcoinClient() + bitcoinClient.network = BitcoinNetwork.Mainnet + const overReservedWallet = + findWalletForRedemptionData.walletWithPendingRedemption + const liveWallet = findWalletForRedemptionData.liveWallet + const amountInSatoshi = testAmount.div( + BigNumber.from("10000000000") + ) + const overReservedWalletPublicKeyHash = + BitcoinHashUtils.computeHash160( + overReservedWallet.data.walletPublicKey + ) + + tbtcContracts.bridge.setWallet( + overReservedWalletPublicKeyHash.toPrefixedString(), + { + state: WalletState.Live, + walletPublicKey: overReservedWallet.data.walletPublicKey, + pendingRedemptionsValue: liveWallet.data.mainUtxo.value.add(1), + mainUtxoHash: tbtcContracts.bridge.buildUtxoHash( + liveWallet.data.mainUtxo + ), + } as Wallet + ) + tbtcContracts.bridge.setWallet( + liveWallet.event.walletPublicKeyHash.toPrefixedString(), + { + state: WalletState.Live, + walletPublicKey: liveWallet.data.walletPublicKey, + pendingRedemptionsValue: BigNumber.from(0), + mainUtxoHash: tbtcContracts.bridge.buildUtxoHash( + liveWallet.data.mainUtxo + ), + } as Wallet + ) + + const redemptionsService = new WalletValidationGuardService( + tbtcContracts, + bitcoinClient, + createCrossChainResolver(createMockL1BitcoinRedeemer()) + ) + + const result = + await redemptionsService.determineValidRedemptionWallet( + amountInSatoshi, + [ + serializeRedemptionWalletCandidate( + overReservedWallet.data.walletPublicKey, + liveWallet.data.mainUtxo + ), + serializeRedemptionWalletCandidate( + liveWallet.data.walletPublicKey, + liveWallet.data.mainUtxo + ), + ], + testRedeemerOutputScript + ) + + expect(result.walletPublicKey.toString()).to.equal( + liveWallet.data.walletPublicKey.toString() + ) + }) + it("should skip API candidates that are not Live on-chain", async () => { const tbtcContracts = new MockTBTCContracts() const bitcoinClient = new MockBitcoinClient() From 3ce15608a3b31425fad08fa343ac0d1b6da91b68 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 18 May 2026 09:18:10 -0500 Subject: [PATCH 6/6] Use Threshold API for redemption wallet lookup --- typescript/api-reference/enums/ApiUrl.md | 17 ++++++++++++++++- typescript/api-reference/enums/endpointUrl.md | 2 +- typescript/src/lib/utils/api.ts | 6 +++++- .../services/redemptions/redemptions-service.ts | 2 +- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/typescript/api-reference/enums/ApiUrl.md b/typescript/api-reference/enums/ApiUrl.md index a640b4296..452673cf2 100644 --- a/typescript/api-reference/enums/ApiUrl.md +++ b/typescript/api-reference/enums/ApiUrl.md @@ -5,12 +5,27 @@ ### Enumeration Members - [TBTC\_EXPLORER](ApiUrl.md#tbtc_explorer) +- [THRESHOLD\_API](ApiUrl.md#threshold_api) ## Enumeration Members ### TBTC\_EXPLORER -• **TBTC\_EXPLORER** = ``"https://api.tbtcscan.com"`` +• **TBTC\_EXPLORER** = ``"https://api.threshold.network"`` + +**`Deprecated`** + +Use THRESHOLD_API. + +#### Defined in + +[lib/utils/api.ts:7](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/lib/utils/api.ts#L7) + +___ + +### THRESHOLD\_API + +• **THRESHOLD\_API** = ``"https://api.threshold.network"`` #### Defined in diff --git a/typescript/api-reference/enums/endpointUrl.md b/typescript/api-reference/enums/endpointUrl.md index f1edcad35..9bd236d47 100644 --- a/typescript/api-reference/enums/endpointUrl.md +++ b/typescript/api-reference/enums/endpointUrl.md @@ -14,4 +14,4 @@ #### Defined in -[lib/utils/api.ts:7](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/lib/utils/api.ts#L7) +[lib/utils/api.ts:11](https://github.com/threshold-network/tbtc-v2/blob/main/typescript/src/lib/utils/api.ts#L11) diff --git a/typescript/src/lib/utils/api.ts b/typescript/src/lib/utils/api.ts index 6c58d3ac3..1f9e10fcd 100644 --- a/typescript/src/lib/utils/api.ts +++ b/typescript/src/lib/utils/api.ts @@ -1,6 +1,10 @@ /* eslint-disable no-unused-vars */ export enum ApiUrl { - TBTC_EXPLORER = "https://api.tbtcscan.com", + THRESHOLD_API = "https://api.threshold.network", + /** + * @deprecated Use THRESHOLD_API. + */ + TBTC_EXPLORER = "https://api.threshold.network", } export enum endpointUrl { diff --git a/typescript/src/services/redemptions/redemptions-service.ts b/typescript/src/services/redemptions/redemptions-service.ts index 59a478654..168825a26 100644 --- a/typescript/src/services/redemptions/redemptions-service.ts +++ b/typescript/src/services/redemptions/redemptions-service.ts @@ -840,7 +840,7 @@ export class RedemptionsService { } const response = await fetch( - `${ApiUrl.TBTC_EXPLORER}${endpointUrl.TBTC_REDEMPTION_WALLET}` + `${ApiUrl.THRESHOLD_API}${endpointUrl.TBTC_REDEMPTION_WALLET}` ) if (!response.ok) { throw new Error("Failed to fetch redemption wallet from server")