diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index f85e8bf80..bad52e692 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -11,6 +11,7 @@ e032bba7ce52a877a77573fb5a14944623c77d02 7ad277ff8369b201515ba22872c1251ec93a6b81 5c7e2e3620ec06fb9a7c358c541d491da977ec08 3d8c4861ac467f0390733916775a9ccfafe752e3 +921349ff07db0effe9bc658afab6961ff262f35f # s/btc/BTC da039720b6eb36e5f7102e83a3e2bb95a09b5772 \ No newline at end of file diff --git a/typescript/src/bitcoin.ts b/typescript/src/bitcoin.ts index c3235cdb4..ceffb0fca 100644 --- a/typescript/src/bitcoin.ts +++ b/typescript/src/bitcoin.ts @@ -333,6 +333,18 @@ export interface Client { address: string ): Promise + /** + * Gets the history of confirmed transactions for given Bitcoin address. + * Returned transactions are sorted from oldest to newest. The returned + * result does not contain unconfirmed transactions living in the mempool + * at the moment of request. + * @param address - Bitcoin address transaction history should be determined for. + * @param limit - Optional parameter that can limit the resulting list to + * a specific number of last transaction. For example, limit = 5 will + * return only the last 5 transactions for the given address. + */ + getTransactionHistory(address: string, limit?: number): Promise + /** * Gets the full transaction object for given transaction hash. * @param transactionHash - Hash of the transaction. diff --git a/typescript/src/chain.ts b/typescript/src/chain.ts index e422e7a68..5b6f619e6 100644 --- a/typescript/src/chain.ts +++ b/typescript/src/chain.ts @@ -23,6 +23,7 @@ import { DkgResultChallengedEvent, DkgResultSubmittedEvent, NewWalletRegisteredEvent, + Wallet, } from "./wallet" import type { ExecutionLoggerFn } from "./backoff" @@ -244,6 +245,21 @@ export interface Bridge { * Returns the attached WalletRegistry instance. */ walletRegistry(): Promise + + /** + * Gets details about a registered wallet. + * @param walletPublicKeyHash The 20-byte wallet public key hash (computed + * using Bitcoin HASH160 over the compressed ECDSA public key). + * @returns Promise with the wallet details. + */ + wallets(walletPublicKeyHash: Hex): Promise + + /** + * Builds the UTXO hash based on the UTXO components. + * @param utxo UTXO components. + * @returns The hash of the UTXO. + */ + buildUtxoHash(utxo: UnspentTransactionOutput): Hex } /** diff --git a/typescript/src/electrum.ts b/typescript/src/electrum.ts index a43993209..022ccce2a 100644 --- a/typescript/src/electrum.ts +++ b/typescript/src/electrum.ts @@ -2,6 +2,7 @@ import bcoin from "bcoin" import pTimeout from "p-timeout" import { Client as BitcoinClient, + createOutputScriptFromAddress, RawTransaction, Transaction, TransactionHash, @@ -232,7 +233,7 @@ export class Client implements BitcoinClient { ): Promise { return this.withElectrum( async (electrum: Electrum) => { - const script = bcoin.Script.fromAddress(address).toRaw().toString("hex") + const script = createOutputScriptFromAddress(address).toString() // eslint-disable-next-line camelcase type UnspentOutput = { tx_pos: number; value: number; tx_hash: string } @@ -253,6 +254,58 @@ export class Client implements BitcoinClient { ) } + // eslint-disable-next-line valid-jsdoc + /** + * @see {BitcoinClient#getTransactionHistory} + */ + getTransactionHistory( + address: string, + limit?: number + ): Promise { + return this.withElectrum(async (electrum: Electrum) => { + const script = createOutputScriptFromAddress(address).toString() + + // eslint-disable-next-line camelcase + type HistoryItem = { height: number; tx_hash: string } + + let historyItems: HistoryItem[] = await this.withBackoffRetrier< + HistoryItem[] + >()(async () => { + return await electrum.blockchain_scripthash_getHistory( + computeScriptHash(script) + ) + }) + + // According to https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-scripthash-get-history + // unconfirmed items living in the mempool are appended at the end of the + // returned list and their height value is either -1 or 0. That means + // we need to take all items with height >0 to obtain a confirmed txs + // history. + historyItems = historyItems.filter((item) => item.height > 0) + + // The list returned from blockchain.scripthash.get_history is sorted by + // the block height in the ascending order though we are sorting it + // again just in case (e.g. API contract changes). + historyItems = historyItems.sort( + (item1, item2) => item1.height - item2.height + ) + + if ( + typeof limit !== "undefined" && + limit > 0 && + historyItems.length > limit + ) { + historyItems = historyItems.slice(-limit) + } + + const transactions = historyItems.map((item) => + this.getTransaction(TransactionHash.from(item.tx_hash)) + ) + + return Promise.all(transactions) + }) + } + // eslint-disable-next-line valid-jsdoc /** * @see {BitcoinClient#getTransaction} diff --git a/typescript/src/ethereum.ts b/typescript/src/ethereum.ts index a83f96348..60490f906 100644 --- a/typescript/src/ethereum.ts +++ b/typescript/src/ethereum.ts @@ -47,6 +47,7 @@ import type { Bridge as ContractBridge, Deposit as ContractDeposit, Redemption as ContractRedemption, + Wallets, } from "../typechain/Bridge" import type { WalletRegistry as ContractWalletRegistry } from "../typechain/WalletRegistry" import type { TBTCVault as ContractTBTCVault } from "../typechain/TBTCVault" @@ -57,6 +58,8 @@ import { DkgResultChallengedEvent, DkgResultSubmittedEvent, NewWalletRegisteredEvent, + Wallet, + WalletState, } from "./wallet" type ContractDepositRequest = ContractDeposit.DepositRequestStructOutput @@ -639,18 +642,20 @@ export class Bridge return undefined } - const { ecdsaWalletID } = await backoffRetrier<{ ecdsaWalletID: string }>( - this._totalRetryAttempts - )(async () => { - return await this._instance.wallets(activeWalletPublicKeyHash) - }) + const { walletPublicKey } = await this.wallets( + Hex.from(activeWalletPublicKeyHash) + ) + return walletPublicKey.toString() + } + + private async getWalletCompressedPublicKey(ecdsaWalletID: Hex): Promise { const walletRegistry = await this.walletRegistry() const uncompressedPublicKey = await walletRegistry.getWalletPublicKey( - Hex.from(ecdsaWalletID) + ecdsaWalletID ) - return compressPublicKey(uncompressedPublicKey) + return Hex.from(compressPublicKey(uncompressedPublicKey)) } // eslint-disable-next-line valid-jsdoc @@ -694,6 +699,69 @@ export class Bridge signerOrProvider: this._instance.signer || this._instance.provider, }) } + + // eslint-disable-next-line valid-jsdoc + /** + * @see {ChainBridge#wallets} + */ + async wallets(walletPublicKeyHash: Hex): Promise { + const wallet = await backoffRetrier( + this._totalRetryAttempts + )(async () => { + return await this._instance.wallets( + walletPublicKeyHash.toPrefixedString() + ) + }) + + return this.parseWalletDetails(wallet) + } + + /** + * Parses a wallet data using data fetched from the on-chain contract. + * @param wallet Data of the wallet. + * @returns Parsed wallet data. + */ + private async parseWalletDetails( + wallet: Wallets.WalletStructOutput + ): Promise { + const ecdsaWalletID = Hex.from(wallet.ecdsaWalletID) + + return { + ecdsaWalletID, + walletPublicKey: await this.getWalletCompressedPublicKey(ecdsaWalletID), + mainUtxoHash: Hex.from(wallet.mainUtxoHash), + pendingRedemptionsValue: wallet.pendingRedemptionsValue, + createdAt: wallet.createdAt, + movingFundsRequestedAt: wallet.movingFundsRequestedAt, + closingStartedAt: wallet.closingStartedAt, + pendingMovedFundsSweepRequestsCount: + wallet.pendingMovedFundsSweepRequestsCount, + state: WalletState.parse(wallet.state), + movingFundsTargetWalletsCommitmentHash: Hex.from( + wallet.movingFundsTargetWalletsCommitmentHash + ), + } + } + + // eslint-disable-next-line valid-jsdoc + /** + * Builds the UTXO hash based on the UTXO components. UTXO hash is computed as + * `keccak256(txHash | txOutputIndex | txOutputValue)`. + * + * @see {ChainBridge#buildUtxoHash} + */ + buildUtxoHash(utxo: UnspentTransactionOutput): Hex { + return Hex.from( + utils.solidityKeccak256( + ["bytes32", "uint32", "uint64"], + [ + utxo.transactionHash.reverse().toPrefixedString(), + utxo.outputIndex, + utxo.value, + ] + ) + ) + } } /** diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 8f5384bde..7bc6e4922 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -13,6 +13,7 @@ import { requestRedemption, submitRedemptionProof, getRedemptionRequest, + findWalletForRedemption, } from "./redemption" import { @@ -31,6 +32,7 @@ export const TBTC = { getRevealedDeposit, requestRedemption, getRedemptionRequest, + findWalletForRedemption, } export const SpvMaintainer = { diff --git a/typescript/src/redemption.ts b/typescript/src/redemption.ts index e3b65c986..f8a0665cf 100644 --- a/typescript/src/redemption.ts +++ b/typescript/src/redemption.ts @@ -10,6 +10,8 @@ import { } from "./bitcoin" import { Bridge, Identifier, TBTCToken } from "./chain" import { assembleTransactionProof } from "./proof" +import { determineWalletMainUtxo, WalletState } from "./wallet" +import { BitcoinNetwork } from "./bitcoin-network" import { Hex } from "./hex" /** @@ -408,3 +410,111 @@ export async function getRedemptionRequest( return redemptionRequests[0] } + +/** + * Finds the oldest active wallet that has enough BTC to handle a redemption request. + * @param amount The amount to be redeemed in satoshis. + * @param redeemerOutputScript The redeemer output script the redeemed funds + * are supposed to be locked on. Must be un-prefixed and not prepended + * with length. + * @param bitcoinNetwork Bitcoin network. + * @param bridge The handle to the Bridge on-chain contract. + * @param bitcoinClient Bitcoin client used to interact with the network. + * @returns Promise with the wallet details needed to request a redemption. + */ +export async function findWalletForRedemption( + amount: BigNumber, + redeemerOutputScript: string, + bitcoinNetwork: BitcoinNetwork, + bridge: Bridge, + bitcoinClient: BitcoinClient +): Promise<{ + walletPublicKey: string + mainUtxo: UnspentTransactionOutput +}> { + const wallets = await bridge.getNewWalletRegisteredEvents() + + let walletData: + | { + walletPublicKey: string + mainUtxo: UnspentTransactionOutput + } + | undefined = undefined + let maxAmount = BigNumber.from(0) + + for (const wallet of wallets) { + const { walletPublicKeyHash } = wallet + const { state, walletPublicKey, pendingRedemptionsValue } = + await bridge.wallets(walletPublicKeyHash) + + // Wallet must be in Live state. + if (state !== WalletState.Live) { + console.debug( + `Wallet is not in Live state ` + + `(wallet public key hash: ${walletPublicKeyHash.toString()}). ` + + `Continue the loop execution to the next wallet...` + ) + continue + } + + // Wallet must have a main UTXO that can be determined. + const mainUtxo = await determineWalletMainUtxo( + walletPublicKeyHash, + bridge, + bitcoinClient, + bitcoinNetwork + ) + if (!mainUtxo) { + console.debug( + `Could not find matching UTXO on chains ` + + `for wallet public key hash (${walletPublicKeyHash.toString()}). ` + + `Continue the loop execution to the next wallet...` + ) + continue + } + + const pendingRedemption = await bridge.pendingRedemptions( + walletPublicKey.toString(), + redeemerOutputScript + ) + + if (pendingRedemption.requestedAt != 0) { + console.debug( + `There is a pending redemption request from this wallet to the ` + + `same Bitcoin address. Given wallet public key hash` + + `(${walletPublicKeyHash.toString()}) and redeemer output script ` + + `(${redeemerOutputScript}) pair can be used for only one ` + + `pending request at the same time. ` + + `Continue the loop execution to the next wallet...` + ) + continue + } + + const walletBTCBalance = mainUtxo.value.sub(pendingRedemptionsValue) + + // Save the max possible redemption amount. + maxAmount = walletBTCBalance.gt(maxAmount) ? walletBTCBalance : maxAmount + + if (walletBTCBalance.gte(amount)) { + walletData = { + walletPublicKey: walletPublicKey.toString(), + mainUtxo, + } + + break + } + + console.debug( + `The wallet (${walletPublicKeyHash.toString()})` + + `cannot handle the redemption request. ` + + `Continue the loop execution to the next wallet...` + ) + } + + if (!walletData) + throw new Error( + `Could not find a wallet with enough funds. Maximum redemption amount is ${maxAmount} Satoshi.` + ) + + return walletData +} diff --git a/typescript/src/wallet.ts b/typescript/src/wallet.ts index 2a0e0298b..5c9a46ae1 100644 --- a/typescript/src/wallet.ts +++ b/typescript/src/wallet.ts @@ -1,6 +1,14 @@ import { BigNumber } from "ethers" import { Hex } from "./hex" -import { Event, Identifier } from "./chain" +import { Bridge, Event, Identifier } from "./chain" +import { + Client as BitcoinClient, + createOutputScriptFromAddress, + encodeToBitcoinAddress, + TransactionOutput, + UnspentTransactionOutput, +} from "./bitcoin" +import { BitcoinNetwork } from "./bitcoin-network" /* eslint-disable no-unused-vars */ export enum WalletState { @@ -209,3 +217,117 @@ type DkgResult = { */ membersHash: Hex } + +/** + * Determines the plain-text wallet main UTXO currently registered in the + * Bridge on-chain contract. The returned main UTXO can be undefined if the + * wallet does not have a main UTXO registered in the Bridge at the moment. + * + * WARNING: THIS FUNCTION CANNOT DETERMINE THE MAIN UTXO IF IT COMES FROM A + * BITCOIN TRANSACTION THAT IS NOT ONE OF THE LATEST FIVE TRANSACTIONS + * TARGETING THE GIVEN WALLET PUBLIC KEY HASH. HOWEVER, SUCH A CASE IS + * VERY UNLIKELY. + * + * @param walletPublicKeyHash - Public key hash of the wallet. + * @param bridge - The handle to the Bridge on-chain contract. + * @param bitcoinClient - Bitcoin client used to interact with the network. + * @param bitcoinNetwork - Bitcoin network. + * @returns Promise holding the wallet main UTXO or undefined value. + */ +export async function determineWalletMainUtxo( + walletPublicKeyHash: Hex, + bridge: Bridge, + bitcoinClient: BitcoinClient, + bitcoinNetwork: BitcoinNetwork +): Promise { + const { mainUtxoHash } = await bridge.wallets(walletPublicKeyHash) + + // Valid case when the wallet doesn't have a main UTXO registered into + // the Bridge. + if ( + mainUtxoHash.equals( + Hex.from( + "0x0000000000000000000000000000000000000000000000000000000000000000" + ) + ) + ) { + return undefined + } + + // Declare a helper function that will try to determine the main UTXO for + // the given wallet address type. + const determine = async ( + witnessAddress: boolean + ): Promise => { + // Build the wallet Bitcoin address based on its public key hash. + const walletAddress = encodeToBitcoinAddress( + walletPublicKeyHash.toString(), + witnessAddress, + bitcoinNetwork + ) + + // Get the wallet transaction history. The wallet main UTXO registered in the + // Bridge almost always comes from the latest BTC transaction made by the wallet. + // However, there may be cases where the BTC transaction was made but their + // SPV proof is not yet submitted to the Bridge thus the registered main UTXO + // points to the second last BTC transaction. In theory, such a gap between + // the actual latest BTC transaction and the registered main UTXO in the + // Bridge may be even wider. The exact behavior is a wallet implementation + // detail and not a protocol invariant so, it may be subject of changes. + // To cover the worst possible cases, we always take the five latest + // transactions made by the wallet for consideration. + const walletTransactions = await bitcoinClient.getTransactionHistory( + walletAddress, + 5 + ) + + // Get the wallet script based on the wallet address. This is required + // to find transaction outputs that lock funds on the wallet. + const walletScript = createOutputScriptFromAddress(walletAddress) + const isWalletOutput = (output: TransactionOutput) => + walletScript.equals(output.scriptPubKey) + + // Start iterating from the latest transaction as the chance it matches + // the wallet main UTXO is the highest. + for (let i = walletTransactions.length - 1; i >= 0; i--) { + const walletTransaction = walletTransactions[i] + + // Find the output that locks the funds on the wallet. Only such an output + // can be a wallet main UTXO. + const outputIndex = walletTransaction.outputs.findIndex(isWalletOutput) + + // Should never happen as all transactions come from wallet history. Just + // in case check whether the wallet output was actually found. + if (outputIndex < 0) { + console.error( + `wallet output for transaction ${walletTransaction.transactionHash.toString()} not found` + ) + continue + } + + // Build a candidate UTXO instance based on the detected output. + const utxo: UnspentTransactionOutput = { + transactionHash: walletTransaction.transactionHash, + outputIndex: outputIndex, + value: walletTransaction.outputs[outputIndex].value, + } + + // Check whether the candidate UTXO hash matches the main UTXO hash stored + // on the Bridge. + if (mainUtxoHash.equals(bridge.buildUtxoHash(utxo))) { + return utxo + } + } + + return undefined + } + + // The most common case is that the wallet uses a witness address for all + // operations. Try to determine the main UTXO for that address first as the + // chance for success is the highest here. + const mainUtxo = await determine(true) + + // In case the main UTXO was not found for witness address, there is still + // a chance it exists for the legacy wallet address. + return mainUtxo ?? (await determine(false)) +} diff --git a/typescript/test/data/redemption.ts b/typescript/test/data/redemption.ts index 8a361c746..958b9dcc2 100644 --- a/typescript/test/data/redemption.ts +++ b/typescript/test/data/redemption.ts @@ -7,10 +7,12 @@ import { UnspentTransactionOutput, TransactionMerkleBranch, TransactionHash, + createOutputScriptFromAddress, } from "../../src/bitcoin" import { RedemptionRequest } from "../../src/redemption" import { Address } from "../../src/ethereum" -import { Hex } from "../../src" +import { BitcoinTransaction, Hex } from "../../src" +import { WalletState } from "../../src/wallet" /** * Private key (testnet) of the wallet. @@ -666,3 +668,217 @@ export const redemptionProof: RedemptionProofTestData = { "03989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d9", }, } + +interface FindWalletForRedemptionWalletData { + data: { + state: WalletState + mainUtxoHash: Hex + walletPublicKey: Hex + btcAddress: string + mainUtxo: UnspentTransactionOutput + transactions: BitcoinTransaction[] + pendingRedemptionsValue: BigNumber + } + event: { + blockNumber: number + blockHash: Hex + transactionHash: Hex + ecdsaWalletID: Hex + walletPublicKeyHash: Hex + } +} + +export const findWalletForRedemptionData: { + liveWallet: FindWalletForRedemptionWalletData + walletWithoutUtxo: FindWalletForRedemptionWalletData + nonLiveWallet: FindWalletForRedemptionWalletData + walletWithPendingRedemption: FindWalletForRedemptionWalletData + pendingRedemption: RedemptionRequest +} = { + liveWallet: { + data: { + state: WalletState.Live, + mainUtxoHash: Hex.from( + "0x3ded9dcfce0ffe479640013ebeeb69b6a82306004f9525b1346ca3b553efc6aa" + ), + walletPublicKey: Hex.from( + "0x028ed84936be6a9f594a2dcc636d4bebf132713da3ce4dac5c61afbf8bbb47d6f7" + ), + btcAddress: "tb1qqwm566yn44rdlhgph8sw8vecta8uutg79afuja", + mainUtxo: { + transactionHash: Hex.from( + "0x5b6d040eb06b3de1a819890d55d251112e55c31db4a3f5eb7cfacf519fad7adb" + ), + outputIndex: 0, + value: BigNumber.from("791613461"), + }, + transactions: [ + { + transactionHash: Hex.from( + "0x5b6d040eb06b3de1a819890d55d251112e55c31db4a3f5eb7cfacf519fad7adb" + ), + inputs: [], // not relevant + outputs: [ + { + outputIndex: 0, + value: BigNumber.from("791613461"), + scriptPubKey: createOutputScriptFromAddress( + "tb1qqwm566yn44rdlhgph8sw8vecta8uutg79afuja" + ), + }, + ], + }, + ], + pendingRedemptionsValue: BigNumber.from(0), + }, + event: { + blockNumber: 8367602, + blockHash: Hex.from( + "0x908ea9c82b388a760e6dd070522e5421d88b8931fbac6702119f9e9a483dd022" + ), + transactionHash: Hex.from( + "0xc1e995d0ac451cc9ffc9d43f105eddbaf2eb45ea57a61074a84fc022ecf5bda9" + ), + ecdsaWalletID: Hex.from( + "0x5314e0e5a62b173f52ea424958e5bc04bd77e2159478934a89d4fa193c7b3b72" + ), + walletPublicKeyHash: Hex.from( + "0x03b74d6893ad46dfdd01b9e0e3b3385f4fce2d1e" + ), + }, + }, + walletWithoutUtxo: { + data: { + state: WalletState.Live, + mainUtxoHash: Hex.from( + "0x0000000000000000000000000000000000000000000000000000000000000000" + ), + walletPublicKey: Hex.from( + "0x030fbbae74e6d85342819e719575949a1349e975b69fb382e9fef671a3a74efc52" + ), + btcAddress: "tb1qkct7r24k4wutnsun84rvp3qsyt8yfpvqz89d2y", + mainUtxo: { + transactionHash: Hex.from( + "0x0000000000000000000000000000000000000000000000000000000000000000" + ), + outputIndex: 0, + value: BigNumber.from("0"), + }, + transactions: [], + pendingRedemptionsValue: BigNumber.from(0), + }, + event: { + blockNumber: 9103428, + blockHash: Hex.from( + "0x92ad328db2cb1d2aad60ac809660e05e2b6763ddd376ca21630e304c98f23600" + ), + transactionHash: Hex.from( + "0x309085ebb92e10eb9e665c7d90c94e053f13b36f9bc8017e820bc879ba629b8e" + ), + ecdsaWalletID: Hex.from( + "0xd27bfaaad9c3489e613eb3664c3b9958bd9a494377123689733267cb4a5767ba" + ), + walletPublicKeyHash: Hex.from( + "0xb617e1aab6abb8b9c3933d46c0c41022ce448580" + ), + }, + }, + + nonLiveWallet: { + data: { + state: WalletState.Unknown, + mainUtxoHash: Hex.from( + "0x0000000000000000000000000000000000000000000000000000000000000000" + ), + walletPublicKey: Hex.from( + "0x02633b102417009ae55103798f4d366dfccb081dcf20025088b9bf10a8e15d8ded" + ), + btcAddress: "tb1qf6jvyd680ncf9dtr5znha9ql5jmw84lupwwuf6", + mainUtxo: { + transactionHash: Hex.from( + "0x0000000000000000000000000000000000000000000000000000000000000000" + ), + outputIndex: 0, + value: BigNumber.from("0"), + }, + transactions: [], + pendingRedemptionsValue: BigNumber.from(0), + }, + event: { + blockNumber: 9171960, + blockHash: Hex.from( + "0xe9a404b724183cb8f77e45718b365051e8d5ccc4a72dfece30af7596eeee4748" + ), + transactionHash: Hex.from( + "0x867f2c985cbe44f92a7ca2c14268b9ae78275e1c339692e1847548725484e72d" + ), + ecdsaWalletID: Hex.from( + "0x96975ddd76bbb2e15ed4498de6d92187ec5282913b3af3891a4e2d60581a8787" + ), + walletPublicKeyHash: Hex.from( + "0x4ea4c237477cf092b563a0a77e941fa4b6e3d7fc" + ), + }, + }, + walletWithPendingRedemption: { + data: { + state: WalletState.Live, + mainUtxoHash: Hex.from( + "0xb3024ef698084cfdfba459338864a595d31081748b28aa5eb02312671a720531" + ), + walletPublicKey: Hex.from( + "0x02ab193a63b3523bfab77d3645d11da10722393687458c4213b350b7e08f50b7ee" + ), + btcAddress: "tb1qx2xejtjltdcau5dpks8ucszkhxdg3fj88404lh", + mainUtxo: { + transactionHash: Hex.from( + "0x81c4884a8c2fccbeb57745a5e59f895a9c1bb8fc42eecc82269100a1a46bbb85" + ), + outputIndex: 0, + value: BigNumber.from("3370000"), // 0.0337 BTC + }, + transactions: [ + { + transactionHash: Hex.from( + "0x81c4884a8c2fccbeb57745a5e59f895a9c1bb8fc42eecc82269100a1a46bbb85" + ), + inputs: [], // not relevant + outputs: [ + { + outputIndex: 0, + value: BigNumber.from("3370000"), // 0.0337 BTC + scriptPubKey: createOutputScriptFromAddress( + "tb1qx2xejtjltdcau5dpks8ucszkhxdg3fj88404lh" + ), + }, + ], + }, + ], + pendingRedemptionsValue: BigNumber.from(2370000), // 0.0237 BTC + }, + event: { + blockNumber: 8981644, + blockHash: Hex.from( + "0x6681b1bb168fb86755c2a796169cb0e06949caac9fc7145d527d94d5209a64ad" + ), + transactionHash: Hex.from( + "0xea3a8853c658145c95165d7847152aeedc3ff29406ec263abfc9b1436402b7b7" + ), + ecdsaWalletID: Hex.from( + "0x7a1437d67f49adfd44e03ddc85be0f6988715d7c39dfb0ca9780f1a88bcdca25" + ), + walletPublicKeyHash: Hex.from( + "0x328d992e5f5b71de51a1b40fcc4056b99a88a647" + ), + }, + }, + pendingRedemption: { + redeemer: Address.from("0xeb9af8E66869902476347a4eFe59a527a57240ED"), + // script for testnet P2PKH address mjc2zGWypwpNyDi4ZxGbBNnUA84bfgiwYc + redeemerOutputScript: "76a9142cd680318747b720d67bf4246eb7403b476adb3488ac", + requestedAmount: BigNumber.from(1000000), + treasuryFee: BigNumber.from(20000), + txMaxFee: BigNumber.from(20000), + requestedAt: 1688724606, + }, +} diff --git a/typescript/test/electrum.test.ts b/typescript/test/electrum.test.ts index c500a7efa..09fca2c2d 100644 --- a/typescript/test/electrum.test.ts +++ b/typescript/test/electrum.test.ts @@ -108,6 +108,28 @@ describe("Electrum", () => { }) }) + describe("getTransactionHistory", () => { + it("should return proper transaction history for the given address", async () => { + // https://live.blockcypher.com/btc-testnet/address/tb1qumuaw3exkxdhtut0u85latkqfz4ylgwstkdzsx + const transactions = await electrumClient.getTransactionHistory( + "tb1qumuaw3exkxdhtut0u85latkqfz4ylgwstkdzsx", + 5 + ) + + const transactionsHashes = transactions.map((t) => + t.transactionHash.toString() + ) + + expect(transactionsHashes).to.be.eql([ + "3ca4ae3f8ee3b48949192bc7a146c8d9862267816258c85e02a44678364551e1", + "f65bc5029251f0042aedb37f90dbb2bfb63a2e81694beef9cae5ec62e954c22e", + "44863a79ce2b8fec9792403d5048506e50ffa7338191db0e6c30d3d3358ea2f6", + "4c6b33b7c0550e0e536a5d119ac7189d71e1296fcb0c258e0c115356895bc0e6", + "605edd75ae0b4fa7cfc7aae8f1399119e9d7ecc212e6253156b60d60f4925d44", + ]) + }) + }) + describe("getTransaction", () => { it("should return proper transaction for the given hash", async () => { const result = await electrumClient.getTransaction( diff --git a/typescript/test/redemption.test.ts b/typescript/test/redemption.test.ts index 04072c3d0..e2efa36d1 100644 --- a/typescript/test/redemption.test.ts +++ b/typescript/test/redemption.test.ts @@ -20,9 +20,11 @@ import { p2pkhWalletAddress, p2wpkhWalletAddress, RedemptionTestData, + findWalletForRedemptionData, } from "./data/redemption" import { assembleRedemptionTransaction, + findWalletForRedemption, getRedemptionRequest, RedemptionRequest, requestRedemption, @@ -34,7 +36,10 @@ import * as chai from "chai" import chaiAsPromised from "chai-as-promised" import { expect } from "chai" import { BigNumberish, BigNumber } from "ethers" +import { BitcoinNetwork } from "../src/bitcoin-network" +import { Wallet } from "../src/wallet" import { MockTBTCToken } from "./utils/mock-tbtc-token" +import { BitcoinTransaction } from "../src" chai.use(chaiAsPromised) @@ -1434,6 +1439,245 @@ describe("Redemption", () => { }) }) }) + + describe("findWalletForRedemption", () => { + let bridge: MockBridge + let bitcoinClient: MockBitcoinClient + // script for testnet P2WSH address + // tb1qau95mxzh2249aa3y8exx76ltc2sq0e7kw8hj04936rdcmnynhswqqz02vv + const redeemerOutputScript = + "0x220020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c" + + context( + "when there are no wallets in the network that can handle redemption", + () => { + const amount: BigNumber = BigNumber.from("1000000") // 0.01 BTC + beforeEach(() => { + bitcoinClient = new MockBitcoinClient() + bridge = new MockBridge() + bridge.newWalletRegisteredEvents = [] + }) + + it("should throw an error", async () => { + await expect( + findWalletForRedemption( + amount, + redeemerOutputScript, + BitcoinNetwork.Testnet, + bridge, + bitcoinClient + ) + ).to.be.rejectedWith( + "Could not find a wallet with enough funds. Maximum redemption amount is 0 Satoshi." + ) + }) + } + ) + + context("when there are registered wallets in the network", () => { + let result: Awaited | never> + const walletsOrder = [ + findWalletForRedemptionData.nonLiveWallet, + findWalletForRedemptionData.walletWithoutUtxo, + findWalletForRedemptionData.walletWithPendingRedemption, + findWalletForRedemptionData.liveWallet, + ] + + beforeEach(async () => { + bitcoinClient = new MockBitcoinClient() + bridge = new MockBridge() + + bridge.newWalletRegisteredEvents = walletsOrder.map( + (wallet) => wallet.event + ) + + const walletsTransactionHistory = new Map< + string, + BitcoinTransaction[] + >() + + walletsOrder.forEach((wallet) => { + const { + state, + mainUtxoHash, + walletPublicKey, + btcAddress, + transactions, + pendingRedemptionsValue, + } = wallet.data + + walletsTransactionHistory.set(btcAddress, transactions) + bridge.setWallet( + wallet.event.walletPublicKeyHash.toPrefixedString(), + { + state, + mainUtxoHash, + walletPublicKey, + pendingRedemptionsValue, + } as Wallet + ) + }) + + bitcoinClient.transactionHistory = walletsTransactionHistory + }) + + context( + "when there is a wallet that can handle the redemption request", + () => { + const amount: BigNumber = BigNumber.from("1000000") // 0.01 BTC + beforeEach(async () => { + result = await findWalletForRedemption( + amount, + redeemerOutputScript, + BitcoinNetwork.Testnet, + bridge, + bitcoinClient + ) + }) + + it("should get all registered wallets", () => { + const bridgeQueryEventsLog = bridge.newWalletRegisteredEventsLog + + expect(bridgeQueryEventsLog.length).to.equal(1) + expect(bridgeQueryEventsLog[0]).to.deep.equal({ + options: undefined, + filterArgs: [], + }) + }) + + it("should return the wallet data that can handle redemption request", () => { + const expectedWalletData = + findWalletForRedemptionData.walletWithPendingRedemption.data + + expect(result).to.deep.eq({ + walletPublicKey: expectedWalletData.walletPublicKey.toString(), + mainUtxo: expectedWalletData.mainUtxo, + }) + }) + } + ) + + context( + "when the redemption request amount is too large and no wallet can handle the request", + () => { + const amount = BigNumber.from("10000000000") // 1 000 BTC + const expectedMaxAmount = walletsOrder + .map((wallet) => wallet.data) + .map((wallet) => wallet.mainUtxo) + .map((utxo) => utxo.value) + .sort((a, b) => (b.gt(a) ? 0 : -1))[0] + + it("should throw an error", async () => { + await expect( + findWalletForRedemption( + amount, + redeemerOutputScript, + BitcoinNetwork.Testnet, + bridge, + bitcoinClient + ) + ).to.be.rejectedWith( + `Could not find a wallet with enough funds. Maximum redemption amount is ${expectedMaxAmount.toString()} Satoshi.` + ) + }) + } + ) + + context( + "when there is pending redemption request from a given wallet to the same address", + () => { + beforeEach(async () => { + const redeemerOutputScript = + findWalletForRedemptionData.pendingRedemption.redeemerOutputScript + const amount: BigNumber = BigNumber.from("1000000") // 0.01 BTC + + const walletPublicKeyHash = + findWalletForRedemptionData.walletWithPendingRedemption.event + .walletPublicKeyHash + + const pendingRedemptions = new Map< + BigNumberish, + RedemptionRequest + >() + + const key = MockBridge.buildRedemptionKey( + walletPublicKeyHash.toString(), + redeemerOutputScript + ) + + pendingRedemptions.set( + key, + findWalletForRedemptionData.pendingRedemption + ) + bridge.setPendingRedemptions(pendingRedemptions) + + result = await findWalletForRedemption( + amount, + redeemerOutputScript, + BitcoinNetwork.Testnet, + bridge, + bitcoinClient + ) + }) + + it("should get all registered wallets", () => { + const bridgeQueryEventsLog = bridge.newWalletRegisteredEventsLog + + expect(bridgeQueryEventsLog.length).to.equal(1) + expect(bridgeQueryEventsLog[0]).to.deep.equal({ + options: undefined, + filterArgs: [], + }) + }) + + it("should skip the wallet for which there is a pending redemption request to the same redeemer output script and return the wallet data that can handle redemption request", () => { + const expectedWalletData = + findWalletForRedemptionData.liveWallet.data + + expect(result).to.deep.eq({ + walletPublicKey: expectedWalletData.walletPublicKey.toString(), + mainUtxo: expectedWalletData.mainUtxo, + }) + }) + } + ) + + context( + "when wallet has pending redemptions and the requested amount is greater than possible", + () => { + beforeEach(async () => { + const wallet = + findWalletForRedemptionData.walletWithPendingRedemption + const walletBTCBalance = wallet.data.mainUtxo.value + + const amount: BigNumber = walletBTCBalance + .sub(wallet.data.pendingRedemptionsValue) + .add(BigNumber.from(500000)) // 0.005 BTC + + console.log("amount", amount.toString()) + + result = await findWalletForRedemption( + amount, + redeemerOutputScript, + BitcoinNetwork.Testnet, + bridge, + bitcoinClient + ) + }) + + it("should skip the wallet wallet with pending redemptions and return the wallet data that can handle redemption request ", () => { + const expectedWalletData = + findWalletForRedemptionData.liveWallet.data + + expect(result).to.deep.eq({ + walletPublicKey: expectedWalletData.walletPublicKey.toString(), + mainUtxo: expectedWalletData.mainUtxo, + }) + }) + } + ) + }) + }) }) async function runRedemptionScenario( diff --git a/typescript/test/utils/mock-bitcoin-client.ts b/typescript/test/utils/mock-bitcoin-client.ts index 95e4305b1..b37dd7820 100644 --- a/typescript/test/utils/mock-bitcoin-client.ts +++ b/typescript/test/utils/mock-bitcoin-client.ts @@ -27,6 +27,7 @@ export class MockBitcoinClient implements Client { position: 0, } private _broadcastLog: RawTransaction[] = [] + private _transactionHistory = new Map() set unspentTransactionOutputs( value: Map @@ -58,6 +59,10 @@ export class MockBitcoinClient implements Client { this._transactionMerkle = value } + set transactionHistory(value: Map) { + this._transactionHistory = value + } + get broadcastLog(): RawTransaction[] { return this._broadcastLog } @@ -80,6 +85,25 @@ export class MockBitcoinClient implements Client { }) } + getTransactionHistory( + address: string, + limit?: number + ): Promise { + return new Promise((resolve, _) => { + let transactions = this._transactionHistory.get(address) as Transaction[] + + if ( + typeof limit !== "undefined" && + limit > 0 && + transactions.length > limit + ) { + transactions = transactions.slice(-limit) + } + + resolve(transactions) + }) + } + getTransaction(transactionHash: TransactionHash): Promise { return new Promise((resolve, _) => { resolve(this._transactions.get(transactionHash.toString()) as Transaction) diff --git a/typescript/test/utils/mock-bridge.ts b/typescript/test/utils/mock-bridge.ts index d342357c2..6a429c655 100644 --- a/typescript/test/utils/mock-bridge.ts +++ b/typescript/test/utils/mock-bridge.ts @@ -15,7 +15,7 @@ import { computeHash160, TransactionHash } from "../../src/bitcoin" import { depositSweepWithNoMainUtxoAndWitnessOutput } from "../data/deposit-sweep" import { Address } from "../../src/ethereum" import { Hex } from "../../src/hex" -import { NewWalletRegisteredEvent } from "../../src/wallet" +import { NewWalletRegisteredEvent, Wallet } from "../../src/wallet" interface DepositSweepProofLogEntry { sweepTx: DecomposedRawTransaction @@ -43,6 +43,15 @@ interface RedemptionProofLogEntry { walletPublicKey: string } +interface NewWalletRegisteredEventsLog { + options?: GetEvents.Options + filterArgs: unknown[] +} + +interface WalletLog { + walletPublicKeyHash: string +} + /** * Mock Bridge used for test purposes. */ @@ -56,6 +65,10 @@ export class MockBridge implements Bridge { private _redemptionProofLog: RedemptionProofLogEntry[] = [] private _deposits = new Map() private _activeWalletPublicKey: string | undefined + private _newWalletRegisteredEvents: NewWalletRegisteredEvent[] = [] + private _newWalletRegisteredEventsLog: NewWalletRegisteredEventsLog[] = [] + private _wallets = new Map() + private _walletsLog: WalletLog[] = [] setPendingRedemptions(value: Map) { this._pendingRedemptions = value @@ -65,6 +78,14 @@ export class MockBridge implements Bridge { this._timedOutRedemptions = value } + setWallet(key: string, value: Wallet) { + this._wallets.set(key, value) + } + + set newWalletRegisteredEvents(value: NewWalletRegisteredEvent[]) { + this._newWalletRegisteredEvents = value + } + get depositSweepProofLog(): DepositSweepProofLogEntry[] { return this._depositSweepProofLog } @@ -81,6 +102,14 @@ export class MockBridge implements Bridge { return this._redemptionProofLog } + get newWalletRegisteredEventsLog(): NewWalletRegisteredEventsLog[] { + return this._newWalletRegisteredEventsLog + } + + get walletsLog(): WalletLog[] { + return this._walletsLog + } + setDeposits(value: Map) { this._deposits = value } @@ -308,10 +337,32 @@ export class MockBridge implements Bridge { options?: GetEvents.Options, ...filterArgs: Array ): Promise { - throw new Error("not implemented") + this._newWalletRegisteredEventsLog.push({ options, filterArgs }) + return this._newWalletRegisteredEvents } walletRegistry(): Promise { throw new Error("not implemented") } + + async wallets(walletPublicKeyHash: Hex): Promise { + this._walletsLog.push({ + walletPublicKeyHash: walletPublicKeyHash.toPrefixedString(), + }) + const wallet = this._wallets.get(walletPublicKeyHash.toPrefixedString()) + return wallet! + } + + buildUtxoHash(utxo: UnspentTransactionOutput): Hex { + return Hex.from( + utils.solidityKeccak256( + ["bytes32", "uint32", "uint64"], + [ + utxo.transactionHash.reverse().toPrefixedString(), + utxo.outputIndex, + utxo.value, + ] + ) + ) + } } diff --git a/typescript/test/wallet.test.ts b/typescript/test/wallet.test.ts new file mode 100644 index 000000000..277b07e5a --- /dev/null +++ b/typescript/test/wallet.test.ts @@ -0,0 +1,287 @@ +import { MockBitcoinClient } from "./utils/mock-bitcoin-client" +import { MockBridge } from "./utils/mock-bridge" +import { BitcoinNetwork, BitcoinTransaction, Hex } from "../src" +import { determineWalletMainUtxo, Wallet } from "../src/wallet" +import { expect } from "chai" +import { encodeToBitcoinAddress } from "../src/bitcoin" +import { BigNumber } from "ethers" + +describe("Wallet", () => { + describe("determineWalletMainUtxo", () => { + // Just an arbitrary 20-byte wallet public key hash. + const walletPublicKeyHash = Hex.from( + "e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0" + ) + + // Helper function facilitating creation of mock transactions. + const mockTransaction = ( + hash: string, + outputs: Record // key: locking script, value: amount of locked satoshis + ): BitcoinTransaction => { + return { + transactionHash: Hex.from(hash), + inputs: [], // not relevant in this test scenario + outputs: Object.entries(outputs).map( + ([scriptPubKey, value], index) => ({ + outputIndex: index, + value: BigNumber.from(value), + scriptPubKey: Hex.from(scriptPubKey), + }) + ), + } + } + + // Create a fake wallet witness transaction history that consists of 6 transactions. + const walletWitnessTransactionHistory: BitcoinTransaction[] = [ + mockTransaction( + "3ca4ae3f8ee3b48949192bc7a146c8d9862267816258c85e02a44678364551e1", + { + "0014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0": 100000, // wallet witness output + "00140000000000000000000000000000000000000001": 200000, + } + ), + mockTransaction( + "4c6b33b7c0550e0e536a5d119ac7189d71e1296fcb0c258e0c115356895bc0e6", + { + "00140000000000000000000000000000000000000001": 100000, + "0014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0": 200000, // wallet witness output + } + ), + mockTransaction( + "44863a79ce2b8fec9792403d5048506e50ffa7338191db0e6c30d3d3358ea2f6", + { + "00140000000000000000000000000000000000000001": 100000, + "00140000000000000000000000000000000000000002": 200000, + "0014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0": 300000, // wallet witness output + } + ), + mockTransaction( + "f65bc5029251f0042aedb37f90dbb2bfb63a2e81694beef9cae5ec62e954c22e", + { + "0014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0": 100000, // wallet witness output + "00140000000000000000000000000000000000000001": 200000, + } + ), + mockTransaction( + "2724545276df61f43f1e92c4b9f1dd3c9109595c022dbd9dc003efbad8ded38b", + { + "0014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0": 100000, // wallet witness output + "00140000000000000000000000000000000000000001": 200000, + } + ), + mockTransaction( + "ea374ab6842723c647c3fc0ab281ca0641eaa768576cf9df695ca5b827140214", + { + "00140000000000000000000000000000000000000001": 100000, + "0014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0": 200000, // wallet witness output + } + ), + ] + + // Create a fake wallet legacy transaction history that consists of 6 transactions. + const walletLegacyTransactionHistory: BitcoinTransaction[] = [ + mockTransaction( + "230a19d8867ff3f5b409e924d9dd6413188e215f9bb52f1c47de6154dac42267", + { + "00140000000000000000000000000000000000000001": 100000, + "76a914e6f9d74726b19b75f16fe1e9feaec048aa4fa1d088ac": 200000, // wallet legacy output + } + ), + mockTransaction( + "b11bfc481b95769b8488bd661d5f61a35f7c3c757160494d63f6e04e532dfcb9", + { + "00140000000000000000000000000000000000000001": 100000, + "00140000000000000000000000000000000000000002": 200000, + "76a914e6f9d74726b19b75f16fe1e9feaec048aa4fa1d088ac": 300000, // wallet legacy output + } + ), + mockTransaction( + "7e91580d989f8541489a37431381ff9babd596111232f1bc7a1a1ba503c27dee", + { + "76a914e6f9d74726b19b75f16fe1e9feaec048aa4fa1d088ac": 100000, // wallet legacy output + "00140000000000000000000000000000000000000001": 200000, + } + ), + mockTransaction( + "5404e339ba82e6e52fcc24cb40029bed8425baa4c7f869626ef9de956186f910", + { + "76a914e6f9d74726b19b75f16fe1e9feaec048aa4fa1d088ac": 100000, // wallet legacy output + "00140000000000000000000000000000000000000001": 200000, + } + ), + mockTransaction( + "05dabb0291c0a6aa522de5ded5cb6d14ee2159e7ff109d3ef0f21de128b56b94", + { + "76a914e6f9d74726b19b75f16fe1e9feaec048aa4fa1d088ac": 100000, // wallet legacy output + "00140000000000000000000000000000000000000001": 200000, + } + ), + mockTransaction( + "00cc0cd13fc4de7a15cb41ab6d58f8b31c75b6b9b4194958c381441a67d09b08", + { + "00140000000000000000000000000000000000000001": 100000, + "76a914e6f9d74726b19b75f16fe1e9feaec048aa4fa1d088ac": 200000, // wallet legacy output + } + ), + ] + + let bridge: MockBridge + let bitcoinClient: MockBitcoinClient + let bitcoinNetwork: BitcoinNetwork + + beforeEach(async () => { + bridge = new MockBridge() + bitcoinClient = new MockBitcoinClient() + }) + + context("when wallet main UTXO is not set in the Bridge", () => { + beforeEach(async () => { + bridge.setWallet(walletPublicKeyHash.toPrefixedString(), { + mainUtxoHash: Hex.from( + "0x0000000000000000000000000000000000000000000000000000000000000000" + ), + } as Wallet) + }) + + it("should return undefined", async () => { + const mainUtxo = await determineWalletMainUtxo( + walletPublicKeyHash, + bridge, + bitcoinClient, + bitcoinNetwork + ) + + expect(mainUtxo).to.be.undefined + }) + }) + + context("when wallet main UTXO is set in the Bridge", () => { + const tests = [ + { + testName: "recent witness transaction", + // Set the main UTXO hash based on the latest transaction from walletWitnessTransactionHistory. + actualMainUtxo: { + transactionHash: Hex.from( + "ea374ab6842723c647c3fc0ab281ca0641eaa768576cf9df695ca5b827140214" + ), + outputIndex: 1, + value: BigNumber.from(200000), + }, + expectedMainUtxo: { + transactionHash: Hex.from( + "ea374ab6842723c647c3fc0ab281ca0641eaa768576cf9df695ca5b827140214" + ), + outputIndex: 1, + value: BigNumber.from(200000), + }, + }, + { + testName: "recent legacy transaction", + // Set the main UTXO hash based on the second last transaction from walletLegacyTransactionHistory. + actualMainUtxo: { + transactionHash: Hex.from( + "05dabb0291c0a6aa522de5ded5cb6d14ee2159e7ff109d3ef0f21de128b56b94" + ), + outputIndex: 0, + value: BigNumber.from(100000), + }, + expectedMainUtxo: { + transactionHash: Hex.from( + "05dabb0291c0a6aa522de5ded5cb6d14ee2159e7ff109d3ef0f21de128b56b94" + ), + outputIndex: 0, + value: BigNumber.from(100000), + }, + }, + { + testName: "old witness transaction", + // Set the main UTXO hash based on the oldest transaction from walletWitnessTransactionHistory. + actualMainUtxo: { + transactionHash: Hex.from( + "3ca4ae3f8ee3b48949192bc7a146c8d9862267816258c85e02a44678364551e1" + ), + outputIndex: 0, + value: BigNumber.from(100000), + }, + expectedMainUtxo: undefined, + }, + { + testName: "old legacy transaction", + // Set the main UTXO hash based on the oldest transaction from walletLegacyTransactionHistory. + actualMainUtxo: { + transactionHash: Hex.from( + "230a19d8867ff3f5b409e924d9dd6413188e215f9bb52f1c47de6154dac42267" + ), + outputIndex: 1, + value: BigNumber.from(200000), + }, + expectedMainUtxo: undefined, + }, + ] + + tests.forEach(({ testName, actualMainUtxo, expectedMainUtxo }) => { + context(`with main UTXO coming from ${testName}`, () => { + const networkTests = [ + { + networkTestName: "bitcoin testnet", + network: BitcoinNetwork.Testnet, + }, + { + networkTestName: "bitcoin mainnet", + network: BitcoinNetwork.Mainnet, + }, + ] + + networkTests.forEach(({ networkTestName, network }) => { + context(`with ${networkTestName} network`, () => { + beforeEach(async () => { + bitcoinNetwork = network + + const walletWitnessAddress = encodeToBitcoinAddress( + walletPublicKeyHash.toString(), + true, + bitcoinNetwork + ) + const walletLegacyAddress = encodeToBitcoinAddress( + walletPublicKeyHash.toString(), + false, + bitcoinNetwork + ) + + // Record the fake transaction history for both address types. + const transactionHistory = new Map< + string, + BitcoinTransaction[] + >() + transactionHistory.set( + walletWitnessAddress, + walletWitnessTransactionHistory + ) + transactionHistory.set( + walletLegacyAddress, + walletLegacyTransactionHistory + ) + bitcoinClient.transactionHistory = transactionHistory + + bridge.setWallet(walletPublicKeyHash.toPrefixedString(), { + mainUtxoHash: bridge.buildUtxoHash(actualMainUtxo), + } as Wallet) + }) + + it("should return the expected main UTXO", async () => { + const mainUtxo = await determineWalletMainUtxo( + walletPublicKeyHash, + bridge, + bitcoinClient, + bitcoinNetwork + ) + + expect(mainUtxo).to.be.eql(expectedMainUtxo) + }) + }) + }) + }) + }) + }) + }) +})