Skip to content

Commit

Permalink
Merge branch 'main' into get-btc-address-from-script-pub-key
Browse files Browse the repository at this point in the history
  • Loading branch information
r-czajkowski committed Jul 12, 2023
2 parents 5ceed1e + ec34e17 commit 51a63a7
Show file tree
Hide file tree
Showing 14 changed files with 1,240 additions and 12 deletions.
1 change: 1 addition & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ e032bba7ce52a877a77573fb5a14944623c77d02
7ad277ff8369b201515ba22872c1251ec93a6b81
5c7e2e3620ec06fb9a7c358c541d491da977ec08
3d8c4861ac467f0390733916775a9ccfafe752e3
921349ff07db0effe9bc658afab6961ff262f35f

# s/btc/BTC
da039720b6eb36e5f7102e83a3e2bb95a09b5772
12 changes: 12 additions & 0 deletions typescript/src/bitcoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,18 @@ export interface Client {
address: string
): Promise<UnspentTransactionOutput[]>

/**
* Gets the history of confirmed transactions for given Bitcoin address.
* Returned transactions are sorted from oldest to newest. The returned
* result does not contain unconfirmed transactions living in the mempool
* at the moment of request.
* @param address - Bitcoin address transaction history should be determined for.
* @param limit - Optional parameter that can limit the resulting list to
* a specific number of last transaction. For example, limit = 5 will
* return only the last 5 transactions for the given address.
*/
getTransactionHistory(address: string, limit?: number): Promise<Transaction[]>

/**
* Gets the full transaction object for given transaction hash.
* @param transactionHash - Hash of the transaction.
Expand Down
16 changes: 16 additions & 0 deletions typescript/src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
DkgResultChallengedEvent,
DkgResultSubmittedEvent,
NewWalletRegisteredEvent,
Wallet,
} from "./wallet"
import type { ExecutionLoggerFn } from "./backoff"

Expand Down Expand Up @@ -244,6 +245,21 @@ export interface Bridge {
* Returns the attached WalletRegistry instance.
*/
walletRegistry(): Promise<WalletRegistry>

/**
* Gets details about a registered wallet.
* @param walletPublicKeyHash The 20-byte wallet public key hash (computed
* using Bitcoin HASH160 over the compressed ECDSA public key).
* @returns Promise with the wallet details.
*/
wallets(walletPublicKeyHash: Hex): Promise<Wallet>

/**
* Builds the UTXO hash based on the UTXO components.
* @param utxo UTXO components.
* @returns The hash of the UTXO.
*/
buildUtxoHash(utxo: UnspentTransactionOutput): Hex
}

/**
Expand Down
55 changes: 54 additions & 1 deletion typescript/src/electrum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import bcoin from "bcoin"
import pTimeout from "p-timeout"
import {
Client as BitcoinClient,
createOutputScriptFromAddress,
RawTransaction,
Transaction,
TransactionHash,
Expand Down Expand Up @@ -232,7 +233,7 @@ export class Client implements BitcoinClient {
): Promise<UnspentTransactionOutput[]> {
return this.withElectrum<UnspentTransactionOutput[]>(
async (electrum: Electrum) => {
const script = bcoin.Script.fromAddress(address).toRaw().toString("hex")
const script = createOutputScriptFromAddress(address).toString()

// eslint-disable-next-line camelcase
type UnspentOutput = { tx_pos: number; value: number; tx_hash: string }
Expand All @@ -253,6 +254,58 @@ export class Client implements BitcoinClient {
)
}

// eslint-disable-next-line valid-jsdoc
/**
* @see {BitcoinClient#getTransactionHistory}
*/
getTransactionHistory(
address: string,
limit?: number
): Promise<Transaction[]> {
return this.withElectrum<Transaction[]>(async (electrum: Electrum) => {
const script = createOutputScriptFromAddress(address).toString()

// eslint-disable-next-line camelcase
type HistoryItem = { height: number; tx_hash: string }

let historyItems: HistoryItem[] = await this.withBackoffRetrier<
HistoryItem[]
>()(async () => {
return await electrum.blockchain_scripthash_getHistory(
computeScriptHash(script)
)
})

// According to https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-scripthash-get-history
// unconfirmed items living in the mempool are appended at the end of the
// returned list and their height value is either -1 or 0. That means
// we need to take all items with height >0 to obtain a confirmed txs
// history.
historyItems = historyItems.filter((item) => item.height > 0)

// The list returned from blockchain.scripthash.get_history is sorted by
// the block height in the ascending order though we are sorting it
// again just in case (e.g. API contract changes).
historyItems = historyItems.sort(
(item1, item2) => item1.height - item2.height
)

if (
typeof limit !== "undefined" &&
limit > 0 &&
historyItems.length > limit
) {
historyItems = historyItems.slice(-limit)
}

const transactions = historyItems.map((item) =>
this.getTransaction(TransactionHash.from(item.tx_hash))
)

return Promise.all(transactions)
})
}

