diff --git a/.eslintrc b/.eslintrc index e4e5a50e..dcc1fd5e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -29,6 +29,14 @@ "@typescript-eslint/no-explicit-any": ["off"], "@typescript-eslint/no-non-null-assertion": ["off"], "@typescript-eslint/no-require-imports": ["warn"], + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], "jsx-a11y/alt-text": ["off"] } } diff --git a/src/features/core/Token.ts b/src/features/core/Token.ts index 2ea8fd9e..9f14fc0a 100644 --- a/src/features/core/Token.ts +++ b/src/features/core/Token.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ import { + ChainMetadata, ChainName, CosmIbcTokenAdapter, CosmNativeTokenAdapter, @@ -26,6 +27,7 @@ import { ProtocolType } from '@hyperlane-xyz/utils'; import { TokenAmount } from './TokenAmount'; import { Numberish, + PROTOCOL_TO_NATIVE_STANDARD, TOKEN_MULTI_CHAIN_STANDARDS, TOKEN_NFT_STANDARDS, TokenStandard, @@ -36,12 +38,12 @@ export interface TokenArgs { chainName: ChainName; standard: TokenStandard; addressOrDenom: Address | string; - collateralizedAddressOrDenom?: Address | string; - gasAddressOrDenom?: string; + collateralAddressOrDenom?: Address | string; + igpTokenAddressOrDenom?: string; decimals: number; symbol: string; name: string; - logoUri?: string; + logoURI?: string; connectedTokens?: Token[]; // Cosmos specific: @@ -58,6 +60,23 @@ export class Token { Object.assign(this, args); } + static FromChainMetadataNativeToken(chainMetadata: ChainMetadata): Token { + if (!chainMetadata.nativeToken) + throw new Error(`ChainMetadata for ${chainMetadata.name} missing nativeToken`); + + const { protocol, name: chainName, nativeToken, logoURI } = chainMetadata; + return new Token({ + protocol, + chainName, + standard: PROTOCOL_TO_NATIVE_STANDARD[protocol], + addressOrDenom: '', + 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. @@ -110,8 +129,8 @@ export class Token { standard, chainName, addressOrDenom, - collateralizedAddressOrDenom, - gasAddressOrDenom, + collateralAddressOrDenom, + igpTokenAddressOrDenom, sourcePort, sourceChannel, } = this; @@ -126,11 +145,11 @@ export class Token { let sealevelAddresses; if (protocol === ProtocolType.Sealevel) { if (!mailbox) throw new Error('mailbox required for Sealevel hyp tokens'); - if (!collateralizedAddressOrDenom) - throw new Error('collateralizedAddressOrDenom required for Sealevel hyp tokens'); + if (!collateralAddressOrDenom) + throw new Error('collateralAddressOrDenom required for Sealevel hyp tokens'); sealevelAddresses = { warpRouter: addressOrDenom, - token: collateralizedAddressOrDenom, + token: collateralAddressOrDenom, mailbox, }; } @@ -150,25 +169,25 @@ export class Token { chainName, multiProvider, { warpRouter: addressOrDenom }, - gasAddressOrDenom, + igpTokenAddressOrDenom, ); } else if (standard === TokenStandard.CwHypCollateral) { - if (!collateralizedAddressOrDenom) - throw new Error('collateralizedAddressOrDenom required for CwHypCollateral'); + if (!collateralAddressOrDenom) + throw new Error('collateralAddressOrDenom required for CwHypCollateral'); return new CwHypCollateralAdapter( chainName, multiProvider, - { warpRouter: addressOrDenom, token: collateralizedAddressOrDenom }, - gasAddressOrDenom, + { warpRouter: addressOrDenom, token: collateralAddressOrDenom }, + igpTokenAddressOrDenom, ); } else if (standard === TokenStandard.CwHypSynthetic) { - if (!collateralizedAddressOrDenom) - throw new Error('collateralizedAddressOrDenom required for CwHypSyntheticAdapter'); + if (!collateralAddressOrDenom) + throw new Error('collateralAddressOrDenom required for CwHypSyntheticAdapter'); return new CwHypSyntheticAdapter( chainName, multiProvider, - { warpRouter: addressOrDenom, token: collateralizedAddressOrDenom }, - gasAddressOrDenom, + { warpRouter: addressOrDenom, token: collateralAddressOrDenom }, + igpTokenAddressOrDenom, ); } else if (standard === TokenStandard.CosmosIbc) { if (!sourcePort || !sourceChannel) @@ -184,13 +203,13 @@ export class Token { } } - getConnectedTokens(): Token[] { - return this.connectedTokens || []; - } - - setConnectedTokens(tokens: Token[]): Token[] { - this.connectedTokens = tokens; - return tokens; + /** + * Convenience method to create an adapter and return an account balance + */ + async getBalance(multiProvider: MultiProtocolProvider, address: Address): Promise { + const adapter = this.getAdapter(multiProvider); + const balance = await adapter.getBalance(address); + return new TokenAmount(balance, this); } amount(amount: Numberish): TokenAmount { @@ -205,6 +224,15 @@ export class Token { return TOKEN_MULTI_CHAIN_STANDARDS.includes(this.standard); } + getConnectedTokens(): Token[] { + return this.connectedTokens || []; + } + + setConnectedTokens(tokens: Token[]): Token[] { + this.connectedTokens = tokens; + return tokens; + } + equals(token: Token): boolean { return ( this.protocol === token.protocol && @@ -212,8 +240,7 @@ export class Token { this.standard === token.standard && this.decimals === token.decimals && this.addressOrDenom.toLowerCase() === token.addressOrDenom.toLowerCase() && - this.collateralizedAddressOrDenom?.toLowerCase() === - token.collateralizedAddressOrDenom?.toLowerCase() + this.collateralAddressOrDenom?.toLowerCase() === token.collateralAddressOrDenom?.toLowerCase() ); } } diff --git a/src/features/core/WarpCore.ts b/src/features/core/WarpCore.ts new file mode 100644 index 00000000..15cd5611 --- /dev/null +++ b/src/features/core/WarpCore.ts @@ -0,0 +1,120 @@ +import debug, { Debugger } from 'debug'; + +import { ChainName, MultiProtocolProvider } from '@hyperlane-xyz/sdk'; +import { ProtocolType } from '@hyperlane-xyz/utils'; + +import { Token } from './Token'; +import { TokenAmount } from './TokenAmount'; +import { HyperlaneChainId, IgpQuoteConstants } from './types'; + +export interface WarpCoreOptions { + loggerName?: string; + igpQuoteConstants?: IgpQuoteConstants; +} + +export class WarpCore { + public readonly multiProvider: MultiProtocolProvider<{ mailbox?: Address }>; + public readonly tokens: Token[]; + public readonly igpQuoteConstants: IgpQuoteConstants; + public readonly logger: Debugger; + + constructor( + multiProvider: MultiProtocolProvider<{ mailbox?: Address }>, + tokens: Token[], + options: WarpCoreOptions, + ) { + this.multiProvider = multiProvider; + this.tokens = tokens; + this.igpQuoteConstants = options?.igpQuoteConstants || {}; + 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: string, + ): WarpCore { + throw new Error('TODO: method not implemented'); + } + + async getTransferGasQuote( + originToken: Token, + destination: HyperlaneChainId, + ): Promise { + const { chainName: originName, protocol: originProtocol } = originToken; + + // Step 1: Determine the amount + + let gasAmount: bigint; + const defaultQuotes = this.igpQuoteConstants[originProtocol]; + // Check constant quotes first + if (typeof defaultQuotes === 'string') { + gasAmount = BigInt(defaultQuotes); + } else if (defaultQuotes?.[originName]) { + gasAmount = BigInt(defaultQuotes[originName]); + } else { + // Otherwise, compute IGP quote via the adapter + const hypAdapter = originToken.getHypAdapter(this.multiProvider); + const destinationDomainId = this.multiProvider.getDomainId(destination); + gasAmount = BigInt(await hypAdapter.quoteGasPayment(destinationDomainId)); + } + + // Step 2: Determine the IGP token + // TODO, it would be more robust to determine this based on on-chain data + // rather than these janky heuristic + + // If the token has an explicit IGP token address set, use that + let igpToken: Token; + if (originToken.igpTokenAddressOrDenom) { + const searchResult = this.findToken(originToken.igpTokenAddressOrDenom, originName); + if (!searchResult) + throw new Error(`IGP token ${originToken.igpTokenAddressOrDenom} is unknown`); + igpToken = searchResult; + } else if (originProtocol === ProtocolType.Cosmos) { + // If the protocol is cosmos, assume the token itself is used + igpToken = originToken; + } else { + // Otherwise use the plain old native token from the route origin + igpToken = Token.FromChainMetadataNativeToken( + this.multiProvider.getChainMetadata(originName), + ); + } + + return new TokenAmount(gasAmount, igpToken); + } + + async validateTransfer( + originTokenAmount: TokenAmount, + destination: HyperlaneChainId, + recipient: Address, + ): Promise | null> { + throw new Error('TODO'); + } + + async getTransferRemoteTxs( + originTokenAmount: TokenAmount, + destination: HyperlaneChainId, + recipient: Address, + ): Promise<{ approveTx; transferTx }> { + throw new Error('TODO'); + } + + // Checks to ensure the destination chain's collateral is sufficient to cover the transfer + async isDestinationCollateralSufficient( + originTokenAmount: TokenAmount, + destination: HyperlaneChainId, + ): Promise { + throw new Error('TODO'); + } + + findToken(addressOrDenom: Address | string, chainName: ChainName): Token | null { + const results = this.tokens.filter( + (token) => + token.chainName === chainName && + token.addressOrDenom.toLowerCase() === addressOrDenom.toLowerCase(), + ); + if (!results.length) return null; + if (results.length > 1) throw new Error(`Ambiguous token search results for ${addressOrDenom}`); + return results[0]; + } +} diff --git a/src/features/core/WarpCoreSpec.ts b/src/features/core/WarpCoreSpec.ts index 32327804..fc9657d8 100644 --- a/src/features/core/WarpCoreSpec.ts +++ b/src/features/core/WarpCoreSpec.ts @@ -36,7 +36,7 @@ export enum TokenStandard { export class Token { constructor({ - protocol, chainName, standard, addressOrDenom, collateralizedAddressOrDenom, + protocol, chainName, standard, addressOrDenom, collateralAddressOrDenom, symbol, decimals, name, logoUri, connectedTokens }) {} diff --git a/src/features/core/types.ts b/src/features/core/types.ts index 62f8efa4..7a1e15d7 100644 --- a/src/features/core/types.ts +++ b/src/features/core/types.ts @@ -1,4 +1,5 @@ import { ChainName } from '@hyperlane-xyz/sdk'; +import { ProtocolType } from '@hyperlane-xyz/utils'; export type HyperlaneChainId = ChainName | ChainId | DomainId; export type Numberish = number | string | bigint; @@ -34,6 +35,9 @@ export enum TokenStandard { CwHypNative = 'CwHypNative', CwHypCollateral = 'CwHypCollateral', CwHypSynthetic = 'CwHypSynthetic', + + // Fuel (TODO) + FuelNative = 'FuelNative', } export const TOKEN_NFT_STANDARDS = [ @@ -55,3 +59,15 @@ export const TOKEN_MULTI_CHAIN_STANDARDS = [ TokenStandard.CwHypCollateral, TokenStandard.CwHypSynthetic, ]; + +export const PROTOCOL_TO_NATIVE_STANDARD: Record = { + [ProtocolType.Ethereum]: TokenStandard.EvmNative, + [ProtocolType.Cosmos]: TokenStandard.CosmosNative, + [ProtocolType.Sealevel]: TokenStandard.SealevelNative, + [ProtocolType.Fuel]: TokenStandard.FuelNative, +}; + +// Map of protocol to either quote constant or to a map of chain name to quote constant +export type IgpQuoteConstants = Partial< + Record> +>;