Skip to content

Commit

Permalink
Request redemption in one transaction (#632)
Browse files Browse the repository at this point in the history
This PR refactors the `requestRedemption` function to request redemption
in one transaction(instead of 2: approve + requestRedemption) using the
[`approveAndCall`
](https://github.com/thesis/solidity-contracts/blob/main/contracts/token/ERC20WithPermit.sol#L207)
function from tBTC token contract. Then the tBTC token contract calls
the
[`receiveApproval`](https://github.com/keep-network/tbtc-v2/blob/main/solidity/contracts/vault/TBTCVault.sol#L193)
function from the `TBTCVault` contract which [burns tBTC tokens and
requests
redemption](https://github.com/keep-network/tbtc-v2/blob/main/solidity/contracts/vault/TBTCVault.sol#L338).
  • Loading branch information
pdyraga authored Jul 6, 2023
2 parents 9596f80 + 885ddd8 commit f5f300b
Show file tree
Hide file tree
Showing 8 changed files with 348 additions and 24 deletions.
11 changes: 10 additions & 1 deletion typescript/src/bitcoin.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"))
}
23 changes: 23 additions & 0 deletions typescript/src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BigNumber>

/**
* 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<Hex>
}
95 changes: 95 additions & 0 deletions typescript/src/ethereum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hex> {
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<ContractTransaction>(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,
}
}
}
22 changes: 12 additions & 10 deletions typescript/src/redemption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void> {
await bridge.requestRedemption(
tBTCToken: TBTCToken
): Promise<Hex> {
return await tBTCToken.requestRedemption(
walletPublicKey,
mainUtxo,
redeemerOutputScript,
Expand Down
69 changes: 69 additions & 0 deletions typescript/test/bitcoin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
hashLEToBigNumber,
bitsToTarget,
targetToDifficulty,
createOutputScriptFromAddress,
} from "../src/bitcoin"
import { calculateDepositRefundLocktime } from "../src/deposit"
import { BitcoinNetwork } from "../src/bitcoin-network"
Expand Down Expand Up @@ -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)
})
}
)
})
})
})
89 changes: 86 additions & 3 deletions typescript/test/ethereum.test.ts
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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,
])
})
})
})
})
Loading

0 comments on commit f5f300b

Please sign in to comment.