fix: Sealevel hyp token adapter fixes (#4500)

### Description

- Fix arg validation for Sealevel HypNative adapters
- Increase Token.ts test coverage to Sealevel adapters
- Swallow getBalance errors for non-existent SVM accounts
- Allow extra properties in ChainMetadata objects

### Related issues

https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/4499

### Backward compatibility

Yes

### Testing

Tested in Warp UI
pull/4506/head
J M Rossy 2 months ago committed by GitHub
parent cd13f6c08f
commit 815542dd78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/young-moose-march.md
  2. 9
      typescript/sdk/src/metadata/chainMetadataTypes.ts
  3. 60
      typescript/sdk/src/token/Token.test.ts
  4. 50
      typescript/sdk/src/token/Token.ts
  5. 63
      typescript/sdk/src/token/adapters/SealevelTokenAdapter.ts

@ -0,0 +1,6 @@
---
'@hyperlane-xyz/sdk': patch
---
Fix arg validation for Sealevel HypNative adapters
Allow extra properties in ChainMetadata objects

@ -248,8 +248,11 @@ export const ChainMetadataSchemaObject = z.object({
.describe('Properties to include when forming transaction requests.'),
});
// Passthrough allows for extra fields to remain in the object (such as extensions consumers may want like `mailbox`)
const ChainMetadataSchemaExtensible = ChainMetadataSchemaObject.passthrough();
// Add refinements to the object schema to conditionally validate certain fields
export const ChainMetadataSchema = ChainMetadataSchemaObject.refine(
export const ChainMetadataSchema = ChainMetadataSchemaExtensible.refine(
(metadata) => {
if (
[ProtocolType.Ethereum, ProtocolType.Sealevel].includes(
@ -333,7 +336,9 @@ export const ChainMetadataSchema = ChainMetadataSchemaObject.refine(
},
);
export type ChainMetadata<Ext = object> = z.infer<typeof ChainMetadataSchema> &
export type ChainMetadata<Ext = object> = z.infer<
typeof ChainMetadataSchemaObject
> &
Ext;
export type BlockExplorer = Exclude<

@ -1,4 +1,5 @@
/* eslint-disable no-console */
import { SystemProgram } from '@solana/web3.js';
import { expect } from 'chai';
import { ethers } from 'ethers';
@ -109,9 +110,32 @@ const STANDARD_TO_TOKEN: Record<TokenStandard, TokenArgs | null> = {
},
[TokenStandard.SealevelNative]:
Token.FromChainMetadataNativeToken(testSealevelChain),
[TokenStandard.SealevelHypNative]: null,
[TokenStandard.SealevelHypCollateral]: null,
[TokenStandard.SealevelHypSynthetic]: null,
[TokenStandard.SealevelHypNative]: {
chainName: testSealevelChain.name,
standard: TokenStandard.SealevelHypNative,
addressOrDenom: '4UMNyNWW75zo69hxoJaRX5iXNUa5FdRPZZa9vDVCiESg',
decimals: 9,
symbol: 'SOL',
name: 'SOL',
},
[TokenStandard.SealevelHypCollateral]: {
chainName: testSealevelChain.name,
standard: TokenStandard.SealevelHypCollateral,
addressOrDenom: 'Fefw54S6NDdwNbPngPePvW4tiFTFQDT7gBPvFoDFeGqg',
collateralAddressOrDenom: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
decimals: 6,
symbol: 'USDC',
name: 'USDC',
},
[TokenStandard.SealevelHypSynthetic]: {
chainName: testSealevelChain.name,
standard: TokenStandard.SealevelHypSynthetic,
addressOrDenom: 'GLpdg3jt6w4eVYiCMhokVZ4mX6hmRvPhcL5RoCjzGr5k',
collateralAddressOrDenom: '8SuhHnSEogAN2udZsoychjTafnaGgM9MCidYZEP8vuVY',
decimals: 9,
symbol: 'SOL',
name: 'SOL',
},
// Cosmos
[TokenStandard.CosmosIcs20]: null,
@ -161,18 +185,19 @@ const STANDARD_TO_TOKEN: Record<TokenStandard, TokenArgs | null> = {
[TokenStandard.CwHypSynthetic]: null,
};
const PROTOCOL_TO_ADDRESS: Partial<Record<ProtocolType, Address>> = {
const PROTOCOL_TO_ADDRESS_FOR_BALANCE_CHECK: Partial<
Record<ProtocolType, Address>
> = {
[ProtocolType.Ethereum]: ethers.constants.AddressZero,
[ProtocolType.Cosmos]:
'neutron13we0myxwzlpx8l5ark8elw5gj5d59dl6cjkzmt80c5q5cv5rt54qvzkv2a',
[ProtocolType.Sealevel]: 'EK6cs8jNnu2d9pmKTGf1Bvre9oW2xNhcCKNdLKx6t74w',
};
const STANDARD_TO_ADDRESS: Partial<Record<TokenStandard, Address>> = {
const STANDARD_TO_ADDRESS_FOR_BALANCE_CHECK: Partial<
Record<TokenStandard, Address>
> = {
[TokenStandard.SealevelSpl]: 'HVSZJ2juJnMxd6yCNarTL56YmgUqzfUiwM7y7LtTXKHR',
[TokenStandard.SealevelSpl2022]:
'EK6cs8jNnu2d9pmKTGf1Bvre9oW2xNhcCKNdLKx6t74w',
[TokenStandard.SealevelNative]:
'EK6cs8jNnu2d9pmKTGf1Bvre9oW2xNhcCKNdLKx6t74w',
[TokenStandard.CwHypNative]: 'inj1fl48vsnmsdzcv85q5d2q4z5ajdha8yu3lj7tt0',
};
@ -181,16 +206,21 @@ describe('Token', () => {
if (!tokenArgs) continue;
it(`Handles ${tokenArgs.standard} standard`, async () => {
const multiProvider =
MultiProtocolProvider.createTestMultiProtocolProvider();
MultiProtocolProvider.createTestMultiProtocolProvider<{
mailbox?: string;
}>();
// A placeholder mailbox address for the sealevel chain
multiProvider.metadata[testSealevelChain.name].mailbox =
SystemProgram.programId.toBase58();
console.debug('Testing token standard', tokenArgs.standard);
const token = new Token(tokenArgs);
expect(token.standard).to.eql(tokenArgs.standard);
const adapter = token.getAdapter(multiProvider);
const address =
STANDARD_TO_ADDRESS[token.standard] ??
PROTOCOL_TO_ADDRESS[token.protocol];
if (!address)
const balanceCheckAddress =
STANDARD_TO_ADDRESS_FOR_BALANCE_CHECK[token.standard] ??
PROTOCOL_TO_ADDRESS_FOR_BALANCE_CHECK[token.protocol];
if (!balanceCheckAddress)
throw new Error(`No address for standard ${tokenArgs.standard}`);
const sandbox = stubMultiProtocolProvider(multiProvider);
@ -199,7 +229,7 @@ describe('Token', () => {
balanceOf: async () => '100',
};
const balance = await adapter.getBalance(address);
const balance = await adapter.getBalance(balanceCheckAddress);
expect(typeof balance).to.eql('bigint');
sandbox.restore();
});

@ -170,13 +170,8 @@ export class Token implements IToken {
multiProvider: MultiProtocolProvider<{ mailbox?: Address }>,
destination?: ChainName,
): IHypTokenAdapter<unknown> {
const {
protocol,
standard,
chainName,
addressOrDenom,
collateralAddressOrDenom,
} = this;
const { standard, chainName, addressOrDenom, collateralAddressOrDenom } =
this;
const chainMetadata = multiProvider.tryGetChainMetadata(chainName);
const mailbox = chainMetadata?.mailbox;
@ -190,19 +185,6 @@ export class Token implements IToken {
`Token chain ${chainName} not found in multiProvider`,
);
let sealevelAddresses;
if (protocol === ProtocolType.Sealevel) {
assert(mailbox, `Mailbox required for Sealevel hyp tokens`);
assert(
collateralAddressOrDenom,
`collateralAddressOrDenom required for Sealevel hyp tokens`,
);
sealevelAddresses = {
warpRouter: addressOrDenom,
token: collateralAddressOrDenom,
mailbox,
};
}
if (standard === TokenStandard.EvmHypNative) {
return new EvmHypNativeAdapter(chainName, multiProvider, {
token: addressOrDenom,
@ -232,24 +214,46 @@ export class Token implements IToken {
token: addressOrDenom,
});
} else if (standard === TokenStandard.SealevelHypNative) {
assert(mailbox, `Mailbox required for Sealevel hyp tokens`);
return new SealevelHypNativeAdapter(
chainName,
multiProvider,
sealevelAddresses!,
{
warpRouter: addressOrDenom,
mailbox,
},
false,
);
} else if (standard === TokenStandard.SealevelHypCollateral) {
assert(mailbox, `Mailbox required for Sealevel hyp tokens`);
assert(
collateralAddressOrDenom,
`collateralAddressOrDenom required for Sealevel hyp collateral tokens`,
);
return new SealevelHypCollateralAdapter(
chainName,
multiProvider,
sealevelAddresses!,
{
warpRouter: addressOrDenom,
token: collateralAddressOrDenom,
mailbox,
},
false,
);
} else if (standard === TokenStandard.SealevelHypSynthetic) {
assert(mailbox, `Mailbox required for Sealevel hyp tokens`);
assert(
collateralAddressOrDenom,
`collateralAddressOrDenom required for Sealevel hyp synthetic tokens`,
);
return new SealevelHypSyntheticAdapter(
chainName,
multiProvider,
sealevelAddresses!,
{
warpRouter: addressOrDenom,
token: collateralAddressOrDenom,
mailbox,
},
false,
);
} else if (standard === TokenStandard.CwHypNative) {

@ -20,7 +20,6 @@ import {
Domain,
addressToBytes,
eqAddress,
isZeroishAddress,
} from '@hyperlane-xyz/utils';
import { BaseSealevelAdapter } from '../../app/MultiProtocolApp.js';
@ -50,7 +49,8 @@ import {
SealevelTransferRemoteSchema,
} from './serialization.js';
// author @tkporter @jmrossy
const NON_EXISTENT_ACCOUNT_ERROR = 'could not find account';
// Interacts with native currencies
export class SealevelNativeTokenAdapter
extends BaseSealevelAdapter
@ -109,10 +109,15 @@ export class SealevelTokenAdapter
async getBalance(owner: Address): Promise<bigint> {
const tokenPubKey = this.deriveAssociatedTokenAccount(new PublicKey(owner));
const response = await this.getProvider().getTokenAccountBalance(
tokenPubKey,
);
return BigInt(response.value.amount);
try {
const response = await this.getProvider().getTokenAccountBalance(
tokenPubKey,
);
return BigInt(response.value.amount);
} catch (error: any) {
if (error.message?.includes(NON_EXISTENT_ACCOUNT_ERROR)) return 0n;
throw error;
}
}
async getMetadata(_isNft?: boolean): Promise<TokenMetadata> {
@ -162,6 +167,12 @@ export class SealevelTokenAdapter
}
}
interface HypTokenAddresses {
token: Address;
warpRouter: Address;
mailbox: Address;
}
// The compute limit to set for the transfer remote instruction.
// This is typically around ~160k, but can be higher depending on
// the index in the merkle tree, which can result in more moderately
@ -175,23 +186,17 @@ export abstract class SealevelHypTokenAdapter
implements IHypTokenAdapter<Transaction>
{
public readonly warpProgramPubKey: PublicKey;
public readonly addresses: HypTokenAddresses;
protected cachedTokenAccountData: SealevelHyperlaneTokenData | undefined;
constructor(
public readonly chainName: ChainName,
public readonly multiProvider: MultiProtocolProvider,
public readonly addresses: {
token: Address;
warpRouter: Address;
mailbox: Address;
},
addresses: HypTokenAddresses,
public readonly isSpl2022: boolean = false,
) {
// Pass in placeholder address to avoid errors for native token addresses (which as represented here as 0s)
const superTokenProgramId = isZeroishAddress(addresses.token)
? SystemProgram.programId.toBase58()
: addresses.token;
super(chainName, multiProvider, { token: superTokenProgramId }, isSpl2022);
super(chainName, multiProvider, { token: addresses.token }, isSpl2022);
this.addresses = addresses;
this.warpProgramPubKey = new PublicKey(addresses.warpRouter);
}
@ -488,14 +493,21 @@ export class SealevelHypNativeAdapter extends SealevelHypTokenAdapter {
constructor(
public readonly chainName: ChainName,
public readonly multiProvider: MultiProtocolProvider,
public readonly addresses: {
token: Address;
addresses: {
// A 'token' address is not required for hyp native tokens (e.g. hypSOL)
token?: Address;
warpRouter: Address;
mailbox: Address;
},
public readonly isSpl2022: boolean = false,
) {
super(chainName, multiProvider, addresses, isSpl2022);
// Pass in placeholder address for 'token' to avoid errors in the parent classes
super(
chainName,
multiProvider,
{ ...addresses, token: SystemProgram.programId.toBase58() },
isSpl2022,
);
this.wrappedNative = new SealevelNativeTokenAdapter(
chainName,
multiProvider,
@ -606,10 +618,15 @@ export class SealevelHypSyntheticAdapter extends SealevelHypTokenAdapter {
override async getBalance(owner: Address): Promise<bigint> {
const tokenPubKey = this.deriveAssociatedTokenAccount(new PublicKey(owner));
const response = await this.getProvider().getTokenAccountBalance(
tokenPubKey,
);
return BigInt(response.value.amount);
try {
const response = await this.getProvider().getTokenAccountBalance(
tokenPubKey,
);
return BigInt(response.value.amount);
} catch (error: any) {
if (error.message?.includes(NON_EXISTENT_ACCOUNT_ERROR)) return 0n;
throw error;
}
}
deriveMintAuthorityAccount(): PublicKey {

Loading…
Cancel
Save