Skip to content

feat: TokenBridge#771

Open
onnovisser wants to merge 4 commits intomainfrom
feat/token-bridge
Open

feat: TokenBridge#771
onnovisser wants to merge 4 commits intomainfrom
feat/token-bridge

Conversation

@onnovisser
Copy link
Contributor

Product requirements

Design notes

  • Takes custody of user's share tokens before calling Spoke.crosschainTransferShares
  • Remaining msg.value optionally gets sent to a relayer, which can be set by the ProtocolGuardian
  • ProtocolGuardian has to file chain ID -> cent ID mapping, because Airlift only submits a destination chain ID
  • extraGasLimit and remoteExtraGasLimit configurable by FM

TODOs

  • Maybe change cent ID file method to allow setting multiple IDs at once

@lemunozm
Copy link
Contributor

lemunozm commented Feb 9, 2026

Original PR for reference: https://github.com/centrifuge/protocol-internal/pull/74

Copy link
Contributor

@wischli wischli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for re-opening and good job on putting this all together. I have a few questions before merging though.


/// @title TokenBridge
/// @notice Wrapper contract for cross-chain token transfers compatible with Glacis Airlift
contract TokenBridge is Auth, ITokenBridge {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q for all of us: Should this contract implement Recoverable to protect against accidently sent tokens?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it's never used as a target for receiving tokens. I would say not (?)

}

/// @inheritdoc ITrustedContractUpdate
function trustedCall(PoolId poolId, ShareClassId scId, bytes memory payload) external auth {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: We can use calldata payload. I discovered this as part of optimizing ARM size code. We still have several code paths in the repo using memory despite the interface declaration of calldata.

Suggested change
function trustedCall(PoolId poolId, ShareClassId scId, bytes memory payload) external auth {
function trustedCall(PoolId poolId, ShareClassId scId, bytes calldata payload) external auth {


/// @inheritdoc ITokenBridge
function send(address token, uint256 amount, bytes32 receiver, uint256 destinationChainId, address refundAddress)
public
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this really be public or instead external?

poolId,
scId,
receiver,
uint128(amount),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CR: Should use safe method from Mathlib to improve debugging in case this edge case occurs

Suggested change
uint128(amount),
amount.toUint128(),

SafeTransferLib.safeApprove(token, address(spoke), type(uint256).max);
}

(PoolId poolId, ShareClassId scId) = spoke.shareTokenDetails(token);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: We should move this up before the actual transfer because it is theoretically fallible such that we can save costs in case this check fails.

Comment on lines +16 to +17
error InvalidChainId();
error InvalidRelayer();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wanted to flag these are not yet used in the codebase

error InvalidToken();
error UnknownTrustedCall();
error ShareTokenDoesNotExist();
error FailedToTransferToRelayer();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

bridge.send(shareToken1, DEFAULT_AMOUNT, receiver.toBytes32(), invalidChainId, user);
}

function testSendInvalidToken() public {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great tests! Could you add one for revoking a chain mapping by setting centrifugeId to 0 and then verifying send() reverts? And maybe also one for catching uint128(amount) overflow.

protocolGuardian.fileTokenBridgeRelayer(makeAddr("relayer"));
}

function testfileCentrifugeIdSuccess() public {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Incorrect camelcase

Suggested change
function testfileCentrifugeIdSuccess() public {
function testFileCentrifugeIdSuccess() public {

protocolGuardian.fileTokenBridgeCentrifugeId(evmChainId, CENTRIFUGE_ID);
}

function testfileCentrifugeIdRevertWhenNotSafe() public {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function testfileCentrifugeIdRevertWhenNotSafe() public {
function testFileCentrifugeIdRevertWhenNotSafe() public {

Copy link
Contributor

@lemunozm lemunozm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor organizational comments, agree with William regarding the allowance/approval part. IIUC, we should change that.

But code looks really good!

Comment on lines +342 to +347
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);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice idea to reduce bytecode!

Comment on lines +39 to +44
/// @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);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my experience, file() methods for things other than dependencies can be somewhat messy in the end and difficult to handle. Could we use here a "normal" name like setDestination(evmChainId, centrifugeId) or something similar?


/// @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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe then we should move send(..) to its own interface like IGlacisAirliftTransfer or so? to reflect that and ensure this method signature remains immutable.

Do you have a reference for where this method is defined?


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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I also think the allowance is never checked. I think approval is something the user of TokenBridge should do before call to TokenBridge for leave TokenBridge to handle the transfer funds.

uint128(amount),
limits.extraGasLimit,
limits.remoteExtraGasLimit,
relayer != address(0) ? relayer : refundAddress // Transfer remaining ETH to relayer if set
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not always to the refundAddress? No fully understand the need of the relayer, could you extend?


/// @title TokenBridge
/// @notice Wrapper contract for cross-chain token transfers compatible with Glacis Airlift
contract TokenBridge is Auth, ITokenBridge {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it's never used as a target for receiving tokens. I would say not (?)

}

/// forge-config: default.isolate = true
function testSendWithRelayerSuccess() public {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, the relayer does not imply any new interaction with the system regarding the normal testSendSuccess(). I think the good usage of the relayer is already covered by UTs, could it be?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants