feat(sdk): XERC20 token adapter (#3911)

### Description

`HypXERC20Adapter` that allows checking for mint and burn limits put in
place on XERC20 contracts.

### Drive-by changes

- Couple methods added to the `XERC20` interface
- Corrects `HypXERC20Lockbox` and `HypXERC20` config during CLI
deployments

### Related issues

- Fixes https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3851

### Backward compatibility

Yes

### Testing

CLI testing.
pull/3967/head
Alex 5 months ago committed by GitHub
parent 12840964b9
commit 51bfff683e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      .changeset/selfish-days-glow.md
  2. 12
      solidity/contracts/test/ERC20Test.sol
  3. 18
      solidity/contracts/token/interfaces/IXERC20.sol
  4. 33
      typescript/cli/src/deploy/warp.ts
  5. 16
      typescript/sdk/src/token/Token.test.ts
  6. 10
      typescript/sdk/src/token/Token.ts
  7. 12
      typescript/sdk/src/token/TokenStandard.ts
  8. 92
      typescript/sdk/src/token/adapters/EvmTokenAdapter.ts
  9. 5
      typescript/sdk/src/token/adapters/ITokenAdapter.ts
  10. 4
      typescript/sdk/src/warp/WarpCore.test.ts
  11. 71
      typescript/sdk/src/warp/WarpCore.ts

@ -0,0 +1,8 @@
---
'@hyperlane-xyz/sdk': minor
'@hyperlane-xyz/core': minor
'@hyperlane-xyz/cli': minor
---
Mint/burn limit checking for xERC20 bridging
Corrects CLI output for HypXERC20 and HypXERC20Lockbox deployments

@ -74,6 +74,18 @@ contract XERC20Test is ERC20Test, IXERC20 {
function owner() external pure returns (address) {
return address(0x0);
}
function burningCurrentLimitOf(
address _bridge
) external view returns (uint256) {
return type(uint256).max;
}
function mintingCurrentLimitOf(
address _bridge
) external view returns (uint256) {
return type(uint256).max;
}
}
contract XERC20LockboxTest is IXERC20Lockbox {

@ -36,4 +36,22 @@ interface IXERC20 is IERC20 {
) external;
function owner() external returns (address);
/**
* @notice Returns the current limit of a bridge
* @param _bridge the bridge we are viewing the limits of
* @return _limit The limit the bridge has
*/
function burningCurrentLimitOf(
address _bridge
) external view returns (uint256 _limit);
/**
* @notice Returns the current limit of a bridge
* @param _bridge the bridge we are viewing the limits of
* @return _limit The limit the bridge has
*/
function mintingCurrentLimitOf(
address _bridge
) external view returns (uint256 _limit);
}

@ -1,5 +1,9 @@
import { confirm } from '@inquirer/prompts';
import {
HypXERC20Lockbox__factory,
HypXERC20__factory,
} from '@hyperlane-xyz/core';
import {
HypERC20Deployer,
HypERC721Deployer,
@ -161,8 +165,33 @@ async function getWarpCoreConfig(
throw new Error('Missing decimals on token metadata');
}
const collateralAddressOrDenom =
config.type === TokenType.collateral ? config.token : undefined;
const collateralAddressOrDenom = await (async () => {
if (config.type === TokenType.XERC20Lockbox) {
const provider = context.multiProvider.tryGetProvider(chainName);
if (!provider) {
throw new Error(`Unable to pull provider for ${chainName}`);
}
const xERC20 = await HypXERC20Lockbox__factory.connect(
config.token,
provider,
).xERC20();
const wrappedToken = await HypXERC20__factory.connect(
xERC20,
provider,
).wrappedToken();
return wrappedToken;
}
if (
config.type === TokenType.collateral ||
config.type === TokenType.XERC20
) {
return config.token;
}
return undefined;
})();
warpCoreConfig.tokens.push({
chainName,
standard: TOKEN_TYPE_TO_STANDARD[config.type],

@ -55,6 +55,22 @@ const STANDARD_TO_TOKEN: Record<TokenStandard, TokenArgs | null> = {
symbol: 'USDC',
name: 'USDC',
},
[TokenStandard.EvmHypXERC20]: {
chainName: TestChainName.test2,
standard: TokenStandard.EvmHypXERC20,
addressOrDenom: '0x8358D8291e3bEDb04804975eEa0fe9fe0fAfB147',
decimals: 6,
symbol: 'USDC',
name: 'USDC',
},
[TokenStandard.EvmHypXERC20Lockbox]: {
chainName: TestChainName.test2,
standard: TokenStandard.EvmHypXERC20Lockbox,
addressOrDenom: '0x8358D8291e3bEDb04804975eEa0fe9fe0fAfB147',
decimals: 6,
symbol: 'USDC',
name: 'USDC',
},
// Sealevel
[TokenStandard.SealevelSpl]: {

@ -41,6 +41,8 @@ import {
EvmHypCollateralAdapter,
EvmHypNativeAdapter,
EvmHypSyntheticAdapter,
EvmHypXERC20Adapter,
EvmHypXERC20LockboxAdapter,
EvmNativeTokenAdapter,
EvmTokenAdapter,
} from './adapters/EvmTokenAdapter.js';
@ -213,6 +215,14 @@ export class Token implements IToken {
return new EvmHypSyntheticAdapter(chainName, multiProvider, {
token: addressOrDenom,
});
} else if (standard === TokenStandard.EvmHypXERC20) {
return new EvmHypXERC20Adapter(chainName, multiProvider, {
token: addressOrDenom,
});
} else if (standard === TokenStandard.EvmHypXERC20Lockbox) {
return new EvmHypXERC20LockboxAdapter(chainName, multiProvider, {
token: addressOrDenom,
});
} else if (standard === TokenStandard.SealevelHypNative) {
return new SealevelHypNativeAdapter(
chainName,

@ -15,6 +15,8 @@ export enum TokenStandard {
EvmHypNative = 'EvmHypNative',
EvmHypCollateral = 'EvmHypCollateral',
EvmHypSynthetic = 'EvmHypSynthetic',
EvmHypXERC20 = 'EvmHypXERC20',
EvmHypXERC20Lockbox = 'EvmHypXERC20Lockbox',
// Sealevel (Solana)
SealevelSpl = 'SealevelSpl',
@ -48,6 +50,8 @@ export const TOKEN_STANDARD_TO_PROTOCOL: Record<TokenStandard, ProtocolType> = {
EvmHypNative: ProtocolType.Ethereum,
EvmHypCollateral: ProtocolType.Ethereum,
EvmHypSynthetic: ProtocolType.Ethereum,
EvmHypXERC20: ProtocolType.Ethereum,
EvmHypXERC20Lockbox: ProtocolType.Ethereum,
// Sealevel (Solana)
SealevelSpl: ProtocolType.Sealevel,
@ -90,6 +94,8 @@ export const TOKEN_NFT_STANDARDS = [
export const TOKEN_COLLATERALIZED_STANDARDS = [
TokenStandard.EvmHypCollateral,
TokenStandard.EvmHypNative,
TokenStandard.EvmHypXERC20,
TokenStandard.EvmHypXERC20Lockbox,
TokenStandard.SealevelHypCollateral,
TokenStandard.SealevelHypNative,
TokenStandard.CwHypCollateral,
@ -100,6 +106,8 @@ export const TOKEN_HYP_STANDARDS = [
TokenStandard.EvmHypNative,
TokenStandard.EvmHypCollateral,
TokenStandard.EvmHypSynthetic,
TokenStandard.EvmHypXERC20,
TokenStandard.EvmHypXERC20Lockbox,
TokenStandard.SealevelHypNative,
TokenStandard.SealevelHypCollateral,
TokenStandard.SealevelHypSynthetic,
@ -128,8 +136,8 @@ export const TOKEN_TYPE_TO_STANDARD: Record<TokenType, TokenStandard> = {
[TokenType.native]: TokenStandard.EvmHypNative,
[TokenType.collateral]: TokenStandard.EvmHypCollateral,
[TokenType.collateralFiat]: TokenStandard.EvmHypCollateral,
[TokenType.XERC20]: TokenStandard.EvmHypCollateral,
[TokenType.XERC20Lockbox]: TokenStandard.EvmHypCollateral,
[TokenType.XERC20]: TokenStandard.EvmHypXERC20,
[TokenType.XERC20Lockbox]: TokenStandard.EvmHypXERC20Lockbox,
[TokenType.collateralVault]: TokenStandard.EvmHypCollateral,
[TokenType.collateralUri]: TokenStandard.EvmHypCollateral,
[TokenType.fastCollateral]: TokenStandard.EvmHypCollateral,

@ -7,6 +7,11 @@ import {
HypERC20Collateral,
HypERC20Collateral__factory,
HypERC20__factory,
HypXERC20,
HypXERC20Lockbox,
HypXERC20Lockbox__factory,
HypXERC20__factory,
IXERC20__factory,
} from '@hyperlane-xyz/core';
import {
Address,
@ -25,6 +30,7 @@ import { TokenMetadata } from '../types.js';
import {
IHypTokenAdapter,
IHypXERC20Adapter,
ITokenAdapter,
InterchainGasQuote,
TransferParams,
@ -279,6 +285,92 @@ export class EvmHypCollateralAdapter
}
}
// Interacts with HypXERC20Lockbox contracts
export class EvmHypXERC20LockboxAdapter
extends EvmHypCollateralAdapter
implements IHypXERC20Adapter<PopulatedTransaction>
{
hypXERC20Lockbox: HypXERC20Lockbox;
constructor(
public readonly chainName: ChainName,
public readonly multiProvider: MultiProtocolProvider,
public readonly addresses: { token: Address },
) {
super(chainName, multiProvider, addresses);
this.hypXERC20Lockbox = HypXERC20Lockbox__factory.connect(
addresses.token,
this.getProvider(),
);
}
async getMintLimit() {
const xERC20 = await this.hypXERC20Lockbox.xERC20();
const limit = await IXERC20__factory.connect(
xERC20,
this.getProvider(),
).mintingCurrentLimitOf(this.contract.address);
return BigInt(limit.toString());
}
async getBurnLimit() {
const xERC20 = await this.hypXERC20Lockbox.xERC20();
const limit = await IXERC20__factory.connect(
xERC20,
this.getProvider(),
).mintingCurrentLimitOf(this.contract.address);
return BigInt(limit.toString());
}
}
// Interacts with HypXERC20 contracts
export class EvmHypXERC20Adapter
extends EvmHypCollateralAdapter
implements IHypXERC20Adapter<PopulatedTransaction>
{
hypXERC20: HypXERC20;
constructor(
public readonly chainName: ChainName,
public readonly multiProvider: MultiProtocolProvider,
public readonly addresses: { token: Address },
) {
super(chainName, multiProvider, addresses);
this.hypXERC20 = HypXERC20__factory.connect(
addresses.token,
this.getProvider(),
);
}
async getMintLimit() {
const xERC20 = await this.hypXERC20.wrappedToken();
const limit = await IXERC20__factory.connect(
xERC20,
this.getProvider(),
).mintingCurrentLimitOf(this.contract.address);
return BigInt(limit.toString());
}
async getBurnLimit() {
const xERC20 = await this.hypXERC20.wrappedToken();
const limit = await IXERC20__factory.connect(
xERC20,
this.getProvider(),
).burningCurrentLimitOf(this.contract.address);
return BigInt(limit.toString());
}
}
// Interacts HypNative contracts
export class EvmHypNativeAdapter
extends EvmHypCollateralAdapter

@ -40,3 +40,8 @@ export interface IHypTokenAdapter<Tx> extends ITokenAdapter<Tx> {
quoteTransferRemoteGas(destination: Domain): Promise<InterchainGasQuote>;
populateTransferRemoteTx(p: TransferRemoteParams): Promise<Tx>;
}
export interface IHypXERC20Adapter<Tx> extends IHypTokenAdapter<Tx> {
getMintLimit(): Promise<bigint>;
getBurnLimit(): Promise<bigint>;
}

@ -222,7 +222,7 @@ describe('WarpCore', () => {
const invalidAmount = await warpCore.validateTransfer({
originTokenAmount: evmHypNative.amount(-10),
destination: test1.name,
destination: test2.name,
recipient: MOCK_ADDRESS,
sender: MOCK_ADDRESS,
});
@ -230,7 +230,7 @@ describe('WarpCore', () => {
const insufficientBalance = await warpCore.validateTransfer({
originTokenAmount: evmHypNative.amount(BIG_TRANSFER_AMOUNT),
destination: test1.name,
destination: test2.name,
recipient: MOCK_ADDRESS,
sender: MOCK_ADDRESS,
});

@ -24,8 +24,10 @@ import { parseTokenConnectionId } from '../token/TokenConnection.js';
import {
TOKEN_COLLATERALIZED_STANDARDS,
TOKEN_STANDARD_TO_PROVIDER_TYPE,
TokenStandard,
} from '../token/TokenStandard.js';
import { EVM_TRANSFER_REMOTE_GAS_ESTIMATE } from '../token/adapters/EvmTokenAdapter.js';
import { IHypXERC20Adapter } from '../token/adapters/ITokenAdapter.js';
import { ChainName, ChainNameOrId } from '../types.js';
import {
@ -432,10 +434,22 @@ export class WarpCore {
return true;
}
let destinationBalance: bigint;
const adapter = destinationToken.getAdapter(this.multiProvider);
const destinationBalance = await adapter.getBalance(
destinationToken.addressOrDenom,
);
if (
destinationToken.standard === TokenStandard.EvmHypXERC20 ||
destinationToken.standard === TokenStandard.EvmHypXERC20Lockbox
) {
destinationBalance = await (
adapter as IHypXERC20Adapter<unknown>
).getMintLimit();
} else {
destinationBalance = await adapter.getBalance(
destinationToken.addressOrDenom,
);
}
const destinationBalanceInOriginDecimals = convertDecimals(
destinationToken.decimals,
originToken.decimals,
@ -504,6 +518,17 @@ export class WarpCore {
const amountError = this.validateAmount(originTokenAmount);
if (amountError) return amountError;
const destinationCollateralError = await this.validateDestinationCollateral(
originTokenAmount,
destination,
);
if (destinationCollateralError) return destinationCollateralError;
const originCollateralError = await this.validateOriginCollateral(
originTokenAmount,
);
if (originCollateralError) return originCollateralError;
const balancesError = await this.validateTokenBalances(
originTokenAmount,
destination,
@ -592,6 +617,7 @@ export class WarpCore {
senderPubKey?: HexString,
): Promise<Record<string, string> | null> {
const { token, amount } = originTokenAmount;
const { amount: senderBalance } = await token.getBalance(
this.multiProvider,
sender,
@ -637,6 +663,45 @@ export class WarpCore {
return null;
}
/**
* Ensure the sender has sufficient balances for transfer and interchain gas
*/
protected async validateDestinationCollateral(
originTokenAmount: TokenAmount,
destination: ChainNameOrId,
): Promise<Record<string, string> | null> {
const valid = await this.isDestinationCollateralSufficient({
originTokenAmount,
destination,
});
if (!valid) return { amount: 'Insufficient collateral on destination' };
return null;
}
/**
* Ensure the sender has sufficient balances for transfer and interchain gas
*/
protected async validateOriginCollateral(
originTokenAmount: TokenAmount,
): Promise<Record<string, string> | null> {
const adapter = originTokenAmount.token.getAdapter(this.multiProvider);
if (
originTokenAmount.token.standard === TokenStandard.EvmHypXERC20 ||
originTokenAmount.token.standard === TokenStandard.EvmHypXERC20Lockbox
) {
const burnLimit = await (
adapter as IHypXERC20Adapter<unknown>
).getBurnLimit();
if (burnLimit < BigInt(originTokenAmount.amount)) {
return { amount: 'Insufficient burn limit on origin' };
}
}
return null;
}
/**
* Search through token list to find token with matching chain and address
*/

Loading…
Cancel
Save