feat: Sealevel IGP quoting in WarpCore / token adapters (#4923)

### Description

- Implements SVM IGP quoting. Unfortunately the warp routes themselves
don't have direct getters for this, so we first need to get the expected
destination gas amount, and then do a call to the IGP
- An unfortunate consequence is that performing a "view call" on
Sealevel (i.e. a simulateTransaction call) requires you to specify a
payer that is capable of paying for the tx as if it were real (i.e. must
have funds, must be an EOA). I couldn't find any workaround other than
just requiring the payer to be passed in :(

### Drive-by changes

- Some further support for non-overhead IGP usage / some minor renaming
to more closely match the program jargon

### Related issues

<!--
- Fixes #[issue number here]
-->

### Backward compatibility

<!--
Are these changes backward compatible? Are there any infrastructure
implications, e.g. changes that would prohibit deploying older commits
using this infra tooling?

Yes/No
-->

### Testing

I tested this with an infra script that was calling the functions
against live warp routes. Not sure what the unit test practices we have
here are though, seems like we don't have any
pull/4722/merge
Trevor Porter 15 hours ago committed by GitHub
parent d0e53f5b0b
commit 97c1f80b73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/tricky-clocks-retire.md
  2. 171
      typescript/sdk/src/gas/adapters/SealevelIgpAdapter.ts
  3. 73
      typescript/sdk/src/gas/adapters/serialization.ts
  4. 6
      typescript/sdk/src/token/adapters/ITokenAdapter.ts
  5. 131
      typescript/sdk/src/token/adapters/SealevelTokenAdapter.ts
  6. 21
      typescript/sdk/src/utils/sealevelSerialization.ts
  7. 9
      typescript/sdk/src/warp/WarpCore.ts

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/sdk': minor
---
Implement Sealevel IGP quoting

@ -1,40 +1,122 @@
import { PublicKey } from '@solana/web3.js';
import { deserializeUnchecked } from 'borsh';
import {
Message,
PublicKey,
SystemProgram,
TransactionInstruction,
VersionedTransaction,
} from '@solana/web3.js';
import { deserializeUnchecked, serialize } from 'borsh';
import { Address } from '@hyperlane-xyz/utils';
import { Address, Domain, assert } from '@hyperlane-xyz/utils';
import { BaseSealevelAdapter } from '../../app/MultiProtocolApp.js';
import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider.js';
import { ChainName } from '../../types.js';
import { SealevelAccountDataWrapper } from '../../utils/sealevelSerialization.js';
import {
SealevelAccountDataWrapper,
SealevelInstructionWrapper,
} from '../../utils/sealevelSerialization.js';
import {
SealeveIgpInstruction,
SealevelIgpQuoteGasPaymentInstruction,
SealevelIgpQuoteGasPaymentResponse,
SealevelIgpQuoteGasPaymentResponseSchema,
SealevelIgpQuoteGasPaymentSchema,
SealevelOverheadIgpData,
SealevelOverheadIgpDataSchema,
} from './serialization.js';
export class SealevelOverheadIgpAdapter extends BaseSealevelAdapter {
export interface IgpPaymentKeys {
programId: PublicKey;
igpAccount: PublicKey;
overheadIgpAccount?: PublicKey;
}
export abstract class SealevelIgpProgramAdapter extends BaseSealevelAdapter {
protected readonly programId: PublicKey;
constructor(
public readonly chainName: ChainName,
public readonly multiProvider: MultiProtocolProvider,
public readonly addresses: { igp: Address },
public readonly addresses: { programId: Address },
) {
super(chainName, multiProvider, addresses);
this.programId = new PublicKey(addresses.programId);
}
async getAccountInfo(): Promise<SealevelOverheadIgpData> {
const address = this.addresses.igp;
abstract getPaymentKeys(): Promise<IgpPaymentKeys>;
// Simulating a transaction requires a payer to have sufficient balance to pay for tx fees.
async quoteGasPayment(
destination: Domain,
gasAmount: bigint,
payerKey: PublicKey,
): Promise<bigint> {
const paymentKeys = await this.getPaymentKeys();
const keys = [
// 0. `[executable]` The system program.
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
// 1. `[]` The IGP account.
{
pubkey: paymentKeys.igpAccount,
isSigner: false,
isWritable: false,
},
];
if (paymentKeys.overheadIgpAccount) {
// 2. `[]` The overhead IGP account (optional).
keys.push({
pubkey: paymentKeys.overheadIgpAccount,
isSigner: false,
isWritable: false,
});
}
const value = new SealevelInstructionWrapper({
instruction: SealeveIgpInstruction.QuoteGasPayment,
data: new SealevelIgpQuoteGasPaymentInstruction({
destination_domain: destination,
gas_amount: BigInt(gasAmount),
}),
});
const quoteGasPaymentInstruction = new TransactionInstruction({
keys,
programId: this.programId,
data: Buffer.from(serialize(SealevelIgpQuoteGasPaymentSchema, value)),
});
const message = Message.compile({
// This is ignored
recentBlockhash: PublicKey.default.toBase58(),
instructions: [quoteGasPaymentInstruction],
payerKey,
});
const tx = new VersionedTransaction(message);
const connection = this.getProvider();
const simulationResponse = await connection.simulateTransaction(tx, {
// ignore the recent blockhash we pass in, and have the node use its latest one
replaceRecentBlockhash: true,
// ignore signature verification
sigVerify: false,
});
const accountInfo = await connection.getAccountInfo(new PublicKey(address));
if (!accountInfo) throw new Error(`No account info found for ${address}}`);
const base64Data = simulationResponse.value.returnData?.data?.[0];
assert(
base64Data,
'No return data when quoting gas payment, may happen if the payer has insufficient funds',
);
const accountData = deserializeUnchecked(
SealevelOverheadIgpDataSchema,
SealevelAccountDataWrapper,
accountInfo.data,
const data = Buffer.from(base64Data, 'base64');
const quote = deserializeUnchecked(
SealevelIgpQuoteGasPaymentResponseSchema,
SealevelIgpQuoteGasPaymentResponse,
data,
);
return accountData.data as SealevelOverheadIgpData;
return quote.payment_quote;
}
// https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/rust/sealevel/programs/hyperlane-sealevel-igp/src/pda_seeds.rs#L7
@ -56,3 +138,62 @@ export class SealevelOverheadIgpAdapter extends BaseSealevelAdapter {
);
}
}
export class SealevelIgpAdapter extends SealevelIgpProgramAdapter {
protected readonly igp: PublicKey;
constructor(
public readonly chainName: ChainName,
public readonly multiProvider: MultiProtocolProvider,
public readonly addresses: { igp: Address; programId: Address },
) {
super(chainName, multiProvider, addresses);
this.igp = new PublicKey(addresses.igp);
}
override async getPaymentKeys(): Promise<IgpPaymentKeys> {
return {
programId: this.programId,
igpAccount: this.igp,
};
}
}
export class SealevelOverheadIgpAdapter extends SealevelIgpProgramAdapter {
protected readonly overheadIgp: PublicKey;
constructor(
public readonly chainName: ChainName,
public readonly multiProvider: MultiProtocolProvider,
public readonly addresses: { overheadIgp: Address; programId: Address },
) {
super(chainName, multiProvider, addresses);
this.overheadIgp = new PublicKey(addresses.overheadIgp);
}
async getAccountInfo(): Promise<SealevelOverheadIgpData> {
const address = this.addresses.overheadIgp;
const connection = this.getProvider();
const accountInfo = await connection.getAccountInfo(new PublicKey(address));
assert(accountInfo, `No account info found for ${address}}`);
const accountData = deserializeUnchecked(
SealevelOverheadIgpDataSchema,
SealevelAccountDataWrapper,
accountInfo.data,
);
return accountData.data as SealevelOverheadIgpData;
}
override async getPaymentKeys(): Promise<IgpPaymentKeys> {
const igpData = await this.getAccountInfo();
return {
programId: this.programId,
igpAccount: igpData.inner_pub_key,
overheadIgpAccount: this.overheadIgp,
};
}
}

@ -4,7 +4,9 @@ import { Domain } from '@hyperlane-xyz/utils';
import {
SealevelAccountDataWrapper,
SealevelInstructionWrapper,
getSealevelAccountDataSchema,
getSealevelSimulationReturnDataSchema,
} from '../../utils/sealevelSerialization.js';
// Should match https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/rust/sealevel/programs/hyperlane-sealevel-igp/src/accounts.rs#L24
@ -89,3 +91,74 @@ export const SealevelOverheadIgpDataSchema = new Map<any, any>([
},
],
]);
/**
* IGP instruction Borsh Schema
*/
// Should match Instruction in https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/8f8853bcd7105a6dd7af3a45c413b137ded6e888/rust/sealevel/programs/hyperlane-sealevel-igp/src/instruction.rs#L19-L42
export enum SealeveIgpInstruction {
Init,
InitIgp,
InitOverheadIgp,
PayForGas,
QuoteGasPayment,
TransferIgpOwnership,
TransferOverheadIgpOwnership,
SetIgpBeneficiary,
SetDestinationGasOverheads,
SetGasOracleConfigs,
Claim,
}
export class SealevelIgpQuoteGasPaymentInstruction {
destination_domain!: number;
gas_amount!: bigint;
constructor(public readonly fields: any) {
Object.assign(this, fields);
}
}
export const SealevelIgpQuoteGasPaymentSchema = new Map<any, any>([
[
SealevelInstructionWrapper,
{
kind: 'struct',
fields: [
['instruction', 'u8'],
['data', SealevelIgpQuoteGasPaymentInstruction],
],
},
],
[
SealevelIgpQuoteGasPaymentInstruction,
{
kind: 'struct',
fields: [
['destination_domain', 'u32'],
['gas_amount', 'u64'],
],
},
],
]);
export class SealevelIgpQuoteGasPaymentResponse {
payment_quote!: bigint;
constructor(public readonly fields: any) {
Object.assign(this, fields);
}
}
export const SealevelIgpQuoteGasPaymentResponseSchema = new Map<any, any>([
[
SealevelAccountDataWrapper,
getSealevelSimulationReturnDataSchema(SealevelIgpQuoteGasPaymentResponse),
],
[
SealevelIgpQuoteGasPaymentResponse,
{
kind: 'struct',
fields: [['payment_quote', 'u64']],
},
],
]);

@ -39,7 +39,11 @@ export interface IHypTokenAdapter<Tx> extends ITokenAdapter<Tx> {
getRouterAddress(domain: Domain): Promise<Buffer>;
getAllRouters(): Promise<Array<{ domain: Domain; address: Buffer }>>;
getBridgedSupply(): Promise<bigint | undefined>;
quoteTransferRemoteGas(destination: Domain): Promise<InterchainGasQuote>;
// Sender is only required for Sealevel origins.
quoteTransferRemoteGas(
destination: Domain,
sender?: Address,
): Promise<InterchainGasQuote>;
populateTransferRemoteTx(p: TransferRemoteParams): Promise<Tx>;
}

@ -19,14 +19,21 @@ import {
Address,
Domain,
addressToBytes,
assert,
eqAddress,
isNullish,
median,
padBytesToLength,
} from '@hyperlane-xyz/utils';
import { BaseSealevelAdapter } from '../../app/MultiProtocolApp.js';
import { SEALEVEL_SPL_NOOP_ADDRESS } from '../../consts/sealevel.js';
import { SealevelOverheadIgpAdapter } from '../../gas/adapters/SealevelIgpAdapter.js';
import {
IgpPaymentKeys,
SealevelIgpAdapter,
SealevelIgpProgramAdapter,
SealevelOverheadIgpAdapter,
} from '../../gas/adapters/SealevelIgpAdapter.js';
import { SealevelInterchainGasPaymasterType } from '../../gas/adapters/serialization.js';
import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider.js';
import { ChainName } from '../../types.js';
@ -283,11 +290,32 @@ export abstract class SealevelHypTokenAdapter
return undefined;
}
// The sender is required, as simulating a transaction on Sealevel requires
// a payer to be specified that has sufficient funds to cover the transaction fee.
async quoteTransferRemoteGas(
_destination: Domain,
destination: Domain,
sender?: Address,
): Promise<InterchainGasQuote> {
// TODO Solana support
return { amount: 0n };
const tokenData = await this.getTokenAccountData();
const destinationGas = tokenData.destination_gas?.get(destination);
if (isNullish(destinationGas)) {
return { amount: 0n };
}
const igp = this.getIgpAdapter(tokenData);
if (!igp) {
return { amount: 0n };
}
assert(sender, 'Sender required for Sealevel transfer remote gas quote');
return {
amount: await igp.quoteGasPayment(
destination,
destinationGas,
new PublicKey(sender),
),
};
}
async populateTransferRemoteTx({
@ -359,34 +387,10 @@ export abstract class SealevelHypTokenAdapter
return tx;
}
async getIgpKeys(): Promise<KeyListParams['igp']> {
async getIgpKeys(): Promise<IgpPaymentKeys | undefined> {
const tokenData = await this.getTokenAccountData();
if (!tokenData.interchain_gas_paymaster) return undefined;
const igpConfig = tokenData.interchain_gas_paymaster;
if (igpConfig.type === SealevelInterchainGasPaymasterType.Igp) {
return {
programId: igpConfig.program_id_pubkey,
};
} else if (
igpConfig.type === SealevelInterchainGasPaymasterType.OverheadIgp
) {
if (!igpConfig.igp_account_pub_key) {
throw new Error('igpAccount field expected for Sealevel Overhead IGP');
}
const overheadAdapter = new SealevelOverheadIgpAdapter(
this.chainName,
this.multiProvider,
{ igp: igpConfig.igp_account_pub_key.toBase58() },
);
const overheadAccountInfo = await overheadAdapter.getAccountInfo();
return {
programId: igpConfig.program_id_pubkey,
igpAccount: igpConfig.igp_account_pub_key,
innerIgpAccount: overheadAccountInfo.inner_pub_key,
};
} else {
throw new Error(`Unsupported IGP type ${igpConfig.type}`);
}
const igpAdapter = this.getIgpAdapter(tokenData);
return igpAdapter?.getPaymentKeys();
}
// Should match https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/rust/sealevel/libraries/hyperlane-sealevel-token/src/processor.rs#L257-L274
@ -457,33 +461,26 @@ export abstract class SealevelHypTokenAdapter
isWritable: true,
},
];
if (igp.igpAccount && igp.innerIgpAccount) {
if (igp.overheadIgpAccount) {
keys = [
...keys,
// 12. [] OPTIONAL - The Overhead IGP account, if the configured IGP is an Overhead IGP
{
pubkey: igp.igpAccount,
pubkey: igp.overheadIgpAccount,
isSigner: false,
isWritable: false,
},
// 13. [writeable] The Overhead's inner IGP account
{
pubkey: igp.innerIgpAccount,
isSigner: false,
isWritable: true,
},
];
} else {
keys = [
...keys,
// 12. [writeable] The IGP account.
{
pubkey: igp.programId,
isSigner: false,
isWritable: true,
},
];
}
keys = [
...keys,
// 13. [writeable] The Overhead's inner IGP account (or the normal IGP account if there's no Overhead IGP).
{
pubkey: igp.igpAccount,
isSigner: false,
isWritable: true,
},
];
}
return keys;
}
@ -565,6 +562,36 @@ export abstract class SealevelHypTokenAdapter
this.logger.debug(`Median priority fee: ${medianFee}`);
return medianFee;
}
protected getIgpAdapter(
tokenData: SealevelHyperlaneTokenData,
): SealevelIgpProgramAdapter | undefined {
const igpConfig = tokenData.interchain_gas_paymaster;
if (!igpConfig || igpConfig.igp_account_pub_key === undefined) {
return undefined;
}
if (igpConfig.type === SealevelInterchainGasPaymasterType.Igp) {
return new SealevelIgpAdapter(this.chainName, this.multiProvider, {
igp: igpConfig.igp_account_pub_key.toBase58(),
programId: igpConfig.program_id_pubkey.toBase58(),
});
} else if (
igpConfig.type === SealevelInterchainGasPaymasterType.OverheadIgp
) {
return new SealevelOverheadIgpAdapter(
this.chainName,
this.multiProvider,
{
overheadIgp: igpConfig.igp_account_pub_key.toBase58(),
programId: igpConfig.program_id_pubkey.toBase58(),
},
);
} else {
throw new Error(`Unsupported IGP type ${igpConfig.type}`);
}
}
}
// Interacts with Hyp Native token programs
@ -766,9 +793,5 @@ interface KeyListParams {
sender: PublicKey;
mailbox: PublicKey;
randomWallet: PublicKey;
igp?: {
programId: PublicKey;
igpAccount?: PublicKey;
innerIgpAccount?: PublicKey;
};
igp?: IgpPaymentKeys;
}

