Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Estimate staking fees #281

Merged
merged 24 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2b3a1a3
Add `depositorFeeDivisor` to Depositor contract
r-czajkowski Feb 29, 2024
61dc761
Estimate staking fees
r-czajkowski Feb 29, 2024
7bbdcf8
Expose fee breakdown for staking operation
r-czajkowski Feb 29, 2024
57fdf57
Merge branch 'main' into sdk-estimate-staking-fees
r-czajkowski Mar 14, 2024
166dac9
Group the staking fees by networks
r-czajkowski Mar 14, 2024
b567014
Expose the `estimateStakingFees` in staking module
r-czajkowski Mar 14, 2024
ac1c50b
Update `estimateStakingFees`
r-czajkowski Mar 14, 2024
6574179
Update `estimateStakingFees` Ethereum function
r-czajkowski Mar 14, 2024
de56282
Update `estimateStakingFees` in staking module
r-czajkowski Mar 18, 2024
554c75e
Merge branch 'main' into sdk-estimate-staking-fees
r-czajkowski Mar 18, 2024
59c15b8
Rename field in `AcreStakingFees` type
r-czajkowski Mar 18, 2024
c706f88
Combine all tBTC Bridge params in one obj and fn
r-czajkowski Mar 18, 2024
a743b88
Add `fromSatoshi` utils function
r-czajkowski Mar 18, 2024
fb089a5
Leave `TODO`
r-czajkowski Mar 18, 2024
a127322
Cache `depositorFeeDivisor`
r-czajkowski Mar 18, 2024
013880d
Merge branch 'main' into sdk-estimate-staking-fees
r-czajkowski Apr 16, 2024
26a1c63
Replace `staking` term with `deposit`
r-czajkowski Apr 16, 2024
4a454b8
Update deposit fees calculation
r-czajkowski Apr 16, 2024
b26a8f9
Update `estimateDepositFees` fn
r-czajkowski Apr 16, 2024
0128a15
Fix typos in comments
r-czajkowski Apr 16, 2024
cd2da4b
Simplify statement in `depositorFeeDivisor` fn
r-czajkowski Apr 16, 2024
1614c5a
Make function names consistent
r-czajkowski Apr 16, 2024
211bba7
Cache the `entryFeeBasisPoints` value
r-czajkowski Apr 16, 2024
1a21936
Rename variables/types/functions in staking module
r-czajkowski Apr 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/contracts/TbtcDepositor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ pragma solidity ^0.8.21;
import "@keep-network/tbtc-v2/contracts/integrator/AbstractTBTCDepositor.sol";

