Tx fee estimatators and WarpCore interface refinements (#3364)

### Description

- Add multi-protocol transaction fee estimators
- Improve WarpCore interface 
- Fix ChainMetadata `transactionOverrides` zod type

### Drive-by changes

Add `gasPrice` field to Neutron chain metadata

### Related issues

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

### Backward compatibility

Small breaking change: Token Adapter `quoteGasPayment` method renamed to `quoteTransferRemoteFee` for clarity.

### Testing

Tested in Warp UI with nexus routes.
pull/3258/head
J M Rossy 8 months ago committed by GitHub
parent 985adc91eb
commit 254466f11a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/chatty-seals-flash.md
  2. 2
      typescript/cli/src/send/transfer.ts
  3. 3
      typescript/sdk/src/consts/chainMetadata.ts
  4. 4
      typescript/sdk/src/index.ts
  5. 2
      typescript/sdk/src/metadata/chainMetadataTypes.ts
  6. 35
      typescript/sdk/src/providers/MultiProtocolProvider.ts
  7. 288
      typescript/sdk/src/providers/transactionFeeEstimators.ts
  8. 5
      typescript/sdk/src/token/IToken.ts
  9. 52
      typescript/sdk/src/token/Token.ts
  10. 16
      typescript/sdk/src/token/adapters/CosmWasmTokenAdapter.ts
  11. 8
      typescript/sdk/src/token/adapters/CosmosTokenAdapter.ts
  12. 14
      typescript/sdk/src/token/adapters/EvmTokenAdapter.ts
  13. 2
      typescript/sdk/src/token/adapters/ITokenAdapter.ts
  14. 4
      typescript/sdk/src/token/adapters/SealevelTokenAdapter.ts
  15. 144
      typescript/sdk/src/warp/WarpCore.test.ts
  16. 346
      typescript/sdk/src/warp/WarpCore.ts
  17. 2
      typescript/sdk/src/warp/example-warp-core-config.yaml
  18. 74
      typescript/sdk/src/warp/types.ts

@ -0,0 +1,6 @@
---
'@hyperlane-xyz/sdk': minor
---
**New Feature**: Add transaction fee estimators to the SDK
**Breaking change**: Token Adapter `quoteGasPayment` method renamed to `quoteTransferRemoteGas` for clarity.

@ -211,7 +211,7 @@ async function executeDelivery({
const destinationDomain = multiProvider.getDomainId(destination);
log('Fetching interchain gas quote');
const interchainGas = await adapter.quoteGasPayment(destinationDomain);
const interchainGas = await adapter.quoteTransferRemoteGas(destinationDomain);
log('Interchain gas quote:', interchainGas);
const transferTx = (await adapter.populateTransferRemoteTx({
weiAmountOrId: wei,

@ -558,6 +558,9 @@ export const neutron: ChainMetadata = {
restUrls: [{ http: 'https://rest-lb.neutron.org' }],
rpcUrls: [{ http: 'https://rpc-kralum.neutron-1.neutron.org' }],
slip44: 118,
transactionOverrides: {
gasPrice: '0.0075',
},
};
export const optimism: ChainMetadata = {

@ -369,9 +369,9 @@ export {
EvmTokenAdapter,
} from './token/adapters/EvmTokenAdapter';
export {
InterchainGasQuote as AdapterInterchainGasQuote,
IHypTokenAdapter,
ITokenAdapter,
InterchainGasQuote,
TransferParams,
TransferRemoteParams,
} from './token/adapters/ITokenAdapter';
@ -432,7 +432,7 @@ export {
export { chainMetadataToWagmiChain, wagmiChainMetadata } from './utils/wagmi';
export { WarpCore, WarpCoreOptions } from './warp/WarpCore';
export {
IgpQuoteConstants,
FeeConstantConfig,
RouteBlacklist,
WarpCoreConfig,
WarpCoreConfigSchema,

@ -174,7 +174,7 @@ export const ChainMetadataSchemaObject = z.object({
.optional()
.describe('Block settings for the chain/deployment.'),
transactionOverrides: z
.object({})
.record(z.any())
.optional()
.describe('Properties to include when forming transaction requests.'),
gasCurrencyCoinGeckoId: z

@ -1,6 +1,12 @@
import { Debugger, debug } from 'debug';
import { objFilter, objMap, pick } from '@hyperlane-xyz/utils';
import {
Address,
HexString,
objFilter,
objMap,
pick,
} from '@hyperlane-xyz/utils';
import { chainMetadata as defaultChainMetadata } from '../consts/chainMetadata';
import { ChainMetadataManager } from '../metadata/ChainMetadataManager';
@ -17,12 +23,17 @@ import {
ProviderType,
SolanaWeb3Provider,
TypedProvider,
TypedTransaction,
ViemProvider,
} from './ProviderType';
import {
ProviderBuilderMap,
defaultProviderBuilderMap,
} from './providerBuilders';
import {
TransactionFeeEstimate,
estimateTransactionFee,
} from './transactionFeeEstimators';
export interface MultiProtocolProviderOptions {
loggerName?: string;
@ -208,6 +219,28 @@ export class MultiProtocolProvider<
}
}
estimateTransactionFee({
chainNameOrId,
transaction,
sender,
senderPubKey,
}: {
chainNameOrId: ChainNameOrId;
transaction: TypedTransaction;
sender: Address;
senderPubKey?: HexString;
}): Promise<TransactionFeeEstimate> {
const provider = this.getProvider(chainNameOrId, transaction.type);
const chainMetadata = this.getChainMetadata(chainNameOrId);
return estimateTransactionFee({
transaction,
provider,
chainMetadata,
sender,
senderPubKey,
});
}
override intersect(
chains: ChainName[],
throwIfNotSubset = false,

@ -0,0 +1,288 @@
import { encodeSecp256k1Pubkey } from '@cosmjs/amino';
import { wasmTypes } from '@cosmjs/cosmwasm-stargate';
import { toUtf8 } from '@cosmjs/encoding';
import { Uint53 } from '@cosmjs/math';
import { Registry } from '@cosmjs/proto-signing';
import { StargateClient, defaultRegistryTypes } from '@cosmjs/stargate';
import { MsgExecuteContract } from 'cosmjs-types/cosmwasm/wasm/v1/tx';
import { Address, HexString, Numberish, assert } from '@hyperlane-xyz/utils';
import { ChainMetadata } from '../metadata/chainMetadataTypes';
import {
CosmJsProvider,
CosmJsTransaction,
CosmJsWasmProvider,
CosmJsWasmTransaction,
EthersV5Provider,
EthersV5Transaction,
ProviderType,
SolanaWeb3Provider,
SolanaWeb3Transaction,
TypedProvider,
TypedTransaction,
ViemProvider,
ViemTransaction,
} from './ProviderType';
export interface TransactionFeeEstimate {
gasUnits: number | bigint;
gasPrice: number | bigint;
fee: number | bigint;
}
export async function estimateTransactionFeeEthersV5({
transaction,
provider,
sender,
}: {
transaction: EthersV5Transaction;
provider: EthersV5Provider;
sender: Address;
}): Promise<TransactionFeeEstimate> {
const ethersProvider = provider.provider;
const gasUnits = await ethersProvider.estimateGas({
...transaction.transaction,
from: sender,
});
return estimateTransactionFeeEthersV5ForGasUnits({
provider: ethersProvider,
gasUnits: BigInt(gasUnits.toString()),
});
}
// Separating out inner function to allow WarpCore to reuse logic
export async function estimateTransactionFeeEthersV5ForGasUnits({
provider,
gasUnits,
}: {
provider: EthersV5Provider['provider'];
gasUnits: bigint;
}): Promise<TransactionFeeEstimate> {
const feeData = await provider.getFeeData();
return computeEvmTxFee(
gasUnits,
feeData.gasPrice ? BigInt(feeData.gasPrice.toString()) : undefined,
feeData.maxFeePerGas ? BigInt(feeData.maxFeePerGas.toString()) : undefined,
feeData.maxPriorityFeePerGas
? BigInt(feeData.maxPriorityFeePerGas.toString())
: undefined,
);
}
export async function estimateTransactionFeeViem({
transaction,
provider,
sender,
}: {
transaction: ViemTransaction;
provider: ViemProvider;
sender: Address;
}): Promise<TransactionFeeEstimate> {
const gasUnits = await provider.provider.estimateGas({
...transaction.transaction,
blockNumber: undefined,
account: sender as `0x${string}`,
});
const feeData = await provider.provider.estimateFeesPerGas();
return computeEvmTxFee(
gasUnits,
feeData.gasPrice,
feeData.maxFeePerGas,
feeData.maxPriorityFeePerGas,
);
}
function computeEvmTxFee(
gasUnits: bigint,
gasPrice?: bigint,
maxFeePerGas?: bigint,
maxPriorityFeePerGas?: bigint,
): TransactionFeeEstimate {
let estGasPrice: bigint;
if (maxFeePerGas && maxPriorityFeePerGas) {
estGasPrice = maxFeePerGas + maxPriorityFeePerGas;
} else if (gasPrice) {
estGasPrice = gasPrice;
} else {
throw new Error('Invalid fee data, neither 1559 nor legacy');
}
return {
gasUnits,
gasPrice: estGasPrice,
fee: gasUnits * estGasPrice,
};
}
export async function estimateTransactionFeeSolanaWeb3({
provider,
transaction,
}: {
transaction: SolanaWeb3Transaction;
provider: SolanaWeb3Provider;
}): Promise<TransactionFeeEstimate> {
const connection = provider.provider;
const { value } = await connection.simulateTransaction(
transaction.transaction,
);
assert(!value.err, `Solana gas estimation failed: ${value.err}`);
const gasUnits = BigInt(value.unitsConsumed || 0);
const recentFees = await connection.getRecentPrioritizationFees();
const gasPrice = BigInt(recentFees[0].prioritizationFee);
return {
gasUnits,
gasPrice,
fee: gasUnits * gasPrice,
};
}
// This is based on a reverse-engineered version of the
// SigningStargateClient's simulate function. It cannot be
// used here because it requires access to the private key.
// https://github.com/cosmos/cosmjs/issues/1568
export async function estimateTransactionFeeCosmJs({
transaction,
provider,
estimatedGasPrice,
sender,
senderPubKey,
memo,
}: {
transaction: CosmJsTransaction;
provider: CosmJsProvider;
estimatedGasPrice: Numberish;
sender: Address;
// Unfortunately the sender pub key is required for this simulation.
// For accounts that have sent a tx, the pub key could be fetched via
// a StargateClient getAccount call. However that will fail for addresses
// that have not yet sent a tx on the queried chain.
// Related: https://github.com/cosmos/cosmjs/issues/889
senderPubKey: HexString;
memo?: string;
}): Promise<TransactionFeeEstimate> {
const stargateClient = await provider.provider;
const message = transaction.transaction;
const registry = new Registry([...defaultRegistryTypes, ...wasmTypes]);
const encodedMsg = registry.encodeAsAny(message);
const encodedPubkey = encodeSecp256k1Pubkey(Buffer.from(senderPubKey, 'hex'));
const { sequence } = await stargateClient.getSequence(sender);
const { gasInfo } = await stargateClient
// @ts-ignore force access to protected method
.forceGetQueryClient()
.tx.simulate([encodedMsg], memo, encodedPubkey, sequence);
assert(gasInfo, 'Gas estimation failed');
const gasUnits = Uint53.fromString(gasInfo.gasUsed.toString()).toNumber();
const gasPrice = parseFloat(estimatedGasPrice.toString());
return {
gasUnits,
gasPrice,
fee: Math.floor(gasUnits * gasPrice),
};
}
export async function estimateTransactionFeeCosmJsWasm({
transaction,
provider,
estimatedGasPrice,
sender,
senderPubKey,
memo,
}: {
transaction: CosmJsWasmTransaction;
provider: CosmJsWasmProvider;
estimatedGasPrice: Numberish;
sender: Address;
senderPubKey: HexString;
memo?: string;
}): Promise<TransactionFeeEstimate> {
const message = {
typeUrl: '/cosmwasm.wasm.v1.MsgExecuteContract',
value: MsgExecuteContract.fromPartial({
sender,
contract: transaction.transaction.contractAddress,
msg: toUtf8(JSON.stringify(transaction.transaction.msg)),
funds: [...(transaction.transaction.funds || [])],
}),
};
const wasmClient = await provider.provider;
// @ts-ignore access a private field here to extract client URL
const url: string = wasmClient.tmClient.client.url;
const stargateClient = StargateClient.connect(url);
return estimateTransactionFeeCosmJs({
transaction: { type: ProviderType.CosmJs, transaction: message },
provider: { type: ProviderType.CosmJs, provider: stargateClient },
estimatedGasPrice,
sender,
senderPubKey,
memo,
});
}
export function estimateTransactionFee({
transaction,
provider,
chainMetadata,
sender,
senderPubKey,
}: {
transaction: TypedTransaction;
provider: TypedProvider;
chainMetadata: ChainMetadata;
sender: Address;
senderPubKey?: HexString;
}): Promise<TransactionFeeEstimate> {
if (
transaction.type === ProviderType.EthersV5 &&
provider.type === ProviderType.EthersV5
) {
return estimateTransactionFeeEthersV5({ transaction, provider, sender });
} else if (
transaction.type === ProviderType.Viem &&
provider.type === ProviderType.Viem
) {
return estimateTransactionFeeViem({ transaction, provider, sender });
} else if (
transaction.type === ProviderType.SolanaWeb3 &&
provider.type === ProviderType.SolanaWeb3
) {
return estimateTransactionFeeSolanaWeb3({ transaction, provider });
} else if (
transaction.type === ProviderType.CosmJs &&
provider.type === ProviderType.CosmJs
) {
const { transactionOverrides } = chainMetadata;
const estimatedGasPrice = transactionOverrides?.gasPrice as Numberish;
assert(estimatedGasPrice, 'gasPrice required for CosmJS gas estimation');
assert(senderPubKey, 'senderPubKey required for CosmJS gas estimation');
return estimateTransactionFeeCosmJs({
transaction,
provider,
estimatedGasPrice,
sender,
senderPubKey,
});
} else if (
transaction.type === ProviderType.CosmJsWasm &&
provider.type === ProviderType.CosmJsWasm
) {
const { transactionOverrides } = chainMetadata;
const estimatedGasPrice = transactionOverrides?.gasPrice as Numberish;
assert(estimatedGasPrice, 'gasPrice required for CosmJS gas estimation');
assert(senderPubKey, 'senderPubKey required for CosmJS gas estimation');
return estimateTransactionFeeCosmJsWasm({
transaction,
provider,
estimatedGasPrice,
sender,
senderPubKey,
});
} else {
throw new Error(
`Unsupported transaction type ${transaction.type} or provider type ${provider.type} for gas estimation`,
);
}
}

@ -82,7 +82,6 @@ export interface IToken extends TokenArgs {
addConnection(connection: TokenConnection): IToken;
removeConnection(token: IToken): IToken;
equals(token: IToken): boolean;
collateralizes(token: IToken): boolean;
equals(token?: IToken): boolean;
isFungibleWith(token?: IToken): boolean;
}

@ -354,7 +354,7 @@ export class Token implements IToken {
return this;
}
removeConnection(token: Token): Token {
removeConnection(token: IToken): Token {
const index = this.connections?.findIndex((t) => t.token.equals(token));
if (index && index >= 0) this.connections?.splice(index, 1);
return this;
@ -363,7 +363,8 @@ export class Token implements IToken {
/**
* Returns true if tokens refer to the same asset
*/
equals(token: Token): boolean {
equals(token?: IToken): boolean {
if (!token) return false;
return (
this.protocol === token.protocol &&
this.chainName === token.chainName &&
@ -377,18 +378,41 @@ export class Token implements IToken {
}
/**
* 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
* Two tokens may not be equal but may still represent the same underlying asset
* The cases for this include:
* 1) A HypCollateral contract token and its wrapped token (eg. EvmHypCollateral and ERC20)
* 2) A HypNative contract and its native currency (eg. EvmHypNative and Ether)
* 3) An IBC token and its native equivalent
* This is useful during fee estimation to determine if a TokenAmount for the transfer and the fee
* are actually fungible (represent the same asset).
* @returns true if the tokens represent the same underlying asset
*/
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;
isFungibleWith(token?: IToken): boolean {
if (!token || token.chainName !== this.chainName) return false;
if (this.equals(token)) return true;
if (TOKEN_COLLATERALIZED_STANDARDS.includes(this.standard)) {
if (
this.collateralAddressOrDenom &&
eqAddress(this.collateralAddressOrDenom, token.addressOrDenom)
) {
return true;
}
if (!this.collateralAddressOrDenom && token.isNative()) {
return true;
}
}
if (
this.standard === TokenStandard.CosmosIbc &&
token.standard === TokenStandard.CosmosNative &&
this.addressOrDenom.toLowerCase() === token.addressOrDenom.toLowerCase()
) {
return true;
}
return false;
}
}

@ -274,7 +274,9 @@ export class CwHypSyntheticAdapter
}));
}
async quoteGasPayment(_destination: Domain): Promise<InterchainGasQuote> {
async quoteTransferRemoteGas(
_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
@ -287,7 +289,7 @@ export class CwHypSyntheticAdapter
// amount: BigInt(resp.gas_amount?.amount || 0),
// addressOrDenom: resp.gas_amount?.denom,
// };
throw new Error('CW adpater quoteGasPayment method not implemented');
throw new Error('CW adpater quoteTransferRemoteGas method not implemented');
}
async populateTransferRemoteTx({
@ -296,7 +298,8 @@ export class CwHypSyntheticAdapter
weiAmountOrId,
interchainGas,
}: TransferRemoteParams): Promise<ExecuteInstruction> {
if (!interchainGas) interchainGas = await this.quoteGasPayment(destination);
if (!interchainGas)
interchainGas = await this.quoteTransferRemoteGas(destination);
const { addressOrDenom: igpDenom, amount: igpAmount } = interchainGas;
assert(igpDenom, 'Interchain gas denom required for Cosmos');
@ -363,8 +366,8 @@ export class CwHypNativeAdapter
return this.cw20adapter.getAllRouters();
}
quoteGasPayment(destination: Domain): Promise<InterchainGasQuote> {
return this.cw20adapter.quoteGasPayment(destination);
quoteTransferRemoteGas(destination: Domain): Promise<InterchainGasQuote> {
return this.cw20adapter.quoteTransferRemoteGas(destination);
}
async getDenom(): Promise<string> {
@ -386,7 +389,8 @@ export class CwHypNativeAdapter
}: TransferRemoteParams): Promise<ExecuteInstruction> {
const collateralDenom = await this.getDenom();
if (!interchainGas) interchainGas = await this.quoteGasPayment(destination);
if (!interchainGas)
interchainGas = await this.quoteTransferRemoteGas(destination);
const { addressOrDenom: igpDenom, amount: igpAmount } = interchainGas;
assert(igpDenom, 'Interchain gas denom required for Cosmos');

@ -104,7 +104,9 @@ export class CosmIbcTokenAdapter
> {
throw new Error('Method not applicable to IBC adapters');
}
async quoteGasPayment(_destination: Domain): Promise<InterchainGasQuote> {
async quoteTransferRemoteGas(
_destination: Domain,
): Promise<InterchainGasQuote> {
// TODO implement IBC interchain transfer gas estimation here
return { amount: 0n, addressOrDenom: this.properties.ibcDenom };
}
@ -159,7 +161,9 @@ export class CosmIbcToWarpTokenAdapter
super(chainName, multiProvider, addresses, properties);
}
async quoteGasPayment(_destination: Domain): Promise<InterchainGasQuote> {
async quoteTransferRemoteGas(
_destination: Domain,
): Promise<InterchainGasQuote> {
// TODO implement IBC interchain transfer gas estimation here
return { amount: 0n, addressOrDenom: this.properties.intermediateIbcDenom };
}

@ -31,6 +31,10 @@ import {
TransferRemoteParams,
} from './ITokenAdapter';
// An estimate of the gas amount for a typical EVM token router transferRemote transaction
// Computed by estimating on a few different chains, taking the max, and then adding ~50% padding
export const EVM_TRANSFER_REMOTE_GAS_ESTIMATE = 450_000n;
// Interacts with native currencies
export class EvmNativeTokenAdapter
extends BaseEvmAdapter
@ -181,7 +185,9 @@ export class EvmHypSyntheticAdapter
return domains.map((d, i) => ({ domain: d, address: routers[i] }));
}
async quoteGasPayment(destination: Domain): Promise<InterchainGasQuote> {
async quoteTransferRemoteGas(
destination: Domain,
): Promise<InterchainGasQuote> {
const gasPayment = await this.contract.quoteGasPayment(destination);
// If EVM hyp contracts eventually support alternative IGP tokens,
// this would need to determine the correct token address
@ -194,7 +200,8 @@ export class EvmHypSyntheticAdapter
recipient,
interchainGas,
}: TransferRemoteParams): Promise<PopulatedTransaction> {
if (!interchainGas) interchainGas = await this.quoteGasPayment(destination);
if (!interchainGas)
interchainGas = await this.quoteTransferRemoteGas(destination);
const recipBytes32 = addressToBytes32(addressToByteHexString(recipient));
return this.contract.populateTransaction.transferRemote(
@ -287,7 +294,8 @@ export class EvmHypNativeAdapter
recipient,
interchainGas,
}: TransferRemoteParams): Promise<PopulatedTransaction> {
if (!interchainGas) interchainGas = await this.quoteGasPayment(destination);
if (!interchainGas)
interchainGas = await this.quoteTransferRemoteGas(destination);
let txValue: bigint | undefined = undefined;
const { addressOrDenom: igpAddressOrDenom, amount: igpAmount } =

@ -37,6 +37,6 @@ 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<InterchainGasQuote>;
quoteTransferRemoteGas(destination: Domain): Promise<InterchainGasQuote>;
populateTransferRemoteTx(p: TransferRemoteParams): Promise<Tx>;
}

@ -243,7 +243,9 @@ export abstract class SealevelHypTokenAdapter
}));
}
async quoteGasPayment(_destination: Domain): Promise<InterchainGasQuote> {
async quoteTransferRemoteGas(
_destination: Domain,
): Promise<InterchainGasQuote> {
// TODO Solana support
return { amount: 0n };
}

@ -17,7 +17,8 @@ import { ChainName } from '../types';
import { WarpCore } from './WarpCore';
import { WarpTxCategory } from './types';
const MOCK_QUOTE = { amount: 20_000n };
const MOCK_LOCAL_QUOTE = { gasUnits: 2_000n, gasPrice: 100, fee: 200_000n };
const MOCK_INTERCHAIN_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
@ -32,6 +33,11 @@ describe('WarpCore', () => {
let cw20: Token;
let cosmosIbc: Token;
// Stub MultiProvider fee estimation to avoid real network calls
sinon
.stub(multiProvider, 'estimateTransactionFee')
.returns(Promise.resolve(MOCK_LOCAL_QUOTE));
it('Constructs', () => {
const fromArgs = new WarpCore(multiProvider, [
Token.FromChainMetadataNativeToken(chainMetadata[Chains.ethereum]),
@ -73,25 +79,39 @@ describe('WarpCore', () => {
it('Gets transfer gas quote', async () => {
const stubs = warpCore.tokens.map((t) =>
sinon.stub(t, 'getHypAdapter').returns({
quoteGasPayment: () => Promise.resolve(MOCK_QUOTE),
quoteTransferRemoteGas: () => Promise.resolve(MOCK_INTERCHAIN_QUOTE),
isApproveRequired: () => Promise.resolve(false),
populateTransferRemoteTx: () => Promise.resolve({}),
} as any),
);
const testQuote = async (
token: Token,
chain: ChainName,
destination: ChainName,
standard: TokenStandard,
quote: InterchainGasQuote = MOCK_QUOTE,
interchainQuote: InterchainGasQuote = MOCK_INTERCHAIN_QUOTE,
) => {
const result = await warpCore.getTransferGasQuote(token, chain);
const result = await warpCore.estimateTransferRemoteFees({
originToken: token,
destination,
sender: ethers.constants.AddressZero,
});
expect(
result.token.standard,
`token standard check for ${token.chainName} to ${chain}`,
result.localQuote.token.standard,
`token local standard check for ${token.chainName} to ${destination}`,
).equals(standard);
expect(
result.amount,
`token amount check for ${token.chainName} to ${chain}`,
).to.equal(quote.amount);
result.localQuote.amount,
`token local amount check for ${token.chainName} to ${destination}`,
).to.equal(MOCK_LOCAL_QUOTE.fee);
expect(
result.interchainQuote.token.standard,
`token interchain standard check for ${token.chainName} to ${destination}`,
).equals(standard);
expect(
result.interchainQuote.amount,
`token interchain amount check for ${token.chainName} to ${destination}`,
).to.equal(interchainQuote.amount);
};
await testQuote(evmHypNative, Chains.arbitrum, TokenStandard.EvmNative);
@ -127,24 +147,24 @@ describe('WarpCore', () => {
const testCollateral = async (
token: Token,
chain: ChainName,
destination: ChainName,
expectedBigResult = true,
) => {
const smallResult = await warpCore.isDestinationCollateralSufficient(
token.amount(TRANSFER_AMOUNT),
chain,
);
const smallResult = await warpCore.isDestinationCollateralSufficient({
originTokenAmount: token.amount(TRANSFER_AMOUNT),
destination,
});
expect(
smallResult,
`small collateral check for ${token.chainName} to ${chain}`,
`small collateral check for ${token.chainName} to ${destination}`,
).to.be.true;
const bigResult = await warpCore.isDestinationCollateralSufficient(
token.amount(BIG_TRANSFER_AMOUNT),
chain,
);
const bigResult = await warpCore.isDestinationCollateralSufficient({
originTokenAmount: token.amount(BIG_TRANSFER_AMOUNT),
destination,
});
expect(
bigResult,
`big collateral check for ${token.chainName} to ${chain}`,
`big collateral check for ${token.chainName} to ${destination}`,
).to.equal(expectedBigResult);
};
@ -164,48 +184,50 @@ describe('WarpCore', () => {
);
const quoteStubs = warpCore.tokens.map((t) =>
sinon.stub(t, 'getHypAdapter').returns({
quoteGasPayment: () => Promise.resolve(MOCK_QUOTE),
quoteTransferRemoteGas: () => Promise.resolve(MOCK_INTERCHAIN_QUOTE),
isApproveRequired: () => Promise.resolve(false),
populateTransferRemoteTx: () => Promise.resolve({}),
} as any),
);
const validResult = await warpCore.validateTransfer(
evmHypNative.amount(TRANSFER_AMOUNT),
Chains.arbitrum,
ethers.constants.AddressZero,
ethers.constants.AddressZero,
);
const validResult = await warpCore.validateTransfer({
originTokenAmount: evmHypNative.amount(TRANSFER_AMOUNT),
destination: Chains.arbitrum,
recipient: ethers.constants.AddressZero,
sender: ethers.constants.AddressZero,
});
expect(validResult).to.be.null;
const invalidChain = await warpCore.validateTransfer(
evmHypNative.amount(TRANSFER_AMOUNT),
'fakechain',
ethers.constants.AddressZero,
ethers.constants.AddressZero,
);
const invalidChain = await warpCore.validateTransfer({
originTokenAmount: evmHypNative.amount(TRANSFER_AMOUNT),
destination: 'fakechain',
recipient: ethers.constants.AddressZero,
sender: 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,
);
const invalidRecipient = await warpCore.validateTransfer({
originTokenAmount: evmHypNative.amount(TRANSFER_AMOUNT),
destination: Chains.neutron,
recipient: ethers.constants.AddressZero,
sender: 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,
);
const invalidAmount = await warpCore.validateTransfer({
originTokenAmount: evmHypNative.amount(-10),
destination: Chains.arbitrum,
recipient: ethers.constants.AddressZero,
sender: 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,
);
const insufficientBalance = await warpCore.validateTransfer({
originTokenAmount: evmHypNative.amount(BIG_TRANSFER_AMOUNT),
destination: Chains.arbitrum,
recipient: ethers.constants.AddressZero,
sender: ethers.constants.AddressZero,
});
expect(Object.keys(insufficientBalance || {})[0]).to.equal('amount');
balanceStubs.forEach((s) => s.restore());
@ -219,26 +241,26 @@ describe('WarpCore', () => {
const adapterStubs = warpCore.tokens.map((t) =>
sinon.stub(t, 'getHypAdapter').returns({
quoteGasPayment: () => Promise.resolve(MOCK_QUOTE),
quoteTransferRemoteGas: () => Promise.resolve(MOCK_INTERCHAIN_QUOTE),
populateTransferRemoteTx: () => Promise.resolve({}),
} as any),
);
const testGetTxs = async (
token: Token,
chain: ChainName,
destination: ChainName,
providerType = ProviderType.EthersV5,
) => {
const result = await warpCore.getTransferRemoteTxs(
token.amount(TRANSFER_AMOUNT),
chain,
ethers.constants.AddressZero,
ethers.constants.AddressZero,
);
const result = await warpCore.getTransferRemoteTxs({
originTokenAmount: token.amount(TRANSFER_AMOUNT),
destination,
sender: ethers.constants.AddressZero,
recipient: ethers.constants.AddressZero,
});
expect(result.length).to.equal(1);
expect(
result[0],
`transfer tx for ${token.chainName} to ${chain}`,
`transfer tx for ${token.chainName} to ${destination}`,
).to.eql({
category: WarpTxCategory.Transfer,
transaction: {},

@ -2,13 +2,19 @@ import debug, { Debugger } from 'debug';
import {
Address,
HexString,
ProtocolType,
assert,
convertDecimals,
convertToProtocolAddress,
isValidAddress,
} from '@hyperlane-xyz/utils';
import { MultiProtocolProvider } from '../providers/MultiProtocolProvider';
import {
TransactionFeeEstimate,
estimateTransactionFeeEthersV5ForGasUnits,
} from '../providers/transactionFeeEstimators';
import { IToken } from '../token/IToken';
import { Token } from '../token/Token';
import { TokenAmount } from '../token/TokenAmount';
@ -17,26 +23,30 @@ import {
TOKEN_COLLATERALIZED_STANDARDS,
TOKEN_STANDARD_TO_PROVIDER_TYPE,
} from '../token/TokenStandard';
import { EVM_TRANSFER_REMOTE_GAS_ESTIMATE } from '../token/adapters/EvmTokenAdapter';
import { ChainName, ChainNameOrId } from '../types';
import {
IgpQuoteConstants,
FeeConstantConfig,
RouteBlacklist,
WarpCoreConfigSchema,
WarpCoreFeeEstimate,
WarpTxCategory,
WarpTypedTransaction,
} from './types';
export interface WarpCoreOptions {
loggerName?: string;
igpQuoteConstants?: IgpQuoteConstants;
localFeeConstants?: FeeConstantConfig;
interchainFeeConstants?: FeeConstantConfig;
routeBlacklist?: RouteBlacklist;
}
export class WarpCore {
public readonly multiProvider: MultiProtocolProvider<{ mailbox?: Address }>;
public readonly tokens: Token[];
public readonly igpQuoteConstants: IgpQuoteConstants;
public readonly localFeeConstants: FeeConstantConfig;
public readonly interchainFeeConstants: FeeConstantConfig;
public readonly routeBlacklist: RouteBlacklist;
public readonly logger: Debugger;
@ -47,12 +57,17 @@ export class WarpCore {
) {
this.multiProvider = multiProvider;
this.tokens = tokens;
this.igpQuoteConstants = options?.igpQuoteConstants || [];
this.localFeeConstants = options?.localFeeConstants || [];
this.interchainFeeConstants = options?.interchainFeeConstants || [];
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
/**
* Takes the serialized representation of a warp config and returns a WarpCore instance
* @param multiProvider the MultiProtocolProvider containing chain metadata
* @param config the config object of type WarpCoreConfig
*/
static FromConfig(
multiProvider: MultiProtocolProvider<{ mailbox?: Address }>,
config: unknown,
@ -90,23 +105,27 @@ export class WarpCore {
}
});
// Create new Warp
return new WarpCore(multiProvider, tokens, {
igpQuoteConstants: parsedConfig.options?.igpQuoteConstants,
routeBlacklist: parsedConfig.options?.routeBlacklist,
});
return new WarpCore(multiProvider, tokens, parsedConfig.options);
}
async getTransferGasQuote(
originToken: IToken,
destination: ChainNameOrId,
): Promise<TokenAmount> {
/**
* Queries the token router for an interchain gas quote (i.e. IGP fee)
*/
async getInterchainTransferFee({
originToken,
destination,
}: {
originToken: IToken;
destination: ChainNameOrId;
}): Promise<TokenAmount> {
this.logger(`Fetching interchain transfer quote to ${destination}`);
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(
const defaultQuote = this.interchainFeeConstants.find(
(q) => q.origin === originName && q.destination === destinationName,
);
if (defaultQuote) {
@ -119,7 +138,9 @@ export class WarpCore {
destinationName,
);
const destinationDomainId = this.multiProvider.getDomainId(destination);
const quote = await hypAdapter.quoteGasPayment(destinationDomainId);
const quote = await hypAdapter.quoteTransferRemoteGas(
destinationDomainId,
);
gasAmount = BigInt(quote.amount);
gasAddressOrDenom = quote.addressOrDenom;
}
@ -132,20 +153,107 @@ export class WarpCore {
);
} else {
const searchResult = this.findToken(originName, gasAddressOrDenom);
assert(searchResult, `IGP token ${gasAddressOrDenom} is unknown`);
assert(searchResult, `Fee token ${gasAddressOrDenom} is unknown`);
igpToken = searchResult;
}
this.logger(`Quoted igp gas payment: ${gasAmount} ${igpToken.symbol}`);
this.logger(
`Quoted interchain transfer fee: ${gasAmount} ${igpToken.symbol}`,
);
return new TokenAmount(gasAmount, igpToken);
}
async getTransferRemoteTxs(
originTokenAmount: TokenAmount,
destination: ChainNameOrId,
sender: Address,
recipient: Address,
): Promise<Array<WarpTypedTransaction>> {
/**
* Simulates a transfer to estimate 'local' gas fees on the origin chain
*/
async getLocalTransferFee({
originToken,
destination,
sender,
senderPubKey,
interchainFee,
}: {
originToken: IToken;
destination: ChainNameOrId;
sender: Address;
senderPubKey?: HexString;
interchainFee?: TokenAmount;
}): Promise<TransactionFeeEstimate> {
const originMetadata = this.multiProvider.getChainMetadata(
originToken.chainName,
);
const destinationMetadata =
this.multiProvider.getChainMetadata(destination);
// Check constant quotes first
const defaultQuote = this.localFeeConstants.find(
(q) =>
q.origin === originMetadata.name &&
q.destination === destinationMetadata.name,
);
if (defaultQuote) {
return { gasUnits: 0, gasPrice: 0, fee: Number(defaultQuote.amount) };
}
// Form transactions to estimate local gas with
const recipient = convertToProtocolAddress(
sender,
destinationMetadata.protocol,
destinationMetadata.bech32Prefix,
);
const txs = await this.getTransferRemoteTxs({
originTokenAmount: originToken.amount(1),
destination,
sender,
recipient,
interchainFee,
});
// Typically the transfers require a single transaction
if (txs.length === 1) {
return this.multiProvider.estimateTransactionFee({
chainNameOrId: originMetadata.name,
transaction: txs[0],
sender,
senderPubKey,
});
}
// On ethereum, sometimes 2 txs are required (one approve, one transferRemote)
else if (
txs.length === 2 &&
originToken.protocol === ProtocolType.Ethereum
) {
const provider = this.multiProvider.getEthersV5Provider(
originMetadata.name,
);
// We use a hard-coded const as an estimate for the transferRemote because we
// cannot reliably simulate the tx when an approval tx is required first
return estimateTransactionFeeEthersV5ForGasUnits({
provider,
gasUnits: EVM_TRANSFER_REMOTE_GAS_ESTIMATE,
});
} else {
throw new Error('Cannot estimate local gas for multiple transactions');
}
}
/**
* Gets a list of populated transactions required to transfer a token to a remote chain
* Typically just 1 transaction but sometimes more, like when an approval is required first
*/
async getTransferRemoteTxs({
originTokenAmount,
destination,
sender,
recipient,
interchainFee,
}: {
originTokenAmount: TokenAmount;
destination: ChainNameOrId;
sender: Address;
recipient: Address;
interchainFee?: TokenAmount;
}): Promise<Array<WarpTypedTransaction>> {
const transactions: Array<WarpTypedTransaction> = [];
const { token, amount } = originTokenAmount;
@ -154,7 +262,7 @@ export class WarpCore {
const providerType = TOKEN_STANDARD_TO_PROVIDER_TYPE[token.standard];
const hypAdapter = token.getHypAdapter(this.multiProvider, destinationName);
if (await this.isApproveRequired(originTokenAmount, sender)) {
if (await this.isApproveRequired({ originTokenAmount, owner: sender })) {
this.logger(`Approval required for transfer of ${token.symbol}`);
const approveTxReq = await hypAdapter.populateApproveTx({
weiAmountOrId: amount.toString(),
@ -170,10 +278,12 @@ export class WarpCore {
transactions.push(approveTx);
}
const interchainGasAmount = await this.getTransferGasQuote(
token,
destination,
);
if (!interchainFee) {
interchainFee = await this.getInterchainTransferFee({
originToken: token,
destination,
});
}
const transferTxReq = await hypAdapter.populateTransferRemoteTx({
weiAmountOrId: amount.toString(),
@ -181,8 +291,8 @@ export class WarpCore {
fromAccountOwner: sender,
recipient,
interchainGas: {
amount: interchainGasAmount.amount,
addressOrDenom: interchainGasAmount.token.addressOrDenom,
amount: interchainFee.amount,
addressOrDenom: interchainFee.token.addressOrDenom,
},
});
this.logger(`Remote transfer tx for ${token.symbol} populated`);
@ -197,13 +307,108 @@ export class WarpCore {
return transactions;
}
/**
* Fetch local and interchain fee estimates for a remote transfer
*/
async estimateTransferRemoteFees({
originToken,
destination,
sender,
senderPubKey,
}: {
originToken: IToken;
destination: ChainNameOrId;
sender: Address;
senderPubKey?: HexString;
}): Promise<WarpCoreFeeEstimate> {
this.logger('Fetching remote transfer fee estimates');
// First get interchain gas quote (aka IGP quote)
// Start with this because it's used in the local fee estimation
const interchainQuote = await this.getInterchainTransferFee({
originToken,
destination,
});
const originMetadata = this.multiProvider.getChainMetadata(
originToken.chainName,
);
// If there's no native token, we can't represent local gas
if (!originMetadata.nativeToken)
throw new Error(`No native token found for ${originMetadata.name}`);
// Next, get the local gas quote
const localFee = await this.getLocalTransferFee({
originToken,
destination,
sender,
senderPubKey,
interchainFee: interchainQuote,
});
// Get the local gas token. This assumes the chain's native token will pay for local gas
// This will need to be smarter if more complex scenarios on Cosmos are supported
const localGasToken = Token.FromChainMetadataNativeToken(originMetadata);
const localQuote = localGasToken.amount(localFee.fee);
return {
interchainQuote,
localQuote,
};
}
/**
* Computes the max transferrable amount of the from the given
* token balance, accounting for local and interchain gas fees
*/
async getMaxTransferAmount({
balance,
destination,
sender,
senderPubKey,
feeEstimate,
}: {
balance: TokenAmount;
destination: ChainNameOrId;
sender: Address;
senderPubKey?: HexString;
feeEstimate?: WarpCoreFeeEstimate;
}): Promise<TokenAmount> {
const originToken = balance.token;
if (!feeEstimate) {
feeEstimate = await this.estimateTransferRemoteFees({
originToken,
destination,
sender,
senderPubKey,
});
}
const { localQuote, interchainQuote } = feeEstimate;
let maxAmount = balance;
if (originToken.isFungibleWith(localQuote.token)) {
maxAmount = maxAmount.minus(localQuote.amount);
}
if (originToken.isFungibleWith(interchainQuote.token)) {
maxAmount = maxAmount.minus(interchainQuote.amount);
}
if (maxAmount.amount > 0) return maxAmount;
else return originToken.amount(0);
}
/**
* Checks if destination chain's collateral is sufficient to cover the transfer
*/
async isDestinationCollateralSufficient(
originTokenAmount: TokenAmount,
destination: ChainNameOrId,
): Promise<boolean> {
async isDestinationCollateralSufficient({
originTokenAmount,
destination,
}: {
originTokenAmount: TokenAmount;
destination: ChainNameOrId;
}): Promise<boolean> {
const { token: originToken, amount } = originTokenAmount;
const destinationName = this.multiProvider.getChainName(destination);
this.logger(
@ -241,10 +446,13 @@ export class WarpCore {
/**
* Checks if a token transfer requires an approval tx first
*/
async isApproveRequired(
originTokenAmount: TokenAmount,
owner: Address,
): Promise<boolean> {
async isApproveRequired({
originTokenAmount,
owner,
}: {
originTokenAmount: TokenAmount;
owner: Address;
}): Promise<boolean> {
const { token, amount } = originTokenAmount;
const adapter = token.getAdapter(this.multiProvider);
const isRequired = await adapter.isApproveRequired(
@ -263,12 +471,19 @@ export class WarpCore {
/**
* 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> {
async validateTransfer({
originTokenAmount,
destination,
recipient,
sender,
senderPubKey,
}: {
originTokenAmount: TokenAmount;
destination: ChainNameOrId;
recipient: Address;
sender: Address;
senderPubKey?: HexString;
}): Promise<Record<string, string> | null> {
const chainError = this.validateChains(
originTokenAmount.token.chainName,
destination,
@ -285,6 +500,7 @@ export class WarpCore {
originTokenAmount,
destination,
sender,
senderPubKey,
);
if (balancesError) return balancesError;
@ -364,29 +580,45 @@ export class WarpCore {
originTokenAmount: TokenAmount,
destination: ChainNameOrId,
sender: Address,
senderPubKey?: HexString,
): Promise<Record<string, string> | null> {
const { token, amount } = originTokenAmount;
const { amount: senderBalance } = await token.getBalance(
this.multiProvider,
sender,
);
const senderBalanceAmount = originTokenAmount.token.amount(senderBalance);
// 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` };
// Next, ensure balances can cover the COMBINED amount and fees
const feeEstimate = await this.estimateTransferRemoteFees({
originToken: token,
destination,
sender,
senderPubKey,
});
const maxTransfer = await this.getMaxTransferAmount({
balance: senderBalanceAmount,
destination,
sender,
senderPubKey,
feeEstimate,
});
if (amount > maxTransfer.amount) {
return { amount: 'Insufficient balance for gas and transfer' };
}
// Finally, ensure there's sufficient balance for the IGP fee, which may
// be a different token than the transfer token
const igpQuote = feeEstimate.interchainQuote;
const igpTokenBalance = await igpQuote.token.getBalance(
this.multiProvider,
sender,
);
if (igpTokenBalance.amount < igpQuote.amount) {
return { amount: `Insufficient ${igpQuote.token.symbol} for gas` };
}
return null;

@ -66,7 +66,7 @@ tokens:
intermediateIbcDenom: untrn
intermediateRouterAddress: neutron1abcdefghijklmnopqrstuvwxyz1234567890ab
options:
igpQuoteConstants:
interchainFeeConstants:
- origin: neutron
destination: arbitrum
amount: 1

@ -1,52 +1,30 @@
import { z } from 'zod';
import { ZChainName } from '../metadata/customZodTypes';
import { TypedTransaction } from '../providers/ProviderType';
import type { 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;
};
import type { TokenAmount } from '../token/TokenAmount';
import type { ChainName } from '../types';
/**
* Configuration used for instantiating a WarpCore
* Contains the relevant tokens and their connections
*/
const FeeConstantConfigSchema = z.array(
z.object({
origin: ZChainName,
destination: ZChainName,
amount: z.union([z.string(), z.number(), z.bigint()]),
addressOrDenom: z.string().optional(),
}),
);
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(),
localFeeConstants: FeeConstantConfigSchema.optional(),
interchainFeeConstants: FeeConstantConfigSchema.optional(),
routeBlacklist: z
.array(
z.object({
@ -59,4 +37,28 @@ export const WarpCoreConfigSchema = z.object({
.optional(),
});
// List of constant values for local or interchain fees
export type FeeConstantConfig = z.infer<typeof FeeConstantConfigSchema>;
// 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;
};
export type WarpCoreConfig = z.infer<typeof WarpCoreConfigSchema>;
export interface WarpCoreFeeEstimate {
interchainQuote: TokenAmount;
localQuote: TokenAmount;
}

Loading…
Cancel
Save