Skip to content

Commit a1fbe58

Browse files
fix(sdk): resolve xERC20Lockbox fee token from on-chain router.token()
The xERC20Lockbox deploy config stores the lockbox address, but the router's token()/feeToken() returns the underlying wrapped ERC20. Fee token resolution now reads token() on-chain for the lockbox variant so the deployed fee contract matches the router's `fee must match token` check. Added an xERC20Lockbox CLI e2e test alongside the existing xERC20 coverage.
1 parent 95e431c commit a1fbe58

7 files changed

Lines changed: 275 additions & 68 deletions

File tree

.changeset/sdk-xerc20-token-fee.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@hyperlane-xyz/sdk': patch
33
---
44

5-
The SDK fee-token resolution now supports xERC20 and xERC20Lockbox warp routes. Previously `getFeeTokenAddress` threw `Unsupported token type for fee resolution` for these types, which blocked applying a `tokenFee` (including OQLF) to xERC20 routes via `warp deploy`/`warp apply`. It now returns the wrapped/collateral token address, matching the contract's `feeToken()` which returns `token()` when a fee hook is set. Added SDK hardhat tests for the xERC20 fee-setting path and CLI e2e tests asserting xERC20 routes can be deployed with fees and updated.
5+
The SDK fee-token resolution now supports xERC20 and xERC20Lockbox warp routes. Previously `getFeeTokenAddress` threw `Unsupported token type for fee resolution` for these types, which blocked applying a `tokenFee` (including OQLF) to xERC20 routes via `warp deploy`/`warp apply`. For plain xERC20 the fee token resolves to the configured xERC20 token. For xERC20Lockbox the deploy config stores the lockbox address, but the router's `token()`/`feeToken()` returns the underlying wrapped ERC20; fee-token resolution now reads this on-chain for the lockbox variant so the deployed fee contract's token matches `token()` and passes the router's `fee must match token` check. Added SDK unit and hardhat tests (including an xERC20Lockbox deploy regression) plus CLI e2e tests asserting xERC20 and xERC20Lockbox routes can be deployed with fees and updated.

typescript/cli/src/tests/ethereum/warp/warp-xerc20-fee.e2e-test.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { expect } from 'chai';
44
import { Wallet } from 'ethers';
55
import { $ } from 'zx';
66