contract TbtcDepositor is AbstractTBTCDepositor {
uint64 public depositorFeeDivisor;

function initializeStakeRequest(
IBridgeTypes.BitcoinTxInfo calldata fundingTx,
IBridgeTypes.DepositRevealInfo calldata reveal,
Expand Down
27 changes: 27 additions & 0 deletions sdk/src/lib/contracts/tbtc-depositor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,31 @@ export type DecodedExtraData = {
referral: number
}

export type StakingFees = {
/**
* The tBTC treasury fee taken from each deposit and transferred to the
* treasury upon sweep proof submission. Is calculated based on the initial
* funding transaction amount.
*/
treasuryFee: bigint
/**
* The tBTC optimistic minting fee, Is calculated AFTER the treasury fee is
* cut.
*/
optimisticMintingFee: bigint
/**
* Maximum amount of BTC transaction fee that can
* be incurred by each swept deposit being part of the given sweep
* transaction.
*/
depositTxMaxFee: bigint
/**
* The Acre network depositor fee taken from each deposit and transferred to
* the treasury upon stake request finalization.
*/
depositorFee: bigint
}

/**
* Interface for communication with the TBTCDepositor on-chain contract.
*/
Expand All @@ -35,4 +60,6 @@ export interface TBTCDepositor extends DepositorProxy {
* @param extraData Encoded extra data.
*/
decodeExtraData(extraData: string): DecodedExtraData

estimateStakingFees(amountToStake: bigint): Promise<StakingFees>
}
90 changes: 90 additions & 0 deletions sdk/src/lib/ethereum/tbtc-depositor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import {
isAddress,
solidityPacked,
zeroPadBytes,
Contract,
} from "ethers"
import {
ChainIdentifier,
DecodedExtraData,
TBTCDepositor,
DepositReceipt,
StakingFees,
} from "../contracts"
import { BitcoinRawTxVectors } from "../bitcoin"
import { EthereumAddress } from "./address"
Expand All @@ -26,6 +28,11 @@ import { EthereumNetwork } from "./network"

import SepoliaTbtcDepositor from "./artifacts/sepolia/TbtcDepositor.json"

type TbtcDepositParameters = {
depositTreasuryFeeDivisor: bigint
depositTxMaxFee: bigint
}

/**
* Ethereum implementation of the TBTCDepositor.
*/
Expand All @@ -36,6 +43,15 @@ class EthereumTBTCDepositor
extends EthersContractWrapper<TbtcDepositorTypechain>
implements TBTCDepositor
{
/**
* Multiplier to convert satoshi to tBTC token units.
*/
readonly #satoshiMultiplier = 10n ** 10n

#tbtcBridgeDepositsParameters: TbtcDepositParameters | undefined

#tbtcOptimisticMintingFeeDivisor: bigint | undefined

constructor(config: EthersContractConfig, network: EthereumNetwork) {
let artifact: EthersContractDeployment

Expand Down Expand Up @@ -128,6 +144,80 @@ class EthereumTBTCDepositor

return { staker, referral }
}

async estimateStakingFees(amountToStake: bigint): Promise<StakingFees> {
const { depositTreasuryFeeDivisor, depositTxMaxFee } =
await this.#getTbtcDepositParameters()

const treasuryFee = amountToStake / depositTreasuryFeeDivisor

// Both deposit amount and treasury fee are in the 1e8 satoshi precision.
// We need to convert them to the 1e18 TBTC precision.
const amountSubTreasury =
(amountToStake - treasuryFee) * this.#satoshiMultiplier

const optimisticMintingFeeDivisor =
await this.#getTbtcOptimisticMintingFeeDivisor()
const optimisticMintingFee =
optimisticMintingFeeDivisor > 0
? amountSubTreasury / optimisticMintingFeeDivisor
: 0n

const depositorFeeDivisor = await this.instance.depositorFeeDivisor()
// Compute depositor fee. The fee is calculated based on the initial funding
// transaction amount, before the tBTC protocol network fees were taken.
const depositorFee =
depositorFeeDivisor > 0n
? (amountToStake * this.#satoshiMultiplier) / depositorFeeDivisor
: 0n

// TODO: Maybe we should group fees by network? Eg.:
// `const fess = { tbtc: {...}, acre: {...}}`
return {
treasuryFee: treasuryFee * this.#satoshiMultiplier,
optimisticMintingFee,
depositTxMaxFee: depositTxMaxFee * this.#satoshiMultiplier,
depositorFee,
}
}

async #getTbtcDepositParameters(): Promise<TbtcDepositParameters> {
if (this.#tbtcBridgeDepositsParameters) {
return this.#tbtcBridgeDepositsParameters
}

const bridgeAddress = await this.instance.bridge()

const bridge = new Contract(bridgeAddress, [
"function depositsParameters()",
])

const depositsParameters =
(await bridge.depositsParameters()) as TbtcDepositParameters

this.#tbtcBridgeDepositsParameters = depositsParameters

return depositsParameters
}

async #getTbtcOptimisticMintingFeeDivisor(): Promise<bigint> {
if (this.#tbtcOptimisticMintingFeeDivisor) {
return this.#tbtcOptimisticMintingFeeDivisor
}

const vaultAddress = await this.getTbtcVaultChainIdentifier()

const vault = new Contract(`0x${vaultAddress.identifierHex}`, [
"function optimisticMintingFeeDivisor()",
])

const optimisticMintingFeeDivisor =
(await vault.optimisticMintingFeeDivisor()) as bigint

this.#tbtcOptimisticMintingFeeDivisor = optimisticMintingFeeDivisor

return optimisticMintingFeeDivisor
}
}

export { EthereumTBTCDepositor, packRevealDepositParameters }
6 changes: 5 additions & 1 deletion sdk/src/modules/staking/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ChainIdentifier, TBTC } from "@keep-network/tbtc-v2.ts"
import { AcreContracts, DepositorProxy } from "../../lib/contracts"
import { AcreContracts, DepositorProxy, StakingFees } from "../../lib/contracts"
import { ChainEIP712Signer } from "../../lib/eip712-signer"
import { StakeInitialization } from "./stake-initialization"

