diff --git a/.env.example b/.env.example index 35c19a9..5761704 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,8 @@ ETHEREUM_SEPOLIA_RPC= ARBITRUM_SEPOLIA_RPC=https://sepolia-rollup.arbitrum.io/rpc OP_SEPOLIA_RPC=https://sepolia.optimism.io BASE_RPC=https://base-mainnet.public.blastapi.io +# Pin Base fork to last block before Aave V3 froze WETH so Aave-dependent tests stay green. +FORK_BLOCK_NUMBER=44880000 ETHEREUM_RPC=https://eth-mainnet.public.blastapi.io ARBITRUM_ONE_RPC=https://arbitrum-one.public.blastapi.io OP_MAINNET_RPC=https://public-op-mainnet.fastnode.io diff --git a/contracts/Repayer.sol b/contracts/Repayer.sol index 42ff114..2f3b943 100644 --- a/contracts/Repayer.sol +++ b/contracts/Repayer.sol @@ -18,6 +18,7 @@ import {SuperchainStandardBridgeAdapter} from "./utils/SuperchainStandardBridgeA import {ArbitrumGatewayAdapter} from "./utils/ArbitrumGatewayAdapter.sol"; import {GnosisOmnibridgeAdapter} from "./utils/GnosisOmnibridgeAdapter.sol"; import {USDT0Adapter} from "./utils/USDT0Adapter.sol"; +import {WBTCOFTAdapter} from "./utils/WBTCOFTAdapter.sol"; import {ERC7201Helper} from "./utils/ERC7201Helper.sol"; /// @title Performs repayment to Liquidity Pools on same/different chains. @@ -35,7 +36,8 @@ contract Repayer is SuperchainStandardBridgeAdapter, ArbitrumGatewayAdapter, GnosisOmnibridgeAdapter, - USDT0Adapter + USDT0Adapter, + WBTCOFTAdapter { using SafeERC20 for IERC20; using BitMaps for BitMaps.BitMap; @@ -116,7 +118,8 @@ contract Repayer is address ethereumAmb, address usdt0Oft, address cctpV2TokenMessenger, - address cctpV2MessageTransmitter + address cctpV2MessageTransmitter, + address wbtcOft ) CCTPV2Adapter( cctpTokenMessenger, @@ -138,6 +141,7 @@ contract Repayer is ethereumAmb ) USDT0Adapter(usdt0Oft) + WBTCOFTAdapter(wbtcOft) { ERC7201Helper.validateStorageLocation( STORAGE_LOCATION, @@ -282,6 +286,9 @@ contract Repayer is } else if (provider == Provider.USDT0) { initiateTransferUSDT0(token, amount, destinationPool, destinationDomain, DOMAIN, _msgSender()); + } else + if (provider == Provider.WBTC_OFT) { + initiateTransferWBTCOFT(token, amount, destinationPool, destinationDomain, DOMAIN, _msgSender()); } else { // Unreachable atm, but could become so when more providers are added to enum. revert UnsupportedProvider(); diff --git a/contracts/interfaces/IRoute.sol b/contracts/interfaces/IRoute.sol index 944dd87..153fe8e 100644 --- a/contracts/interfaces/IRoute.sol +++ b/contracts/interfaces/IRoute.sol @@ -35,7 +35,8 @@ interface IRoute { ARBITRUM_GATEWAY, GNOSIS_OMNIBRIDGE, USDT0, - CCTP_V2 + CCTP_V2, + WBTC_OFT } enum PoolType { @@ -47,4 +48,5 @@ interface IRoute { error ProcessFailed(); error UnsupportedDomain(); error InvalidLength(); + error InvalidToken(); } diff --git a/contracts/testing/TestRepayer.sol b/contracts/testing/TestRepayer.sol index a7fed91..396db1e 100644 --- a/contracts/testing/TestRepayer.sol +++ b/contracts/testing/TestRepayer.sol @@ -22,7 +22,8 @@ contract TestRepayer is Repayer { address ethereumAmb, address usdt0Oft, address cctpV2TokenMessenger, - address cctpV2MessageTransmitter + address cctpV2MessageTransmitter, + address wbtcOft ) Repayer( localDomain, assets, @@ -41,7 +42,8 @@ contract TestRepayer is Repayer { ethereumAmb, usdt0Oft, cctpV2TokenMessenger, - cctpV2MessageTransmitter + cctpV2MessageTransmitter, + wbtcOft ) {} function domainCCTP(Domain destinationDomain) public pure override returns (uint32) { diff --git a/contracts/testing/TestWBTCOFT.sol b/contracts/testing/TestWBTCOFT.sol new file mode 100644 index 0000000..551fa33 --- /dev/null +++ b/contracts/testing/TestWBTCOFT.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IOFT} from "../interfaces/IOFT.sol"; +import {SendParam, MessagingFee} from "../interfaces/ILayerZero.sol"; + +/// @notice Minimal WBTC-style token with permissioned burn for testing. +/// The OFT calls burn() directly without needing an approval from the token holder. +/// To simplify the test setup, burn function here can be called by anyone. +contract TestWBTC is ERC20 { + constructor() ERC20("TestWBTC", "tWBTC") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function burn(address from, uint256 amount) public { + _burn(from, amount); + } + + function decimals() public pure override returns (uint8) { + return 8; + } +} + +/// @notice Test mock for a WBTCOFTAdapter-style OFT (Ethereum adapter pattern). +/// Locks the underlying token via transferFrom — requires approval from the sender. +contract TestWBTCOFTAdapter is IOFT { + using SafeERC20 for IERC20; + + address private immutable TOKEN; + uint256 public constant NATIVE_FEE = 1e10; + + error EtherTransferFailed(); + + constructor(address _token) { + TOKEN = _token; + } + + function token() public view returns (address) { + return TOKEN; + } + + function quoteSend(SendParam calldata, bool) external pure returns (MessagingFee memory) { + return MessagingFee(NATIVE_FEE, 0); + } + + function send( + SendParam calldata _sendParam, + MessagingFee calldata, + address refundAddress + ) external payable virtual { + IERC20(TOKEN).safeTransferFrom(msg.sender, address(this), _sendParam.amountLD); + (bool success,) = payable(refundAddress).call{value: msg.value - NATIVE_FEE}(""); + if (!success) revert EtherTransferFailed(); + } +} + +/// @notice Test mock for a native-OFT-style WBTC OFT (non-Ethereum chains). +/// Calls token.burn() directly — no approval needed because burn is permissioned to the OFT. +contract TestWBTCOFTNative is TestWBTCOFTAdapter { + constructor(address _token) TestWBTCOFTAdapter(_token) {} + + function send( + SendParam calldata _sendParam, + MessagingFee calldata, + address refundAddress + ) external payable override { + TestWBTC(token()).burn(msg.sender, _sendParam.amountLD); + (bool success,) = payable(refundAddress).call{value: msg.value - NATIVE_FEE}(""); + if (!success) revert EtherTransferFailed(); + } +} diff --git a/contracts/utils/USDT0Adapter.sol b/contracts/utils/USDT0Adapter.sol index 81b6444..aef7a3e 100644 --- a/contracts/utils/USDT0Adapter.sol +++ b/contracts/utils/USDT0Adapter.sol @@ -14,8 +14,6 @@ abstract contract USDT0Adapter is LayerZeroHelper { /// On all other chains it is an OUpgradeable (burns/mints USDT0 directly — no approval needed). IOFT immutable public USDT0_OFT; - error InvalidToken(); - event USDT0Transfer(address token, address receiver, uint32 dstEid, uint256 amount); constructor(address usdt0Oft) { diff --git a/contracts/utils/WBTCOFTAdapter.sol b/contracts/utils/WBTCOFTAdapter.sol new file mode 100644 index 0000000..2546ac3 --- /dev/null +++ b/contracts/utils/WBTCOFTAdapter.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {SendParam, MessagingFee} from ".././interfaces/ILayerZero.sol"; +import {IOFT} from ".././interfaces/IOFT.sol"; +import {LayerZeroHelper} from "./LayerZeroHelper.sol"; + +abstract contract WBTCOFTAdapter is LayerZeroHelper { + using SafeERC20 for IERC20; + + /// @notice The WBTC OFT contract on the local chain. + /// On Ethereum this is a WBTCOFTAdapter (locks/unlocks canonical WBTC via transferFrom). + /// On all other supported chains it is an OFT that burns/mints WBTC directly — no approval needed. + IOFT immutable public WBTC_OFT; + + event WBTCOFTTransfer(address token, address receiver, uint32 dstEid, uint256 amount); + + constructor(address wbtcOft) { + // No check for address(0): allows deployment on chains where WBTC OFT is not available. + WBTC_OFT = IOFT(wbtcOft); + } + + /// @notice Initiates a cross-chain transfer of WBTC via the LayerZero OFT. + /// @dev The caller must supply sufficient native currency (msg.value) to cover the LayerZero + /// messaging fee. Any excess is refunded to `caller` by the OFT contract. + /// amountLD and minAmountLD are set equal — no slippage is accepted. + /// @param token The ERC-20 token to bridge. Must match WBTC_OFT.token(). + /// @param amount The amount to send in local decimals (8 for WBTC). + /// @param destinationPool The recipient address on the destination chain. + /// @param destinationDomain The destination domain. + /// @param localDomain The local domain; used to decide whether approval is needed. + /// @param caller The address that initiated the call; used as the LayerZero fee refund address. + function initiateTransferWBTCOFT( + IERC20 token, + uint256 amount, + address destinationPool, + Domain destinationDomain, + Domain localDomain, + address caller + ) internal { + IOFT oft = WBTC_OFT; + require(address(oft) != address(0), ZeroAddress()); + require(address(token) == oft.token(), InvalidToken()); + + // On Ethereum the OFT is a WBTCOFTAdapter that pulls tokens via transferFrom. + // On other chains the OFT burns from msg.sender directly — no approval needed. + if (localDomain == Domain.ETHEREUM) { + token.forceApprove(address(oft), amount); + } + + uint32 dstEid = layerZeroEndpointId(destinationDomain); + + SendParam memory sendParam = SendParam({ + dstEid: dstEid, + to: _addressToBytes32(destinationPool), + amountLD: amount, + minAmountLD: amount, + extraOptions: new bytes(0), + composeMsg: new bytes(0), + oftCmd: new bytes(0) + }); + + MessagingFee memory fee = MessagingFee(msg.value, 0); + // solhint-disable-next-line check-send-result + oft.send{value: msg.value}(sendParam, fee, caller); + + emit WBTCOFTTransfer(address(token), destinationPool, dstEid, amount); + } +} diff --git a/coverage-baseline.json b/coverage-baseline.json index 7979eac..a8ad9f2 100644 --- a/coverage-baseline.json +++ b/coverage-baseline.json @@ -1,6 +1,6 @@ { "lines": "99.61", "functions": "99.56", - "branches": "92.65", + "branches": "92.73", "statements": "99.61" } \ No newline at end of file diff --git a/hardhat.config.ts b/hardhat.config.ts index 5b994a2..16dce9e 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -818,7 +818,13 @@ const config: HardhatUserConfig = { url: isSet(process.env.DRY_RUN) || isSet(process.env.FORK_TEST) ? process.env[`${process.env.DRY_RUN || process.env.FORK_TEST}_RPC`]! : (process.env.FORK_PROVIDER || process.env.BASE_RPC || "https://base-mainnet.public.blastapi.io"), - blockNumber: process.env.FORK_BLOCK_NUMBER ? parseInt(process.env.FORK_BLOCK_NUMBER) : undefined, + // FORK_BLOCK_NUMBER only applies to the default Base fork, not to FORK_TEST or DRY_RUN + // because each chain has its own block-number space. + blockNumber: process.env.FORK_BLOCK_NUMBER + && !isSet(process.env.DRY_RUN) + && !isSet(process.env.FORK_TEST) + ? parseInt(process.env.FORK_BLOCK_NUMBER) + : undefined, }, accounts: isSet(process.env.DRY_RUN) ? [{privateKey: process.env.PRIVATE_KEY!, balance: "100000000000000000000"}] diff --git a/network.config.ts b/network.config.ts index a338000..46361fc 100644 --- a/network.config.ts +++ b/network.config.ts @@ -93,6 +93,7 @@ export enum Provider { GNOSIS_OMNIBRIDGE = "GNOSIS_OMNIBRIDGE", USDT0 = "USDT0", CCTP_V2 = "CCTP_V2", + WBTC_OFT = "WBTC_OFT", } export enum Token { @@ -202,6 +203,7 @@ export interface NetworkConfig { GnosisUSDCxDAI?: string; GnosisUSDCTransmuter?: string; USDT0OFT?: string; + WBTCOFT?: string; Tokens: { [Token.USDC]: TokenInfo; [Token.USDT]?: TokenInfo; @@ -260,6 +262,7 @@ export const networkConfig: NetworksConfig = { Omnibridge: "0x88ad09518695c6c3712AC10a214bE5109a655671", GnosisAMB: "0x4C36d2919e407f0Cc2Ee3c993ccF8ac26d9CE64e", USDT0OFT: "0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee", + WBTCOFT: "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", Tokens: { USDC: tokenInfo("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 6), USDT: tokenInfo("0xdAC17F958D2ee523a2206206994597C13D831ec7", 6), @@ -397,6 +400,7 @@ export const networkConfig: NetworksConfig = { Omnibridge: "0x88ad09518695c6c3712AC10a214bE5109a655671", GnosisAMB: "0x4C36d2919e407f0Cc2Ee3c993ccF8ac26d9CE64e", USDT0OFT: "0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee", + WBTCOFT: "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", Tokens: { USDC: tokenInfo("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 6), USDT: tokenInfo("0xdAC17F958D2ee523a2206206994597C13D831ec7", 6), @@ -634,6 +638,7 @@ export const networkConfig: NetworksConfig = { StargateTreasurer: "0x644abb1e17291b4403966119d15Ab081e4a487e9", EverclearFeeAdapter: "0xd0185bfb8107c5b2336bC73cE3fdd9Bfb504540e", OptimismStandardBridge: "0x4200000000000000000000000000000000000010", + WBTCOFT: "0xC3f854B2970f8727D28527ECE33176faC67FeF48", Tokens: { USDC: tokenInfo("0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", 6), USDT: tokenInfo("0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", 6), @@ -779,6 +784,7 @@ export const networkConfig: NetworksConfig = { AcrossV3SpokePool: "0x6f26Bf09B1C792e3228e5467807a900A503c0281", StargateTreasurer: "0x644abb1e17291b4403966119d15Ab081e4a487e9", EverclearFeeAdapter: "0xd0185bfb8107c5b2336bC73cE3fdd9Bfb504540e", + WBTCOFT: "0xC3f854B2970f8727D28527ECE33176faC67FeF48", Tokens: { USDC: tokenInfo("0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", 6), USDT: tokenInfo("0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", 6), @@ -1180,6 +1186,7 @@ export const networkConfig: NetworksConfig = { AcrossV3SpokePool: "0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64", StargateTreasurer: "0xd47b03ee6d86Cf251ee7860FB2ACf9f91B9fD4d7", EverclearFeeAdapter: "0xd0185bfb8107c5b2336bC73cE3fdd9Bfb504540e", + WBTCOFT: "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", Tokens: { USDC: tokenInfo("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", 6), USDT: tokenInfo("0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2", 6), @@ -1303,6 +1310,7 @@ export const networkConfig: NetworksConfig = { AcrossV3SpokePool: "0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64", StargateTreasurer: "0xd47b03ee6d86Cf251ee7860FB2ACf9f91B9fD4d7", EverclearFeeAdapter: "0xd0185bfb8107c5b2336bC73cE3fdd9Bfb504540e", + WBTCOFT: "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", Tokens: { USDC: tokenInfo("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", 6), USDT: tokenInfo("0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2", 6), @@ -1659,6 +1667,7 @@ export const networkConfig: NetworksConfig = { StargateTreasurer: "0x6D205337F45D6850c3c3006e28d5b52c8a432c35", EverclearFeeAdapter: "0x877Fd0A881B63eBE413124EeE6abbCD7E82cf10b", USDT0OFT: "0xc07bE8994D035631c36fb4a89C918CeFB2f03EC3", + WBTCOFT: "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", Tokens: { USDC: tokenInfo("0x078D782b760474a361dDA0AF3839290b0EF57AD6", 6), USDT: tokenInfo("0x9151434b16b9763660705744891fA906F660EcC5", 6), @@ -1747,6 +1756,7 @@ export const networkConfig: NetworksConfig = { StargateTreasurer: "0x6D205337F45D6850c3c3006e28d5b52c8a432c35", EverclearFeeAdapter: "0x877Fd0A881B63eBE413124EeE6abbCD7E82cf10b", USDT0OFT: "0xc07bE8994D035631c36fb4a89C918CeFB2f03EC3", + WBTCOFT: "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", Tokens: { USDC: tokenInfo("0x078D782b760474a361dDA0AF3839290b0EF57AD6", 6), USDT: tokenInfo("0x9151434b16b9763660705744891fA906F660EcC5", 6), @@ -1843,6 +1853,7 @@ export const networkConfig: NetworksConfig = { AcrossV3SpokePool: "0x4e8E101924eDE233C13e2D8622DC8aED2872d505", StargateTreasurer: "0x0a6A15964fEe494A881338D65940430797F0d97C", EverclearFeeAdapter: "0xd0185bfb8107c5b2336bC73cE3fdd9Bfb504540e", + WBTCOFT: "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", Tokens: { USDC: tokenInfo("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", 18), WBTC: tokenInfo("0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c", 18), @@ -1897,6 +1908,7 @@ export const networkConfig: NetworksConfig = { AcrossV3SpokePool: "0x4e8E101924eDE233C13e2D8622DC8aED2872d505", StargateTreasurer: "0x0a6A15964fEe494A881338D65940430797F0d97C", EverclearFeeAdapter: "0xd0185bfb8107c5b2336bC73cE3fdd9Bfb504540e", + WBTCOFT: "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", Tokens: { USDC: tokenInfo("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", 18), WBTC: tokenInfo("0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c", 18), @@ -2597,6 +2609,7 @@ export interface StandaloneRepayerConfig { GnosisUSDCTransmuter?: string; GnosisAMB?: string; USDT0OFT?: string; + WBTCOFT?: string; // Repayer tokens are used from the general network config. WrappedNativeToken: string; RepayerRoutes: RepayerRoutesConfig; diff --git a/scripts/common.ts b/scripts/common.ts index fd01cdc..92b822f 100644 --- a/scripts/common.ts +++ b/scripts/common.ts @@ -52,6 +52,7 @@ export const ProviderSolidity = { GNOSIS_OMNIBRIDGE: 7n, USDT0: 8n, CCTP_V2: 9n, + WBTC_OFT: 10n, }; export const DomainSolidity = { @@ -109,6 +110,7 @@ export const SolidityProvider: { [n: number]: Provider } = { 7: Provider.GNOSIS_OMNIBRIDGE, 8: Provider.USDT0, 9: Provider.CCTP_V2, + 10: Provider.WBTC_OFT, }; export const CCTPDomain: { [n: number]: Network } = { diff --git a/scripts/deploy.ts b/scripts/deploy.ts index cf36045..3258a11 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -142,6 +142,7 @@ export async function main() { if (!config.GnosisUSDCTransmuter) config.GnosisUSDCTransmuter = ZERO_ADDRESS; if (!config.GnosisAMB) config.GnosisAMB = ZERO_ADDRESS; if (!config.USDT0OFT) config.USDT0OFT = ZERO_ADDRESS; + if (!config.WBTCOFT) config.WBTCOFT = ZERO_ADDRESS; let mainPool: LiquidityPool | undefined = undefined; let aavePoolLongTerm: LiquidityPoolAaveLongTerm; @@ -444,6 +445,7 @@ export async function main() { config.USDT0OFT, config.CCTPV2.TokenMessenger, config.CCTPV2.MessageTransmitter, + config.WBTCOFT, ], [ config.Admin, diff --git a/scripts/deployRepayer.ts b/scripts/deployRepayer.ts index 81b0d2e..df67af9 100644 --- a/scripts/deployRepayer.ts +++ b/scripts/deployRepayer.ts @@ -91,6 +91,7 @@ export async function main() { if (!config.GnosisUSDCTransmuter) config.GnosisUSDCTransmuter = ZERO_ADDRESS; if (!config.GnosisAMB) config.GnosisAMB = ZERO_ADDRESS; if (!config.USDT0OFT) config.USDT0OFT = ZERO_ADDRESS; + if (!config.WBTCOFT) config.WBTCOFT = ZERO_ADDRESS; const inputOutputTokens = getInputOutputTokens(network, config); const repayerVersion = config.IsTest ? "TestRepayer" : "Repayer"; @@ -119,6 +120,7 @@ export async function main() { config.USDT0OFT, config.CCTPV2.TokenMessenger, config.CCTPV2.MessageTransmitter, + config.WBTCOFT, ], [ config.Admin, diff --git a/scripts/deployStandaloneRepayer.ts b/scripts/deployStandaloneRepayer.ts index a400b23..17dd7f9 100644 --- a/scripts/deployStandaloneRepayer.ts +++ b/scripts/deployStandaloneRepayer.ts @@ -97,6 +97,7 @@ export async function main() { if (!config.GnosisUSDCTransmuter) config.GnosisUSDCTransmuter = ZERO_ADDRESS; if (!config.GnosisAMB) config.GnosisAMB = ZERO_ADDRESS; if (!config.USDT0OFT) config.USDT0OFT = ZERO_ADDRESS; + if (!config.WBTCOFT) config.WBTCOFT = ZERO_ADDRESS; const inputOutputTokens = getInputOutputTokens(network, networkConfig[network]); const repayerVersion = config.IsTest ? "TestRepayer" : "Repayer"; @@ -125,6 +126,7 @@ export async function main() { config.USDT0OFT, config.CCTPV2.TokenMessenger, config.CCTPV2.MessageTransmitter, + config.WBTCOFT, ], [ deployer, diff --git a/scripts/upgradeRepayer.ts b/scripts/upgradeRepayer.ts index f20238f..5185ff4 100644 --- a/scripts/upgradeRepayer.ts +++ b/scripts/upgradeRepayer.ts @@ -65,6 +65,7 @@ export async function main() { if (!config.GnosisUSDCTransmuter) config.GnosisUSDCTransmuter = ZERO_ADDRESS; if (!config.GnosisAMB) config.GnosisAMB = ZERO_ADDRESS; if (!config.USDT0OFT) config.USDT0OFT = ZERO_ADDRESS; + if (!config.WBTCOFT) config.WBTCOFT = ZERO_ADDRESS; const repayerAddress = await getDeployProxyXAddress("Repayer"); const repayerVersion = config.IsTest ? "TestRepayer" : "Repayer"; @@ -93,6 +94,7 @@ export async function main() { config.USDT0OFT, config.CCTPV2.TokenMessenger, config.CCTPV2.MessageTransmitter, + config.WBTCOFT, ], "Repayer", ); diff --git a/specific-fork-test/arbitrum/RepayerUSDT0.ts b/specific-fork-test/arbitrum/RepayerUSDT0.ts index 071cc8a..64df4ae 100644 --- a/specific-fork-test/arbitrum/RepayerUSDT0.ts +++ b/specific-fork-test/arbitrum/RepayerUSDT0.ts @@ -61,6 +61,7 @@ describe.skip("Repayer USDT0 (Arbitrum fork), https://github.com/NomicFoundation ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, forkNetworkConfig.USDT0OFT, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; diff --git a/specific-fork-test/ethereum/Repayer.ts b/specific-fork-test/ethereum/Repayer.ts index aaeb367..f4129d4 100644 --- a/specific-fork-test/ethereum/Repayer.ts +++ b/specific-fork-test/ethereum/Repayer.ts @@ -116,6 +116,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, forkNetworkConfig.Omnibridge!, ZERO_ADDRESS, ZERO_ADDRESS, forkNetworkConfig.GnosisAMB!, ZERO_ADDRESS, forkNetworkConfig.CCTPV2!.TokenMessenger!, forkNetworkConfig.CCTPV2!.MessageTransmitter!, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( diff --git a/specific-fork-test/ethereum/RepayerGnosisOmnibridge.ts b/specific-fork-test/ethereum/RepayerGnosisOmnibridge.ts index 15fb5c5..456349a 100644 --- a/specific-fork-test/ethereum/RepayerGnosisOmnibridge.ts +++ b/specific-fork-test/ethereum/RepayerGnosisOmnibridge.ts @@ -64,6 +64,7 @@ describe("Repayer Gnosis Omnibridge (Ethereum fork)", function () { ZERO_ADDRESS, forkNetworkConfig.GnosisAMB, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; diff --git a/specific-fork-test/ethereum/RepayerUSDT0.ts b/specific-fork-test/ethereum/RepayerUSDT0.ts index 5ed5d8c..7798048 100644 --- a/specific-fork-test/ethereum/RepayerUSDT0.ts +++ b/specific-fork-test/ethereum/RepayerUSDT0.ts @@ -67,6 +67,7 @@ describe("Repayer USDT0 (Ethereum fork)", function () { ZERO_ADDRESS, forkNetworkConfig.GnosisAMB, forkNetworkConfig.USDT0OFT, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; diff --git a/specific-fork-test/ethereum/RepayerWBTCOFT.ts b/specific-fork-test/ethereum/RepayerWBTCOFT.ts new file mode 100644 index 0000000..34e384d --- /dev/null +++ b/specific-fork-test/ethereum/RepayerWBTCOFT.ts @@ -0,0 +1,141 @@ +import { + loadFixture, setBalance, setCode +} from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import {expect} from "chai"; +import hre from "hardhat"; +import { + getCreateAddress, getContractAt, deploy, deployX, +} from "../../test/helpers"; +import { + ProviderSolidity as Provider, DomainSolidity as Domain, + DEFAULT_ADMIN_ROLE, assertAddress, ZERO_ADDRESS, +} from "../../scripts/common"; +import { + TransparentUpgradeableProxy, ProxyAdmin, + TestLiquidityPool, Repayer, +} from "../../typechain-types"; +import {networkConfig} from "../../network.config"; + +describe("Repayer WBTC OFT (Ethereum fork)", function () { + const deployAll = async () => { + const [deployer, admin, repayUser, setTokensUser] = await hre.ethers.getSigners(); + await setCode(repayUser.address, "0x00"); + + const forkNetworkConfig = networkConfig.ETHEREUM; + + const REPAYER_ROLE = hre.ethers.encodeBytes32String("REPAYER_ROLE"); + const DEPOSIT_PROFIT_ROLE = hre.ethers.encodeBytes32String("DEPOSIT_PROFIT_ROLE"); + + assertAddress(forkNetworkConfig.Tokens.WBTC?.Address, "WBTC address is missing from ETHEREUM config"); + assertAddress(forkNetworkConfig.WBTCOFT, "WBTCOFT address is missing from ETHEREUM config"); + assertAddress(forkNetworkConfig.Omnibridge, "ETHEREUM Omnibridge address is missing"); + assertAddress(forkNetworkConfig.GnosisAMB, "ETHEREUM GnosisAMB address is missing"); + + const usdc = await hre.ethers.getContractAt("ERC20", forkNetworkConfig.Tokens.USDC.Address); + const wbtc = await hre.ethers.getContractAt("ERC20", forkNetworkConfig.Tokens.WBTC.Address); + const weth = await hre.ethers.getContractAt("IWrappedNativeToken", forkNetworkConfig.WrappedNativeToken); + + const wbtcOft = await hre.ethers.getContractAt("IOFT", forkNetworkConfig.WBTCOFT!); + expect(await wbtcOft.token()).to.equal(forkNetworkConfig.Tokens.WBTC.Address); + + const liquidityPool = (await deploy( + "TestLiquidityPool", + deployer, + {}, + usdc, + deployer, + forkNetworkConfig.WrappedNativeToken + )) as TestLiquidityPool; + + const WBTC_DEC = 10n ** (await wbtc.decimals()); + + const repayerImpl = ( + await deployX("Repayer", deployer, "RepayerEthereumWBTCOFT", {}, + Domain.ETHEREUM, + usdc, + forkNetworkConfig.CCTP!.TokenMessenger!, + forkNetworkConfig.CCTP!.MessageTransmitter!, + forkNetworkConfig.AcrossV3SpokePool!, + forkNetworkConfig.EverclearFeeAdapter!, + weth, + forkNetworkConfig.StargateTreasurer!, + forkNetworkConfig.OptimismStandardBridge!, + forkNetworkConfig.BaseStandardBridge!, + forkNetworkConfig.ArbitrumGatewayRouter!, + forkNetworkConfig.Omnibridge, + ZERO_ADDRESS, + ZERO_ADDRESS, + forkNetworkConfig.GnosisAMB, + forkNetworkConfig.USDT0OFT!, ZERO_ADDRESS, ZERO_ADDRESS, + forkNetworkConfig.WBTCOFT!, + ) + ) as Repayer; + + const repayerInit = (await repayerImpl.initialize.populateTransaction( + admin, + repayUser, + setTokensUser, + [liquidityPool], + [Domain.BASE], + [Provider.WBTC_OFT], + [true], + [], + )).data; + + const repayerProxy = (await deployX( + "TransparentUpgradeableProxy", deployer, "TransparentUpgradeableProxyEthereumWBTCOFT", {}, + repayerImpl, admin, repayerInit + )) as TransparentUpgradeableProxy; + const repayer = (await getContractAt("Repayer", repayerProxy, deployer)) as Repayer; + const repayerProxyAdminAddress = await getCreateAddress(repayerProxy, 1); + const repayerAdmin = (await getContractAt("ProxyAdmin", repayerProxyAdminAddress, admin)) as ProxyAdmin; + + await liquidityPool.grantRole(DEPOSIT_PROFIT_ROLE, repayer); + + return { + deployer, admin, repayUser, usdc, wbtc, setTokensUser, weth, + WBTC_DEC, liquidityPool, repayer, repayerProxy, repayerAdmin, + REPAYER_ROLE, DEFAULT_ADMIN_ROLE, forkNetworkConfig, + }; + }; + + it("Should allow repayer to bridge WBTC from Ethereum to Base via WBTC OFT on fork", async function () { + const {repayer, WBTC_DEC, wbtc, repayUser, liquidityPool, forkNetworkConfig} = await loadFixture(deployAll); + + assertAddress( + process.env.WBTC_OWNER_ETH_ADDRESS, + "Env variables not configured (WBTC_OWNER_ETH_ADDRESS missing)" + ); + const wbtcOwner = await hre.ethers.getImpersonatedSigner(process.env.WBTC_OWNER_ETH_ADDRESS!); + await setBalance(process.env.WBTC_OWNER_ETH_ADDRESS!, 10n ** 18n); + + const amount = 4n * WBTC_DEC; + await wbtc.connect(wbtcOwner).transfer(repayer, 10n * WBTC_DEC); + + const wbtcOftAddress = forkNetworkConfig.WBTCOFT!; + const wbtcBalanceBefore = await wbtc.balanceOf(repayer); + + const tx = repayer.connect(repayUser).initiateRepay( + wbtc, + amount, + liquidityPool, + Domain.BASE, + Provider.WBTC_OFT, + "0x", + {value: hre.ethers.parseEther("0.1")} + ); + await expect(tx) + .to.emit(repayer, "InitiateRepay") + .withArgs(wbtc.target, amount, liquidityPool.target, Domain.BASE, Provider.WBTC_OFT); + // OFTAdapter on Ethereum locks WBTC via transferFrom: WBTC moves from repayer to the OFT. + await expect(tx) + .to.emit(wbtc, "Transfer") + .withArgs(repayer.target, wbtcOftAddress, amount); + + await expect(tx) + .to.emit(repayer, "WBTCOFTTransfer") + .withArgs(wbtc.target, liquidityPool.target, "30184", amount); + + expect(await wbtc.balanceOf(repayer)).to.equal(wbtcBalanceBefore - amount); + }); +}); diff --git a/specific-fork-test/gnosis/RepayerGnosisOmnibridge.ts b/specific-fork-test/gnosis/RepayerGnosisOmnibridge.ts index 4523e3c..0e24829 100644 --- a/specific-fork-test/gnosis/RepayerGnosisOmnibridge.ts +++ b/specific-fork-test/gnosis/RepayerGnosisOmnibridge.ts @@ -62,6 +62,7 @@ describe("Repayer Gnosis Omnibridge (Gnosis Chain fork)", function () { ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; diff --git a/specific-fork-test/polygon/RepayerUSDT0.ts b/specific-fork-test/polygon/RepayerUSDT0.ts index 013d120..40ebc93 100644 --- a/specific-fork-test/polygon/RepayerUSDT0.ts +++ b/specific-fork-test/polygon/RepayerUSDT0.ts @@ -61,6 +61,7 @@ describe.skip("Repayer USDT0 (Polygon fork), https://github.com/NomicFoundation/ ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, forkNetworkConfig.USDT0OFT, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; diff --git a/specific-fork-test/unichain/RepayerUSDT0.ts b/specific-fork-test/unichain/RepayerUSDT0.ts index 8b51d85..e675fa4 100644 --- a/specific-fork-test/unichain/RepayerUSDT0.ts +++ b/specific-fork-test/unichain/RepayerUSDT0.ts @@ -61,6 +61,7 @@ describe("Repayer USDT0 (Unichain fork)", function () { ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, forkNetworkConfig.USDT0OFT, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; diff --git a/test/Repayer.ts b/test/Repayer.ts index 0c5b959..483c834 100644 --- a/test/Repayer.ts +++ b/test/Repayer.ts @@ -21,6 +21,7 @@ import { TestSuperchainStandardBridge, IWrappedNativeToken, TestArbitrumGatewayRouter, TestGnosisOmnibridge, TestGnosisAMB, TestUSDCTransmuter, TestUSDT0, TestUSDT0OFTAdapter, TestUSDT0OFTNative, + TestWBTC, TestWBTCOFTAdapter, TestWBTCOFTNative, TestEverclearFeeAdapter, } from "../typechain-types"; import {networkConfig} from "../network.config"; @@ -128,6 +129,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, cctpV2TokenMessenger, cctpV2MessageTransmitter, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -828,6 +830,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; @@ -1188,6 +1191,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const outputToken = addressToBytes32(usdc.target); @@ -1252,6 +1256,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const outputToken = addressToBytes32(eurc.target as string); @@ -1387,6 +1392,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -1458,6 +1464,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -1526,6 +1533,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -1593,6 +1601,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -1661,6 +1670,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -1753,6 +1763,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -1812,6 +1823,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -1892,6 +1904,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -1960,6 +1973,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -2041,6 +2055,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -2115,6 +2130,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; @@ -2190,6 +2206,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -2257,6 +2274,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -2359,6 +2377,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -2426,6 +2445,7 @@ describe("Repayer", function () { ZERO_ADDRESS, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -2704,6 +2724,7 @@ describe("Repayer", function () { optimismBridge, baseBridge, arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -2737,6 +2758,7 @@ describe("Repayer", function () { optimismBridge, baseBridge, arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, cctpV2TokenMessenger, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -2950,6 +2972,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; @@ -3091,6 +3114,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; @@ -3262,6 +3286,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; @@ -3443,6 +3468,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ethereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, ethereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -3505,6 +3531,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, gnosisOmnibridge, usdc.target, dummySwap.target, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -3566,6 +3593,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, gnosisOmnibridge, usdc.target, usdceSwap.target, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -3635,6 +3663,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, gnosisOmnibridge, usdc.target, usdceSwap.target, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -3683,6 +3712,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ethereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, ethereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -3726,6 +3756,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ethereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, )).to.be.revertedWithCustomError(factory, "ZeroAddress"); }); @@ -3753,18 +3784,21 @@ describe("Repayer", function () { await expect(factory.deploy( Domain.GNOSIS_CHAIN, ...baseArgs, ZERO_ADDRESS, usdc, usdceSwap, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, )).to.be.revertedWithCustomError(factory, "ZeroAddress"); // USDCxDAI is 0 await expect(factory.deploy( Domain.GNOSIS_CHAIN, ...baseArgs, gnosisOmnibridge, ZERO_ADDRESS, usdceSwap, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, )).to.be.revertedWithCustomError(factory, "ZeroAddress"); // USDCe swap is 0 await expect(factory.deploy( Domain.GNOSIS_CHAIN, ...baseArgs, gnosisOmnibridge, usdc, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, )).to.be.revertedWithCustomError(factory, "ZeroAddress"); }); @@ -3788,24 +3822,28 @@ describe("Repayer", function () { await expect(factory.deploy( Domain.BASE, ...baseArgs, someAddress, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, )).to.be.revertedWithCustomError(factory, "ZeroAddress"); // Non-zero gnosisUsdcxdai await expect(factory.deploy( Domain.BASE, ...baseArgs, ZERO_ADDRESS, someAddress, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, )).to.be.revertedWithCustomError(factory, "ZeroAddress"); // Non-zero gnosisUsdceSwap await expect(factory.deploy( Domain.BASE, ...baseArgs, ZERO_ADDRESS, ZERO_ADDRESS, someAddress, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, )).to.be.revertedWithCustomError(factory, "ZeroAddress"); // Non-zero ethereumAmb await expect(factory.deploy( Domain.BASE, ...baseArgs, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, someAddress, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, )).to.be.revertedWithCustomError(factory, "ZeroAddress"); }); @@ -3834,6 +3872,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ethereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, ethereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -3884,6 +3923,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ethereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, ethereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -3950,6 +3990,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ethereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, ethereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -4004,12 +4045,14 @@ describe("Repayer", function () { await expect(factory.deploy( Domain.ETHEREUM, ...baseArgs, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ethereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, )).to.be.revertedWithCustomError(factory, "ZeroAddress"); // AMB is 0 await expect(factory.deploy( Domain.ETHEREUM, ...baseArgs, ethereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, )).to.be.revertedWithCustomError(factory, "ZeroAddress"); }); @@ -4038,6 +4081,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ethereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, ethereumAmb, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -4098,6 +4142,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, testOFT, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -4169,6 +4214,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, testOFT, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -4236,6 +4282,7 @@ describe("Repayer", function () { arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, testOFT, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -4285,6 +4332,7 @@ describe("Repayer", function () { baseBridge, arbitrumGatewayRouter, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction( @@ -4310,6 +4358,256 @@ describe("Repayer", function () { )).to.be.revertedWithCustomError(repayer, "ZeroAddress"); }); + it("Should perform WBTC OFT repay with a mock OFT adapter (approval required)", async function () { + // Adapter pattern (Ethereum): OFT calls transferFrom → forceApprove is triggered. + const { + usdc, admin, repayUser, liquidityPool, deployer, + cctpTokenMessenger, cctpMessageTransmitter, acrossV3SpokePool, + everclearFeeAdapter, weth, stargateTreasurerTrue, + optimismBridge, baseBridge, arbitrumGatewayRouter, setTokensUser, + sharedEthereumOmnibridge, sharedEthereumAmb, + } = await loadFixture(deployAll); + + const testWbtc = (await deploy("TestWBTC", deployer, {})) as TestWBTC; + const testOFT = ( + await deploy("TestWBTCOFTAdapter", deployer, {}, testWbtc) + ) as TestWBTCOFTAdapter; + expect(await testOFT.token()).to.eq(testWbtc.target); + + const WBTC_DEC = 10n ** (await testWbtc.decimals()); + + // Ethereum domain required so localDomain == Domain.ETHEREUM → forceApprove path. + const repayerImpl = ( + await deployX("Repayer", deployer, "RepayerWBTCOFTAdapter", {}, + Domain.ETHEREUM, + usdc, + cctpTokenMessenger, + cctpMessageTransmitter, + acrossV3SpokePool, + everclearFeeAdapter, + weth, + stargateTreasurerTrue, + optimismBridge, + baseBridge, + arbitrumGatewayRouter, + sharedEthereumOmnibridge, ZERO_ADDRESS, ZERO_ADDRESS, sharedEthereumAmb, + ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + testOFT, + ) + ) as Repayer; + const repayerInit = (await repayerImpl.initialize.populateTransaction( + admin, repayUser, setTokensUser, + [liquidityPool], [Domain.ARBITRUM_ONE], [Provider.WBTC_OFT], [true], [], + )).data; + const repayerProxy = (await deployX( + "TransparentUpgradeableProxy", deployer, "TransparentUpgradeableProxyRepayerWBTCOFTAdapter", {}, + repayerImpl, admin, repayerInit + )) as TransparentUpgradeableProxy; + const repayer = (await getContractAt("Repayer", repayerProxy, deployer)) as Repayer; + + const amount = 4n * WBTC_DEC; + await testWbtc.mint(repayer.target, 10n * WBTC_DEC); + + const tx = repayer.connect(repayUser).initiateRepay( + testWbtc, + amount, + liquidityPool, + Domain.ARBITRUM_ONE, + Provider.WBTC_OFT, + "0x", + {value: 1n * ETH} + ); + await expect(tx) + .to.emit(repayer, "InitiateRepay") + .withArgs(testWbtc.target, amount, liquidityPool.target, Domain.ARBITRUM_ONE, Provider.WBTC_OFT); + // Adapter locks via transferFrom: tokens move from repayer to OFT contract. + await expect(tx) + .to.emit(testWbtc, "Transfer") + .withArgs(repayer.target, testOFT.target, amount); + + await expect(tx) + .to.emit(repayer, "WBTCOFTTransfer") + .withArgs(testWbtc.target, liquidityPool.target, "30110", amount); + await expect(tx).to.changeEtherBalance(repayUser, -(await testOFT.NATIVE_FEE())); + }); + + it("Should perform WBTC OFT repay with a native OFT (no approval needed, token is burned)", async function () { + // Native OFT pattern (non-Ethereum): OFT calls token.burn() → no approval needed. + const { + usdc, admin, repayUser, liquidityPool, deployer, + cctpTokenMessenger, cctpMessageTransmitter, acrossV3SpokePool, + everclearFeeAdapter, weth, stargateTreasurerTrue, + optimismBridge, baseBridge, arbitrumGatewayRouter, setTokensUser, + } = await loadFixture(deployAll); + + const testWbtc = (await deploy("TestWBTC", deployer, {})) as TestWBTC; + const testOFT = ( + await deploy("TestWBTCOFTNative", deployer, {}, testWbtc) + ) as TestWBTCOFTNative; + expect(await testOFT.token()).to.eq(testWbtc.target); + + const WBTC_DEC = 10n ** (await testWbtc.decimals()); + + // BASE domain → localDomain != ETHEREUM → no forceApprove, OFT calls burn(). + const repayerImpl = ( + await deployX("Repayer", deployer, "RepayerWBTCOFTNative", {}, + Domain.BASE, + usdc, + cctpTokenMessenger, + cctpMessageTransmitter, + acrossV3SpokePool, + everclearFeeAdapter, + weth, + stargateTreasurerTrue, + optimismBridge, + baseBridge, + arbitrumGatewayRouter, + ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + testOFT, + ) + ) as Repayer; + const repayerInit = (await repayerImpl.initialize.populateTransaction( + admin, repayUser, setTokensUser, + [liquidityPool], [Domain.ETHEREUM], [Provider.WBTC_OFT], [true], [], + )).data; + const repayerProxy = (await deployX( + "TransparentUpgradeableProxy", deployer, "TransparentUpgradeableProxyRepayerWBTCOFTNative", {}, + repayerImpl, admin, repayerInit + )) as TransparentUpgradeableProxy; + const repayer = (await getContractAt("Repayer", repayerProxy, deployer)) as Repayer; + + const amount = 4n * WBTC_DEC; + await testWbtc.mint(repayer.target, 10n * WBTC_DEC); + + const tx = repayer.connect(repayUser).initiateRepay( + testWbtc, + amount, + liquidityPool, + Domain.ETHEREUM, + Provider.WBTC_OFT, + "0x", + {value: 1n * ETH} + ); + await expect(tx) + .to.emit(repayer, "InitiateRepay") + .withArgs(testWbtc.target, amount, liquidityPool.target, Domain.ETHEREUM, Provider.WBTC_OFT); + // Native OFT burns via token.burn(): Transfer to zero address, no Transfer to OFT. + await expect(tx) + .to.emit(testWbtc, "Transfer") + .withArgs(repayer.target, hre.ethers.ZeroAddress, amount); + + await expect(tx) + .to.emit(repayer, "WBTCOFTTransfer") + .withArgs(testWbtc.target, liquidityPool.target, "30101", amount); + await expect(tx).to.changeEtherBalance(repayUser, -(await testOFT.NATIVE_FEE())); + }); + + it("Should revert WBTC OFT repay if token doesn't match OFT.token()", async function () { + const { + usdc, admin, repayUser, liquidityPool, deployer, + cctpTokenMessenger, cctpMessageTransmitter, acrossV3SpokePool, + everclearFeeAdapter, weth, stargateTreasurerTrue, + optimismBridge, baseBridge, arbitrumGatewayRouter, setTokensUser, + eurc, eurcOwner, EURC_DEC, + } = await loadFixture(deployAll); + + const testWbtc = (await deploy("TestWBTC", deployer, {})) as TestWBTC; + const testOFT = ( + await deploy("TestWBTCOFTAdapter", deployer, {}, testWbtc) + ) as TestWBTCOFTAdapter; + + const repayerImpl = ( + await deployX("Repayer", deployer, "RepayerWBTCOFTTokenMismatch", {}, + Domain.BASE, + usdc, + cctpTokenMessenger, + cctpMessageTransmitter, + acrossV3SpokePool, + everclearFeeAdapter, + weth, + stargateTreasurerTrue, + optimismBridge, + baseBridge, + arbitrumGatewayRouter, + ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + testOFT, + ) + ) as Repayer; + const repayerInit = (await repayerImpl.initialize.populateTransaction( + admin, repayUser, setTokensUser, + [liquidityPool], [Domain.ETHEREUM], [Provider.WBTC_OFT], [true], [], + )).data; + const repayerProxy = (await deployX( + "TransparentUpgradeableProxy", deployer, "TransparentUpgradeableProxyRepayerWBTCOFTTokenMismatch", {}, + repayerImpl, admin, repayerInit + )) as TransparentUpgradeableProxy; + const repayer = (await getContractAt("Repayer", repayerProxy, deployer)) as Repayer; + + await eurc.connect(eurcOwner).transfer(repayer, 10n * EURC_DEC); + + await expect(repayer.connect(repayUser).initiateRepay( + eurc, + 4n * EURC_DEC, + liquidityPool, + Domain.ETHEREUM, + Provider.WBTC_OFT, + "0x", + {value: 1n * ETH} + )).to.be.revertedWithCustomError(repayer, "InvalidToken"); + }); + + it("Should revert WBTC OFT repay if OFT address is zero", async function () { + const { + USDC_DEC, usdc, admin, repayUser, liquidityPool, deployer, + cctpTokenMessenger, cctpMessageTransmitter, acrossV3SpokePool, + everclearFeeAdapter, weth, stargateTreasurerTrue, + optimismBridge, baseBridge, arbitrumGatewayRouter, setTokensUser, + } = await loadFixture(deployAll); + + // Default fixture passes ZERO_ADDRESS for wbtcOft already, but we redeploy + // explicitly to make the intent clear and isolate this case. + const repayerImpl = ( + await deployX("Repayer", deployer, "RepayerWBTCOFTZero", {}, + Domain.BASE, + usdc, + cctpTokenMessenger, + cctpMessageTransmitter, + acrossV3SpokePool, + everclearFeeAdapter, + weth, + stargateTreasurerTrue, + optimismBridge, + baseBridge, + arbitrumGatewayRouter, + ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, + ) + ) as Repayer; + const repayerInit = (await repayerImpl.initialize.populateTransaction( + admin, repayUser, setTokensUser, + [liquidityPool], [Domain.ETHEREUM], [Provider.WBTC_OFT], [true], [], + )).data; + const repayerProxy = (await deployX( + "TransparentUpgradeableProxy", deployer, "TransparentUpgradeableProxyRepayerWBTCOFTZero", {}, + repayerImpl, admin, repayerInit + )) as TransparentUpgradeableProxy; + const repayer = (await getContractAt("Repayer", repayerProxy, deployer)) as Repayer; + + await usdc.transfer(repayer, 10n * USDC_DEC); + + await expect(repayer.connect(repayUser).initiateRepay( + usdc, + 4n * USDC_DEC, + liquidityPool, + Domain.ETHEREUM, + Provider.WBTC_OFT, + "0x", + {value: 1n * ETH} + )).to.be.revertedWithCustomError(repayer, "ZeroAddress"); + }); + describe("Repayer on BSC domain", function () { // BSC tokens have 18 decimals; destination chains (e.g. Ethereum) use 6 (USDC/USDT) or 8 (WBTC). // AdapterHelper._destAmountToLocal scales destination output amounts back to BSC 18-decimal units: @@ -4381,6 +4679,7 @@ describe("Repayer", function () { ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS, + ZERO_ADDRESS, ) ) as Repayer; const repayerInit = (await repayerImpl.initialize.populateTransaction(