Skip to content

Commit

Permalink
Wait for Bitcoin funding transaction (#262)
Browse files Browse the repository at this point in the history
Closes: #255

We want to initiate the stake when we are sure the BTC funding
transaction is visible by Bitcoin node. The default implementation of
minting in tbtc-v2 throws an error when the deposit tx is unavailable.
In our SDK, we make sure the deposit transaction is available and then
initiate minting/staking. Here we add the back off mechanism - the SDK
retires the function that looks for a deposit transaction a `retries`
number of times. The result will return the function's return value if
no exceptions are thrown. In our case, we want to try to find the
deposit transaction 5 times with a 5 seconds backoff step that will be
increased exponentially for subsequent retry attempts. If it fails after
6 tries it will throw an error. The consumer (dapp) can override these
options by passing them to `stake` function.
  • Loading branch information
nkuba authored Mar 13, 2024
2 parents 0ed5b1e + e53aaec commit ee213c0
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,8 @@ export default function DepositBTCModal() {

const onDepositBTCSuccess = useCallback(() => {
setStatus(PROCESS_STATUSES.LOADING)
// Let's call the stake function with a delay,
// to make sure for the moment that it doesn't return an error about funds not found
// TODO: Remove the delay when SDK is updated
setTimeout(() => {
logPromiseFailure(handleStake())
}, 10000)

logPromiseFailure(handleStake())
}, [setStatus, handleStake])

const { sendBitcoinTransaction } =
Expand Down
5 changes: 5 additions & 0 deletions sdk/src/lib/utils/backoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { backoffRetrier } from "@keep-network/tbtc-v2.ts"

type BackoffRetrierParameters = Parameters<typeof backoffRetrier>

export { backoffRetrier, BackoffRetrierParameters }
1 change: 1 addition & 0 deletions sdk/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./hex"
export * from "./ethereum-signer"
export * from "./backoff"
49 changes: 42 additions & 7 deletions sdk/src/modules/staking/stake-initialization.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import {
ChainIdentifier,
Deposit as TbtcDeposit,
} from "@keep-network/tbtc-v2.ts"
import { Deposit as TbtcDeposit } from "@keep-network/tbtc-v2.ts"
import {
ChainEIP712Signer,
ChainSignedMessage,
Domain,
Message,
Types,
} from "../../lib/eip712-signer"
import { AcreContracts, DepositReceipt } from "../../lib/contracts"
import { Hex } from "../../lib/utils"
import {
AcreContracts,
DepositReceipt,
ChainIdentifier,
} from "../../lib/contracts"
import { Hex, backoffRetrier, BackoffRetrierParameters } from "../../lib/utils"

type StakeOptions = {
/**
* The number of retries to perform before bubbling the failure out.
* @see backoffRetrier for more details.
*/
retires: BackoffRetrierParameters[0]
/**
* Initial backoff step in milliseconds that will be increased exponentially
* for subsequent retry attempts. (default = 1000 ms)
* @see backoffRetrier for more details.
*/
backoffStepMs: BackoffRetrierParameters[1]
}

/**
* Represents an instance of the staking flow. Staking flow requires a few steps
Expand Down Expand Up @@ -138,15 +153,35 @@ class StakeInitialization {
* does not exist.
* @dev Use it as the last step of the staking flow. It requires signed
* staking message otherwise throws an error.
* @param options Optional options parameters to initialize stake.
* @see StakeOptions for more details.
* @returns Transaction hash of the stake initiation transaction.
*/
async stake(): Promise<Hex> {
async stake(
options: StakeOptions = { retires: 5, backoffStepMs: 5_000 },
): Promise<Hex> {
if (!this.#signedMessage) {
throw new Error("Sign message first")
}

await this.#waitForBitcoinFundingTx(options)

return this.#tbtcDeposit.initiateMinting()
}

async #waitForBitcoinFundingTx({
retires,
backoffStepMs,
}: StakeOptions): Promise<void> {
await backoffRetrier<void>(
retires,
backoffStepMs,
)(async () => {
const utxos = await this.#tbtcDeposit.detectFunding()

if (utxos.length === 0) throw new Error("Deposit not funded yet")
})
}
}

