Skip to content

Implement tBTC Bridge fees reimbursement #942

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

Merged
merged 19 commits into from
Jun 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
467 changes: 237 additions & 230 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

71 changes: 71 additions & 0 deletions solidity/contracts/BitcoinDepositor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "@keep-network/tbtc-v2/contracts/integrator/AbstractTBTCDepositor.sol";

import {stBTC} from "./stBTC.sol";
import {FeesReimbursementPool} from "./FeesReimbursementPool.sol";

/// @title Bitcoin Depositor contract.
/// @notice The contract integrates Acre depositing with tBTC minting.
Expand Down Expand Up @@ -76,6 +77,16 @@ contract BitcoinDepositor is AbstractTBTCDepositor, Ownable2StepUpgradeable {
/// `1/50 = 0.02 = 2%`.
uint64 public depositorFeeDivisor;

/// @notice Fees reimbursement pool.
FeesReimbursementPool public feesReimbursementPool;

/// @notice Maximum deposit amount threshold for tBTC Bridge fees reimbursement.
/// For deposits below this threshold, the fees will be reimbursed
/// from the fees reimbursement pool.
/// @dev If the threshold is set to 0, the fees reimbursement is disabled.
/// The threshold is in tBTC token precision.
uint256 public bridgeFeesReimbursementThreshold;

/// @notice Emitted when a deposit is initialized.
/// @dev Deposit details can be fetched from {{ Bridge.DepositRevealed }}
/// event emitted in the same transaction.
Expand Down Expand Up @@ -117,6 +128,17 @@ contract BitcoinDepositor is AbstractTBTCDepositor, Ownable2StepUpgradeable {
/// @param depositorFeeDivisor New value of the depositor fee divisor.
event DepositorFeeDivisorUpdated(uint64 depositorFeeDivisor);

/// @notice Emitted when a fees reimbursement pool is updated.
/// @param newFeesReimbursementPool New value of the fees reimbursement pool.
event FeesReimbursementPoolUpdated(address newFeesReimbursementPool);

/// @notice Emitted when a tBTC Bridge fees reimbursement threshold is updated.
/// @param bridgeFeesReimbursementThreshold New value of the tBTC Bridge fees
/// reimbursement threshold.
event BridgeFeesReimbursementThresholdUpdated(
uint256 bridgeFeesReimbursementThreshold
);

/// Reverts if the tBTC Token address is zero.
error TbtcTokenZeroAddress();

Expand Down Expand Up @@ -145,6 +167,9 @@ contract BitcoinDepositor is AbstractTBTCDepositor, Ownable2StepUpgradeable {
uint256 bridgeMinDepositAmount
);

/// @dev Attempted to set fees reimbursement pool to a zero address.
error FeesReimbursementPoolZeroAddress();

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
Expand Down Expand Up @@ -258,6 +283,25 @@ contract BitcoinDepositor is AbstractTBTCDepositor, Ownable2StepUpgradeable {
bytes32 extraData
) = _finalizeDeposit(depositKey);

if (
bridgeFeesReimbursementThreshold > 0 &&
initialAmount <= bridgeFeesReimbursementThreshold
) {
uint256 tbtcBridgeFee = initialAmount - tbtcAmount;

if (tbtcBridgeFee > 0) {
if (address(feesReimbursementPool) == address(0)) {
revert FeesReimbursementPoolZeroAddress();
}

uint256 reimbursedAmount = feesReimbursementPool.reimburse(
tbtcBridgeFee
);

tbtcAmount += reimbursedAmount;
}
}

// Compute depositor fee. The fee is calculated based on the initial funding
// transaction amount, before the tBTC protocol network fees were taken.
uint256 depositorFee = depositorFeeDivisor > 0
Expand Down Expand Up @@ -325,6 +369,33 @@ contract BitcoinDepositor is AbstractTBTCDepositor, Ownable2StepUpgradeable {
emit DepositorFeeDivisorUpdated(newDepositorFeeDivisor);
}

/// @notice Updates the fees reimbursement pool contract address.
/// @param newFeesReimbursementPool New address of the fees reimbursement pool.
function updateFeesReimbursementPool(
address newFeesReimbursementPool
) external onlyOwner {
if (address(newFeesReimbursementPool) == address(0)) {
revert FeesReimbursementPoolZeroAddress();
}

emit FeesReimbursementPoolUpdated(newFeesReimbursementPool);

feesReimbursementPool = FeesReimbursementPool(newFeesReimbursementPool);
}

/// @notice Updates the tBTC Bridge fees reimbursement threshold.
/// @param newBridgeFeesReimbursementThreshold New value of the tBTC Bridge fees
/// reimbursement threshold.
function updateBridgeFeesReimbursementThreshold(
uint256 newBridgeFeesReimbursementThreshold
) external onlyOwner {
emit BridgeFeesReimbursementThresholdUpdated(
newBridgeFeesReimbursementThreshold
);

bridgeFeesReimbursementThreshold = newBridgeFeesReimbursementThreshold;
}

/// @notice Encodes deposit owner address and referral as extra data.
/// @dev Packs the data to bytes32: 20 bytes of deposit owner address and
/// 2 bytes of referral, 10 bytes of trailing zeros.
Expand Down
90 changes: 90 additions & 0 deletions solidity/contracts/FeesReimbursementPool.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity 0.8.24;

import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/// @title Fees Reimbursement Pool
/// @notice A contract that allows the Bitcoin Depositor to reimburse fees
/// for deposits.
contract FeesReimbursementPool is Ownable2StepUpgradeable {
using SafeERC20 for IERC20;

address public tbtcToken;
address public bitcoinDepositor;

/// @dev Reverts if the tBTC Token address is zero.
error TbtcTokenZeroAddress();

/// @dev Reverts if the BitcoinDepositor address is zero.
error BitcoinDepositorZeroAddress();

/// @dev Caller is not the Bitcoin Depositor contract.
error CallerNotBitcoinDepositor();

/// @dev Attempted to reimburse zero amount.
error ZeroAmount();

/// @dev Emitted when the amount is reimbursed.
event Reimbursed(uint256 amount);

/// @dev Emitted when the given amount is withdrawn by the governance.
event Withdrawn(address indexed recipient, uint256 amount);

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}

/// @notice Initialize the fees reimbursement pool.
/// @param _tbtcToken The tBTC token address.
/// @param _bitcoinDepositor The bitcoin depositor address.
function initialize(
address _tbtcToken,
address _bitcoinDepositor
) external initializer {
if (_tbtcToken == address(0)) {
revert TbtcTokenZeroAddress();
}
if (_bitcoinDepositor == address(0)) {
revert BitcoinDepositorZeroAddress();
}

__Ownable2Step_init();
__Ownable_init(msg.sender);

tbtcToken = _tbtcToken;
bitcoinDepositor = _bitcoinDepositor;
}

/// @notice Reimburse the fees.
/// @param reimbursedAmount The amount to reimburse.
/// @return The amount reimbursed.
function reimburse(uint256 reimbursedAmount) external returns (uint256) {
if (msg.sender != bitcoinDepositor) revert CallerNotBitcoinDepositor();
if (reimbursedAmount == 0) revert ZeroAmount();

uint256 availableBalance = IERC20(tbtcToken).balanceOf(address(this));

if (availableBalance < reimbursedAmount) {
reimbursedAmount = availableBalance;
}

emit Reimbursed(reimbursedAmount);

if (reimbursedAmount > 0) {
IERC20(tbtcToken).safeTransfer(msg.sender, reimbursedAmount);
}

return reimbursedAmount;
}

/// @notice Withdraw the tokens from the pool.
/// @param to The address to withdraw to.
/// @param amount The amount to withdraw.
function withdraw(address to, uint256 amount) external onlyOwner {
emit Withdrawn(to, amount);
IERC20(tbtcToken).safeTransfer(to, amount);
}
}
49 changes: 48 additions & 1 deletion solidity/contracts/test/upgrades/BitcoinDepositorV2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "@keep-network/tbtc-v2/contracts/integrator/AbstractTBTCDepositor.sol";

import {stBTC} from "../../stBTC.sol";
import {FeesReimbursementPool} from "../../FeesReimbursementPool.sol";

/// @title BitcoinDepositorV2
/// @dev This is a contract used to test Bitcoin Depositor upgradeability.
Expand Down Expand Up @@ -56,6 +57,16 @@ contract BitcoinDepositorV2 is AbstractTBTCDepositor, Ownable2StepUpgradeable {
/// `1/50 = 0.02 = 2%`.
uint64 public depositorFeeDivisor;

/// @notice Fees reimbursement pool.
FeesReimbursementPool public feesReimbursementPool;

/// @notice Minimum deposit amount threshold for tBTC Bridge fees reimbursement.
/// For deposits below this threshold, the fees will be reimbursed
/// from the fees reimbursement pool.
/// @dev If the threshold is set to 0, the fees reimbursement is disabled.
/// The threshold is in tBTC token precision.
uint256 public bridgeFeesReimbursementThreshold;

// TEST: New variable;
uint256 public newVariable;

Expand All @@ -78,6 +89,7 @@ contract BitcoinDepositorV2 is AbstractTBTCDepositor, Ownable2StepUpgradeable {
/// event emitted in the same transaction.
/// @param depositKey Deposit key identifying the deposit.
/// @param caller Address that finalized the deposit.
/// @param referral Data used for referral program.
/// @param initialAmount Amount of funding transaction.
/// @param bridgedAmount Amount of tBTC tokens that was bridged by the tBTC bridge.
/// @param depositorFee Depositor fee amount.
Expand All @@ -99,6 +111,13 @@ contract BitcoinDepositorV2 is AbstractTBTCDepositor, Ownable2StepUpgradeable {
/// @param depositorFeeDivisor New value of the depositor fee divisor.
event DepositorFeeDivisorUpdated(uint64 depositorFeeDivisor);

/// @notice Emitted when a tBTC Bridge fees reimbursement threshold is updated.
/// @param bridgeFeesReimbursementThreshold New value of the tBTC Bridge fees
/// reimbursement threshold.
event BridgeFeesReimbursementThresholdUpdated(
uint256 bridgeFeesReimbursementThreshold
);

// TEST: New event;
event NewEvent();

Expand Down Expand Up @@ -224,10 +243,25 @@ contract BitcoinDepositorV2 is AbstractTBTCDepositor, Ownable2StepUpgradeable {
bytes32 extraData
) = _finalizeDeposit(depositKey);

if (
bridgeFeesReimbursementThreshold > 0 &&
initialAmount < bridgeFeesReimbursementThreshold
) {
uint256 tbtcBridgeFee = initialAmount - tbtcAmount;

if (tbtcBridgeFee > 0) {
uint256 reimbursedAmount = feesReimbursementPool.reimburse(
tbtcBridgeFee
);

tbtcAmount += reimbursedAmount;
}
}

// Compute depositor fee. The fee is calculated based on the initial funding
// transaction amount, before the tBTC protocol network fees were taken.
uint256 depositorFee = depositorFeeDivisor > 0
? (initialAmount / depositorFeeDivisor)
? Math.ceilDiv(initialAmount, depositorFeeDivisor)
: 0;

// Ensure the depositor fee does not exceed the approximate minted tBTC
Expand Down Expand Up @@ -294,6 +328,19 @@ contract BitcoinDepositorV2 is AbstractTBTCDepositor, Ownable2StepUpgradeable {
emit DepositorFeeDivisorUpdated(newDepositorFeeDivisor);
}

/// @notice Updates the tBTC Bridge fees reimbursement threshold.
/// @param newBridgeFeesReimbursementThreshold New value of the tBTC Bridge fees
/// reimbursement threshold.
function updateBridgeFeesReimbursementThreshold(
uint256 newBridgeFeesReimbursementThreshold
) external onlyOwner {
bridgeFeesReimbursementThreshold = newBridgeFeesReimbursementThreshold;

emit BridgeFeesReimbursementThresholdUpdated(
newBridgeFeesReimbursementThreshold
);
}

/// @notice Encodes deposit owner address and referral as extra data.
/// @dev Packs the data to bytes32: 20 bytes of deposit owner address and
/// 2 bytes of referral, 10 bytes of trailing zeros.
Expand Down
43 changes: 43 additions & 0 deletions solidity/deploy/51_deploy_fees_reimbursement_pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { DeployFunction } from "hardhat-deploy/types"
import type { HardhatRuntimeEnvironment } from "hardhat/types"
import { waitForTransaction } from "../helpers/deployment"

const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => {
const { deployments, helpers, getNamedAccounts } = hre
const { governance } = await getNamedAccounts()
const { deployer } = await helpers.signers.getNamedSigners()
const { log } = deployments

const tbtcToken = await deployments.get("TBTC")
const bitcoinDepositor = await deployments.get("BitcoinDepositor")

let deployment = await deployments.getOrNull("FeesReimbursementPool")
if (deployment && helpers.address.isValid(deployment.address)) {
log(`using FeesReimbursementPool at ${deployment.address}`)
} else {
;[, deployment] = await helpers.upgrades.deployProxy(
"FeesReimbursementPool",
{
contractName: "FeesReimbursementPool",
initializerArgs: [tbtcToken.address, bitcoinDepositor.address],
factoryOpts: { signer: deployer },
proxyOpts: {
kind: "transparent",
initialOwner: governance,
},
},
)

if (deployment.transactionHash && hre.network.tags.etherscan) {
await waitForTransaction(hre, deployment.transactionHash)
await helpers.etherscan.verify(deployment)
}

// TODO: Add Tenderly verification
}
}

export default func

func.tags = ["FeesReimbursementPool"]
func.dependencies = ["BitcoinDepositor", "TBTC"]
32 changes: 32 additions & 0 deletions solidity/deploy/52_update_fees_reimbursement_pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { DeployFunction } from "hardhat-deploy/types"
import type { HardhatRuntimeEnvironment } from "hardhat/types"

const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => {
const { deployments, getNamedAccounts } = hre

const { deployer } = await getNamedAccounts()
const { log } = deployments

const feesReimbursementPool = await deployments.get("FeesReimbursementPool")

log(
`updating fees reimbursement pool of BitcoinDepositor to ${feesReimbursementPool.address}`,
)

await deployments.execute(
"BitcoinDepositor",
{ from: deployer, log: true, waitConfirmations: 1 },
"updateFeesReimbursementPool",
feesReimbursementPool.address,
)
}

export default func

func.tags = ["UpdateFeesReimbursementPool"]
func.dependencies = ["BitcoinDepositor", "FeesReimbursementPool"]

// Run only on Hardhat network. On all other networks this function needs to be
// called by the governance.
func.skip = async (hre: HardhatRuntimeEnvironment): Promise<boolean> =>
Promise.resolve(hre.network.name !== "hardhat")
Loading
Loading