7-
import { type XERC20VSTest } from '@hyperlane-xyz/core';
7+
import { type XERC20LockboxTest, type XERC20VSTest } from '@hyperlane-xyz/core';
88
import { type ChainAddresses } from '@hyperlane-xyz/registry';
99
import {
1010
type ChainMetadata,
@@ -14,11 +14,20 @@ import {
1414
type WarpRouteDeployConfig,
1515
WarpRouteDeployConfigSchema,
1616
} from '@hyperlane-xyz/sdk';
17-
import { type Address, assert, isNullish } from '@hyperlane-xyz/utils';
17+
import {
18+
type Address,
19+
assert,
20+
eqAddress,
21+
isNullish,
22+
} from '@hyperlane-xyz/utils';
1823

1924
import { readYamlOrJson, writeYamlOrJson } from '../../../utils/files.js';
2025
import { deployOrUseExistingCore } from '../commands/core.js';
21-
import { deployXERC20VSToken } from '../commands/helpers.js';
26+
import {
27+
deployToken,
28+
deployXERC20LockboxToken,
29+
deployXERC20VSToken,
30+
} from '../commands/helpers.js';
2231
import {
2332
hyperlaneWarpApply,
2433
hyperlaneWarpDeploy,
@@ -143,4 +152,73 @@ describe('hyperlane warp xERC20 token fee e2e tests', function () {
143152
expect(fee.token).to.equal(xerc20.address);
144153
});
145154
});
155+
156+
// Regression for the xERC20Lockbox fee-token resolution bug: the deploy
157+
// config stores token = lockbox address, but the router's token()/feeToken()
158+
// returns the underlying wrapped ERC20. The fee contract must be deployed
159+
// with token() so the router's fee==token() check passes; the SDK reads this
160+
// on-chain for the lockbox variant.
161+
describe('xERC20Lockbox token fee', () => {
162+
const LB_SYMBOL = 'XLBFEE';
163+
const LB_WARP_ROUTE_ID = getWarpRouteId(LB_SYMBOL, [CHAIN_NAME_2]);
164+
const LB_CORE_PATH = getCombinedWarpRoutePath(LB_SYMBOL, [CHAIN_NAME_2]);
165+
const LB_REGISTRY_DEPLOY_PATH = getCombinedWarpDeployPath(LB_SYMBOL, [
166+
CHAIN_NAME_2,
167+
]);
168+
const LB_DEPLOY_PATH = `${TEMP_PATH}/warp-xerc20lockbox-fee-deploy.yaml`;
169+
170+
let lockbox: XERC20LockboxTest;
171+
let wrappedToken: Address;
172+
173+
before(async function () {
174+
const underlying = await deployToken(
175+
ANVIL_KEY,
176+
CHAIN_NAME_2,
177+
18,
178+
LB_SYMBOL,
179+
);
180+
lockbox = await deployXERC20LockboxToken(
181+
ANVIL_KEY,
182+
CHAIN_NAME_2,
183+
underlying,
184+
);
185+
// The lockbox exposes its underlying wrapped ERC20 via ERC20().
186+
wrappedToken = await lockbox.ERC20();
187+
});
188+
189+
function buildLockboxConfig(bps?: number): WarpRouteDeployConfig {
190+
return WarpRouteDeployConfigSchema.parse({
191+
[CHAIN_NAME_2]: {
192+
type: TokenType.XERC20Lockbox,
193+
token: lockbox.address,
194+
mailbox: chain2Addresses.mailbox,
195+
owner: ownerAddress,
196+
...(isNullish(bps)
197+
? {}
198+
: { tokenFee: { type: TokenFeeType.LinearFee, bps } }),
199+
},
200+
});
201+
}
202+
203+
it('deploys an xERC20Lockbox warp route with a LinearFee resolved to the wrapped token', async function () {
204+
writeYamlOrJson(LB_DEPLOY_PATH, buildLockboxConfig(FEE_BPS));
205+
await hyperlaneWarpDeploy(LB_DEPLOY_PATH, LB_WARP_ROUTE_ID);
206+
207+
const config = (
208+
await readWarpConfig(
209+
CHAIN_NAME_2,
210+
LB_CORE_PATH,
211+
LB_REGISTRY_DEPLOY_PATH,
212+
)
213+
)[CHAIN_NAME_2];
214+
215+
const fee = TokenFeeConfigSchema.parse(config.tokenFee);
216+
assert(fee.type === TokenFeeType.LinearFee, 'expected a LinearFee');
217+
expect(fee.bps).to.equal(FEE_BPS);
218+
// The fee token must be the underlying wrapped ERC20 (router.token()),
219+
// NOT the lockbox address stored in the deploy config.
220+
expect(eqAddress(fee.token, wrappedToken)).to.be.true;
221+
expect(eqAddress(fee.token, lockbox.address)).to.be.false;
222+
});
223+
});
146224
});

typescript/sdk/src/token/EvmWarpModule.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1898,10 +1898,11 @@ export class EvmWarpModule extends HyperlaneModule<
18981898
}
18991899

