Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions contracts/Repayer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -35,7 +36,8 @@ contract Repayer is
SuperchainStandardBridgeAdapter,
ArbitrumGatewayAdapter,
GnosisOmnibridgeAdapter,
USDT0Adapter
USDT0Adapter,
WBTCOFTAdapter
{
using SafeERC20 for IERC20;
using BitMaps for BitMaps.BitMap;
Expand Down Expand Up @@ -116,7 +118,8 @@ contract Repayer is
address ethereumAmb,
address usdt0Oft,
address cctpV2TokenMessenger,
address cctpV2MessageTransmitter
address cctpV2MessageTransmitter,
address wbtcOft
)
CCTPV2Adapter(
cctpTokenMessenger,
Expand All @@ -138,6 +141,7 @@ contract Repayer is
ethereumAmb
)
USDT0Adapter(usdt0Oft)
WBTCOFTAdapter(wbtcOft)
{
ERC7201Helper.validateStorageLocation(
STORAGE_LOCATION,
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion contracts/interfaces/IRoute.sol
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ interface IRoute {
ARBITRUM_GATEWAY,
GNOSIS_OMNIBRIDGE,
USDT0,
CCTP_V2
CCTP_V2,
WBTC_OFT
}

enum PoolType {
Expand All @@ -47,4 +48,5 @@ interface IRoute {
error ProcessFailed();
error UnsupportedDomain();
error InvalidLength();
error InvalidToken();
}
6 changes: 4 additions & 2 deletions contracts/testing/TestRepayer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ contract TestRepayer is Repayer {
address ethereumAmb,
address usdt0Oft,
address cctpV2TokenMessenger,
address cctpV2MessageTransmitter
address cctpV2MessageTransmitter,
address wbtcOft
) Repayer(
localDomain,
assets,
Expand All @@ -41,7 +42,8 @@ contract TestRepayer is Repayer {
ethereumAmb,
usdt0Oft,
cctpV2TokenMessenger,
cctpV2MessageTransmitter
cctpV2MessageTransmitter,
wbtcOft
) {}

function domainCCTP(Domain destinationDomain) public pure override returns (uint32) {
Expand Down
75 changes: 75 additions & 0 deletions contracts/testing/TestWBTCOFT.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
2 changes: 0 additions & 2 deletions contracts/utils/USDT0Adapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
70 changes: 70 additions & 0 deletions contracts/utils/WBTCOFTAdapter.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 1 addition & 1 deletion coverage-baseline.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"lines": "99.61",
"functions": "99.56",
"branches": "92.65",
"branches": "92.73",
"statements": "99.61"
}
8 changes: 7 additions & 1 deletion hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]
Expand Down
13 changes: 13 additions & 0 deletions network.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export enum Provider {
GNOSIS_OMNIBRIDGE = "GNOSIS_OMNIBRIDGE",
USDT0 = "USDT0",
CCTP_V2 = "CCTP_V2",
WBTC_OFT = "WBTC_OFT",
}

export enum Token {
Expand Down Expand Up @@ -202,6 +203,7 @@ export interface NetworkConfig {
GnosisUSDCxDAI?: string;
GnosisUSDCTransmuter?: string;
USDT0OFT?: string;
WBTCOFT?: string;
Tokens: {
[Token.USDC]: TokenInfo;
[Token.USDT]?: TokenInfo;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions scripts/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const ProviderSolidity = {
GNOSIS_OMNIBRIDGE: 7n,
USDT0: 8n,
CCTP_V2: 9n,
WBTC_OFT: 10n,
};

export const DomainSolidity = {
Expand Down Expand Up @@ -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 } = {
Expand Down
Loading
Loading