Expand Down Expand Up @@ -62,6 +62,10 @@ class StakingModule {
deposit,
)
}

async estimateStakingFees(amount: bigint): Promise<StakingFees> {
return this.#contracts.tbtcDepositor.estimateStakingFees(amount)
}
}

export { StakingModule, StakeInitialization }
169 changes: 161 additions & 8 deletions sdk/test/lib/ethereum/tbtc-depositor.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import ethers, { Contract, ZeroAddress } from "ethers"
import ethers, { Contract, ZeroAddress, getAddress } from "ethers"
import {
EthereumTBTCDepositor,
EthereumAddress,
Hex,
EthereumSigner,
StakingFees,
} from "../../../src"
import { extraDataValidTestData } from "./data"

Expand All @@ -12,26 +13,44 @@ jest.mock("ethers", (): object => ({
...jest.requireActual("ethers"),
}))

const testData = {
depositorFeeDivisor: 1000n,
depositParameters: {
depositTreasuryFeeDivisor: 2_000n, // 1/2000 == 5bps == 0.05% == 0.0005
depositTxMaxFee: 100_000n, // 100000 satoshi = 0.001 BTC
},
optimisticMintingFeeDivisor: 500n, // 1/500 = 0.002 = 0.2%0
}

describe("TBTCDepositor", () => {
const spyOnEthersDataSlice = jest.spyOn(ethers, "dataSlice")
const spyOnEthersContract = jest.spyOn(ethers, "Contract")

const vaultAddress = EthereumAddress.from(
ethers.Wallet.createRandom().address,
)
const bridgeAddress = EthereumAddress.from(
ethers.Wallet.createRandom().address,
)

const mockedContractInstance = {
tbtcVault: jest.fn().mockImplementation(() => vaultAddress.identifierHex),
tbtcVault: jest
.fn()
.mockImplementation(() => `0x${vaultAddress.identifierHex}`),
initializeStakeRequest: jest.fn(),
bridge: jest.fn().mockResolvedValue(`0x${bridgeAddress.identifierHex}`),
depositorFeeDivisor: jest
.fn()
.mockResolvedValue(testData.depositorFeeDivisor),
}

let depositor: EthereumTBTCDepositor
let depositorAddress: EthereumAddress

beforeEach(async () => {
jest
.spyOn(ethers, "Contract")
.mockImplementationOnce(
() => mockedContractInstance as unknown as Contract,
)
beforeAll(async () => {
spyOnEthersContract.mockImplementationOnce(
() => mockedContractInstance as unknown as Contract,
)

// TODO: get the address from artifact imported from `core` package.
depositorAddress = EthereumAddress.from(
Expand Down Expand Up @@ -243,4 +262,138 @@ describe("TBTCDepositor", () => {
},
)
})

describe("estimateStakingFees", () => {
const mockedBridgeContractInstance = {
depositsParameters: jest
.fn()
.mockResolvedValue(testData.depositParameters),
}

const mockedVaultContractInstance = {
optimisticMintingFeeDivisor: jest
.fn()
.mockResolvedValue(testData.optimisticMintingFeeDivisor),
}

const amountToStake = 10_000_000n // 0.1 BTC

const expectedResult = {
// The fee is calculated based on the initial funding
// transaction amount. `amountToStake / depositTreasuryFeeDivisor`
// 0.00005 tBTC in 1e18 precision.
treasuryFee: 50000000000000n,
// Maximum amount of BTC transaction fee that can
// be incurred by each swept deposit being part of the given sweep
// transaction.
// 0.001 tBTC in 1e18 precision.
depositTxMaxFee: 1000000000000000n,
// Divisor used to compute the depositor fee taken from each deposit
// and transferred to the treasury upon stake request finalization.
// `depositorFee = depositedAmount / depositorFeeDivisor`
// 0.0001 tBTC in 1e18 precision.
depositorFee: 100000000000000n,
// The optimistic fee is a percentage AFTER
// the treasury fee is cut:
// `fee = (depositAmount - treasuryFee) / optimisticMintingFeeDivisor`
// 0.0001999 tBTC in 1e18 precision.
optimisticMintingFee: 199900000000000n,
}

beforeAll(() => {
spyOnEthersContract.mockClear()

spyOnEthersContract.mockImplementation((target: string) => {
if (getAddress(target) === getAddress(bridgeAddress.identifierHex))
return mockedBridgeContractInstance as unknown as Contract
if (getAddress(target) === getAddress(vaultAddress.identifierHex))
return mockedVaultContractInstance as unknown as Contract

throw new Error("Cannot create mocked contract instance")
})
})

describe("when network fees are not yet cached", () => {
let result: StakingFees

beforeAll(async () => {
result = await depositor.estimateStakingFees(amountToStake)
})

it("should get the bridge contract address", () => {
expect(mockedContractInstance.bridge).toHaveBeenCalled()
})

it("should create the ethers Contract instance of the Bridge contract", () => {
expect(Contract).toHaveBeenNthCalledWith(
1,
`0x${bridgeAddress.identifierHex}`,
["function depositsParameters()"],
)
})

it("should get the deposit parameters from chain", () => {
expect(
mockedBridgeContractInstance.depositsParameters,
).toHaveBeenCalled()
})

it("should get the vault contract address", () => {
expect(mockedContractInstance.tbtcVault).toHaveBeenCalled()
})

it("should create the ethers Contract instance of the Bridge contract", () => {
expect(Contract).toHaveBeenNthCalledWith(
2,
`0x${vaultAddress.identifierHex}`,
["function optimisticMintingFeeDivisor()"],
)
})

it("should get the optimistic minting fee divisor", () => {
expect(
mockedVaultContractInstance.optimisticMintingFeeDivisor,
).toHaveBeenCalled()
})

it("should get the depositor fee divisor", () => {
expect(mockedContractInstance.depositorFeeDivisor).toHaveBeenCalled()
})

it("should return correct fees", () => {
expect(result).toMatchObject(expectedResult)
})
})

describe("when network fees are already cached", () => {
let result2: StakingFees

beforeAll(async () => {
mockedContractInstance.bridge.mockClear()
mockedContractInstance.tbtcVault.mockClear()
mockedBridgeContractInstance.depositsParameters.mockClear()
mockedVaultContractInstance.optimisticMintingFeeDivisor.mockClear()

result2 = await depositor.estimateStakingFees(amountToStake)
})

it("should get the deposit parameters from cache", () => {
expect(mockedContractInstance.bridge).toHaveBeenCalledTimes(0)
expect(
mockedBridgeContractInstance.depositsParameters,
).toHaveBeenCalledTimes(0)
})

it("should get the optimistic minting fee divisor from cache", () => {
expect(mockedContractInstance.tbtcVault).toHaveBeenCalledTimes(0)
expect(
mockedVaultContractInstance.optimisticMintingFeeDivisor,
).toHaveBeenCalledTimes(0)
})

it("should return correct fees", () => {
expect(result2).toMatchObject(expectedResult)
})
})
})
})
Loading
Loading