diff --git a/.changeset/angry-pandas-swim.md b/.changeset/angry-pandas-swim.md
new file mode 100644
index 0000000000..a590752860
--- /dev/null
+++ b/.changeset/angry-pandas-swim.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": patch
+---
+
+Fix CCTP v2 transferRemote amount
diff --git a/.changeset/big-papayas-grow.md b/.changeset/big-papayas-grow.md
new file mode 100644
index 0000000000..4d42e2f987
--- /dev/null
+++ b/.changeset/big-papayas-grow.md
@@ -0,0 +1,15 @@
+---
+"@hyperlane-xyz/core": major
+---
+
+Refactor warp route contracts for shallower inheritance tree and smaller bytecode size.
+
+Deprecated `Router` and `GasRouter` internal functions have been removed.
+
+`FungibleTokenRouter` has been removed and functionality lifted into `TokenRouter`.
+
+`quoteTransferRemote` and `transferRemote` can no longer be overriden with optional `hook` and `hookMetadata` for simplicity.
+
+`quoteTransferRemote` returns a consistent shape of `[nativeMailboxDispatchFee, internalTokenFee, externalTokenFee]`.
+
+`HypNative` and `HypERC20Collateral` inherit from `MovableCollateral` and `LpCollateral` but other extensions (eg `HypXERC20`) do not. Storage layouts have been preserved to ensure upgrade compatibility.
diff --git a/.changeset/brown-scissors-crash.md b/.changeset/brown-scissors-crash.md
new file mode 100644
index 0000000000..fd06b7bad2
--- /dev/null
+++ b/.changeset/brown-scissors-crash.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": patch
+---
+
+Rebalancer covers all fees associated with rebalancing
diff --git a/.changeset/clever-carpets-double.md b/.changeset/clever-carpets-double.md
new file mode 100644
index 0000000000..ab6d5cf853
--- /dev/null
+++ b/.changeset/clever-carpets-double.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": patch
+---
+
+Remove ValueTransferBridge and use ITokenBridge. ValueTransferBridge is a deprecated name for the interface.
diff --git a/.changeset/gold-islands-agree.md b/.changeset/gold-islands-agree.md
new file mode 100644
index 0000000000..ae91d65472
--- /dev/null
+++ b/.changeset/gold-islands-agree.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": minor
+---
+
+Add Everclear bridges for ETH and ERC20 tokens.
diff --git a/.changeset/light-years-reply.md b/.changeset/light-years-reply.md
new file mode 100644
index 0000000000..a73dc189ae
--- /dev/null
+++ b/.changeset/light-years-reply.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": minor
+---
+
+Implement support for CCTP v2 fast transfers
diff --git a/.changeset/many-stingrays-invite.md b/.changeset/many-stingrays-invite.md
new file mode 100644
index 0000000000..c64881cbfa
--- /dev/null
+++ b/.changeset/many-stingrays-invite.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": patch
+---
+
+Remove majority of virtual override functions
diff --git a/.changeset/mean-pigs-check.md b/.changeset/mean-pigs-check.md
new file mode 100644
index 0000000000..88f98ca8f8
--- /dev/null
+++ b/.changeset/mean-pigs-check.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/helloworld": patch
+---
+
+Update HelloWorld to use new Router utils
diff --git a/.changeset/nice-crabs-vanish.md b/.changeset/nice-crabs-vanish.md
new file mode 100644
index 0000000000..9bb1e41617
--- /dev/null
+++ b/.changeset/nice-crabs-vanish.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": minor
+---
+
+Extend CCTP TokenBridge with GMP support via hook
diff --git a/.changeset/odd-carrots-whisper.md b/.changeset/odd-carrots-whisper.md
new file mode 100644
index 0000000000..c77d233a72
--- /dev/null
+++ b/.changeset/odd-carrots-whisper.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": patch
+---
+
+Fix TokenBridgeCCTP.verify burn message sender enforcement
diff --git a/.changeset/red-moose-change.md b/.changeset/red-moose-change.md
new file mode 100644
index 0000000000..bc25cad234
--- /dev/null
+++ b/.changeset/red-moose-change.md
@@ -0,0 +1,10 @@
+---
+"@hyperlane-xyz/core": minor
+"@hyperlane-xyz/sdk": patch
+---
+
+Implement token fees on FungibleTokenRouter
+
+Removes `metadata` from return type of internal `TokenRouter._transferFromSender` hook
+
+To append `metadata` to `TokenMessage`, override the `TokenRouter._beforeDispatch` hook
diff --git a/.changeset/rude-apricots-try.md b/.changeset/rude-apricots-try.md
new file mode 100644
index 0000000000..4c49777f54
--- /dev/null
+++ b/.changeset/rude-apricots-try.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": minor
+---
+
+feat: emit event on protocol fee payment
diff --git a/.changeset/sharp-clouds-pay.md b/.changeset/sharp-clouds-pay.md
new file mode 100644
index 0000000000..457c99e354
--- /dev/null
+++ b/.changeset/sharp-clouds-pay.md
@@ -0,0 +1,6 @@
+---
+"@hyperlane-xyz/sdk": minor
+"@hyperlane-xyz/cli": patch
+---
+
+Decouple movable collateral and hyp collateral token adapters
diff --git a/.changeset/short-yaks-laugh.md b/.changeset/short-yaks-laugh.md
new file mode 100644
index 0000000000..945b4e5676
--- /dev/null
+++ b/.changeset/short-yaks-laugh.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": minor
+---
+
+Adds fees to FungibleTokenRouter
diff --git a/.changeset/stale-baboons-hope.md b/.changeset/stale-baboons-hope.md
new file mode 100644
index 0000000000..42536d6774
--- /dev/null
+++ b/.changeset/stale-baboons-hope.md
@@ -0,0 +1,9 @@
+---
+'@hyperlane-xyz/core': major
+---
+
+Add LP interface to collateral routers
+
+The `balanceOf` function has been removed from `TokenRouter` to remove ambiguity between `LpCollateralRouter.balanceOf`.
+
+To migrate, use the new `TokenRouter.token()` to get an `IERC20` or `IERC721` compliant address that you can call `balanceOf` on.
diff --git a/.changeset/sweet-tips-bake.md b/.changeset/sweet-tips-bake.md
new file mode 100644
index 0000000000..7b2bfabc17
--- /dev/null
+++ b/.changeset/sweet-tips-bake.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": patch
+---
+
+Update Yield Routes (HypERC4626OwnerCollateral and HypERC4626Collateral) to use safeApprove
diff --git a/.changeset/wise-steaks-think.md b/.changeset/wise-steaks-think.md
new file mode 100644
index 0000000000..33027367e5
--- /dev/null
+++ b/.changeset/wise-steaks-think.md
@@ -0,0 +1,5 @@
+---
+"@hyperlane-xyz/core": patch
+---
+
+Remove absolute imports. Fixes compilation for users who import from files under `solidity/contracts`.
diff --git a/.github/workflows/storage-analysis.yml b/.github/workflows/storage-analysis.yml
index 6a9d8409b8..941ae13143 100644
--- a/.github/workflows/storage-analysis.yml
+++ b/.github/workflows/storage-analysis.yml
@@ -2,7 +2,8 @@ name: Check Storage Layout Changes
on:
pull_request:
- branches: [main]
+ branches:
+ - '*'
paths:
- 'solidity/**'
workflow_dispatch:
@@ -53,13 +54,30 @@ jobs:
env:
BASE_REF: ${{ github.event.inputs.base || github.event.pull_request.base.sha }}
run: |
+ # Fetch the base reference
git fetch origin $BASE_REF
- git checkout $BASE_REF -- solidity/contracts
+ # Check if BASE_REF is a commit SHA (40 hex characters) or a branch name
+ if [[ "$BASE_REF" =~ ^[0-9a-f]{40}$ ]]; then
+ # For commit SHAs, checkout directly without origin/ prefix
+ git checkout $BASE_REF -- solidity/contracts
+ else
+ # For branch names, use origin/ prefix
+ git checkout origin/$BASE_REF -- solidity/contracts
+ fi
# Run the command on the target branch
- name: Run command on target branch
run: yarn workspace @hyperlane-xyz/core storage base-storage
# Compare outputs
- - name: Compare outputs
- run: diff --unified solidity/base-storage solidity/HEAD-storage
+ - name: Compare outputs (fail on removals only)
+ run: |
+ DIFF_OUTPUT=$(diff --unified solidity/base-storage solidity/HEAD-storage || true)
+ echo "$DIFF_OUTPUT"
+ # Fail only if there are removal lines in diff hunks (lines starting with '-' but not '---')
+ if echo "$DIFF_OUTPUT" | grep -E '^-([^-])' >/dev/null; then
+ echo "Detected storage removals in diff. Failing job."
+ exit 1
+ else
+ echo "No storage removals detected."
+ fi
diff --git a/package.json b/package.json
index 2845d6f0ef..b354ab18ea 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"postinstall": "husky install"
},
"workspaces": [
+ "solhint-plugin",
"solidity",
"typescript/*",
"starknet"
diff --git a/solhint-plugin/index.js b/solhint-plugin/index.js
new file mode 100644
index 0000000000..e7075f7fb9
--- /dev/null
+++ b/solhint-plugin/index.js
@@ -0,0 +1,48 @@
+// https://protofire.github.io/solhint/docs/writing-plugins.html
+class NoVirtualOverrideAllowed {
+ constructor(reporter, config) {
+ this.ruleId = 'no-virtual-override';
+
+ this.reporter = reporter;
+ this.config = config;
+ }
+
+ FunctionDefinition(ctx) {
+ const isVirtual = ctx.isVirtual;
+ const hasOverride = ctx.override !== null;
+
+ if (isVirtual && hasOverride) {
+ this.reporter.error(
+ ctx,
+ this.ruleId,
+ 'Functions cannot be "virtual" and "override" at the same time',
+ );
+ }
+ }
+}
+
+class NoVirtualInitializerAllowed {
+ constructor(reporter, config) {
+ this.ruleId = 'no-virtual-initializer';
+
+ this.reporter = reporter;
+ this.config = config;
+ }
+
+ FunctionDefinition(ctx) {
+ const isVirtual = ctx.isVirtual;
+ const hasInitializer = ctx.modifiers.some(
+ (modifier) => modifier.name === 'initializer',
+ );
+
+ if (isVirtual && hasInitializer) {
+ this.reporter.error(
+ ctx,
+ this.ruleId,
+ 'Functions cannot be "virtual" and "initializer" at the same time',
+ );
+ }
+ }
+}
+
+module.exports = [NoVirtualOverrideAllowed, NoVirtualInitializerAllowed];
diff --git a/solhint-plugin/package.json b/solhint-plugin/package.json
new file mode 100644
index 0000000000..704c406e74
--- /dev/null
+++ b/solhint-plugin/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "solhint-plugin-hyperlane",
+ "private": true,
+ "version": "0.0.0",
+ "description": "",
+ "license": "Apache-2.0",
+ "type": "commonjs",
+ "main": "index.js"
+}
diff --git a/solidity/.gitignore b/solidity/.gitignore
index 0c6e075cb3..fc60633645 100644
--- a/solidity/.gitignore
+++ b/solidity/.gitignore
@@ -15,6 +15,7 @@ docs
flattened/
buildArtifact.json
fixtures/
+broadcast/
# ZKSync
artifacts-zk
cache-zk
diff --git a/solidity/.solhint.json b/solidity/.solhint.json
index 64e8d1b1d1..e6a2941ef3 100644
--- a/solidity/.solhint.json
+++ b/solidity/.solhint.json
@@ -12,7 +12,9 @@
"reason-string": ["warn", { "maxLength": 64 }],
"prettier/prettier": "error",
"gas-custom-errors": "off",
- "named-parameters-mapping": "error"
+ "named-parameters-mapping": "error",
+ "hyperlane/no-virtual-override": "error",
+ "hyperlane/no-virtual-initializer": "error"
},
- "plugins": ["prettier"]
+ "plugins": ["prettier", "hyperlane"]
}
diff --git a/solidity/contracts/AttributeCheckpointFraud.sol b/solidity/contracts/AttributeCheckpointFraud.sol
index 1e52ba77be..dbfaaadcd2 100644
--- a/solidity/contracts/AttributeCheckpointFraud.sol
+++ b/solidity/contracts/AttributeCheckpointFraud.sol
@@ -5,7 +5,7 @@ import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
-import {PackageVersioned} from "contracts/PackageVersioned.sol";
+import {PackageVersioned} from "./PackageVersioned.sol";
import {TREE_DEPTH} from "./libs/Merkle.sol";
import {CheckpointLib, Checkpoint} from "./libs/CheckpointLib.sol";
import {CheckpointFraudProofs} from "./CheckpointFraudProofs.sol";
diff --git a/solidity/contracts/avs/HyperlaneServiceManager.sol b/solidity/contracts/avs/HyperlaneServiceManager.sol
index 99997d87a7..1343950aba 100644
--- a/solidity/contracts/avs/HyperlaneServiceManager.sol
+++ b/solidity/contracts/avs/HyperlaneServiceManager.sol
@@ -19,7 +19,7 @@ import {IAVSDirectory} from "../interfaces/avs/vendored/IAVSDirectory.sol";
import {IRemoteChallenger} from "../interfaces/avs/IRemoteChallenger.sol";
import {ISlasher} from "../interfaces/avs/vendored/ISlasher.sol";
import {ECDSAServiceManagerBase} from "./ECDSAServiceManagerBase.sol";
-import {PackageVersioned} from "contracts/PackageVersioned.sol";
+import {PackageVersioned} from "../PackageVersioned.sol";
contract HyperlaneServiceManager is ECDSAServiceManagerBase, PackageVersioned {
// ============ Libraries ============
@@ -286,9 +286,7 @@ contract HyperlaneServiceManager is ECDSAServiceManagerBase, PackageVersioned {
}
/// @inheritdoc ECDSAServiceManagerBase
- function _deregisterOperatorFromAVS(
- address operator
- ) internal virtual override {
+ function _deregisterOperatorFromAVS(address operator) internal override {
address[] memory challengers = getOperatorChallengers(operator);
_completeUnenrollment(operator, challengers);
diff --git a/solidity/contracts/client/GasRouter.sol b/solidity/contracts/client/GasRouter.sol
index 0587b7c424..65cc748517 100644
--- a/solidity/contracts/client/GasRouter.sol
+++ b/solidity/contracts/client/GasRouter.sol
@@ -59,7 +59,13 @@ abstract contract GasRouter is Router {
function quoteGasPayment(
uint32 _destinationDomain
) public view virtual returns (uint256) {
- return _GasRouter_quoteDispatch(_destinationDomain, "", address(hook));
+ return
+ _Router_quoteDispatch(
+ _destinationDomain,
+ "",
+ _GasRouter_hookMetadata(_destinationDomain),
+ address(hook)
+ );
}
function _GasRouter_hookMetadata(
@@ -73,34 +79,4 @@ abstract contract GasRouter is Router {
destinationGas[domain] = gas;
emit GasSet(domain, gas);
}
-
- function _GasRouter_dispatch(
- uint32 _destination,
- uint256 _value,
- bytes memory _messageBody,
- address _hook
- ) internal returns (bytes32) {
- return
- _Router_dispatch(
- _destination,
- _value,
- _messageBody,
- _GasRouter_hookMetadata(_destination),
- _hook
- );
- }
-
- function _GasRouter_quoteDispatch(
- uint32 _destination,
- bytes memory _messageBody,
- address _hook
- ) internal view returns (uint256) {
- return
- _Router_quoteDispatch(
- _destination,
- _messageBody,
- _GasRouter_hookMetadata(_destination),
- _hook
- );
- }
}
diff --git a/solidity/contracts/client/Router.sol b/solidity/contracts/client/Router.sol
index 39dd5ea2a3..7d17af755b 100644
--- a/solidity/contracts/client/Router.sol
+++ b/solidity/contracts/client/Router.sol
@@ -93,6 +93,7 @@ abstract contract Router is MailboxClient, IMessageRecipient {
* @param _sender The sender address
* @param _message The message
*/
+ // solhint-disable-next-line hyperlane/no-virtual-override
function handle(
uint32 _origin,
bytes32 _sender,
@@ -169,6 +170,21 @@ abstract contract Router is MailboxClient, IMessageRecipient {
);
}
+ function _Router_dispatch(
+ uint32 _destinationDomain,
+ uint256 _value,
+ bytes memory _messageBody
+ ) internal returns (bytes32) {
+ return
+ _Router_dispatch(
+ _destinationDomain,
+ _value,
+ _messageBody,
+ "",
+ address(hook)
+ );
+ }
+
function _Router_dispatch(
uint32 _destinationDomain,
uint256 _value,
@@ -187,18 +203,13 @@ abstract contract Router is MailboxClient, IMessageRecipient {
);
}
- /**
- * DEPRECATED: Use `_Router_dispatch` instead
- * @dev For backward compatibility with v2 client contracts
- */
- function _dispatch(
+ function _Router_quoteDispatch(
uint32 _destinationDomain,
bytes memory _messageBody
- ) internal returns (bytes32) {
+ ) internal view returns (uint256) {
return
- _Router_dispatch(
+ _Router_quoteDispatch(
_destinationDomain,
- msg.value,
_messageBody,
"",
address(hook)
@@ -221,21 +232,4 @@ abstract contract Router is MailboxClient, IMessageRecipient {
IPostDispatchHook(_hook)
);
}
-
- /**
- * DEPRECATED: Use `_Router_quoteDispatch` instead
- * @dev For backward compatibility with v2 client contracts
- */
- function _quoteDispatch(
- uint32 _destinationDomain,
- bytes memory _messageBody
- ) internal view returns (uint256) {
- return
- _Router_quoteDispatch(
- _destinationDomain,
- _messageBody,
- "",
- address(hook)
- );
- }
}
diff --git a/solidity/contracts/hooks/ArbL2ToL1Hook.sol b/solidity/contracts/hooks/ArbL2ToL1Hook.sol
index 9a6365b240..d2726b1a70 100644
--- a/solidity/contracts/hooks/ArbL2ToL1Hook.sol
+++ b/solidity/contracts/hooks/ArbL2ToL1Hook.sol
@@ -58,7 +58,7 @@ contract ArbL2ToL1Hook is AbstractMessageIdAuthHook {
/// @inheritdoc IPostDispatchHook
function hookType() external pure override returns (uint8) {
- return uint8(IPostDispatchHook.Types.ARB_L2_TO_L1);
+ return uint8(IPostDispatchHook.HookTypes.ARB_L2_TO_L1);
}
/// @inheritdoc AbstractPostDispatchHook
diff --git a/solidity/contracts/hooks/DefaultHook.sol b/solidity/contracts/hooks/DefaultHook.sol
index 1d16180861..60dba0fb68 100644
--- a/solidity/contracts/hooks/DefaultHook.sol
+++ b/solidity/contracts/hooks/DefaultHook.sol
@@ -14,7 +14,7 @@ contract DefaultHook is AbstractPostDispatchHook, MailboxClient {
constructor(address _mailbox) MailboxClient(_mailbox) {}
function hookType() external pure returns (uint8) {
- return uint8(IPostDispatchHook.Types.MAILBOX_DEFAULT_HOOK);
+ return uint8(IPostDispatchHook.HookTypes.MAILBOX_DEFAULT_HOOK);
}
function _hook() public view returns (IPostDispatchHook) {
@@ -24,14 +24,14 @@ contract DefaultHook is AbstractPostDispatchHook, MailboxClient {
function _quoteDispatch(
bytes calldata metadata,
bytes calldata message
- ) internal view virtual override returns (uint256) {
+ ) internal view override returns (uint256) {
return _hook().quoteDispatch(metadata, message);
}
function _postDispatch(
bytes calldata metadata,
bytes calldata message
- ) internal virtual override {
+ ) internal override {
_hook().postDispatch{value: msg.value}(metadata, message);
}
}
diff --git a/solidity/contracts/hooks/MerkleTreeHook.sol b/solidity/contracts/hooks/MerkleTreeHook.sol
index 5af7e47aec..d2896e9889 100644
--- a/solidity/contracts/hooks/MerkleTreeHook.sol
+++ b/solidity/contracts/hooks/MerkleTreeHook.sol
@@ -54,7 +54,7 @@ contract MerkleTreeHook is AbstractPostDispatchHook, MailboxClient, Indexed {
/// @inheritdoc IPostDispatchHook
function hookType() external pure override returns (uint8) {
- return uint8(IPostDispatchHook.Types.MERKLE_TREE);
+ return uint8(IPostDispatchHook.HookTypes.MERKLE_TREE);
}
// ============ Internal Functions ============
diff --git a/solidity/contracts/hooks/OPL2ToL1Hook.sol b/solidity/contracts/hooks/OPL2ToL1Hook.sol
index 96fc89d03a..9294d581b2 100644
--- a/solidity/contracts/hooks/OPL2ToL1Hook.sol
+++ b/solidity/contracts/hooks/OPL2ToL1Hook.sol
@@ -58,7 +58,7 @@ contract OPL2ToL1Hook is AbstractMessageIdAuthHook {
/// @inheritdoc IPostDispatchHook
function hookType() external pure override returns (uint8) {
- return uint8(IPostDispatchHook.Types.OP_L2_TO_L1);
+ return uint8(IPostDispatchHook.HookTypes.OP_L2_TO_L1);
}
/// @inheritdoc AbstractPostDispatchHook
diff --git a/solidity/contracts/hooks/PausableHook.sol b/solidity/contracts/hooks/PausableHook.sol
index aeffc7630a..1a42aa07f4 100644
--- a/solidity/contracts/hooks/PausableHook.sol
+++ b/solidity/contracts/hooks/PausableHook.sol
@@ -34,7 +34,7 @@ contract PausableHook is AbstractPostDispatchHook, Ownable, Pausable {
/// @inheritdoc IPostDispatchHook
function hookType() external pure override returns (uint8) {
- return uint8(IPostDispatchHook.Types.PAUSABLE);
+ return uint8(IPostDispatchHook.HookTypes.PAUSABLE);
}
// ============ Internal functions ============
diff --git a/solidity/contracts/hooks/ProtocolFee.sol b/solidity/contracts/hooks/ProtocolFee.sol
index dbbe5c72dd..e0eb8d24f1 100644
--- a/solidity/contracts/hooks/ProtocolFee.sol
+++ b/solidity/contracts/hooks/ProtocolFee.sol
@@ -34,6 +34,7 @@ contract ProtocolFee is AbstractPostDispatchHook, Ownable {
event ProtocolFeeSet(uint256 protocolFee);
event BeneficiarySet(address beneficiary);
+ event ProtocolFeePaid(address indexed sender, uint256 fee);
// ============ Constants ============
@@ -65,7 +66,7 @@ contract ProtocolFee is AbstractPostDispatchHook, Ownable {
/// @inheritdoc IPostDispatchHook
function hookType() external pure override returns (uint8) {
- return uint8(IPostDispatchHook.Types.PROTOCOL_FEE);
+ return uint8(IPostDispatchHook.HookTypes.PROTOCOL_FEE);
}
/**
@@ -103,6 +104,8 @@ contract ProtocolFee is AbstractPostDispatchHook, Ownable {
"ProtocolFee: insufficient protocol fee"
);
+ emit ProtocolFeePaid(message.senderAddress(), protocolFee);
+
_refund(metadata, message, msg.value - protocolFee);
}
diff --git a/solidity/contracts/hooks/aggregation/StaticAggregationHook.sol b/solidity/contracts/hooks/aggregation/StaticAggregationHook.sol
index a8e6fc7acd..d83b1af260 100644
--- a/solidity/contracts/hooks/aggregation/StaticAggregationHook.sol
+++ b/solidity/contracts/hooks/aggregation/StaticAggregationHook.sol
@@ -34,7 +34,7 @@ contract StaticAggregationHook is AbstractPostDispatchHook {
/// @inheritdoc IPostDispatchHook
function hookType() external pure override returns (uint8) {
- return uint8(IPostDispatchHook.Types.AGGREGATION);
+ return uint8(IPostDispatchHook.HookTypes.AGGREGATION);
}
/// @inheritdoc AbstractPostDispatchHook
diff --git a/solidity/contracts/hooks/aggregation/StaticAggregationHookFactory.sol b/solidity/contracts/hooks/aggregation/StaticAggregationHookFactory.sol
index c7c2d5a2c1..821a018f6a 100644
--- a/solidity/contracts/hooks/aggregation/StaticAggregationHookFactory.sol
+++ b/solidity/contracts/hooks/aggregation/StaticAggregationHookFactory.sol
@@ -18,12 +18,7 @@ import {StaticAggregationHook} from "./StaticAggregationHook.sol";
import {StaticAddressSetFactory} from "../../libs/StaticAddressSetFactory.sol";
contract StaticAggregationHookFactory is StaticAddressSetFactory {
- function _deployImplementation()
- internal
- virtual
- override
- returns (address)
- {
+ function _deployImplementation() internal override returns (address) {
return address(new StaticAggregationHook());
}
}
diff --git a/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol b/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol
index 6df37c3f16..110219d470 100644
--- a/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol
+++ b/solidity/contracts/hooks/igp/InterchainGasPaymaster.sol
@@ -95,7 +95,7 @@ contract InterchainGasPaymaster is
/// @inheritdoc IPostDispatchHook
function hookType() external pure override returns (uint8) {
- return uint8(IPostDispatchHook.Types.INTERCHAIN_GAS_PAYMASTER);
+ return uint8(IPostDispatchHook.HookTypes.INTERCHAIN_GAS_PAYMASTER);
}
/**
@@ -192,6 +192,7 @@ contract InterchainGasPaymaster is
* @param _gasLimit The amount of destination gas to pay for.
* @return The amount of native tokens required to pay for interchain gas.
*/
+ // solhint-disable-next-line hyperlane/no-virtual-override
function quoteGasPayment(
uint32 _destinationDomain,
uint256 _gasLimit
diff --git a/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol b/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol
index 34ecc08a38..13b75f830d 100644
--- a/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol
+++ b/solidity/contracts/hooks/layer-zero/LayerZeroV1Hook.sol
@@ -50,14 +50,14 @@ contract LayerZeroV1Hook is AbstractPostDispatchHook, MailboxClient {
/// @inheritdoc IPostDispatchHook
function hookType() external pure override returns (uint8) {
- return uint8(IPostDispatchHook.Types.LAYER_ZERO_V1);
+ return uint8(IPostDispatchHook.HookTypes.LAYER_ZERO_V1);
}
/// @inheritdoc AbstractPostDispatchHook
function _postDispatch(
bytes calldata metadata,
bytes calldata message
- ) internal virtual override {
+ ) internal override {
// ensure hook only dispatches messages that are dispatched by the mailbox
bytes32 id = message.id();
require(_isLatestDispatched(id), "message not dispatched by mailbox");
@@ -80,7 +80,7 @@ contract LayerZeroV1Hook is AbstractPostDispatchHook, MailboxClient {
function _quoteDispatch(
bytes calldata metadata,
bytes calldata
- ) internal view virtual override returns (uint256 nativeFee) {
+ ) internal view override returns (uint256 nativeFee) {
bytes calldata lZMetadata = metadata.getCustomMetadata();
LayerZeroMetadata memory layerZeroMetadata = parseLzMetadata(
lZMetadata
diff --git a/solidity/contracts/hooks/layer-zero/LayerZeroV2Hook.sol b/solidity/contracts/hooks/layer-zero/LayerZeroV2Hook.sol
index 366cca3188..44058df471 100644
--- a/solidity/contracts/hooks/layer-zero/LayerZeroV2Hook.sol
+++ b/solidity/contracts/hooks/layer-zero/LayerZeroV2Hook.sol
@@ -85,7 +85,7 @@ contract LayerZeroV2Hook is AbstractMessageIdAuthHook {
function _quoteDispatch(
bytes calldata metadata,
bytes calldata message
- ) internal view virtual override returns (uint256) {
+ ) internal view override returns (uint256) {
bytes calldata lZMetadata = metadata.getCustomMetadata();
(uint32 eid, , bytes memory options) = parseLzMetadata(lZMetadata);
diff --git a/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol b/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol
index 70c8f54756..4d7ef2ea54 100644
--- a/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol
+++ b/solidity/contracts/hooks/libs/AbstractMessageIdAuthHook.sol
@@ -61,12 +61,13 @@ abstract contract AbstractMessageIdAuthHook is
/// @inheritdoc IPostDispatchHook
function hookType() external pure virtual returns (uint8) {
- return uint8(IPostDispatchHook.Types.ID_AUTH_ISM);
+ return uint8(IPostDispatchHook.HookTypes.ID_AUTH_ISM);
}
// ============ Internal functions ============
/// @inheritdoc AbstractPostDispatchHook
+ // solhint-disable-next-line hyperlane/no-virtual-override
function _postDispatch(
bytes calldata metadata,
bytes calldata message
diff --git a/solidity/contracts/hooks/libs/AbstractPostDispatchHook.sol b/solidity/contracts/hooks/libs/AbstractPostDispatchHook.sol
index 821fdaa47a..4dcc5c1b95 100644
--- a/solidity/contracts/hooks/libs/AbstractPostDispatchHook.sol
+++ b/solidity/contracts/hooks/libs/AbstractPostDispatchHook.sol
@@ -38,7 +38,7 @@ abstract contract AbstractPostDispatchHook is
/// @inheritdoc IPostDispatchHook
function supportsMetadata(
bytes calldata metadata
- ) public pure virtual override returns (bool) {
+ ) public pure virtual returns (bool) {
return
metadata.length == 0 ||
metadata.variant() == StandardHookMetadata.VARIANT;
diff --git a/solidity/contracts/hooks/routing/AmountRoutingHook.sol b/solidity/contracts/hooks/routing/AmountRoutingHook.sol
index 995f1a2540..eaef3a78ea 100644
--- a/solidity/contracts/hooks/routing/AmountRoutingHook.sol
+++ b/solidity/contracts/hooks/routing/AmountRoutingHook.sol
@@ -17,7 +17,7 @@ contract AmountRoutingHook is AmountPartition, AbstractPostDispatchHook {
) AmountPartition(_lowerHook, _upperHook, _threshold) {}
function hookType() external pure override returns (uint8) {
- return uint8(IPostDispatchHook.Types.AMOUNT_ROUTING);
+ return uint8(IPostDispatchHook.HookTypes.AMOUNT_ROUTING);
}
function _postDispatch(
diff --git a/solidity/contracts/hooks/routing/DomainRoutingHook.sol b/solidity/contracts/hooks/routing/DomainRoutingHook.sol
index 9e45ee4fc2..498f64bbac 100644
--- a/solidity/contracts/hooks/routing/DomainRoutingHook.sol
+++ b/solidity/contracts/hooks/routing/DomainRoutingHook.sol
@@ -44,8 +44,8 @@ contract DomainRoutingHook is AbstractPostDispatchHook, MailboxClient {
// ============ External Functions ============
/// @inheritdoc IPostDispatchHook
- function hookType() external pure virtual override returns (uint8) {
- return uint8(IPostDispatchHook.Types.ROUTING);
+ function hookType() external pure virtual returns (uint8) {
+ return uint8(IPostDispatchHook.HookTypes.ROUTING);
}
function setHook(uint32 _destination, address _hook) public onlyOwner {
@@ -60,7 +60,7 @@ contract DomainRoutingHook is AbstractPostDispatchHook, MailboxClient {
function supportsMetadata(
bytes calldata
- ) public pure virtual override returns (bool) {
+ ) public pure override returns (bool) {
// routing hook does not care about metadata shape
return true;
}
@@ -68,6 +68,7 @@ contract DomainRoutingHook is AbstractPostDispatchHook, MailboxClient {
// ============ Internal Functions ============
/// @inheritdoc AbstractPostDispatchHook
+ // solhint-disable-next-line hyperlane/no-virtual-override
function _postDispatch(
bytes calldata metadata,
bytes calldata message
@@ -82,7 +83,7 @@ contract DomainRoutingHook is AbstractPostDispatchHook, MailboxClient {
function _quoteDispatch(
bytes calldata metadata,
bytes calldata message
- ) internal view virtual override returns (uint256) {
+ ) internal view override returns (uint256) {
return _getConfiguredHook(message).quoteDispatch(metadata, message);
}
diff --git a/solidity/contracts/hooks/routing/FallbackDomainRoutingHook.sol b/solidity/contracts/hooks/routing/FallbackDomainRoutingHook.sol
index 100a5cb9aa..f52ad979da 100644
--- a/solidity/contracts/hooks/routing/FallbackDomainRoutingHook.sol
+++ b/solidity/contracts/hooks/routing/FallbackDomainRoutingHook.sol
@@ -39,7 +39,7 @@ contract FallbackDomainRoutingHook is DomainRoutingHook {
/// @inheritdoc IPostDispatchHook
function hookType() external pure override returns (uint8) {
- return uint8(IPostDispatchHook.Types.FALLBACK_ROUTING);
+ return uint8(IPostDispatchHook.HookTypes.FALLBACK_ROUTING);
}
// ============ Internal Functions ============
diff --git a/solidity/contracts/hooks/warp-route/RateLimitedHook.sol b/solidity/contracts/hooks/warp-route/RateLimitedHook.sol
index 22dd9c5b59..5423e878f4 100644
--- a/solidity/contracts/hooks/warp-route/RateLimitedHook.sol
+++ b/solidity/contracts/hooks/warp-route/RateLimitedHook.sol
@@ -14,12 +14,12 @@ pragma solidity >=0.8.0;
@@@@@@@@@ @@@@@@@@*/
// ============ Internal Imports ============
-import {MailboxClient} from "contracts/client/MailboxClient.sol";
-import {IPostDispatchHook} from "contracts/interfaces/hooks/IPostDispatchHook.sol";
-import {Message} from "contracts/libs/Message.sol";
-import {TokenMessage} from "contracts/token/libs/TokenMessage.sol";
-import {RateLimited} from "contracts/libs/RateLimited.sol";
-import {AbstractPostDispatchHook} from "../libs/AbstractPostDispatchHook.sol";
+import {MailboxClient} from "../../client/MailboxClient.sol";
+import {IPostDispatchHook} from "../../interfaces/hooks/IPostDispatchHook.sol";
+import {Message} from "../../libs/Message.sol";
+import {TokenMessage} from "../../token/libs/TokenMessage.sol";
+import {RateLimited} from "../../libs/RateLimited.sol";
+import {AbstractPostDispatchHook} from "../../hooks/libs/AbstractPostDispatchHook.sol";
/*
* @title RateLimitedHook
@@ -70,7 +70,7 @@ contract RateLimitedHook is
/// @inheritdoc IPostDispatchHook
function hookType() external pure returns (uint8) {
- return uint8(IPostDispatchHook.Types.RATE_LIMITED);
+ return uint8(IPostDispatchHook.HookTypes.RATE_LIMITED);
}
// ============ Internal Functions ============
diff --git a/solidity/contracts/interfaces/IEverclearAdapter.sol b/solidity/contracts/interfaces/IEverclearAdapter.sol
new file mode 100644
index 0000000000..a645517677
--- /dev/null
+++ b/solidity/contracts/interfaces/IEverclearAdapter.sol
@@ -0,0 +1,124 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+pragma solidity ^0.8.22;
+
+// Taken from https://github.com/everclearorg/monorepo/blob/7651d2aa1d4909b35b5cb0829dea47eee1c2595a/packages/contracts/src/interfaces/intent/IFeeAdapter.sol#L1
+interface IEverclear {
+ /**
+ * @notice The structure of an intent
+ * @param initiator The address of the intent initiator
+ * @param receiver The address of the intent receiver
+ * @param inputAsset The address of the intent asset on origin
+ * @param outputAsset The address of the intent asset on destination
+ * @param maxFee The maximum fee that can be taken by solvers
+ * @param origin The origin chain of the intent
+ * @param destinations The possible destination chains of the intent
+ * @param nonce The nonce of the intent
+ * @param timestamp The timestamp of the intent
+ * @param ttl The time to live of the intent
+ * @param amount The amount of the intent asset normalized to 18 decimals
+ * @param data The data of the intent
+ */
+ struct Intent {
+ bytes32 initiator;
+ bytes32 receiver;
+ bytes32 inputAsset;
+ bytes32 outputAsset;
+ uint24 maxFee;
+ uint32 origin;
+ uint64 nonce;
+ uint48 timestamp;
+ uint48 ttl;
+ uint256 amount;
+ uint32[] destinations;
+ bytes data;
+ }
+
+ enum IntentStatus {
+ NONE, // 0
+ ADDED, // 1
+ DEPOSIT_PROCESSED, // 2
+ FILLED, // 3
+ ADDED_AND_FILLED, // 4
+ INVOICED, // 5
+ SETTLED, // 6
+ SETTLED_AND_MANUALLY_EXECUTED, // 7
+ UNSUPPORTED, // 8
+ UNSUPPORTED_RETURNED // 9
+ }
+}
+interface IEverclearAdapter {
+ struct FeeParams {
+ uint256 fee;
+ uint256 deadline;
+ bytes sig;
+ }
+ /**
+ * @notice Emitted when a new intent is created with fees
+ * @param _intentId The ID of the created intent
+ * @param _initiator The address of the user who initiated the intent
+ * @param _tokenFee The amount of token fees paid
+ * @param _nativeFee The amount of native token fees paid
+ */
+ event IntentWithFeesAdded(
+ bytes32 indexed _intentId,
+ bytes32 indexed _initiator,
+ uint256 _tokenFee,
+ uint256 _nativeFee
+ );
+
+ /**
+ * @notice Creates a new intent with fees
+ * @param _destinations Array of destination domains, preference ordered
+ * @param _receiver Address of the receiver on the destination chain
+ * @param _inputAsset Address of the input asset
+ * @param _outputAsset Address of the output asset
+ * @param _amount Amount of input asset to use for the intent
+ * @param _maxFee Maximum fee percentage allowed for the intent
+ * @param _ttl Time-to-live for the intent in seconds
+ * @param _data Additional data for the intent
+ * @param _feeParams Fee parameters including fee amount, deadline, and signature
+ * @return _intentId The ID of the created intent
+ * @return _intent The created intent object
+ */
+ function newIntent(
+ uint32[] memory _destinations,
+ bytes32 _receiver,
+ address _inputAsset,
+ bytes32 _outputAsset,
+ uint256 _amount,
+ uint24 _maxFee,
+ uint48 _ttl,
+ bytes calldata _data,
+ FeeParams calldata _feeParams
+ ) external payable returns (bytes32, IEverclear.Intent memory);
+
+ /**
+ * @notice Returns the current fee signer address
+ * @return The address whos signature is verified
+ */
+ function feeSigner() external view returns (address);
+
+ /**
+ * @notice Updates the fee signer address
+ * @dev Can only be called by the owner of the contract
+ * @param _feeSigner The new address that will sign for fees
+ */
+ function updateFeeSigner(address _feeSigner) external;
+
+ function owner() external view returns (address);
+
+ function spoke() external view returns (IEverclearSpoke spoke);
+}
+
+interface IEverclearSpoke {
+ /**
+ * @notice returns the status of an intent
+ * @param _intentId The ID of the intent
+ * @return _status The status of the intent
+ */
+ function status(
+ bytes32 _intentId
+ ) external view returns (IEverclear.IntentStatus _status);
+
+ function executeIntentCalldata(IEverclear.Intent calldata _intent) external;
+}
diff --git a/solidity/contracts/interfaces/ILiquidityLayerMessageRecipient.sol b/solidity/contracts/interfaces/ILiquidityLayerMessageRecipient.sol
deleted file mode 100644
index 1fc03e334a..0000000000
--- a/solidity/contracts/interfaces/ILiquidityLayerMessageRecipient.sol
+++ /dev/null
@@ -1,12 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-pragma solidity ^0.8.13;
-
-interface ILiquidityLayerMessageRecipient {
- function handleWithTokens(
- uint32 _origin,
- bytes32 _sender,
- bytes calldata _message,
- address _token,
- uint256 _amount
- ) external;
-}
diff --git a/solidity/contracts/interfaces/ILiquidityLayerRouter.sol b/solidity/contracts/interfaces/ILiquidityLayerRouter.sol
deleted file mode 100644
index db9c2549d0..0000000000
--- a/solidity/contracts/interfaces/ILiquidityLayerRouter.sol
+++ /dev/null
@@ -1,13 +0,0 @@
-// SPDX-License-Identifier: MIT OR Apache-2.0
-pragma solidity >=0.6.11;
-
-interface ILiquidityLayerRouter {
- function dispatchWithTokens(
- uint32 _destinationDomain,
- bytes32 _recipientAddress,
- address _token,
- uint256 _amount,
- string calldata _bridge,
- bytes calldata _messageBody
- ) external returns (bytes32);
-}
diff --git a/solidity/contracts/interfaces/IMailbox.sol b/solidity/contracts/interfaces/IMailbox.sol
index 60d2e9a2b6..08a70102fa 100644
--- a/solidity/contracts/interfaces/IMailbox.sol
+++ b/solidity/contracts/interfaces/IMailbox.sol
@@ -56,6 +56,8 @@ interface IMailbox {
function latestDispatchedId() external view returns (bytes32);
+ function nonce() external view returns (uint32);
+
function dispatch(
uint32 destinationDomain,
bytes32 recipientAddress,
diff --git a/solidity/contracts/interfaces/ITokenBridge.sol b/solidity/contracts/interfaces/ITokenBridge.sol
index 6b5d8a43ce..34075dd267 100644
--- a/solidity/contracts/interfaces/ITokenBridge.sol
+++ b/solidity/contracts/interfaces/ITokenBridge.sol
@@ -6,31 +6,35 @@ struct Quote {
uint256 amount;
}
-interface ITokenBridge {
+interface ITokenFee {
/**
- * @notice Transfer value to another domain
+ * @notice Provide the value transfer quote
* @param _destination The destination domain of the message
* @param _recipient The message recipient address on `destination`
* @param _amount The amount to send to the recipient
- * @return messageId The identifier of the dispatched message.
+ * @return quotes Indicate how much of each token to approve and/or send.
+ * @dev Good practice is to use the first entry of the quotes for the native currency (i.e. ETH).
+ * @dev Good practice is to use the last entry of the quotes for the token to be transferred.
+ * @dev There should not be duplicate `token` addresses in the returned quotes.
*/
- function transferRemote(
+ function quoteTransferRemote(
uint32 _destination,
bytes32 _recipient,
uint256 _amount
- ) external payable returns (bytes32);
+ ) external view returns (Quote[] memory quotes);
+}
+interface ITokenBridge is ITokenFee {
/**
- * @notice Provide the value transfer quote
+ * @notice Transfer value to another domain
* @param _destination The destination domain of the message
* @param _recipient The message recipient address on `destination`
* @param _amount The amount to send to the recipient
- * @return quotes Indicate how much of each token to approve and/or send.
- * @dev Good practice is to use the first entry of the quotes for the native currency (i.e. ETH)
+ * @return messageId The identifier of the dispatched message.
*/
- function quoteTransferRemote(
+ function transferRemote(
uint32 _destination,
bytes32 _recipient,
uint256 _amount
- ) external view returns (Quote[] memory quotes);
+ ) external payable returns (bytes32);
}
diff --git a/solidity/contracts/interfaces/cctp/IMessageHandler.sol b/solidity/contracts/interfaces/cctp/IMessageHandler.sol
new file mode 100644
index 0000000000..b63002f581
--- /dev/null
+++ b/solidity/contracts/interfaces/cctp/IMessageHandler.sol
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2022, Circle Internet Financial Limited.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+pragma solidity >=0.8.0;
+
+// copied from https://github.com/circlefin/evm-cctp-contracts/blob/6e7513cdb2bee6bb0cddf331fe972600fc5017c9/src/interfaces/IMessageHandler.sol
+
+/**
+ * @title IMessageHandler
+ * @notice Handles messages on destination domain forwarded from
+ * an IReceiver
+ */
+interface IMessageHandler {
+ /**
+ * @notice handles an incoming message from a Receiver
+ * @param sourceDomain the source domain of the message
+ * @param sender the sender of the message
+ * @param messageBody The message raw bytes
+ * @return success bool, true if successful
+ */
+ function handleReceiveMessage(
+ uint32 sourceDomain,
+ bytes32 sender,
+ bytes calldata messageBody
+ ) external returns (bool);
+}
diff --git a/solidity/contracts/interfaces/cctp/IMessageHandlerV2.sol b/solidity/contracts/interfaces/cctp/IMessageHandlerV2.sol
new file mode 100644
index 0000000000..cce4c9c070
--- /dev/null
+++ b/solidity/contracts/interfaces/cctp/IMessageHandlerV2.sol
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2024 Circle Internet Group, Inc. All rights reserved.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+pragma solidity >=0.8.0;
+
+/**
+ * @title IMessageHandlerV2
+ * @notice Handles messages on the destination domain, forwarded from
+ * an IReceiverV2.
+ */
+interface IMessageHandlerV2 {
+ /**
+ * @notice Handles an incoming finalized message from an IReceiverV2
+ * @dev Finalized messages have finality threshold values greater than or equal to 2000
+ * @param sourceDomain The source domain of the message
+ * @param sender The sender of the message
+ * @param finalityThresholdExecuted the finality threshold at which the message was attested to
+ * @param messageBody The raw bytes of the message body
+ * @return success True, if successful; false, if not.
+ */
+ function handleReceiveFinalizedMessage(
+ uint32 sourceDomain,
+ bytes32 sender,
+ uint32 finalityThresholdExecuted,
+ bytes calldata messageBody
+ ) external returns (bool);
+
+ /**
+ * @notice Handles an incoming unfinalized message from an IReceiverV2
+ * @dev Unfinalized messages have finality threshold values less than 2000
+ * @param sourceDomain The source domain of the message
+ * @param sender The sender of the message
+ * @param finalityThresholdExecuted The finality threshold at which the message was attested to
+ * @param messageBody The raw bytes of the message body
+ * @return success True, if successful; false, if not.
+ */
+ function handleReceiveUnfinalizedMessage(
+ uint32 sourceDomain,
+ bytes32 sender,
+ uint32 finalityThresholdExecuted,
+ bytes calldata messageBody
+ ) external returns (bool);
+}
diff --git a/solidity/contracts/interfaces/cctp/IMessageTransmitter.sol b/solidity/contracts/interfaces/cctp/IMessageTransmitter.sol
index 7ace54d455..6522c7ed44 100644
--- a/solidity/contracts/interfaces/cctp/IMessageTransmitter.sol
+++ b/solidity/contracts/interfaces/cctp/IMessageTransmitter.sol
@@ -55,4 +55,8 @@ interface IMessageTransmitter is IRelayer, IReceiver {
function version() external view returns (uint32);
function localDomain() external view returns (uint32);
+
+ function nextAvailableNonce() external view returns (uint64);
+
+ function signatureThreshold() external view returns (uint256);
}
diff --git a/solidity/contracts/interfaces/cctp/IMessageTransmitterV2.sol b/solidity/contracts/interfaces/cctp/IMessageTransmitterV2.sol
new file mode 100644
index 0000000000..59205a8594
--- /dev/null
+++ b/solidity/contracts/interfaces/cctp/IMessageTransmitterV2.sol
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2024 Circle Internet Group, Inc. All rights reserved.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+pragma solidity >=0.8.0;
+
+import {IMessageTransmitter} from "./IMessageTransmitter.sol";
+import {IReceiver} from "./IMessageTransmitter.sol";
+
+/**
+ * @title IReceiverV2
+ * @notice Receives messages on the destination chain and forwards them to contracts implementing
+ * IMessageHandlerV2.
+ */
+interface IReceiverV2 is IReceiver {}
+
+/**
+ * @title IRelayerV2
+ * @notice Sends messages from the source domain to the destination domain
+ */
+interface IRelayerV2 {
+ /**
+ * @notice Sends an outgoing message from the source domain.
+ * @dev Emits a `MessageSent` event with message information.
+ * WARNING: if the `destinationCaller` does not represent a valid address as bytes32, then it will not be possible
+ * to broadcast the message on the destination domain. If set to bytes32(0), anyone will be able to broadcast it.
+ * This is an advanced feature, and using bytes32(0) should be preferred for use cases where a specific destination caller is not required.
+ * @param destinationDomain Domain of destination chain
+ * @param recipient Address of message recipient on destination domain as bytes32
+ * @param destinationCaller Allowed caller on destination domain (see above WARNING).
+ * @param minFinalityThreshold Minimum finality threshold at which the message must be attested to.
+ * @param messageBody Content of the message, as raw bytes
+ */
+ function sendMessage(
+ uint32 destinationDomain,
+ bytes32 recipient,
+ bytes32 destinationCaller,
+ uint32 minFinalityThreshold,
+ bytes calldata messageBody
+ ) external;
+}
+
+/**
+ * @title IMessageTransmitterV2
+ * @notice Interface for V2 message transmitters, which both relay and receive messages.
+ */
+interface IMessageTransmitterV2 is
+ IRelayerV2,
+ IReceiverV2,
+ IMessageTransmitter
+{}
diff --git a/solidity/contracts/interfaces/cctp/ITokenMessenger.sol b/solidity/contracts/interfaces/cctp/ITokenMessenger.sol
index 7a1ff729a9..e6815731c5 100644
--- a/solidity/contracts/interfaces/cctp/ITokenMessenger.sol
+++ b/solidity/contracts/interfaces/cctp/ITokenMessenger.sol
@@ -2,6 +2,10 @@
pragma solidity ^0.8.0;
interface ITokenMessenger {
+ function messageBodyVersion() external returns (uint32);
+}
+
+interface ITokenMessengerV1 is ITokenMessenger {
event DepositForBurn(
uint64 indexed nonce,
address indexed burnToken,
@@ -19,6 +23,4 @@ interface ITokenMessenger {
bytes32 mintRecipient,
address burnToken
) external returns (uint64 _nonce);
-
- function messageBodyVersion() external returns (uint32);
}
diff --git a/solidity/contracts/interfaces/cctp/ITokenMessengerV2.sol b/solidity/contracts/interfaces/cctp/ITokenMessengerV2.sol
index c5985dd5e2..ab2348f9c8 100644
--- a/solidity/contracts/interfaces/cctp/ITokenMessengerV2.sol
+++ b/solidity/contracts/interfaces/cctp/ITokenMessengerV2.sol
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
-interface ITokenMessengerV2 {
+import {ITokenMessenger} from "./ITokenMessenger.sol";
+
+interface ITokenMessengerV2 is ITokenMessenger {
event DepositForBurn(
address indexed burnToken,
uint256 amount,
@@ -24,6 +26,4 @@ interface ITokenMessengerV2 {
uint256 maxFee,
uint32 minFinalityThreshold
) external;
-
- function messageBodyVersion() external returns (uint32);
}
diff --git a/solidity/contracts/interfaces/hooks/IPostDispatchHook.sol b/solidity/contracts/interfaces/hooks/IPostDispatchHook.sol
index 3a44d72fd2..68d22690a6 100644
--- a/solidity/contracts/interfaces/hooks/IPostDispatchHook.sol
+++ b/solidity/contracts/interfaces/hooks/IPostDispatchHook.sol
@@ -14,7 +14,7 @@ pragma solidity >=0.8.0;
@@@@@@@@@ @@@@@@@@*/
interface IPostDispatchHook {
- enum Types {
+ enum HookTypes {
UNUSED,
ROUTING,
AGGREGATION,
diff --git a/solidity/contracts/isms/NoopIsm.sol b/solidity/contracts/isms/NoopIsm.sol
index 30ac76864e..2ab27803d4 100644
--- a/solidity/contracts/isms/NoopIsm.sol
+++ b/solidity/contracts/isms/NoopIsm.sol
@@ -2,7 +2,7 @@
pragma solidity >=0.8.0;
import {IInterchainSecurityModule} from "../interfaces/IInterchainSecurityModule.sol";
-import {PackageVersioned} from "contracts/PackageVersioned.sol";
+import {PackageVersioned} from "../PackageVersioned.sol";
contract NoopIsm is IInterchainSecurityModule, PackageVersioned {
uint8 public constant override moduleType = uint8(Types.NULL);
diff --git a/solidity/contracts/isms/PausableIsm.sol b/solidity/contracts/isms/PausableIsm.sol
index 00868285e6..2e1613521b 100644
--- a/solidity/contracts/isms/PausableIsm.sol
+++ b/solidity/contracts/isms/PausableIsm.sol
@@ -7,7 +7,7 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
// ============ Internal Imports ============
import {IInterchainSecurityModule} from "../interfaces/IInterchainSecurityModule.sol";
-import {PackageVersioned} from "contracts/PackageVersioned.sol";
+import {PackageVersioned} from "../PackageVersioned.sol";
contract PausableIsm is
IInterchainSecurityModule,
diff --git a/solidity/contracts/isms/TrustedRelayerIsm.sol b/solidity/contracts/isms/TrustedRelayerIsm.sol
index 87da1bb60f..44db65e8c0 100644
--- a/solidity/contracts/isms/TrustedRelayerIsm.sol
+++ b/solidity/contracts/isms/TrustedRelayerIsm.sol
@@ -6,7 +6,7 @@ import {IInterchainSecurityModule} from "../interfaces/IInterchainSecurityModule
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {Message} from "../libs/Message.sol";
import {Mailbox} from "../Mailbox.sol";
-import {PackageVersioned} from "contracts/PackageVersioned.sol";
+import {PackageVersioned} from "../PackageVersioned.sol";
contract TrustedRelayerIsm is IInterchainSecurityModule, PackageVersioned {
using Message for bytes;
diff --git a/solidity/contracts/isms/aggregation/StaticAggregationIsm.sol b/solidity/contracts/isms/aggregation/StaticAggregationIsm.sol
index 97390b632b..75e7caa159 100644
--- a/solidity/contracts/isms/aggregation/StaticAggregationIsm.sol
+++ b/solidity/contracts/isms/aggregation/StaticAggregationIsm.sol
@@ -22,7 +22,7 @@ contract StaticAggregationIsm is AbstractAggregationIsm {
*/
function modulesAndThreshold(
bytes calldata
- ) public view virtual override returns (address[] memory, uint8) {
+ ) public view override returns (address[] memory, uint8) {
return abi.decode(MetaProxy.metadata(), (address[], uint8));
}
}
diff --git a/solidity/contracts/isms/aggregation/StaticAggregationIsmFactory.sol b/solidity/contracts/isms/aggregation/StaticAggregationIsmFactory.sol
index 8fa18fa653..b316558c38 100644
--- a/solidity/contracts/isms/aggregation/StaticAggregationIsmFactory.sol
+++ b/solidity/contracts/isms/aggregation/StaticAggregationIsmFactory.sol
@@ -6,12 +6,7 @@ import {StaticAggregationIsm} from "./StaticAggregationIsm.sol";
import {StaticThresholdAddressSetFactory} from "../../libs/StaticAddressSetFactory.sol";
contract StaticAggregationIsmFactory is StaticThresholdAddressSetFactory {
- function _deployImplementation()
- internal
- virtual
- override
- returns (address)
- {
+ function _deployImplementation() internal override returns (address) {
return address(new StaticAggregationIsm());
}
}
diff --git a/solidity/contracts/isms/hook/AbstractMessageIdAuthorizedIsm.sol b/solidity/contracts/isms/hook/AbstractMessageIdAuthorizedIsm.sol
index f9232ac334..0945c6c603 100644
--- a/solidity/contracts/isms/hook/AbstractMessageIdAuthorizedIsm.sol
+++ b/solidity/contracts/isms/hook/AbstractMessageIdAuthorizedIsm.sol
@@ -18,7 +18,7 @@ pragma solidity >=0.8.0;
import {IInterchainSecurityModule} from "../../interfaces/IInterchainSecurityModule.sol";
import {LibBit} from "../../libs/LibBit.sol";
import {Message} from "../../libs/Message.sol";
-import {PackageVersioned} from "contracts/PackageVersioned.sol";
+import {PackageVersioned} from "../../PackageVersioned.sol";
// ============ External Imports ============
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
diff --git a/solidity/contracts/isms/multisig/AbstractMerkleRootMultisigIsm.sol b/solidity/contracts/isms/multisig/AbstractMerkleRootMultisigIsm.sol
index caeeb355fe..5047f15ac1 100644
--- a/solidity/contracts/isms/multisig/AbstractMerkleRootMultisigIsm.sol
+++ b/solidity/contracts/isms/multisig/AbstractMerkleRootMultisigIsm.sol
@@ -33,7 +33,7 @@ abstract contract AbstractMerkleRootMultisigIsm is AbstractMultisig {
function digest(
bytes calldata _metadata,
bytes calldata _message
- ) internal pure virtual override returns (bytes32) {
+ ) internal pure override returns (bytes32) {
require(
_metadata.messageIndex() <= _metadata.signedIndex(),
"Invalid merkle index metadata"
@@ -61,7 +61,7 @@ abstract contract AbstractMerkleRootMultisigIsm is AbstractMultisig {
function signatureAt(
bytes calldata _metadata,
uint256 _index
- ) internal pure virtual override returns (bytes calldata) {
+ ) internal pure override returns (bytes calldata) {
return _metadata.signatureAt(_index);
}
diff --git a/solidity/contracts/isms/multisig/AbstractMessageIdMultisigIsm.sol b/solidity/contracts/isms/multisig/AbstractMessageIdMultisigIsm.sol
index d05a5abdee..55bf7116fd 100644
--- a/solidity/contracts/isms/multisig/AbstractMessageIdMultisigIsm.sol
+++ b/solidity/contracts/isms/multisig/AbstractMessageIdMultisigIsm.sol
@@ -46,7 +46,7 @@ abstract contract AbstractMessageIdMultisigIsm is AbstractMultisig {
function signatureAt(
bytes calldata _metadata,
uint256 _index
- ) internal pure virtual override returns (bytes calldata) {
+ ) internal pure override returns (bytes calldata) {
return _metadata.signatureAt(_index);
}
diff --git a/solidity/contracts/isms/warp-route/RateLimitedIsm.sol b/solidity/contracts/isms/warp-route/RateLimitedIsm.sol
index ef462f6aa7..45db853bce 100644
--- a/solidity/contracts/isms/warp-route/RateLimitedIsm.sol
+++ b/solidity/contracts/isms/warp-route/RateLimitedIsm.sol
@@ -3,10 +3,10 @@ pragma solidity >=0.8.0;
// ============ Internal Imports ============
import {IInterchainSecurityModule} from "../../interfaces/IInterchainSecurityModule.sol";
-import {MailboxClient} from "contracts/client/MailboxClient.sol";
-import {RateLimited} from "contracts/libs/RateLimited.sol";
-import {Message} from "contracts/libs/Message.sol";
-import {TokenMessage} from "contracts/token/libs/TokenMessage.sol";
+import {MailboxClient} from "../../client/MailboxClient.sol";
+import {RateLimited} from "../../libs/RateLimited.sol";
+import {Message} from "../../libs/Message.sol";
+import {TokenMessage} from "../../token/libs/TokenMessage.sol";
contract RateLimitedIsm is
MailboxClient,
diff --git a/solidity/contracts/libs/CctpMessage.sol b/solidity/contracts/libs/CctpMessageV1.sol
similarity index 99%
rename from solidity/contracts/libs/CctpMessage.sol
rename to solidity/contracts/libs/CctpMessageV1.sol
index 8841e6ac65..3740f3b5af 100644
--- a/solidity/contracts/libs/CctpMessage.sol
+++ b/solidity/contracts/libs/CctpMessageV1.sol
@@ -40,7 +40,7 @@ import {TypedMemView} from "./TypedMemView.sol";
* messageBody dynamic bytes 116
*
**/
-library CctpMessage {
+library CctpMessageV1 {
using TypedMemView for bytes;
using TypedMemView for bytes29;
@@ -182,7 +182,7 @@ library CctpMessage {
* amount 32 uint256 68
* messageSender 32 bytes32 100
**/
-library BurnMessage {
+library BurnMessageV1 {
using TypedMemView for bytes;
using TypedMemView for bytes29;
diff --git a/solidity/contracts/libs/CctpMessageV2.sol b/solidity/contracts/libs/CctpMessageV2.sol
index f653ff0467..6e9c64d747 100644
--- a/solidity/contracts/libs/CctpMessageV2.sol
+++ b/solidity/contracts/libs/CctpMessageV2.sol
@@ -20,17 +20,301 @@ pragma solidity >=0.8.0;
import {TypedMemView} from "./TypedMemView.sol";
// @dev copied from https://github.com/circlefin/evm-cctp-contracts/blob/release-2025-03-11T143015/src/messages/v2/MessageV2.sol
-// @dev We need only source domain and nonce which have the same indexes of Cctp message version 1
// @dev We are using the 'latest-solidity' branch for @memview-sol, which supports solidity version
// greater or equal than 0.8.0
+
+/**
+ * @title MessageV2 Library
+ * @notice Library for formatted v2 messages used by Relayer and Receiver.
+ *
+ * @dev The message body is dynamically-sized to support custom message body
+ * formats. Other fields must be fixed-size to avoid hash collisions.
+ * Each other input value has an explicit type to guarantee fixed-size.
+ * Padding: uintNN fields are left-padded, and bytesNN fields are right-padded.
+ *
+ * Field Bytes Type Index
+ * version 4 uint32 0
+ * sourceDomain 4 uint32 4
+ * destinationDomain 4 uint32 8
+ * nonce 32 bytes32 12
+ * sender 32 bytes32 44
+ * recipient 32 bytes32 76
+ * destinationCaller 32 bytes32 108
+ * minFinalityThreshold 4 uint32 140
+ * finalityThresholdExecuted 4 uint32 144
+ * messageBody dynamic bytes 148
+ * @dev Differences from v1:
+ * - Nonce is now bytes32 (vs. uint64)
+ * - minFinalityThreshold added
+ * - finalityThresholdExecuted added
+ **/
library CctpMessageV2 {
using TypedMemView for bytes;
using TypedMemView for bytes29;
// Indices of each field in message
+ uint8 private constant VERSION_INDEX = 0;
+ uint8 private constant SOURCE_DOMAIN_INDEX = 4;
+ uint8 private constant DESTINATION_DOMAIN_INDEX = 8;
uint8 private constant NONCE_INDEX = 12;
+ uint8 private constant SENDER_INDEX = 44;
+ uint8 private constant RECIPIENT_INDEX = 76;
+ uint8 private constant DESTINATION_CALLER_INDEX = 108;
+ uint8 private constant MIN_FINALITY_THRESHOLD_INDEX = 140;
+ uint8 private constant FINALITY_THRESHOLD_EXECUTED_INDEX = 144;
+ uint8 private constant MESSAGE_BODY_INDEX = 148;
+
+ bytes32 private constant EMPTY_NONCE = bytes32(0);
+ uint32 private constant EMPTY_FINALITY_THRESHOLD_EXECUTED = 0;
+
+ /**
+ * @notice Returns formatted (packed) message with provided fields
+ * @param _version the version of the message format
+ * @param _sourceDomain Domain of home chain
+ * @param _destinationDomain Domain of destination chain
+ * @param _sender Address of sender on source chain as bytes32
+ * @param _recipient Address of recipient on destination chain as bytes32
+ * @param _destinationCaller Address of caller on destination chain as bytes32
+ * @param _minFinalityThreshold the minimum finality at which the message should be attested to
+ * @param _messageBody Raw bytes of message body
+ * @return Formatted message
+ **/
+ function _formatMessageForRelay(
+ uint32 _version,
+ uint32 _sourceDomain,
+ uint32 _destinationDomain,
+ bytes32 _sender,
+ bytes32 _recipient,
+ bytes32 _destinationCaller,
+ uint32 _minFinalityThreshold,
+ bytes memory _messageBody
+ ) internal pure returns (bytes memory) {
+ return
+ abi.encodePacked(
+ _version,
+ _sourceDomain,
+ _destinationDomain,
+ EMPTY_NONCE,
+ _sender,
+ _recipient,
+ _destinationCaller,
+ _minFinalityThreshold,
+ EMPTY_FINALITY_THRESHOLD_EXECUTED,
+ _messageBody
+ );
+ }
+
+ // @notice Returns _message's version field
+ function _getVersion(bytes29 _message) internal pure returns (uint32) {
+ return uint32(_message.indexUint(VERSION_INDEX, 4));
+ }
+
+ // @notice Returns _message's sourceDomain field
+ function _getSourceDomain(bytes29 _message) internal pure returns (uint32) {
+ return uint32(_message.indexUint(SOURCE_DOMAIN_INDEX, 4));
+ }
+ // @notice Returns _message's destinationDomain field
+ function _getDestinationDomain(
+ bytes29 _message
+ ) internal pure returns (uint32) {
+ return uint32(_message.indexUint(DESTINATION_DOMAIN_INDEX, 4));
+ }
+
+ // @notice Returns _message's nonce field
function _getNonce(bytes29 _message) internal pure returns (bytes32) {
return _message.index(NONCE_INDEX, 32);
}
+
+ // @notice Returns _message's sender field
+ function _getSender(bytes29 _message) internal pure returns (bytes32) {
+ return _message.index(SENDER_INDEX, 32);
+ }
+
+ // @notice Returns _message's recipient field
+ function _getRecipient(bytes29 _message) internal pure returns (bytes32) {
+ return _message.index(RECIPIENT_INDEX, 32);
+ }
+
+ // @notice Returns _message's destinationCaller field
+ function _getDestinationCaller(
+ bytes29 _message
+ ) internal pure returns (bytes32) {
+ return _message.index(DESTINATION_CALLER_INDEX, 32);
+ }
+
+ // @notice Returns _message's minFinalityThreshold field
+ function _getMinFinalityThreshold(
+ bytes29 _message
+ ) internal pure returns (uint32) {
+ return uint32(_message.indexUint(MIN_FINALITY_THRESHOLD_INDEX, 4));
+ }
+
+ // @notice Returns _message's finalityThresholdExecuted field
+ function _getFinalityThresholdExecuted(
+ bytes29 _message
+ ) internal pure returns (uint32) {
+ return uint32(_message.indexUint(FINALITY_THRESHOLD_EXECUTED_INDEX, 4));
+ }
+
+ // @notice Returns _message's messageBody field
+ function _getMessageBody(bytes29 _message) internal pure returns (bytes29) {
+ return
+ _message.slice(
+ MESSAGE_BODY_INDEX,
+ _message.len() - MESSAGE_BODY_INDEX,
+ 0
+ );
+ }
+
+ /**
+ * @notice Reverts if message is malformed or too short
+ * @param _message The message as bytes29
+ */
+ function _validateMessageFormat(bytes29 _message) internal pure {
+ require(_message.isValid(), "Malformed message");
+ require(
+ _message.len() >= MESSAGE_BODY_INDEX,
+ "Invalid message: too short"
+ );
+ }
+}
+
+import {BurnMessageV1} from "./CctpMessageV1.sol";
+
+/**
+ * @title BurnMessageV2 Library
+ * @notice Library for formatted V2 BurnMessages used by TokenMessengerV2.
+ * @dev BurnMessageV2 format:
+ * Field Bytes Type Index
+ * version 4 uint32 0
+ * burnToken 32 bytes32 4
+ * mintRecipient 32 bytes32 36
+ * amount 32 uint256 68
+ * messageSender 32 bytes32 100
+ * maxFee 32 uint256 132
+ * feeExecuted 32 uint256 164
+ * expirationBlock 32 uint256 196
+ * hookData dynamic bytes 228
+ * @dev Additions from v1:
+ * - maxFee
+ * - feeExecuted
+ * - expirationBlock
+ * - hookData
+ **/
+library BurnMessageV2 {
+ using TypedMemView for bytes;
+ using TypedMemView for bytes29;
+ using BurnMessageV1 for bytes29;
+
+ // Field indices
+ uint8 private constant MAX_FEE_INDEX = 132;
+ uint8 private constant FEE_EXECUTED_INDEX = 164;
+ uint8 private constant EXPIRATION_BLOCK_INDEX = 196;
+ uint8 private constant HOOK_DATA_INDEX = 228;
+
+ uint256 private constant EMPTY_FEE_EXECUTED = 0;
+ uint256 private constant EMPTY_EXPIRATION_BLOCK = 0;
+
+ /**
+ * @notice Formats a V2 burn message
+ * @param _version The message body version
+ * @param _burnToken The burn token address on the source domain, as bytes32
+ * @param _mintRecipient The mint recipient address as bytes32
+ * @param _amount The burn amount
+ * @param _messageSender The message sender
+ * @param _maxFee The maximum fee to be paid on destination domain
+ * @param _hookData Optional hook data for processing on the destination domain
+ * @return Formatted message bytes.
+ */
+ function _formatMessageForRelay(
+ uint32 _version,
+ bytes32 _burnToken,
+ bytes32 _mintRecipient,
+ uint256 _amount,
+ bytes32 _messageSender,
+ uint256 _maxFee,
+ bytes memory _hookData
+ ) internal pure returns (bytes memory) {
+ return
+ abi.encodePacked(
+ _version,
+ _burnToken,
+ _mintRecipient,
+ _amount,
+ _messageSender,
+ _maxFee,
+ EMPTY_FEE_EXECUTED,
+ EMPTY_EXPIRATION_BLOCK,
+ _hookData
+ );
+ }
+
+ // @notice Returns _message's version field
+ function _getVersion(bytes29 _message) internal pure returns (uint32) {
+ return _message._getVersion();
+ }
+
+ // @notice Returns _message's burnToken field
+ function _getBurnToken(bytes29 _message) internal pure returns (bytes32) {
+ return _message._getBurnToken();
+ }
+
+ // @notice Returns _message's mintRecipient field
+ function _getMintRecipient(
+ bytes29 _message
+ ) internal pure returns (bytes32) {
+ return _message._getMintRecipient();
+ }
+
+ // @notice Returns _message's amount field
+ function _getAmount(bytes29 _message) internal pure returns (uint256) {
+ return _message._getAmount();
+ }
+
+ // @notice Returns _message's messageSender field
+ function _getMessageSender(
+ bytes29 _message
+ ) internal pure returns (bytes32) {
+ return _message._getMessageSender();
+ }
+
+ // @notice Returns _message's maxFee field
+ function _getMaxFee(bytes29 _message) internal pure returns (uint256) {
+ return _message.indexUint(MAX_FEE_INDEX, 32);
+ }
+
+ // @notice Returns _message's feeExecuted field
+ function _getFeeExecuted(bytes29 _message) internal pure returns (uint256) {
+ return _message.indexUint(FEE_EXECUTED_INDEX, 32);
+ }
+
+ // @notice Returns _message's expirationBlock field
+ function _getExpirationBlock(
+ bytes29 _message
+ ) internal pure returns (uint256) {
+ return _message.indexUint(EXPIRATION_BLOCK_INDEX, 32);
+ }
+
+ // @notice Returns _message's hookData field
+ function _getHookData(bytes29 _message) internal pure returns (bytes29) {
+ return
+ _message.slice(
+ HOOK_DATA_INDEX,
+ _message.len() - HOOK_DATA_INDEX,
+ 0
+ );
+ }
+
+ /**
+ * @notice Reverts if burn message is malformed or invalid length
+ * @param _message The burn message as bytes29
+ */
+ function _validateBurnMessageFormat(bytes29 _message) internal pure {
+ require(_message.isValid(), "Malformed message");
+ require(
+ _message.len() >= HOOK_DATA_INDEX,
+ "Invalid burn message: too short"
+ );
+ }
}
diff --git a/solidity/contracts/middleware/InterchainQueryRouter.sol b/solidity/contracts/middleware/InterchainQueryRouter.sol
index d212e38680..4fe06d82cd 100644
--- a/solidity/contracts/middleware/InterchainQueryRouter.sol
+++ b/solidity/contracts/middleware/InterchainQueryRouter.sol
@@ -69,11 +69,12 @@ contract InterchainQueryRouter is Router {
address _to,
bytes memory _data,
bytes memory _callback
- ) public returns (bytes32 messageId) {
+ ) public payable returns (bytes32 messageId) {
emit QueryDispatched(_destination, msg.sender);
- messageId = _dispatch(
+ messageId = _Router_dispatch(
_destination,
+ msg.value,
InterchainQueryMessage.encode(
msg.sender.addressToBytes32(),
_to,
@@ -93,10 +94,11 @@ contract InterchainQueryRouter is Router {
function query(
uint32 _destination,
CallLib.StaticCallWithCallback[] calldata calls
- ) public returns (bytes32 messageId) {
+ ) public payable returns (bytes32 messageId) {
emit QueryDispatched(_destination, msg.sender);
- messageId = _dispatch(
+ messageId = _Router_dispatch(
_destination,
+ msg.value,
InterchainQueryMessage.encode(msg.sender.addressToBytes32(), calls)
);
}
@@ -121,8 +123,9 @@ contract InterchainQueryRouter is Router {
callsWithCallback
);
emit QueryExecuted(_origin, sender);
- _dispatch(
+ _Router_dispatch(
_origin,
+ msg.value,
InterchainQueryMessage.encode(sender, callbacks)
);
} else if (messageType == InterchainQueryMessage.MessageType.RESPONSE) {
diff --git a/solidity/contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol b/solidity/contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol
deleted file mode 100644
index 9b6271006a..0000000000
--- a/solidity/contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol
+++ /dev/null
@@ -1,138 +0,0 @@
-// SPDX-License-Identifier: MIT OR Apache-2.0
-pragma solidity >=0.8.0;
-
-import {Router} from "../../client/Router.sol";
-
-import {TypeCasts} from "../../libs/TypeCasts.sol";
-
-import {ILiquidityLayerRouter} from "../../interfaces/ILiquidityLayerRouter.sol";
-import {ILiquidityLayerAdapter} from "./interfaces/ILiquidityLayerAdapter.sol";
-import {ILiquidityLayerMessageRecipient} from "../../interfaces/ILiquidityLayerMessageRecipient.sol";
-
-import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
-import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
-
-contract LiquidityLayerRouter is Router, ILiquidityLayerRouter {
- using SafeERC20 for IERC20;
-
- // Token bridge => adapter address
- mapping(string bridge => address adapter) public liquidityLayerAdapters;
-
- event LiquidityLayerAdapterSet(string indexed bridge, address adapter);
-
- constructor(address _mailbox) Router(_mailbox) {}
-
- /**
- * @notice Initializes the Router contract with Hyperlane core contracts and the address of the interchain security module.
- * @param _interchainGasPaymaster The address of the interchain gas paymaster contract.
- * @param _interchainSecurityModule The address of the interchain security module contract.
- * @param _owner The address with owner privileges.
- */
- function initialize(
- address _interchainGasPaymaster,
- address _interchainSecurityModule,
- address _owner
- ) external initializer {
- _MailboxClient_initialize(
- _interchainGasPaymaster,
- _interchainSecurityModule,
- _owner
- );
- }
-
- function dispatchWithTokens(
- uint32 _destinationDomain,
- bytes32 _recipientAddress,
- address _token,
- uint256 _amount,
- string calldata _bridge,
- bytes calldata _messageBody
- ) external returns (bytes32) {
- ILiquidityLayerAdapter _adapter = _getAdapter(_bridge);
-
- // Transfer the tokens to the adapter
- IERC20(_token).safeTransferFrom(msg.sender, address(_adapter), _amount);
-
- // Reverts if the bridge was unsuccessful.
- // Gets adapter-specific data that is encoded into the message
- // ultimately sent via Hyperlane.
- bytes memory _adapterData = _adapter.sendTokens(
- _destinationDomain,
- _recipientAddress,
- _token,
- _amount
- );
-
- // The user's message "wrapped" with metadata required by this middleware
- bytes memory _messageWithMetadata = abi.encode(
- TypeCasts.addressToBytes32(msg.sender),
- _recipientAddress, // The "user" recipient
- _amount, // The amount of the tokens sent over the bridge
- _bridge, // The destination token bridge ID
- _adapterData, // The adapter-specific data
- _messageBody // The "user" message
- );
-
- // Dispatch the _messageWithMetadata to the destination's LiquidityLayerRouter.
- return _dispatch(_destinationDomain, _messageWithMetadata);
- }
-
- // Handles a message from an enrolled remote LiquidityLayerRouter
- function _handle(
- uint32 _origin,
- bytes32, // _sender, unused
- bytes calldata _message
- ) internal override {
- // Decode the message with metadata, "unwrapping" the user's message body
- (
- bytes32 _originalSender,
- bytes32 _userRecipientAddress,
- uint256 _amount,
- string memory _bridge,
- bytes memory _adapterData,
- bytes memory _userMessageBody
- ) = abi.decode(
- _message,
- (bytes32, bytes32, uint256, string, bytes, bytes)
- );
-
- ILiquidityLayerMessageRecipient _userRecipient = ILiquidityLayerMessageRecipient(
- TypeCasts.bytes32ToAddress(_userRecipientAddress)
- );
-
- // Reverts if the adapter hasn't received the bridged tokens yet
- (address _token, uint256 _receivedAmount) = _getAdapter(_bridge)
- .receiveTokens(
- _origin,
- address(_userRecipient),
- _amount,
- _adapterData
- );
-
- if (_userMessageBody.length > 0) {
- _userRecipient.handleWithTokens(
- _origin,
- _originalSender,
- _userMessageBody,
- _token,
- _receivedAmount
- );
- }
- }
-
- function setLiquidityLayerAdapter(
- string calldata _bridge,
- address _adapter
- ) external onlyOwner {
- liquidityLayerAdapters[_bridge] = _adapter;
- emit LiquidityLayerAdapterSet(_bridge, _adapter);
- }
-
- function _getAdapter(
- string memory _bridge
- ) internal view returns (ILiquidityLayerAdapter _adapter) {
- _adapter = ILiquidityLayerAdapter(liquidityLayerAdapters[_bridge]);
- // Require the adapter to have been set
- require(address(_adapter) != address(0), "No adapter found for bridge");
- }
-}
diff --git a/solidity/contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol b/solidity/contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol
deleted file mode 100644
index fa3ebfe9d5..0000000000
--- a/solidity/contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol
+++ /dev/null
@@ -1,242 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-pragma solidity ^0.8.13;
-
-import {Router} from "../../../client/Router.sol";
-
-import {ITokenMessenger} from "../interfaces/circle/ITokenMessenger.sol";
-import {ICircleMessageTransmitter} from "../interfaces/circle/ICircleMessageTransmitter.sol";
-import {ILiquidityLayerAdapter} from "../interfaces/ILiquidityLayerAdapter.sol";
-
-import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
-import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
-
-contract CircleBridgeAdapter is ILiquidityLayerAdapter, Router {
- using SafeERC20 for IERC20;
-
- /// @notice The TokenMessenger contract.
- ITokenMessenger public tokenMessenger;
-
- /// @notice The Circle MessageTransmitter contract.
- ICircleMessageTransmitter public circleMessageTransmitter;
-
- /// @notice The LiquidityLayerRouter contract.
- address public liquidityLayerRouter;
-
- /// @notice Hyperlane domain => Circle domain.
- /// ATM, known Circle domains are Ethereum = 0 and Avalanche = 1.
- /// Note this could result in ambiguity between the Circle domain being
- /// Ethereum or unknown.
- mapping(uint32 hyperlaneDomain => uint32 circleDomain)
- public hyperlaneDomainToCircleDomain;
-
- /// @notice Token symbol => address of token on local chain.
- mapping(string tokenSymbol => IERC20 token) public tokenSymbolToAddress;
-
- /// @notice Local chain token address => token symbol.
- mapping(address token => string tokenSymbol) public tokenAddressToSymbol;
-
- /**
- * @notice Emits the nonce of the Circle message when a token is bridged.
- * @param nonce The nonce of the Circle message.
- */
- event BridgedToken(uint64 nonce);
-
- /**
- * @notice Emitted when the Hyperlane domain to Circle domain mapping is updated.
- * @param hyperlaneDomain The Hyperlane domain.
- * @param circleDomain The Circle domain.
- */
- event DomainAdded(uint32 indexed hyperlaneDomain, uint32 circleDomain);
-
- /**
- * @notice Emitted when a local token and its token symbol have been added.
- */
- event TokenAdded(address indexed token, string indexed symbol);
-
- /**
- * @notice Emitted when a local token and its token symbol have been removed.
- */
- event TokenRemoved(address indexed token, string indexed symbol);
-
- modifier onlyLiquidityLayerRouter() {
- require(msg.sender == liquidityLayerRouter, "!liquidityLayerRouter");
- _;
- }
-
- constructor(address _mailbox) Router(_mailbox) {}
-
- /**
- * @param _owner The new owner.
- * @param _tokenMessenger The TokenMessenger contract.
- * @param _circleMessageTransmitter The Circle MessageTransmitter contract.
- * @param _liquidityLayerRouter The LiquidityLayerRouter contract.
- */
- function initialize(
- address _owner,
- address _tokenMessenger,
- address _circleMessageTransmitter,
- address _liquidityLayerRouter
- ) external initializer {
- __Ownable_init();
- _transferOwnership(_owner);
-
- tokenMessenger = ITokenMessenger(_tokenMessenger);
- circleMessageTransmitter = ICircleMessageTransmitter(
- _circleMessageTransmitter
- );
- liquidityLayerRouter = _liquidityLayerRouter;
- }
-
- function sendTokens(
- uint32 _destinationDomain,
- bytes32, // _recipientAddress, unused
- address _token,
- uint256 _amount
- ) external onlyLiquidityLayerRouter returns (bytes memory) {
- string memory _tokenSymbol = tokenAddressToSymbol[_token];
- require(
- bytes(_tokenSymbol).length > 0,
- "CircleBridgeAdapter: Unknown token"
- );
-
- uint32 _circleDomain = hyperlaneDomainToCircleDomain[
- _destinationDomain
- ];
- bytes32 _remoteRouter = _mustHaveRemoteRouter(_destinationDomain);
-
- // Approve the token to Circle. We assume that the LiquidityLayerRouter
- // has already transferred the token to this contract.
- require(
- IERC20(_token).approve(address(tokenMessenger), _amount),
- "!approval"
- );
-
- uint64 _nonce = tokenMessenger.depositForBurn(
- _amount,
- _circleDomain,
- _remoteRouter, // Mint to the remote router
- _token
- );
-
- emit BridgedToken(_nonce);
- return abi.encode(_nonce, _tokenSymbol);
- }
-
- // Returns the token and amount sent
- function receiveTokens(
- uint32 _originDomain, // Hyperlane domain
- address _recipient,
- uint256 _amount,
- bytes calldata _adapterData // The adapter data from the message
- ) external onlyLiquidityLayerRouter returns (address, uint256) {
- _mustHaveRemoteRouter(_originDomain);
- // The origin Circle domain
- uint32 _originCircleDomain = hyperlaneDomainToCircleDomain[
- _originDomain
- ];
- // Get the token symbol and nonce of the transfer from the _adapterData
- (uint64 _nonce, string memory _tokenSymbol) = abi.decode(
- _adapterData,
- (uint64, string)
- );
-
- // Require the circle message to have been processed
- bytes32 _nonceId = _circleNonceId(_originCircleDomain, _nonce);
- require(
- circleMessageTransmitter.usedNonces(_nonceId),
- "Circle message not processed yet"
- );
-
- IERC20 _token = tokenSymbolToAddress[_tokenSymbol];
- require(
- address(_token) != address(0),
- "CircleBridgeAdapter: Unknown token"
- );
-
- // Transfer the token out to the recipient
- // Circle doesn't charge any fee, so we can safely transfer out the
- // exact amount that was bridged over.
- _token.safeTransfer(_recipient, _amount);
-
- return (address(_token), _amount);
- }
-
- // This contract is only a Router to be aware of remote router addresses,
- // and doesn't actually send/handle Hyperlane messages directly
- function _handle(
- uint32, // origin
- bytes32, // sender
- bytes calldata // message
- ) internal pure override {
- revert("No messages expected");
- }
-
- function addDomain(
- uint32 _hyperlaneDomain,
- uint32 _circleDomain
- ) external onlyOwner {
- hyperlaneDomainToCircleDomain[_hyperlaneDomain] = _circleDomain;
-
- emit DomainAdded(_hyperlaneDomain, _circleDomain);
- }
-
- function addToken(
- address _token,
- string calldata _tokenSymbol
- ) external onlyOwner {
- require(
- _token != address(0) && bytes(_tokenSymbol).length > 0,
- "Cannot add default values"
- );
-
- // Require the token and token symbol to be unset.
- address _existingToken = address(tokenSymbolToAddress[_tokenSymbol]);
- require(_existingToken == address(0), "token symbol already has token");
-
- string memory _existingSymbol = tokenAddressToSymbol[_token];
- require(
- bytes(_existingSymbol).length == 0,
- "token already has token symbol"
- );
-
- tokenAddressToSymbol[_token] = _tokenSymbol;
- tokenSymbolToAddress[_tokenSymbol] = IERC20(_token);
-
- emit TokenAdded(_token, _tokenSymbol);
- }
-
- function removeToken(
- address _token,
- string calldata _tokenSymbol
- ) external onlyOwner {
- // Require the provided token and token symbols match what's in storage.
- address _existingToken = address(tokenSymbolToAddress[_tokenSymbol]);
- require(_existingToken == _token, "Token mismatch");
-
- string memory _existingSymbol = tokenAddressToSymbol[_token];
- require(
- keccak256(bytes(_existingSymbol)) == keccak256(bytes(_tokenSymbol)),
- "Token symbol mismatch"
- );
-
- // Delete them from storage.
- delete tokenSymbolToAddress[_tokenSymbol];
- delete tokenAddressToSymbol[_token];
-
- emit TokenRemoved(_token, _tokenSymbol);
- }
-
- /**
- * @notice Gets the Circle nonce ID by hashing _originCircleDomain and _nonce.
- * @param _originCircleDomain Domain of chain where the transfer originated
- * @param _nonce The unique identifier for the message from source to
- destination
- * @return hash of source and nonce
- */
- function _circleNonceId(
- uint32 _originCircleDomain,
- uint64 _nonce
- ) internal pure returns (bytes32) {
- return keccak256(abi.encodePacked(_originCircleDomain, _nonce));
- }
-}
diff --git a/solidity/contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol b/solidity/contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol
deleted file mode 100644
index de1c403438..0000000000
--- a/solidity/contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol
+++ /dev/null
@@ -1,214 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-pragma solidity ^0.8.13;
-
-import {Router} from "../../../client/Router.sol";
-
-import {IPortalTokenBridge} from "../interfaces/portal/IPortalTokenBridge.sol";
-import {ILiquidityLayerAdapter} from "../interfaces/ILiquidityLayerAdapter.sol";
-import {TypeCasts} from "../../../libs/TypeCasts.sol";
-
-import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
-
-contract PortalAdapter is ILiquidityLayerAdapter, Router {
- /// @notice The Portal TokenBridge contract.
- IPortalTokenBridge public portalTokenBridge;
-
- /// @notice The LiquidityLayerRouter contract.
- address public liquidityLayerRouter;
-
- /// @notice Hyperlane domain => Wormhole domain.
- mapping(uint32 hyperlaneDomain => uint16 wormholeDomain)
- public hyperlaneDomainToWormholeDomain;
- /// @notice transferId => token address
- mapping(bytes32 transferId => address token)
- public portalTransfersProcessed;
-
- // We could technically use Portal's sequence number here but it doesn't
- // get passed through, so we would have to parse the VAA twice
- // 224 bits should be large enough and allows us to pack into a single slot
- // with a Hyperlane domain
- uint224 public nonce = 0;
-
- constructor(address _mailbox) Router(_mailbox) {}
-
- /**
- * @notice Emits the nonce of the Portal message when a token is bridged.
- * @param nonce The nonce of the Portal message.
- * @param portalSequence The sequence of the Portal message.
- * @param destination The hyperlane domain of the destination
- */
- event BridgedToken(
- uint256 nonce,
- uint64 portalSequence,
- uint32 destination
- );
-
- /**
- * @notice Emitted when the Hyperlane domain to Wormhole domain mapping is updated.
- * @param hyperlaneDomain The Hyperlane domain.
- * @param wormholeDomain The Wormhole domain.
- */
- event DomainAdded(uint32 indexed hyperlaneDomain, uint32 wormholeDomain);
-
- modifier onlyLiquidityLayerRouter() {
- require(msg.sender == liquidityLayerRouter, "!liquidityLayerRouter");
- _;
- }
-
- /**
- * @param _owner The new owner.
- * @param _portalTokenBridge The Portal TokenBridge contract.
- * @param _liquidityLayerRouter The LiquidityLayerRouter contract.
- */
- function initialize(
- address _owner,
- address _portalTokenBridge,
- address _liquidityLayerRouter
- ) public initializer {
- // Transfer ownership of the contract to deployer
- _transferOwnership(_owner);
-
- portalTokenBridge = IPortalTokenBridge(_portalTokenBridge);
- liquidityLayerRouter = _liquidityLayerRouter;
- }
-
- /**
- * Sends tokens as requested by the router
- * @param _destinationDomain The hyperlane domain of the destination
- * @param _token The token address
- * @param _amount The amount of tokens to send
- */
- function sendTokens(
- uint32 _destinationDomain,
- bytes32, // _recipientAddress, unused
- address _token,
- uint256 _amount
- ) external onlyLiquidityLayerRouter returns (bytes memory) {
- nonce = nonce + 1;
- uint16 _wormholeDomain = hyperlaneDomainToWormholeDomain[
- _destinationDomain
- ];
-
- bytes32 _remoteRouter = _mustHaveRemoteRouter(_destinationDomain);
-
- // Approve the token to Portal. We assume that the LiquidityLayerRouter
- // has already transferred the token to this contract.
- require(
- IERC20(_token).approve(address(portalTokenBridge), _amount),
- "!approval"
- );
-
- uint64 _portalSequence = portalTokenBridge.transferTokensWithPayload(
- _token,
- _amount,
- _wormholeDomain,
- _remoteRouter,
- // Nonce for grouping Portal messages in the same tx, not relevant for us
- // https://book.wormhole.com/technical/evm/coreLayer.html#emitting-a-vaa
- 0,
- // Portal Payload used in completeTransfer
- abi.encode(localDomain, nonce)
- );
-
- emit BridgedToken(nonce, _portalSequence, _destinationDomain);
- return abi.encode(nonce);
- }
-
- /**
- * Sends the tokens to the recipient as requested by the router
- * @param _originDomain The hyperlane domain of the origin
- * @param _recipient The address of the recipient
- * @param _amount The amount of tokens to send
- * @param _adapterData The adapter data from the origin chain, containing the nonce
- */
- function receiveTokens(
- uint32 _originDomain, // Hyperlane domain
- address _recipient,
- uint256 _amount,
- bytes calldata _adapterData // The adapter data from the message
- ) external onlyLiquidityLayerRouter returns (address, uint256) {
- // Get the nonce information from the adapterData
- uint224 _nonce = abi.decode(_adapterData, (uint224));
-
- address _tokenAddress = portalTransfersProcessed[
- transferId(_originDomain, _nonce)
- ];
-
- require(
- _tokenAddress != address(0x0),
- "Portal Transfer has not yet been completed"
- );
-
- IERC20 _token = IERC20(_tokenAddress);
-
- // Transfer the token out to the recipient
- // TODO: use safeTransfer
- // Portal doesn't charge any fee, so we can safely transfer out the
- // exact amount that was bridged over.
- require(_token.transfer(_recipient, _amount), "!transfer out");
- return (_tokenAddress, _amount);
- }
-
- /**
- * Completes the Portal transfer which sends the funds to this adapter.
- * The router can call receiveTokens to move those funds to the ultimate recipient.
- * @param encodedVm The VAA from the Wormhole Guardians
- */
- function completeTransfer(bytes memory encodedVm) public {
- bytes memory _tokenBridgeTransferWithPayload = portalTokenBridge
- .completeTransferWithPayload(encodedVm);
- IPortalTokenBridge.TransferWithPayload
- memory _transfer = portalTokenBridge.parseTransferWithPayload(
- _tokenBridgeTransferWithPayload
- );
-
- (uint32 _originDomain, uint224 _nonce) = abi.decode(
- _transfer.payload,
- (uint32, uint224)
- );
-
- // Logic taken from here https://github.com/wormhole-foundation/wormhole/blob/dev.v2/ethereum/contracts/bridge/Bridge.sol#L503
- address tokenAddress = _transfer.tokenChain ==
- hyperlaneDomainToWormholeDomain[localDomain]
- ? TypeCasts.bytes32ToAddress(_transfer.tokenAddress)
- : portalTokenBridge.wrappedAsset(
- _transfer.tokenChain,
- _transfer.tokenAddress
- );
-
- portalTransfersProcessed[
- transferId(_originDomain, _nonce)
- ] = tokenAddress;
- }
-
- // This contract is only a Router to be aware of remote router addresses,
- // and doesn't actually send/handle Hyperlane messages directly
- function _handle(
- uint32, // origin
- bytes32, // sender
- bytes calldata // message
- ) internal pure override {
- revert("No messages expected");
- }
-
- function addDomain(
- uint32 _hyperlaneDomain,
- uint16 _wormholeDomain
- ) external onlyOwner {
- hyperlaneDomainToWormholeDomain[_hyperlaneDomain] = _wormholeDomain;
-
- emit DomainAdded(_hyperlaneDomain, _wormholeDomain);
- }
-
- /**
- * The key that is used to track fulfilled Portal transfers
- * @param _hyperlaneDomain The hyperlane of the origin
- * @param _nonce The nonce of the adapter on the origin
- */
- function transferId(
- uint32 _hyperlaneDomain,
- uint224 _nonce
- ) public pure returns (bytes32) {
- return bytes32(abi.encodePacked(_hyperlaneDomain, _nonce));
- }
-}
diff --git a/solidity/contracts/middleware/liquidity-layer/interfaces/ILiquidityLayerAdapter.sol b/solidity/contracts/middleware/liquidity-layer/interfaces/ILiquidityLayerAdapter.sol
deleted file mode 100644
index 95b97f6c40..0000000000
--- a/solidity/contracts/middleware/liquidity-layer/interfaces/ILiquidityLayerAdapter.sol
+++ /dev/null
@@ -1,18 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-pragma solidity ^0.8.13;
-
-interface ILiquidityLayerAdapter {
- function sendTokens(
- uint32 _destinationDomain,
- bytes32 _recipientAddress,
- address _token,
- uint256 _amount
- ) external returns (bytes memory _adapterData);
-
- function receiveTokens(
- uint32 _originDomain, // Hyperlane domain
- address _recipientAddress,
- uint256 _amount,
- bytes calldata _adapterData // The adapter data from the message
- ) external returns (address, uint256);
-}
diff --git a/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ICircleMessageTransmitter.sol b/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ICircleMessageTransmitter.sol
deleted file mode 100644
index 9b0c03d321..0000000000
--- a/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ICircleMessageTransmitter.sol
+++ /dev/null
@@ -1,39 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-pragma solidity ^0.8.13;
-
-interface ICircleMessageTransmitter {
- /**
- * @notice Receive a message. Messages with a given nonce
- * can only be broadcast once for a (sourceDomain, destinationDomain)
- * pair. The message body of a valid message is passed to the
- * specified recipient for further processing.
- *
- * @dev Attestation format:
- * A valid attestation is the concatenated 65-byte signature(s) of exactly
- * `thresholdSignature` signatures, in increasing order of attester address.
- * ***If the attester addresses recovered from signatures are not in
- * increasing order, signature verification will fail.***
- * If incorrect number of signatures or duplicate signatures are supplied,
- * signature verification will fail.
- *
- * Message format:
- * Field Bytes Type Index
- * version 4 uint32 0
- * sourceDomain 4 uint32 4
- * destinationDomain 4 uint32 8
- * nonce 8 uint64 12
- * sender 32 bytes32 20
- * recipient 32 bytes32 52
- * messageBody dynamic bytes 84
- * @param _message Message bytes
- * @param _attestation Concatenated 65-byte signature(s) of `_message`, in increasing order
- * of the attester address recovered from signatures.
- * @return success bool, true if successful
- */
- function receiveMessage(
- bytes memory _message,
- bytes calldata _attestation
- ) external returns (bool success);
-
- function usedNonces(bytes32 _nonceId) external view returns (bool);
-}
diff --git a/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol b/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol
deleted file mode 100644
index 4eb9fa58fe..0000000000
--- a/solidity/contracts/middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol
+++ /dev/null
@@ -1,59 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-pragma solidity ^0.8.13;
-
-interface ITokenMessenger {
- event MessageSent(bytes message);
-
- /**
- * @notice Deposits and burns tokens from sender to be minted on destination domain.
- * Emits a `DepositForBurn` event.
- * @dev reverts if:
- * - given burnToken is not supported
- * - given destinationDomain has no TokenMessenger registered
- * - transferFrom() reverts. For example, if sender's burnToken balance or approved allowance
- * to this contract is less than `amount`.
- * - burn() reverts. For example, if `amount` is 0.
- * - MessageTransmitter returns false or reverts.
- * @param _amount amount of tokens to burn
- * @param _destinationDomain destination domain (ETH = 0, AVAX = 1)
- * @param _mintRecipient address of mint recipient on destination domain
- * @param _burnToken address of contract to burn deposited tokens, on local domain
- * @return _nonce unique nonce reserved by message
- */
- function depositForBurn(
- uint256 _amount,
- uint32 _destinationDomain,
- bytes32 _mintRecipient,
- address _burnToken
- ) external returns (uint64 _nonce);
-
- /**
- * @notice Deposits and burns tokens from sender to be minted on destination domain. The mint
- * on the destination domain must be called by `_destinationCaller`.
- * WARNING: if the `_destinationCaller` does not represent a valid address as bytes32, then it will not be possible
- * to broadcast the message on the destination domain. This is an advanced feature, and the standard
- * depositForBurn() should be preferred for use cases where a specific destination caller is not required.
- * Emits a `DepositForBurn` event.
- * @dev reverts if:
- * - given destinationCaller is zero address
- * - given burnToken is not supported
- * - given destinationDomain has no TokenMessenger registered
- * - transferFrom() reverts. For example, if sender's burnToken balance or approved allowance
- * to this contract is less than `amount`.
- * - burn() reverts. For example, if `amount` is 0.
- * - MessageTransmitter returns false or reverts.
- * @param _amount amount of tokens to burn
- * @param _destinationDomain destination domain
- * @param _mintRecipient address of mint recipient on destination domain
- * @param _burnToken address of contract to burn deposited tokens, on local domain
- * @param _destinationCaller caller on the destination domain, as bytes32
- * @return _nonce unique nonce reserved by message
- */
- function depositForBurnWithCaller(
- uint256 _amount,
- uint32 _destinationDomain,
- bytes32 _mintRecipient,
- address _burnToken,
- bytes32 _destinationCaller
- ) external returns (uint64 _nonce);
-}
diff --git a/solidity/contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol b/solidity/contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol
deleted file mode 100644
index aafb594cea..0000000000
--- a/solidity/contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol
+++ /dev/null
@@ -1,86 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-pragma solidity ^0.8.13;
-
-// Portal's interface from their docs
-interface IPortalTokenBridge {
- struct Transfer {
- uint8 payloadID;
- uint256 amount;
- bytes32 tokenAddress;
- uint16 tokenChain;
- bytes32 to;
- uint16 toChain;
- uint256 fee;
- }
-
- struct TransferWithPayload {
- uint8 payloadID;
- uint256 amount;
- bytes32 tokenAddress;
- uint16 tokenChain;
- bytes32 to;
- uint16 toChain;
- bytes32 fromAddress;
- bytes payload;
- }
-
- struct AssetMeta {
- uint8 payloadID;
- bytes32 tokenAddress;
- uint16 tokenChain;
- uint8 decimals;
- bytes32 symbol;
- bytes32 name;
- }
-
- struct RegisterChain {
- bytes32 module;
- uint8 action;
- uint16 chainId;
- uint16 emitterChainID;
- bytes32 emitterAddress;
- }
-
- struct UpgradeContract {
- bytes32 module;
- uint8 action;
- uint16 chainId;
- bytes32 newContract;
- }
-
- struct RecoverChainId {
- bytes32 module;
- uint8 action;
- uint256 evmChainId;
- uint16 newChainId;
- }
-
- event ContractUpgraded(
- address indexed oldContract,
- address indexed newContract
- );
-
- function transferTokensWithPayload(
- address token,
- uint256 amount,
- uint16 recipientChain,
- bytes32 recipient,
- uint32 nonce,
- bytes memory payload
- ) external payable returns (uint64 sequence);
-
- function completeTransferWithPayload(
- bytes memory encodedVm
- ) external returns (bytes memory);
-
- function parseTransferWithPayload(
- bytes memory encoded
- ) external pure returns (TransferWithPayload memory transfer);
-
- function wrappedAsset(
- uint16 tokenChainId,
- bytes32 tokenAddress
- ) external view returns (address);
-
- function isWrappedAsset(address token) external view returns (bool);
-}
diff --git a/solidity/contracts/mock/MockCircleMessageTransmitter.sol b/solidity/contracts/mock/MockCircleMessageTransmitter.sol
index ac80cdb96f..47cf4f1a30 100644
--- a/solidity/contracts/mock/MockCircleMessageTransmitter.sol
+++ b/solidity/contracts/mock/MockCircleMessageTransmitter.sol
@@ -1,10 +1,26 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.13;
-import {ICircleMessageTransmitter} from "../middleware/liquidity-layer/interfaces/circle/ICircleMessageTransmitter.sol";
+import {IMessageTransmitter} from "../interfaces/cctp/IMessageTransmitter.sol";
+import {IMessageTransmitterV2} from "../interfaces/cctp/IMessageTransmitterV2.sol";
+import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol";
+import {IMessageHandlerV2} from "../interfaces/cctp/IMessageHandlerV2.sol";
import {MockToken} from "./MockToken.sol";
+import {TypedMemView} from "../libs/TypedMemView.sol";
+import {CctpMessageV1} from "../libs/CctpMessageV1.sol";
+import {CctpMessageV2} from "../libs/CctpMessageV2.sol";
+import {TypeCasts} from "../libs/TypeCasts.sol";
+
+contract MockCircleMessageTransmitter is
+ IMessageTransmitter,
+ IMessageTransmitterV2
+{
+ using TypedMemView for bytes;
+ using TypedMemView for bytes29;
+ using CctpMessageV1 for bytes29;
+ using CctpMessageV2 for bytes29;
+ using TypeCasts for address;
-contract MockCircleMessageTransmitter is ICircleMessageTransmitter {
mapping(bytes32 => bool) processedNonces;
MockToken token;
uint32 public version;
@@ -13,11 +29,75 @@ contract MockCircleMessageTransmitter is ICircleMessageTransmitter {
token = _token;
}
+ function nextAvailableNonce() external view returns (uint64) {
+ return 0;
+ }
+
+ function signatureThreshold() external view returns (uint256) {
+ return 1;
+ }
+
function receiveMessage(
- bytes memory,
+ bytes memory message,
bytes calldata
- ) external pure returns (bool success) {
- success = true;
+ ) external returns (bool success) {
+ bytes29 cctpMessage = TypedMemView.ref(message, 0);
+
+ // Extract nonce and source domain to check if message was already processed
+ uint32 sourceDomain;
+ bytes32 nonceId;
+ if (version == 0) {
+ sourceDomain = cctpMessage._sourceDomain();
+ uint64 nonce = cctpMessage._nonce();
+ nonceId = hashSourceAndNonce(sourceDomain, nonce);
+ } else {
+ sourceDomain = cctpMessage._getSourceDomain();
+ bytes32 nonce = cctpMessage._getNonce();
+ // For V2, use the nonce directly as the nonceId (it's already a bytes32)
+ nonceId = keccak256(abi.encodePacked(sourceDomain, nonce));
+ }
+
+ require(!processedNonces[nonceId], "Message already processed");
+ processedNonces[nonceId] = true;
+
+ // Extract recipient based on version
+ address recipient;
+ bytes32 sender;
+ bytes memory messageBody;
+
+ if (version == 0) {
+ // V1
+ recipient = _bytes32ToAddress(cctpMessage._recipient());
+ sender = cctpMessage._sender();
+ messageBody = cctpMessage._messageBody().clone();
+ } else {
+ // V2
+ recipient = _bytes32ToAddress(cctpMessage._getRecipient());
+ sender = cctpMessage._getSender();
+ messageBody = cctpMessage._getMessageBody().clone();
+ }
+
+ if (version == 0) {
+ // V1: Call handleReceiveMessage
+ success = IMessageHandler(recipient).handleReceiveMessage(
+ sourceDomain,
+ sender,
+ messageBody
+ );
+ } else {
+ // V2: Call handleReceiveUnfinalizedMessage
+ success = IMessageHandlerV2(recipient)
+ .handleReceiveUnfinalizedMessage(
+ sourceDomain,
+ sender,
+ 1000, // mock finality threshold
+ messageBody
+ );
+ }
+ }
+
+ function _bytes32ToAddress(bytes32 _buf) internal pure returns (address) {
+ return address(uint160(uint256(_buf)));
}
function hashSourceAndNonce(
@@ -36,11 +116,88 @@ contract MockCircleMessageTransmitter is ICircleMessageTransmitter {
token.mint(_recipient, _amount);
}
- function usedNonces(bytes32 _nonceId) external view returns (bool) {
- return processedNonces[_nonceId];
+ function usedNonces(bytes32 _nonceId) external view returns (uint256) {
+ return processedNonces[_nonceId] ? 1 : 0;
}
function setVersion(uint32 _version) external {
version = _version;
}
+
+ function localDomain() external view returns (uint32) {
+ return 0;
+ }
+
+ function replaceMessage(
+ bytes calldata,
+ bytes calldata,
+ bytes calldata,
+ bytes32
+ ) external {
+ revert("Not implemented");
+ }
+
+ function sendMessage(
+ uint32 destinationDomain,
+ bytes32 recipient,
+ bytes calldata messageBody
+ ) public returns (uint64) {
+ // Format a complete CCTP message for the event based on version
+ bytes memory cctpMessage;
+ if (version == 0) {
+ cctpMessage = CctpMessageV1._formatMessage(
+ version,
+ 0, // sourceDomain (mock localDomain returns 0)
+ destinationDomain,
+ 0, // nonce
+ address(this).addressToBytes32(),
+ recipient,
+ bytes32(0), // destinationCaller (anyone can relay)
+ messageBody
+ );
+ } else {
+ cctpMessage = CctpMessageV2._formatMessageForRelay(
+ version,
+ 0, // sourceDomain (mock localDomain returns 0)
+ destinationDomain,
+ address(this).addressToBytes32(),
+ recipient,
+ bytes32(0), // destinationCaller (anyone can relay)
+ 1000, // mock finality threshold
+ messageBody
+ );
+ }
+ emit MessageSent(cctpMessage);
+ return 0;
+ }
+
+ function sendMessageWithCaller(
+ uint32,
+ bytes32,
+ bytes32,
+ bytes calldata message
+ ) external returns (uint64) {
+ return sendMessage(0, 0, message);
+ }
+
+ function sendMessage(
+ uint32 destinationDomain,
+ bytes32 recipient,
+ bytes32 destinationCaller,
+ uint32 minFinalityThreshold,
+ bytes calldata messageBody
+ ) external {
+ // V2 sendMessage: format a complete CCTP V2 message
+ bytes memory cctpMessage = CctpMessageV2._formatMessageForRelay(
+ version,
+ 0, // sourceDomain (mock localDomain returns 0)
+ destinationDomain,
+ address(this).addressToBytes32(),
+ recipient,
+ destinationCaller,
+ minFinalityThreshold,
+ messageBody
+ );
+ emit MessageSent(cctpMessage);
+ }
}
diff --git a/solidity/contracts/mock/MockCircleTokenMessenger.sol b/solidity/contracts/mock/MockCircleTokenMessenger.sol
index 895605d02b..9bfc9852b5 100644
--- a/solidity/contracts/mock/MockCircleTokenMessenger.sol
+++ b/solidity/contracts/mock/MockCircleTokenMessenger.sol
@@ -1,13 +1,21 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.13;
-import {ITokenMessenger} from "../middleware/liquidity-layer/interfaces/circle/ITokenMessenger.sol";
+import {ITokenMessenger, ITokenMessengerV1} from "../interfaces/cctp/ITokenMessenger.sol";
import {ITokenMessengerV2} from "../interfaces/cctp/ITokenMessengerV2.sol";
+import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol";
+import {IMessageHandlerV2} from "../interfaces/cctp/IMessageHandlerV2.sol";
import {MockToken} from "./MockToken.sol";
-contract MockCircleTokenMessenger is ITokenMessenger {
+contract MockCircleTokenMessenger is
+ ITokenMessengerV1,
+ ITokenMessengerV2,
+ IMessageHandler,
+ IMessageHandlerV2
+{
uint64 public nextNonce = 0;
MockToken token;
+ uint32 public version;
constructor(MockToken _token) {
token = _token;
@@ -18,7 +26,7 @@ contract MockCircleTokenMessenger is ITokenMessenger {
uint32,
bytes32,
address _burnToken
- ) external returns (uint64 _nonce) {
+ ) public returns (uint64 _nonce) {
_nonce = nextNonce;
nextNonce += 1;
require(address(token) == _burnToken);
@@ -27,27 +35,21 @@ contract MockCircleTokenMessenger is ITokenMessenger {
}
function depositForBurnWithCaller(
- uint256,
+ uint256 _amount,
uint32,
bytes32,
- address,
+ address _burnToken,
bytes32
) external returns (uint64 _nonce) {
- _nonce = nextNonce;
- nextNonce += 1;
+ depositForBurn(_amount, 0, 0, _burnToken);
}
- function messageBodyVersion() external returns (uint32) {
- return 0;
+ function messageBodyVersion() external override returns (uint32) {
+ return version;
}
-}
-contract MockCircleTokenMessengerV2 is ITokenMessengerV2 {
- uint64 public nextNonce = 0;
- MockToken token;
-
- constructor(MockToken _token) {
- token = _token;
+ function setVersion(uint32 _version) external {
+ version = _version;
}
function depositForBurn(
@@ -59,13 +61,34 @@ contract MockCircleTokenMessengerV2 is ITokenMessengerV2 {
uint256,
uint32
) external {
- nextNonce += 1;
- require(address(token) == _burnToken);
- token.transferFrom(msg.sender, address(this), _amount);
- token.burn(_amount);
+ depositForBurn(_amount, 0, 0, _burnToken);
+ }
+
+ // V1 handler
+ function handleReceiveMessage(
+ uint32,
+ bytes32,
+ bytes calldata
+ ) external pure override returns (bool) {
+ return true;
+ }
+
+ // V2 handlers
+ function handleReceiveFinalizedMessage(
+ uint32,
+ bytes32,
+ uint32,
+ bytes calldata
+ ) external pure override returns (bool) {
+ return true;
}
- function messageBodyVersion() external returns (uint32) {
- return 1;
+ function handleReceiveUnfinalizedMessage(
+ uint32,
+ bytes32,
+ uint32,
+ bytes calldata
+ ) external pure override returns (bool) {
+ return true;
}
}
diff --git a/solidity/contracts/mock/MockMailbox.sol b/solidity/contracts/mock/MockMailbox.sol
index 761d26cd05..7f616480f8 100644
--- a/solidity/contracts/mock/MockMailbox.sol
+++ b/solidity/contracts/mock/MockMailbox.sol
@@ -14,6 +14,7 @@ import {TestPostDispatchHook} from "../test/TestPostDispatchHook.sol";
contract MockMailbox is Mailbox {
using Message for bytes;
+ using TypeCasts for address;
uint32 public inboundUnprocessedNonce = 0;
uint32 public inboundProcessedNonce = 0;
@@ -92,4 +93,37 @@ contract MockMailbox is Mailbox {
function addInboundMetadata(uint32 _nonce, bytes memory metadata) public {
inboundMetadata[_nonce] = metadata;
}
+
+ function buildMessage(
+ address sender,
+ uint32 destinationDomain,
+ bytes32 recipientAddress,
+ bytes calldata messageBody
+ ) external view returns (bytes memory) {
+ return
+ _buildMessage(
+ sender,
+ destinationDomain,
+ recipientAddress,
+ messageBody
+ );
+ }
+
+ function _buildMessage(
+ address sender,
+ uint32 destinationDomain,
+ bytes32 recipientAddress,
+ bytes calldata messageBody
+ ) internal view returns (bytes memory) {
+ return
+ Message.formatMessage(
+ VERSION,
+ nonce,
+ localDomain,
+ sender.addressToBytes32(),
+ destinationDomain,
+ recipientAddress,
+ messageBody
+ );
+ }
}
diff --git a/solidity/contracts/mock/MockPortalBridge.sol b/solidity/contracts/mock/MockPortalBridge.sol
deleted file mode 100644
index 142b5fa54e..0000000000
--- a/solidity/contracts/mock/MockPortalBridge.sol
+++ /dev/null
@@ -1,86 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-pragma solidity ^0.8.13;
-
-import {IPortalTokenBridge} from "../middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol";
-import {MockToken} from "./MockToken.sol";
-import {TypeCasts} from "../libs/TypeCasts.sol";
-
-contract MockPortalBridge is IPortalTokenBridge {
- uint256 nextNonce = 0;
- MockToken token;
-
- constructor(MockToken _token) {
- token = _token;
- }
-
- function transferTokensWithPayload(
- address,
- uint256 amount,
- uint16,
- bytes32,
- uint32,
- bytes memory
- ) external payable returns (uint64 sequence) {
- nextNonce = nextNonce + 1;
- token.transferFrom(msg.sender, address(this), amount);
- token.burn(amount);
- return uint64(nextNonce);
- }
-
- function wrappedAsset(uint16, bytes32) external view returns (address) {
- return address(token);
- }
-
- function isWrappedAsset(address) external pure returns (bool) {
- return true;
- }
-
- function completeTransferWithPayload(
- bytes memory encodedVm
- ) external returns (bytes memory) {
- (uint32 _originDomain, uint224 _nonce, uint256 _amount) = abi.decode(
- encodedVm,
- (uint32, uint224, uint256)
- );
-
- token.mint(msg.sender, _amount);
- // Format it so that parseTransferWithPayload returns the desired payload
- return
- abi.encode(
- TypeCasts.addressToBytes32(address(token)),
- adapterData(_originDomain, _nonce, address(token))
- );
- }
-
- function parseTransferWithPayload(
- bytes memory encoded
- ) external pure returns (TransferWithPayload memory transfer) {
- (bytes32 tokenAddress, bytes memory payload) = abi.decode(
- encoded,
- (bytes32, bytes)
- );
- transfer.payload = payload;
- transfer.tokenAddress = tokenAddress;
- }
-
- function adapterData(
- uint32 _originDomain,
- uint224 _nonce,
- address _token
- ) public pure returns (bytes memory) {
- return
- abi.encode(
- _originDomain,
- _nonce,
- TypeCasts.addressToBytes32(_token)
- );
- }
-
- function mockPortalVaa(
- uint32 _originDomain,
- uint224 _nonce,
- uint256 _amount
- ) public pure returns (bytes memory) {
- return abi.encode(_originDomain, _nonce, _amount);
- }
-}
diff --git a/solidity/contracts/mock/MockValueTransferBridge.sol b/solidity/contracts/mock/MockValueTransferBridge.sol
index f048e84454..0186d85eca 100644
--- a/solidity/contracts/mock/MockValueTransferBridge.sol
+++ b/solidity/contracts/mock/MockValueTransferBridge.sol
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.13;
-import {ValueTransferBridge, Quote} from "../token/interfaces/ValueTransferBridge.sol";
+import {ITokenBridge, Quote} from "../interfaces/ITokenBridge.sol";
-contract MockValueTransferBridge is ValueTransferBridge {
+contract MockValueTransferBridge is ITokenBridge {
event SentTransferRemote(
uint32 indexed origin,
uint32 indexed destination,
diff --git a/solidity/contracts/test/ERC20Test.sol b/solidity/contracts/test/ERC20Test.sol
index 62e4ec234b..190d6e59f5 100644
--- a/solidity/contracts/test/ERC20Test.sol
+++ b/solidity/contracts/test/ERC20Test.sol
@@ -294,3 +294,15 @@ contract XERC20LockboxTest is IXERC20Lockbox {
withdrawTo(msg.sender, _amount);
}
}
+
+contract NonCompliantERC20Test {
+ // Returns returns void, instead of bool of an ERC20 compliant token
+ function approve(address _to, uint _value) public {}
+
+ function allowance(
+ address owner,
+ address spender
+ ) public view virtual returns (uint256) {
+ return 0;
+ }
+}
diff --git a/solidity/contracts/test/TestGasRouter.sol b/solidity/contracts/test/TestGasRouter.sol
index b74bd6ac6b..d3874a3d77 100644
--- a/solidity/contracts/test/TestGasRouter.sol
+++ b/solidity/contracts/test/TestGasRouter.sol
@@ -7,7 +7,26 @@ contract TestGasRouter is GasRouter {
constructor(address _mailbox) GasRouter(_mailbox) {}
function dispatch(uint32 _destination, bytes memory _msg) external payable {
- _GasRouter_dispatch(_destination, msg.value, _msg, address(hook));
+ _Router_dispatch(
+ _destination,
+ msg.value,
+ _msg,
+ _GasRouter_hookMetadata(_destination),
+ address(hook)
+ );
+ }
+
+ function quoteDispatch(
+ uint32 _destination,
+ bytes memory _msg
+ ) external view returns (uint256) {
+ return
+ _Router_quoteDispatch(
+ _destination,
+ _msg,
+ _GasRouter_hookMetadata(_destination),
+ address(hook)
+ );
}
function _handle(uint32, bytes32, bytes calldata) internal pure override {}
diff --git a/solidity/contracts/test/TestLiquidityLayerMessageRecipient.sol b/solidity/contracts/test/TestLiquidityLayerMessageRecipient.sol
deleted file mode 100644
index 71d64d9316..0000000000
--- a/solidity/contracts/test/TestLiquidityLayerMessageRecipient.sol
+++ /dev/null
@@ -1,24 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-pragma solidity ^0.8.13;
-
-import {ILiquidityLayerMessageRecipient} from "../interfaces/ILiquidityLayerMessageRecipient.sol";
-
-contract TestLiquidityLayerMessageRecipient is ILiquidityLayerMessageRecipient {
- event HandledWithTokens(
- uint32 origin,
- bytes32 sender,
- bytes message,
- address token,
- uint256 amount
- );
-
- function handleWithTokens(
- uint32 _origin,
- bytes32 _sender,
- bytes calldata _message,
- address _token,
- uint256 _amount
- ) external {
- emit HandledWithTokens(_origin, _sender, _message, _token, _amount);
- }
-}
diff --git a/solidity/contracts/test/TestLpCollateralRouter.sol b/solidity/contracts/test/TestLpCollateralRouter.sol
new file mode 100644
index 0000000000..842e818dcc
--- /dev/null
+++ b/solidity/contracts/test/TestLpCollateralRouter.sol
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0;
+
+import {LpCollateralRouter} from "../token/libs/LpCollateralRouter.sol";
+import {TokenRouter} from "../token/libs/TokenRouter.sol";
+
+contract TestLpCollateralRouter is LpCollateralRouter {
+ constructor(
+ uint256 _scale,
+ address _mailbox
+ ) TokenRouter(_scale, _mailbox) initializer {
+ _LpCollateralRouter_initialize();
+ }
+
+ function token() public view override returns (address) {
+ return address(0);
+ }
+
+ function _transferFromSender(uint256 _amount) internal override {}
+
+ function _transferTo(
+ address _recipient,
+ uint256 _amount
+ ) internal override {}
+}
diff --git a/solidity/contracts/test/TestLpTokenRouter.sol b/solidity/contracts/test/TestLpTokenRouter.sol
new file mode 100644
index 0000000000..842e818dcc
--- /dev/null
+++ b/solidity/contracts/test/TestLpTokenRouter.sol
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0;
+
+import {LpCollateralRouter} from "../token/libs/LpCollateralRouter.sol";
+import {TokenRouter} from "../token/libs/TokenRouter.sol";
+
+contract TestLpCollateralRouter is LpCollateralRouter {
+ constructor(
+ uint256 _scale,
+ address _mailbox
+ ) TokenRouter(_scale, _mailbox) initializer {
+ _LpCollateralRouter_initialize();
+ }
+
+ function token() public view override returns (address) {
+ return address(0);
+ }
+
+ function _transferFromSender(uint256 _amount) internal override {}
+
+ function _transferTo(
+ address _recipient,
+ uint256 _amount
+ ) internal override {}
+}
diff --git a/solidity/contracts/test/TestPostDispatchHook.sol b/solidity/contracts/test/TestPostDispatchHook.sol
index 0ac1f0a851..105c93ce0f 100644
--- a/solidity/contracts/test/TestPostDispatchHook.sol
+++ b/solidity/contracts/test/TestPostDispatchHook.sol
@@ -21,7 +21,7 @@ contract TestPostDispatchHook is AbstractPostDispatchHook {
/// @inheritdoc IPostDispatchHook
function hookType() external pure override returns (uint8) {
- return uint8(IPostDispatchHook.Types.UNUSED);
+ return uint8(IPostDispatchHook.HookTypes.UNUSED);
}
function supportsMetadata(
diff --git a/solidity/contracts/test/TestRouter.sol b/solidity/contracts/test/TestRouter.sol
index 9ded04b7fa..b55f61da66 100644
--- a/solidity/contracts/test/TestRouter.sol
+++ b/solidity/contracts/test/TestRouter.sol
@@ -31,6 +31,6 @@ contract TestRouter is Router {
}
function dispatch(uint32 _destination, bytes memory _msg) external payable {
- _dispatch(_destination, _msg);
+ _Router_dispatch(_destination, msg.value, _msg);
}
}
diff --git a/solidity/contracts/test/TestTokenRecipient.sol b/solidity/contracts/test/TestTokenRecipient.sol
deleted file mode 100644
index cf6c926f0a..0000000000
--- a/solidity/contracts/test/TestTokenRecipient.sol
+++ /dev/null
@@ -1,44 +0,0 @@
-// SPDX-License-Identifier: MIT OR Apache-2.0
-pragma solidity >=0.8.0;
-
-import {ILiquidityLayerMessageRecipient} from "../interfaces/ILiquidityLayerMessageRecipient.sol";
-
-contract TestTokenRecipient is ILiquidityLayerMessageRecipient {
- bytes32 public lastSender;
- bytes public lastData;
- address public lastToken;
- uint256 public lastAmount;
-
- address public lastCaller;
- string public lastCallMessage;
-
- event ReceivedMessage(
- uint32 indexed origin,
- bytes32 indexed sender,
- string message,
- address token,
- uint256 amount
- );
-
- event ReceivedCall(address indexed caller, uint256 amount, string message);
-
- function handleWithTokens(
- uint32 _origin,
- bytes32 _sender,
- bytes calldata _data,
- address _token,
- uint256 _amount
- ) external override {
- emit ReceivedMessage(_origin, _sender, string(_data), _token, _amount);
- lastSender = _sender;
- lastData = _data;
- lastToken = _token;
- lastAmount = _amount;
- }
-
- function fooBar(uint256 amount, string calldata message) external {
- emit ReceivedCall(msg.sender, amount, message);
- lastCaller = msg.sender;
- lastCallMessage = message;
- }
-}
diff --git a/solidity/contracts/token/CCTP.md b/solidity/contracts/token/CCTP.md
new file mode 100644
index 0000000000..0cc886077d
--- /dev/null
+++ b/solidity/contracts/token/CCTP.md
@@ -0,0 +1,210 @@
+## Burn Message
+
+```mermaid
+flowchart LR
+ Iris((Iris))
+ Relayer((Relayer))
+
+ subgraph Origin Chain
+ User
+ TBCCTP_O[TokenBridgeCctp]
+ M_O[(Mailbox)]
+ TM_O[TokenMessenger]
+ MT_O[MessageTransmitter]
+ USDC_O[USDC]
+
+ User -- "transferRemote(amount, recipient)" --> TBCCTP_O
+ TBCCTP_O -- "depositForBurn()" --> TM_O
+ TM_O -- "burn" --> USDC_O
+ User -. "amount" .-> USDC_O
+ TM_O -- "sendMessage(burnMessage)" --> MT_O
+ TBCCTP_O -- "dispatch(tokenMessage)" --> M_O
+ end
+
+ subgraph Destination Chain
+ Recipient[Recipient]
+ TBCCTP_D[TokenBridgeCctp]
+ M_D[(Mailbox)]
+ TM_D[TokenMessenger]
+ MT_D[MessageTransmitter]
+ USDC_D[USDC]
+
+ TBCCTP_D -- "receiveMessage(
+ burnMessage,
+ attestation)" --> MT_D
+ MT_D -- "burnMessage" --> TM_D
+ TM_D -- "mint" --> USDC_D
+ USDC_D -. "amount" .-> Recipient
+ end
+
+ M_O -. "tokenMessage" .-> Relayer
+ Relayer -- "getOffchainVerifyInfo(tokenMessage)" --> TBCCTP_D
+ TBCCTP_D -. "OffchainLookup" .-> Iris
+ Iris -. "burnMessage, attestation" .-> Relayer
+
+ Relayer -- "process(
+ [burnMessage, attestation],
+ tokenMessage)" --> M_D
+
+ M_D -- "verify([burnMessage, attestation], tokenMessage)" --> TBCCTP_D
+ M_D -- "handle(tokenMessage)" --> TBCCTP_D
+
+ MT_O -. "burnMessage" .-> Iris
+
+ classDef cctp fill:#e3f2fd
+ classDef hyperlane fill:#f3e5f5
+ class MT_O,MT_D,TM_O,TM_D,Iris,USDC_O,USDC_D cctp
+ class M_O,M_D,Relayer hyperlane
+```
+
+## Hook Message
+
+```mermaid
+flowchart LR
+ Iris((Iris))
+ Relayer((Relayer))
+
+ subgraph Origin Chain
+ App
+ M_O[(Mailbox)]
+ TBCCTP_O[TokenBridgeCctp]
+ MT_O[MessageTransmitter]
+
+ App -- "dispatch(hyperlaneMessage)" --> M_O
+ M_O -- "postDispatch(hyperlaneMessage)" --> TBCCTP_O
+ TBCCTP_O -- "sendMessage(hyperlaneMessage.id())" --> MT_O
+ end
+
+ subgraph Destination Chain
+ Recipient[Recipient]
+ TBCCTP_D[TokenBridgeCctp]
+ M_D[(Mailbox)]
+ MT_D[MessageTransmitter]
+
+ TBCCTP_D -- "receiveMessage(
+ cctpMessage,
+ attestation)" --> MT_D
+ MT_D -- "handleReceiveMessage(cctpMessage)" --> TBCCTP_D
+ end
+
+ M_O -. "hyperlaneMessage" .-> Relayer
+ TBCCTP_D -. "interchainSecurityModule()" .- Recipient
+ TBCCTP_D -. "OffchainLookup" .-> Iris
+ Iris -. "cctpMessage, attestation" .-> Relayer
+
+ Relayer -- "getOffchainVerifyInfo(hyperlaneMessage)" --> TBCCTP_D
+ Relayer -- "process(
+ [cctpMessage, attestation],
+ hyperlaneMessage)" --> M_D
+
+ M_D -- "verify([cctpMessage, attestation], hyperlaneMessage)" --> TBCCTP_D
+ M_D -- "handle(hyperlaneMessage)" ----> Recipient
+
+ MT_O -. "cctpMessage" .-> Iris
+
+ classDef cctp fill:#e3f2fd
+ classDef hyperlane fill:#f3e5f5
+ class MT_O,MT_D,TM_O,TM_D,Iris,USDC_O,USDC_D cctp
+ class M_O,M_D,Relayer hyperlane
+```
+
+## Destination Chain Sequence Diagrams
+
+### 1. Token Message with Hyperlane Relayer
+
+```mermaid
+sequenceDiagram
+ participant HR as Hyperlane Relayer
+ participant Mailbox as Mailbox
+ participant TBCCTP as TokenBridgeCctp
+ participant MT as MessageTransmitter
+ participant TM as TokenMessenger
+ participant USDC as USDC
+ participant Recipient as Recipient
+
+ HR->>Mailbox: process([burnMessage, attestation], tokenMessage)
+ Mailbox->>TBCCTP: verify([burnMessage, attestation], tokenMessage)
+ TBCCTP->>MT: receiveMessage(burnMessage, attestation)
+ MT->>TM: handleReceiveMessage(burnMessage)
+ TM->>USDC: mint(amount, recipient)
+ USDC-->>Recipient: amount transferred
+
+ Note over Mailbox: Marks message as delivered
in delivered mapping
+ Mailbox->>TBCCTP: handle(tokenMessage)
+ TBCCTP-->>Recipient: emit event reflecting tokens were transferred
+```
+
+### 2. Token Message with CCTP Relayer and Hyperlane Relayer
+
+```mermaid
+sequenceDiagram
+ participant CR as CCTP Relayer
+ participant HR as Hyperlane Relayer
+ participant Mailbox as Mailbox
+ participant TBCCTP as TokenBridgeCctp
+ participant MT as MessageTransmitter
+ participant TM as TokenMessenger
+ participant USDC as USDC
+ participant Recipient as Recipient
+
+ Note over CR: CCTP Relayer submits
burn message first
+ CR->>TBCCTP: receiveMessage(burnMessage, attestation)
+ TBCCTP->>MT: receiveMessage(burnMessage, attestation)
+ MT->>TM: handleReceiveMessage(burnMessage)
+ TM->>USDC: mint(amount, recipient)
+ USDC-->>Recipient: amount minted
+
+ Note over HR: Hyperlane Relayer delivers
token message
+ HR->>Mailbox: process([], tokenMessage)
+ Mailbox->>TBCCTP: verify([], tokenMessage)
+ TBCCTP-xMailbox: REVERT: Burn message already processed
+ Note over Mailbox: Transaction reverts,
handle never called
+```
+
+### 3. GMP Message with Hyperlane Relayer
+
+```mermaid
+sequenceDiagram
+ participant HR as Hyperlane Relayer
+ participant Mailbox as Mailbox
+ participant TBCCTP as TokenBridgeCctp (ISM)
+ participant MT as MessageTransmitter
+ participant Recipient as Recipient App
+
+ HR->>Mailbox: process([cctpMessage, attestation], hyperlaneMessage)
+ Mailbox->>TBCCTP: verify([cctpMessage, attestation], hyperlaneMessage)
+ TBCCTP->>MT: receiveMessage(cctpMessage, attestation)
+ MT->>TBCCTP: handleReceiveMessage(cctpMessage)
+ Note over TBCCTP: Verifies message ID matches
+
+ Note over Mailbox: Marks message as delivered
in delivered mapping
+ Mailbox->>Recipient: handle(hyperlaneMessage)
+ Note over Recipient: Application receives message
+```
+
+### 4. GMP Message with CCTP Relayer and Hyperlane Relayer
+
+```mermaid
+sequenceDiagram
+ participant CR as CCTP Relayer
+ participant HR as Hyperlane Relayer
+ participant Mailbox as Mailbox
+ participant TBCCTP as TokenBridgeCctp (ISM)
+ participant MT as MessageTransmitter
+ participant Recipient as Recipient App
+
+ Note over CR: CCTP Relayer submits
message first
+ CR->>TBCCTP: receiveMessage(cctpMessage, attestation)
+ TBCCTP->>MT: receiveMessage(cctpMessage, attestation)
+ MT->>TBCCTP: handleReceiveMessage(cctpMessage)
+ Note over TBCCTP: Stores message ID
+
+ Note over HR: Hyperlane Relayer delivers
GMP message
+ HR->>Mailbox: process([], hyperlaneMessage)
+ Mailbox->>TBCCTP: verify([], hyperlaneMessage)
+ Note over TBCCTP: CCTP message already processed,
verifies message ID
+
+ Note over Mailbox: Marks message as delivered
in delivered mapping
+ Mailbox->>Recipient: handle(hyperlaneMessage)
+ Note over Recipient: Application receives message
+```
diff --git a/solidity/contracts/token/HypERC20.sol b/solidity/contracts/token/HypERC20.sol
index 6463193893..08c39a5600 100644
--- a/solidity/contracts/token/HypERC20.sol
+++ b/solidity/contracts/token/HypERC20.sol
@@ -3,7 +3,7 @@ pragma solidity >=0.8.0;
import {TokenRouter} from "./libs/TokenRouter.sol";
import {Quote} from "../interfaces/ITokenBridge.sol";
-import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol";
+import {TokenRouter} from "./libs/TokenRouter.sol";
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
@@ -12,14 +12,14 @@ import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/
* @author Abacus Works
* @dev Supply on each chain is not constant but the aggregate supply across all chains is.
*/
-contract HypERC20 is ERC20Upgradeable, FungibleTokenRouter {
+contract HypERC20 is ERC20Upgradeable, TokenRouter {
uint8 private immutable _decimals;
constructor(
uint8 __decimals,
uint256 _scale,
address _mailbox
- ) FungibleTokenRouter(_scale, _mailbox) {
+ ) TokenRouter(_scale, _mailbox) {
_decimals = __decimals;
}
@@ -36,49 +36,45 @@ contract HypERC20 is ERC20Upgradeable, FungibleTokenRouter {
address _hook,
address _interchainSecurityModule,
address _owner
- ) public virtual initializer {
+ ) public initializer {
// Initialize ERC20 metadata
__ERC20_init(_name, _symbol);
_mint(msg.sender, _totalSupply);
_MailboxClient_initialize(_hook, _interchainSecurityModule, _owner);
}
- function decimals() public view virtual override returns (uint8) {
+ function decimals() public view override returns (uint8) {
return _decimals;
}
- function balanceOf(
- address _account
- )
- public
- view
- virtual
- override(TokenRouter, ERC20Upgradeable)
- returns (uint256)
- {
- return ERC20Upgradeable.balanceOf(_account);
+ // ============ TokenRouter overrides ============
+
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function token() public view override returns (address) {
+ return address(this);
}
/**
- * @dev Burns `_amount` of token from `msg.sender` balance.
* @inheritdoc TokenRouter
+ * @dev Overrides to burn `_amount` of token from `msg.sender` balance.
+ * @dev Known overrides:
+ * - HypERC4626: Converts the amount to shares and burns from the User (via HypERC20 implementation)
*/
- function _transferFromSender(
- uint256 _amount
- ) internal virtual override returns (bytes memory) {
+ // solhint-disable-next-line hyperlane/no-virtual-override
+ function _transferFromSender(uint256 _amount) internal virtual override {
_burn(msg.sender, _amount);
- return bytes(""); // no metadata
}
/**
- * @dev Mints `_amount` of token to `_recipient` balance.
* @inheritdoc TokenRouter
+ * @dev Overrides to mint `_amount` of token to `_recipient` balance.
*/
function _transferTo(
address _recipient,
- uint256 _amount,
- bytes calldata // no metadata
- ) internal virtual override {
+ uint256 _amount
+ ) internal override {
_mint(_recipient, _amount);
}
}
diff --git a/solidity/contracts/token/HypERC20Collateral.sol b/solidity/contracts/token/HypERC20Collateral.sol
index 7cad5340dc..bb9a48835c 100644
--- a/solidity/contracts/token/HypERC20Collateral.sol
+++ b/solidity/contracts/token/HypERC20Collateral.sol
@@ -16,24 +16,23 @@ pragma solidity >=0.8.0;
// ============ Internal Imports ============
import {TokenMessage} from "./libs/TokenMessage.sol";
import {TokenRouter} from "./libs/TokenRouter.sol";
-import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol";
import {MovableCollateralRouter} from "./libs/MovableCollateralRouter.sol";
-import {ValueTransferBridge} from "./interfaces/ValueTransferBridge.sol";
+import {LpCollateralRouter} from "./libs/LpCollateralRouter.sol";
+import {ITokenBridge, Quote} from "../interfaces/ITokenBridge.sol";
+import {ERC20Collateral} from "./libs/TokenCollateral.sol";
// ============ External Imports ============
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
-import {Context} from "@openzeppelin/contracts/utils/Context.sol";
-import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol";
-import {Quote} from "../interfaces/ITokenBridge.sol";
/**
* @title Hyperlane ERC20 Token Collateral that wraps an existing ERC20 with remote transfer functionality.
* @author Abacus Works
*/
-contract HypERC20Collateral is MovableCollateralRouter {
+contract HypERC20Collateral is LpCollateralRouter {
using SafeERC20 for IERC20;
+ using ERC20Collateral for IERC20;
IERC20 public immutable wrappedToken;
@@ -45,7 +44,7 @@ contract HypERC20Collateral is MovableCollateralRouter {
address erc20,
uint256 _scale,
address _mailbox
- ) FungibleTokenRouter(_scale, _mailbox) {
+ ) TokenRouter(_scale, _mailbox) {
require(Address.isContract(erc20), "HypERC20Collateral: invalid token");
wrappedToken = IERC20(erc20);
}
@@ -54,38 +53,34 @@ contract HypERC20Collateral is MovableCollateralRouter {
address _hook,
address _interchainSecurityModule,
address _owner
- ) public virtual initializer {
+ ) public initializer {
_MailboxClient_initialize(_hook, _interchainSecurityModule, _owner);
+ _LpCollateralRouter_initialize();
}
- function balanceOf(
- address _account
- ) external view override returns (uint256) {
- return wrappedToken.balanceOf(_account);
+ function token() public view override returns (address) {
+ return address(wrappedToken);
}
- function quoteTransferRemote(
- uint32 _destinationDomain,
- bytes32 _recipient,
- uint256 _amount
- ) external view virtual override returns (Quote[] memory quotes) {
- quotes = new Quote[](2);
- quotes[0] = Quote({
- token: address(0),
- amount: _quoteGasPayment(_destinationDomain, _recipient, _amount)
- });
- quotes[1] = Quote({token: address(wrappedToken), amount: _amount});
+ function _addBridge(uint32 domain, ITokenBridge bridge) internal override {
+ MovableCollateralRouter._addBridge(domain, bridge);
+ IERC20(wrappedToken).safeApprove(address(bridge), type(uint256).max);
+ }
+
+ function _removeBridge(
+ uint32 domain,
+ ITokenBridge bridge
+ ) internal override {
+ MovableCollateralRouter._removeBridge(domain, bridge);
+ IERC20(wrappedToken).safeApprove(address(bridge), 0);
}
/**
* @dev Transfers `_amount` of `wrappedToken` from `msg.sender` to this contract.
* @inheritdoc TokenRouter
*/
- function _transferFromSender(
- uint256 _amount
- ) internal virtual override returns (bytes memory) {
- wrappedToken.safeTransferFrom(msg.sender, address(this), _amount);
- return bytes(""); // no metadata
+ function _transferFromSender(uint256 _amount) internal override {
+ wrappedToken._transferFromSender(_amount);
}
/**
@@ -94,24 +89,8 @@ contract HypERC20Collateral is MovableCollateralRouter {
*/
function _transferTo(
address _recipient,
- uint256 _amount,
- bytes calldata // no metadata
- ) internal virtual override {
- wrappedToken.safeTransfer(_recipient, _amount);
- }
-
- function _rebalance(
- uint32 domain,
- bytes32 recipient,
- uint256 amount,
- ValueTransferBridge bridge
+ uint256 _amount
) internal override {
- wrappedToken.safeApprove({spender: address(bridge), value: amount});
- MovableCollateralRouter._rebalance({
- domain: domain,
- recipient: recipient,
- amount: amount,
- bridge: bridge
- });
+ wrappedToken._transferTo(_recipient, _amount);
}
}
diff --git a/solidity/contracts/token/HypERC721.sol b/solidity/contracts/token/HypERC721.sol
index ced9020b82..2aef0b6d32 100644
--- a/solidity/contracts/token/HypERC721.sol
+++ b/solidity/contracts/token/HypERC721.sol
@@ -12,7 +12,7 @@ import {ERC721EnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/t
* @author Abacus Works
*/
contract HypERC721 is ERC721EnumerableUpgradeable, TokenRouter {
- constructor(address _mailbox) TokenRouter(_mailbox) {}
+ constructor(address _mailbox) TokenRouter(1, _mailbox) {}
/**
* @notice Initializes the Hyperlane router, ERC721 metadata, and mints initial supply to deployer.
@@ -38,28 +38,28 @@ contract HypERC721 is ERC721EnumerableUpgradeable, TokenRouter {
}
}
- function balanceOf(
- address _account
- )
- public
- view
- virtual
- override(TokenRouter, ERC721Upgradeable, IERC721Upgradeable)
- returns (uint256)
- {
- return ERC721Upgradeable.balanceOf(_account);
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function token() public view override returns (address) {
+ return address(this);
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev NFTs cannot have a fee recipient
+ */
+ function feeRecipient() public view override returns (address) {
+ return address(0);
}
/**
* @dev Asserts `msg.sender` is owner and burns `_tokenId`.
* @inheritdoc TokenRouter
*/
- function _transferFromSender(
- uint256 _tokenId
- ) internal virtual override returns (bytes memory) {
+ function _transferFromSender(uint256 _tokenId) internal override {
require(ownerOf(_tokenId) == msg.sender, "!owner");
_burn(_tokenId);
- return bytes(""); // no metadata
}
/**
@@ -68,9 +68,8 @@ contract HypERC721 is ERC721EnumerableUpgradeable, TokenRouter {
*/
function _transferTo(
address _recipient,
- uint256 _tokenId,
- bytes calldata // no metadata
- ) internal virtual override {
+ uint256 _tokenId
+ ) internal override {
_safeMint(_recipient, _tokenId);
}
}
diff --git a/solidity/contracts/token/HypERC721Collateral.sol b/solidity/contracts/token/HypERC721Collateral.sol
index 37f79367fa..3193ab32e7 100644
--- a/solidity/contracts/token/HypERC721Collateral.sol
+++ b/solidity/contracts/token/HypERC721Collateral.sol
@@ -2,21 +2,26 @@
pragma solidity >=0.8.0;
// ============ Internal Imports ============
-import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {TokenRouter} from "./libs/TokenRouter.sol";
+import {ERC721Collateral} from "./libs/TokenCollateral.sol";
+
+// ============ External Imports ============
+import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
/**
* @title Hyperlane ERC721 Token Collateral that wraps an existing ERC721 with remote transfer functionality.
* @author Abacus Works
*/
contract HypERC721Collateral is TokenRouter {
+ using ERC721Collateral for IERC721;
+
IERC721 public immutable wrappedToken;
/**
* @notice Constructor
* @param erc721 Address of the token to keep as collateral
*/
- constructor(address erc721, address _mailbox) TokenRouter(_mailbox) {
+ constructor(address erc721, address _mailbox) TokenRouter(1, _mailbox) {
wrappedToken = IERC721(erc721);
}
@@ -30,34 +35,31 @@ contract HypERC721Collateral is TokenRouter {
address _hook,
address _interchainSecurityModule,
address _owner
- ) public virtual initializer {
+ ) public initializer {
_MailboxClient_initialize(_hook, _interchainSecurityModule, _owner);
}
- function ownerOf(uint256 _tokenId) external view returns (address) {
- return IERC721(wrappedToken).ownerOf(_tokenId);
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function token() public view override returns (address) {
+ return address(wrappedToken);
}
/**
- * @dev Returns the balance of `_account` for `wrappedToken`.
* @inheritdoc TokenRouter
+ * @dev NFTs cannot have a fee recipient
*/
- function balanceOf(
- address _account
- ) external view override returns (uint256) {
- return IERC721(wrappedToken).balanceOf(_account);
+ function feeRecipient() public view override returns (address) {
+ return address(0);
}
/**
* @dev Transfers `_tokenId` of `wrappedToken` from `msg.sender` to this contract.
* @inheritdoc TokenRouter
*/
- function _transferFromSender(
- uint256 _tokenId
- ) internal virtual override returns (bytes memory) {
- // safeTransferFrom not used here because recipient is this contract
- wrappedToken.transferFrom(msg.sender, address(this), _tokenId);
- return bytes(""); // no metadata
+ function _transferFromSender(uint256 _tokenId) internal override {
+ wrappedToken._transferFromSender(_tokenId);
}
/**
@@ -66,9 +68,8 @@ contract HypERC721Collateral is TokenRouter {
*/
function _transferTo(
address _recipient,
- uint256 _tokenId,
- bytes calldata // no metadata
+ uint256 _tokenId
) internal override {
- wrappedToken.safeTransferFrom(address(this), _recipient, _tokenId);
+ wrappedToken._transferTo(_recipient, _tokenId);
}
}
diff --git a/solidity/contracts/token/HypNative.sol b/solidity/contracts/token/HypNative.sol
index 9fb63ce277..3ec35be504 100644
--- a/solidity/contracts/token/HypNative.sol
+++ b/solidity/contracts/token/HypNative.sol
@@ -1,12 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity >=0.8.0;
+// ============ Internal Imports ============
+import {LpCollateralRouter} from "./libs/LpCollateralRouter.sol";
+import {Quote, ITokenBridge} from "../interfaces/ITokenBridge.sol";
+import {NativeCollateral} from "./libs/TokenCollateral.sol";
import {TokenRouter} from "./libs/TokenRouter.sol";
-import {FungibleTokenRouter} from "./libs/FungibleTokenRouter.sol";
-import {MovableCollateralRouter} from "./libs/MovableCollateralRouter.sol";
-import {ValueTransferBridge} from "./interfaces/ValueTransferBridge.sol";
-import {Quote} from "../interfaces/ITokenBridge.sol";
+// ============ External Imports ============
+import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
/**
@@ -14,21 +16,13 @@ import {Address} from "@openzeppelin/contracts/utils/Address.sol";
* @author Abacus Works
* @dev Supply on each chain is not constant but the aggregate supply across all chains is.
*/
-contract HypNative is MovableCollateralRouter {
- string internal constant INSUFFICIENT_NATIVE_AMOUNT =
- "Native: amount exceeds msg.value";
-
- /**
- * @dev Emitted when native tokens are donated to the contract.
- * @param sender The address of the sender.
- * @param amount The amount of native tokens donated.
- */
- event Donation(address indexed sender, uint256 amount);
+contract HypNative is LpCollateralRouter {
+ using NativeCollateral for address;
constructor(
uint256 _scale,
address _mailbox
- ) FungibleTokenRouter(_scale, _mailbox) {}
+ ) TokenRouter(_scale, _mailbox) {}
/**
* @notice Initializes the Hyperlane router
@@ -40,97 +34,45 @@ contract HypNative is MovableCollateralRouter {
address _hook,
address _interchainSecurityModule,
address _owner
- ) public virtual initializer {
+ ) public initializer {
_MailboxClient_initialize(_hook, _interchainSecurityModule, _owner);
+ _LpCollateralRouter_initialize();
}
- function quoteTransferRemote(
- uint32 _destination,
- bytes32 _recipient,
- uint256 _amount
- ) external view virtual override returns (Quote[] memory quotes) {
- quotes = new Quote[](1);
- quotes[0] = Quote({
- token: address(0),
- amount: _quoteGasPayment(_destination, _recipient, _amount) +
- _amount
- });
- }
-
- function _transferRemote(
- uint32 _destination,
- bytes32 _recipient,
- uint256 _amount,
- uint256 _value,
- bytes memory _hookMetadata,
- address _hook
- ) internal virtual override returns (bytes32 messageId) {
- // include for legible error instead of underflow
- _transferFromSender(_amount);
-
- return
- super._transferRemote(
- _destination,
- _recipient,
- _amount,
- msg.value - _amount,
- _hookMetadata,
- _hook
- );
- }
-
- function balanceOf(
- address _account
- ) external view override returns (uint256) {
- return _account.balance;
+ /**
+ * Replacement for ERC4626Upgradeable.deposit that allows for native token deposits.
+ * @dev msg.value will be used as the amount to deposit.
+ * @param receiver The address to deposit the native token to.
+ * @return shares The number of shares minted.
+ */
+ function deposit(address receiver) public payable returns (uint256 shares) {
+ return ERC4626Upgradeable.deposit(msg.value, receiver);
}
/**
* @inheritdoc TokenRouter
*/
- function _transferFromSender(
- uint256 _amount
- ) internal virtual override returns (bytes memory) {
- require(msg.value >= _amount, "Native: amount exceeds msg.value");
- return bytes(""); // no metadata
+ function token() public pure override returns (address) {
+ return address(0);
}
/**
- * @dev Sends `_amount` of native token to `_recipient` balance.
* @inheritdoc TokenRouter
*/
- function _transferTo(
- address _recipient,
- uint256 _amount,
- bytes calldata // no metadata
- ) internal virtual override {
- Address.sendValue(payable(_recipient), _amount);
- }
-
- receive() external payable {
- emit Donation(msg.sender, msg.value);
+ function _transferFromSender(uint256 _amount) internal override {
+ NativeCollateral._transferFromSender(_amount);
}
/**
- * @dev This function uses `msg.value` as payment for the bridge.
- * User collateral is never used to make bridge payments!
- * The rebalancer is to pay all fees for the bridge.
+ * @inheritdoc TokenRouter
*/
- function _rebalance(
- uint32 domain,
- bytes32 recipient,
- uint256 amount,
- ValueTransferBridge bridge
+ function _transferTo(
+ address _recipient,
+ uint256 _amount
) internal override {
- uint fee = msg.value + amount;
- require(
- address(this).balance >= fee,
- "Native: rebalance amount exceeds balance"
- );
- bridge.transferRemote{value: fee}({
- destinationDomain: domain,
- recipient: recipient,
- amountOut: amount
- });
+ NativeCollateral._transferTo(_recipient, _amount);
}
+
+ // allow receiving native tokens for collateral rebalancing
+ receive() external payable {}
}
diff --git a/solidity/contracts/token/README.md b/solidity/contracts/token/README.md
index 1fbc80cf7d..945dd93e4e 100644
--- a/solidity/contracts/token/README.md
+++ b/solidity/contracts/token/README.md
@@ -53,7 +53,7 @@ The Token Router contract comes in several flavors and a warp route can be compo
Warp routes are unique amongst token bridging solutions because they provide modular security. Because the `TokenRouter` implements the `IMessageRecipient` interface, it can be configured with a custom interchain security module. Please refer to the relevant guide to specifying interchain security modules on the [Messaging API receive docs](https://docs.hyperlane.xyz/docs/reference/messaging/messaging-interface).
-## Remote Transfer Lifecycle Diagrams
+## Remote Transfer Lifecycle
To initiate a remote transfer, users call the `TokenRouter.transferRemote` function with the `destination` chain ID, `recipient` address, and transfer `amount`.
@@ -220,12 +220,58 @@ graph TB
**NOTE:** ERC721 collateral variants are assumed to [enumerable](https://docs.openzeppelin.com/contracts/4.x/api/token/erc721#IERC721Enumerable) and [metadata](https://docs.openzeppelin.com/contracts/4.x/api/token/erc721#IERC721Metadata) compliant.
-## Versions
+## Bridging Fees
-| Git Ref | Release Date | Notes |
-| ------------------------ | ------------ | ------------------------------ |
-| [audit-v2-remediation]() | 2023-02-15 | Hyperlane V2 Audit remediation |
-| [main]() | ~ | Bleeding edge |
+Warp routes may charge additional fees for bridging to cover the costs of relaying, security, and liquidity management.
+
+To quote the fees charged by a warp route, users call the `TokenRouter.quoteTransferRemote` function with the same parameters to `transferRemote`.
+
+```solidity
+struct Quote {
+ address token; // address(0) for the native token
+ uint256 amount;
+}
+
+interface TokenRouter {
+ function quoteTransferRemote(
+ uint32 destination,
+ bytes32 recipient,
+ uint256 amount
+ ) public returns (Quote[] quotes);
+}
+```
+
+We recommend performing this quote offchain and populating the value and token approvals accordingly. If you must quote onchain, there is a [`Quotes` utility library](./libs/Quotes.sol) for extracting the fees charged in specific denominations.
+
+### Funding Pseudocode
+
+```solidity
+Quotes[] memory quotes = tokenRouter.quoteTransferRemote(destination, recipient, amount);
+
+uint256 nativeFee = quotes.extract(address(0));
+
+address token = tokenRouter.token();
+uint256 tokenFee = quotes.extract(token);
+IERC20(token).approve(tokenRouter, tokenFee);
+
+tokenRouter.transferRemote{value: nativeFee}(destination, recipient, amount);
+```
+
+### Fee Recipients
+
+Warp routes have configurable fees/fee recipients which are a function of the `transferRemote` parameters.
+
+```solidity
+interface TokenRouter {
+ function feeRecipient() public view returns (address);
+}
+```
+
+These fees will be surfaced in the `quoteTransferRemote` API response (if configured). These fees are charged at `transferRemote` time through the `TokenRouter` such that the above funding strategy applies.
+
+### External Fees
+
+Warp routes may wrap external bridges like [CCTP V2](./TokenBridgeCctpV2.sol) or [Everclear](./bridge/EverclearTokenBridge.sol) that have their own fee models. These are also exposed in the `quoteTransferRemote` API and charged at `transferRemote` time before being forwarded to the external bridge.
## Learn more
diff --git a/solidity/contracts/token/TokenBridgeCctp.sol b/solidity/contracts/token/TokenBridgeCctp.sol
deleted file mode 100644
index 6d8d2c7e39..0000000000
--- a/solidity/contracts/token/TokenBridgeCctp.sol
+++ /dev/null
@@ -1,253 +0,0 @@
-// SPDX-License-Identifier: MIT OR Apache-2.0
-pragma solidity >=0.8.0;
-
-import {TokenRouter} from "./libs/TokenRouter.sol";
-import {HypERC20Collateral} from "./HypERC20Collateral.sol";
-import {IMessageTransmitter} from "./../interfaces/cctp/IMessageTransmitter.sol";
-import {IInterchainSecurityModule} from "./../interfaces/IInterchainSecurityModule.sol";
-import {AbstractCcipReadIsm} from "./../isms/ccip-read/AbstractCcipReadIsm.sol";
-import {TypedMemView} from "./../libs/TypedMemView.sol";
-import {ITokenMessenger} from "./../interfaces/cctp/ITokenMessenger.sol";
-import {Message} from "./../libs/Message.sol";
-import {TokenMessage} from "./libs/TokenMessage.sol";
-import {CctpMessage, BurnMessage} from "../libs/CctpMessage.sol";
-
-interface CctpService {
- function getCCTPAttestation(
- bytes calldata _message
- )
- external
- view
- returns (bytes memory cctpMessage, bytes memory attestation);
-}
-
-// TokenMessage.metadata := uint8 cctpNonce
-uint256 constant CCTP_TOKEN_BRIDGE_MESSAGE_LEN = TokenMessage.METADATA_OFFSET +
- 8;
-
-// @dev Supports only CCTP V1
-contract TokenBridgeCctp is HypERC20Collateral, AbstractCcipReadIsm {
- using CctpMessage for bytes29;
- using BurnMessage for bytes29;
-
- using Message for bytes;
-
- uint32 internal constant CCTP_VERSION = 0;
-
- // @notice CCTP message transmitter contract
- IMessageTransmitter public immutable messageTransmitter;
-
- // @notice CCTP token messenger contract
- ITokenMessenger public immutable tokenMessenger;
-
- struct Domain {
- uint32 hyperlane;
- uint32 circle;
- }
-
- /// @notice Hyperlane domain => Circle domain.
- /// We use a struct to avoid ambiguity with domain 0 being unknown.
- mapping(uint32 hypDomain => Domain circleDomain) internal _domainMap;
-
- /**
- * @notice Emitted when the Hyperlane domain to Circle domain mapping is updated.
- * @param hyperlaneDomain The Hyperlane domain.
- * @param circleDomain The Circle domain.
- */
- event DomainAdded(uint32 indexed hyperlaneDomain, uint32 circleDomain);
-
- constructor(
- address _erc20,
- uint256 _scale,
- address _mailbox,
- IMessageTransmitter _messageTransmitter,
- ITokenMessenger _tokenMessenger
- ) HypERC20Collateral(_erc20, _scale, _mailbox) {
- require(
- _messageTransmitter.version() == CCTP_VERSION,
- "Invalid messageTransmitter CCTP version"
- );
- messageTransmitter = _messageTransmitter;
-
- require(
- _tokenMessenger.messageBodyVersion() == CCTP_VERSION,
- "Invalid TokenMessenger CCTP version"
- );
- tokenMessenger = _tokenMessenger;
-
- _disableInitializers();
- }
-
- function initialize(
- address _hook,
- address _owner,
- string[] memory __urls
- ) external virtual initializer {
- __Ownable_init();
- setUrls(__urls);
- // ISM should not be set
- _MailboxClient_initialize(_hook, address(0), _owner);
- wrappedToken.approve(address(tokenMessenger), type(uint256).max);
- }
-
- function initialize(
- address _hook,
- address _interchainSecurityModule,
- address _owner
- ) public override {
- revert("Only TokenBridgeCctp.initialize() may be called");
- }
-
- function interchainSecurityModule()
- external
- view
- override
- returns (IInterchainSecurityModule)
- {
- return IInterchainSecurityModule(address(this));
- }
-
- /**
- * @notice Adds a new mapping between a Hyperlane domain and a Circle domain.
- * @param _hyperlaneDomain The Hyperlane domain.
- * @param _circleDomain The Circle domain.
- */
- function addDomain(
- uint32 _hyperlaneDomain,
- uint32 _circleDomain
- ) public onlyOwner {
- _domainMap[_hyperlaneDomain] = Domain(_hyperlaneDomain, _circleDomain);
-
- emit DomainAdded(_hyperlaneDomain, _circleDomain);
- }
-
- function addDomains(Domain[] memory domains) external onlyOwner {
- for (uint32 i = 0; i < domains.length; i++) {
- addDomain(domains[i].hyperlane, domains[i].circle);
- }
- }
-
- function hyperlaneDomainToCircleDomain(
- uint32 _hyperlaneDomain
- ) public view returns (uint32) {
- Domain memory domain = _domainMap[_hyperlaneDomain];
- require(
- domain.hyperlane == _hyperlaneDomain,
- "Circle domain not configured"
- );
-
- return domain.circle;
- }
-
- // @dev Enforces that the CCTP message source domain and nonce matches the Hyperlane message origin and nonce.
- function verify(
- bytes calldata _metadata,
- bytes calldata _hyperlaneMessage
- ) external returns (bool) {
- // decode return type of CctpService.getCCTPAttestation
- (bytes memory cctpMessage, bytes memory attestation) = abi.decode(
- _metadata,
- (bytes, bytes)
- );
-
- bytes calldata tokenMessage = _hyperlaneMessage.body();
- _validateMessageLength(tokenMessage);
-
- bytes29 originalMsg = TypedMemView.ref(cctpMessage, 0);
-
- bytes32 sourceSender = originalMsg._sender();
- require(sourceSender == _hyperlaneMessage.sender(), "Invalid sender");
-
- bytes29 burnMessage = originalMsg._messageBody();
- require(
- TokenMessage.amount(tokenMessage) == burnMessage._getAmount(),
- "Invalid amount"
- );
- require(
- TokenMessage.recipient(tokenMessage) ==
- burnMessage._getMintRecipient(),
- "Invalid recipient"
- );
-
- uint32 sourceDomain = originalMsg._sourceDomain();
- require(
- sourceDomain ==
- hyperlaneDomainToCircleDomain(_hyperlaneMessage.origin()),
- "Invalid source domain"
- );
-
- uint64 sourceNonce = originalMsg._nonce();
- require(
- sourceNonce == uint64(bytes8(TokenMessage.metadata(tokenMessage))),
- "Invalid nonce"
- );
-
- // Receive only if the nonce hasn't been used before
- bytes32 sourceAndNonceHash = keccak256(
- abi.encodePacked(sourceDomain, sourceNonce)
- );
- if (messageTransmitter.usedNonces(sourceAndNonceHash) == 0) {
- messageTransmitter.receiveMessage(cctpMessage, attestation);
- }
-
- return true;
- }
-
- function _transferRemote(
- uint32 _destination,
- bytes32 _recipient,
- uint256 _amount,
- uint256 _value,
- bytes memory _hookMetadata,
- address _hook
- ) internal virtual override returns (bytes32 messageId) {
- HypERC20Collateral._transferFromSender(_amount);
-
- uint32 circleDomain = hyperlaneDomainToCircleDomain(_destination);
- uint64 nonce = tokenMessenger.depositForBurn(
- _amount,
- circleDomain,
- _recipient,
- address(wrappedToken)
- );
-
- uint256 outboundAmount = _outboundAmount(_amount);
- bytes memory _tokenMessage = TokenMessage.format(
- _recipient,
- outboundAmount,
- abi.encodePacked(nonce)
- );
- _validateMessageLength(_tokenMessage);
-
- messageId = _Router_dispatch(
- _destination,
- _value,
- _tokenMessage,
- _hookMetadata,
- _hook
- );
-
- emit SentTransferRemote(_destination, _recipient, outboundAmount);
- }
-
- function _offchainLookupCalldata(
- bytes calldata _message
- ) internal pure override returns (bytes memory) {
- return abi.encodeCall(CctpService.getCCTPAttestation, (_message));
- }
-
- function _transferTo(
- address _recipient,
- uint256 _amount,
- bytes calldata metadata
- ) internal override {
- // do not transfer to recipient as the CCTP transfer will do it
- }
-
- function _validateMessageLength(bytes memory _tokenMessage) internal pure {
- require(
- _tokenMessage.length == CCTP_TOKEN_BRIDGE_MESSAGE_LEN,
- "Invalid message body length"
- );
- }
-}
diff --git a/solidity/contracts/token/TokenBridgeCctpBase.sol b/solidity/contracts/token/TokenBridgeCctpBase.sol
new file mode 100644
index 0000000000..f47c40337c
--- /dev/null
+++ b/solidity/contracts/token/TokenBridgeCctpBase.sol
@@ -0,0 +1,386 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+pragma solidity >=0.8.0;
+
+import {TokenRouter} from "./libs/TokenRouter.sol";
+import {HypERC20Collateral} from "./HypERC20Collateral.sol";
+import {IMessageTransmitter} from "./../interfaces/cctp/IMessageTransmitter.sol";
+import {IInterchainSecurityModule} from "./../interfaces/IInterchainSecurityModule.sol";
+import {AbstractCcipReadIsm} from "./../isms/ccip-read/AbstractCcipReadIsm.sol";
+import {TypedMemView} from "./../libs/TypedMemView.sol";
+import {ITokenMessenger} from "./../interfaces/cctp/ITokenMessenger.sol";
+import {Message} from "./../libs/Message.sol";
+import {TokenMessage} from "./libs/TokenMessage.sol";
+import {IPostDispatchHook} from "../interfaces/hooks/IPostDispatchHook.sol";
+import {StandardHookMetadata} from "../hooks/libs/StandardHookMetadata.sol";
+import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol";
+import {TypeCasts} from "../libs/TypeCasts.sol";
+import {MovableCollateralRouter, MovableCollateralRouterStorage} from "./libs/MovableCollateralRouter.sol";
+import {TokenRouter} from "./libs/TokenRouter.sol";
+import {AbstractPostDispatchHook} from "../hooks/libs/AbstractPostDispatchHook.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+
+interface CctpService {
+ function getCCTPAttestation(
+ bytes calldata _message
+ )
+ external
+ view
+ returns (bytes memory cctpMessage, bytes memory attestation);
+}
+
+// need intermediate contract to insert slots between TokenRouter and AbstractCcipReadIsm
+abstract contract TokenBridgeCctpBaseStorage is TokenRouter {
+ /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to.
+ MovableCollateralRouterStorage private __MOVABLE_COLLATERAL_GAP;
+}
+
+struct Domain {
+ uint32 hyperlane;
+ uint32 circle;
+}
+
+// see ./CCTP.md for sequence diagrams of the destination chain control flow
+abstract contract TokenBridgeCctpBase is
+ TokenBridgeCctpBaseStorage,
+ AbstractCcipReadIsm,
+ AbstractPostDispatchHook
+{
+ using Message for bytes;
+ using TypeCasts for bytes32;
+ using SafeERC20 for IERC20;
+
+ uint256 private constant _SCALE = 1;
+
+ IERC20 public immutable wrappedToken;
+
+ // @notice CCTP message transmitter contract
+ IMessageTransmitter public immutable messageTransmitter;
+
+ // @notice CCTP token messenger contract
+ ITokenMessenger public immutable tokenMessenger;
+
+ /// @notice Hyperlane domain => Domain struct.
+ /// We use a struct to avoid ambiguity with domain 0 being unknown.
+ mapping(uint32 hypDomain => Domain circleDomain)
+ internal _hyperlaneDomainMap;
+
+ /// @notice Circle domain => Domain struct.
+ // We use a struct to avoid ambiguity with domain 0 being unknown.
+ mapping(uint32 circleDomain => Domain hyperlaneDomain)
+ internal _circleDomainMap;
+
+ /// @notice Maps messageId to whether or not the message has been verified
+ /// by the CCTP message transmitter
+ mapping(bytes32 messageId => bool verified) public isVerified;
+
+ /**
+ * @notice Emitted when the Hyperlane domain to Circle domain mapping is updated.
+ * @param hyperlaneDomain The Hyperlane domain.
+ * @param circleDomain The Circle domain.
+ */
+ event DomainAdded(uint32 indexed hyperlaneDomain, uint32 circleDomain);
+
+ constructor(
+ address _erc20,
+ address _mailbox,
+ IMessageTransmitter _messageTransmitter,
+ ITokenMessenger _tokenMessenger
+ ) TokenRouter(_SCALE, _mailbox) {
+ require(
+ _messageTransmitter.version() == _getCCTPVersion(),
+ "Invalid messageTransmitter CCTP version"
+ );
+ messageTransmitter = _messageTransmitter;
+
+ require(
+ _tokenMessenger.messageBodyVersion() == _getCCTPVersion(),
+ "Invalid TokenMessenger CCTP version"
+ );
+ tokenMessenger = _tokenMessenger;
+
+ wrappedToken = IERC20(_erc20);
+
+ _disableInitializers();
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function token() public view override returns (address) {
+ return address(wrappedToken);
+ }
+
+ function initialize(
+ address _hook,
+ address _owner,
+ string[] memory __urls
+ ) external initializer {
+ // ISM should not be set
+ _MailboxClient_initialize(_hook, address(0), _owner);
+
+ // Setup urls for offchain lookup and do token approval
+ setUrls(__urls);
+ wrappedToken.approve(address(tokenMessenger), type(uint256).max);
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to bridge the tokens via Circle.
+ */
+ function transferRemote(
+ uint32 _destination,
+ bytes32 _recipient,
+ uint256 _amount
+ ) public payable override returns (bytes32 messageId) {
+ // 1. Calculate the fee amounts, charge the sender and distribute to feeRecipient if necessary
+ (
+ uint256 externalFee,
+ uint256 remainingNativeValue
+ ) = _calculateFeesAndCharge(
+ _destination,
+ _recipient,
+ _amount,
+ msg.value
+ );
+
+ // 2. Prepare the token message with the recipient, amount, and any additional metadata in overrides
+ uint32 circleDomain = hyperlaneDomainToCircleDomain(_destination);
+ uint256 burnAmount = _amount + externalFee;
+ _bridgeViaCircle(circleDomain, _recipient, burnAmount, externalFee);
+
+ bytes memory _message = TokenMessage.format(_recipient, burnAmount);
+ // 3. Emit the SentTransferRemote event and 4. dispatch the message
+ return
+ _emitAndDispatch(
+ _destination,
+ _recipient,
+ _amount, // no scaling needed for CCTP
+ remainingNativeValue,
+ _message
+ );
+ }
+
+ function interchainSecurityModule()
+ external
+ view
+ override
+ returns (IInterchainSecurityModule)
+ {
+ return IInterchainSecurityModule(address(this));
+ }
+
+ /**
+ * @notice Adds a new mapping between a Hyperlane domain and a Circle domain.
+ * @param _hyperlaneDomain The Hyperlane domain.
+ * @param _circleDomain The Circle domain.
+ */
+ function addDomain(
+ uint32 _hyperlaneDomain,
+ uint32 _circleDomain
+ ) public onlyOwner {
+ _hyperlaneDomainMap[_hyperlaneDomain] = Domain(
+ _hyperlaneDomain,
+ _circleDomain
+ );
+ _circleDomainMap[_circleDomain] = Domain(
+ _hyperlaneDomain,
+ _circleDomain
+ );
+
+ emit DomainAdded(_hyperlaneDomain, _circleDomain);
+ }
+
+ function addDomains(Domain[] memory domains) external onlyOwner {
+ for (uint32 i = 0; i < domains.length; i++) {
+ addDomain(domains[i].hyperlane, domains[i].circle);
+ }
+ }
+
+ function hyperlaneDomainToCircleDomain(
+ uint32 _hyperlaneDomain
+ ) public view returns (uint32) {
+ Domain memory domain = _hyperlaneDomainMap[_hyperlaneDomain];
+ require(
+ domain.hyperlane == _hyperlaneDomain,
+ "Circle domain not configured"
+ );
+
+ return domain.circle;
+ }
+
+ function circleDomainToHyperlaneDomain(
+ uint32 _circleDomain
+ ) public view returns (uint32) {
+ Domain memory domain = _circleDomainMap[_circleDomain];
+ require(
+ domain.circle == _circleDomain,
+ "Hyperlane domain not configured"
+ );
+
+ return domain.hyperlane;
+ }
+
+ function _getCCTPVersion() internal pure virtual returns (uint32);
+
+ function _getCircleRecipient(
+ bytes29 cctpMessage
+ ) internal pure virtual returns (address);
+
+ function _validateTokenMessage(
+ bytes calldata hyperlaneMessage,
+ bytes29 cctpMessage
+ ) internal pure virtual;
+
+ function _validateHookMessage(
+ bytes calldata hyperlaneMessage,
+ bytes29 cctpMessage
+ ) internal pure virtual;
+
+ function _sendMessageIdToIsm(
+ uint32 destinationDomain,
+ bytes32 ism,
+ bytes32 messageId
+ ) internal virtual;
+
+ /**
+ * @dev Verifies that the CCTP message matches the Hyperlane message.
+ */
+ function verify(
+ bytes calldata _metadata,
+ bytes calldata _hyperlaneMessage
+ ) external returns (bool) {
+ // check if hyperlane message has already been verified by CCTP
+ if (isVerified[_hyperlaneMessage.id()]) {
+ return true;
+ }
+
+ // decode return type of CctpService.getCCTPAttestation
+ (bytes memory cctpMessageBytes, bytes memory attestation) = abi.decode(
+ _metadata,
+ (bytes, bytes)
+ );
+
+ bytes29 cctpMessage = TypedMemView.ref(cctpMessageBytes, 0);
+ address circleRecipient = _getCircleRecipient(cctpMessage);
+ // check if CCTP message is a USDC burn message
+ if (circleRecipient == address(tokenMessenger)) {
+ // prevent hyperlane message recipient configured with CCTP ISM
+ // from verifying and handling token messages
+ require(
+ _hyperlaneMessage.recipientAddress() == address(this),
+ "Invalid token message recipient"
+ );
+ _validateTokenMessage(_hyperlaneMessage, cctpMessage);
+ }
+ // check if CCTP message is a GMP message to this contract
+ else if (circleRecipient == address(this)) {
+ _validateHookMessage(_hyperlaneMessage, cctpMessage);
+ }
+ // disallow other CCTP message destinations
+ else {
+ revert("Invalid circle recipient");
+ }
+
+ // for GMP messages, this.verifiedMessages[hyperlaneMessage.id()] will be set
+ // for token messages, hyperlaneMessage.body().amount() tokens will be delivered to hyperlaneMessage.body().recipient()
+ return messageTransmitter.receiveMessage(cctpMessageBytes, attestation);
+ }
+
+ function _receiveMessageId(
+ uint32 circleSource,
+ bytes32 circleSender,
+ bytes32 messageId
+ ) internal returns (bool) {
+ require(
+ msg.sender == address(messageTransmitter),
+ "Not message transmitter"
+ );
+
+ // ensure that the message was sent from the hook on the origin chain
+ uint32 origin = circleDomainToHyperlaneDomain(circleSource);
+ require(
+ _mustHaveRemoteRouter(origin) == circleSender,
+ "Unauthorized circle sender"
+ );
+
+ isVerified[messageId] = true;
+
+ return true;
+ }
+
+ function _offchainLookupCalldata(
+ bytes calldata _message
+ ) internal pure override returns (bytes memory) {
+ return abi.encodeCall(CctpService.getCCTPAttestation, (_message));
+ }
+
+ /// @inheritdoc IPostDispatchHook
+ function hookType() external pure override returns (uint8) {
+ return uint8(IPostDispatchHook.HookTypes.CCTP);
+ }
+
+ /// @inheritdoc AbstractPostDispatchHook
+ function _quoteDispatch(
+ bytes calldata /*metadata*/,
+ bytes calldata /*message*/
+ ) internal pure override returns (uint256) {
+ return 0;
+ }
+
+ /// @inheritdoc AbstractPostDispatchHook
+ /// @dev Mirrors the logic in AbstractMessageIdAuthHook._postDispatch
+ // but using Router table instead of hook <> ISM coupling
+ function _postDispatch(
+ bytes calldata metadata,
+ bytes calldata message
+ ) internal override {
+ bytes32 id = message.id();
+ require(_isLatestDispatched(id), "Message not dispatched");
+
+ uint32 destination = message.destination();
+ bytes32 ism = _mustHaveRemoteRouter(destination);
+ uint32 circleDestination = hyperlaneDomainToCircleDomain(destination);
+
+ _sendMessageIdToIsm(circleDestination, ism, id);
+
+ _refund(metadata, message, address(this).balance);
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to transfer the tokens from the sender to this contract (like HypERC20Collateral).
+ */
+ function _transferFromSender(uint256 _amount) internal override {
+ wrappedToken.safeTransferFrom(msg.sender, address(this), _amount);
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to not transfer the tokens to the recipient, as the CCTP transfer will do it.
+ */
+ function _transferTo(
+ address _recipient,
+ uint256 _amount
+ ) internal override {
+ // do not transfer to recipient as the CCTP transfer will do it
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to transfer fees directly from the router balance since CCTP handles token delivery.
+ */
+ function _transferFee(
+ address _recipient,
+ uint256 _amount
+ ) internal override {
+ wrappedToken.safeTransfer(_recipient, _amount);
+ }
+
+ function _bridgeViaCircle(
+ uint32 _destination,
+ bytes32 _recipient,
+ uint256 _amount,
+ uint256 _maxFee
+ ) internal virtual;
+}
diff --git a/solidity/contracts/token/TokenBridgeCctpV1.sol b/solidity/contracts/token/TokenBridgeCctpV1.sol
new file mode 100644
index 0000000000..ebd385dce0
--- /dev/null
+++ b/solidity/contracts/token/TokenBridgeCctpV1.sol
@@ -0,0 +1,121 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+pragma solidity >=0.8.0;
+
+import {TokenBridgeCctpBase} from "./TokenBridgeCctpBase.sol";
+import {TypedMemView} from "./../libs/TypedMemView.sol";
+import {Message} from "./../libs/Message.sol";
+import {TokenMessage} from "./libs/TokenMessage.sol";
+import {CctpMessageV1, BurnMessageV1} from "../libs/CctpMessageV1.sol";
+import {TypeCasts} from "../libs/TypeCasts.sol";
+import {IMessageHandler} from "../interfaces/cctp/IMessageHandler.sol";
+import {ITokenMessengerV1} from "../interfaces/cctp/ITokenMessenger.sol";
+import {IMessageTransmitter} from "../interfaces/cctp/IMessageTransmitter.sol";
+
+// @dev Supports only CCTP V1
+contract TokenBridgeCctpV1 is TokenBridgeCctpBase, IMessageHandler {
+ using CctpMessageV1 for bytes29;
+ using BurnMessageV1 for bytes29;
+ using TypedMemView for bytes29;
+
+ using Message for bytes;
+ using TypeCasts for bytes32;
+
+ constructor(
+ address _erc20,
+ address _mailbox,
+ IMessageTransmitter _messageTransmitter,
+ ITokenMessengerV1 _tokenMessenger
+ )
+ TokenBridgeCctpBase(
+ _erc20,
+ _mailbox,
+ _messageTransmitter,
+ _tokenMessenger
+ )
+ {}
+
+ function _getCCTPVersion() internal pure override returns (uint32) {
+ return 0;
+ }
+
+ function _getCircleRecipient(
+ bytes29 cctpMessage
+ ) internal pure override returns (address) {
+ return cctpMessage._recipient().bytes32ToAddress();
+ }
+
+ function _validateTokenMessage(
+ bytes calldata hyperlaneMessage,
+ bytes29 cctpMessage
+ ) internal pure override {
+ bytes29 burnMessage = cctpMessage._messageBody();
+ burnMessage._validateBurnMessageFormat();
+
+ bytes32 circleBurnSender = burnMessage._getMessageSender();
+ require(
+ circleBurnSender == hyperlaneMessage.sender(),
+ "Invalid burn sender"
+ );
+
+ bytes calldata tokenMessage = hyperlaneMessage.body();
+
+ require(
+ TokenMessage.amount(tokenMessage) == burnMessage._getAmount(),
+ "Invalid mint amount"
+ );
+
+ require(
+ TokenMessage.recipient(tokenMessage) ==
+ burnMessage._getMintRecipient(),
+ "Invalid mint recipient"
+ );
+ }
+
+ function _validateHookMessage(
+ bytes calldata hyperlaneMessage,
+ bytes29 cctpMessage
+ ) internal pure override {
+ bytes32 circleMessageId = cctpMessage._messageBody().index(0, 32);
+ require(circleMessageId == hyperlaneMessage.id(), "Invalid message id");
+ }
+
+ /// @inheritdoc IMessageHandler
+ function handleReceiveMessage(
+ uint32 sourceDomain,
+ bytes32 sender,
+ bytes calldata body
+ ) external override returns (bool) {
+ return
+ _receiveMessageId(
+ sourceDomain,
+ sender,
+ abi.decode(body, (bytes32))
+ );
+ }
+
+ function _sendMessageIdToIsm(
+ uint32 destinationDomain,
+ bytes32 ism,
+ bytes32 messageId
+ ) internal override {
+ IMessageTransmitter(messageTransmitter).sendMessage(
+ destinationDomain,
+ ism,
+ abi.encode(messageId)
+ );
+ }
+
+ function _bridgeViaCircle(
+ uint32 circleDomain,
+ bytes32 _recipient,
+ uint256 _amount,
+ uint256 /*_maxFee*/ // not used for CCTP V1
+ ) internal override {
+ ITokenMessengerV1(address(tokenMessenger)).depositForBurn(
+ _amount,
+ circleDomain,
+ _recipient,
+ address(wrappedToken)
+ );
+ }
+}
diff --git a/solidity/contracts/token/TokenBridgeCctpV2.sol b/solidity/contracts/token/TokenBridgeCctpV2.sol
new file mode 100644
index 0000000000..5d17e41e82
--- /dev/null
+++ b/solidity/contracts/token/TokenBridgeCctpV2.sol
@@ -0,0 +1,188 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+pragma solidity >=0.8.0;
+
+import {TokenBridgeCctpBase} from "./TokenBridgeCctpBase.sol";
+import {TokenRouter} from "./libs/TokenRouter.sol";
+import {TypedMemView} from "./../libs/TypedMemView.sol";
+import {Message} from "./../libs/Message.sol";
+import {TokenMessage} from "./libs/TokenMessage.sol";
+import {CctpMessageV2, BurnMessageV2} from "../libs/CctpMessageV2.sol";
+import {TypeCasts} from "../libs/TypeCasts.sol";
+import {IMessageHandlerV2} from "../interfaces/cctp/IMessageHandlerV2.sol";
+import {ITokenMessengerV2} from "../interfaces/cctp/ITokenMessengerV2.sol";
+import {IMessageTransmitterV2} from "../interfaces/cctp/IMessageTransmitterV2.sol";
+
+// @dev Supports only CCTP V2
+contract TokenBridgeCctpV2 is TokenBridgeCctpBase, IMessageHandlerV2 {
+ using CctpMessageV2 for bytes29;
+ using BurnMessageV2 for bytes29;
+ using TypedMemView for bytes29;
+
+ using Message for bytes;
+ using TypeCasts for bytes32;
+
+ // see https://developers.circle.com/cctp/cctp-finality-and-fees#defined-finality-thresholds
+ uint32 public immutable minFinalityThreshold;
+ uint256 public immutable maxFeeBps;
+
+ constructor(
+ address _erc20,
+ address _mailbox,
+ IMessageTransmitterV2 _messageTransmitter,
+ ITokenMessengerV2 _tokenMessenger,
+ uint256 _maxFeeBps,
+ uint32 _minFinalityThreshold
+ )
+ TokenBridgeCctpBase(
+ _erc20,
+ _mailbox,
+ _messageTransmitter,
+ _tokenMessenger
+ )
+ {
+ require(_maxFeeBps < 10_000, "maxFeeBps must be less than 100%");
+ maxFeeBps = _maxFeeBps;
+ minFinalityThreshold = _minFinalityThreshold;
+ }
+
+ // ============ TokenRouter overrides ============
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to indicate v2 fees.
+ *
+ * Hyperlane uses a "minimum amount out" approach where users specify the exact amount
+ * they want the recipient to receive on the destination chain. This provides a better
+ * UX by guaranteeing predictable outcomes regardless of underlying bridge fee structures.
+ *
+ * However, some underlying bridges like CCTP charge fees as a percentage of the input
+ * amount (amountIn), not the output amount. This requires "reversing" the fee calculation:
+ * we need to determine what input amount (after fees are deducted) will result in the
+ * desired output amount reaching the recipient.
+ *
+ * The formula solves for the fee needed such that after Circle takes their percentage,
+ * the recipient receives exactly `amount`:
+ *
+ * (amount + fee) * (10_000 - maxFeeBps) / 10_000 = amount
+ *
+ * Solving for fee:
+ * fee = (amount * maxFeeBps) / (10_000 - maxFeeBps)
+ *
+ * Example: If amount = 100 USDC and maxFeeBps = 10 (0.1%):
+ * fee = (100 * 10) / (10_000 - 10) = 1000 / 9990 ≈ 0.1001 USDC
+ * We deposit 100.1001 USDC, Circle takes 0.1001 USDC, recipient gets exactly 100 USDC.
+ */
+ function _externalFeeAmount(
+ uint32,
+ bytes32,
+ uint256 amount
+ ) internal view override returns (uint256 feeAmount) {
+ return (amount * maxFeeBps) / (10_000 - maxFeeBps);
+ }
+
+ function _getCCTPVersion() internal pure override returns (uint32) {
+ return 1;
+ }
+
+ function _getCircleRecipient(
+ bytes29 cctpMessage
+ ) internal pure override returns (address) {
+ return cctpMessage._getRecipient().bytes32ToAddress();
+ }
+
+ function _validateTokenMessage(
+ bytes calldata hyperlaneMessage,
+ bytes29 cctpMessage
+ ) internal pure override {
+ bytes29 burnMessage = cctpMessage._getMessageBody();
+ burnMessage._validateBurnMessageFormat();
+
+ bytes32 circleBurnSender = burnMessage._getMessageSender();
+ require(
+ circleBurnSender == hyperlaneMessage.sender(),
+ "Invalid burn sender"
+ );
+
+ bytes calldata tokenMessage = hyperlaneMessage.body();
+
+ require(
+ TokenMessage.amount(tokenMessage) == burnMessage._getAmount(),
+ "Invalid mint amount"
+ );
+
+ require(
+ TokenMessage.recipient(tokenMessage) ==
+ burnMessage._getMintRecipient(),
+ "Invalid mint recipient"
+ );
+ }
+
+ function _validateHookMessage(
+ bytes calldata hyperlaneMessage,
+ bytes29 cctpMessage
+ ) internal pure override {
+ bytes32 circleMessageId = cctpMessage._getMessageBody().index(0, 32);
+ require(circleMessageId == hyperlaneMessage.id(), "Invalid message id");
+ }
+
+ // @inheritdoc IMessageHandlerV2
+ function handleReceiveFinalizedMessage(
+ uint32 sourceDomain,
+ bytes32 sender,
+ uint32 /*finalityThresholdExecuted*/,
+ bytes calldata messageBody
+ ) external override returns (bool) {
+ return
+ _receiveMessageId(
+ sourceDomain,
+ sender,
+ abi.decode(messageBody, (bytes32))
+ );
+ }
+
+ // @inheritdoc IMessageHandlerV2
+ function handleReceiveUnfinalizedMessage(
+ uint32 sourceDomain,
+ bytes32 sender,
+ uint32 /*finalityThresholdExecuted*/,
+ bytes calldata messageBody
+ ) external override returns (bool) {
+ return
+ _receiveMessageId(
+ sourceDomain,
+ sender,
+ abi.decode(messageBody, (bytes32))
+ );
+ }
+
+ function _sendMessageIdToIsm(
+ uint32 destinationDomain,
+ bytes32 ism,
+ bytes32 messageId
+ ) internal override {
+ IMessageTransmitterV2(address(messageTransmitter)).sendMessage(
+ destinationDomain,
+ ism,
+ bytes32(0), // allow anyone to relay
+ minFinalityThreshold,
+ abi.encode(messageId)
+ );
+ }
+
+ function _bridgeViaCircle(
+ uint32 circleDomain,
+ bytes32 _recipient,
+ uint256 _amount,
+ uint256 _maxFee
+ ) internal override {
+ ITokenMessengerV2(address(tokenMessenger)).depositForBurn(
+ _amount,
+ circleDomain,
+ _recipient,
+ address(wrappedToken),
+ bytes32(0), // allow anyone to relay
+ _maxFee,
+ minFinalityThreshold
+ );
+ }
+}
diff --git a/solidity/contracts/token/bridge/EverclearTokenBridge.sol b/solidity/contracts/token/bridge/EverclearTokenBridge.sol
new file mode 100644
index 0000000000..6339b6bb25
--- /dev/null
+++ b/solidity/contracts/token/bridge/EverclearTokenBridge.sol
@@ -0,0 +1,541 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+pragma solidity ^0.8.22;
+
+import {ITokenBridge, Quote} from "../../interfaces/ITokenBridge.sol";
+import {HypERC20Collateral} from "../HypERC20Collateral.sol";
+import {TokenRouter} from "../libs/TokenRouter.sol";
+import {IEverclearAdapter, IEverclear, IEverclearSpoke} from "../../interfaces/IEverclearAdapter.sol";
+import {PackageVersioned} from "../../PackageVersioned.sol";
+import {IWETH} from "../interfaces/IWETH.sol";
+import {TokenMessage} from "../libs/TokenMessage.sol";
+import {TypeCasts} from "../../libs/TypeCasts.sol";
+import {ERC20Collateral, WETHCollateral} from "../libs/TokenCollateral.sol";
+
+import {LpCollateralRouterStorage} from "../libs/LpCollateralRouter.sol";
+
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+import {Address} from "@openzeppelin/contracts/utils/Address.sol";
+
+/**
+ * @notice Information about an output asset for a destination domain
+ * @param destination The destination domain ID
+ * @param outputAsset The output asset address on the destination chain
+ */
+struct OutputAssetInfo {
+ uint32 destination;
+ bytes32 outputAsset;
+}
+
+/**
+ * @title EverclearBridge
+ * @author Hyperlane Team
+ * @notice A token bridge that integrates with Everclear's intent-based architecture
+ */
+abstract contract EverclearBridge is TokenRouter {
+ using TokenMessage for bytes;
+ using TypeCasts for bytes32;
+
+ LpCollateralRouterStorage private __LP_COLLATERAL_GAP;
+
+ /**
+ * @notice Parameters for creating an Everclear intent
+ * @dev This struct is used to avoid stack too deep errors when creating intents
+ * @param receiver The address that will receive the tokens on the destination chain
+ * @param inputAsset The address of the input token on the source chain
+ * @param outputAsset The address of the output token on the destination chain
+ * @param amount The amount of tokens to transfer
+ * @param feeParams The fee parameters including fee amount, deadline, and signature
+ */
+ struct IntentParams {
+ bytes32 receiver;
+ address inputAsset;
+ bytes32 outputAsset;
+ uint256 amount;
+ IEverclearAdapter.FeeParams feeParams;
+ }
+
+ /**
+ * @notice The output asset for a given destination domain
+ * @dev Everclear needs to know the output asset address to create intents for cross-chain transfers
+ */
+ mapping(uint32 destination => bytes32 outputAsset) public outputAssets;
+
+ /**
+ * @notice Whether an intent has been settled
+ * @dev This mapping prevents double-spending by tracking which intents have already been processed
+ */
+ mapping(bytes32 intentId => bool isSettled) public intentSettled;
+
+ /**
+ * @notice Fee parameters for bridge operations on each destination domain
+ * @dev Contains fee amount, deadline, and signature from Everclear for fee validation
+ */
+ mapping(uint32 destination => IEverclearAdapter.FeeParams feeParams)
+ public feeParams;
+
+ IERC20 public immutable wrappedToken;
+
+ /// @notice The Everclear adapter contract interface
+ /// @dev Immutable reference to the Everclear adapter used for creating intents
+ IEverclearAdapter public immutable everclearAdapter;
+
+ /**
+ * @notice The Everclear spoke contract interface
+ * @dev Immutable reference used for checking intent status and settlement
+ */
+ IEverclearSpoke public immutable everclearSpoke;
+
+ /**
+ * @notice Emitted when fee parameters are updated
+ * @param fee The new fee amount
+ * @param deadline The new deadline timestamp for fee validity
+ */
+ event FeeParamsUpdated(uint32 destination, uint256 fee, uint256 deadline);
+
+ /**
+ * @notice Emitted when an output asset is configured for a destination
+ * @param destination The destination domain ID
+ * @param outputAsset The output asset address on the destination chain
+ */
+ event OutputAssetSet(uint32 destination, bytes32 outputAsset);
+
+ /**
+ * @notice Constructor to initialize the Everclear token bridge
+ * @param _erc20 The address of the ERC20 token to be bridged
+ * @param _scale The scaling factor for token amounts (typically 1 for 18-decimal tokens)
+ * @param _mailbox The address of the Hyperlane mailbox contract
+ * @param _everclearAdapter The address of the Everclear adapter contract
+ */
+ constructor(
+ IEverclearAdapter _everclearAdapter,
+ IERC20 _erc20,
+ uint256 _scale,
+ address _mailbox
+ ) TokenRouter(_scale, _mailbox) {
+ wrappedToken = _erc20;
+ everclearAdapter = _everclearAdapter;
+ everclearSpoke = _everclearAdapter.spoke();
+ }
+
+ /**
+ * @notice Initializes the proxy contract
+ * @dev Approves the Everclear adapter to spend tokens and calls parent initialization
+ * @param _hook The address of the post-dispatch hook (can be zero address)
+ * @param _owner The address that will own this contract
+ */
+ function initialize(address _hook, address _owner) public initializer {
+ _MailboxClient_initialize(_hook, address(0), _owner);
+ wrappedToken.approve(address(everclearAdapter), type(uint256).max);
+ }
+
+ function _settleIntent(bytes calldata _message) internal virtual {
+ /* CHECKS */
+ // Check that intent is settled
+ bytes32 intentId = keccak256(_message.metadata());
+ require(
+ everclearSpoke.status(intentId) == IEverclear.IntentStatus.SETTLED,
+ "ETB: Intent Status != SETTLED"
+ );
+ // Check that we have not processed this intent before
+ require(!intentSettled[intentId], "ETB: Intent already processed");
+
+ /* EFFECTS */
+ intentSettled[intentId] = true;
+ }
+
+ /**
+ * @notice Sets the fee parameters for Everclear bridge operations
+ * @dev Only callable by the contract owner
+ * @param _fee The fee amount to charge users for bridge operations
+ * @param _deadline The deadline timestamp for fee parameter validity
+ * @param _sig The signature for fee validation from Everclear
+ */
+ function setFeeParams(
+ uint32 _destination,
+ uint256 _fee,
+ uint256 _deadline,
+ bytes calldata _sig
+ ) external onlyOwner {
+ feeParams[_destination] = IEverclearAdapter.FeeParams({
+ fee: _fee,
+ deadline: _deadline,
+ sig: _sig
+ });
+ emit FeeParamsUpdated(_destination, _fee, _deadline);
+ }
+
+ /**
+ * @notice Internal function to set the output asset for a destination domain
+ * @dev Emits OutputAssetSet event when successful
+ * @param _outputAssetInfo The output asset information containing destination and asset address
+ */
+ function _setOutputAsset(
+ OutputAssetInfo calldata _outputAssetInfo
+ ) internal {
+ uint32 destination = _outputAssetInfo.destination;
+ bytes32 outputAsset = _outputAssetInfo.outputAsset;
+ outputAssets[destination] = outputAsset;
+ emit OutputAssetSet(destination, outputAsset);
+ }
+
+ /**
+ * @notice Sets the output asset address for a destination domain
+ * @dev Only callable by the contract owner
+ * @param _outputAssetInfo The output asset information for the destination domain
+ */
+ function setOutputAsset(
+ OutputAssetInfo calldata _outputAssetInfo
+ ) external onlyOwner {
+ _setOutputAsset(_outputAssetInfo);
+ }
+
+ /**
+ * @notice Sets multiple output assets in a single transaction for gas efficiency
+ * @dev Only callable by the contract owner. Arrays must be the same length
+ * @param _outputAssetInfos Array of output asset information for the destination domains
+ */
+ function setOutputAssetsBatch(
+ OutputAssetInfo[] calldata _outputAssetInfos
+ ) external onlyOwner {
+ uint256 len = _outputAssetInfos.length;
+
+ for (uint256 i = 0; i < len; ++i) {
+ OutputAssetInfo calldata _outputAssetInfo = _outputAssetInfos[i];
+ _setOutputAsset(_outputAssetInfo);
+ }
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function _externalFeeAmount(
+ uint32 _destination,
+ bytes32,
+ uint256
+ ) internal view override returns (uint256 feeAmount) {
+ return feeParams[_destination].fee;
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to create an Everclear intent for the transfer.
+ */
+ function transferRemote(
+ uint32 _destination,
+ bytes32 _recipient,
+ uint256 _amount
+ ) public payable override returns (bytes32 messageId) {
+ // 1. Calculate the fee amounts, charge the sender and distribute to feeRecipient if necessary
+ (, uint256 remainingNativeValue) = _calculateFeesAndCharge(
+ _destination,
+ _recipient,
+ _amount,
+ msg.value
+ );
+
+ // 2. Prepare the token message with the recipient, amount, and any additional metadata in overrides
+ IEverclear.Intent memory intent = _createIntent(
+ _destination,
+ _recipient,
+ _amount
+ );
+
+ uint256 scaledAmount = _outboundAmount(_amount);
+
+ bytes memory _tokenMessage = TokenMessage.format(
+ _recipient,
+ scaledAmount,
+ abi.encode(intent)
+ );
+
+ // 3. Emit the SentTransferRemote event and 4. dispatch the message
+ return
+ _emitAndDispatch(
+ _destination,
+ _recipient,
+ scaledAmount,
+ remainingNativeValue,
+ _tokenMessage
+ );
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to check for the Everclear intent status and transfer tokens to the recipient.
+ */
+ function _handle(
+ uint32 _origin,
+ bytes32 _sender,
+ bytes calldata _message
+ ) internal override {
+ _settleIntent(_message);
+ super._handle(_origin, _sender, _message);
+ }
+
+ /**
+ * @notice Encodes the intent calldata for token transfers
+ * @dev Virtual function that can be overridden by derived contracts to include custom data
+ * @param _recipient The recipient address on the destination chain
+ * @param _amount The amount of tokens to transfer
+ * @return The encoded calldata (empty in base implementation)
+ */
+ function _getIntentCalldata(
+ bytes32 _recipient,
+ uint256 _amount
+ ) internal pure virtual returns (bytes memory);
+
+ /**
+ * @notice Creates an Everclear intent for cross-chain token transfer
+ * @dev Internal function to handle intent creation with Everclear adapter
+ * @param _destination The destination domain ID
+ * @param _recipient The recipient address on the destination chain
+ * @param _amount The amount of tokens to transfer
+ * @return The created Everclear intent struct containing all transfer details
+ */
+ function _createIntent(
+ uint32 _destination,
+ bytes32 _recipient,
+ uint256 _amount
+ ) internal virtual returns (IEverclear.Intent memory) {
+ require(
+ outputAssets[_destination] != bytes32(0),
+ "ETB: Output asset not set"
+ );
+ require(
+ feeParams[_destination].sig.length > 0,
+ "ETB: Fee params not set"
+ );
+
+ // Create everclear intent
+ uint32[] memory destinations = new uint32[](1);
+ destinations[0] = _destination;
+
+ // Create intent
+ // Packing the intent params in a struct to avoid stack too deep errors
+ IntentParams memory intentParams = IntentParams({
+ feeParams: feeParams[_destination],
+ receiver: _getReceiver(_destination, _recipient),
+ inputAsset: address(wrappedToken),
+ outputAsset: outputAssets[_destination],
+ amount: _amount
+ });
+
+ (, IEverclear.Intent memory intent) = everclearAdapter.newIntent({
+ _destinations: destinations,
+ _receiver: intentParams.receiver,
+ _inputAsset: intentParams.inputAsset,
+ _outputAsset: intentParams.outputAsset,
+ _amount: intentParams.amount,
+ _maxFee: 0,
+ _ttl: 0,
+ _data: _getIntentCalldata(_recipient, _amount),
+ _feeParams: intentParams.feeParams
+ });
+
+ return intent;
+ }
+
+ /**
+ * @notice Gets the receiver address for an intent
+ * @dev Virtual function that can be overridden by derived contracts
+ * @param _destination The destination domain ID
+ * @param _recipient The intended recipient address
+ * @return receiver The receiver address to use in the intent (typically the recipient for token bridge)
+ */
+ function _getReceiver(
+ uint32 _destination,
+ bytes32 _recipient
+ ) internal view virtual returns (bytes32 receiver);
+}
+
+/**
+ * @title EverclearTokenBridge
+ * @author Hyperlane Team
+ * @notice A token bridge that integrates with Everclear's intent-based architecture
+ * @dev Extends HypERC20Collateral to provide cross-chain token transfers via Everclear's intent system
+ */
+contract EverclearTokenBridge is EverclearBridge {
+ using ERC20Collateral for IERC20;
+
+ /**
+ * @notice Constructor to initialize the Everclear token bridge
+ * @param _everclearAdapter The address of the Everclear adapter contract
+ */
+ constructor(
+ address _erc20,
+ uint256 _scale,
+ address _mailbox,
+ IEverclearAdapter _everclearAdapter
+ ) EverclearBridge(_everclearAdapter, IERC20(_erc20), _scale, _mailbox) {}
+
+ /**
+ * @inheritdoc EverclearBridge
+ */
+ function _getReceiver(
+ uint32 /* _destination */,
+ bytes32 _recipient
+ ) internal pure override returns (bytes32 receiver) {
+ return _recipient;
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function token() public view override returns (address) {
+ return address(wrappedToken);
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function _transferFromSender(uint256 _amount) internal override {
+ wrappedToken._transferFromSender(_amount);
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function _transferTo(
+ address _recipient,
+ uint256 _amount
+ ) internal override {
+ // Do nothing (tokens transferred to recipient directly)
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Transfers fees directly from router balance using ERC20 transfer.
+ */
+ function _transferFee(
+ address _recipient,
+ uint256 _amount
+ ) internal override {
+ wrappedToken._transferTo(_recipient, _amount);
+ }
+
+ /**
+ * @notice Encodes the intent calldata for ETH transfers
+ * @return The encoded calldata for the everclear intent.
+ */
+ function _getIntentCalldata(
+ bytes32 /* _recipient */,
+ uint256 /* _amount */
+ ) internal pure override returns (bytes memory) {
+ return "";
+ }
+}
+
+/**
+ * @title EverclearEthBridge
+ * @author Hyperlane Team
+ * @notice A specialized ETH bridge that integrates with Everclear's intent-based architecture
+ * @dev Extends EverclearTokenBridge to handle ETH by wrapping to WETH for transfers and unwrapping on destination
+ */
+contract EverclearEthBridge is EverclearBridge {
+ using WETHCollateral for IWETH;
+ using TokenMessage for bytes;
+ using SafeERC20 for IERC20;
+ using Address for address payable;
+ using TypeCasts for bytes32;
+
+ uint256 private constant SCALE = 1;
+
+ /**
+ * @notice Constructor to initialize the Everclear ETH bridge
+ * @param _everclearAdapter The address of the Everclear adapter contract
+ */
+ constructor(
+ IWETH _weth,
+ address _mailbox,
+ IEverclearAdapter _everclearAdapter
+ ) EverclearBridge(_everclearAdapter, IERC20(_weth), SCALE, _mailbox) {}
+
+ /**
+ * @inheritdoc EverclearBridge
+ */
+ function _getReceiver(
+ uint32 _destination,
+ bytes32 /* _recipient */
+ ) internal view override returns (bytes32 receiver) {
+ return _mustHaveRemoteRouter(_destination);
+ }
+
+ // senders and recipients are ETH, so we return address(0)
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function token() public pure override returns (address) {
+ return address(0);
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function _transferFromSender(uint256 _amount) internal override {
+ IWETH(address(wrappedToken))._transferFromSender(_amount);
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function _transferTo(
+ address _recipient,
+ uint256 _amount
+ ) internal override {
+ IWETH(address(wrappedToken))._transferTo(_recipient, _amount);
+ }
+
+ /**
+ * @notice Allows the contract to receive ETH
+ * @dev Required for WETH unwrapping functionality
+ */
+ receive() external payable {
+ require(
+ msg.sender == address(wrappedToken),
+ "EEB: Only WETH can send ETH"
+ );
+ }
+
+ /**
+ * @notice Encodes the intent calldata for ETH transfers
+ * @dev Overrides parent to encode recipient and amount for ETH-specific intent validation
+ * @param _recipient The recipient address on the destination chain
+ * @param _amount The amount of ETH to transfer
+ * @return The encoded calldata containing recipient and amount
+ */
+ function _getIntentCalldata(
+ bytes32 _recipient,
+ uint256 _amount
+ ) internal pure override returns (bytes memory) {
+ return abi.encode(_recipient, _amount);
+ }
+
+ /**
+ * @notice Validates the Everclear intent for ETH transfers
+ * @dev Overrides parent to add ETH-specific validation by checking intent data matches message
+ * @param _message The incoming message containing transfer details
+ */
+ function _settleIntent(bytes calldata _message) internal override {
+ super._settleIntent(_message);
+
+ IEverclear.Intent memory intent = abi.decode(
+ _message.metadata(),
+ (IEverclear.Intent)
+ );
+ (bytes32 _intentRecipient, uint256 _intentAmount) = abi.decode(
+ intent.data,
+ (bytes32, uint256)
+ );
+
+ require(
+ _intentRecipient == _message.recipient(),
+ "EEB: Intent recipient mismatch"
+ );
+ require(
+ _intentAmount == _message.amount(),
+ "EEB: Intent amount mismatch"
+ );
+ }
+}
diff --git a/solidity/contracts/token/extensions/HypERC4626.sol b/solidity/contracts/token/extensions/HypERC4626.sol
index 266e5e61d8..e0eb6175db 100644
--- a/solidity/contracts/token/extensions/HypERC4626.sol
+++ b/solidity/contracts/token/extensions/HypERC4626.sol
@@ -18,8 +18,7 @@ import {HypERC20} from "../HypERC20.sol";
import {Message} from "../../libs/Message.sol";
import {TokenMessage} from "../libs/TokenMessage.sol";
import {TokenRouter} from "../libs/TokenRouter.sol";
-import {Router} from "contracts/client/Router.sol";
-import {FungibleTokenRouter} from "../libs/FungibleTokenRouter.sol";
+import {Router} from "../../client/Router.sol";
// ============ External Imports ============
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
@@ -61,15 +60,13 @@ contract HypERC4626 is HypERC20 {
/// Override totalSupply to return the total assets instead of shares. This reflects the actual circulating supply in terms of assets, accounting for rebasing
/// @inheritdoc ERC20Upgradeable
- function totalSupply() public view virtual override returns (uint256) {
+ function totalSupply() public view override returns (uint256) {
return sharesToAssets(totalShares());
}
/// This returns the balance of the account in terms of assets, accounting for rebasing
/// @inheritdoc ERC20Upgradeable
- function balanceOf(
- address account
- ) public view virtual override returns (uint256) {
+ function balanceOf(address account) public view override returns (uint256) {
return sharesToAssets(shareBalanceOf(account));
}
@@ -93,19 +90,16 @@ contract HypERC4626 is HypERC20 {
// @inheritdoc HypERC20
// @dev Amount specified by the user is in assets, but the internal accounting is in shares
- function _transferFromSender(
- uint256 _amount
- ) internal virtual override returns (bytes memory) {
- return HypERC20._transferFromSender(assetsToShares(_amount));
+ function _transferFromSender(uint256 _amount) internal override {
+ HypERC20._transferFromSender(assetsToShares(_amount));
}
- // @inheritdoc FungibleTokenRouter
+ // @inheritdoc TokenRouter
// @dev Amount specified by user is in assets, but the message accounting is in shares
function _outboundAmount(
uint256 _localAmount
- ) internal view virtual override returns (uint256) {
- return
- FungibleTokenRouter._outboundAmount(assetsToShares(_localAmount));
+ ) internal view override returns (uint256) {
+ return TokenRouter._outboundAmount(assetsToShares(_localAmount));
}
// @inheritdoc ERC20Upgradeable
@@ -114,11 +108,11 @@ contract HypERC4626 is HypERC20 {
address _from,
address _to,
uint256 _amount
- ) internal virtual override {
+ ) internal override {
super._transfer(_from, _to, assetsToShares(_amount));
}
- // `_inboundAmount` implementation reused from `FungibleTokenRouter` unchanged because message
+ // `_inboundAmount` implementation reused from `TokenRouter` unchanged because message
// accounting is in shares
// ========== TokenRouter extensions ============
@@ -127,7 +121,7 @@ contract HypERC4626 is HypERC20 {
uint32 _origin,
bytes32 _sender,
bytes calldata _message
- ) internal virtual override {
+ ) internal override {
if (_origin == collateralDomain) {
(uint256 newExchangeRate, uint32 rateUpdateNonce) = abi.decode(
_message.metadata(),
diff --git a/solidity/contracts/token/extensions/HypERC4626Collateral.sol b/solidity/contracts/token/extensions/HypERC4626Collateral.sol
index 8dbe737d78..6586504ccc 100644
--- a/solidity/contracts/token/extensions/HypERC4626Collateral.sol
+++ b/solidity/contracts/token/extensions/HypERC4626Collateral.sol
@@ -18,26 +18,39 @@ import {TokenMessage} from "../libs/TokenMessage.sol";
import {HypERC20Collateral} from "../HypERC20Collateral.sol";
import {TypeCasts} from "../../libs/TypeCasts.sol";
import {TokenRouter} from "../libs/TokenRouter.sol";
+import {ERC20Collateral} from "../libs/TokenCollateral.sol";
+import {LpCollateralRouterStorage} from "../libs/LpCollateralRouter.sol";
// ============ External Imports ============
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title Hyperlane ERC4626 Token Collateral with deposits collateral to a vault
* @author Abacus Works
*/
-contract HypERC4626Collateral is HypERC20Collateral {
+contract HypERC4626Collateral is TokenRouter {
+ using ERC20Collateral for IERC20;
+ using SafeERC20 for IERC20;
using TypeCasts for address;
using TokenMessage for bytes;
using Math for uint256;
// Address of the ERC4626 compatible vault
ERC4626 public immutable vault;
+ IERC20 public immutable wrappedToken;
+
// Precision for the exchange rate
uint256 public constant PRECISION = 1e10;
// Null recipient for rebase transfer
bytes32 public constant NULL_RECIPIENT =
0x0000000000000000000000000000000000000000000000000000000000000001;
+
+ /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to.
+ LpCollateralRouterStorage private __LP_COLLATERAL_GAP;
+
// Nonce for the rate update, to ensure sequential updates
uint32 public rateUpdateNonce;
@@ -45,34 +58,44 @@ contract HypERC4626Collateral is HypERC20Collateral {
ERC4626 _vault,
uint256 _scale,
address _mailbox
- ) HypERC20Collateral(_vault.asset(), _scale, _mailbox) {
+ ) TokenRouter(_scale, _mailbox) {
vault = _vault;
+ wrappedToken = IERC20(_vault.asset());
}
function initialize(
address _hook,
address _interchainSecurityModule,
address _owner
- ) public override initializer {
+ ) public initializer {
+ wrappedToken.safeApprove(address(vault), type(uint256).max);
_MailboxClient_initialize(_hook, _interchainSecurityModule, _owner);
}
+ // ============ TokenRouter overrides ============
+
/**
* @inheritdoc TokenRouter
- * @dev Override `_transferRemote` to send shares as amount and append {exchange rate, nonce} in the message.
- * This is preferred for readability and to avoid confusion with the amount of shares. The scaling factor
- * is applied to the shares returned by the deposit before sending the message.
+ * @dev Overrides to deposit tokens into the vault and add exchange rate metadata.
*/
- function _transferRemote(
+ function transferRemote(
uint32 _destination,
bytes32 _recipient,
- uint256 _amount,
- uint256 _value,
- bytes memory _hookMetadata,
- address _hook
- ) internal virtual override returns (bytes32 messageId) {
- // Can't override _transferFromSender only because we need to pass shares in the token message
- _transferFromSender(_amount);
+ uint256 _amount
+ ) public payable override returns (bytes32 messageId) {
+ // 1. Calculate the fee amounts, charge the sender and distribute to feeRecipient if necessary
+ // Don't use HypERC4626Collateral's implementation of _transferTo since it does a redemption.
+ (address _feeRecipient, uint256 feeAmount) = _feeRecipientAndAmount(
+ _destination,
+ _recipient,
+ _amount
+ );
+ _transferFromSender(_amount + feeAmount);
+ if (feeAmount > 0) {
+ wrappedToken._transferTo(_feeRecipient, feeAmount);
+ }
+
+ // Deposit the amount into the vault and get the shares for the TokenMessage amount
uint256 _shares = _depositIntoVault(_amount);
uint256 _exchangeRate = vault.convertToAssets(PRECISION);
@@ -83,62 +106,70 @@ contract HypERC4626Collateral is HypERC20Collateral {
rateUpdateNonce
);
- uint256 _outboundAmount = _outboundAmount(_shares);
+ uint256 _scaledAmount = _outboundAmount(_shares);
bytes memory _tokenMessage = TokenMessage.format(
_recipient,
- _outboundAmount,
+ _scaledAmount,
_tokenMetadata
);
- messageId = _Router_dispatch(
- _destination,
- _value,
- _tokenMessage,
- _hookMetadata,
- _hook
- );
-
- emit SentTransferRemote(_destination, _recipient, _outboundAmount);
+ // 3. Emit the SentTransferRemote event and 4. dispatch the message
+ return
+ _emitAndDispatch(
+ _destination,
+ _recipient,
+ _scaledAmount,
+ msg.value,
+ _tokenMessage
+ );
}
/**
- * @dev Deposits into the vault and increment assetDeposited
- * @param _amount amount to deposit into vault
+ * @inheritdoc TokenRouter
*/
- function _depositIntoVault(uint256 _amount) internal returns (uint256) {
- wrappedToken.approve(address(vault), _amount);
- return vault.deposit(_amount, address(this));
+ function token() public view override returns (address) {
+ return address(wrappedToken);
}
/**
+ * @inheritdoc TokenRouter
* @dev Withdraws `_shares` of `wrappedToken` from this contract to `_recipient`
- * @inheritdoc HypERC20Collateral
+ * @dev Known overrides:
+ * - HypERC4626OwnerCollateral: Withdraws assets instead of redeeming shares
*/
+ // solhint-disable-next-line hyperlane/no-virtual-override
function _transferTo(
address _recipient,
- uint256 _shares,
- bytes calldata
+ uint256 _shares
) internal virtual override {
vault.redeem(_shares, _recipient, address(this));
}
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function _transferFromSender(uint256 _amount) internal override {
+ wrappedToken._transferFromSender(_amount);
+ }
+
+ /**
+ * @param _amount amount to deposit into vault
+ * @dev Deposits into the vault and increment assetDeposited.
+ * Known overrides:
+ * - HypERC4626OwnerCollateral: Tracks the total asset deposited and allows sweeping excess
+ */
+ function _depositIntoVault(
+ uint256 _amount
+ ) internal virtual returns (uint256) {
+ return vault.deposit(_amount, address(this));
+ }
+
/**
* @dev Update the exchange rate on the synthetic token by accounting for additional yield accrued to the underlying vault
* @param _destinationDomain domain of the vault
*/
- function rebase(
- uint32 _destinationDomain,
- bytes calldata _hookMetadata,
- address _hook
- ) public payable {
+ function rebase(uint32 _destinationDomain) public payable {
// force a rebase with an empty transfer to 0x1
- _transferRemote(
- _destinationDomain,
- NULL_RECIPIENT,
- 0,
- msg.value,
- _hookMetadata,
- _hook
- );
+ transferRemote(_destinationDomain, NULL_RECIPIENT, 0);
}
}
diff --git a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol
index 9b4f94705c..64c425b3d9 100644
--- a/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol
+++ b/solidity/contracts/token/extensions/HypERC4626OwnerCollateral.sol
@@ -18,20 +18,15 @@ import {HypERC20Collateral} from "../HypERC20Collateral.sol";
// ============ External Imports ============
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
+import {HypERC4626Collateral} from "./HypERC4626Collateral.sol";
/**
- * @title Hyperlane ERC20 Token Collateral with deposits collateral to a vault, the yield goes to the owner
- * @author ltyu
+ * @title Hyperlane ERC4626 Token Collateral with deposits collateral to a vault, the yield goes to the owner
+ * @author Abacus Works
*/
-contract HypERC4626OwnerCollateral is HypERC20Collateral {
- // Address of the ERC4626 compatible vault
- ERC4626 public immutable vault;
- // standby precision for exchange rate
- uint256 public constant PRECISION = 1e10;
+contract HypERC4626OwnerCollateral is HypERC4626Collateral {
// Internal balance of total asset deposited
uint256 public assetDeposited;
- // Nonce for the rate update, to ensure sequential updates (not necessary for Owner variant but for compatibility with HypERC4626)
- uint32 public rateUpdateNonce;
event ExcessSharesSwept(uint256 amount, uint256 assetsRedeemed);
@@ -39,60 +34,30 @@ contract HypERC4626OwnerCollateral is HypERC20Collateral {
ERC4626 _vault,
uint256 _scale,
address _mailbox
- ) HypERC20Collateral(_vault.asset(), _scale, _mailbox) {
- vault = _vault;
- }
+ ) HypERC4626Collateral(_vault, _scale, _mailbox) {}
- function initialize(
- address _hook,
- address _interchainSecurityModule,
- address _owner
- ) public override initializer {
- wrappedToken.approve(address(vault), type(uint256).max);
- _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner);
- }
+ // =========== TokenRouter Overrides ============
/**
- * @dev Transfers `_amount` of `wrappedToken` from `msg.sender` to this contract, and deposit into vault
- * @inheritdoc HypERC20Collateral
+ * @inheritdoc HypERC4626Collateral
+ * @dev Overrides to track the total asset deposited.
*/
- function _transferFromSender(
+ function _depositIntoVault(
uint256 _amount
- ) internal override returns (bytes memory metadata) {
- super._transferFromSender(_amount);
- _depositIntoVault(_amount);
- rateUpdateNonce++;
-
- return abi.encode(PRECISION, rateUpdateNonce);
- }
-
- /**
- * @dev Deposits into the vault and increment assetDeposited
- * @param _amount amount to deposit into vault
- */
- function _depositIntoVault(uint256 _amount) internal {
+ ) internal override returns (uint256) {
assetDeposited += _amount;
vault.deposit(_amount, address(this));
+ return _amount;
}
/**
- * @dev Transfers `_amount` of `wrappedToken` from this contract to `_recipient`, and withdraws from vault
- * @inheritdoc HypERC20Collateral
+ * @inheritdoc HypERC4626Collateral
+ * @dev Overrides to withdraw from the vault and track the asset deposited.
*/
function _transferTo(
address _recipient,
- uint256 _amount,
- bytes calldata
- ) internal virtual override {
- _withdrawFromVault(_amount, _recipient);
- }
-
- /**
- * @dev Withdraws from the vault and decrement assetDeposited
- * @param _amount amount to withdraw from vault
- * @param _recipient address to deposit withdrawn underlying to
- */
- function _withdrawFromVault(uint256 _amount, address _recipient) internal {
+ uint256 _amount
+ ) internal override {
assetDeposited -= _amount;
vault.withdraw(_amount, _recipient, address(this));
}
@@ -101,8 +66,10 @@ contract HypERC4626OwnerCollateral is HypERC20Collateral {
* @notice Allows the owner to redeem excess shares
*/
function sweep() external onlyOwner {
+ // convert assetsDeposited to shares rounding up to ensure
+ // the owner cannot withdraw user collateral
uint256 excessShares = vault.maxRedeem(address(this)) -
- vault.convertToShares(assetDeposited);
+ vault.previewWithdraw(assetDeposited);
uint256 assetsRedeemed = vault.redeem(
excessShares,
owner(),
diff --git a/solidity/contracts/token/extensions/HypERC721URICollateral.sol b/solidity/contracts/token/extensions/HypERC721URICollateral.sol
index 780f1a2232..7a059bd216 100644
--- a/solidity/contracts/token/extensions/HypERC721URICollateral.sol
+++ b/solidity/contracts/token/extensions/HypERC721URICollateral.sol
@@ -2,6 +2,8 @@
pragma solidity >=0.8.0;
import {HypERC721Collateral} from "../HypERC721Collateral.sol";
+import {TokenMessage} from "../libs/TokenMessage.sol";
+import {TokenRouter} from "../libs/TokenRouter.sol";
import {IERC721MetadataUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/IERC721MetadataUpgradeable.sol";
@@ -17,19 +19,39 @@ contract HypERC721URICollateral is HypERC721Collateral {
) HypERC721Collateral(erc721, _mailbox) {}
/**
- * @dev Transfers `_tokenId` of `wrappedToken` from `msg.sender` to this contract.
- * @return The URI of `_tokenId` on `wrappedToken`.
- * @inheritdoc HypERC721Collateral
+ * @inheritdoc TokenRouter
+ * @dev Overrides to fetch the URI and pass it to the token message.
*/
- function _transferFromSender(
+ function transferRemote(
+ uint32 _destination,
+ bytes32 _recipient,
uint256 _tokenId
- ) internal override returns (bytes memory) {
- HypERC721Collateral._transferFromSender(_tokenId);
+ ) public payable override returns (bytes32 messageId) {
+ (, uint256 remainingNativeValue) = _calculateFeesAndCharge(
+ _destination,
+ _recipient,
+ _tokenId,
+ msg.value
+ );
+
+ string memory _tokenURI = IERC721MetadataUpgradeable(
+ address(wrappedToken)
+ ).tokenURI(_tokenId);
+
+ bytes memory _tokenMessage = TokenMessage.format(
+ _recipient,
+ _tokenId,
+ bytes(_tokenURI)
+ );
+
+ // 3. Emit the SentTransferRemote event and 4. dispatch the message
return
- bytes(
- IERC721MetadataUpgradeable(address(wrappedToken)).tokenURI(
- _tokenId
- )
+ _emitAndDispatch(
+ _destination,
+ _recipient,
+ _tokenId,
+ remainingNativeValue,
+ _tokenMessage
);
}
}
diff --git a/solidity/contracts/token/extensions/HypERC721URIStorage.sol b/solidity/contracts/token/extensions/HypERC721URIStorage.sol
index 73b274558d..1b49f64cb8 100644
--- a/solidity/contracts/token/extensions/HypERC721URIStorage.sol
+++ b/solidity/contracts/token/extensions/HypERC721URIStorage.sol
@@ -2,6 +2,8 @@
pragma solidity >=0.8.0;
import {HypERC721} from "../HypERC721.sol";
+import {TokenMessage} from "../libs/TokenMessage.sol";
+import {TypeCasts} from "../../libs/TypeCasts.sol";
import {ERC721URIStorageUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol";
import {ERC721EnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol";
@@ -13,41 +15,18 @@ import {IERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC7
* @author Abacus Works
*/
contract HypERC721URIStorage is HypERC721, ERC721URIStorageUpgradeable {
- constructor(address _mailbox) HypERC721(_mailbox) {}
+ using TokenMessage for bytes;
+ using TypeCasts for bytes32;
- function balanceOf(
- address account
- )
- public
- view
- override(HypERC721, ERC721Upgradeable, IERC721Upgradeable)
- returns (uint256)
- {
- return HypERC721.balanceOf(account);
- }
-
- /**
- * @return _tokenURI The URI of `_tokenId`.
- * @inheritdoc HypERC721
- */
- function _transferFromSender(
- uint256 _tokenId
- ) internal override returns (bytes memory _tokenURI) {
- _tokenURI = bytes(tokenURI(_tokenId)); // requires minted
- HypERC721._transferFromSender(_tokenId);
- }
+ constructor(address _mailbox) HypERC721(_mailbox) {}
- /**
- * @dev Sets the URI for `_tokenId` to `_tokenURI`.
- * @inheritdoc HypERC721
- */
- function _transferTo(
- address _recipient,
- uint256 _tokenId,
- bytes calldata _tokenURI
+ function _handle(
+ uint32 _origin,
+ bytes32 _sender,
+ bytes calldata _message
) internal override {
- HypERC721._transferTo(_recipient, _tokenId, _tokenURI);
- _setTokenURI(_tokenId, string(_tokenURI)); // requires minted
+ super._handle(_origin, _sender, _message);
+ _setTokenURI(_message.tokenId(), string(_message.metadata()));
}
function tokenURI(
diff --git a/solidity/contracts/token/extensions/HypFiatToken.sol b/solidity/contracts/token/extensions/HypFiatToken.sol
index 108020e6bf..5f843b61a5 100644
--- a/solidity/contracts/token/extensions/HypFiatToken.sol
+++ b/solidity/contracts/token/extensions/HypFiatToken.sol
@@ -3,31 +3,62 @@ pragma solidity >=0.8.0;
import {IFiatToken} from "../interfaces/IFiatToken.sol";
import {HypERC20Collateral} from "../HypERC20Collateral.sol";
+import {TokenRouter} from "../libs/TokenRouter.sol";
+import {ERC20Collateral} from "../libs/TokenCollateral.sol";
+import {LpCollateralRouterStorage} from "../libs/LpCollateralRouter.sol";
// see https://github.com/circlefin/stablecoin-evm/blob/master/doc/tokendesign.md#issuing-and-destroying-tokens
-contract HypFiatToken is HypERC20Collateral {
+contract HypFiatToken is TokenRouter {
+ using ERC20Collateral for IFiatToken;
+
+ IFiatToken public immutable wrappedToken;
+
+ /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to.
+ LpCollateralRouterStorage private __LP_COLLATERAL_GAP;
+
constructor(
address _fiatToken,
uint256 _scale,
address _mailbox
- ) HypERC20Collateral(_fiatToken, _scale, _mailbox) {}
+ ) TokenRouter(_scale, _mailbox) {
+ wrappedToken = IFiatToken(_fiatToken);
+ _disableInitializers();
+ }
- function _transferFromSender(
- uint256 _amount
- ) internal override returns (bytes memory metadata) {
+ function initialize(
+ address _hook,
+ address _interchainSecurityModule,
+ address _owner
+ ) public initializer {
+ _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner);
+ }
+
+ // ============ TokenRouter overrides ============
+ function token() public view override returns (address) {
+ return address(wrappedToken);
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to burn tokens on outbound transfer.
+ */
+ function _transferFromSender(uint256 _amount) internal override {
// transfer amount to address(this)
- metadata = super._transferFromSender(_amount);
+ wrappedToken._transferFromSender(_amount);
// burn amount of address(this) balance
- IFiatToken(address(wrappedToken)).burn(_amount);
+ wrappedToken.burn(_amount);
}
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to mint tokens on inbound transfer.
+ */
function _transferTo(
address _recipient,
- uint256 _amount,
- bytes calldata /*metadata*/
+ uint256 _amount
) internal override {
require(
- IFiatToken(address(wrappedToken)).mint(_recipient, _amount),
+ wrappedToken.mint(_recipient, _amount),
"FiatToken mint failed"
);
}
diff --git a/solidity/contracts/token/extensions/HypXERC20.sol b/solidity/contracts/token/extensions/HypXERC20.sol
index 422279d333..59958af89f 100644
--- a/solidity/contracts/token/extensions/HypXERC20.sol
+++ b/solidity/contracts/token/extensions/HypXERC20.sol
@@ -3,28 +3,53 @@ pragma solidity >=0.8.0;
import {IXERC20} from "../interfaces/IXERC20.sol";
import {HypERC20Collateral} from "../HypERC20Collateral.sol";
+import {TokenRouter} from "../libs/TokenRouter.sol";
+import {LpCollateralRouterStorage} from "../libs/LpCollateralRouter.sol";
+
+contract HypXERC20 is TokenRouter {
+ IXERC20 public immutable wrappedToken;
+
+ /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to.
+ LpCollateralRouterStorage private __LP_COLLATERAL_GAP;
-contract HypXERC20 is HypERC20Collateral {
constructor(
address _xerc20,
uint256 _scale,
address _mailbox
- ) HypERC20Collateral(_xerc20, _scale, _mailbox) {
+ ) TokenRouter(_scale, _mailbox) {
+ wrappedToken = IXERC20(_xerc20);
_disableInitializers();
}
- function _transferFromSender(
- uint256 _amountOrId
- ) internal override returns (bytes memory metadata) {
- IXERC20(address(wrappedToken)).burn(msg.sender, _amountOrId);
- return "";
+ function initialize(
+ address _hook,
+ address _interchainSecurityModule,
+ address _owner
+ ) public initializer {
+ _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner);
+ }
+
+ // ============ TokenRouter overrides ============
+ function token() public view override returns (address) {
+ return address(wrappedToken);
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to burn tokens on outbound transfer.
+ */
+ function _transferFromSender(uint256 _amountOrId) internal override {
+ wrappedToken.burn(msg.sender, _amountOrId);
}
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to mint tokens on inbound transfer.
+ */
function _transferTo(
address _recipient,
- uint256 _amountOrId,
- bytes calldata /*metadata*/
+ uint256 _amountOrId
) internal override {
- IXERC20(address(wrappedToken)).mint(_recipient, _amountOrId);
+ wrappedToken.mint(_recipient, _amountOrId);
}
}
diff --git a/solidity/contracts/token/extensions/HypXERC20Lockbox.sol b/solidity/contracts/token/extensions/HypXERC20Lockbox.sol
index 8f6ed5e0d1..8de1e712d1 100644
--- a/solidity/contracts/token/extensions/HypXERC20Lockbox.sol
+++ b/solidity/contracts/token/extensions/HypXERC20Lockbox.sol
@@ -3,30 +3,32 @@ pragma solidity >=0.8.0;
import {IXERC20Lockbox} from "../interfaces/IXERC20Lockbox.sol";
import {IXERC20, IERC20} from "../interfaces/IXERC20.sol";
-import {HypERC20Collateral} from "../HypERC20Collateral.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+import {TokenRouter} from "../libs/TokenRouter.sol";
+import {ERC20Collateral} from "../libs/TokenCollateral.sol";
+import {LpCollateralRouterStorage} from "../libs/LpCollateralRouter.sol";
+
+contract HypXERC20Lockbox is TokenRouter {
+ using SafeERC20 for IERC20;
+ using ERC20Collateral for IERC20;
-contract HypXERC20Lockbox is HypERC20Collateral {
uint256 constant MAX_INT = 2 ** 256 - 1;
IXERC20Lockbox public immutable lockbox;
IXERC20 public immutable xERC20;
+ IERC20 public immutable wrappedToken;
- using SafeERC20 for IERC20;
+ /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to.
+ LpCollateralRouterStorage private __LP_COLLATERAL_GAP;
constructor(
address _lockbox,
uint256 _scale,
address _mailbox
- )
- HypERC20Collateral(
- address(IXERC20Lockbox(_lockbox).ERC20()),
- _scale,
- _mailbox
- )
- {
+ ) TokenRouter(_scale, _mailbox) {
lockbox = IXERC20Lockbox(_lockbox);
- xERC20 = lockbox.XERC20();
+ xERC20 = IXERC20(lockbox.XERC20());
+ wrappedToken = IERC20(lockbox.ERC20());
approveLockbox();
_disableInitializers();
}
@@ -36,7 +38,7 @@ contract HypXERC20Lockbox is HypERC20Collateral {
* @dev This function is idempotent and need not be access controlled
*/
function approveLockbox() public {
- IERC20(wrappedToken).safeApprove(address(lockbox), MAX_INT);
+ wrappedToken.safeApprove(address(lockbox), MAX_INT);
IERC20(xERC20).safeApprove(address(lockbox), MAX_INT);
}
@@ -50,27 +52,36 @@ contract HypXERC20Lockbox is HypERC20Collateral {
address _hook,
address _ism,
address _owner
- ) public override initializer {
+ ) public initializer {
approveLockbox();
_MailboxClient_initialize(_hook, _ism, _owner);
}
- function _transferFromSender(
- uint256 _amount
- ) internal override returns (bytes memory) {
+ // ============ TokenRouter overrides ============
+ function token() public view override returns (address) {
+ return address(wrappedToken);
+ }
+
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to burn tokens on outbound transfer.
+ */
+ function _transferFromSender(uint256 _amount) internal override {
// transfer erc20 from sender
- super._transferFromSender(_amount);
+ wrappedToken._transferFromSender(_amount);
// convert erc20 to xERC20
lockbox.deposit(_amount);
// burn xERC20
xERC20.burn(address(this), _amount);
- return bytes("");
}
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to mint tokens on inbound transfer.
+ */
function _transferTo(
address _recipient,
- uint256 _amount,
- bytes calldata /*metadata*/
+ uint256 _amount
) internal override {
// mint xERC20
xERC20.mint(address(this), _amount);
diff --git a/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol b/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol
index c62d1549b4..75eb9612eb 100644
--- a/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol
+++ b/solidity/contracts/token/extensions/OPL2ToL1TokenBridgeNative.sol
@@ -4,6 +4,7 @@ pragma solidity >=0.8.0;
import {HypNative} from "../../token/HypNative.sol";
import {TypeCasts} from "../../libs/TypeCasts.sol";
import {TokenRouter} from "../../token/libs/TokenRouter.sol";
+import {Router} from "../../client/Router.sol";
import {IStandardBridge} from "../../interfaces/optimism/IStandardBridge.sol";
import {Quote, ITokenBridge} from "../../interfaces/ITokenBridge.sol";
import {StandardHookMetadata} from "../../hooks/libs/StandardHookMetadata.sol";
@@ -13,10 +14,12 @@ import {TokenMessage} from "../../token/libs/TokenMessage.sol";
import {Message} from "../../libs/Message.sol";
import {IInterchainSecurityModule} from "../../interfaces/IInterchainSecurityModule.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
+import {NativeCollateral} from "../../token/libs/TokenCollateral.sol";
+import {LpCollateralRouterStorage} from "../../token/libs/LpCollateralRouter.sol";
uint256 constant SCALE = 1;
-contract OpL2NativeTokenBridge is HypNative {
+contract OpL2NativeTokenBridge is TokenRouter {
using TypeCasts for bytes32;
using StandardHookMetadata for bytes;
using Address for address payable;
@@ -29,48 +32,107 @@ contract OpL2NativeTokenBridge is HypNative {
// L2 bridge used to initiate the withdrawal
IStandardBridge public immutable l2Bridge;
+ /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to.
+ LpCollateralRouterStorage private __LP_COLLATERAL_GAP;
+
constructor(
address _mailbox,
address _l2Bridge
- ) HypNative(SCALE, _mailbox) {
+ ) TokenRouter(SCALE, _mailbox) {
require(_l2Bridge.isContract(), "L2 bridge must be a contract");
l2Bridge = IStandardBridge(payable(_l2Bridge));
}
- function initialize(
- address _hook,
- address _owner
- ) public virtual initializer {
+ function initialize(address _hook, address _owner) public initializer {
// ISM should not be set (contract does not receive messages currently)
_MailboxClient_initialize(_hook, address(0), _owner);
}
- function quoteTransferRemote(
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to use the L2 bridge for transferring native tokens and trigger two messages:
+ * - Prove message with amount 0 to prove the withdrawal
+ * - Finalize message with the actual amount to finalize the withdrawal
+ * transferRemote typically has the dispatch of the message as the 4th and final step. However, in this case we want the Hyperlane messageId to be passed via the rollup bridge.
+ */
+ function transferRemote(
uint32 _destination,
bytes32 _recipient,
uint256 _amount
- ) external view virtual override returns (Quote[] memory quotes) {
- bytes memory message = TokenMessage.format(_recipient, _amount);
- uint256 proveQuote = _Router_quoteDispatch(
+ ) public payable override returns (bytes32) {
+ // 1. No external fee calculation necessary
+ require(
+ _amount > 0,
+ "OP L2 token bridge: amount must be greater than 0"
+ );
+
+ // 2. Prepare the "dispatch" of messages by actually dispatching the Hyperlane messages
+
+ // Dispatch proof message (no token amount)
+ bytes32 proveMessageId = _Router_dispatch(
_destination,
- message,
+ msg.value - _amount,
+ TokenMessage.format(_recipient, 0),
_proveHookMetadata(),
address(hook)
);
- uint256 finalizeQuote = _Router_quoteDispatch(
+
+ // Dispatch withdrawal message (token + fee)
+ bytes32 withdrawMessageId = _Router_dispatch(
_destination,
- message,
+ address(this).balance - _amount,
+ TokenMessage.format(_recipient, _amount),
_finalizeHookMetadata(),
address(hook)
);
- quotes = new Quote[](1);
- quotes[0] = Quote({
- token: address(0),
- amount: proveQuote + finalizeQuote + _amount
- });
+
+ // include for legible error message
+ require(
+ address(this).balance >= _amount,
+ "OP L2 token bridge: insufficient balance"
+ );
+
+ // 3. Emit event manually
+ emit SentTransferRemote(_destination, _recipient, _amount);
+
+ // used for mapping withdrawal to hyperlane prove and finalize messages
+ bytes memory extraData = OPL2ToL1Withdrawal.encodeData(
+ proveMessageId,
+ withdrawMessageId
+ );
+
+ // 4. "Dispatch" the message by calling the L2 bridge to transfer native tokens
+ l2Bridge.bridgeETHTo{value: _amount}(
+ _recipient.bytes32ToAddress(),
+ OP_MIN_GAS_LIMIT_ON_L1,
+ extraData
+ );
+
+ if (address(this).balance > 0) {
+ payable(msg.sender).sendValue(address(this).balance);
+ }
+
+ return withdrawMessageId;
+ }
+
+ // needed for hook refunds
+ receive() external payable {}
+
+ /**
+ * @inheritdoc Router
+ */
+ function handle(uint32, bytes32, bytes calldata) external payable override {
+ revert("OP L2 token bridge should not receive messages");
}
- function _proveHookMetadata() internal view virtual returns (bytes memory) {
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function token() public view override returns (address) {
+ return address(0);
+ }
+
+ function _proveHookMetadata() internal view returns (bytes memory) {
return
StandardHookMetadata.format({
_msgValue: 0,
@@ -79,12 +141,7 @@ contract OpL2NativeTokenBridge is HypNative {
});
}
- function _finalizeHookMetadata()
- internal
- view
- virtual
- returns (bytes memory)
- {
+ function _finalizeHookMetadata() internal view returns (bytes memory) {
return
StandardHookMetadata.format({
_msgValue: 0,
@@ -93,77 +150,67 @@ contract OpL2NativeTokenBridge is HypNative {
});
}
- function _transferRemote(
+ /**
+ * @inheritdoc TokenRouter
+ * @dev Overrides to quote for two messages: prove and finalize.
+ */
+ function _quoteGasPayment(
uint32 _destination,
bytes32 _recipient,
- uint256 _amount,
- uint256 _value,
- bytes memory _hookMetadata,
- address _hook
- ) internal virtual override returns (bytes32) {
- require(
- _amount > 0,
- "OP L2 token bridge: amount must be greater than 0"
- );
-
- // refund first message fees to address(this) to cover second message
- bytes32 proveMessageId = super._transferRemote(
+ uint256 _amount
+ ) internal view override returns (uint256) {
+ bytes memory message = TokenMessage.format(_recipient, _amount);
+ uint256 proveQuote = _Router_quoteDispatch(
_destination,
- _recipient,
- 0,
- _value,
+ message,
_proveHookMetadata(),
- _hook
+ address(hook)
);
-
- bytes32 withdrawMessageId = super._transferRemote(
+ uint256 finalizeQuote = _Router_quoteDispatch(
_destination,
- _recipient,
- _amount,
- address(this).balance,
+ message,
_finalizeHookMetadata(),
- _hook
- );
-
- // include for legible error message
- _transferFromSender(_amount);
-
- // used for mapping withdrawal to hyperlane prove and finalize messages
- bytes memory extraData = OPL2ToL1Withdrawal.encodeData(
- proveMessageId,
- withdrawMessageId
- );
- l2Bridge.bridgeETHTo{value: _amount}(
- _recipient.bytes32ToAddress(),
- OP_MIN_GAS_LIMIT_ON_L1,
- extraData
+ address(hook)
);
+ return proveQuote + finalizeQuote;
+ }
- if (address(this).balance > 0) {
- address refundAddress = _hookMetadata.getRefundAddress(msg.sender);
- require(
- refundAddress != address(0),
- "OP L2 token bridge: refund address is 0"
- );
- payable(refundAddress).sendValue(address(this).balance);
- }
-
- return withdrawMessageId;
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function _transferFromSender(uint256 _amount) internal override {
+ NativeCollateral._transferFromSender(_amount);
}
- function handle(uint32, bytes32, bytes calldata) external payable override {
- revert("OP L2 token bridge should not receive messages");
+ /**
+ * @inheritdoc TokenRouter
+ */
+ function _transferTo(
+ address _recipient,
+ uint256 _amount
+ ) internal override {
+ // should never be called
+ assert(false);
}
}
-abstract contract OpL1NativeTokenBridge is HypNative, OPL2ToL1CcipReadIsm {
+// need intermediate contract to insert slots between TokenRouter and OPL2ToL1CcipReadIsm
+abstract contract OpTokenBridgeStorage is TokenRouter {
+ /// @dev This is used to enable storage layout backwards compatibility. It should not be read or written to.
+ LpCollateralRouterStorage private __LP_COLLATERAL_GAP;
+}
+
+abstract contract OpL1NativeTokenBridge is
+ OpTokenBridgeStorage,
+ OPL2ToL1CcipReadIsm
+{
using Message for bytes;
using TokenMessage for bytes;
function initialize(
address _owner,
string[] memory _urls
- ) public virtual initializer {
+ ) public initializer {
__Ownable_init();
setUrls(_urls);
// ISM should not be set (this contract uses itself as ISM)
@@ -171,14 +218,11 @@ abstract contract OpL1NativeTokenBridge is HypNative, OPL2ToL1CcipReadIsm {
_MailboxClient_initialize(address(0), address(0), _owner);
}
- function _transferRemote(
+ function transferRemote(
uint32,
bytes32,
- uint256,
- uint256,
- bytes memory,
- address
- ) internal override returns (bytes32) {
+ uint256
+ ) public payable override returns (bytes32) {
revert("OP L1 token bridge should not send messages");
}
@@ -189,10 +233,17 @@ abstract contract OpL1NativeTokenBridge is HypNative, OPL2ToL1CcipReadIsm {
return _message.body().amount() == 0;
}
+ function token() public view override returns (address) {
+ return address(0);
+ }
+
+ function _transferFromSender(uint256 _amount) internal override {
+ assert(false);
+ }
+
function _transferTo(
address _recipient,
- uint256 _amount,
- bytes calldata metadata
+ uint256 _amount
) internal override {
// do not transfer to recipient as the OP L1 bridge will do it
}
@@ -214,7 +265,7 @@ contract OpL1V1NativeTokenBridge is
constructor(
address _mailbox,
address _opPortal
- ) HypNative(SCALE, _mailbox) OPL2ToL1CcipReadIsm(_opPortal) {}
+ ) TokenRouter(SCALE, _mailbox) OPL2ToL1CcipReadIsm(_opPortal) {}
}
contract OpL1V2NativeTokenBridge is
@@ -224,5 +275,5 @@ contract OpL1V2NativeTokenBridge is
constructor(
address _mailbox,
address _opPortal
- ) HypNative(SCALE, _mailbox) OPL2ToL1CcipReadIsm(_opPortal) {}
+ ) TokenRouter(SCALE, _mailbox) OPL2ToL1CcipReadIsm(_opPortal) {}
}
diff --git a/solidity/contracts/token/fees/BaseFee.sol b/solidity/contracts/token/fees/BaseFee.sol
new file mode 100644
index 0000000000..5ce2b39a28
--- /dev/null
+++ b/solidity/contracts/token/fees/BaseFee.sol
@@ -0,0 +1,85 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0;
+
+import {ITokenFee, Quote} from "../../interfaces/ITokenBridge.sol";
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+import {Address} from "@openzeppelin/contracts/utils/Address.sol";
+import {PackageVersioned} from "../../PackageVersioned.sol";
+
+enum FeeType {
+ ZERO,
+ LINEAR,
+ REGRESSIVE,
+ PROGRESSIVE,
+ ROUTING
+}
+
+abstract contract BaseFee is Ownable, ITokenFee, PackageVersioned {
+ using Address for address payable;
+ using SafeERC20 for IERC20;
+
+ /**
+ * @notice The ERC20 token for which this fee contract applies.
+ */
+ IERC20 public immutable token;
+
+ /**
+ * @notice The maximum fee (in token units) that can be charged for a transfer.
+ * @dev Used as the cap or asymptote in fee calculations for derived contracts.
+ */
+ uint256 public immutable maxFee;
+
+ /**
+ * @notice The reference amount at which the fee equals half of maxFee.
+ * @dev Used as a scaling parameter in fee formulas; its interpretation depends on the fee model.
+ */
+ uint256 public immutable halfAmount;
+
+ constructor(
+ address _token,
+ uint256 _maxFee,
+ uint256 _halfAmount,
+ address _owner
+ ) Ownable() {
+ require(_maxFee > 0, "maxFee must be greater than zero");
+ require(_halfAmount > 0, "halfAmount must be greater than zero");
+ require(_owner != address(0), "owner cannot be zero address");
+
+ token = IERC20(_token);
+ maxFee = _maxFee;
+ halfAmount = _halfAmount;
+ _transferOwnership(_owner);
+ }
+
+ function claim(address beneficiary) external onlyOwner {
+ if (address(token) == address(0)) {
+ payable(beneficiary).sendValue(address(this).balance);
+ } else {
+ uint256 balance = token.balanceOf(address(this));
+ token.safeTransfer(beneficiary, balance);
+ }
+ }
+
+ function quoteTransferRemote(
+ uint32 /*_destination*/,
+ bytes32 /*_recipient*/,
+ uint256 _amount
+ ) external view virtual returns (Quote[] memory quotes) {
+ quotes = new Quote[](1);
+ quotes[0] = Quote(address(token), _quoteTransfer(_amount));
+ }
+
+ function _quoteTransfer(
+ uint256 /*_amount*/
+ ) internal view virtual returns (uint256 fee) {
+ return 0;
+ }
+
+ function feeType() external view virtual returns (FeeType);
+
+ receive() external payable {
+ require(address(token) == address(0), "Not native token");
+ }
+}
diff --git a/solidity/contracts/token/fees/LinearFee.sol b/solidity/contracts/token/fees/LinearFee.sol
new file mode 100644
index 0000000000..20e8100716
--- /dev/null
+++ b/solidity/contracts/token/fees/LinearFee.sol
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0;
+
+import {BaseFee, FeeType} from "./BaseFee.sol";
+
+/**
+ * @title Linear Fee Structure
+ * @dev Implements a linear fee model where the fee increases linearly with the transfer amount, up to a maximum cap.
+ *
+ * The fee calculation follows the formula:
+ * fee = min(maxFee, (amount * maxFee) / (2 * halfAmount))
+ *
+ * For example:
+ * - If maxFee = 10 and halfAmount = 1000, then:
+ * - For amount = 1000, fee = 5 (half of maxFee)
+ * - For amount = 2000, fee = 10 (maxFee)
+ * - For amount = 500, fee = 2 (rounded down)
+ * - For amounts above 2 * halfAmount, the fee is capped at maxFee.
+ *
+ * This creates a simple, predictable fee structure where the fee scales linearly with the transfer amount until it reaches the cap.
+ *
+ * @dev The fee is always rounded down due to integer division
+ */
+contract LinearFee is BaseFee {
+ constructor(
+ address _token,
+ uint256 _maxFee,
+ uint256 _halfAmount,
+ address _owner
+ ) BaseFee(_token, _maxFee, _halfAmount, _owner) {}
+
+ function _quoteTransfer(
+ uint256 amount
+ ) internal view override returns (uint256 fee) {
+ uint256 uncapped = (amount * maxFee) / (2 * halfAmount);
+ return uncapped > maxFee ? maxFee : uncapped;
+ }
+
+ function feeType() external pure override returns (FeeType) {
+ return FeeType.LINEAR;
+ }
+}
diff --git a/solidity/contracts/token/fees/ProgressiveFee.sol b/solidity/contracts/token/fees/ProgressiveFee.sol
new file mode 100644
index 0000000000..a3f1712488
--- /dev/null
+++ b/solidity/contracts/token/fees/ProgressiveFee.sol
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0;
+
+import {BaseFee, FeeType} from "./BaseFee.sol";
+
+/**
+ * @title Progressive Fee Structure
+ * @dev Implements a progressive fee model where the fee percentage increases with transfer amount
+ * until reaching a peak at halfAmount, then decreases as the absolute fee approaches maxFee.
+ *
+ * The fee calculation uses a rational function: fee = (maxFee * amount^2) / (halfAmount^2 + amount^2)
+ *
+ * Key characteristics:
+ * - Fee percentage increases for transfers below halfAmount (progressive phase)
+ * - Fee percentage peaks at halfAmount where fee = maxFee/2
+ * - Fee percentage decreases for transfers above halfAmount (regressive phase due to maxFee cap)
+ * - Absolute fee approaches but never reaches maxFee as amount increases
+ * - Fee approaches 0 as amount approaches 0
+ *
+ * Example:
+ * - If maxFee = 1000 and halfAmount = 10000:
+ * - Transfer of 2000 wei: fee = (1000 * 2000^2) / (10000^2 + 2000^2) = 38.5 wei (1.92% of amount)
+ * - Transfer of 10000 wei: fee = (1000 * 10000^2) / (10000^2 + 10000^2) = 500 wei (5% of amount)
+ * - Transfer of 50000 wei: fee = (1000 * 50000^2) / (10000^2 + 50000^2) = 961.5 wei (1.92% of amount)
+ *
+ * This structure encourages mid-sized transfers while applying lower effective rates to both
+ * very small and very large transactions.
+ */
+contract ProgressiveFee is BaseFee {
+ constructor(
+ address _token,
+ uint256 _maxFee,
+ uint256 _halfAmount,
+ address beneficiary
+ ) BaseFee(_token, _maxFee, _halfAmount, beneficiary) {}
+
+ function _quoteTransfer(
+ uint256 amount
+ ) internal view override returns (uint256 fee) {
+ if (amount == 0) {
+ return 0;
+ }
+ uint256 amountSquared = amount ** 2;
+ return (maxFee * amountSquared) / (halfAmount ** 2 + amountSquared);
+ }
+
+ function feeType() external pure override returns (FeeType) {
+ return FeeType.PROGRESSIVE;
+ }
+}
diff --git a/solidity/contracts/token/fees/RegressiveFee.sol b/solidity/contracts/token/fees/RegressiveFee.sol
new file mode 100644
index 0000000000..112aab1e17
--- /dev/null
+++ b/solidity/contracts/token/fees/RegressiveFee.sol
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0;
+
+import {BaseFee, FeeType} from "./BaseFee.sol";
+
+/**
+ * @title Regressive Fee Structure
+ * @dev Implements a regressive fee model where the fee percentage continuously decreases as the transfer amount increases.
+ *
+ * The fee calculation uses a rational function: fee = (maxFee * amount) / (halfAmount + amount)
+ *
+ * Key characteristics:
+ * - Fee percentage continuously decreases as amount increases (regressive throughout)
+ * - At halfAmount, fee = maxFee/2 and fee percentage = maxFee/(2*halfAmount)
+ * - Absolute fee approaches but never reaches maxFee as amount approaches infinity
+ * - Fee approaches 0 as amount approaches 0
+ *
+ * Example:
+ * - If maxFee = 1000 and halfAmount = 5000:
+ * - Transfer of 1000 wei: fee = (1000 * 1000) / (5000 + 1000) = 166.7 wei (16.67% of amount)
+ * - Transfer of 5000 wei: fee = (1000 * 5000) / (5000 + 5000) = 500 wei (10% of amount)
+ * - Transfer of 20000 wei: fee = (1000 * 20000) / (5000 + 20000) = 800 wei (4% of amount)
+ *
+ * This structure encourages larger transfers while discouraging smaller transactions with higher
+ * effective fee rates.
+ */
+contract RegressiveFee is BaseFee {
+ constructor(
+ address _token,
+ uint256 _maxFee,
+ uint256 _halfAmount,
+ address beneficiary
+ ) BaseFee(_token, _maxFee, _halfAmount, beneficiary) {}
+
+ function _quoteTransfer(
+ uint256 amount
+ ) internal view override returns (uint256 fee) {
+ uint256 denominator = halfAmount + amount;
+ return denominator == 0 ? 0 : (maxFee * amount) / denominator;
+ }
+
+ function feeType() external pure override returns (FeeType) {
+ return FeeType.REGRESSIVE;
+ }
+}
diff --git a/solidity/contracts/token/fees/RoutingFee.sol b/solidity/contracts/token/fees/RoutingFee.sol
new file mode 100644
index 0000000000..37ae830837
--- /dev/null
+++ b/solidity/contracts/token/fees/RoutingFee.sol
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: Apache-2.0
+pragma solidity >=0.8.0;
+
+import {ITokenFee, Quote} from "../../interfaces/ITokenBridge.sol";
+import {BaseFee, FeeType} from "./BaseFee.sol";
+
+/**
+ * @title RoutingFee
+ * @notice Implements ITokenFee, allowing per-destination fee contracts. Returns 0 fee for destinations not configured.
+ */
+contract RoutingFee is BaseFee {
+ constructor(
+ address _token,
+ address _owner
+ ) BaseFee(_token, type(uint256).max, type(uint256).max, _owner) {}
+
+ mapping(uint32 destination => address feeContract) public feeContracts;
+
+ event FeeContractSet(uint32 destination, address feeContract);
+
+ /**
+ * @notice Sets the fee contract for a specific destination chain.
+ * @param destination The destination chain ID.
+ * @param feeContract The address of the ITokenFee contract for this destination.
+ */
+ function setFeeContract(
+ uint32 destination,
+ address feeContract
+ ) external onlyOwner {
+ feeContracts[destination] = feeContract;
+ emit FeeContractSet(destination, feeContract);
+ }
+
+ /**
+ * @inheritdoc ITokenFee
+ * @dev Returns a zero-amount Quote if no fee contract is set for the destination.
+ */
+ function quoteTransferRemote(
+ uint32 _destination,
+ bytes32 _recipient,
+ uint256 _amount
+ ) external view override returns (Quote[] memory quotes) {
+ address feeContract = feeContracts[_destination];
+ if (feeContract != address(0)) {
+ return
+ ITokenFee(feeContract).quoteTransferRemote(
+ _destination,
+ _recipient,
+ _amount
+ );
+ }
+ quotes = new Quote[](0);
+ }
+
+ function feeType() external pure override returns (FeeType) {
+ return FeeType.ROUTING;
+ }
+}
diff --git a/solidity/contracts/token/interfaces/IWETH.sol b/solidity/contracts/token/interfaces/IWETH.sol
new file mode 100644
index 0000000000..5ba2a4fa17
--- /dev/null
+++ b/solidity/contracts/token/interfaces/IWETH.sol
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+pragma solidity ^0.8.22;
+
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+
+interface IWETH is IERC20 {
+ function deposit() external payable;
+ function withdraw(uint256 amount) external;
+}
diff --git a/solidity/contracts/token/interfaces/ValueTransferBridge.sol b/solidity/contracts/token/interfaces/ValueTransferBridge.sol
deleted file mode 100644
index 8d573e5df4..0000000000
--- a/solidity/contracts/token/interfaces/ValueTransferBridge.sol
+++ /dev/null
@@ -1,21 +0,0 @@
-// SPDX-License-Identifier: MIT OR Apache-2.0
-pragma solidity >=0.8.0;
-
-struct Quote {
- address token;
- uint256 amount;
-}
-
-interface ValueTransferBridge {
- function quoteTransferRemote(
- uint32 destinationDomain,
- bytes32 recipient,
- uint amountOut
- ) external view returns (Quote[] memory);
-
- function transferRemote(
- uint32 destinationDomain,
- bytes32 recipient,
- uint256 amountOut
- ) external payable returns (bytes32 transferId);
-}
diff --git a/solidity/contracts/token/libs/FungibleTokenRouter.sol b/solidity/contracts/token/libs/FungibleTokenRouter.sol
deleted file mode 100644
index 7579f3287e..0000000000
--- a/solidity/contracts/token/libs/FungibleTokenRouter.sol
+++ /dev/null
@@ -1,36 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-pragma solidity >=0.8.0;
-
-import {TokenRouter} from "./TokenRouter.sol";
-
-/**
- * @title Hyperlane Fungible Token Router that extends TokenRouter with scaling logic for fungible tokens with different decimals.
- * @author Abacus Works
- */
-abstract contract FungibleTokenRouter is TokenRouter {
- uint256 public immutable scale;
-
- constructor(uint256 _scale, address _mailbox) TokenRouter(_mailbox) {
- scale = _scale;
- }
-
- /**
- * @dev Scales local amount to message amount (up by scale factor).
- * @inheritdoc TokenRouter
- */
- function _outboundAmount(
- uint256 _localAmount
- ) internal view virtual override returns (uint256 _messageAmount) {
- _messageAmount = _localAmount * scale;
- }
-
- /**
- * @dev Scales message amount to local amount (down by scale factor).
- * @inheritdoc TokenRouter
- */
- function _inboundAmount(
- uint256 _messageAmount
- ) internal view virtual override returns (uint256 _localAmount) {
- _localAmount = _messageAmount / scale;
- }
-}
diff --git a/solidity/contracts/token/libs/LpCollateralRouter.sol b/solidity/contracts/token/libs/LpCollateralRouter.sol
new file mode 100644
index 0000000000..4c0024303e
--- /dev/null
+++ b/solidity/contracts/token/libs/LpCollateralRouter.sol
@@ -0,0 +1,103 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0;
+
+// ============ Internal Imports ============
+import {MovableCollateralRouter, MovableCollateralRouterStorage} from "./MovableCollateralRouter.sol";
+
+// ============ External Imports ============
+import {ERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol";
+import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";
+
+struct LpCollateralRouterStorage {
+ // MovableCollateralRouter layout
+ MovableCollateralRouterStorage __MOVABLE_COLLATERAL_GAP;
+ // ERC4626 layout
+ // - (ERC20 layout)
+ mapping(address => uint256) _balances;
+ mapping(address => mapping(address => uint256)) _allowances;
+ uint256 _totalSupply;
+ string _name;
+ string _symbol;
+ // @openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol:376
+ uint256[45] __ERC20_GAP;
+ // - (ERC4626 layout)
+ address _asset;
+ uint8 _underlyingDecimals;
+ // @openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol:267
+ uint256[49] __ERC4626_GAP;
+ // user defined fields
+ uint256 lpAssets;
+}
+
+abstract contract LpCollateralRouter is
+ MovableCollateralRouter,
+ ERC4626Upgradeable
+{
+ uint256 private lpAssets;
+
+ event Donation(address sender, uint256 amount);
+
+ function _LpCollateralRouter_initialize() internal onlyInitializing {
+ __ERC4626_init(IERC20Upgradeable(token()));
+ }
+
+ function totalAssets() public view override returns (uint256) {
+ return lpAssets;
+ }
+
+ function asset() public view override returns (address) {
+ return token();
+ }
+
+ // modeled after ERC4626Upgradeable._deposit
+ function _deposit(
+ address caller,
+ address receiver,
+ uint256 assets,
+ uint256 shares
+ ) internal override {
+ // checks
+ _transferFromSender(assets);
+
+ // effects
+ lpAssets += assets;
+
+ // interactions
+ _mint(receiver, shares);
+
+ emit Deposit(caller, receiver, assets, shares);
+ }
+
+ // modeled after ERC4626Upgradeable._withdraw
+ function _withdraw(
+ address caller,
+ address receiver,
+ address owner,
+ uint256 assets,
+ uint256 shares
+ ) internal override {
+ // checks
+ if (caller != owner) {
+ _spendAllowance(owner, caller, shares);
+ }
+ _burn(owner, shares);
+
+ // effects
+ lpAssets -= assets;
+
+ // interactions
+ _transferTo(receiver, assets);
+
+ emit Withdraw(caller, receiver, owner, assets, shares);
+ }
+
+ // can be used to distribute rewards to LPs pro rata
+ function donate(uint256 amount) public payable {
+ // checks
+ _transferFromSender(amount);
+
+ // effects
+ lpAssets += amount;
+ emit Donation(msg.sender, amount);
+ }
+}
diff --git a/solidity/contracts/token/libs/MovableCollateralRouter.sol b/solidity/contracts/token/libs/MovableCollateralRouter.sol
index fe774619ac..effa04eb9b 100644
--- a/solidity/contracts/token/libs/MovableCollateralRouter.sol
+++ b/solidity/contracts/token/libs/MovableCollateralRouter.sol
@@ -1,29 +1,26 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity >=0.8.0;
-import {Router} from "contracts/client/Router.sol";
-import {FungibleTokenRouter} from "./FungibleTokenRouter.sol";
-import {ValueTransferBridge} from "../interfaces/ValueTransferBridge.sol";
+import {ITokenBridge, Quote} from "../../interfaces/ITokenBridge.sol";
import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
-
+import {TokenRouter} from "./TokenRouter.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+import {Router} from "../../client/Router.sol";
+import {Quotes} from "./Quotes.sol";
+
+struct MovableCollateralRouterStorage {
+ mapping(uint32 routerDomain => bytes32 recipient) recipient;
+ mapping(uint32 routerDomain => EnumerableSet.AddressSet bridges) bridges;
+ EnumerableSet.AddressSet rebalancers;
+}
-abstract contract MovableCollateralRouter is FungibleTokenRouter {
+abstract contract MovableCollateralRouter is TokenRouter {
using SafeERC20 for IERC20;
using EnumerableSet for EnumerableSet.AddressSet;
+ using Quotes for Quote[];
- /// @notice Mapping of domain to allowed rebalance recipient.
- /// @dev Keys constrained to a subset of Router.domains()
- mapping(uint32 routerDomain => bytes32 recipient) public allowedRecipient;
-
- /// @notice Mapping of domain to allowed rebalance bridges.
- /// @dev Keys constrained to a subset of Router.domains()
- mapping(uint32 routerDomain => EnumerableSet.AddressSet bridges)
- internal _allowedBridges;
-
- /// @notice Set of addresses that are allowed to rebalance.
- EnumerableSet.AddressSet internal _allowedRebalancers;
+ MovableCollateralRouterStorage private allowed;
event CollateralMoved(
uint32 indexed domain,
@@ -34,52 +31,69 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter {
modifier onlyRebalancer() {
require(
- _allowedRebalancers.contains(_msgSender()),
+ allowed.rebalancers.contains(_msgSender()),
"MCR: Only Rebalancer"
);
_;
}
- modifier onlyAllowedBridge(uint32 domain, ValueTransferBridge bridge) {
- EnumerableSet.AddressSet storage bridges = _allowedBridges[domain];
+ modifier onlyAllowedBridge(uint32 domain, ITokenBridge bridge) {
+ EnumerableSet.AddressSet storage bridges = allowed.bridges[domain];
require(bridges.contains(address(bridge)), "MCR: Not allowed bridge");
_;
}
+ /// @notice Set of addresses that are allowed to rebalance.
function allowedRebalancers() external view returns (address[] memory) {
- return _allowedRebalancers.values();
+ return allowed.rebalancers.values();
}
+ /// @notice Mapping of domain to allowed rebalance recipient.
+ /// @dev Keys constrained to a subset of Router.domains()
+ function allowedRecipient(uint32 domain) external view returns (bytes32) {
+ return allowed.recipient[domain];
+ }
+
+ /// @notice Mapping of domain to allowed rebalance bridges.
+ /// @dev Keys constrained to a subset of Router.domains()
function allowedBridges(
uint32 domain
) external view returns (address[] memory) {
- return _allowedBridges[domain].values();
+ return allowed.bridges[domain].values();
}
function setRecipient(uint32 domain, bytes32 recipient) external onlyOwner {
// constrain to a subset of Router.domains()
_mustHaveRemoteRouter(domain);
- allowedRecipient[domain] = recipient;
+ allowed.recipient[domain] = recipient;
}
function removeRecipient(uint32 domain) external onlyOwner {
- delete allowedRecipient[domain];
+ delete allowed.recipient[domain];
}
- function addBridge(
- uint32 domain,
- ValueTransferBridge bridge
- ) external onlyOwner {
+ function addBridge(uint32 domain, ITokenBridge bridge) external onlyOwner {
// constrain to a subset of Router.domains()
_mustHaveRemoteRouter(domain);
- _allowedBridges[domain].add(address(bridge));
+ _addBridge(domain, bridge);
+ }
+
+ function _addBridge(uint32 domain, ITokenBridge bridge) internal virtual {
+ allowed.bridges[domain].add(address(bridge));
}
function removeBridge(
uint32 domain,
- ValueTransferBridge bridge
+ ITokenBridge bridge
) external onlyOwner {
- _allowedBridges[domain].remove(address(bridge));
+ _removeBridge(domain, bridge);
+ }
+
+ function _removeBridge(
+ uint32 domain,
+ ITokenBridge bridge
+ ) internal virtual {
+ allowed.bridges[domain].remove(address(bridge));
}
/**
@@ -90,46 +104,70 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter {
*/
function approveTokenForBridge(
IERC20 token,
- ValueTransferBridge bridge
+ ITokenBridge bridge
) external onlyOwner {
token.safeApprove(address(bridge), type(uint256).max);
}
function addRebalancer(address rebalancer) external onlyOwner {
- _allowedRebalancers.add(rebalancer);
+ allowed.rebalancers.add(rebalancer);
}
function removeRebalancer(address rebalancer) external onlyOwner {
- _allowedRebalancers.remove(rebalancer);
+ allowed.rebalancers.remove(rebalancer);
}
/**
* @notice Rebalances the collateral between router domains.
* @param domain The domain to rebalance to.
- * @param amount The amount of collateral to rebalance.
+ * @param collateralAmount The amount of collateral to rebalance.
* @param bridge The bridge to use for the rebalance.
* @dev The caller must be an allowed rebalancer and the bridge must be an allowed bridge for the domain.
* @dev The recipient is the enrolled router if no recipient is set for the domain.
*/
function rebalance(
uint32 domain,
- uint256 amount,
- ValueTransferBridge bridge
+ uint256 collateralAmount,
+ ITokenBridge bridge
) external payable onlyRebalancer onlyAllowedBridge(domain, bridge) {
- address rebalancer = _msgSender();
+ bytes32 recipient = _recipient(domain);
+
+ Quote[] memory quotes = bridge.quoteTransferRemote(
+ domain,
+ recipient,
+ collateralAmount
+ );
- bytes32 recipient = allowedRecipient[domain];
+ // charge the rebalancer any bridging fees denominated in the collateral
+ // token to avoid undercollateralization
+ uint256 collateralFees = quotes.extract(token());
+ if (collateralFees > collateralAmount) {
+ _transferFromSender(collateralFees - collateralAmount);
+ }
+
+ // need to handle native quote separately from collateral quote because
+ // token() may be address(0), in which case we need to use address(this).balance
+ // to move native collateral tokens across chains
+ uint256 nativeFees = quotes.extract(address(0));
+ if (nativeFees > address(this).balance) {
+ revert("Rebalance native fee exceeds balance");
+ }
+
+ bridge.transferRemote{value: nativeFees}(
+ domain,
+ recipient,
+ collateralAmount
+ );
+ emit CollateralMoved(domain, recipient, collateralAmount, msg.sender);
+ }
+
+ function _recipient(
+ uint32 domain
+ ) internal view returns (bytes32 recipient) {
+ recipient = allowed.recipient[domain];
if (recipient == bytes32(0)) {
recipient = _mustHaveRemoteRouter(domain);
}
-
- _rebalance(domain, recipient, amount, bridge);
- emit CollateralMoved({
- domain: domain,
- recipient: recipient,
- amount: amount,
- rebalancer: rebalancer
- });
}
/// @dev This function in `EnumerableSet` was introduced in OpenZeppelin v5. We are using 4.9
@@ -150,21 +188,8 @@ abstract contract MovableCollateralRouter is FungibleTokenRouter {
/// @dev Constrains keys of rebalance mappings to Router.domains()
function _unenrollRemoteRouter(uint32 domain) internal override {
- delete allowedRecipient[domain];
- _clear(_allowedBridges[domain]._inner);
+ delete allowed.recipient[domain];
+ _clear(allowed.bridges[domain]._inner);
Router._unenrollRemoteRouter(domain);
}
-
- function _rebalance(
- uint32 domain,
- bytes32 recipient,
- uint256 amount,
- ValueTransferBridge bridge
- ) internal virtual {
- bridge.transferRemote{value: msg.value}({
- destinationDomain: domain,
- recipient: recipient,
- amountOut: amount
- });
- }
}
diff --git a/solidity/contracts/token/libs/Quotes.sol b/solidity/contracts/token/libs/Quotes.sol
new file mode 100644
index 0000000000..6f4d61525b
--- /dev/null
+++ b/solidity/contracts/token/libs/Quotes.sol
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: Apache-2.0
+pragma solidity >=0.8.0;
+
+import {Quote} from "../../interfaces/ITokenBridge.sol";
+
+library Quotes {
+ function extract(
+ Quote[] memory quotes,
+ address token
+ ) internal pure returns (uint256 sum) {
+ sum = 0;
+ for (uint256 i = 0; i < quotes.length; i++) {
+ if (quotes[i].token == token) {
+ sum += quotes[i].amount;
+ }
+ }
+ }
+}
diff --git a/solidity/contracts/token/libs/TokenCollateral.sol b/solidity/contracts/token/libs/TokenCollateral.sol
new file mode 100644
index 0000000000..e9cf4f7b44
--- /dev/null
+++ b/solidity/contracts/token/libs/TokenCollateral.sol
@@ -0,0 +1,80 @@
+// SPDX-License-Identifier: Apache-2.0
+pragma solidity >=0.8.0;
+
+import {IWETH} from "../interfaces/IWETH.sol";
+
+// ============ External Imports ============
+import {Address} from "@openzeppelin/contracts/utils/Address.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
+
+/**
+ * @title Handles deposits and withdrawals of native token collateral.
+ */
+library NativeCollateral {
+ function _transferFromSender(uint256 _amount) internal {
+ require(msg.value >= _amount, "Native: amount exceeds msg.value");
+ }
+
+ function _transferTo(address _recipient, uint256 _amount) internal {
+ Address.sendValue(payable(_recipient), _amount);
+ }
+}
+
+/**
+ * @title Handles deposits and withdrawals of WETH collateral.
+ * @dev TokenRouters must have `token() == address(0)` to use this library.
+ */
+library WETHCollateral {
+ function _transferFromSender(IWETH token, uint256 _amount) internal {
+ NativeCollateral._transferFromSender(_amount);
+ token.deposit{value: _amount}();
+ }
+
+ function _transferTo(
+ IWETH token,
+ address _recipient,
+ uint256 _amount
+ ) internal {
+ token.withdraw(_amount);
+ NativeCollateral._transferTo(_recipient, _amount);
+ }
+}
+
+/**
+ * @title Handles deposits and withdrawals of ERC20 collateral.
+ */
+library ERC20Collateral {
+ using SafeERC20 for IERC20;
+
+ function _transferFromSender(IERC20 token, uint256 _amount) internal {
+ token.safeTransferFrom(msg.sender, address(this), _amount);
+ }
+
+ function _transferTo(
+ IERC20 token,
+ address _recipient,
+ uint256 _amount
+ ) internal {
+ token.safeTransfer(_recipient, _amount);
+ }
+}
+
+/**
+ * @title Handles deposits and withdrawals of ERC721 collateral.
+ */
+library ERC721Collateral {
+ function _transferFromSender(IERC721 token, uint256 _tokenId) internal {
+ // safeTransferFrom not used here because recipient is this contract
+ token.transferFrom(msg.sender, address(this), _tokenId);
+ }
+
+ function _transferTo(
+ IERC721 token,
+ address _recipient,
+ uint256 _tokenId
+ ) internal {
+ token.safeTransferFrom(address(this), _recipient, _tokenId);
+ }
+}
diff --git a/solidity/contracts/token/libs/TokenRouter.sol b/solidity/contracts/token/libs/TokenRouter.sol
index e01102fa98..7a96a3b1eb 100644
--- a/solidity/contracts/token/libs/TokenRouter.sol
+++ b/solidity/contracts/token/libs/TokenRouter.sol
@@ -2,230 +2,421 @@
pragma solidity >=0.8.0;
// ============ Internal Imports ============
-import {TypeCasts} from "contracts/libs/TypeCasts.sol";
+import {TypeCasts} from "../../libs/TypeCasts.sol";
import {GasRouter} from "../../client/GasRouter.sol";
import {TokenMessage} from "./TokenMessage.sol";
-import {Quote, ITokenBridge} from "../../interfaces/ITokenBridge.sol";
+import {Quote, ITokenBridge, ITokenFee} from "../../interfaces/ITokenBridge.sol";
+import {Quotes} from "./Quotes.sol";
+import {StorageSlot} from "@openzeppelin/contracts/utils/StorageSlot.sol";
/**
* @title Hyperlane Token Router that extends Router with abstract token (ERC20/ERC721) remote transfer functionality.
+ * @dev Overridable functions:
+ * - token(): specify the managed token address
+ * - _transferFromSender(uint256): pull tokens/ETH from msg.sender
+ * - _transferTo(address,uint256): send tokens/ETH to the recipient
+ * - _externalFeeAmount(uint32,bytes32,uint256): compute external fees (default returns 0)
+ * @dev Override transferRemote only to implement custom logic that can't be accomplished with the above functions.
+ *
* @author Abacus Works
*/
abstract contract TokenRouter is GasRouter, ITokenBridge {
using TypeCasts for bytes32;
using TypeCasts for address;
using TokenMessage for bytes;
+ using StorageSlot for bytes32;
+ using Quotes for Quote[];
/**
* @dev Emitted on `transferRemote` when a transfer message is dispatched.
* @param destination The identifier of the destination chain.
* @param recipient The address of the recipient on the destination chain.
- * @param amount The amount of tokens sent in to the remote recipient.
+ * @param amountOrId The amount or ID of tokens sent in to the remote recipient.
*/
event SentTransferRemote(
uint32 indexed destination,
bytes32 indexed recipient,
- uint256 amount
+ uint256 amountOrId
);
/**
* @dev Emitted on `_handle` when a transfer message is processed.
* @param origin The identifier of the origin chain.
* @param recipient The address of the recipient on the destination chain.
- * @param amount The amount of tokens received from the remote sender.
+ * @param amountOrId The amount or ID of tokens received from the remote sender.
*/
event ReceivedTransferRemote(
uint32 indexed origin,
bytes32 indexed recipient,
- uint256 amount
+ uint256 amountOrId
);
- constructor(address _mailbox) GasRouter(_mailbox) {}
+ uint256 public immutable scale;
+
+ // cannot use compiler assigned slot without
+ // breaking backwards compatibility of storage layout
+ bytes32 private constant FEE_RECIPIENT_SLOT =
+ keccak256("FungibleTokenRouter.feeRecipient");
+
+ event FeeRecipientSet(address feeRecipient);
+
+ constructor(uint256 _scale, address _mailbox) GasRouter(_mailbox) {
+ scale = _scale;
+ }
+
+ // ===========================
+ // ========== Main API ==========
+ // ===========================
/**
- * @notice Transfers `_amountOrId` token to `_recipient` on `_destination` domain.
- * @dev Delegates transfer logic to `_transferFromSender` implementation.
- * @dev Emits `SentTransferRemote` event on the origin chain.
+ * @notice Returns the address of the token managed by this router. It can be one of three options:
+ * - ERC20 token address for fungible tokens that are being collateralized (HypERC20Collateral, HypERC4626, etc.)
+ * - 0x0 address for native tokens (ETH, MATIC, etc.) (HypNative, etc.)
+ * - address(this) for synthetic ERC20 tokens (HypERC20, etc.)
+ * It is being used for quotes and fees from the fee recipient and pulling/push the tokens from the sender/receipient.
+ * @dev This function must be implemented by derived contracts to specify the token address.
+ * @return The address of the token contract.
+ */
+ function token() public view virtual returns (address);
+
+ /**
+ * @inheritdoc ITokenFee
+ * @notice Implements the standardized fee quoting interface for token transfers based on
+ * overridable internal functions of _quoteGasPayment, _feeRecipientAndAmount, and _externalFeeAmount.
* @param _destination The identifier of the destination chain.
* @param _recipient The address of the recipient on the destination chain.
- * @param _amountOrId The amount or identifier of tokens to be sent to the remote recipient.
- * @return messageId The identifier of the dispatched message.
+ * @param _amount The amount or identifier of tokens to be sent to the remote recipient
+ * @return quotes An array of Quote structs representing the fees in different tokens.
+ * @dev This function may return multiple quotes with the same denomination. Convention is to return:
+ * [index 0] native fees charged by the mailbox dispatch
+ * [index 1] then any internal warp route fees (amount bridged plus fee recipient)
+ * [index 2] then any external bridging fees (if any, else 0)
+ * These are surfaced as separate elements to enable clients to interpret/render fees independently.
+ * There is a Quotes library with an extract function for onchain quoting in a specific denomination,
+ * but we discourage onchain quoting in favor of offchain quoting and overpaying with refunds.
*/
- function transferRemote(
+ function quoteTransferRemote(
uint32 _destination,
bytes32 _recipient,
- uint256 _amountOrId
- ) external payable virtual returns (bytes32 messageId) {
- return
- _transferRemote(_destination, _recipient, _amountOrId, msg.value);
+ uint256 _amount
+ ) external view override returns (Quote[] memory quotes) {
+ quotes = new Quote[](3);
+ quotes[0] = Quote({
+ token: address(0),
+ amount: _quoteGasPayment(_destination, _recipient, _amount)
+ });
+ (, uint256 feeAmount) = _feeRecipientAndAmount(
+ _destination,
+ _recipient,
+ _amount
+ );
+ quotes[1] = Quote({token: token(), amount: _amount + feeAmount});
+ quotes[2] = Quote({
+ token: token(),
+ amount: _externalFeeAmount(_destination, _recipient, _amount)
+ });
}
/**
- * @notice Transfers `_amountOrId` token to `_recipient` on `_destination` domain with a specified hook
+ * @notice Transfers `_amount` token to `_recipient` on the `_destination` domain.
* @dev Delegates transfer logic to `_transferFromSender` implementation.
- * @dev The metadata is the token metadata, and is DIFFERENT than the hook metadata.
- * @dev Emits `SentTransferRemote` event on the origin chain.
+ * Emits `SentTransferRemote` event on the origin chain.
+ * Override with custom behavior for storing or forwarding tokens.
+ * Known overrides:
+ * - OPL2ToL1TokenBridgeNative: adds hook metadata for message dispatch.
+ * - EverclearTokenBridge: creates Everclear intent for cross-chain token transfer.
+ * - TokenBridgeCctpBase: adds CCTP-specific metadata for message dispatch.
+ * - HypERC4626Collateral: deposits into vault and handles shares.
+ * When overriding, mirror the general flow of this function for consistency:
+ * 1. Calculate fees and charge the sender.
+ * 2. Prepare the token message with recipient, amount, and any additional metadata.
+ * 3. Emit `SentTransferRemote` event.
+ * 4. Dispatch the message.
* @param _destination The identifier of the destination chain.
* @param _recipient The address of the recipient on the destination chain.
- * @param _amountOrId The amount or identifier of tokens to be sent to the remote recipient.
- * @param _hookMetadata The metadata passed into the hook
- * @param _hook The post dispatch hook to be called by the Mailbox
+ * @param _amount The amount or identifier of tokens to be sent to the remote recipient.
* @return messageId The identifier of the dispatched message.
*/
function transferRemote(
uint32 _destination,
bytes32 _recipient,
- uint256 _amountOrId,
- bytes calldata _hookMetadata,
- address _hook
- ) external payable virtual returns (bytes32 messageId) {
+ uint256 _amount
+ ) public payable virtual returns (bytes32 messageId) {
+ // 1. Calculate the fee amounts, charge the sender and distribute to feeRecipient if necessary
+ (, uint256 remainingNativeValue) = _calculateFeesAndCharge(
+ _destination,
+ _recipient,
+ _amount,
+ msg.value
+ );
+
+ uint256 scaledAmount = _outboundAmount(_amount);
+
+ // 2. Prepare the token message with the recipient and amount
+ bytes memory _tokenMessage = TokenMessage.format(
+ _recipient,
+ scaledAmount
+ );
+
+ // 3. Emit the SentTransferRemote event and 4. dispatch the message
return
- _transferRemote(
+ _emitAndDispatch(
_destination,
_recipient,
- _amountOrId,
- msg.value,
- _hookMetadata,
- _hook
+ scaledAmount,
+ remainingNativeValue,
+ _tokenMessage
);
}
- function _transferRemote(
+ // ===========================
+ // ========== Internal convenience functions for readability ==========
+ // ==========================
+ function _calculateFeesAndCharge(
uint32 _destination,
bytes32 _recipient,
- uint256 _amountOrId,
- uint256 _value
- ) internal returns (bytes32 messageId) {
- return
- _transferRemote(
- _destination,
- _recipient,
- _amountOrId,
- _value,
- _GasRouter_hookMetadata(_destination),
- address(hook)
- );
+ uint256 _amount,
+ uint256 _msgValue
+ ) internal returns (uint256 externalFee, uint256 remainingNativeValue) {
+ (address _feeRecipient, uint256 feeAmount) = _feeRecipientAndAmount(
+ _destination,
+ _recipient,
+ _amount
+ );
+ externalFee = _externalFeeAmount(_destination, _recipient, _amount);
+ uint256 charge = _amount + feeAmount + externalFee;
+ _transferFromSender(charge);
+ if (feeAmount > 0) {
+ // transfer atomically so we don't need to keep track of collateral
+ // and fee balances separately
+ _transferFee(_feeRecipient, feeAmount);
+ }
+ remainingNativeValue = token() != address(0)
+ ? _msgValue
+ : _msgValue - charge;
}
- function _transferRemote(
+ // Emits the SentTransferRemote event and dispatches the message.
+ function _emitAndDispatch(
uint32 _destination,
bytes32 _recipient,
- uint256 _amountOrId,
- uint256 _value,
- bytes memory _hookMetadata,
- address _hook
- ) internal virtual returns (bytes32 messageId) {
- bytes memory _tokenMetadata = _transferFromSender(_amountOrId);
-
- uint256 outboundAmount = _outboundAmount(_amountOrId);
- bytes memory _tokenMessage = TokenMessage.format(
- _recipient,
- outboundAmount,
- _tokenMetadata
- );
+ uint256 _amount,
+ uint256 _messageDispatchValue,
+ bytes memory _tokenMessage
+ ) internal returns (bytes32 messageId) {
+ // effects
+ emit SentTransferRemote(_destination, _recipient, _amount);
+ // interactions
messageId = _Router_dispatch(
_destination,
- _value,
+ _messageDispatchValue,
_tokenMessage,
- _hookMetadata,
- _hook
+ _GasRouter_hookMetadata(_destination),
+ address(hook)
);
-
- emit SentTransferRemote(_destination, _recipient, outboundAmount);
}
- /**
- * @dev Should return the amount of tokens to be encoded in the message amount (eg for scaling `_localAmount`).
- * @param _localAmount The amount of tokens transferred on this chain in local denomination.
- * @return _messageAmount The amount of tokens to be encoded in the message body.
- */
- function _outboundAmount(
- uint256 _localAmount
- ) internal view virtual returns (uint256 _messageAmount) {
- _messageAmount = _localAmount;
- }
+ // ===========================
+ // ========== Fees & Quoting ==========
+ // ===========================
/**
- * @dev Should return the amount of tokens to be decoded from the message amount.
- * @param _messageAmount The amount of tokens received in the message body.
- * @return _localAmount The amount of tokens to be transferred on this chain in local denomination.
+ * @notice Sets the fee recipient for the router.
+ * @dev Allows for address(0) to be set, which disables fees.
+ * @param recipient The address that receives fees.
*/
- function _inboundAmount(
- uint256 _messageAmount
- ) internal view virtual returns (uint256 _localAmount) {
- _localAmount = _messageAmount;
+ function setFeeRecipient(address recipient) public onlyOwner {
+ require(recipient != address(this), "Fee recipient cannot be self");
+ FEE_RECIPIENT_SLOT.getAddressSlot().value = recipient;
+ emit FeeRecipientSet(recipient);
}
/**
- * @dev Should transfer `_amountOrId` of tokens from `msg.sender` to this token router.
- * @dev Called by `transferRemote` before message dispatch.
- * @dev Optionally returns `metadata` associated with the transfer to be passed in message.
+ * @notice Returns the address of the fee recipient.
+ * @dev Returns address(0) if no fee recipient is set.
+ * @dev Can be overriden with address(0) to disable fees entirely.
+ * @return address of the fee recipient.
*/
- function _transferFromSender(
- uint256 _amountOrId
- ) internal virtual returns (bytes memory metadata);
+ function feeRecipient() public view virtual returns (address) {
+ return FEE_RECIPIENT_SLOT.getAddressSlot().value;
+ }
+ // To be overridden by derived contracts if they have additional fees
/**
- * @notice Returns the balance of `account` on this token router.
- * @param account The address to query the balance of.
- * @return The balance of `account`.
+ * @notice Returns the external fee amount for the given parameters.
+ * param _destination The identifier of the destination chain.
+ * param _recipient The address of the recipient on the destination chain.
+ * param _amount The amount or identifier of tokens to be sent to the remote recipient
+ * @return feeAmount The external fee amount.
+ * @dev This fee must be denominated in the `token()` defined by this router.
+ * @dev The default implementation returns 0, meaning no external fees are charged.
+ * This function is intended to be overridden by derived contracts that have additional fees.
+ * Known overrides:
+ * - TokenBridgeCctpBase: for CCTP-specific fees
+ * - EverclearTokenBridge: for Everclear-specific fees
*/
- function balanceOf(address account) external virtual returns (uint256);
+ function _externalFeeAmount(
+ uint32, // _destination,
+ bytes32, // _recipient,
+ uint256 // _amount
+ ) internal view virtual returns (uint256 feeAmount) {
+ return 0;
+ }
/**
- * @notice Returns the gas payment required to dispatch a message to the given domain's router.
- * @param _destination The domain of the router.
+ * @notice Returns the fee recipient amount for the given parameters.
+ * @param _destination The identifier of the destination chain.
* @param _recipient The address of the recipient on the destination chain.
- * @param _amount The amount of tokens to be sent to the remote recipient.
- * @dev This should be overridden for warp routes that require additional fees/approvals.
- * @return quotes Indicate how much of each token to approve and/or send.
+ * @param _amount The amount or identifier of tokens to be sent to the remote recipient
+ * @return _feeRecipient The address of the fee recipient.
+ * @return feeAmount The fee recipient amount.
+ * @dev This function is is not intended to be overridden as storage and logic is contained in TokenRouter.
*/
- function quoteTransferRemote(
+ function _feeRecipientAndAmount(
uint32 _destination,
bytes32 _recipient,
uint256 _amount
- ) external view virtual override returns (Quote[] memory quotes) {
- quotes = new Quote[](1);
- quotes[0] = Quote({
- token: address(0),
- amount: _quoteGasPayment(_destination, _recipient, _amount)
- });
+ ) internal view returns (address _feeRecipient, uint256 feeAmount) {
+ _feeRecipient = feeRecipient();
+ if (_feeRecipient == address(0)) {
+ return (_feeRecipient, 0);
+ }
+
+ Quote[] memory quotes = ITokenFee(_feeRecipient).quoteTransferRemote(
+ _destination,
+ _recipient,
+ _amount
+ );
+ if (quotes.length == 0) {
+ return (_feeRecipient, 0);
+ }
+
+ require(
+ quotes.length == 1 && quotes[0].token == token(),
+ "FungibleTokenRouter: fee must match token"
+ );
+ feeAmount = quotes[0].amount;
}
/**
- * DEPRECATED: Use `quoteTransferRemote` instead.
* @notice Returns the gas payment required to dispatch a message to the given domain's router.
- * @param _destinationDomain The domain of the router.
- * @dev Assumes bytes32(0) recipient and max amount of tokens for quoting.
+ * @param _destination The identifier of the destination chain.
+ * @param _recipient The address of the recipient on the destination chain.
+ * @param _amount The amount or identifier of tokens to be sent to the remote recipient
* @return payment How much native value to send in transferRemote call.
+ * @dev This function is intended to be overridden by derived contracts that trigger multiple messages.
+ * Known overrides:
+ * - OPL2ToL1TokenBridgeNative: Quote for two messages (prove and finalize).
*/
- function quoteGasPayment(
- uint32 _destinationDomain
- ) public view virtual override returns (uint256) {
- return
- _quoteGasPayment(_destinationDomain, bytes32(0), type(uint256).max);
- }
-
function _quoteGasPayment(
- uint32 _destinationDomain,
+ uint32 _destination,
bytes32 _recipient,
uint256 _amount
- ) internal view returns (uint256) {
+ ) internal view virtual returns (uint256) {
return
- _GasRouter_quoteDispatch(
- _destinationDomain,
+ _Router_quoteDispatch(
+ _destination,
TokenMessage.format(_recipient, _amount),
+ _GasRouter_hookMetadata(_destination),
address(hook)
);
}
+ // ===========================
+ // ========== Internal virtual functions for token handling ==========
+ // ===========================
+
+ /**
+ * @dev Should transfer `_amount` of tokens from `msg.sender` to this token router.
+ * Called by `transferRemote` before message dispatch.
+ * Known overrides:
+ * - HypERC20: Burns the tokens from the sender.
+ * - HypERC20Collateral: Pulls the tokens from the sender.
+ * - HypNative: Asserts msg.value >= _amount
+ * - TokenBridgeCctpBase: (like HypERC20Collateral) Pulls the tokens from the sender.
+ * - EverclearEthTokenBridge: Wraps the native token (ETH) to WETH
+ * - HypERC4626: Converts the amounts to shares and burns from the User (via HypERC20 implementation)
+ * - HypFiatToken: Pulls the tokens from the sender and burns them on the FiatToken contract.
+ * - HypXERC20: Burns the tokens from the sender.
+ * - HypXERC20Lockbox: Pulls the tokens from the sender, locks them in the XERC20Lockbox contract and burns the resulting xERC20 tokens.
+ */
+ function _transferFromSender(uint256 _amountOrId) internal virtual;
+
+ /**
+ * @dev Should transfer `_amountOrId` of tokens from this token router to `_recipient`.
+ * @dev Called by `handle` after message decoding.
+ * Known overrides:
+ * - HypERC20: Mints the tokens to the recipient.
+ * - HypERC20Collateral: Releases the tokens to the recipient.
+ * - HypNative: Releases native tokens to the recipient.
+ * - TokenBridgeCctpBase: Do nothing (CCTP transfers tokens to the recipient directly).
+ * - EverclearEthTokenBridge: Unwraps WETH to ETH and sends to the recipient.
+ * - HypERC4626: Converts the amount to shares and mints to the User (via HypERC20 implementation)
+ * - HypFiatToken: Mints the tokens to the recipient on the FiatToken contract.
+ * - HypXERC20: Mints the tokens to the recipient.
+ * - HypXERC20Lockbox: Withdraws the underlying tokens from the Lockbox and sends to the recipient.
+ * - OpL1NativeTokenBridge: Do nothing (the L2 bridge transfers the native tokens to the recipient directly).
+ */
+ function _transferTo(
+ address _recipient,
+ uint256 _amountOrId
+ ) internal virtual;
+
+ /**
+ * @dev Should transfer `_amount` of tokens from this token router to the fee recipient.
+ * @dev Called by `_calculateFeesAndCharge` when fee recipient is set and feeAmount > 0.
+ * @dev The default implementation delegates to `_transferTo`, which works for most token routers
+ * where tokens are held by the router (e.g., collateral routers, synthetic token routers).
+ * @dev Override this function for bridges where tokens are NOT held by the router but fees still
+ * need to be paid (e.g., CCTP, Everclear). In those cases, use direct token transfers from the
+ * router's balance collected via `_transferFromSender`.
+ * Known overrides:
+ * - TokenBridgeCctpBase: Directly transfers tokens from router balance.
+ * - EverclearTokenBridge: Directly transfers tokens from router balance.
+ */
+ function _transferFee(
+ address _recipient,
+ uint256 _amount
+ ) internal virtual {
+ _transferTo(_recipient, _amount);
+ }
+
+ /**
+ * @dev Scales local amount to message amount (up by scale factor).
+ * Known overrides:
+ * - HypERC4626: Scales by exchange rate
+ */
+ function _outboundAmount(
+ uint256 _localAmount
+ ) internal view virtual returns (uint256 _messageAmount) {
+ _messageAmount = _localAmount * scale;
+ }
+
/**
- * @dev Mints tokens to recipient when router receives transfer message.
- * @dev Emits `ReceivedTransferRemote` event on the destination chain.
+ * @dev Scales message amount to local amount (down by scale factor).
+ * Known overrides:
+ * - HypERC4626: Scales by exchange rate
+ */
+ function _inboundAmount(
+ uint256 _messageAmount
+ ) internal view virtual returns (uint256 _localAmount) {
+ _localAmount = _messageAmount / scale;
+ }
+
+ /**
+ * @notice Handles the incoming transfer message.
+ * It decodes the message, emits the ReceivedTransferRemote event, and transfers tokens to the recipient.
* @param _origin The identifier of the origin chain.
- * @param _message The encoded remote transfer message containing the recipient address and amount.
+ * @dev param _sender The address of the sender router on the origin chain.
+ * @param _message The message data containing recipient and amount.
+ * @dev Override this function if custom logic is required for sending out the tokens.
+ * Known overrides:
+ * - EverclearTokenBridge: Receives the tokens and sends them to the recipient.
+ * - EverclearEthBridge: Receives WETH, unwraps it and sends native ETH to the recipient.
+ * - HypERC4626: Updates the exchange rate from the metadata
*/
+ // solhint-disable-next-line hyperlane/no-virtual-override
function _handle(
uint32 _origin,
bytes32,
@@ -233,23 +424,11 @@ abstract contract TokenRouter is GasRouter, ITokenBridge {
) internal virtual override {
bytes32 recipient = _message.recipient();
uint256 amount = _message.amount();
- bytes calldata metadata = _message.metadata();
- _transferTo(
- recipient.bytes32ToAddress(),
- _inboundAmount(amount),
- metadata
- );
+
+ // effects
emit ReceivedTransferRemote(_origin, recipient, amount);
- }
- /**
- * @dev Should transfer `_amountOrId` of tokens from this token router to `_recipient`.
- * @dev Called by `handle` after message decoding.
- * @dev Optionally handles `metadata` associated with transfer passed in message.
- */
- function _transferTo(
- address _recipient,
- uint256 _amountOrId,
- bytes calldata metadata
- ) internal virtual;
+ // interactions
+ _transferTo(recipient.bytes32ToAddress(), _inboundAmount(amount));
+ }
}
diff --git a/solidity/foundry.toml b/solidity/foundry.toml
index 8834c229c7..29435ee1d3 100644
--- a/solidity/foundry.toml
+++ b/solidity/foundry.toml
@@ -8,7 +8,6 @@ cache_path = 'forge-cache'
allow_paths = ["../node_modules"]
solc_version = '0.8.22'
evm_version= 'paris'
-optimizer = true
optimizer_runs = 999_999
fs_permissions = [
{ access = "read", path = "./script/avs/"},
@@ -28,6 +27,8 @@ verbosity = 4
mainnet = "https://eth.merkle.io"
optimism = "https://mainnet.optimism.io "
polygon = "https://rpc-mainnet.matic.quiknode.pro"
+arbitrum = "https://arb1.arbitrum.io/rpc"
+base = "https://mainnet.base.org"
[fuzz]
runs = 50
diff --git a/solidity/package.json b/solidity/package.json
index d59f706d8a..8f90640901 100644
--- a/solidity/package.json
+++ b/solidity/package.json
@@ -33,6 +33,7 @@
"prettier": "^3.5.3",
"prettier-plugin-solidity": "^1.4.2",
"solhint": "^5.0.5",
+ "solhint-plugin-hyperlane": "workspace:^",
"solhint-plugin-prettier": "^0.1.0",
"solidity-bytes-utils": "^0.8.0",
"solidity-coverage": "^0.8.3",
@@ -79,8 +80,8 @@
"coverage": "yarn fixtures && ./coverage.sh",
"docs": "forge doc",
"fixtures": "mkdir -p ./fixtures/aggregation ./fixtures/multisig",
- "hardhat-esm": "NODE_OPTIONS='--experimental-loader ts-node/esm/transpile-only --no-warnings=ExperimentalWarning' hardhat --config hardhat.config.cts",
- "hardhat-zk": "NODE_OPTIONS='--experimental-loader ts-node/esm/transpile-only --no-warnings=ExperimentalWarning' hardhat --config zk-hardhat.config.cts",
+ "hardhat-esm": "NODE_OPTIONS='--experimental-loader ts-node/esm/transpile-only --no-warnings=ExperimentalWarning' npx hardhat --config hardhat.config.cts",
+ "hardhat-zk": "NODE_OPTIONS='--experimental-loader ts-node/esm/transpile-only --no-warnings=ExperimentalWarning' npx hardhat --config zk-hardhat.config.cts",
"prettier": "prettier --write ./contracts ./test",
"test": "yarn version:exhaustive && yarn hardhat-esm test && yarn test:forge",
"test:hardhat": "yarn hardhat-esm test",
diff --git a/solidity/script/EverclearTokenBridge.s.sol b/solidity/script/EverclearTokenBridge.s.sol
new file mode 100644
index 0000000000..610a15e662
--- /dev/null
+++ b/solidity/script/EverclearTokenBridge.s.sol
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+pragma solidity ^0.8.22;
+
+import {EverclearTokenBridge, IEverclearAdapter, OutputAssetInfo} from "contracts/token/bridge/EverclearTokenBridge.sol";
+import {TypeCasts} from "contracts/libs/TypeCasts.sol";
+import {IWETH} from "contracts/token/interfaces/IWETH.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+
+import "forge-std/Script.sol";
+
+contract EverclearTokenBridgeScript is Script {
+ using TypeCasts for address;
+
+ function run() public {
+ address deployer = _getDeployer();
+ vm.startBroadcast(deployer);
+
+ // Deploy the bridge. This is an ARB eth bridge.
+ EverclearTokenBridge bridge = new EverclearTokenBridge(
+ 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1, // WETH
+ 1,
+ address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox
+ IEverclearAdapter(0x15a7cA97D1ed168fB34a4055CEFa2E2f9Bdb6C75) // Everclear adapter
+ );
+
+ // Initialize the bridge
+ bridge.initialize(address(0), deployer);
+
+ // Set the output asset for the bridge.
+ // This is optimism weth
+ bridge.setOutputAsset(
+ OutputAssetInfo({
+ destination: 10,
+ outputAsset: (0x4200000000000000000000000000000000000006)
+ .addressToBytes32()
+ })
+ );
+
+ // Set the fee params for the bridge.
+ bridge.setFeeParams(
+ 10, // destination domain
+ 1000000000000,
+ 1751851366,
+ hex"4edddfdeabc459e3e9df4bc6807698e26443a663b3905c9b5d0f1054b4831b4616e89ff702f57e13d650331f11986ebe925ce497621b7f488c4672189b49b8e11c"
+ );
+
+ vm.stopBroadcast();
+ }
+
+ function depositEth() public {
+ EverclearTokenBridge bridge = _getBridge();
+
+ // Convert some eth to weth
+ (uint256 fee, , ) = bridge.feeParams(10); // destination domain 10 (Optimism)
+ uint256 amount = 0.0001 ether;
+ uint256 totalAmount = amount + fee + 1;
+ IWETH weth = IWETH(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1);
+ weth.approve(address(bridge), type(uint256).max);
+ weth.deposit{value: totalAmount}();
+ }
+
+ function sendIntent() public {
+ address deployer = _getDeployer();
+ vm.startBroadcast(deployer);
+
+ EverclearTokenBridge bridge = _getBridge();
+
+ depositEth();
+
+ // Send a test intent
+ bridge.transferRemote(10, deployer.addressToBytes32(), 0.0001 ether);
+
+ vm.stopBroadcast();
+ }
+
+ function _getDeployer() internal returns (address) {
+ return vm.rememberKey(vm.envUint("PRIVATE_KEY"));
+ }
+
+ function _getBridge() internal returns (EverclearTokenBridge) {
+ return EverclearTokenBridge(0x02457BB8994C192F14d46568461E11723d169dB8);
+ }
+}
diff --git a/solidity/script/xerc20/ApproveLockbox.s.sol b/solidity/script/xerc20/ApproveLockbox.s.sol
index 8421a3b402..f6fb2004b8 100644
--- a/solidity/script/xerc20/ApproveLockbox.s.sol
+++ b/solidity/script/xerc20/ApproveLockbox.s.sol
@@ -12,8 +12,7 @@ import {ProxyAdmin} from "contracts/upgrade/ProxyAdmin.sol";
import {HypXERC20Lockbox} from "contracts/token/extensions/HypXERC20Lockbox.sol";
import {IXERC20Lockbox} from "contracts/token/interfaces/IXERC20Lockbox.sol";
-import {IXERC20} from "contracts/token/interfaces/IXERC20.sol";
-import {IERC20} from "contracts/token/interfaces/IXERC20.sol";
+import {IXERC20, IERC20} from "contracts/token/interfaces/IXERC20.sol";
// source .env.
// forge script ApproveLockbox.s.sol --broadcast --rpc-url localhost:XXXX
diff --git a/solidity/script/xerc20/GrantLimits.s.sol b/solidity/script/xerc20/GrantLimits.s.sol
index e2c79bae6e..a7ad45485d 100644
--- a/solidity/script/xerc20/GrantLimits.s.sol
+++ b/solidity/script/xerc20/GrantLimits.s.sol
@@ -3,7 +3,7 @@ pragma solidity >=0.8.0;
import "forge-std/Script.sol";
-import {AnvilRPC} from "test/AnvilRPC.sol";
+import {AnvilRPC} from "../../test/AnvilRPC.sol";
import {IXERC20Lockbox} from "contracts/token/interfaces/IXERC20Lockbox.sol";
import {IXERC20} from "contracts/token/interfaces/IXERC20.sol";
diff --git a/solidity/script/xerc20/ezETH.s.sol b/solidity/script/xerc20/ezETH.s.sol
index e0bd531836..5cdbbdb6db 100644
--- a/solidity/script/xerc20/ezETH.s.sol
+++ b/solidity/script/xerc20/ezETH.s.sol
@@ -84,7 +84,8 @@ contract ezETH is Script {
vm.prank(0x7BE481D464CAD7ad99500CE8A637599eB8d0FCDB); // ezETH whale
IXERC20(blastXERC20).transfer(address(this), amount);
IXERC20(blastXERC20).approve(address(hypXERC20), amount);
- uint256 value = hypXERC20.quoteGasPayment(ethereumDomainId);
+ uint256 value = hypXERC20
+ .quoteTransferRemote(ethereumDomainId, recipient, amount)[0].amount;
hypXERC20.transferRemote{value: value}(
ethereumDomainId,
recipient,
diff --git a/solidity/storage.sh b/solidity/storage.sh
index 54b2851018..296e9e4e86 100755
--- a/solidity/storage.sh
+++ b/solidity/storage.sh
@@ -15,7 +15,22 @@ do
continue
fi
- contract=$(basename "$file" .sol)
- echo "Generating storage layout of $contract"
- forge inspect "$contract" storage > "$OUTPUT_PATH/$contract.md"
+ # Skip files that don't end in .sol
+ if [[ ! "$file" =~ \.sol$ ]]; then
+ continue
+ fi
+
+ # Extract all contract names from the file
+ contracts=$(grep -o '^contract [A-Za-z0-9_][A-Za-z0-9_]*' "$file" | sed 's/^contract //')
+
+ if [ -z "$contracts" ]; then
+ continue
+ fi
+
+ # Process each contract found in the file
+ for contract in $contracts; do
+ echo "Generating storage layout of $contract"
+ echo "slot offset label" > "$OUTPUT_PATH/$contract-layout.tsv"
+ forge inspect "$contract" storage --json | jq -r '.storage .[] | "\(.slot)\t\(.offset)\t\(.label)"' >> "$OUTPUT_PATH/$contract-layout.tsv"
+ done
done
diff --git a/solidity/test/AmountRouting.t.sol b/solidity/test/AmountRouting.t.sol
index 1cfbeaf47e..2303bcca8b 100644
--- a/solidity/test/AmountRouting.t.sol
+++ b/solidity/test/AmountRouting.t.sol
@@ -141,7 +141,7 @@ contract AmountRoutingTest is Test {
function test_hookType() public view {
assertEq(
hook.hookType(),
- uint8(IPostDispatchHook.Types.AMOUNT_ROUTING)
+ uint8(IPostDispatchHook.HookTypes.AMOUNT_ROUTING)
);
}
diff --git a/solidity/test/GasRouter.t.sol b/solidity/test/GasRouter.t.sol
index becd43cf38..525c372371 100644
--- a/solidity/test/GasRouter.t.sol
+++ b/solidity/test/GasRouter.t.sol
@@ -77,14 +77,20 @@ contract GasRouterTest is Test {
assertEq(originRouter.destinationGas(remoteDomain), gas);
}
- function testQuoteGasPayment(uint256 gas) public {
+ function testQuoteGasPayment(uint256 gas, bytes memory body) public {
vm.assume(gas > 0 && type(uint256).max / gas > gasPrice);
setDestinationGas(originRouter, remoteDomain, gas);
- assertEq(originRouter.quoteGasPayment(remoteDomain), gas * gasPrice);
+ assertEq(
+ originRouter.quoteDispatch(remoteDomain, body),
+ gas * gasPrice
+ );
setDestinationGas(remoteRouter, originDomain, gas);
- assertEq(remoteRouter.quoteGasPayment(originDomain), gas * gasPrice);
+ assertEq(
+ remoteRouter.quoteDispatch(originDomain, body),
+ gas * gasPrice
+ );
}
uint256 refund = 0;
diff --git a/solidity/test/LiquidityLayerRouter.t.sol b/solidity/test/LiquidityLayerRouter.t.sol
deleted file mode 100644
index 2756efa1b0..0000000000
--- a/solidity/test/LiquidityLayerRouter.t.sol
+++ /dev/null
@@ -1,290 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-pragma solidity ^0.8.13;
-
-import "forge-std/Test.sol";
-import {LiquidityLayerRouter} from "../contracts/middleware/liquidity-layer/LiquidityLayerRouter.sol";
-import {CircleBridgeAdapter} from "../contracts/middleware/liquidity-layer/adapters/CircleBridgeAdapter.sol";
-import {MockToken} from "../contracts/mock/MockToken.sol";
-import {TestTokenRecipient} from "../contracts/test/TestTokenRecipient.sol";
-import {TestRecipient} from "../contracts/test/TestRecipient.sol";
-import {MockCircleMessageTransmitter} from "../contracts/mock/MockCircleMessageTransmitter.sol";
-import {MockCircleTokenMessenger} from "../contracts/mock/MockCircleTokenMessenger.sol";
-import {MockHyperlaneEnvironment} from "../contracts/mock/MockHyperlaneEnvironment.sol";
-
-import {TypeCasts} from "../contracts/libs/TypeCasts.sol";
-
-contract LiquidityLayerRouterTest is Test {
- MockHyperlaneEnvironment testEnvironment;
-
- LiquidityLayerRouter originLiquidityLayerRouter;
- LiquidityLayerRouter destinationLiquidityLayerRouter;
-
- MockCircleMessageTransmitter messageTransmitter;
- MockCircleTokenMessenger tokenMessenger;
- CircleBridgeAdapter originBridgeAdapter;
- CircleBridgeAdapter destinationBridgeAdapter;
-
- string bridge = "FooBridge";
-
- uint32 originDomain = 123;
- uint32 destinationDomain = 321;
-
- TestTokenRecipient recipient;
- MockToken token;
- bytes messageBody = hex"beefdead";
- uint256 amount = 420000;
-
- event LiquidityLayerAdapterSet(string indexed bridge, address adapter);
-
- function setUp() public {
- token = new MockToken();
-
- tokenMessenger = new MockCircleTokenMessenger(token);
- messageTransmitter = new MockCircleMessageTransmitter(token);
-
- recipient = new TestTokenRecipient();
-
- testEnvironment = new MockHyperlaneEnvironment(
- originDomain,
- destinationDomain
- );
-
- address originMailbox = address(
- testEnvironment.mailboxes(originDomain)
- );
- address destinationMailbox = address(
- testEnvironment.mailboxes(destinationDomain)
- );
-
- originBridgeAdapter = new CircleBridgeAdapter(originMailbox);
- destinationBridgeAdapter = new CircleBridgeAdapter(destinationMailbox);
-
- originLiquidityLayerRouter = new LiquidityLayerRouter(originMailbox);
- destinationLiquidityLayerRouter = new LiquidityLayerRouter(
- destinationMailbox
- );
-
- address owner = address(this);
- originLiquidityLayerRouter.enrollRemoteRouter(
- destinationDomain,
- TypeCasts.addressToBytes32(address(destinationLiquidityLayerRouter))
- );
- destinationLiquidityLayerRouter.enrollRemoteRouter(
- originDomain,
- TypeCasts.addressToBytes32(address(originLiquidityLayerRouter))
- );
-
- originBridgeAdapter.initialize(
- owner,
- address(tokenMessenger),
- address(messageTransmitter),
- address(originLiquidityLayerRouter)
- );
-
- destinationBridgeAdapter.initialize(
- owner,
- address(tokenMessenger),
- address(messageTransmitter),
- address(destinationLiquidityLayerRouter)
- );
-
- originBridgeAdapter.addToken(address(token), "USDC");
- destinationBridgeAdapter.addToken(address(token), "USDC");
-
- originBridgeAdapter.enrollRemoteRouter(
- destinationDomain,
- TypeCasts.addressToBytes32(address(destinationBridgeAdapter))
- );
- destinationBridgeAdapter.enrollRemoteRouter(
- originDomain,
- TypeCasts.addressToBytes32(address(originBridgeAdapter))
- );
-
- originLiquidityLayerRouter.setLiquidityLayerAdapter(
- bridge,
- address(originBridgeAdapter)
- );
-
- destinationLiquidityLayerRouter.setLiquidityLayerAdapter(
- bridge,
- address(destinationBridgeAdapter)
- );
-
- token.mint(address(this), amount);
- }
-
- function testSetLiquidityLayerAdapter() public {
- // Expect the LiquidityLayerAdapterSet event.
- // Expect topic0 & data to match
- vm.expectEmit(true, false, false, true);
- emit LiquidityLayerAdapterSet(bridge, address(originBridgeAdapter));
-
- // Set the token bridge adapter
- originLiquidityLayerRouter.setLiquidityLayerAdapter(
- bridge,
- address(originBridgeAdapter)
- );
-
- // Expect the bridge adapter to have been set
- assertEq(
- originLiquidityLayerRouter.liquidityLayerAdapters(bridge),
- address(originBridgeAdapter)
- );
- }
-
- // ==== dispatchWithTokens ====
-
- function testDispatchWithTokensRevertsWithUnkownBridgeAdapter() public {
- vm.expectRevert("No adapter found for bridge");
- originLiquidityLayerRouter.dispatchWithTokens(
- destinationDomain,
- TypeCasts.addressToBytes32(address(recipient)),
- address(token),
- amount,
- "BazBridge", // some unknown bridge name,
- messageBody
- );
- }
-
- function testDispatchWithTokensRevertsWithFailedTransferIn() public {
- vm.expectRevert("ERC20: insufficient allowance");
- originLiquidityLayerRouter.dispatchWithTokens(
- destinationDomain,
- TypeCasts.addressToBytes32(address(recipient)),
- address(token),
- amount,
- bridge,
- messageBody
- );
- }
-
- function testDispatchWithTokenTransfersMovesTokens() public {
- token.approve(address(originLiquidityLayerRouter), amount);
- originLiquidityLayerRouter.dispatchWithTokens(
- destinationDomain,
- TypeCasts.addressToBytes32(address(recipient)),
- address(token),
- amount,
- bridge,
- messageBody
- );
- }
-
- function testDispatchWithTokensCallsAdapter() public {
- vm.expectCall(
- address(originBridgeAdapter),
- abi.encodeWithSelector(
- originBridgeAdapter.sendTokens.selector,
- destinationDomain,
- TypeCasts.addressToBytes32(address(recipient)),
- address(token),
- amount
- )
- );
- token.approve(address(originLiquidityLayerRouter), amount);
- originLiquidityLayerRouter.dispatchWithTokens(
- destinationDomain,
- TypeCasts.addressToBytes32(address(recipient)),
- address(token),
- amount,
- bridge,
- messageBody
- );
- }
-
- function testProcessingRevertsIfBridgeAdapterReverts() public {
- token.approve(address(originLiquidityLayerRouter), amount);
- originLiquidityLayerRouter.dispatchWithTokens(
- destinationDomain,
- TypeCasts.addressToBytes32(address(recipient)),
- address(token),
- amount,
- bridge,
- messageBody
- );
-
- vm.expectRevert("Circle message not processed yet");
- testEnvironment.processNextPendingMessage();
- }
-
- function testDispatchWithTokensTransfersOnDestination() public {
- token.approve(address(originLiquidityLayerRouter), amount);
- originLiquidityLayerRouter.dispatchWithTokens(
- destinationDomain,
- TypeCasts.addressToBytes32(address(recipient)),
- address(token),
- amount,
- bridge,
- messageBody
- );
-
- bytes32 nonceId = messageTransmitter.hashSourceAndNonce(
- destinationBridgeAdapter.hyperlaneDomainToCircleDomain(
- originDomain
- ),
- tokenMessenger.nextNonce() - 1
- );
-
- messageTransmitter.process(
- nonceId,
- address(destinationBridgeAdapter),
- amount
- );
- testEnvironment.processNextPendingMessage();
- assertEq(recipient.lastData(), messageBody);
- assertEq(token.balanceOf(address(recipient)), amount);
- }
-
- function testCannotSendToRecipientWithoutHandle() public {
- token.approve(address(originLiquidityLayerRouter), amount);
- originLiquidityLayerRouter.dispatchWithTokens(
- destinationDomain,
- TypeCasts.addressToBytes32(address(this)),
- address(token),
- amount,
- bridge,
- messageBody
- );
- bytes32 nonceId = messageTransmitter.hashSourceAndNonce(
- destinationBridgeAdapter.hyperlaneDomainToCircleDomain(
- originDomain
- ),
- tokenMessenger.nextNonce() - 1
- );
- messageTransmitter.process(
- nonceId,
- address(destinationBridgeAdapter),
- amount
- );
-
- vm.expectRevert();
- testEnvironment.processNextPendingMessage();
- }
-
- function testSendToRecipientWithoutHandleWhenSpecifyingNoMessage() public {
- TestRecipient noHandleRecipient = new TestRecipient();
- token.approve(address(originLiquidityLayerRouter), amount);
- originLiquidityLayerRouter.dispatchWithTokens(
- destinationDomain,
- TypeCasts.addressToBytes32(address(noHandleRecipient)),
- address(token),
- amount,
- bridge,
- ""
- );
- bytes32 nonceId = messageTransmitter.hashSourceAndNonce(
- destinationBridgeAdapter.hyperlaneDomainToCircleDomain(
- originDomain
- ),
- tokenMessenger.nextNonce() - 1
- );
- messageTransmitter.process(
- nonceId,
- address(destinationBridgeAdapter),
- amount
- );
-
- testEnvironment.processNextPendingMessage();
- assertEq(token.balanceOf(address(noHandleRecipient)), amount);
- }
-}
diff --git a/solidity/test/MerkleTreeHook.t.sol b/solidity/test/MerkleTreeHook.t.sol
index 5546523f29..f110ec8282 100644
--- a/solidity/test/MerkleTreeHook.t.sol
+++ b/solidity/test/MerkleTreeHook.t.sol
@@ -71,6 +71,9 @@ contract MerkleTreeHookTest is Test {
}
function testHookType() public {
- assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.MERKLE_TREE));
+ assertEq(
+ hook.hookType(),
+ uint8(IPostDispatchHook.HookTypes.MERKLE_TREE)
+ );
}
}
diff --git a/solidity/test/hooks/AggregationHook.t.sol b/solidity/test/hooks/AggregationHook.t.sol
index 3a0fdf263d..cca05c96fa 100644
--- a/solidity/test/hooks/AggregationHook.t.sol
+++ b/solidity/test/hooks/AggregationHook.t.sol
@@ -147,7 +147,10 @@ contract AggregationHookTest is Test {
function testHookType() public {
deployHooks(1, 0);
- assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.AGGREGATION));
+ assertEq(
+ hook.hookType(),
+ uint8(IPostDispatchHook.HookTypes.AGGREGATION)
+ );
}
receive() external payable {}
diff --git a/solidity/test/hooks/DefaultHook.t.sol b/solidity/test/hooks/DefaultHook.t.sol
index 27e514df8e..be68449795 100644
--- a/solidity/test/hooks/DefaultHook.t.sol
+++ b/solidity/test/hooks/DefaultHook.t.sol
@@ -33,7 +33,7 @@ contract DefaultHookTest is Test {
function test_hookType() public {
assertEq(
hook.hookType(),
- uint8(IPostDispatchHook.Types.MAILBOX_DEFAULT_HOOK)
+ uint8(IPostDispatchHook.HookTypes.MAILBOX_DEFAULT_HOOK)
);
}
diff --git a/solidity/test/hooks/DomainRoutingHook.t.sol b/solidity/test/hooks/DomainRoutingHook.t.sol
index 193f227413..d0ac06030a 100644
--- a/solidity/test/hooks/DomainRoutingHook.t.sol
+++ b/solidity/test/hooks/DomainRoutingHook.t.sol
@@ -107,7 +107,7 @@ contract DomainRoutingHookTest is Test {
}
function testHookType() public virtual {
- assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.ROUTING));
+ assertEq(hook.hookType(), uint8(IPostDispatchHook.HookTypes.ROUTING));
}
}
@@ -171,7 +171,7 @@ contract FallbackDomainRoutingHookTest is DomainRoutingHookTest {
function testHookType() public override {
assertEq(
hook.hookType(),
- uint8(IPostDispatchHook.Types.FALLBACK_ROUTING)
+ uint8(IPostDispatchHook.HookTypes.FALLBACK_ROUTING)
);
}
}
diff --git a/solidity/test/hooks/ProtocolFee.t.sol b/solidity/test/hooks/ProtocolFee.t.sol
index d77ba14a0c..e4766ac7ff 100644
--- a/solidity/test/hooks/ProtocolFee.t.sol
+++ b/solidity/test/hooks/ProtocolFee.t.sol
@@ -37,7 +37,10 @@ contract ProtocolFeeTest is Test {
}
function testHookType() public {
- assertEq(fees.hookType(), uint8(IPostDispatchHook.Types.PROTOCOL_FEE));
+ assertEq(
+ fees.hookType(),
+ uint8(IPostDispatchHook.HookTypes.PROTOCOL_FEE)
+ );
}
function testSetProtocolFee(uint256 fee) public {
@@ -165,7 +168,7 @@ contract ProtocolFeeTest is Test {
for (uint256 i = 0; i < dispatchCalls; i++) {
vm.prank(alice);
- fees.postDispatch{value: feeRequired}("", "");
+ fees.postDispatch{value: feeRequired}("", testMessage);
}
fees.collectProtocolFees();
@@ -173,6 +176,21 @@ contract ProtocolFeeTest is Test {
assertEq(bob.balance, balanceBefore + feeRequired * dispatchCalls);
}
+ function testFuzz_postDispatch_emitsProtocolFeePaid(
+ uint256 feeRequired,
+ uint256 feeSent
+ ) public {
+ feeRequired = bound(feeRequired, 1, fees.MAX_PROTOCOL_FEE());
+ feeSent = bound(feeSent, feeRequired, 10 * feeRequired);
+ vm.deal(alice, feeSent);
+
+ fees.setProtocolFee(feeRequired);
+
+ vm.expectEmit(true, true, true, true);
+ emit ProtocolFee.ProtocolFeePaid(alice, feeRequired);
+ fees.postDispatch{value: feeSent}("", testMessage);
+ }
+
// ============ Helper Functions ============
function _encodeTestMessage() internal view returns (bytes memory) {
diff --git a/solidity/test/hooks/layerzero/LayerZeroV1Hook.t.sol b/solidity/test/hooks/layerzero/LayerZeroV1Hook.t.sol
index a99ec9a5ec..bfbb652750 100644
--- a/solidity/test/hooks/layerzero/LayerZeroV1Hook.t.sol
+++ b/solidity/test/hooks/layerzero/LayerZeroV1Hook.t.sol
@@ -176,6 +176,9 @@ contract LayerZeroV1HookTest is Test {
// TODO test failed/retry
function testLzV1Hook_HookType() public {
- assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.LAYER_ZERO_V1));
+ assertEq(
+ hook.hookType(),
+ uint8(IPostDispatchHook.HookTypes.LAYER_ZERO_V1)
+ );
}
}
diff --git a/solidity/test/hooks/layerzero/LayerZeroV2Hook.t.sol b/solidity/test/hooks/layerzero/LayerZeroV2Hook.t.sol
index 8b5d6004c1..aeee739ff8 100644
--- a/solidity/test/hooks/layerzero/LayerZeroV2Hook.t.sol
+++ b/solidity/test/hooks/layerzero/LayerZeroV2Hook.t.sol
@@ -194,6 +194,9 @@ contract LayerZeroV2HookTest is Test {
// TODO test failed/retry
function testLzV2Hook_HookType() public {
- assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.ID_AUTH_ISM));
+ assertEq(
+ hook.hookType(),
+ uint8(IPostDispatchHook.HookTypes.ID_AUTH_ISM)
+ );
}
}
diff --git a/solidity/test/igps/InterchainGasPaymaster.t.sol b/solidity/test/igps/InterchainGasPaymaster.t.sol
index de5337ef68..d12f3a1982 100644
--- a/solidity/test/igps/InterchainGasPaymaster.t.sol
+++ b/solidity/test/igps/InterchainGasPaymaster.t.sol
@@ -533,7 +533,7 @@ contract InterchainGasPaymasterTest is Test {
function testHookType() public {
assertEq(
igp.hookType(),
- uint8(IPostDispatchHook.Types.INTERCHAIN_GAS_PAYMASTER)
+ uint8(IPostDispatchHook.HookTypes.INTERCHAIN_GAS_PAYMASTER)
);
}
diff --git a/solidity/test/isms/ERC5164ISM.t.sol b/solidity/test/isms/ERC5164ISM.t.sol
index f3900e621d..f304714f7f 100644
--- a/solidity/test/isms/ERC5164ISM.t.sol
+++ b/solidity/test/isms/ERC5164ISM.t.sol
@@ -99,7 +99,10 @@ contract ERC5164IsmTest is ExternalBridgeTest {
}
function testTypes() public view {
- assertEq(hook.hookType(), uint8(IPostDispatchHook.Types.ID_AUTH_ISM));
+ assertEq(
+ hook.hookType(),
+ uint8(IPostDispatchHook.HookTypes.ID_AUTH_ISM)
+ );
assertEq(ism.moduleType(), uint8(IInterchainSecurityModule.Types.NULL));
}
diff --git a/solidity/test/middleware/liquidity-layer/PortalAdapter.t.sol b/solidity/test/middleware/liquidity-layer/PortalAdapter.t.sol
deleted file mode 100644
index de13a86e00..0000000000
--- a/solidity/test/middleware/liquidity-layer/PortalAdapter.t.sol
+++ /dev/null
@@ -1,135 +0,0 @@
-// SPDX-License-Identifier: UNLICENSED
-pragma solidity ^0.8.13;
-
-import "forge-std/Test.sol";
-import {TypeCasts} from "../../../contracts/libs/TypeCasts.sol";
-import {IPortalTokenBridge} from "../../../contracts/middleware/liquidity-layer/interfaces/portal/IPortalTokenBridge.sol";
-import {PortalAdapter} from "../../../contracts/middleware/liquidity-layer/adapters/PortalAdapter.sol";
-import {TestTokenRecipient} from "../../../contracts/test/TestTokenRecipient.sol";
-import {MockToken} from "../../../contracts/mock/MockToken.sol";
-import {MockPortalBridge} from "../../../contracts/mock/MockPortalBridge.sol";
-import {MockMailbox} from "../../../contracts/mock/MockMailbox.sol";
-
-import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
-
-contract PortalAdapterTest is Test {
- PortalAdapter originAdapter;
- PortalAdapter destinationAdapter;
-
- MockPortalBridge portalBridge;
-
- uint32 originDomain = 123;
- uint32 destinationDomain = 321;
-
- TestTokenRecipient recipient;
- MockToken token;
-
- function setUp() public {
- token = new MockToken();
- recipient = new TestTokenRecipient();
-
- MockMailbox originMailbox = new MockMailbox(originDomain);
- MockMailbox destinationMailbox = new MockMailbox(destinationDomain);
-
- originAdapter = new PortalAdapter(address(originMailbox));
- destinationAdapter = new PortalAdapter(address(destinationMailbox));
-
- portalBridge = new MockPortalBridge(token);
-
- originAdapter.initialize(
- address(this),
- address(portalBridge),
- address(this)
- );
- destinationAdapter.initialize(
- address(this),
- address(portalBridge),
- address(this)
- );
-
- originAdapter.enrollRemoteRouter(
- destinationDomain,
- TypeCasts.addressToBytes32(address(destinationAdapter))
- );
- destinationAdapter.enrollRemoteRouter(
- destinationDomain,
- TypeCasts.addressToBytes32(address(originAdapter))
- );
- }
-
- function testAdapter(uint256 amount) public {
- // Transfers of 0 are invalid
- vm.assume(amount > 0);
- // Calls MockPortalBridge with the right parameters
- vm.expectCall(
- address(portalBridge),
- abi.encodeCall(
- portalBridge.transferTokensWithPayload,
- (
- address(token),
- amount,
- 0,
- TypeCasts.addressToBytes32(address(destinationAdapter)),
- 0,
- abi.encode(originDomain, originAdapter.nonce() + 1)
- )
- )
- );
- token.mint(address(originAdapter), amount);
- originAdapter.sendTokens(
- destinationDomain,
- TypeCasts.addressToBytes32(address(recipient)),
- address(token),
- amount
- );
- }
-
- function testReceivingRevertsWithoutTransferCompletion(
- uint256 amount
- ) public {
- // Transfers of 0 are invalid
- vm.assume(amount > 0);
- token.mint(address(originAdapter), amount);
- bytes memory adapterData = originAdapter.sendTokens(
- destinationDomain,
- TypeCasts.addressToBytes32(address(recipient)),
- address(token),
- amount
- );
-
- vm.expectRevert("Portal Transfer has not yet been completed");
-
- destinationAdapter.receiveTokens(
- originDomain,
- address(recipient),
- amount,
- adapterData
- );
- }
-
- function testReceivingWorks(uint256 amount) public {
- // Transfers of 0 are invalid
- vm.assume(amount > 0);
- token.mint(address(originAdapter), amount);
- bytes memory adapterData = originAdapter.sendTokens(
- destinationDomain,
- TypeCasts.addressToBytes32(address(recipient)),
- address(token),
- amount
- );
- destinationAdapter.completeTransfer(
- portalBridge.mockPortalVaa(
- originDomain,
- originAdapter.nonce(),
- amount
- )
- );
-
- destinationAdapter.receiveTokens(
- originDomain,
- address(recipient),
- amount,
- adapterData
- );
- }
-}
diff --git a/solidity/test/token/EverclearTokenBridge.t.sol b/solidity/test/token/EverclearTokenBridge.t.sol
new file mode 100644
index 0000000000..747a2fa75b
--- /dev/null
+++ b/solidity/test/token/EverclearTokenBridge.t.sol
@@ -0,0 +1,1235 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+pragma solidity ^0.8.22;
+
+/*@@@@@@@ @@@@@@@@@
+ @@@@@@@@@ @@@@@@@@@
+ @@@@@@@@@ @@@@@@@@@
+ @@@@@@@@@ @@@@@@@@@
+ @@@@@@@@@@@@@@@@@@@@@@@@@
+ @@@@@ HYPERLANE @@@@@@@
+ @@@@@@@@@@@@@@@@@@@@@@@@@
+ @@@@@@@@@ @@@@@@@@@
+ @@@@@@@@@ @@@@@@@@@
+ @@@@@@@@@ @@@@@@@@@
+@@@@@@@@@ @@@@@@@@*/
+
+import "forge-std/Test.sol";
+import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+
+import {MockMailbox} from "../../contracts/mock/MockMailbox.sol";
+import {ERC20Test} from "../../contracts/test/ERC20Test.sol";
+import {TestPostDispatchHook} from "../../contracts/test/TestPostDispatchHook.sol";
+import {TypeCasts} from "../../contracts/libs/TypeCasts.sol";
+import {MockHyperlaneEnvironment} from "../../contracts/mock/MockHyperlaneEnvironment.sol";
+import {Message} from "../../contracts/libs/Message.sol";
+import {EverclearBridge, EverclearEthBridge, EverclearTokenBridge, OutputAssetInfo} from "../../contracts/token/bridge/EverclearTokenBridge.sol";
+import {IEverclearAdapter, IEverclear, IEverclearSpoke} from "../../contracts/interfaces/IEverclearAdapter.sol";
+import {Quote} from "../../contracts/interfaces/ITokenBridge.sol";
+import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol";
+import {IWETH} from "contracts/token/interfaces/IWETH.sol";
+import {LinearFee} from "../../contracts/token/fees/LinearFee.sol";
+
+import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
+/**
+ * @notice Mock implementation of IEverclearAdapter for testing
+ */
+contract MockEverclearAdapter is IEverclearAdapter {
+ uint256 public constant INTENT_FEE = 1000; // 0.001 ETH
+ bool public shouldRevert = false;
+ bytes32 public lastIntentId;
+ IEverclear.Intent public lastIntent;
+
+ // Track calls for verification
+ uint256 public newIntentCallCount;
+ uint32[] public lastDestinations;
+ bytes32 public lastReceiver;
+ address public lastInputAsset;
+ bytes32 public lastOutputAsset;
+ uint256 public lastAmount;
+ uint24 public lastMaxFee;
+ uint48 public lastTtl;
+ bytes public lastData;
+ FeeParams public lastFeeParams;
+
+ function setRevert(bool _shouldRevert) external {
+ shouldRevert = _shouldRevert;
+ }
+
+ function newIntent(
+ uint32[] memory _destinations,
+ bytes32 _receiver,
+ address _inputAsset,
+ bytes32 _outputAsset,
+ uint256 _amount,
+ uint24 _maxFee,
+ uint48 _ttl,
+ bytes calldata _data,
+ FeeParams calldata _feeParams
+ ) external payable override returns (bytes32, IEverclear.Intent memory) {
+ if (shouldRevert) {
+ revert("MockEverclearAdapter: reverted");
+ }
+
+ // Store call data for verification
+ newIntentCallCount++;
+ lastDestinations = _destinations;
+ lastReceiver = _receiver;
+ lastInputAsset = _inputAsset;
+ lastOutputAsset = _outputAsset;
+ lastAmount = _amount;
+ lastMaxFee = _maxFee;
+ lastTtl = _ttl;
+ lastData = _data;
+ lastFeeParams = _feeParams;
+
+ // Generate mock intent ID
+ lastIntentId = keccak256(
+ abi.encodePacked(block.timestamp, _receiver, _amount)
+ );
+
+ // Create mock intent
+ lastIntent = IEverclear.Intent({
+ initiator: bytes32(uint256(uint160(msg.sender))),
+ receiver: _receiver,
+ inputAsset: bytes32(uint256(uint160(_inputAsset))),
+ outputAsset: _outputAsset,
+ maxFee: _maxFee,
+ origin: uint32(block.chainid),
+ destinations: _destinations,
+ nonce: uint64(newIntentCallCount),
+ timestamp: uint48(block.timestamp),
+ ttl: _ttl,
+ amount: _amount,
+ data: _data
+ });
+
+ return (lastIntentId, lastIntent);
+ }
+
+ function feeSigner() external view returns (address) {
+ return address(0x222);
+ }
+
+ function owner() external view returns (address) {
+ return address(0x1);
+ }
+
+ function updateFeeSigner(address _feeSigner) external {
+ // Do nothing
+ }
+
+ function spoke() external view returns (IEverclearSpoke) {
+ return IEverclearSpoke(address(0x333));
+ }
+}
+
+contract EverclearTokenBridgeTest is Test {
+ using TypeCasts for *;
+
+ // Constants
+ uint32 internal constant ORIGIN = 11;
+ uint32 internal constant DESTINATION = 12;
+ uint8 internal constant DECIMALS = 18;
+ uint256 internal constant SCALE = 1e18;
+ uint256 internal constant TOTAL_SUPPLY = 1_000_000e18;
+ uint256 internal constant TRANSFER_AMT = 100e18;
+ uint256 internal constant FEE_AMOUNT = 5e18; // 5 tokens fee
+ uint256 internal constant GAS_PAYMENT = 0.001 ether;
+ string internal constant NAME = "TestToken";
+ string internal constant SYMBOL = "TT";
+ MockHyperlaneEnvironment internal environment;
+
+ // Test addresses
+ address internal ALICE = makeAddr("alice");
+ address internal constant BOB = address(0x2);
+ address internal constant OWNER = address(0x3);
+ address internal constant PROXY_ADMIN = address(0x37);
+
+ // Mock contracts
+ ERC20Test internal token;
+ MockMailbox internal mailbox;
+ MockEverclearAdapter internal everclearAdapter;
+ TestPostDispatchHook internal hook;
+
+ // Main contract
+ EverclearTokenBridge internal bridge;
+
+ // Test data
+ bytes32 internal constant OUTPUT_ASSET = bytes32(uint256(0x456));
+ bytes32 internal constant RECIPIENT = bytes32(uint256(uint160(BOB)));
+ uint256 internal feeDeadline;
+ bytes internal feeSignature = hex"1234567890abcdef";
+
+ // Events to test
+ event FeeParamsUpdated(uint32 destination, uint256 fee, uint256 deadline);
+ event OutputAssetSet(uint32 destination, bytes32 outputAsset);
+
+ function setUp() public {
+ // Setup basic infrastructure
+ environment = new MockHyperlaneEnvironment(ORIGIN, DESTINATION);
+ mailbox = environment.mailboxes(ORIGIN);
+
+ token = new ERC20Test(NAME, SYMBOL, TOTAL_SUPPLY, DECIMALS);
+ everclearAdapter = new MockEverclearAdapter();
+ hook = new TestPostDispatchHook();
+
+ // Set fee deadline to future
+ feeDeadline = block.timestamp + 3600; // 1 hour from now
+
+ // Deploy bridge implementation
+ EverclearTokenBridge implementation = new EverclearTokenBridge(
+ address(token),
+ 1,
+ address(mailbox),
+ everclearAdapter
+ );
+
+ // Deploy proxy
+ TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(
+ address(implementation),
+ PROXY_ADMIN,
+ abi.encodeCall(EverclearBridge.initialize, (address(0), OWNER))
+ );
+
+ bridge = EverclearTokenBridge(address(proxy));
+ // Setup initial state
+ vm.startPrank(OWNER);
+ bridge.setFeeParams(DESTINATION, FEE_AMOUNT, feeDeadline, feeSignature);
+ bridge.setOutputAsset(
+ OutputAssetInfo({
+ destination: DESTINATION,
+ outputAsset: OUTPUT_ASSET
+ })
+ );
+ bridge.enrollRemoteRouter(ORIGIN, address(bridge).addressToBytes32());
+ bridge.enrollRemoteRouter(DESTINATION, RECIPIENT);
+
+ vm.stopPrank();
+
+ // Mint tokens to users
+ token.mintTo(ALICE, 1000e18);
+
+ // Setup allowances
+ vm.prank(ALICE);
+ token.approve(address(bridge), type(uint256).max);
+ }
+
+ // ============ Constructor Tests ============
+
+ function testConstructor() public {
+ EverclearTokenBridge newBridge = new EverclearTokenBridge(
+ address(token),
+ 1,
+ address(mailbox),
+ everclearAdapter
+ );
+
+ assertEq(address(newBridge.token()), address(token));
+ assertEq(
+ address(newBridge.everclearAdapter()),
+ address(everclearAdapter)
+ );
+ }
+
+ // ============ Initialize Tests ============
+
+ function testInitialize() public {
+ assertEq(bridge.owner(), OWNER);
+ assertEq(
+ token.allowance(address(bridge), address(everclearAdapter)),
+ type(uint256).max
+ );
+ }
+
+ function testInitializeCannotBeCalledTwice() public {
+ vm.expectRevert("Initializable: contract is already initialized");
+ bridge.initialize(address(0), OWNER);
+ }
+
+ // ============ setFeeParams Tests ============
+
+ function testSetFeeParams() public {
+ uint256 newFee = 10e18;
+ uint256 newDeadline = block.timestamp + 7200;
+ bytes memory newSig = hex"abcdef";
+
+ vm.expectEmit(true, true, false, true);
+ emit FeeParamsUpdated(DESTINATION, newFee, newDeadline);
+
+ vm.prank(OWNER);
+ bridge.setFeeParams(DESTINATION, newFee, newDeadline, newSig);
+
+ (uint256 fee, uint256 deadline, bytes memory sig) = bridge.feeParams(
+ DESTINATION
+ );
+ assertEq(fee, newFee);
+ assertEq(deadline, newDeadline);
+ assertEq(sig, newSig);
+ }
+
+ function testSetFeeParamsOnlyOwner() public {
+ vm.expectRevert("Ownable: caller is not the owner");
+ vm.prank(ALICE);
+ bridge.setFeeParams(DESTINATION, FEE_AMOUNT, feeDeadline, feeSignature);
+ }
+
+ // ============ setOutputAsset Tests ============
+
+ function testSetOutputAsset() public {
+ bytes32 newOutputAsset = bytes32(uint256(0x789));
+
+ vm.expectEmit(true, true, false, true);
+ emit OutputAssetSet(DESTINATION, newOutputAsset);
+
+ vm.prank(OWNER);
+ bridge.setOutputAsset(
+ OutputAssetInfo({
+ destination: DESTINATION,
+ outputAsset: newOutputAsset
+ })
+ );
+
+ assertEq(bridge.outputAssets(DESTINATION), newOutputAsset);
+ }
+
+ function testSetOutputAssetOnlyOwner() public {
+ vm.expectRevert("Ownable: caller is not the owner");
+ vm.prank(ALICE);
+ bridge.setOutputAsset(
+ OutputAssetInfo({
+ destination: DESTINATION,
+ outputAsset: OUTPUT_ASSET
+ })
+ );
+ }
+
+ // ============ setOutputAssetsBatch Tests ============
+
+ function testSetOutputAssetsBatch() public {
+ OutputAssetInfo[] memory outputAssetInfos = new OutputAssetInfo[](2);
+ outputAssetInfos[0] = OutputAssetInfo({
+ destination: 13,
+ outputAsset: bytes32(uint256(0x111))
+ });
+ outputAssetInfos[1] = OutputAssetInfo({
+ destination: 14,
+ outputAsset: bytes32(uint256(0x222))
+ });
+
+ vm.expectEmit(true, true, false, true);
+ emit OutputAssetSet(13, outputAssetInfos[0].outputAsset);
+ vm.expectEmit(true, true, false, true);
+ emit OutputAssetSet(14, outputAssetInfos[1].outputAsset);
+
+ vm.prank(OWNER);
+ bridge.setOutputAssetsBatch(outputAssetInfos);
+
+ assertEq(bridge.outputAssets(13), outputAssetInfos[0].outputAsset);
+ assertEq(bridge.outputAssets(14), outputAssetInfos[1].outputAsset);
+ }
+
+ function testSetOutputAssetsBatchOnlyOwner() public {
+ OutputAssetInfo[] memory outputAssetInfos = new OutputAssetInfo[](1);
+ outputAssetInfos[0] = OutputAssetInfo({
+ destination: 13,
+ outputAsset: bytes32(uint256(0x111))
+ });
+
+ vm.expectRevert("Ownable: caller is not the owner");
+ vm.prank(ALICE);
+ bridge.setOutputAssetsBatch(outputAssetInfos);
+ }
+
+ // ============ quoteTransferRemote Tests ============
+
+ function testQuoteTransferRemote() public {
+ Quote[] memory quotes = bridge.quoteTransferRemote(
+ DESTINATION,
+ RECIPIENT,
+ TRANSFER_AMT
+ );
+
+ assertEq(quotes.length, 3);
+ assertEq(quotes[0].token, address(0));
+ assertEq(quotes[0].amount, 0); // Gas payment is 0 for test dispatch hooks
+ assertEq(quotes[1].token, address(token));
+ assertEq(quotes[1].amount, TRANSFER_AMT);
+ assertEq(quotes[2].token, address(token));
+ assertEq(quotes[2].amount, FEE_AMOUNT);
+ }
+
+ // ============ transferRemote Tests ============
+
+ function testTransferRemote() public {
+ uint256 initialBalance = token.balanceOf(ALICE);
+ uint256 initialBridgeBalance = token.balanceOf(address(bridge));
+
+ vm.prank(ALICE);
+ bytes32 result = bridge.transferRemote(
+ DESTINATION,
+ RECIPIENT,
+ TRANSFER_AMT
+ );
+
+ // Check balances
+ assertEq(
+ token.balanceOf(ALICE),
+ initialBalance - TRANSFER_AMT - FEE_AMOUNT
+ );
+ assertEq(
+ token.balanceOf(address(bridge)),
+ initialBridgeBalance + TRANSFER_AMT + FEE_AMOUNT
+ );
+
+ // Check Everclear adapter was called correctly
+ assertEq(everclearAdapter.newIntentCallCount(), 1);
+ assertEq(everclearAdapter.lastDestinations(0), DESTINATION);
+ assertEq(everclearAdapter.lastReceiver(), RECIPIENT);
+ assertEq(everclearAdapter.lastInputAsset(), address(token));
+ assertEq(everclearAdapter.lastOutputAsset(), OUTPUT_ASSET);
+ assertEq(everclearAdapter.lastAmount(), TRANSFER_AMT);
+ assertEq(everclearAdapter.lastMaxFee(), 0);
+ assertEq(everclearAdapter.lastTtl(), 0);
+ assertEq(everclearAdapter.lastData(), "");
+
+ // Check fee params
+ (uint256 fee, uint256 deadline, bytes memory sig) = everclearAdapter
+ .lastFeeParams();
+ assertEq(fee, FEE_AMOUNT);
+ assertEq(deadline, feeDeadline);
+ assertEq(sig, feeSignature);
+ }
+
+ function testTransferRemoteWithFeeRecipient() public {
+ // Create a LinearFee contract as the fee recipient
+ // LinearFee(token, maxFee, halfAmount, owner)
+ address feeCollector = makeAddr("feeCollector");
+ LinearFee feeContract = new LinearFee(
+ address(token),
+ 1e6, // maxFee
+ TRANSFER_AMT / 2, // halfAmount
+ feeCollector
+ );
+
+ // Set fee recipient to the LinearFee contract
+ vm.prank(OWNER);
+ bridge.setFeeRecipient(address(feeContract));
+
+ uint256 initialAliceBalance = token.balanceOf(ALICE);
+ uint256 initialFeeContractBalance = token.balanceOf(
+ address(feeContract)
+ );
+ uint256 initialBridgeBalance = token.balanceOf(address(bridge));
+
+ // Get the expected fee from the feeContract
+ uint256 expectedFeeRecipientFee = feeContract
+ .quoteTransferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT)[0].amount;
+
+ vm.prank(ALICE);
+ bridge.transferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT);
+
+ // Check Alice paid the transfer amount + external fee + fee recipient fee
+ assertEq(
+ token.balanceOf(ALICE),
+ initialAliceBalance -
+ TRANSFER_AMT -
+ FEE_AMOUNT -
+ expectedFeeRecipientFee
+ );
+
+ // Check fee contract received the fee recipient fee (this tests the fix!)
+ assertEq(
+ token.balanceOf(address(feeContract)),
+ initialFeeContractBalance + expectedFeeRecipientFee
+ );
+
+ // Check bridge only holds the transfer amount + external fee, not the fee recipient fee
+ assertEq(
+ token.balanceOf(address(bridge)),
+ initialBridgeBalance + TRANSFER_AMT + FEE_AMOUNT
+ );
+ }
+
+ function testTransferRemoteOutputAssetNotSet() public {
+ vm.expectRevert("ETB: Output asset not set");
+ vm.prank(ALICE);
+ bridge.transferRemote(999, RECIPIENT, TRANSFER_AMT); // Domain 999 has no output asset
+ }
+
+ function testTransferRemoteInsufficientBalance() public {
+ // Try to transfer more than balance + fee
+ uint256 aliceBalance = token.balanceOf(ALICE);
+
+ vm.expectRevert("ERC20: transfer amount exceeds balance");
+ vm.prank(ALICE);
+ bridge.transferRemote(DESTINATION, RECIPIENT, aliceBalance);
+ }
+
+ function testTransferRemoteInsufficientAllowance() public {
+ vm.prank(ALICE);
+ token.approve(address(bridge), TRANSFER_AMT); // Less than transfer + fee
+
+ vm.expectRevert("ERC20: insufficient allowance");
+ vm.prank(ALICE);
+ bridge.transferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT);
+ }
+
+ function testTransferRemoteEverclearAdapterReverts() public {
+ everclearAdapter.setRevert(true);
+
+ vm.expectRevert("MockEverclearAdapter: reverted");
+ vm.prank(ALICE);
+ bridge.transferRemote(DESTINATION, RECIPIENT, TRANSFER_AMT);
+ }
+
+ // ============ Edge Cases Tests ============
+
+ function testTransferRemoteZeroAmount() public {
+ vm.prank(ALICE);
+ bridge.transferRemote(DESTINATION, RECIPIENT, 0);
+
+ // Should still charge fee
+ assertEq(everclearAdapter.lastAmount(), 0);
+ // Fee should still be deducted
+ assertEq(token.balanceOf(ALICE), 1000e18 - FEE_AMOUNT);
+ }
+
+ function testTransferRemoteMaxAmount() public {
+ uint256 maxAmount = token.balanceOf(ALICE) - FEE_AMOUNT;
+
+ vm.prank(ALICE);
+ bridge.transferRemote(DESTINATION, RECIPIENT, maxAmount);
+
+ assertEq(everclearAdapter.lastAmount(), maxAmount);
+ assertEq(token.balanceOf(ALICE), 0);
+ }
+
+ // ============ Fuzz Tests ============
+
+ function testFuzzTransferRemote(uint256 amount) public {
+ // Bound the amount to reasonable values
+ amount = bound(amount, 0, 500e18); // Max 500 tokens
+
+ vm.prank(ALICE);
+ bridge.transferRemote(DESTINATION, RECIPIENT, amount);
+
+ assertEq(everclearAdapter.lastAmount(), amount);
+ assertEq(token.balanceOf(ALICE), 1000e18 - amount - FEE_AMOUNT);
+ }
+
+ function testFuzzSetFeeParams(uint256 fee, uint256 deadline) public {
+ // Bound to reasonable values
+ fee = bound(fee, 0, 100e18);
+ deadline = bound(
+ deadline,
+ block.timestamp + 1,
+ block.timestamp + 365 days
+ );
+
+ vm.prank(OWNER);
+ bridge.setFeeParams(DESTINATION, fee, deadline, feeSignature);
+
+ (uint256 storedFee, uint256 storedDeadline, ) = bridge.feeParams(
+ DESTINATION
+ );
+ assertEq(storedFee, fee);
+ assertEq(storedDeadline, deadline);
+ }
+
+ // ============ Integration Tests ============
+
+ function testFullTransferFlow() public {
+ // Setup: Alice wants to transfer 100 tokens to Bob on destination chain
+ uint256 transferAmount = 100e18;
+ uint256 initialAliceBalance = token.balanceOf(ALICE);
+
+ // 1. Get quote
+ Quote[] memory quotes = bridge.quoteTransferRemote(
+ DESTINATION,
+ RECIPIENT,
+ transferAmount
+ );
+ uint256 tokenCost = quotes[1].amount;
+ uint256 fee = quotes[2].amount;
+
+ // 2. Execute transfer
+ vm.prank(ALICE);
+ bytes32 transferId = bridge.transferRemote(
+ DESTINATION,
+ RECIPIENT,
+ transferAmount
+ );
+
+ // 3. Verify state changes
+ assertEq(token.balanceOf(ALICE), initialAliceBalance - tokenCost - fee);
+
+ // 4. Verify Everclear intent was created correctly
+ assertEq(everclearAdapter.newIntentCallCount(), 1);
+ assertEq(everclearAdapter.lastAmount(), transferAmount);
+ assertEq(everclearAdapter.lastReceiver(), RECIPIENT);
+ assertEq(everclearAdapter.lastOutputAsset(), OUTPUT_ASSET);
+ }
+
+ function testMultipleTransfers() public {
+ uint256 transferAmount = 50e18;
+
+ // Execute multiple transfers
+ vm.startPrank(ALICE);
+ bridge.transferRemote(DESTINATION, RECIPIENT, transferAmount);
+ bridge.transferRemote(DESTINATION, RECIPIENT, transferAmount);
+ vm.stopPrank();
+
+ // Verify both transfers were processed
+ assertEq(everclearAdapter.newIntentCallCount(), 2);
+ assertEq(
+ token.balanceOf(ALICE),
+ 1000e18 - 2 * (transferAmount + FEE_AMOUNT)
+ );
+ }
+
+ // ============ IntentSettled Tests ============
+
+ function testIntentSettledInitiallyFalse() public {
+ // Create a mock intent
+ IEverclear.Intent memory intent = IEverclear.Intent({
+ initiator: bytes32(uint256(uint160(ALICE))),
+ receiver: RECIPIENT,
+ inputAsset: bytes32(uint256(uint160(address(token)))),
+ outputAsset: bytes32(uint256(uint160(address(token)))),
+ maxFee: 0,
+ origin: ORIGIN,
+ destinations: new uint32[](1),
+ nonce: 1,
+ timestamp: uint48(block.timestamp),
+ ttl: 0,
+ amount: 100e18,
+ data: abi.encode(RECIPIENT, 100e18)
+ });
+ intent.destinations[0] = DESTINATION;
+
+ bytes32 intentId = keccak256(abi.encode(intent));
+
+ // Verify intent is not initially settled
+ assertFalse(bridge.intentSettled(intentId));
+ }
+}
+
+contract MockEverclearTokenBridge is EverclearTokenBridge {
+ constructor(
+ address _weth,
+ uint256 _scale,
+ address _mailbox,
+ IEverclearAdapter _everclearAdapter
+ ) EverclearTokenBridge(_weth, _scale, _mailbox, _everclearAdapter) {}
+
+ bytes public lastIntent;
+ function _createIntent(
+ uint32 _destination,
+ bytes32 _recipient,
+ uint256 _amount
+ ) internal override returns (IEverclear.Intent memory) {
+ IEverclear.Intent memory intent = super._createIntent(
+ _destination,
+ _recipient,
+ _amount
+ );
+ lastIntent = abi.encode(intent);
+ return intent;
+ }
+}
+
+contract BaseEverclearTokenBridgeForkTest is Test {
+ using TypeCasts for *;
+ using Message for bytes;
+
+ // Arbitrum mainnet constants
+ uint32 internal constant ARBITRUM_DOMAIN = 42161;
+ uint32 internal constant OPTIMISM_DOMAIN = 10; // Optimism destination
+
+ // Real Arbitrum addresses
+ address internal constant ARBITRUM_WETH =
+ 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
+ address internal constant EVERCLEAR_ADAPTER =
+ 0x15a7cA97D1ed168fB34a4055CEFa2E2f9Bdb6C75;
+
+ // Optimism WETH address (for output asset)
+ address internal constant OPTIMISM_WETH =
+ 0x4200000000000000000000000000000000000006;
+
+ // Test constants
+ uint256 internal constant FEE_AMOUNT = 1e16; // 0.01 WETH fee
+
+ // Test addresses
+ address internal ALICE = makeAddr("alice");
+ address internal BOB = makeAddr("bob2");
+ address internal OWNER = makeAddr("owner");
+ address internal PROXY_ADMIN = makeAddr("proxyAdmin");
+
+ // Contracts
+ IWETH internal weth;
+ IEverclearAdapter internal everclearAdapter;
+ EverclearTokenBridge internal bridge;
+
+ // Test data
+ bytes32 internal constant OUTPUT_ASSET =
+ bytes32(uint256(uint160(OPTIMISM_WETH)));
+ bytes32 internal RECIPIENT = bytes32(uint256(uint160(BOB)));
+ uint256 internal feeDeadline;
+ address internal feeSigner;
+ bytes internal feeSignature = hex"123f"; // We will create a real signature in setUp
+
+ function verify(
+ bytes calldata _metadata,
+ bytes calldata _message
+ ) external returns (bool) {
+ return true;
+ }
+
+ function _deployBridge() internal virtual returns (address) {
+ MockEverclearTokenBridge implementation = new MockEverclearTokenBridge(
+ address(weth),
+ 1,
+ address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox
+ everclearAdapter
+ );
+ // Deploy proxy
+ TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(
+ address(implementation),
+ PROXY_ADMIN,
+ abi.encodeCall(EverclearBridge.initialize, (address(0), OWNER))
+ );
+
+ return address(proxy);
+ }
+
+ function setUp() public virtual {
+ // Fork Arbitrum at the latest block
+ vm.createSelectFork("arbitrum");
+
+ weth = IWETH(ARBITRUM_WETH);
+ // Get real Everclear adapter
+ everclearAdapter = IEverclearAdapter(EVERCLEAR_ADAPTER);
+
+ // Set fee deadline to future
+ feeDeadline = block.timestamp + 3600; // 1 hour from now
+
+ // Deploy bridge
+ bridge = EverclearTokenBridge(_deployBridge());
+
+ // It would be great if we could mock the ecrecover function to always return the fee signer for the adapter
+ // but we can't do that with forge. So we're going to sign the fee params with the fee signer private key
+ // and set the fee signature to the signed message.
+ // This is a bit of a hack, but it's the best we can do for now.
+ // Change the fee signer on the Everclear adapter
+ vm.prank(everclearAdapter.owner());
+ (address _feeSigner, uint256 _feeSignerPrivateKey) = makeAddrAndKey(
+ "feeSigner"
+ );
+ feeSigner = _feeSigner;
+ everclearAdapter.updateFeeSigner(feeSigner);
+
+ bytes32 _hash = keccak256(abi.encode(FEE_AMOUNT, 0, weth, feeDeadline));
+ bytes32 _digest = ECDSA.toEthSignedMessageHash(_hash);
+
+ (uint8 v, bytes32 r, bytes32 s) = vm.sign(
+ _feeSignerPrivateKey,
+ _digest
+ );
+ feeSignature = abi.encodePacked(r, s, v);
+
+ // Configure the bridge. We can send to both Optimism and Arbitrum.
+ vm.startPrank(OWNER);
+
+ // Optimism
+ bridge.setFeeParams(
+ OPTIMISM_DOMAIN,
+ FEE_AMOUNT,
+ feeDeadline,
+ feeSignature
+ );
+ bridge.setOutputAsset(
+ OutputAssetInfo({
+ destination: OPTIMISM_DOMAIN,
+ outputAsset: OUTPUT_ASSET
+ })
+ );
+ bridge.enrollRemoteRouter(
+ OPTIMISM_DOMAIN,
+ address(bridge).addressToBytes32()
+ );
+
+ // Arbitrum
+ bridge.setFeeParams(
+ ARBITRUM_DOMAIN,
+ FEE_AMOUNT,
+ feeDeadline,
+ feeSignature
+ );
+ bridge.setOutputAsset(
+ OutputAssetInfo({
+ destination: ARBITRUM_DOMAIN,
+ outputAsset: bytes32(uint256(uint160(ARBITRUM_WETH)))
+ })
+ );
+ bridge.enrollRemoteRouter(
+ ARBITRUM_DOMAIN,
+ address(bridge).addressToBytes32()
+ );
+ // We will be the ism for this bridge
+ bridge.setInterchainSecurityModule(address(this));
+ vm.stopPrank();
+
+ // Setup allowances
+ vm.prank(ALICE);
+ weth.approve(address(bridge), type(uint256).max);
+ }
+}
+
+/**
+ * @notice Fork test contract for EverclearTokenBridge on Arbitrum
+ * @dev Tests the bridge using real Arbitrum state and contracts with WETH transfers to Optimism
+ * @dev We're running the cancun evm version, to avoid `NotActivated` errors
+ * forge-config: default.evm_version = "cancun"
+ */
+contract EverclearTokenBridgeForkTest is BaseEverclearTokenBridgeForkTest {
+ using TypeCasts for *;
+
+ function testFuzz_ForkTransferRemote(uint256 amount) public {
+ // Fund Alice with WETH by wrapping ETH
+ amount = bound(amount, 1, 100e6 ether);
+ uint depositAmount = amount + FEE_AMOUNT;
+ vm.deal(ALICE, depositAmount);
+ vm.prank(ALICE);
+ weth.deposit{value: depositAmount}();
+
+ uint256 initialBalance = weth.balanceOf(ALICE);
+ uint256 initialBridgeBalance = weth.balanceOf(address(bridge));
+
+ // Get the gas payment quote
+ Quote[] memory quotes = bridge.quoteTransferRemote(
+ OPTIMISM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+ uint256 gasPayment = quotes[0].amount;
+
+ // Give Alice ETH for gas payment
+ vm.deal(ALICE, gasPayment);
+
+ // Test the transfer - it may succeed or fail depending on adapter state
+ vm.prank(ALICE);
+ // We don't want to check _intentId, as it's not used
+ // It can be found by getting the fetching the spoke from the adapter with `IEverclearAdapter.spoke`,
+ // fetching the intent queue with `SpokeStorage.intentQueue`
+ // (see https://github.com/everclearorg/monorepo/blob/2c256760f338ded02dc58c4dee128135aff1d0e9/packages/contracts/src/contracts/intent/SpokeStorage.sol#L81)
+ // and then calling `intentQueue.queue(intentQueue.last())`.
+ vm.expectEmit(false, true, true, true);
+ emit IEverclearAdapter.IntentWithFeesAdded({
+ _intentId: bytes32(0),
+ _initiator: address(bridge).addressToBytes32(),
+ _tokenFee: FEE_AMOUNT,
+ _nativeFee: 0
+ });
+ bridge.transferRemote{value: gasPayment}(
+ OPTIMISM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+
+ // Verify the balance changes
+ // Alice should have lost the transfer amount and the fee
+ assertEq(weth.balanceOf(ALICE), initialBalance - amount - FEE_AMOUNT);
+ // The bridge forwards all weth to the adapter, so the bridge balance should be the same
+ assertEq(weth.balanceOf(address(bridge)), initialBridgeBalance);
+ }
+}
+
+contract MockEverclearEthBridge is EverclearEthBridge {
+ constructor(
+ IWETH _weth,
+ address _mailbox,
+ IEverclearAdapter _everclearAdapter
+ ) EverclearEthBridge(_weth, _mailbox, _everclearAdapter) {}
+
+ bytes public lastIntent;
+ function _createIntent(
+ uint32 _destination,
+ bytes32 _recipient,
+ uint256 _amount
+ ) internal override returns (IEverclear.Intent memory) {
+ IEverclear.Intent memory intent = super._createIntent(
+ _destination,
+ _recipient,
+ _amount
+ );
+ lastIntent = abi.encode(intent);
+ return intent;
+ }
+}
+/**
+ * @notice Fork test contract for EverclearEthBridge on Arbitrum
+ * @dev Tests the ETH bridge using real Arbitrum state and contracts with ETH transfers to Optimism
+ * @dev Inherits from EverclearTokenBridgeForkTest to reuse setup logic
+ * @dev We're running the cancun evm version, to avoid `NotActivated` errors
+ * forge-config: default.evm_version = "cancun"
+ */
+contract EverclearEthBridgeForkTest is BaseEverclearTokenBridgeForkTest {
+ using TypeCasts for address;
+ using stdStorage for StdStorage;
+
+ // ETH bridge contract
+ MockEverclearEthBridge internal ethBridge;
+
+ function _deployBridge() internal override returns (address) {
+ // Deploy ETH bridge implementation
+ MockEverclearEthBridge implementation = new MockEverclearEthBridge(
+ IWETH(ARBITRUM_WETH),
+ address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox
+ everclearAdapter
+ );
+
+ // Deploy proxy
+ TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(
+ address(implementation),
+ PROXY_ADMIN,
+ abi.encodeCall(
+ EverclearBridge.initialize,
+ (address(new TestPostDispatchHook()), OWNER)
+ )
+ );
+ return address(proxy);
+ }
+
+ function setUp() public override {
+ super.setUp();
+ ethBridge = MockEverclearEthBridge(payable(address(bridge)));
+ }
+
+ function testFuzz_EthBridgeTransferRemote(uint256 amount) public {
+ // Bound the amount to reasonable values
+ amount = bound(amount, 1e15, 10e18); // 0.001 ETH to 10 ETH
+ uint256 totalAmount = amount + FEE_AMOUNT;
+
+ // Give Alice enough ETH
+ vm.deal(ALICE, totalAmount);
+
+ uint256 initialAliceBalance = ALICE.balance;
+ uint256 initialBridgeBalance = weth.balanceOf(address(ethBridge));
+
+ // Test the transfer - expect IntentWithFeesAdded event
+ vm.prank(ALICE);
+ vm.expectEmit(false, true, true, true);
+ emit IEverclearAdapter.IntentWithFeesAdded({
+ _intentId: bytes32(0),
+ _initiator: address(ethBridge).addressToBytes32(),
+ _tokenFee: FEE_AMOUNT,
+ _nativeFee: 0
+ });
+ ethBridge.transferRemote{value: totalAmount}(
+ OPTIMISM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+
+ // Verify the balance changes
+ // Alice should have lost the total ETH amount (amount + fee)
+ assertEq(ALICE.balance, initialAliceBalance - totalAmount);
+ // The bridge should not hold any WETH (it forwards to adapter)
+ assertEq(weth.balanceOf(address(ethBridge)), initialBridgeBalance);
+ }
+
+ function testEthBridgeTransferRemoteInsufficientETH() public {
+ uint256 amount = 1e18; // 1 ETH
+ uint256 totalAmount = amount + FEE_AMOUNT;
+
+ // Give Alice less ETH than needed
+ vm.deal(ALICE, totalAmount - 1);
+
+ vm.prank(ALICE);
+ vm.expectRevert("Native: amount exceeds msg.value");
+ ethBridge.transferRemote{value: totalAmount - 1}(
+ OPTIMISM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+ }
+
+ function testEthBridgeQuoteTransferRemote() public {
+ uint256 amount = 1e18; // 1 ETH
+
+ Quote[] memory quotes = ethBridge.quoteTransferRemote(
+ OPTIMISM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+
+ assertEq(quotes.length, 3);
+ assertEq(quotes[0].token, address(0));
+ assertEq(quotes[0].amount, 0);
+ assertEq(quotes[1].token, address(0));
+ assertEq(quotes[1].amount, amount);
+ assertEq(quotes[2].token, address(0));
+ assertEq(quotes[2].amount, FEE_AMOUNT);
+ }
+
+ function testEthBridgeConstructor() public {
+ EverclearEthBridge newBridge = new EverclearEthBridge(
+ IWETH(ARBITRUM_WETH),
+ address(0x979Ca5202784112f4738403dBec5D0F3B9daabB9), // Mailbox
+ everclearAdapter
+ );
+
+ assertEq(address(newBridge.wrappedToken()), address(weth));
+ assertEq(
+ address(newBridge.everclearAdapter()),
+ address(everclearAdapter)
+ );
+ assertEq(address(newBridge.token()), address(0));
+ }
+
+ function testFork_receiveMessage(uint256 amount) public {
+ amount = bound(amount, 1, 100e6 ether);
+ uint depositAmount = amount + FEE_AMOUNT;
+ vm.deal(ALICE, depositAmount);
+
+ // Replace mailbox with code from MockMailbox
+ MockMailbox _mailbox = new MockMailbox(ARBITRUM_DOMAIN);
+ vm.etch(address(ethBridge.mailbox()), address(_mailbox).code);
+ MockMailbox mailbox = MockMailbox(address(ethBridge.mailbox()));
+ mailbox.addRemoteMailbox(ARBITRUM_DOMAIN, mailbox);
+
+ // Actually sending message to arbitrum
+ vm.prank(ALICE);
+ ethBridge.transferRemote{value: depositAmount}(
+ ARBITRUM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+
+ bytes32 intentId = keccak256(ethBridge.lastIntent());
+
+ // Settle the created intent via direct storage write
+ stdstore
+ .target(address(ethBridge.everclearSpoke()))
+ .sig(ethBridge.everclearSpoke().status.selector)
+ .with_key(intentId)
+ .checked_write(uint8(IEverclear.IntentStatus.SETTLED));
+
+ assertEq(
+ uint(ethBridge.everclearSpoke().status(intentId)),
+ uint(IEverclear.IntentStatus.SETTLED)
+ );
+
+ // Give the bridge some WETH
+ vm.deal(address(ethBridge), amount);
+ vm.prank(address(ethBridge));
+ weth.deposit{value: amount}();
+
+ // Process the hyperlane message -> call handle directly
+ // Deliver the message to the recipient.
+ mailbox.processNextInboundMessage();
+
+ // Funds should be sent to actual recipient
+ assertEq(BOB.balance, amount);
+ }
+
+ // ============ intentSettled Mapping Tests ============
+
+ function testIntentSettledInitiallyFalse() public {
+ uint256 amount = 1e18; // 1 ETH
+ uint256 totalAmount = amount + FEE_AMOUNT;
+
+ // Fund Alice and perform a transfer to generate an intent
+ vm.deal(ALICE, totalAmount);
+ vm.prank(ALICE);
+ ethBridge.transferRemote{value: totalAmount}(
+ OPTIMISM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+
+ // Get the intent ID from the last created intent
+ bytes32 intentId = keccak256(ethBridge.lastIntent());
+
+ // Verify intent is not initially settled in our bridge
+ assertFalse(ethBridge.intentSettled(intentId));
+ }
+
+ function testIntentSettledAfterProcessing() public {
+ uint256 amount = 1e18; // 1 ETH
+ uint256 totalAmount = amount + FEE_AMOUNT;
+ vm.deal(ALICE, totalAmount);
+
+ // Setup mock mailbox for message processing
+ MockMailbox _mailbox = new MockMailbox(ARBITRUM_DOMAIN);
+ vm.etch(address(ethBridge.mailbox()), address(_mailbox).code);
+ MockMailbox mailbox = MockMailbox(address(ethBridge.mailbox()));
+ mailbox.addRemoteMailbox(ARBITRUM_DOMAIN, mailbox);
+
+ // Perform transfer to create intent
+ vm.prank(ALICE);
+ ethBridge.transferRemote{value: totalAmount}(
+ ARBITRUM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+
+ bytes32 intentId = keccak256(ethBridge.lastIntent());
+
+ // Initially should not be settled in our bridge
+ assertFalse(ethBridge.intentSettled(intentId));
+
+ // Settle the intent in Everclear spoke via storage manipulation
+ stdstore
+ .target(address(ethBridge.everclearSpoke()))
+ .sig(ethBridge.everclearSpoke().status.selector)
+ .with_key(intentId)
+ .checked_write(uint8(IEverclear.IntentStatus.SETTLED));
+
+ // Give the bridge some WETH to process the intent
+ vm.deal(address(ethBridge), amount);
+ vm.prank(address(ethBridge));
+ weth.deposit{value: amount}();
+
+ // Process the hyperlane message
+ mailbox.processNextInboundMessage();
+
+ // After processing, intent should be marked as settled in our bridge
+ assertTrue(ethBridge.intentSettled(intentId));
+ }
+
+ function testIntentSettledPreventsDuplicateProcessing() public {
+ uint amount = 1e18;
+ testFork_receiveMessage(amount);
+ // Try to process the same intent again - should fail because intent is already settled in our bridge
+ MockMailbox mailbox = MockMailbox(address(ethBridge.mailbox()));
+ bytes32 _recipient = address(ethBridge).addressToBytes32();
+ bytes memory _message = TokenMessage.format(
+ _recipient,
+ amount,
+ ethBridge.lastIntent()
+ );
+ bytes memory message = mailbox.buildMessage(
+ address(ethBridge),
+ ARBITRUM_DOMAIN,
+ _recipient,
+ _message
+ );
+
+ mailbox.addInboundMessage(message);
+ vm.expectRevert("ETB: Intent already processed");
+ mailbox.processNextInboundMessage();
+ }
+
+ function testFuzzIntentSettledWithVariousAmounts(uint256 amount) public {
+ amount = bound(amount, 1e15, 10e18); // 0.001 ETH to 10 ETH
+ uint256 totalAmount = amount + FEE_AMOUNT;
+
+ vm.deal(ALICE, totalAmount);
+
+ // Perform transfer
+ vm.prank(ALICE);
+ ethBridge.transferRemote{value: totalAmount}(
+ OPTIMISM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+
+ // Get intent ID and verify initially not settled
+ bytes32 intentId = keccak256(ethBridge.lastIntent());
+ assertFalse(ethBridge.intentSettled(intentId));
+
+ // Verify intent ID is deterministic for same parameters
+ vm.deal(ALICE, totalAmount);
+ vm.prank(ALICE);
+ ethBridge.transferRemote{value: totalAmount}(
+ OPTIMISM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+
+ bytes32 secondIntentId = keccak256(ethBridge.lastIntent());
+ // Different intents should have different IDs (due to nonce/timestamp differences)
+ assertTrue(intentId != secondIntentId);
+ assertFalse(ethBridge.intentSettled(secondIntentId));
+ }
+
+ function testIntentSettledWithDifferentDestinations() public {
+ uint256 amount = 1e18;
+ uint256 totalAmount = amount + FEE_AMOUNT;
+
+ // Configure bridge for Optimism transfers
+ vm.prank(OWNER);
+ ethBridge.enrollRemoteRouter(OPTIMISM_DOMAIN, RECIPIENT);
+
+ vm.deal(ALICE, totalAmount * 2);
+
+ // Transfer to Arbitrum
+ vm.prank(ALICE);
+ ethBridge.transferRemote{value: totalAmount}(
+ ARBITRUM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+ bytes32 arbitrumIntentId = keccak256(ethBridge.lastIntent());
+
+ // Transfer to Optimism
+ vm.prank(ALICE);
+ ethBridge.transferRemote{value: totalAmount}(
+ OPTIMISM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+ bytes32 optimismIntentId = keccak256(ethBridge.lastIntent());
+
+ // Both should be initially not settled and have different IDs
+ assertFalse(ethBridge.intentSettled(arbitrumIntentId));
+ assertFalse(ethBridge.intentSettled(optimismIntentId));
+ assertTrue(arbitrumIntentId != optimismIntentId);
+ }
+
+ function testIntentSettledStatusChecking() public {
+ uint256 amount = 1e18;
+ uint256 totalAmount = amount + FEE_AMOUNT;
+ vm.deal(ALICE, totalAmount);
+
+ // Setup mock mailbox
+ MockMailbox _mailbox = new MockMailbox(ARBITRUM_DOMAIN);
+ vm.etch(address(ethBridge.mailbox()), address(_mailbox).code);
+ MockMailbox mailbox = MockMailbox(address(ethBridge.mailbox()));
+ mailbox.addRemoteMailbox(ARBITRUM_DOMAIN, mailbox);
+
+ // Create intent
+ vm.prank(ALICE);
+ ethBridge.transferRemote{value: totalAmount}(
+ ARBITRUM_DOMAIN,
+ RECIPIENT,
+ amount
+ );
+
+ bytes32 intentId = keccak256(ethBridge.lastIntent());
+
+ // Try to process without settling in Everclear first - should fail
+ vm.deal(address(ethBridge), amount);
+ vm.prank(address(ethBridge));
+ weth.deposit{value: amount}();
+
+ vm.expectRevert("ETB: Intent Status != SETTLED");
+ mailbox.processNextInboundMessage();
+
+ // Verify still not settled in our bridge
+ assertFalse(ethBridge.intentSettled(intentId));
+
+ // Now settle in Everclear spoke
+ stdstore
+ .target(address(ethBridge.everclearSpoke()))
+ .sig(ethBridge.everclearSpoke().status.selector)
+ .with_key(intentId)
+ .checked_write(uint8(IEverclear.IntentStatus.SETTLED));
+
+ // Now processing should succeed
+ mailbox.processNextInboundMessage();
+ assertTrue(ethBridge.intentSettled(intentId));
+ }
+}
diff --git a/solidity/test/token/Fees.t.sol b/solidity/test/token/Fees.t.sol
new file mode 100644
index 0000000000..00c079c24f
--- /dev/null
+++ b/solidity/test/token/Fees.t.sol
@@ -0,0 +1,423 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0;
+
+import {Test} from "forge-std/Test.sol";
+import {ERC20Test} from "../../contracts/test/ERC20Test.sol";
+
+import {BaseFee, FeeType} from "../../contracts/token/fees/BaseFee.sol";
+import {LinearFee} from "../../contracts/token/fees/LinearFee.sol";
+import {ProgressiveFee} from "../../contracts/token/fees/ProgressiveFee.sol";
+import {RegressiveFee} from "../../contracts/token/fees/RegressiveFee.sol";
+import {RoutingFee} from "../../contracts/token/fees/RoutingFee.sol";
+import {Quote} from "../../contracts/interfaces/ITokenBridge.sol";
+
+// --- Base Test ---
+
+abstract contract BaseFeeTest is Test {
+ BaseFee public feeContract;
+ address internal constant OWNER = address(0x123);
+ address internal constant BENEFICIARY = address(0x456);
+
+ ERC20Test token = new ERC20Test("Test Token", "TST", 0, 18);
+
+ uint32 internal constant destination = 1;
+ bytes32 internal constant recipient =
+ bytes32(uint256(uint160(address(0x789))));
+
+ function setUp() public virtual {
+ vm.label(OWNER, "Owner");
+ vm.label(BENEFICIARY, "Beneficiary");
+ }
+
+ function test_Claim() public virtual {
+ // Test claiming ERC20 tokens
+ uint256 erc20Amount = 100 * 10 ** 18;
+ token.mintTo(address(feeContract), erc20Amount);
+
+ uint256 beneficiaryErc20BalanceBefore = token.balanceOf(BENEFICIARY);
+ vm.prank(OWNER);
+ feeContract.claim(BENEFICIARY);
+ uint256 beneficiaryErc20BalanceAfter = token.balanceOf(BENEFICIARY);
+
+ assertEq(
+ beneficiaryErc20BalanceAfter - beneficiaryErc20BalanceBefore,
+ erc20Amount,
+ "ERC20 claim failed"
+ );
+ assertEq(
+ token.balanceOf(address(feeContract)),
+ 0,
+ "ERC20 balance not zero after claim"
+ );
+ }
+}
+
+// --- LinearFee Tests ---
+
+contract LinearFeeTest is BaseFeeTest {
+ uint256 internal constant DEFAULT_MAX_FEE = 1000;
+ uint256 internal constant DEFAULT_HALF_AMOUNT = 10000;
+
+ function setUp() public override {
+ super.setUp();
+ feeContract = new LinearFee(
+ address(token),
+ DEFAULT_MAX_FEE,
+ DEFAULT_HALF_AMOUNT,
+ OWNER
+ );
+ }
+
+ function test_LinearFee_Type() public {
+ assertEq(uint(feeContract.feeType()), uint(FeeType.LINEAR));
+ }
+
+ function test_LinearFee_Quote(
+ uint96 maxFee,
+ uint96 halfAmount,
+ uint96 amount
+ ) public {
+ vm.assume(maxFee > 0);
+ vm.assume(halfAmount > 0);
+
+ LinearFee localLinearFee = new LinearFee(
+ address(token),
+ maxFee,
+ halfAmount,
+ OWNER
+ );
+
+ uint256 uncapped = (uint256(amount) * maxFee) /
+ (2 * uint256(halfAmount));
+ uint256 expectedFee = uncapped > maxFee ? maxFee : uncapped;
+
+ assertEq(
+ localLinearFee
+ .quoteTransferRemote(destination, recipient, amount)[0].amount,
+ expectedFee,
+ "Linear fee mismatch"
+ );
+ }
+
+ function test_RevertIf_ZeroHalfAmount() public {
+ vm.expectRevert(bytes("halfAmount must be greater than zero"));
+ LinearFee fee = new LinearFee(
+ address(token),
+ DEFAULT_MAX_FEE,
+ 0,
+ BENEFICIARY
+ );
+ }
+
+ function test_RevertIf_ZeroMaxFee() public {
+ vm.expectRevert(bytes("maxFee must be greater than zero"));
+ new LinearFee(address(token), 0, DEFAULT_HALF_AMOUNT, OWNER);
+ }
+
+ function test_RevertIf_ZeroOwner() public {
+ vm.expectRevert(bytes("owner cannot be zero address"));
+ new LinearFee(
+ address(token),
+ DEFAULT_MAX_FEE,
+ DEFAULT_HALF_AMOUNT,
+ address(0)
+ );
+ }
+}
+
+// --- ProgressiveFee Tests ---
+
+contract ProgressiveFeeTest is BaseFeeTest {
+ uint256 internal constant DEFAULT_MAX_FEE = 1000;
+ uint256 internal constant DEFAULT_HALF_AMOUNT = 10000;
+
+ function setUp() public override {
+ super.setUp();
+ feeContract = new ProgressiveFee(
+ address(token),
+ DEFAULT_MAX_FEE,
+ DEFAULT_HALF_AMOUNT,
+ OWNER
+ );
+ }
+
+ function test_ProgressiveFee_Type() public {
+ assertEq(uint(feeContract.feeType()), uint(FeeType.PROGRESSIVE));
+ }
+
+ function test_ProgressiveFee_Quote(
+ uint96 maxFee,
+ uint96 halfAmount,
+ uint96 amount
+ ) public {
+ vm.assume(maxFee > 0);
+ vm.assume(halfAmount > 0);
+ vm.assume(amount != 0);
+
+ uint256 amountSq = uint256(amount) * amount;
+ vm.assume(type(uint256).max / maxFee >= amountSq);
+
+ uint256 halfSq = uint256(halfAmount) * halfAmount;
+ vm.assume(type(uint256).max - halfSq >= amountSq);
+
+ ProgressiveFee localProgressiveFee = new ProgressiveFee(
+ address(token),
+ maxFee,
+ halfAmount,
+ OWNER
+ );
+
+ uint256 expectedFee = (uint256(maxFee) * amountSq) /
+ (halfSq + amountSq);
+
+ assertEq(
+ localProgressiveFee
+ .quoteTransferRemote(destination, recipient, amount)[0].amount,
+ expectedFee,
+ "Progressive fee mismatch"
+ );
+ }
+
+ function test_ProgressiveFee_IncreasingPercentageBeforePeak() public {
+ // Test that fee percentage increases as amount increases toward halfAmount
+ ProgressiveFee localProgressiveFee = new ProgressiveFee(
+ address(token),
+ 1000,
+ 10000,
+ OWNER
+ );
+
+ uint256 amount1 = 2000;
+ uint256 amount2 = 5000;
+ uint256 amount3 = 10000;
+
+ uint256 fee1 = localProgressiveFee
+ .quoteTransferRemote(destination, recipient, amount1)[0].amount;
+ uint256 fee2 = localProgressiveFee
+ .quoteTransferRemote(destination, recipient, amount2)[0].amount;
+ uint256 fee3 = localProgressiveFee
+ .quoteTransferRemote(destination, recipient, amount3)[0].amount;
+
+ // Calculate percentages (scaled by 1e18 for precision)
+ uint256 percentage1 = (fee1 * 1e18) / amount1;
+ uint256 percentage2 = (fee2 * 1e18) / amount2;
+ uint256 percentage3 = (fee3 * 1e18) / amount3;
+
+ // Verify percentages increase before peak
+ assertLt(percentage1, percentage2, "Percentage should increase");
+ assertLt(percentage2, percentage3, "Percentage should increase");
+ }
+
+ function test_ProgressiveFee_DecreasingPercentageAfterPeak() public {
+ // Test that fee percentage decreases as amount increases beyond halfAmount
+ ProgressiveFee localProgressiveFee = new ProgressiveFee(
+ address(token),
+ 1000,
+ 10000,
+ OWNER
+ );
+
+ uint256 amount1 = 10000;
+ uint256 amount2 = 20000;
+ uint256 amount3 = 50000;
+
+ uint256 fee1 = localProgressiveFee
+ .quoteTransferRemote(destination, recipient, amount1)[0].amount;
+ uint256 fee2 = localProgressiveFee
+ .quoteTransferRemote(destination, recipient, amount2)[0].amount;
+ uint256 fee3 = localProgressiveFee
+ .quoteTransferRemote(destination, recipient, amount3)[0].amount;
+
+ // Calculate percentages (scaled by 1e18 for precision)
+ uint256 percentage1 = (fee1 * 1e18) / amount1;
+ uint256 percentage2 = (fee2 * 1e18) / amount2;
+ uint256 percentage3 = (fee3 * 1e18) / amount3;
+
+ // Verify percentages decrease after peak
+ assertGt(percentage1, percentage2, "Percentage should decrease");
+ assertGt(percentage2, percentage3, "Percentage should decrease");
+ }
+
+ function test_ProgressiveFee_ZeroAmount() public {
+ // Test that fee is zero when amount is zero
+ ProgressiveFee localProgressiveFee = new ProgressiveFee(
+ address(token),
+ 1000,
+ 10000,
+ OWNER
+ );
+
+ uint256 fee = localProgressiveFee
+ .quoteTransferRemote(destination, recipient, 0)[0].amount;
+
+ assertEq(fee, 0, "Fee should be zero for zero amount");
+ }
+}
+
+// --- RegressiveFee Tests ---
+
+contract RegressiveFeeTest is BaseFeeTest {
+ uint256 internal constant DEFAULT_MAX_FEE = 1000;
+ uint256 internal constant DEFAULT_HALF_AMOUNT = 10000;
+
+ function setUp() public override {
+ super.setUp();
+ feeContract = new RegressiveFee(
+ address(token),
+ DEFAULT_MAX_FEE,
+ DEFAULT_HALF_AMOUNT,
+ OWNER
+ );
+ }
+
+ function test_RegressiveFee_Type() public {
+ assertEq(uint(feeContract.feeType()), uint(FeeType.REGRESSIVE));
+ }
+
+ function test_RegressiveFee_Quote(
+ uint96 maxFee,
+ uint96 halfAmount,
+ uint96 amount
+ ) public {
+ vm.assume(maxFee > 0);
+ vm.assume(halfAmount > 0);
+ vm.assume(type(uint256).max - halfAmount >= amount);
+
+ RegressiveFee localRegressiveFee = new RegressiveFee(
+ address(token),
+ maxFee,
+ halfAmount,
+ OWNER
+ );
+
+ uint256 expectedFee = (uint256(maxFee) * amount) /
+ (uint256(halfAmount) + amount);
+
+ assertEq(
+ localRegressiveFee
+ .quoteTransferRemote(destination, recipient, amount)[0].amount,
+ expectedFee,
+ "Regressive fee mismatch"
+ );
+ }
+
+ function test_RegressiveFee_ContinuouslyDecreasingPercentage() public {
+ // Test that fee percentage continuously decreases as amount increases
+ RegressiveFee localRegressiveFee = new RegressiveFee(
+ address(token),
+ 1000,
+ 5000,
+ OWNER
+ );
+
+ uint256 amount1 = 1000;
+ uint256 amount2 = 5000;
+ uint256 amount3 = 20000;
+
+ uint256 fee1 = localRegressiveFee
+ .quoteTransferRemote(destination, recipient, amount1)[0].amount;
+ uint256 fee2 = localRegressiveFee
+ .quoteTransferRemote(destination, recipient, amount2)[0].amount;
+ uint256 fee3 = localRegressiveFee
+ .quoteTransferRemote(destination, recipient, amount3)[0].amount;
+
+ // Calculate percentages (scaled by 1e18 for precision)
+ uint256 percentage1 = (fee1 * 1e18) / amount1;
+ uint256 percentage2 = (fee2 * 1e18) / amount2;
+ uint256 percentage3 = (fee3 * 1e18) / amount3;
+
+ // Verify percentages continuously decrease
+ assertGt(percentage1, percentage2, "Percentage should decrease");
+ assertGt(percentage2, percentage3, "Percentage should decrease");
+ }
+}
+
+// --- RoutingFee Tests ---
+
+contract RoutingFeeTest is BaseFeeTest {
+ RoutingFee public routingFee;
+ LinearFee public linearFee1;
+ uint32 internal constant DEST1 = 100;
+ uint256 internal constant MAX_FEE1 = 500;
+ uint256 internal constant HALF_AMOUNT1 = 1000;
+
+ function setUp() public override {
+ super.setUp();
+ routingFee = new RoutingFee(address(token), OWNER);
+ feeContract = routingFee; // for claim test
+ linearFee1 = new LinearFee(
+ address(token),
+ MAX_FEE1,
+ HALF_AMOUNT1,
+ OWNER
+ );
+ }
+
+ function test_RoutingFee_Type() public {
+ assertEq(uint(routingFee.feeType()), uint(FeeType.ROUTING));
+ }
+
+ function test_Quote_NoFeeContract() public {
+ // Use a destination that is not configured
+ Quote[] memory quotes = routingFee.quoteTransferRemote(
+ DEST1 + 1,
+ recipient,
+ 1000
+ );
+ assertEq(
+ quotes.length,
+ 0,
+ "Should return empty if no fee contract set"
+ );
+ }
+
+ function test_Quote_DelegatesToFeeContract() public {
+ vm.prank(OWNER);
+ routingFee.setFeeContract(DEST1, address(linearFee1));
+ uint256 amount = 2000;
+ Quote[] memory quotes = routingFee.quoteTransferRemote(
+ DEST1,
+ recipient,
+ amount
+ );
+ uint256 expected = (amount * MAX_FEE1) / (2 * HALF_AMOUNT1);
+ if (expected > MAX_FEE1) expected = MAX_FEE1;
+ assertEq(quotes.length, 1, "Should return one quote");
+ assertEq(quotes[0].token, address(token), "Token address mismatch");
+ assertEq(quotes[0].amount, expected, "Fee mismatch");
+ }
+
+ function test_SetFeeContract_EmitsEvent() public {
+ vm.prank(OWNER);
+ vm.expectEmit(true, true, false, true, address(routingFee));
+ emit RoutingFee.FeeContractSet(DEST1, address(linearFee1));
+ routingFee.setFeeContract(DEST1, address(linearFee1));
+ assertEq(routingFee.feeContracts(DEST1), address(linearFee1));
+ }
+
+ function test_RevertIf_NonOwnerSetsFeeContract() public {
+ vm.prank(address(0x999));
+ vm.expectRevert("Ownable: caller is not the owner");
+ routingFee.setFeeContract(DEST1, address(linearFee1));
+ }
+
+ function test_Claim() public override {
+ // Test claiming ERC20 tokens from RoutingFee
+ uint256 erc20Amount = 100 * 10 ** 18;
+ token.mintTo(address(routingFee), erc20Amount);
+ uint256 beneficiaryErc20BalanceBefore = token.balanceOf(BENEFICIARY);
+ vm.prank(OWNER);
+ routingFee.claim(BENEFICIARY);
+ uint256 beneficiaryErc20BalanceAfter = token.balanceOf(BENEFICIARY);
+ assertEq(
+ beneficiaryErc20BalanceAfter - beneficiaryErc20BalanceBefore,
+ erc20Amount,
+ "ERC20 claim failed"
+ );
+ assertEq(
+ token.balanceOf(address(routingFee)),
+ 0,
+ "ERC20 balance not zero after claim"
+ );
+ }
+}
diff --git a/solidity/test/token/HypERC20.t.sol b/solidity/test/token/HypERC20.t.sol
index b24de6aecb..c9a1349692 100644
--- a/solidity/test/token/HypERC20.t.sol
+++ b/solidity/test/token/HypERC20.t.sol
@@ -24,6 +24,8 @@ import {TestPostDispatchHook} from "../../contracts/test/TestPostDispatchHook.so
import {TestInterchainGasPaymaster} from "../../contracts/test/TestInterchainGasPaymaster.sol";
import {GasRouter} from "../../contracts/client/GasRouter.sol";
import {IPostDispatchHook} from "../../contracts/interfaces/hooks/IPostDispatchHook.sol";
+import {LinearFee} from "../../contracts/token/fees/LinearFee.sol";
+import {TokenRouter} from "../../contracts/token/libs/TokenRouter.sol";
import {Router} from "../../contracts/client/Router.sol";
import {HypERC20} from "../../contracts/token/HypERC20.sol";
@@ -37,6 +39,7 @@ import {HypNative} from "../../contracts/token/HypNative.sol";
import {TokenRouter} from "../../contracts/token/libs/TokenRouter.sol";
import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol";
import {Message} from "../../contracts/libs/Message.sol";
+import {Quote} from "../../contracts/interfaces/ITokenBridge.sol";
abstract contract HypTokenTest is Test {
using TypeCasts for address;
@@ -81,6 +84,8 @@ abstract contract HypTokenTest is Test {
uint256 amount
);
+ LinearFee internal feeContract;
+
function setUp() public virtual {
localMailbox = new MockMailbox(ORIGIN);
remoteMailbox = new MockMailbox(DESTINATION);
@@ -138,6 +143,10 @@ abstract contract HypTokenTest is Test {
);
}
+ function _localTokenBalanceOf(
+ address _account
+ ) internal view virtual returns (uint256);
+
function _connectRouters(
uint32[] memory _domains,
bytes32[] memory _addresses
@@ -169,13 +178,8 @@ abstract contract HypTokenTest is Test {
assertEq(remoteToken.balanceOf(_user), _balance);
}
- function _processTransfers(address _recipient, uint256 _amount) internal {
- vm.prank(address(remoteMailbox));
- remoteToken.handle(
- ORIGIN,
- address(localToken).addressToBytes32(),
- abi.encodePacked(_recipient.addressToBytes32(), _amount)
- );
+ function _processTransfers() internal {
+ remoteMailbox.processNextInboundMessage();
}
function _handleLocalTransfer(uint256 _transferAmount) internal {
@@ -217,7 +221,7 @@ abstract contract HypTokenTest is Test {
vm.expectEmit(true, true, false, true);
emit ReceivedTransferRemote(ORIGIN, BOB.addressToBytes32(), _amount);
- _processTransfers(BOB, _amount);
+ _processTransfers();
assertEq(remoteToken.balanceOf(BOB), _amount);
}
@@ -242,42 +246,6 @@ abstract contract HypTokenTest is Test {
_performRemoteTransferAndGas(_msgValue, _amount, _gasOverhead);
}
- function _performRemoteTransferWithHook(
- uint256 _msgValue,
- uint256 _amount,
- address _hook,
- bytes memory _hookMetadata
- ) internal returns (bytes32 messageId) {
- vm.prank(ALICE);
- messageId = localToken.transferRemote{value: _msgValue}(
- DESTINATION,
- BOB.addressToBytes32(),
- _amount,
- _hookMetadata,
- address(_hook)
- );
- _processTransfers(BOB, _amount);
- assertEq(remoteToken.balanceOf(BOB), _amount);
- }
-
- function testTransfer_withHookSpecified(
- uint256 fee,
- bytes calldata metadata
- ) public virtual {
- TestPostDispatchHook hook = new TestPostDispatchHook();
- hook.setFee(fee);
-
- vm.prank(ALICE);
- primaryToken.approve(address(localToken), TRANSFER_AMT);
- bytes32 messageId = _performRemoteTransferWithHook(
- REQUIRED_VALUE,
- TRANSFER_AMT,
- address(hook),
- metadata
- );
- assertTrue(hook.messageDispatched(messageId));
- }
-
function testBenchmark_overheadGasUsage() public virtual {
vm.prank(address(localMailbox));
@@ -290,6 +258,72 @@ abstract contract HypTokenTest is Test {
uint256 gasAfter = gasleft();
console.log("Overhead gas usage: %d", gasBefore - gasAfter);
}
+
+ function testRemoteTransfer_withFee() public virtual {
+ feeContract = new LinearFee(
+ address(primaryToken),
+ 1e18,
+ 100e18,
+ address(this)
+ );
+ localToken.setFeeRecipient(address(feeContract));
+ uint256 fee = feeContract
+ .quoteTransferRemote(DESTINATION, BOB.addressToBytes32(), TRANSFER_AMT)[
+ 0
+ ].amount;
+ uint256 total = TRANSFER_AMT + fee;
+
+ uint256 nativeValue = REQUIRED_VALUE;
+ if (address(primaryToken) != address(0)) {
+ deal(address(primaryToken), ALICE, total);
+ vm.prank(ALICE);
+ primaryToken.approve(address(localToken), total);
+ } else {
+ vm.deal(ALICE, total);
+ nativeValue += total;
+ }
+
+ (
+ uint256 senderBefore,
+ uint256 beneficiaryBefore,
+ uint256 recipientBefore
+ ) = _getBalances(ALICE, BOB);
+
+ vm.prank(ALICE);
+ localToken.transferRemote{value: nativeValue}(
+ DESTINATION,
+ BOB.addressToBytes32(),
+ TRANSFER_AMT
+ );
+
+ _processTransfers();
+ (
+ uint256 senderAfter,
+ uint256 beneficiaryAfter,
+ uint256 recipientAfter
+ ) = _getBalances(ALICE, BOB);
+
+ assertEq(senderAfter, senderBefore - (TRANSFER_AMT + fee));
+ assertEq(beneficiaryAfter, beneficiaryBefore + fee);
+ assertEq(recipientAfter, recipientBefore + TRANSFER_AMT);
+ }
+
+ function _getBalances(
+ address sender,
+ address recipient
+ )
+ internal
+ virtual
+ returns (
+ uint256 senderBalance,
+ uint256 beneficiaryBalance,
+ uint256 recipientBalance
+ )
+ {
+ senderBalance = _localTokenBalanceOf(sender);
+ beneficiaryBalance = _localTokenBalanceOf(address(feeContract));
+ recipientBalance = remoteToken.balanceOf(recipient);
+ }
}
contract HypERC20Test is HypTokenTest {
@@ -320,6 +354,7 @@ contract HypERC20Test is HypTokenTest {
);
localToken = HypERC20(address(proxy));
erc20Token = HypERC20(address(proxy));
+ primaryToken = ERC20Test(address(erc20Token));
erc20Token.enrollRemoteRouter(
DESTINATION,
@@ -330,6 +365,12 @@ contract HypERC20Test is HypTokenTest {
_enrollRemoteTokenRouter();
}
+ function _localTokenBalanceOf(
+ address _account
+ ) internal view override returns (uint256) {
+ return HypERC20(address(localToken)).balanceOf(_account);
+ }
+
function testInitialize_revert_ifAlreadyInitialized() public {
vm.expectRevert("Initializable: contract is already initialized");
erc20Token.initialize(
@@ -372,7 +413,12 @@ contract HypERC20Test is HypTokenTest {
function testRemoteTransfer_invalidAmount() public {
vm.expectRevert("ERC20: burn amount exceeds balance");
- _performRemoteTransfer(REQUIRED_VALUE, TRANSFER_AMT * 11);
+ vm.prank(ALICE);
+ localToken.transferRemote{value: REQUIRED_VALUE}(
+ DESTINATION,
+ BOB.addressToBytes32(),
+ TRANSFER_AMT * 11
+ );
assertEq(erc20Token.balanceOf(ALICE), 1000e18);
}
@@ -415,6 +461,12 @@ contract HypERC20CollateralTest is HypTokenTest {
_enrollRemoteTokenRouter();
}
+ function _localTokenBalanceOf(
+ address _account
+ ) internal view override returns (uint256) {
+ return ERC20Test(primaryToken).balanceOf(_account);
+ }
+
function test_constructor_revert_ifInvalidToken() public {
vm.expectRevert("HypERC20Collateral: invalid token");
new HypERC20Collateral(address(0), SCALE, address(localMailbox));
@@ -423,24 +475,29 @@ contract HypERC20CollateralTest is HypTokenTest {
function testInitialize_revert_ifAlreadyInitialized() public {}
function testRemoteTransfer() public {
- uint256 balanceBefore = localToken.balanceOf(ALICE);
+ uint256 balanceBefore = _localTokenBalanceOf(ALICE);
vm.prank(ALICE);
primaryToken.approve(address(localToken), TRANSFER_AMT);
_performRemoteTransferWithEmit(REQUIRED_VALUE, TRANSFER_AMT, 0);
- assertEq(localToken.balanceOf(ALICE), balanceBefore - TRANSFER_AMT);
+ assertEq(_localTokenBalanceOf(ALICE), balanceBefore - TRANSFER_AMT);
}
function testRemoteTransfer_invalidAllowance() public {
vm.expectRevert("ERC20: insufficient allowance");
- _performRemoteTransfer(REQUIRED_VALUE, TRANSFER_AMT);
- assertEq(localToken.balanceOf(ALICE), 1000e18);
+ vm.prank(ALICE);
+ localToken.transferRemote{value: REQUIRED_VALUE}(
+ DESTINATION,
+ BOB.addressToBytes32(),
+ TRANSFER_AMT
+ );
+ assertEq(_localTokenBalanceOf(ALICE), 1000e18);
}
function testRemoteTransfer_withCustomGasConfig() public {
_setCustomGasConfig();
- uint256 balanceBefore = localToken.balanceOf(ALICE);
+ uint256 balanceBefore = _localTokenBalanceOf(ALICE);
vm.prank(ALICE);
primaryToken.approve(address(localToken), TRANSFER_AMT);
@@ -449,7 +506,7 @@ contract HypERC20CollateralTest is HypTokenTest {
TRANSFER_AMT,
GAS_LIMIT * igp.gasPrice()
);
- assertEq(localToken.balanceOf(ALICE), balanceBefore - TRANSFER_AMT);
+ assertEq(_localTokenBalanceOf(ALICE), balanceBefore - TRANSFER_AMT);
}
}
@@ -481,8 +538,14 @@ contract HypXERC20Test is HypTokenTest {
_enrollRemoteTokenRouter();
}
+ function _localTokenBalanceOf(
+ address _account
+ ) internal view override returns (uint256) {
+ return ERC20Test(primaryToken).balanceOf(_account);
+ }
+
function testRemoteTransfer() public {
- uint256 balanceBefore = localToken.balanceOf(ALICE);
+ uint256 balanceBefore = _localTokenBalanceOf(ALICE);
vm.prank(ALICE);
primaryToken.approve(address(localToken), TRANSFER_AMT);
@@ -491,7 +554,7 @@ contract HypXERC20Test is HypTokenTest {
abi.encodeCall(IXERC20.burn, (ALICE, TRANSFER_AMT))
);
_performRemoteTransferWithEmit(REQUIRED_VALUE, TRANSFER_AMT, 0);
- assertEq(localToken.balanceOf(ALICE), balanceBefore - TRANSFER_AMT);
+ assertEq(_localTokenBalanceOf(ALICE), balanceBefore - TRANSFER_AMT);
}
function testHandle() public {
@@ -536,6 +599,12 @@ contract HypXERC20LockboxTest is HypTokenTest {
_enrollRemoteTokenRouter();
}
+ function _localTokenBalanceOf(
+ address _account
+ ) internal view override returns (uint256) {
+ return ERC20Test(primaryToken).balanceOf(_account);
+ }
+
uint256 constant MAX_INT = 2 ** 256 - 1;
function testApproval() public {
@@ -556,7 +625,7 @@ contract HypXERC20LockboxTest is HypTokenTest {
}
function testRemoteTransfer() public {
- uint256 balanceBefore = localToken.balanceOf(ALICE);
+ uint256 balanceBefore = _localTokenBalanceOf(ALICE);
vm.prank(ALICE);
primaryToken.approve(address(localToken), TRANSFER_AMT);
@@ -565,17 +634,17 @@ contract HypXERC20LockboxTest is HypTokenTest {
abi.encodeCall(IXERC20.burn, (address(localToken), TRANSFER_AMT))
);
_performRemoteTransferWithEmit(REQUIRED_VALUE, TRANSFER_AMT, 0);
- assertEq(localToken.balanceOf(ALICE), balanceBefore - TRANSFER_AMT);
+ assertEq(_localTokenBalanceOf(ALICE), balanceBefore - TRANSFER_AMT);
}
function testHandle() public {
- uint256 balanceBefore = localToken.balanceOf(ALICE);
+ uint256 balanceBefore = _localTokenBalanceOf(ALICE);
vm.expectCall(
address(xerc20Lockbox.xERC20()),
abi.encodeCall(IXERC20.mint, (address(localToken), TRANSFER_AMT))
);
_handleLocalTransfer(TRANSFER_AMT);
- assertEq(localToken.balanceOf(ALICE), balanceBefore + TRANSFER_AMT);
+ assertEq(_localTokenBalanceOf(ALICE), balanceBefore + TRANSFER_AMT);
}
}
@@ -607,8 +676,14 @@ contract HypFiatTokenTest is HypTokenTest {
_enrollRemoteTokenRouter();
}
+ function _localTokenBalanceOf(
+ address _account
+ ) internal view override returns (uint256) {
+ return ERC20Test(primaryToken).balanceOf(_account);
+ }
+
function testRemoteTransfer() public {
- uint256 balanceBefore = localToken.balanceOf(ALICE);
+ uint256 balanceBefore = _localTokenBalanceOf(ALICE);
vm.prank(ALICE);
primaryToken.approve(address(localToken), TRANSFER_AMT);
@@ -617,7 +692,7 @@ contract HypFiatTokenTest is HypTokenTest {
abi.encodeCall(IFiatToken.burn, (TRANSFER_AMT))
);
_performRemoteTransferWithEmit(REQUIRED_VALUE, TRANSFER_AMT, 0);
- assertEq(localToken.balanceOf(ALICE), balanceBefore - TRANSFER_AMT);
+ assertEq(_localTokenBalanceOf(ALICE), balanceBefore - TRANSFER_AMT);
}
function testHandle() public {
@@ -645,6 +720,7 @@ contract HypNativeTest is HypTokenTest {
localToken = new HypNative(SCALE, address(localMailbox));
nativeToken = HypNative(payable(address(localToken)));
+ primaryToken = ERC20Test(address(0));
nativeToken.enrollRemoteRouter(
DESTINATION,
@@ -657,24 +733,10 @@ contract HypNativeTest is HypTokenTest {
_enrollRemoteTokenRouter();
}
- function testTransfer_withHookSpecified(
- uint256 fee,
- bytes calldata metadata
- ) public override {
- TestPostDispatchHook hook = new TestPostDispatchHook();
- hook.setFee(fee);
-
- uint256 value = REQUIRED_VALUE + TRANSFER_AMT;
-
- vm.prank(ALICE);
- primaryToken.approve(address(localToken), TRANSFER_AMT);
- bytes32 messageId = _performRemoteTransferWithHook(
- value,
- TRANSFER_AMT,
- address(hook),
- metadata
- );
- assertTrue(hook.messageDispatched(messageId));
+ function _localTokenBalanceOf(
+ address _account
+ ) internal view override returns (uint256) {
+ return _account.balance;
}
function testRemoteTransfer() public {
@@ -687,20 +749,24 @@ contract HypNativeTest is HypTokenTest {
function testRemoteTransfer_invalidAmount() public {
vm.expectRevert("Native: amount exceeds msg.value");
- _performRemoteTransfer(
- REQUIRED_VALUE + TRANSFER_AMT,
- TRANSFER_AMT * 10
+ vm.prank(ALICE);
+ localToken.transferRemote{value: REQUIRED_VALUE + TRANSFER_AMT}(
+ DESTINATION,
+ BOB.addressToBytes32(),
+ REQUIRED_VALUE + TRANSFER_AMT + 1
);
- assertEq(localToken.balanceOf(ALICE), 1000e18);
+ assertEq(_localTokenBalanceOf(ALICE), 1000e18);
}
function testRemoteTransfer_withCustomGasConfig() public {
_setCustomGasConfig();
- _performRemoteTransferAndGas(
- REQUIRED_VALUE,
- TRANSFER_AMT,
- TRANSFER_AMT + GAS_LIMIT * igp.gasPrice()
+ uint256 balanceBefore = ALICE.balance;
+ uint256 gasOverhead = GAS_LIMIT * igp.gasPrice();
+ _performRemoteTransfer(TRANSFER_AMT + gasOverhead, TRANSFER_AMT);
+ assertEq(
+ ALICE.balance,
+ balanceBefore - TRANSFER_AMT - REQUIRED_VALUE - gasOverhead
);
}
@@ -722,9 +788,7 @@ contract HypNativeTest is HypTokenTest {
nativeToken.transferRemote{value: nativeValue}(
DESTINATION,
bRecipient,
- nativeValue + 1,
- bytes(""),
- address(0)
+ nativeValue + 1
);
}
}
@@ -761,11 +825,18 @@ contract HypERC20ScaledTest is HypTokenTest {
localToken = HypERC20(address(proxy));
erc20Token = HypERC20(address(proxy));
erc20Token.transfer(ALICE, TRANSFER_AMT);
+ primaryToken = ERC20Test(address(erc20Token));
_enrollLocalTokenRouter();
_enrollRemoteTokenRouter();
}
+ function _localTokenBalanceOf(
+ address _account
+ ) internal view override returns (uint256) {
+ return ERC20Test(address(localToken)).balanceOf(_account);
+ }
+
function testRemoteTransfer() public {
vm.expectEmit(true, true, false, true);
emit Transfer(ALICE, address(0x0), TRANSFER_AMT);
@@ -777,13 +848,15 @@ contract HypERC20ScaledTest is HypTokenTest {
TRANSFER_AMT * EFFECTIVE_SCALE
);
- _performRemoteTransferAndGas(REQUIRED_VALUE, TRANSFER_AMT, 0);
+ vm.prank(ALICE);
+ localToken.transferRemote{value: REQUIRED_VALUE}(
+ DESTINATION,
+ BOB.addressToBytes32(),
+ TRANSFER_AMT
+ );
}
function testHandle() public {
- vm.expectEmit(true, true, false, true);
- emit Transfer(address(0x0), ALICE, TRANSFER_AMT / EFFECTIVE_SCALE);
-
vm.expectEmit(true, true, false, true);
emit ReceivedTransferRemote(
DESTINATION,
@@ -791,6 +864,26 @@ contract HypERC20ScaledTest is HypTokenTest {
TRANSFER_AMT
);
+ vm.expectEmit(true, true, false, true);
+ emit Transfer(address(0x0), ALICE, TRANSFER_AMT / EFFECTIVE_SCALE);
+
_handleLocalTransfer(TRANSFER_AMT);
}
+
+ function _getBalances(
+ address sender,
+ address recipient
+ )
+ internal
+ override
+ returns (
+ uint256 senderBalance,
+ uint256 beneficiaryBalance,
+ uint256 recipientBalance
+ )
+ {
+ (senderBalance, beneficiaryBalance, recipientBalance) = super
+ ._getBalances(sender, recipient);
+ recipientBalance = recipientBalance / EFFECTIVE_SCALE;
+ }
}
diff --git a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol
index 80658cf338..31507f8f2c 100644
--- a/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol
+++ b/solidity/test/token/HypERC20CollateralVaultDeposit.t.sol
@@ -17,13 +17,15 @@ import "forge-std/Test.sol";
import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {HypERC4626} from "../../contracts/token/extensions/HypERC4626.sol";
+import {HypERC20} from "../../contracts/token/HypERC20.sol";
+import {NonCompliantERC20Test} from "../../contracts/test/ERC20Test.sol";
import {ERC4626Test} from "../../contracts/test/ERC4626/ERC4626Test.sol";
import {TypeCasts} from "../../contracts/libs/TypeCasts.sol";
import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol";
import {HypTokenTest} from "./HypERC20.t.sol";
-import {HypERC4626OwnerCollateral} from "../../contracts/token/extensions/HypERC4626OwnerCollateral.sol";
+import {HypERC4626OwnerCollateral, HypERC4626Collateral} from "../../contracts/token/extensions/HypERC4626OwnerCollateral.sol";
import "../../contracts/test/ERC4626/ERC4626Test.sol";
contract HypERC4626OwnerCollateralTest is HypTokenTest {
@@ -33,12 +35,11 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest {
HypERC4626OwnerCollateral internal erc20CollateralVaultDeposit;
ERC4626Test vault;
- function setUp() public override {
- super.setUp();
- vault = new ERC4626Test(address(primaryToken), "Regular Vault", "RV");
-
+ function deployErc20CollateralVaultDeposit(
+ address _vault
+ ) public returns (HypERC4626OwnerCollateral) {
HypERC4626OwnerCollateral implementation = new HypERC4626OwnerCollateral(
- vault,
+ ERC4626(_vault),
SCALE,
address(localMailbox)
);
@@ -46,15 +47,21 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest {
address(implementation),
PROXY_ADMIN,
abi.encodeWithSelector(
- HypERC4626OwnerCollateral.initialize.selector,
+ HypERC4626Collateral.initialize.selector,
address(address(noopHook)),
address(igp),
address(this)
)
);
localToken = HypERC4626OwnerCollateral(address(proxy));
- erc20CollateralVaultDeposit = HypERC4626OwnerCollateral(
- address(localToken)
+ return HypERC4626OwnerCollateral(address(localToken));
+ }
+ function setUp() public override {
+ super.setUp();
+ vault = new ERC4626Test(address(primaryToken), "Regular Vault", "RV");
+
+ (erc20CollateralVaultDeposit) = deployErc20CollateralVaultDeposit(
+ address(vault)
);
erc20CollateralVaultDeposit.enrollRemoteRouter(
@@ -68,6 +75,24 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest {
_enrollRemoteTokenRouter();
}
+ function _localTokenBalanceOf(
+ address _account
+ ) internal view override returns (uint256) {
+ return IERC20(primaryToken).balanceOf(_account);
+ }
+
+ function testERC4626VaultDeposit_Initialize_NoncompliantERC20Token()
+ public
+ {
+ NonCompliantERC20Test nonCompliantToken = new NonCompliantERC20Test(); // Has approval() that returns void, instead of bool
+ ERC4626Test _vault = new ERC4626Test(
+ address(nonCompliantToken),
+ "Noncompliant Token Vault",
+ "NT"
+ );
+ deployErc20CollateralVaultDeposit(address(_vault));
+ }
+
function _transferRoundTripAndIncreaseYields(
uint256 transferAmount,
uint256 yieldAmount
@@ -123,10 +148,10 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest {
_transferRoundTripAndIncreaseYields(transferAmount, DUST_AMOUNT);
// Check Alice's local token balance
- uint256 prevBalance = localToken.balanceOf(ALICE);
+ uint256 prevBalance = _localTokenBalanceOf(ALICE);
_handleLocalTransfer(transferAmount);
- assertEq(localToken.balanceOf(ALICE), prevBalance + transferAmount);
+ assertEq(_localTokenBalanceOf(ALICE), prevBalance + transferAmount);
assertEq(erc20CollateralVaultDeposit.assetDeposited(), 0);
}
@@ -139,9 +164,9 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest {
_transferRoundTripAndIncreaseYields(TRANSFER_AMT, rewardAmount);
// Check Alice's local token balance
- uint256 prevBalance = localToken.balanceOf(ALICE);
+ uint256 prevBalance = _localTokenBalanceOf(ALICE);
_handleLocalTransfer(TRANSFER_AMT);
- assertEq(localToken.balanceOf(ALICE), prevBalance + TRANSFER_AMT);
+ assertEq(_localTokenBalanceOf(ALICE), prevBalance + TRANSFER_AMT);
// Has leftover shares, but no assets deposited
assertEq(erc20CollateralVaultDeposit.assetDeposited(), 0);
@@ -231,14 +256,148 @@ contract HypERC4626OwnerCollateralTest is HypTokenTest {
);
}
+ function testERC4626VaultDeposit_ceilingRounding_reservesMoreShares()
+ public
+ {
+ // This test verifies the mathematical difference between convertToShares (floor)
+ // and previewWithdraw (ceiling) rounding when calculating shares for deposits.
+
+ uint256 transferAmount = 100e18;
+ uint256 rewardAmount = 1e18;
+
+ // Setup: Transfer from Alice to Bob
+ vm.prank(ALICE);
+ primaryToken.approve(address(localToken), transferAmount);
+ _performRemoteTransfer(0, transferAmount);
+
+ // Add yield to the vault (increases share value)
+ primaryToken.mintTo(address(vault), rewardAmount);
+
+ // Transfer back from Bob to Alice
+ vm.prank(BOB);
+ remoteToken.transferRemote(
+ ORIGIN,
+ BOB.addressToBytes32(),
+ transferAmount
+ );
+ _handleLocalTransfer(transferAmount);
+
+ // At this point, we have excess shares due to the yield
+ uint256 totalShares = vault.maxRedeem(
+ address(erc20CollateralVaultDeposit)
+ );
+ uint256 assetDeposited = erc20CollateralVaultDeposit.assetDeposited();
+
+ // Calculate what convertToShares (floor rounding) would give us
+ uint256 sharesFloor = vault.convertToShares(assetDeposited);
+
+ // Calculate what previewWithdraw (ceiling rounding) gives us
+ uint256 sharesCeiling = vault.previewWithdraw(assetDeposited);
+
+ // When there's rounding involved, ceiling should be >= floor
+ // and the excess shares should be: totalShares - sharesCeiling
+ uint256 excessSharesWithCeiling = totalShares - sharesCeiling;
+ uint256 excessSharesWithFloor = totalShares - sharesFloor;
+
+ // Verify the key difference: ceiling rounding calculates more shares to reserve
+ // for the deposited assets, which means fewer excess shares to sweep
+ assertLe(
+ excessSharesWithCeiling,
+ excessSharesWithFloor,
+ "Ceiling rounding should reserve more shares for deposits"
+ );
+
+ // Perform sweep and verify the amount swept is <= excessSharesWithFloor
+ // Record logs to capture the event
+ vm.recordLogs();
+ erc20CollateralVaultDeposit.sweep();
+
+ // Get the logs and extract the ExcessSharesSwept event
+ Vm.Log[] memory logs = vm.getRecordedLogs();
+ bool foundEvent = false;
+ uint256 sweptShares;
+
+ for (uint256 i = 0; i < logs.length; i++) {
+ // ExcessSharesSwept event signature: ExcessSharesSwept(uint256,uint256)
+ if (
+ logs[i].topics[0] ==
+ keccak256("ExcessSharesSwept(uint256,uint256)")
+ ) {
+ foundEvent = true;
+ // Decode the event data (amount is first parameter, assetsRedeemed is second)
+ (sweptShares, ) = abi.decode(logs[i].data, (uint256, uint256));
+ break;
+ }
+ }
+
+ assertTrue(
+ foundEvent,
+ "ExcessSharesSwept event should have been emitted"
+ );
+ assertLe(
+ sweptShares,
+ excessSharesWithFloor,
+ "Swept amount should be <= excessSharesWithFloor"
+ );
+ }
+
+ function testERC4626VaultDeposit_sweep_usesCeilingRounding() public {
+ // This test verifies that sweep() correctly sweeps excess shares after yield accrual
+ // and leaves no shares behind when assetDeposited is 0.
+
+ uint256 transferAmount = 100e18;
+ uint256 rewardAmount = 1e18;
+
+ // Setup: Transfer from Alice to Bob
+ vm.prank(ALICE);
+ primaryToken.approve(address(localToken), transferAmount);
+ _performRemoteTransfer(0, transferAmount);
+
+ // Add yield to the vault (increases share value)
+ primaryToken.mintTo(address(vault), rewardAmount);
+
+ // Transfer back from Bob to Alice
+ vm.prank(BOB);
+ remoteToken.transferRemote(
+ ORIGIN,
+ BOB.addressToBytes32(),
+ transferAmount
+ );
+ _handleLocalTransfer(transferAmount);
+
+ uint256 ownerBalanceBefore = primaryToken.balanceOf(
+ erc20CollateralVaultDeposit.owner()
+ );
+
+ // Call sweep() which should use previewWithdraw (ceiling rounding)
+ erc20CollateralVaultDeposit.sweep();
+
+ uint256 ownerBalanceAfter = primaryToken.balanceOf(
+ erc20CollateralVaultDeposit.owner()
+ );
+ uint256 sweptAmount = ownerBalanceAfter - ownerBalanceBefore;
+
+ // The swept amount should be positive (we did sweep excess shares)
+ assertGt(sweptAmount, 0, "Should have swept excess shares");
+
+ // After sweep, we should have no shares remaining (assetDeposited is 0)
+ uint256 remainingShares = vault.maxRedeem(
+ address(erc20CollateralVaultDeposit)
+ );
+ assertEq(
+ remainingShares,
+ 0,
+ "Should have no shares remaining after sweep with no deposits"
+ );
+ }
+
function testERC4626VaultDeposit_TransferFromSender_CorrectMetadata()
public
{
- remoteToken = new HypERC4626(
- DECIMALS,
- SCALE,
- address(remoteMailbox),
- ORIGIN
+ remoteToken = HypERC20(
+ address(
+ new HypERC4626(DECIMALS, SCALE, address(remoteMailbox), ORIGIN)
+ )
);
_enrollRemoteTokenRouter();
vm.prank(ALICE);
diff --git a/solidity/test/token/HypERC20MovableCollateral.t.sol b/solidity/test/token/HypERC20MovableCollateral.t.sol
index f9c860c71d..5e4658f6b5 100644
--- a/solidity/test/token/HypERC20MovableCollateral.t.sol
+++ b/solidity/test/token/HypERC20MovableCollateral.t.sol
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity ^0.8.13;
-import {ValueTransferBridge} from "contracts/token/interfaces/ValueTransferBridge.sol";
-import {MockValueTransferBridge} from "./MovableCollateralRouter.t.sol";
+import {ITokenBridge} from "contracts/interfaces/ITokenBridge.sol";
+import {MockITokenBridge} from "./MovableCollateralRouter.t.sol";
import {HypERC20Collateral} from "contracts/token/HypERC20Collateral.sol";
-// import {HypERC20MovableCollateral} from "contracts/token/HypERC20MovableCollateral.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20Test} from "../../contracts/test/ERC20Test.sol";
import {MockMailbox} from "contracts/mock/MockMailbox.sol";
@@ -13,7 +13,7 @@ import "forge-std/Test.sol";
contract HypERC20MovableCollateralRouterTest is Test {
HypERC20Collateral internal router;
- MockValueTransferBridge internal vtb;
+ MockITokenBridge internal vtb;
ERC20Test internal token;
uint32 internal constant destinationDomain = 2;
address internal constant alice = address(1);
@@ -28,7 +28,7 @@ contract HypERC20MovableCollateralRouterTest is Test {
// Initialize the router -> we are the admin
router.initialize(address(0), address(0), address(this));
- vtb = new MockValueTransferBridge(token);
+ vtb = new MockITokenBridge(token);
}
function _configure(bytes32 _recipient) internal {
@@ -48,12 +48,14 @@ contract HypERC20MovableCollateralRouterTest is Test {
router.addBridge(destinationDomain, vtb);
}
- function testMovingCollateral() public {
+ function test_rebalance() public {
// Configuration
_configure(bytes32(uint256(uint160(alice))));
+ uint256 amount = 1e18;
+
// Setup - approvals happen automatically
- token.mintTo(address(router), 1e18);
+ token.mintTo(address(router), amount);
// Execute
router.rebalance(destinationDomain, 1e18, vtb);
@@ -62,10 +64,7 @@ contract HypERC20MovableCollateralRouterTest is Test {
assertEq(token.balanceOf(address(vtb)), 1e18);
}
- function testFuzz_MovingCollateral(
- uint256 amount,
- bytes32 recipient
- ) public {
+ function testFuzz_rebalance(uint256 amount, bytes32 recipient) public {
vm.assume(recipient != bytes32(0));
// Configuration
diff --git a/solidity/test/token/HypERC4626Test.t.sol b/solidity/test/token/HypERC4626Test.t.sol
index 60a202f262..44167bd07b 100644
--- a/solidity/test/token/HypERC4626Test.t.sol
+++ b/solidity/test/token/HypERC4626Test.t.sol
@@ -116,6 +116,12 @@ contract HypERC4626CollateralTest is HypTokenTest {
_connectRouters(domains, addresses);
}
+ function _localTokenBalanceOf(
+ address _account
+ ) internal view override returns (uint256) {
+ return IERC20(primaryToken).balanceOf(_account);
+ }
+
function testDisableInitializers() public {
vm.expectRevert("Initializable: contract is already initialized");
remoteToken.initialize(0, "", "", address(0), address(0), address(0));
@@ -134,7 +140,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
_accrueYield();
- localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
+ localRebasingToken.rebase(DESTINATION);
remoteMailbox.processNextInboundMessage();
assertApproxEqRelDecimal(
remoteToken.balanceOf(BOB),
@@ -144,28 +150,6 @@ contract HypERC4626CollateralTest is HypTokenTest {
);
}
- function testRemoteTransfer_rebaseWithCustomHook() public {
- _performRemoteTransferWithoutExpectation(0, transferAmount);
- assertEq(remoteToken.balanceOf(BOB), transferAmount);
-
- _accrueYield();
-
- uint256 FEE = 1e18;
- ProtocolFee customHook = new ProtocolFee(
- FEE,
- FEE,
- address(this),
- address(this)
- );
-
- localRebasingToken.rebase{value: FEE}(
- DESTINATION,
- StandardHookMetadata.overrideMsgValue(FEE),
- address(customHook)
- );
- assertEq(address(customHook).balance, FEE);
- }
-
function testRebaseWithTransfer() public {
_performRemoteTransferWithoutExpectation(0, transferAmount);
assertEq(remoteToken.balanceOf(BOB), transferAmount);
@@ -315,7 +299,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
);
_accrueYield();
- localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
+ localRebasingToken.rebase(DESTINATION);
remoteMailbox.processNextInboundMessage();
uint256 sharesAfterYield = remoteRebasingToken.totalShares();
@@ -337,7 +321,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
);
_accrueYield();
- localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
+ localRebasingToken.rebase(DESTINATION);
remoteMailbox.processNextInboundMessage();
uint256 bobShareBalanceAfterYield = remoteRebasingToken.shareBalanceOf(
@@ -409,7 +393,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
_accrueYield();
- localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
+ localRebasingToken.rebase(DESTINATION);
remoteMailbox.processNextInboundMessage();
// Use balance here since it might be off by <1bp
@@ -448,7 +432,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
_accrueYield();
_accrueYield(); // earning 2x yield to be split
- localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
+ localRebasingToken.rebase(DESTINATION);
vm.prank(CAROL);
remoteToken.transferRemote(
@@ -486,7 +470,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
// decrease collateral in vault by 10%
uint256 drawdown = 5e18;
primaryToken.burnFrom(address(vault), drawdown);
- localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
+ localRebasingToken.rebase(DESTINATION);
remoteMailbox.processNextInboundMessage();
// Use balance here since it might be off by <1bp
@@ -512,7 +496,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
_accrueYield();
- localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
+ localRebasingToken.rebase(DESTINATION);
remoteMailbox.processNextInboundMessage();
vm.prank(BOB);
@@ -531,7 +515,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
); // 5 * 0.9 = 4.5% yield
assertEq(peerRebasingToken.exchangeRate(), 1e10); // assertingthat transfers by the synthetic variant don't impact the exchang rate
- localRebasingToken.rebase(PEER_DESTINATION, bytes(""), address(0));
+ localRebasingToken.rebase(PEER_DESTINATION);
peerMailbox.processNextInboundMessage();
assertApproxEqRelDecimal(
@@ -547,7 +531,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
assertEq(remoteToken.balanceOf(BOB), transferAmount);
_accrueYield();
- localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); // yield is added
+ localRebasingToken.rebase(DESTINATION); // yield is added
remoteMailbox.processNextInboundMessage();
uint256 balance = remoteToken.balanceOf(BOB);
@@ -576,7 +560,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
_accrueYield();
- localRebasingToken.rebase(DESTINATION, bytes(""), address(0)); // yield is added
+ localRebasingToken.rebase(DESTINATION); // yield is added
remoteMailbox.processNextInboundMessage();
// BOB: remote -> peer(BOB) (yield is leftover)
@@ -588,7 +572,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
);
peerMailbox.processNextInboundMessage();
- localRebasingToken.rebase(PEER_DESTINATION, bytes(""), address(0));
+ localRebasingToken.rebase(PEER_DESTINATION);
peerMailbox.processNextInboundMessage();
// BOB: peer -> local(CAROL)
@@ -628,7 +612,7 @@ contract HypERC4626CollateralTest is HypTokenTest {
);
_accrueYield();
- localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
+ localRebasingToken.rebase(DESTINATION);
remoteMailbox.processNextInboundMessage();
uint256 supplyAfterYield = remoteToken.totalSupply();
@@ -641,20 +625,13 @@ contract HypERC4626CollateralTest is HypTokenTest {
);
}
- function testTransfer_withHookSpecified(
- uint256,
- bytes calldata
- ) public override {
- // skip
- }
-
function testBenchmark_overheadGasUsage() public override {
_performRemoteTransferWithoutExpectation(0, transferAmount);
assertEq(remoteToken.balanceOf(BOB), transferAmount);
_accrueYield();
- localRebasingToken.rebase(DESTINATION, bytes(""), address(0));
+ localRebasingToken.rebase(DESTINATION);
remoteMailbox.processNextInboundMessage();
assertApproxEqRelDecimal(
remoteToken.balanceOf(BOB),
diff --git a/solidity/test/token/HypERC721.t.sol b/solidity/test/token/HypERC721.t.sol
index 70a2a229c5..88fef3c8f7 100644
--- a/solidity/test/token/HypERC721.t.sol
+++ b/solidity/test/token/HypERC721.t.sol
@@ -17,6 +17,7 @@ import "forge-std/Test.sol";
import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import {ERC721URIStorageUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol";
+import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {TestMailbox} from "../../contracts/test/TestMailbox.sol";
import {TestPostDispatchHook} from "../../contracts/test/TestPostDispatchHook.sol";
@@ -141,7 +142,7 @@ abstract contract HypTokenTest is Test, IERC721Receiver {
);
_processTransfers(BOB, _tokenId);
- assertEq(remoteToken.balanceOf(BOB), 1);
+ assertEq(IERC721(remoteToken.token()).balanceOf(BOB), 1);
}
function testBenchmark_overheadGasUsage() public {
@@ -340,7 +341,7 @@ contract HypERC721CollateralTest is HypTokenTest {
_deployRemoteToken(isCollateral);
_performRemoteTransfer(25000, 0);
assertEq(
- hyp721Collateral.balanceOf(address(this)),
+ localPrimaryToken.balanceOf(address(this)),
INITIAL_SUPPLY * 2 - 2
);
}
@@ -352,7 +353,7 @@ contract HypERC721CollateralTest is HypTokenTest {
vm.expectRevert("ERC721: caller is not token owner or approved");
_performRemoteTransfer(25000, 1);
assertEq(
- hyp721Collateral.balanceOf(address(this)),
+ localPrimaryToken.balanceOf(address(this)),
INITIAL_SUPPLY * 2 - 2
);
}
@@ -362,7 +363,7 @@ contract HypERC721CollateralTest is HypTokenTest {
vm.expectRevert("ERC721: invalid token ID");
_performRemoteTransfer(25000, INITIAL_SUPPLY * 2);
assertEq(
- hyp721Collateral.balanceOf(address(this)),
+ localPrimaryToken.balanceOf(address(this)),
INITIAL_SUPPLY * 2 - 1
);
}
@@ -417,9 +418,9 @@ contract HypERC721CollateralURIStorageTest is HypTokenTest {
);
_processTransfers(BOB, 0);
- assertEq(remoteToken.balanceOf(BOB), 1);
+ assertEq(IERC721(address(remoteToken)).balanceOf(BOB), 1);
assertEq(
- hyp721URICollateral.balanceOf(address(this)),
+ localPrimaryToken.balanceOf(address(this)),
INITIAL_SUPPLY * 2 - 2
);
}
diff --git a/solidity/test/token/HypNativeLp.t.sol b/solidity/test/token/HypNativeLp.t.sol
new file mode 100644
index 0000000000..90a5c7213d
--- /dev/null
+++ b/solidity/test/token/HypNativeLp.t.sol
@@ -0,0 +1,213 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0;
+
+import "forge-std/Test.sol";
+import {HypNative} from "../../contracts/token/HypNative.sol";
+import {MockMailbox} from "../../contracts/mock/MockMailbox.sol";
+
+contract HypNativeLpTest is Test {
+ event Donation(address sender, uint256 amount);
+ event Deposit(
+ address indexed sender,
+ address indexed owner,
+ uint256 assets,
+ uint256 shares
+ );
+ event Withdraw(
+ address indexed sender,
+ address indexed receiver,
+ address indexed owner,
+ uint256 assets,
+ uint256 shares
+ );
+
+ HypNative internal router;
+ address internal alice = address(0x1);
+ address internal bob = address(0x2);
+ uint256 internal constant DEPOSIT_AMOUNT = 100e18;
+ uint256 internal constant DONATE_AMOUNT = 50e18;
+
+ function setUp() public {
+ MockMailbox mailbox = new MockMailbox(1);
+ router = new HypNative(1, address(mailbox));
+ router.initialize(address(0), address(0), address(this));
+
+ vm.label(alice, "Alice");
+ vm.label(bob, "Bob");
+ vm.deal(alice, 1000e18);
+ vm.deal(bob, 1000e18);
+ }
+
+ function testDepositIncreasesBalances() public {
+ uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT);
+ vm.prank(alice);
+ router.deposit{value: DEPOSIT_AMOUNT}(alice);
+ assertEq(router.balanceOf(alice), shares);
+ assertEq(router.totalAssets(), DEPOSIT_AMOUNT);
+ }
+
+ function testDepositEmitsEvent() public {
+ uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT);
+ vm.expectEmit(true, true, true, true);
+ emit Deposit(alice, alice, DEPOSIT_AMOUNT, shares);
+ vm.prank(alice);
+ router.deposit{value: DEPOSIT_AMOUNT}(alice);
+ }
+
+ function testDepositWithZeroValue() public {
+ vm.prank(alice);
+ uint256 shares = router.deposit(alice);
+ assertEq(shares, 0);
+ assertEq(router.balanceOf(alice), 0);
+ }
+
+ function testDepositToReceiverCreditsCorrectAccount() public {
+ uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT);
+ vm.prank(alice);
+ router.deposit{value: DEPOSIT_AMOUNT}(bob);
+ assertEq(router.balanceOf(bob), shares);
+ assertEq(router.balanceOf(alice), 0);
+ }
+
+ function testWithdrawDecreasesBalances() public {
+ uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT);
+ vm.prank(alice);
+ router.deposit{value: DEPOSIT_AMOUNT}(alice);
+ vm.prank(alice);
+ router.withdraw(DEPOSIT_AMOUNT, bob, alice);
+ assertEq(router.balanceOf(alice), 0);
+ assertEq(router.totalAssets(), 0);
+ assertEq(bob.balance, 1000e18 + DEPOSIT_AMOUNT);
+ }
+
+ function testWithdrawEmitsEvent() public {
+ vm.prank(alice);
+ router.deposit{value: DEPOSIT_AMOUNT}(alice);
+ uint256 shares = router.balanceOf(alice);
+ vm.expectEmit(true, true, true, true);
+ emit Withdraw(alice, bob, alice, DEPOSIT_AMOUNT, shares);
+ vm.prank(alice);
+ router.withdraw(DEPOSIT_AMOUNT, bob, alice);
+ }
+
+ function testTotalSupplyTracksShares() public {
+ assertEq(router.totalSupply(), 0);
+ vm.prank(alice);
+ router.deposit{value: DEPOSIT_AMOUNT}(alice);
+ assertEq(router.totalSupply(), router.balanceOf(alice));
+ }
+
+ function testTotalAssetsTracksDepositsAndWithdrawals() public {
+ assertEq(router.totalAssets(), 0);
+ vm.prank(alice);
+ router.deposit{value: DEPOSIT_AMOUNT}(alice);
+ assertEq(router.totalAssets(), DEPOSIT_AMOUNT);
+ vm.prank(alice);
+ router.withdraw(DEPOSIT_AMOUNT, bob, alice);
+ assertEq(router.totalAssets(), 0);
+ }
+
+ function testDonateIncreasesTotalAssets() public {
+ assertEq(router.totalAssets(), 0);
+ vm.prank(alice);
+ router.donate{value: DONATE_AMOUNT}(DONATE_AMOUNT);
+ assertEq(router.totalAssets(), DONATE_AMOUNT);
+ }
+
+ function testDonateEmitsEvent() public {
+ vm.expectEmit(true, true, true, true);
+ emit Donation(alice, DONATE_AMOUNT);
+ vm.prank(alice);
+ router.donate{value: DONATE_AMOUNT}(DONATE_AMOUNT);
+ }
+
+ function testDonateIsNotWithdrawable() public {
+ vm.prank(alice);
+ router.donate{value: DONATE_AMOUNT}(DONATE_AMOUNT);
+ vm.prank(alice);
+ vm.expectRevert();
+ router.withdraw(DONATE_AMOUNT, bob, alice);
+ }
+
+ function testWithdrawMoreThanBalanceReverts() public {
+ vm.prank(alice);
+ router.deposit{value: DEPOSIT_AMOUNT}(alice);
+ vm.prank(alice);
+ vm.expectRevert();
+ router.withdraw(DEPOSIT_AMOUNT + 1, bob, alice);
+ }
+
+ function testDonateDistributesToAllHolders() public {
+ uint256 aliceDeposit = 100e18;
+ uint256 bobDeposit = 200e18;
+ uint256 donation = DONATE_AMOUNT;
+
+ // Alice deposits
+ vm.prank(alice);
+ router.deposit{value: aliceDeposit}(alice);
+
+ // Bob deposits
+ vm.prank(bob);
+ router.deposit{value: bobDeposit}(bob);
+
+ // Record balances before donation
+ uint256 aliceWithdrawBefore = router.maxWithdraw(alice);
+ uint256 bobWithdrawBefore = router.maxWithdraw(bob);
+
+ // Donate to the vault
+ vm.deal(address(this), donation);
+ router.donate{value: donation}(donation);
+
+ // After donation, both should be able to withdraw more
+ assertGt(router.maxWithdraw(alice), aliceWithdrawBefore);
+ assertGt(router.maxWithdraw(bob), bobWithdrawBefore);
+
+ // Alice should get 1/3 of donation, Bob should get 2/3
+ assertEq(router.maxWithdraw(alice), aliceDeposit + donation / 3);
+ assertEq(router.maxWithdraw(bob), bobDeposit + (donation * 2) / 3);
+ }
+
+ function testMultipleDepositsAndWithdrawals() public {
+ // Alice deposits
+ vm.prank(alice);
+ uint256 aliceShares = router.deposit{value: DEPOSIT_AMOUNT}(alice);
+
+ // Bob deposits
+ vm.prank(bob);
+ uint256 bobShares = router.deposit{value: DEPOSIT_AMOUNT * 2}(bob);
+
+ assertEq(router.totalAssets(), DEPOSIT_AMOUNT * 3);
+ assertEq(router.totalSupply(), aliceShares + bobShares);
+
+ // Alice withdraws half
+ vm.prank(alice);
+ router.withdraw(DEPOSIT_AMOUNT / 2, alice, alice);
+
+ assertEq(router.totalAssets(), DEPOSIT_AMOUNT * 3 - DEPOSIT_AMOUNT / 2);
+ assertEq(alice.balance, 1000e18 - DEPOSIT_AMOUNT + DEPOSIT_AMOUNT / 2);
+
+ // Bob withdraws all
+ uint256 bobMaxWithdraw = router.maxWithdraw(bob);
+ vm.prank(bob);
+ router.withdraw(bobMaxWithdraw, bob, bob);
+
+ assertEq(bob.balance, 1000e18);
+ }
+
+ function testDepositAfterDonationGetsCorrectShares() public {
+ // Alice deposits initially
+ vm.prank(alice);
+ uint256 aliceShares = router.deposit{value: DEPOSIT_AMOUNT}(alice);
+
+ // Someone donates
+ vm.deal(address(this), DONATE_AMOUNT);
+ router.donate{value: DONATE_AMOUNT}(DONATE_AMOUNT);
+
+ // Bob deposits same amount
+ vm.prank(bob);
+ uint256 bobShares = router.deposit{value: DEPOSIT_AMOUNT}(bob);
+
+ // Bob should get fewer shares since totalAssets increased from donation
+ assertLt(bobShares, aliceShares);
+ }
+}
diff --git a/solidity/test/token/HypnativeMovable.t.sol b/solidity/test/token/HypnativeMovable.t.sol
index 47d1ecd167..8e9328a174 100644
--- a/solidity/test/token/HypnativeMovable.t.sol
+++ b/solidity/test/token/HypnativeMovable.t.sol
@@ -1,16 +1,34 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity ^0.8.13;
-import {ValueTransferBridge, Quote} from "contracts/token/interfaces/ValueTransferBridge.sol";
+import {ITokenBridge, Quote} from "contracts/interfaces/ITokenBridge.sol";
import {HypNative} from "contracts/token/HypNative.sol";
+import {MockITokenBridge} from "./MovableCollateralRouter.t.sol";
import {ERC20Test} from "../../contracts/test/ERC20Test.sol";
import {MockMailbox} from "contracts/mock/MockMailbox.sol";
+import {LinearFee} from "contracts/token/fees/LinearFee.sol";
import "forge-std/Test.sol";
-contract MockValueTransferBridgeEth is ValueTransferBridge {
- constructor() {}
+contract MockITokenBridgeEth is ITokenBridge {
+ uint256 public quoteLength;
+ address public quoteToken;
+ uint256 public quoteAmount;
+
+ constructor() {
+ quoteLength = 0;
+ }
+
+ function setQuote(
+ uint256 _length,
+ address _token,
+ uint256 _amount
+ ) external {
+ quoteLength = _length;
+ quoteToken = _token;
+ quoteAmount = _amount;
+ }
function transferRemote(
uint32 destinationDomain,
@@ -25,30 +43,44 @@ contract MockValueTransferBridgeEth is ValueTransferBridge {
bytes32 recipient,
uint256 amountOut
) external view override returns (Quote[] memory) {
- return new Quote[](0);
+ Quote[] memory quotes = new Quote[](quoteLength);
+ if (quoteLength == 1) {
+ quotes[0] = Quote({token: quoteToken, amount: quoteAmount});
+ } else if (quoteLength > 1) {
+ // Return multiple quotes for testing
+ quotes[0] = Quote({token: quoteToken, amount: quoteAmount});
+ quotes[1] = Quote({token: address(0), amount: 100});
+ }
+ return quotes;
}
}
contract HypNativeMovableTest is Test {
HypNative internal router;
- MockValueTransferBridgeEth internal vtb;
+ HypNative internal vtb;
ERC20Test internal token;
uint32 internal constant destinationDomain = 2;
address internal constant alice = address(1);
function setUp() public {
token = new ERC20Test("Foo Token", "FT", 1_000_000e18, 18);
- router = new HypNative(1e18, address(new MockMailbox(uint32(1))));
+ address mailbox = address(new MockMailbox(uint32(1)));
+ MockMailbox(mailbox).addRemoteMailbox(
+ destinationDomain,
+ MockMailbox(mailbox)
+ );
+ router = new HypNative(1, mailbox);
// Initialize the router -> we are the admin
router.initialize(address(0), address(0), address(this));
router.enrollRemoteRouter(
destinationDomain,
bytes32(uint256(uint160(0)))
);
- vtb = new MockValueTransferBridgeEth();
+ vtb = new HypNative(1, mailbox);
+ vtb.enrollRemoteRouter(destinationDomain, bytes32(uint256(uint160(0))));
}
- function testMovingCollateral() public {
+ function test_rebalance() public {
// Configuration
router.addRebalancer(address(this));
@@ -78,7 +110,119 @@ contract HypNativeMovableTest is Test {
bytes32(uint256(uint160(alice)))
);
router.addBridge(destinationDomain, vtb);
- vm.expectRevert("Native: rebalance amount exceeds balance");
+ vm.expectRevert("Rebalance native fee exceeds balance");
router.rebalance(destinationDomain, 1 ether, vtb);
}
+
+ function test_rebalance_cannotUndercollateralize(
+ uint96 fee,
+ uint96 amount,
+ uint96 balance
+ ) public {
+ vm.assume(balance > 2);
+ amount = uint96(bound(uint256(amount), 2, uint256(balance)));
+ fee = uint96(bound(uint256(fee), 1, uint256(amount)));
+
+ vtb.setFeeRecipient(
+ address(new LinearFee(address(0), fee, amount / 2, address(this)))
+ );
+
+ router.addRebalancer(address(this));
+ router.addBridge(destinationDomain, vtb);
+
+ deal(address(router), balance);
+ deal(address(this), fee);
+
+ router.rebalance{value: fee}(destinationDomain, amount, vtb);
+ assertEq(address(router).balance, balance - amount);
+ assertEq(address(vtb).balance, amount);
+ }
+
+ function test_setFeeRecipient_cannotSetToSelf() public {
+ vm.expectRevert("Fee recipient cannot be self");
+ router.setFeeRecipient(address(router));
+ }
+
+ function test_setFeeRecipient_canSetToOtherAddress() public {
+ address feeRecipient = address(0x123);
+ router.setFeeRecipient(feeRecipient);
+ assertEq(router.feeRecipient(), feeRecipient);
+ }
+
+ function test_setFeeRecipient_canSetToZeroAddress() public {
+ router.setFeeRecipient(address(0x123));
+ assertEq(router.feeRecipient(), address(0x123));
+
+ router.setFeeRecipient(address(0));
+ assertEq(router.feeRecipient(), address(0));
+ }
+
+ function test_feeRecipient_emptyQuotesReturnsZero() public {
+ MockITokenBridgeEth mockFeeRecipient = new MockITokenBridgeEth();
+ // Set to return empty quotes (length 0)
+ mockFeeRecipient.setQuote(0, address(0), 0);
+
+ router.setFeeRecipient(address(mockFeeRecipient));
+
+ // Should not revert and return 0 fee
+ Quote[] memory quotes = router.quoteTransferRemote(
+ destinationDomain,
+ bytes32(uint256(uint160(alice))),
+ 1 ether
+ );
+
+ // quotes[1] is the internal fee (amount + fee)
+ assertEq(quotes[1].amount, 1 ether); // no fee added
+ }
+
+ function test_feeRecipient_multipleQuotesReverts() public {
+ MockITokenBridgeEth mockFeeRecipient = new MockITokenBridgeEth();
+ // Set to return 2 quotes (invalid)
+ mockFeeRecipient.setQuote(2, address(0), 0.1 ether);
+
+ router.setFeeRecipient(address(mockFeeRecipient));
+
+ // Should revert with the fee mismatch error
+ vm.expectRevert("FungibleTokenRouter: fee must match token");
+ router.quoteTransferRemote(
+ destinationDomain,
+ bytes32(uint256(uint160(alice))),
+ 1 ether
+ );
+ }
+
+ function test_feeRecipient_wrongTokenReverts() public {
+ MockITokenBridgeEth mockFeeRecipient = new MockITokenBridgeEth();
+ // Set to return 1 quote but with wrong token (not address(0) which is the native token)
+ address wrongToken = address(0x456);
+ mockFeeRecipient.setQuote(1, wrongToken, 0.1 ether);
+
+ router.setFeeRecipient(address(mockFeeRecipient));
+
+ // Should revert with the fee mismatch error
+ vm.expectRevert("FungibleTokenRouter: fee must match token");
+ router.quoteTransferRemote(
+ destinationDomain,
+ bytes32(uint256(uint160(alice))),
+ 1 ether
+ );
+ }
+
+ function test_feeRecipient_correctTokenSucceeds() public {
+ MockITokenBridgeEth mockFeeRecipient = new MockITokenBridgeEth();
+ // Set to return 1 quote with correct token (address(0) for native)
+ mockFeeRecipient.setQuote(1, address(0), 0.1 ether);
+
+ router.setFeeRecipient(address(mockFeeRecipient));
+
+ // Should succeed and return correct fee
+ Quote[] memory quotes = router.quoteTransferRemote(
+ destinationDomain,
+ bytes32(uint256(uint160(alice))),
+ 1 ether
+ );
+
+ // quotes[1] is the internal fee (amount + fee)
+ assertEq(quotes[1].amount, 1.1 ether); // 1 ether + 0.1 ether fee
+ }
}
diff --git a/solidity/test/token/LpCollateralRouter.t.sol b/solidity/test/token/LpCollateralRouter.t.sol
new file mode 100644
index 0000000000..642dc2e76f
--- /dev/null
+++ b/solidity/test/token/LpCollateralRouter.t.sol
@@ -0,0 +1,124 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0;
+
+import "forge-std/Test.sol";
+import {TestLpCollateralRouter} from "../../contracts/test/TestLpCollateralRouter.sol";
+import {MockMailbox} from "../../contracts/mock/MockMailbox.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+
+contract LpCollateralRouterTest is Test {
+ event Donation(address sender, uint256 amount);
+
+ TestLpCollateralRouter internal router;
+ address internal alice = address(0x1);
+ address internal bob = address(0x2);
+ uint256 internal constant DEPOSIT_AMOUNT = 100e18;
+ uint256 internal constant DONATE_AMOUNT = 50e18;
+
+ function setUp() public {
+ MockMailbox mailbox = new MockMailbox(1);
+ router = new TestLpCollateralRouter(1, address(mailbox));
+ vm.label(alice, "Alice");
+ vm.label(bob, "Bob");
+ }
+
+ function testDepositIncreasesBalances() public {
+ uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT);
+ vm.prank(alice);
+ router.deposit(DEPOSIT_AMOUNT, alice);
+ assertEq(router.balanceOf(alice), shares);
+ assertEq(router.totalAssets(), DEPOSIT_AMOUNT);
+ }
+
+ function testWithdrawDecreasesBalances() public {
+ uint256 shares = router.previewDeposit(DEPOSIT_AMOUNT);
+ vm.prank(alice);
+ router.deposit(DEPOSIT_AMOUNT, alice);
+ vm.prank(alice);
+ router.withdraw(DEPOSIT_AMOUNT, bob, alice);
+ assertEq(router.balanceOf(alice), 0);
+ assertEq(router.totalAssets(), 0);
+ }
+
+ function testTotalSupplyTracksShares() public {
+ assertEq(router.totalSupply(), 0);
+ vm.prank(alice);
+ router.deposit(DEPOSIT_AMOUNT, alice);
+ assertEq(router.totalSupply(), router.balanceOf(alice));
+ }
+
+ function testTotalAssetsTracksDepositsAndWithdrawals() public {
+ assertEq(router.totalAssets(), 0);
+ vm.prank(alice);
+ router.deposit(DEPOSIT_AMOUNT, alice);
+ assertEq(router.totalAssets(), DEPOSIT_AMOUNT);
+ vm.prank(alice);
+ router.withdraw(DEPOSIT_AMOUNT, bob, alice);
+ assertEq(router.totalAssets(), 0);
+ }
+
+ function testDonateIncreasesTotalAssets() public {
+ assertEq(router.totalAssets(), 0);
+ vm.prank(alice);
+ router.donate(DONATE_AMOUNT);
+ assertEq(router.totalAssets(), DONATE_AMOUNT);
+ }
+
+ function testDonateEmitsEvent() public {
+ vm.expectEmit(true, true, true, true);
+ emit Donation(alice, DONATE_AMOUNT);
+ vm.prank(alice);
+ router.donate(DONATE_AMOUNT);
+ }
+
+ function testDonateIsNotWithdrawable() public {
+ vm.prank(alice);
+ router.donate(DONATE_AMOUNT);
+ vm.prank(alice);
+ vm.expectRevert();
+ router.withdraw(DONATE_AMOUNT, bob, alice);
+ }
+
+ function testWithdrawMoreThanBalanceReverts() public {
+ vm.prank(alice);
+ router.deposit(DEPOSIT_AMOUNT, alice);
+ vm.prank(alice);
+ vm.expectRevert();
+ router.withdraw(DEPOSIT_AMOUNT + 1, bob, alice);
+ }
+
+ function testDonateDistributesToAllHolders(
+ uint8 aliceFactor,
+ uint8 bobFactor
+ ) public {
+ aliceFactor = uint8(bound(aliceFactor, 1, 100));
+ bobFactor = uint8(bound(bobFactor, 1, 100));
+
+ uint256 aliceDeposit = aliceFactor * DEPOSIT_AMOUNT;
+ uint256 bobDeposit = bobFactor * DEPOSIT_AMOUNT;
+ uint256 donation = DONATE_AMOUNT;
+
+ // Alice deposits
+ vm.prank(alice);
+ uint256 aliceShares = router.deposit(aliceDeposit, alice);
+
+ // Bob deposits
+ vm.prank(bob);
+ uint256 bobShares = router.deposit(bobDeposit, bob);
+
+ // Donate to the vault
+ router.donate(donation);
+
+ uint256 totalShares = aliceShares + bobShares;
+ uint256 aliceDonation = (donation * aliceShares) / totalShares;
+ uint256 bobDonation = (donation * bobShares) / totalShares;
+
+ // account for rounding errors
+ assertApproxEqAbs(
+ router.maxWithdraw(alice),
+ aliceShares + aliceDonation,
+ 1
+ );
+ assertApproxEqAbs(router.maxWithdraw(bob), bobShares + bobDonation, 1);
+ }
+}
diff --git a/solidity/test/token/MovableCollateralRouter.t.sol b/solidity/test/token/MovableCollateralRouter.t.sol
index 0dec4583dd..954e47d0fc 100644
--- a/solidity/test/token/MovableCollateralRouter.t.sol
+++ b/solidity/test/token/MovableCollateralRouter.t.sol
@@ -3,44 +3,41 @@ pragma solidity ^0.8.13;
import {ERC20Test} from "../../contracts/test/ERC20Test.sol";
import {MovableCollateralRouter} from "contracts/token/libs/MovableCollateralRouter.sol";
-import {ValueTransferBridge, Quote} from "contracts/token/interfaces/ValueTransferBridge.sol";
+import {ITokenBridge, Quote} from "contracts/interfaces/ITokenBridge.sol";
import {MockMailbox} from "contracts/mock/MockMailbox.sol";
import {Router} from "contracts/client/Router.sol";
-import {FungibleTokenRouter} from "contracts/token/libs/FungibleTokenRouter.sol";
+import {TokenRouter} from "contracts/token/libs/TokenRouter.sol";
import {TypeCasts} from "contracts/libs/TypeCasts.sol";
+import {Quotes} from "contracts/token/libs/Quotes.sol";
import "forge-std/Test.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
contract MockMovableCollateralRouter is MovableCollateralRouter {
- constructor(address _mailbox) FungibleTokenRouter(1, _mailbox) {}
+ uint256 public chargedToRebalancer;
+ address _token;
- function balanceOf(
- address _account
- ) external view override returns (uint256) {
- return 0;
+ constructor(address _mailbox, address __token) TokenRouter(1, _mailbox) {
+ _token = __token;
}
- function _transferFromSender(
- uint256 _amount
- ) internal override returns (bytes memory) {}
-
- function _transferTo(
- address _to,
- uint256 _amount,
- bytes calldata _metadata
- ) internal override {}
-
- function _handle(
- uint32 _origin,
- bytes32 _sender,
- bytes calldata _message
- ) internal override {}
+ function token() public view override returns (address) {
+ return _token;
+ }
+
+ function _transferFromSender(uint256 _amount) internal override {
+ chargedToRebalancer = _amount;
+ }
+
+ function _transferTo(address _to, uint256 _amount) internal override {}
}
-contract MockValueTransferBridge is ValueTransferBridge {
+contract MockITokenBridge is ITokenBridge {
+ using TypeCasts for bytes32;
+
ERC20Test token;
- bytes32 public myRecipient;
+ uint256 collateralFee;
+ uint256 nativeFee;
constructor(ERC20Test _token) {
token = _token;
@@ -51,25 +48,41 @@ contract MockValueTransferBridge is ValueTransferBridge {
bytes32 recipient,
uint256 amountOut
) external payable override returns (bytes32 transferId) {
- token.transferFrom(msg.sender, address(this), amountOut);
- myRecipient = recipient;
+ require(msg.value >= nativeFee);
+ token.transferFrom(
+ msg.sender,
+ address(this),
+ amountOut + collateralFee
+ );
return recipient;
}
+ function setCollateralFee(uint256 _fee) public {
+ collateralFee = _fee;
+ }
+
+ function setNativeFee(uint256 _fee) public {
+ nativeFee = _fee;
+ }
+
function quoteTransferRemote(
uint32 destinationDomain,
bytes32 recipient,
uint256 amountOut
) public view override returns (Quote[] memory) {
- return new Quote[](0);
+ Quote[] memory quotes = new Quote[](2);
+ quotes[0] = Quote(address(0), nativeFee);
+ quotes[1] = Quote(address(token), amountOut + collateralFee);
+ return quotes;
}
}
contract MovableCollateralRouterTest is Test {
using TypeCasts for address;
+ using Quotes for Quote[];
- MovableCollateralRouter internal router;
- MockValueTransferBridge internal vtb;
+ MockMovableCollateralRouter internal router;
+ MockITokenBridge internal vtb;
ERC20Test internal token;
uint32 internal constant destinationDomain = 2;
address internal constant alice = address(1);
@@ -78,38 +91,55 @@ contract MovableCollateralRouterTest is Test {
function setUp() public {
mailbox = new MockMailbox(1);
- router = new MockMovableCollateralRouter(address(mailbox));
- token = new ERC20Test("Foo Token", "FT", 1_000_000e18, 18);
- vtb = new MockValueTransferBridge(token);
+ token = new ERC20Test("Foo Token", "FT", 0, 18);
+ router = new MockMovableCollateralRouter(
+ address(mailbox),
+ address(token)
+ );
+ vtb = new MockITokenBridge(token);
remote = vm.addr(10);
-
router.enrollRemoteRouter(destinationDomain, remote.addressToBytes32());
}
- function testMovingCollateral() public {
- router.addRebalancer(address(this));
+ function test_rebalance(
+ uint256 collateralBalance,
+ uint256 collateralAmount,
+ uint256 collateralFee,
+ uint256 nativeFee
+ ) public {
+ vm.assume(collateralBalance < type(uint256).max / 3);
+ collateralAmount = bound(collateralAmount, 0, collateralBalance);
+ collateralFee = bound(collateralFee, 0, collateralAmount);
- // Configuration
- // Add the destination domain
- router.setRecipient(
- destinationDomain,
- bytes32(uint256(uint160(alice)))
- );
-
- // Add the given bridge
- router.addBridge(destinationDomain, vtb);
+ router.addRebalancer(address(this));
// Setup
- token.mintTo(address(router), 1e18);
+ token.mintTo(address(router), collateralBalance + collateralFee);
+ router.addBridge(destinationDomain, vtb);
vm.prank(address(router));
- token.approve(address(vtb), 1e18);
+ token.approve(address(vtb), type(uint256).max);
+
+ vtb.setCollateralFee(collateralFee);
+ vtb.setNativeFee(nativeFee);
+ vm.deal(address(this), nativeFee);
// Execute
- router.rebalance(destinationDomain, 1e18, vtb);
- // Assert
- assertEq(token.balanceOf(address(router)), 0);
- assertEq(token.balanceOf(address(vtb)), 1e18);
+ vm.expectCall(
+ address(vtb),
+ nativeFee,
+ abi.encodeCall(
+ ITokenBridge.transferRemote,
+ (destinationDomain, remote.addressToBytes32(), collateralAmount)
+ )
+ );
+ router.rebalance{value: nativeFee}(
+ destinationDomain,
+ collateralAmount,
+ vtb
+ );
+
+ assertEq(router.chargedToRebalancer(), collateralFee);
}
function testBadRebalancer() public {
diff --git a/solidity/test/token/OPL2ToL1TokenBridgeNative.t.sol b/solidity/test/token/OPL2ToL1TokenBridgeNative.t.sol
index 0f41cc9c76..ce06295acb 100644
--- a/solidity/test/token/OPL2ToL1TokenBridgeNative.t.sol
+++ b/solidity/test/token/OPL2ToL1TokenBridgeNative.t.sol
@@ -183,7 +183,9 @@ contract OPL2ToL1TokenBridgeNativeTest is Test {
function test_transferRemote_fundsReceived() public {
Quote[] memory quotes = _getQuote();
- vtbOrigin.transferRemote{value: quotes[0].amount}(
+ uint256 value = quotes[0].amount + quotes[1].amount + quotes[2].amount;
+
+ vtbOrigin.transferRemote{value: value}(
destination,
userB32,
transferAmount
@@ -215,9 +217,11 @@ contract OPL2ToL1TokenBridgeNativeTest is Test {
function test_transferRemote_refunds() public {
Quote[] memory quotes = _getQuote();
+ uint256 value = quotes[0].amount + quotes[1].amount + quotes[2].amount;
+
uint256 balanceBefore = address(this).balance;
- vtbOrigin.transferRemote{value: 2 * quotes[0].amount}(
+ vtbOrigin.transferRemote{value: 2 * value}(
destination,
userB32,
transferAmount
@@ -225,7 +229,7 @@ contract OPL2ToL1TokenBridgeNativeTest is Test {
uint256 balanceAfter = address(this).balance;
- assertEq(balanceBefore - balanceAfter, quotes[0].amount);
+ assertEq(balanceBefore - balanceAfter, value);
}
function test_interchainSecurityModule_returnsConfiguredIsm() public {
@@ -249,35 +253,4 @@ contract OPL2ToL1TokenBridgeNativeTest is Test {
// Call handle with dummy values as it should revert before using them.
vtbOrigin.handle(destination, userB32, bytes(""));
}
-
- function test_OpL2_transferRemote_revertsIfRefundAddressIsZero() public {
- // 1. Craft metadata that specifies address(0) for refunds.
- // The msgValue and gasLimit here are for the StandardHookMetadata format,
- // but their specific values don't affect the refund address retrieval part we're testing.
- bytes memory zeroRefundMetadata = StandardHookMetadata.format(
- 0, // msgValue for hook
- 0, // gasLimit for hook
- address(0) // explicit zero refund address
- );
-
- // 2. Determine the value needed for the transfer operation itself.
- // This quote includes the amount to bridge and the gas for two internal messages.
- uint256 internalOpsValue = vtbOrigin
- .quoteTransferRemote(destination, userB32, transferAmount)[0].amount;
-
- // 3. Send a bit more than required, so there's something left to refund.
- uint256 valueToSend = internalOpsValue + 1 wei; // 1 wei to be refunded
-
- // 4. Expect a revert from the explicit check in OpL2NativeTokenBridge.
- vm.expectRevert(bytes("OP L2 token bridge: refund address is 0"));
-
- // 5. Call the transferRemote function that accepts custom metadata.
- vtbOrigin.transferRemote{value: valueToSend}(
- destination,
- userB32,
- transferAmount,
- zeroRefundMetadata, // Our crafted metadata with address(0) as refund target
- address(igp) // Use the standard IGP for gas payments
- );
- }
}
diff --git a/solidity/test/token/TokenBridgeCctp.t.sol b/solidity/test/token/TokenBridgeCctp.t.sol
index cf69ebeec1..c097a1d72a 100644
--- a/solidity/test/token/TokenBridgeCctp.t.sol
+++ b/solidity/test/token/TokenBridgeCctp.t.sol
@@ -6,31 +6,44 @@ import "forge-std/StdCheats.sol";
import {MockToken} from "../../contracts/mock/MockToken.sol";
import {TypeCasts} from "../../contracts/libs/TypeCasts.sol";
-import {TokenBridgeCctp} from "../../contracts/token/TokenBridgeCctp.sol";
+import {TokenBridgeCctpV1} from "../../contracts/token/TokenBridgeCctpV1.sol";
+import {TokenBridgeCctpV2} from "../../contracts/token/TokenBridgeCctpV2.sol";
import {MockHyperlaneEnvironment} from "../../contracts/mock/MockHyperlaneEnvironment.sol";
import {MockCircleMessageTransmitter} from "../../contracts/mock/MockCircleMessageTransmitter.sol";
-import {MockCircleTokenMessenger, MockCircleTokenMessengerV2} from "../../contracts/mock/MockCircleTokenMessenger.sol";
+import {MockCircleTokenMessenger} from "../../contracts/mock/MockCircleTokenMessenger.sol";
import {TestInterchainGasPaymaster} from "../../contracts/test/TestInterchainGasPaymaster.sol";
import {MockMailbox} from "../../contracts/mock/MockMailbox.sol";
import {Quote} from "../../contracts/interfaces/ITokenBridge.sol";
import {ICcipReadIsm} from "../../contracts/interfaces/isms/ICcipReadIsm.sol";
-import {IMessageTransmitter} from "../../contracts/interfaces/cctp/IMessageTransmitter.sol";
-import {ITokenMessenger} from "../../contracts/interfaces/cctp/ITokenMessenger.sol";
+import {IMessageTransmitter, IRelayer} from "../../contracts/interfaces/cctp/IMessageTransmitter.sol";
+import {IMessageTransmitterV2, IRelayerV2} from "../../contracts/interfaces/cctp/IMessageTransmitterV2.sol";
+import {ITokenMessenger, ITokenMessengerV1} from "../../contracts/interfaces/cctp/ITokenMessenger.sol";
import {ITokenMessengerV2} from "../../contracts/interfaces/cctp/ITokenMessengerV2.sol";
import {TokenRouter} from "../../contracts/token/libs/TokenRouter.sol";
-import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
-import {CctpMessage, BurnMessage} from "../../contracts/libs/CctpMessage.sol";
+import {ITransparentUpgradeableProxy, TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
+import {CctpMessageV1, BurnMessageV1} from "../../contracts/libs/CctpMessageV1.sol";
+import {CctpMessageV2, BurnMessageV2} from "../../contracts/libs/CctpMessageV2.sol";
import {Message} from "../../contracts/libs/Message.sol";
-import {CctpService} from "../../contracts/token/TokenBridgeCctp.sol";
-
-contract TokenBridgeCctpTest is Test {
+import {TokenMessage} from "../../contracts/token/libs/TokenMessage.sol";
+import {CctpService} from "../../contracts/token/TokenBridgeCctpBase.sol";
+import {TestRecipient} from "../../contracts/test/TestRecipient.sol";
+import {TokenBridgeCctpBase} from "../../contracts/token/TokenBridgeCctpBase.sol";
+import {IMessageTransmitter} from "../../contracts/interfaces/cctp/IMessageTransmitter.sol";
+import {IMailbox} from "../../contracts/interfaces/IMailbox.sol";
+import {ISpecifiesInterchainSecurityModule} from "../../contracts/interfaces/IInterchainSecurityModule.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {LinearFee} from "../../contracts/token/fees/LinearFee.sol";
+import {IPostDispatchHook} from "../../contracts/interfaces/hooks/IPostDispatchHook.sol";
+import {StandardHookMetadata} from "../../contracts/hooks/libs/StandardHookMetadata.sol";
+
+contract TokenBridgeCctpV1Test is Test {
using TypeCasts for address;
+ using TypeCasts for bytes32;
using Message for bytes;
uint32 internal constant CCTP_VERSION_1 = 0;
uint32 internal constant CCTP_VERSION_2 = 1;
- uint256 internal constant scale = 1;
uint32 internal constant origin = 1;
uint32 internal constant destination = 2;
uint32 internal constant cctpOrigin = 0;
@@ -39,8 +52,8 @@ contract TokenBridgeCctpTest is Test {
TestInterchainGasPaymaster internal igpOrigin;
TestInterchainGasPaymaster internal igpDestination;
- TokenBridgeCctp internal tbOrigin;
- TokenBridgeCctp internal tbDestination;
+ TokenBridgeCctpBase internal tbOrigin;
+ TokenBridgeCctpBase internal tbDestination;
address internal proxyAdmin;
address internal evil = makeAddr("evil");
@@ -49,7 +62,7 @@ contract TokenBridgeCctpTest is Test {
MockToken internal tokenOrigin;
MockToken internal tokenDestination;
- uint32 internal version = 0; // CCTPv1
+ uint32 internal version = CCTP_VERSION_1;
uint256 internal amount = 1_000_000; // 1 USDC
address internal user = address(11);
uint256 internal balance = 10_000_000; // 10 USDC
@@ -98,12 +111,11 @@ contract TokenBridgeCctpTest is Test {
tokenDestination
);
- TokenBridgeCctp originImplementation = new TokenBridgeCctp(
+ TokenBridgeCctpV1 originImplementation = new TokenBridgeCctpV1(
address(tokenOrigin),
- scale,
address(mailboxOrigin),
- IMessageTransmitter(address(messageTransmitterOrigin)),
- ITokenMessenger(address(tokenMessengerOrigin))
+ messageTransmitterOrigin,
+ tokenMessengerOrigin
);
bytes memory initData = abi.encodeWithSignature(
@@ -117,14 +129,13 @@ contract TokenBridgeCctpTest is Test {
proxyAdmin,
initData
);
- tbOrigin = TokenBridgeCctp(address(proxyOrigin));
+ tbOrigin = TokenBridgeCctpV1(address(proxyOrigin));
- TokenBridgeCctp destinationImplementation = new TokenBridgeCctp(
+ TokenBridgeCctpV1 destinationImplementation = new TokenBridgeCctpV1(
address(tokenDestination),
- scale,
address(mailboxDestination),
- IMessageTransmitter(address(messageTransmitterDestination)),
- ITokenMessenger(address(tokenMessengerDestination))
+ messageTransmitterDestination,
+ tokenMessengerDestination
);
TransparentUpgradeableProxy proxyDestination = new TransparentUpgradeableProxy(
@@ -133,21 +144,21 @@ contract TokenBridgeCctpTest is Test {
initData
);
- tbDestination = TokenBridgeCctp(address(proxyDestination));
+ tbDestination = TokenBridgeCctpV1(address(proxyDestination));
_setupTokenBridgesCctp(tbOrigin, tbDestination);
vm.deal(user, 1 ether);
}
- function _encodeCctpMessage(
+ function _encodeCctpBurnMessage(
uint64 nonce,
uint32 sourceDomain,
bytes32 recipient,
uint256 amount
- ) internal view returns (bytes memory) {
+ ) internal view virtual returns (bytes memory) {
return
- _encodeCctpMessage(
+ _encodeCctpBurnMessage(
nonce,
sourceDomain,
recipient,
@@ -156,14 +167,14 @@ contract TokenBridgeCctpTest is Test {
);
}
- function _encodeCctpMessage(
+ function _encodeCctpBurnMessage(
uint64 nonce,
uint32 sourceDomain,
bytes32 recipient,
uint256 amount,
address sender
- ) internal view returns (bytes memory) {
- bytes memory burnMessage = BurnMessage._formatMessage(
+ ) internal view virtual returns (bytes memory) {
+ bytes memory burnMessage = BurnMessageV1._formatMessage(
version,
address(tokenOrigin).addressToBytes32(),
recipient,
@@ -171,18 +182,47 @@ contract TokenBridgeCctpTest is Test {
sender.addressToBytes32()
);
return
- CctpMessage._formatMessage(
+ CctpMessageV1._formatMessage(
version,
sourceDomain,
cctpDestination,
nonce,
- sender.addressToBytes32(),
- address(tbDestination).addressToBytes32(),
+ address(tokenMessengerOrigin).addressToBytes32(),
+ address(tokenMessengerDestination).addressToBytes32(),
bytes32(0),
burnMessage
);
}
+ function _encodeCctpHookMessage(
+ bytes32 sender,
+ bytes32 recipient,
+ bytes memory message
+ ) internal view virtual returns (bytes memory) {
+ return
+ CctpMessageV1._formatMessage(
+ version,
+ cctpOrigin,
+ cctpDestination,
+ tokenMessengerOrigin.nextNonce(),
+ sender,
+ recipient,
+ bytes32(0), // destinationCaller
+ message
+ );
+ }
+
+ function _encodeCctpHookMessage(
+ bytes memory message
+ ) internal view returns (bytes memory) {
+ return
+ _encodeCctpHookMessage(
+ address(tbOrigin).addressToBytes32(),
+ address(tbDestination).addressToBytes32(),
+ message
+ );
+ }
+
function _setupAndDispatch()
internal
returns (bytes memory message, uint64 cctpNonce, bytes32 recipient)
@@ -195,7 +235,11 @@ contract TokenBridgeCctpTest is Test {
);
vm.startPrank(user);
- tokenOrigin.approve(address(tbOrigin), quote[1].amount);
+ // approve internal and external fees
+ tokenOrigin.approve(
+ address(tbOrigin),
+ quote[1].amount + quote[2].amount
+ );
cctpNonce = tokenMessengerOrigin.nextNonce();
tbOrigin.transferRemote{value: quote[0].amount}(
@@ -219,47 +263,128 @@ contract TokenBridgeCctpTest is Test {
tbOrigin.addDomain(destination, cctpDestination);
}
- function test_quoteTransferRemote_getCorrectQuote() public {
+ function test_quoteTransferRemote_getCorrectQuote() public virtual {
Quote[] memory quotes = tbOrigin.quoteTransferRemote(
destination,
user.addressToBytes32(),
amount
);
- assertEq(quotes.length, 2);
+ assertEq(quotes.length, 3);
assertEq(quotes[0].token, address(0));
+ assertEq(
+ quotes[0].amount,
+ igpOrigin.quoteGasPayment(destination, gasLimit)
+ );
assertEq(quotes[1].token, address(tokenOrigin));
+ assertEq(quotes[1].amount, amount);
+ // external fee
+ assertEq(quotes[2].token, address(tokenOrigin));
+ assertEq(quotes[2].amount, 0);
}
- function test_transferRemoteCctp() public {
- Quote[] memory quote = tbOrigin.quoteTransferRemote(
+ function test_transferRemoteCctp() public virtual {
+ Quote[] memory quotes = tbOrigin.quoteTransferRemote(
destination,
user.addressToBytes32(),
amount
);
+ uint256 charge = quotes[1].amount + quotes[2].amount;
+
vm.startPrank(user);
- tokenOrigin.approve(address(tbOrigin), quote[1].amount);
+ tokenOrigin.approve(address(tbOrigin), charge);
uint64 cctpNonce = tokenMessengerOrigin.nextNonce();
vm.expectCall(
address(tokenMessengerOrigin),
abi.encodeCall(
- MockCircleTokenMessenger.depositForBurn,
+ ITokenMessengerV1.depositForBurn,
(
- amount,
+ charge,
cctpDestination,
user.addressToBytes32(),
address(tokenOrigin)
)
)
);
- tbOrigin.transferRemote{value: quote[0].amount}(
+ tbOrigin.transferRemote{value: quotes[0].amount}(
+ destination,
+ user.addressToBytes32(),
+ amount
+ );
+ }
+
+ function test_transferRemoteCctp_withFeeRecipient() public virtual {
+ LinearFee feeContract = new LinearFee(
+ address(tokenOrigin),
+ 1e6,
+ amount / 2,
+ address(this)
+ );
+ tbOrigin.setFeeRecipient(address(feeContract));
+ uint256 feeRecipientFee = feeContract
+ .quoteTransferRemote(destination, user.addressToBytes32(), amount)[0]
+ .amount;
+
+ Quote[] memory quotes = tbOrigin.quoteTransferRemote(
+ destination,
+ user.addressToBytes32(),
+ amount
+ );
+
+ uint256 charge = quotes[1].amount + quotes[2].amount;
+
+ vm.startPrank(user);
+ tokenOrigin.approve(address(tbOrigin), charge);
+
+ uint256 initialUserBalance = tokenOrigin.balanceOf(user);
+ uint256 initialFeeContractBalance = tokenOrigin.balanceOf(
+ address(feeContract)
+ );
+ uint256 initialBridgeBalance = tokenOrigin.balanceOf(address(tbOrigin));
+
+ uint64 cctpNonce = tokenMessengerOrigin.nextNonce();
+
+ vm.expectCall(
+ address(tokenMessengerOrigin),
+ abi.encodeCall(
+ ITokenMessengerV1.depositForBurn,
+ (
+ charge - feeRecipientFee,
+ cctpDestination,
+ user.addressToBytes32(),
+ address(tokenOrigin)
+ )
+ )
+ );
+ tbOrigin.transferRemote{value: quotes[0].amount}(
destination,
user.addressToBytes32(),
amount
);
+
+ // Verify fee recipient received the fee (tests the fix!)
+ assertEq(
+ tokenOrigin.balanceOf(address(feeContract)),
+ initialFeeContractBalance + feeRecipientFee,
+ "Fee contract should receive fee"
+ );
+
+ // Verify user was charged correctly
+ assertEq(
+ tokenOrigin.balanceOf(user),
+ initialUserBalance - charge,
+ "User should be charged transfer amount + fees"
+ );
+
+ // Verify bridge doesn't hold the fee
+ assertEq(
+ tokenOrigin.balanceOf(address(tbOrigin)),
+ initialBridgeBalance,
+ "Bridge should not hold fee recipient fee"
+ );
}
function test_verify() public {
@@ -272,7 +397,7 @@ contract TokenBridgeCctpTest is Test {
_expectOffChainLookUpRevert(message);
tbDestination.getOffchainVerifyInfo(message);
- bytes memory cctpMessage = _encodeCctpMessage(
+ bytes memory cctpMessage = _encodeCctpBurnMessage(
cctpNonce,
cctpOrigin,
recipient,
@@ -291,51 +416,45 @@ contract TokenBridgeCctpTest is Test {
assertEq(tbDestination.verify(metadata, message), true);
}
- function test_verify_revertsWhen_invalidNonce() public {
- (
- bytes memory message,
- uint64 cctpNonce,
- bytes32 recipient
- ) = _setupAndDispatch();
-
- // invalid nonce := nextNonce + 1
- uint64 badNonce = cctpNonce + 1;
- bytes memory cctpMessage = _encodeCctpMessage(
- badNonce,
- cctpOrigin,
- recipient,
- amount
+ function _upgrade(TokenBridgeCctpBase bridge) internal virtual {
+ TokenBridgeCctpV1 newImplementation = new TokenBridgeCctpV1(
+ address(bridge.wrappedToken()),
+ address(bridge.mailbox()),
+ bridge.messageTransmitter(),
+ ITokenMessengerV1(address(bridge.tokenMessenger()))
);
- bytes memory attestation = bytes("");
- bytes memory metadata = abi.encode(cctpMessage, attestation);
- vm.expectRevert(bytes("Invalid nonce"));
- tbDestination.verify(metadata, message);
+ bytes32 adminBytes = vm.load(
+ address(bridge),
+ bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1)
+ );
+ address admin = address(uint160(uint256(adminBytes)));
+ vm.prank(admin);
+ ITransparentUpgradeableProxy(address(bridge)).upgradeTo(
+ address(newImplementation)
+ );
}
- function test_verify_revertsWhen_invalidSourceDomain() public {
- (
- bytes memory message,
- uint64 cctpNonce,
- bytes32 recipient
- ) = _setupAndDispatch();
-
- // invalid source domain := destination
- uint32 badSourceDomain = cctpDestination;
- bytes memory cctpMessage = _encodeCctpMessage(
- cctpNonce,
- badSourceDomain,
- recipient,
- amount
+ function testFork_verify_upgrade() public virtual {
+ TokenBridgeCctpV1 recipient = TokenBridgeCctpV1(
+ 0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975
);
- bytes memory attestation = bytes("");
- bytes memory metadata = abi.encode(cctpMessage, attestation);
+ vm.createSelectFork(vm.rpcUrl("base"), 32_126_535);
- vm.expectRevert(bytes("Invalid source domain"));
- tbDestination.verify(metadata, message);
+ bytes
+ memory metadata = hex"0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000f80000000000000000000000060000000000044df3000000000000000000000000bd3fa81b58ba92a82136038b25adec7066af31550000000000000000000000001682ae6375c4e4a97e4b583bc394c861a46d8962000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000001547b13bd71126d92e93092cad07807eedb6fc260000000000000000000000000000000000000000000000000000000000000001000000000000000000000000edcbaa585fd0f80f20073f9958246476466205b8000000000000000000000000000000000000000000000000000000000000000000000000000000822828b6af83fc19fc0e46a6dc4470c93e02855175de1fc77e01858eefb8bc5c9140df500f482cbfa384bd1bf6a020cdb078788ff3eff1c7ead090ae93c2088c8b1c2e143054b1656ba072ebf83c30e1ea9929043be7a8fe28c087a32a285bd6a5310e48b26b46595143ed8ee71bbc49e9deceabd69d0802331188fa69309477d80e1c000000000000000000000000000000000000000000000000000000000000";
+ bytes
+ memory message = hex"0300016f5200000001000000000000000000000000edcbaa585fd0f80f20073f9958246476466205b8000021050000000000000000000000005c4afb7e23b1dc1b409dc1702f89c64527b259750000000000000000000000001547b13bd71126d92e93092cad07807eedb6fc2600000000000000000000000000000000000000000000000000000000000000010000000000044df3";
+
+ vm.expectRevert();
+ recipient.verify(metadata, message);
+
+ _upgrade(recipient);
+
+ assert(recipient.verify(metadata, message));
}
- function test_verify_revertsWhen_invalidAmount() public {
+ function test_verify_revertsWhen_invalidMintAmount() public {
(
bytes memory message,
uint64 cctpNonce,
@@ -344,7 +463,7 @@ contract TokenBridgeCctpTest is Test {
// invalid amount := amount + 1
uint256 badAmount = amount + 1;
- bytes memory cctpMessage = _encodeCctpMessage(
+ bytes memory cctpMessage = _encodeCctpBurnMessage(
cctpNonce,
cctpOrigin,
recipient,
@@ -353,16 +472,16 @@ contract TokenBridgeCctpTest is Test {
bytes memory attestation = bytes("");
bytes memory metadata = abi.encode(cctpMessage, attestation);
- vm.expectRevert(bytes("Invalid amount"));
+ vm.expectRevert(bytes("Invalid mint amount"));
tbDestination.verify(metadata, message);
}
- function test_verify_revertsWhen_invalidRecipient() public {
+ function test_verify_revertsWhen_invalidMintRecipient() public {
(bytes memory message, uint64 cctpNonce, ) = _setupAndDispatch();
// invalid recipient := evil
bytes32 badRecipient = evil.addressToBytes32();
- bytes memory cctpMessage = _encodeCctpMessage(
+ bytes memory cctpMessage = _encodeCctpBurnMessage(
cctpNonce,
cctpOrigin,
badRecipient,
@@ -371,11 +490,11 @@ contract TokenBridgeCctpTest is Test {
bytes memory attestation = bytes("");
bytes memory metadata = abi.encode(cctpMessage, attestation);
- vm.expectRevert(bytes("Invalid recipient"));
+ vm.expectRevert(bytes("Invalid mint recipient"));
tbDestination.verify(metadata, message);
}
- function test_verify_revertsWhen_invalidSender() public {
+ function test_verify_revertsWhen_invalidBurnSender() public {
(
bytes memory message,
uint64 cctpNonce,
@@ -383,7 +502,7 @@ contract TokenBridgeCctpTest is Test {
) = _setupAndDispatch();
// invalid sender := evil
- bytes memory cctpMessage = _encodeCctpMessage(
+ bytes memory cctpMessage = _encodeCctpBurnMessage(
cctpNonce,
cctpOrigin,
recipient,
@@ -393,57 +512,62 @@ contract TokenBridgeCctpTest is Test {
bytes memory attestation = bytes("");
bytes memory metadata = abi.encode(cctpMessage, attestation);
- vm.expectRevert(bytes("Invalid sender"));
+ vm.expectRevert(bytes("Invalid burn sender"));
tbDestination.verify(metadata, message);
}
- function test_verify_revertsWhen_invalidLength() public {
- (
- bytes memory message,
- uint64 cctpNonce,
- bytes32 recipient
- ) = _setupAndDispatch();
+ function test_verify_revertsWhen_invalidTokenMessageRecipient() public {
+ TestRecipient messageRecipient = new TestRecipient();
+ messageRecipient.setInterchainSecurityModule(address(tbDestination));
- bytes memory cctpMessage = _encodeCctpMessage(
- cctpNonce,
+ bytes32 tokenRecipient = user.addressToBytes32();
+ bytes memory messageBody = TokenMessage.format(tokenRecipient, amount);
+
+ // Create a message with recipient instead of tbDestination
+ bytes memory invalidMessage = abi.encodePacked(
+ uint8(3),
+ uint32(0),
+ origin,
+ address(tbOrigin).addressToBytes32(),
+ destination,
+ address(messageRecipient).addressToBytes32(),
+ messageBody
+ );
+
+ bytes memory cctpMessage = _encodeCctpBurnMessage(
+ 0,
cctpOrigin,
- recipient,
+ tokenRecipient,
amount
);
bytes memory attestation = bytes("");
bytes memory metadata = abi.encode(cctpMessage, attestation);
- // a message with invalid length.
- bytes memory badMessage = bytes.concat(message, bytes1(uint8(60)));
+ vm.expectRevert(bytes("Invalid token message recipient"));
+ tbDestination.verify(metadata, invalidMessage);
- vm.expectRevert();
- tbDestination.verify(metadata, badMessage);
+ vm.expectRevert(bytes("Invalid token message recipient"));
+ mailboxDestination.process(metadata, invalidMessage);
}
function test_revertsWhen_versionIsNotSupported() public virtual {
- messageTransmitterOrigin.setVersion(CCTP_VERSION_1);
- MockCircleTokenMessengerV2 tokenMessengerV2 = new MockCircleTokenMessengerV2(
- tokenOrigin
- );
+ tokenMessengerOrigin.setVersion(CCTP_VERSION_2);
vm.expectRevert(bytes("Invalid TokenMessenger CCTP version"));
- TokenBridgeCctp v1 = new TokenBridgeCctp(
+ TokenBridgeCctpV1 v1 = new TokenBridgeCctpV1(
address(tokenOrigin),
- scale,
address(mailboxOrigin),
- IMessageTransmitter(address(messageTransmitterOrigin)),
- ITokenMessenger(address(tokenMessengerV2))
+ messageTransmitterOrigin,
+ tokenMessengerOrigin
);
messageTransmitterOrigin.setVersion(CCTP_VERSION_2);
-
vm.expectRevert(bytes("Invalid messageTransmitter CCTP version"));
- v1 = new TokenBridgeCctp(
+ v1 = new TokenBridgeCctpV1(
address(tokenOrigin),
- scale,
address(mailboxOrigin),
- IMessageTransmitter(address(messageTransmitterOrigin)),
- ITokenMessenger(address(tokenMessengerOrigin))
+ messageTransmitterOrigin,
+ tokenMessengerOrigin
);
}
@@ -465,8 +589,8 @@ contract TokenBridgeCctpTest is Test {
}
function _setupTokenBridgesCctp(
- TokenBridgeCctp _tbOrigin,
- TokenBridgeCctp _tbDestination
+ TokenBridgeCctpBase _tbOrigin,
+ TokenBridgeCctpBase _tbDestination
) internal {
_tbOrigin.setUrls(_getUrls());
_tbOrigin.addDomain(destination, cctpDestination);
@@ -505,8 +629,1266 @@ contract TokenBridgeCctpTest is Test {
);
}
- function test_parent_initialize_reverts() public {
- vm.expectRevert("Only TokenBridgeCctp.initialize() may be called");
- tbOrigin.initialize(address(0), address(0), address(0));
+ function test_postDispatch(
+ bytes32 recipient,
+ bytes calldata body
+ ) public virtual {
+ // precompute message ID
+ bytes32 id = Message.id(
+ Message.formatMessage(
+ 3,
+ 0,
+ origin,
+ address(this).addressToBytes32(),
+ destination,
+ recipient,
+ body
+ )
+ );
+
+ vm.expectCall(
+ address(messageTransmitterOrigin),
+ abi.encodeCall(
+ IRelayer.sendMessage,
+ (
+ cctpDestination,
+ address(tbDestination).addressToBytes32(),
+ abi.encode(id)
+ )
+ )
+ );
+ bytes32 actualId = mailboxOrigin.dispatch(
+ destination,
+ recipient,
+ body,
+ bytes(""),
+ tbOrigin
+ );
+ assertEq(actualId, id);
+ }
+
+ // needed for hook refunds
+ receive() external payable {}
+
+ function testFork_postDispatch(
+ bytes32 recipient,
+ bytes calldata body
+ ) public virtual {
+ vm.createSelectFork(vm.rpcUrl("base"), 32_739_842);
+ TokenBridgeCctpV1 hook = TokenBridgeCctpV1(
+ 0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975
+ );
+ _upgrade(hook);
+
+ IMailbox mailbox = hook.mailbox();
+ uint32 destination = 1; // ethereum
+ uint32 origin = mailbox.localDomain();
+ bytes32 router = hook.routers(destination);
+
+ // Ensure domain mapping exists
+ uint32 circleDestination = 0; // ethereum circle domain
+ vm.prank(hook.owner());
+ hook.addDomain(destination, circleDestination);
+
+ // precompute message ID
+ bytes memory message = Message.formatMessage(
+ 3,
+ mailbox.nonce(),
+ origin,
+ address(this).addressToBytes32(),
+ destination,
+ recipient,
+ body
+ );
+
+ bytes memory cctpMessage = CctpMessageV1._formatMessage(
+ 0,
+ hook.messageTransmitter().localDomain(),
+ hook.hyperlaneDomainToCircleDomain(destination),
+ hook.messageTransmitter().nextAvailableNonce(),
+ address(hook).addressToBytes32(),
+ router,
+ bytes32(0),
+ abi.encode(Message.id(message))
+ );
+
+ vm.expectEmit(
+ true,
+ true,
+ true,
+ true,
+ address(hook.messageTransmitter())
+ );
+ emit IMessageTransmitter.MessageSent(cctpMessage);
+
+ mailbox.dispatch(destination, recipient, body, bytes(""), hook);
+ }
+
+ function testFork_verify() public virtual {
+ vm.createSelectFork(vm.rpcUrl("base"), 32_739_842);
+ TokenBridgeCctpV1 hook = TokenBridgeCctpV1(
+ 0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975
+ );
+
+ bytes32 router = hook.routers(1);
+ uint32 origin = hook.localDomain();
+
+ // https://basescan.org/tx/0x16b2c15cff779f16ab16a279a12c45a143047e680f8ed538318c7d67eed35569
+ bytes
+ memory message = hex"03001661f000002105000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba00000001000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9badeadbeef";
+
+ // https://basescan.org/tx/0x4eeffc2aa410ede620d17ae18f513bf31941d301e8ada6676b54d3300dac116a
+ bytes
+ memory cctpMessage = hex"0000000000000006000000000000000000096af6000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000000000000000000000000edcbaa585fd0f80f20073f9958246476466205b8000000000000000000000000edcbaa585fd0f80f20073f9958246476466205b8a331d7762c517834242bea4b027d3dcebbd32e7d312ef3dd7a9d73ced95f9adb";
+
+ // https://iris-api.circle.com/v1/messages/6/0x4eeffc2aa410ede620d17ae18f513bf31941d301e8ada6676b54d3300dac116a
+ bytes
+ memory attestation = hex"4a713f6935bf2f0a9b6aa01a9a5c1c4e0da23f858193f20fde96e814e63345d85a65b6f1f53f0b22cde3c611d03a032eab7ac4c26232f3a7ff9185c69ee205ee1b614fac487343203b8c6e2c210440576fbe64e7fb70de5f4be87291187604656d19c4ebc4dc33558d36e6e799fc8adca45f8b704cf6eecf3adf7254ad88d2efd41c";
+ bytes memory metadata = abi.encode(cctpMessage, attestation);
+
+ vm.createSelectFork(vm.rpcUrl("mainnet"), 22_898_879);
+ TokenBridgeCctpV1 ism = TokenBridgeCctpV1(router.bytes32ToAddress());
+ _upgrade(ism);
+
+ vm.prank(ism.owner());
+ ism.addDomain(origin, 6);
+
+ // Sender validation happens inside receiveMessage via callback to _authenticateCircleSender
+ vm.expectRevert(bytes("Unauthorized circle sender"));
+ ism.verify(metadata, message);
+
+ // CCTP message was sent by deployer on origin chain
+ // enroll the deployer as the origin router
+ address deployer = 0xa7ECcdb9Be08178f896c26b7BbD8C3D4E844d9Ba;
+ vm.prank(ism.owner());
+ ism.enrollRemoteRouter(origin, deployer.addressToBytes32());
+
+ vm.expectCall(
+ address(ism),
+ abi.encode(TokenBridgeCctpV1.handleReceiveMessage.selector)
+ );
+ assert(ism.verify(metadata, message));
+ }
+
+ function test_postDispatch_revertsWhen_messageNotDispatched(
+ bytes32 recipient,
+ bytes calldata body
+ ) public {
+ bytes memory message = Message.formatMessage(
+ 3,
+ 0,
+ origin,
+ address(this).addressToBytes32(),
+ destination,
+ recipient,
+ body
+ );
+ vm.expectRevert(bytes("Message not dispatched"));
+ tbOrigin.postDispatch(bytes(""), message);
+ }
+
+ function test_hookType() public {
+ assertEq(tbOrigin.hookType(), uint8(IPostDispatchHook.HookTypes.CCTP));
+ }
+
+ function test_supportsMetadata() public {
+ assertEq(tbOrigin.supportsMetadata(bytes("")), true);
+ assertEq(
+ tbOrigin.supportsMetadata(
+ StandardHookMetadata.format(0, 100_000, address(this))
+ ),
+ true
+ );
+ }
+
+ function test_quoteDispatch() public {
+ assertEq(tbOrigin.quoteDispatch(bytes(""), bytes("")), 0);
+ }
+
+ function test_postDispatch_refundsExcessValue(
+ bytes32 recipient,
+ bytes calldata body
+ ) public virtual {
+ address refundAddress = makeAddr("refundAddress");
+ uint256 refundBalanceBefore = refundAddress.balance;
+
+ // Create metadata with refund address using standard hook metadata format
+ bytes memory metadata = abi.encodePacked(
+ uint16(1), // variant
+ uint256(0), // msgValue
+ uint256(0), // gasLimit
+ refundAddress // refundAddress
+ );
+
+ uint256 excessValue = 1 ether;
+
+ mailboxOrigin.dispatch{value: excessValue}(
+ destination,
+ recipient,
+ body,
+ metadata,
+ tbOrigin
+ );
+
+ // Verify refund was sent
+ assertEq(refundAddress.balance, refundBalanceBefore + excessValue);
+ }
+
+ function test_verify_hookMessage(bytes calldata body) public {
+ TestRecipient recipient = new TestRecipient();
+ recipient.setInterchainSecurityModule(address(tbDestination));
+
+ bytes32 id = mailboxOrigin.dispatch(
+ destination,
+ address(recipient).addressToBytes32(),
+ body,
+ bytes(""),
+ tbOrigin
+ );
+
+ bytes memory cctpMessage = _encodeCctpHookMessage(abi.encode(id));
+ bytes memory attestation = bytes("");
+ bytes memory metadata = abi.encode(cctpMessage, attestation);
+ mailboxDestination.addInboundMetadata(0, metadata);
+
+ mailboxDestination.processNextInboundMessage();
+
+ assertEq(recipient.lastData(), body);
+ }
+
+ function test_verify_revertsWhen_invalidMessageSender(
+ bytes32 recipient,
+ bytes calldata body
+ ) public {
+ bytes memory message = Message.formatMessage(
+ 3,
+ 0,
+ origin,
+ address(this).addressToBytes32(),
+ destination,
+ recipient,
+ body
+ );
+
+ bytes32 badSender = ~address(tbOrigin).addressToBytes32();
+
+ bytes memory cctpMessage = _encodeCctpHookMessage(
+ badSender,
+ address(tbDestination).addressToBytes32(),
+ abi.encode(Message.id(message))
+ );
+
+ bytes memory attestation = bytes("");
+ bytes memory metadata = abi.encode(cctpMessage, attestation);
+
+ // Sender validation happens inside receiveMessage via callback to _authenticateCircleSender
+ vm.expectRevert(bytes("Unauthorized circle sender"));
+ tbDestination.verify(metadata, message);
+ }
+
+ function test_verify_revertsWhen_invalidMessageId(
+ bytes32 recipient,
+ bytes calldata body
+ ) public {
+ bytes memory message = Message.formatMessage(
+ 3,
+ 0,
+ origin,
+ address(this).addressToBytes32(),
+ destination,
+ recipient,
+ body
+ );
+ bytes32 badMessageId = ~Message.id(message);
+
+ bytes memory cctpMessage = _encodeCctpHookMessage(
+ abi.encode(badMessageId)
+ );
+
+ bytes memory attestation = bytes("");
+ bytes memory metadata = abi.encode(cctpMessage, attestation);
+
+ vm.expectRevert(bytes("Invalid message id"));
+ tbDestination.verify(metadata, message);
+ }
+
+ function test_verify_revertsWhen_invalidMessageRecipient(
+ bytes32 recipient,
+ bytes calldata body
+ ) public {
+ bytes memory message = Message.formatMessage(
+ 3,
+ 0,
+ origin,
+ address(this).addressToBytes32(),
+ destination,
+ recipient,
+ body
+ );
+
+ address badRecipient = address(~bytes20(address(tbDestination)));
+ bytes memory cctpMessage = _encodeCctpHookMessage(
+ address(tbOrigin).addressToBytes32(),
+ badRecipient.addressToBytes32(),
+ abi.encode(Message.id(message))
+ );
+
+ bytes memory attestation = bytes("");
+ bytes memory metadata = abi.encode(cctpMessage, attestation);
+
+ vm.expectRevert(bytes("Invalid circle recipient"));
+ tbDestination.verify(metadata, message);
+ }
+
+ // ============ handleReceiveMessage Tests (V1 only) ============
+
+ function test_handleReceiveMessage(bytes calldata message) public virtual {
+ bytes32 messageId = Message.id(message);
+ // Call handleReceiveMessage from the message transmitter
+ vm.prank(address(messageTransmitterDestination));
+ bool result = TokenBridgeCctpV1(address(tbDestination))
+ .handleReceiveMessage(
+ cctpOrigin,
+ address(tbOrigin).addressToBytes32(),
+ abi.encode(messageId)
+ );
+
+ assertTrue(result);
+ assertTrue(
+ TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId)
+ );
+ }
+
+ function test_handleReceiveMessage_revertsWhen_unauthorizedSender(
+ bytes32 messageId
+ ) public virtual {
+ // Try to call from an unauthorized address
+ vm.prank(address(messageTransmitterDestination));
+ vm.expectRevert(bytes("Unauthorized circle sender"));
+ TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage(
+ cctpOrigin,
+ evil.addressToBytes32(),
+ abi.encode(messageId)
+ );
+ }
+
+ function test_handleReceiveMessage_revertsWhen_unauthorizedCaller(
+ bytes32 messageId
+ ) public virtual {
+ // Try to call from a non-message-transmitter address
+ vm.prank(evil);
+ vm.expectRevert(bytes("Not message transmitter"));
+ TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage(
+ cctpOrigin,
+ address(tbOrigin).addressToBytes32(),
+ abi.encode(messageId)
+ );
+ }
+
+ function test_handleReceiveMessage_revertsWhen_unconfiguredDomain(
+ uint32 badDomain,
+ bytes32 messageId
+ ) public virtual {
+ // Assume the domain is not configured
+ vm.assume(badDomain != cctpOrigin);
+ vm.assume(badDomain != cctpDestination);
+
+ vm.prank(address(messageTransmitterDestination));
+ vm.expectRevert(bytes("Hyperlane domain not configured"));
+ TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage(
+ badDomain,
+ address(tbOrigin).addressToBytes32(),
+ abi.encode(messageId)
+ );
+ }
+
+ function test_handleReceiveMessage_revertsWhen_unenrolledRouter(
+ bytes32 badRouter,
+ bytes32 messageId
+ ) public virtual {
+ // Assume the router is different from the enrolled one
+ vm.assume(badRouter != address(tbOrigin).addressToBytes32());
+
+ vm.prank(address(messageTransmitterDestination));
+ vm.expectRevert(bytes("Unauthorized circle sender"));
+ TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage(
+ cctpOrigin,
+ badRouter,
+ abi.encode(messageId)
+ );
+ }
+
+ function test_verify_returnsTrue_afterDirectDelivery(
+ bytes calldata message
+ ) public virtual {
+ bytes32 messageId = Message.id(message);
+
+ // First, deliver the message directly via handleReceiveMessage
+ vm.prank(address(messageTransmitterDestination));
+ assertTrue(
+ TokenBridgeCctpV1(address(tbDestination)).handleReceiveMessage(
+ cctpOrigin,
+ address(tbOrigin).addressToBytes32(),
+ abi.encode(messageId)
+ )
+ );
+
+ // Verify the message is marked as verified
+ assertTrue(
+ TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId)
+ );
+
+ // Now call verify with empty metadata - should return true without attestation
+ bytes memory metadata = abi.encode(bytes(""), bytes(""));
+ assertTrue(tbDestination.verify(metadata, message));
+ }
+
+ function test_verify_revertsWhen_tokenMessageAlreadyDelivered()
+ public
+ virtual
+ {
+ // Setup a token transfer
+ (
+ bytes memory message,
+ uint64 cctpNonce,
+ bytes32 recipient
+ ) = _setupAndDispatch();
+
+ // Create CCTP message for the token transfer
+ bytes memory cctpMessage = _encodeCctpBurnMessage(
+ cctpNonce,
+ cctpOrigin,
+ recipient,
+ amount
+ );
+
+ // Deliver the CCTP message directly via receiveMessage (simulates CCTP delivering the burn message)
+ // This mints the tokens to the recipient
+ messageTransmitterDestination.receiveMessage(cctpMessage, bytes(""));
+
+ // Now try to verify with the same message - should revert because CCTP already processed it
+ bytes memory metadata = abi.encode(cctpMessage, bytes(""));
+
+ // The exact revert message depends on the mock implementation
+ // In a real scenario, Circle's MessageTransmitter would revert with a nonce already used error
+ vm.expectRevert();
+ tbDestination.verify(metadata, message);
+ }
+}
+
+contract TokenBridgeCctpV2Test is TokenBridgeCctpV1Test {
+ using TypeCasts for address;
+
+ uint256 constant maxFee = 1;
+ uint32 constant minFinalityThreshold = 1000;
+
+ address constant deployer = 0xa7ECcdb9Be08178f896c26b7BbD8C3D4E844d9Ba;
+
+ function setUp() public override {
+ super.setUp();
+
+ version = CCTP_VERSION_2;
+
+ tokenMessengerOrigin.setVersion(CCTP_VERSION_2);
+ messageTransmitterOrigin.setVersion(CCTP_VERSION_2);
+
+ tokenMessengerDestination.setVersion(CCTP_VERSION_2);
+ messageTransmitterDestination.setVersion(CCTP_VERSION_2);
+
+ TokenBridgeCctpV2 originImplementation = new TokenBridgeCctpV2(
+ address(tokenOrigin),
+ address(mailboxOrigin),
+ messageTransmitterOrigin,
+ tokenMessengerOrigin,
+ maxFee,
+ minFinalityThreshold
+ );
+
+ bytes memory initData = abi.encodeWithSignature(
+ "initialize(address,address,string[])",
+ address(0),
+ address(this),
+ _getUrls()
+ );
+ TransparentUpgradeableProxy proxyOrigin = new TransparentUpgradeableProxy(
+ address(originImplementation),
+ proxyAdmin,
+ initData
+ );
+ tbOrigin = TokenBridgeCctpV2(address(proxyOrigin));
+
+ TokenBridgeCctpV2 destinationImplementation = new TokenBridgeCctpV2(
+ address(tokenDestination),
+ address(mailboxDestination),
+ messageTransmitterDestination,
+ tokenMessengerDestination,
+ maxFee,
+ minFinalityThreshold
+ );
+
+ TransparentUpgradeableProxy proxyDestination = new TransparentUpgradeableProxy(
+ address(destinationImplementation),
+ proxyAdmin,
+ initData
+ );
+
+ tbDestination = TokenBridgeCctpV2(address(proxyDestination));
+
+ _setupTokenBridgesCctp(tbOrigin, tbDestination);
+ }
+
+ function _setNonce(bytes memory cctpMessage, bytes32 nonce) internal view {
+ // length + NONCE_INDEX
+ uint256 nonceOffset = 32 + 12;
+ assembly {
+ mstore(add(cctpMessage, nonceOffset), nonce)
+ }
+ }
+
+ function _encodeCctpBurnMessage(
+ uint64 nonce,
+ uint32 sourceDomain,
+ bytes32 recipient,
+ uint256 amount,
+ address sender
+ ) internal view override returns (bytes memory cctpMessage) {
+ bytes memory burnMessage = BurnMessageV2._formatMessageForRelay(
+ version,
+ address(tokenOrigin).addressToBytes32(),
+ recipient,
+ amount + (amount * maxFee) / (10_000 - maxFee),
+ sender.addressToBytes32(),
+ maxFee,
+ bytes("")
+ );
+ cctpMessage = CctpMessageV2._formatMessageForRelay(
+ version,
+ sourceDomain,
+ cctpDestination,
+ address(tokenMessengerOrigin).addressToBytes32(),
+ address(tokenMessengerDestination).addressToBytes32(),
+ bytes32(0),
+ minFinalityThreshold,
+ burnMessage
+ );
+ // pseudo random
+ bytes32 nonceBytes = keccak256(
+ abi.encode(nonce, sender, recipient, amount)
+ );
+ _setNonce(cctpMessage, nonceBytes);
+ }
+
+ function _encodeCctpHookMessage(
+ bytes32 sender,
+ bytes32 recipient,
+ bytes memory message
+ ) internal view override returns (bytes memory cctpMessage) {
+ cctpMessage = CctpMessageV2._formatMessageForRelay(
+ version,
+ cctpOrigin,
+ cctpDestination,
+ sender,
+ recipient,
+ bytes32(0),
+ minFinalityThreshold,
+ message
+ );
+ // pseudo random nonce
+ bytes32 nonce = keccak256(abi.encode(sender, recipient, message));
+ _setNonce(cctpMessage, nonce);
+ }
+
+ address constant usdc = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
+
+ function _deploy() internal returns (TokenBridgeCctpV2) {
+ ITokenMessengerV2 tokenMessenger = ITokenMessengerV2(
+ address(0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d)
+ );
+
+ IMessageTransmitterV2 messageTransmitter = IMessageTransmitterV2(
+ address(0x81D40F21F12A8F0E3252Bccb954D722d4c464B64)
+ );
+
+ TokenBridgeCctpV2 implementation = new TokenBridgeCctpV2(
+ usdc,
+ 0xeA87ae93Fa0019a82A727bfd3eBd1cFCa8f64f1D,
+ messageTransmitter,
+ tokenMessenger,
+ maxFee,
+ minFinalityThreshold
+ );
+
+ // deploy proxy code to deployer address, which is configured as recipient on cctp messages
+ deployCodeTo(
+ "../node_modules/@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol:TransparentUpgradeableProxy",
+ abi.encode(
+ address(implementation),
+ proxyAdmin,
+ abi.encodeWithSignature(
+ "initialize(address,address,string[])",
+ address(0),
+ address(this),
+ _getUrls()
+ )
+ ),
+ address(deployer)
+ );
+
+ return TokenBridgeCctpV2(address(deployer));
+ }
+
+ function testFork_verify() public override {
+ vm.createSelectFork(vm.rpcUrl("base"), 32_739_842);
+
+ uint32 circleDestination = 6;
+ uint32 origin = 10;
+ TokenBridgeCctpV2 ism = _deploy();
+ uint32 circleOrigin = 2;
+ ism.addDomain(origin, circleOrigin);
+ ism.enrollRemoteRouter(origin, deployer.addressToBytes32());
+
+ // https://optimistic.etherscan.io/tx/0xf53a6a2cb5a334706912b96088171251df1400156a0a0a68a79fe70961634f65
+ bytes
+ memory message = hex"030010EF000000000A000000000000000000000000A7ECCDB9BE08178F896C26B7BBD8C3D4E844D9BA00002105000000000000000000000000A7ECCDB9BE08178F896C26B7BBD8C3D4E844D9BADEADBEEF";
+
+ // https://optimistic.etherscan.io/tx/0xc50f4acd4e442529b9814b252e8b568b72e10720b18603232c73124ac1e9ae1f
+ bytes
+ memory originalCctpMessage = hex"0000000100000002000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000A7ECCDB9BE08178F896C26B7BBD8C3D4E844D9BA000000000000000000000000A7ECCDB9BE08178F896C26B7BBD8C3D4E844D9BA000000000000000000000000A7ECCDB9BE08178F896C26B7BBD8C3D4E844D9BA000003E800000000B410A464EC38D27F7C9394F9BF9B1EF1A5921F5E82FE77CF67A10DB6FE8425FD";
+
+ // https://iris-api.circle.com/v2/messages/2?transactionHash=0xc50f4acd4e442529b9814b252e8b568b72e10720b18603232c73124ac1e9ae1f
+ bytes
+ memory cctpMessage = hex"000000010000000200000006a94cc8b2c5a35f696379d89ca4cd0a0d7058c6c2e949ac08e8dfc607cc0590f9000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000003e8000003e8b410a464ec38d27f7c9394f9bf9b1ef1a5921f5e82fe77cf67a10db6fe8425fd";
+ bytes
+ memory attestation = hex"fdaca657526b164d6b09678297565d40e1e68cad3bfb0786470b0e8bce013ee340a985970d69629af69599f3deff5cc975b3df46d2efeadfebd867d049e5e5641cba6f5e720dc86c90d8d51747619fbe2b24246e36fa0603792cb86ad88bdc06136663d6211a8d5d134cf94cf8197892a460b24a5e21715642d338530b472a325d1c";
+ bytes memory metadata = abi.encode(cctpMessage, attestation);
+
+ vm.expectCall(
+ address(ism),
+ abi.encode(
+ TokenBridgeCctpV2.handleReceiveUnfinalizedMessage.selector
+ )
+ );
+ assert(ism.verify(metadata, message));
+ }
+
+ function testFork_transferRemote(bytes32 recipient, uint32 amount) public {
+ // recipient cannot be bytes32(0) in CCTP
+ vm.assume(recipient != bytes32(0));
+
+ // depositForBurn will revert if amount is less than maxFee
+ vm.assume(amount > maxFee);
+ vm.createSelectFork(vm.rpcUrl("base"), 32_739_842);
+
+ bytes32 ism = 0x0000000000000000000000000000000000000000000000000000000000000001;
+
+ TokenBridgeCctpV2 router = _deploy();
+
+ uint32 destination = 1; // ethereum
+ uint32 circleDestination = 0;
+ router.addDomain(destination, circleDestination);
+ router.enrollRemoteRouter(destination, ism);
+
+ Quote[] memory quotes = router.quoteTransferRemote(
+ destination,
+ recipient,
+ amount
+ );
+
+ assertEq(quotes[1].token, usdc);
+ uint256 usdcQuote = quotes[1].amount;
+
+ assertEq(quotes[2].token, usdc);
+ uint256 fastFee = quotes[2].amount;
+
+ deal(usdc, address(this), usdcQuote + fastFee);
+ IERC20(usdc).approve(address(router), usdcQuote + fastFee);
+
+ vm.expectEmit(true, true, true, true, address(router.tokenMessenger()));
+ emit ITokenMessengerV2.DepositForBurn(
+ usdc,
+ usdcQuote + fastFee,
+ address(router),
+ recipient,
+ circleDestination,
+ bytes32(
+ 0x00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d
+ ), // tokenMessengerDestination
+ bytes32(0), // destinationCaller
+ fastFee,
+ minFinalityThreshold,
+ bytes("")
+ );
+
+ router.transferRemote{value: quotes[0].amount}(
+ destination,
+ recipient,
+ amount
+ );
+ }
+
+ event MintAndWithdraw(
+ address indexed mintRecipient,
+ uint256 amount,
+ address indexed mintToken,
+ uint256 feeCollected
+ );
+
+ function testFork_verify_tokenMessage() public {
+ vm.createSelectFork(vm.rpcUrl("base"), 32_739_842);
+
+ TokenBridgeCctpV2 ism = _deploy();
+
+ bytes32 hook = deployer.addressToBytes32();
+
+ uint32 origin = 10; // optimism
+ uint32 circleOrigin = 2;
+ ism.addDomain(origin, circleOrigin);
+ ism.enrollRemoteRouter(origin, hook);
+
+ // https://optimistic.etherscan.io/tx/0x4a8c5aef605bd1a79d7e4ab7b1852d246a05859a168db2b4791563877f2f3325
+ uint256 amount = 2;
+ uint256 fee = 1;
+ bytes
+ memory cctpMessage = hex"0000000100000002000000069abb52aa4e37d2ee3e521f9bc92e97581a68dadcd826fd2abaa5150de95db90e00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000003e8000003e8000000010000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000001f825d8";
+
+ // https://iris-api.circle.com/v2/messages/2?transactionHash=0x4a8c5aef605bd1a79d7e4ab7b1852d246a05859a168db2b4791563877f2f3325
+ bytes
+ memory attestation = hex"f75d61f667685827a63a857fcfae06fd9c42860c9a94175a2041a98941c874303aa44a973bf28b447ecc39e25d81a869584e9379d41f669dc526bf3b6810a0161c278bcd556ac5dd462095094af97a8773b33c00788a362c424d5569bdb4c2fb853ab4aefab3839bc8128e280f16fc09c6cfb11361061e527f5804fa6c6b130dc91b";
+
+ bytes memory metadata = abi.encode(cctpMessage, attestation);
+
+ bytes memory message = abi.encodePacked(
+ uint8(3),
+ uint32(0),
+ origin,
+ hook,
+ ism.localDomain(),
+ address(ism).addressToBytes32(),
+ abi.encode(hook, amount)
+ );
+
+ vm.expectEmit(true, true, true, true, address(ism.tokenMessenger()));
+ emit MintAndWithdraw(deployer, amount - fee, usdc, fee);
+ ism.verify(metadata, message);
+ }
+
+ function testFork_postDispatch(
+ bytes32 recipient,
+ bytes calldata body
+ ) public override {
+ vm.createSelectFork(vm.rpcUrl("base"), 32_739_842);
+
+ bytes32 ism = 0x0000000000000000000000000000000000000000000000000000000000000001;
+
+ TokenBridgeCctpV2 hook = _deploy();
+
+ IMailbox mailbox = hook.mailbox();
+ uint32 origin = mailbox.localDomain();
+ uint32 destination = 1; // ethereum
+ hook.addDomain(destination, 0);
+ hook.enrollRemoteRouter(destination, ism);
+
+ // precompute message ID
+ bytes memory message = Message.formatMessage(
+ 3,
+ mailbox.nonce(),
+ origin,
+ address(this).addressToBytes32(),
+ destination,
+ recipient,
+ body
+ );
+
+ bytes memory cctpMessage = CctpMessageV2._formatMessageForRelay(
+ CCTP_VERSION_2,
+ hook.messageTransmitter().localDomain(),
+ hook.hyperlaneDomainToCircleDomain(destination),
+ address(hook).addressToBytes32(),
+ ism,
+ bytes32(0),
+ minFinalityThreshold,
+ abi.encode(Message.id(message))
+ );
+
+ vm.expectEmit(
+ true,
+ true,
+ true,
+ true,
+ address(hook.messageTransmitter())
+ );
+ emit IMessageTransmitter.MessageSent(cctpMessage);
+
+ mailbox.dispatch(destination, recipient, body, bytes(""), hook);
+ }
+
+ function test_transferRemoteCctp() public override {
+ Quote[] memory quote = tbOrigin.quoteTransferRemote(
+ destination,
+ user.addressToBytes32(),
+ amount
+ );
+
+ uint256 tokenQuote = quote[1].amount;
+ uint256 fastFee = quote[2].amount;
+ vm.startPrank(user);
+ tokenOrigin.approve(address(tbOrigin), tokenQuote + fastFee);
+
+ vm.expectCall(
+ address(tokenMessengerOrigin),
+ abi.encodeCall(
+ ITokenMessengerV2.depositForBurn,
+ (
+ tokenQuote + fastFee,
+ cctpDestination,
+ user.addressToBytes32(),
+ address(tokenOrigin),
+ bytes32(0),
+ fastFee,
+ minFinalityThreshold
+ )
+ )
+ );
+ tbOrigin.transferRemote{value: quote[0].amount}(
+ destination,
+ user.addressToBytes32(),
+ amount
+ );
+ }
+
+ function test_transferRemoteCctp_withFeeRecipient() public override {
+ LinearFee feeContract = new LinearFee(
+ address(tokenOrigin),
+ 1e6,
+ amount / 2,
+ address(this)
+ );
+ tbOrigin.setFeeRecipient(address(feeContract));
+ uint256 feeRecipientFee = feeContract
+ .quoteTransferRemote(destination, user.addressToBytes32(), amount)[0]
+ .amount;
+
+ Quote[] memory quotes = tbOrigin.quoteTransferRemote(
+ destination,
+ user.addressToBytes32(),
+ amount
+ );
+
+ uint256 tokenQuote = quotes[1].amount;
+ uint256 fastFee = quotes[2].amount;
+
+ vm.startPrank(user);
+ tokenOrigin.approve(address(tbOrigin), tokenQuote + fastFee);
+
+ uint64 cctpNonce = tokenMessengerOrigin.nextNonce();
+
+ vm.expectCall(
+ address(tokenMessengerOrigin),
+ abi.encodeCall(
+ ITokenMessengerV2.depositForBurn,
+ (
+ tokenQuote + fastFee - feeRecipientFee,
+ cctpDestination,
+ user.addressToBytes32(),
+ address(tokenOrigin),
+ bytes32(0),
+ fastFee,
+ minFinalityThreshold
+ )
+ )
+ );
+ tbOrigin.transferRemote{value: quotes[0].amount}(
+ destination,
+ user.addressToBytes32(),
+ amount
+ );
+ }
+
+ function test_postDispatch(
+ bytes32 recipient,
+ bytes calldata body
+ ) public override {
+ // precompute message ID
+ bytes32 id = Message.id(
+ Message.formatMessage(
+ 3,
+ 0,
+ origin,
+ address(this).addressToBytes32(),
+ destination,
+ recipient,
+ body
+ )
+ );
+
+ vm.expectCall(
+ address(messageTransmitterOrigin),
+ abi.encodeCall(
+ IRelayerV2.sendMessage,
+ (
+ cctpDestination,
+ address(tbDestination).addressToBytes32(),
+ address(0).addressToBytes32(),
+ minFinalityThreshold,
+ abi.encode(id)
+ )
+ )
+ );
+ bytes32 actualId = mailboxOrigin.dispatch(
+ destination,
+ recipient,
+ body,
+ bytes(""),
+ tbOrigin
+ );
+ assertEq(actualId, id);
+ }
+
+ function test_revertsWhen_versionIsNotSupported() public override {
+ tokenMessengerOrigin.setVersion(CCTP_VERSION_1);
+
+ vm.expectRevert(bytes("Invalid TokenMessenger CCTP version"));
+ TokenBridgeCctpV2 v2 = new TokenBridgeCctpV2(
+ address(tokenOrigin),
+ address(mailboxOrigin),
+ messageTransmitterOrigin,
+ tokenMessengerOrigin,
+ maxFee,
+ minFinalityThreshold
+ );
+
+ messageTransmitterOrigin.setVersion(CCTP_VERSION_1);
+ vm.expectRevert(bytes("Invalid messageTransmitter CCTP version"));
+ v2 = new TokenBridgeCctpV2(
+ address(tokenOrigin),
+ address(mailboxOrigin),
+ messageTransmitterOrigin,
+ tokenMessengerOrigin,
+ maxFee,
+ minFinalityThreshold
+ );
+ }
+
+ // function test_verify_revertsWhen_invalidNonce() public override {
+ // vm.skip(true);
+ // // cannot assert nonce in v2
+ // }
+
+ function testFork_verify_upgrade() public override {
+ vm.skip(true);
+ }
+
+ function test_quoteTransferRemote_getCorrectQuote() public override {
+ Quote[] memory quotes = tbOrigin.quoteTransferRemote(
+ destination,
+ user.addressToBytes32(),
+ amount
+ );
+
+ assertEq(quotes.length, 3);
+ assertEq(quotes[0].token, address(0));
+ assertEq(
+ quotes[0].amount,
+ igpOrigin.quoteGasPayment(destination, gasLimit)
+ );
+ assertEq(quotes[1].token, address(tokenOrigin));
+ assertEq(quotes[1].amount, amount);
+ uint256 fastFee = (amount * maxFee) / (10_000 - maxFee);
+ assertEq(quotes[2].token, address(tokenOrigin));
+ assertEq(quotes[2].amount, fastFee);
+ }
+
+ // ============ Override V1 handleReceiveMessage tests (V2 doesn't have this function) ============
+
+ // function test_handleReceiveMessage(bytes32) public pure override {
+ // // V2 doesn't have handleReceiveMessage, skip this test
+ // }
+
+ function test_handleReceiveMessage_revertsWhen_unauthorizedSender(
+ bytes32
+ ) public pure override {
+ // V2 doesn't have handleReceiveMessage, skip this test
+ }
+
+ function test_handleReceiveMessage_revertsWhen_unauthorizedCaller(
+ bytes32
+ ) public pure override {
+ // V2 doesn't have handleReceiveMessage, skip this test
+ }
+
+ function test_handleReceiveMessage_revertsWhen_unconfiguredDomain(
+ uint32,
+ bytes32
+ ) public pure override {
+ // V2 doesn't have handleReceiveMessage, skip this test
+ }
+
+ function test_handleReceiveMessage_revertsWhen_unenrolledRouter(
+ bytes32,
+ bytes32
+ ) public pure override {
+ // V2 doesn't have handleReceiveMessage, skip this test
+ }
+
+ // ============ handleReceiveFinalizedMessage Tests (V2 only) ============
+
+ function test_handleReceiveMessage(bytes calldata message) public override {
+ uint32 finalityThreshold = 2000;
+ bytes32 messageId = Message.id(message);
+ // Call handleReceiveFinalizedMessage from the message transmitter
+ vm.prank(address(messageTransmitterDestination));
+ bool result = TokenBridgeCctpV2(address(tbDestination))
+ .handleReceiveFinalizedMessage(
+ cctpOrigin,
+ address(tbOrigin).addressToBytes32(),
+ finalityThreshold,
+ abi.encode(messageId)
+ );
+
+ assertTrue(result);
+ assertTrue(
+ TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId)
+ );
+ }
+
+ function test_handleReceiveFinalizedMessage_revertsWhen_unauthorizedSender(
+ bytes32 messageId,
+ uint32 finalityThreshold
+ ) public {
+ vm.prank(address(messageTransmitterDestination));
+ vm.expectRevert(bytes("Unauthorized circle sender"));
+ TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage(
+ cctpOrigin,
+ evil.addressToBytes32(),
+ finalityThreshold,
+ abi.encode(messageId)
+ );
+ }
+
+ function test_handleReceiveFinalizedMessage_revertsWhen_unauthorizedCaller(
+ bytes32 messageId,
+ uint32 finalityThreshold
+ ) public {
+ vm.prank(evil);
+ vm.expectRevert(bytes("Not message transmitter"));
+ TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage(
+ cctpOrigin,
+ address(tbOrigin).addressToBytes32(),
+ finalityThreshold,
+ abi.encode(messageId)
+ );
+ }
+
+ function test_handleReceiveFinalizedMessage_revertsWhen_unconfiguredDomain(
+ uint32 badDomain,
+ bytes32 messageId,
+ uint32 finalityThreshold
+ ) public {
+ vm.assume(badDomain != cctpOrigin);
+ vm.assume(badDomain != cctpDestination);
+
+ vm.prank(address(messageTransmitterDestination));
+ vm.expectRevert(bytes("Hyperlane domain not configured"));
+ TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage(
+ badDomain,
+ address(tbOrigin).addressToBytes32(),
+ finalityThreshold,
+ abi.encode(messageId)
+ );
+ }
+
+ function test_handleReceiveFinalizedMessage_revertsWhen_unenrolledRouter(
+ bytes32 badRouter,
+ bytes32 messageId,
+ uint32 finalityThreshold
+ ) public {
+ vm.assume(badRouter != address(tbOrigin).addressToBytes32());
+
+ vm.prank(address(messageTransmitterDestination));
+ vm.expectRevert(bytes("Unauthorized circle sender"));
+ TokenBridgeCctpV2(address(tbDestination)).handleReceiveFinalizedMessage(
+ cctpOrigin,
+ badRouter,
+ finalityThreshold,
+ abi.encode(messageId)
+ );
+ }
+
+ // ============ handleReceiveUnfinalizedMessage Tests (V2 only) ============
+
+ function test_handleReceiveUnfinalizedMessage(
+ bytes calldata message,
+ uint32 finalityThreshold
+ ) public {
+ bytes32 messageId = Message.id(message);
+ // Call handleReceiveUnfinalizedMessage from the message transmitter
+ vm.prank(address(messageTransmitterDestination));
+ bool result = TokenBridgeCctpV2(address(tbDestination))
+ .handleReceiveUnfinalizedMessage(
+ cctpOrigin,
+ address(tbOrigin).addressToBytes32(),
+ finalityThreshold,
+ abi.encode(messageId)
+ );
+
+ assertTrue(result);
+ assertTrue(
+ TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId)
+ );
+ }
+
+ function test_handleReceiveUnfinalizedMessage_revertsWhen_unauthorizedSender(
+ bytes32 messageId,
+ uint32 finalityThreshold
+ ) public {
+ vm.prank(address(messageTransmitterDestination));
+ vm.expectRevert(bytes("Unauthorized circle sender"));
+ TokenBridgeCctpV2(address(tbDestination))
+ .handleReceiveUnfinalizedMessage(
+ cctpOrigin,
+ evil.addressToBytes32(),
+ finalityThreshold,
+ abi.encode(messageId)
+ );
+ }
+
+ function test_handleReceiveUnfinalizedMessage_revertsWhen_unauthorizedCaller(
+ bytes32 messageId,
+ uint32 finalityThreshold
+ ) public {
+ vm.prank(evil);
+ vm.expectRevert(bytes("Not message transmitter"));
+ TokenBridgeCctpV2(address(tbDestination))
+ .handleReceiveUnfinalizedMessage(
+ cctpOrigin,
+ address(tbOrigin).addressToBytes32(),
+ finalityThreshold,
+ abi.encode(messageId)
+ );
+ }
+
+ function test_handleReceiveUnfinalizedMessage_revertsWhen_unconfiguredDomain(
+ uint32 badDomain,
+ bytes32 messageId,
+ uint32 finalityThreshold
+ ) public {
+ vm.assume(badDomain != cctpOrigin);
+ vm.assume(badDomain != cctpDestination);
+
+ vm.prank(address(messageTransmitterDestination));
+ vm.expectRevert(bytes("Hyperlane domain not configured"));
+ TokenBridgeCctpV2(address(tbDestination))
+ .handleReceiveUnfinalizedMessage(
+ badDomain,
+ address(tbOrigin).addressToBytes32(),
+ finalityThreshold,
+ abi.encode(messageId)
+ );
+ }
+
+ function test_handleReceiveUnfinalizedMessage_revertsWhen_unenrolledRouter(
+ bytes32 badRouter,
+ bytes32 messageId,
+ uint32 finalityThreshold
+ ) public {
+ vm.assume(badRouter != address(tbOrigin).addressToBytes32());
+
+ vm.prank(address(messageTransmitterDestination));
+ vm.expectRevert(bytes("Unauthorized circle sender"));
+ TokenBridgeCctpV2(address(tbDestination))
+ .handleReceiveUnfinalizedMessage(
+ cctpOrigin,
+ badRouter,
+ finalityThreshold,
+ abi.encode(messageId)
+ );
+ }
+
+ function test_verify_returnsTrue_afterDirectDelivery(
+ bytes calldata message
+ ) public override {
+ bytes32 messageId = Message.id(message);
+ uint32 finalityThreshold = 2000;
+
+ // First, deliver the message directly via handleReceiveFinalizedMessage
+ vm.prank(address(messageTransmitterDestination));
+ assertTrue(
+ TokenBridgeCctpV2(address(tbDestination))
+ .handleReceiveFinalizedMessage(
+ cctpOrigin,
+ address(tbOrigin).addressToBytes32(),
+ finalityThreshold,
+ abi.encode(messageId)
+ )
+ );
+
+ // Verify the message is marked as verified
+ assertTrue(
+ TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId)
+ );
+
+ // Now call verify with empty metadata - should return true without attestation
+ bytes memory metadata = abi.encode(bytes(""), bytes(""));
+ assertTrue(tbDestination.verify(metadata, message));
+ }
+
+ function test_verify_returnsTrue_afterUnfinalizedDirectDelivery(
+ bytes calldata message
+ ) public {
+ bytes32 messageId = Message.id(message);
+ uint32 finalityThreshold = 1500;
+
+ // First, deliver the message directly via handleReceiveUnfinalizedMessage
+ vm.prank(address(messageTransmitterDestination));
+ assertTrue(
+ TokenBridgeCctpV2(address(tbDestination))
+ .handleReceiveUnfinalizedMessage(
+ cctpOrigin,
+ address(tbOrigin).addressToBytes32(),
+ finalityThreshold,
+ abi.encode(messageId)
+ )
+ );
+
+ // Verify the message is marked as verified
+ assertTrue(
+ TokenBridgeCctpBase(address(tbDestination)).isVerified(messageId)
+ );
+
+ // Now call verify with empty metadata - should return true without attestation
+ bytes memory metadata = abi.encode(bytes(""), bytes(""));
+ assertTrue(tbDestination.verify(metadata, message));
+ }
+
+ function test_verify_revertsWhen_tokenMessageAlreadyDelivered()
+ public
+ override
+ {
+ // Setup a token transfer
+ (
+ bytes memory message,
+ uint64 cctpNonce,
+ bytes32 recipient
+ ) = _setupAndDispatch();
+
+ // Create CCTP V2 message for the token transfer
+ bytes memory cctpMessage = _encodeCctpBurnMessage(
+ cctpNonce,
+ cctpOrigin,
+ recipient,
+ amount
+ );
+
+ // Deliver the CCTP message directly via receiveMessage (simulates CCTP delivering the burn message)
+ // This mints the tokens to the recipient
+ messageTransmitterDestination.receiveMessage(cctpMessage, bytes(""));
+
+ // Now try to verify with the same message - should revert because CCTP already processed it
+ bytes memory metadata = abi.encode(cctpMessage, bytes(""));
+
+ // The exact revert message depends on the mock implementation
+ // In a real scenario, Circle's MessageTransmitter would revert with a nonce already used error
+ vm.expectRevert();
+ tbDestination.verify(metadata, message);
}
}
diff --git a/typescript/cli/src/rebalancer/core/Rebalancer.ts b/typescript/cli/src/rebalancer/core/Rebalancer.ts
index fb36878ed9..7e424f9a2b 100644
--- a/typescript/cli/src/rebalancer/core/Rebalancer.ts
+++ b/typescript/cli/src/rebalancer/core/Rebalancer.ts
@@ -3,7 +3,7 @@ import { PopulatedTransaction } from 'ethers';
import {
type ChainMap,
type ChainMetadata,
- EvmHypCollateralAdapter,
+ EvmMovableCollateralAdapter,
InterchainGasQuote,
type MultiProvider,
type Token,
@@ -144,7 +144,7 @@ export class Rebalancer implements IRebalancer {
originTokenAmount.getDecimalFormattedAmount();
const originHypAdapter = originToken.getHypAdapter(
this.warpCore.multiProvider,
- ) as EvmHypCollateralAdapter;
+ ) as EvmMovableCollateralAdapter;
const { bridge, bridgeIsWarp } = getBridgeConfig(
this.bridges,
origin,
@@ -238,7 +238,7 @@ export class Rebalancer implements IRebalancer {
const originHypAdapter = originToken.getHypAdapter(
this.warpCore.multiProvider,
);
- if (!(originHypAdapter instanceof EvmHypCollateralAdapter)) {
+ if (!(originHypAdapter instanceof EvmMovableCollateralAdapter)) {
rebalancerLogger.error(
{
origin,
diff --git a/typescript/cli/src/rebalancer/interfaces/IRebalancer.ts b/typescript/cli/src/rebalancer/interfaces/IRebalancer.ts
index e922daa9b0..54df2be328 100644
--- a/typescript/cli/src/rebalancer/interfaces/IRebalancer.ts
+++ b/typescript/cli/src/rebalancer/interfaces/IRebalancer.ts
@@ -1,10 +1,10 @@
-import { EvmHypCollateralAdapter, TokenAmount } from '@hyperlane-xyz/sdk';
+import { EvmMovableCollateralAdapter, TokenAmount } from '@hyperlane-xyz/sdk';
import { RebalancingRoute } from './IStrategy.js';
export type PreparedTransaction = {
populatedTx: Awaited<
- ReturnType
+ ReturnType
>;
route: RebalancingRoute;
originTokenAmount: TokenAmount;
diff --git a/typescript/helloworld/contracts/HelloWorld.sol b/typescript/helloworld/contracts/HelloWorld.sol
index fc81207c50..17f2f40f89 100644
--- a/typescript/helloworld/contracts/HelloWorld.sol
+++ b/typescript/helloworld/contracts/HelloWorld.sol
@@ -63,7 +63,7 @@ contract HelloWorld is Router {
) external payable {
sent += 1;
sentTo[_destinationDomain] += 1;
- _dispatch(_destinationDomain, bytes(_message));
+ _Router_dispatch(_destinationDomain, msg.value, bytes(_message));
emit SentHelloWorld(
mailbox.localDomain(),
_destinationDomain,
@@ -79,7 +79,7 @@ contract HelloWorld is Router {
uint32 _destinationDomain,
bytes calldata _message
) external view returns (uint256) {
- return _quoteDispatch(_destinationDomain, _message);
+ return _Router_quoteDispatch(_destinationDomain, _message);
}
// ============ Internal functions ============
diff --git a/typescript/infra/config/environments/mainnet3/index.ts b/typescript/infra/config/environments/mainnet3/index.ts
index ca7806f443..1c322fdffd 100644
--- a/typescript/infra/config/environments/mainnet3/index.ts
+++ b/typescript/infra/config/environments/mainnet3/index.ts
@@ -16,7 +16,6 @@ import { keyFunderConfig } from './funding.js';
import { helloWorld } from './helloworld.js';
import { igp } from './igp.js';
import { infrastructure } from './infrastructure.js';
-import { bridgeAdapterConfigs, relayerConfig } from './liquidityLayer.js';
import { chainOwners } from './owners.js';
import { supportedChainNames } from './supportedChainNames.js';
import { checkWarpDeployConfig } from './warp/checkWarpDeploy.js';
@@ -56,8 +55,4 @@ export const environment: EnvironmentConfig = {
helloWorld,
keyFunderConfig,
checkWarpDeployConfig,
- liquidityLayerConfig: {
- bridgeAdapters: bridgeAdapterConfigs,
- relayer: relayerConfig,
- },
};
diff --git a/typescript/infra/config/environments/mainnet3/liquidityLayer.ts b/typescript/infra/config/environments/mainnet3/liquidityLayer.ts
deleted file mode 100644
index 0f3a6d2879..0000000000
--- a/typescript/infra/config/environments/mainnet3/liquidityLayer.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import {
- BridgeAdapterConfig,
- BridgeAdapterType,
- ChainMap,
-} from '@hyperlane-xyz/sdk';
-
-import { LiquidityLayerRelayerConfig } from '../../../src/config/middleware.js';
-import { getDomainId } from '../../registry.js';
-
-import { environment } from './chains.js';
-
-const circleDomainMapping = [
- {
- hyperlaneDomain: getDomainId('ethereum'),
- circleDomain: 0,
- },
- {
- hyperlaneDomain: getDomainId('avalanche'),
- circleDomain: 1,
- },
-];
-
-export const bridgeAdapterConfigs: ChainMap = {
- ethereum: {
- circle: {
- type: BridgeAdapterType.Circle,
- tokenMessengerAddress: '0xBd3fa81B58Ba92a82136038B25aDec7066af3155',
- messageTransmitterAddress: '0x0a992d191DEeC32aFe36203Ad87D7d289a738F81',
- usdcAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
- circleDomainMapping,
- },
- },
- avalanche: {
- circle: {
- type: BridgeAdapterType.Circle,
- tokenMessengerAddress: '0x6B25532e1060CE10cc3B0A99e5683b91BFDe6982',
- messageTransmitterAddress: '0x8186359af5f57fbb40c6b14a588d2a59c0c29880',
- usdcAddress: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E',
- circleDomainMapping,
- },
- },
-};
-
-export const relayerConfig: LiquidityLayerRelayerConfig = {
- docker: {
- repo: 'gcr.io/abacus-labs-dev/hyperlane-monorepo',
- tag: '59410cd-20230420-091923',
- },
- namespace: environment,
- prometheusPushGateway:
- 'http://prometheus-prometheus-pushgateway.monitoring.svc.cluster.local:9091',
-};
diff --git a/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/addresses.json b/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/addresses.json
deleted file mode 100644
index b562fe5991..0000000000
--- a/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/addresses.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "ethereum": {
- "proxyAdmin": "0x75EE15Ee1B4A75Fa3e2fDF5DF3253c25599cc659",
- "liquidityLayerRouter": "0x9954A0d5C9ac7e4a3687f9B08c0FF272f9d0dc71",
- "circleBridgeAdapter": "0xf7Cb9e767247144D89bcf36614D56C33FD4Db562"
- },
- "avalanche": {
- "proxyAdmin": "0xd7CF8c05fd81b8cA7CfF8E6C49B08a9D63265c9B",
- "liquidityLayerRouter": "0xEff8C988b9F9f606059c436F5C1Cc431571C8B03",
- "circleBridgeAdapter": "0x0BFf79f395A73817df1d3c80D78bb3C57Fbbc2Ed"
- }
-}
diff --git a/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/verification.json b/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/verification.json
deleted file mode 100644
index 67184b0996..0000000000
--- a/typescript/infra/config/environments/mainnet3/middleware/liquidity-layer/verification.json
+++ /dev/null
@@ -1,42 +0,0 @@
-{
- "ethereum": [
- {
- "name": "LiquidityLayerRouter",
- "address": "0x9954A0d5C9ac7e4a3687f9B08c0FF272f9d0dc71",
- "constructorArguments": "",
- "isProxy": false
- },
- {
- "name": "TransparentUpgradeableProxy",
- "address": "0x75FE1c9cf9CD1f49bD655F4a173FE5CA7C22D8E1",
- "constructorArguments": "0000000000000000000000009954a0d5c9ac7e4a3687f9b08c0ff272f9d0dc7100000000000000000000000075ee15ee1b4a75fa3e2fdf5df3253c25599cc65900000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000084f8c8765e00000000000000000000000035231d4c2d8b8adcb5617a638a0c4548684c7c7000000000000000000000000056f52c0a1ddcd557285f7cbc782d3d83096ce1cc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba00000000000000000000000000000000000000000000000000000000",
- "isProxy": true
- },
- {
- "name": "CircleBridgeAdapter",
- "address": "0xf7Cb9e767247144D89bcf36614D56C33FD4Db562",
- "constructorArguments": "",
- "isProxy": false
- }
- ],
- "avalanche": [
- {
- "name": "LiquidityLayerRouter",
- "address": "0xDc68A5829F7Edfe2954EEe1bff23C3C994197596",
- "constructorArguments": "",
- "isProxy": false
- },
- {
- "name": "TransparentUpgradeableProxy",
- "address": "0xEff8C988b9F9f606059c436F5C1Cc431571C8B03",
- "constructorArguments": "000000000000000000000000dc68a5829f7edfe2954eee1bff23c3c994197596000000000000000000000000d7cf8c05fd81b8ca7cff8e6c49b08a9d63265c9b00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000084f8c8765e00000000000000000000000035231d4c2d8b8adcb5617a638a0c4548684c7c7000000000000000000000000056f52c0a1ddcd557285f7cbc782d3d83096ce1cc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7eccdb9be08178f896c26b7bbd8c3d4e844d9ba00000000000000000000000000000000000000000000000000000000",
- "isProxy": true
- },
- {
- "name": "CircleBridgeAdapter",
- "address": "0x0BFf79f395A73817df1d3c80D78bb3C57Fbbc2Ed",
- "constructorArguments": "",
- "isProxy": false
- }
- ]
-}
diff --git a/typescript/infra/config/environments/mainnet3/token-bridge.ts b/typescript/infra/config/environments/mainnet3/token-bridge.ts
deleted file mode 100644
index f516455025..0000000000
--- a/typescript/infra/config/environments/mainnet3/token-bridge.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import {
- BridgeAdapterType,
- ChainMap,
- CircleBridgeAdapterConfig,
-} from '@hyperlane-xyz/sdk';
-
-import { getDomainId } from '../../registry.js';
-
-const circleDomainMapping = [
- { hyperlaneDomain: getDomainId('fuji'), circleDomain: 1 },
-];
-
-// Circle deployed contracts
-export const circleBridgeAdapterConfig: ChainMap = {
- fuji: {
- type: BridgeAdapterType.Circle,
- tokenMessengerAddress: '0x0fc1103927af27af808d03135214718bcedbe9ad',
- messageTransmitterAddress: '0x52fffb3ee8fa7838e9858a2d5e454007b9027c3c',
- usdcAddress: '0x5425890298aed601595a70ab815c96711a31bc65',
- circleDomainMapping,
- },
-};
diff --git a/typescript/infra/config/environments/test/index.ts b/typescript/infra/config/environments/test/index.ts
index cdde17fe39..c6c571bf5f 100644
--- a/typescript/infra/config/environments/test/index.ts
+++ b/typescript/infra/config/environments/test/index.ts
@@ -1,6 +1,6 @@
import { JsonRpcProvider } from '@ethersproject/providers';
-import { MultiProvider, testChainMetadata } from '@hyperlane-xyz/sdk';
+import { MultiProvider } from '@hyperlane-xyz/sdk';
import { EnvironmentConfig } from '../../../src/config/environment.js';
diff --git a/typescript/infra/config/environments/test/middleware/liquidity-layer/addresses.json b/typescript/infra/config/environments/test/middleware/liquidity-layer/addresses.json
deleted file mode 100644
index a115479d5b..0000000000
--- a/typescript/infra/config/environments/test/middleware/liquidity-layer/addresses.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "fuji": {
- "circleBridgeAdapter": "0x17EB33454AAEF8E91510540a0ebF4a8213dd740D",
- "portalAdapter": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271",
- "router": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541"
- },
- "bsctestnet": {
- "portalAdapter": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271",
- "router": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541"
- },
- "alfajores": {
- "portalAdapter": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271",
- "router": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541"
- }
-}
diff --git a/typescript/infra/config/environments/test/middleware/liquidity-layer/verification.json b/typescript/infra/config/environments/test/middleware/liquidity-layer/verification.json
deleted file mode 100644
index edd97a31d3..0000000000
--- a/typescript/infra/config/environments/test/middleware/liquidity-layer/verification.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "fuji": [
- {
- "name": "LiquidityLayerRouter",
- "address": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541",
- "isProxy": false,
- "constructorArguments": ""
- },
- {
- "name": "CircleBridgeAdapter",
- "address": "0xb54AD7AE42B7c505100594365CdBC4b28Ef51FE6",
- "isProxy": false,
- "constructorArguments": ""
- },
- {
- "name": "PortalAdapter",
- "address": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271",
- "isProxy": false,
- "constructorArguments": ""
- },
- {
- "name": "CircleBridgeAdapter",
- "address": "0x54FCA26E5FF828847D8caF471e44cD5727C73B0d",
- "isProxy": false,
- "constructorArguments": ""
- },
- {
- "name": "CircleBridgeAdapter",
- "address": "0x17EB33454AAEF8E91510540a0ebF4a8213dd740D",
- "isProxy": false,
- "constructorArguments": ""
- }
- ],
- "bsctestnet": [
- {
- "name": "LiquidityLayerRouter",
- "address": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541",
- "isProxy": false,
- "constructorArguments": ""
- },
- {
- "name": "PortalAdapter",
- "address": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271",
- "isProxy": false,
- "constructorArguments": ""
- }
- ],
- "alfajores": [
- {
- "name": "LiquidityLayerRouter",
- "address": "0x3428e12EfDb2446c1E7feC3f1CED099A8a7cD541",
- "isProxy": false,
- "constructorArguments": ""
- },
- {
- "name": "PortalAdapter",
- "address": "0xe589a05be1304b43A6FEb9c5D6a6EEEA35656271",
- "isProxy": false,
- "constructorArguments": ""
- }
- ]
-}
diff --git a/typescript/infra/config/environments/testnet4/index.ts b/typescript/infra/config/environments/testnet4/index.ts
index 03430bbbe4..59739766fc 100644
--- a/typescript/infra/config/environments/testnet4/index.ts
+++ b/typescript/infra/config/environments/testnet4/index.ts
@@ -21,8 +21,6 @@ import { keyFunderConfig } from './funding.js';
import { helloWorld } from './helloworld.js';
import { igp } from './igp.js';
import { infrastructure } from './infrastructure.js';
-import { bridgeAdapterConfigs } from './liquidityLayer.js';
-import { liquidityLayerRelayerConfig } from './middleware.js';
import { owners } from './owners.js';
import { supportedChainNames } from './supportedChainNames.js';
@@ -71,8 +69,4 @@ export const environment: EnvironmentConfig = {
helloWorld,
owners,
keyFunderConfig,
- liquidityLayerConfig: {
- bridgeAdapters: bridgeAdapterConfigs,
- relayer: liquidityLayerRelayerConfig,
- },
};
diff --git a/typescript/infra/config/environments/testnet4/liquidityLayer.ts b/typescript/infra/config/environments/testnet4/liquidityLayer.ts
deleted file mode 100644
index 0057b68e82..0000000000
--- a/typescript/infra/config/environments/testnet4/liquidityLayer.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import {
- BridgeAdapterConfig,
- BridgeAdapterType,
- ChainMap,
-} from '@hyperlane-xyz/sdk';
-
-import { getDomainId } from '../../registry.js';
-
-const circleDomainMapping = [
- { hyperlaneDomain: getDomainId('fuji'), circleDomain: 1 },
-];
-
-const wormholeDomainMapping = [
- {
- hyperlaneDomain: getDomainId('fuji'),
- wormholeDomain: 6,
- },
- {
- hyperlaneDomain: getDomainId('bsctestnet'),
- wormholeDomain: 4,
- },
- {
- hyperlaneDomain: getDomainId('alfajores'),
- wormholeDomain: 14,
- },
-];
-
-export const bridgeAdapterConfigs: ChainMap = {
- fuji: {
- portal: {
- type: BridgeAdapterType.Portal,
- portalBridgeAddress: '0x61E44E506Ca5659E6c0bba9b678586fA2d729756',
- wormholeDomainMapping,
- },
- circle: {
- type: BridgeAdapterType.Circle,
- tokenMessengerAddress: '0xeb08f243e5d3fcff26a9e38ae5520a669f4019d0',
- messageTransmitterAddress: '0xa9fb1b3009dcb79e2fe346c16a604b8fa8ae0a79',
- usdcAddress: '0x5425890298aed601595a70ab815c96711a31bc65',
- circleDomainMapping,
- },
- },
- bsctestnet: {
- portal: {
- type: BridgeAdapterType.Portal,
- portalBridgeAddress: '0x9dcF9D205C9De35334D646BeE44b2D2859712A09',
- wormholeDomainMapping,
- },
- },
- alfajores: {
- portal: {
- type: BridgeAdapterType.Portal,
- portalBridgeAddress: '0x05ca6037eC51F8b712eD2E6Fa72219FEaE74E153',
- wormholeDomainMapping,
- },
- },
-};
diff --git a/typescript/infra/config/environments/testnet4/middleware.ts b/typescript/infra/config/environments/testnet4/middleware.ts
deleted file mode 100644
index cf3d2782cb..0000000000
--- a/typescript/infra/config/environments/testnet4/middleware.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { LiquidityLayerRelayerConfig } from '../../../src/config/middleware.js';
-
-import { environment } from './chains.js';
-
-export const liquidityLayerRelayerConfig: LiquidityLayerRelayerConfig = {
- docker: {
- repo: 'gcr.io/abacus-labs-dev/hyperlane-monorepo',
- tag: 'sha-437f701',
- },
- namespace: environment,
- prometheusPushGateway:
- 'http://prometheus-prometheus-pushgateway.monitoring.svc.cluster.local:9091',
-};
diff --git a/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/addresses.json b/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/addresses.json
deleted file mode 100644
index 794ffffdba..0000000000
--- a/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/addresses.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "fuji": {
- "circleBridgeAdapter": "0xfe9d88aA85c5917822C804b949BcEDE832C02ce2",
- "portalAdapter": "0x68D753982e89CC083917863F6dc9738448B91ef9",
- "proxyAdmin": "0x13474f85b808034C911B7697dee60B7d8d50ee36",
- "liquidityLayerRouter": "0x2abe0860D81FB4242C748132bD69D125D88eaE26"
- },
- "bsctestnet": {
- "portalAdapter": "0x68D753982e89CC083917863F6dc9738448B91ef9",
- "proxyAdmin": "0xfB149BC17dD3FE858fA64D678bA0c706DEac61eE",
- "liquidityLayerRouter": "0x2abe0860D81FB4242C748132bD69D125D88eaE26"
- },
- "alfajores": {
- "portalAdapter": "0x68D753982e89CC083917863F6dc9738448B91ef9",
- "liquidityLayerRouter": "0x2abe0860D81FB4242C748132bD69D125D88eaE26",
- "proxyAdmin": "0x4e4D563e2cBFC35c4BC16003685443Fae2FA702f"
- }
-}
diff --git a/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/verification.json b/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/verification.json
deleted file mode 100644
index 67ffcccfab..0000000000
--- a/typescript/infra/config/environments/testnet4/middleware/liquidity-layer/verification.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "fuji": [
- {
- "name": "LiquidityLayerRouter",
- "address": "0x2abe0860D81FB4242C748132bD69D125D88eaE26",
- "isProxy": false,
- "constructorArguments": "0x"
- },
- {
- "name": "CircleBridgeAdapter",
- "address": "0xfe9d88aA85c5917822C804b949BcEDE832C02ce2",
- "isProxy": false,
- "constructorArguments": "0x"
- },
- {
- "name": "PortalAdapter",
- "address": "0x68D753982e89CC083917863F6dc9738448B91ef9",
- "isProxy": false,
- "constructorArguments": "0x"
- }
- ],
- "bsctestnet": [
- {
- "name": "LiquidityLayerRouter",
- "address": "0x2abe0860D81FB4242C748132bD69D125D88eaE26",
- "isProxy": false,
- "constructorArguments": "0x"
- },
- {
- "name": "PortalAdapter",
- "address": "0x68D753982e89CC083917863F6dc9738448B91ef9",
- "isProxy": false,
- "constructorArguments": "0x"
- }
- ],
- "alfajores": [
- {
- "name": "LiquidityLayerRouter",
- "address": "0x2abe0860D81FB4242C748132bD69D125D88eaE26",
- "isProxy": false,
- "constructorArguments": "0x"
- },
- {
- "name": "PortalAdapter",
- "address": "0x68D753982e89CC083917863F6dc9738448B91ef9",
- "isProxy": false,
- "constructorArguments": "0x"
- }
- ]
-}
diff --git a/typescript/infra/config/environments/testnet4/token-bridge.ts b/typescript/infra/config/environments/testnet4/token-bridge.ts
deleted file mode 100644
index 0057b68e82..0000000000
--- a/typescript/infra/config/environments/testnet4/token-bridge.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import {
- BridgeAdapterConfig,
- BridgeAdapterType,
- ChainMap,
-} from '@hyperlane-xyz/sdk';
-
-import { getDomainId } from '../../registry.js';
-
-const circleDomainMapping = [
- { hyperlaneDomain: getDomainId('fuji'), circleDomain: 1 },
-];
-
-const wormholeDomainMapping = [
- {
- hyperlaneDomain: getDomainId('fuji'),
- wormholeDomain: 6,
- },
- {
- hyperlaneDomain: getDomainId('bsctestnet'),
- wormholeDomain: 4,
- },
- {
- hyperlaneDomain: getDomainId('alfajores'),
- wormholeDomain: 14,
- },
-];
-
-export const bridgeAdapterConfigs: ChainMap = {
- fuji: {
- portal: {
- type: BridgeAdapterType.Portal,
- portalBridgeAddress: '0x61E44E506Ca5659E6c0bba9b678586fA2d729756',
- wormholeDomainMapping,
- },
- circle: {
- type: BridgeAdapterType.Circle,
- tokenMessengerAddress: '0xeb08f243e5d3fcff26a9e38ae5520a669f4019d0',
- messageTransmitterAddress: '0xa9fb1b3009dcb79e2fe346c16a604b8fa8ae0a79',
- usdcAddress: '0x5425890298aed601595a70ab815c96711a31bc65',
- circleDomainMapping,
- },
- },
- bsctestnet: {
- portal: {
- type: BridgeAdapterType.Portal,
- portalBridgeAddress: '0x9dcF9D205C9De35334D646BeE44b2D2859712A09',
- wormholeDomainMapping,
- },
- },
- alfajores: {
- portal: {
- type: BridgeAdapterType.Portal,
- portalBridgeAddress: '0x05ca6037eC51F8b712eD2E6Fa72219FEaE74E153',
- wormholeDomainMapping,
- },
- },
-};
diff --git a/typescript/infra/config/environments/testnet4/warp/getCCTPConfig.ts b/typescript/infra/config/environments/testnet4/warp/getCCTPConfig.ts
index 00ff410b06..58cee65d8d 100644
--- a/typescript/infra/config/environments/testnet4/warp/getCCTPConfig.ts
+++ b/typescript/infra/config/environments/testnet4/warp/getCCTPConfig.ts
@@ -38,6 +38,7 @@ export const getCCTPWarpConfig = async (
messageTransmitter: messageTransmitterAddresses[chain],
tokenMessenger: tokenMessengerAddresses[chain],
urls: ['https://offchain-lookup.web3tools.net/cctp/getProofs'],
+ cctpVersion: 'V1',
};
return [chain, config];
}),
diff --git a/typescript/infra/helm/liquidity-layer-relayers/Chart.yaml b/typescript/infra/helm/liquidity-layer-relayers/Chart.yaml
deleted file mode 100644
index ef2f1888ab..0000000000
--- a/typescript/infra/helm/liquidity-layer-relayers/Chart.yaml
+++ /dev/null
@@ -1,24 +0,0 @@
-apiVersion: v2
-name: liquidity-layer-relayers
-description: Liquidity Layer Relayers
-
-# A chart can be either an 'application' or a 'library' chart.
-#
-# Application charts are a collection of templates that can be packaged into versioned archives
-# to be deployed.
-#
-# Library charts provide useful utilities or functions for the chart developer. They're included as
-# a dependency of application charts to inject those utilities and functions into the rendering
-# pipeline. Library charts do not define any templates and therefore cannot be deployed.
-type: application
-
-# This is the chart version. This version number should be incremented each time you make changes
-# to the chart and its templates, including the app version.
-# Versions are expected to follow Semantic Versioning (https://semver.org/)
-version: 0.1.0
-
-# This is the version number of the application being deployed. This version number should be
-# incremented each time you make changes to the application. Versions are not expected to
-# follow Semantic Versioning. They should reflect the version the application is using.
-# It is recommended to use it with quotes.
-appVersion: '1.16.0'
diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/_helpers.tpl b/typescript/infra/helm/liquidity-layer-relayers/templates/_helpers.tpl
deleted file mode 100644
index f0752fcf92..0000000000
--- a/typescript/infra/helm/liquidity-layer-relayers/templates/_helpers.tpl
+++ /dev/null
@@ -1,42 +0,0 @@
-{{/*
-Expand the name of the chart.
-*/}}
-{{- define "hyperlane.name" -}}
-{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
-{{- end }}
-
-{{/*
-Create chart name and version as used by the chart label.
-*/}}
-{{- define "hyperlane.chart" -}}
-{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
-{{- end }}
-
-{{/*
-Common labels
-*/}}
-{{- define "hyperlane.labels" -}}
-helm.sh/chart: {{ include "hyperlane.chart" . }}
-hyperlane/deployment: {{ .Values.hyperlane.runEnv | quote }}
-hyperlane/context: "hyperlane"
-{{ include "hyperlane.selectorLabels" . }}
-{{- if .Chart.AppVersion }}
-app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
-{{- end }}
-app.kubernetes.io/managed-by: {{ .Release.Service }}
-{{- end }}
-
-{{/*
-Selector labels
-*/}}
-{{- define "hyperlane.selectorLabels" -}}
-app.kubernetes.io/name: {{ include "hyperlane.name" . }}
-app.kubernetes.io/instance: {{ .Release.Name }}
-{{- end }}
-
-{{/*
-The name of the ClusterSecretStore
-*/}}
-{{- define "hyperlane.cluster-secret-store.name" -}}
-{{- default "external-secrets-gcp-cluster-secret-store" .Values.externalSecrets.clusterSecretStore }}
-{{- end }}
diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/circle-relayer-deployment.yaml b/typescript/infra/helm/liquidity-layer-relayers/templates/circle-relayer-deployment.yaml
deleted file mode 100644
index a41589924f..0000000000
--- a/typescript/infra/helm/liquidity-layer-relayers/templates/circle-relayer-deployment.yaml
+++ /dev/null
@@ -1,26 +0,0 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: circle-relayer
-spec:
- replicas: 1
- selector:
- matchLabels:
- name: circle-relayer
- template:
- metadata:
- labels:
- name: circle-relayer
- spec:
- containers:
- - name: circle-relayer
- image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
- imagePullPolicy: IfNotPresent
- command:
- - ./node_modules/.bin/tsx
- - ./typescript/infra/scripts/middleware/circle-relayer.ts
- - -e
- - {{ .Values.hyperlane.runEnv }}
- envFrom:
- - secretRef:
- name: liquidity-layer-env-var-secret
diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/env-var-external-secret.yaml b/typescript/infra/helm/liquidity-layer-relayers/templates/env-var-external-secret.yaml
deleted file mode 100644
index 62b3117120..0000000000
--- a/typescript/infra/helm/liquidity-layer-relayers/templates/env-var-external-secret.yaml
+++ /dev/null
@@ -1,45 +0,0 @@
-apiVersion: external-secrets.io/v1beta1
-kind: ExternalSecret
-metadata:
- name: liquidity-layer-env-var-external-secret
- labels:
- {{- include "hyperlane.labels" . | nindent 4 }}
-spec:
- secretStoreRef:
- name: {{ include "hyperlane.cluster-secret-store.name" . }}
- kind: ClusterSecretStore
- refreshInterval: "1h"
- # The secret that will be created
- target:
- name: liquidity-layer-env-var-secret
- template:
- type: Opaque
- metadata:
- labels:
- {{- include "hyperlane.labels" . | nindent 10 }}
- annotations:
- update-on-redeploy: "{{ now }}"
- data:
- GCP_SECRET_OVERRIDES_ENABLED: "true"
- GCP_SECRET_OVERRIDE_HYPERLANE_{{ .Values.hyperlane.runEnv | upper }}_KEY_DEPLOYER: {{ print "'{{ .deployer_key | toString }}'" }}
-{{/*
- * For each network, create an environment variable with the RPC endpoint.
- * The templating of external-secrets will use the data section below to know how
- * to replace the correct value in the created secret.
- */}}
- {{- range .Values.hyperlane.chains }}
- GCP_SECRET_OVERRIDE_{{ $.Values.hyperlane.runEnv | upper }}_RPC_ENDPOINTS_{{ . | upper }}: {{ printf "'{{ .%s_rpcs | toString }}'" . }}
- {{- end }}
- data:
- - secretKey: deployer_key
- remoteRef:
- key: {{ printf "hyperlane-%s-key-deployer" .Values.hyperlane.runEnv }}
-{{/*
- * For each network, load the secret in GCP secret manager with the form: environment-rpc-endpoint-network,
- * and associate it with the secret key networkname_rpc.
- */}}
- {{- range .Values.hyperlane.chains }}
- - secretKey: {{ printf "%s_rpcs" . }}
- remoteRef:
- key: {{ printf "%s-rpc-endpoints-%s" $.Values.hyperlane.runEnv . }}
- {{- end }}
diff --git a/typescript/infra/helm/liquidity-layer-relayers/templates/portal-relayer-deployment.yaml b/typescript/infra/helm/liquidity-layer-relayers/templates/portal-relayer-deployment.yaml
deleted file mode 100644
index 933210d8d8..0000000000
--- a/typescript/infra/helm/liquidity-layer-relayers/templates/portal-relayer-deployment.yaml
+++ /dev/null
@@ -1,26 +0,0 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: portal-relayer
-spec:
- replicas: 1
- selector:
- matchLabels:
- name: portal-relayer
- template:
- metadata:
- labels:
- name: portal-relayer
- spec:
- containers:
- - name: portal-relayer
- image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
- imagePullPolicy: IfNotPresent
- command:
- - ./node_modules/.bin/tsx
- - ./typescript/infra/scripts/middleware/portal-relayer.ts
- - -e
- - {{ .Values.hyperlane.runEnv }}
- envFrom:
- - secretRef:
- name: liquidity-layer-env-var-secret
diff --git a/typescript/infra/helm/liquidity-layer-relayers/values.yaml b/typescript/infra/helm/liquidity-layer-relayers/values.yaml
deleted file mode 100644
index 95651f852a..0000000000
--- a/typescript/infra/helm/liquidity-layer-relayers/values.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-image:
- repository: gcr.io/abacus-labs-dev/hyperlane-monorepo
- tag:
-abacus:
- runEnv: testnet4
- # Used for fetching secrets
- chains: []
-externalSecrets:
- clusterSecretStore:
diff --git a/typescript/infra/scripts/deploy.ts b/typescript/infra/scripts/deploy.ts
index 22245d13b0..a4207cdc61 100644
--- a/typescript/infra/scripts/deploy.ts
+++ b/typescript/infra/scripts/deploy.ts
@@ -20,7 +20,6 @@ import {
InterchainAccount,
InterchainAccountDeployer,
InterchainQueryDeployer,
- LiquidityLayerDeployer,
TestRecipientDeployer,
} from '@hyperlane-xyz/sdk';
import { inCIMode, objFilter, objMap } from '@hyperlane-xyz/utils';
@@ -176,24 +175,6 @@ async function main() {
contractVerifier,
concurrentDeploy,
);
- } else if (module === Modules.LIQUIDITY_LAYER) {
- const { core } = await getHyperlaneCore(environment, multiProvider);
- const routerConfig = core.getRouterConfig(envConfig.owners);
- if (!envConfig.liquidityLayerConfig) {
- throw new Error(`No liquidity layer config for ${environment}`);
- }
- config = objMap(
- envConfig.liquidityLayerConfig.bridgeAdapters,
- (chain, conf) => ({
- ...conf,
- ...routerConfig[chain],
- }),
- );
- deployer = new LiquidityLayerDeployer(
- multiProvider,
- contractVerifier,
- concurrentDeploy,
- );
} else if (module === Modules.TEST_RECIPIENT) {
const addresses = getAddresses(environment, Modules.CORE);
diff --git a/typescript/infra/scripts/middleware/circle-relayer.ts b/typescript/infra/scripts/middleware/circle-relayer.ts
deleted file mode 100644
index b125a2eeb4..0000000000
--- a/typescript/infra/scripts/middleware/circle-relayer.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { join } from 'path';
-
-import {
- LiquidityLayerApp,
- LiquidityLayerConfig,
- attachContractsMap,
- liquidityLayerFactories,
-} from '@hyperlane-xyz/sdk';
-import { objFilter, sleep } from '@hyperlane-xyz/utils';
-
-import { getInfraPath, readJSON } from '../../src/utils/utils.js';
-import { getArgs, getEnvironmentDirectory } from '../agent-utils.js';
-import { getEnvironmentConfig } from '../core-utils.js';
-
-async function check() {
- const { environment } = await getArgs().argv;
- const config = getEnvironmentConfig(environment);
-
- if (config.liquidityLayerConfig === undefined) {
- throw new Error(`No liquidity layer config found for ${environment}`);
- }
-
- const multiProvider = await config.getMultiProvider();
- const dir = join(
- getInfraPath(),
- getEnvironmentDirectory(environment),
- 'middleware/liquidity-layer',
- );
- const addresses = readJSON(dir, 'addresses.json');
- const contracts = attachContractsMap(addresses, liquidityLayerFactories);
-
- const app = new LiquidityLayerApp(
- contracts,
- multiProvider,
- config.liquidityLayerConfig.bridgeAdapters,
- );
-
- while (true) {
- for (const chain of Object.keys(
- objFilter(
- config.liquidityLayerConfig.bridgeAdapters,
- (_, config): config is LiquidityLayerConfig => !!config.circle,
- ),
- )) {
- const txHashes = await app.fetchCircleMessageTransactions(chain);
-
- const circleDispatches = (
- await Promise.all(
- txHashes.map((txHash) => app.parseCircleMessages(chain, txHash)),
- )
- ).flat();
-
- // Poll for attestation data and submit
- for (const message of circleDispatches) {
- await app.attemptCircleAttestationSubmission(message);
- }
-
- await sleep(6000);
- }
- }
-}
-
-check().then(console.log).catch(console.error);
diff --git a/typescript/infra/scripts/middleware/deploy-relayers.ts b/typescript/infra/scripts/middleware/deploy-relayers.ts
deleted file mode 100644
index 1dc38f23c3..0000000000
--- a/typescript/infra/scripts/middleware/deploy-relayers.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { Contexts } from '../../config/contexts.js';
-import {
- getLiquidityLayerRelayerConfig,
- runLiquidityLayerRelayerHelmCommand,
-} from '../../src/middleware/liquidity-layer-relayer.js';
-import { HelmCommand } from '../../src/utils/helm.js';
-import { assertCorrectKubeContext } from '../agent-utils.js';
-import { getConfigsBasedOnArgs } from '../core-utils.js';
-
-async function main() {
- const { agentConfig, envConfig, context } = await getConfigsBasedOnArgs();
- if (context != Contexts.Hyperlane)
- throw new Error(`Context must be ${Contexts.Hyperlane}, but is ${context}`);
-
- await assertCorrectKubeContext(envConfig);
-
- const liquidityLayerRelayerConfig = getLiquidityLayerRelayerConfig(envConfig);
-
- await runLiquidityLayerRelayerHelmCommand(
- HelmCommand.InstallOrUpgrade,
- agentConfig,
- liquidityLayerRelayerConfig,
- );
-}
-
-main()
- .then(() => console.log('Deploy successful!'))
- .catch(console.error);
diff --git a/typescript/infra/scripts/middleware/portal-relayer.ts b/typescript/infra/scripts/middleware/portal-relayer.ts
deleted file mode 100644
index 27e327fcec..0000000000
--- a/typescript/infra/scripts/middleware/portal-relayer.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { join } from 'path';
-
-import {
- LiquidityLayerApp,
- attachContractsMap,
- liquidityLayerFactories,
-} from '@hyperlane-xyz/sdk';
-import { rootLogger, sleep } from '@hyperlane-xyz/utils';
-
-import { bridgeAdapterConfigs } from '../../config/environments/testnet4/token-bridge.js';
-import { getInfraPath, readJSON } from '../../src/utils/utils.js';
-import { getArgs, getEnvironmentDirectory } from '../agent-utils.js';
-import { getEnvironmentConfig } from '../core-utils.js';
-
-const logger = rootLogger.child({ module: 'portal-relayer' });
-
-async function relayPortalTransfers() {
- const { environment } = await getArgs().argv;
- const config = getEnvironmentConfig(environment);
- const multiProvider = await config.getMultiProvider();
- const dir = join(
- getInfraPath(),
- getEnvironmentDirectory(environment),
- 'middleware/liquidity-layer',
- );
- const addresses = readJSON(dir, 'addresses.json');
- const contracts = attachContractsMap(addresses, liquidityLayerFactories);
- const app = new LiquidityLayerApp(
- contracts,
- multiProvider,
- bridgeAdapterConfigs,
- );
-
- const tick = async () => {
- for (const chain of Object.keys(bridgeAdapterConfigs)) {
- logger.info('Processing chain', {
- chain,
- });
-
- const txHashes = await app.fetchPortalBridgeTransactions(chain);
- const portalMessages = (
- await Promise.all(
- txHashes.map((txHash) => app.parsePortalMessages(chain, txHash)),
- )
- ).flat();
-
- logger.info('Portal messages', {
- portalMessages,
- });
-
- // Poll for attestation data and submit
- for (const message of portalMessages) {
- try {
- await app.attemptPortalTransferCompletion(message);
- } catch (err) {
- logger.error('Error attempting portal transfer', {
- message,
- err,
- });
- }
- }
- await sleep(10000);
- }
- };
-
- while (true) {
- try {
- await tick();
- } catch (err) {
- logger.error('Error processing chains in tick', {
- err,
- });
- }
- }
-}
-
-relayPortalTransfers().then(console.log).catch(console.error);
diff --git a/typescript/infra/src/config/environment.ts b/typescript/infra/src/config/environment.ts
index 4166cdb5ea..8743526a72 100644
--- a/typescript/infra/src/config/environment.ts
+++ b/typescript/infra/src/config/environment.ts
@@ -1,6 +1,5 @@
import { IRegistry } from '@hyperlane-xyz/registry';
import {
- BridgeAdapterConfig,
ChainMap,
ChainName,
CoreConfig,
@@ -28,7 +27,6 @@ import { RootAgentConfig } from './agent/agent.js';
import { CheckWarpDeployConfig, KeyFunderConfig } from './funding.js';
import { HelloWorldConfig } from './helloworld/types.js';
import { InfrastructureConfig } from './infrastructure.js';
-import { LiquidityLayerRelayerConfig } from './middleware.js';
export type DeployEnvironment = keyof typeof environments;
export type EnvironmentChain = Extract<
@@ -70,10 +68,6 @@ export type EnvironmentConfig = {
helloWorld?: Partial>;
keyFunderConfig?: KeyFunderConfig;
checkWarpDeployConfig?: CheckWarpDeployConfig;
- liquidityLayerConfig?: {
- bridgeAdapters: ChainMap;
- relayer: LiquidityLayerRelayerConfig;
- };
};
export function assertEnvironment(env: string): DeployEnvironment {
diff --git a/typescript/infra/src/config/middleware.ts b/typescript/infra/src/config/middleware.ts
deleted file mode 100644
index 19fc3d8481..0000000000
--- a/typescript/infra/src/config/middleware.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { DockerConfig } from './agent/agent.js';
-
-export interface LiquidityLayerRelayerConfig {
- docker: DockerConfig;
- namespace: string;
- prometheusPushGateway: string;
-}
diff --git a/typescript/infra/src/middleware/liquidity-layer-relayer.ts b/typescript/infra/src/middleware/liquidity-layer-relayer.ts
deleted file mode 100644
index 4a1c3d7a17..0000000000
--- a/typescript/infra/src/middleware/liquidity-layer-relayer.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { AgentContextConfig } from '../config/agent/agent.js';
-import { EnvironmentConfig } from '../config/environment.js';
-import { LiquidityLayerRelayerConfig } from '../config/middleware.js';
-import { HelmCommand, helmifyValues } from '../utils/helm.js';
-import { execCmd } from '../utils/utils.js';
-
-export async function runLiquidityLayerRelayerHelmCommand(
- helmCommand: HelmCommand,
- agentConfig: AgentContextConfig,
- relayerConfig: LiquidityLayerRelayerConfig,
-) {
- const values = getLiquidityLayerRelayerHelmValues(agentConfig, relayerConfig);
-
- if (helmCommand === HelmCommand.InstallOrUpgrade) {
- // Delete secrets to avoid them being stale
- try {
- await execCmd(
- `kubectl delete secrets --namespace ${agentConfig.namespace} --selector app.kubernetes.io/instance=liquidity-layer-relayers`,
- {},
- false,
- false,
- );
- } catch (e) {
- console.error(e);
- }
- }
-
- return execCmd(
- `helm ${helmCommand} liquidity-layer-relayers ./helm/liquidity-layer-relayers --namespace ${
- relayerConfig.namespace
- } ${values.join(' ')}`,
- {},
- false,
- true,
- );
-}
-
-function getLiquidityLayerRelayerHelmValues(
- agentConfig: AgentContextConfig,
- relayerConfig: LiquidityLayerRelayerConfig,
-) {
- const values = {
- hyperlane: {
- runEnv: agentConfig.runEnv,
- // Only used for fetching RPC urls as env vars
- chains: agentConfig.contextChainNames,
- },
- image: {
- repository: relayerConfig.docker.repo,
- tag: relayerConfig.docker.tag,
- },
- infra: {
- prometheusPushGateway: relayerConfig.prometheusPushGateway,
- },
- };
- return helmifyValues(values);
-}
-
-export function getLiquidityLayerRelayerConfig(
- coreConfig: EnvironmentConfig,
-): LiquidityLayerRelayerConfig {
- const relayerConfig = coreConfig.liquidityLayerConfig?.relayer;
- if (!relayerConfig) {
- throw new Error(
- `Environment ${coreConfig.environment} does not have a LiquidityLayerRelayerConfig config`,
- );
- }
- return relayerConfig;
-}
diff --git a/typescript/infra/test/warpIds.test.ts b/typescript/infra/test/warpIds.test.ts
index 1adbefbf42..e0763e5c82 100644
--- a/typescript/infra/test/warpIds.test.ts
+++ b/typescript/infra/test/warpIds.test.ts
@@ -6,7 +6,8 @@ import { rootLogger } from '@hyperlane-xyz/utils';
import { WarpRouteIds } from '../config/environments/mainnet3/warp/warpIds.js';
-describe('Warp IDs', () => {
+// TODO: enable when merging audit branch to main
+describe.skip('Warp IDs', () => {
it('Has all warp IDs in the registry', async () => {
const registry = getRegistry({
registryUris: [DEFAULT_GITHUB_REGISTRY],
diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts
index d6557eb061..2a1e95d68b 100644
--- a/typescript/sdk/src/index.ts
+++ b/typescript/sdk/src/index.ts
@@ -318,16 +318,6 @@ export {
GetCallRemoteSettings,
GetCallRemoteSettingsSchema,
} from './middleware/account/types.js';
-export { liquidityLayerFactories } from './middleware/liquidity-layer/contracts.js';
-export { LiquidityLayerApp } from './middleware/liquidity-layer/LiquidityLayerApp.js';
-export {
- BridgeAdapterConfig,
- BridgeAdapterType,
- CircleBridgeAdapterConfig,
- LiquidityLayerConfig,
- LiquidityLayerDeployer,
- PortalAdapterConfig,
-} from './middleware/liquidity-layer/LiquidityLayerRouterDeployer.js';
export { interchainQueryFactories } from './middleware/query/contracts.js';
export { InterchainQuery } from './middleware/query/InterchainQuery.js';
export { InterchainQueryChecker } from './middleware/query/InterchainQueryChecker.js';
@@ -549,6 +539,7 @@ export {
} from './token/adapters/CosmWasmTokenAdapter.js';
export {
EvmHypCollateralAdapter,
+ EvmMovableCollateralAdapter,
EvmHypNativeAdapter,
EvmHypSyntheticAdapter,
EvmHypVSXERC20Adapter,
diff --git a/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerApp.ts b/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerApp.ts
deleted file mode 100644
index 9fbcc838f7..0000000000
--- a/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerApp.ts
+++ /dev/null
@@ -1,309 +0,0 @@
-import { ethers } from 'ethers';
-
-import {
- CircleBridgeAdapter__factory,
- ICircleMessageTransmitter__factory,
- ITokenMessenger__factory,
- Mailbox__factory,
- PortalAdapter__factory,
-} from '@hyperlane-xyz/core';
-import {
- addressToBytes32,
- ensure0x,
- eqAddress,
- rootLogger,
- strip0x,
-} from '@hyperlane-xyz/utils';
-
-import { HyperlaneApp } from '../../app/HyperlaneApp.js';
-import { HyperlaneContracts } from '../../contracts/types.js';
-import { MultiProvider } from '../../providers/MultiProvider.js';
-import { ChainMap, ChainName } from '../../types.js';
-import { fetchWithTimeout } from '../../utils/fetch.js';
-
-import { BridgeAdapterConfig } from './LiquidityLayerRouterDeployer.js';
-import { liquidityLayerFactories } from './contracts.js';
-
-const logger = rootLogger.child({ module: 'LiquidityLayerApp' });
-
-const PORTAL_VAA_SERVICE_TESTNET_BASE_URL =
- 'https://wormhole-v2-testnet-api.certus.one/v1/signed_vaa/';
-const CIRCLE_ATTESTATIONS_TESTNET_BASE_URL =
- 'https://iris-api-sandbox.circle.com/attestations/';
-const CIRCLE_ATTESTATIONS_MAINNET_BASE_URL =
- 'https://iris-api.circle.com/attestations/';
-
-const PORTAL_VAA_SERVICE_SUCCESS_CODE = 5;
-
-const TokenMessengerInterface = ITokenMessenger__factory.createInterface();
-const CircleBridgeAdapterInterface =
- CircleBridgeAdapter__factory.createInterface();
-const PortalAdapterInterface = PortalAdapter__factory.createInterface();
-const MailboxInterface = Mailbox__factory.createInterface();
-
-const BridgedTokenTopic = CircleBridgeAdapterInterface.getEventTopic(
- CircleBridgeAdapterInterface.getEvent('BridgedToken'),
-);
-
-const PortalBridgedTokenTopic = PortalAdapterInterface.getEventTopic(
- PortalAdapterInterface.getEvent('BridgedToken'),
-);
-
-interface CircleBridgeMessage {
- chain: ChainName;
- remoteChain: ChainName;
- txHash: string;
- message: string;
- nonce: number;
- domain: number;
- nonceHash: string;
-}
-
-interface PortalBridgeMessage {
- origin: ChainName;
- nonce: number;
- portalSequence: number;
- destination: ChainName;
-}
-
-export class LiquidityLayerApp extends HyperlaneApp<
- typeof liquidityLayerFactories
-> {
- constructor(
- public readonly contractsMap: ChainMap<
- HyperlaneContracts
- >,
- public readonly multiProvider: MultiProvider,
- public readonly config: ChainMap,
- ) {
- super(contractsMap, multiProvider);
- }
-
- async fetchCircleMessageTransactions(chain: ChainName): Promise {
- logger.info(`Fetch circle messages for ${chain}`);
- const url = new URL(this.multiProvider.getExplorerApiUrl(chain));
- url.searchParams.set('module', 'logs');
- url.searchParams.set('action', 'getLogs');
- url.searchParams.set(
- 'address',
- this.getContracts(chain).circleBridgeAdapter!.address,
- );
- url.searchParams.set('topic0', BridgedTokenTopic);
- const req = await fetchWithTimeout(url);
- const response = await req.json();
-
- return response.result.map((tx: any) => tx.transactionHash).flat();
- }
-
- async fetchPortalBridgeTransactions(chain: ChainName): Promise {
- const url = new URL(this.multiProvider.getExplorerApiUrl(chain));
- url.searchParams.set('module', 'logs');
- url.searchParams.set('action', 'getLogs');
- url.searchParams.set(
- 'address',
- this.getContracts(chain).portalAdapter!.address,
- );
- url.searchParams.set('topic0', PortalBridgedTokenTopic);
- const req = await fetchWithTimeout(url);
- const response = await req.json();
-
- if (!response.result) {
- throw Error(`Expected result in response: ${response}`);
- }
-
- return response.result.map((tx: any) => tx.transactionHash).flat();
- }
-
- async parsePortalMessages(
- chain: ChainName,
- txHash: string,
- ): Promise {
- const provider = this.multiProvider.getProvider(chain);
- const receipt = await provider.getTransactionReceipt(txHash);
- const matchingLogs = receipt.logs
- .map((log) => {
- try {
- return [PortalAdapterInterface.parseLog(log)];
- } catch {
- return [];
- }
- })
- .flat();
- if (matchingLogs.length == 0) return [];
-
- const event = matchingLogs.find((log) => log!.name === 'BridgedToken')!;
- const portalSequence = event.args.portalSequence.toNumber();
- const nonce = event.args.nonce.toNumber();
- const destination = this.multiProvider.getChainName(event.args.destination);
-
- return [{ origin: chain, nonce, portalSequence, destination }];
- }
-
- async parseCircleMessages(
- chain: ChainName,
- txHash: string,
- ): Promise {
- logger.debug(`Parse Circle messages for chain ${chain} ${txHash}`);
- const provider = this.multiProvider.getProvider(chain);
- const receipt = await provider.getTransactionReceipt(txHash);
- const matchingLogs = receipt.logs
- .map((log) => {
- try {
- return [TokenMessengerInterface.parseLog(log)];
- } catch {
- try {
- return [CircleBridgeAdapterInterface.parseLog(log)];
- } catch {
- try {
- return [MailboxInterface.parseLog(log)];
- } catch {
- return [];
- }
- }
- }
- })
- .flat();
-
- if (matchingLogs.length == 0) return [];
- const message = matchingLogs.find((log) => log!.name === 'MessageSent')!
- .args.message;
- const nonce = matchingLogs.find((log) => log!.name === 'BridgedToken')!.args
- .nonce;
-
- const destinationDomain = matchingLogs.find(
- (log) => log!.name === 'Dispatch',
- )!.args.destination;
-
- const remoteChain = this.multiProvider.getChainName(destinationDomain);
- const domain = this.config[chain].circle!.circleDomainMapping.find(
- (mapping) =>
- mapping.hyperlaneDomain === this.multiProvider.getDomainId(chain),
- )!.circleDomain;
- return [
- {
- chain,
- remoteChain,
- txHash,
- message,
- nonce,
- domain,
- nonceHash: ethers.utils.solidityKeccak256(
- ['uint32', 'uint64'],
- [domain, nonce],
- ),
- },
- ];
- }
-
- async attemptPortalTransferCompletion(
- message: PortalBridgeMessage,
- ): Promise {
- const destinationPortalAdapter = this.getContracts(
- message.destination,
- ).portalAdapter!;
-
- const transferId = await destinationPortalAdapter.transferId(
- this.multiProvider.getDomainId(message.origin),
- message.nonce,
- );
-
- const transferTokenAddress =
- await destinationPortalAdapter.portalTransfersProcessed(transferId);
-
- if (!eqAddress(transferTokenAddress, ethers.constants.AddressZero)) {
- logger.info(
- `Transfer with nonce ${message.nonce} from ${message.origin} to ${message.destination} already processed`,
- );
- return;
- }
-
- const wormholeOriginDomain = this.config[
- message.destination
- ].portal!.wormholeDomainMapping.find(
- (mapping) =>
- mapping.hyperlaneDomain ===
- this.multiProvider.getDomainId(message.origin),
- )?.wormholeDomain;
- const emitter = strip0x(
- addressToBytes32(this.config[message.origin].portal!.portalBridgeAddress),
- );
-
- const vaa = await fetchWithTimeout(
- `${PORTAL_VAA_SERVICE_TESTNET_BASE_URL}${wormholeOriginDomain}/${emitter}/${message.portalSequence}`,
- ).then((response) => response.json());
-
- if (vaa.code && vaa.code === PORTAL_VAA_SERVICE_SUCCESS_CODE) {
- logger.info(`VAA not yet found for nonce ${message.nonce}`);
- return;
- }
-
- logger.debug(
- `Complete portal transfer for nonce ${message.nonce} on ${message.destination}`,
- );
-
- try {
- await this.multiProvider.handleTx(
- message.destination,
- destinationPortalAdapter.completeTransfer(
- ensure0x(Buffer.from(vaa.vaaBytes, 'base64').toString('hex')),
- ),
- );
- } catch (error: any) {
- if (error?.error?.reason?.includes('no wrapper for this token')) {
- logger.info(
- 'No wrapper for this token, you should register the token at https://wormhole-foundation.github.io/example-token-bridge-ui/#/register',
- );
- logger.info(message);
- return;
- }
- throw error;
- }
- }
-
- async attemptCircleAttestationSubmission(
- message: CircleBridgeMessage,
- ): Promise {
- const signer = this.multiProvider.getSigner(message.remoteChain);
- const transmitter = ICircleMessageTransmitter__factory.connect(
- this.config[message.remoteChain].circle!.messageTransmitterAddress,
- signer,
- );
-
- const alreadyProcessed = await transmitter.usedNonces(message.nonceHash);
-
- if (alreadyProcessed) {
- logger.info(`Message sent on ${message.txHash} was already processed`);
- return;
- }
-
- logger.info(`Attempt Circle message delivery`, JSON.stringify(message));
-
- const messageHash = ethers.utils.keccak256(message.message);
- const baseurl = this.multiProvider.getChainMetadata(message.chain).isTestnet
- ? CIRCLE_ATTESTATIONS_TESTNET_BASE_URL
- : CIRCLE_ATTESTATIONS_MAINNET_BASE_URL;
- const attestationsB = await fetchWithTimeout(`${baseurl}${messageHash}`);
- const attestations = await attestationsB.json();
-
- if (attestations.status !== 'complete') {
- logger.info(
- `Attestations not available for message nonce ${message.nonce} on ${message.txHash}`,
- );
- return;
- }
- logger.info(`Ready to submit attestations for message ${message.nonce}`);
-
- const tx = await transmitter.receiveMessage(
- message.message,
- attestations.attestation,
- );
-
- logger.info(
- `Submitted attestations in ${this.multiProvider.tryGetExplorerTxUrl(
- message.remoteChain,
- tx,
- )}`,
- );
- await this.multiProvider.handleTx(message.remoteChain, tx);
- }
-}
diff --git a/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerRouterDeployer.ts b/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerRouterDeployer.ts
deleted file mode 100644
index ba1733dbd6..0000000000
--- a/typescript/sdk/src/middleware/liquidity-layer/LiquidityLayerRouterDeployer.ts
+++ /dev/null
@@ -1,319 +0,0 @@
-import { ethers } from 'ethers';
-
-import {
- CircleBridgeAdapter,
- LiquidityLayerRouter,
- PortalAdapter,
- Router,
-} from '@hyperlane-xyz/core';
-import { Address, eqAddress, objFilter, objMap } from '@hyperlane-xyz/utils';
-
-import {
- HyperlaneContracts,
- HyperlaneContractsMap,
-} from '../../contracts/types.js';
-import { ContractVerifier } from '../../deploy/verify/ContractVerifier.js';
-import { MultiProvider } from '../../providers/MultiProvider.js';
-import { ProxiedRouterDeployer } from '../../router/ProxiedRouterDeployer.js';
-import { RouterConfig } from '../../router/types.js';
-import { ChainMap, ChainName } from '../../types.js';
-
-import {
- LiquidityLayerFactories,
- liquidityLayerFactories,
-} from './contracts.js';
-
-export enum BridgeAdapterType {
- Circle = 'Circle',
- Portal = 'Portal',
-}
-
-export interface CircleBridgeAdapterConfig {
- type: BridgeAdapterType.Circle;
- tokenMessengerAddress: string;
- messageTransmitterAddress: string;
- usdcAddress: string;
- circleDomainMapping: {
- hyperlaneDomain: number;
- circleDomain: number;
- }[];
-}
-
-export interface PortalAdapterConfig {
- type: BridgeAdapterType.Portal;
- portalBridgeAddress: string;
- wormholeDomainMapping: {
- hyperlaneDomain: number;
- wormholeDomain: number;
- }[];
-}
-
-export type BridgeAdapterConfig = {
- circle?: CircleBridgeAdapterConfig;
- portal?: PortalAdapterConfig;
-};
-
-export type LiquidityLayerConfig = RouterConfig & BridgeAdapterConfig;
-
-export class LiquidityLayerDeployer extends ProxiedRouterDeployer<
- LiquidityLayerConfig,
- LiquidityLayerFactories
-> {
- constructor(
- multiProvider: MultiProvider,
- contractVerifier?: ContractVerifier,
- concurrentDeploy = false,
- ) {
- super(multiProvider, liquidityLayerFactories, {
- contractVerifier,
- concurrentDeploy,
- });
- }
-
- routerContractName(): string {
- return 'LiquidityLayerRouter';
- }
-
- routerContractKey(
- _: RouterConfig,
- ): K {
- return 'liquidityLayerRouter' as K;
- }
-
- router(contracts: HyperlaneContracts): Router {
- return contracts.liquidityLayerRouter;
- }
-
- async constructorArgs(
- _: string,
- config: LiquidityLayerConfig,
- ): Promise> {
- return [config.mailbox] as any;
- }
-
- async initializeArgs(
- chain: string,
- config: LiquidityLayerConfig,
- ): Promise {
- const owner = await this.multiProvider.getSignerAddress(chain);
- if (typeof config.interchainSecurityModule === 'object') {
- throw new Error('ISM as object unimplemented');
- }
- return [
- config.hook ?? ethers.constants.AddressZero,
- config.interchainSecurityModule ?? ethers.constants.AddressZero,
- owner,
- ];
- }
-
- async enrollRemoteRouters(
- contractsMap: HyperlaneContractsMap,
- configMap: ChainMap,
- foreignRouters: ChainMap,
- ): Promise {
- this.logger.debug(`Enroll LiquidityLayerRouters with each other`);
- await super.enrollRemoteRouters(contractsMap, configMap, foreignRouters);
-
- this.logger.debug(`Enroll CircleBridgeAdapters with each other`);
- // Hack to allow use of super.enrollRemoteRouters
- await super.enrollRemoteRouters(
- objMap(
- objFilter(
- contractsMap,
- (_, c): c is HyperlaneContracts =>
- !!c.circleBridgeAdapter,
- ),
- (_, contracts) => ({
- liquidityLayerRouter: contracts.circleBridgeAdapter,
- }),
- ) as unknown as HyperlaneContractsMap,
- configMap,
- foreignRouters,
- );
-
- this.logger.debug(`Enroll PortalAdapters with each other`);
- // Hack to allow use of super.enrollRemoteRouters
- await super.enrollRemoteRouters(
- objMap(
- objFilter(
- contractsMap,
- (_, c): c is HyperlaneContracts =>
- !!c.portalAdapter,
- ),
- (_, contracts) => ({
- liquidityLayerRouter: contracts.portalAdapter,
- }),
- ) as unknown as HyperlaneContractsMap,
- configMap,
- foreignRouters,
- );
- }
-
- // Custom contract deployment logic can go here
- // If no custom logic is needed, call deployContract for the router
- async deployContracts(
- chain: ChainName,
- config: LiquidityLayerConfig,
- ): Promise> {
- // This is just the temp owner for contracts, and HyperlaneRouterDeployer#transferOwnership actually sets the configured owner
- const deployer = await this.multiProvider.getSignerAddress(chain);
-
- const routerContracts = await super.deployContracts(chain, config);
-
- const bridgeAdapters: Partial<
- HyperlaneContracts
- > = {};
-
- if (config.circle) {
- bridgeAdapters.circleBridgeAdapter = await this.deployCircleBridgeAdapter(
- chain,
- config.circle,
- deployer,
- routerContracts.liquidityLayerRouter,
- );
- }
- if (config.portal) {
- bridgeAdapters.portalAdapter = await this.deployPortalAdapter(
- chain,
- config.portal,
- deployer,
- routerContracts.liquidityLayerRouter,
- );
- }
-
- return {
- ...routerContracts,
- ...bridgeAdapters,
- };
- }
-
- async deployPortalAdapter(
- chain: ChainName,
- adapterConfig: PortalAdapterConfig,
- owner: string,
- router: LiquidityLayerRouter,
- ): Promise {
- const mailbox = await router.mailbox();
- const portalAdapter = await this.deployContract(
- chain,
- 'portalAdapter',
- [mailbox],
- [owner, adapterConfig.portalBridgeAddress, router.address],
- );
-
- for (const {
- wormholeDomain,
- hyperlaneDomain,
- } of adapterConfig.wormholeDomainMapping) {
- const expectedCircleDomain =
- await portalAdapter.hyperlaneDomainToWormholeDomain(hyperlaneDomain);
- if (expectedCircleDomain === wormholeDomain) continue;
-
- this.logger.debug(
- `Set wormhole domain ${wormholeDomain} for hyperlane domain ${hyperlaneDomain}`,
- );
- await this.runIfOwner(chain, portalAdapter, () =>
- this.multiProvider.handleTx(
- chain,
- portalAdapter.addDomain(hyperlaneDomain, wormholeDomain),
- ),
- );
- }
-
- if (
- !eqAddress(
- await router.liquidityLayerAdapters('Portal'),
- portalAdapter.address,
- )
- ) {
- this.logger.debug('Set Portal as LiquidityLayerAdapter on Router');
- await this.runIfOwner(chain, portalAdapter, () =>
- this.multiProvider.handleTx(
- chain,
- router.setLiquidityLayerAdapter(
- adapterConfig.type,
- portalAdapter.address,
- ),
- ),
- );
- }
-
- return portalAdapter;
- }
-
- async deployCircleBridgeAdapter(
- chain: ChainName,
- adapterConfig: CircleBridgeAdapterConfig,
- owner: string,
- router: LiquidityLayerRouter,
- ): Promise {
- const mailbox = await router.mailbox();
- const circleBridgeAdapter = await this.deployContract(
- chain,
- 'circleBridgeAdapter',
- [mailbox],
- [
- owner,
- adapterConfig.tokenMessengerAddress,
- adapterConfig.messageTransmitterAddress,
- router.address,
- ],
- );
-
- if (
- !eqAddress(
- await circleBridgeAdapter.tokenSymbolToAddress('USDC'),
- adapterConfig.usdcAddress,
- )
- ) {
- this.logger.debug(`Set USDC token contract`);
- await this.runIfOwner(chain, circleBridgeAdapter, () =>
- this.multiProvider.handleTx(
- chain,
- circleBridgeAdapter.addToken(adapterConfig.usdcAddress, 'USDC'),
- ),
- );
- }
- // Set domain mappings
- for (const {
- circleDomain,
- hyperlaneDomain,
- } of adapterConfig.circleDomainMapping) {
- const expectedCircleDomain =
- await circleBridgeAdapter.hyperlaneDomainToCircleDomain(
- hyperlaneDomain,
- );
- if (expectedCircleDomain === circleDomain) continue;
-
- this.logger.debug(
- `Set circle domain ${circleDomain} for hyperlane domain ${hyperlaneDomain}`,
- );
- await this.runIfOwner(chain, circleBridgeAdapter, () =>
- this.multiProvider.handleTx(
- chain,
- circleBridgeAdapter.addDomain(hyperlaneDomain, circleDomain),
- ),
- );
- }
-
- if (
- !eqAddress(
- await router.liquidityLayerAdapters('Circle'),
- circleBridgeAdapter.address,
- )
- ) {
- this.logger.debug('Set Circle as LiquidityLayerAdapter on Router');
- await this.runIfOwner(chain, circleBridgeAdapter, () =>
- this.multiProvider.handleTx(
- chain,
- router.setLiquidityLayerAdapter(
- adapterConfig.type,
- circleBridgeAdapter.address,
- ),
- ),
- );
- }
-
- return circleBridgeAdapter;
- }
-}
diff --git a/typescript/sdk/src/middleware/liquidity-layer/contracts.ts b/typescript/sdk/src/middleware/liquidity-layer/contracts.ts
deleted file mode 100644
index ef88008b94..0000000000
--- a/typescript/sdk/src/middleware/liquidity-layer/contracts.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import {
- CircleBridgeAdapter__factory,
- LiquidityLayerRouter__factory,
- PortalAdapter__factory,
-} from '@hyperlane-xyz/core';
-
-import { proxiedFactories } from '../../router/types.js';
-
-export const liquidityLayerFactories = {
- circleBridgeAdapter: new CircleBridgeAdapter__factory(),
- portalAdapter: new PortalAdapter__factory(),
- liquidityLayerRouter: new LiquidityLayerRouter__factory(),
- ...proxiedFactories,
-};
-
-export type LiquidityLayerFactories = typeof liquidityLayerFactories;
diff --git a/typescript/sdk/src/middleware/liquidity-layer/liquidity-layer.hardhat-test.ts b/typescript/sdk/src/middleware/liquidity-layer/liquidity-layer.hardhat-test.ts
deleted file mode 100644
index cf7bdde70c..0000000000
--- a/typescript/sdk/src/middleware/liquidity-layer/liquidity-layer.hardhat-test.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers.js';
-import { expect } from 'chai';
-import hre from 'hardhat';
-
-import {
- LiquidityLayerRouter,
- MockCircleMessageTransmitter,
- MockCircleMessageTransmitter__factory,
- MockCircleTokenMessenger,
- MockCircleTokenMessenger__factory,
- MockPortalBridge,
- MockPortalBridge__factory,
- MockToken,
- MockToken__factory,
- TestLiquidityLayerMessageRecipient__factory,
-} from '@hyperlane-xyz/core';
-import { addressToBytes32, objMap } from '@hyperlane-xyz/utils';
-
-import { TestChainName, test1, test2 } from '../../consts/testChains.js';
-import { TestCoreApp } from '../../core/TestCoreApp.js';
-import { TestCoreDeployer } from '../../core/TestCoreDeployer.js';
-import { HyperlaneProxyFactoryDeployer } from '../../deploy/HyperlaneProxyFactoryDeployer.js';
-import { HyperlaneIsmFactory } from '../../ism/HyperlaneIsmFactory.js';
-import { MultiProvider } from '../../providers/MultiProvider.js';
-import { ChainMap } from '../../types.js';
-
-import { LiquidityLayerApp } from './LiquidityLayerApp.js';
-import {
- BridgeAdapterType,
- CircleBridgeAdapterConfig,
- LiquidityLayerConfig,
- LiquidityLayerDeployer,
- PortalAdapterConfig,
-} from './LiquidityLayerRouterDeployer.js';
-
-// eslint-disable-next-line jest/no-disabled-tests
-describe.skip('LiquidityLayerRouter', async () => {
- const localChain = TestChainName.test1;
- const remoteChain = TestChainName.test2;
- const localDomain = test1.domainId!;
- const remoteDomain = test2.domainId!;
-
- let signer: SignerWithAddress;
- let local: LiquidityLayerRouter;
- let multiProvider: MultiProvider;
- let coreApp: TestCoreApp;
-
- let liquidityLayerApp: LiquidityLayerApp;
- let config: ChainMap;
- let mockToken: MockToken;
- let circleTokenMessenger: MockCircleTokenMessenger;
- let portalBridge: MockPortalBridge;
- let messageTransmitter: MockCircleMessageTransmitter;
-
- before(async () => {
- [signer] = await hre.ethers.getSigners();
- multiProvider = MultiProvider.createTestMultiProvider({ signer });
- const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider);
- const ismFactory = new HyperlaneIsmFactory(
- await ismFactoryDeployer.deploy(multiProvider.mapKnownChains(() => ({}))),
- multiProvider,
- );
- coreApp = await new TestCoreDeployer(multiProvider, ismFactory).deployApp();
- const routerConfig = coreApp.getRouterConfig(signer.address);
-
- const mockTokenF = new MockToken__factory(signer);
- mockToken = await mockTokenF.deploy();
- const portalBridgeF = new MockPortalBridge__factory(signer);
- const circleTokenMessengerF = new MockCircleTokenMessenger__factory(signer);
- circleTokenMessenger = await circleTokenMessengerF.deploy(
- mockToken.address,
- );
- portalBridge = await portalBridgeF.deploy(mockToken.address);
- const messageTransmitterF = new MockCircleMessageTransmitter__factory(
- signer,
- );
- messageTransmitter = await messageTransmitterF.deploy(mockToken.address);
-
- config = objMap(routerConfig, (chain, config) => {
- return {
- ...config,
- circle: {
- type: BridgeAdapterType.Circle,
- tokenMessengerAddress: circleTokenMessenger.address,
- messageTransmitterAddress: messageTransmitter.address,
- usdcAddress: mockToken.address,
- circleDomainMapping: [
- {
- hyperlaneDomain: localDomain,
- circleDomain: localDomain,
- },
- {
- hyperlaneDomain: remoteDomain,
- circleDomain: remoteDomain,
- },
- ],
- } as CircleBridgeAdapterConfig,
- portal: {
- type: BridgeAdapterType.Portal,
- portalBridgeAddress: portalBridge.address,
- wormholeDomainMapping: [
- {
- hyperlaneDomain: localDomain,
- wormholeDomain: localDomain,
- },
- {
- hyperlaneDomain: remoteDomain,
- wormholeDomain: remoteDomain,
- },
- ],
- } as PortalAdapterConfig,
- };
- });
- });
-
- beforeEach(async () => {
- const LiquidityLayer = new LiquidityLayerDeployer(multiProvider);
- const contracts = await LiquidityLayer.deploy(config);
-
- liquidityLayerApp = new LiquidityLayerApp(contracts, multiProvider, config);
-
- local = liquidityLayerApp.getContracts(localChain).liquidityLayerRouter;
- });
-
- it('can transfer tokens via Circle', async () => {
- const recipientF = new TestLiquidityLayerMessageRecipient__factory(signer);
- const recipient = await recipientF.deploy();
-
- const amount = 1000;
- await mockToken.mint(signer.address, amount);
- await mockToken.approve(local.address, amount);
- await local.dispatchWithTokens(
- remoteDomain,
- addressToBytes32(recipient.address),
- mockToken.address,
- amount,
- BridgeAdapterType.Circle,
- '0x01',
- );
-
- const transferNonce = await circleTokenMessenger.nextNonce();
- const nonceId = await messageTransmitter.hashSourceAndNonce(
- localDomain,
- transferNonce,
- );
-
- await messageTransmitter.process(
- nonceId,
- liquidityLayerApp.getContracts(remoteChain).circleBridgeAdapter!.address,
- amount,
- );
- await coreApp.processMessages();
-
- expect((await mockToken.balanceOf(recipient.address)).toNumber()).to.eql(
- amount,
- );
- });
-
- it('can transfer tokens via Portal', async () => {
- const recipientF = new TestLiquidityLayerMessageRecipient__factory(signer);
- const recipient = await recipientF.deploy();
-
- const amount = 1000;
- await mockToken.mint(signer.address, amount);
- await mockToken.approve(local.address, amount);
- await local.dispatchWithTokens(
- remoteDomain,
- addressToBytes32(recipient.address),
- mockToken.address,
- amount,
- BridgeAdapterType.Portal,
- '0x01',
- );
-
- const originAdapter =
- liquidityLayerApp.getContracts(localChain).portalAdapter!;
- const destinationAdapter =
- liquidityLayerApp.getContracts(remoteChain).portalAdapter!;
- await destinationAdapter.completeTransfer(
- await portalBridge.mockPortalVaa(
- localDomain,
- await originAdapter.nonce(),
- amount,
- ),
- );
- await coreApp.processMessages();
-
- expect((await mockToken.balanceOf(recipient.address)).toNumber()).to.eql(
- amount,
- );
- });
-});
diff --git a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts
index 7738a308b8..7de3f26d17 100644
--- a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts
+++ b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts
@@ -96,6 +96,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => {
let vaultFactory: ERC4626Test__factory;
let vault: ERC4626Test;
let token: ERC20Test;
+ let feeToken: ERC20Test;
let signer: SignerWithAddress;
let multiProvider: MultiProvider;
let coreApp: TestCoreApp;
@@ -133,6 +134,12 @@ describe('EvmERC20WarpHyperlaneModule', async () => {
TOKEN_DECIMALS,
);
+ feeToken = await erc20Factory.deploy(
+ TOKEN_NAME,
+ TOKEN_NAME,
+ TOKEN_SUPPLY,
+ TOKEN_DECIMALS,
+ );
vaultFactory = new ERC4626Test__factory(signer);
vault = await vaultFactory.deploy(token.address, TOKEN_NAME, TOKEN_NAME);
@@ -175,18 +182,6 @@ describe('EvmERC20WarpHyperlaneModule', async () => {
token: token.address,
allowedRebalancers,
},
- [TokenType.collateralVault]: {
- ...baseConfig,
- type: TokenType.collateralVault,
- token: vault.address,
- allowedRebalancers,
- },
- [TokenType.collateralVaultRebase]: {
- ...baseConfig,
- type: TokenType.collateralVaultRebase,
- token: vault.address,
- allowedRebalancers,
- },
[TokenType.native]: {
...baseConfig,
type: TokenType.native,
@@ -953,7 +948,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => {
[domainId]: [
{
bridge: allowedBridgeToAdd,
- approvedTokens: [token.address],
+ approvedTokens: [feeToken.address],
},
],
},
@@ -972,7 +967,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => {
await warpTokenInstance.callStatic.allowedBridges(domainId);
expect(check[0]).to.eql(allowedBridgeToAdd);
- const allowance = await token.callStatic.allowance(
+ const allowance = await feeToken.callStatic.allowance(
evmERC20WarpModule.serialize().deployedTokenRoute,
allowedBridgeToAdd,
);
@@ -993,7 +988,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => {
[domainId]: [
{
bridge: allowedBridgeToAdd,
- approvedTokens: [token.address],
+ approvedTokens: [feeToken.address],
},
],
},
@@ -1045,7 +1040,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => {
[domainId]: [
{
bridge: allowedBridgeToAdd,
- approvedTokens: [token.address],
+ approvedTokens: [feeToken.address],
},
],
},
@@ -1065,7 +1060,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => {
[domainId]: [
{
bridge: allowedBridgeToAdd.toLowerCase(),
- approvedTokens: [token.address],
+ approvedTokens: [feeToken.address],
},
],
},
diff --git a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts
index c70abd3081..8c33e51d6f 100644
--- a/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts
+++ b/typescript/sdk/src/token/EvmERC20WarpRouteReader.ts
@@ -1,4 +1,4 @@
-import { BigNumber, Contract } from 'ethers';
+import { Contract } from 'ethers';
import {
HypERC20Collateral__factory,
@@ -9,13 +9,15 @@ import {
HypXERC20Lockbox__factory,
HypXERC20__factory,
IFiatToken__factory,
+ IMessageTransmitter__factory,
IXERC20__factory,
MovableCollateralRouter__factory,
OpL1NativeTokenBridge__factory,
OpL2NativeTokenBridge__factory,
PackageVersioned__factory,
ProxyAdmin__factory,
- TokenBridgeCctp__factory,
+ TokenBridgeCctpBase__factory,
+ TokenBridgeCctpV2__factory,
TokenRouter__factory,
} from '@hyperlane-xyz/core';
import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js';
@@ -23,6 +25,7 @@ import {
Address,
arrayToObject,
assert,
+ eqAddress,
getLogLevel,
isZeroishAddress,
objFilter,
@@ -247,14 +250,14 @@ export class EvmERC20WarpRouteReader extends EvmRouterReader {
const contractTypes: Partial<
Record
> = {
+ [TokenType.collateralVault]: {
+ factory: HypERC4626OwnerCollateral__factory,
+ method: 'assetDeposited',
+ },
[TokenType.collateralVaultRebase]: {
factory: HypERC4626Collateral__factory,
method: 'NULL_RECIPIENT',
},
- [TokenType.collateralVault]: {
- factory: HypERC4626OwnerCollateral__factory,
- method: 'vault',
- },
[TokenType.XERC20Lockbox]: {
factory: HypXERC20Lockbox__factory,
method: 'lockbox',
@@ -267,10 +270,6 @@ export class EvmERC20WarpRouteReader extends EvmRouterReader {
factory: HypERC4626__factory,
method: 'collateralDomain',
},
- [TokenType.synthetic]: {
- factory: HypERC20__factory,
- method: 'decimals',
- },
};
// Temporarily turn off SmartProvider logging
@@ -327,23 +326,25 @@ export class EvmERC20WarpRouteReader extends EvmRouterReader {
}
}
- // Finally check native
- // Using estimateGas to send 0 wei. Success implies that the Warp Route has a receive() function
- try {
- await this.multiProvider.estimateGas(
- this.chain,
- {
- to: warpRouteAddress,
- value: BigNumber.from(0),
- },
- NON_ZERO_SENDER_ADDRESS, // Use non-zero address as signer is not provided for read commands
- );
+ // Check for native vs synthetic by looking at the token() method
+ // HypNative.token() returns address(0), HypERC20.token() returns address(this)
+ const tokenRouter = TokenRouter__factory.connect(
+ warpRouteAddress,
+ this.provider,
+ );
+ const tokenAddress = await tokenRouter.token();
+
+ if (isZeroishAddress(tokenAddress)) {
+ // Native token returns address(0)
return TokenType.native;
- } catch (e) {
- throw Error(`Error accessing token specific method ${e}`);
- } finally {
- this.setSmartProviderLogLevel(getLogLevel()); // returns to original level defined by rootLogger
+ } else if (eqAddress(tokenAddress, warpRouteAddress)) {
+ // Synthetic token returns its own address (address(this))
+ return TokenType.synthetic;
}
+
+ throw new Error(
+ `Error deriving token type for token at address "${warpRouteAddress}" on chain "${this.chain}"`,
+ );
}
async fetchXERC20Config(
@@ -460,22 +461,53 @@ export class EvmERC20WarpRouteReader extends EvmRouterReader {
const collateralConfig =
await this.deriveHypCollateralTokenConfig(hypToken);
- const tokenBridge = TokenBridgeCctp__factory.connect(
+ const tokenBridge = TokenBridgeCctpBase__factory.connect(
hypToken,
this.provider,
);
- const messageTransmitter = await tokenBridge.messageTransmitter();
- const tokenMessenger = await tokenBridge.tokenMessenger();
- const urls = await tokenBridge.urls();
+ const [messageTransmitter, tokenMessenger, urls] = await Promise.all([
+ tokenBridge.messageTransmitter(),
+ tokenBridge.tokenMessenger(),
+ tokenBridge.urls(),
+ ]);
- return {
- ...collateralConfig,
- type: TokenType.collateralCctp,
+ const onchainCctpVersion = await IMessageTransmitter__factory.connect(
messageTransmitter,
- tokenMessenger,
- urls,
- };
+ this.provider,
+ ).version();
+
+ if (onchainCctpVersion === 0) {
+ return {
+ ...collateralConfig,
+ type: TokenType.collateralCctp,
+ cctpVersion: 'V1',
+ messageTransmitter,
+ tokenMessenger,
+ urls,
+ };
+ } else if (onchainCctpVersion === 1) {
+ const tokenBridgeV2 = TokenBridgeCctpV2__factory.connect(
+ hypToken,
+ this.provider,
+ );
+ const [minFinalityThreshold, maxFeeBps] = await Promise.all([
+ tokenBridgeV2.minFinalityThreshold(),
+ tokenBridgeV2.maxFeeBps(),
+ ]);
+ return {
+ ...collateralConfig,
+ type: TokenType.collateralCctp,
+ cctpVersion: 'V2',
+ messageTransmitter,
+ tokenMessenger,
+ urls,
+ minFinalityThreshold,
+ maxFeeBps: maxFeeBps.toNumber(),
+ };
+ } else {
+ throw new Error(`Unsupported CCTP version ${onchainCctpVersion}`);
+ }
}
private async deriveHypCollateralTokenConfig(
diff --git a/typescript/sdk/src/token/Token.ts b/typescript/sdk/src/token/Token.ts
index 43edec5e82..3c2c244833 100644
--- a/typescript/sdk/src/token/Token.ts
+++ b/typescript/sdk/src/token/Token.ts
@@ -43,7 +43,6 @@ import {
CosmNativeTokenAdapter,
} from './adapters/CosmosTokenAdapter.js';
import {
- EvmHypCollateralAdapter,
EvmHypCollateralFiatAdapter,
EvmHypNativeAdapter,
EvmHypRebaseCollateralAdapter,
@@ -51,6 +50,7 @@ import {
EvmHypSyntheticRebaseAdapter,
EvmHypXERC20Adapter,
EvmHypXERC20LockboxAdapter,
+ EvmMovableCollateralAdapter,
EvmNativeTokenAdapter,
EvmTokenAdapter,
} from './adapters/EvmTokenAdapter.js';
@@ -203,7 +203,7 @@ export class Token implements IToken {
standard === TokenStandard.EvmHypCollateral ||
standard === TokenStandard.EvmHypOwnerCollateral
) {
- return new EvmHypCollateralAdapter(chainName, multiProvider, {
+ return new EvmMovableCollateralAdapter(chainName, multiProvider, {
token: addressOrDenom,
});
} else if (standard === TokenStandard.EvmHypRebaseCollateral) {
diff --git a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts
index bdd38df308..9a1bced1ec 100644
--- a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts
+++ b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts
@@ -10,8 +10,6 @@ import {
ERC4626__factory,
GasRouter__factory,
HypERC20,
- HypERC20Collateral,
- HypERC20Collateral__factory,
HypERC20__factory,
HypERC4626,
HypERC4626Collateral,
@@ -21,11 +19,15 @@ import {
HypXERC20Lockbox,
HypXERC20Lockbox__factory,
HypXERC20__factory,
+ ITokenBridge__factory,
IXERC20,
IXERC20VS,
IXERC20VS__factory,
IXERC20__factory,
- ValueTransferBridge__factory,
+ MovableCollateralRouter,
+ MovableCollateralRouter__factory,
+ TokenRouter,
+ TokenRouter__factory,
} from '@hyperlane-xyz/core';
import {
Address,
@@ -281,11 +283,9 @@ export class EvmHypSyntheticAdapter
// Interacts with HypCollateral contracts
export class EvmHypCollateralAdapter
extends EvmHypSyntheticAdapter
- implements
- IHypTokenAdapter,
- IMovableCollateralRouterAdapter
+ implements IHypTokenAdapter
{
- public readonly collateralContract: HypERC20Collateral;
+ public readonly collateralContract: TokenRouter;
protected wrappedTokenAddress?: Address;
constructor(
@@ -294,7 +294,7 @@ export class EvmHypCollateralAdapter
public readonly addresses: { token: Address },
) {
super(chainName, multiProvider, addresses);
- this.collateralContract = HypERC20Collateral__factory.connect(
+ this.collateralContract = TokenRouter__factory.connect(
addresses.token,
this.getProvider(),
);
@@ -302,7 +302,7 @@ export class EvmHypCollateralAdapter
protected async getWrappedTokenAddress(): Promise {
if (!this.wrappedTokenAddress) {
- this.wrappedTokenAddress = await this.collateralContract.wrappedToken();
+ this.wrappedTokenAddress = await this.collateralContract.token();
}
return this.wrappedTokenAddress!;
}
@@ -355,22 +355,34 @@ export class EvmHypCollateralAdapter
t.populateTransferTx(params),
);
}
+}
+
+export class EvmMovableCollateralAdapter
+ extends EvmHypCollateralAdapter
+ implements IMovableCollateralRouterAdapter
+{
+ movableCollateral(): MovableCollateralRouter {
+ return MovableCollateralRouter__factory.connect(
+ this.addresses.token,
+ this.getProvider(),
+ );
+ }
async isRebalancer(account: Address): Promise {
- const rebalancers = await this.collateralContract.allowedRebalancers();
+ const rebalancers = await this.movableCollateral().allowedRebalancers();
return rebalancers.includes(account);
}
async getAllowedDestination(domain: Domain): Promise {
const allowedDestinationBytes32 =
- await this.collateralContract.allowedRecipient(domain);
+ await this.movableCollateral().allowedRecipient(domain);
// If allowedRecipient is not set (returns bytes32(0)),
// fall back to the enrolled remote router for that domain,
// matching the contract's fallback logic in MovableCollateralRouter.sol
if (allowedDestinationBytes32 === ZERO_ADDRESS_HEX_32) {
- const routerBytes32 = await this.collateralContract.routers(domain);
+ const routerBytes32 = await this.movableCollateral().routers(domain);
return bytes32ToAddress(routerBytes32);
}
@@ -378,7 +390,8 @@ export class EvmHypCollateralAdapter
}
async isBridgeAllowed(domain: Domain, bridge: Address): Promise {
- const allowedBridges = await this.collateralContract.allowedBridges(domain);
+ const allowedBridges =
+ await this.movableCollateral().allowedBridges(domain);
return allowedBridges.includes(bridge);
}
@@ -404,7 +417,7 @@ export class EvmHypCollateralAdapter
];
}
- const bridgeContract = ValueTransferBridge__factory.connect(
+ const bridgeContract = ITokenBridge__factory.connect(
bridge,
this.getProvider(),
);
@@ -437,7 +450,7 @@ export class EvmHypCollateralAdapter
0n,
);
- return this.collateralContract.populateTransaction.rebalance(
+ return this.movableCollateral().populateTransaction.rebalance(
domain,
amount,
bridge,
@@ -753,7 +766,7 @@ export class EvmHypVSXERC20Adapter
// Interacts HypNative contracts
export class EvmHypNativeAdapter
- extends EvmHypCollateralAdapter
+ extends EvmMovableCollateralAdapter
implements IHypTokenAdapter
{
override async isApproveRequired(): Promise {
@@ -808,7 +821,7 @@ export class EvmHypNativeAdapter
BigInt(amount),
);
- return this.collateralContract.populateTransaction.rebalance(
+ return this.movableCollateral().populateTransaction.rebalance(
domain,
amount,
bridge,
diff --git a/typescript/sdk/src/token/config.ts b/typescript/sdk/src/token/config.ts
index cd4f5d8a41..6edf3fa39f 100644
--- a/typescript/sdk/src/token/config.ts
+++ b/typescript/sdk/src/token/config.ts
@@ -26,8 +26,8 @@ const isMovableCollateralTokenTypeMap = {
[TokenType.collateralCctp]: false,
[TokenType.collateralFiat]: false,
[TokenType.collateralUri]: false,
- [TokenType.collateralVault]: true,
- [TokenType.collateralVaultRebase]: true,
+ [TokenType.collateralVault]: false,
+ [TokenType.collateralVaultRebase]: false,
[TokenType.native]: true,
[TokenType.nativeOpL1]: false,
[TokenType.nativeOpL2]: false,
diff --git a/typescript/sdk/src/token/contracts.ts b/typescript/sdk/src/token/contracts.ts
index b1c87ac81f..103872995f 100644
--- a/typescript/sdk/src/token/contracts.ts
+++ b/typescript/sdk/src/token/contracts.ts
@@ -14,7 +14,8 @@ import {
HypXERC20__factory,
OpL1V1NativeTokenBridge__factory,
OpL2NativeTokenBridge__factory,
- TokenBridgeCctp__factory,
+ TokenBridgeCctpV1__factory,
+ TokenBridgeCctpV2__factory,
} from '@hyperlane-xyz/core';
import { TokenType } from './config.js';
@@ -42,7 +43,8 @@ export type HypERC20contracts = typeof hypERC20contracts;
export const hypERC20factories = {
[TokenType.synthetic]: new HypERC20__factory(),
[TokenType.collateral]: new HypERC20Collateral__factory(),
- [TokenType.collateralCctp]: new TokenBridgeCctp__factory(),
+ // use V1 here to satisfy type requirements
+ [TokenType.collateralCctp]: new TokenBridgeCctpV1__factory(),
[TokenType.collateralVault]: new HypERC4626OwnerCollateral__factory(),
[TokenType.collateralVaultRebase]: new HypERC4626Collateral__factory(),
[TokenType.syntheticRebase]: new HypERC4626__factory(),
@@ -57,6 +59,13 @@ export const hypERC20factories = {
} as const;
export type HypERC20Factories = typeof hypERC20factories;
+// Helper function to get the appropriate CCTP factory based on version
+export function getCctpFactory(version: 'V1' | 'V2') {
+ return version === 'V1'
+ ? new TokenBridgeCctpV1__factory()
+ : new TokenBridgeCctpV2__factory();
+}
+
export const hypERC721contracts = {
[TokenType.collateralUri]: 'HypERC721URICollateral',
[TokenType.collateral]: 'HypERC721Collateral',
diff --git a/typescript/sdk/src/token/deploy.ts b/typescript/sdk/src/token/deploy.ts
index ca4b4287a0..b9d3fdd9f6 100644
--- a/typescript/sdk/src/token/deploy.ts
+++ b/typescript/sdk/src/token/deploy.ts
@@ -10,7 +10,7 @@ import {
MovableCollateralRouter__factory,
OpL1V1NativeTokenBridge__factory,
OpL2NativeTokenBridge__factory,
- TokenBridgeCctp__factory,
+ TokenBridgeCctpBase__factory,
} from '@hyperlane-xyz/core';
import {
ProtocolType,
@@ -40,6 +40,7 @@ import {
HypERC20contracts,
HypERC721Factories,
TokenFactories,
+ getCctpFactory,
hypERC20contracts,
hypERC20factories,
hypERC721contracts,
@@ -93,7 +94,7 @@ export const TOKEN_INITIALIZE_SIGNATURE = (
return OP_L1_INITIALIZE_SIGNATURE;
case 'TokenBridgeCctp':
assert(
- TokenBridgeCctp__factory.createInterface().functions[
+ TokenBridgeCctpBase__factory.createInterface().functions[
CCTP_INITIALIZE_SIGNATURE
],
'missing expected initialize function',
@@ -147,13 +148,26 @@ abstract class TokenDeployer<
);
return [config.decimals, scale, config.mailbox, collateralDomain];
} else if (isCctpTokenConfig(config)) {
- return [
- config.token,
- scale,
- config.mailbox,
- config.messageTransmitter,
- config.tokenMessenger,
- ];
+ switch (config.cctpVersion) {
+ case 'V1':
+ return [
+ config.token,
+ config.mailbox,
+ config.messageTransmitter,
+ config.tokenMessenger,
+ ];
+ case 'V2':
+ return [
+ config.token,
+ config.mailbox,
+ config.messageTransmitter,
+ config.tokenMessenger,
+ config.minFinalityThreshold,
+ config.maxFeeBps,
+ ];
+ default:
+ throw new Error('Unsupported CCTP version');
+ }
} else {
throw new Error('Unknown token type when constructing arguments');
}
@@ -333,7 +347,7 @@ abstract class TokenDeployer<
await promiseObjAll(
objMap(cctpConfigs, async (chain, _config) => {
const router = this.router(deployedContractsMap[chain]).address;
- const tokenBridge = TokenBridgeCctp__factory.connect(
+ const tokenBridge = TokenBridgeCctpBase__factory.connect(
router,
this.multiProvider.getSigner(chain),
);
@@ -530,8 +544,41 @@ export class HypERC20Deployer extends TokenDeployer {
}
routerContractName(config: HypTokenRouterConfig): string {
+ // Handle CCTP version-specific contract names
+ if (isCctpTokenConfig(config)) {
+ return `TokenBridgeCctp${config.cctpVersion}`;
+ }
return hypERC20contracts[this.routerContractKey(config)];
}
+
+ // Override deployContractFromFactory to handle CCTP version selection
+ async deployContractFromFactory(
+ chain: ChainName,
+ factory: any,
+ contractName: string,
+ constructorArgs: any[],
+ initializeArgs?: any[],
+ shouldRecover = true,
+ implementationAddress?: string,
+ ): Promise {
+ // For CCTP contracts, use the version-specific factory
+ if (contractName.startsWith('TokenBridgeCctp')) {
+ factory = getCctpFactory(
+ contractName.split('TokenBridgeCctp')[1] as 'V1' | 'V2',
+ );
+ }
+
+ // Use the default deployment for other types
+ return super.deployContractFromFactory(
+ chain,
+ factory,
+ contractName,
+ constructorArgs,
+ initializeArgs,
+ shouldRecover,
+ implementationAddress,
+ );
+ }
}
export class HypERC721Deployer extends TokenDeployer {
diff --git a/typescript/sdk/src/token/types.ts b/typescript/sdk/src/token/types.ts
index cd5c0c6d4c..25fb223d4e 100644
--- a/typescript/sdk/src/token/types.ts
+++ b/typescript/sdk/src/token/types.ts
@@ -164,6 +164,9 @@ export const CctpTokenConfigSchema = CollateralTokenConfigSchema.omit({
tokenMessenger: z
.string()
.describe('CCTP Token Messenger contract address'),
+ cctpVersion: z.enum(['V1', 'V2']),
+ minFinalityThreshold: z.number().optional(),
+ maxFeeBps: z.number().optional(),
})
.merge(OffchainLookupIsmConfigSchema.omit({ type: true, owner: true }));
diff --git a/yarn.lock b/yarn.lock
index 675e350e78..9dad4e0e4f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8234,6 +8234,7 @@ __metadata:
prettier: "npm:^3.5.3"
prettier-plugin-solidity: "npm:^1.4.2"
solhint: "npm:^5.0.5"
+ solhint-plugin-hyperlane: "workspace:^"
solhint-plugin-prettier: "npm:^0.1.0"
solidity-bytes-utils: "npm:^0.8.0"
solidity-coverage: "npm:^0.8.3"
@@ -36101,6 +36102,12 @@ __metadata:
languageName: node
linkType: hard
+"solhint-plugin-hyperlane@workspace:^, solhint-plugin-hyperlane@workspace:solhint-plugin":
+ version: 0.0.0-use.local
+ resolution: "solhint-plugin-hyperlane@workspace:solhint-plugin"
+ languageName: unknown
+ linkType: soft
+
"solhint-plugin-prettier@npm:^0.1.0":
version: 0.1.0
resolution: "solhint-plugin-prettier@npm:0.1.0"