From bda6695d90218304af48f02f5ae6313bc1396c36 Mon Sep 17 00:00:00 2001 From: Onno Visser <23527729+onnovisser@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:50:44 +0100 Subject: [PATCH 1/4] token bridge --- script/FullDeployer.s.sol | 19 +- src/admin/ProtocolGuardian.sol | 29 +- src/admin/interfaces/IProtocolGuardian.sol | 9 + src/bridge/TokenBridge.sol | 110 ++++++++ src/bridge/interfaces/ITokenBridge.sol | 91 +++++++ test/bridge/integration/TokenBridge.t.sol | 145 ++++++++++ test/bridge/unit/TokenBridge.t.sol | 300 +++++++++++++++++++++ test/core/spoke/integration/BaseTest.sol | 1 + test/core/spoke/unit/Spoke.t.sol | 2 +- test/core/unit/ProtocolGuardian.t.sol | 53 +++- test/integration/Deployer.t.sol | 16 ++ 11 files changed, 769 insertions(+), 6 deletions(-) create mode 100644 src/bridge/TokenBridge.sol create mode 100644 src/bridge/interfaces/ITokenBridge.sol create mode 100644 test/bridge/integration/TokenBridge.t.sol create mode 100644 test/bridge/unit/TokenBridge.t.sol diff --git a/script/FullDeployer.s.sol b/script/FullDeployer.s.sol index 51e3f2dac..87c6d054e 100644 --- a/script/FullDeployer.s.sol +++ b/script/FullDeployer.s.sol @@ -39,6 +39,7 @@ import {SyncDepositVaultFactory} from "../src/vaults/factories/SyncDepositVaultF import "forge-std/Script.sol"; +import {TokenBridge} from "../src/bridge/TokenBridge.sol"; import {SubsidyManager} from "../src/utils/SubsidyManager.sol"; import {AxelarAdapter} from "../src/adapters/AxelarAdapter.sol"; import {WormholeAdapter} from "../src/adapters/WormholeAdapter.sol"; @@ -101,6 +102,7 @@ struct FullReport { TokenRecoverer tokenRecoverer; ProtocolGuardian protocolGuardian; OpsGuardian opsGuardian; + TokenBridge tokenBridge; SubsidyManager subsidyManager; RefundEscrowFactory refundEscrowFactory; AsyncVaultFactory asyncVaultFactory; @@ -141,6 +143,7 @@ contract FullActionBatcher is CoreActionBatcher { ) public onlyDeployer { // Rely Root report.tokenRecoverer.rely(address(report.root)); + report.tokenBridge.rely(address(report.root)); report.subsidyManager.rely(address(report.root)); report.refundEscrowFactory.rely(address(report.root)); @@ -183,6 +186,7 @@ contract FullActionBatcher is CoreActionBatcher { report.core.messageDispatcher.rely(address(report.protocolGuardian)); report.root.rely(address(report.protocolGuardian)); report.tokenRecoverer.rely(address(report.protocolGuardian)); + report.tokenBridge.rely(address(report.protocolGuardian)); // Permanent ward for ongoing adapter maintenance if (address(report.layerZeroAdapter) != address(0)) { report.layerZeroAdapter.rely(address(report.protocolGuardian)); @@ -261,10 +265,10 @@ contract FullActionBatcher is CoreActionBatcher { report.batchRequestManager.file("hub", address(report.core.hub)); // Endorse methods - report.root.endorse(address(report.core.balanceSheet)); report.root.endorse(address(report.asyncRequestManager)); report.root.endorse(address(report.vaultRouter)); + report.root.endorse(address(report.tokenBridge)); // Connect adapters for (uint256 i; i < connectionList.length; i++) { @@ -326,6 +330,7 @@ contract FullActionBatcher is CoreActionBatcher { function revokeFull(FullReport memory report) public onlyDeployer { if (report.root.wards(address(this)) == 1) report.root.deny(address(this)); report.tokenRecoverer.deny(address(this)); + report.tokenBridge.deny(address(this)); report.refundEscrowFactory.deny(address(this)); report.asyncVaultFactory.deny(address(this)); @@ -375,6 +380,7 @@ contract FullDeployer is CoreDeployer { TokenRecoverer public tokenRecoverer; ProtocolGuardian public protocolGuardian; OpsGuardian public opsGuardian; + TokenBridge public tokenBridge; SubsidyManager public subsidyManager; RefundEscrowFactory public refundEscrowFactory; @@ -426,12 +432,19 @@ contract FullDeployer is CoreDeployer { ) ); + tokenBridge = TokenBridge( + create3( + generateSalt("tokenBridge"), + abi.encodePacked(type(TokenBridge).creationCode, abi.encode(spoke, batcher)) + ) + ); + protocolGuardian = ProtocolGuardian( create3( generateSalt("protocolGuardian"), abi.encodePacked( type(ProtocolGuardian).creationCode, - abi.encode(ISafe(address(batcher)), root, gateway, messageDispatcher) + abi.encode(ISafe(address(batcher)), root, gateway, messageDispatcher, tokenBridge) ) ) ); @@ -701,6 +714,7 @@ contract FullDeployer is CoreDeployer { register("tokenRecoverer", address(tokenRecoverer)); register("protocolGuardian", address(protocolGuardian)); register("opsGuardian", address(opsGuardian)); + register("tokenBridge", address(tokenBridge)); register("refundEscrowFactory", address(refundEscrowFactory)); register("subsidyManager", address(subsidyManager)); @@ -751,6 +765,7 @@ contract FullDeployer is CoreDeployer { tokenRecoverer, protocolGuardian, opsGuardian, + tokenBridge, subsidyManager, refundEscrowFactory, asyncVaultFactory, diff --git a/src/admin/ProtocolGuardian.sol b/src/admin/ProtocolGuardian.sol index b0916d683..cff42d4f9 100644 --- a/src/admin/ProtocolGuardian.sol +++ b/src/admin/ProtocolGuardian.sol @@ -11,6 +11,8 @@ import {PoolId} from "../core/types/PoolId.sol"; import {IGateway} from "../core/messaging/interfaces/IGateway.sol"; import {IScheduleAuthMessageSender} from "../core/messaging/interfaces/IGatewaySenders.sol"; +import {ITokenBridge} from "../bridge/interfaces/ITokenBridge.sol"; + /// @title ProtocolGuardian /// @notice This contract provides emergency controls and protocol-level management including pausing, /// permission scheduling, cross-chain upgrade coordination, and adapter configuration. @@ -23,12 +25,20 @@ contract ProtocolGuardian is IProtocolGuardian { ISafe public safe; IGateway public gateway; IScheduleAuthMessageSender public sender; - - constructor(ISafe safe_, IRoot root_, IGateway gateway_, IScheduleAuthMessageSender sender_) { + ITokenBridge public tokenBridge; + + constructor( + ISafe safe_, + IRoot root_, + IGateway gateway_, + IScheduleAuthMessageSender sender_, + ITokenBridge tokenBridge_ + ) { safe = safe_; root = root_; gateway = gateway_; sender = sender_; + tokenBridge = tokenBridge_; } modifier onlySafe() { @@ -50,6 +60,7 @@ contract ProtocolGuardian is IProtocolGuardian { if (what == "safe") safe = ISafe(data); else if (what == "gateway") gateway = IGateway(data); else if (what == "sender") sender = IScheduleAuthMessageSender(data); + else if (what == "tokenBridge") tokenBridge = ITokenBridge(data); else revert FileUnrecognizedParam(); emit File(what, data); } @@ -116,6 +127,20 @@ contract ProtocolGuardian is IProtocolGuardian { gateway.blockOutgoing(centrifugeId, GLOBAL_POOL, isBlocked); } + //---------------------------------------------------------------------------------------------- + // TokenBridge Management + //---------------------------------------------------------------------------------------------- + + /// @inheritdoc IProtocolGuardian + function fileTokenBridgeRelayer(address relayer) external onlySafe { + tokenBridge.file("relayer", relayer); + } + + /// @inheritdoc IProtocolGuardian + function fileTokenBridgeCentrifugeId(uint256 evmChainId, uint16 centrifugeId) external onlySafe { + tokenBridge.file("centrifugeId", evmChainId, centrifugeId); + } + //---------------------------------------------------------------------------------------------- // Helpers //---------------------------------------------------------------------------------------------- diff --git a/src/admin/interfaces/IProtocolGuardian.sol b/src/admin/interfaces/IProtocolGuardian.sol index 7c083146c..2133e1468 100644 --- a/src/admin/interfaces/IProtocolGuardian.sol +++ b/src/admin/interfaces/IProtocolGuardian.sol @@ -57,6 +57,15 @@ interface IProtocolGuardian { /// @param isBlocked True to block outgoing messages, false to unblock function blockOutgoing(uint16 centrifugeId, bool isBlocked) external; + /// @notice Configure TokenBridge relayer address + /// @param relayer The relayer address to set + function fileTokenBridgeRelayer(address relayer) external; + + /// @notice Configure TokenBridge chain ID mapping + /// @param evmChainId The EVM chain ID + /// @param centrifugeId The corresponding Centrifuge chain ID + function fileTokenBridgeCentrifugeId(uint256 evmChainId, uint16 centrifugeId) external; + /// @notice Updates a contract parameter /// @param what Accepts a bytes32 representation of 'safe', 'gateway', or 'sender' /// @param data New value for the parameter diff --git a/src/bridge/TokenBridge.sol b/src/bridge/TokenBridge.sol new file mode 100644 index 000000000..cfc40e04b --- /dev/null +++ b/src/bridge/TokenBridge.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {ITokenBridge} from "./interfaces/ITokenBridge.sol"; + +import {Auth} from "../misc/Auth.sol"; +import {IERC20} from "../misc/interfaces/IERC20.sol"; +import {SafeTransferLib} from "../misc/libraries/SafeTransferLib.sol"; + +import {PoolId} from "../core/types/PoolId.sol"; +import {ISpoke} from "../core/spoke/interfaces/ISpoke.sol"; +import {ShareClassId} from "../core/types/ShareClassId.sol"; +import {ITrustedContractUpdate} from "../core/utils/interfaces/IContractUpdate.sol"; + +/// @title TokenBridge +/// @notice Wrapper contract for cross-chain token transfers compatible with Glacis Airlift +contract TokenBridge is Auth, ITokenBridge { + ISpoke public immutable spoke; + + address public relayer; + mapping(PoolId => mapping(ShareClassId => GasLimits)) public gasLimits; + mapping(uint256 evmChainId => uint16 centrifugeId) public chainIdToCentrifugeId; + + constructor(ISpoke spoke_, address deployer) Auth(deployer) { + spoke = spoke_; + } + + //---------------------------------------------------------------------------------------------- + // Administration + //---------------------------------------------------------------------------------------------- + + /// @inheritdoc ITokenBridge + function file(bytes32 what, address data) external auth { + if (what == "relayer") relayer = data; + else revert FileUnrecognizedParam(); + emit File(what, data); + } + + /// @inheritdoc ITokenBridge + function file(bytes32 what, uint256 evmChainId, uint16 centrifugeId) external auth { + if (what == "centrifugeId") chainIdToCentrifugeId[evmChainId] = centrifugeId; + else revert FileUnrecognizedParam(); + emit File(what, evmChainId, centrifugeId); + } + + /// @inheritdoc ITrustedContractUpdate + function trustedCall(PoolId poolId, ShareClassId scId, bytes memory payload) external auth { + uint8 kindValue = abi.decode(payload, (uint8)); + require(kindValue <= uint8(type(TrustedCall).max), UnknownTrustedCall()); + + TrustedCall kind = TrustedCall(kindValue); + if (kind == TrustedCall.SetGasLimits) { + (, uint128 extraGasLimit, uint128 remoteExtraGasLimit) = abi.decode(payload, (uint8, uint128, uint128)); + + require(address(spoke.shareToken(poolId, scId)) != address(0), ShareTokenDoesNotExist()); + + gasLimits[poolId][scId] = GasLimits(extraGasLimit, remoteExtraGasLimit); + emit UpdateGasLimits(poolId, scId, extraGasLimit, remoteExtraGasLimit); + } + } + + //---------------------------------------------------------------------------------------------- + // Bridging + //---------------------------------------------------------------------------------------------- + + /// @inheritdoc ITokenBridge + function send(address token, uint256 amount, bytes32 receiver, uint256 destinationChainId, address refundAddress) + public + payable + returns (bytes memory) + { + uint16 centrifugeId = chainIdToCentrifugeId[destinationChainId]; + require(centrifugeId != 0, InvalidChainId()); + + SafeTransferLib.safeTransferFrom(token, msg.sender, address(this), amount); + if (IERC20(token).allowance(address(this), address(spoke)) == 0) { + SafeTransferLib.safeApprove(token, address(spoke), type(uint256).max); + } + + (PoolId poolId, ShareClassId scId) = spoke.shareTokenDetails(token); + GasLimits memory limits = gasLimits[poolId][scId]; + + spoke.crosschainTransferShares{ + value: msg.value + }( + centrifugeId, + poolId, + scId, + receiver, + uint128(amount), + limits.extraGasLimit, + limits.remoteExtraGasLimit, + relayer != address(0) ? relayer : refundAddress // Transfer remaining ETH to relayer if set + ); + + return bytes(""); + } + + /// @inheritdoc ITokenBridge + function send( + address token, + uint256 amount, + bytes32 receiver, + uint256 destinationChainId, + address refundAddress, + bytes32 /* outputToken */ + ) public payable returns (bytes memory) { + return send(token, amount, receiver, destinationChainId, refundAddress); + } +} diff --git a/src/bridge/interfaces/ITokenBridge.sol b/src/bridge/interfaces/ITokenBridge.sol new file mode 100644 index 000000000..f1d93080a --- /dev/null +++ b/src/bridge/interfaces/ITokenBridge.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +import {PoolId} from "../../core/types/PoolId.sol"; +import {ShareClassId} from "../../core/types/ShareClassId.sol"; +import {ITrustedContractUpdate} from "../../core/utils/interfaces/IContractUpdate.sol"; + +interface ITokenBridge is ITrustedContractUpdate { + event File(bytes32 indexed what, address data); + event File(bytes32 indexed what, uint256 evmChainId, uint16 centrifugeId); + event UpdateGasLimits( + PoolId indexed poolId, ShareClassId indexed scId, uint128 extraGasLimit, uint128 remoteExtraGasLimit + ); + + error FileUnrecognizedParam(); + error InvalidChainId(); + error InvalidRelayer(); + error InvalidToken(); + error UnknownTrustedCall(); + error ShareTokenDoesNotExist(); + error FailedToTransferToRelayer(); + + enum TrustedCall { + SetGasLimits + } + + struct GasLimits { + uint128 extraGasLimit; + uint128 remoteExtraGasLimit; + } + + //---------------------------------------------------------------------------------------------- + // Administration + //---------------------------------------------------------------------------------------------- + + /// @notice Configure contract parameters + /// @param what The parameter name to configure + /// @param data The address value to set + function file(bytes32 what, address data) external; + + /// @notice Configure chain ID mapping + /// @param what Must be "centrifugeId" + /// @param evmChainId The EVM chain ID + /// @param centrifugeId The corresponding Centrifuge chain ID + function file(bytes32 what, uint256 evmChainId, uint16 centrifugeId) external; + + //---------------------------------------------------------------------------------------------- + // Bridging + //---------------------------------------------------------------------------------------------- + + /// @notice Send a token from chain A to chain B after approving this contract with the token + /// @dev This function transfers tokens from the caller and initiates a cross-chain transfer + /// @dev These methods match the expected interface from Glacis Airlift for cross-chain token transfers + /// @param token The address of the token sending across chains + /// @param amount The amount of the token to send across chains + /// @param receiver The target address that should receive the funds on the destination chain + /// @param destinationChainId The Ethereum chain ID of the destination chain + /// @param refundAddress The address that should receive any funds if the cross-chain gas value is too high + /// @return sendResponse The response from the token's handler function (not standardized) + function send(address token, uint256 amount, bytes32 receiver, uint256 destinationChainId, address refundAddress) + external + payable + returns (bytes memory); + + /// @notice Send a token from chain A to chain B with a specific output token + /// @dev This allows routing through a specific bridge when multiple bridges are available + /// @dev These methods match the expected interface from Glacis Airlift for cross-chain token transfers + /// @param token The address of the token sending across chains + /// @param amount The amount of the token to send across chains + /// @param receiver The target address that should receive the funds on the destination chain + /// @param destinationChainId The Ethereum chain ID of the destination chain + /// @param refundAddress The address that should receive any funds if the cross-chain gas value is too high + /// @param outputToken The address of the token to receive on the destination chain + /// @return sendResponse The response from the token's handler function (not standardized) + function send( + address token, + uint256 amount, + bytes32 receiver, + uint256 destinationChainId, + address refundAddress, + bytes32 outputToken + ) external payable returns (bytes memory); + + //---------------------------------------------------------------------------------------------- + // View methods + //---------------------------------------------------------------------------------------------- + + /// @notice Returns the relayer address + /// @return The relayer address + function relayer() external view returns (address); +} diff --git a/test/bridge/integration/TokenBridge.t.sol b/test/bridge/integration/TokenBridge.t.sol new file mode 100644 index 000000000..42e65a611 --- /dev/null +++ b/test/bridge/integration/TokenBridge.t.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {CastLib} from "../../../src/misc/libraries/CastLib.sol"; + +import "../../core/spoke/integration/BaseTest.sol"; + +import {AssetId} from "../../../src/core/types/AssetId.sol"; +import {ShareClassId} from "../../../src/core/types/ShareClassId.sol"; +import {IShareToken} from "../../../src/core/spoke/interfaces/IShareToken.sol"; + +import {ITokenBridge} from "../../../src/bridge/interfaces/ITokenBridge.sol"; + +abstract contract TokenBridgeBaseTest is BaseTest { + uint128 constant DEFAULT_AMOUNT = 100_000_000; + + ShareClassId scId; + + AssetId assetId; + address vault; + IShareToken shareToken; + + address user = makeAddr("user"); + address receiver = makeAddr("receiver"); + address relayer = makeAddr("relayer"); + + uint256 constant DESTINATION_CHAIN_ID = 2031; + uint16 constant DESTINATION_CENT_ID = OTHER_CHAIN_ID; + + function setUp() public override { + super.setUp(); + + scId = ShareClassId.wrap(defaultShareClassId); + + (, address vaultAddress, uint128 createdAssetId) = deployVault( + VaultKind.SyncDepositAsyncRedeem, + 18, + address(freelyTransferableHook), + defaultShareClassId, + address(erc20), + erc20TokenId, + OTHER_CHAIN_ID + ); + assetId = AssetId.wrap(createdAssetId); + vault = vaultAddress; + shareToken = spoke.shareToken(POOL_A, scId); + + tokenBridge.file("centrifugeId", DESTINATION_CHAIN_ID, DESTINATION_CENT_ID); + + vm.deal(user, 1 ether); + } +} + +contract TokenBridgeSendTest is TokenBridgeBaseTest { + using CastLib for *; + + /// forge-config: default.isolate = true + function testSendSuccess() public { + uint128 extraGasLimit = 50_000; + uint128 remoteExtraGasLimit = 100_000; + + bytes memory payload = + abi.encode(uint8(ITokenBridge.TrustedCall.SetGasLimits), extraGasLimit, remoteExtraGasLimit); + tokenBridge.trustedCall(POOL_A, scId, payload); + + depositSync(vault, user, DEFAULT_AMOUNT); + + uint256 shareBalance = shareToken.balanceOf(user); + assertGt(shareBalance, 0); + + vm.prank(user); + shareToken.approve(address(tokenBridge), shareBalance); + + vm.expectCall( + address(messageDispatcher), + 0.1 ether, + abi.encodeWithSignature( + "sendInitiateTransferShares(uint16,uint64,bytes16,bytes32,uint128,uint128,uint128,address)", + DESTINATION_CENT_ID, + POOL_A, + scId, + receiver.toBytes32(), + uint128(shareBalance), + extraGasLimit, + remoteExtraGasLimit, + address(user) + ) + ); + + vm.prank(user); + tokenBridge.send{ + value: 0.1 ether + }(address(shareToken), shareBalance, receiver.toBytes32(), DESTINATION_CHAIN_ID, user); + + assertEq(shareToken.balanceOf(address(tokenBridge)), 0); + assertGt(user.balance, 0.99 ether); // Got refunded + } + + /// forge-config: default.isolate = true + function testSendWithRelayerSuccess() public { + tokenBridge.file("relayer", relayer); + + bytes memory payload = abi.encode(uint8(ITokenBridge.TrustedCall.SetGasLimits), 0, 0); + tokenBridge.trustedCall(POOL_A, scId, payload); + + depositSync(vault, user, DEFAULT_AMOUNT); + + uint256 shareBalance = shareToken.balanceOf(user); + assertGt(shareBalance, 0); + + vm.prank(user); + shareToken.approve(address(tokenBridge), shareBalance); + + vm.expectCall( + address(messageDispatcher), + 0.1 ether, + abi.encodeWithSignature( + "sendInitiateTransferShares(uint16,uint64,bytes16,bytes32,uint128,uint128,uint128,address)", + DESTINATION_CENT_ID, + POOL_A, + scId, + receiver.toBytes32(), + uint128(shareBalance / 2), + 0, + 0, + address(relayer) + ), + 2 + ); + + // Call twice to test approval reuse + vm.prank(user); + tokenBridge.send{ + value: 0.1 ether + }(address(shareToken), shareBalance / 2, receiver.toBytes32(), DESTINATION_CHAIN_ID, user); + + vm.prank(user); + tokenBridge.send{ + value: 0.1 ether + }(address(shareToken), shareBalance / 2, receiver.toBytes32(), DESTINATION_CHAIN_ID, user); + + assertEq(shareToken.balanceOf(address(tokenBridge)), 0); + assertGt(relayer.balance, 0.09 ether); // Eth sent to relayer + } +} diff --git a/test/bridge/unit/TokenBridge.t.sol b/test/bridge/unit/TokenBridge.t.sol new file mode 100644 index 000000000..678c3d875 --- /dev/null +++ b/test/bridge/unit/TokenBridge.t.sol @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {IAuth} from "../../../src/misc/Auth.sol"; +import {IERC20} from "../../../src/misc/interfaces/IERC20.sol"; +import {CastLib} from "../../../src/misc/libraries/CastLib.sol"; + +import {PoolId} from "../../../src/core/types/PoolId.sol"; +import {ISpoke} from "../../../src/core/spoke/interfaces/ISpoke.sol"; +import {ShareClassId} from "../../../src/core/types/ShareClassId.sol"; + +import "forge-std/Test.sol"; + +import {TokenBridge} from "../../../src/bridge/TokenBridge.sol"; +import {ITokenBridge} from "../../../src/bridge/interfaces/ITokenBridge.sol"; + +contract IsContract {} + +contract TokenBridgeTest is Test { + uint128 constant DEFAULT_AMOUNT = 100_000_000; + PoolId constant POOL_A = PoolId.wrap(12); + PoolId constant POOL_B = PoolId.wrap(34); + ShareClassId constant SC_1 = ShareClassId.wrap(bytes16("1")); + ShareClassId constant SC_2 = ShareClassId.wrap(bytes16("2")); + uint256 constant EVM_CHAIN_ID_1 = 1; + uint256 constant EVM_CHAIN_ID_2 = 2031; + uint16 constant CENTRIFUGE_ID_1 = 2; + uint16 constant CENTRIFUGE_ID_2 = 3; + + address spoke = address(new IsContract()); + address shareToken1 = makeAddr("shareToken1"); + address shareToken2 = makeAddr("shareToken2"); + address user = makeAddr("user"); + address receiver = makeAddr("receiver"); + address relayer = makeAddr("relayer"); + address unauthorized = makeAddr("unauthorized"); + + TokenBridge bridge = new TokenBridge(ISpoke(spoke), address(this)); + + function setUp() public virtual { + _setupMocks(); + + vm.deal(user, 1 ether); + } + + function _setupMocks() internal { + vm.mockCall( + spoke, abi.encodeWithSelector(ISpoke.shareTokenDetails.selector, shareToken1), abi.encode(POOL_A, SC_1) + ); + vm.mockCall( + spoke, abi.encodeWithSelector(ISpoke.shareTokenDetails.selector, shareToken2), abi.encode(POOL_B, SC_2) + ); + + vm.mockCall(spoke, abi.encodeWithSelector(ISpoke.shareToken.selector, POOL_A, SC_1), abi.encode(shareToken1)); + vm.mockCall(spoke, abi.encodeWithSelector(ISpoke.shareToken.selector, POOL_B, SC_2), abi.encode(shareToken2)); + + vm.mockCall( + spoke, + abi.encodeWithSignature( + "crosschainTransferShares(uint16,uint64,bytes16,bytes32,uint128,uint128,uint128,address)" + ), + abi.encode() + ); + + vm.mockCall(shareToken1, abi.encodeWithSelector(IERC20.transferFrom.selector), abi.encode(true)); + vm.mockCall(shareToken1, abi.encodeWithSelector(IERC20.approve.selector), abi.encode(true)); + vm.mockCall(shareToken1, abi.encodeWithSelector(IERC20.allowance.selector), abi.encode(0)); + + vm.mockCall(shareToken2, abi.encodeWithSelector(IERC20.transferFrom.selector), abi.encode(true)); + vm.mockCall(shareToken2, abi.encodeWithSelector(IERC20.approve.selector), abi.encode(true)); + vm.mockCall(shareToken2, abi.encodeWithSelector(IERC20.allowance.selector), abi.encode(0)); + } +} + +contract TokenBridgeConstructorTest is TokenBridgeTest { + function testConstructor() public view { + assertEq(address(bridge.spoke()), address(spoke)); + assertEq(bridge.relayer(), address(0)); + } +} + +contract TokenBridgeFileTest is TokenBridgeTest { + function testFileRelayerSuccess() public { + vm.expectEmit(true, true, true, true); + emit ITokenBridge.File("relayer", relayer); + bridge.file("relayer", relayer); + + assertEq(bridge.relayer(), relayer); + } + + function testFileRelayerUnrecognizedParam() public { + vm.expectRevert(ITokenBridge.FileUnrecognizedParam.selector); + bridge.file("invalid", relayer); + } + + function testFileRelayerUnauthorized() public { + vm.prank(unauthorized); + vm.expectRevert(IAuth.NotAuthorized.selector); + bridge.file("relayer", relayer); + } + + function testFileChainIdSuccess() public { + vm.expectEmit(true, true, true, true); + emit ITokenBridge.File("centrifugeId", EVM_CHAIN_ID_1, CENTRIFUGE_ID_1); + bridge.file("centrifugeId", EVM_CHAIN_ID_1, CENTRIFUGE_ID_1); + + assertEq(bridge.chainIdToCentrifugeId(EVM_CHAIN_ID_1), CENTRIFUGE_ID_1); + } + + function testFileChainIdMultiple() public { + bridge.file("centrifugeId", EVM_CHAIN_ID_1, CENTRIFUGE_ID_1); + bridge.file("centrifugeId", EVM_CHAIN_ID_2, CENTRIFUGE_ID_2); + + assertEq(bridge.chainIdToCentrifugeId(EVM_CHAIN_ID_1), CENTRIFUGE_ID_1); + assertEq(bridge.chainIdToCentrifugeId(EVM_CHAIN_ID_2), CENTRIFUGE_ID_2); + } + + function testFileChainIdUnrecognizedParam() public { + vm.expectRevert(ITokenBridge.FileUnrecognizedParam.selector); + bridge.file("invalid", EVM_CHAIN_ID_1, CENTRIFUGE_ID_1); + } + + function testFileChainIdUnauthorized() public { + vm.prank(unauthorized); + vm.expectRevert(IAuth.NotAuthorized.selector); + bridge.file("centrifugeId", EVM_CHAIN_ID_1, CENTRIFUGE_ID_1); + } +} + +contract TokenBridgeTrustedCallTest is TokenBridgeTest { + function testSetGasLimitsSuccess() public { + uint128 extraGasLimit = 100_000; + uint128 remoteExtraGasLimit = 200_000; + + bytes memory payload = + abi.encode(uint8(ITokenBridge.TrustedCall.SetGasLimits), extraGasLimit, remoteExtraGasLimit); + + vm.expectEmit(true, true, true, true); + emit ITokenBridge.UpdateGasLimits(POOL_A, SC_1, extraGasLimit, remoteExtraGasLimit); + bridge.trustedCall(POOL_A, SC_1, payload); + + (uint128 storedExtra, uint128 storedRemote) = bridge.gasLimits(POOL_A, SC_1); + assertEq(storedExtra, extraGasLimit); + assertEq(storedRemote, remoteExtraGasLimit); + } + + function testSetGasLimitsMultipleShareClasses() public { + bytes memory payload1 = + abi.encode(uint8(ITokenBridge.TrustedCall.SetGasLimits), uint128(100_000), uint128(200_000)); + bytes memory payload2 = + abi.encode(uint8(ITokenBridge.TrustedCall.SetGasLimits), uint128(150_000), uint128(250_000)); + + bridge.trustedCall(POOL_A, SC_1, payload1); + bridge.trustedCall(POOL_B, SC_2, payload2); + + (uint128 extra1, uint128 remote1) = bridge.gasLimits(POOL_A, SC_1); + (uint128 extra2, uint128 remote2) = bridge.gasLimits(POOL_B, SC_2); + + assertEq(extra1, 100_000); + assertEq(remote1, 200_000); + assertEq(extra2, 150_000); + assertEq(remote2, 250_000); + } + + function testSetGasLimitsUnknownTrustedCall() public { + bytes memory payload = abi.encode(uint8(99)); + + vm.expectRevert(ITokenBridge.UnknownTrustedCall.selector); + bridge.trustedCall(POOL_A, SC_1, payload); + } + + function testSetGasLimitsShareTokenDoesNotExist() public { + PoolId invalidPool = PoolId.wrap(999); + ShareClassId invalidSc = ShareClassId.wrap(bytes16("invalid")); + + vm.mockCall( + spoke, abi.encodeWithSelector(ISpoke.shareToken.selector, invalidPool, invalidSc), abi.encode(address(0)) + ); + + bytes memory payload = abi.encode(uint8(ITokenBridge.TrustedCall.SetGasLimits), uint128(100), uint128(200)); + + vm.expectRevert(ITokenBridge.ShareTokenDoesNotExist.selector); + bridge.trustedCall(invalidPool, invalidSc, payload); + } + + function testSetGasLimitsUnauthorized() public { + bytes memory payload = abi.encode(uint8(ITokenBridge.TrustedCall.SetGasLimits), uint128(100), uint128(200)); + + vm.prank(unauthorized); + vm.expectRevert(IAuth.NotAuthorized.selector); + bridge.trustedCall(POOL_A, SC_1, payload); + } +} + +contract TokenBridgeSendTest is TokenBridgeTest { + using CastLib for *; + + function setUp() public override { + super.setUp(); + bridge.file("centrifugeId", EVM_CHAIN_ID_1, CENTRIFUGE_ID_1); + } + + function testSendSuccess() public { + vm.expectCall( + spoke, + abi.encodeWithSignature( + "crosschainTransferShares(uint16,uint64,bytes16,bytes32,uint128,uint128,uint128,address)", + CENTRIFUGE_ID_1, + POOL_A, + SC_1, + receiver.toBytes32(), + DEFAULT_AMOUNT, + 0, + 0, + user + ) + ); + + bridge.send{value: 0.1 ether}(shareToken1, DEFAULT_AMOUNT, receiver.toBytes32(), EVM_CHAIN_ID_1, user); + } + + function testSendWithRelayer() public { + bridge.file("relayer", relayer); + + vm.expectCall( + spoke, + abi.encodeWithSignature( + "crosschainTransferShares(uint16,uint64,bytes16,bytes32,uint128,uint128,uint128,address)", + CENTRIFUGE_ID_1, + POOL_A, + SC_1, + receiver.toBytes32(), + uint128(DEFAULT_AMOUNT), + uint128(0), + uint128(0), + relayer + ) + ); + + bridge.send(shareToken1, DEFAULT_AMOUNT, receiver.toBytes32(), EVM_CHAIN_ID_1, user); + } + + function testSendWithGasLimits() public { + uint128 extraGasLimit = 50_000; + uint128 remoteExtraGasLimit = 100_000; + + bytes memory payload = + abi.encode(uint8(ITokenBridge.TrustedCall.SetGasLimits), extraGasLimit, remoteExtraGasLimit); + bridge.trustedCall(POOL_A, SC_1, payload); + + vm.expectCall( + spoke, + abi.encodeWithSignature( + "crosschainTransferShares(uint16,uint64,bytes16,bytes32,uint128,uint128,uint128,address)", + CENTRIFUGE_ID_1, + POOL_A, + SC_1, + receiver.toBytes32(), + uint128(DEFAULT_AMOUNT), + extraGasLimit, + remoteExtraGasLimit, + user + ) + ); + + bridge.send{value: 0.1 ether}(shareToken1, DEFAULT_AMOUNT, receiver.toBytes32(), EVM_CHAIN_ID_1, user); + } + + function testSendInvalidChainId() public { + uint256 invalidChainId = 999; + + vm.expectRevert(ITokenBridge.InvalidChainId.selector); + bridge.send(shareToken1, DEFAULT_AMOUNT, receiver.toBytes32(), invalidChainId, user); + } + + function testSendInvalidToken() public { + address invalidToken = makeAddr("invalidToken"); + + vm.mockCall(invalidToken, abi.encodeWithSelector(IERC20.transferFrom.selector), abi.encode(true)); + vm.mockCall(invalidToken, abi.encodeWithSelector(IERC20.approve.selector), abi.encode(true)); + vm.mockCall(invalidToken, abi.encodeWithSelector(IERC20.allowance.selector), abi.encode(0)); + + vm.mockCallRevert( + spoke, + abi.encodeWithSelector(ISpoke.shareTokenDetails.selector, invalidToken), + abi.encodeWithSelector(ISpoke.ShareTokenDoesNotExist.selector) + ); + + vm.expectRevert(ISpoke.ShareTokenDoesNotExist.selector); + bridge.send(invalidToken, DEFAULT_AMOUNT, receiver.toBytes32(), EVM_CHAIN_ID_1, user); + } + + function testSendWithOutputToken() public { + bytes32 outputToken = bytes32(uint256(uint160(makeAddr("outputToken")))); + + bridge.send{ + value: 0.1 ether + }(shareToken1, DEFAULT_AMOUNT, receiver.toBytes32(), EVM_CHAIN_ID_1, user, outputToken); + } +} diff --git a/test/core/spoke/integration/BaseTest.sol b/test/core/spoke/integration/BaseTest.sol index c9ad63a7a..c1d14924e 100644 --- a/test/core/spoke/integration/BaseTest.sol +++ b/test/core/spoke/integration/BaseTest.sol @@ -121,6 +121,7 @@ contract BaseTest is FullDeployer, Test { fullRestrictionsHook.rely(address(this)); freelyTransferableHook.rely(address(this)); redemptionRestrictionsHook.rely(address(this)); + tokenBridge.rely(address(this)); vm.stopPrank(); removeFullDeployerAccess(batcher); diff --git a/test/core/spoke/unit/Spoke.t.sol b/test/core/spoke/unit/Spoke.t.sol index 77fe3fa10..96e2a7a2a 100644 --- a/test/core/spoke/unit/Spoke.t.sol +++ b/test/core/spoke/unit/Spoke.t.sol @@ -1132,7 +1132,7 @@ contract SpokeTestPricesPoolPer is SpokeTest { } } -contract SpokeTestshareTokenDetails is SpokeTest { +contract SpokeTestShareTokenDetails is SpokeTest { function testErrShareTokenDoesNotExist() public { address nonExistentToken = makeAddr("nonExistentToken"); diff --git a/test/core/unit/ProtocolGuardian.t.sol b/test/core/unit/ProtocolGuardian.t.sol index f4e5399cf..63fddc880 100644 --- a/test/core/unit/ProtocolGuardian.t.sol +++ b/test/core/unit/ProtocolGuardian.t.sol @@ -15,6 +15,8 @@ import {IProtocolGuardian} from "../../../src/admin/interfaces/IProtocolGuardian import "forge-std/Test.sol"; +import {ITokenBridge} from "../../../src/bridge/interfaces/ITokenBridge.sol"; + contract IsContract {} contract ProtocolGuardianTest is Test { @@ -24,6 +26,7 @@ contract ProtocolGuardianTest is Test { ISafe immutable SAFE = ISafe(address(new IsContract())); IGateway immutable gateway = IGateway(address(new IsContract())); IScheduleAuthMessageSender immutable sender = IScheduleAuthMessageSender(address(new IsContract())); + ITokenBridge immutable tokenBridge = ITokenBridge(address(new IsContract())); address immutable OWNER = makeAddr("owner"); address immutable UNAUTHORIZED = makeAddr("unauthorized"); @@ -42,7 +45,7 @@ contract ProtocolGuardianTest is Test { ProtocolGuardian protocolGuardian; function setUp() public { - protocolGuardian = new ProtocolGuardian(SAFE, root, gateway, sender); + protocolGuardian = new ProtocolGuardian(SAFE, root, gateway, sender, tokenBridge); vm.deal(address(SAFE), 1 ether); } @@ -51,6 +54,7 @@ contract ProtocolGuardianTest is Test { assertEq(address(protocolGuardian.root()), address(root)); assertEq(address(protocolGuardian.gateway()), address(gateway)); assertEq(address(protocolGuardian.sender()), address(sender)); + assertEq(address(protocolGuardian.tokenBridge()), address(tokenBridge)); } } @@ -310,3 +314,50 @@ contract ProtocolGuardianTestFile is ProtocolGuardianTest { protocolGuardian.file("safe", makeAddr("address")); } } + +contract ProtocolGuardianTestTokenBridge is ProtocolGuardianTest { + function testFileRelayerSuccess() public { + address relayer = makeAddr("relayer"); + + vm.mockCall( + address(tokenBridge), + abi.encodeWithSignature("file(bytes32,address)", bytes32("relayer"), relayer), + abi.encode() + ); + vm.expectCall( + address(tokenBridge), abi.encodeWithSignature("file(bytes32,address)", bytes32("relayer"), relayer) + ); + + vm.prank(address(SAFE)); + protocolGuardian.fileTokenBridgeRelayer(relayer); + } + + function testFileRelayerRevertWhenNotSafe() public { + vm.prank(UNAUTHORIZED); + vm.expectRevert(IProtocolGuardian.NotTheAuthorizedSafe.selector); + protocolGuardian.fileTokenBridgeRelayer(makeAddr("relayer")); + } + + function testfileCentrifugeIdSuccess() public { + uint256 evmChainId = 23; + + vm.mockCall( + address(tokenBridge), + abi.encodeWithSignature("file(bytes32,uint256,uint16)", bytes32("centrifugeId"), evmChainId, CENTRIFUGE_ID), + abi.encode() + ); + vm.expectCall( + address(tokenBridge), + abi.encodeWithSignature("file(bytes32,uint256,uint16)", bytes32("centrifugeId"), evmChainId, CENTRIFUGE_ID) + ); + + vm.prank(address(SAFE)); + protocolGuardian.fileTokenBridgeCentrifugeId(evmChainId, CENTRIFUGE_ID); + } + + function testfileCentrifugeIdRevertWhenNotSafe() public { + vm.prank(UNAUTHORIZED); + vm.expectRevert(IProtocolGuardian.NotTheAuthorizedSafe.selector); + protocolGuardian.fileTokenBridgeCentrifugeId(23, CENTRIFUGE_ID); + } +} diff --git a/test/integration/Deployer.t.sol b/test/integration/Deployer.t.sol index bea65693b..bf5754a53 100644 --- a/test/integration/Deployer.t.sol +++ b/test/integration/Deployer.t.sol @@ -733,6 +733,22 @@ contract FullDeploymentTestPeripherals is FullDeploymentConfigTest { // dependencies set correctly assertEq(address(chainlinkAdapter.ccipRouter()), CHAINLINK_CCIP_ROUTER); } + + function testTokenBridge(address nonWard) public view { + // permissions set correctly + vm.assume(nonWard != address(root)); + vm.assume(nonWard != address(protocolGuardian)); + + assertEq(tokenBridge.wards(address(root)), 1); + assertEq(tokenBridge.wards(address(protocolGuardian)), 1); + assertEq(tokenBridge.wards(nonWard), 0); + + // dependencies set correctly + assertEq(address(tokenBridge.spoke()), address(spoke)); + + // root endorsements + assertEq(root.endorsed(address(tokenBridge)), true); + } } contract FullDeploymentTestAdaptersValidation is FullDeploymentConfigTest { From 1586ccc6fb9b6fab51d99b38399c7891b850864c Mon Sep 17 00:00:00 2001 From: Onno Visser <23527729+onnovisser@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:53:42 +0100 Subject: [PATCH 2/4] remove send with outputToken --- src/bridge/TokenBridge.sol | 16 +--------------- src/bridge/interfaces/ITokenBridge.sol | 19 ------------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/src/bridge/TokenBridge.sol b/src/bridge/TokenBridge.sol index cfc40e04b..3c474ef0c 100644 --- a/src/bridge/TokenBridge.sol +++ b/src/bridge/TokenBridge.sol @@ -80,9 +80,7 @@ contract TokenBridge is Auth, ITokenBridge { (PoolId poolId, ShareClassId scId) = spoke.shareTokenDetails(token); GasLimits memory limits = gasLimits[poolId][scId]; - spoke.crosschainTransferShares{ - value: msg.value - }( + spoke.crosschainTransferShares{value: msg.value}( centrifugeId, poolId, scId, @@ -95,16 +93,4 @@ contract TokenBridge is Auth, ITokenBridge { return bytes(""); } - - /// @inheritdoc ITokenBridge - function send( - address token, - uint256 amount, - bytes32 receiver, - uint256 destinationChainId, - address refundAddress, - bytes32 /* outputToken */ - ) public payable returns (bytes memory) { - return send(token, amount, receiver, destinationChainId, refundAddress); - } } diff --git a/src/bridge/interfaces/ITokenBridge.sol b/src/bridge/interfaces/ITokenBridge.sol index f1d93080a..7c84b7aff 100644 --- a/src/bridge/interfaces/ITokenBridge.sol +++ b/src/bridge/interfaces/ITokenBridge.sol @@ -62,25 +62,6 @@ interface ITokenBridge is ITrustedContractUpdate { payable returns (bytes memory); - /// @notice Send a token from chain A to chain B with a specific output token - /// @dev This allows routing through a specific bridge when multiple bridges are available - /// @dev These methods match the expected interface from Glacis Airlift for cross-chain token transfers - /// @param token The address of the token sending across chains - /// @param amount The amount of the token to send across chains - /// @param receiver The target address that should receive the funds on the destination chain - /// @param destinationChainId The Ethereum chain ID of the destination chain - /// @param refundAddress The address that should receive any funds if the cross-chain gas value is too high - /// @param outputToken The address of the token to receive on the destination chain - /// @return sendResponse The response from the token's handler function (not standardized) - function send( - address token, - uint256 amount, - bytes32 receiver, - uint256 destinationChainId, - address refundAddress, - bytes32 outputToken - ) external payable returns (bytes memory); - //---------------------------------------------------------------------------------------------- // View methods //---------------------------------------------------------------------------------------------- From 686fd216c9d4e28d9cf95d79524c055caaf89685 Mon Sep 17 00:00:00 2001 From: Onno Visser <23527729+onnovisser@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:02:24 +0100 Subject: [PATCH 3/4] remove send with token test --- test/bridge/unit/TokenBridge.t.sol | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/bridge/unit/TokenBridge.t.sol b/test/bridge/unit/TokenBridge.t.sol index 678c3d875..901249396 100644 --- a/test/bridge/unit/TokenBridge.t.sol +++ b/test/bridge/unit/TokenBridge.t.sol @@ -289,12 +289,4 @@ contract TokenBridgeSendTest is TokenBridgeTest { vm.expectRevert(ISpoke.ShareTokenDoesNotExist.selector); bridge.send(invalidToken, DEFAULT_AMOUNT, receiver.toBytes32(), EVM_CHAIN_ID_1, user); } - - function testSendWithOutputToken() public { - bytes32 outputToken = bytes32(uint256(uint160(makeAddr("outputToken")))); - - bridge.send{ - value: 0.1 ether - }(shareToken1, DEFAULT_AMOUNT, receiver.toBytes32(), EVM_CHAIN_ID_1, user, outputToken); - } } From bca6b8d618800045a6f485917b056b601e0c6e0c Mon Sep 17 00:00:00 2001 From: Onno Visser <23527729+onnovisser@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:52:41 +0100 Subject: [PATCH 4/4] get FullActionBatcher below bytecode limit --- script/FullDeployer.s.sol | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/script/FullDeployer.s.sol b/script/FullDeployer.s.sol index 87c6d054e..1096c1898 100644 --- a/script/FullDeployer.s.sol +++ b/script/FullDeployer.s.sol @@ -160,10 +160,7 @@ contract FullActionBatcher is CoreActionBatcher { report.batchRequestManager.rely(address(report.root)); - if (address(report.layerZeroAdapter) != address(0)) report.layerZeroAdapter.rely(address(report.root)); - if (address(report.wormholeAdapter) != address(0)) report.wormholeAdapter.rely(address(report.root)); - if (address(report.axelarAdapter) != address(0)) report.axelarAdapter.rely(address(report.root)); - if (address(report.chainlinkAdapter) != address(0)) report.chainlinkAdapter.rely(address(report.root)); + _relyAdapters(report, address(report.root)); // Rely spoke report.asyncRequestManager.rely(address(report.core.spoke)); @@ -188,25 +185,13 @@ contract FullActionBatcher is CoreActionBatcher { report.tokenRecoverer.rely(address(report.protocolGuardian)); report.tokenBridge.rely(address(report.protocolGuardian)); // Permanent ward for ongoing adapter maintenance - if (address(report.layerZeroAdapter) != address(0)) { - report.layerZeroAdapter.rely(address(report.protocolGuardian)); - } - if (address(report.wormholeAdapter) != address(0)) { - report.wormholeAdapter.rely(address(report.protocolGuardian)); - } - if (address(report.axelarAdapter) != address(0)) report.axelarAdapter.rely(address(report.protocolGuardian)); - if (address(report.chainlinkAdapter) != address(0)) { - report.chainlinkAdapter.rely(address(report.protocolGuardian)); - } + _relyAdapters(report, address(report.protocolGuardian)); // Rely opsGuardian report.core.multiAdapter.rely(address(report.opsGuardian)); report.core.hub.rely(address(report.opsGuardian)); // Temporal ward for initial adapter wiring - if (address(report.layerZeroAdapter) != address(0)) report.layerZeroAdapter.rely(address(report.opsGuardian)); - if (address(report.wormholeAdapter) != address(0)) report.wormholeAdapter.rely(address(report.opsGuardian)); - if (address(report.axelarAdapter) != address(0)) report.axelarAdapter.rely(address(report.opsGuardian)); - if (address(report.chainlinkAdapter) != address(0)) report.chainlinkAdapter.rely(address(report.opsGuardian)); + _relyAdapters(report, address(report.opsGuardian)); // Rely tokenRecoverer report.root.rely(address(report.tokenRecoverer)); @@ -353,6 +338,14 @@ contract FullActionBatcher is CoreActionBatcher { if (address(report.chainlinkAdapter) != address(0)) report.chainlinkAdapter.deny(address(this)); } + /// @dev helper function to save some bytes + function _relyAdapters(FullReport memory report, address ward) internal { + if (address(report.layerZeroAdapter) != address(0)) report.layerZeroAdapter.rely(ward); + if (address(report.wormholeAdapter) != address(0)) report.wormholeAdapter.rely(ward); + if (address(report.axelarAdapter) != address(0)) report.axelarAdapter.rely(ward); + if (address(report.chainlinkAdapter) != address(0)) report.chainlinkAdapter.rely(ward); + } + function _setLayerZeroUlnConfig(LayerZeroAdapter adapter, uint32 eid, SetConfigParam[] memory params) internal { ILayerZeroEndpointV2Like endpoint = ILayerZeroEndpointV2Like(address(adapter.endpoint())); address oapp = address(adapter);