diff --git a/typescript/src/bitcoin.ts b/typescript/src/bitcoin.ts index bb330be3f..735c3dc94 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/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/redemption.ts b/typescript/src/redemption.ts index 05a2a6dda..f8a0665cf 100644 --- a/typescript/src/redemption.ts +++ b/typescript/src/redemption.ts @@ -7,11 +7,10 @@ import { UnspentTransactionOutput, Client as BitcoinClient, TransactionHash, - encodeToBitcoinAddress, } from "./bitcoin" import { Bridge, Identifier, TBTCToken } from "./chain" import { assembleTransactionProof } from "./proof" -import { WalletState } from "./wallet" +import { determineWalletMainUtxo, WalletState } from "./wallet" import { BitcoinNetwork } from "./bitcoin-network" import { Hex } from "./hex" @@ -445,7 +444,7 @@ export async function findWalletForRedemption( for (const wallet of wallets) { const { walletPublicKeyHash } = wallet - const { state, mainUtxoHash, walletPublicKey, pendingRedemptionsValue } = + const { state, walletPublicKey, pendingRedemptionsValue } = await bridge.wallets(walletPublicKeyHash) // Wallet must be in Live state. @@ -458,18 +457,20 @@ export async function findWalletForRedemption( continue } - if ( - mainUtxoHash.equals( - Hex.from( - "0x0000000000000000000000000000000000000000000000000000000000000000" - ) - ) - ) { + // Wallet must have a main UTXO that can be determined. + const mainUtxo = await determineWalletMainUtxo( + walletPublicKeyHash, + bridge, + bitcoinClient, + bitcoinNetwork + ) + if (!mainUtxo) { console.debug( - `Main utxo not set for wallet public ` + - `key hash(${walletPublicKeyHash.toString()}). ` + + `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( @@ -489,38 +490,6 @@ export async function findWalletForRedemption( continue } - const walletBitcoinAddress = encodeToBitcoinAddress( - wallet.walletPublicKeyHash.toString(), - true, - bitcoinNetwork - ) - - // TODO: In case a wallet is working on something (e.g. redemption) and a - // Bitcoin transaction was already submitted by the wallet to the bitcoin - // chain (new utxo returned from bitcoin client), but proof hasn't been - // submitted yet to the Bridge (old main utxo returned from the Bridge) the - // `findWalletForRedemption` function will not find such a wallet. To cover - // this case, we should take, for example, the last 5 transactions made by - // the wallet into account. We will address this issue in a follow-up work. - const utxos = await bitcoinClient.findAllUnspentTransactionOutputs( - walletBitcoinAddress - ) - - // We need to find correct utxo- utxo components must point to the recent - // main UTXO of the given wallet, as currently known on the chain. - const mainUtxo = utxos.find((utxo) => - mainUtxoHash.equals(bridge.buildUtxoHash(utxo)) - ) - - 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 walletBTCBalance = mainUtxo.value.sub(pendingRedemptionsValue) // Save the max possible redemption amount. 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 285169c11..958b9dcc2 100644 --- a/typescript/test/data/redemption.ts +++ b/typescript/test/data/redemption.ts @@ -7,10 +7,11 @@ 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" /** @@ -668,13 +669,14 @@ export const redemptionProof: RedemptionProofTestData = { }, } -interface FindWalletForRedemptionWaleltData { +interface FindWalletForRedemptionWalletData { data: { state: WalletState mainUtxoHash: Hex walletPublicKey: Hex btcAddress: string - utxos: UnspentTransactionOutput[] + mainUtxo: UnspentTransactionOutput + transactions: BitcoinTransaction[] pendingRedemptionsValue: BigNumber } event: { @@ -687,10 +689,10 @@ interface FindWalletForRedemptionWaleltData { } export const findWalletForRedemptionData: { - liveWallet: FindWalletForRedemptionWaleltData - walletWithoutUtxo: FindWalletForRedemptionWaleltData - nonLiveWallet: FindWalletForRedemptionWaleltData - walletWithPendingRedemption: FindWalletForRedemptionWaleltData + liveWallet: FindWalletForRedemptionWalletData + walletWithoutUtxo: FindWalletForRedemptionWalletData + nonLiveWallet: FindWalletForRedemptionWalletData + walletWithPendingRedemption: FindWalletForRedemptionWalletData pendingRedemption: RedemptionRequest } = { liveWallet: { @@ -703,13 +705,28 @@ export const findWalletForRedemptionData: { "0x028ed84936be6a9f594a2dcc636d4bebf132713da3ce4dac5c61afbf8bbb47d6f7" ), btcAddress: "tb1qqwm566yn44rdlhgph8sw8vecta8uutg79afuja", - utxos: [ + mainUtxo: { + transactionHash: Hex.from( + "0x5b6d040eb06b3de1a819890d55d251112e55c31db4a3f5eb7cfacf519fad7adb" + ), + outputIndex: 0, + value: BigNumber.from("791613461"), + }, + transactions: [ { transactionHash: Hex.from( "0x5b6d040eb06b3de1a819890d55d251112e55c31db4a3f5eb7cfacf519fad7adb" ), - outputIndex: 0, - value: BigNumber.from("791613461"), + inputs: [], // not relevant + outputs: [ + { + outputIndex: 0, + value: BigNumber.from("791613461"), + scriptPubKey: createOutputScriptFromAddress( + "tb1qqwm566yn44rdlhgph8sw8vecta8uutg79afuja" + ), + }, + ], }, ], pendingRedemptionsValue: BigNumber.from(0), @@ -740,15 +757,14 @@ export const findWalletForRedemptionData: { "0x030fbbae74e6d85342819e719575949a1349e975b69fb382e9fef671a3a74efc52" ), btcAddress: "tb1qkct7r24k4wutnsun84rvp3qsyt8yfpvqz89d2y", - utxos: [ - { - transactionHash: Hex.from( - "0x0000000000000000000000000000000000000000000000000000000000000000" - ), - outputIndex: 0, - value: BigNumber.from("0"), - }, - ], + mainUtxo: { + transactionHash: Hex.from( + "0x0000000000000000000000000000000000000000000000000000000000000000" + ), + outputIndex: 0, + value: BigNumber.from("0"), + }, + transactions: [], pendingRedemptionsValue: BigNumber.from(0), }, event: { @@ -778,15 +794,14 @@ export const findWalletForRedemptionData: { "0x02633b102417009ae55103798f4d366dfccb081dcf20025088b9bf10a8e15d8ded" ), btcAddress: "tb1qf6jvyd680ncf9dtr5znha9ql5jmw84lupwwuf6", - utxos: [ - { - transactionHash: Hex.from( - "0x0000000000000000000000000000000000000000000000000000000000000000" - ), - outputIndex: 0, - value: BigNumber.from("0"), - }, - ], + mainUtxo: { + transactionHash: Hex.from( + "0x0000000000000000000000000000000000000000000000000000000000000000" + ), + outputIndex: 0, + value: BigNumber.from("0"), + }, + transactions: [], pendingRedemptionsValue: BigNumber.from(0), }, event: { @@ -815,13 +830,28 @@ export const findWalletForRedemptionData: { "0x02ab193a63b3523bfab77d3645d11da10722393687458c4213b350b7e08f50b7ee" ), btcAddress: "tb1qx2xejtjltdcau5dpks8ucszkhxdg3fj88404lh", - utxos: [ + mainUtxo: { + transactionHash: Hex.from( + "0x81c4884a8c2fccbeb57745a5e59f895a9c1bb8fc42eecc82269100a1a46bbb85" + ), + outputIndex: 0, + value: BigNumber.from("3370000"), // 0.0337 BTC + }, + transactions: [ { transactionHash: Hex.from( "0x81c4884a8c2fccbeb57745a5e59f895a9c1bb8fc42eecc82269100a1a46bbb85" ), - outputIndex: 0, - value: BigNumber.from("3370000"), // 0.0337 BTC + inputs: [], // not relevant + outputs: [ + { + outputIndex: 0, + value: BigNumber.from("3370000"), // 0.0337 BTC + scriptPubKey: createOutputScriptFromAddress( + "tb1qx2xejtjltdcau5dpks8ucszkhxdg3fj88404lh" + ), + }, + ], }, ], pendingRedemptionsValue: BigNumber.from(2370000), // 0.0237 BTC 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 15b321075..e2efa36d1 100644 --- a/typescript/test/redemption.test.ts +++ b/typescript/test/redemption.test.ts @@ -39,6 +39,7 @@ 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) @@ -1490,9 +1491,9 @@ describe("Redemption", () => { (wallet) => wallet.event ) - const walletsUnspentTransacionOutputs = new Map< + const walletsTransactionHistory = new Map< string, - UnspentTransactionOutput[] + BitcoinTransaction[] >() walletsOrder.forEach((wallet) => { @@ -1501,11 +1502,11 @@ describe("Redemption", () => { mainUtxoHash, walletPublicKey, btcAddress, - utxos, + transactions, pendingRedemptionsValue, } = wallet.data - walletsUnspentTransacionOutputs.set(btcAddress, utxos) + walletsTransactionHistory.set(btcAddress, transactions) bridge.setWallet( wallet.event.walletPublicKeyHash.toPrefixedString(), { @@ -1517,8 +1518,7 @@ describe("Redemption", () => { ) }) - bitcoinClient.unspentTransactionOutputs = - walletsUnspentTransacionOutputs + bitcoinClient.transactionHistory = walletsTransactionHistory }) context( @@ -1545,29 +1545,13 @@ describe("Redemption", () => { }) }) - it("should get wallet data details", () => { - const bridgeWalletDetailsLogs = bridge.walletsLog - - const wallets = Array.from(walletsOrder) - // Remove last live wallet. - wallets.pop() - - expect(bridgeWalletDetailsLogs.length).to.eql(wallets.length) - - wallets.forEach((wallet, index) => { - expect(bridgeWalletDetailsLogs[index].walletPublicKeyHash).to.eql( - wallet.event.walletPublicKeyHash.toPrefixedString() - ) - }) - }) - 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.utxos[0], + mainUtxo: expectedWalletData.mainUtxo, }) }) } @@ -1579,8 +1563,7 @@ describe("Redemption", () => { const amount = BigNumber.from("10000000000") // 1 000 BTC const expectedMaxAmount = walletsOrder .map((wallet) => wallet.data) - .map((wallet) => wallet.utxos) - .flat() + .map((wallet) => wallet.mainUtxo) .map((utxo) => utxo.value) .sort((a, b) => (b.gt(a) ? 0 : -1))[0] @@ -1647,24 +1630,13 @@ describe("Redemption", () => { }) }) - it("should get wallet data details", () => { - const bridgeWalletDetailsLogs = bridge.walletsLog - - expect(bridgeWalletDetailsLogs.length).to.eql(walletsOrder.length) - walletsOrder.forEach((wallet, index) => { - expect(bridgeWalletDetailsLogs[index].walletPublicKeyHash).to.eql( - wallet.event.walletPublicKeyHash.toPrefixedString() - ) - }) - }) - 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.utxos[0], + mainUtxo: expectedWalletData.mainUtxo, }) }) } @@ -1676,7 +1648,7 @@ describe("Redemption", () => { beforeEach(async () => { const wallet = findWalletForRedemptionData.walletWithPendingRedemption - const walletBTCBalance = wallet.data.utxos[0].value + const walletBTCBalance = wallet.data.mainUtxo.value const amount: BigNumber = walletBTCBalance .sub(wallet.data.pendingRedemptionsValue) @@ -1699,7 +1671,7 @@ describe("Redemption", () => { expect(result).to.deep.eq({ walletPublicKey: expectedWalletData.walletPublicKey.toString(), - mainUtxo: expectedWalletData.utxos[0], + mainUtxo: expectedWalletData.mainUtxo, }) }) } 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/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) + }) + }) + }) + }) + }) + }) + }) +})