diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 19c8360ed..501a57084 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -419,7 +419,7 @@ jobs: - name: Install Slither env: - SLITHER_VERSION: 0.8.3 + SLITHER_VERSION: 0.9.0 run: pip3 install slither-analyzer==$SLITHER_VERSION # We need this step because the `@keep-network/tbtc` which we update in diff --git a/cross-chain/base/.openzeppelin/sepolia.json b/cross-chain/base/.openzeppelin/sepolia.json new file mode 100644 index 000000000..a088e7559 --- /dev/null +++ b/cross-chain/base/.openzeppelin/sepolia.json @@ -0,0 +1,303 @@ +{ + "manifestVersion": "3.2", + "admin": { + "address": "0xDd0007713CB99564B7835FD628A1718e8F9f9785", + "txHash": "0x078036699e010480a0d2a4c443352cf0f6311f8882dddcf0b4c6c5136bcc69b4" + }, + "proxies": [ + { + "address": "0x0c5e36731008f4AFC1AF5Da2C4D5E07eE4a3EB69", + "txHash": "0x5a405183332f623649fcf19f8506cf2582882d5dc2b05582e0066388ef122229", + "kind": "transparent" + } + ], + "impls": { + "e1501c59bd3fc7501392b17ad132c0ef733008f7128caa90d854edd898c505ec": { + "address": "0x720Cb49A8b3c03E199075544F7f1F4d772Dd6d06", + "txHash": "0xf055a10c164376e93258e15ff257e76fc8eff5a737820237271a8fb3e3506fe4", + "layout": { + "solcVersion": "0.8.17", + "storage": [ + { + "label": "bridge", + "offset": 0, + "slot": "0", + "type": "t_contract(IBridge)3414", + "contract": "AbstractTBTCDepositor", + "src": "@keep-network/tbtc-v2/contracts/integrator/AbstractTBTCDepositor.sol:95" + }, + { + "label": "tbtcVault", + "offset": 0, + "slot": "1", + "type": "t_contract(ITBTCVault)3440", + "contract": "AbstractTBTCDepositor", + "src": "@keep-network/tbtc-v2/contracts/integrator/AbstractTBTCDepositor.sol:96" + }, + { + "label": "__gap", + "offset": 0, + "slot": "2", + "type": "t_array(t_uint256)47_storage", + "contract": "AbstractTBTCDepositor", + "src": "@keep-network/tbtc-v2/contracts/integrator/AbstractTBTCDepositor.sol:111" + }, + { + "label": "_initialized", + "offset": 0, + "slot": "49", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:62", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "49", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:67" + }, + { + "label": "__gap", + "offset": 0, + "slot": "50", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:36" + }, + { + "label": "_owner", + "offset": 0, + "slot": "100", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "101", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "reimbursementPool", + "offset": 0, + "slot": "150", + "type": "t_contract(ReimbursementPool)2999", + "contract": "Reimbursable", + "src": "@keep-network/random-beacon/contracts/Reimbursable.sol:51" + }, + { + "label": "__gap", + "offset": 0, + "slot": "151", + "type": "t_array(t_uint256)49_storage", + "contract": "Reimbursable", + "src": "@keep-network/random-beacon/contracts/Reimbursable.sol:51" + }, + { + "label": "deposits", + "offset": 0, + "slot": "200", + "type": "t_mapping(t_uint256,t_enum(DepositState)3465)", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:111" + }, + { + "label": "tbtcToken", + "offset": 0, + "slot": "201", + "type": "t_contract(IERC20Upgradeable)6699", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:113" + }, + { + "label": "wormhole", + "offset": 0, + "slot": "202", + "type": "t_contract(IWormhole)5318", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:115" + }, + { + "label": "wormholeRelayer", + "offset": 0, + "slot": "203", + "type": "t_contract(IWormholeRelayer)5358", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:116" + }, + { + "label": "wormholeTokenBridge", + "offset": 0, + "slot": "204", + "type": "t_contract(IWormholeTokenBridge)5443", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:118" + }, + { + "label": "l2WormholeGateway", + "offset": 0, + "slot": "205", + "type": "t_address", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:119" + }, + { + "label": "l2ChainId", + "offset": 20, + "slot": "205", + "type": "t_uint16", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:121" + }, + { + "label": "l2BitcoinDepositor", + "offset": 0, + "slot": "206", + "type": "t_address", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:123" + }, + { + "label": "l2FinalizeDepositGasLimit", + "offset": 0, + "slot": "207", + "type": "t_uint256", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:128" + }, + { + "label": "gasReimbursements", + "offset": 0, + "slot": "208", + "type": "t_mapping(t_uint256,t_struct(GasReimbursement)3472_storage)", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:137" + }, + { + "label": "initializeDepositGasOffset", + "offset": 0, + "slot": "209", + "type": "t_uint256", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:146" + }, + { + "label": "finalizeDepositGasOffset", + "offset": 0, + "slot": "210", + "type": "t_uint256", + "contract": "L1BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:153" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)47_storage": { + "label": "uint256[47]", + "numberOfBytes": "1504" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_contract(IBridge)3414": { + "label": "contract IBridge", + "numberOfBytes": "20" + }, + "t_contract(IERC20Upgradeable)6699": { + "label": "contract IERC20Upgradeable", + "numberOfBytes": "20" + }, + "t_contract(ITBTCVault)3440": { + "label": "contract ITBTCVault", + "numberOfBytes": "20" + }, + "t_contract(IWormhole)5318": { + "label": "contract IWormhole", + "numberOfBytes": "20" + }, + "t_contract(IWormholeRelayer)5358": { + "label": "contract IWormholeRelayer", + "numberOfBytes": "20" + }, + "t_contract(IWormholeTokenBridge)5443": { + "label": "contract IWormholeTokenBridge", + "numberOfBytes": "20" + }, + "t_contract(ReimbursementPool)2999": { + "label": "contract ReimbursementPool", + "numberOfBytes": "20" + }, + "t_enum(DepositState)3465": { + "label": "enum L1BitcoinDepositor.DepositState", + "members": [ + "Unknown", + "Initialized", + "Finalized" + ], + "numberOfBytes": "1" + }, + "t_mapping(t_uint256,t_enum(DepositState)3465)": { + "label": "mapping(uint256 => enum L1BitcoinDepositor.DepositState)", + "numberOfBytes": "32" + }, + "t_mapping(t_uint256,t_struct(GasReimbursement)3472_storage)": { + "label": "mapping(uint256 => struct L1BitcoinDepositor.GasReimbursement)", + "numberOfBytes": "32" + }, + "t_struct(GasReimbursement)3472_storage": { + "label": "struct L1BitcoinDepositor.GasReimbursement", + "members": [ + { + "label": "receiver", + "type": "t_address", + "offset": 0, + "slot": "0" + }, + { + "label": "gasSpent", + "type": "t_uint96", + "offset": 20, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_uint16": { + "label": "uint16", + "numberOfBytes": "2" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + }, + "t_uint96": { + "label": "uint96", + "numberOfBytes": "12" + } + } + } + } + } +} diff --git a/cross-chain/base/.openzeppelin/unknown-84532.json b/cross-chain/base/.openzeppelin/unknown-84532.json index c94e49b5b..0bb616427 100644 --- a/cross-chain/base/.openzeppelin/unknown-84532.json +++ b/cross-chain/base/.openzeppelin/unknown-84532.json @@ -14,6 +14,11 @@ "address": "0xc3D46e0266d95215589DE639cC4E93b79f88fc6C", "txHash": "0xc362207cb5c36e09f5eae5bcc206c760e01af0a042fb7a48f6a4b2078eafcd24", "kind": "transparent" + }, + { + "address": "0x04BE8F183572ec802aD26756F3E9398098700E76", + "txHash": "0xaeb9ee6679e0f96108788abffa9ed78c943c09565686dfdb826378e9bd1487df", + "kind": "transparent" } ], "impls": { @@ -453,6 +458,126 @@ } } } + }, + "3369e76dd2db12ab8efac7048dbdc97f3c608ed52d148e172acb7764f92977e1": { + "address": "0x1Ecd87C8D510A7390a561AE0Ac54FBe7e5125BcF", + "txHash": "0xf53c89cdde65e96dd9d040a21421c54b21414ac552d5201c82cc50f794efd9c8", + "layout": { + "solcVersion": "0.8.17", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:62", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:67" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:36" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "wormholeRelayer", + "offset": 0, + "slot": "101", + "type": "t_contract(IWormholeRelayer)338", + "contract": "L2BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L2BitcoinDepositor.sol:54" + }, + { + "label": "l2WormholeGateway", + "offset": 0, + "slot": "102", + "type": "t_contract(IL2WormholeGateway)88", + "contract": "L2BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L2BitcoinDepositor.sol:58" + }, + { + "label": "l1ChainId", + "offset": 20, + "slot": "102", + "type": "t_uint16", + "contract": "L2BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L2BitcoinDepositor.sol:64" + }, + { + "label": "l1BitcoinDepositor", + "offset": 0, + "slot": "103", + "type": "t_address", + "contract": "L2BitcoinDepositor", + "src": "@keep-network/tbtc-v2/contracts/l2/L2BitcoinDepositor.sol:67" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_contract(IL2WormholeGateway)88": { + "label": "contract IL2WormholeGateway", + "numberOfBytes": "20" + }, + "t_contract(IWormholeRelayer)338": { + "label": "contract IWormholeRelayer", + "numberOfBytes": "20" + }, + "t_uint16": { + "label": "uint16", + "numberOfBytes": "2" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + } + } } } } diff --git a/cross-chain/base/contracts/test/BaseL1BitcoinDepositorUpgraded.sol b/cross-chain/base/contracts/test/BaseL1BitcoinDepositorUpgraded.sol new file mode 100644 index 000000000..eeed54f12 --- /dev/null +++ b/cross-chain/base/contracts/test/BaseL1BitcoinDepositorUpgraded.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.17; + +import "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol"; + +/// @notice L1BitcoinDepositor for Base - upgraded version. +/// @dev This contract is intended solely for testing purposes. +contract BaseL1BitcoinDepositorUpgraded is L1BitcoinDepositor { + string public newVar; + + function initializeV2(string memory _newVar) public { + newVar = _newVar; + } +} \ No newline at end of file diff --git a/cross-chain/base/contracts/test/BaseL2BitcoinDepositorUpgraded.sol b/cross-chain/base/contracts/test/BaseL2BitcoinDepositorUpgraded.sol new file mode 100644 index 000000000..ca5d32a25 --- /dev/null +++ b/cross-chain/base/contracts/test/BaseL2BitcoinDepositorUpgraded.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.17; + +import "@keep-network/tbtc-v2/contracts/l2/L2BitcoinDepositor.sol"; + +/// @notice L2BitcoinDepositor for Base - upgraded version. +/// @dev This contract is intended solely for testing purposes. +contract BaseL2BitcoinDepositorUpgraded is L2BitcoinDepositor { + string public newVar; + + function initializeV2(string memory _newVar) public { + newVar = _newVar; + } +} \ No newline at end of file diff --git a/cross-chain/base/deploy_helpers/wormhole_chains.ts b/cross-chain/base/deploy_helpers/wormhole_chains.ts new file mode 100644 index 000000000..6ead540a1 --- /dev/null +++ b/cross-chain/base/deploy_helpers/wormhole_chains.ts @@ -0,0 +1,31 @@ +export type WormholeChains = { + l1ChainId: number + l2ChainId: number +} + +/** + * Returns Wormhole L1 and L2 chain IDs for the given network. + * Source: https://docs.wormhole.com/wormhole/reference/constants#chain-ids + * @param network Network name. + */ +export function getWormholeChains(network: string): WormholeChains { + let l1ChainId: number + let l2ChainId: number + + switch (network) { + case "mainnet": + case "base": + l1ChainId = 2 // L1 Ethereum mainnet + l2ChainId = 30 // L2 Base mainnet + break + case "sepolia": + case "baseSepolia": + l1ChainId = 10002 // L1 Ethereum Sepolia testnet + l2ChainId = 10004 // L2 Base Sepolia testnet + break + default: + throw new Error("Unsupported network") + } + + return { l1ChainId, l2ChainId } +} diff --git a/cross-chain/base/deploy_l1/00_deploy_base_l1_bitcoin_depositor.ts b/cross-chain/base/deploy_l1/00_deploy_base_l1_bitcoin_depositor.ts new file mode 100644 index 000000000..6bf2e6f29 --- /dev/null +++ b/cross-chain/base/deploy_l1/00_deploy_base_l1_bitcoin_depositor.ts @@ -0,0 +1,53 @@ +import type { HardhatRuntimeEnvironment } from "hardhat/types" +import type { DeployFunction } from "hardhat-deploy/types" +import { getWormholeChains } from "../deploy_helpers/wormhole_chains" + +const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + const { ethers, getNamedAccounts, helpers, deployments } = hre + const { deployer } = await getNamedAccounts() + const l2Deployments = hre.companionNetworks.l2.deployments + + const wormholeChains = getWormholeChains(hre.network.name) + + const tbtcBridge = await deployments.get("Bridge") + const tbtcVault = await deployments.get("TBTCVault") + const wormhole = await deployments.get("Wormhole") + const wormholeRelayer = await deployments.get("WormholeRelayer") + const wormholeTokenBridge = await deployments.get("TokenBridge") + const baseWormholeGateway = await l2Deployments.get("BaseWormholeGateway") + + const [, proxyDeployment] = await helpers.upgrades.deployProxy( + "BaseL1BitcoinDepositor", + { + contractName: + "@keep-network/tbtc-v2/contracts/l2/L1BitcoinDepositor.sol:L1BitcoinDepositor", + initializerArgs: [ + tbtcBridge.address, + tbtcVault.address, + wormhole.address, + wormholeRelayer.address, + wormholeTokenBridge.address, + baseWormholeGateway.address, + wormholeChains.l2ChainId, + ], + factoryOpts: { signer: await ethers.getSigner(deployer) }, + proxyOpts: { + kind: "transparent", + }, + } + ) + + if (hre.network.tags.etherscan) { + // We use `verify` instead of `verify:verify` as the `verify` task is defined + // in "@openzeppelin/hardhat-upgrades" to perform Etherscan verification + // of Proxy and Implementation contracts. + await hre.run("verify", { + address: proxyDeployment.address, + constructorArgsParams: proxyDeployment.args, + }) + } +} + +export default func + +func.tags = ["BaseL1BitcoinDepositor"] diff --git a/cross-chain/base/deploy_l1/01_attach_base_l2_bitcoin_depositor.ts b/cross-chain/base/deploy_l1/01_attach_base_l2_bitcoin_depositor.ts new file mode 100644 index 000000000..c35aaa21d --- /dev/null +++ b/cross-chain/base/deploy_l1/01_attach_base_l2_bitcoin_depositor.ts @@ -0,0 +1,25 @@ +import type { HardhatRuntimeEnvironment } from "hardhat/types" +import type { DeployFunction } from "hardhat-deploy/types" + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const { deployments, getNamedAccounts } = hre + const { deployer } = await getNamedAccounts() + const { execute } = deployments + const l2Deployments = hre.companionNetworks.l2.deployments + + const baseL2BitcoinDepositor = await l2Deployments.get( + "BaseL2BitcoinDepositor" + ) + + await execute( + "BaseL1BitcoinDepositor", + { from: deployer, log: true, waitConfirmations: 1 }, + "attachL2BitcoinDepositor", + baseL2BitcoinDepositor.address + ) +} + +export default func + +func.tags = ["AttachBaseL2BitcoinDepositor"] +func.dependencies = ["BaseL1BitcoinDepositor"] diff --git a/cross-chain/base/deploy_l1/03_transfer_base_l1_bitcoin_depositor_ownership.ts b/cross-chain/base/deploy_l1/03_transfer_base_l1_bitcoin_depositor_ownership.ts new file mode 100644 index 000000000..7c7d230de --- /dev/null +++ b/cross-chain/base/deploy_l1/03_transfer_base_l1_bitcoin_depositor_ownership.ts @@ -0,0 +1,19 @@ +import type { HardhatRuntimeEnvironment } from "hardhat/types" +import type { DeployFunction } from "hardhat-deploy/types" + +const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + const { getNamedAccounts, helpers } = hre + const { deployer, governance } = await getNamedAccounts() + + await helpers.ownable.transferOwnership( + "BaseL1BitcoinDepositor", + governance, + deployer + ) +} + +export default func + +func.tags = ["TransferBaseL1BitcoinDepositorOwnership"] +func.dependencies = ["BaseL1BitcoinDepositor"] +func.runAtTheEnd = true diff --git a/cross-chain/base/deploy_l2/25_deploy_base_l2_bitcoin_depositor.ts b/cross-chain/base/deploy_l2/25_deploy_base_l2_bitcoin_depositor.ts new file mode 100644 index 000000000..fd24fd0b2 --- /dev/null +++ b/cross-chain/base/deploy_l2/25_deploy_base_l2_bitcoin_depositor.ts @@ -0,0 +1,48 @@ +import type { HardhatRuntimeEnvironment } from "hardhat/types" +import type { DeployFunction } from "hardhat-deploy/types" +import { getWormholeChains } from "../deploy_helpers/wormhole_chains" + +const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + const { ethers, getNamedAccounts, helpers, deployments } = hre + const { deployer } = await getNamedAccounts() + + const wormholeChains = getWormholeChains(hre.network.name) + + const baseWormholeRelayer = await deployments.get("BaseWormholeRelayer") + const baseWormholeGateway = await deployments.get("BaseWormholeGateway") + + const [, proxyDeployment] = await helpers.upgrades.deployProxy( + "BaseL2BitcoinDepositor", + { + contractName: + "@keep-network/tbtc-v2/contracts/l2/L2BitcoinDepositor.sol:L2BitcoinDepositor", + initializerArgs: [ + baseWormholeRelayer.address, + baseWormholeGateway.address, + wormholeChains.l1ChainId, + ], + factoryOpts: { signer: await ethers.getSigner(deployer) }, + proxyOpts: { + kind: "transparent", + }, + } + ) + + // Contracts can be verified on L2 Base Etherscan in a similar way as we + // do it on L1 Etherscan + if (hre.network.tags.basescan) { + // We use `verify` instead of `verify:verify` as the `verify` task is defined + // in "@openzeppelin/hardhat-upgrades" to verify the proxy’s implementation + // contract, the proxy itself and any proxy-related contracts, as well as + // link the proxy to the implementation contract’s ABI on (Ether)scan. + await hre.run("verify", { + address: proxyDeployment.address, + constructorArgsParams: [], + }) + } +} + +export default func + +func.tags = ["BaseL2BitcoinDepositor"] +func.dependencies = ["BaseWormholeGateway"] diff --git a/cross-chain/base/deploy_l2/26_attach_base_l1_bitcoin_depositor.ts b/cross-chain/base/deploy_l2/26_attach_base_l1_bitcoin_depositor.ts new file mode 100644 index 000000000..d945af44b --- /dev/null +++ b/cross-chain/base/deploy_l2/26_attach_base_l1_bitcoin_depositor.ts @@ -0,0 +1,25 @@ +import type { HardhatRuntimeEnvironment } from "hardhat/types" +import type { DeployFunction } from "hardhat-deploy/types" + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const { deployments, getNamedAccounts } = hre + const { deployer } = await getNamedAccounts() + const { execute } = deployments + const l1Deployments = hre.companionNetworks.l1.deployments + + const baseL1BitcoinDepositor = await l1Deployments.get( + "BaseL1BitcoinDepositor" + ) + + await execute( + "BaseL2BitcoinDepositor", + { from: deployer, log: true, waitConfirmations: 1 }, + "attachL1BitcoinDepositor", + baseL1BitcoinDepositor.address + ) +} + +export default func + +func.tags = ["AttachBaseL1BitcoinDepositor"] +func.dependencies = ["BaseL2BitcoinDepositor"] diff --git a/cross-chain/base/deploy_l2/27_transfer_base_l2_bitcoin_depositor_ownership.ts b/cross-chain/base/deploy_l2/27_transfer_base_l2_bitcoin_depositor_ownership.ts new file mode 100644 index 000000000..2c499eead --- /dev/null +++ b/cross-chain/base/deploy_l2/27_transfer_base_l2_bitcoin_depositor_ownership.ts @@ -0,0 +1,19 @@ +import type { HardhatRuntimeEnvironment } from "hardhat/types" +import type { DeployFunction } from "hardhat-deploy/types" + +const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + const { getNamedAccounts, helpers } = hre + const { deployer, governance } = await getNamedAccounts() + + await helpers.ownable.transferOwnership( + "BaseL2BitcoinDepositor", + governance, + deployer + ) +} + +export default func + +func.tags = ["TransferBaseL2BitcoinDepositorOwnership"] +func.dependencies = ["BaseL2BitcoinDepositor"] +func.runAtTheEnd = true diff --git a/cross-chain/base/deployments/baseSepolia/BaseL2BitcoinDepositor.json b/cross-chain/base/deployments/baseSepolia/BaseL2BitcoinDepositor.json new file mode 100644 index 000000000..a4f4e4337 --- /dev/null +++ b/cross-chain/base/deployments/baseSepolia/BaseL2BitcoinDepositor.json @@ -0,0 +1,429 @@ +{ + "address": "0x04BE8F183572ec802aD26756F3E9398098700E76", + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "components": [ + { + "internalType": "bytes4", + "name": "version", + "type": "bytes4" + }, + { + "internalType": "bytes", + "name": "inputVector", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "outputVector", + "type": "bytes" + }, + { + "internalType": "bytes4", + "name": "locktime", + "type": "bytes4" + } + ], + "indexed": false, + "internalType": "struct IBridgeTypes.BitcoinTxInfo", + "name": "fundingTx", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint32", + "name": "fundingOutputIndex", + "type": "uint32" + }, + { + "internalType": "bytes8", + "name": "blindingFactor", + "type": "bytes8" + }, + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "bytes20", + "name": "refundPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "bytes4", + "name": "refundLocktime", + "type": "bytes4" + }, + { + "internalType": "address", + "name": "vault", + "type": "address" + } + ], + "indexed": false, + "internalType": "struct IBridgeTypes.DepositRevealInfo", + "name": "reveal", + "type": "tuple" + }, + { + "indexed": true, + "internalType": "address", + "name": "l2DepositOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "l2Sender", + "type": "address" + } + ], + "name": "DepositInitialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_l1BitcoinDepositor", + "type": "address" + } + ], + "name": "attachL1BitcoinDepositor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_wormholeRelayer", + "type": "address" + }, + { + "internalType": "address", + "name": "_l2WormholeGateway", + "type": "address" + }, + { + "internalType": "uint16", + "name": "_l1ChainId", + "type": "uint16" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes4", + "name": "version", + "type": "bytes4" + }, + { + "internalType": "bytes", + "name": "inputVector", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "outputVector", + "type": "bytes" + }, + { + "internalType": "bytes4", + "name": "locktime", + "type": "bytes4" + } + ], + "internalType": "struct IBridgeTypes.BitcoinTxInfo", + "name": "fundingTx", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint32", + "name": "fundingOutputIndex", + "type": "uint32" + }, + { + "internalType": "bytes8", + "name": "blindingFactor", + "type": "bytes8" + }, + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "bytes20", + "name": "refundPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "bytes4", + "name": "refundLocktime", + "type": "bytes4" + }, + { + "internalType": "address", + "name": "vault", + "type": "address" + } + ], + "internalType": "struct IBridgeTypes.DepositRevealInfo", + "name": "reveal", + "type": "tuple" + }, + { + "internalType": "address", + "name": "l2DepositOwner", + "type": "address" + } + ], + "name": "initializeDeposit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "l1BitcoinDepositor", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "l1ChainId", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "l2WormholeGateway", + "outputs": [ + { + "internalType": "contract IL2WormholeGateway", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + }, + { + "internalType": "bytes[]", + "name": "additionalVaas", + "type": "bytes[]" + }, + { + "internalType": "bytes32", + "name": "sourceAddress", + "type": "bytes32" + }, + { + "internalType": "uint16", + "name": "sourceChain", + "type": "uint16" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "receiveWormholeMessages", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "wormholeRelayer", + "outputs": [ + { + "internalType": "contract IWormholeRelayer", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "transactionHash": "0xaeb9ee6679e0f96108788abffa9ed78c943c09565686dfdb826378e9bd1487df", + "receipt": { + "to": null, + "from": "0x68ad60CC5e8f3B7cC53beaB321cf0e6036962dBc", + "contractAddress": "0x04BE8F183572ec802aD26756F3E9398098700E76", + "transactionIndex": 2, + "gasUsed": "704802", + "logsBloom": "0x00000000000000000000000000000000400000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202000001000000000000000000000000000000000000020000000000000000000800000000800000000000000000000000400002000200000000000000000000000000000000000080000000000000800080000000000000000000000000000400000000000100000000000000000000000000000020000000000000000020040000000008000400000000000000000020000000000000000000080000000000000000000000000000000000000000400000", + "blockHash": "0xcb1f5aa73f2f63d359224b51e7abf66a431e3420daa6940b72a3a58dd5a00c85", + "transactionHash": "0xaeb9ee6679e0f96108788abffa9ed78c943c09565686dfdb826378e9bd1487df", + "logs": [ + { + "transactionIndex": 2, + "blockNumber": 7063584, + "transactionHash": "0xaeb9ee6679e0f96108788abffa9ed78c943c09565686dfdb826378e9bd1487df", + "address": "0x04BE8F183572ec802aD26756F3E9398098700E76", + "topics": [ + "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", + "0x0000000000000000000000001ecd87c8d510a7390a561ae0ac54fbe7e5125bcf" + ], + "data": "0x", + "logIndex": 1, + "blockHash": "0xcb1f5aa73f2f63d359224b51e7abf66a431e3420daa6940b72a3a58dd5a00c85" + }, + { + "transactionIndex": 2, + "blockNumber": 7063584, + "transactionHash": "0xaeb9ee6679e0f96108788abffa9ed78c943c09565686dfdb826378e9bd1487df", + "address": "0x04BE8F183572ec802aD26756F3E9398098700E76", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x00000000000000000000000068ad60cc5e8f3b7cc53beab321cf0e6036962dbc" + ], + "data": "0x", + "logIndex": 2, + "blockHash": "0xcb1f5aa73f2f63d359224b51e7abf66a431e3420daa6940b72a3a58dd5a00c85" + }, + { + "transactionIndex": 2, + "blockNumber": 7063584, + "transactionHash": "0xaeb9ee6679e0f96108788abffa9ed78c943c09565686dfdb826378e9bd1487df", + "address": "0x04BE8F183572ec802aD26756F3E9398098700E76", + "topics": [ + "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": 3, + "blockHash": "0xcb1f5aa73f2f63d359224b51e7abf66a431e3420daa6940b72a3a58dd5a00c85" + }, + { + "transactionIndex": 2, + "blockNumber": 7063584, + "transactionHash": "0xaeb9ee6679e0f96108788abffa9ed78c943c09565686dfdb826378e9bd1487df", + "address": "0x04BE8F183572ec802aD26756F3E9398098700E76", + "topics": [ + "0x7e644d79422f17c01e4894b5f4f588d331ebfa28653d42ae832dc59e38c9798f" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b2f6c5b73239c39360ee0ea95047565dab13e3c7", + "logIndex": 4, + "blockHash": "0xcb1f5aa73f2f63d359224b51e7abf66a431e3420daa6940b72a3a58dd5a00c85" + } + ], + "blockNumber": 7063584, + "cumulativeGasUsed": "804788", + "status": 1, + "byzantium": true + }, + "numDeployments": 1, + "implementation": "0x1Ecd87C8D510A7390a561AE0Ac54FBe7e5125BcF", + "devdoc": "Contract deployed as upgradable proxy" +} \ No newline at end of file diff --git a/cross-chain/base/deployments/sepolia/.chainId b/cross-chain/base/deployments/sepolia/.chainId new file mode 100644 index 000000000..bd8d1cd44 --- /dev/null +++ b/cross-chain/base/deployments/sepolia/.chainId @@ -0,0 +1 @@ +11155111 \ No newline at end of file diff --git a/cross-chain/base/deployments/sepolia/BaseL1BitcoinDepositor.json b/cross-chain/base/deployments/sepolia/BaseL1BitcoinDepositor.json new file mode 100644 index 000000000..1826e6b36 --- /dev/null +++ b/cross-chain/base/deployments/sepolia/BaseL1BitcoinDepositor.json @@ -0,0 +1,681 @@ +{ + "address": "0x0c5e36731008f4AFC1AF5Da2C4D5E07eE4a3EB69", + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "depositKey", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "l2DepositOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "l1Sender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "initialAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tbtcAmount", + "type": "uint256" + } + ], + "name": "DepositFinalized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "depositKey", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "l2DepositOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "l1Sender", + "type": "address" + } + ], + "name": "DepositInitialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "initializeDepositGasOffset", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "finalizeDepositGasOffset", + "type": "uint256" + } + ], + "name": "GasOffsetParametersUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "l2FinalizeDepositGasLimit", + "type": "uint256" + } + ], + "name": "L2FinalizeDepositGasLimitUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "newReimbursementPool", + "type": "address" + } + ], + "name": "ReimbursementPoolUpdated", + "type": "event" + }, + { + "inputs": [], + "name": "SATOSHI_MULTIPLIER", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_l2BitcoinDepositor", + "type": "address" + } + ], + "name": "attachL2BitcoinDepositor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "bridge", + "outputs": [ + { + "internalType": "contract IBridge", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "deposits", + "outputs": [ + { + "internalType": "enum L1BitcoinDepositor.DepositState", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "depositKey", + "type": "uint256" + } + ], + "name": "finalizeDeposit", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "finalizeDepositGasOffset", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "gasReimbursements", + "outputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "uint96", + "name": "gasSpent", + "type": "uint96" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_tbtcBridge", + "type": "address" + }, + { + "internalType": "address", + "name": "_tbtcVault", + "type": "address" + }, + { + "internalType": "address", + "name": "_wormhole", + "type": "address" + }, + { + "internalType": "address", + "name": "_wormholeRelayer", + "type": "address" + }, + { + "internalType": "address", + "name": "_wormholeTokenBridge", + "type": "address" + }, + { + "internalType": "address", + "name": "_l2WormholeGateway", + "type": "address" + }, + { + "internalType": "uint16", + "name": "_l2ChainId", + "type": "uint16" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes4", + "name": "version", + "type": "bytes4" + }, + { + "internalType": "bytes", + "name": "inputVector", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "outputVector", + "type": "bytes" + }, + { + "internalType": "bytes4", + "name": "locktime", + "type": "bytes4" + } + ], + "internalType": "struct IBridgeTypes.BitcoinTxInfo", + "name": "fundingTx", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint32", + "name": "fundingOutputIndex", + "type": "uint32" + }, + { + "internalType": "bytes8", + "name": "blindingFactor", + "type": "bytes8" + }, + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "bytes20", + "name": "refundPubKeyHash", + "type": "bytes20" + }, + { + "internalType": "bytes4", + "name": "refundLocktime", + "type": "bytes4" + }, + { + "internalType": "address", + "name": "vault", + "type": "address" + } + ], + "internalType": "struct IBridgeTypes.DepositRevealInfo", + "name": "reveal", + "type": "tuple" + }, + { + "internalType": "address", + "name": "l2DepositOwner", + "type": "address" + } + ], + "name": "initializeDeposit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "initializeDepositGasOffset", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "l2BitcoinDepositor", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "l2ChainId", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "l2FinalizeDepositGasLimit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "l2WormholeGateway", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "quoteFinalizeDeposit", + "outputs": [ + { + "internalType": "uint256", + "name": "cost", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "reimbursementPool", + "outputs": [ + { + "internalType": "contract ReimbursementPool", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "tbtcToken", + "outputs": [ + { + "internalType": "contract IERC20Upgradeable", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "tbtcVault", + "outputs": [ + { + "internalType": "contract ITBTCVault", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_initializeDepositGasOffset", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_finalizeDepositGasOffset", + "type": "uint256" + } + ], + "name": "updateGasOffsetParameters", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_l2FinalizeDepositGasLimit", + "type": "uint256" + } + ], + "name": "updateL2FinalizeDepositGasLimit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract ReimbursementPool", + "name": "_reimbursementPool", + "type": "address" + } + ], + "name": "updateReimbursementPool", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "wormhole", + "outputs": [ + { + "internalType": "contract IWormhole", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "wormholeRelayer", + "outputs": [ + { + "internalType": "contract IWormholeRelayer", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "wormholeTokenBridge", + "outputs": [ + { + "internalType": "contract IWormholeTokenBridge", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "transactionHash": "0x5a405183332f623649fcf19f8506cf2582882d5dc2b05582e0066388ef122229", + "receipt": { + "to": null, + "from": "0x68ad60CC5e8f3B7cC53beaB321cf0e6036962dBc", + "contractAddress": "0x0c5e36731008f4AFC1AF5Da2C4D5E07eE4a3EB69", + "transactionIndex": 94, + "gasUsed": "887851", + "logsBloom": "0x00000000000000000000000000000000400000000000000400800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202000001000000000000000000000000000000000000020000000000000000000800000000800000000000000008000000400000000200000000000000000000000000000040000080000000000000800000000000000000000000000000000400000000000000000000000000000010000000000020000000000000200020040000000000000400000000000000000020000000000000000000000000000000100000000000000000000000000000000000", + "blockHash": "0x9d4a08d2b6fd26df76447aa1b5e0054686b1d462dc12be4d271dca1299060a2c", + "transactionHash": "0x5a405183332f623649fcf19f8506cf2582882d5dc2b05582e0066388ef122229", + "logs": [ + { + "transactionIndex": 94, + "blockNumber": 5441536, + "transactionHash": "0x5a405183332f623649fcf19f8506cf2582882d5dc2b05582e0066388ef122229", + "address": "0x0c5e36731008f4AFC1AF5Da2C4D5E07eE4a3EB69", + "topics": [ + "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", + "0x000000000000000000000000720cb49a8b3c03e199075544f7f1f4d772dd6d06" + ], + "data": "0x", + "logIndex": 75, + "blockHash": "0x9d4a08d2b6fd26df76447aa1b5e0054686b1d462dc12be4d271dca1299060a2c" + }, + { + "transactionIndex": 94, + "blockNumber": 5441536, + "transactionHash": "0x5a405183332f623649fcf19f8506cf2582882d5dc2b05582e0066388ef122229", + "address": "0x0c5e36731008f4AFC1AF5Da2C4D5E07eE4a3EB69", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x00000000000000000000000068ad60cc5e8f3b7cc53beab321cf0e6036962dbc" + ], + "data": "0x", + "logIndex": 76, + "blockHash": "0x9d4a08d2b6fd26df76447aa1b5e0054686b1d462dc12be4d271dca1299060a2c" + }, + { + "transactionIndex": 94, + "blockNumber": 5441536, + "transactionHash": "0x5a405183332f623649fcf19f8506cf2582882d5dc2b05582e0066388ef122229", + "address": "0x0c5e36731008f4AFC1AF5Da2C4D5E07eE4a3EB69", + "topics": [ + "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": 77, + "blockHash": "0x9d4a08d2b6fd26df76447aa1b5e0054686b1d462dc12be4d271dca1299060a2c" + }, + { + "transactionIndex": 94, + "blockNumber": 5441536, + "transactionHash": "0x5a405183332f623649fcf19f8506cf2582882d5dc2b05582e0066388ef122229", + "address": "0x0c5e36731008f4AFC1AF5Da2C4D5E07eE4a3EB69", + "topics": [ + "0x7e644d79422f17c01e4894b5f4f588d331ebfa28653d42ae832dc59e38c9798f" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dd0007713cb99564b7835fd628a1718e8f9f9785", + "logIndex": 78, + "blockHash": "0x9d4a08d2b6fd26df76447aa1b5e0054686b1d462dc12be4d271dca1299060a2c" + } + ], + "blockNumber": 5441536, + "cumulativeGasUsed": "8903785", + "status": 1, + "byzantium": true + }, + "numDeployments": 1, + "implementation": "0x720Cb49A8b3c03E199075544F7f1F4d772Dd6d06", + "devdoc": "Contract deployed as upgradable proxy" +} \ No newline at end of file diff --git a/cross-chain/base/external/baseSepolia/BaseWormholeRelayer.json b/cross-chain/base/external/baseSepolia/BaseWormholeRelayer.json new file mode 100644 index 000000000..7b945deb8 --- /dev/null +++ b/cross-chain/base/external/baseSepolia/BaseWormholeRelayer.json @@ -0,0 +1,3 @@ +{ + "address": "0x93BAD53DDfB6132b0aC8E37f6029163E63372cEE" +} \ No newline at end of file diff --git a/cross-chain/base/external/sepolia/Bridge.json b/cross-chain/base/external/sepolia/Bridge.json new file mode 100644 index 000000000..4a4dba6e1 --- /dev/null +++ b/cross-chain/base/external/sepolia/Bridge.json @@ -0,0 +1,3 @@ +{ + "address": "0x9b1a7fE5a16A15F2f9475C5B231750598b113403" +} \ No newline at end of file diff --git a/cross-chain/base/external/sepolia/TBTCVault.json b/cross-chain/base/external/sepolia/TBTCVault.json new file mode 100644 index 000000000..25914993d --- /dev/null +++ b/cross-chain/base/external/sepolia/TBTCVault.json @@ -0,0 +1,3 @@ +{ + "address": "0xB5679dE944A79732A75CE556191DF11F489448d5" +} \ No newline at end of file diff --git a/cross-chain/base/external/sepolia/Wormhole.json b/cross-chain/base/external/sepolia/Wormhole.json new file mode 100644 index 000000000..f83c46ca5 --- /dev/null +++ b/cross-chain/base/external/sepolia/Wormhole.json @@ -0,0 +1,3 @@ +{ + "address": "0x4a8bc80Ed5a4067f1CCf107057b8270E0cC11A78" +} \ No newline at end of file diff --git a/cross-chain/base/external/sepolia/WormholeRelayer.json b/cross-chain/base/external/sepolia/WormholeRelayer.json new file mode 100644 index 000000000..677a1e21d --- /dev/null +++ b/cross-chain/base/external/sepolia/WormholeRelayer.json @@ -0,0 +1,3 @@ +{ + "address": "0x7B1bD7a6b4E61c2a123AC6BC2cbfC614437D0470" +} \ No newline at end of file diff --git a/cross-chain/base/hardhat.config.ts b/cross-chain/base/hardhat.config.ts index 9ab642587..9e47a214a 100644 --- a/cross-chain/base/hardhat.config.ts +++ b/cross-chain/base/hardhat.config.ts @@ -52,6 +52,9 @@ const config: HardhatUserConfig = { ? process.env.L1_ACCOUNTS_PRIVATE_KEYS.split(",") : undefined, tags: ["etherscan"], + companionNetworks: { + l2: "baseSepolia", + }, }, mainnet: { url: process.env.L1_CHAIN_API_URL || "", @@ -88,9 +91,9 @@ const config: HardhatUserConfig = { // In case of deployment failing with underpriced transaction error set // the `gasPrice` parameter. // gasPrice: 1000000000, - // companionNetworks: { - // l1: "sepolia", - // }, + companionNetworks: { + l1: "sepolia", + }, }, base: { url: process.env.L2_CHAIN_API_URL || "", diff --git a/solidity/contracts/integrator/AbstractTBTCDepositor.sol b/solidity/contracts/integrator/AbstractTBTCDepositor.sol index 0ba4928ba..10910ed9a 100644 --- a/solidity/contracts/integrator/AbstractTBTCDepositor.sol +++ b/solidity/contracts/integrator/AbstractTBTCDepositor.sol @@ -133,8 +133,8 @@ abstract contract AbstractTBTCDepositor { /// as the Bridge won't allow the same deposit to be revealed twice. // slither-disable-next-line dead-code function _initializeDeposit( - IBridgeTypes.BitcoinTxInfo calldata fundingTx, - IBridgeTypes.DepositRevealInfo calldata reveal, + IBridgeTypes.BitcoinTxInfo memory fundingTx, + IBridgeTypes.DepositRevealInfo memory reveal, bytes32 extraData ) internal returns (uint256 depositKey, uint256 initialDepositAmount) { require(reveal.vault == address(tbtcVault), "Vault address mismatch"); @@ -278,7 +278,7 @@ abstract contract AbstractTBTCDepositor { /// @param txInfo Bitcoin transaction data, see `IBridgeTypes.BitcoinTxInfo` struct. /// @return txHash Bitcoin transaction hash. // slither-disable-next-line dead-code - function _calculateBitcoinTxHash(IBridgeTypes.BitcoinTxInfo calldata txInfo) + function _calculateBitcoinTxHash(IBridgeTypes.BitcoinTxInfo memory txInfo) internal view returns (bytes32) diff --git a/solidity/contracts/integrator/ITBTCVault.sol b/solidity/contracts/integrator/ITBTCVault.sol index a9f665637..881d27602 100644 --- a/solidity/contracts/integrator/ITBTCVault.sol +++ b/solidity/contracts/integrator/ITBTCVault.sol @@ -25,4 +25,7 @@ interface ITBTCVault { /// @dev See {TBTCVault#optimisticMintingFeeDivisor} function optimisticMintingFeeDivisor() external view returns (uint32); + + /// @dev See {TBTCVault#tbtcToken} + function tbtcToken() external view returns (address); } diff --git a/solidity/contracts/l2/L1BitcoinDepositor.sol b/solidity/contracts/l2/L1BitcoinDepositor.sol new file mode 100644 index 000000000..fccd99f91 --- /dev/null +++ b/solidity/contracts/l2/L1BitcoinDepositor.sol @@ -0,0 +1,613 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ + +pragma solidity ^0.8.17; + +import "@keep-network/random-beacon/contracts/Reimbursable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import "../integrator/AbstractTBTCDepositor.sol"; +import "../integrator/IBridge.sol"; +import "../integrator/ITBTCVault.sol"; +import "./Wormhole.sol"; + +/// @title L1BitcoinDepositor +/// @notice This contract is part of the direct bridging mechanism allowing +/// users to obtain ERC20 TBTC on supported L2 chains, without the need +/// to interact with the L1 tBTC ledger chain where minting occurs. +/// +/// `L1BitcoinDepositor` is deployed on the L1 chain and interacts with +/// their L2 counterpart, the `L2BitcoinDepositor`, deployed on the given +/// L2 chain. Each `L1BitcoinDepositor` & `L2BitcoinDepositor` pair is +/// responsible for a specific L2 chain. +/// +/// The outline of the direct bridging mechanism is as follows: +/// 1. An L2 user issues a Bitcoin funding transaction to a P2(W)SH +/// deposit address that embeds the `L1BitcoinDepositor` contract +/// and L2 user addresses. The `L1BitcoinDepositor` contract serves +/// as the actual depositor on the L1 chain while the L2 user +/// address is set as the deposit owner who will receive the +/// minted ERC20 TBTC. +/// 2. The data about the Bitcoin funding transaction and deposit +/// address are passed to the relayer. In the first iteration of +/// the direct bridging mechanism, this is achieved using an +/// on-chain event emitted by the `L2BitcoinDepositor` contract. +/// Further iterations assumes those data are passed off-chain, e.g. +/// through a REST API exposed by the relayer. +/// 3. The relayer uses the data to initialize a deposit on the L1 +/// chain by calling the `initializeDeposit` function of the +/// `L1BitcoinDepositor` contract. The `initializeDeposit` function +/// reveals the deposit to the tBTC Bridge so minting of ERC20 L1 TBTC +/// can occur. +/// 4. Once minting is complete, the `L1BitcoinDepositor` contract +/// receives minted ERC20 L1 TBTC. The relayer then calls the +/// `finalizeDeposit` function of the `L1BitcoinDepositor` contract +/// to transfer the minted ERC20 L1 TBTC to the L2 user address. This +/// is achieved using the Wormhole protocol. First, the `finalizeDeposit` +/// function initiates a Wormhole token transfer that locks the ERC20 +/// L1 TBTC within the Wormhole Token Bridge contract and assigns +/// Wormhole-wrapped L2 TBTC to the corresponding `L2WormholeGateway` +/// contract. Then, `finalizeDeposit` notifies the `L2BitcoinDepositor` +/// contract by sending a Wormhole message containing the VAA +/// of the Wormhole token transfer. The `L2BitcoinDepositor` contract +/// receives the Wormhole message, and calls the `L2WormholeGateway` +/// contract that redeems Wormhole-wrapped L2 TBTC from the Wormhole +/// Token Bridge and uses it to mint canonical L2 TBTC to the L2 user +/// address. +contract L1BitcoinDepositor is + AbstractTBTCDepositor, + OwnableUpgradeable, + Reimbursable +{ + using SafeERC20Upgradeable for IERC20Upgradeable; + + /// @notice Reflects the deposit state: + /// - Unknown deposit has not been initialized yet. + /// - Initialized deposit has been initialized with a call to + /// `initializeDeposit` function and is known to this contract. + /// - Finalized deposit led to TBTC ERC20 minting and was finalized + /// with a call to `finalizeDeposit` function that transferred + /// TBTC ERC20 to the L2 deposit owner. + enum DepositState { + Unknown, + Initialized, + Finalized + } + + /// @notice Holds information about a deferred gas reimbursement. + struct GasReimbursement { + /// @notice Receiver that is supposed to receive the reimbursement. + address receiver; + /// @notice Gas expenditure that is meant to be reimbursed. + uint96 gasSpent; + } + + /// @notice Holds the deposit state, keyed by the deposit key calculated for + /// the individual deposit during the call to `initializeDeposit` + /// function. + mapping(uint256 => DepositState) public deposits; + /// @notice ERC20 L1 TBTC token contract. + IERC20Upgradeable public tbtcToken; + /// @notice `Wormhole` core contract on L1. + IWormhole public wormhole; + /// @notice `WormholeRelayer` contract on L1. + IWormholeRelayer public wormholeRelayer; + /// @notice Wormhole `TokenBridge` contract on L1. + IWormholeTokenBridge public wormholeTokenBridge; + /// @notice tBTC `L2WormholeGateway` contract on the corresponding L2 chain. + address public l2WormholeGateway; + /// @notice Wormhole chain ID of the corresponding L2 chain. + uint16 public l2ChainId; + /// @notice tBTC `L2BitcoinDepositor` contract on the corresponding L2 chain. + address public l2BitcoinDepositor; + /// @notice Gas limit necessary to execute the L2 part of the deposit + /// finalization. This value is used to calculate the payment for + /// the Wormhole Relayer that is responsible to execute the + /// deposit finalization on the corresponding L2 chain. Can be + /// updated by the owner. + uint256 public l2FinalizeDepositGasLimit; + /// @notice Holds deferred gas reimbursements for deposit initialization + /// (indexed by deposit key). Reimbursement for deposit + /// initialization is paid out upon deposit finalization. This is + /// because the tBTC Bridge accepts all (even invalid) deposits but + /// mints ERC20 TBTC only for the valid ones. Paying out the + /// reimbursement directly upon initialization would make the + /// reimbursement pool vulnerable to malicious actors that could + /// drain it by initializing invalid deposits. + mapping(uint256 => GasReimbursement) public gasReimbursements; + /// @notice Gas that is meant to balance the overall cost of deposit initialization. + /// Can be updated by the owner based on the current market conditions. + uint256 public initializeDepositGasOffset; + /// @notice Gas that is meant to balance the overall cost of deposit finalization. + /// Can be updated by the owner based on the current market conditions. + uint256 public finalizeDepositGasOffset; + + event DepositInitialized( + uint256 indexed depositKey, + address indexed l2DepositOwner, + address indexed l1Sender + ); + + event DepositFinalized( + uint256 indexed depositKey, + address indexed l2DepositOwner, + address indexed l1Sender, + uint256 initialAmount, + uint256 tbtcAmount + ); + + event L2FinalizeDepositGasLimitUpdated(uint256 l2FinalizeDepositGasLimit); + + event GasOffsetParametersUpdated( + uint256 initializeDepositGasOffset, + uint256 finalizeDepositGasOffset + ); + + /// @dev This modifier comes from the `Reimbursable` base contract and + /// must be overridden to protect the `updateReimbursementPool` call. + modifier onlyReimbursableAdmin() override { + require(msg.sender == owner(), "Caller is not the owner"); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize( + address _tbtcBridge, + address _tbtcVault, + address _wormhole, + address _wormholeRelayer, + address _wormholeTokenBridge, + address _l2WormholeGateway, + uint16 _l2ChainId + ) external initializer { + __AbstractTBTCDepositor_initialize(_tbtcBridge, _tbtcVault); + __Ownable_init(); + + tbtcToken = IERC20Upgradeable(ITBTCVault(_tbtcVault).tbtcToken()); + wormhole = IWormhole(_wormhole); + wormholeRelayer = IWormholeRelayer(_wormholeRelayer); + wormholeTokenBridge = IWormholeTokenBridge(_wormholeTokenBridge); + // slither-disable-next-line missing-zero-check + l2WormholeGateway = _l2WormholeGateway; + l2ChainId = _l2ChainId; + l2FinalizeDepositGasLimit = 500_000; + initializeDepositGasOffset = 60_000; + finalizeDepositGasOffset = 20_000; + } + + /// @notice Sets the address of the `L2BitcoinDepositor` contract on the + /// corresponding L2 chain. This function solves the chicken-and-egg + /// problem of setting the `L2BitcoinDepositor` contract address + /// on the `L1BitcoinDepositor` contract and vice versa. + /// @param _l2BitcoinDepositor Address of the `L2BitcoinDepositor` contract. + /// @dev Requirements: + /// - Can be called only by the contract owner, + /// - The address must not be set yet, + /// - The new address must not be 0x0. + function attachL2BitcoinDepositor(address _l2BitcoinDepositor) + external + onlyOwner + { + require( + l2BitcoinDepositor == address(0), + "L2 Bitcoin Depositor already set" + ); + require( + _l2BitcoinDepositor != address(0), + "L2 Bitcoin Depositor must not be 0x0" + ); + l2BitcoinDepositor = _l2BitcoinDepositor; + } + + /// @notice Updates the gas limit necessary to execute the L2 part of the + /// deposit finalization. + /// @param _l2FinalizeDepositGasLimit New gas limit. + /// @dev Requirements: + /// - Can be called only by the contract owner. + function updateL2FinalizeDepositGasLimit(uint256 _l2FinalizeDepositGasLimit) + external + onlyOwner + { + l2FinalizeDepositGasLimit = _l2FinalizeDepositGasLimit; + emit L2FinalizeDepositGasLimitUpdated(_l2FinalizeDepositGasLimit); + } + + /// @notice Updates the values of gas offset parameters. + /// @dev Can be called only by the contract owner. The caller is responsible + /// for validating parameters. + /// @param _initializeDepositGasOffset New initialize deposit gas offset. + /// @param _finalizeDepositGasOffset New finalize deposit gas offset. + function updateGasOffsetParameters( + uint256 _initializeDepositGasOffset, + uint256 _finalizeDepositGasOffset + ) external onlyOwner { + initializeDepositGasOffset = _initializeDepositGasOffset; + finalizeDepositGasOffset = _finalizeDepositGasOffset; + + emit GasOffsetParametersUpdated( + _initializeDepositGasOffset, + _finalizeDepositGasOffset + ); + } + + /// @notice Initializes the deposit process on L1 by revealing the deposit + /// data (funding transaction and components of the P2(W)SH deposit + /// address) to the tBTC Bridge. Once tBTC minting is completed, + /// this call should be followed by a call to `finalizeDeposit`. + /// Callers of `initializeDeposit` are eligible for a gas refund + /// that is paid out upon deposit finalization (only if the + /// reimbursement pool is attached). + /// + /// The Bitcoin funding transaction must transfer funds to a P2(W)SH + /// deposit address whose underlying script is built from the + /// following components: + /// + /// DROP + /// DROP + /// DROP + /// DUP HASH160 EQUAL + /// IF + /// CHECKSIG + /// ELSE + /// DUP HASH160 EQUALVERIFY + /// CHECKLOCKTIMEVERIFY DROP + /// CHECKSIG + /// ENDIF + /// + /// Where: + /// + /// 20-byte L1 address of the + /// `L1BitcoinDepositor` contract. + /// + /// L2 deposit owner address in the Wormhole + /// format, i.e. 32-byte value left-padded with 0. + /// + /// 8-byte deposit blinding factor, as used in the + /// tBTC bridge. + /// + /// The compressed Bitcoin public key (33 + /// bytes and 02 or 03 prefix) of the deposit's wallet hashed in the + /// HASH160 Bitcoin opcode style. This must point to the active tBTC + /// bridge wallet. + /// + /// The compressed Bitcoin public key (33 bytes + /// and 02 or 03 prefix) that can be used to make the deposit refund + /// after the tBTC bridge refund locktime passed. Hashed in the + /// HASH160 Bitcoin opcode style. This is needed only as a security + /// measure protecting the user in case tBTC bridge completely stops + /// functioning. + /// + /// The Bitcoin script refund locktime (4-byte LE), + /// according to tBTC bridge rules. + /// + /// Please consult tBTC `Bridge.revealDepositWithExtraData` function + /// documentation for more information. + /// @param fundingTx Bitcoin funding transaction data. + /// @param reveal Deposit reveal data. + /// @param l2DepositOwner Address of the L2 deposit owner. + /// @dev Requirements: + /// - The L2 deposit owner address must not be 0x0, + /// - The function can be called only one time for the given Bitcoin + /// funding transaction, + /// - The L2 deposit owner must be embedded in the Bitcoin P2(W)SH + /// deposit script as the field. The 20-byte + /// address must be expressed as a 32-byte value left-padded with 0. + /// If the value in the Bitcoin script and the value passed as + /// parameter do not match, the function will revert, + /// - All the requirements of tBTC Bridge.revealDepositWithExtraData + /// must be met. + function initializeDeposit( + IBridgeTypes.BitcoinTxInfo calldata fundingTx, + IBridgeTypes.DepositRevealInfo calldata reveal, + address l2DepositOwner + ) external { + uint256 gasStart = gasleft(); + + require( + l2DepositOwner != address(0), + "L2 deposit owner must not be 0x0" + ); + + // Convert the L2 deposit owner address into the Wormhole format and + // encode it as deposit extra data. + bytes32 extraData = WormholeUtils.toWormholeAddress(l2DepositOwner); + + // Input parameters do not have to be validated in any way. + // The tBTC Bridge is responsible for validating whether the provided + // Bitcoin funding transaction transfers funds to the P2(W)SH deposit + // address built from the reveal data. Despite the tBTC Bridge accepts + // all transactions that meet the format requirements, it mints ERC20 + // L1 TBTC only for the ones that actually occurred on the Bitcoin + // network and gathered enough confirmations. + (uint256 depositKey, ) = _initializeDeposit( + fundingTx, + reveal, + extraData + ); + + require( + deposits[depositKey] == DepositState.Unknown, + "Wrong deposit state" + ); + + // slither-disable-next-line reentrancy-benign + deposits[depositKey] = DepositState.Initialized; + + // slither-disable-next-line reentrancy-events + emit DepositInitialized(depositKey, l2DepositOwner, msg.sender); + + if (address(reimbursementPool) != address(0)) { + uint256 gasSpent = (gasStart - gasleft()) + + initializeDepositGasOffset; + + // Should not happen as long as initializeDepositGasOffset is + // set to a reasonable value. If it happens, it's better to + // omit the reimbursement than to revert the transaction. + if (gasSpent > type(uint96).max) { + return; + } + + // Do not issue a reimbursement immediately. Record + // a deferred reimbursement that will be paid out upon deposit + // finalization. This is because the tBTC Bridge accepts all + // (even invalid) deposits but mints ERC20 TBTC only for the valid + // ones. Paying out the reimbursement directly upon initialization + // would make the reimbursement pool vulnerable to malicious actors + // that could drain it by initializing invalid deposits. + // slither-disable-next-line reentrancy-benign + gasReimbursements[depositKey] = GasReimbursement({ + receiver: msg.sender, + gasSpent: uint96(gasSpent) + }); + } + } + + /// @notice Finalizes the deposit process by transferring ERC20 L1 TBTC + /// to the L2 deposit owner. This function should be called after + /// the deposit was initialized with a call to `initializeDeposit` + /// function and after ERC20 L1 TBTC was minted by the tBTC Bridge + /// to the `L1BitcoinDepositor` contract. Please note several hours + /// may pass between `initializeDeposit`and `finalizeDeposit`. + /// If the reimbursement pool is attached, the function pays out + /// a gas and call's value refund to the caller as well as the + /// deferred gas refund to the caller of `initializeDeposit` + /// corresponding to the finalized deposit. + /// @param depositKey The deposit key, as emitted in the `DepositInitialized` + /// event emitted by the `initializeDeposit` function for the deposit. + /// @dev Requirements: + /// - `initializeDeposit` was called for the given deposit before, + /// - ERC20 L1 TBTC was minted by tBTC Bridge to this contract, + /// - The function was not called for the given deposit before, + /// - The call must carry a payment for the Wormhole Relayer that + /// is responsible for executing the deposit finalization on the + /// corresponding L2 chain. The payment must be equal to the + /// value returned by the `quoteFinalizeDeposit` function. + function finalizeDeposit(uint256 depositKey) external payable { + uint256 gasStart = gasleft(); + + require( + deposits[depositKey] == DepositState.Initialized, + "Wrong deposit state" + ); + + deposits[depositKey] = DepositState.Finalized; + + ( + uint256 initialDepositAmount, + uint256 tbtcAmount, + // Deposit extra data is actually the L2 deposit owner + // address in Wormhole format. + bytes32 l2DepositOwner + ) = _finalizeDeposit(depositKey); + + // slither-disable-next-line reentrancy-events + emit DepositFinalized( + depositKey, + WormholeUtils.fromWormholeAddress(l2DepositOwner), + msg.sender, + initialDepositAmount, + tbtcAmount + ); + + _transferTbtc(tbtcAmount, l2DepositOwner); + + // `ReimbursementPool` calls the untrusted receiver address using a + // low-level call. Reentrancy risk is mitigated by making sure that + // `ReimbursementPool.refund` is a non-reentrant function and executing + // reimbursements as the last step of the deposit finalization. + if (address(reimbursementPool) != address(0)) { + // If there is a deferred reimbursement for this deposit + // initialization, pay it out now. + GasReimbursement memory reimbursement = gasReimbursements[ + depositKey + ]; + if (reimbursement.receiver != address(0)) { + delete gasReimbursements[depositKey]; + + reimbursementPool.refund( + reimbursement.gasSpent, + reimbursement.receiver + ); + } + + // Pay out the reimbursement for deposit finalization. As this + // call is payable and this transaction carries out a msg.value + // that covers Wormhole cost, we need to reimburse that as well. + // However, the `ReimbursementPool` issues refunds based on + // gas spent. We need to convert msg.value accordingly using + // the `_refundToGasSpent` function. + uint256 msgValueOffset = _refundToGasSpent(msg.value); + reimbursementPool.refund( + (gasStart - gasleft()) + + msgValueOffset + + finalizeDepositGasOffset, + msg.sender + ); + } + } + + /// @notice The `ReimbursementPool` contract issues refunds based on + /// gas spent. If there is a need to get a specific refund based + /// on WEI value, such a value must be first converted to gas spent. + /// This function does such a conversion. + /// @param refund Refund value in WEI. + /// @return Refund value as gas spent. + /// @dev This function is the reverse of the logic used + /// within `ReimbursementPool.refund`. + function _refundToGasSpent(uint256 refund) internal returns (uint256) { + uint256 maxGasPrice = reimbursementPool.maxGasPrice(); + uint256 staticGas = reimbursementPool.staticGas(); + + uint256 gasPrice = tx.gasprice < maxGasPrice + ? tx.gasprice + : maxGasPrice; + + // Should not happen but check just in case of weird ReimbursementPool + // configuration. + if (gasPrice == 0) { + return 0; + } + + uint256 gasSpent = (refund / gasPrice); + + // Should not happen but check just in case of weird ReimbursementPool + // configuration. + if (staticGas > gasSpent) { + return 0; + } + + return gasSpent - staticGas; + } + + /// @notice Quotes the payment that must be attached to the `finalizeDeposit` + /// function call. The payment is necessary to cover the cost of + /// the Wormhole Relayer that is responsible for executing the + /// deposit finalization on the corresponding L2 chain. + /// @return cost The cost of the `finalizeDeposit` function call in WEI. + function quoteFinalizeDeposit() external view returns (uint256 cost) { + cost = _quoteFinalizeDeposit(wormhole.messageFee()); + } + + /// @notice Internal version of the `quoteFinalizeDeposit` function that + /// works with a custom Wormhole message fee. + /// @param messageFee Custom Wormhole message fee. + /// @return cost The cost of the `finalizeDeposit` function call in WEI. + /// @dev Implemented based on examples presented as part of the Wormhole SDK: + /// https://github.com/wormhole-foundation/hello-token/blob/8ec757248788dc12183f13627633e1d6fd1001bb/src/example-extensions/HelloTokenWithoutSDK.sol#L23 + function _quoteFinalizeDeposit(uint256 messageFee) + internal + view + returns (uint256 cost) + { + // Cost of delivering token and payload to `l2ChainId`. + (uint256 deliveryCost, ) = wormholeRelayer.quoteEVMDeliveryPrice( + l2ChainId, + 0, + l2FinalizeDepositGasLimit + ); + + // Total cost = delivery cost + cost of publishing the `sending token` + // Wormhole message. + cost = deliveryCost + messageFee; + } + + /// @notice Transfers ERC20 L1 TBTC to the L2 deposit owner using the Wormhole + /// protocol. The function initiates a Wormhole token transfer that + /// locks the ERC20 L1 TBTC within the Wormhole Token Bridge contract + /// and assigns Wormhole-wrapped L2 TBTC to the corresponding + /// `L2WormholeGateway` contract. Then, the function notifies the + /// `L2BitcoinDepositor` contract by sending a Wormhole message + /// containing the VAA of the Wormhole token transfer. The + /// `L2BitcoinDepositor` contract receives the Wormhole message, + /// and calls the `L2WormholeGateway` contract that redeems + /// Wormhole-wrapped L2 TBTC from the Wormhole Token Bridge and + /// uses it to mint canonical L2 TBTC to the L2 deposit owner address. + /// @param amount Amount of TBTC L1 ERC20 to transfer (1e18 precision). + /// @param l2Receiver Address of the L2 deposit owner. + /// @dev Requirements: + /// - The normalized amount (1e8 precision) must be greater than 0, + /// - The appropriate payment for the Wormhole Relayer must be + /// attached to the call (as calculated by `quoteFinalizeDeposit`). + /// @dev Implemented based on examples presented as part of the Wormhole SDK: + /// https://github.com/wormhole-foundation/hello-token/blob/8ec757248788dc12183f13627633e1d6fd1001bb/src/example-extensions/HelloTokenWithoutSDK.sol#L29 + function _transferTbtc(uint256 amount, bytes32 l2Receiver) internal { + // Wormhole supports the 1e8 precision at most. TBTC is 1e18 so + // the amount needs to be normalized. + amount = WormholeUtils.normalize(amount); + + require(amount > 0, "Amount too low to bridge"); + + // Cost of requesting a `finalizeDeposit` message to be sent to + // `l2ChainId` with a gasLimit of `l2FinalizeDepositGasLimit`. + uint256 wormholeMessageFee = wormhole.messageFee(); + uint256 cost = _quoteFinalizeDeposit(wormholeMessageFee); + + require(msg.value == cost, "Payment for Wormhole Relayer is too low"); + + // The Wormhole Token Bridge will pull the TBTC amount + // from this contract. We need to approve the transfer first. + tbtcToken.safeIncreaseAllowance(address(wormholeTokenBridge), amount); + + // Initiate a Wormhole token transfer that will lock L1 TBTC within + // the Wormhole Token Bridge contract and assign Wormhole-wrapped + // L2 TBTC to the corresponding `L2WormholeGateway` contract. + // slither-disable-next-line arbitrary-send-eth + uint64 transferSequence = wormholeTokenBridge.transferTokensWithPayload{ + value: wormholeMessageFee + }( + address(tbtcToken), + amount, + l2ChainId, + WormholeUtils.toWormholeAddress(l2WormholeGateway), + 0, // Nonce is a free field that is not relevant in this context. + abi.encode(l2Receiver) // Set the L2 receiver address as the transfer payload. + ); + + // Construct the VAA key corresponding to the above Wormhole token transfer. + WormholeTypes.VaaKey[] + memory additionalVaas = new WormholeTypes.VaaKey[](1); + additionalVaas[0] = WormholeTypes.VaaKey({ + chainId: wormhole.chainId(), + emitterAddress: WormholeUtils.toWormholeAddress( + address(wormholeTokenBridge) + ), + sequence: transferSequence + }); + + // The Wormhole token transfer initiated above must be finalized on + // the L2 chain. We achieve that by sending the transfer's VAA to the + // `L2BitcoinDepositor` contract. Once, the `L2BitcoinDepositor` + // contract receives it, it calls the `L2WormholeGateway` contract + // that redeems Wormhole-wrapped L2 TBTC from the Wormhole Token + // Bridge and use it to mint canonical L2 TBTC to the receiver address. + // slither-disable-next-line arbitrary-send-eth,unused-return + wormholeRelayer.sendVaasToEvm{value: cost - wormholeMessageFee}( + l2ChainId, + l2BitcoinDepositor, + bytes(""), // No payload needed. The L2 receiver address is already encoded in the Wormhole token transfer payload. + 0, // No receiver value needed. + l2FinalizeDepositGasLimit, + additionalVaas, + l2ChainId, // Set the L2 chain as the refund chain to avoid cross-chain refunds. + msg.sender // Set the caller as the refund receiver. + ); + } +} diff --git a/solidity/contracts/l2/L2BitcoinDepositor.sol b/solidity/contracts/l2/L2BitcoinDepositor.sol new file mode 100644 index 000000000..f7a8f4007 --- /dev/null +++ b/solidity/contracts/l2/L2BitcoinDepositor.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ + +pragma solidity ^0.8.17; + +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import "../integrator/IBridge.sol"; +import "./Wormhole.sol"; + +/// @title IL2WormholeGateway +/// @notice Interface to the `L2WormholeGateway` contract. +interface IL2WormholeGateway { + /// @dev See ./L2WormholeGateway.sol#receiveTbtc + function receiveTbtc(bytes memory vaa) external; +} + +/// @title L2BitcoinDepositor +/// @notice This contract is part of the direct bridging mechanism allowing +/// users to obtain ERC20 TBTC on supported L2 chains, without the need +/// to interact with the L1 tBTC ledger chain where minting occurs. +/// +/// `L2BitcoinDepositor` is deployed on the L2 chain and interacts with +/// their L1 counterpart, the `L1BitcoinDepositor`, deployed on the +/// L1 tBTC ledger chain. Each `L1BitcoinDepositor` & `L2BitcoinDepositor` +/// pair is responsible for a specific L2 chain. +/// +/// Please consult the `L1BitcoinDepositor` docstring for an +/// outline of the direct bridging mechanism +// slither-disable-next-line locked-ether +contract L2BitcoinDepositor is IWormholeReceiver, OwnableUpgradeable { + /// @notice `WormholeRelayer` contract on L2. + IWormholeRelayer public wormholeRelayer; + /// @notice tBTC `L2WormholeGateway` contract on L2. + IL2WormholeGateway public l2WormholeGateway; + /// @notice Wormhole chain ID of the corresponding L1 chain. + uint16 public l1ChainId; + /// @notice tBTC `L1BitcoinDepositor` contract on the corresponding L1 chain. + address public l1BitcoinDepositor; + + event DepositInitialized( + IBridgeTypes.BitcoinTxInfo fundingTx, + IBridgeTypes.DepositRevealInfo reveal, + address indexed l2DepositOwner, + address indexed l2Sender + ); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize( + address _wormholeRelayer, + address _l2WormholeGateway, + uint16 _l1ChainId + ) external initializer { + __Ownable_init(); + + wormholeRelayer = IWormholeRelayer(_wormholeRelayer); + l2WormholeGateway = IL2WormholeGateway(_l2WormholeGateway); + l1ChainId = _l1ChainId; + } + + /// @notice Sets the address of the `L1BitcoinDepositor` contract on the + /// corresponding L1 chain. This function solves the chicken-and-egg + /// problem of setting the `L1BitcoinDepositor` contract address + /// on the `L2BitcoinDepositor` contract and vice versa. + /// @param _l1BitcoinDepositor Address of the `L1BitcoinDepositor` contract. + /// @dev Requirements: + /// - Can be called only by the contract owner, + /// - The address must not be set yet, + /// - The new address must not be 0x0. + function attachL1BitcoinDepositor(address _l1BitcoinDepositor) + external + onlyOwner + { + require( + l1BitcoinDepositor == address(0), + "L1 Bitcoin Depositor already set" + ); + require( + _l1BitcoinDepositor != address(0), + "L1 Bitcoin Depositor must not be 0x0" + ); + l1BitcoinDepositor = _l1BitcoinDepositor; + } + + /// @notice Initializes the deposit process on L2 by emitting an event + /// containing the deposit data (funding transaction and + /// components of the P2(W)SH deposit address). The event is + /// supposed to be picked up by the relayer and used to initialize + /// the deposit on L1 through the `L1BitcoinDepositor` contract. + /// @param fundingTx Bitcoin funding transaction data. + /// @param reveal Deposit reveal data. + /// @param l2DepositOwner Address of the L2 deposit owner. + /// @dev The alternative approach of using Wormhole Relayer to send the + /// deposit data to L1 was considered. However, it turned out to be + /// too expensive. For example, relying deposit data from Base L2 to + /// Ethereum L1 costs around ~0.045 ETH (~170 USD at the moment of writing). + /// Moreover, the next iteration of the direct bridging mechanism + /// assumes that no L2 transaction will be required to initialize the + /// deposit and the relayer should obtain the deposit data off-chain. + /// There is a high chance this function will be removed then. + /// That said, there was no sense to explore another cross-chain + /// messaging solutions. Relying on simple on-chain event and custom + /// off-chain relayer seems to be the most reasonable way to go. It + /// also aligns with the future direction of the direct bridging mechanism. + function initializeDeposit( + IBridgeTypes.BitcoinTxInfo calldata fundingTx, + IBridgeTypes.DepositRevealInfo calldata reveal, + address l2DepositOwner + ) external { + emit DepositInitialized(fundingTx, reveal, l2DepositOwner, msg.sender); + } + + /// @notice Receives Wormhole messages originating from the corresponding + /// `L1BitcoinDepositor` contract that lives on the L1 chain. + /// Messages are issued upon deposit finalization on L1 and + /// are supposed to carry the VAA of the Wormhole token transfer of + /// ERC20 L1 TBTC to the L2 chain. This contract performs some basic + /// checks and forwards the VAA to the `L2WormholeGateway` contract + /// that is authorized to withdraw the Wormhole-wrapped L2 TBTC + /// from the Wormhole Token Bridge (representing the ERC20 TBTC + /// locked on L1) and use it to mint the canonical L2 TBTC for the + /// deposit owner. + /// @param additionalVaas Additional VAAs that are part of the Wormhole message. + /// @param sourceAddress Address of the source of the message (in Wormhole format). + /// @param sourceChain Wormhole chain ID of the source chain. + /// @dev Requirements: + /// - Can be called only by the Wormhole Relayer contract, + /// - The source chain must be the expected L1 chain, + /// - The source address must be the corresponding + /// `L1BitcoinDepositor` contract, + /// - The message must carry exactly 1 additional VAA key representing + /// the token transfer. + function receiveWormholeMessages( + bytes memory, + bytes[] memory additionalVaas, + bytes32 sourceAddress, + uint16 sourceChain, + bytes32 + ) external payable { + require( + msg.sender == address(wormholeRelayer), + "Caller is not Wormhole Relayer" + ); + + require( + sourceChain == l1ChainId, + "Source chain is not the expected L1 chain" + ); + + require( + WormholeUtils.fromWormholeAddress(sourceAddress) == + l1BitcoinDepositor, + "Source address is not the expected L1 Bitcoin depositor" + ); + + require( + additionalVaas.length == 1, + "Expected 1 additional VAA key for token transfer" + ); + + l2WormholeGateway.receiveTbtc(additionalVaas[0]); + } +} diff --git a/solidity/contracts/l2/L2WormholeGateway.sol b/solidity/contracts/l2/L2WormholeGateway.sol index 88fec4514..148ddbc74 100644 --- a/solidity/contracts/l2/L2WormholeGateway.sol +++ b/solidity/contracts/l2/L2WormholeGateway.sol @@ -20,51 +20,9 @@ import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable. import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "./Wormhole.sol"; import "./L2TBTC.sol"; -/// @title IWormholeTokenBridge -/// @notice Wormhole Token Bridge interface. Contains only selected functions -/// used by L2WormholeGateway. -interface IWormholeTokenBridge { - function completeTransferWithPayload(bytes memory encodedVm) - external - returns (bytes memory); - - function parseTransferWithPayload(bytes memory encoded) - external - pure - returns (TransferWithPayload memory transfer); - - function transferTokens( - address token, - uint256 amount, - uint16 recipientChain, - bytes32 recipient, - uint256 arbiterFee, - uint32 nonce - ) external payable returns (uint64 sequence); - - function transferTokensWithPayload( - address token, - uint256 amount, - uint16 recipientChain, - bytes32 recipient, - uint32 nonce, - bytes memory payload - ) external payable returns (uint64 sequence); - - struct TransferWithPayload { - uint8 payloadID; - uint256 amount; - bytes32 tokenAddress; - uint16 tokenChain; - bytes32 to; - uint16 toChain; - bytes32 fromAddress; - bytes payload; - } -} - /// @title L2WormholeGateway /// @notice Selected cross-ecosystem bridges are given the minting authority for /// tBTC token on L2 and sidechains. This contract gives a minting @@ -100,6 +58,7 @@ interface IWormholeTokenBridge { /// Wormhole tBTC representation through the bridge in an equal amount. /// @dev This contract is supposed to be deployed behind a transparent /// upgradeable proxy. +// slither-disable-next-line missing-inheritance contract L2WormholeGateway is Initializable, OwnableUpgradeable, @@ -216,7 +175,7 @@ contract L2WormholeGateway is // Normalize the amount to bridge. The dust can not be bridged due to // the decimal shift in the Wormhole Bridge contract. - amount = normalize(amount); + amount = WormholeUtils.normalize(amount); // Check again after dropping the dust. require(amount != 0, "Amount too low to bridge"); @@ -362,7 +321,7 @@ contract L2WormholeGateway is pure returns (bytes32) { - return bytes32(uint256(uint160(_address))); + return WormholeUtils.toWormholeAddress(_address); } /// @notice Converts Wormhole address into Ethereum format. @@ -372,16 +331,6 @@ contract L2WormholeGateway is pure returns (address) { - return address(uint160(uint256(_address))); - } - - /// @dev Eliminates the dust that cannot be bridged with Wormhole - /// due to the decimal shift in the Wormhole Bridge contract. - /// See https://github.com/wormhole-foundation/wormhole/blob/96682bdbeb7c87bfa110eade0554b3d8cbf788d2/ethereum/contracts/bridge/Bridge.sol#L276-L288 - function normalize(uint256 amount) internal pure returns (uint256) { - // slither-disable-next-line divide-before-multiply - amount /= 10**10; - amount *= 10**10; - return amount; + return WormholeUtils.fromWormholeAddress(_address); } } diff --git a/solidity/contracts/l2/Wormhole.sol b/solidity/contracts/l2/Wormhole.sol new file mode 100644 index 000000000..2721d6a44 --- /dev/null +++ b/solidity/contracts/l2/Wormhole.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: GPL-3.0-only + +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ + +pragma solidity ^0.8.17; + +/// @title WormholeTypes +/// @notice Namespace which groups all types relevant to Wormhole interfaces. +library WormholeTypes { + /// @dev See: https://github.com/wormhole-foundation/wormhole-solidity-sdk/blob/2b7db51f99b49eda99b44f4a044e751cb0b2e8ea/src/interfaces/IWormholeRelayer.sol#L22 + struct VaaKey { + uint16 chainId; + bytes32 emitterAddress; + uint64 sequence; + } +} + +/// @title IWormhole +/// @notice Wormhole interface. +/// @dev See: https://github.com/wormhole-foundation/wormhole-solidity-sdk/blob/2b7db51f99b49eda99b44f4a044e751cb0b2e8ea/src/interfaces/IWormhole.sol#L6 +interface IWormhole { + /// @dev See: https://github.com/wormhole-foundation/wormhole-solidity-sdk/blob/2b7db51f99b49eda99b44f4a044e751cb0b2e8ea/src/interfaces/IWormhole.sol#L109 + function chainId() external view returns (uint16); + + /// @dev See: https://github.com/wormhole-foundation/wormhole-solidity-sdk/blob/2b7db51f99b49eda99b44f4a044e751cb0b2e8ea/src/interfaces/IWormhole.sol#L117 + function messageFee() external view returns (uint256); +} + +/// @title IWormholeRelayer +/// @notice Wormhole Relayer interface. +/// @dev See: https://github.com/wormhole-foundation/wormhole-solidity-sdk/blob/2b7db51f99b49eda99b44f4a044e751cb0b2e8ea/src/interfaces/IWormholeRelayer.sol#L74 +interface IWormholeRelayer { + /// @dev See: https://github.com/wormhole-foundation/wormhole-solidity-sdk/blob/2b7db51f99b49eda99b44f4a044e751cb0b2e8ea/src/interfaces/IWormholeRelayer.sol#L442 + function quoteEVMDeliveryPrice( + uint16 targetChain, + uint256 receiverValue, + uint256 gasLimit + ) + external + view + returns ( + uint256 nativePriceQuote, + uint256 targetChainRefundPerGasUnused + ); + + /// @dev See: https://github.com/wormhole-foundation/wormhole-solidity-sdk/blob/2b7db51f99b49eda99b44f4a044e751cb0b2e8ea/src/interfaces/IWormholeRelayer.sol#L182 + function sendVaasToEvm( + uint16 targetChain, + address targetAddress, + bytes memory payload, + uint256 receiverValue, + uint256 gasLimit, + WormholeTypes.VaaKey[] memory vaaKeys, + uint16 refundChain, + address refundAddress + ) external payable returns (uint64 sequence); +} + +/// @title IWormholeReceiver +/// @notice Wormhole Receiver interface. +/// @dev See: https://github.com/wormhole-foundation/wormhole-solidity-sdk/blob/2b7db51f99b49eda99b44f4a044e751cb0b2e8ea/src/interfaces/IWormholeReceiver.sol#L8 +interface IWormholeReceiver { + /// @dev See: https://github.com/wormhole-foundation/wormhole-solidity-sdk/blob/2b7db51f99b49eda99b44f4a044e751cb0b2e8ea/src/interfaces/IWormholeReceiver.sol#L44 + function receiveWormholeMessages( + bytes memory payload, + bytes[] memory additionalVaas, + bytes32 sourceAddress, + uint16 sourceChain, + bytes32 deliveryHash + ) external payable; +} + +/// @title IWormholeTokenBridge +/// @notice Wormhole Token Bridge interface. +/// @dev See: https://github.com/wormhole-foundation/wormhole-solidity-sdk/blob/2b7db51f99b49eda99b44f4a044e751cb0b2e8ea/src/interfaces/ITokenBridge.sol#L9 +interface IWormholeTokenBridge { + function completeTransferWithPayload(bytes memory encodedVm) + external + returns (bytes memory); + + function parseTransferWithPayload(bytes memory encoded) + external + pure + returns (TransferWithPayload memory transfer); + + function transferTokens( + address token, + uint256 amount, + uint16 recipientChain, + bytes32 recipient, + uint256 arbiterFee, + uint32 nonce + ) external payable returns (uint64 sequence); + + function transferTokensWithPayload( + address token, + uint256 amount, + uint16 recipientChain, + bytes32 recipient, + uint32 nonce, + bytes memory payload + ) external payable returns (uint64 sequence); + + struct TransferWithPayload { + uint8 payloadID; + uint256 amount; + bytes32 tokenAddress; + uint16 tokenChain; + bytes32 to; + uint16 toChain; + bytes32 fromAddress; + bytes payload; + } +} + +/// @title WormholeUtils +/// @notice Library for Wormhole utilities. +library WormholeUtils { + /// @notice Converts Ethereum address into Wormhole format. + /// @param _address The address to convert. + function toWormholeAddress(address _address) + internal + pure + returns (bytes32) + { + return bytes32(uint256(uint160(_address))); + } + + /// @notice Converts Wormhole address into Ethereum format. + /// @param _address The address to convert. + function fromWormholeAddress(bytes32 _address) + internal + pure + returns (address) + { + return address(uint160(uint256(_address))); + } + + /// @dev Eliminates the dust that cannot be bridged with Wormhole + /// due to the decimal shift in the Wormhole Bridge contract. + /// See https://github.com/wormhole-foundation/wormhole/blob/96682bdbeb7c87bfa110eade0554b3d8cbf788d2/ethereum/contracts/bridge/Bridge.sol#L276-L288 + function normalize(uint256 amount) internal pure returns (uint256) { + // slither-disable-next-line divide-before-multiply + amount /= 10**10; + amount *= 10**10; + return amount; + } +} diff --git a/solidity/contracts/test/TestTBTCDepositor.sol b/solidity/contracts/test/TestTBTCDepositor.sol index a54155b11..8bd914c6d 100644 --- a/solidity/contracts/test/TestTBTCDepositor.sol +++ b/solidity/contracts/test/TestTBTCDepositor.sol @@ -220,4 +220,8 @@ contract MockTBTCVault is ITBTCVault { function setOptimisticMintingFeeDivisor(uint32 value) external { optimisticMintingFeeDivisor = value; } + + function tbtcToken() external view returns (address) { + revert("Not implemented"); + } } diff --git a/solidity/contracts/test/WormholeBridgeStub.sol b/solidity/contracts/test/WormholeBridgeStub.sol index 91bd9d417..7afe9413c 100644 --- a/solidity/contracts/test/WormholeBridgeStub.sol +++ b/solidity/contracts/test/WormholeBridgeStub.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.17; import "./TestERC20.sol"; -import "../l2/L2WormholeGateway.sol"; +import "../l2/Wormhole.sol"; /// @dev Stub contract used in L2WormholeGateway unit tests. /// Stub contract is used instead of a smock because of the token transfer diff --git a/solidity/test/l2/L1BitcoinDepositor.test.ts b/solidity/test/l2/L1BitcoinDepositor.test.ts new file mode 100644 index 000000000..5ca5ac208 --- /dev/null +++ b/solidity/test/l2/L1BitcoinDepositor.test.ts @@ -0,0 +1,1466 @@ +import { ethers, getUnnamedAccounts, helpers, waffle } from "hardhat" +import { randomBytes } from "crypto" +import chai, { expect } from "chai" +import { FakeContract, smock } from "@defi-wonderland/smock" +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" +import { BigNumber, ContractTransaction } from "ethers" +import { + IBridge, + IERC20, + IL2WormholeGateway, + ITBTCVault, + IWormhole, + IWormholeRelayer, + IWormholeTokenBridge, + L1BitcoinDepositor, + ReimbursementPool, + TestERC20, +} from "../../typechain" +import type { + BitcoinTxInfoStruct, + DepositRevealInfoStruct, +} from "../../typechain/L2BitcoinDepositor" +import { to1ePrecision } from "../helpers/contract-test-helpers" + +chai.use(smock.matchers) + +const { createSnapshot, restoreSnapshot } = helpers.snapshot +const { lastBlockTime } = helpers.time +// Just arbitrary values. +const l1ChainId = 10 +const l2ChainId = 20 + +describe("L1BitcoinDepositor", () => { + const contractsFixture = async () => { + const { deployer, governance } = await helpers.signers.getNamedSigners() + + const accounts = await getUnnamedAccounts() + const relayer = await ethers.getSigner(accounts[1]) + + const bridge = await smock.fake("IBridge") + const tbtcToken = await ( + await ethers.getContractFactory("TestERC20") + ).deploy() + const tbtcVault = await smock.fake("ITBTCVault", { + // The TBTCVault contract address must be known in advance and match + // the one used in initializeDeposit fixture. This is necessary to + // pass the vault address check in the initializeDeposit function. + address: tbtcVaultAddress, + }) + // Attack the tbtcToken mock to the tbtcVault mock. + tbtcVault.tbtcToken.returns(tbtcToken.address) + + const wormhole = await smock.fake("IWormhole") + wormhole.chainId.returns(l1ChainId) + + const wormholeRelayer = await smock.fake( + "IWormholeRelayer" + ) + const wormholeTokenBridge = await smock.fake( + "IWormholeTokenBridge" + ) + const l2WormholeGateway = await smock.fake( + "IL2WormholeGateway" + ) + // Just an arbitrary L2BitcoinDepositor address. + const l2BitcoinDepositor = "0xeE6F5f69860f310114185677D017576aed0dEC83" + const reimbursementPool = await smock.fake( + "ReimbursementPool" + ) + + const deployment = await helpers.upgrades.deployProxy( + // Hacky workaround allowing to deploy proxy contract any number of times + // without clearing `deployments/hardhat` directory. + // See: https://github.com/keep-network/hardhat-helpers/issues/38 + `L1BitcoinDepositor_${randomBytes(8).toString("hex")}`, + { + contractName: "L1BitcoinDepositor", + initializerArgs: [ + bridge.address, + tbtcVault.address, + wormhole.address, + wormholeRelayer.address, + wormholeTokenBridge.address, + l2WormholeGateway.address, + l2ChainId, + ], + factoryOpts: { signer: deployer }, + proxyOpts: { + kind: "transparent", + }, + } + ) + const l1BitcoinDepositor = deployment[0] as L1BitcoinDepositor + + await l1BitcoinDepositor + .connect(deployer) + .transferOwnership(governance.address) + + return { + governance, + relayer, + bridge, + tbtcToken, + tbtcVault, + wormhole, + wormholeRelayer, + wormholeTokenBridge, + l2WormholeGateway, + l2BitcoinDepositor, + reimbursementPool, + l1BitcoinDepositor, + } + } + + let governance: SignerWithAddress + let relayer: SignerWithAddress + + let bridge: FakeContract + let tbtcToken: TestERC20 + let tbtcVault: FakeContract + let wormhole: FakeContract + let wormholeRelayer: FakeContract + let wormholeTokenBridge: FakeContract + let l2WormholeGateway: FakeContract + let l2BitcoinDepositor: string + let reimbursementPool: FakeContract + let l1BitcoinDepositor: L1BitcoinDepositor + + before(async () => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ + governance, + relayer, + bridge, + tbtcToken, + tbtcVault, + wormhole, + wormholeRelayer, + wormholeTokenBridge, + l2WormholeGateway, + l1BitcoinDepositor, + reimbursementPool, + l2BitcoinDepositor, + } = await waffle.loadFixture(contractsFixture)) + }) + + describe("attachL2BitcoinDepositor", () => { + context("when the caller is not the owner", () => { + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .attachL2BitcoinDepositor(l2BitcoinDepositor) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when the caller is the owner", () => { + context("when the L2BitcoinDepositor is already attached", () => { + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(governance) + .attachL2BitcoinDepositor(l2BitcoinDepositor) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(governance) + .attachL2BitcoinDepositor(l2BitcoinDepositor) + ).to.be.revertedWith("L2 Bitcoin Depositor already set") + }) + }) + + context("when the L2BitcoinDepositor is not attached", () => { + context("when new L2BitcoinDepositor is zero", () => { + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(governance) + .attachL2BitcoinDepositor(ethers.constants.AddressZero) + ).to.be.revertedWith("L2 Bitcoin Depositor must not be 0x0") + }) + }) + + context("when new L2BitcoinDepositor is non-zero", () => { + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(governance) + .attachL2BitcoinDepositor(l2BitcoinDepositor) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should set the l2BitcoinDepositor address properly", async () => { + expect(await l1BitcoinDepositor.l2BitcoinDepositor()).to.equal( + l2BitcoinDepositor + ) + }) + }) + }) + }) + }) + + describe("updateReimbursementPool", () => { + context("when the caller is not the owner", () => { + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .updateReimbursementPool(reimbursementPool.address) + ).to.be.revertedWith("'Caller is not the owner") + }) + }) + + context("when the caller is the owner", () => { + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(governance) + .updateReimbursementPool(reimbursementPool.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should set the reimbursementPool address properly", async () => { + expect(await l1BitcoinDepositor.reimbursementPool()).to.equal( + reimbursementPool.address + ) + }) + + it("should emit ReimbursementPoolUpdated event", async () => { + await expect( + l1BitcoinDepositor + .connect(governance) + .updateReimbursementPool(reimbursementPool.address) + ) + .to.emit(l1BitcoinDepositor, "ReimbursementPoolUpdated") + .withArgs(reimbursementPool.address) + }) + }) + }) + + describe("updateL2FinalizeDepositGasLimit", () => { + context("when the caller is not the owner", () => { + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .updateL2FinalizeDepositGasLimit(100) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when the caller is the owner", () => { + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(governance) + .updateL2FinalizeDepositGasLimit(100) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should set the gas limit properly", async () => { + expect(await l1BitcoinDepositor.l2FinalizeDepositGasLimit()).to.equal( + 100 + ) + }) + + it("should emit L2FinalizeDepositGasLimitUpdated event", async () => { + await expect( + l1BitcoinDepositor + .connect(governance) + .updateL2FinalizeDepositGasLimit(100) + ) + .to.emit(l1BitcoinDepositor, "L2FinalizeDepositGasLimitUpdated") + .withArgs(100) + }) + }) + }) + + describe("updateGasOffsetParameters", () => { + context("when the caller is not the owner", () => { + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .updateGasOffsetParameters(1000, 2000) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when the caller is the owner", () => { + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(governance) + .updateGasOffsetParameters(1000, 2000) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should set the gas offset params properly", async () => { + expect( + await l1BitcoinDepositor.initializeDepositGasOffset() + ).to.be.equal(1000) + + expect(await l1BitcoinDepositor.finalizeDepositGasOffset()).to.be.equal( + 2000 + ) + }) + + it("should emit GasOffsetParametersUpdated event", async () => { + await expect( + l1BitcoinDepositor + .connect(governance) + .updateGasOffsetParameters(1000, 2000) + ) + .to.emit(l1BitcoinDepositor, "GasOffsetParametersUpdated") + .withArgs(1000, 2000) + }) + }) + }) + + describe("initializeDeposit", () => { + context("when the L2 deposit owner is zero", () => { + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + ethers.constants.AddressZero + ) + ).to.be.revertedWith("L2 deposit owner must not be 0x0") + }) + }) + + context("when the L2 deposit owner is non-zero", () => { + context("when the requested vault is not TBTCVault", () => { + it("should revert", async () => { + const corruptedReveal = JSON.parse( + JSON.stringify(initializeDepositFixture.reveal) + ) + + // Set another vault address deliberately. This value must be + // different from the tbtcVaultAddress constant used in the fixture. + corruptedReveal.vault = ethers.constants.AddressZero + + await expect( + l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + corruptedReveal, + initializeDepositFixture.l2DepositOwner + ) + ).to.be.revertedWith("Vault address mismatch") + }) + }) + + context("when the requested vault is TBTCVault", () => { + context("when the deposit state is wrong", () => { + context("when the deposit state is Initialized", () => { + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + }) + + after(async () => { + bridge.revealDepositWithExtraData.reset() + + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + ).to.be.revertedWith("Wrong deposit state") + }) + }) + + context("when the deposit state is Finalized", () => { + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + + // Set the Bridge mock to return a deposit state that allows + // to finalize the deposit. Set only relevant fields. + const revealedAt = (await lastBlockTime()) - 7200 + const finalizedAt = await lastBlockTime() + bridge.deposits + .whenCalledWith(initializeDepositFixture.depositKey) + .returns({ + depositor: ethers.constants.AddressZero, + amount: BigNumber.from(100000), + revealedAt, + vault: ethers.constants.AddressZero, + treasuryFee: BigNumber.from(0), + sweptAt: finalizedAt, + extraData: ethers.constants.HashZero, + }) + + // Set the TBTCVault mock to return a deposit state + // that allows to finalize the deposit. + tbtcVault.optimisticMintingRequests + .whenCalledWith(initializeDepositFixture.depositKey) + .returns([revealedAt, finalizedAt]) + + // Set Wormhole mocks to allow deposit finalization. + const messageFee = 1000 + const deliveryCost = 5000 + wormhole.messageFee.returns(messageFee) + wormholeRelayer.quoteEVMDeliveryPrice.returns({ + nativePriceQuote: BigNumber.from(deliveryCost), + targetChainRefundPerGasUnused: BigNumber.from(0), + }) + wormholeTokenBridge.transferTokensWithPayload.returns(0) + wormholeRelayer.sendVaasToEvm.returns(0) + + await l1BitcoinDepositor + .connect(relayer) + .finalizeDeposit(initializeDepositFixture.depositKey, { + value: messageFee + deliveryCost, + }) + }) + + after(async () => { + bridge.revealDepositWithExtraData.reset() + bridge.deposits.reset() + tbtcVault.optimisticMintingRequests.reset() + wormhole.messageFee.reset() + wormholeRelayer.quoteEVMDeliveryPrice.reset() + wormholeTokenBridge.transferTokensWithPayload.reset() + wormholeRelayer.sendVaasToEvm.reset() + + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + ).to.be.revertedWith("Wrong deposit state") + }) + }) + }) + + context("when the deposit state is Unknown", () => { + context("when the reimbursement pool is not set", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + + bridge.revealDepositWithExtraData + .whenCalledWith( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + toWormholeAddress(initializeDepositFixture.l2DepositOwner) + ) + .returns() + + tx = await l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + }) + + after(async () => { + bridge.revealDepositWithExtraData.reset() + + await restoreSnapshot() + }) + + it("should reveal the deposit to the Bridge", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(bridge.revealDepositWithExtraData).to.have.been.calledOnce + + const { fundingTx, reveal, l2DepositOwner } = + initializeDepositFixture + + // The `calledOnceWith` assertion is not used here because + // it doesn't use deep equality comparison and returns false + // despite comparing equal objects. We use a workaround + // to compare the arguments manually. + const call = bridge.revealDepositWithExtraData.getCall(0) + expect(call.args[0]).to.eql([ + fundingTx.version, + fundingTx.inputVector, + fundingTx.outputVector, + fundingTx.locktime, + ]) + expect(call.args[1]).to.eql([ + reveal.fundingOutputIndex, + reveal.blindingFactor, + reveal.walletPubKeyHash, + reveal.refundPubKeyHash, + reveal.refundLocktime, + reveal.vault, + ]) + expect(call.args[2]).to.eql( + toWormholeAddress(l2DepositOwner.toLowerCase()) + ) + }) + + it("should set the deposit state to Initialized", async () => { + expect( + await l1BitcoinDepositor.deposits( + initializeDepositFixture.depositKey + ) + ).to.equal(1) + }) + + it("should emit DepositInitialized event", async () => { + await expect(tx) + .to.emit(l1BitcoinDepositor, "DepositInitialized") + .withArgs( + initializeDepositFixture.depositKey, + initializeDepositFixture.l2DepositOwner, + relayer.address + ) + }) + + it("should not store the deferred gas reimbursement", async () => { + expect( + await l1BitcoinDepositor.gasReimbursements( + initializeDepositFixture.depositKey + ) + ).to.eql([ethers.constants.AddressZero, BigNumber.from(0)]) + }) + }) + + context("when the reimbursement pool is set", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + + bridge.revealDepositWithExtraData + .whenCalledWith( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + toWormholeAddress(initializeDepositFixture.l2DepositOwner) + ) + .returns() + + await l1BitcoinDepositor + .connect(governance) + .updateReimbursementPool(reimbursementPool.address) + + tx = await l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + }) + + after(async () => { + bridge.revealDepositWithExtraData.reset() + + await restoreSnapshot() + }) + + it("should reveal the deposit to the Bridge", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(bridge.revealDepositWithExtraData).to.have.been.calledOnce + + const { fundingTx, reveal, l2DepositOwner } = + initializeDepositFixture + + // The `calledOnceWith` assertion is not used here because + // it doesn't use deep equality comparison and returns false + // despite comparing equal objects. We use a workaround + // to compare the arguments manually. + const call = bridge.revealDepositWithExtraData.getCall(0) + expect(call.args[0]).to.eql([ + fundingTx.version, + fundingTx.inputVector, + fundingTx.outputVector, + fundingTx.locktime, + ]) + expect(call.args[1]).to.eql([ + reveal.fundingOutputIndex, + reveal.blindingFactor, + reveal.walletPubKeyHash, + reveal.refundPubKeyHash, + reveal.refundLocktime, + reveal.vault, + ]) + expect(call.args[2]).to.eql( + toWormholeAddress(l2DepositOwner.toLowerCase()) + ) + }) + + it("should set the deposit state to Initialized", async () => { + expect( + await l1BitcoinDepositor.deposits( + initializeDepositFixture.depositKey + ) + ).to.equal(1) + }) + + it("should emit DepositInitialized event", async () => { + await expect(tx) + .to.emit(l1BitcoinDepositor, "DepositInitialized") + .withArgs( + initializeDepositFixture.depositKey, + initializeDepositFixture.l2DepositOwner, + relayer.address + ) + }) + + it("should store the deferred gas reimbursement", async () => { + const gasReimbursement = + await l1BitcoinDepositor.gasReimbursements( + initializeDepositFixture.depositKey + ) + + expect(gasReimbursement.receiver).to.equal(relayer.address) + // It doesn't make much sense to check the exact gas spent value + // here because a Bridge mock is used in for testing and + // the resulting value won't be realistic. We only check that + // the gas spent is greater than zero which means the deferred + // reimbursement has been recorded properly. + expect(gasReimbursement.gasSpent.toNumber()).to.be.greaterThan(0) + }) + }) + }) + }) + }) + }) + + describe("finalizeDeposit", () => { + before(async () => { + await createSnapshot() + + // The L2BitcoinDepositor contract must be attached to the L1BitcoinDepositor + // contract before the finalizeDeposit function is called. + await l1BitcoinDepositor + .connect(governance) + .attachL2BitcoinDepositor(l2BitcoinDepositor) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when the deposit state is wrong", () => { + context("when the deposit state is Unknown", () => { + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .finalizeDeposit(initializeDepositFixture.depositKey) + ).to.be.revertedWith("Wrong deposit state") + }) + }) + + context("when the deposit state is Finalized", () => { + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + + // Set the Bridge mock to return a deposit state that allows + // to finalize the deposit. Set only relevant fields. + const revealedAt = (await lastBlockTime()) - 7200 + const finalizedAt = await lastBlockTime() + bridge.deposits + .whenCalledWith(initializeDepositFixture.depositKey) + .returns({ + depositor: ethers.constants.AddressZero, + amount: BigNumber.from(100000), + revealedAt, + vault: ethers.constants.AddressZero, + treasuryFee: BigNumber.from(0), + sweptAt: finalizedAt, + extraData: ethers.constants.HashZero, + }) + + // Set the TBTCVault mock to return a deposit state + // that allows to finalize the deposit. + tbtcVault.optimisticMintingRequests + .whenCalledWith(initializeDepositFixture.depositKey) + .returns([revealedAt, finalizedAt]) + + // Set Wormhole mocks to allow deposit finalization. + const messageFee = 1000 + const deliveryCost = 5000 + wormhole.messageFee.returns(messageFee) + wormholeRelayer.quoteEVMDeliveryPrice.returns({ + nativePriceQuote: BigNumber.from(deliveryCost), + targetChainRefundPerGasUnused: BigNumber.from(0), + }) + wormholeTokenBridge.transferTokensWithPayload.returns(0) + wormholeRelayer.sendVaasToEvm.returns(0) + + await l1BitcoinDepositor + .connect(relayer) + .finalizeDeposit(initializeDepositFixture.depositKey, { + value: messageFee + deliveryCost, + }) + }) + + after(async () => { + bridge.revealDepositWithExtraData.reset() + bridge.deposits.reset() + tbtcVault.optimisticMintingRequests.reset() + wormhole.messageFee.reset() + wormholeRelayer.quoteEVMDeliveryPrice.reset() + wormholeTokenBridge.transferTokensWithPayload.reset() + wormholeRelayer.sendVaasToEvm.reset() + + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .finalizeDeposit(initializeDepositFixture.depositKey) + ).to.be.revertedWith("Wrong deposit state") + }) + }) + }) + + context("when the deposit state is Initialized", () => { + context("when the deposit is not finalized by the Bridge", () => { + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + + // Set the Bridge mock to return a deposit state that does not allow + // to finalize the deposit. Set only relevant fields. + const revealedAt = (await lastBlockTime()) - 7200 + bridge.deposits + .whenCalledWith(initializeDepositFixture.depositKey) + .returns({ + depositor: ethers.constants.AddressZero, + amount: BigNumber.from(100000), + revealedAt, + vault: ethers.constants.AddressZero, + treasuryFee: BigNumber.from(0), + sweptAt: 0, + extraData: ethers.constants.HashZero, + }) + + // Set the TBTCVault mock to return a deposit state + // that does not allow to finalize the deposit. + tbtcVault.optimisticMintingRequests + .whenCalledWith(initializeDepositFixture.depositKey) + .returns([revealedAt, 0]) + }) + + after(async () => { + bridge.revealDepositWithExtraData.reset() + bridge.deposits.reset() + tbtcVault.optimisticMintingRequests.reset() + + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .finalizeDeposit(initializeDepositFixture.depositKey) + ).to.be.revertedWith("Deposit not finalized by the bridge") + }) + }) + + context("when the deposit is finalized by the Bridge", () => { + context("when normalized amount is too low to bridge", () => { + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + + // Set the Bridge mock to return a deposit state that pass the + // finalization check but fails the normalized amount check. + // Set only relevant fields. + const revealedAt = (await lastBlockTime()) - 7200 + const finalizedAt = await lastBlockTime() + bridge.deposits + .whenCalledWith(initializeDepositFixture.depositKey) + .returns({ + depositor: ethers.constants.AddressZero, + amount: BigNumber.from(0), + revealedAt, + vault: ethers.constants.AddressZero, + treasuryFee: BigNumber.from(0), + sweptAt: finalizedAt, + extraData: ethers.constants.HashZero, + }) + + // Set the TBTCVault mock to return a deposit state that pass the + // finalization check and move to the normalized amount check. + tbtcVault.optimisticMintingRequests + .whenCalledWith(initializeDepositFixture.depositKey) + .returns([revealedAt, finalizedAt]) + }) + + after(async () => { + bridge.revealDepositWithExtraData.reset() + bridge.deposits.reset() + tbtcVault.optimisticMintingRequests.reset() + + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .finalizeDeposit(initializeDepositFixture.depositKey) + ).to.be.revertedWith("Amount too low to bridge") + }) + }) + + context("when normalized amount is not too low to bridge", () => { + context("when payment for Wormhole Relayer is too low", () => { + const messageFee = 1000 + const deliveryCost = 5000 + + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + + // Set the Bridge mock to return a deposit state that allows + // to finalize the deposit. Set only relevant fields. + const revealedAt = (await lastBlockTime()) - 7200 + const finalizedAt = await lastBlockTime() + bridge.deposits + .whenCalledWith(initializeDepositFixture.depositKey) + .returns({ + depositor: ethers.constants.AddressZero, + amount: BigNumber.from(100000), + revealedAt, + vault: ethers.constants.AddressZero, + treasuryFee: BigNumber.from(0), + sweptAt: finalizedAt, + extraData: ethers.constants.HashZero, + }) + + // Set the TBTCVault mock to return a deposit state + // that allows to finalize the deposit. + tbtcVault.optimisticMintingRequests + .whenCalledWith(initializeDepositFixture.depositKey) + .returns([revealedAt, finalizedAt]) + + // Set Wormhole mocks to allow deposit finalization. + wormhole.messageFee.returns(messageFee) + wormholeRelayer.quoteEVMDeliveryPrice.returns({ + nativePriceQuote: BigNumber.from(deliveryCost), + targetChainRefundPerGasUnused: BigNumber.from(0), + }) + wormholeTokenBridge.transferTokensWithPayload.returns(0) + wormholeRelayer.sendVaasToEvm.returns(0) + }) + + after(async () => { + bridge.revealDepositWithExtraData.reset() + bridge.deposits.reset() + tbtcVault.optimisticMintingRequests.reset() + wormhole.messageFee.reset() + wormholeRelayer.quoteEVMDeliveryPrice.reset() + wormholeTokenBridge.transferTokensWithPayload.reset() + wormholeRelayer.sendVaasToEvm.reset() + + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + l1BitcoinDepositor + .connect(relayer) + .finalizeDeposit(initializeDepositFixture.depositKey, { + // Use a value by 1 WEI less than required. + value: messageFee + deliveryCost - 1, + }) + ).to.be.revertedWith("Payment for Wormhole Relayer is too low") + }) + }) + + context("when payment for Wormhole Relayer is not too low", () => { + const satoshiMultiplier = to1ePrecision(1, 10) + const messageFee = 1000 + const deliveryCost = 5000 + const transferSequence = 10 // Just an arbitrary value. + const depositAmount = BigNumber.from(100000) + const treasuryFee = BigNumber.from(500) + const optimisticMintingFeeDivisor = 20 // 5% + const depositTxMaxFee = BigNumber.from(1000) + + // amountSubTreasury = (depositAmount - treasuryFee) * satoshiMultiplier = 99500 * 1e10 + // omFee = amountSubTreasury / optimisticMintingFeeDivisor = 4975 * 1e10 + // txMaxFee = depositTxMaxFee * satoshiMultiplier = 1000 * 1e10 + // tbtcAmount = amountSubTreasury - omFee - txMaxFee = 93525 * 1e10 + const expectedTbtcAmount = to1ePrecision(93525, 10) + + let tx: ContractTransaction + + context("when the reimbursement pool is not set", () => { + before(async () => { + await createSnapshot() + + await l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + + // Set Bridge fees. Set only relevant fields. + bridge.depositParameters.returns({ + depositDustThreshold: 0, + depositTreasuryFeeDivisor: 0, + depositTxMaxFee, + depositRevealAheadPeriod: 0, + }) + tbtcVault.optimisticMintingFeeDivisor.returns( + optimisticMintingFeeDivisor + ) + + // Set the Bridge mock to return a deposit state that allows + // to finalize the deposit. + const revealedAt = (await lastBlockTime()) - 7200 + const finalizedAt = await lastBlockTime() + bridge.deposits + .whenCalledWith(initializeDepositFixture.depositKey) + .returns({ + depositor: l1BitcoinDepositor.address, + amount: depositAmount, + revealedAt, + vault: initializeDepositFixture.reveal.vault, + treasuryFee, + sweptAt: finalizedAt, + extraData: toWormholeAddress( + initializeDepositFixture.l2DepositOwner + ), + }) + + // Set the TBTCVault mock to return a deposit state + // that allows to finalize the deposit. + tbtcVault.optimisticMintingRequests + .whenCalledWith(initializeDepositFixture.depositKey) + .returns([revealedAt, finalizedAt]) + + // Set Wormhole mocks to allow deposit finalization. + wormhole.messageFee.returns(messageFee) + wormholeRelayer.quoteEVMDeliveryPrice.returns({ + nativePriceQuote: BigNumber.from(deliveryCost), + targetChainRefundPerGasUnused: BigNumber.from(0), + }) + wormholeTokenBridge.transferTokensWithPayload.returns( + transferSequence + ) + // Return arbitrary sent value. + wormholeRelayer.sendVaasToEvm.returns(100) + + tx = await l1BitcoinDepositor + .connect(relayer) + .finalizeDeposit(initializeDepositFixture.depositKey, { + value: messageFee + deliveryCost, + }) + }) + + after(async () => { + bridge.depositParameters.reset() + tbtcVault.optimisticMintingFeeDivisor.reset() + bridge.revealDepositWithExtraData.reset() + bridge.deposits.reset() + tbtcVault.optimisticMintingRequests.reset() + wormhole.messageFee.reset() + wormholeRelayer.quoteEVMDeliveryPrice.reset() + wormholeTokenBridge.transferTokensWithPayload.reset() + wormholeRelayer.sendVaasToEvm.reset() + + await restoreSnapshot() + }) + + it("should set the deposit state to Finalized", async () => { + expect( + await l1BitcoinDepositor.deposits( + initializeDepositFixture.depositKey + ) + ).to.equal(2) + }) + + it("should emit DepositFinalized event", async () => { + await expect(tx) + .to.emit(l1BitcoinDepositor, "DepositFinalized") + .withArgs( + initializeDepositFixture.depositKey, + initializeDepositFixture.l2DepositOwner, + relayer.address, + depositAmount.mul(satoshiMultiplier), + expectedTbtcAmount + ) + }) + + it("should increase TBTC allowance for Wormhole Token Bridge", async () => { + expect( + await tbtcToken.allowance( + l1BitcoinDepositor.address, + wormholeTokenBridge.address + ) + ).to.equal(expectedTbtcAmount) + }) + + it("should create a proper Wormhole token transfer", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(wormholeTokenBridge.transferTokensWithPayload).to.have + .been.calledOnce + + // The `calledOnceWith` assertion is not used here because + // it doesn't use deep equality comparison and returns false + // despite comparing equal objects. We use a workaround + // to compare the arguments manually. + const call = + wormholeTokenBridge.transferTokensWithPayload.getCall(0) + expect(call.value).to.equal(messageFee) + expect(call.args[0]).to.equal(tbtcToken.address) + expect(call.args[1]).to.equal(expectedTbtcAmount) + expect(call.args[2]).to.equal( + await l1BitcoinDepositor.l2ChainId() + ) + expect(call.args[3]).to.equal( + toWormholeAddress(l2WormholeGateway.address.toLowerCase()) + ) + expect(call.args[4]).to.equal(0) + expect(call.args[5]).to.equal( + ethers.utils.defaultAbiCoder.encode( + ["address"], + [initializeDepositFixture.l2DepositOwner] + ) + ) + }) + + it("should send transfer VAA to L2", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(wormholeRelayer.sendVaasToEvm).to.have.been.calledOnce + + // The `calledOnceWith` assertion is not used here because + // it doesn't use deep equality comparison and returns false + // despite comparing equal objects. We use a workaround + // to compare the arguments manually. + const call = wormholeRelayer.sendVaasToEvm.getCall(0) + expect(call.value).to.equal(deliveryCost) + expect(call.args[0]).to.equal( + await l1BitcoinDepositor.l2ChainId() + ) + expect(call.args[1]).to.equal(l2BitcoinDepositor) + expect(call.args[2]).to.equal("0x") + expect(call.args[3]).to.equal(0) + expect(call.args[4]).to.equal( + await l1BitcoinDepositor.l2FinalizeDepositGasLimit() + ) + expect(call.args[5]).to.eql([ + [ + l1ChainId, + toWormholeAddress( + wormholeTokenBridge.address.toLowerCase() + ), + BigNumber.from(transferSequence), + ], + ]) + expect(call.args[6]).to.equal( + await l1BitcoinDepositor.l2ChainId() + ) + expect(call.args[7]).to.equal(relayer.address) + }) + + it("should not call the reimbursement pool", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(reimbursementPool.refund).to.not.have.been.called + }) + }) + + context("when the reimbursement pool is set", () => { + // Use 1Gwei to make sure it's smaller than default gas price + // used by Hardhat (200 Gwei) and this value will be used + // for msgValueOffset calculation. + const reimbursementPoolMaxGasPrice = BigNumber.from(1000000000) + const reimbursementPoolStaticGas = 10000 // Just an arbitrary value. + + let initializeDepositGasSpent: BigNumber + + before(async () => { + await createSnapshot() + + reimbursementPool.maxGasPrice.returns( + reimbursementPoolMaxGasPrice + ) + reimbursementPool.staticGas.returns(reimbursementPoolStaticGas) + + await l1BitcoinDepositor + .connect(governance) + .updateReimbursementPool(reimbursementPool.address) + + await l1BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + + // Capture the gas spent for the initializeDeposit call + // for post-finalization comparison. + initializeDepositGasSpent = ( + await l1BitcoinDepositor.gasReimbursements( + initializeDepositFixture.depositKey + ) + ).gasSpent + + // Set Bridge fees. Set only relevant fields. + bridge.depositParameters.returns({ + depositDustThreshold: 0, + depositTreasuryFeeDivisor: 0, + depositTxMaxFee, + depositRevealAheadPeriod: 0, + }) + tbtcVault.optimisticMintingFeeDivisor.returns( + optimisticMintingFeeDivisor + ) + + // Set the Bridge mock to return a deposit state that allows + // to finalize the deposit. + const revealedAt = (await lastBlockTime()) - 7200 + const finalizedAt = await lastBlockTime() + bridge.deposits + .whenCalledWith(initializeDepositFixture.depositKey) + .returns({ + depositor: l1BitcoinDepositor.address, + amount: depositAmount, + revealedAt, + vault: initializeDepositFixture.reveal.vault, + treasuryFee, + sweptAt: finalizedAt, + extraData: toWormholeAddress( + initializeDepositFixture.l2DepositOwner + ), + }) + + // Set the TBTCVault mock to return a deposit state + // that allows to finalize the deposit. + tbtcVault.optimisticMintingRequests + .whenCalledWith(initializeDepositFixture.depositKey) + .returns([revealedAt, finalizedAt]) + + // Set Wormhole mocks to allow deposit finalization. + wormhole.messageFee.returns(messageFee) + wormholeRelayer.quoteEVMDeliveryPrice.returns({ + nativePriceQuote: BigNumber.from(deliveryCost), + targetChainRefundPerGasUnused: BigNumber.from(0), + }) + wormholeTokenBridge.transferTokensWithPayload.returns( + transferSequence + ) + // Return arbitrary sent value. + wormholeRelayer.sendVaasToEvm.returns(100) + + tx = await l1BitcoinDepositor + .connect(relayer) + .finalizeDeposit(initializeDepositFixture.depositKey, { + value: messageFee + deliveryCost, + }) + }) + + after(async () => { + reimbursementPool.maxGasPrice.reset() + reimbursementPool.staticGas.reset() + bridge.depositParameters.reset() + tbtcVault.optimisticMintingFeeDivisor.reset() + bridge.revealDepositWithExtraData.reset() + bridge.deposits.reset() + tbtcVault.optimisticMintingRequests.reset() + wormhole.messageFee.reset() + wormholeRelayer.quoteEVMDeliveryPrice.reset() + wormholeTokenBridge.transferTokensWithPayload.reset() + wormholeRelayer.sendVaasToEvm.reset() + + await restoreSnapshot() + }) + + it("should set the deposit state to Finalized", async () => { + expect( + await l1BitcoinDepositor.deposits( + initializeDepositFixture.depositKey + ) + ).to.equal(2) + }) + + it("should emit DepositFinalized event", async () => { + await expect(tx) + .to.emit(l1BitcoinDepositor, "DepositFinalized") + .withArgs( + initializeDepositFixture.depositKey, + initializeDepositFixture.l2DepositOwner, + relayer.address, + depositAmount.mul(satoshiMultiplier), + expectedTbtcAmount + ) + }) + + it("should increase TBTC allowance for Wormhole Token Bridge", async () => { + expect( + await tbtcToken.allowance( + l1BitcoinDepositor.address, + wormholeTokenBridge.address + ) + ).to.equal(expectedTbtcAmount) + }) + + it("should create a proper Wormhole token transfer", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(wormholeTokenBridge.transferTokensWithPayload).to.have + .been.calledOnce + + // The `calledOnceWith` assertion is not used here because + // it doesn't use deep equality comparison and returns false + // despite comparing equal objects. We use a workaround + // to compare the arguments manually. + const call = + wormholeTokenBridge.transferTokensWithPayload.getCall(0) + expect(call.value).to.equal(messageFee) + expect(call.args[0]).to.equal(tbtcToken.address) + expect(call.args[1]).to.equal(expectedTbtcAmount) + expect(call.args[2]).to.equal( + await l1BitcoinDepositor.l2ChainId() + ) + expect(call.args[3]).to.equal( + toWormholeAddress(l2WormholeGateway.address.toLowerCase()) + ) + expect(call.args[4]).to.equal(0) + expect(call.args[5]).to.equal( + ethers.utils.defaultAbiCoder.encode( + ["address"], + [initializeDepositFixture.l2DepositOwner] + ) + ) + }) + + it("should send transfer VAA to L2", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(wormholeRelayer.sendVaasToEvm).to.have.been.calledOnce + + // The `calledOnceWith` assertion is not used here because + // it doesn't use deep equality comparison and returns false + // despite comparing equal objects. We use a workaround + // to compare the arguments manually. + const call = wormholeRelayer.sendVaasToEvm.getCall(0) + expect(call.value).to.equal(deliveryCost) + expect(call.args[0]).to.equal( + await l1BitcoinDepositor.l2ChainId() + ) + expect(call.args[1]).to.equal(l2BitcoinDepositor) + expect(call.args[2]).to.equal("0x") + expect(call.args[3]).to.equal(0) + expect(call.args[4]).to.equal( + await l1BitcoinDepositor.l2FinalizeDepositGasLimit() + ) + expect(call.args[5]).to.eql([ + [ + l1ChainId, + toWormholeAddress( + wormholeTokenBridge.address.toLowerCase() + ), + BigNumber.from(transferSequence), + ], + ]) + expect(call.args[6]).to.equal( + await l1BitcoinDepositor.l2ChainId() + ) + expect(call.args[7]).to.equal(relayer.address) + }) + + it("should pay out proper reimbursements", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(reimbursementPool.refund).to.have.been.calledTwice + + // First call is the deferred gas reimbursement for deposit + // initialization. + const call1 = reimbursementPool.refund.getCall(0) + // Should reimburse the exact value stored upon deposit initialization. + expect(call1.args[0]).to.equal(initializeDepositGasSpent) + expect(call1.args[1]).to.equal(relayer.address) + + // Second call is the refund for deposit finalization. + const call2 = reimbursementPool.refund.getCall(1) + // It doesn't make much sense to check the exact gas spent + // value here because Wormhole contracts mocks are used for + // testing and the resulting value won't be realistic. + // We only check that the reimbursement is greater than the + // message value attached to the finalizeDeposit call which + // is a good indicator that the reimbursement has been + // calculated properly. + const msgValueOffset = BigNumber.from(messageFee + deliveryCost) + .div(reimbursementPoolMaxGasPrice) + .sub(reimbursementPoolStaticGas) + expect( + BigNumber.from(call2.args[0]).toNumber() + ).to.be.greaterThan(msgValueOffset.toNumber()) + expect(call2.args[1]).to.equal(relayer.address) + }) + }) + }) + }) + }) + }) + }) + + describe("quoteFinalizeDeposit", () => { + before(async () => { + await createSnapshot() + + wormhole.messageFee.returns(1000) + + wormholeRelayer.quoteEVMDeliveryPrice + .whenCalledWith( + await l1BitcoinDepositor.l2ChainId(), + 0, + await l1BitcoinDepositor.l2FinalizeDepositGasLimit() + ) + .returns({ + nativePriceQuote: BigNumber.from(5000), + targetChainRefundPerGasUnused: BigNumber.from(0), + }) + }) + + after(async () => { + wormhole.messageFee.reset() + wormholeRelayer.quoteEVMDeliveryPrice.reset() + + await restoreSnapshot() + }) + + it("should return the correct cost", async () => { + const cost = await l1BitcoinDepositor.quoteFinalizeDeposit() + expect(cost).to.be.equal(6000) // delivery cost + message fee + }) + }) +}) + +// Just an arbitrary TBTCVault address. +const tbtcVaultAddress = "0xB5679dE944A79732A75CE556191DF11F489448d5" + +export type InitializeDepositFixture = { + // Deposit key built as keccak256(fundingTxHash, reveal.fundingOutputIndex) + depositKey: string + fundingTx: BitcoinTxInfoStruct + reveal: DepositRevealInfoStruct + l2DepositOwner: string +} + +// Fixture used for initializeDeposit test scenario. +export const initializeDepositFixture: InitializeDepositFixture = { + depositKey: + "0x97a4104f4114ba56dde79d02c4e8296596c3259da60d0e53fa97170f7cf7258d", + fundingTx: { + version: "0x01000000", + inputVector: + "0x01dfe39760a5edabdab013114053d789ada21e356b59fea41d980396" + + "c1a4474fad0100000023220020e57edf10136b0434e46bc08c5ac5a1e4" + + "5f64f778a96f984d0051873c7a8240f2ffffffff", + outputVector: + "0x02804f1200000000002200202f601522e7bb1f7de5c56bdbd45590b3" + + "499bad09190581dcaa17e152d8f0c2a9b7e837000000000017a9148688" + + "4e6be1525dab5ae0b451bd2c72cee67dcf4187", + locktime: "0x00000000", + }, + reveal: { + fundingOutputIndex: 0, + blindingFactor: "0xba863847d2d0fee3", + walletPubKeyHash: "0xf997563fee8610ca28f99ac05bd8a29506800d4d", + refundPubKeyHash: "0x7ac2d9378a1c47e589dfb8095ca95ed2140d2726", + refundLocktime: "0xde2b4c67", + vault: tbtcVaultAddress, + }, + l2DepositOwner: "0x23b82a7108F9CEb34C3CDC44268be21D151d4124", +} + +// eslint-disable-next-line import/prefer-default-export +export function toWormholeAddress(address: string): string { + return `0x000000000000000000000000${address.slice(2)}` +} diff --git a/solidity/test/l2/L2BitcoinDepositor.test.ts b/solidity/test/l2/L2BitcoinDepositor.test.ts new file mode 100644 index 000000000..4b3747151 --- /dev/null +++ b/solidity/test/l2/L2BitcoinDepositor.test.ts @@ -0,0 +1,350 @@ +import { ethers, getUnnamedAccounts, helpers, waffle } from "hardhat" +import { randomBytes } from "crypto" +import chai, { expect } from "chai" +import { FakeContract, smock } from "@defi-wonderland/smock" +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" +import { ContractTransaction } from "ethers" +import { + IL2WormholeGateway, + IWormholeRelayer, + L2BitcoinDepositor, +} from "../../typechain" +import { + initializeDepositFixture, + toWormholeAddress, +} from "./L1BitcoinDepositor.test" + +chai.use(smock.matchers) + +const { impersonateAccount } = helpers.account +const { createSnapshot, restoreSnapshot } = helpers.snapshot + +describe("L2BitcoinDepositor", () => { + const contractsFixture = async () => { + const { deployer, governance } = await helpers.signers.getNamedSigners() + + const accounts = await getUnnamedAccounts() + const relayer = await ethers.getSigner(accounts[1]) + + const wormholeRelayer = await smock.fake( + "IWormholeRelayer" + ) + const l2WormholeGateway = await smock.fake( + "IL2WormholeGateway" + ) + // Just an arbitrary chain ID. + const l1ChainId = 2 + // Just an arbitrary L1BitcoinDepositor address. + const l1BitcoinDepositor = "0xeE6F5f69860f310114185677D017576aed0dEC83" + + const deployment = await helpers.upgrades.deployProxy( + // Hacky workaround allowing to deploy proxy contract any number of times + // without clearing `deployments/hardhat` directory. + // See: https://github.com/keep-network/hardhat-helpers/issues/38 + `L2BitcoinDepositor_${randomBytes(8).toString("hex")}`, + { + contractName: "L2BitcoinDepositor", + initializerArgs: [ + wormholeRelayer.address, + l2WormholeGateway.address, + l1ChainId, + ], + factoryOpts: { signer: deployer }, + proxyOpts: { + kind: "transparent", + }, + } + ) + const l2BitcoinDepositor = deployment[0] as L2BitcoinDepositor + + await l2BitcoinDepositor + .connect(deployer) + .transferOwnership(governance.address) + + return { + governance, + relayer, + wormholeRelayer, + l2WormholeGateway, + l1BitcoinDepositor, + l2BitcoinDepositor, + } + } + + let governance: SignerWithAddress + let relayer: SignerWithAddress + + let wormholeRelayer: FakeContract + let l2WormholeGateway: FakeContract + let l1BitcoinDepositor: string + let l2BitcoinDepositor: L2BitcoinDepositor + + before(async () => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;({ + governance, + relayer, + wormholeRelayer, + l2WormholeGateway, + l1BitcoinDepositor, + l2BitcoinDepositor, + } = await waffle.loadFixture(contractsFixture)) + }) + + describe("attachL1BitcoinDepositor", () => { + context("when the caller is not the owner", () => { + it("should revert", async () => { + await expect( + l2BitcoinDepositor + .connect(relayer) + .attachL1BitcoinDepositor(l1BitcoinDepositor) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when the caller is the owner", () => { + context("when the L1BitcoinDepositor is already attached", () => { + before(async () => { + await createSnapshot() + + await l2BitcoinDepositor + .connect(governance) + .attachL1BitcoinDepositor(l1BitcoinDepositor) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert", async () => { + await expect( + l2BitcoinDepositor + .connect(governance) + .attachL1BitcoinDepositor(l1BitcoinDepositor) + ).to.be.revertedWith("L1 Bitcoin Depositor already set") + }) + }) + + context("when the L1BitcoinDepositor is not attached", () => { + context("when new L1BitcoinDepositor is zero", () => { + it("should revert", async () => { + await expect( + l2BitcoinDepositor + .connect(governance) + .attachL1BitcoinDepositor(ethers.constants.AddressZero) + ).to.be.revertedWith("L1 Bitcoin Depositor must not be 0x0") + }) + }) + + context("when new L1BitcoinDepositor is non-zero", () => { + before(async () => { + await createSnapshot() + + await l2BitcoinDepositor + .connect(governance) + .attachL1BitcoinDepositor(l1BitcoinDepositor) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should set the l1BitcoinDepositor address properly", async () => { + expect(await l2BitcoinDepositor.l1BitcoinDepositor()).to.equal( + l1BitcoinDepositor + ) + }) + }) + }) + }) + }) + + describe("initializeDeposit", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + + tx = await l2BitcoinDepositor + .connect(relayer) + .initializeDeposit( + initializeDepositFixture.fundingTx, + initializeDepositFixture.reveal, + initializeDepositFixture.l2DepositOwner + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should emit DepositInitialized event", async () => { + const { fundingTx, reveal, l2DepositOwner } = initializeDepositFixture + + // The `expect.to.emit.withArgs` assertion has troubles with + // matching complex event arguments as it uses strict equality + // underneath. To overcome that problem, we manually get event's + // arguments and check it against the expected ones using deep + // equality assertion (eql). + const receipt = await ethers.provider.getTransactionReceipt(tx.hash) + expect(receipt.logs.length).to.be.equal(1) + expect( + l2BitcoinDepositor.interface.parseLog(receipt.logs[0]).args + ).to.be.eql([ + [ + fundingTx.version, + fundingTx.inputVector, + fundingTx.outputVector, + fundingTx.locktime, + ], + [ + reveal.fundingOutputIndex, + reveal.blindingFactor, + reveal.walletPubKeyHash, + reveal.refundPubKeyHash, + reveal.refundLocktime, + reveal.vault, + ], + l2DepositOwner, + relayer.address, + ]) + }) + }) + + describe("receiveWormholeMessages", () => { + before(async () => { + await createSnapshot() + + await l2BitcoinDepositor + .connect(governance) + .attachL1BitcoinDepositor(l1BitcoinDepositor) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when the caller is not the WormholeRelayer", () => { + it("should revert", async () => { + await expect( + l2BitcoinDepositor + .connect(relayer) + // Parameters don't matter as the call should revert before. + .receiveWormholeMessages( + ethers.constants.HashZero, + [], + ethers.constants.HashZero, + 0, + ethers.constants.HashZero + ) + ).to.be.revertedWith("Caller is not Wormhole Relayer") + }) + }) + + context("when the caller is the WormholeRelayer", () => { + let wormholeRelayerSigner: SignerWithAddress + + before(async () => { + await createSnapshot() + + wormholeRelayerSigner = await impersonateAccount( + wormholeRelayer.address, + { + from: governance, + value: 10, + } + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when the source chain is not the expected L1", () => { + it("should revert", async () => { + await expect( + l2BitcoinDepositor + .connect(wormholeRelayerSigner) + .receiveWormholeMessages( + ethers.constants.HashZero, + [], + ethers.constants.HashZero, + 0, + ethers.constants.HashZero + ) + ).to.be.revertedWith("Source chain is not the expected L1 chain") + }) + }) + + context("when the source chain is the expected L1", () => { + context("when the source address is not the L1BitcoinDepositor", () => { + it("should revert", async () => { + await expect( + l2BitcoinDepositor + .connect(wormholeRelayerSigner) + .receiveWormholeMessages( + ethers.constants.HashZero, + [], + toWormholeAddress(relayer.address), + await l2BitcoinDepositor.l1ChainId(), + ethers.constants.HashZero + ) + ).to.be.revertedWith( + "Source address is not the expected L1 Bitcoin depositor" + ) + }) + }) + + context("when the source address is the L1BitcoinDepositor", () => { + context("when the number of additional VAAs is not 1", () => { + it("should revert", async () => { + await expect( + l2BitcoinDepositor + .connect(wormholeRelayerSigner) + .receiveWormholeMessages( + ethers.constants.HashZero, + [], + toWormholeAddress(l1BitcoinDepositor), + await l2BitcoinDepositor.l1ChainId(), + ethers.constants.HashZero + ) + ).to.be.revertedWith( + "Expected 1 additional VAA key for token transfer" + ) + }) + }) + + context("when the number of additional VAAs is 1", () => { + before(async () => { + await createSnapshot() + + l2WormholeGateway.receiveTbtc.returns() + + await l2BitcoinDepositor + .connect(wormholeRelayerSigner) + .receiveWormholeMessages( + ethers.constants.HashZero, + ["0x1234"], + toWormholeAddress(l1BitcoinDepositor), + await l2BitcoinDepositor.l1ChainId(), + ethers.constants.HashZero + ) + }) + + after(async () => { + l2WormholeGateway.receiveTbtc.reset() + + await restoreSnapshot() + }) + + it("should pass the VAA to the L2WormholeGateway", async () => { + expect(l2WormholeGateway.receiveTbtc).to.have.been.calledOnceWith( + "0x1234" + ) + }) + }) + }) + }) + }) + }) +})