// eslint-disable-next-line valid-jsdoc
/**
* @see {BitcoinClient#getTransaction}
Expand Down
82 changes: 75 additions & 7 deletions typescript/src/ethereum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type {
Bridge as ContractBridge,
Deposit as ContractDeposit,
Redemption as ContractRedemption,
Wallets,
} from "../typechain/Bridge"
import type { WalletRegistry as ContractWalletRegistry } from "../typechain/WalletRegistry"
import type { TBTCVault as ContractTBTCVault } from "../typechain/TBTCVault"
Expand All @@ -57,6 +58,8 @@ import {
DkgResultChallengedEvent,
DkgResultSubmittedEvent,
NewWalletRegisteredEvent,
Wallet,
WalletState,
} from "./wallet"

type ContractDepositRequest = ContractDeposit.DepositRequestStructOutput
Expand Down Expand Up @@ -639,18 +642,20 @@ export class Bridge
return undefined
}

const { ecdsaWalletID } = await backoffRetrier<{ ecdsaWalletID: string }>(
this._totalRetryAttempts
)(async () => {
return await this._instance.wallets(activeWalletPublicKeyHash)
})
const { walletPublicKey } = await this.wallets(
Hex.from(activeWalletPublicKeyHash)
)

return walletPublicKey.toString()
}

private async getWalletCompressedPublicKey(ecdsaWalletID: Hex): Promise<Hex> {
const walletRegistry = await this.walletRegistry()
const uncompressedPublicKey = await walletRegistry.getWalletPublicKey(
Hex.from(ecdsaWalletID)
ecdsaWalletID
)

return compressPublicKey(uncompressedPublicKey)
return Hex.from(compressPublicKey(uncompressedPublicKey))
}

// eslint-disable-next-line valid-jsdoc
Expand Down Expand Up @@ -694,6 +699,69 @@ export class Bridge
signerOrProvider: this._instance.signer || this._instance.provider,
})
}

// eslint-disable-next-line valid-jsdoc
/**
* @see {ChainBridge#wallets}
*/
async wallets(walletPublicKeyHash: Hex): Promise<Wallet> {
const wallet = await backoffRetrier<Wallets.WalletStructOutput>(
this._totalRetryAttempts
)(async () => {
return await this._instance.wallets(
walletPublicKeyHash.toPrefixedString()
)
})

return this.parseWalletDetails(wallet)
}

/**
* Parses a wallet data using data fetched from the on-chain contract.
* @param wallet Data of the wallet.
* @returns Parsed wallet data.
*/
private async parseWalletDetails(
wallet: Wallets.WalletStructOutput
): Promise<Wallet> {
const ecdsaWalletID = Hex.from(wallet.ecdsaWalletID)

return {
ecdsaWalletID,
walletPublicKey: await this.getWalletCompressedPublicKey(ecdsaWalletID),
mainUtxoHash: Hex.from(wallet.mainUtxoHash),
pendingRedemptionsValue: wallet.pendingRedemptionsValue,
createdAt: wallet.createdAt,
movingFundsRequestedAt: wallet.movingFundsRequestedAt,
closingStartedAt: wallet.closingStartedAt,
pendingMovedFundsSweepRequestsCount:
wallet.pendingMovedFundsSweepRequestsCount,
state: WalletState.parse(wallet.state),
movingFundsTargetWalletsCommitmentHash: Hex.from(
wallet.movingFundsTargetWalletsCommitmentHash
),
}
}

// eslint-disable-next-line valid-jsdoc
/**
* Builds the UTXO hash based on the UTXO components. UTXO hash is computed as
* `keccak256(txHash | txOutputIndex | txOutputValue)`.
*
* @see {ChainBridge#buildUtxoHash}
*/
buildUtxoHash(utxo: UnspentTransactionOutput): Hex {
return Hex.from(
utils.solidityKeccak256(
["bytes32", "uint32", "uint64"],
[
utxo.transactionHash.reverse().toPrefixedString(),
utxo.outputIndex,
utxo.value,
]
)
)
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
requestRedemption,
submitRedemptionProof,
getRedemptionRequest,
findWalletForRedemption,
} from "./redemption"