// eslint-disable-next-line import/prefer-default-export
Expand Down
84 changes: 82 additions & 2 deletions sdk/test/modules/staking.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EthereumAddress } from "@keep-network/tbtc-v2.ts"
import { BitcoinTxHash } from "@keep-network/tbtc-v2.ts"
import { ethers } from "ethers"
import {
AcreContracts,
Expand All @@ -7,6 +7,7 @@ import {
StakeInitialization,
DepositorProxy,
DepositReceipt,
EthereumAddress,
} from "../../src"
import { MockAcreContracts } from "../utils/mock-acre-contracts"
import { MockMessageSigner } from "../utils/mock-message-signer"
Expand Down Expand Up @@ -36,6 +37,11 @@ const stakingModuleData: {
const stakingInitializationData: {
depositReceipt: Omit<DepositReceipt, "depositor">
mockedInitializeTxHash: Hex
fundingUtxo: {
transactionHash: BitcoinTxHash
outputIndex: number
value: bigint
}
} = {
depositReceipt: {
blindingFactor: Hex.from("555555"),
Expand All @@ -45,6 +51,13 @@ const stakingInitializationData: {
extraData: stakingModuleData.initializeStake.extraData,
},
mockedInitializeTxHash: Hex.from("999999"),
fundingUtxo: {
transactionHash: BitcoinTxHash.from(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
),
outputIndex: 1,
value: 2222n,
},
}

describe("Staking", () => {
Expand Down Expand Up @@ -213,11 +226,12 @@ describe("Staking", () => {

describe("when message has already been signed", () => {
let tx: Hex
const { mockedInitializeTxHash: mockedTxHash } =
const { mockedInitializeTxHash: mockedTxHash, fundingUtxo } =
stakingInitializationData

beforeAll(async () => {
mockedDeposit.initiateMinting.mockResolvedValue(mockedTxHash)
mockedDeposit.detectFunding.mockResolvedValue([fundingUtxo])
await result.signMessage()

tx = await result.stake()
Expand All @@ -231,6 +245,72 @@ describe("Staking", () => {
expect(tx).toBe(mockedTxHash)
})
})

describe("when waiting for bitcoin deposit tx", () => {
const { mockedInitializeTxHash: mockedTxHash } =
stakingInitializationData

describe("when can't find transaction after max number of retries", () => {
beforeEach(async () => {
jest.useFakeTimers()

mockedDeposit.initiateMinting.mockResolvedValue(mockedTxHash)
mockedDeposit.detectFunding.mockClear()
mockedDeposit.detectFunding.mockResolvedValue([])

await result.signMessage()
})

it("should throw an error", async () => {
// eslint-disable-next-line no-void
void expect(result.stake()).rejects.toThrow(
"Deposit not funded yet",
)

await jest.runAllTimersAsync()

expect(mockedDeposit.detectFunding).toHaveBeenCalledTimes(6)
})
})

describe("when the funding tx is available", () => {
const { fundingUtxo } = stakingInitializationData
let txPromise: Promise<Hex>

beforeAll(async () => {
jest.useFakeTimers()

mockedDeposit.initiateMinting.mockResolvedValue(mockedTxHash)

mockedDeposit.detectFunding.mockClear()
mockedDeposit.detectFunding
// First attempt. Deposit not funded yet.
.mockResolvedValueOnce([])
// Second attempt. Deposit funded.
.mockResolvedValueOnce([fundingUtxo])

await result.signMessage()

txPromise = result.stake()

await jest.runAllTimersAsync()
})

it("should wait for deposit transaction", () => {
expect(mockedDeposit.detectFunding).toHaveBeenCalledTimes(2)
})

it("should stake tokens via tbtc depositor proxy", () => {
expect(mockedDeposit.initiateMinting).toHaveBeenCalled()
})

it("should return transaction hash", async () => {
const txHash = await txPromise

expect(txHash).toBe(mockedTxHash)
})
})
})
})
})
})
Expand Down

0 comments on commit ee213c0

Please sign in to comment.