From b62ab4c1410eca2098363487e58e4197ebf11363 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Fri, 9 Jun 2023 08:00:08 +0200 Subject: [PATCH 01/17] Update the `TBTCToken` interface Add the `approveAndCall` function that calls `receiveApproval` function on spender previously approving the spender to withdraw from the caller multiple times, up to the `amount` amount. If this function is called again, it overwrites the current allowance with `amount`. We need this function because we want to call redemption request in one tx instead of two(approve + requestRedemption). --- typescript/src/chain.ts | 16 ++++++++++++++++ typescript/src/ethereum.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/typescript/src/chain.ts b/typescript/src/chain.ts index dae8606d2..61c8d71bb 100644 --- a/typescript/src/chain.ts +++ b/typescript/src/chain.ts @@ -396,4 +396,20 @@ export interface TBTCToken { // TODO: Consider adding a custom type to handle conversion from ERC with 1e18 // precision to Bitcoin in 1e8 precision (satoshi). totalSupply(blockNumber?: number): Promise + + /** + * Calls `receiveApproval` function on spender previously approving the spender + * to withdraw from the caller multiple times, up to the `amount` amount. If + * this function is called again, it overwrites the current allowance with + * `amount`. + * @param spender Address of contract authorized to spend. + * @param amount The max amount they can spend. + * @param extraData Extra information to send to the approved contract. + * @returns Transaction hash of the approve and call transaction. + */ + approveAndCall( + spender: Identifier, + amount: BigNumber, + extraData: Hex + ): Promise } diff --git a/typescript/src/ethereum.ts b/typescript/src/ethereum.ts index 69cf0c86e..32deceee3 100644 --- a/typescript/src/ethereum.ts +++ b/typescript/src/ethereum.ts @@ -1099,4 +1099,20 @@ export class TBTCToken blockTag: blockNumber ?? "latest", }) } + + async approveAndCall( + spender: ChainIdentifier, + amount: BigNumber, + extraData: Hex + ): Promise { + const tx = await sendWithRetry(async () => { + return await this._instance.approveAndCall( + spender.identifierHex, + amount, + extraData.toPrefixedString() + ) + }, this._totalRetryAttempts) + + return Hex.from(tx.hash) + } } From bc7bf177f7db0102112eff8c6dbf5d3fe5c61296 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Fri, 9 Jun 2023 08:35:25 +0200 Subject: [PATCH 02/17] Update the `Bridge` interface Add the `buildRedemptionData` fn that builds the redemption data required to request a redemption via `TBTCToken.approveAndCall` function. The built data should be passed as `extraData` to this function. --- typescript/src/chain.ts | 20 ++++++++++ typescript/src/ethereum.ts | 77 ++++++++++++++++++++++++++++++++++---- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/typescript/src/chain.ts b/typescript/src/chain.ts index 61c8d71bb..4d76fbbd3 100644 --- a/typescript/src/chain.ts +++ b/typescript/src/chain.ts @@ -244,6 +244,26 @@ export interface Bridge { * Returns the attached WalletRegistry instance. */ walletRegistry(): Promise + + /** + * Builds the redemption data required to request a redemption via + * @see TBTCToken#approveAndCall - the built data should be passed as + * `extraData` the @see TBTCToken#approveAndCall function. + * @param redeemer On-chain identifier of the redeemer. + * @param walletPublicKey The Bitcoin public key of the wallet. Must be in the + * compressed form (33 bytes long with 02 or 03 prefix). + * @param mainUtxo The main UTXO of the wallet. Must match the main UTXO + * held by the on-chain Bridge contract. + * @param redeemerOutputScript The output script that the redeemed funds will + * be locked to. Must be un-prefixed and not prepended with length. + * @returns The + */ + buildRedemptionData( + redeemer: Identifier, + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string + ): Hex } /** diff --git a/typescript/src/ethereum.ts b/typescript/src/ethereum.ts index 32deceee3..2c3c96260 100644 --- a/typescript/src/ethereum.ts +++ b/typescript/src/ethereum.ts @@ -488,6 +488,39 @@ export class Bridge redeemerOutputScript: string, amount: BigNumber ): Promise { + const redemptionData = this.parseRequestRedemptionTransactionData( + walletPublicKey, + mainUtxo, + redeemerOutputScript + ) + + await sendWithRetry(async () => { + return await this._instance.requestRedemption( + redemptionData.walletPublicKeyHash, + redemptionData.mainUtxo, + redemptionData.prefixedRawRedeemerOutputScript, + amount + ) + }, this._totalRetryAttempts) + } + + /** + * Parses the request redemption data to the proper form. + * @param walletPublicKey - The Bitcoin public key of the wallet. Must be in + * the compressed form (33 bytes long with 02 or 03 prefix). + * @param mainUtxo - The main UTXO of the wallet. Must match the main UTXO + * held by the on-chain contract. + * @param redeemerOutputScript - The output script that the redeemed funds + * will be locked to. Must be un-prefixed and not prepended with + * length. + * @returns Parsed data that can be passed to the contract to request + * redemption. + */ + private parseRequestRedemptionTransactionData = ( + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string + ) => { const walletPublicKeyHash = `0x${computeHash160(walletPublicKey)}` const mainUtxoParam = { @@ -506,14 +539,11 @@ export class Bridge rawRedeemerOutputScript, ]).toString("hex")}` - await sendWithRetry(async () => { - return await this._instance.requestRedemption( - walletPublicKeyHash, - mainUtxoParam, - prefixedRawRedeemerOutputScript, - amount - ) - }, this._totalRetryAttempts) + return { + walletPublicKeyHash, + mainUtxo: mainUtxoParam, + prefixedRawRedeemerOutputScript, + } } // eslint-disable-next-line valid-jsdoc @@ -694,6 +724,37 @@ export class Bridge signerOrProvider: this._instance.signer || this._instance.provider, }) } + + // eslint-disable-next-line valid-jsdoc + /** + * @see {ChainBridge#buildRedemptionData} + */ + buildRedemptionData( + redeemer: ChainIdentifier, + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string + ): Hex { + const redemptionData = this.parseRequestRedemptionTransactionData( + walletPublicKey, + mainUtxo, + redeemerOutputScript + ) + + return Hex.from( + utils.defaultAbiCoder.encode( + ["address", "bytes20", "bytes32", "uint32", "uint64", "bytes"], + [ + redeemer.identifierHex, + redemptionData.walletPublicKeyHash, + redemptionData.mainUtxo.txHash, + redemptionData.mainUtxo.txOutputIndex, + redemptionData.mainUtxo.txOutputValue, + redemptionData.prefixedRawRedeemerOutputScript, + ] + ) + ) + } } /** From 9605398dc922445b9a122021126aa1d85d896150 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Fri, 9 Jun 2023 08:37:24 +0200 Subject: [PATCH 03/17] Refactor `requestRedemption` fn Request a redemption via `TBTCToken` interface using the `approveAndCall` function- thanks to this we can request redemption in one transaction. --- typescript/src/redemption.ts | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/typescript/src/redemption.ts b/typescript/src/redemption.ts index ec96659d5..fd1f0325f 100644 --- a/typescript/src/redemption.ts +++ b/typescript/src/redemption.ts @@ -8,8 +8,9 @@ import { Client as BitcoinClient, TransactionHash, } from "./bitcoin" -import { Bridge, Identifier } from "./chain" +import { Bridge, Identifier, TBTCToken } from "./chain" import { assembleTransactionProof } from "./proof" +import { Hex } from "./hex" /** * Represents a redemption request. @@ -55,29 +56,39 @@ export interface RedemptionRequest { /** * Requests a redemption from the on-chain Bridge contract. + * @param redeemer - On-chain identifier of the redeemer. * @param walletPublicKey - The Bitcoin public key of the wallet. Must be in the * compressed form (33 bytes long with 02 or 03 prefix). - * @param mainUtxo - The main UTXO of the wallet. Must match the main UTXO - * held by the on-chain Bridge contract. + * @param mainUtxo - The main UTXO of the wallet. Must match the main UTXO held + * by the on-chain Bridge contract. * @param redeemerOutputScript - The output script that the redeemed funds will * be locked to. Must be un-prefixed and not prepended with length. - * @param amount - The amount to be redeemed in satoshis. + * @param amount - The amount to be redeemed with the precision of the tBTC + * on-chain token contract. + * @param vault - The vault address. * @param bridge - Handle to the Bridge on-chain contract. - * @returns Empty promise. + * @param tBTCToken - Handle to the TBTCToken on-chain contract. + * @returns Transaction hash of the request redemption transaction. */ export async function requestRedemption( + redeemer: Identifier, walletPublicKey: string, mainUtxo: UnspentTransactionOutput, redeemerOutputScript: string, amount: BigNumber, - bridge: Bridge -): Promise { - await bridge.requestRedemption( + vault: Identifier, + bridge: Bridge, + tBTCToken: TBTCToken +): Promise { + const redemptionData = bridge.buildRedemptionData( + redeemer, walletPublicKey, mainUtxo, - redeemerOutputScript, - amount + // TODO: We should pass `redeemerOutputScript` in that form. + bcoin.Script.fromAddress(redeemerOutputScript).toRaw().toString("hex") ) + + return await tBTCToken.approveAndCall(vault, amount, redemptionData) } /** From bc7ef6f7246ade93f03daf2aeaa7e8186e26bd1b Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Wed, 14 Jun 2023 09:41:30 +0200 Subject: [PATCH 04/17] Update `requestRedemption` fn For testing purposes, we were passing the BTC address as `redeemerOutputScript` and then created the output script based on this address. Here we remove this code snipped because the output script that the redeemed funds will be locked to is expected to be un-prefixed and not prepended with length and we should pass the output script in a proper form to the `requestRedemption` function. --- typescript/src/redemption.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/typescript/src/redemption.ts b/typescript/src/redemption.ts index fd1f0325f..6742e70fb 100644 --- a/typescript/src/redemption.ts +++ b/typescript/src/redemption.ts @@ -84,8 +84,7 @@ export async function requestRedemption( redeemer, walletPublicKey, mainUtxo, - // TODO: We should pass `redeemerOutputScript` in that form. - bcoin.Script.fromAddress(redeemerOutputScript).toRaw().toString("hex") + redeemerOutputScript ) return await tBTCToken.approveAndCall(vault, amount, redemptionData) From 0dbddae5bb48f2654f79dcc088b04c65e464e52d Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Wed, 14 Jun 2023 09:49:12 +0200 Subject: [PATCH 05/17] Add bitcoin helper function That creates the output script from the BTC address. --- typescript/src/bitcoin.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/typescript/src/bitcoin.ts b/typescript/src/bitcoin.ts index 98b8e0e27..f4bcdbdb5 100644 --- a/typescript/src/bitcoin.ts +++ b/typescript/src/bitcoin.ts @@ -1,4 +1,4 @@ -import bcoin, { TX } from "bcoin" +import bcoin, { TX, Script } from "bcoin" import wif from "wif" import bufio from "bufio" import hash160 from "bcrypto/lib/hash160" @@ -603,3 +603,12 @@ export function locktimeToNumber(locktimeLE: Buffer | string): number { const locktimeBE: Buffer = Hex.from(locktimeLE).reverse().toBuffer() return BigNumber.from(locktimeBE).toNumber() } + +/** + * Creates the output script from the BTC address. + * @param address BTC address. + * @returns The un-prefixed and not prepended with length otput script. + */ +export function createOutputScriptFromAddress(address: string): string { + return Script.fromAddress(address).toRaw().toString("hex") +} From c5cb81907ec97e208b4795a0dba2d077a252b552 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Wed, 14 Jun 2023 14:42:29 +0200 Subject: [PATCH 06/17] Fix failing tests Adjust the `requestRedemption` tests to the new implementation. --- typescript/test/redemption.test.ts | 20 ++++++++-- typescript/test/utils/mock-bridge.ts | 48 ++++++++++++++++++++++++ typescript/test/utils/mock-tbtc-token.ts | 33 ++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 typescript/test/utils/mock-tbtc-token.ts diff --git a/typescript/test/redemption.test.ts b/typescript/test/redemption.test.ts index a32a09ae3..c159016c5 100644 --- a/typescript/test/redemption.test.ts +++ b/typescript/test/redemption.test.ts @@ -34,6 +34,8 @@ import * as chai from "chai" import chaiAsPromised from "chai-as-promised" import { expect } from "chai" import { BigNumberish, BigNumber } from "ethers" +import { Address } from "../src/ethereum" +import { MockTBTCToken } from "./utils/mock-tbtc-token" chai.use(chaiAsPromised) @@ -45,28 +47,40 @@ describe("Redemption", () => { data.pendingRedemptions[0].pendingRedemption.redeemerOutputScript const amount = data.pendingRedemptions[0].pendingRedemption.requestedAmount const bridge: MockBridge = new MockBridge() + const vault = Address.from("0xb622eA9D678ddF15135a20d59Ff26D28eC246bfB") + const token: MockTBTCToken = new MockTBTCToken() + const redeemer = Address.from("0x117284D8C50f334a1E2b7712649cB23C7a04Ae74") beforeEach(async () => { bcoin.set("testnet") await requestRedemption( + redeemer, walletPublicKey, mainUtxo, redeemerOutputScript, amount, - bridge + vault, + bridge, + token ) }) it("should submit redemption proof with correct arguments", () => { - const bridgeLog = bridge.requestRedemptionLog + const bridgeLog = bridge.buildRedemptionDataLog + const tokenLog = token.approveAndCallLog + expect(bridgeLog.length).to.equal(1) expect(bridgeLog[0].walletPublicKey).to.equal( redemptionProof.expectedRedemptionProof.walletPublicKey ) expect(bridgeLog[0].mainUtxo).to.equal(mainUtxo) expect(bridgeLog[0].redeemerOutputScript).to.equal(redeemerOutputScript) - expect(bridgeLog[0].amount).to.equal(amount) + expect(bridgeLog[0].redeemer).to.equal(redeemer) + + expect(tokenLog.length).to.equal(1) + expect(tokenLog[0].spender).to.equal(vault) + expect(tokenLog[0].amount).to.equal(amount) }) }) diff --git a/typescript/test/utils/mock-bridge.ts b/typescript/test/utils/mock-bridge.ts index d342357c2..2466c145f 100644 --- a/typescript/test/utils/mock-bridge.ts +++ b/typescript/test/utils/mock-bridge.ts @@ -43,6 +43,13 @@ interface RedemptionProofLogEntry { walletPublicKey: string } +interface BuildRedemptionDataLogEntry { + redeemer: Identifier + walletPublicKey: string + mainUtxo: UnspentTransactionOutput + redeemerOutputScript: string +} + /** * Mock Bridge used for test purposes. */ @@ -56,6 +63,7 @@ export class MockBridge implements Bridge { private _redemptionProofLog: RedemptionProofLogEntry[] = [] private _deposits = new Map() private _activeWalletPublicKey: string | undefined + private _buildRedemptionDataLog: BuildRedemptionDataLogEntry[] = [] setPendingRedemptions(value: Map) { this._pendingRedemptions = value @@ -81,6 +89,10 @@ export class MockBridge implements Bridge { return this._redemptionProofLog } + get buildRedemptionDataLog(): BuildRedemptionDataLogEntry[] { + return this._buildRedemptionDataLog + } + setDeposits(value: Map) { this._deposits = value } @@ -314,4 +326,40 @@ export class MockBridge implements Bridge { walletRegistry(): Promise { throw new Error("not implemented") } + + buildRedemptionData( + redeemer: Identifier, + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string + ): Hex { + this._buildRedemptionDataLog.push({ + redeemer, + walletPublicKey, + mainUtxo, + redeemerOutputScript, + }) + + // Convert the output script to raw bytes buffer. + const rawRedeemerOutputScript = Buffer.from(redeemerOutputScript, "hex") + // Prefix the output script bytes buffer with 0x and its own length. + const prefixedRawRedeemerOutputScript = `0x${Buffer.concat([ + Buffer.from([rawRedeemerOutputScript.length]), + rawRedeemerOutputScript, + ]).toString("hex")}` + + return Hex.from( + utils.defaultAbiCoder.encode( + ["address", "bytes20", "bytes32", "uint32", "uint64", "bytes"], + [ + redeemer.identifierHex, + `0x${computeHash160(walletPublicKey)}`, + mainUtxo.transactionHash.reverse().toPrefixedString(), + mainUtxo.outputIndex, + mainUtxo.value, + prefixedRawRedeemerOutputScript, + ] + ) + ) + } } diff --git a/typescript/test/utils/mock-tbtc-token.ts b/typescript/test/utils/mock-tbtc-token.ts new file mode 100644 index 000000000..5c101ff28 --- /dev/null +++ b/typescript/test/utils/mock-tbtc-token.ts @@ -0,0 +1,33 @@ +import { Identifier, TBTCToken } from "../../src/chain" +import { Hex } from "../../src/hex" +import { BigNumber } from "ethers" + +interface ApproveAndCallLog { + spender: Identifier + amount: BigNumber + extraData: Hex +} + +export class MockTBTCToken implements TBTCToken { + private _approveAndCallLog: ApproveAndCallLog[] = [] + + get approveAndCallLog() { + return this._approveAndCallLog + } + + totalSupply(blockNumber?: number | undefined): Promise { + throw new Error("Method not implemented.") + } + async approveAndCall( + spender: Identifier, + amount: BigNumber, + extraData: Hex + ): Promise { + this._approveAndCallLog.push({ spender, amount, extraData }) + + // Random tx hash + return Hex.from( + "0xf7d0c92c8de4d117d915c2a8a54ee550047f926bc00b91b651c40628751cfe29" + ) + } +} From 3a9d84230386a3ac0d3d980b6c8217583974a27b Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Tue, 4 Jul 2023 17:15:19 +0200 Subject: [PATCH 07/17] Fix typo in docs `otput` -> `output`. --- typescript/src/bitcoin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/src/bitcoin.ts b/typescript/src/bitcoin.ts index f4bcdbdb5..df0bc83bb 100644 --- a/typescript/src/bitcoin.ts +++ b/typescript/src/bitcoin.ts @@ -607,7 +607,7 @@ export function locktimeToNumber(locktimeLE: Buffer | string): number { /** * Creates the output script from the BTC address. * @param address BTC address. - * @returns The un-prefixed and not prepended with length otput script. + * @returns The un-prefixed and not prepended with length output script. */ export function createOutputScriptFromAddress(address: string): string { return Script.fromAddress(address).toRaw().toString("hex") From 07693eb9e59fd5f78bb462cc11a925a4fae2d4b7 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Tue, 4 Jul 2023 17:36:47 +0200 Subject: [PATCH 08/17] Add unit test for `createOutputScriptFromAddress` Cover the `createOutputScriptFromAddress` function with tests to ensure that the output script is created correctly for all supported types of addresses by bridge. --- typescript/test/bitcoin.test.ts | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/typescript/test/bitcoin.test.ts b/typescript/test/bitcoin.test.ts index 7321c99c7..2313061df 100644 --- a/typescript/test/bitcoin.test.ts +++ b/typescript/test/bitcoin.test.ts @@ -11,6 +11,7 @@ import { hashLEToBigNumber, bitsToTarget, targetToDifficulty, + createOutputScriptFromAddress, } from "../src/bitcoin" import { calculateDepositRefundLocktime } from "../src/deposit" import { BitcoinNetwork } from "../src/bitcoin-network" @@ -464,4 +465,55 @@ describe("Bitcoin", () => { expect(targetToDifficulty(target)).to.equal(expectedDifficulty) }) }) + + describe("createOutputScriptFromAddress", () => { + const btcAddresses = { + P2PKH: { + address: "mjc2zGWypwpNyDi4ZxGbBNnUA84bfgiwYc", + redeemerOutputScript: + "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", + }, + P2WPKH: { + address: "tb1qumuaw3exkxdhtut0u85latkqfz4ylgwstkdzsx", + redeemerOutputScript: + "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + }, + P2SH: { + address: "2MsM67NLa71fHvTUBqNENW15P68nHB2vVXb", + redeemerOutputScript: + "0x17a914011beb6fb8499e075a57027fb0a58384f2d3f78487", + }, + P2WSH: { + address: + "tb1qau95mxzh2249aa3y8exx76ltc2sq0e7kw8hj04936rdcmnynhswqqz02vv", + redeemerOutputScript: + "0x220020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c", + }, + } + + Object.entries(btcAddresses).forEach( + ([ + addressType, + { address, redeemerOutputScript: expectedRedeemerOutputScript }, + ]) => { + it(`should create correct output script for ${addressType} address type`, () => { + const result = createOutputScriptFromAddress(address) + + // Check if we can build the prefixed raw redeemer output script based + // on the result. + // Convert the output script to raw bytes buffer. + const rawRedeemerOutputScript = Buffer.from(result, "hex") + // Prefix the output script bytes buffer with 0x and its own length. + const prefixedRawRedeemerOutputScript = `0x${Buffer.concat([ + Buffer.from([rawRedeemerOutputScript.length]), + rawRedeemerOutputScript, + ]).toString("hex")}` + + expect(prefixedRawRedeemerOutputScript).to.eq( + expectedRedeemerOutputScript + ) + }) + } + ) + }) }) From 759cc78fb5f1bd3189a9285bf483723384c3290e Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Tue, 4 Jul 2023 17:46:25 +0200 Subject: [PATCH 09/17] Update the `createOutputScriptFromAddress` fn Return `Hex` instead of `string`- we want to operate on `Hex`. --- typescript/src/bitcoin.ts | 4 ++-- typescript/test/bitcoin.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/typescript/src/bitcoin.ts b/typescript/src/bitcoin.ts index df0bc83bb..bb330be3f 100644 --- a/typescript/src/bitcoin.ts +++ b/typescript/src/bitcoin.ts @@ -609,6 +609,6 @@ export function locktimeToNumber(locktimeLE: Buffer | string): number { * @param address BTC address. * @returns The un-prefixed and not prepended with length output script. */ -export function createOutputScriptFromAddress(address: string): string { - return Script.fromAddress(address).toRaw().toString("hex") +export function createOutputScriptFromAddress(address: string): Hex { + return Hex.from(Script.fromAddress(address).toRaw().toString("hex")) } diff --git a/typescript/test/bitcoin.test.ts b/typescript/test/bitcoin.test.ts index 2313061df..93b849ae8 100644 --- a/typescript/test/bitcoin.test.ts +++ b/typescript/test/bitcoin.test.ts @@ -502,7 +502,7 @@ describe("Bitcoin", () => { // Check if we can build the prefixed raw redeemer output script based // on the result. // Convert the output script to raw bytes buffer. - const rawRedeemerOutputScript = Buffer.from(result, "hex") + const rawRedeemerOutputScript = Buffer.from(result.toString(), "hex") // Prefix the output script bytes buffer with 0x and its own length. const prefixedRawRedeemerOutputScript = `0x${Buffer.concat([ Buffer.from([rawRedeemerOutputScript.length]), From 9249ee5ca9c71cf6754c68c5d3d891e19d77d567 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Tue, 4 Jul 2023 18:32:19 +0200 Subject: [PATCH 10/17] Refactor the `requestRedemption` fn We do not want to expose the low-level function from chain handles(see `buildRedemptionData` from `EthereumBridge` handle). The goal of `src/ethereum.ts` is to translate domain-level objects into low-level Ethereum chain primitives and perform a call. Instead of having `buildRedemptionData` in `Bridge` interface here we modify the `approveAndCall` interface and replace `extraData: Hex` with domain-level objects, as accepted by `buildRedemptionData`. Here we also rename the `approveAndCall` to `requestRedemption` and explain in the docs it happens via `approveAndCall`- although `requestRedemption` does not exist on the Solidity code of TBTC token, having it in `TBTCToken` Typescript API is fine given we abstract the complexity and make the API human-readable. --- typescript/src/chain.ts | 73 ++++++----- typescript/src/ethereum.ts | 148 +++++++++++------------ typescript/src/redemption.ts | 12 +- typescript/test/redemption.test.ts | 23 ++-- typescript/test/utils/mock-tbtc-token.ts | 24 ++-- 5 files changed, 138 insertions(+), 142 deletions(-) diff --git a/typescript/src/chain.ts b/typescript/src/chain.ts index 4d76fbbd3..93e3407f1 100644 --- a/typescript/src/chain.ts +++ b/typescript/src/chain.ts @@ -244,26 +244,6 @@ export interface Bridge { * Returns the attached WalletRegistry instance. */ walletRegistry(): Promise - - /** - * Builds the redemption data required to request a redemption via - * @see TBTCToken#approveAndCall - the built data should be passed as - * `extraData` the @see TBTCToken#approveAndCall function. - * @param redeemer On-chain identifier of the redeemer. - * @param walletPublicKey The Bitcoin public key of the wallet. Must be in the - * compressed form (33 bytes long with 02 or 03 prefix). - * @param mainUtxo The main UTXO of the wallet. Must match the main UTXO - * held by the on-chain Bridge contract. - * @param redeemerOutputScript The output script that the redeemed funds will - * be locked to. Must be un-prefixed and not prepended with length. - * @returns The - */ - buildRedemptionData( - redeemer: Identifier, - walletPublicKey: string, - mainUtxo: UnspentTransactionOutput, - redeemerOutputScript: string - ): Hex } /** @@ -401,6 +381,40 @@ export interface TBTCVault { getOptimisticMintingFinalizedEvents: GetEvents.Function } +/** + * Represnts data required to request redemption. + */ +export interface RequestRedemptionData { + /** + * On-chain identifier of the redeemer. + */ + redeemer: Identifier + /** + * The Bitcoin public key of the wallet. Must be in the compressed form (33 + * bytes long with 02 or 03 prefix). + */ + walletPublicKey: string + /** + * The main UTXO of the wallet. Must match the main UTXO held by the on-chain + * Bridge contract. + */ + mainUtxo: UnspentTransactionOutput + /** + * The output script that the redeemed funds will be locked to. Must be + * un-prefixed and not prepended with length. + */ + redeemerOutputScript: string + /** + * The amount to be redeemed with the precision of the tBTC on-chain token + * contract. + */ + amount: BigNumber + /** + * The vault address. + */ + vault: Identifier +} + /** * Interface for communication with the TBTC v2 token on-chain contract. */ @@ -418,18 +432,13 @@ export interface TBTCToken { totalSupply(blockNumber?: number): Promise /** - * Calls `receiveApproval` function on spender previously approving the spender - * to withdraw from the caller multiple times, up to the `amount` amount. If - * this function is called again, it overwrites the current allowance with - * `amount`. - * @param spender Address of contract authorized to spend. - * @param amount The max amount they can spend. - * @param extraData Extra information to send to the approved contract. + * Requests redemption in one transacion using the `approveAndCall` function + * from the tBTC on-chain token contract. Then the tBTC token contract calls + * the `receiveApproval` function from the `TBTCVault` contract which burns + * tBTC tokens and requests redemption. + * @param requestRedemptionData Data required to request redemption @see + * {@link RequestRedemptionData}. * @returns Transaction hash of the approve and call transaction. */ - approveAndCall( - spender: Identifier, - amount: BigNumber, - extraData: Hex - ): Promise + requestRedemption(requestRedemptionData: RequestRedemptionData): Promise } diff --git a/typescript/src/ethereum.ts b/typescript/src/ethereum.ts index 2c3c96260..d69ec026b 100644 --- a/typescript/src/ethereum.ts +++ b/typescript/src/ethereum.ts @@ -5,6 +5,7 @@ import { TBTCToken as ChainTBTCToken, Identifier as ChainIdentifier, GetEvents, + RequestRedemptionData, } from "./chain" import { BigNumber, @@ -488,39 +489,6 @@ export class Bridge redeemerOutputScript: string, amount: BigNumber ): Promise { - const redemptionData = this.parseRequestRedemptionTransactionData( - walletPublicKey, - mainUtxo, - redeemerOutputScript - ) - - await sendWithRetry(async () => { - return await this._instance.requestRedemption( - redemptionData.walletPublicKeyHash, - redemptionData.mainUtxo, - redemptionData.prefixedRawRedeemerOutputScript, - amount - ) - }, this._totalRetryAttempts) - } - - /** - * Parses the request redemption data to the proper form. - * @param walletPublicKey - The Bitcoin public key of the wallet. Must be in - * the compressed form (33 bytes long with 02 or 03 prefix). - * @param mainUtxo - The main UTXO of the wallet. Must match the main UTXO - * held by the on-chain contract. - * @param redeemerOutputScript - The output script that the redeemed funds - * will be locked to. Must be un-prefixed and not prepended with - * length. - * @returns Parsed data that can be passed to the contract to request - * redemption. - */ - private parseRequestRedemptionTransactionData = ( - walletPublicKey: string, - mainUtxo: UnspentTransactionOutput, - redeemerOutputScript: string - ) => { const walletPublicKeyHash = `0x${computeHash160(walletPublicKey)}` const mainUtxoParam = { @@ -539,11 +507,14 @@ export class Bridge rawRedeemerOutputScript, ]).toString("hex")}` - return { - walletPublicKeyHash, - mainUtxo: mainUtxoParam, - prefixedRawRedeemerOutputScript, - } + await sendWithRetry(async () => { + return await this._instance.requestRedemption( + walletPublicKeyHash, + mainUtxoParam, + prefixedRawRedeemerOutputScript, + amount + ) + }, this._totalRetryAttempts) } // eslint-disable-next-line valid-jsdoc @@ -724,37 +695,6 @@ export class Bridge signerOrProvider: this._instance.signer || this._instance.provider, }) } - - // eslint-disable-next-line valid-jsdoc - /** - * @see {ChainBridge#buildRedemptionData} - */ - buildRedemptionData( - redeemer: ChainIdentifier, - walletPublicKey: string, - mainUtxo: UnspentTransactionOutput, - redeemerOutputScript: string - ): Hex { - const redemptionData = this.parseRequestRedemptionTransactionData( - walletPublicKey, - mainUtxo, - redeemerOutputScript - ) - - return Hex.from( - utils.defaultAbiCoder.encode( - ["address", "bytes20", "bytes32", "uint32", "uint64", "bytes"], - [ - redeemer.identifierHex, - redemptionData.walletPublicKeyHash, - redemptionData.mainUtxo.txHash, - redemptionData.mainUtxo.txOutputIndex, - redemptionData.mainUtxo.txOutputValue, - redemptionData.prefixedRawRedeemerOutputScript, - ] - ) - ) - } } /** @@ -1161,14 +1101,20 @@ export class TBTCToken }) } - async approveAndCall( - spender: ChainIdentifier, - amount: BigNumber, - extraData: Hex + // eslint-disable-next-line valid-jsdoc + /** + * @see {ChainTBTCToken#requestRedemption} + */ + async requestRedemption( + requestRedemptionData: RequestRedemptionData ): Promise { + const { vault, amount, ...restData } = requestRedemptionData + + const extraData = this.buildRequestRedemptionData(restData) + const tx = await sendWithRetry(async () => { return await this._instance.approveAndCall( - spender.identifierHex, + vault.identifierHex, amount, extraData.toPrefixedString() ) @@ -1176,4 +1122,58 @@ export class TBTCToken return Hex.from(tx.hash) } + + private buildRequestRedemptionData( + requestRedemptionData: Omit + ): Hex { + const { redeemer, ...restData } = requestRedemptionData + const { walletPublicKeyHash, mainUtxo, prefixedRawRedeemerOutputScript } = + this.buildBridgeRequestRedemptionData(restData) + + return Hex.from( + utils.defaultAbiCoder.encode( + ["address", "bytes20", "bytes32", "uint32", "uint64", "bytes"], + [ + redeemer.identifierHex, + walletPublicKeyHash, + mainUtxo.txHash, + mainUtxo.txOutputIndex, + mainUtxo.txOutputValue, + prefixedRawRedeemerOutputScript, + ] + ) + ) + } + + private buildBridgeRequestRedemptionData( + data: Pick< + RequestRedemptionData, + "mainUtxo" | "walletPublicKey" | "redeemerOutputScript" + > + ) { + const { walletPublicKey, mainUtxo, redeemerOutputScript } = data + const walletPublicKeyHash = `0x${computeHash160(walletPublicKey)}` + + const mainUtxoParam = { + // The Ethereum Bridge expects this hash to be in the Bitcoin internal + // byte order. + txHash: mainUtxo.transactionHash.reverse().toPrefixedString(), + txOutputIndex: mainUtxo.outputIndex, + txOutputValue: mainUtxo.value, + } + + // Convert the output script to raw bytes buffer. + const rawRedeemerOutputScript = Buffer.from(redeemerOutputScript, "hex") + // Prefix the output script bytes buffer with 0x and its own length. + const prefixedRawRedeemerOutputScript = `0x${Buffer.concat([ + Buffer.from([rawRedeemerOutputScript.length]), + rawRedeemerOutputScript, + ]).toString("hex")}` + + return { + walletPublicKeyHash, + mainUtxo: mainUtxoParam, + prefixedRawRedeemerOutputScript, + } + } } diff --git a/typescript/src/redemption.ts b/typescript/src/redemption.ts index 6742e70fb..f52b8b3d1 100644 --- a/typescript/src/redemption.ts +++ b/typescript/src/redemption.ts @@ -66,7 +66,6 @@ export interface RedemptionRequest { * @param amount - The amount to be redeemed with the precision of the tBTC * on-chain token contract. * @param vault - The vault address. - * @param bridge - Handle to the Bridge on-chain contract. * @param tBTCToken - Handle to the TBTCToken on-chain contract. * @returns Transaction hash of the request redemption transaction. */ @@ -77,17 +76,16 @@ export async function requestRedemption( redeemerOutputScript: string, amount: BigNumber, vault: Identifier, - bridge: Bridge, tBTCToken: TBTCToken ): Promise { - const redemptionData = bridge.buildRedemptionData( + return await tBTCToken.requestRedemption({ redeemer, walletPublicKey, mainUtxo, - redeemerOutputScript - ) - - return await tBTCToken.approveAndCall(vault, amount, redemptionData) + redeemerOutputScript, + amount, + vault, + }) } /** diff --git a/typescript/test/redemption.test.ts b/typescript/test/redemption.test.ts index c159016c5..d682f96fe 100644 --- a/typescript/test/redemption.test.ts +++ b/typescript/test/redemption.test.ts @@ -46,7 +46,6 @@ describe("Redemption", () => { const redeemerOutputScript = data.pendingRedemptions[0].pendingRedemption.redeemerOutputScript const amount = data.pendingRedemptions[0].pendingRedemption.requestedAmount - const bridge: MockBridge = new MockBridge() const vault = Address.from("0xb622eA9D678ddF15135a20d59Ff26D28eC246bfB") const token: MockTBTCToken = new MockTBTCToken() const redeemer = Address.from("0x117284D8C50f334a1E2b7712649cB23C7a04Ae74") @@ -61,26 +60,22 @@ describe("Redemption", () => { redeemerOutputScript, amount, vault, - bridge, token ) }) it("should submit redemption proof with correct arguments", () => { - const bridgeLog = bridge.buildRedemptionDataLog - const tokenLog = token.approveAndCallLog - - expect(bridgeLog.length).to.equal(1) - expect(bridgeLog[0].walletPublicKey).to.equal( - redemptionProof.expectedRedemptionProof.walletPublicKey - ) - expect(bridgeLog[0].mainUtxo).to.equal(mainUtxo) - expect(bridgeLog[0].redeemerOutputScript).to.equal(redeemerOutputScript) - expect(bridgeLog[0].redeemer).to.equal(redeemer) + const tokenLog = token.requestRedemptionLog expect(tokenLog.length).to.equal(1) - expect(tokenLog[0].spender).to.equal(vault) - expect(tokenLog[0].amount).to.equal(amount) + expect(tokenLog[0]).to.deep.equal({ + redeemer, + walletPublicKey, + mainUtxo, + redeemerOutputScript, + amount, + vault, + }) }) }) diff --git a/typescript/test/utils/mock-tbtc-token.ts b/typescript/test/utils/mock-tbtc-token.ts index 5c101ff28..cb0f8e93e 100644 --- a/typescript/test/utils/mock-tbtc-token.ts +++ b/typescript/test/utils/mock-tbtc-token.ts @@ -1,31 +1,25 @@ -import { Identifier, TBTCToken } from "../../src/chain" +import { RequestRedemptionData, TBTCToken } from "../../src/chain" import { Hex } from "../../src/hex" import { BigNumber } from "ethers" -interface ApproveAndCallLog { - spender: Identifier - amount: BigNumber - extraData: Hex -} +interface RequestRedemptionLog extends RequestRedemptionData {} export class MockTBTCToken implements TBTCToken { - private _approveAndCallLog: ApproveAndCallLog[] = [] + private _requestRedemptionLog: RequestRedemptionLog[] = [] - get approveAndCallLog() { - return this._approveAndCallLog + get requestRedemptionLog() { + return this._requestRedemptionLog } totalSupply(blockNumber?: number | undefined): Promise { throw new Error("Method not implemented.") } - async approveAndCall( - spender: Identifier, - amount: BigNumber, - extraData: Hex + + async requestRedemption( + requestRedemptionData: RequestRedemptionData ): Promise { - this._approveAndCallLog.push({ spender, amount, extraData }) + this._requestRedemptionLog.push(requestRedemptionData) - // Random tx hash return Hex.from( "0xf7d0c92c8de4d117d915c2a8a54ee550047f926bc00b91b651c40628751cfe29" ) From b5190e10129859f2264c91e3cddebd21f9946431 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Wed, 5 Jul 2023 15:50:08 +0200 Subject: [PATCH 11/17] Add unit tests for `createOutputScriptFromAddress` Test this fn with mainnet addressess: - P2PKH (`1*`) - P2WPKH (`bc1q*`) - P2SH (`3*`) - P2WSH (`bc1q*`) --- typescript/test/bitcoin.test.ts | 161 ++++++++++++++++++++++---------- 1 file changed, 114 insertions(+), 47 deletions(-) diff --git a/typescript/test/bitcoin.test.ts b/typescript/test/bitcoin.test.ts index 93b849ae8..a1bb5da82 100644 --- a/typescript/test/bitcoin.test.ts +++ b/typescript/test/bitcoin.test.ts @@ -467,53 +467,120 @@ describe("Bitcoin", () => { }) describe("createOutputScriptFromAddress", () => { - const btcAddresses = { - P2PKH: { - address: "mjc2zGWypwpNyDi4ZxGbBNnUA84bfgiwYc", - redeemerOutputScript: - "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", - }, - P2WPKH: { - address: "tb1qumuaw3exkxdhtut0u85latkqfz4ylgwstkdzsx", - redeemerOutputScript: - "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", - }, - P2SH: { - address: "2MsM67NLa71fHvTUBqNENW15P68nHB2vVXb", - redeemerOutputScript: - "0x17a914011beb6fb8499e075a57027fb0a58384f2d3f78487", - }, - P2WSH: { - address: - "tb1qau95mxzh2249aa3y8exx76ltc2sq0e7kw8hj04936rdcmnynhswqqz02vv", - redeemerOutputScript: - "0x220020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c", - }, - } - - Object.entries(btcAddresses).forEach( - ([ - addressType, - { address, redeemerOutputScript: expectedRedeemerOutputScript }, - ]) => { - it(`should create correct output script for ${addressType} address type`, () => { - const result = createOutputScriptFromAddress(address) - - // Check if we can build the prefixed raw redeemer output script based - // on the result. - // Convert the output script to raw bytes buffer. - const rawRedeemerOutputScript = Buffer.from(result.toString(), "hex") - // Prefix the output script bytes buffer with 0x and its own length. - const prefixedRawRedeemerOutputScript = `0x${Buffer.concat([ - Buffer.from([rawRedeemerOutputScript.length]), - rawRedeemerOutputScript, - ]).toString("hex")}` - - expect(prefixedRawRedeemerOutputScript).to.eq( - expectedRedeemerOutputScript - ) - }) + context("with testnet addresses", () => { + const btcAddresses = { + P2PKH: { + address: "mjc2zGWypwpNyDi4ZxGbBNnUA84bfgiwYc", + redeemerOutputScript: + "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", + outputScript: "76a9142cd680318747b720d67bf4246eb7403b476adb3488ac", + }, + P2WPKH: { + address: "tb1qumuaw3exkxdhtut0u85latkqfz4ylgwstkdzsx", + redeemerOutputScript: + "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + outputScript: "0014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + }, + P2SH: { + address: "2MsM67NLa71fHvTUBqNENW15P68nHB2vVXb", + redeemerOutputScript: + "0x17a914011beb6fb8499e075a57027fb0a58384f2d3f78487", + outputScript: "a914011beb6fb8499e075a57027fb0a58384f2d3f78487", + }, + P2WSH: { + address: + "tb1qau95mxzh2249aa3y8exx76ltc2sq0e7kw8hj04936rdcmnynhswqqz02vv", + redeemerOutputScript: + "0x220020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c", + outputScript: + "0020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c", + }, } - ) + + Object.entries(btcAddresses).forEach( + ([ + addressType, + { + address, + redeemerOutputScript: expectedRedeemerOutputScript, + outputScript: expectedOutputScript, + }, + ]) => { + it(`should create correct output script for ${addressType} address type`, () => { + const result = createOutputScriptFromAddress(address) + + expect(result.toString()).to.eq(expectedOutputScript) + // Check if we can build the prefixed raw redeemer output script based + // on the result. + expect(buildRawPrefixedOutputScript(result.toString())).to.eq( + expectedRedeemerOutputScript + ) + }) + } + ) + }) + + context("with mainnet addresses", () => { + const btcAddresses = { + P2PKH: { + address: "12higDjoCCNXSA95xZMWUdPvXNmkAduhWv", + redeemerOutputScript: + "0x1976a91412ab8dc588ca9d5787dde7eb29569da63c3a238c88ac", + outputScript: "76a91412ab8dc588ca9d5787dde7eb29569da63c3a238c88ac", + }, + P2WPKH: { + address: "bc1q34aq5drpuwy3wgl9lhup9892qp6svr8ldzyy7c", + redeemerOutputScript: + "0x1600148d7a0a3461e3891723e5fdf8129caa0075060cff", + outputScript: "00148d7a0a3461e3891723e5fdf8129caa0075060cff", + }, + P2SH: { + address: "342ftSRCvFHfCeFFBuz4xwbeqnDw6BGUey", + redeemerOutputScript: + "0x17a91419a7d869032368fd1f1e26e5e73a4ad0e474960e87", + outputScript: "a91419a7d869032368fd1f1e26e5e73a4ad0e474960e87", + }, + P2WSH: { + address: + "bc1qeklep85ntjz4605drds6aww9u0qr46qzrv5xswd35uhjuj8ahfcqgf6hak", + redeemerOutputScript: + "0x220020cdbf909e935c855d3e8d1b61aeb9c5e3c03ae8021b286839b1a72f2e48fdba70", + outputScript: + "0020cdbf909e935c855d3e8d1b61aeb9c5e3c03ae8021b286839b1a72f2e48fdba70", + }, + } + + Object.entries(btcAddresses).forEach( + ([ + addressType, + { + address, + redeemerOutputScript: expectedRedeemerOutputScript, + outputScript: expectedOutputScript, + }, + ]) => { + it(`should create correct output script for ${addressType} address type`, () => { + const result = createOutputScriptFromAddress(address) + + expect(result.toString()).to.eq(expectedOutputScript) + // Check if we can build the prefixed raw redeemer output script based + // on the result. + expect(buildRawPrefixedOutputScript(result.toString())).to.eq( + expectedRedeemerOutputScript + ) + }) + } + ) + }) }) }) + +const buildRawPrefixedOutputScript = (outputScript: string) => { + // Convert the output script to raw bytes buffer. + const rawRedeemerOutputScript = Buffer.from(outputScript.toString(), "hex") + // Prefix the output script bytes buffer with 0x and its own length. + return `0x${Buffer.concat([ + Buffer.from([rawRedeemerOutputScript.length]), + rawRedeemerOutputScript, + ]).toString("hex")}` +} From 06601b951b668fdda9680e1f9879dbbcf6755f4a Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Wed, 5 Jul 2023 16:07:07 +0200 Subject: [PATCH 12/17] Simplify `requestRedemption` API Get rid of two parms: - `vault`- `requestRedemption` is exposed on `TBTCToken` and there is no other choice than working with `TBTCVault` contract for this interaction. We should limit the surface of developer errors- we can resolve the `vault` address in the `TBTCToken` handle implementation from the `TBTCTokenContract.owner()` call. - `redeemer`- we can get the address from the contract instance using `this._instance.signer.getAddress()`. The `TBTCToken` contract always passes `msg.sender` to the `receiveApproval` fn so it's not possible to request redemption for someone else using approve and call pattern. --- typescript/src/chain.ts | 52 +++++++----------------- typescript/src/ethereum.ts | 52 ++++++++++++++++-------- typescript/src/redemption.ts | 14 ++----- typescript/test/redemption.test.ts | 7 ---- typescript/test/utils/mock-tbtc-token.ts | 22 ++++++++-- 5 files changed, 72 insertions(+), 75 deletions(-) diff --git a/typescript/src/chain.ts b/typescript/src/chain.ts index 93e3407f1..e422e7a68 100644 --- a/typescript/src/chain.ts +++ b/typescript/src/chain.ts @@ -381,40 +381,6 @@ export interface TBTCVault { getOptimisticMintingFinalizedEvents: GetEvents.Function } -/** - * Represnts data required to request redemption. - */ -export interface RequestRedemptionData { - /** - * On-chain identifier of the redeemer. - */ - redeemer: Identifier - /** - * The Bitcoin public key of the wallet. Must be in the compressed form (33 - * bytes long with 02 or 03 prefix). - */ - walletPublicKey: string - /** - * The main UTXO of the wallet. Must match the main UTXO held by the on-chain - * Bridge contract. - */ - mainUtxo: UnspentTransactionOutput - /** - * The output script that the redeemed funds will be locked to. Must be - * un-prefixed and not prepended with length. - */ - redeemerOutputScript: string - /** - * The amount to be redeemed with the precision of the tBTC on-chain token - * contract. - */ - amount: BigNumber - /** - * The vault address. - */ - vault: Identifier -} - /** * Interface for communication with the TBTC v2 token on-chain contract. */ @@ -436,9 +402,21 @@ export interface TBTCToken { * from the tBTC on-chain token contract. Then the tBTC token contract calls * the `receiveApproval` function from the `TBTCVault` contract which burns * tBTC tokens and requests redemption. - * @param requestRedemptionData Data required to request redemption @see - * {@link RequestRedemptionData}. + * @param walletPublicKey - The Bitcoin public key of the wallet. Must be in + * the compressed form (33 bytes long with 02 or 03 prefix). + * @param mainUtxo - The main UTXO of the wallet. Must match the main UTXO + * held by the on-chain Bridge contract. + * @param redeemerOutputScript - The output script that the redeemed funds + * will be locked to. Must be un-prefixed and not prepended with + * length. + * @param amount - The amount to be redeemed with the precision of the tBTC + * on-chain token contract. * @returns Transaction hash of the approve and call transaction. */ - requestRedemption(requestRedemptionData: RequestRedemptionData): Promise + requestRedemption( + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string, + amount: BigNumber + ): Promise } diff --git a/typescript/src/ethereum.ts b/typescript/src/ethereum.ts index d69ec026b..5f0eb6a68 100644 --- a/typescript/src/ethereum.ts +++ b/typescript/src/ethereum.ts @@ -5,7 +5,6 @@ import { TBTCToken as ChainTBTCToken, Identifier as ChainIdentifier, GetEvents, - RequestRedemptionData, } from "./chain" import { BigNumber, @@ -1106,15 +1105,27 @@ export class TBTCToken * @see {ChainTBTCToken#requestRedemption} */ async requestRedemption( - requestRedemptionData: RequestRedemptionData + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string, + amount: BigNumber ): Promise { - const { vault, amount, ...restData } = requestRedemptionData + const redeemer = await this._instance?.signer?.getAddress() + if (!redeemer) { + throw new Error("Signer not provided.") + } - const extraData = this.buildRequestRedemptionData(restData) + const vault = await this._instance.owner() + const extraData = this.buildRequestRedemptionData( + Address.from(redeemer), + walletPublicKey, + mainUtxo, + redeemerOutputScript + ) const tx = await sendWithRetry(async () => { return await this._instance.approveAndCall( - vault.identifierHex, + vault, amount, extraData.toPrefixedString() ) @@ -1124,11 +1135,20 @@ export class TBTCToken } private buildRequestRedemptionData( - requestRedemptionData: Omit + redeemer: Address, + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string ): Hex { - const { redeemer, ...restData } = requestRedemptionData - const { walletPublicKeyHash, mainUtxo, prefixedRawRedeemerOutputScript } = - this.buildBridgeRequestRedemptionData(restData) + const { + walletPublicKeyHash, + prefixedRawRedeemerOutputScript, + mainUtxo: _mainUtxo, + } = this.buildBridgeRequestRedemptionData( + walletPublicKey, + mainUtxo, + redeemerOutputScript + ) return Hex.from( utils.defaultAbiCoder.encode( @@ -1136,9 +1156,9 @@ export class TBTCToken [ redeemer.identifierHex, walletPublicKeyHash, - mainUtxo.txHash, - mainUtxo.txOutputIndex, - mainUtxo.txOutputValue, + _mainUtxo.txHash, + _mainUtxo.txOutputIndex, + _mainUtxo.txOutputValue, prefixedRawRedeemerOutputScript, ] ) @@ -1146,12 +1166,10 @@ export class TBTCToken } private buildBridgeRequestRedemptionData( - data: Pick< - RequestRedemptionData, - "mainUtxo" | "walletPublicKey" | "redeemerOutputScript" - > + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string ) { - const { walletPublicKey, mainUtxo, redeemerOutputScript } = data const walletPublicKeyHash = `0x${computeHash160(walletPublicKey)}` const mainUtxoParam = { diff --git a/typescript/src/redemption.ts b/typescript/src/redemption.ts index f52b8b3d1..e3b65c986 100644 --- a/typescript/src/redemption.ts +++ b/typescript/src/redemption.ts @@ -55,8 +55,7 @@ export interface RedemptionRequest { } /** - * Requests a redemption from the on-chain Bridge contract. - * @param redeemer - On-chain identifier of the redeemer. + * Requests a redemption of tBTC into BTC. * @param walletPublicKey - The Bitcoin public key of the wallet. Must be in the * compressed form (33 bytes long with 02 or 03 prefix). * @param mainUtxo - The main UTXO of the wallet. Must match the main UTXO held @@ -65,27 +64,22 @@ export interface RedemptionRequest { * be locked to. Must be un-prefixed and not prepended with length. * @param amount - The amount to be redeemed with the precision of the tBTC * on-chain token contract. - * @param vault - The vault address. * @param tBTCToken - Handle to the TBTCToken on-chain contract. * @returns Transaction hash of the request redemption transaction. */ export async function requestRedemption( - redeemer: Identifier, walletPublicKey: string, mainUtxo: UnspentTransactionOutput, redeemerOutputScript: string, amount: BigNumber, - vault: Identifier, tBTCToken: TBTCToken ): Promise { - return await tBTCToken.requestRedemption({ - redeemer, + return await tBTCToken.requestRedemption( walletPublicKey, mainUtxo, redeemerOutputScript, - amount, - vault, - }) + amount + ) } /** diff --git a/typescript/test/redemption.test.ts b/typescript/test/redemption.test.ts index d682f96fe..04072c3d0 100644 --- a/typescript/test/redemption.test.ts +++ b/typescript/test/redemption.test.ts @@ -34,7 +34,6 @@ import * as chai from "chai" import chaiAsPromised from "chai-as-promised" import { expect } from "chai" import { BigNumberish, BigNumber } from "ethers" -import { Address } from "../src/ethereum" import { MockTBTCToken } from "./utils/mock-tbtc-token" chai.use(chaiAsPromised) @@ -46,20 +45,16 @@ describe("Redemption", () => { const redeemerOutputScript = data.pendingRedemptions[0].pendingRedemption.redeemerOutputScript const amount = data.pendingRedemptions[0].pendingRedemption.requestedAmount - const vault = Address.from("0xb622eA9D678ddF15135a20d59Ff26D28eC246bfB") const token: MockTBTCToken = new MockTBTCToken() - const redeemer = Address.from("0x117284D8C50f334a1E2b7712649cB23C7a04Ae74") beforeEach(async () => { bcoin.set("testnet") await requestRedemption( - redeemer, walletPublicKey, mainUtxo, redeemerOutputScript, amount, - vault, token ) }) @@ -69,12 +64,10 @@ describe("Redemption", () => { expect(tokenLog.length).to.equal(1) expect(tokenLog[0]).to.deep.equal({ - redeemer, walletPublicKey, mainUtxo, redeemerOutputScript, amount, - vault, }) }) }) diff --git a/typescript/test/utils/mock-tbtc-token.ts b/typescript/test/utils/mock-tbtc-token.ts index cb0f8e93e..2e37d3e68 100644 --- a/typescript/test/utils/mock-tbtc-token.ts +++ b/typescript/test/utils/mock-tbtc-token.ts @@ -1,8 +1,14 @@ -import { RequestRedemptionData, TBTCToken } from "../../src/chain" +import { TBTCToken } from "../../src/chain" import { Hex } from "../../src/hex" import { BigNumber } from "ethers" +import { UnspentTransactionOutput } from "../../src/bitcoin" -interface RequestRedemptionLog extends RequestRedemptionData {} +interface RequestRedemptionLog { + walletPublicKey: string + mainUtxo: UnspentTransactionOutput + redeemerOutputScript: string + amount: BigNumber +} export class MockTBTCToken implements TBTCToken { private _requestRedemptionLog: RequestRedemptionLog[] = [] @@ -16,9 +22,17 @@ export class MockTBTCToken implements TBTCToken { } async requestRedemption( - requestRedemptionData: RequestRedemptionData + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string, + amount: BigNumber ): Promise { - this._requestRedemptionLog.push(requestRedemptionData) + this._requestRedemptionLog.push({ + walletPublicKey, + mainUtxo, + redeemerOutputScript, + amount, + }) return Hex.from( "0xf7d0c92c8de4d117d915c2a8a54ee550047f926bc00b91b651c40628751cfe29" From 5c3aba9d26f30258caaa795c31348f524eda801f Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Wed, 5 Jul 2023 18:36:42 +0200 Subject: [PATCH 13/17] Add unit tests Cover the `requestRedemption` fn from Ethereum Bridge handle with unit tests. --- typescript/test/ethereum.test.ts | 89 ++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/typescript/test/ethereum.test.ts b/typescript/test/ethereum.test.ts index 6bc3ad4bb..780180ffa 100644 --- a/typescript/test/ethereum.test.ts +++ b/typescript/test/ethereum.test.ts @@ -1,15 +1,17 @@ -import { Address, Bridge } from "../src/ethereum" +import { Address, Bridge, TBTCToken } from "../src/ethereum" import { deployMockContract, MockContract, } from "@ethereum-waffle/mock-contract" import chai, { assert, expect } from "chai" -import { BigNumber, constants } from "ethers" +import { BigNumber, Wallet, constants, utils } from "ethers" import { abi as BridgeABI } from "@keep-network/tbtc-v2/artifacts/Bridge.json" +import { abi as TBTCTokenABI } from "@keep-network/tbtc-v2/artifacts/TBTC.json" import { abi as WalletRegistryABI } from "@keep-network/ecdsa/artifacts/WalletRegistry.json" import { MockProvider } from "@ethereum-waffle/provider" import { waffleChai } from "@ethereum-waffle/chai" -import { TransactionHash } from "../src/bitcoin" +import { TransactionHash, computeHash160 } from "../src/bitcoin" +import { Hex } from "../src/hex" chai.use(waffleChai) @@ -470,4 +472,85 @@ describe("Ethereum", () => { "Expected contract function was not called" ) } + + describe("TBTCToken", () => { + let tbtcToken: MockContract + let tokenHandle: TBTCToken + const signer: Wallet = new MockProvider().getWallets()[0] + + beforeEach(async () => { + tbtcToken = await deployMockContract( + signer, + `${JSON.stringify(TBTCTokenABI)}` + ) + + tokenHandle = new TBTCToken({ + address: tbtcToken.address, + signerOrProvider: signer, + }) + }) + + describe("requestRedemption", () => { + const data = { + vault: Address.from("0x24BE35e7C04E2e0a628614Ce0Ed58805e1C894F7"), + walletPublicKey: + "03989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d9", + mainUtxo: { + transactionHash: TransactionHash.from( + "f8eaf242a55ea15e602f9f990e33f67f99dfbe25d1802bbde63cc1caabf99668" + ), + outputIndex: 8, + value: BigNumber.from(9999), + }, + redeemer: Address.from(signer.address), + amount: BigNumber.from(10000), + redeemerOutputScript: { + unprefixed: + "0020cdbf909e935c855d3e8d1b61aeb9c5e3c03ae8021b286839b1a72f2e48fdba70", + prefixed: + "0x220020cdbf909e935c855d3e8d1b61aeb9c5e3c03ae8021b286839b1a72f2e48fdba70", + }, + } + + beforeEach(async () => { + await tbtcToken.mock.owner.returns(data.vault.identifierHex) + await tbtcToken.mock.approveAndCall.returns(true) + + await tokenHandle.requestRedemption( + data.walletPublicKey, + data.mainUtxo, + data.redeemerOutputScript.unprefixed, + data.amount + ) + }) + + it("should request the redemption", async () => { + const { + walletPublicKey, + mainUtxo, + redeemerOutputScript, + redeemer, + vault, + amount, + } = data + const expectedExtraData = utils.defaultAbiCoder.encode( + ["address", "bytes20", "bytes32", "uint32", "uint64", "bytes"], + [ + redeemer.identifierHex, + Hex.from(computeHash160(walletPublicKey)).toPrefixedString(), + mainUtxo.transactionHash.reverse().toPrefixedString(), + mainUtxo.outputIndex, + mainUtxo.value, + redeemerOutputScript.prefixed, + ] + ) + + assertContractCalledWith(tbtcToken, "approveAndCall", [ + vault.identifierHex, + amount, + expectedExtraData, + ]) + }) + }) + }) }) From e814ecaeeb083f4d66a2882007de4b0b7320e4b4 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Thu, 6 Jul 2023 13:56:22 +0200 Subject: [PATCH 14/17] Remove unnecessary method from `MockBridge` It's no longer needed since we refactored the `requestRedemption` function in `9249ee5` and `06601b9`. --- typescript/test/utils/mock-bridge.ts | 36 ---------------------------- 1 file changed, 36 deletions(-) diff --git a/typescript/test/utils/mock-bridge.ts b/typescript/test/utils/mock-bridge.ts index 2466c145f..f3b06c658 100644 --- a/typescript/test/utils/mock-bridge.ts +++ b/typescript/test/utils/mock-bridge.ts @@ -326,40 +326,4 @@ export class MockBridge implements Bridge { walletRegistry(): Promise { throw new Error("not implemented") } - - buildRedemptionData( - redeemer: Identifier, - walletPublicKey: string, - mainUtxo: UnspentTransactionOutput, - redeemerOutputScript: string - ): Hex { - this._buildRedemptionDataLog.push({ - redeemer, - walletPublicKey, - mainUtxo, - redeemerOutputScript, - }) - - // Convert the output script to raw bytes buffer. - const rawRedeemerOutputScript = Buffer.from(redeemerOutputScript, "hex") - // Prefix the output script bytes buffer with 0x and its own length. - const prefixedRawRedeemerOutputScript = `0x${Buffer.concat([ - Buffer.from([rawRedeemerOutputScript.length]), - rawRedeemerOutputScript, - ]).toString("hex")}` - - return Hex.from( - utils.defaultAbiCoder.encode( - ["address", "bytes20", "bytes32", "uint32", "uint64", "bytes"], - [ - redeemer.identifierHex, - `0x${computeHash160(walletPublicKey)}`, - mainUtxo.transactionHash.reverse().toPrefixedString(), - mainUtxo.outputIndex, - mainUtxo.value, - prefixedRawRedeemerOutputScript, - ] - ) - ) - } } From dbd818e25ed83e919b30f33cfd68b7f1d782c0e3 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Thu, 6 Jul 2023 14:00:30 +0200 Subject: [PATCH 15/17] Update the error msg in `requestRedemption` Drop the dot `.` from the end of the error message. --- typescript/src/ethereum.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/src/ethereum.ts b/typescript/src/ethereum.ts index 5f0eb6a68..dc217304e 100644 --- a/typescript/src/ethereum.ts +++ b/typescript/src/ethereum.ts @@ -1112,7 +1112,7 @@ export class TBTCToken ): Promise { const redeemer = await this._instance?.signer?.getAddress() if (!redeemer) { - throw new Error("Signer not provided.") + throw new Error("Signer not provided") } const vault = await this._instance.owner() From b9633d18a84759f6e32ff808e8f1f643796ce140 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Thu, 6 Jul 2023 14:06:00 +0200 Subject: [PATCH 16/17] Update `createOutputScriptFromAddress` tests `buildRawPrefixedOutputScript` is only converting the string to a buffer and prepends the length. If the result is `expectedOutputScript` this will always work. Here we remove this assertion, the most important part of this test is whether `createOutputScriptFromAddress` recognizes and handles the given address type correctly. --- typescript/test/bitcoin.test.ts | 54 ++------------------------------- 1 file changed, 2 insertions(+), 52 deletions(-) diff --git a/typescript/test/bitcoin.test.ts b/typescript/test/bitcoin.test.ts index a1bb5da82..6bc7de6c4 100644 --- a/typescript/test/bitcoin.test.ts +++ b/typescript/test/bitcoin.test.ts @@ -471,50 +471,30 @@ describe("Bitcoin", () => { const btcAddresses = { P2PKH: { address: "mjc2zGWypwpNyDi4ZxGbBNnUA84bfgiwYc", - redeemerOutputScript: - "0x1976a9142cd680318747b720d67bf4246eb7403b476adb3488ac", outputScript: "76a9142cd680318747b720d67bf4246eb7403b476adb3488ac", }, P2WPKH: { address: "tb1qumuaw3exkxdhtut0u85latkqfz4ylgwstkdzsx", - redeemerOutputScript: - "0x160014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", outputScript: "0014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", }, P2SH: { address: "2MsM67NLa71fHvTUBqNENW15P68nHB2vVXb", - redeemerOutputScript: - "0x17a914011beb6fb8499e075a57027fb0a58384f2d3f78487", outputScript: "a914011beb6fb8499e075a57027fb0a58384f2d3f78487", }, P2WSH: { address: "tb1qau95mxzh2249aa3y8exx76ltc2sq0e7kw8hj04936rdcmnynhswqqz02vv", - redeemerOutputScript: - "0x220020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c", outputScript: "0020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c", }, } Object.entries(btcAddresses).forEach( - ([ - addressType, - { - address, - redeemerOutputScript: expectedRedeemerOutputScript, - outputScript: expectedOutputScript, - }, - ]) => { + ([addressType, { address, outputScript: expectedOutputScript }]) => { it(`should create correct output script for ${addressType} address type`, () => { const result = createOutputScriptFromAddress(address) expect(result.toString()).to.eq(expectedOutputScript) - // Check if we can build the prefixed raw redeemer output script based - // on the result. - expect(buildRawPrefixedOutputScript(result.toString())).to.eq( - expectedRedeemerOutputScript - ) }) } ) @@ -524,63 +504,33 @@ describe("Bitcoin", () => { const btcAddresses = { P2PKH: { address: "12higDjoCCNXSA95xZMWUdPvXNmkAduhWv", - redeemerOutputScript: - "0x1976a91412ab8dc588ca9d5787dde7eb29569da63c3a238c88ac", outputScript: "76a91412ab8dc588ca9d5787dde7eb29569da63c3a238c88ac", }, P2WPKH: { address: "bc1q34aq5drpuwy3wgl9lhup9892qp6svr8ldzyy7c", - redeemerOutputScript: - "0x1600148d7a0a3461e3891723e5fdf8129caa0075060cff", outputScript: "00148d7a0a3461e3891723e5fdf8129caa0075060cff", }, P2SH: { address: "342ftSRCvFHfCeFFBuz4xwbeqnDw6BGUey", - redeemerOutputScript: - "0x17a91419a7d869032368fd1f1e26e5e73a4ad0e474960e87", outputScript: "a91419a7d869032368fd1f1e26e5e73a4ad0e474960e87", }, P2WSH: { address: "bc1qeklep85ntjz4605drds6aww9u0qr46qzrv5xswd35uhjuj8ahfcqgf6hak", - redeemerOutputScript: - "0x220020cdbf909e935c855d3e8d1b61aeb9c5e3c03ae8021b286839b1a72f2e48fdba70", outputScript: "0020cdbf909e935c855d3e8d1b61aeb9c5e3c03ae8021b286839b1a72f2e48fdba70", }, } Object.entries(btcAddresses).forEach( - ([ - addressType, - { - address, - redeemerOutputScript: expectedRedeemerOutputScript, - outputScript: expectedOutputScript, - }, - ]) => { + ([addressType, { address, outputScript: expectedOutputScript }]) => { it(`should create correct output script for ${addressType} address type`, () => { const result = createOutputScriptFromAddress(address) expect(result.toString()).to.eq(expectedOutputScript) - // Check if we can build the prefixed raw redeemer output script based - // on the result. - expect(buildRawPrefixedOutputScript(result.toString())).to.eq( - expectedRedeemerOutputScript - ) }) } ) }) }) }) - -const buildRawPrefixedOutputScript = (outputScript: string) => { - // Convert the output script to raw bytes buffer. - const rawRedeemerOutputScript = Buffer.from(outputScript.toString(), "hex") - // Prefix the output script bytes buffer with 0x and its own length. - return `0x${Buffer.concat([ - Buffer.from([rawRedeemerOutputScript.length]), - rawRedeemerOutputScript, - ]).toString("hex")}` -} From 5f0365d67f8f73486fdd3b95fe90b3e4463e36e9 Mon Sep 17 00:00:00 2001 From: Rafal Czajkowski Date: Thu, 6 Jul 2023 14:54:03 +0200 Subject: [PATCH 17/17] Remove unnecessary fields in `MockBridge` class See e814eca. --- typescript/test/utils/mock-bridge.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/typescript/test/utils/mock-bridge.ts b/typescript/test/utils/mock-bridge.ts index f3b06c658..d342357c2 100644 --- a/typescript/test/utils/mock-bridge.ts +++ b/typescript/test/utils/mock-bridge.ts @@ -43,13 +43,6 @@ interface RedemptionProofLogEntry { walletPublicKey: string } -interface BuildRedemptionDataLogEntry { - redeemer: Identifier - walletPublicKey: string - mainUtxo: UnspentTransactionOutput - redeemerOutputScript: string -} - /** * Mock Bridge used for test purposes. */ @@ -63,7 +56,6 @@ export class MockBridge implements Bridge { private _redemptionProofLog: RedemptionProofLogEntry[] = [] private _deposits = new Map() private _activeWalletPublicKey: string | undefined - private _buildRedemptionDataLog: BuildRedemptionDataLogEntry[] = [] setPendingRedemptions(value: Map) { this._pendingRedemptions = value @@ -89,10 +81,6 @@ export class MockBridge implements Bridge { return this._redemptionProofLog } - get buildRedemptionDataLog(): BuildRedemptionDataLogEntry[] { - return this._buildRedemptionDataLog - } - setDeposits(value: Map) { this._deposits = value }