diff --git a/package.json b/package.json index f9df30b44..4fb8ee1ec 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@solana-program/system": "^0.7.0", "@solana-program/token-2022": "^0.4.0", "@solana/web3.js": "^1.31.0", + "@solana-program/system": "^0.7.0", "@types/mocha": "^10.0.1", "@uma/sdk": "^0.34.10", "arweave": "^1.14.4", diff --git a/src/arch/svm/SpokeUtils.ts b/src/arch/svm/SpokeUtils.ts index 82d46d014..187339921 100644 --- a/src/arch/svm/SpokeUtils.ts +++ b/src/arch/svm/SpokeUtils.ts @@ -1,12 +1,36 @@ +import { + SvmAddress, + getTokenInformationFromAddress, + BigNumber, + isDefined, + isUnsafeDepositId, + toAddressType, + toBN, + getMessageHash, + keccak256, + chainIsSvm, + chunk, +} from "../../utils"; +import { SvmSpokeClient } from "@across-protocol/contracts"; +import { getStatePda, SvmCpiEventsClient, getFillStatusPda, unwrapEventData, getEventAuthority } from "./"; +import { Deposit, FillStatus, FillWithBlock, RelayData } from "../../interfaces"; +import { + TOKEN_PROGRAM_ADDRESS, + ASSOCIATED_TOKEN_PROGRAM_ADDRESS, + getApproveCheckedInstruction, +} from "@solana-program/token"; +import { + Address, + some, + address, + getProgramDerivedAddress, + fetchEncodedAccounts, + fetchEncodedAccount, + type TransactionSigner, +} from "@solana/kit"; import assert from "assert"; import { Logger } from "winston"; -import { Address, fetchEncodedAccounts, fetchEncodedAccount } from "@solana/kit"; import { fetchState, decodeFillStatusAccount } from "@across-protocol/contracts/dist/src/svm/clients/SvmSpoke"; - -import { SvmCpiEventsClient } from "./eventsClient"; -import { Deposit, FillStatus, FillWithBlock, RelayData } from "../../interfaces"; -import { BigNumber, chainIsSvm, chunk, isUnsafeDepositId } from "../../utils"; -import { getFillStatusPda, unwrapEventData } from "./utils"; import { SVMEventNames, SVMProvider } from "./types"; /** @@ -269,6 +293,168 @@ export async function findFillEvent( return undefined; } +/** + * @param spokePool Address (program ID) of the SvmSpoke. + * @param deposit V3Deopsit instance. + * @param relayer Address of the relayer filling the deposit. + * @param repaymentChainId Optional repaymentChainId (defaults to destinationChainId). + * @returns An Ethers UnsignedTransaction instance. + */ +export async function fillRelayInstruction( + spokePool: SvmAddress, + deposit: Omit, + relayer: TransactionSigner, + recipientTokenAccount: Address, + repaymentChainId = deposit.destinationChainId +) { + const programId = spokePool.toBase58(); + const relayerAddress = SvmAddress.from(relayer.address); + + // @todo we need to convert the deposit's relayData to svm-like since the interface assumes the data originates from an EVM Spoke pool. + // Once we migrate to `Address` types, this can be modified/removed. + const [depositor, recipient, exclusiveRelayer, inputToken, outputToken] = [ + deposit.depositor, + deposit.recipient, + deposit.exclusiveRelayer, + deposit.inputToken, + deposit.outputToken, + ].map((addr) => toAddressType(addr).forceSvmAddress()); + + const _relayDataHash = getRelayDataHash(deposit, deposit.destinationChainId); + const relayDataHash = new Uint8Array(Buffer.from(_relayDataHash.slice(2), "hex")); + + // Create ATA for the relayer and recipient token accounts + const relayerTokenAccount = await getAssociatedTokenAddress(relayerAddress, outputToken); + + const [statePda, fillStatusPda, eventAuthority] = await Promise.all([ + getStatePda(spokePool.toV2Address()), + getFillStatusPda(spokePool.toV2Address(), deposit, deposit.destinationChainId), + getEventAuthority(), + ]); + const depositIdBuffer = Buffer.alloc(32); + const shortenedBuffer = Buffer.from(deposit.depositId.toHexString().slice(2), "hex"); + shortenedBuffer.copy(depositIdBuffer, 32 - shortenedBuffer.length); + + return SvmSpokeClient.getFillRelayInstruction({ + signer: relayer, + state: statePda, + mint: outputToken.toV2Address(), + relayerTokenAccount: relayerTokenAccount, + recipientTokenAccount: recipientTokenAccount, + fillStatus: fillStatusPda, + eventAuthority, + program: address(programId), + relayHash: relayDataHash, + relayData: some({ + depositor: depositor.toV2Address(), + recipient: recipient.toV2Address(), + exclusiveRelayer: exclusiveRelayer.toV2Address(), + inputToken: inputToken.toV2Address(), + outputToken: outputToken.toV2Address(), + inputAmount: deposit.inputAmount.toBigInt(), + outputAmount: deposit.outputAmount.toBigInt(), + originChainId: BigInt(deposit.originChainId), + fillDeadline: deposit.fillDeadline, + exclusivityDeadline: deposit.exclusivityDeadline, + depositId: new Uint8Array(depositIdBuffer), + message: new Uint8Array(Buffer.from(deposit.message.slice(2), "hex")), + }), + repaymentChainId: some(BigInt(repaymentChainId)), + repaymentAddress: some(relayerAddress.toV2Address()), + }); +} + +/** + * @param mint Address of the token corresponding to the account being made. + * @param relayer Address of the relayer filling the deposit. + * @returns An instruction for creating a new token account. + */ +export function createTokenAccountsInstruction( + mint: SvmAddress, + relayer: TransactionSigner +): SvmSpokeClient.CreateTokenAccountsInstruction { + return SvmSpokeClient.getCreateTokenAccountsInstruction({ + signer: relayer, + mint: mint.toV2Address(), + }); +} + +/** + * @param mint Address of the token corresponding to the account being made. + * @param amount Amount of the token to approve. + * @param relayer Address of the relayer filling the deposit. + * @param spokePool Address (program ID) of the SvmSpoke. + * @returns A token approval instruction. + */ +export async function createApproveInstruction( + mint: SvmAddress, + amount: BigNumber, + relayer: SvmAddress, + spokePool: SvmAddress, + mintDecimals?: number +) { + const [relayerTokenAccount, statePda] = await Promise.all([ + getAssociatedTokenAddress(relayer, mint, TOKEN_PROGRAM_ADDRESS), + getStatePda(spokePool.toV2Address()), + ]); + + // If no mint decimals were supplied, then assign it to whatever value we have in TOKEN_SYMBOLS_MAP. + // If this token is not in TOKEN_SYMBOLS_MAP, then throw an error. + mintDecimals ??= getTokenInformationFromAddress(mint.toBase58())?.decimals; + if (!isDefined(mintDecimals)) { + throw new Error(`No mint decimals found for token ${mint.toBase58()}`); + } + + return getApproveCheckedInstruction({ + source: relayerTokenAccount, + mint: mint.toV2Address(), + delegate: statePda, + owner: relayer.toV2Address(), + amount: amount.toBigInt(), + decimals: mintDecimals, + }); +} + +export async function getAssociatedTokenAddress( + owner: SvmAddress, + mint: SvmAddress, + tokenProgramId: Address = TOKEN_PROGRAM_ADDRESS +): Promise> { + const [associatedToken] = await getProgramDerivedAddress({ + programAddress: ASSOCIATED_TOKEN_PROGRAM_ADDRESS, + seeds: [owner.toBuffer(), SvmAddress.from(tokenProgramId).toBuffer(), mint.toBuffer()], + }); + return associatedToken; +} + +export function getRelayDataHash(relayData: RelayData, destinationChainId: number): string { + const toBuffer = (hex: string, byteLength: number, littleEndian: boolean = true) => { + const buffer = Buffer.from(hex.slice(2), "hex"); + if (buffer.length < byteLength) { + const zeroPad = Buffer.alloc(byteLength); + buffer.copy(zeroPad, byteLength - buffer.length); + return littleEndian ? zeroPad.reverse() : zeroPad; + } + return littleEndian ? buffer.slice(0, byteLength).reverse() : buffer.slice(0, byteLength); + }; + const contentToHash = Buffer.concat([ + toBuffer(relayData.depositor, 32, false), + toBuffer(relayData.recipient, 32, false), + toBuffer(relayData.exclusiveRelayer, 32, false), + toBuffer(relayData.inputToken, 32, false), + toBuffer(relayData.outputToken, 32, false), + toBuffer(relayData.inputAmount.toHexString(), 8), + toBuffer(relayData.outputAmount.toHexString(), 8), + toBuffer(toBN(relayData.originChainId).toHexString(), 8), + toBuffer(relayData.depositId.toHexString(), 32, false), + toBuffer(toBN(relayData.fillDeadline).toHexString(), 4), + toBuffer(toBN(relayData.exclusivityDeadline).toHexString(), 4), + toBuffer(getMessageHash(relayData.message), 32, false), + toBuffer(toBN(destinationChainId).toHexString(), 8), + ]); + return keccak256(contentToHash); +} + async function resolveFillStatusFromPdaEvents( fillStatusPda: Address, toSlot: bigint, diff --git a/src/arch/svm/utils.ts b/src/arch/svm/utils.ts index 39a619655..7be59751e 100644 --- a/src/arch/svm/utils.ts +++ b/src/arch/svm/utils.ts @@ -1,11 +1,32 @@ import { BN, BorshEventCoder, Idl } from "@coral-xyz/anchor"; -import { address, Address, getAddressEncoder, getProgramDerivedAddress, getU64Encoder, isAddress } from "@solana/kit"; +import { + address, + getProgramDerivedAddress, + getU64Encoder, + getAddressEncoder, + Address, + isAddress, + type TransactionSigner, +} from "@solana/kit"; import { BigNumber, getRelayDataHash, isUint8Array, SvmAddress } from "../../utils"; - import { SvmSpokeClient } from "@across-protocol/contracts"; import { FillType, RelayData } from "../../interfaces"; import { EventName, SVMEventNames, SVMProvider } from "./types"; +/** + * Basic void TransactionSigner type + */ +export const SolanaVoidSigner: (simulationAddress: string) => TransactionSigner = ( + simulationAddress: string +) => { + return { + address: address(simulationAddress), + signAndSendTransactions: async () => { + return await Promise.resolve([]); + }, + }; +}; + /** * Helper to determine if the current RPC network is devnet. */ diff --git a/src/gasPriceOracle/adapters/arbitrum.ts b/src/gasPriceOracle/adapters/arbitrum.ts index 4a9eac8ce..12ef6bc01 100644 --- a/src/gasPriceOracle/adapters/arbitrum.ts +++ b/src/gasPriceOracle/adapters/arbitrum.ts @@ -1,6 +1,6 @@ import { providers } from "ethers"; import { bnOne } from "../../utils"; -import { GasPriceEstimate } from "../types"; +import { EvmGasPriceEstimate } from "../types"; import * as ethereum from "./ethereum"; import { GasPriceEstimateOptions } from "../oracle"; @@ -16,7 +16,10 @@ import { GasPriceEstimateOptions } from "../oracle"; * function. * @returns GasPriceEstimate */ -export async function eip1559(provider: providers.Provider, opts: GasPriceEstimateOptions): Promise { +export async function eip1559( + provider: providers.Provider, + opts: GasPriceEstimateOptions +): Promise { const { maxFeePerGas: _maxFeePerGas, maxPriorityFeePerGas } = await ethereum.eip1559(provider, opts); // eip1559() sets maxFeePerGas = lastBaseFeePerGas + maxPriorityFeePerGas, so back out priority fee. diff --git a/src/gasPriceOracle/adapters/ethereum.ts b/src/gasPriceOracle/adapters/ethereum.ts index 18f01cfe1..70d4d40cd 100644 --- a/src/gasPriceOracle/adapters/ethereum.ts +++ b/src/gasPriceOracle/adapters/ethereum.ts @@ -1,7 +1,7 @@ import assert from "assert"; import { providers } from "ethers"; import { BigNumber, bnZero, fixedPointAdjustment, getNetworkName, parseUnits } from "../../utils"; -import { GasPriceEstimate } from "../types"; +import { EvmGasPriceEstimate } from "../types"; import { gasPriceError } from "../util"; import { GasPriceEstimateOptions } from "../oracle"; @@ -13,7 +13,7 @@ import { GasPriceEstimateOptions } from "../oracle"; * @param priorityFeeMultiplier Amount to multiply priority fee or unused for legacy gas pricing. * @returns Promise of gas price estimate object. */ -export function eip1559(provider: providers.Provider, opts: GasPriceEstimateOptions): Promise { +export function eip1559(provider: providers.Provider, opts: GasPriceEstimateOptions): Promise { return eip1559Raw(provider, opts.chainId, opts.baseFeeMultiplier, opts.priorityFeeMultiplier); } @@ -29,7 +29,7 @@ export async function eip1559Raw( chainId: number, baseFeeMultiplier: BigNumber, priorityFeeMultiplier: BigNumber -): Promise { +): Promise { const [{ baseFeePerGas }, _maxPriorityFeePerGas] = await Promise.all([ provider.getBlock("pending"), (provider as providers.JsonRpcProvider).send("eth_maxPriorityFeePerGas", []), @@ -54,7 +54,10 @@ export async function eip1559Raw( * @dev Its recommended to use the eip1559Raw method over this one where possible as it will be more accurate. * @returns GasPriceEstimate */ -export async function legacy(provider: providers.Provider, opts: GasPriceEstimateOptions): Promise { +export async function legacy( + provider: providers.Provider, + opts: GasPriceEstimateOptions +): Promise { const { chainId, baseFeeMultiplier } = opts; const gasPrice = await provider.getGasPrice(); diff --git a/src/gasPriceOracle/adapters/linea-viem.ts b/src/gasPriceOracle/adapters/linea-viem.ts index 3cce4b69e..f85c2ee6b 100644 --- a/src/gasPriceOracle/adapters/linea-viem.ts +++ b/src/gasPriceOracle/adapters/linea-viem.ts @@ -1,3 +1,4 @@ +import { PopulatedTransaction } from "ethers"; import { Address, Hex, PublicClient } from "viem"; import { estimateGas } from "viem/linea"; import { DEFAULT_SIMULATED_RELAYER_ADDRESS as account } from "../../constants"; @@ -26,7 +27,8 @@ export async function eip1559( provider: PublicClient, opts: GasPriceEstimateOptions ): Promise { - const { unsignedTx, priorityFeeMultiplier } = opts; + const { unsignedTx: _unsignedTx, priorityFeeMultiplier } = opts; + const unsignedTx = _unsignedTx as PopulatedTransaction; // Cast the opaque unsignedTx type to an ethers PopulatedTransaction. const { baseFeePerGas, priorityFeePerGas: _priorityFeePerGas } = await estimateGas(provider, { account: (unsignedTx?.from as Address) ?? account, to: (unsignedTx?.to as Address) ?? account, diff --git a/src/gasPriceOracle/adapters/polygon.ts b/src/gasPriceOracle/adapters/polygon.ts index 1dcabc346..e86ddcaa5 100644 --- a/src/gasPriceOracle/adapters/polygon.ts +++ b/src/gasPriceOracle/adapters/polygon.ts @@ -2,7 +2,7 @@ import { providers } from "ethers"; import { BaseHTTPAdapter, BaseHTTPAdapterArgs } from "../../priceClient/adapters/baseAdapter"; import { BigNumber, bnZero, fixedPointAdjustment, isDefined, parseUnits } from "../../utils"; import { CHAIN_IDs } from "../../constants"; -import { GasPriceEstimate } from "../types"; +import { EvmGasPriceEstimate } from "../types"; import { gasPriceError } from "../util"; import { eip1559 } from "./ethereum"; import { GasPriceEstimateOptions } from "../oracle"; @@ -38,7 +38,7 @@ export class PolygonGasStation extends BaseHTTPAdapter { this.chainId = chainId; } - async getFeeData(strategy: "safeLow" | "standard" | "fast" = "fast"): Promise { + async getFeeData(strategy: "safeLow" | "standard" | "fast" = "fast"): Promise { const gas = await this.query("v2", {}); const gasPrice = (gas as GasStationV2Response)?.[strategy]; @@ -69,7 +69,7 @@ export class PolygonGasStation extends BaseHTTPAdapter { } class MockRevertingPolygonGasStation extends PolygonGasStation { - getFeeData(): Promise { + getFeeData(): Promise { throw new Error(); } } @@ -78,7 +78,7 @@ export const MockPolygonGasStationBaseFee = () => parseUnits("12", 9); export const MockPolygonGasStationPriorityFee = () => parseUnits("1", 9); class MockPolygonGasStation extends PolygonGasStation { - getFeeData(): Promise { + getFeeData(): Promise { return Promise.resolve({ maxPriorityFeePerGas: MockPolygonGasStationPriorityFee(), maxFeePerGas: MockPolygonGasStationBaseFee().add(MockPolygonGasStationPriorityFee()), @@ -90,12 +90,12 @@ class MockPolygonGasStation extends PolygonGasStation { * @notice Returns the gas price suggested by the Polygon GasStation API or reconstructs it using * the eip1559() method as a fallback. * @param provider Ethers Provider. - * @returns GasPriceEstimate + * @returns EvmGasPriceEstimate */ export async function gasStation( provider: providers.Provider, opts: GasPriceEstimateOptions -): Promise { +): Promise { const { chainId, baseFeeMultiplier, priorityFeeMultiplier } = opts; let gasStation: PolygonGasStation; if (process.env.TEST_POLYGON_GAS_STATION === "true") { diff --git a/src/gasPriceOracle/adapters/solana.ts b/src/gasPriceOracle/adapters/solana.ts new file mode 100644 index 000000000..183a8134d --- /dev/null +++ b/src/gasPriceOracle/adapters/solana.ts @@ -0,0 +1,45 @@ +import { SVMProvider } from "../../arch/svm"; +import { toBN, dedupArray, parseUnits } from "../../utils"; +import { GasPriceEstimate } from "../types"; +import { GasPriceEstimateOptions } from "../oracle"; +import { CompilableTransactionMessage, TransactionMessageBytesBase64, compileTransaction } from "@solana/kit"; + +/** + * @notice Returns result of getFeeForMessage and getRecentPrioritizationFees RPC calls. + * @returns GasPriceEstimate + */ +export async function messageFee(provider: SVMProvider, opts: GasPriceEstimateOptions): Promise { + const { unsignedTx: _unsignedTx } = opts; + + // Cast the opaque unsignedTx type to a solana-kit CompilableTransactionMessage. + const unsignedTx = _unsignedTx as CompilableTransactionMessage; + const compiledTransaction = compileTransaction(unsignedTx); + + // Get this base fee. This should result in LAMPORTS_PER_SIGNATURE * nSignatures. + const encodedTransactionMessage = Buffer.from(compiledTransaction.messageBytes).toString( + "base64" + ) as TransactionMessageBytesBase64; + const baseFeeResponse = await provider.getFeeForMessage(encodedTransactionMessage).send(); + + // Get the priority fee by calling `getRecentPrioritzationFees` on all the addresses in the transaction's instruction array. + const instructionAddresses = dedupArray(unsignedTx.instructions.map((instruction) => instruction.programAddress)); + const recentPriorityFees = await provider.getRecentPrioritizationFees(instructionAddresses).send(); + + // Take the most recent 25 slots and find the average of the nonzero priority fees. + const nonzeroPrioritizationFees = recentPriorityFees + .slice(125) + .map((value) => value.prioritizationFee) + .filter((fee) => fee > 0); + const totalPrioritizationFees = nonzeroPrioritizationFees.reduce((acc, fee) => acc + fee, BigInt(0)); + + // Optionally impose a minimum priority fee, denoted in microLamports/computeUnit. + const flooredPriorityFeePerGas = parseUnits(process.env[`MIN_PRIORITY_FEE_PER_GAS_${opts.chainId}`] || "0", 6); + let microLamportsPerComputeUnit = toBN(totalPrioritizationFees / BigInt(nonzeroPrioritizationFees.length)); + if (microLamportsPerComputeUnit.lt(flooredPriorityFeePerGas)) { + microLamportsPerComputeUnit = flooredPriorityFeePerGas; + } + return { + baseFee: toBN(baseFeeResponse!.value!), + microLamportsPerComputeUnit, + }; +} diff --git a/src/gasPriceOracle/index.ts b/src/gasPriceOracle/index.ts index 2143d881b..71f9158f7 100644 --- a/src/gasPriceOracle/index.ts +++ b/src/gasPriceOracle/index.ts @@ -1,2 +1,2 @@ export { getGasPriceEstimate } from "./oracle"; -export { GasPriceEstimate } from "./types"; +export { GasPriceEstimate, EvmGasPriceEstimate, SvmGasPriceEstimate } from "./types"; diff --git a/src/gasPriceOracle/oracle.ts b/src/gasPriceOracle/oracle.ts index 4bd2ff954..d5a6b6c7d 100644 --- a/src/gasPriceOracle/oracle.ts +++ b/src/gasPriceOracle/oracle.ts @@ -1,14 +1,16 @@ import assert from "assert"; import { Transport } from "viem"; -import { PopulatedTransaction, providers } from "ethers"; +import { providers } from "ethers"; import { CHAIN_IDs } from "../constants"; import { BigNumber, chainIsOPStack, fixedPointAdjustment, toBNWei } from "../utils"; +import { SVMProvider as SolanaProvider } from "../arch/svm"; import { GasPriceEstimate } from "./types"; import { getPublicClient } from "./util"; import * as arbitrum from "./adapters/arbitrum"; import * as ethereum from "./adapters/ethereum"; import * as polygon from "./adapters/polygon"; import * as lineaViem from "./adapters/linea-viem"; +import * as solana from "./adapters/solana"; export interface GasPriceEstimateOptions { // baseFeeMultiplier Multiplier applied to base fee for EIP1559 gas prices (or total fee for legacy). @@ -19,8 +21,8 @@ export interface GasPriceEstimateOptions { legacyFallback: boolean; // chainId The chain ID to query for gas prices. If omitted can be inferred by provider. chainId: number; - // unsignedTx The unsigned transaction used for simulation by Linea's Viem provider to produce the priority gas fee. - unsignedTx?: PopulatedTransaction; + // unsignedTx The unsigned transaction used for simulation by Linea's Viem provider to produce the priority gas fee, or alternatively, by Solana's provider to determine the base/priority fee. + unsignedTx?: unknown; // transport Viem Transport object to use for querying gas fees used for testing. transport?: Transport; } @@ -39,7 +41,7 @@ const VIEM_CHAINS = [CHAIN_IDs.LINEA]; * @returns An object of type GasPriceEstimate. */ export async function getGasPriceEstimate( - provider: providers.Provider, + provider: providers.Provider | SolanaProvider, opts: Partial = {} ): Promise { const baseFeeMultiplier = opts.baseFeeMultiplier ?? toBNWei("1"); @@ -53,6 +55,20 @@ export async function getGasPriceEstimate( `Require 1.0 < priority fee multiplier (${priorityFeeMultiplier}) <= 5.0 for a total gas multiplier within [+1.0, +5.0]` ); + // Exit here if we need to estimate on Solana. + if (!(provider instanceof providers.Provider)) { + const optsWithDefaults: GasPriceEstimateOptions = { + ...GAS_PRICE_ESTIMATE_DEFAULTS, + baseFeeMultiplier, + priorityFeeMultiplier, + ...opts, + chainId: opts.chainId ?? CHAIN_IDs.SOLANA, + }; + return solana.messageFee(provider, optsWithDefaults); + } + + // Cast the provider to an ethers provider, which should be given to the oracle when querying an EVM network. + provider = provider as providers.Provider; const chainId = opts.chainId ?? (await provider.getNetwork()).chainId; const optsWithDefaults: GasPriceEstimateOptions = { ...GAS_PRICE_ESTIMATE_DEFAULTS, diff --git a/src/gasPriceOracle/types.ts b/src/gasPriceOracle/types.ts index f2ac56369..955e754f1 100644 --- a/src/gasPriceOracle/types.ts +++ b/src/gasPriceOracle/types.ts @@ -2,12 +2,18 @@ import { type Chain, type Transport, PublicClient, FeeValuesEIP1559 } from "viem import { BigNumber } from "../utils"; export type InternalGasPriceEstimate = FeeValuesEIP1559; +export type GasPriceEstimate = EvmGasPriceEstimate | SvmGasPriceEstimate; -export type GasPriceEstimate = { +export type EvmGasPriceEstimate = { maxFeePerGas: BigNumber; maxPriorityFeePerGas: BigNumber; }; +export type SvmGasPriceEstimate = { + baseFee: BigNumber; + microLamportsPerComputeUnit: BigNumber; +}; + export interface GasPriceFeed { (provider: PublicClient, chainId: number): Promise; } diff --git a/src/relayFeeCalculator/chain-queries/baseQuery.ts b/src/relayFeeCalculator/chain-queries/baseQuery.ts index b3342da78..9b489ebfa 100644 --- a/src/relayFeeCalculator/chain-queries/baseQuery.ts +++ b/src/relayFeeCalculator/chain-queries/baseQuery.ts @@ -19,10 +19,10 @@ import { import assert from "assert"; import { Logger, QueryInterface } from "../relayFeeCalculator"; import { Transport } from "viem"; -import { getGasPriceEstimate } from "../../gasPriceOracle/oracle"; +import { getGasPriceEstimate, EvmGasPriceEstimate } from "../../gasPriceOracle"; type Provider = providers.Provider; type OptimismProvider = L2Provider; -type SymbolMappingType = Record< +export type SymbolMappingType = Record< string, { addresses: Record; @@ -213,7 +213,9 @@ export class QueryBase implements QueryInterface { ? Promise.resolve({ maxFeePerGas: _gasPrice }) : getGasPriceEstimate(provider, { chainId, baseFeeMultiplier, priorityFeeMultiplier, transport, unsignedTx }), ] as const; - const [nativeGasCost, { maxFeePerGas: gasPrice }] = await Promise.all(queries); + const [nativeGasCost, _gasPriceEstimate] = await Promise.all(queries); + // It should be safe to cast to an EvmGasPriceEstimate here since QueryBase is only used for EVM chains. + const gasPrice = (_gasPriceEstimate as EvmGasPriceEstimate).maxFeePerGas; assert(nativeGasCost.gt(bnZero), "Gas cost should not be 0"); let tokenGasCost: BigNumber; diff --git a/src/relayFeeCalculator/chain-queries/factory.ts b/src/relayFeeCalculator/chain-queries/factory.ts index 941f947a8..fa664f4e9 100644 --- a/src/relayFeeCalculator/chain-queries/factory.ts +++ b/src/relayFeeCalculator/chain-queries/factory.ts @@ -4,10 +4,12 @@ import { getDeployedAddress } from "@across-protocol/contracts"; import { asL2Provider } from "@eth-optimism/sdk"; import { providers } from "ethers"; import { DEFAULT_SIMULATED_RELAYER_ADDRESS, CUSTOM_GAS_TOKENS } from "../../constants"; -import { chainIsOPStack, isDefined } from "../../utils"; +import { chainIsOPStack, isDefined, chainIsSvm, SvmAddress } from "../../utils"; import { QueryBase } from "./baseQuery"; +import { SVMProvider as svmProvider } from "../../arch/svm"; import { DEFAULT_LOGGER, Logger } from "../relayFeeCalculator"; import { CustomGasTokenQueries } from "./customGasToken"; +import { SvmQuery } from "./svmQuery"; /** * Some chains have a fixed gas price that is applied to the gas estimates. We should override @@ -20,21 +22,21 @@ const fixedGasPrice = { export class QueryBase__factory { static create( chainId: number, - provider: providers.Provider, + provider: providers.Provider | svmProvider, symbolMapping = TOKEN_SYMBOLS_MAP, spokePoolAddress = getDeployedAddress("SpokePool", chainId), simulatedRelayerAddress = DEFAULT_SIMULATED_RELAYER_ADDRESS, coingeckoProApiKey?: string, logger: Logger = DEFAULT_LOGGER, coingeckoBaseCurrency = "eth" - ): QueryBase { + ): QueryBase | SvmQuery { assert(isDefined(spokePoolAddress)); const customGasTokenSymbol = CUSTOM_GAS_TOKENS[chainId]; if (customGasTokenSymbol) { return new CustomGasTokenQueries({ queryBaseArgs: [ - provider, + provider as providers.Provider, symbolMapping, spokePoolAddress, simulatedRelayerAddress, @@ -46,9 +48,23 @@ export class QueryBase__factory { customGasTokenSymbol, }); } + if (chainIsSvm(chainId)) { + return new SvmQuery( + provider as svmProvider, + symbolMapping, + SvmAddress.from(spokePoolAddress), + SvmAddress.from(simulatedRelayerAddress), + logger, + coingeckoProApiKey, + fixedGasPrice[chainId], + coingeckoBaseCurrency + ); + } // For OPStack chains, we need to wrap the provider in an L2Provider - provider = chainIsOPStack(chainId) ? asL2Provider(provider) : provider; + provider = chainIsOPStack(chainId) + ? asL2Provider(provider as providers.Provider) + : (provider as providers.Provider); return new QueryBase( provider, diff --git a/src/relayFeeCalculator/chain-queries/index.ts b/src/relayFeeCalculator/chain-queries/index.ts index 49061c0fb..633efdb43 100644 --- a/src/relayFeeCalculator/chain-queries/index.ts +++ b/src/relayFeeCalculator/chain-queries/index.ts @@ -1,3 +1,4 @@ export * from "./baseQuery"; export * from "./factory"; export * from "./customGasToken"; +export * from "./svmQuery"; diff --git a/src/relayFeeCalculator/chain-queries/svmQuery.ts b/src/relayFeeCalculator/chain-queries/svmQuery.ts new file mode 100644 index 000000000..d0c69fc49 --- /dev/null +++ b/src/relayFeeCalculator/chain-queries/svmQuery.ts @@ -0,0 +1,193 @@ +import { pipe } from "@solana/functional"; +import { Coingecko } from "../../coingecko"; +import { SymbolMappingType } from "./"; +import { CHAIN_IDs, DEFAULT_SIMULATED_RELAYER_ADDRESS } from "../../constants"; +import { Deposit } from "../../interfaces"; +import { getGasPriceEstimate, SvmGasPriceEstimate } from "../../gasPriceOracle"; +import { + BigNumberish, + TransactionCostEstimate, + BigNumber, + SvmAddress, + toBN, + isDefined, + toAddressType, +} from "../../utils"; +import { Logger, QueryInterface } from "../relayFeeCalculator"; +import { + fillRelayInstruction, + createApproveInstruction, + createTokenAccountsInstruction, + SVMProvider, + SolanaVoidSigner, + getAssociatedTokenAddress, +} from "../../arch/svm"; +import { + createTransactionMessage, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + appendTransactionMessageInstructions, + getComputeUnitEstimateForTransactionMessageFactory, + fetchEncodedAccount, + IInstruction, +} from "@solana/kit"; +import { TOKEN_PROGRAM_ADDRESS, getMintSize, getInitializeMintInstruction, fetchMint } from "@solana-program/token"; +import { getCreateAccountInstruction } from "@solana-program/system"; + +/** + * A special QueryBase implementation for SVM used for querying gas costs, token prices, and decimals of various tokens + * on Solana. + */ +export class SvmQuery implements QueryInterface { + protected computeUnitEstimator; + + /** + * Instantiates a SvmQuery instance + * @param provider A valid solana/kit rpc client. + * @param symbolMapping A mapping to valid ERC20 tokens and their respective characteristics + * @param spokePoolAddress The valid address of the Spoke Pool deployment + * @param simulatedRelayerAddress The address that these queries will reference as the sender. Note: This address must be approved for USDC + * @param logger A logging utility to report logs + * @param coingeckoProApiKey An optional CoinGecko API key that links to a PRO account + * @param fixedGasPrice Overrides the gas price with a fixed value. Note: primarily used for the Boba blockchain + * @param coingeckoBaseCurrency The basis currency that CoinGecko will use to resolve pricing + */ + constructor( + readonly provider: SVMProvider, + readonly symbolMapping: SymbolMappingType, + readonly spokePoolAddress: SvmAddress, + readonly simulatedRelayerAddress: SvmAddress, + readonly logger: Logger, + readonly coingeckoProApiKey?: string, + readonly fixedGasPrice?: BigNumberish, + readonly coingeckoBaseCurrency: string = "eth" + ) { + this.computeUnitEstimator = getComputeUnitEstimateForTransactionMessageFactory({ + rpc: provider, + }); + } + + /** + * Retrieves the current gas costs of performing a fillRelay contract at the referenced SpokePool. + * @param deposit V3 deposit instance. + * @param relayerAddress Relayer address to simulate with. + * @param options + * @param options.gasPrice Optional gas price to use for the simulation. + * @param options.gasUnits Optional gas units to use for the simulation. + * @param options.transport Optional transport object for custom gas price retrieval. + * @returns The gas estimate for this function call (multiplied with the optional buffer). + */ + async getGasCosts( + deposit: Omit, + _relayer = DEFAULT_SIMULATED_RELAYER_ADDRESS, + options: Partial<{ + gasPrice: BigNumberish; + gasUnits: BigNumberish; + baseFeeMultiplier: BigNumber; + priorityFeeMultiplier: BigNumber; + }> = {} + ): Promise { + // If the user did not have a token account created on destination, then we need to include this as a gas cost. + const mint = toAddressType(deposit.outputToken).forceSvmAddress(); + const owner = toAddressType(deposit.recipient).forceSvmAddress(); + const associatedToken = await getAssociatedTokenAddress(owner, mint); + const simulatedSigner = SolanaVoidSigner(this.simulatedRelayerAddress.toBase58()); + + // If the recipient has an associated token account on destination, then skip generating the instruction for creating a new token account. + let recipientCreateTokenAccountInstructions: IInstruction[] | undefined = undefined; + const [associatedTokenAccountExists, mintInfo] = await Promise.all([ + (await fetchEncodedAccount(this.provider, associatedToken)).exists, + fetchMint(this.provider, mint.toV2Address()), + ]); + if (!associatedTokenAccountExists) { + const space = BigInt(getMintSize()); + const rent = await this.provider.getMinimumBalanceForRentExemption(space).send(); + const createAccountIx = getCreateAccountInstruction({ + payer: simulatedSigner, + newAccount: SolanaVoidSigner(mint.toBase58()), + lamports: rent, + space, + programAddress: TOKEN_PROGRAM_ADDRESS, + }); + + const initializeMintIx = getInitializeMintInstruction({ + mint: mint.toV2Address(), + decimals: mintInfo.data.decimals, + mintAuthority: owner.toV2Address(), + }); + recipientCreateTokenAccountInstructions = [createAccountIx, initializeMintIx]; + } + + const [createTokenAccountsIx, approveIx, fillIx] = await Promise.all([ + createTokenAccountsInstruction(mint, simulatedSigner), + createApproveInstruction( + mint, + deposit.outputAmount, + this.simulatedRelayerAddress, + this.spokePoolAddress, + mintInfo.data.decimals + ), + fillRelayInstruction(this.spokePoolAddress, deposit, simulatedSigner, associatedToken), + ]); + + // Get the most recent confirmed blockhash. + const recentBlockhash = await this.provider.getLatestBlockhash().send(); + const fillRelayTx = pipe( + createTransactionMessage({ version: 0 }), + (tx) => setTransactionMessageFeePayer(this.simulatedRelayerAddress.toV2Address(), tx), + (tx) => setTransactionMessageLifetimeUsingBlockhash(recentBlockhash.value, tx), + (tx) => + isDefined(recipientCreateTokenAccountInstructions) + ? appendTransactionMessageInstructions(recipientCreateTokenAccountInstructions, tx) + : tx, + (tx) => appendTransactionMessageInstructions([createTokenAccountsIx, approveIx, fillIx], tx) + ); + + const [computeUnitsConsumed, _gasPriceEstimate] = await Promise.all([ + toBN(await this.computeUnitEstimator(fillRelayTx)), + getGasPriceEstimate(this.provider, { + unsignedTx: fillRelayTx, + baseFeeMultiplier: options.baseFeeMultiplier, + priorityFeeMultiplier: options.priorityFeeMultiplier, + }), + ]); + + // We can cast the gas price estimate to an SvmGasPriceEstimate here since the oracle should always + // query the Solana adapter. + const gasPriceEstimate = _gasPriceEstimate as SvmGasPriceEstimate; + const gasPrice = gasPriceEstimate.baseFee.add( + gasPriceEstimate.microLamportsPerComputeUnit.mul(computeUnitsConsumed).div(toBN(1_000_000)) // 1_000_000 microLamports/lamport. + ); + + return { + nativeGasCost: computeUnitsConsumed, + tokenGasCost: gasPrice, + gasPrice, + }; + } + + /** + * Retrieves the current price of a token + * @param tokenSymbol A valid [CoinGecko-ID](https://api.coingecko.com/api/v3/coins/list) + * @returns The resolved token price within the specified coingeckoBaseCurrency + */ + async getTokenPrice(tokenSymbol: string): Promise { + if (!this.symbolMapping[tokenSymbol]) throw new Error(`${tokenSymbol} does not exist in mapping`); + const coingeckoInstance = Coingecko.get(this.logger, this.coingeckoProApiKey); + const [, price] = await coingeckoInstance.getCurrentPriceByContract( + this.symbolMapping[tokenSymbol].addresses[CHAIN_IDs.MAINNET], + this.coingeckoBaseCurrency + ); + return price; + } + + /** + * Resolves the number of decimal places a token can have + * @param tokenSymbol A valid Across-Enabled Token ID + * @returns The number of decimals of precision for the corresponding tokenSymbol + */ + getTokenDecimals(tokenSymbol: string): number { + if (!this.symbolMapping[tokenSymbol]) throw new Error(`${tokenSymbol} does not exist in mapping`); + return this.symbolMapping[tokenSymbol].decimals; + } +} diff --git a/src/utils/AddressUtils.ts b/src/utils/AddressUtils.ts index 4c0aa7f67..66e213f77 100644 --- a/src/utils/AddressUtils.ts +++ b/src/utils/AddressUtils.ts @@ -1,5 +1,6 @@ import { providers, utils } from "ethers"; import bs58 from "bs58"; +import { Address as V2Address } from "@solana/kit"; import { BigNumber, chainIsEvm } from "./"; /** @@ -77,9 +78,9 @@ export function isValidEvmAddress(address: string): boolean { * @returns a child `Address` type most fitting for the chain ID. * @todo: Change this to `toAddress` once we remove the other `toAddress` function. */ -export function toAddressType(address: string, chainId: number): Address | EvmAddress | SvmAddress { +export function toAddressType(address: string): Address | EvmAddress | SvmAddress { try { - if (chainIsEvm(chainId)) { + if (utils.isHexString(address)) { return EvmAddress.from(address); } return SvmAddress.from(address); @@ -146,6 +147,11 @@ export class Address { return this.toBytes32(); } + // Converts the address to a Buffer type. + toBuffer(): Buffer { + return Buffer.from(this.rawAddress); + } + // Implements `Hexable` for `Address`. Needed for encoding purposes. This class is treated by default as a bytes32 primitive type, but can change for subclasses. toHexString(): string { return this.toBytes32(); @@ -185,6 +191,16 @@ export class Address { return utils.stripZeros(this.rawAddress).length === 0; } + // Forces `rawAddress` to become an SvmAddress type. This will only throw if `rawAddress.length > 32`. + forceSvmAddress(): SvmAddress { + return SvmAddress.from(this.toBase58()); + } + + // Forces `rawAddress` to become an EvmAddress type. This will throw if `rawAddress.length > 20`. + forceEvmAddress(): EvmAddress { + return EvmAddress.from(this.toEvmAddress()); + } + // Checks if the other address is equivalent to this address. eq(other: Address): boolean { return this.toString() === other.toString(); @@ -253,6 +269,11 @@ export class SvmAddress extends Address { return this.toBase58(); } + // Small utility to convert an SvmAddress to a Solana Kit branded type. + toV2Address(): V2Address { + return this.toBase58() as V2Address; + } + // Constructs a new SvmAddress type. static from(address: string, encoding: "base58" | "base16" = "base58"): SvmAddress { if (encoding === "base58") { diff --git a/src/utils/SpokeUtils.ts b/src/utils/SpokeUtils.ts index c8e0c320c..3c500552a 100644 --- a/src/utils/SpokeUtils.ts +++ b/src/utils/SpokeUtils.ts @@ -3,9 +3,10 @@ import { MAX_SAFE_DEPOSIT_ID, ZERO_ADDRESS, ZERO_BYTES } from "../constants"; import { Deposit, RelayData } from "../interfaces"; import { toBytes32 } from "./AddressUtils"; import { keccak256 } from "./common"; -import { BigNumber, toBN } from "./BigNumberUtils"; +import { BigNumber } from "./BigNumberUtils"; import { isMessageEmpty } from "./DepositUtils"; import { chainIsSvm } from "./NetworkUtils"; +import { svm } from "../arch"; /** * Produce the RelayData for a Deposit. @@ -45,7 +46,7 @@ export function getRelayDataHash(relayData: RelayData, destinationChainId: numbe exclusiveRelayer: toBytes32(relayData.exclusiveRelayer), }; if (chainIsSvm(destinationChainId)) { - return _getRelayDataHashSvm(_relayData, destinationChainId); + return svm.getRelayDataHash(_relayData, destinationChainId); } return keccak256( ethersUtils.defaultAbiCoder.encode( @@ -75,46 +76,6 @@ export function getRelayHashFromEvent(e: RelayData & { destinationChainId: numbe return getRelayDataHash(e, e.destinationChainId); } -function _getRelayDataHashSvm(relayData: RelayData, destinationChainId: number): string { - const uint8ArrayFromHexString = (hex: string, littleEndian: boolean = false): Uint8Array => { - const buffer = Buffer.from(hex.slice(2), "hex"); - if (buffer.length < 32) { - const zeroPad = new Uint8Array(32); - buffer.copy(zeroPad, 32 - buffer.length); - return littleEndian ? zeroPad.reverse() : zeroPad; - } - const result = new Uint8Array(buffer.slice(0, 32)); - return littleEndian ? result.reverse() : result; - }; - const uint8ArrayFromInt = (num: BigNumber, byteLength: number, littleEndian: boolean = true): Uint8Array => { - const buffer = Buffer.from(num.toHexString().slice(2), "hex"); - if (buffer.length < byteLength) { - const zeroPad = new Uint8Array(byteLength); - buffer.copy(zeroPad, byteLength - buffer.length); - return littleEndian ? zeroPad.reverse() : zeroPad; - } - const result = new Uint8Array(buffer.slice(0, byteLength)); - return littleEndian ? result.reverse() : result; - }; - const contentToHash = Buffer.concat([ - uint8ArrayFromHexString(relayData.depositor), - uint8ArrayFromHexString(relayData.recipient), - uint8ArrayFromHexString(relayData.exclusiveRelayer), - uint8ArrayFromHexString(relayData.inputToken), - uint8ArrayFromHexString(relayData.outputToken), - uint8ArrayFromInt(relayData.inputAmount, 8), - uint8ArrayFromInt(relayData.outputAmount, 8), - uint8ArrayFromInt(toBN(relayData.originChainId), 8), - uint8ArrayFromInt(relayData.depositId, 32, false), - uint8ArrayFromInt(toBN(relayData.fillDeadline), 4), - uint8ArrayFromInt(toBN(relayData.exclusivityDeadline), 4), - uint8ArrayFromHexString(getMessageHash(relayData.message)), - uint8ArrayFromInt(toBN(destinationChainId), 8), - ]); - const returnHash = keccak256(contentToHash); - return returnHash; -} - export function isUnsafeDepositId(depositId: BigNumber): boolean { // SpokePool.unsafeDepositV3() produces a uint256 depositId by hashing the msg.sender, depositor and input // uint256 depositNonce. There is a possibility that this resultant uint256 is less than the maxSafeDepositId (i.e.