Skip to content

Commit 554ff1a

Browse files
authored
feat(sdk): Add M0 PortalLite token adapter support (#7186)
1 parent 154a727 commit 554ff1a

File tree

5 files changed

+190
-0
lines changed

5 files changed

+190
-0
lines changed

.changeset/m0-portal-adapter.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@hyperlane-xyz/sdk': minor
3+
---
4+
5+
Add M0 PortalLite token adapter support for bridging M tokens
6+
7+
- Add new TokenStandard.EvmM0PortalLite for M0 Portal integration
8+
- Implement M0PortalLiteTokenAdapter for handling M0 token transfers
9+
- Support for M0's transferMLikeToken function to bridge wrapped M tokens (e.g., mUSD)
10+
- Built-in gas estimation via Portal's quoteTransfer function

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ const STANDARD_TO_TOKEN: Record<TokenStandard, TokenArgs | null> = {
123123
symbol: 'USDC',
124124
name: 'USDC',
125125
},
126+
[TokenStandard.EvmM0PortalLite]: {
127+
chainName: TestChainName.test2,
128+
standard: TokenStandard.EvmM0PortalLite,
129+
addressOrDenom: '0x36f586A30502AE3afb555b8aA4dCc05d233c2ecE', // Portal address
130+
collateralAddressOrDenom: '0xaca92e438df0b2401ff60da7e4337b687a2435da', // mUSD token address
131+
decimals: 6,
132+
symbol: 'mUSD',
133+
name: 'MetaMask USD',
134+
},
126135

127136
// Sealevel
128137
[TokenStandard.SealevelSpl]: {

typescript/sdk/src/token/Token.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import type {
6060
IHypTokenAdapter,
6161
ITokenAdapter,
6262
} from './adapters/ITokenAdapter.js';
63+
import { M0PortalLiteTokenAdapter } from './adapters/M0PortalLiteTokenAdapter.js';
6364
import {
6465
RadixHypCollateralAdapter,
6566
RadixHypSyntheticAdapter,
@@ -337,6 +338,17 @@ export class Token implements IToken {
337338
return new RadixHypSyntheticAdapter(chainName, multiProvider, {
338339
token: addressOrDenom,
339340
});
341+
} else if (standard === TokenStandard.EvmM0PortalLite) {
342+
assert(
343+
collateralAddressOrDenom,
344+
'collateralAddressOrDenom (mToken address) required for M0PortalLite',
345+
);
346+
return new M0PortalLiteTokenAdapter(
347+
multiProvider,
348+
chainName,
349+
addressOrDenom, // portal address
350+
collateralAddressOrDenom, // mToken address
351+
);
340352
} else {
341353
throw new Error(`No hyp adapter found for token standard: ${standard}`);
342354
}

typescript/sdk/src/token/TokenStandard.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export enum TokenStandard {
2323
EvmHypXERC20Lockbox = 'EvmHypXERC20Lockbox',
2424
EvmHypVSXERC20 = 'EvmHypVSXERC20',
2525
EvmHypVSXERC20Lockbox = 'EvmHypVSXERC20Lockbox',
26+
EvmM0PortalLite = 'EvmM0PortalLite',
2627

2728
// Sealevel (Solana)
2829
SealevelSpl = 'SealevelSpl',
@@ -78,6 +79,7 @@ export const TOKEN_STANDARD_TO_PROTOCOL: Record<TokenStandard, ProtocolType> = {
7879
EvmHypXERC20Lockbox: ProtocolType.Ethereum,
7980
EvmHypVSXERC20: ProtocolType.Ethereum,
8081
EvmHypVSXERC20Lockbox: ProtocolType.Ethereum,
82+
EvmM0PortalLite: ProtocolType.Ethereum,
8183

8284
// Sealevel (Solana)
8385
SealevelSpl: ProtocolType.Sealevel,
@@ -181,6 +183,7 @@ export const TOKEN_HYP_STANDARDS = [
181183
TokenStandard.EvmHypXERC20Lockbox,
182184
TokenStandard.EvmHypVSXERC20,
183185
TokenStandard.EvmHypVSXERC20Lockbox,
186+
TokenStandard.EvmM0PortalLite,
184187
TokenStandard.SealevelHypNative,
185188
TokenStandard.SealevelHypCollateral,
186189
TokenStandard.SealevelHypSynthetic,
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import {
2+
Contract,
3+
PopulatedTransaction,
4+
constants as ethersConstants,
5+
} from 'ethers';
6+
7+
import { ERC20__factory } from '@hyperlane-xyz/core';
8+
import {
9+
Address,
10+
Domain,
11+
addressToBytes32,
12+
strip0x,
13+
} from '@hyperlane-xyz/utils';
14+
15+
import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider.js';
16+
import { ChainName } from '../../types.js';
17+
18+
import { EvmTokenAdapter } from './EvmTokenAdapter.js';
19+
import {
20+
IHypTokenAdapter,
21+
InterchainGasQuote,
22+
TransferRemoteParams,
23+
} from './ITokenAdapter.js';
24+
25+
/**
26+
* M0PortalLiteTokenAdapter - Adapter for M0 PortalLite token transfers
27+
*
28+
* This adapter extends EvmTokenAdapter for basic ERC20 operations and adds
29+
* support for cross-chain transfers via the M0 Portal. The Portal handles
30+
* bridging of M tokens (like mUSD) between chains.
31+
*
32+
* Key differences from standard ERC20:
33+
* - Approvals are made to the Portal contract (not the recipient)
34+
* - Cross-chain transfers use Portal's transferMLikeToken function
35+
* - M tokens use index-based accounting which can cause rounding
36+
*/
37+
38+
// From https://github.com/m0-foundation/m-portal-lite/blob/main/src/Portal.sol
39+
const PORTAL_LITE_ABI = [
40+
'function transfer(uint256 amount, uint256 destinationChainId, address recipient, address refundAddress) external payable returns (bytes32)',
41+
'function transferMLikeToken(uint256 amount, address sourceToken, uint256 destinationChainId, address destinationToken, address recipient, address refundAddress) external payable returns (bytes32)',
42+
'function quoteTransfer(uint256 amount, uint256 destinationChainId, address recipient) external view returns (uint256)',
43+
'function currentIndex() external view returns (uint128)',
44+
'function mToken() external view returns (address)',
45+
];
46+
47+
export class M0PortalLiteTokenAdapter
48+
extends EvmTokenAdapter
49+
implements IHypTokenAdapter<PopulatedTransaction>
50+
{
51+
public readonly portalContract: Contract;
52+
53+
constructor(
54+
multiProvider: MultiProtocolProvider,
55+
chainName: ChainName,
56+
private readonly portalAddress: Address,
57+
mTokenAddress: Address,
58+
) {
59+
// Initialize parent EvmTokenAdapter with the M token
60+
super(chainName, multiProvider, { token: mTokenAddress }, ERC20__factory);
61+
62+
// Initialize the Portal contract for cross-chain transfers
63+
this.portalContract = new Contract(
64+
this.portalAddress,
65+
PORTAL_LITE_ABI,
66+
this.getProvider(),
67+
);
68+
}
69+
70+
// ========== ITokenAdapter overrides ==========
71+
72+
override async getMinimumTransferAmount(
73+
_recipient: Address,
74+
): Promise<bigint> {
75+
// M tokens use index-based accounting which can cause rounding
76+
// Return a small minimum to avoid rounding to 0
77+
return 1n;
78+
}
79+
80+
// ========== IHypTokenAdapter implementation ==========
81+
82+
async getDomains(): Promise<Domain[]> {
83+
// This should be configured based on deployment
84+
// For now return empty - configuration will come from WarpCore config
85+
return [];
86+
}
87+
88+
async getRouterAddress(_domain: Domain): Promise<Buffer> {
89+
// PortalLite doesn't use traditional routers
90+
// Return the portal address as the "router"
91+
return Buffer.from(strip0x(addressToBytes32(this.portalAddress)), 'hex');
92+
}
93+
94+
async getAllRouters(): Promise<Array<{ domain: Domain; address: Buffer }>> {
95+
return [];
96+
}
97+
98+
async getBridgedSupply(): Promise<bigint | undefined> {
99+
// For simple transfer support, we don't need bridged supply tracking
100+
// WarpCore can work without this for basic transfers
101+
return undefined;
102+
}
103+
104+
async quoteTransferRemoteGas(
105+
destination: Domain,
106+
sender?: Address,
107+
_customHook?: Address,
108+
): Promise<InterchainGasQuote> {
109+
const destinationChainId = this.multiProvider.getChainId(
110+
this.multiProvider.getChainName(destination),
111+
);
112+
113+
// Use PortalLite's built-in gas estimation
114+
const gasQuote = await this.portalContract.quoteTransfer(
115+
1n, // Amount doesn't affect gas quote
116+
destinationChainId,
117+
sender || ethersConstants.AddressZero, // Recipient doesn't affect quote
118+
);
119+
120+
return {
121+
amount: BigInt(gasQuote.toString()),
122+
};
123+
}
124+
125+
async populateTransferRemoteTx(
126+
params: TransferRemoteParams,
127+
): Promise<PopulatedTransaction> {
128+
const destinationChainId = this.multiProvider.getChainId(
129+
this.multiProvider.getChainName(params.destination),
130+
);
131+
132+
// Get gas quote if not provided
133+
const gasQuote =
134+
params.interchainGas?.amount ||
135+
(
136+
await this.quoteTransferRemoteGas(
137+
params.destination,
138+
params.fromAccountOwner,
139+
)
140+
).amount;
141+
142+
// Use Portal's transferMLikeToken function to support wrapped tokens like mUSD
143+
// Both source and destination use the same token address (mUSD on both chains)
144+
return this.portalContract.populateTransaction.transferMLikeToken(
145+
BigInt(params.weiAmountOrId.toString()),
146+
this.addresses.token, // source token
147+
destinationChainId,
148+
this.addresses.token, // destination token (same address on both chains for mUSD)
149+
params.recipient,
150+
params.fromAccountOwner || ethersConstants.AddressZero, // refundAddress
151+
{
152+
value: gasQuote,
153+
},
154+
);
155+
}
156+
}

0 commit comments

Comments
 (0)