diff --git a/package.json b/package.json index 84e2077dc..f6cf949ce 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "@pinata/sdk": "^2.1.0", "@solana/kit": "^2.1.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 2b6923185..4cc2fefb2 100644 --- a/src/arch/svm/SpokeUtils.ts +++ b/src/arch/svm/SpokeUtils.ts @@ -1,10 +1,34 @@ -import { Rpc, SolanaRpcApi, Address } from "@solana/kit"; - +import { + SvmAddress, + getTokenInformationFromAddress, + BigNumber, + isDefined, + isUnsafeDepositId, + toAddressType, + toBN, + getMessageHash, + keccak256, +} from "../../utils"; +import { SvmSpokeClient } from "@across-protocol/contracts"; +import { getStatePda } from "./"; import { Deposit, FillStatus, FillWithBlock, RelayData } from "../../interfaces"; -import { BigNumber, isUnsafeDepositId } from "../../utils"; +import { + TOKEN_PROGRAM_ADDRESS, + ASSOCIATED_TOKEN_PROGRAM_ADDRESS, + getApproveCheckedInstruction, +} from "@solana-program/token"; +import { + Address, + Rpc, + SolanaRpcApi, + some, + getProgramDerivedAddress, + type TransactionSigner, + address, +} from "@solana/kit"; import { fetchState } from "@across-protocol/contracts/dist/src/svm/clients/SvmSpoke"; -type Provider = Rpc; +export type Provider = Rpc; /** * @param spokePool SpokePool Contract instance. @@ -174,3 +198,187 @@ export function findFillEvent( ): Promise { throw new Error("fillStatusArray: not implemented"); } + +/** + * @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(_relayDataHash, spokePool.toV2Address()), + 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 async function getFillStatusPda( + relayDataHash: string, + spokePool: Address = SvmSpokeClient.SVM_SPOKE_PROGRAM_ADDRESS +): Promise> { + const [fillStatusPda] = await getProgramDerivedAddress({ + programAddress: spokePool, + seeds: [Buffer.from("fills"), Buffer.from(relayDataHash.slice(2), "hex")], + }); + return fillStatusPda; +} + +export async function getEventAuthority( + spokePool: Address = SvmSpokeClient.SVM_SPOKE_PROGRAM_ADDRESS, + extraSeeds: string[] = [] +): Promise> { + const [eventAuthority] = await getProgramDerivedAddress({ + programAddress: spokePool, + seeds: [Buffer.from("__event_authority"), ...extraSeeds], + }); + return eventAuthority; +} + +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); +} diff --git a/src/arch/svm/utils.ts b/src/arch/svm/utils.ts index ab7359f28..285dd1158 100644 --- a/src/arch/svm/utils.ts +++ b/src/arch/svm/utils.ts @@ -1,7 +1,28 @@ import { BN, BorshEventCoder, Idl } from "@coral-xyz/anchor"; -import web3, { address, getProgramDerivedAddress, getU64Encoder, Address, RpcTransport } from "@solana/kit"; +import web3, { + address, + getProgramDerivedAddress, + getU64Encoder, + Address, + RpcTransport, + type TransactionSigner, +} from "@solana/kit"; import { EventName, SVMEventNames } 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..979701b48 --- /dev/null +++ b/src/gasPriceOracle/adapters/solana.ts @@ -0,0 +1,34 @@ +import { Provider } from "../../arch/svm"; +import { toBN, dedupArray } 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: Provider, 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(); + + const nonzeroPrioritizationFees = recentPriorityFees.map((value) => value.prioritizationFee).filter((fee) => fee > 0); + const totalPrioritizationFees = nonzeroPrioritizationFees.reduce((acc, fee) => acc + fee, BigInt(0)); + return { + baseFee: toBN(baseFeeResponse!.value!), + microLamportsPerComputeUnit: toBN(totalPrioritizationFees / BigInt(nonzeroPrioritizationFees.length)), + }; +} 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..3e2152b8e 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 { Provider 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,21 @@ export async function getGasPriceEstimate( `Require 1.0 < priority fee multiplier (${priorityFeeMultiplier}) <= 5.0 for a total gas multiplier within [+1.0, +5.0]` ); + const isEthersProvider = provider instanceof providers.Provider; + // Exit here if we need to estimate on Solana. + if (!isEthersProvider) { + const optsWithDefaults: GasPriceEstimateOptions = { + ...GAS_PRICE_ESTIMATE_DEFAULTS, + baseFeeMultiplier, + priorityFeeMultiplier, + ...opts, + chainId: CHAIN_IDs.SOLANA, + }; + return solana.messageFee(provider as SolanaProvider, 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..3b0b77acd 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 { Provider 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..4b83f983e --- /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, + Provider, + 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: Provider, + 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 696bd7c69..3c500552a 100644 --- a/src/utils/SpokeUtils.ts +++ b/src/utils/SpokeUtils.ts @@ -5,6 +5,8 @@ import { toBytes32 } from "./AddressUtils"; import { keccak256 } from "./common"; import { BigNumber } from "./BigNumberUtils"; import { isMessageEmpty } from "./DepositUtils"; +import { chainIsSvm } from "./NetworkUtils"; +import { svm } from "../arch"; /** * Produce the RelayData for a Deposit. @@ -43,6 +45,9 @@ export function getRelayDataHash(relayData: RelayData, destinationChainId: numbe outputToken: toBytes32(relayData.outputToken), exclusiveRelayer: toBytes32(relayData.exclusiveRelayer), }; + if (chainIsSvm(destinationChainId)) { + return svm.getRelayDataHash(_relayData, destinationChainId); + } return keccak256( ethersUtils.defaultAbiCoder.encode( [ diff --git a/yarn.lock b/yarn.lock index f9d97c94f..c318aa4c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2501,6 +2501,11 @@ bs58 "^6.0.0" dotenv "^16.4.5" +"@solana-program/system@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@solana-program/system/-/system-0.7.0.tgz#3e21c9fb31d3795eb65ba5cb663947c19b305bad" + integrity sha512-FKTBsKHpvHHNc1ATRm7SlC5nF/VdJtOSjldhcyfMN9R7xo712Mo2jHIzvBgn8zQO5Kg0DcWuKB7268Kv1ocicw== + "@solana-program/token@^0.5.1": version "0.5.1" resolved "https://registry.yarnpkg.com/@solana-program/token/-/token-0.5.1.tgz#10e327df23f05a7f892fd33a9b6418f17dd62296"