diff --git a/typescript/src/bitcoin.ts b/typescript/src/bitcoin.ts index 98b8e0e27..bb330be3f 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 output script. + */ +export function createOutputScriptFromAddress(address: string): Hex { + return Hex.from(Script.fromAddress(address).toRaw().toString("hex")) +} diff --git a/typescript/src/chain.ts b/typescript/src/chain.ts index dae8606d2..e422e7a68 100644 --- a/typescript/src/chain.ts +++ b/typescript/src/chain.ts @@ -396,4 +396,27 @@ 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 + + /** + * 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 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( + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string, + amount: BigNumber + ): Promise } diff --git a/typescript/src/ethereum.ts b/typescript/src/ethereum.ts index be9260382..a83f96348 100644 --- a/typescript/src/ethereum.ts +++ b/typescript/src/ethereum.ts @@ -1099,4 +1099,99 @@ export class TBTCToken blockTag: blockNumber ?? "latest", }) } + + // eslint-disable-next-line valid-jsdoc + /** + * @see {ChainTBTCToken#requestRedemption} + */ + async requestRedemption( + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string, + amount: BigNumber + ): Promise { + const redeemer = await this._instance?.signer?.getAddress() + if (!redeemer) { + throw new Error("Signer not provided") + } + + 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, + amount, + extraData.toPrefixedString() + ) + }, this._totalRetryAttempts) + + return Hex.from(tx.hash) + } + + private buildRequestRedemptionData( + redeemer: Address, + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string + ): Hex { + const { + walletPublicKeyHash, + prefixedRawRedeemerOutputScript, + mainUtxo: _mainUtxo, + } = this.buildBridgeRequestRedemptionData( + walletPublicKey, + mainUtxo, + redeemerOutputScript + ) + + return Hex.from( + utils.defaultAbiCoder.encode( + ["address", "bytes20", "bytes32", "uint32", "uint64", "bytes"], + [ + redeemer.identifierHex, + walletPublicKeyHash, + _mainUtxo.txHash, + _mainUtxo.txOutputIndex, + _mainUtxo.txOutputValue, + prefixedRawRedeemerOutputScript, + ] + ) + ) + } + + private buildBridgeRequestRedemptionData( + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string + ) { + 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 ec96659d5..e3b65c986 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. @@ -54,25 +55,26 @@ export interface RedemptionRequest { } /** - * Requests a redemption from the on-chain Bridge contract. + * 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 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 bridge - Handle to the Bridge on-chain contract. - * @returns Empty promise. + * @param amount - The amount to be redeemed with the precision of the tBTC + * on-chain token contract. + * @param tBTCToken - Handle to the TBTCToken on-chain contract. + * @returns Transaction hash of the request redemption transaction. */ export async function requestRedemption( walletPublicKey: string, mainUtxo: UnspentTransactionOutput, redeemerOutputScript: string, amount: BigNumber, - bridge: Bridge -): Promise { - await bridge.requestRedemption( + tBTCToken: TBTCToken +): Promise { + return await tBTCToken.requestRedemption( walletPublicKey, mainUtxo, redeemerOutputScript, diff --git a/typescript/test/bitcoin.test.ts b/typescript/test/bitcoin.test.ts index 7321c99c7..6bc7de6c4 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,72 @@ describe("Bitcoin", () => { expect(targetToDifficulty(target)).to.equal(expectedDifficulty) }) }) + + describe("createOutputScriptFromAddress", () => { + context("with testnet addresses", () => { + const btcAddresses = { + P2PKH: { + address: "mjc2zGWypwpNyDi4ZxGbBNnUA84bfgiwYc", + outputScript: "76a9142cd680318747b720d67bf4246eb7403b476adb3488ac", + }, + P2WPKH: { + address: "tb1qumuaw3exkxdhtut0u85latkqfz4ylgwstkdzsx", + outputScript: "0014e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0", + }, + P2SH: { + address: "2MsM67NLa71fHvTUBqNENW15P68nHB2vVXb", + outputScript: "a914011beb6fb8499e075a57027fb0a58384f2d3f78487", + }, + P2WSH: { + address: + "tb1qau95mxzh2249aa3y8exx76ltc2sq0e7kw8hj04936rdcmnynhswqqz02vv", + outputScript: + "0020ef0b4d985752aa5ef6243e4c6f6bebc2a007e7d671ef27d4b1d0db8dcc93bc1c", + }, + } + + Object.entries(btcAddresses).forEach( + ([addressType, { address, outputScript: expectedOutputScript }]) => { + it(`should create correct output script for ${addressType} address type`, () => { + const result = createOutputScriptFromAddress(address) + + expect(result.toString()).to.eq(expectedOutputScript) + }) + } + ) + }) + + context("with mainnet addresses", () => { + const btcAddresses = { + P2PKH: { + address: "12higDjoCCNXSA95xZMWUdPvXNmkAduhWv", + outputScript: "76a91412ab8dc588ca9d5787dde7eb29569da63c3a238c88ac", + }, + P2WPKH: { + address: "bc1q34aq5drpuwy3wgl9lhup9892qp6svr8ldzyy7c", + outputScript: "00148d7a0a3461e3891723e5fdf8129caa0075060cff", + }, + P2SH: { + address: "342ftSRCvFHfCeFFBuz4xwbeqnDw6BGUey", + outputScript: "a91419a7d869032368fd1f1e26e5e73a4ad0e474960e87", + }, + P2WSH: { + address: + "bc1qeklep85ntjz4605drds6aww9u0qr46qzrv5xswd35uhjuj8ahfcqgf6hak", + outputScript: + "0020cdbf909e935c855d3e8d1b61aeb9c5e3c03ae8021b286839b1a72f2e48fdba70", + }, + } + + Object.entries(btcAddresses).forEach( + ([addressType, { address, outputScript: expectedOutputScript }]) => { + it(`should create correct output script for ${addressType} address type`, () => { + const result = createOutputScriptFromAddress(address) + + expect(result.toString()).to.eq(expectedOutputScript) + }) + } + ) + }) + }) }) 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, + ]) + }) + }) + }) }) diff --git a/typescript/test/redemption.test.ts b/typescript/test/redemption.test.ts index a32a09ae3..04072c3d0 100644 --- a/typescript/test/redemption.test.ts +++ b/typescript/test/redemption.test.ts @@ -34,6 +34,7 @@ import * as chai from "chai" import chaiAsPromised from "chai-as-promised" import { expect } from "chai" import { BigNumberish, BigNumber } from "ethers" +import { MockTBTCToken } from "./utils/mock-tbtc-token" chai.use(chaiAsPromised) @@ -44,7 +45,7 @@ describe("Redemption", () => { const redeemerOutputScript = data.pendingRedemptions[0].pendingRedemption.redeemerOutputScript const amount = data.pendingRedemptions[0].pendingRedemption.requestedAmount - const bridge: MockBridge = new MockBridge() + const token: MockTBTCToken = new MockTBTCToken() beforeEach(async () => { bcoin.set("testnet") @@ -54,19 +55,20 @@ describe("Redemption", () => { mainUtxo, redeemerOutputScript, amount, - bridge + token ) }) it("should submit redemption proof with correct arguments", () => { - const bridgeLog = bridge.requestRedemptionLog - 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) + const tokenLog = token.requestRedemptionLog + + expect(tokenLog.length).to.equal(1) + expect(tokenLog[0]).to.deep.equal({ + walletPublicKey, + mainUtxo, + redeemerOutputScript, + amount, + }) }) }) diff --git a/typescript/test/utils/mock-tbtc-token.ts b/typescript/test/utils/mock-tbtc-token.ts new file mode 100644 index 000000000..2e37d3e68 --- /dev/null +++ b/typescript/test/utils/mock-tbtc-token.ts @@ -0,0 +1,41 @@ +import { TBTCToken } from "../../src/chain" +import { Hex } from "../../src/hex" +import { BigNumber } from "ethers" +import { UnspentTransactionOutput } from "../../src/bitcoin" + +interface RequestRedemptionLog { + walletPublicKey: string + mainUtxo: UnspentTransactionOutput + redeemerOutputScript: string + amount: BigNumber +} + +export class MockTBTCToken implements TBTCToken { + private _requestRedemptionLog: RequestRedemptionLog[] = [] + + get requestRedemptionLog() { + return this._requestRedemptionLog + } + + totalSupply(blockNumber?: number | undefined): Promise { + throw new Error("Method not implemented.") + } + + async requestRedemption( + walletPublicKey: string, + mainUtxo: UnspentTransactionOutput, + redeemerOutputScript: string, + amount: BigNumber + ): Promise { + this._requestRedemptionLog.push({ + walletPublicKey, + mainUtxo, + redeemerOutputScript, + amount, + }) + + return Hex.from( + "0xf7d0c92c8de4d117d915c2a8a54ee550047f926bc00b91b651c40628751cfe29" + ) + } +}