import {
Expand All @@ -31,6 +32,7 @@ export const TBTC = {
getRevealedDeposit,
requestRedemption,
getRedemptionRequest,
findWalletForRedemption,
}

export const SpvMaintainer = {
Expand Down
110 changes: 110 additions & 0 deletions typescript/src/redemption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from "./bitcoin"
import { Bridge, Identifier, TBTCToken } from "./chain"
import { assembleTransactionProof } from "./proof"
import { determineWalletMainUtxo, WalletState } from "./wallet"
import { BitcoinNetwork } from "./bitcoin-network"
import { Hex } from "./hex"

/**
Expand Down Expand Up @@ -408,3 +410,111 @@ export async function getRedemptionRequest(

return redemptionRequests[0]
}

/**
* Finds the oldest active wallet that has enough BTC to handle a redemption request.
* @param amount The amount to be redeemed in satoshis.
* @param redeemerOutputScript The redeemer output script the redeemed funds
* are supposed to be locked on. Must be un-prefixed and not prepended
* with length.
* @param bitcoinNetwork Bitcoin network.
* @param bridge The handle to the Bridge on-chain contract.
* @param bitcoinClient Bitcoin client used to interact with the network.
* @returns Promise with the wallet details needed to request a redemption.
*/
export async function findWalletForRedemption(
amount: BigNumber,
redeemerOutputScript: string,
bitcoinNetwork: BitcoinNetwork,
bridge: Bridge,
bitcoinClient: BitcoinClient
): Promise<{
walletPublicKey: string
mainUtxo: UnspentTransactionOutput
}> {
const wallets = await bridge.getNewWalletRegisteredEvents()

let walletData:
| {
walletPublicKey: string
mainUtxo: UnspentTransactionOutput
}
| undefined = undefined
let maxAmount = BigNumber.from(0)

for (const wallet of wallets) {
const { walletPublicKeyHash } = wallet
const { state, walletPublicKey, pendingRedemptionsValue } =
await bridge.wallets(walletPublicKeyHash)

// Wallet must be in Live state.
if (state !== WalletState.Live) {
console.debug(
`Wallet is not in Live state ` +
`(wallet public key hash: ${walletPublicKeyHash.toString()}). ` +
`Continue the loop execution to the next wallet...`
)
continue
}

// Wallet must have a main UTXO that can be determined.
const mainUtxo = await determineWalletMainUtxo(
walletPublicKeyHash,
bridge,
bitcoinClient,
bitcoinNetwork
)
if (!mainUtxo) {
console.debug(
`Could not find matching UTXO on chains ` +
`for wallet public key hash (${walletPublicKeyHash.toString()}). ` +
`Continue the loop execution to the next wallet...`
)
continue
}

const pendingRedemption = await bridge.pendingRedemptions(
walletPublicKey.toString(),
redeemerOutputScript
)

if (pendingRedemption.requestedAt != 0) {
console.debug(
`There is a pending redemption request from this wallet to the ` +
`same Bitcoin address. Given wallet public key hash` +
`(${walletPublicKeyHash.toString()}) and redeemer output script ` +
`(${redeemerOutputScript}) pair can be used for only one ` +
`pending request at the same time. ` +
`Continue the loop execution to the next wallet...`
)
continue
}

const walletBTCBalance = mainUtxo.value.sub(pendingRedemptionsValue)

// Save the max possible redemption amount.
maxAmount = walletBTCBalance.gt(maxAmount) ? walletBTCBalance : maxAmount

if (walletBTCBalance.gte(amount)) {
walletData = {
walletPublicKey: walletPublicKey.toString(),
mainUtxo,
}

break
}

console.debug(
`The wallet (${walletPublicKeyHash.toString()})` +
`cannot handle the redemption request. ` +
`Continue the loop execution to the next wallet...`
)
}

if (!walletData)
throw new Error(
`Could not find a wallet with enough funds. Maximum redemption amount is ${maxAmount} Satoshi.`
)

return walletData
}
Loading

0 comments on commit 51a63a7

Please sign in to comment.