@ -28,3 +28,24 @@ export function getSealevelAccountDataSchema<T>(
],
};
}
// The format of simulation return data from the Sealevel programs.
// A trailing non-zero byte was added due to a bug in Sealevel RPCs that would
// truncate responses with trailing zero bytes.
export class SealevelSimulationReturnData<T> {
return_data!: T;
trailing_byte!: number;
constructor(public readonly fields: any) {
Object.assign(this, fields);
}
}
export function getSealevelSimulationReturnDataSchema<T>(DataClass: T) {
return {
kind: 'struct',
fields: [
['data', DataClass],
['trailing_byte', 'u8'],
],
};
}

@ -118,14 +118,17 @@ export class WarpCore {
}
/**
* Queries the token router for an interchain gas quote (i.e. IGP fee)
* Queries the token router for an interchain gas quote (i.e. IGP fee).
* Sender is only required for Sealevel origins.
*/
async getInterchainTransferFee({
originToken,
destination,
sender,
}: {
originToken: IToken;
destination: ChainNameOrId;
sender?: Address;
}): Promise<TokenAmount> {
this.logger.debug(`Fetching interchain transfer quote to ${destination}`);
const { chainName: originName } = originToken;
@ -149,6 +152,7 @@ export class WarpCore {
const destinationDomainId = this.multiProvider.getDomainId(destination);
const quote = await hypAdapter.quoteTransferRemoteGas(
destinationDomainId,
sender,
);
gasAmount = BigInt(quote.amount);
gasAddressOrDenom = quote.addressOrDenom;
@ -345,6 +349,7 @@ export class WarpCore {
interchainFee = await this.getInterchainTransferFee({
originToken: token,
destination,
sender,
});
}
@ -391,6 +396,7 @@ export class WarpCore {
const interchainQuote = await this.getInterchainTransferFee({
originToken,
destination,
sender,
});
// Next, get the local gas quote
@ -678,6 +684,7 @@ export class WarpCore {
const interchainQuote = await this.getInterchainTransferFee({
originToken,
destination,
sender,
});
// Get balance of the IGP fee token, which may be different from the transfer token
const interchainQuoteTokenBalance = originToken.isFungibleWith(

Loading…
Cancel
Save