19001900
const routerAddress = this.args.addresses.deployedTokenRoute;
1901-
const resolvedTokenFee = resolveTokenFeeAddress(
1901+
const resolvedTokenFee = await resolveTokenFeeAddress(
19021902
expectedConfig.tokenFee,
19031903
routerAddress,
19041904
expectedConfig,
1905+
this.multiProvider.getProvider(this.chainId),
19051906
);
19061907

19071908
const currentTokenFee = actualConfig.tokenFee;

typescript/sdk/src/token/configUtils.test.ts

Lines changed: 71 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { expect } from 'chai';
2-
import { constants } from 'ethers';
2+
import { constants, providers } from 'ethers';
3+
import sinon from 'sinon';
34

5+
import { HypXERC20Lockbox__factory } from '@hyperlane-xyz/core';
46
import { assert } from '@hyperlane-xyz/utils';
57

68
import {
79
DEFAULT_ROUTER_KEY,
8-
ResolvedCrossCollateralRoutingFeeConfigInput,
9-
ResolvedLinearFeeConfigInput,
1010
ResolvedRoutingFeeConfigInput,
1111
TokenFeeType,
1212
} from '../fee/types.js';
@@ -623,6 +623,22 @@ describe('configUtils', () => {
623623
const ROUTER_ADDRESS = '0x1234567890123456789012345678901234567890';
624624
const OWNER_ADDRESS = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd';
625625
const COLLATERAL_TOKEN = '0x9999999999999999999999999999999999999999';
626+
// The lockbox's on-chain token() returns the underlying wrapped ERC20,
627+
// which is distinct from the lockbox address stored in tokenConfig.token.
628+
const LOCKBOX_ADDRESS = '0x8888888888888888888888888888888888888888';
629+
const LOCKBOX_WRAPPED_TOKEN = '0x7777777777777777777777777777777777777777';
630+
631+
let sandbox: sinon.SinonSandbox;
632+
let provider: providers.Provider;
633+
634+
beforeEach(() => {
635+
sandbox = sinon.createSandbox();
636+
provider = buildMultiProvider().getProvider(test1.name);
637+
});
638+
639+
afterEach(() => {
640+
sandbox.restore();
641+
});
626642

627643
const syntheticConfig: HypTokenConfig = {
628644
type: TokenType.synthetic,
@@ -644,95 +660,109 @@ describe('configUtils', () => {
644660

645661
const xerc20LockboxConfig: HypTokenConfig = {
646662
type: TokenType.XERC20Lockbox,
647-
token: COLLATERAL_TOKEN,
663+
token: LOCKBOX_ADDRESS,
648664
};
649665

650-
it('should resolve token to router address for synthetic tokens', () => {
666+
it('should resolve token to router address for synthetic tokens', async () => {
651667
const input = {
652668
type: TokenFeeType.LinearFee,
653669
owner: OWNER_ADDRESS,
654670
bps: 100,
655671
};
656672

657-
const result = resolveTokenFeeAddress(
673+
const result = await resolveTokenFeeAddress(
658674
input,
659675
ROUTER_ADDRESS,
660676
syntheticConfig,
661-
) as ResolvedLinearFeeConfigInput;
677+
provider,
678+
);
662679

680+
assert(result.type === TokenFeeType.LinearFee, 'expected a LinearFee');
663681
expect(result.token).to.equal(ROUTER_ADDRESS);
664682
expect(result.owner).to.equal(OWNER_ADDRESS);
665683
});
666684

667-
it('should resolve token to collateral address for collateral tokens', () => {
685+
it('should resolve token to collateral address for collateral tokens', async () => {
668686
const input = {
669687
type: TokenFeeType.LinearFee,
670688
owner: OWNER_ADDRESS,
671689
bps: 100,
672690
};
673691

674-
const result = resolveTokenFeeAddress(
692+
const result = await resolveTokenFeeAddress(
675693
input,
676694
ROUTER_ADDRESS,
677695
collateralConfig,
678-
) as ResolvedLinearFeeConfigInput;
696+
provider,
697+
);
679698

699+
assert(result.type === TokenFeeType.LinearFee, 'expected a LinearFee');
680700
expect(result.token).to.equal(COLLATERAL_TOKEN);
681701
});
682702

683-
it('should resolve token to AddressZero for native tokens', () => {
703+
it('should resolve token to AddressZero for native tokens', async () => {
684704
const input = {
685705
type: TokenFeeType.LinearFee,
686706
owner: OWNER_ADDRESS,
687707
bps: 100,
688708
};
689709

690-
const result = resolveTokenFeeAddress(
710+
const result = await resolveTokenFeeAddress(
691711
input,
692712
ROUTER_ADDRESS,
693713
nativeConfig,
694-
) as ResolvedLinearFeeConfigInput;
714+
provider,
715+
);
695716

717+
assert(result.type === TokenFeeType.LinearFee, 'expected a LinearFee');
696718
expect(result.token).to.equal(constants.AddressZero);
697719
});
698720

699-
it('should resolve token to wrapped token address for xERC20 tokens', () => {
721+
it('should resolve token to the xERC20 token address for xERC20 tokens', async () => {
700722
const input = {
701723
type: TokenFeeType.LinearFee,
702724
owner: OWNER_ADDRESS,
703725
bps: 100,
704726
};
705727

706-
const result = resolveTokenFeeAddress(
728+
const result = await resolveTokenFeeAddress(
707729
input,
708730
ROUTER_ADDRESS,
709731
xerc20Config,
732+
provider,
710733
);
711734

712-
expect('token' in result ? result.token : undefined).to.equal(
713-
COLLATERAL_TOKEN,
714-
);
735+
assert(result.type === TokenFeeType.LinearFee, 'expected a LinearFee');
736+
expect(result.token).to.equal(COLLATERAL_TOKEN);
715737
});
716738

717-
it('should resolve token to wrapped token address for xERC20Lockbox tokens', () => {
739+
it('should resolve token to the on-chain wrapped token for xERC20Lockbox tokens', async () => {
740+
// For a lockbox, the fee token must match the router's token() (the
741+
// underlying wrapped ERC20), NOT the lockbox address in the config.
742+
const tokenStub = sandbox.stub().resolves(LOCKBOX_WRAPPED_TOKEN);
743+
sandbox.stub(HypXERC20Lockbox__factory, 'connect').returns({
744+
token: tokenStub,
745+
} as any);
746+
718747
const input = {
719748
type: TokenFeeType.LinearFee,
720749
owner: OWNER_ADDRESS,
721750
bps: 100,
722751
};
723752

724-
const result = resolveTokenFeeAddress(
753+
const result = await resolveTokenFeeAddress(
725754
input,
726755
ROUTER_ADDRESS,
727756
xerc20LockboxConfig,
757+
provider,
728758
);
729759

730-
expect('token' in result ? result.token : undefined).to.equal(
731-
COLLATERAL_TOKEN,
732-
);
760+
assert(result.type === TokenFeeType.LinearFee, 'expected a LinearFee');
761+
expect(result.token).to.equal(LOCKBOX_WRAPPED_TOKEN);
762+
expect(result.token).to.not.equal(LOCKBOX_ADDRESS);
733763
});
734764

735-
it('should resolve nested feeContracts tokens for RoutingFee', () => {
765+
it('should resolve nested feeContracts tokens for RoutingFee', async () => {
736766
const input = {
737767
type: TokenFeeType.RoutingFee,
738768
owner: OWNER_ADDRESS,
@@ -750,37 +780,38 @@ describe('configUtils', () => {
750780
},
751781
};
752782

753-
const result = resolveTokenFeeAddress(
783+
const result = await resolveTokenFeeAddress(
754784
input,
755785
ROUTER_ADDRESS,
756786
syntheticConfig,
757-
) as ResolvedRoutingFeeConfigInput;
787+
provider,
788+
);
758789

790+
assert(result.type === TokenFeeType.RoutingFee, 'expected a RoutingFee');
759791
expect(result.token).to.equal(ROUTER_ADDRESS);
760-
expect(result.type).to.equal(TokenFeeType.RoutingFee);
761-
762792
expect(result.feeContracts.ethereum.token).to.equal(ROUTER_ADDRESS);
763793
expect(result.feeContracts.arbitrum.token).to.equal(ROUTER_ADDRESS);
764794
});
765795

766-
it('should handle RoutingFee with empty feeContracts', () => {
796+
it('should handle RoutingFee with empty feeContracts', async () => {
767797
const input = {
768798
type: TokenFeeType.RoutingFee,
769799
owner: OWNER_ADDRESS,
770800
feeContracts: {},
771801
};
772802

773-
const result = resolveTokenFeeAddress(
803+
const result = await resolveTokenFeeAddress(
774804
input,
775805
ROUTER_ADDRESS,
776806
syntheticConfig,
777-
) as ResolvedRoutingFeeConfigInput;
807+
provider,
808+
);
778809

810+
assert(result.type === TokenFeeType.RoutingFee, 'expected a RoutingFee');
779811
expect(result.token).to.equal(ROUTER_ADDRESS);
780-
expect(result.type).to.equal(TokenFeeType.RoutingFee);
781812
});
782813

783-
it('should resolve token for nested cross collateral feeContracts', () => {
814+
it('should resolve token for nested cross collateral feeContracts', async () => {
784815
const ROUTER_KEY =
785816
'0x1111111111111111111111111111111111111111111111111111111111111111';
786817
const input = {
@@ -802,12 +833,17 @@ describe('configUtils', () => {
802833
},
803834
};
804835

805-
const result = resolveTokenFeeAddress(
836+
const result = await resolveTokenFeeAddress(
806837
input,
807838
ROUTER_ADDRESS,
808839
syntheticConfig,
809-
) as ResolvedCrossCollateralRoutingFeeConfigInput;
840+
provider,
841+
);
810842

843+
assert(
844+
result.type === TokenFeeType.CrossCollateralRoutingFee,
845+
'expected a CrossCollateralRoutingFee',
846+
);
811847
expect(result.feeContracts.ethereum[DEFAULT_ROUTER_KEY]?.token).to.equal(
812848
ROUTER_ADDRESS,
813849
);

0 commit comments

Comments
 (0)