Implement WarpCore and Token classes (#3272)

### Description

Implements the WarpCore and auxiliary classes.

Summary of changes:
- Add `WarpCore`, `Token`, and `TokenAmount` classes
- Define TokenStandard enum and TokenConnection type
- Improve IGP quote handling in token adapters
- Define `ChainNameOrId` type, also use in MultiProvider
- Add optional `denom` field to chain metadata native token

### Related issues

Fixes https://github.com/hyperlane-xyz/hyperlane-warp-ui-template/issues/129

### Backward compatibility

No: The params to the `IHypTokenAdapter` `populateTransferRemoteTx` method have changed. `txValue` has been replaced with `interchainGas`

### Testing

- Created new tests
- Integrated into Warp UI
injective-ism-fix
J M Rossy 9 months ago committed by GitHub
parent 65fa0daae8
commit aea9e1438a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      .changeset/lazy-guests-enjoy.md
  2. 2
      typescript/cli/ci-test.sh
  3. 45
      typescript/cli/src/send/transfer.ts
  4. 2
      typescript/infra/scripts/warp-routes/monitor-warp-routes-balances.ts
  5. 7
      typescript/sdk/logos/black/plume.svg
  6. 2
      typescript/sdk/logos/color/plume.svg
  7. 3
      typescript/sdk/package.json
  8. 7
      typescript/sdk/src/consts/chainMetadata.ts
  9. 7
      typescript/sdk/src/gas/oracle/types.ts
  10. 42
      typescript/sdk/src/index.ts
  11. 48
      typescript/sdk/src/metadata/ChainMetadataManager.ts
  12. 27
      typescript/sdk/src/metadata/chainMetadataTypes.ts
  13. 2
      typescript/sdk/src/metadata/customZodTypes.ts
  14. 35
      typescript/sdk/src/providers/MultiProtocolProvider.ts
  15. 34
      typescript/sdk/src/providers/MultiProvider.ts
  16. 16
      typescript/sdk/src/providers/ProviderType.ts
  17. 1
      typescript/sdk/src/providers/providerBuilders.ts
  18. 88
      typescript/sdk/src/token/IToken.ts
  19. 176
      typescript/sdk/src/token/Token.test.ts
  20. 394
      typescript/sdk/src/token/Token.ts
  21. 48
      typescript/sdk/src/token/TokenAmount.test.ts
  22. 29
      typescript/sdk/src/token/TokenAmount.ts
  23. 73
      typescript/sdk/src/token/TokenConnection.ts
  24. 137
      typescript/sdk/src/token/TokenStandard.ts
  25. 135
      typescript/sdk/src/token/adapters/CosmWasmTokenAdapter.ts
  26. 51
      typescript/sdk/src/token/adapters/CosmosTokenAdapter.ts
  27. 168
      typescript/sdk/src/token/adapters/EvmTokenAdapter.ts
  28. 41
      typescript/sdk/src/token/adapters/ITokenAdapter.ts
  29. 54
      typescript/sdk/src/token/adapters/SealevelTokenAdapter.ts
  30. 4
      typescript/sdk/src/types.ts
  31. 264
      typescript/sdk/src/warp/WarpCore.test.ts
  32. 445
      typescript/sdk/src/warp/WarpCore.ts
  33. 73
      typescript/sdk/src/warp/example-warp-core-config.yaml
  34. 62
      typescript/sdk/src/warp/types.ts
  35. 2
      typescript/utils/index.ts
  36. 2
      typescript/utils/src/types.ts
  37. 5
      typescript/utils/src/validation.ts
  38. 1
      yarn.lock

@ -0,0 +1,8 @@
---
'@hyperlane-xyz/utils': minor
'@hyperlane-xyz/sdk': minor
---
Add `WarpCore`, `Token`, and `TokenAmount` classes for interacting with Warp Route instances.
_Breaking change_: The params to the `IHypTokenAdapter` `populateTransferRemoteTx` method have changed. `txValue` has been replaced with `interchainGas`.

@ -6,7 +6,7 @@
# motivation is to test both the bare bone deployment (included in the docs) and the deployment with the routing over igp hook (which is closer to production deployment)
HOOK_FLAG=$1
if [ -z "$HOOK_FLAG" ]; then
echo "Usage: fork.sh <hook>"
echo "Usage: ci-test.sh <hook>"
exit 1
fi

@ -1,5 +1,5 @@
import { input } from '@inquirer/prompts';
import { BigNumber, ethers } from 'ethers';
import { PopulatedTransaction, ethers } from 'ethers';
import {
ERC20__factory,
@ -9,8 +9,11 @@ import {
import {
ChainName,
EvmHypCollateralAdapter,
EvmHypNativeAdapter,
EvmHypSyntheticAdapter,
HyperlaneContractsMap,
HyperlaneCore,
IHypTokenAdapter,
MultiProtocolProvider,
MultiProvider,
TokenType,
@ -176,6 +179,9 @@ async function executeDelivery({
const provider = multiProvider.getProvider(origin);
const connectedSigner = signer.connect(provider);
// TODO replace all code below with WarpCore
// https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3259
if (tokenType === TokenType.collateral) {
const wrappedToken = await getWrappedToken(routerAddress, provider);
const token = ERC20__factory.connect(wrappedToken, connectedSigner);
@ -186,24 +192,33 @@ async function executeDelivery({
}
}
// TODO move next section into MultiProtocolTokenApp when it exists
const adapter = new EvmHypCollateralAdapter(
origin,
MultiProtocolProvider.fromMultiProvider(multiProvider),
{ token: routerAddress },
);
let adapter: IHypTokenAdapter<PopulatedTransaction>;
const multiProtocolProvider =
MultiProtocolProvider.fromMultiProvider(multiProvider);
if (tokenType === TokenType.native) {
adapter = new EvmHypNativeAdapter(origin, multiProtocolProvider, {
token: routerAddress,
});
} else if (tokenType === TokenType.collateral) {
adapter = new EvmHypCollateralAdapter(origin, multiProtocolProvider, {
token: routerAddress,
});
} else {
adapter = new EvmHypSyntheticAdapter(origin, multiProtocolProvider, {
token: routerAddress,
});
}
const destinationDomain = multiProvider.getDomainId(destination);
const gasPayment = await adapter.quoteGasPayment(destinationDomain);
const txValue =
tokenType === TokenType.native
? BigNumber.from(gasPayment).add(wei).toString()
: gasPayment;
const transferTx = await adapter.populateTransferRemoteTx({
log('Fetching interchain gas quote');
const interchainGas = await adapter.quoteGasPayment(destinationDomain);
log('Interchain gas quote:', interchainGas);
const transferTx = (await adapter.populateTransferRemoteTx({
weiAmountOrId: wei,
destination: destinationDomain,
recipient,
txValue,
});
interchainGas,
})) as ethers.PopulatedTransaction;
const txResponse = await connectedSigner.sendTransaction(transferTx);
const txReceipt = await multiProvider.handleTx(origin, txResponse);

@ -165,6 +165,8 @@ async function checkBalance(
);
}
case ProtocolType.Cosmos: {
if (!token.tokenAddress)
throw new Error('Token address missing for cosmos token');
const adapter = new CwNativeTokenAdapter(
chain,
multiProtocolProvider,

@ -1,5 +1,4 @@
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2001_197)">
<svg viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="175" cy="175" r="175" fill="url(#paint0_radial_2001_197)"/>
<path d="M285.938 65.8225V147.468L245.115 106.645L285.938 65.8225Z" fill="white" fill-opacity="0.95"/>
<path d="M285.938 65.8225H204.292L245.115 106.645L285.938 65.8225Z" fill="white"/>
@ -16,14 +15,10 @@
<path d="M122.648 229.115V147.468L163.471 188.291L122.648 229.115Z" fill="white" fill-opacity="0.6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M81.8217 269.938L122.644 229.115L163.467 269.938H93.1967L62.4844 289.275L81.8217 269.938Z" fill="#F8F8F8" fill-opacity="0.3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M81.8229 258.567V188.292L122.646 229.115L89.497 262.264L89.4983 262.265L62.4875 289.275L81.8229 258.567Z" fill="#F9F9F9" fill-opacity="0.4"/>
</g>
<defs>
<radialGradient id="paint0_radial_2001_197" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(52 300) rotate(-44.5937) scale(348.966 454.21)">
<stop offset="0.231932" stop-color="#0F0F0F"/>
<stop offset="0.871225" stop-color="#737373"/>
</radialGradient>
<clipPath id="clip0_2001_197">
<rect width="350" height="350" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -1,4 +1,4 @@
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="175" cy="175" r="175" fill="url(#paint0_radial_2001_152)"/>
<path d="M285.938 65.8225V147.468L245.115 106.645L285.938 65.8225Z" fill="white" fill-opacity="0.95"/>
<path d="M285.938 65.8225H204.292L245.115 106.645L285.938 65.8225Z" fill="white"/>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -38,7 +38,8 @@
"prettier": "^2.8.8",
"sinon": "^13.0.2",
"ts-node": "^10.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"yaml": "^2.3.1"
},
"files": [
"/dist",

@ -439,6 +439,7 @@ export const injective: ChainMetadata = {
name: Chains.injective,
nativeToken: {
decimals: 18,
denom: 'inj',
name: 'Injective',
symbol: 'INJ',
},
@ -612,6 +613,7 @@ export const neutron: ChainMetadata = {
name: Chains.neutron,
nativeToken: {
decimals: 6,
denom: 'untrn',
name: 'Neutron',
symbol: 'NTRN',
},
@ -897,7 +899,6 @@ export const solana: ChainMetadata = {
url: 'https://explorer.solana.com',
},
],
blocks: {
confirmations: 1,
estimateBlockTime: 0.4,
@ -1039,7 +1040,7 @@ export const viction: ChainMetadata = {
apiUrl: 'https://www.vicscan.xyz/api',
family: ExplorerFamily.Other,
name: 'Vicscan',
url: 'https://www.vicscan.xyz/',
url: 'https://www.vicscan.xyz',
},
],
blocks: {
@ -1060,7 +1061,7 @@ export const viction: ChainMetadata = {
protocol: ProtocolType.Ethereum,
rpcUrls: [
{
http: 'https://rpc.tomochain.com/',
http: 'https://rpc.tomochain.com',
},
{
http: 'https://viction.blockpi.network/v1/rpc/public',

@ -14,7 +14,12 @@ export type StorageGasOracleConfig = Pick<
'gasPrice' | 'tokenExchangeRate'
>;
export const formatGasOracleConfig = (config: StorageGasOracleConfig) => ({
export const formatGasOracleConfig = (
config: StorageGasOracleConfig,
): {
tokenExchangeRate: string;
gasPrice: string;
} => ({
tokenExchangeRate: ethers.utils.formatUnits(
config.tokenExchangeRate,
TOKEN_EXCHANGE_RATE_EXPONENT,

@ -86,14 +86,14 @@ export {
OwnerViolation,
ViolationType,
} from './deploy/types';
export { PostDeploymentContractVerifier } from './deploy/verify/PostDeploymentContractVerifier';
export { ContractVerifier } from './deploy/verify/ContractVerifier';
export { PostDeploymentContractVerifier } from './deploy/verify/PostDeploymentContractVerifier';
export {
BuildArtifact,
CompilerOptions,
ContractVerificationInput,
VerificationInput,
ExplorerLicenseType,
BuildArtifact,
VerificationInput,
} from './deploy/verify/types';
export * as verificationUtils from './deploy/verify/utils';
export { HyperlaneIgp } from './gas/HyperlaneIgp';
@ -136,7 +136,6 @@ export {
ProtocolFeeHookConfig,
} from './hook/types';
export { HyperlaneIsmFactory } from './ism/HyperlaneIsmFactory';
export { collectValidators, moduleCanCertainlyVerify } from './ism/utils';
export {
buildAggregationIsmConfigs,
buildMultisigIsmConfigs,
@ -153,6 +152,7 @@ export {
PausableIsmConfig,
RoutingIsmConfig,
} from './ism/types';
export { collectValidators, moduleCanCertainlyVerify } from './ism/utils';
export {
ChainMetadataManager,
ChainMetadataManagerOptions,
@ -322,6 +322,27 @@ export {
RouterViolationType,
proxiedFactories,
} from './router/types';
export { IToken, TokenArgs, TokenConfigSchema } from './token/IToken';
export { Token } from './token/Token';
export { TokenAmount } from './token/TokenAmount';
export {
HyperlaneTokenConnection,
IbcToHyperlaneTokenConnection,
IbcTokenConnection,
TokenConnection,
TokenConnectionConfigSchema,
TokenConnectionType,
} from './token/TokenConnection';
export {
PROTOCOL_TO_NATIVE_STANDARD,
TOKEN_COLLATERALIZED_STANDARDS,
TOKEN_COSMWASM_STANDARDS,
TOKEN_HYP_STANDARDS,
TOKEN_MULTI_CHAIN_STANDARDS,
TOKEN_NFT_STANDARDS,
TOKEN_STANDARD_TO_PROTOCOL,
TokenStandard,
} from './token/TokenStandard';
export {
CW20Metadata,
CwHypCollateralAdapter,
@ -337,11 +358,13 @@ export {
} from './token/adapters/CosmosTokenAdapter';
export {
EvmHypCollateralAdapter,
EvmHypNativeAdapter,
EvmHypSyntheticAdapter,
EvmNativeTokenAdapter,
EvmTokenAdapter,
} from './token/adapters/EvmTokenAdapter';
export {
InterchainGasQuote as AdapterInterchainGasQuote,
IHypTokenAdapter,
ITokenAdapter,
TransferParams,
@ -389,8 +412,8 @@ export { HypERC20Deployer, HypERC721Deployer } from './token/deploy';
export {
ChainMap,
ChainName,
ChainNameOrId,
Connection,
NameOrDomain,
TestChainNames,
} from './types';
export { MultiGeneric } from './utils/MultiGeneric';
@ -402,3 +425,12 @@ export {
getSealevelAccountDataSchema,
} from './utils/sealevelSerialization';
export { chainMetadataToWagmiChain, wagmiChainMetadata } from './utils/wagmi';
export { WarpCore, WarpCoreOptions } from './warp/WarpCore';
export {
IgpQuoteConstants,
RouteBlacklist,
WarpCoreConfig,
WarpCoreConfigSchema,
WarpTxCategory,
WarpTypedTransaction,
} from './warp/types';

@ -3,7 +3,7 @@ import { Debugger, debug } from 'debug';
import { ProtocolType, exclude, pick } from '@hyperlane-xyz/utils';
import { chainMetadata as defaultChainMetadata } from '../consts/chainMetadata';
import { ChainMap, ChainName } from '../types';
import { ChainMap, ChainName, ChainNameOrId } from '../types';
import {
getExplorerAddressUrl,
@ -89,7 +89,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* @throws if chain's metadata has not been set
*/
tryGetChainMetadata(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
): ChainMetadata<MetaExt> | null {
// First check if it's a chain name
if (this.metadata[chainNameOrId]) return this.metadata[chainNameOrId];
@ -104,7 +104,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* Get the metadata for a given chain name, chain id, or domain id
* @throws if chain's metadata has not been set
*/
getChainMetadata(chainNameOrId: ChainName | number): ChainMetadata<MetaExt> {
getChainMetadata(chainNameOrId: ChainNameOrId): ChainMetadata<MetaExt> {
const chainMetadata = this.tryGetChainMetadata(chainNameOrId);
if (!chainMetadata) {
throw new Error(`No chain metadata set for ${chainNameOrId}`);
@ -112,10 +112,18 @@ export class ChainMetadataManager<MetaExt = {}> {
return chainMetadata;
}
/**
* Returns true if the given chain name, chain id, or domain id is
* include in this manager's metadata, false otherwise
*/
hasChain(chainNameOrId: ChainNameOrId): boolean {
return !!this.tryGetChainMetadata(chainNameOrId);
}
/**
* Get the name for a given chain name, chain id, or domain id
*/
tryGetChainName(chainNameOrId: ChainName | number): string | null {
tryGetChainName(chainNameOrId: ChainNameOrId): string | null {
return this.tryGetChainMetadata(chainNameOrId)?.name ?? null;
}
@ -123,7 +131,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* Get the name for a given chain name, chain id, or domain id
* @throws if chain's metadata has not been set
*/
getChainName(chainNameOrId: ChainName | number): string {
getChainName(chainNameOrId: ChainNameOrId): string {
return this.getChainMetadata(chainNameOrId).name;
}
@ -137,7 +145,7 @@ export class ChainMetadataManager<MetaExt = {}> {
/**
* Get the id for a given chain name, chain id, or domain id
*/
tryGetChainId(chainNameOrId: ChainName | number): number | string | null {
tryGetChainId(chainNameOrId: ChainNameOrId): number | string | null {
return this.tryGetChainMetadata(chainNameOrId)?.chainId ?? null;
}
@ -145,7 +153,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* Get the id for a given chain name, chain id, or domain id
* @throws if chain's metadata has not been set
*/
getChainId(chainNameOrId: ChainName | number): number | string {
getChainId(chainNameOrId: ChainNameOrId): number | string {
return this.getChainMetadata(chainNameOrId).chainId;
}
@ -159,7 +167,7 @@ export class ChainMetadataManager<MetaExt = {}> {
/**
* Get the domain id for a given chain name, chain id, or domain id
*/
tryGetDomainId(chainNameOrId: ChainName | number): number | null {
tryGetDomainId(chainNameOrId: ChainNameOrId): number | null {
const metadata = this.tryGetChainMetadata(chainNameOrId);
if (!metadata) return null;
return getDomainId(metadata) ?? null;
@ -169,7 +177,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* Get the domain id for a given chain name, chain id, or domain id
* @throws if chain's metadata has not been set
*/
getDomainId(chainNameOrId: ChainName | number): number {
getDomainId(chainNameOrId: ChainNameOrId): number {
const domainId = this.tryGetDomainId(chainNameOrId);
if (!domainId) throw new Error(`No domain id set for ${chainNameOrId}`);
return domainId;
@ -178,7 +186,7 @@ export class ChainMetadataManager<MetaExt = {}> {
/**
* Get the protocol type for a given chain name, chain id, or domain id
*/
tryGetProtocol(chainNameOrId: ChainName | number): ProtocolType | null {
tryGetProtocol(chainNameOrId: ChainNameOrId): ProtocolType | null {
return this.tryGetChainMetadata(chainNameOrId)?.protocol ?? null;
}
@ -186,7 +194,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* Get the protocol type for a given chain name, chain id, or domain id
* @throws if chain's metadata or protocol has not been set
*/
getProtocol(chainNameOrId: ChainName | number): ProtocolType {
getProtocol(chainNameOrId: ChainNameOrId): ProtocolType {
return this.getChainMetadata(chainNameOrId).protocol;
}
@ -227,7 +235,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* Get an RPC URL for a given chain name, chain id, or domain id
* @throws if chain's metadata has not been set
*/
getRpcUrl(chainNameOrId: ChainName | number): string {
getRpcUrl(chainNameOrId: ChainNameOrId): string {
const { rpcUrls } = this.getChainMetadata(chainNameOrId);
if (!rpcUrls?.length || !rpcUrls[0].http)
throw new Error(`No RPC URl configured for ${chainNameOrId}`);
@ -237,7 +245,7 @@ export class ChainMetadataManager<MetaExt = {}> {
/**
* Get a block explorer URL for a given chain name, chain id, or domain id
*/
tryGetExplorerUrl(chainNameOrId: ChainName | number): string | null {
tryGetExplorerUrl(chainNameOrId: ChainNameOrId): string | null {
const metadata = this.tryGetChainMetadata(chainNameOrId);
if (!metadata) return null;
return getExplorerBaseUrl(metadata);
@ -247,7 +255,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* Get a block explorer URL for a given chain name, chain id, or domain id
* @throws if chain's metadata or block explorer data has no been set
*/
getExplorerUrl(chainNameOrId: ChainName | number): string {
getExplorerUrl(chainNameOrId: ChainNameOrId): string {
const url = this.tryGetExplorerUrl(chainNameOrId);
if (!url) throw new Error(`No explorer url set for ${chainNameOrId}`);
return url;
@ -284,7 +292,7 @@ export class ChainMetadataManager<MetaExt = {}> {
/**
* Get a block explorer's API URL for a given chain name, chain id, or domain id
*/
tryGetExplorerApiUrl(chainNameOrId: ChainName | number): string | null {
tryGetExplorerApiUrl(chainNameOrId: ChainNameOrId): string | null {
const metadata = this.tryGetChainMetadata(chainNameOrId);
if (!metadata) return null;
return getExplorerApiUrl(metadata);
@ -294,7 +302,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* Get a block explorer API URL for a given chain name, chain id, or domain id
* @throws if chain's metadata or block explorer data has no been set
*/
getExplorerApiUrl(chainNameOrId: ChainName | number): string {
getExplorerApiUrl(chainNameOrId: ChainNameOrId): string {
const url = this.tryGetExplorerApiUrl(chainNameOrId);
if (!url) throw new Error(`No explorer api url set for ${chainNameOrId}`);
return url;
@ -304,7 +312,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* Get a block explorer URL for given chain's tx
*/
tryGetExplorerTxUrl(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
response: { hash: string },
): string | null {
const metadata = this.tryGetChainMetadata(chainNameOrId);
@ -317,7 +325,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* @throws if chain's metadata or block explorer data has no been set
*/
getExplorerTxUrl(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
response: { hash: string },
): string {
return `${this.getExplorerUrl(chainNameOrId)}/tx/${response.hash}`;
@ -327,7 +335,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* Get a block explorer URL for given chain's address
*/
async tryGetExplorerAddressUrl(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
address?: string,
): Promise<string | null> {
const metadata = this.tryGetChainMetadata(chainNameOrId);
@ -340,7 +348,7 @@ export class ChainMetadataManager<MetaExt = {}> {
* @throws if address or the chain's block explorer data has no been set
*/
async getExplorerAddressUrl(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
address?: string,
): Promise<string> {
const url = await this.tryGetExplorerAddressUrl(chainNameOrId, address);

@ -6,7 +6,7 @@ import { SafeParseReturnType, z } from 'zod';
import { ProtocolType } from '@hyperlane-xyz/utils';
import { ZNzUint, ZUint } from './customZodTypes';
import { ZChainName, ZNzUint, ZUint } from './customZodTypes';
export enum ExplorerFamily {
Etherscan = 'etherscan',
@ -61,12 +61,9 @@ export type RpcUrl = z.infer<typeof RpcUrlSchema>;
* Specified as a Zod schema
*/
export const ChainMetadataSchemaObject = z.object({
name: z
.string()
.regex(/^[a-z][a-z0-9]*$/)
.describe(
'The unique string identifier of the chain, used as the key in ChainMap dictionaries.',
),
name: ZChainName.describe(
'The unique string identifier of the chain, used as the key in ChainMap dictionaries.',
),
protocol: z
.nativeEnum(ProtocolType)
.describe(
@ -99,6 +96,7 @@ export const ChainMetadataSchemaObject = z.object({
name: z.string(),
symbol: z.string(),
decimals: ZUint.lt(256),
denom: z.string().optional(),
})
.optional()
.describe(
@ -241,6 +239,21 @@ export const ChainMetadataSchema = ChainMetadataSchemaObject.refine(
message: 'Rest and gRPC URLs required for Cosmos chains',
path: ['restUrls', 'grpcUrls'],
},
)
.refine(
(metadata) => {
if (
metadata.protocol === ProtocolType.Cosmos &&
metadata.nativeToken &&
!metadata.nativeToken.denom
)
return false;
else return true;
},
{
message: 'Denom values are required for Cosmos native tokens',
path: ['nativeToken', 'denom'],
},
);
export type ChainMetadata<Ext = object> = z.infer<typeof ChainMetadataSchema> &

@ -16,3 +16,5 @@ export const ZHash = z
.regex(
/^(0x([0-9a-fA-F]{32}|[0-9a-fA-F]{40}|[0-9a-fA-F]{64}|[0-9a-fA-F]{128}))|([123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{32})$/,
);
/** Zod ChainName schema */
export const ZChainName = z.string().regex(/^[a-z][a-z0-9]*$/);

@ -1,17 +1,18 @@
import { Debugger, debug } from 'debug';
import { ProtocolType, objFilter, objMap, pick } from '@hyperlane-xyz/utils';
import { objFilter, objMap, pick } from '@hyperlane-xyz/utils';
import { chainMetadata as defaultChainMetadata } from '../consts/chainMetadata';
import { ChainMetadataManager } from '../metadata/ChainMetadataManager';
import type { ChainMetadata } from '../metadata/chainMetadataTypes';
import type { ChainMap, ChainName } from '../types';
import type { ChainMap, ChainName, ChainNameOrId } from '../types';
import { MultiProvider, MultiProviderOptions } from './MultiProvider';
import {
CosmJsProvider,
CosmJsWasmProvider,
EthersV5Provider,
PROTOCOL_TO_DEFAULT_PROVIDER_TYPE,
ProviderMap,
ProviderType,
SolanaWeb3Provider,
@ -23,14 +24,6 @@ import {
defaultProviderBuilderMap,
} from './providerBuilders';
export const PROTOCOL_DEFAULT_PROVIDER_TYPE: Partial<
Record<ProtocolType, ProviderType>
> = {
[ProtocolType.Ethereum]: ProviderType.EthersV5,
[ProtocolType.Sealevel]: ProviderType.SolanaWeb3,
[ProtocolType.Cosmos]: ProviderType.CosmJsWasm,
};
export interface MultiProtocolProviderOptions {
loggerName?: string;
providers?: ChainMap<ProviderMap<TypedProvider>>;
@ -116,13 +109,13 @@ export class MultiProtocolProvider<
}
tryGetProvider(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
type?: ProviderType,
): TypedProvider | null {
const metadata = this.tryGetChainMetadata(chainNameOrId);
if (!metadata) return null;
const { protocol, name, chainId, rpcUrls } = metadata;
type = type || PROTOCOL_DEFAULT_PROVIDER_TYPE[protocol];
type = type || PROTOCOL_TO_DEFAULT_PROVIDER_TYPE[protocol];
if (!type) return null;
if (this.providers[name]?.[type]) return this.providers[name][type]!;
@ -137,7 +130,7 @@ export class MultiProtocolProvider<
}
getProvider(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
type?: ProviderType,
): TypedProvider {
const provider = this.tryGetProvider(chainNameOrId, type);
@ -147,7 +140,7 @@ export class MultiProtocolProvider<
}
protected getSpecificProvider<T>(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
type: ProviderType,
): T {
const provider = this.getProvider(chainNameOrId, type);
@ -159,7 +152,7 @@ export class MultiProtocolProvider<
}
getEthersV5Provider(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
): EthersV5Provider['provider'] {
return this.getSpecificProvider<EthersV5Provider['provider']>(
chainNameOrId,
@ -167,7 +160,7 @@ export class MultiProtocolProvider<
);
}
getViemProvider(chainNameOrId: ChainName | number): ViemProvider['provider'] {
getViemProvider(chainNameOrId: ChainNameOrId): ViemProvider['provider'] {
return this.getSpecificProvider<ViemProvider['provider']>(
chainNameOrId,
ProviderType.Viem,
@ -175,7 +168,7 @@ export class MultiProtocolProvider<
}
getSolanaWeb3Provider(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
): SolanaWeb3Provider['provider'] {
return this.getSpecificProvider<SolanaWeb3Provider['provider']>(
chainNameOrId,
@ -183,9 +176,7 @@ export class MultiProtocolProvider<
);
}
getCosmJsProvider(
chainNameOrId: ChainName | number,
): CosmJsProvider['provider'] {
getCosmJsProvider(chainNameOrId: ChainNameOrId): CosmJsProvider['provider'] {
return this.getSpecificProvider<CosmJsProvider['provider']>(
chainNameOrId,
ProviderType.CosmJs,
@ -193,7 +184,7 @@ export class MultiProtocolProvider<
}
getCosmJsWasmProvider(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
): CosmJsWasmProvider['provider'] {
return this.getSpecificProvider<CosmJsWasmProvider['provider']>(
chainNameOrId,
@ -202,7 +193,7 @@ export class MultiProtocolProvider<
}
setProvider(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
provider: TypedProvider,
): TypedProvider {
const chainName = this.getChainName(chainNameOrId);

@ -15,7 +15,7 @@ import { chainMetadata as defaultChainMetadata } from '../consts/chainMetadata';
import { CoreChainName, TestChains } from '../consts/chains';
import { ChainMetadataManager } from '../metadata/ChainMetadataManager';
import { ChainMetadata } from '../metadata/chainMetadataTypes';
import { ChainMap, ChainName } from '../types';
import { ChainMap, ChainName, ChainNameOrId } from '../types';
import { ProviderBuilderFn, defaultProviderBuilder } from './providerBuilders';
@ -74,7 +74,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
/**
* Get an Ethers provider for a given chain name, chain id, or domain id
*/
tryGetProvider(chainNameOrId: ChainName | number): Provider | null {
tryGetProvider(chainNameOrId: ChainNameOrId): Provider | null {
const metadata = this.tryGetChainMetadata(chainNameOrId);
if (!metadata) return null;
const { name, chainId, rpcUrls } = metadata;
@ -99,7 +99,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* Get an Ethers provider for a given chain name, chain id, or domain id
* @throws if chain's metadata has not been set
*/
getProvider(chainNameOrId: ChainName | number): Provider {
getProvider(chainNameOrId: ChainNameOrId): Provider {
const provider = this.tryGetProvider(chainNameOrId);
if (!provider)
throw new Error(`No chain metadata set for ${chainNameOrId}`);
@ -110,7 +110,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* Sets an Ethers provider for a given chain name, chain id, or domain id
* @throws if chain's metadata has not been set
*/
setProvider(chainNameOrId: ChainName | number, provider: Provider): Provider {
setProvider(chainNameOrId: ChainNameOrId, provider: Provider): Provider {
const chainName = this.getChainName(chainNameOrId);
this.providers[chainName] = provider;
const signer = this.signers[chainName];
@ -135,7 +135,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* Get an Ethers signer for a given chain name, chain id, or domain id
* If signer is not yet connected, it will be connected
*/
tryGetSigner(chainNameOrId: ChainName | number): Signer | null {
tryGetSigner(chainNameOrId: ChainNameOrId): Signer | null {
const chainName = this.tryGetChainName(chainNameOrId);
if (!chainName) return null;
const signer = this.signers[chainName];
@ -151,7 +151,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* If signer is not yet connected, it will be connected
* @throws if chain's metadata or signer has not been set
*/
getSigner(chainNameOrId: ChainName | number): Signer {
getSigner(chainNameOrId: ChainNameOrId): Signer {
const signer = this.tryGetSigner(chainNameOrId);
if (!signer) throw new Error(`No chain signer set for ${chainNameOrId}`);
return signer;
@ -161,7 +161,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* Get an Ethers signer for a given chain name, chain id, or domain id
* @throws if chain's metadata or signer has not been set
*/
async getSignerAddress(chainNameOrId: ChainName | number): Promise<Address> {
async getSignerAddress(chainNameOrId: ChainNameOrId): Promise<Address> {
const signer = this.getSigner(chainNameOrId);
const address = await signer.getAddress();
return address;
@ -171,7 +171,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* Sets an Ethers Signer for a given chain name, chain id, or domain id
* @throws if chain's metadata has not been set or shared signer has already been set
*/
setSigner(chainNameOrId: ChainName | number, signer: Signer): Signer {
setSigner(chainNameOrId: ChainNameOrId, signer: Signer): Signer {
if (this.useSharedSigner) {
throw new Error('MultiProvider already set to use a shared signer');
}
@ -201,7 +201,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* Gets the Signer if it's been set, otherwise the provider
*/
tryGetSignerOrProvider(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
): Signer | Provider | null {
return (
this.tryGetSigner(chainNameOrId) || this.tryGetProvider(chainNameOrId)
@ -212,7 +212,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* Gets the Signer if it's been set, otherwise the provider
* @throws if chain metadata has not been set
*/
getSignerOrProvider(chainNameOrId: ChainName | number): Signer | Provider {
getSignerOrProvider(chainNameOrId: ChainNameOrId): Signer | Provider {
return this.tryGetSigner(chainNameOrId) || this.getProvider(chainNameOrId);
}
@ -258,7 +258,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* Get a block explorer URL for given chain's address
*/
override async tryGetExplorerAddressUrl(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
address?: string,
): Promise<string | null> {
if (address) return super.tryGetExplorerAddressUrl(chainNameOrId, address);
@ -275,7 +275,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* @throws if chain's metadata has not been set
*/
getTransactionOverrides(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
): Partial<providers.TransactionRequest> {
return this.getChainMetadata(chainNameOrId)?.transactionOverrides ?? {};
}
@ -285,7 +285,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* @throws if chain's metadata or signer has not been set or tx fails
*/
async handleDeploy<F extends ContractFactory>(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
factory: F,
params: Parameters<F['deploy']>,
): Promise<Awaited<ReturnType<F['deploy']>>> {
@ -316,7 +316,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* @throws if chain's metadata or signer has not been set or tx fails
*/
async handleTx(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
tx: ContractTransaction | Promise<ContractTransaction>,
): Promise<ContractReceipt> {
const confirmations =
@ -336,7 +336,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* @throws if chain's metadata has not been set or tx fails
*/
async prepareTx(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
tx: PopulatedTransaction,
from?: string,
): Promise<providers.TransactionRequest> {
@ -354,7 +354,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* @throws if chain's metadata has not been set or tx fails
*/
async estimateGas(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
tx: PopulatedTransaction,
from?: string,
): Promise<BigNumber> {
@ -375,7 +375,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
* @throws if chain's metadata or signer has not been set or tx fails
*/
async sendTransaction(
chainNameOrId: ChainName | number,
chainNameOrId: ChainNameOrId,
tx: PopulatedTransaction | Promise<PopulatedTransaction>,
): Promise<ContractReceipt> {
const txReq = await this.prepareTx(chainNameOrId, await tx);

@ -23,6 +23,8 @@ import type {
TransactionReceipt as VTransactionReceipt,
} from 'viem';
import { ProtocolType } from '@hyperlane-xyz/utils';
export enum ProviderType {
EthersV5 = 'ethers-v5',
// EthersV6 = 'ethers-v6', Disabled for now to simplify build tooling
@ -30,8 +32,20 @@ export enum ProviderType {
SolanaWeb3 = 'solana-web3',
CosmJs = 'cosmjs',
CosmJsWasm = 'cosmjs-wasm',
// TODO fuel provider types not yet defined below
Fuel = 'fuel',
}
export const PROTOCOL_TO_DEFAULT_PROVIDER_TYPE: Record<
ProtocolType,
ProviderType
> = {
[ProtocolType.Ethereum]: ProviderType.EthersV5,
[ProtocolType.Sealevel]: ProviderType.SolanaWeb3,
[ProtocolType.Cosmos]: ProviderType.CosmJsWasm,
[ProtocolType.Fuel]: ProviderType.Fuel,
};
export type ProviderMap<Value> = Partial<Record<ProviderType, Value>>;
/**
@ -172,7 +186,7 @@ export interface CosmJsTransaction extends TypedTransactionBase<CmTransaction> {
export interface CosmJsWasmTransaction
extends TypedTransactionBase<ExecuteInstruction> {
type: ProviderType.CosmJs;
type: ProviderType.CosmJsWasm;
transaction: ExecuteInstruction;
}

@ -127,6 +127,7 @@ export const defaultProviderBuilderMap: ProviderBuilderMap = {
[ProviderType.SolanaWeb3]: defaultSolProviderBuilder,
[ProviderType.CosmJs]: defaultCosmJsProviderBuilder,
[ProviderType.CosmJsWasm]: defaultCosmJsWasmProviderBuilder,
[ProtocolType.Fuel]: defaultFuelProviderBuilder,
};
export const protocolToDefaultProviderBuilder: Record<

@ -0,0 +1,88 @@
import { z } from 'zod';
import { Address, Numberish, ProtocolType } from '@hyperlane-xyz/utils';
import { ZChainName, ZUint } from '../metadata/customZodTypes';
import type { MultiProtocolProvider } from '../providers/MultiProtocolProvider';
import type { ChainName } from '../types';
import type { TokenAmount } from './TokenAmount';
import {
type TokenConnection,
TokenConnectionConfigSchema,
} from './TokenConnection';
import { TokenStandard } from './TokenStandard';
import type { IHypTokenAdapter, ITokenAdapter } from './adapters/ITokenAdapter';
export const TokenConfigSchema = z.object({
chainName: ZChainName.describe(
'The name of the chain, must correspond to a chain in the multiProvider chainMetadata',
),
standard: z
.nativeEnum(TokenStandard)
.describe('The type of token. See TokenStandard for valid values.'),
decimals: ZUint.lt(256).describe('The decimals value (e.g. 18 for Eth)'),
symbol: z.string().min(1).describe('The symbol of the token'),
name: z.string().min(1).describe('The name of the token'),
addressOrDenom: z
.string()
.min(1)
.or(z.null())
.describe('The address or denom, or null for native tokens'),
collateralAddressOrDenom: z
.string()
.min(1)
.optional()
.describe('The address or denom of the collateralized token'),
igpTokenAddressOrDenom: z
.string()
.min(1)
.optional()
.describe('The address or denom of the token for IGP payments'),
logoURI: z.string().optional().describe('The URI of the token logo'),
connections: z
.array(TokenConnectionConfigSchema)
.optional()
.describe('The list of token connections (e.g. warp or IBC)'),
});
export type TokenArgs = Omit<
z.infer<typeof TokenConfigSchema>,
'addressOrDenom' | 'connections'
> & {
addressOrDenom: Address | string;
connections?: Array<TokenConnection>;
};
export interface IToken extends TokenArgs {
protocol: ProtocolType;
getAdapter(multiProvider: MultiProtocolProvider): ITokenAdapter<unknown>;
getHypAdapter(
multiProvider: MultiProtocolProvider<{ mailbox?: Address }>,
destination?: ChainName,
): IHypTokenAdapter<unknown>;
getBalance(
multiProvider: MultiProtocolProvider,
address: Address,
): Promise<TokenAmount>;
amount(amount: Numberish): TokenAmount;
isNft(): boolean;
isNative(): boolean;
isHypToken(): boolean;
isIbcToken(): boolean;
isMultiChainToken(): boolean;
getConnections(): TokenConnection[];
getConnectionForChain(chain: ChainName): TokenConnection | undefined;
addConnection(connection: TokenConnection): IToken;
removeConnection(token: IToken): IToken;
equals(token: IToken): boolean;
collateralizes(token: IToken): boolean;
}

@ -0,0 +1,176 @@
/* eslint-disable no-console */
import { expect } from 'chai';
import { ethers } from 'ethers';
import { Address, ProtocolType } from '@hyperlane-xyz/utils';
import { chainMetadata } from '../consts/chainMetadata';
import { Chains } from '../consts/chains';
import { MultiProtocolProvider } from '../providers/MultiProtocolProvider';
import { TokenArgs } from './IToken';
import { Token } from './Token';
import { TokenStandard } from './TokenStandard';
// null values represent TODOs here, ideally all standards should be tested
const STANDARD_TO_TOKEN: Record<TokenStandard, TokenArgs | null> = {
// EVM
[TokenStandard.ERC20]: {
chainName: Chains.ethereum,
standard: TokenStandard.ERC20,
addressOrDenom: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
decimals: 6,
symbol: 'USDC',
name: 'USDC',
},
[TokenStandard.ERC721]: null,
[TokenStandard.EvmNative]: Token.FromChainMetadataNativeToken(
chainMetadata.optimism,
),
[TokenStandard.EvmHypNative]: {
chainName: Chains.inevm,
standard: TokenStandard.EvmHypNative,
addressOrDenom: '0x26f32245fCF5Ad53159E875d5Cae62aEcf19c2d4',
decimals: 18,
symbol: 'INJ',
name: 'Injective Coin',
},
[TokenStandard.EvmHypCollateral]: {
chainName: Chains.goerli,
standard: TokenStandard.EvmHypCollateral,
addressOrDenom: '0x145de8760021c4ac6676376691b78038d3DE9097',
collateralAddressOrDenom: '0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6',
decimals: 18,
symbol: 'WETH',
name: 'Weth',
},
[TokenStandard.EvmHypSynthetic]: {
chainName: Chains.inevm,
standard: TokenStandard.EvmHypSynthetic,
addressOrDenom: '0x8358D8291e3bEDb04804975eEa0fe9fe0fAfB147',
decimals: 6,
symbol: 'USDC',
name: 'USDC',
},
// Sealevel
[TokenStandard.SealevelSpl]: {
chainName: Chains.solana,
standard: TokenStandard.SealevelSpl,
addressOrDenom: 'So11111111111111111111111111111111111111112',
decimals: 9,
symbol: 'Wrapped SOL',
name: 'SOL',
},
[TokenStandard.SealevelSpl2022]: {
chainName: Chains.solana,
standard: TokenStandard.SealevelSpl2022,
addressOrDenom: '21zHSATJqhNkcpoNkhFzPJW9LARSmoinLEeDtdygGuWh',
decimals: 6,
symbol: 'SOLMAX',
name: 'Solana Maxi',
},
[TokenStandard.SealevelNative]: Token.FromChainMetadataNativeToken(
chainMetadata.solana,
),
[TokenStandard.SealevelHypNative]: null,
[TokenStandard.SealevelHypCollateral]: null,
[TokenStandard.SealevelHypSynthetic]: null,
// Cosmos
[TokenStandard.CosmosIcs20]: null,
[TokenStandard.CosmosIcs721]: null,
[TokenStandard.CosmosNative]: Token.FromChainMetadataNativeToken(
chainMetadata.neutron,
),
[TokenStandard.CosmosIbc]: {
chainName: Chains.neutron,
standard: TokenStandard.CosmosIbc,
addressOrDenom:
'ibc/773B4D0A3CD667B2275D5A4A7A2F0909C0BA0F4059C0B9181E680DDF4965DCC7',
decimals: 6,
symbol: 'TIA',
name: 'TIA',
},
[TokenStandard.CW20]: null,
[TokenStandard.CWNative]: {
chainName: Chains.neutron,
standard: TokenStandard.CWNative,
addressOrDenom:
'ibc/5751B8BCDA688FD0A8EC0B292EEF1CDEAB4B766B63EC632778B196D317C40C3A',
decimals: 6,
symbol: 'ASTRO',
name: 'ASTRO',
},
[TokenStandard.CW721]: null,
[TokenStandard.CwHypNative]: {
chainName: Chains.injective,
standard: TokenStandard.CwHypNative,
addressOrDenom: 'inj1mv9tjvkaw7x8w8y9vds8pkfq46g2vcfkjehc6k',
igpTokenAddressOrDenom: 'inj',
decimals: 18,
symbol: 'INJ',
name: 'Injective Coin',
},
[TokenStandard.CwHypCollateral]: {
chainName: Chains.neutron,
standard: TokenStandard.CwHypCollateral,
addressOrDenom:
'neutron1jyyjd3x0jhgswgm6nnctxvzla8ypx50tew3ayxxwkrjfxhvje6kqzvzudq',
collateralAddressOrDenom:
'ibc/773B4D0A3CD667B2275D5A4A7A2F0909C0BA0F4059C0B9181E680DDF4965DCC7',
decimals: 6,
symbol: 'TIA.n',
name: 'TIA.n',
},
[TokenStandard.CwHypSynthetic]: null,
// Fuel
[TokenStandard.FuelNative]: null,
};
const PROTOCOL_TO_ADDRESS: Partial<Record<ProtocolType, Address>> = {
[ProtocolType.Ethereum]: ethers.constants.AddressZero,
[ProtocolType.Cosmos]:
'neutron13we0myxwzlpx8l5ark8elw5gj5d59dl6cjkzmt80c5q5cv5rt54qvzkv2a',
[ProtocolType.Fuel]: '',
};
const STANDARD_TO_ADDRESS: Partial<Record<TokenStandard, Address>> = {
[TokenStandard.SealevelSpl]: 'HVSZJ2juJnMxd6yCNarTL56YmgUqzfUiwM7y7LtTXKHR',
[TokenStandard.SealevelSpl2022]:
'EK6cs8jNnu2d9pmKTGf1Bvre9oW2xNhcCKNdLKx6t74w',
[TokenStandard.SealevelNative]:
'EK6cs8jNnu2d9pmKTGf1Bvre9oW2xNhcCKNdLKx6t74w',
[TokenStandard.CwHypNative]: 'inj1fl48vsnmsdzcv85q5d2q4z5ajdha8yu3lj7tt0',
};
describe('Token', () => {
it('Handles all standards', async () => {
const multiProvider = new MultiProtocolProvider();
for (const tokenArgs of Object.values(STANDARD_TO_TOKEN)) {
if (!tokenArgs) continue;
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 adddress =
STANDARD_TO_ADDRESS[token.standard] ??
PROTOCOL_TO_ADDRESS[token.protocol];
if (!adddress)
throw new Error(`No address for standard ${tokenArgs.standard}`);
const balance = await adapter.getBalance(adddress);
expect(typeof balance).to.eql('bigint');
}
})
.timeout(120_000)
.retries(3);
it('Constructs from ChainMetadata', () => {
for (const metadata of Object.values(chainMetadata)) {
if (!metadata.nativeToken) continue;
const token = Token.FromChainMetadataNativeToken(metadata);
expect(token.symbol).to.eql(metadata.nativeToken.symbol);
}
});
});

@ -0,0 +1,394 @@
/* eslint-disable @typescript-eslint/no-empty-interface */
import { MsgTransferEncodeObject } from '@cosmjs/stargate';
import {
Address,
Numberish,
ProtocolType,
assert,
eqAddress,
} from '@hyperlane-xyz/utils';
import { ChainMetadata } from '../metadata/chainMetadataTypes';
import { MultiProtocolProvider } from '../providers/MultiProtocolProvider';
import { ChainName } from '../types';
import type { IToken, TokenArgs } from './IToken';
import { TokenAmount } from './TokenAmount';
import { TokenConnection, TokenConnectionType } from './TokenConnection';
import {
PROTOCOL_TO_NATIVE_STANDARD,
TOKEN_COLLATERALIZED_STANDARDS,
TOKEN_HYP_STANDARDS,
TOKEN_MULTI_CHAIN_STANDARDS,
TOKEN_NFT_STANDARDS,
TOKEN_STANDARD_TO_PROTOCOL,
TokenStandard,
} from './TokenStandard';
import {
CwHypCollateralAdapter,
CwHypNativeAdapter,
CwHypSyntheticAdapter,
CwNativeTokenAdapter,
CwTokenAdapter,
} from './adapters/CosmWasmTokenAdapter';
import {
CosmIbcToWarpTokenAdapter,
CosmIbcTokenAdapter,
CosmNativeTokenAdapter,
} from './adapters/CosmosTokenAdapter';
import {
EvmHypCollateralAdapter,
EvmHypNativeAdapter,
EvmHypSyntheticAdapter,
EvmNativeTokenAdapter,
EvmTokenAdapter,
} from './adapters/EvmTokenAdapter';
import type { IHypTokenAdapter, ITokenAdapter } from './adapters/ITokenAdapter';
import {
SealevelHypCollateralAdapter,
SealevelHypNativeAdapter,
SealevelHypSyntheticAdapter,
SealevelNativeTokenAdapter,
SealevelTokenAdapter,
} from './adapters/SealevelTokenAdapter';
// Declaring the interface in addition to class allows
// Typescript to infer the members vars from TokenArgs
export interface Token extends TokenArgs {}
export class Token implements IToken {
public readonly protocol: ProtocolType;
constructor(args: TokenArgs) {
Object.assign(this, args);
this.protocol = TOKEN_STANDARD_TO_PROTOCOL[this.standard];
}
static FromChainMetadataNativeToken(chainMetadata: ChainMetadata): Token {
const { protocol, name: chainName, nativeToken, logoURI } = chainMetadata;
assert(
nativeToken,
`ChainMetadata for ${chainMetadata.name} missing nativeToken`,
);
return new Token({
chainName,
standard: PROTOCOL_TO_NATIVE_STANDARD[protocol],
addressOrDenom: nativeToken.denom ?? '',
decimals: nativeToken.decimals,
symbol: nativeToken.symbol,
name: nativeToken.name,
logoURI,
});
}
/**
* Returns a TokenAdapter for the token and multiProvider
* @throws If multiProvider does not contain this token's chain.
* @throws If token is an NFT (TODO NFT Adapter support)
*/
getAdapter(multiProvider: MultiProtocolProvider): ITokenAdapter<unknown> {
const { standard, chainName, addressOrDenom } = this;
assert(!this.isNft(), 'NFT adapters not yet supported');
assert(
multiProvider.tryGetChainMetadata(chainName),
`Token chain ${chainName} not found in multiProvider`,
);
if (standard === TokenStandard.ERC20) {
return new EvmTokenAdapter(chainName, multiProvider, {
token: addressOrDenom,
});
} else if (standard === TokenStandard.EvmNative) {
return new EvmNativeTokenAdapter(chainName, multiProvider, {});
} else if (standard === TokenStandard.SealevelSpl) {
return new SealevelTokenAdapter(
chainName,
multiProvider,
{ token: addressOrDenom },
false,
);
} else if (standard === TokenStandard.SealevelSpl2022) {
return new SealevelTokenAdapter(
chainName,
multiProvider,
{ token: addressOrDenom },
true,
);
} else if (standard === TokenStandard.SealevelNative) {
return new SealevelNativeTokenAdapter(chainName, multiProvider, {});
} else if (standard === TokenStandard.CosmosIcs20) {
throw new Error('Cosmos ICS20 token adapter not yet supported');
} else if (standard === TokenStandard.CosmosNative) {
return new CosmNativeTokenAdapter(
chainName,
multiProvider,
{},
{ ibcDenom: addressOrDenom },
);
} else if (standard === TokenStandard.CW20) {
return new CwTokenAdapter(chainName, multiProvider, {
token: addressOrDenom,
});
} else if (standard === TokenStandard.CWNative) {
return new CwNativeTokenAdapter(
chainName,
multiProvider,
{},
addressOrDenom,
);
} else if (this.isHypToken()) {
return this.getHypAdapter(multiProvider);
} else if (this.isIbcToken()) {
// Passing in a stub connection here because it's not required
// for an IBC adapter to fulfill the ITokenAdapter interface
return this.getIbcAdapter(multiProvider, {
token: this,
sourcePort: 'transfer',
sourceChannel: 'channel-0',
type: TokenConnectionType.Ibc,
});
} else {
throw new Error(`No adapter found for token standard: ${standard}`);
}
}
/**
* Returns a HypTokenAdapter for the token and multiProvider
* @throws If not applicable to this token's standard.
* @throws If multiProvider does not contain this token's chain.
* @throws If token is an NFT (TODO NFT Adapter support)
*/
getHypAdapter(
multiProvider: MultiProtocolProvider<{ mailbox?: Address }>,
destination?: ChainName,
): IHypTokenAdapter<unknown> {
const {
protocol,
standard,
chainName,
addressOrDenom,
collateralAddressOrDenom,
} = this;
const chainMetadata = multiProvider.tryGetChainMetadata(chainName);
const mailbox = chainMetadata?.mailbox;
assert(
this.isMultiChainToken(),
`Token standard ${standard} not applicable to hyp adapter`,
);
assert(!this.isNft(), 'NFT adapters not yet supported');
assert(
chainMetadata,
`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,
});
} else if (standard === TokenStandard.EvmHypCollateral) {
return new EvmHypCollateralAdapter(chainName, multiProvider, {
token: addressOrDenom,
});
} else if (standard === TokenStandard.EvmHypSynthetic) {
return new EvmHypSyntheticAdapter(chainName, multiProvider, {
token: addressOrDenom,
});
} else if (standard === TokenStandard.SealevelHypNative) {
return new SealevelHypNativeAdapter(
chainName,
multiProvider,
sealevelAddresses!,
false,
);
} else if (standard === TokenStandard.SealevelHypCollateral) {
return new SealevelHypCollateralAdapter(
chainName,
multiProvider,
sealevelAddresses!,
false,
);
} else if (standard === TokenStandard.SealevelHypSynthetic) {
return new SealevelHypSyntheticAdapter(
chainName,
multiProvider,
sealevelAddresses!,
false,
);
} else if (standard === TokenStandard.CwHypNative) {
return new CwHypNativeAdapter(chainName, multiProvider, {
warpRouter: addressOrDenom,
});
} else if (standard === TokenStandard.CwHypCollateral) {
assert(
collateralAddressOrDenom,
'collateralAddressOrDenom required for CwHypCollateral',
);
return new CwHypCollateralAdapter(chainName, multiProvider, {
warpRouter: addressOrDenom,
token: collateralAddressOrDenom,
});
} else if (standard === TokenStandard.CwHypSynthetic) {
assert(
collateralAddressOrDenom,
'collateralAddressOrDenom required for CwHypSyntheticAdapter',
);
return new CwHypSyntheticAdapter(chainName, multiProvider, {
warpRouter: addressOrDenom,
token: collateralAddressOrDenom,
});
} else if (standard === TokenStandard.CosmosIbc) {
assert(destination, 'destination required for IBC token adapters');
const connection = this.getConnectionForChain(destination);
assert(connection, `No connection found for chain ${destination}`);
return this.getIbcAdapter(multiProvider, connection);
} else {
throw new Error(`No hyp adapter found for token standard: ${standard}`);
}
}
protected getIbcAdapter(
multiProvider: MultiProtocolProvider,
connection: TokenConnection,
): IHypTokenAdapter<MsgTransferEncodeObject> {
if (connection.type === TokenConnectionType.Ibc) {
const { sourcePort, sourceChannel } = connection;
return new CosmIbcTokenAdapter(
this.chainName,
multiProvider,
{},
{ ibcDenom: this.addressOrDenom, sourcePort, sourceChannel },
);
} else if (connection.type === TokenConnectionType.IbcHyperlane) {
const {
sourcePort,
sourceChannel,
intermediateChainName,
intermediateIbcDenom,
intermediateRouterAddress,
} = connection;
const destinationRouterAddress = connection.token.addressOrDenom;
return new CosmIbcToWarpTokenAdapter(
this.chainName,
multiProvider,
{
intermediateRouterAddress,
destinationRouterAddress,
},
{
ibcDenom: this.addressOrDenom,
sourcePort,
sourceChannel,
intermediateIbcDenom,
intermediateChainName,
},
);
} else {
throw new Error(`Unsupported IBC connection type: ${connection.type}`);
}
}
/**
* Convenience method to create an adapter and return an account balance
*/
async getBalance(
multiProvider: MultiProtocolProvider,
address: Address,
): Promise<TokenAmount> {
const adapter = this.getAdapter(multiProvider);
const balance = await adapter.getBalance(address);
return new TokenAmount(balance, this);
}
amount(amount: Numberish): TokenAmount {
return new TokenAmount(amount, this);
}
isNft(): boolean {
return TOKEN_NFT_STANDARDS.includes(this.standard);
}
isNative(): boolean {
return Object.values(PROTOCOL_TO_NATIVE_STANDARD).includes(this.standard);
}
isHypToken(): boolean {
return TOKEN_HYP_STANDARDS.includes(this.standard);
}
isIbcToken(): boolean {
return this.standard === TokenStandard.CosmosIbc;
}
isMultiChainToken(): boolean {
return TOKEN_MULTI_CHAIN_STANDARDS.includes(this.standard);
}
getConnections(): TokenConnection[] {
return this.connections || [];
}
getConnectionForChain(chain: ChainName): TokenConnection | undefined {
// A token cannot have > 1 connected token for the same chain
return this.getConnections().filter((t) => t.token.chainName === chain)[0];
}
addConnection(connection: TokenConnection): Token {
this.connections = [...(this.connections || []), connection];
return this;
}
removeConnection(token: Token): Token {
const index = this.connections?.findIndex((t) => t.token.equals(token));
if (index && index >= 0) this.connections?.splice(index, 1);
return this;
}
/**
* Returns true if tokens refer to the same asset
*/
equals(token: Token): boolean {
return (
this.protocol === token.protocol &&
this.chainName === token.chainName &&
this.standard === token.standard &&
this.decimals === token.decimals &&
this.addressOrDenom.toLowerCase() ===
token.addressOrDenom.toLowerCase() &&
this.collateralAddressOrDenom?.toLowerCase() ===
token.collateralAddressOrDenom?.toLowerCase()
);
}
/**
* Checks if this token is both:
* 1) Of a TokenStandard that uses other tokens as collateral (eg. EvmHypCollateral)
* 2) Has a collateralAddressOrDenom address that matches the given token
* E.g. ERC20 Token ABC, EvmHypCollateral DEF that wraps ABC, DEF.collateralizes(ABC) === true
*/
collateralizes(token: Token): boolean {
if (token.chainName !== this.chainName) return false;
if (!TOKEN_COLLATERALIZED_STANDARDS.includes(this.standard)) return false;
const isCollateralWrapper =
this.collateralAddressOrDenom &&
eqAddress(this.collateralAddressOrDenom, token.addressOrDenom);
const isNativeWrapper = !this.collateralAddressOrDenom && token.isNative();
return isCollateralWrapper || isNativeWrapper;
}
}

@ -0,0 +1,48 @@
import { expect } from 'chai';
import { ethers } from 'ethers';
import { chainMetadata } from '../consts/chainMetadata';
import { Chains } from '../consts/chains';
import { Token } from './Token';
import { TokenAmount } from './TokenAmount';
import { TokenStandard } from './TokenStandard';
const token1 = new Token({
chainName: Chains.ethereum,
standard: TokenStandard.ERC20,
addressOrDenom: ethers.constants.AddressZero,
decimals: 4,
symbol: 'FAKE',
name: 'Fake Token',
});
const token2 = Token.FromChainMetadataNativeToken(
chainMetadata[Chains.neutron],
);
describe('TokenAmount', () => {
let tokenAmount1: TokenAmount;
let tokenAmount2: TokenAmount;
it('Constructs', () => {
tokenAmount1 = new TokenAmount(123456789, token1);
tokenAmount2 = new TokenAmount('1', token2);
expect(!!tokenAmount1).to.eq(true);
expect(!!tokenAmount2).to.eq(true);
});
it('Formats human readable string', () => {
expect(tokenAmount1.getDecimalFormattedAmount()).to.eq(12345.6789);
expect(tokenAmount2.getDecimalFormattedAmount()).to.eq(0.000001);
});
it('Does arithmetic', () => {
expect(tokenAmount1.plus(1).amount).to.eq(123456790n);
expect(tokenAmount2.minus(1).amount).to.eq(0n);
});
it('Checks equality', () => {
expect(tokenAmount1.equals(tokenAmount2)).to.be.false;
expect(tokenAmount1.equals(new TokenAmount(123456789n, token1))).to.true;
});
});

@ -0,0 +1,29 @@
import { Numberish, fromWei } from '@hyperlane-xyz/utils';
import type { IToken } from './IToken';
export class TokenAmount {
public readonly amount: bigint;
constructor(_amount: Numberish, public readonly token: IToken) {
this.amount = BigInt(_amount);
}
getDecimalFormattedAmount(): number {
return Number(fromWei(this.amount.toString(), this.token.decimals));
}
plus(amount: Numberish): TokenAmount {
return new TokenAmount(this.amount + BigInt(amount), this.token);
}
minus(amount: Numberish): TokenAmount {
return new TokenAmount(this.amount - BigInt(amount), this.token);
}
equals(tokenAmount: TokenAmount): boolean {
return (
this.amount === tokenAmount.amount && this.token.equals(tokenAmount.token)
);
}
}

@ -0,0 +1,73 @@
import { z } from 'zod';
import { Address } from '@hyperlane-xyz/utils';
import { ZChainName } from '../metadata/customZodTypes';
import { ChainName } from '../types';
import type { IToken } from './IToken';
export enum TokenConnectionType {
Hyperlane = 'hyperlane',
Ibc = 'ibc',
IbcHyperlane = 'ibc-hyperlane', // a.k.a. one-click two-hop
}
interface TokenConnectionBase {
type?: TokenConnectionType;
token: IToken; // the token that is being connected to
}
export interface HyperlaneTokenConnection extends TokenConnectionBase {
type?: TokenConnectionType.Hyperlane;
}
export interface IbcTokenConnection extends TokenConnectionBase {
type: TokenConnectionType.Ibc;
sourcePort: string;
sourceChannel: string;
}
export interface IbcToHyperlaneTokenConnection extends TokenConnectionBase {
type: TokenConnectionType.IbcHyperlane;
sourcePort: string;
sourceChannel: string;
intermediateChainName: ChainName;
intermediateIbcDenom: string;
intermediateRouterAddress: Address;
}
export type TokenConnection =
| HyperlaneTokenConnection
| IbcTokenConnection
| IbcToHyperlaneTokenConnection;
const TokenConnectionRegex = /^(.+)|(.+)|(.+)$/;
// Distinct from type above in that it uses a
// serialized representation of the tokens instead
// of the possibly recursive Token references
export const TokenConnectionConfigSchema = z
.object({
type: z.literal(TokenConnectionType.Hyperlane).optional(),
token: z.string().regex(TokenConnectionRegex),
})
.or(
z.object({
type: z.literal(TokenConnectionType.Ibc),
token: z.string().regex(TokenConnectionRegex),
sourcePort: z.string(),
sourceChannel: z.string(),
}),
)
.or(
z.object({
type: z.literal(TokenConnectionType.IbcHyperlane),
token: z.string().regex(TokenConnectionRegex),
sourcePort: z.string(),
sourceChannel: z.string(),
intermediateChainName: ZChainName,
intermediateIbcDenom: z.string(),
intermediateRouterAddress: z.string(),
}),
);

@ -0,0 +1,137 @@
import { ProtocolType, objMap } from '@hyperlane-xyz/utils';
import {
PROTOCOL_TO_DEFAULT_PROVIDER_TYPE,
ProviderType,
} from '../providers/ProviderType';
export enum TokenStandard {
// EVM
ERC20 = 'ERC20',
ERC721 = 'ERC721',
EvmNative = 'EvmNative',
EvmHypNative = 'EvmHypNative',
EvmHypCollateral = 'EvmHypCollateral',
EvmHypSynthetic = 'EvmHypSynthetic',
// Sealevel (Solana)
SealevelSpl = 'SealevelSpl',
SealevelSpl2022 = 'SealevelSpl2022',
SealevelNative = 'SealevelNative',
SealevelHypNative = 'SealevelHypNative',
SealevelHypCollateral = 'SealevelHypCollateral',
SealevelHypSynthetic = 'SealevelHypSynthetic',
// Cosmos
CosmosIcs20 = 'CosmosIcs20',
CosmosIcs721 = 'CosmosIcs721',
CosmosNative = 'CosmosNative',
CosmosIbc = 'CosmosIbc',
// CosmWasm
CW20 = 'CW20',
CWNative = 'CWNative',
CW721 = 'CW721',
CwHypNative = 'CwHypNative',
CwHypCollateral = 'CwHypCollateral',
CwHypSynthetic = 'CwHypSynthetic',
// Fuel (TODO)
FuelNative = 'FuelNative',
}
// Allows for omission of protocol field in token args
export const TOKEN_STANDARD_TO_PROTOCOL: Record<TokenStandard, ProtocolType> = {
// EVM
ERC20: ProtocolType.Ethereum,
ERC721: ProtocolType.Ethereum,
EvmNative: ProtocolType.Ethereum,
EvmHypNative: ProtocolType.Ethereum,
EvmHypCollateral: ProtocolType.Ethereum,
EvmHypSynthetic: ProtocolType.Ethereum,
// Sealevel (Solana)
SealevelSpl: ProtocolType.Sealevel,
SealevelSpl2022: ProtocolType.Sealevel,
SealevelNative: ProtocolType.Sealevel,
SealevelHypNative: ProtocolType.Sealevel,
SealevelHypCollateral: ProtocolType.Sealevel,
SealevelHypSynthetic: ProtocolType.Sealevel,
// Cosmos
CosmosIcs20: ProtocolType.Cosmos,
CosmosIcs721: ProtocolType.Cosmos,
CosmosNative: ProtocolType.Cosmos,
CosmosIbc: ProtocolType.Cosmos,
// CosmWasm
CW20: ProtocolType.Cosmos,
CWNative: ProtocolType.Cosmos,
CW721: ProtocolType.Cosmos,
CwHypNative: ProtocolType.Cosmos,
CwHypCollateral: ProtocolType.Cosmos,
CwHypSynthetic: ProtocolType.Cosmos,
// Fuel (TODO)
FuelNative: ProtocolType.Fuel,
};
export const TOKEN_STANDARD_TO_PROVIDER_TYPE: Record<
TokenStandard,
ProviderType
> = objMap(TOKEN_STANDARD_TO_PROTOCOL, (k, v) => {
if (k.startsWith('Cosmos')) return ProviderType.CosmJs;
return PROTOCOL_TO_DEFAULT_PROVIDER_TYPE[v];
});
export const TOKEN_NFT_STANDARDS = [
TokenStandard.ERC721,
TokenStandard.CosmosIcs721,
TokenStandard.CW721,
// TODO solana here
];
export const TOKEN_COLLATERALIZED_STANDARDS = [
TokenStandard.EvmHypCollateral,
TokenStandard.EvmHypNative,
TokenStandard.SealevelHypCollateral,
TokenStandard.SealevelHypNative,
TokenStandard.CwHypCollateral,
TokenStandard.CwHypNative,
];
export const TOKEN_HYP_STANDARDS = [
TokenStandard.EvmHypNative,
TokenStandard.EvmHypCollateral,
TokenStandard.EvmHypSynthetic,
TokenStandard.SealevelHypNative,
TokenStandard.SealevelHypCollateral,
TokenStandard.SealevelHypSynthetic,
TokenStandard.CwHypNative,
TokenStandard.CwHypCollateral,
TokenStandard.CwHypSynthetic,
];
export const TOKEN_MULTI_CHAIN_STANDARDS = [
...TOKEN_HYP_STANDARDS,
TokenStandard.CosmosIbc,
];
// Useful for differentiating from norma Cosmos standards
// (e.g. for determining the appropriate cosmos client)
export const TOKEN_COSMWASM_STANDARDS = [
TokenStandard.CW20,
TokenStandard.CWNative,
TokenStandard.CW721,
TokenStandard.CwHypNative,
TokenStandard.CwHypCollateral,
TokenStandard.CwHypSynthetic,
];
export const PROTOCOL_TO_NATIVE_STANDARD: Record<ProtocolType, TokenStandard> =
{
[ProtocolType.Ethereum]: TokenStandard.EvmNative,
[ProtocolType.Cosmos]: TokenStandard.CosmosNative,
[ProtocolType.Sealevel]: TokenStandard.SealevelNative,
[ProtocolType.Fuel]: TokenStandard.FuelNative,
};

@ -1,11 +1,11 @@
import { ExecuteInstruction } from '@cosmjs/cosmwasm-stargate';
import { Coin } from '@cosmjs/stargate';
import BigNumber from 'bignumber.js';
import {
Address,
Domain,
addressToBytes32,
assert,
strip0x,
} from '@hyperlane-xyz/utils';
@ -16,6 +16,7 @@ import {
QueryMsg as Cw20Query,
TokenInfoResponse,
} from '../../cw-types/Cw20Base.types';
import { QuoteDispatchResponse } from '../../cw-types/Mailbox.types';
import {
DomainsResponse,
InterchainSecurityModuleResponse,
@ -34,6 +35,7 @@ import { ERC20Metadata } from '../config';
import {
IHypTokenAdapter,
ITokenAdapter,
InterchainGasQuote,
TransferParams,
TransferRemoteParams,
} from './ITokenAdapter';
@ -41,27 +43,31 @@ import {
// Interacts with IBC denom tokens in CosmWasm
export class CwNativeTokenAdapter
extends BaseCosmWasmAdapter
implements ITokenAdapter
implements ITokenAdapter<ExecuteInstruction>
{
constructor(
public readonly chainName: string,
public readonly multiProvider: MultiProtocolProvider,
public readonly addresses: Record<string, Address>,
public readonly ibcDenom: string = 'untrn',
public readonly denom: string,
) {
super(chainName, multiProvider, addresses);
}
async getBalance(address: Address): Promise<string> {
async getBalance(address: Address): Promise<bigint> {
const provider = await this.getProvider();
const balance = await provider.getBalance(address, this.ibcDenom);
return balance.amount;
const balance = await provider.getBalance(address, this.denom);
return BigInt(balance.amount);
}
async getMetadata(): Promise<CW20Metadata> {
throw new Error('Metadata not available to native tokens');
}
async isApproveRequired(): Promise<boolean> {
return false;
}
async populateApproveTx(
_params: TransferParams,
): Promise<ExecuteInstruction> {
@ -79,7 +85,7 @@ export class CwNativeTokenAdapter
funds: [
{
amount: weiAmountOrId.toString(),
denom: this.ibcDenom,
denom: this.denom,
},
],
};
@ -92,13 +98,12 @@ type CW20Response = TokenInfoResponse | BalanceResponse;
// Interacts with CW20/721 contracts
export class CwTokenAdapter
extends BaseCosmWasmAdapter
implements ITokenAdapter
implements ITokenAdapter<ExecuteInstruction>
{
constructor(
public readonly chainName: string,
public readonly multiProvider: MultiProtocolProvider,
public readonly addresses: { token: Address },
public readonly denom = 'untrn',
) {
super(chainName, multiProvider, addresses);
}
@ -120,10 +125,10 @@ export class CwTokenAdapter
};
}
async getBalance(address: Address): Promise<string> {
async getBalance(address: Address): Promise<bigint> {
const provider = await this.getProvider();
const balance = await provider.getBalance(address, this.addresses.token);
return balance.amount;
return BigInt(balance.amount);
}
async getMetadata(): Promise<CW20Metadata> {
@ -136,6 +141,10 @@ export class CwTokenAdapter
};
}
async isApproveRequired(): Promise<boolean> {
return false;
}
async populateApproveTx({
weiAmountOrId,
recipient,
@ -171,17 +180,17 @@ type TokenRouterResponse =
| DomainsResponse
| OwnerResponse
| RouteResponseForHexBinary
| RoutesResponseForHexBinary;
| RoutesResponseForHexBinary
| QuoteDispatchResponse;
export class CwHypSyntheticAdapter
extends CwTokenAdapter
implements IHypTokenAdapter
implements IHypTokenAdapter<ExecuteInstruction>
{
constructor(
public readonly chainName: ChainName,
public readonly multiProvider: MultiProtocolProvider<any>,
public readonly addresses: { token: Address; warpRouter: Address },
public readonly gasDenom = 'untrn',
) {
super(chainName, multiProvider, addresses);
}
@ -205,7 +214,7 @@ export class CwHypSyntheticAdapter
};
}
async tokenType(): Promise<TokenType> {
async getTokenType(): Promise<TokenType> {
const resp = await this.queryRouter<TokenTypeResponse>({
token_default: {
token_type: {},
@ -214,11 +223,11 @@ export class CwHypSyntheticAdapter
return resp.type;
}
async interchainSecurityModule(): Promise<Address> {
async getInterchainSecurityModule(): Promise<Address> {
throw new Error('Router does not support ISM config yet.');
}
async owner(): Promise<Address> {
async getOwner(): Promise<Address> {
const resp = await this.queryRouter<OwnerResponse>({
ownable: {
get_owner: {},
@ -265,19 +274,32 @@ export class CwHypSyntheticAdapter
}));
}
quoteGasPayment(_destination: number): Promise<string> {
throw new Error('Method not implemented.');
async quoteGasPayment(_destination: Domain): Promise<InterchainGasQuote> {
// TODO this may require separate queries to get the hook and/or mailbox
// before making a query for the QuoteDispatchResponse
// Punting on this given that only static quotes are used for now
// const resp = await this.queryRouter<QuoteDispatchResponse>({
// router: {
// TODO: {},
// },
// });
// return {
// amount: BigInt(resp.gas_amount?.amount || 0),
// addressOrDenom: resp.gas_amount?.denom,
// };
throw new Error('CW adpater quoteGasPayment method not implemented');
}
populateTransferRemoteTx({
async populateTransferRemoteTx({
destination,
recipient,
weiAmountOrId,
txValue,
}: TransferRemoteParams): ExecuteInstruction {
if (!txValue) {
throw new Error('txValue is required for native tokens');
}
interchainGas,
}: TransferRemoteParams): Promise<ExecuteInstruction> {
if (!interchainGas) interchainGas = await this.quoteGasPayment(destination);
const { addressOrDenom: igpDenom, amount: igpAmount } = interchainGas;
assert(igpDenom, 'Interchain gas denom required for Cosmos');
return this.prepareRouter(
{
transfer_remote: {
@ -288,8 +310,8 @@ export class CwHypSyntheticAdapter
},
[
{
amount: txValue.toString(),
denom: this.gasDenom,
amount: igpAmount.toString(),
denom: igpDenom,
},
],
);
@ -298,7 +320,7 @@ export class CwHypSyntheticAdapter
export class CwHypNativeAdapter
extends CwNativeTokenAdapter
implements IHypTokenAdapter
implements IHypTokenAdapter<ExecuteInstruction>
{
private readonly cw20adapter: CwHypSyntheticAdapter;
@ -306,30 +328,27 @@ export class CwHypNativeAdapter
public readonly chainName: ChainName,
public readonly multiProvider: MultiProtocolProvider<any>,
public readonly addresses: { warpRouter: Address },
public readonly gasDenom = 'untrn',
) {
super(chainName, multiProvider, addresses, gasDenom);
this.cw20adapter = new CwHypSyntheticAdapter(
chainName,
multiProvider,
{ token: '', warpRouter: addresses.warpRouter },
gasDenom,
);
super(chainName, multiProvider, addresses, '');
this.cw20adapter = new CwHypSyntheticAdapter(chainName, multiProvider, {
token: '',
warpRouter: addresses.warpRouter,
});
}
async getBalance(address: string): Promise<string> {
async getBalance(address: string): Promise<bigint> {
const provider = await this.getProvider();
const denom = await this.denom();
const denom = await this.getDenom();
const balance = await provider.getBalance(address, denom);
return balance.amount;
return BigInt(balance.amount);
}
async interchainSecurityModule(): Promise<Address> {
return this.cw20adapter.interchainSecurityModule();
async getInterchainSecurityModule(): Promise<Address> {
return this.cw20adapter.getInterchainSecurityModule();
}
async owner(): Promise<Address> {
return this.cw20adapter.owner();
async getOwner(): Promise<Address> {
return this.cw20adapter.getOwner();
}
async getDomains(): Promise<Domain[]> {
@ -344,12 +363,12 @@ export class CwHypNativeAdapter
return this.cw20adapter.getAllRouters();
}
quoteGasPayment(destination: number): Promise<string> {
quoteGasPayment(destination: Domain): Promise<InterchainGasQuote> {
return this.cw20adapter.quoteGasPayment(destination);
}
async denom(): Promise<string> {
const tokenType = await this.cw20adapter.tokenType();
async getDenom(): Promise<string> {
const tokenType = await this.cw20adapter.getTokenType();
if ('native' in tokenType) {
if ('fungible' in tokenType.native) {
return tokenType.native.fungible.denom;
@ -363,18 +382,19 @@ export class CwHypNativeAdapter
destination,
recipient,
weiAmountOrId,
txValue,
interchainGas,
}: TransferRemoteParams): Promise<ExecuteInstruction> {
if (!txValue) {
throw new Error('txValue is required for native tokens');
}
const collateralDenom = await this.denom();
const collateralDenom = await this.getDenom();
if (!interchainGas) interchainGas = await this.quoteGasPayment(destination);
const { addressOrDenom: igpDenom, amount: igpAmount } = interchainGas;
assert(igpDenom, 'Interchain gas denom required for Cosmos');
const funds: Coin[] =
collateralDenom === this.gasDenom
collateralDenom === igpDenom
? [
{
amount: new BigNumber(weiAmountOrId).plus(txValue).toFixed(0),
amount: (BigInt(weiAmountOrId) + igpAmount).toString(),
denom: collateralDenom,
},
]
@ -384,8 +404,8 @@ export class CwHypNativeAdapter
denom: collateralDenom,
},
{
amount: txValue.toString(),
denom: this.gasDenom,
amount: igpAmount.toString(),
denom: igpDenom,
},
];
@ -404,14 +424,13 @@ export class CwHypNativeAdapter
export class CwHypCollateralAdapter
extends CwHypNativeAdapter
implements IHypTokenAdapter
implements IHypTokenAdapter<ExecuteInstruction>
{
constructor(
public readonly chainName: ChainName,
public readonly multiProvider: MultiProtocolProvider<any>,
public readonly addresses: { warpRouter: Address; token: Address },
public readonly gasDenom = 'untrn',
) {
super(chainName, multiProvider, addresses, gasDenom);
super(chainName, multiProvider, addresses);
}
}

@ -1,7 +1,7 @@
import { MsgTransferEncodeObject } from '@cosmjs/stargate';
import Long from 'long';
import { Address, Domain } from '@hyperlane-xyz/utils';
import { Address, Domain, assert } from '@hyperlane-xyz/utils';
import { BaseCosmosAdapter } from '../../app/MultiProtocolApp';
import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider';
@ -12,6 +12,7 @@ import { CwHypCollateralAdapter } from './CosmWasmTokenAdapter';
import {
IHypTokenAdapter,
ITokenAdapter,
InterchainGasQuote,
TransferParams,
TransferRemoteParams,
} from './ITokenAdapter';
@ -21,7 +22,7 @@ const COSMOS_IBC_TRANSFER_TIMEOUT = 600_000; // 10 minutes
// Interacts with native tokens on a Cosmos chain (e.g TIA on Celestia)
export class CosmNativeTokenAdapter
extends BaseCosmosAdapter
implements ITokenAdapter
implements ITokenAdapter<MsgTransferEncodeObject>
{
constructor(
public readonly chainName: ChainName,
@ -36,17 +37,23 @@ export class CosmNativeTokenAdapter
super(chainName, multiProvider, addresses);
}
async getBalance(address: string): Promise<string> {
async getBalance(address: string): Promise<bigint> {
const provider = await this.getProvider();
const coin = await provider.getBalance(address, this.properties.ibcDenom);
return coin.amount;
return BigInt(coin.amount);
}
getMetadata(): Promise<MinimalTokenMetadata> {
throw new Error('Metadata not available to native tokens');
}
populateApproveTx(_transferParams: TransferParams): unknown {
async isApproveRequired(): Promise<boolean> {
return false;
}
populateApproveTx(
_transferParams: TransferParams,
): Promise<MsgTransferEncodeObject> {
throw new Error('Approve not required for native tokens');
}
@ -62,7 +69,7 @@ export class CosmNativeTokenAdapter
// methods don't apply to IBC transfers the way they do for Warp transfers
export class CosmIbcTokenAdapter
extends CosmNativeTokenAdapter
implements IHypTokenAdapter
implements IHypTokenAdapter<MsgTransferEncodeObject>
{
constructor(
public readonly chainName: ChainName,
@ -97,8 +104,9 @@ export class CosmIbcTokenAdapter
> {
throw new Error('Method not applicable to IBC adapters');
}
quoteGasPayment(_destination: Domain): Promise<string> {
throw new Error('Method not applicable to IBC adapters');
async quoteGasPayment(_destination: Domain): Promise<InterchainGasQuote> {
// TODO implement IBC interchain transfer gas estimation here
return { amount: 0n, addressOrDenom: this.properties.ibcDenom };
}
async populateTransferRemoteTx(
@ -134,7 +142,7 @@ export class CosmIbcTokenAdapter
// A.k.a. 'One-Click' cosmos to evm transfers
export class CosmIbcToWarpTokenAdapter
extends CosmIbcTokenAdapter
implements IHypTokenAdapter
implements IHypTokenAdapter<MsgTransferEncodeObject>
{
constructor(
public readonly chainName: ChainName,
@ -144,13 +152,18 @@ export class CosmIbcToWarpTokenAdapter
destinationRouterAddress: Address;
},
public readonly properties: CosmIbcTokenAdapter['properties'] & {
derivedIbcDenom: string;
intermediateIbcDenom: string;
intermediateChainName: ChainName;
},
) {
super(chainName, multiProvider, addresses, properties);
}
async quoteGasPayment(_destination: Domain): Promise<InterchainGasQuote> {
// TODO implement IBC interchain transfer gas estimation here
return { amount: 0n, addressOrDenom: this.properties.intermediateIbcDenom };
}
async populateTransferRemoteTx(
transferParams: TransferRemoteParams,
): Promise<MsgTransferEncodeObject> {
@ -158,12 +171,24 @@ export class CosmIbcToWarpTokenAdapter
this.properties.intermediateChainName,
this.multiProvider,
{
token: this.properties.derivedIbcDenom,
token: this.properties.intermediateIbcDenom,
warpRouter: this.addresses.intermediateRouterAddress,
},
this.properties.derivedIbcDenom,
);
const transfer = await cwAdapter.populateTransferRemoteTx(transferParams);
assert(
transferParams.interchainGas?.addressOrDenom === this.properties.ibcDenom,
'Only same-denom interchain gas is supported for IBC to Warp transfers',
);
// This transformation is necessary to ensure the CW adapter recognizes the gas
// denom is the same as this adapter's denom (e.g. utia & igp/77...)
const intermediateInterchainGas = {
addressOrDenom: this.properties.intermediateIbcDenom,
amount: transferParams.interchainGas?.amount || 0n,
};
const transfer = await cwAdapter.populateTransferRemoteTx({
...transferParams,
interchainGas: intermediateInterchainGas,
});
const cwMemo = {
wasm: {
contract: transfer.contractAddress,

@ -4,12 +4,14 @@ import {
ERC20,
ERC20__factory,
HypERC20,
HypERC20Collateral,
HypERC20Collateral__factory,
HypERC20__factory,
} from '@hyperlane-xyz/core';
import {
Address,
Domain,
Numberish,
addressToByteHexString,
addressToBytes32,
bytes32ToAddress,
@ -24,6 +26,7 @@ import { MinimalTokenMetadata } from '../config';
import {
IHypTokenAdapter,
ITokenAdapter,
InterchainGasQuote,
TransferParams,
TransferRemoteParams,
} from './ITokenAdapter';
@ -31,11 +34,11 @@ import {
// Interacts with native currencies
export class EvmNativeTokenAdapter
extends BaseEvmAdapter
implements ITokenAdapter
implements ITokenAdapter<PopulatedTransaction>
{
async getBalance(address: Address): Promise<string> {
async getBalance(address: Address): Promise<bigint> {
const balance = await this.getProvider().getBalance(address);
return balance.toString();
return BigInt(balance.toString());
}
async getMetadata(): Promise<MinimalTokenMetadata> {
@ -43,6 +46,14 @@ export class EvmNativeTokenAdapter
throw new Error('Metadata not available to native tokens');
}
async isApproveRequired(
_owner: Address,
_spender: Address,
_weiAmountOrId: Numberish,
): Promise<boolean> {
return false;
}
async populateApproveTx(
_params: TransferParams,
): Promise<PopulatedTransaction> {
@ -53,7 +64,7 @@ export class EvmNativeTokenAdapter
weiAmountOrId,
recipient,
}: TransferParams): Promise<PopulatedTransaction> {
const value = BigNumber.from(weiAmountOrId);
const value = BigNumber.from(weiAmountOrId.toString());
return { value, to: recipient };
}
}
@ -61,7 +72,7 @@ export class EvmNativeTokenAdapter
// Interacts with ERC20/721 contracts
export class EvmTokenAdapter<T extends ERC20 = ERC20>
extends EvmNativeTokenAdapter
implements ITokenAdapter
implements ITokenAdapter<PopulatedTransaction>
{
public readonly contract: T;
@ -78,9 +89,9 @@ export class EvmTokenAdapter<T extends ERC20 = ERC20>
);
}
override async getBalance(address: Address): Promise<string> {
override async getBalance(address: Address): Promise<bigint> {
const balance = await this.contract.balanceOf(address);
return balance.toString();
return BigInt(balance.toString());
}
override async getMetadata(isNft?: boolean): Promise<MinimalTokenMetadata> {
@ -92,25 +103,40 @@ export class EvmTokenAdapter<T extends ERC20 = ERC20>
return { decimals, symbol, name };
}
override async isApproveRequired(
owner: Address,
spender: Address,
weiAmountOrId: Numberish,
): Promise<boolean> {
const allowance = await this.contract.allowance(owner, spender);
return allowance.lt(weiAmountOrId);
}
override populateApproveTx({
weiAmountOrId,
recipient,
}: TransferParams): Promise<PopulatedTransaction> {
return this.contract.populateTransaction.approve(recipient, weiAmountOrId);
return this.contract.populateTransaction.approve(
recipient,
weiAmountOrId.toString(),
);
}
override populateTransferTx({
weiAmountOrId,
recipient,
}: TransferParams): Promise<PopulatedTransaction> {
return this.contract.populateTransaction.transfer(recipient, weiAmountOrId);
return this.contract.populateTransaction.transfer(
recipient,
weiAmountOrId.toString(),
);
}
}
// Interacts with Hyp Synthetic token contracts (aka 'HypTokens')
export class EvmHypSyntheticAdapter<T extends HypERC20 = HypERC20>
extends EvmTokenAdapter<T>
implements IHypTokenAdapter
export class EvmHypSyntheticAdapter
extends EvmTokenAdapter<HypERC20>
implements IHypTokenAdapter<PopulatedTransaction>
{
constructor(
public readonly chainName: ChainName,
@ -121,6 +147,14 @@ export class EvmHypSyntheticAdapter<T extends HypERC20 = HypERC20>
super(chainName, multiProvider, addresses, contractFactory);
}
override async isApproveRequired(
_owner: Address,
_spender: Address,
_weiAmountOrId: Numberish,
): Promise<boolean> {
return false;
}
getDomains(): Promise<Domain[]> {
return this.contract.domains();
}
@ -147,64 +181,130 @@ export class EvmHypSyntheticAdapter<T extends HypERC20 = HypERC20>
return domains.map((d, i) => ({ domain: d, address: routers[i] }));
}
async quoteGasPayment(destination: Domain): Promise<string> {
async quoteGasPayment(destination: Domain): Promise<InterchainGasQuote> {
const gasPayment = await this.contract.quoteGasPayment(destination);
return gasPayment.toString();
// If EVM hyp contracts eventually support alternative IGP tokens,
// this would need to determine the correct token address
return { amount: BigInt(gasPayment.toString()) };
}
populateTransferRemoteTx({
async populateTransferRemoteTx({
weiAmountOrId,
destination,
recipient,
txValue,
interchainGas,
}: TransferRemoteParams): Promise<PopulatedTransaction> {
if (!interchainGas) interchainGas = await this.quoteGasPayment(destination);
const recipBytes32 = addressToBytes32(addressToByteHexString(recipient));
return this.contract.populateTransaction.transferRemote(
destination,
recipBytes32,
weiAmountOrId,
{
// Note, typically the value is the gas payment as quoted by IGP
value: txValue,
},
{ value: interchainGas.amount.toString() },
);
}
}
// Interacts with HypCollateral and HypNative contracts
// Interacts with HypCollateral contracts
export class EvmHypCollateralAdapter
extends EvmHypSyntheticAdapter
implements IHypTokenAdapter
implements IHypTokenAdapter<PopulatedTransaction>
{
public readonly collateralContract: HypERC20Collateral;
protected wrappedTokenAddress?: Address;
constructor(
public readonly chainName: ChainName,
public readonly multiProvider: MultiProtocolProvider,
public readonly addresses: { token: Address },
public readonly contractFactory: any = HypERC20Collateral__factory,
) {
super(chainName, multiProvider, addresses, contractFactory);
super(chainName, multiProvider, addresses);
this.collateralContract = HypERC20Collateral__factory.connect(
addresses.token,
this.getProvider(),
);
}
protected async getWrappedTokenAddress(): Promise<Address> {
if (!this.wrappedTokenAddress) {
this.wrappedTokenAddress = await this.collateralContract.wrappedToken();
}
return this.wrappedTokenAddress;
}
protected async getWrappedTokenAdapter(): Promise<
ITokenAdapter<PopulatedTransaction>
> {
return new EvmTokenAdapter(this.chainName, this.multiProvider, {
token: await this.getWrappedTokenAddress(),
});
}
override getMetadata(isNft?: boolean): Promise<MinimalTokenMetadata> {
return this.getWrappedTokenAdapter().then((t) => t.getMetadata(isNft));
}
override getMetadata(): Promise<MinimalTokenMetadata> {
// TODO pass through metadata from wrapped token or chainMetadata config
throw new Error(
'Metadata not available for HypCollateral/HypNative contract.',
override isApproveRequired(
owner: Address,
spender: Address,
weiAmountOrId: Numberish,
): Promise<boolean> {
return this.getWrappedTokenAdapter().then((t) =>
t.isApproveRequired(owner, spender, weiAmountOrId),
);
}
override populateApproveTx(
_params: TransferParams,
params: TransferParams,
): Promise<PopulatedTransaction> {
throw new Error(
'Approve not applicable to HypCollateral/HypNative contract.',
return this.getWrappedTokenAdapter().then((t) =>
t.populateApproveTx(params),
);
}
override populateTransferTx(
_params: TransferParams,
params: TransferParams,
): Promise<PopulatedTransaction> {
throw new Error(
'Local transfer not supported for HypCollateral/HypNative contract.',
return this.getWrappedTokenAdapter().then((t) =>
t.populateTransferTx(params),
);
}
}
// Interacts HypNative contracts
export class EvmHypNativeAdapter
extends EvmHypCollateralAdapter
implements IHypTokenAdapter<PopulatedTransaction>
{
override async isApproveRequired(): Promise<boolean> {
return false;
}
override async populateTransferRemoteTx({
weiAmountOrId,
destination,
recipient,
interchainGas,
}: TransferRemoteParams): Promise<PopulatedTransaction> {
if (!interchainGas) interchainGas = await this.quoteGasPayment(destination);
let txValue: bigint | undefined = undefined;
const { addressOrDenom: igpAddressOrDenom, amount: igpAmount } =
interchainGas;
// If the igp token is native Eth
if (!igpAddressOrDenom) {
txValue = igpAmount + BigInt(weiAmountOrId);
} else {
txValue = igpAmount;
}
const recipBytes32 = addressToBytes32(addressToByteHexString(recipient));
return this.contract.populateTransaction.transferRemote(
destination,
recipBytes32,
weiAmountOrId,
{ value: txValue?.toString() },
);
}
}

@ -1,37 +1,42 @@
import { Address, Domain } from '@hyperlane-xyz/utils';
import { Address, Domain, Numberish } from '@hyperlane-xyz/utils';
import { MinimalTokenMetadata } from '../config';
export interface TransferParams {
weiAmountOrId: string | number;
weiAmountOrId: Numberish;
recipient: Address;
// Solana-specific params
// Included here optionally to keep Adapter types simple
fromTokenAccount?: Address;
// Required for Cosmos + Solana
fromAccountOwner?: Address;
// Required for Solana
fromTokenAccount?: Address;
}
export interface TransferRemoteParams extends TransferParams {
destination: Domain;
txValue?: string;
interchainGas?: InterchainGasQuote;
}
export interface InterchainGasQuote {
addressOrDenom?: string; // undefined values represent default native tokens
amount: bigint;
}
export interface ITokenAdapter {
getBalance(address: Address): Promise<string>;
export interface ITokenAdapter<Tx> {
getBalance(address: Address): Promise<bigint>;
getMetadata(isNft?: boolean): Promise<MinimalTokenMetadata>;
populateApproveTx(TransferParams: TransferParams): unknown | Promise<unknown>;
populateTransferTx(
TransferParams: TransferParams,
): unknown | Promise<unknown>;
isApproveRequired(
owner: Address,
spender: Address,
weiAmountOrId: Numberish,
): Promise<boolean>;
populateApproveTx(params: TransferParams): Promise<Tx>;
populateTransferTx(params: TransferParams): Promise<Tx>;
}
export interface IHypTokenAdapter extends ITokenAdapter {
export interface IHypTokenAdapter<Tx> extends ITokenAdapter<Tx> {
getDomains(): Promise<Domain[]>;
getRouterAddress(domain: Domain): Promise<Buffer>;
getAllRouters(): Promise<Array<{ domain: Domain; address: Buffer }>>;
quoteGasPayment(destination: Domain): Promise<string>;
populateTransferRemoteTx(
TransferParams: TransferRemoteParams,
): unknown | Promise<unknown>;
quoteGasPayment(destination: Domain): Promise<InterchainGasQuote>;
populateTransferRemoteTx(p: TransferRemoteParams): Promise<Tx>;
}

@ -13,7 +13,6 @@ import {
Transaction,
TransactionInstruction,
} from '@solana/web3.js';
import BigNumber from 'bignumber.js';
import { deserializeUnchecked, serialize } from 'borsh';
import {
@ -39,6 +38,7 @@ import { MinimalTokenMetadata } from '../config';
import {
IHypTokenAdapter,
ITokenAdapter,
InterchainGasQuote,
TransferParams,
TransferRemoteParams,
} from './ITokenAdapter';
@ -54,33 +54,37 @@ import {
// Interacts with native currencies
export class SealevelNativeTokenAdapter
extends BaseSealevelAdapter
implements ITokenAdapter
implements ITokenAdapter<Transaction>
{
async getBalance(address: Address): Promise<string> {
async getBalance(address: Address): Promise<bigint> {
const balance = await this.getProvider().getBalance(new PublicKey(address));
return balance.toString();
return BigInt(balance.toString());
}
async getMetadata(): Promise<MinimalTokenMetadata> {
throw new Error('Metadata not available to native tokens');
}
populateApproveTx(_params: TransferParams): Transaction {
async isApproveRequired(): Promise<boolean> {
return false;
}
async populateApproveTx(): Promise<Transaction> {
throw new Error('Approve not required for native tokens');
}
populateTransferTx({
async populateTransferTx({
weiAmountOrId,
recipient,
fromAccountOwner,
}: TransferParams): Transaction {
}: TransferParams): Promise<Transaction> {
if (!fromAccountOwner)
throw new Error('fromAccountOwner required for Sealevel');
return new Transaction().add(
SystemProgram.transfer({
fromPubkey: new PublicKey(fromAccountOwner),
toPubkey: new PublicKey(recipient),
lamports: new BigNumber(weiAmountOrId).toNumber(),
lamports: BigInt(weiAmountOrId),
}),
);
}
@ -89,7 +93,7 @@ export class SealevelNativeTokenAdapter
// Interacts with SPL token programs
export class SealevelTokenAdapter
extends BaseSealevelAdapter
implements ITokenAdapter
implements ITokenAdapter<Transaction>
{
public readonly tokenProgramPubKey: PublicKey;
@ -103,12 +107,12 @@ export class SealevelTokenAdapter
this.tokenProgramPubKey = new PublicKey(addresses.token);
}
async getBalance(owner: Address): Promise<string> {
async getBalance(owner: Address): Promise<bigint> {
const tokenPubKey = this.deriveAssociatedTokenAccount(new PublicKey(owner));
const response = await this.getProvider().getTokenAccountBalance(
tokenPubKey,
);
return response.value.amount;
return BigInt(response.value.amount);
}
async getMetadata(_isNft?: boolean): Promise<MinimalTokenMetadata> {
@ -116,16 +120,20 @@ export class SealevelTokenAdapter
return { decimals: 9, symbol: 'SPL', name: 'SPL Token' };
}
async isApproveRequired(): Promise<boolean> {
return false;
}
populateApproveTx(_params: TransferParams): Promise<Transaction> {
throw new Error('Approve not required for sealevel tokens');
}
populateTransferTx({
async populateTransferTx({
weiAmountOrId,
recipient,
fromAccountOwner,
fromTokenAccount,
}: TransferParams): Transaction {
}: TransferParams): Promise<Transaction> {
if (!fromTokenAccount)
throw new Error('fromTokenAccount required for Sealevel');
if (!fromAccountOwner)
@ -135,7 +143,7 @@ export class SealevelTokenAdapter
new PublicKey(fromTokenAccount),
new PublicKey(recipient),
new PublicKey(fromAccountOwner),
new BigNumber(weiAmountOrId).toNumber(),
BigInt(weiAmountOrId),
),
);
}
@ -164,7 +172,7 @@ const TRANSFER_REMOTE_COMPUTE_LIMIT = 1_000_000;
export abstract class SealevelHypTokenAdapter
extends SealevelTokenAdapter
implements IHypTokenAdapter
implements IHypTokenAdapter<Transaction>
{
public readonly warpProgramPubKey: PublicKey;
protected cachedTokenAccountData: SealevelHyperlaneTokenData | undefined;
@ -235,9 +243,9 @@ export abstract class SealevelHypTokenAdapter
}));
}
async quoteGasPayment(_destination: Domain): Promise<string> {
async quoteGasPayment(_destination: Domain): Promise<InterchainGasQuote> {
// TODO Solana support
return '0';
return { amount: 0n };
}
async populateTransferRemoteTx({
@ -264,7 +272,7 @@ export abstract class SealevelHypTokenAdapter
data: new SealevelTransferRemoteInstruction({
destination_domain: destination,
recipient: addressToBytes(recipient),
amount_or_id: new BigNumber(weiAmountOrId).toNumber(),
amount_or_id: BigInt(weiAmountOrId),
}),
});
const serializedData = serialize(SealevelTransferRemoteSchema, value);
@ -492,7 +500,7 @@ export class SealevelHypNativeAdapter extends SealevelHypTokenAdapter {
);
}
override async getBalance(owner: Address): Promise<string> {
override async getBalance(owner: Address): Promise<bigint> {
return this.wrappedNative.getBalance(owner);
}
@ -525,7 +533,7 @@ export class SealevelHypNativeAdapter extends SealevelHypTokenAdapter {
// Interacts with Hyp Collateral token programs
export class SealevelHypCollateralAdapter extends SealevelHypTokenAdapter {
async getBalance(owner: Address): Promise<string> {
async getBalance(owner: Address): Promise<bigint> {
// Special case where the owner is the warp route program ID.
// This is because collateral warp routes don't hold escrowed collateral
// tokens in their associated token account - instead, they hold them in
@ -535,7 +543,7 @@ export class SealevelHypCollateralAdapter extends SealevelHypTokenAdapter {
const response = await this.getProvider().getTokenAccountBalance(
collateralAccount,
);
return response.value.amount;
return BigInt(response.value.amount);
}
return super.getBalance(owner);
@ -593,12 +601,12 @@ export class SealevelHypSyntheticAdapter extends SealevelHypTokenAdapter {
];
}
override async getBalance(owner: Address): Promise<string> {
override async getBalance(owner: Address): Promise<bigint> {
const tokenPubKey = this.deriveAssociatedTokenAccount(new PublicKey(owner));
const response = await this.getProvider().getTokenAccountBalance(
tokenPubKey,
);
return response.value.amount;
return BigInt(response.value.amount);
}
deriveMintAuthorityAccount(): PublicKey {

@ -1,5 +1,7 @@
import type { ethers } from 'ethers';
import type { ChainId, Domain } from '@hyperlane-xyz/utils';
// An alias for string to clarify type is a chain name
export type ChainName = string;
// A map of chain names to a value type
@ -7,6 +9,6 @@ export type ChainMap<Value> = Record<string, Value>;
// The names of test chains, should be kept up to date if new are added
export type TestChainNames = 'test1' | 'test2' | 'test3';
export type NameOrDomain = ChainName | number;
export type ChainNameOrId = ChainName | ChainId | Domain;
export type Connection = ethers.providers.Provider | ethers.Signer;

@ -0,0 +1,264 @@
import { expect } from 'chai';
import { ethers } from 'ethers';
import fs from 'fs';
import path from 'path';
import sinon from 'sinon';
import { parse as yamlParse } from 'yaml';
import { chainMetadata } from '../consts/chainMetadata';
import { Chains } from '../consts/chains';
import { MultiProtocolProvider } from '../providers/MultiProtocolProvider';
import { ProviderType } from '../providers/ProviderType';
import { Token } from '../token/Token';
import { TokenStandard } from '../token/TokenStandard';
import { InterchainGasQuote } from '../token/adapters/ITokenAdapter';
import { ChainName } from '../types';
import { WarpCore } from './WarpCore';
import { WarpTxCategory } from './types';
const MOCK_QUOTE = { amount: 20_000n };
const TRANSFER_AMOUNT = BigInt('1000000000000000000'); // 1 units @ 18 decimals
const BIG_TRANSFER_AMOUNT = BigInt('100000000000000000000'); // 100 units @ 18 decimals
const MOCK_BALANCE = BigInt('10000000000000000000'); // 10 units @ 18 decimals
describe('WarpCore', () => {
const multiProvider = new MultiProtocolProvider();
let warpCore: WarpCore;
let evmHypNative: Token;
let evmHypSynthetic: Token;
let sealevelHypSynthetic: Token;
let cwHypCollateral: Token;
let cw20: Token;
let cosmosIbc: Token;
it('Constructs', () => {
const fromArgs = new WarpCore(multiProvider, [
Token.FromChainMetadataNativeToken(chainMetadata[Chains.ethereum]),
]);
const exampleConfig = yamlParse(
fs.readFileSync(
path.join(__dirname, './example-warp-core-config.yaml'),
'utf-8',
),
);
const fromConfig = WarpCore.FromConfig(multiProvider, exampleConfig);
expect(fromArgs).to.be.instanceOf(WarpCore);
expect(fromConfig).to.be.instanceOf(WarpCore);
expect(fromConfig.tokens.length).to.equal(exampleConfig.tokens.length);
warpCore = fromConfig;
[
evmHypNative,
evmHypSynthetic,
sealevelHypSynthetic,
cwHypCollateral,
cw20,
cosmosIbc,
] = warpCore.tokens;
});
it('Finds tokens', () => {
expect(
warpCore.findToken(Chains.ethereum, evmHypNative.addressOrDenom),
).to.be.instanceOf(Token);
expect(
warpCore.findToken(Chains.ethereum, sealevelHypSynthetic.addressOrDenom),
).to.be.null;
expect(
warpCore.findToken(Chains.neutron, cw20.addressOrDenom),
).to.be.instanceOf(Token);
});
it('Gets transfer gas quote', async () => {
const stubs = warpCore.tokens.map((t) =>
sinon.stub(t, 'getHypAdapter').returns({
quoteGasPayment: () => Promise.resolve(MOCK_QUOTE),
} as any),
);
const testQuote = async (
token: Token,
chain: ChainName,
standard: TokenStandard,
quote: InterchainGasQuote = MOCK_QUOTE,
) => {
const result = await warpCore.getTransferGasQuote(token, chain);
expect(
result.token.standard,
`token standard check for ${token.chainName} to ${chain}`,
).equals(standard);
expect(
result.amount,
`token amount check for ${token.chainName} to ${chain}`,
).to.equal(quote.amount);
};
await testQuote(evmHypNative, Chains.arbitrum, TokenStandard.EvmNative);
await testQuote(evmHypNative, Chains.neutron, TokenStandard.EvmNative);
await testQuote(evmHypNative, Chains.solana, TokenStandard.EvmNative);
await testQuote(evmHypSynthetic, Chains.ethereum, TokenStandard.EvmNative);
await testQuote(
sealevelHypSynthetic,
Chains.ethereum,
TokenStandard.SealevelNative,
);
await testQuote(cosmosIbc, Chains.arbitrum, TokenStandard.CosmosNative);
// Note, this route uses an igp quote const config
await testQuote(
cwHypCollateral,
Chains.arbitrum,
TokenStandard.CosmosNative,
{
amount: 1n,
addressOrDenom: 'untrn',
},
);
stubs.forEach((s) => s.restore());
});
it('Checks for destination collateral', async () => {
const stubs = warpCore.tokens.map((t) =>
sinon.stub(t, 'getHypAdapter').returns({
getBalance: () => Promise.resolve(MOCK_BALANCE),
} as any),
);
const testCollateral = async (
token: Token,
chain: ChainName,
expectedBigResult = true,
) => {
const smallResult = await warpCore.isDestinationCollateralSufficient(
token.amount(TRANSFER_AMOUNT),
chain,
);
expect(
smallResult,
`small collateral check for ${token.chainName} to ${chain}`,
).to.be.true;
const bigResult = await warpCore.isDestinationCollateralSufficient(
token.amount(BIG_TRANSFER_AMOUNT),
chain,
);
expect(
bigResult,
`big collateral check for ${token.chainName} to ${chain}`,
).to.equal(expectedBigResult);
};
await testCollateral(evmHypNative, Chains.arbitrum);
await testCollateral(evmHypNative, Chains.neutron, false);
await testCollateral(evmHypNative, Chains.solana);
await testCollateral(cwHypCollateral, Chains.arbitrum);
stubs.forEach((s) => s.restore());
});
it('Validates transfers', async () => {
const balanceStubs = warpCore.tokens.map((t) =>
sinon
.stub(t, 'getBalance')
.returns(Promise.resolve({ amount: MOCK_BALANCE } as any)),
);
const quoteStubs = warpCore.tokens.map((t) =>
sinon.stub(t, 'getHypAdapter').returns({
quoteGasPayment: () => Promise.resolve(MOCK_QUOTE),
} as any),
);
const validResult = await warpCore.validateTransfer(
evmHypNative.amount(TRANSFER_AMOUNT),
Chains.arbitrum,
ethers.constants.AddressZero,
ethers.constants.AddressZero,
);
expect(validResult).to.be.null;
const invalidChain = await warpCore.validateTransfer(
evmHypNative.amount(TRANSFER_AMOUNT),
'fakechain',
ethers.constants.AddressZero,
ethers.constants.AddressZero,
);
expect(Object.keys(invalidChain || {})[0]).to.equal('destination');
const invalidRecipient = await warpCore.validateTransfer(
evmHypNative.amount(TRANSFER_AMOUNT),
Chains.neutron,
ethers.constants.AddressZero,
ethers.constants.AddressZero,
);
expect(Object.keys(invalidRecipient || {})[0]).to.equal('recipient');
const invalidAmount = await warpCore.validateTransfer(
evmHypNative.amount(-10),
Chains.arbitrum,
ethers.constants.AddressZero,
ethers.constants.AddressZero,
);
expect(Object.keys(invalidAmount || {})[0]).to.equal('amount');
const insufficientBalance = await warpCore.validateTransfer(
evmHypNative.amount(BIG_TRANSFER_AMOUNT),
Chains.arbitrum,
ethers.constants.AddressZero,
ethers.constants.AddressZero,
);
expect(Object.keys(insufficientBalance || {})[0]).to.equal('amount');
balanceStubs.forEach((s) => s.restore());
quoteStubs.forEach((s) => s.restore());
});
it('Gets transfer remote txs', async () => {
const coreStub = sinon
.stub(warpCore, 'isApproveRequired')
.returns(Promise.resolve(false));
const adapterStubs = warpCore.tokens.map((t) =>
sinon.stub(t, 'getHypAdapter').returns({
quoteGasPayment: () => Promise.resolve(MOCK_QUOTE),
populateTransferRemoteTx: () => Promise.resolve({}),
} as any),
);
const testGetTxs = async (
token: Token,
chain: ChainName,
providerType = ProviderType.EthersV5,
) => {
const result = await warpCore.getTransferRemoteTxs(
token.amount(TRANSFER_AMOUNT),
chain,
ethers.constants.AddressZero,
ethers.constants.AddressZero,
);
expect(result.length).to.equal(1);
expect(
result[0],
`transfer tx for ${token.chainName} to ${chain}`,
).to.eql({
category: WarpTxCategory.Transfer,
transaction: {},
type: providerType,
});
};
await testGetTxs(evmHypNative, Chains.arbitrum);
await testGetTxs(evmHypNative, Chains.neutron);
await testGetTxs(evmHypNative, Chains.solana);
await testGetTxs(evmHypSynthetic, Chains.ethereum);
await testGetTxs(
sealevelHypSynthetic,
Chains.ethereum,
ProviderType.SolanaWeb3,
);
await testGetTxs(cwHypCollateral, Chains.arbitrum, ProviderType.CosmJsWasm);
await testGetTxs(cosmosIbc, Chains.arbitrum, ProviderType.CosmJs);
coreStub.restore();
adapterStubs.forEach((s) => s.restore());
});
});

@ -0,0 +1,445 @@
import debug, { Debugger } from 'debug';
import {
Address,
ProtocolType,
assert,
convertDecimals,
isValidAddress,
} from '@hyperlane-xyz/utils';
import { MultiProtocolProvider } from '../providers/MultiProtocolProvider';
import { IToken } from '../token/IToken';
import { Token } from '../token/Token';
import { TokenAmount } from '../token/TokenAmount';
import {
TOKEN_COLLATERALIZED_STANDARDS,
TOKEN_STANDARD_TO_PROVIDER_TYPE,
} from '../token/TokenStandard';
import { ChainName, ChainNameOrId } from '../types';
import {
IgpQuoteConstants,
RouteBlacklist,
WarpCoreConfigSchema,
WarpTxCategory,
WarpTypedTransaction,
} from './types';
export interface WarpCoreOptions {
loggerName?: string;
igpQuoteConstants?: IgpQuoteConstants;
routeBlacklist?: RouteBlacklist;
}
export class WarpCore {
public readonly multiProvider: MultiProtocolProvider<{ mailbox?: Address }>;
public readonly tokens: Token[];
public readonly igpQuoteConstants: IgpQuoteConstants;
public readonly routeBlacklist: RouteBlacklist;
public readonly logger: Debugger;
constructor(
multiProvider: MultiProtocolProvider<{ mailbox?: Address }>,
tokens: Token[],
options?: WarpCoreOptions,
) {
this.multiProvider = multiProvider;
this.tokens = tokens;
this.igpQuoteConstants = options?.igpQuoteConstants || [];
this.routeBlacklist = options?.routeBlacklist || [];
this.logger = debug(options?.loggerName || 'hyperlane:WarpCore');
}
// Takes the serialized representation of a complete warp config and returns a WarpCore instance
static FromConfig(
multiProvider: MultiProtocolProvider<{ mailbox?: Address }>,
config: unknown,
): WarpCore {
// Validate and parse config data
const parsedConfig = WarpCoreConfigSchema.parse(config);
// Instantiate all tokens
const tokens = parsedConfig.tokens.map(
(t) =>
new Token({
...t,
addressOrDenom: t.addressOrDenom || '',
connections: undefined,
}),
);
// Connect tokens together
parsedConfig.tokens.forEach((config, i) => {
for (const connection of config.connections || []) {
const token1 = tokens[i];
// TODO see https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3298
const [_protocol, chainName, addrOrDenom] = connection.token.split('|');
const token2 = tokens.find(
(t) => t.chainName === chainName && t.addressOrDenom === addrOrDenom,
);
assert(
token2,
`Connected token not found: ${chainName} ${addrOrDenom}`,
);
token1.addConnection({
...connection,
token: token2,
});
}
});
// Create new Warp
return new WarpCore(multiProvider, tokens, {
igpQuoteConstants: parsedConfig.options?.igpQuoteConstants,
routeBlacklist: parsedConfig.options?.routeBlacklist,
});
}
async getTransferGasQuote(
originToken: IToken,
destination: ChainNameOrId,
): Promise<TokenAmount> {
const { chainName: originName } = originToken;
const destinationName = this.multiProvider.getChainName(destination);
let gasAmount: bigint;
let gasAddressOrDenom: string | undefined;
// Check constant quotes first
const defaultQuote = this.igpQuoteConstants.find(
(q) => q.origin === originName && q.destination === destinationName,
);
if (defaultQuote) {
gasAmount = BigInt(defaultQuote.amount.toString());
gasAddressOrDenom = defaultQuote.addressOrDenom;
} else {
// Otherwise, compute IGP quote via the adapter
const hypAdapter = originToken.getHypAdapter(
this.multiProvider,
destinationName,
);
const destinationDomainId = this.multiProvider.getDomainId(destination);
const quote = await hypAdapter.quoteGasPayment(destinationDomainId);
gasAmount = BigInt(quote.amount);
gasAddressOrDenom = quote.addressOrDenom;
}
let igpToken: Token;
if (!gasAddressOrDenom) {
// An empty/undefined addressOrDenom indicates the native token
igpToken = Token.FromChainMetadataNativeToken(
this.multiProvider.getChainMetadata(originName),
);
} else {
const searchResult = this.findToken(originName, gasAddressOrDenom);
assert(searchResult, `IGP token ${gasAddressOrDenom} is unknown`);
igpToken = searchResult;
}
this.logger(`Quoted igp gas payment: ${gasAmount} ${igpToken.symbol}`);
return new TokenAmount(gasAmount, igpToken);
}
async getTransferRemoteTxs(
originTokenAmount: TokenAmount,
destination: ChainNameOrId,
sender: Address,
recipient: Address,
): Promise<Array<WarpTypedTransaction>> {
const transactions: Array<WarpTypedTransaction> = [];
const { token, amount } = originTokenAmount;
const destinationName = this.multiProvider.getChainName(destination);
const destinationDomainId = this.multiProvider.getDomainId(destination);
const providerType = TOKEN_STANDARD_TO_PROVIDER_TYPE[token.standard];
const hypAdapter = token.getHypAdapter(this.multiProvider, destinationName);
if (await this.isApproveRequired(originTokenAmount, sender)) {
this.logger(`Approval required for transfer of ${token.symbol}`);
const approveTxReq = await hypAdapter.populateApproveTx({
weiAmountOrId: amount.toString(),
recipient: token.addressOrDenom,
});
this.logger(`Approval tx for ${token.symbol} populated`);
const approveTx = {
category: WarpTxCategory.Approval,
type: providerType,
transaction: approveTxReq,
} as WarpTypedTransaction;
transactions.push(approveTx);
}
const interchainGasAmount = await this.getTransferGasQuote(
token,
destination,
);
const transferTxReq = await hypAdapter.populateTransferRemoteTx({
weiAmountOrId: amount.toString(),
destination: destinationDomainId,
fromAccountOwner: sender,
recipient,
interchainGas: {
amount: interchainGasAmount.amount,
addressOrDenom: interchainGasAmount.token.addressOrDenom,
},
});
this.logger(`Remote transfer tx for ${token.symbol} populated`);
const transferTx = {
category: WarpTxCategory.Transfer,
type: providerType,
transaction: transferTxReq,
} as WarpTypedTransaction;
transactions.push(transferTx);
return transactions;
}
/**
* Checks if destination chain's collateral is sufficient to cover the transfer
*/
async isDestinationCollateralSufficient(
originTokenAmount: TokenAmount,
destination: ChainNameOrId,
): Promise<boolean> {
const { token: originToken, amount } = originTokenAmount;
const destinationName = this.multiProvider.getChainName(destination);
this.logger(
`Checking collateral for ${originToken.symbol} to ${destination}`,
);
const destinationToken =
originToken.getConnectionForChain(destinationName)?.token;
assert(destinationToken, `No connection found for ${destinationName}`);
if (!TOKEN_COLLATERALIZED_STANDARDS.includes(destinationToken.standard)) {
this.logger(`${destinationToken.symbol} is not collateralized, skipping`);
return true;
}
const adapter = destinationToken.getAdapter(this.multiProvider);
const destinationBalance = await adapter.getBalance(
destinationToken.addressOrDenom,
);
const destinationBalanceInOriginDecimals = convertDecimals(
destinationToken.decimals,
originToken.decimals,
destinationBalance.toString(),
);
const isSufficient = BigInt(destinationBalanceInOriginDecimals) >= amount;
this.logger(
`${originTokenAmount.token.symbol} to ${destination} has ${
isSufficient ? 'sufficient' : 'INSUFFICIENT'
} collateral`,
);
return isSufficient;
}
/**
* Checks if a token transfer requires an approval tx first
*/
async isApproveRequired(
originTokenAmount: TokenAmount,
owner: Address,
): Promise<boolean> {
const { token, amount } = originTokenAmount;
const adapter = token.getAdapter(this.multiProvider);
const isRequired = await adapter.isApproveRequired(
owner,
token.addressOrDenom,
amount,
);
this.logger(
`Approval is${isRequired ? '' : ' not'} required for transfer of ${
token.symbol
}`,
);
return isRequired;
}
/**
* Ensure the remote token transfer would be valid for the given chains, amount, sender, and recipient
*/
async validateTransfer(
originTokenAmount: TokenAmount,
destination: ChainNameOrId,
sender: Address,
recipient: Address,
): Promise<Record<string, string> | null> {
const chainError = this.validateChains(
originTokenAmount.token.chainName,
destination,
);
if (chainError) return chainError;
const recipientError = this.validateRecipient(recipient, destination);
if (recipientError) return recipientError;
const amountError = this.validateAmount(originTokenAmount);
if (amountError) return amountError;
const balancesError = await this.validateTokenBalances(
originTokenAmount,
destination,
sender,
);
if (balancesError) return balancesError;
return null;
}
/**
* Ensure the origin and destination chains are valid and known by this WarpCore
*/
protected validateChains(
origin: ChainNameOrId,
destination: ChainNameOrId,
): Record<string, string> | null {
if (!origin) return { origin: 'Origin chain required' };
if (!destination) return { destination: 'Destination chain required' };
const originMetadata = this.multiProvider.tryGetChainMetadata(origin);
const destinationMetadata =
this.multiProvider.tryGetChainMetadata(destination);
if (!originMetadata) return { origin: 'Origin chain metadata missing' };
if (!destinationMetadata)
return { destination: 'Destination chain metadata missing' };
if (
this.routeBlacklist.some(
(bl) =>
bl.origin === originMetadata.name &&
bl.destination === destinationMetadata.name,
)
) {
return { destination: 'Route is not currently allowed' };
}
return null;
}
/**
* Ensure recipient address is valid for the destination chain
*/
protected validateRecipient(
recipient: Address,
destination: ChainNameOrId,
): Record<string, string> | null {
const destinationMetadata =
this.multiProvider.getChainMetadata(destination);
const { protocol, bech32Prefix } = destinationMetadata;
// Ensure recip address is valid for the destination chain's protocol
if (!isValidAddress(recipient, protocol))
return { recipient: 'Invalid recipient' };
// Also ensure the address denom is correct if the dest protocol is Cosmos
if (protocol === ProtocolType.Cosmos) {
if (!bech32Prefix) {
this.logger(`No bech32 prefix found for chain ${destination}`);
return { destination: 'Invalid chain data' };
} else if (!recipient.startsWith(bech32Prefix)) {
this.logger(`Recipient prefix should be ${bech32Prefix}`);
return { recipient: `Invalid recipient prefix` };
}
}
return null;
}
/**
* Ensure token amount is valid
*/
protected validateAmount(
originTokenAmount: TokenAmount,
): Record<string, string> | null {
if (!originTokenAmount.amount || originTokenAmount.amount < 0n) {
const isNft = originTokenAmount.token.isNft();
return { amount: isNft ? 'Invalid Token Id' : 'Invalid amount' };
}
return null;
}
/**
* Ensure the sender has sufficient balances for transfer and interchain gas
*/
protected async validateTokenBalances(
originTokenAmount: TokenAmount,
destination: ChainNameOrId,
sender: Address,
): Promise<Record<string, string> | null> {
const { token, amount } = originTokenAmount;
const { amount: senderBalance } = await token.getBalance(
this.multiProvider,
sender,
);
// First check basic token balance
if (amount > senderBalance) return { amount: 'Insufficient balance' };
// Next, ensure balances can cover IGP fees
const igpQuote = await this.getTransferGasQuote(token, destination);
if (token.equals(igpQuote.token) || token.collateralizes(igpQuote.token)) {
const total = amount + igpQuote.amount;
if (senderBalance < total)
return { amount: 'Insufficient balance for gas and transfer' };
} else {
const igpTokenBalance = await igpQuote.token.getBalance(
this.multiProvider,
sender,
);
if (igpTokenBalance.amount < igpQuote.amount)
return { amount: `Insufficient ${igpQuote.token.symbol} for gas` };
}
return null;
}
/**
* Search through token list to find token with matching chain and address
*/
findToken(
chainName: ChainName,
addressOrDenom?: Address | string,
): Token | null {
if (!addressOrDenom) return null;
const results = this.tokens.filter(
(token) =>
token.chainName === chainName &&
token.addressOrDenom.toLowerCase() === addressOrDenom.toLowerCase(),
);
if (results.length === 1) return results[0];
if (results.length > 1)
throw new Error(`Ambiguous token search results for ${addressOrDenom}`);
// If the token is not found, check to see if it matches the denom of chain's native token
// This is a convenience so WarpConfigs don't need to include definitions for native tokens
const chainMetadata = this.multiProvider.getChainMetadata(chainName);
if (chainMetadata.nativeToken?.denom === addressOrDenom) {
return Token.FromChainMetadataNativeToken(chainMetadata);
}
return null;
}
/**
* Get the list of chains referenced by the tokens in this WarpCore
*/
getTokenChains(): ChainName[] {
return [...new Set(this.tokens.map((t) => t.chainName)).values()];
}
/**
* Get the subset of tokens whose chain matches the given chainName
*/
getTokensForChain(chainName: ChainName): Token[] {
return this.tokens.filter((t) => t.chainName === chainName);
}
/**
* Get the subset of tokens whose chain matches the given chainName
* and which are connected to a token on the given destination chain
*/
getTokensForRoute(origin: ChainName, destination: ChainName): Token[] {
return this.tokens.filter(
(t) => t.chainName === origin && t.getConnectionForChain(destination),
);
}
}

@ -0,0 +1,73 @@
# An example Warp Core config
# Contains the token + route data needed to create a Warp Core
---
tokens:
# Eth Mainnet HypNative token
- chainName: ethereum
standard: EvmHypNative
decimals: 18
symbol: ETH
name: Ether
addressOrDenom: '0x1234567890123456789012345678901234567890'
connections:
- { token: ethereum|arbitrum|0x9876543210987654321098765432109876543210 }
- { token: cosmos|neutron|neutron1abcdefghijklmnopqrstuvwxyz1234567890ab }
- { token: sealevel|solana|s0LaBcEeFgHiJkLmNoPqRsTuVwXyZ456789012345678 }
# Arbitrum HypSynthetic token
- chainName: arbitrum
standard: EvmHypSynthetic
decimals: 18
symbol: ETH
name: Ether
addressOrDenom: '0x9876543210987654321098765432109876543210'
connections:
- { token: ethereum|ethereum|0x1234567890123456789012345678901234567890 }
- { token: cosmos|neutron|neutron1abcdefghijklmnopqrstuvwxyz1234567890ab }
# Solana HypSynthetic
- chainName: solana
standard: SealevelHypSynthetic
decimals: 9
symbol: ETH.sol
name: Ether on Solana
addressOrDenom: s0LaBcEeFgHiJkLmNoPqRsTuVwXyZ456789012345678
connections:
- { token: ethereum|ethereum|0x1234567890123456789012345678901234567890 }
# Cosmos Neutron HypCollateral token
- chainName: neutron
standard: CwHypCollateral
decimals: 18
symbol: ETH.in
name: Ether on Neutron
addressOrDenom: neutron1abcdefghijklmnopqrstuvwxyz1234567890ab
collateralAddressOrDenom: neutron1c0ll4t3r4lc0ll4t3r4lc0ll4t3r4lc0ll4t3r
connections:
- { token: ethereum|ethereum|0x1234567890123456789012345678901234567890 }
- { token: ethereum|arbitrum|0x9876543210987654321098765432109876543210 }
# Cosmos Neutron Collateralized token
- chainName: neutron
standard: CW20
decimals: 18
symbol: ETH.in
name: Ether on Neutron
addressOrDenom: neutron1c0ll4t3r4lc0ll4t3r4lc0ll4t3r4lc0ll4t3r
# Cosmos Injective token with IBC two-hop
- chainName: injective
standard: CosmosIbc
decimals: 18
symbol: INJ
name: Injective
addressOrDenom: inj
connections:
- token: ethereum|arbitrum|0x9876543210987654321098765432109876543210
type: ibc
sourcePort: transfer
sourceChannel: channel-1
intermediateChainName: neutron
intermediateIbcDenom: untrn
intermediateRouterAddress: neutron1abcdefghijklmnopqrstuvwxyz1234567890ab
options:
igpQuoteConstants:
- origin: neutron
destination: arbitrum
amount: 1
addressOrDenom: untrn

@ -0,0 +1,62 @@
import { z } from 'zod';
import { ZChainName } from '../metadata/customZodTypes';
import { TypedTransaction } from '../providers/ProviderType';
import { TokenConfigSchema } from '../token/IToken';
import { ChainName } from '../types';
// Map of protocol to either quote constant or to a map of chain name to quote constant
export type IgpQuoteConstants = Array<{
origin: ChainName;
destination: ChainName;
amount: string | number | bigint;
addressOrDenom?: string;
}>;
// List of chain pairs to blacklist for warp routes
export type RouteBlacklist = Array<{
origin: ChainName;
destination: ChainName;
}>;
// Transaction types for warp core remote transfers
export enum WarpTxCategory {
Approval = 'approval',
Transfer = 'transfer',
}
export type WarpTypedTransaction = TypedTransaction & {
category: WarpTxCategory;
};
/**
* Configuration used for instantiating a WarpCore
* Contains the relevant tokens and their connections
*/
export const WarpCoreConfigSchema = z.object({
tokens: z.array(TokenConfigSchema),
options: z
.object({
igpQuoteConstants: z
.array(
z.object({
origin: ZChainName,
destination: ZChainName,
amount: z.union([z.string(), z.number(), z.bigint()]),
addressOrDenom: z.string().optional(),
}),
)
.optional(),
routeBlacklist: z
.array(
z.object({
origin: ZChainName,
destination: ZChainName,
}),
)
.optional(),
})
.optional(),
});
export type WarpCoreConfig = z.infer<typeof WarpCoreConfigSchema>;

@ -110,12 +110,14 @@ export {
AddressBytes32,
CallData,
ChainCaip2Id,
ChainId,
Checkpoint,
Domain,
HexString,
InterchainSecurityModuleType,
MerkleProof,
MessageStatus,
Numberish,
ParsedLegacyMultisigIsmMetadata,
ParsedMessage,
ProtocolSmallestUnit,

@ -17,11 +17,13 @@ export const ProtocolSmallestUnit = {
/********* BASIC TYPES *********/
export type Domain = number;
export type ChainId = string | number;
export type Address = string;
export type AddressBytes32 = string;
export type ChainCaip2Id = `${string}:${string}`; // e.g. ethereum:1 or sealevel:1399811149
export type TokenCaip19Id = `${string}:${string}/${string}:${string}`; // e.g. ethereum:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f
export type HexString = string;
export type Numberish = number | string | bigint;
// copied from node_modules/@ethersproject/bytes/src.ts/index.ts
export type SignatureLike =

@ -1,4 +1,7 @@
export function assert(predicate: any, errorMessage?: string) {
export function assert<T>(
predicate: T,
errorMessage?: string,
): asserts predicate is NonNullable<T> {
if (!predicate) {
throw new Error(errorMessage ?? 'Error');
}

@ -4433,6 +4433,7 @@ __metadata:
ts-node: "npm:^10.8.0"
typescript: "npm:5.1.6"
viem: "npm:^1.20.0"
yaml: "npm:^2.3.1"
zod: "npm:^3.21.2"
peerDependencies:
"@ethersproject/abi": "*"

Loading…
Cancel
Save