diff --git a/bun.lock b/bun.lock index 7b6c775..aa1fe9c 100644 --- a/bun.lock +++ b/bun.lock @@ -77,6 +77,8 @@ "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + "bufferutil": ["bufferutil@4.0.9", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw=="], + "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -165,6 +167,8 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -221,6 +225,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "utf-8-validate": ["utf-8-validate@5.0.10", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "viem": ["viem@2.31.7", "", { "dependencies": { "@noble/curves": "1.9.2", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.0.8", "isows": "1.0.7", "ox": "0.8.1", "ws": "8.18.2" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-mpB8Hp6xK77E/b/yJmpAIQcxcOfpbrwWNItjnXaIA8lxZYt4JS433Pge2gg6Hp3PwyFtaUMh01j5L8EXnLTjQQ=="], diff --git a/src/abis/requestForExecutionLog.ts b/src/abis/requestForExecutionLog.ts new file mode 100644 index 0000000..cd5c3d5 --- /dev/null +++ b/src/abis/requestForExecutionLog.ts @@ -0,0 +1,57 @@ +export const RequestForExecutionLogABI = [ + { + type: "event", + name: "RequestForExecution", + inputs: [ + { + name: "quoterAddress", + type: "address", + indexed: true, + internalType: "address", + }, + { + name: "amtPaid", + type: "uint256", + indexed: false, + internalType: "uint256", + }, + { + name: "dstChain", + type: "uint16", + indexed: false, + internalType: "uint16", + }, + { + name: "dstAddr", + type: "bytes32", + indexed: false, + internalType: "bytes32", + }, + { + name: "refundAddr", + type: "address", + indexed: false, + internalType: "address", + }, + { + name: "signedQuote", + type: "bytes", + indexed: false, + internalType: "bytes", + }, + { + name: "requestBytes", + type: "bytes", + indexed: false, + internalType: "bytes", + }, + { + name: "relayInstructions", + type: "bytes", + indexed: false, + internalType: "bytes", + }, + ], + anonymous: false, + }, +] as const; diff --git a/src/abis/vaaV1ReceiveWithGasDropoffAbi.ts b/src/abis/vaaV1ReceiveWithGasDropoffAbi.ts new file mode 100644 index 0000000..8a7ffa8 --- /dev/null +++ b/src/abis/vaaV1ReceiveWithGasDropoffAbi.ts @@ -0,0 +1,37 @@ +export const vaaV1ReceiveWithGasDropAbi = [ + { + type: "function", + name: "VERSION", + inputs: [], + outputs: [{ name: "", type: "string", internalType: "string" }], + stateMutability: "view", + }, + { + type: "function", + name: "receiveMessage", + inputs: [ + { name: "contractAddr", type: "address", internalType: "address" }, + { name: "message", type: "bytes", internalType: "bytes" }, + { name: "payeeAddress", type: "address", internalType: "address" }, + { name: "dropOffValue", type: "uint256", internalType: "uint256" }, + ], + outputs: [], + stateMutability: "payable", + }, + { + type: "error", + name: "DropOffFailed", + inputs: [ + { name: "", type: "address", internalType: "address" }, + { name: "", type: "uint256", internalType: "uint256" }, + ], + }, + { + type: "error", + name: "InvalidMsgValue", + inputs: [ + { name: "msgValue", type: "uint256", internalType: "uint256" }, + { name: "dropOffValue", type: "uint256", internalType: "uint256" }, + ], + }, +] as const; diff --git a/src/api/quote.ts b/src/api/quote.ts index 393c46d..76f20dd 100644 --- a/src/api/quote.ts +++ b/src/api/quote.ts @@ -1,6 +1,6 @@ import { type Request, type Response } from "express"; -import { enabledChains, type ChainConfig } from "../chains"; -import { createPublicClient, http, isHex, padHex, toBytes } from "viem"; +import { enabledChains } from "../chains"; +import { isHex, padHex, toBytes } from "viem"; import type { Quote } from "@wormhole-foundation/sdk-definitions"; import { PAYEE_PUBLIC_KEY, @@ -12,25 +12,7 @@ import { getTotalGasLimitAndMsgValue, signQuote, } from "../utils"; -import { anvil } from "viem/chains"; - -function getChainConfig(chainId: string): ChainConfig | undefined { - const numericId = parseInt(chainId); - return enabledChains[numericId]; -} - -async function getGasPrice(chainConfig: ChainConfig): Promise { - try { - const transport = http(chainConfig.rpc); - const client = createPublicClient({ - chain: anvil, - transport, - }); - return await client.getGasPrice(); - } catch (e) { - throw new Error(`unable to determine gas price`); - } -} +import { EvmHandler } from "../relay/platform/evm"; export const quoteHandler = async (req: Request, res: Response) => { const enabledChainIds = Object.keys(enabledChains); @@ -66,8 +48,8 @@ export const quoteHandler = async (req: Request, res: Response) => { return; } - const srcChain = getChainConfig(srcChainId); - const dstChain = getChainConfig(dstChainId); + const srcChain = enabledChains[parseInt(srcChainId)]; + const dstChain = enabledChains[parseInt(dstChainId)]; if (!srcChain || !dstChain) { res.status(500).send("Internal error: Invalid chain configuration"); @@ -77,7 +59,7 @@ export const quoteHandler = async (req: Request, res: Response) => { const expiryTime = new Date(); expiryTime.setHours(expiryTime.getHours() + 1); - const dstGasPrice = await getGasPrice(dstChain); + const dstGasPrice = await EvmHandler.getGasPrice(dstChain); const quote: Quote = { quote: { diff --git a/src/api/status.ts b/src/api/status.ts index cecba52..85b5d8d 100644 --- a/src/api/status.ts +++ b/src/api/status.ts @@ -1,5 +1,260 @@ import { type Request, type Response } from "express"; +import { messageQueue } from "../relay/queue"; +import { + EvmHandler, + type RequestForExecutionWithId, +} from "../relay/platform/evm"; +import { enabledChains, type ChainConfig } from "../chains"; +import { + RelayStatus, + type Capabilities, + type RelayRequestData, +} from "../types"; +import { + fromHex, + isAddressEqual, + isHex, + recoverAddress, + toHex, + keccak256, +} from "viem"; +import { deserialize, serialize } from "binary-layout"; +import { + quoteLayout, + signedQuoteLayout, + type SignedQuote, +} from "@wormhole-foundation/sdk-definitions"; +import { QUOTER_PUBLIC_KEY } from "../consts"; +import { estimateQuote, getTotalGasLimitAndMsgValue } from "../utils"; +import { + deserializeRequestForExecution, + type RequestLayout, +} from "../layouts/request"; +import { serializeRequestId } from "../layouts/requestId"; +import { processRelayRequests } from "../relay/relayer"; + +function isPositiveWholeNumber(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isInteger(value) && + value >= 0 && + value === Math.floor(value) + ); +} export const statusHandler = async (req: Request, res: Response) => { - res.status(500).send(); + try { + const enabledChainIds = Object.keys(enabledChains); + const chainId = req.body?.chainId; + const txHash = req.body?.txHash; + + if (typeof txHash !== "string" || !txHash) { + return res + .status(400) + .json({ message: "txHash must be a valid string." }); + } + + if (chainId && !isPositiveWholeNumber(chainId)) { + return res + .status(400) + .json({ message: "chainId, if defined, must be a number." }); + } + + if (!enabledChainIds.includes(chainId.toString())) { + res + .status(400) + .send( + `Unsupported chainId: ${chainId}, supported chains: ${enabledChainIds.join( + ",", + )}`, + ); + return; + } + + const chainConfig = enabledChains[parseInt(chainId)]; + + if (!chainConfig) { + res.status(500).send("Internal error: Invalid chain configuration"); + return; + } + + const existentRelayRequestData = messageQueue.getRequest({ + txHash, + chainId, + }); + + if (existentRelayRequestData && existentRelayRequestData.length > 0) { + res.status(200).json(existentRelayRequestData); + } + + const requestsForExecution = await EvmHandler.getRequestsForExecution( + txHash, + chainConfig, + ); + + const relayRequests: Array = []; + + for (const rfe of requestsForExecution) { + const dstChainConfig = enabledChains[rfe.dstChain]; + + if (!dstChainConfig) { + res.status(500).send("Internal error: Invalid chain configuration"); + return; + } + + const transaction = await verifyAndCreateTransaction( + rfe, + chainConfig, + dstChainConfig, + ); + + relayRequests.push(transaction); + } + + // Fire and forget - simulating async processing + messageQueue.enqueueAndProcess( + { chainId, txHash }, + relayRequests, + processRelayRequests, + ); + + return res.status(200).json(relayRequests); + } catch (error) { + console.error("Error handling status request:", error); + res.status(500).json({ error: "Failed to process request" }); + } }; + +async function verifyAndCreateTransaction( + rfe: RequestForExecutionWithId, + srcChainConfig: ChainConfig, + dstChainConfig: ChainConfig, +): Promise { + const txHash = rfe.id.hash; + const chainId = rfe.id.chain; + + const signedQuote: SignedQuote = deserialize( + signedQuoteLayout, + fromHex(rfe.signedQuoteBytes, "bytes"), + ); + + await verifySignedQuote(signedQuote); + + const { gasLimit, msgValue } = getTotalGasLimitAndMsgValue( + rfe.relayInstructionsBytes, + ); + + const estimatedCost = estimateQuote( + signedQuote, + gasLimit, + msgValue, + srcChainConfig.gasPriceDecimals, + srcChainConfig.nativeDecimals, + dstChainConfig.nativeDecimals, + ); + + const instruction = deserializeRequestForExecution(rfe.requestBytes); + const id = serializeRequestId(rfe.id); + + const status = getRelayStatus( + txHash, + dstChainConfig.capabilities, + rfe.amtPaid, + estimatedCost, + gasLimit, + msgValue, + instruction, + ); + + return { + id: id, + chainId: chainId, + estimatedCost: estimatedCost, + indexedAt: new Date(), + instruction: instruction, + requestForExecution: { + amtPaid: rfe.amtPaid, + dstAddr: rfe.dstAddr, + dstChain: rfe.dstChain, + quoterAddress: rfe.quoterAddress, + refundAddr: rfe.refundAddr, + requestBytes: rfe.requestBytes, + relayInstructionsBytes: rfe.relayInstructionsBytes, + signedQuoteBytes: rfe.signedQuoteBytes, + timestamp: rfe.timestamp, + }, + signedQuote, + status: status, + txHash: txHash, + txs: [], + }; +} + +async function verifySignedQuote(signedQuote: SignedQuote): Promise { + if ( + !isAddressEqual(QUOTER_PUBLIC_KEY, toHex(signedQuote.quote.quoterAddress)) + ) { + throw new Error( + `Bad quoterAddress. Expected: ${QUOTER_PUBLIC_KEY}, Received: ${signedQuote.quote.quoterAddress}`, + ); + } + if (!isHex(signedQuote.signature)) { + throw new Error(`Bad signature`); + } + const recoveredPublicKey = await recoverAddress({ + hash: keccak256(serialize(quoteLayout, signedQuote)), + signature: signedQuote.signature, + }); + if ( + !isAddressEqual(recoveredPublicKey, toHex(signedQuote.quote.quoterAddress)) + ) { + throw new Error( + `Bad quote signature recovery. Expected: ${signedQuote.quote.quoterAddress}, Received: ${recoveredPublicKey}`, + ); + } +} + +export function getRelayStatus( + txHash: string, + dstCapabilities: Capabilities, + amtPaid: bigint, + estimatedCost: bigint, + gasLimit: bigint, + msgValue: bigint, + instruction: RequestLayout, +) { + const { maxMsgValue, maxGasLimit, requestPrefixes } = dstCapabilities; + const isSupportedGasLimit = gasLimit <= maxGasLimit; + const isSupportedMsgValue = msgValue <= maxMsgValue; + + const isSupportedRequestPrefix = requestPrefixes.includes( + instruction.request.prefix, + ); + + if (!isSupportedRequestPrefix) { + console.warn( + `Unsupported request prefix ${instruction.request.prefix} for txHash ${txHash}. Supported prefixes are ${requestPrefixes.join(", ")}`, + ); + } + + if (!isSupportedGasLimit) { + console.warn( + `Unsupported gasLimit ${gasLimit} for ${txHash} as it exceeds the maximum allowed gasLimit ${maxGasLimit}.`, + ); + } + + if (!isSupportedMsgValue) { + console.warn( + `Unsupported msgValue ${msgValue} for ${txHash} as it exceeds the maximum allowed msgValue ${maxGasLimit}.`, + ); + } + + const isSupported = + isSupportedRequestPrefix && isSupportedGasLimit && isSupportedMsgValue; + + if (!isSupported) { + return RelayStatus.Unsupported; + } + + return amtPaid < estimatedCost ? RelayStatus.Underpaid : RelayStatus.Pending; +} diff --git a/src/chains.ts b/src/chains.ts index 824fa12..356434d 100644 --- a/src/chains.ts +++ b/src/chains.ts @@ -1,13 +1,18 @@ +import type { Chain } from "viem"; import { RequestPrefix, type Capabilities } from "./types"; +import { baseSepolia, sepolia } from "viem/chains"; export interface ChainConfig { wormholeChainId: number; evmChainId: number; + executorAddress: string; rpc: string; name: string; gasPriceDecimals: number; nativeDecimals: number; capabilities: Capabilities; + coreContractAddress: string; + viemChain?: Chain; } export const enabledChains: Record = { @@ -18,6 +23,9 @@ export const enabledChains: Record = { name: "Ethereum Sepolia", gasPriceDecimals: 18, nativeDecimals: 18, + executorAddress: "0xD0fb39f5a3361F21457653cB70F9D0C9bD86B66B", + coreContractAddress: "0x4a8bc80Ed5a4067f1CCf107057b8270E0cC11A78", + viemChain: sepolia, capabilities: { requestPrefixes: [RequestPrefix.ERV1], gasDropOffLimit: 100_000_000_000n, @@ -32,6 +40,9 @@ export const enabledChains: Record = { name: "Base Sepolia", gasPriceDecimals: 18, nativeDecimals: 18, + viemChain: baseSepolia, + coreContractAddress: "0x79A1027a6A159502049F10906D333EC57E95F083", + executorAddress: "0x51B47D493CBA7aB97e3F8F163D6Ce07592CE4482", capabilities: { requestPrefixes: [RequestPrefix.ERV1], gasDropOffLimit: 100_000_000_000n, diff --git a/src/consts.ts b/src/consts.ts index 69f8079..ed267cc 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -1,4 +1,4 @@ -import { toHex } from "viem"; +import { padHex, toHex } from "viem"; import { mnemonicToAccount } from "viem/accounts"; const account = mnemonicToAccount( @@ -13,3 +13,8 @@ export const EVM_PRIVATE_KEY = toHex(account.getHdKey().privateKey || "0x"); export const QUOTER_PUBLIC_KEY = account.address; export const QUOTER_PRIVATE_KEY = toHex(account.getHdKey().privateKey || "0x"); + +export const EMPTY_ADDRESS = padHex( + "0x0000000000000000000000000000000000000000000000000000000000000000", + { size: 32 }, +); diff --git a/src/layouts/conversions.ts b/src/layouts/conversions.ts new file mode 100644 index 0000000..ebb834d --- /dev/null +++ b/src/layouts/conversions.ts @@ -0,0 +1,12 @@ +import { type CustomConversion } from "binary-layout"; +import { fromBytes, fromHex } from "viem"; + +export const hexConversion = { + to: (encoded: Uint8Array) => fromBytes(encoded, "hex"), + from: (decoded: `0x${string}`) => fromHex(decoded, "bytes"), +} as const satisfies CustomConversion; + +export const dateConversion = { + to: (encoded: bigint) => new Date(Number(encoded * 1000n)), + from: (decoded: Date) => BigInt(decoded.getTime()) / 1000n, +} as const satisfies CustomConversion; diff --git a/src/layouts/request.ts b/src/layouts/request.ts new file mode 100644 index 0000000..f5b4fe6 --- /dev/null +++ b/src/layouts/request.ts @@ -0,0 +1,100 @@ +import { + type DeriveType, + deserialize, + type Layout, + serialize, +} from "binary-layout"; +import { fromHex, toHex } from "viem"; +import { RequestPrefix } from "../types"; +import { hexConversion } from "./conversions"; + +export const modularMessageRequestLayout = [ + { name: "chain", binary: "uint", size: 2 }, + { name: "address", binary: "bytes", size: 32, custom: hexConversion }, + { name: "sequence", binary: "uint", size: 8 }, + { + name: "payload", + binary: "bytes", + lengthSize: 4, + custom: hexConversion, + }, +] as const satisfies Layout; + +export type ModularMessageRequest = DeriveType< + typeof modularMessageRequestLayout +>; + +export const vaaV1RequestLayout = [ + { name: "chain", binary: "uint", size: 2 }, + { name: "address", binary: "bytes", size: 32, custom: hexConversion }, + { name: "sequence", binary: "uint", size: 8 }, +] as const satisfies Layout; + +export type VAAv1Request = DeriveType; + +export const nttV1RequestLayout = [ + { name: "srcChain", binary: "uint", size: 2 }, + { + name: "srcManager", + binary: "bytes", + size: 32, + custom: hexConversion, + }, + { + name: "messageId", + binary: "bytes", + size: 32, + custom: hexConversion, + }, +] as const satisfies Layout; + +export type NTTv1Request = DeriveType; + +export const cctpV1RequestLayout = [ + { name: "sourceDomain", binary: "uint", size: 4 }, + { name: "nonce", binary: "uint", size: 8 }, +] as const satisfies Layout; + +export type CCTPv1Request = DeriveType; + +export const cctpV2RequestLayout = [ + { + name: "cctpV2Request", + binary: "switch", + idSize: 1, + idTag: "cctpV2RequestPrefix", + layouts: [[[0x01, "auto"], []]], + }, +] as const satisfies Layout; + +export type CCTPv2Request = DeriveType; + +export const requestLayout = [ + { + name: "request", + binary: "switch", + idSize: 4, + idTag: "prefix", + layouts: [ + [[0x45524d31, RequestPrefix.ERM1], modularMessageRequestLayout], + [[0x45525631, RequestPrefix.ERV1], vaaV1RequestLayout], + [[0x45524e31, RequestPrefix.ERN1], nttV1RequestLayout], + [[0x45524331, RequestPrefix.ERC1], cctpV1RequestLayout], + [[0x45524332, RequestPrefix.ERC2], cctpV2RequestLayout], + ], + }, +] as const satisfies Layout; + +export type RequestLayout = DeriveType; + +export function deserializeRequestForExecution( + requestBytes: `0x${string}`, +): RequestLayout { + return deserialize(requestLayout, fromHex(requestBytes, "bytes")); +} + +export function serializeRequestForExecution( + instruction: RequestLayout, +): `0x${string}` { + return toHex(serialize(requestLayout, instruction)); +} diff --git a/src/layouts/requestId.ts b/src/layouts/requestId.ts new file mode 100644 index 0000000..16f55f9 --- /dev/null +++ b/src/layouts/requestId.ts @@ -0,0 +1,34 @@ +import { + type DeriveType, + deserialize, + type Layout, + serialize, +} from "binary-layout"; +import { fromHex, toHex } from "viem"; +import { hexConversion } from "./conversions"; + +const requestIdChainLayout = [ + { name: "chain", binary: "uint", size: 2 }, +] as const satisfies Layout; + +const evmRequestIdLayout = [ + ...requestIdChainLayout, + { name: "hash", binary: "bytes", size: 32, custom: hexConversion }, + { name: "logIndex", binary: "uint", size: 32 }, +] as const satisfies Layout; + +export const requestIdLayout = { + binary: "switch", + idSize: 1, + idTag: "type", + layouts: [[[0, "Evm"], evmRequestIdLayout]], +} as const satisfies Layout; +export type RequestId = DeriveType; + +export function deserializeRequestId(requestIdBytes: `0x${string}`): RequestId { + return deserialize(requestIdLayout, fromHex(requestIdBytes, "bytes")); +} + +export function serializeRequestId(id: RequestId) { + return toHex(serialize(requestIdLayout, id)); +} diff --git a/src/mockGuardian.ts b/src/mockGuardian.ts index eb071bc..13be5a0 100644 --- a/src/mockGuardian.ts +++ b/src/mockGuardian.ts @@ -92,5 +92,5 @@ export async function mockWormhole( const base64 = Buffer.from(serialize(signedVaa)).toString("base64"); return base64; } - return ""; + throw new Error(`Vaa not found for txHash: ${txHash}.`); } diff --git a/src/relay/platform/evm.ts b/src/relay/platform/evm.ts new file mode 100644 index 0000000..d1ea7a9 --- /dev/null +++ b/src/relay/platform/evm.ts @@ -0,0 +1,260 @@ +import { + createPublicClient, + createWalletClient, + decodeEventLog, + fromHex, + getAddress, + http, + isAddressEqual, + isHex, + padHex, + toEventHash, + toHex, + trim, + type Hex, + type PublicClient, + type TransactionReceipt, +} from "viem"; +import type { ChainConfig } from "../../chains"; +import type { RequestId } from "../../layouts/requestId"; +import type { + RelayRequestData, + RequestForExecution, + TxInfo, +} from "../../types"; +import { + anvil, + type OpStackTransactionReceipt, + type ZksyncTransactionReceipt, +} from "viem/chains"; +import { RequestForExecutionLogABI } from "../../abis/requestForExecutionLog"; +import { + getFirstDropOffInstruction, + getTotalGasLimitAndMsgValue, + getTotalMsgValueFromGasInstructions, +} from "../../utils"; +import { + relayInstructionsLayout, + type RelayInstructions, +} from "@wormhole-foundation/sdk-definitions"; +import { deserialize } from "binary-layout"; +import { EVM_PRIVATE_KEY } from "../../consts"; +import { privateKeyToAccount } from "viem/accounts"; +import { vaaV1ReceiveWithGasDropAbi } from "../../abis/vaaV1ReceiveWithGasDropoffAbi"; +import { mockWormhole } from "../../mockGuardian"; + +const REQUEST_FOR_EXECUTION_TOPIC = toEventHash( + "RequestForExecution(address,uint256,uint16,bytes32,address,bytes,bytes,bytes)", +); + +export type RequestForExecutionWithId = RequestForExecution & { + id: RequestId; +}; + +export class EvmHandler { + private constructor() {} + + static async getGasPrice(chainConfig: ChainConfig): Promise { + try { + const transport = http(chainConfig.rpc); + const client = createPublicClient({ + chain: anvil, + transport, + }); + return await client.getGasPrice(); + } catch (e) { + throw new Error(`unable to determine gas price`); + } + } + + static async getRequestsForExecution( + txHash: string, + chainConfig: ChainConfig, + ): Promise> { + const results: Array = []; + + if (!isHex(txHash)) { + throw new Error(`Invalid txHash ${txHash}`); + } + + try { + const transport = http(chainConfig.rpc); + const client = createPublicClient({ + chain: anvil, + transport, + }); + + const transactionReceipt = await client.getTransactionReceipt({ + hash: txHash, + }); + + const block = await client.getBlock({ + blockNumber: transactionReceipt.blockNumber, + }); + + if (!transactionReceipt) return results; + + for ( + let logIndex = 0; + logIndex < transactionReceipt.logs.length; + logIndex++ + ) { + const log = transactionReceipt.logs[logIndex]; + + if ( + log && + log.removed === false && + isAddressEqual( + log.address, + chainConfig.executorAddress as `0x${string}`, + ) && + log.topics.length === 2 && + log.topics[0] === REQUEST_FOR_EXECUTION_TOPIC + ) { + const { + args: { + quoterAddress, + amtPaid, + dstChain, + dstAddr, + refundAddr, + signedQuote: signedQuoteBytes, + requestBytes, + relayInstructions: relayInstructionsBytes, + }, + } = decodeEventLog({ + abi: RequestForExecutionLogABI, + topics: log.topics, + data: log.data, + }); + + results.push({ + id: { + type: "Evm", + chain: chainConfig.wormholeChainId, + hash: transactionReceipt.transactionHash, + logIndex: BigInt(logIndex), + }, + amtPaid, + dstAddr, + dstChain: Number(dstChain), + quoterAddress, + refundAddr, + signedQuoteBytes, + requestBytes, + relayInstructionsBytes, + timestamp: new Date(Number(block.timestamp) * 1000), + }); + } + } + } catch (e) { + console.error(e); + } + + return results; + } + + static async relayVAAv1( + chainConfig: ChainConfig, + relayRequest: RelayRequestData, + ): Promise> { + if ( + !isHex(relayRequest.txHash) || + !isHex(chainConfig.coreContractAddress) + ) { + throw new Error(`TxHash not hex!`); + } + + const transport = http(chainConfig.rpc); + const publicClient = createPublicClient({ + chain: anvil, + transport, + }); + + const { maxMsgValue, gasDropOffLimit } = chainConfig.capabilities; + + const relayInstructions = deserializeRelayInstructions( + relayRequest.requestForExecution.relayInstructionsBytes, + ); + + const { gasLimit } = getTotalGasLimitAndMsgValue( + relayRequest.requestForExecution.relayInstructionsBytes, + ); + + const relayMsgValue = getTotalMsgValueFromGasInstructions( + relayInstructions, + maxMsgValue, + ); + + const { dropOff, recipient } = getFirstDropOffInstruction( + relayInstructions, + gasDropOffLimit, + ); + + const account = privateKeyToAccount(EVM_PRIVATE_KEY); + + const client = createWalletClient({ + account, + chain: chainConfig.viemChain, + transport, + }); + + const base64Vaa = await mockWormhole( + chainConfig.rpc, + relayRequest.txHash, + chainConfig.coreContractAddress, + ); + + const payloadHex = toHex(Buffer.from(base64Vaa, "base64")); + + const { request } = await publicClient.simulateContract({ + account, + address: "0x13b62003C8b126Ec0748376e7ab22F79Fb8bbDF2", + gas: gasLimit, + value: relayMsgValue + dropOff, + abi: vaaV1ReceiveWithGasDropAbi, + functionName: "receiveMessage", + args: [ + trimToAddress(relayRequest.requestForExecution.dstAddr), + payloadHex, + trimToAddress(toHex(recipient.address)), + dropOff, + ], + }); + + const hash = await client.writeContract(request); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash, + }); + + const block = await publicClient.getBlock({ blockHash: receipt.blockHash }); + const blockTime = new Date(Number(block.timestamp) * 1000); + + let totalCostValue = + receipt.effectiveGasPrice * receipt.gasUsed + (request.value || 0n); + + const txInfo = { + txHash: receipt.transactionHash, + chainId: chainConfig.wormholeChainId, + blockNumber: receipt.blockNumber, + blockTime, + cost: totalCostValue, + }; + return [txInfo]; + } +} + +function deserializeRelayInstructions( + relayInstructionsBytes: `0x${string}`, +): RelayInstructions { + return deserialize( + relayInstructionsLayout, + fromHex(relayInstructionsBytes, "bytes"), + ); +} +function trimToAddress(hex: Hex) { + return getAddress( + padHex(trim(hex, { dir: "left" }), { dir: "left", size: 20 }), + ); +} diff --git a/src/relay/queue.ts b/src/relay/queue.ts new file mode 100644 index 0000000..6aaf17d --- /dev/null +++ b/src/relay/queue.ts @@ -0,0 +1,58 @@ +import { + RelayAbortedError, + RelayStatus, + UnsupportedRelayRequestError, + type RelayRequestData, + type TxInfo, +} from "../types"; + +class InMemoryRelayRequestQueue { + private store: Map> = new Map(); + + async enqueueAndProcess( + key: RelayRequestKey, + requests: Array, + callback: (data: RelayRequestData) => Promise>, + ): Promise { + this.store.set(this.relayKeyToString(key), requests); + + for (const relayData of requests) { + let txInfos: Array = []; + try { + txInfos = await callback(relayData); + } catch (e: unknown) { + relayData.status = RelayStatus.Failed; + + if (e instanceof UnsupportedRelayRequestError) { + relayData.status = RelayStatus.Unsupported; + } + + if (e instanceof RelayAbortedError) { + relayData.status = RelayStatus.Aborted; + } + + relayData.failureCause = e instanceof Error ? e.message : String(e); + + continue; + } + + relayData.status = RelayStatus.Submitted; + relayData.txs = txInfos; + } + + this.store.set(this.relayKeyToString(key), requests); + } + + getRequest = (key: RelayRequestKey): Array | undefined => + this.store.get(this.relayKeyToString(key)); + + relayKeyToString = (key: RelayRequestKey): string => + `${key.chainId}/${key.txHash}`; +} + +export type RelayRequestKey = { + txHash: string; + chainId: number; +}; + +export const messageQueue = new InMemoryRelayRequestQueue(); diff --git a/src/relay/relayer.ts b/src/relay/relayer.ts new file mode 100644 index 0000000..a0f3594 --- /dev/null +++ b/src/relay/relayer.ts @@ -0,0 +1,61 @@ +import { enabledChains } from "../chains"; +import { + RelayAbortedError, + RequestPrefix, + UnsupportedRelayRequestError, + type RelayRequestData, + type TxInfo, +} from "../types"; +import { EvmHandler } from "./platform/evm"; + +export const processRelayRequests = async ( + relayRequest: RelayRequestData, +): Promise> => { + const chainConfig = enabledChains[relayRequest.chainId]; + + console.log( + `Relaying ${relayRequest.id} of type ${relayRequest.instruction?.request.prefix}`, + ); + + if (!chainConfig) { + throw new RelayAbortedError( + `Error in chain configuration: Chain ID ${relayRequest.chainId} not configured.`, + ); + } + + if (!relayRequest.instruction) { + throw new RelayAbortedError( + `Relay Request of ID ${relayRequest.id} without instruction set, aborting.`, + ); + } + + const { request } = relayRequest.instruction; + const { prefix } = request; + + if (!chainConfig.capabilities.requestPrefixes.includes(prefix)) { + throw new UnsupportedRelayRequestError( + `Request type of ${relayRequest.instruction.request.prefix} not supported for Chain ID ${relayRequest.chainId}`, + ); + } + + let relayedTransactions: Array = []; + + switch (prefix) { + case RequestPrefix.ERV1: + relayedTransactions = await EvmHandler.relayVAAv1( + chainConfig, + relayRequest, + ); + break; + case RequestPrefix.ERC2: + case RequestPrefix.ERC1: + case RequestPrefix.ERM1: + case RequestPrefix.ERN1: + default: + throw new UnsupportedRelayRequestError( + `Request of type ${prefix} not supported.`, + ); + } + + return relayedTransactions; +}; diff --git a/src/types.ts b/src/types.ts index a8dce1e..16b1f9f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,6 @@ +import type { SignedQuote } from "@wormhole-foundation/sdk-definitions"; +import type { RequestLayout } from "./layouts/request"; + export enum RequestPrefix { ERM1 = "ERM1", // MM ERV1 = "ERV1", // VAA_V1 @@ -12,3 +15,60 @@ export type Capabilities = { maxGasLimit: bigint; maxMsgValue: bigint; }; + +export type RequestForExecution = { + quoterAddress: `0x${string}`; + amtPaid: bigint; + dstChain: number; + dstAddr: `0x${string}`; + refundAddr: `0x${string}`; + signedQuoteBytes: `0x${string}`; + requestBytes: `0x${string}`; + relayInstructionsBytes: `0x${string}`; + timestamp: Date; +}; + +export type RelayRequestData = { + id: `0x${string}`; + chainId: number; + estimatedCost: bigint; + indexedAt: Date; + instruction?: RequestLayout; + requestForExecution: RequestForExecution; + signedQuote: SignedQuote; + status: RelayStatus; + txHash: string; + failureCause?: string; + txs?: Array; +}; + +export type TxInfo = { + txHash: string; + chainId: number; + blockNumber: bigint; + blockTime: Date | null; + cost: bigint; +}; + +export enum RelayStatus { + Pending = "pending", + Failed = "failed", + Unsupported = "unsupported", + Submitted = "submitted", + Underpaid = "underpaid", + Aborted = "aborted", +} + +export class RelayAbortedError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "RelayAbortedError"; + } +} + +export class UnsupportedRelayRequestError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "UnsupportedRelayRequestError"; + } +} diff --git a/src/utils.ts b/src/utils.ts index a02488d..2d3af46 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,13 +1,15 @@ import { quoteLayout, relayInstructionsLayout, + UniversalAddress, type Quote, type RelayInstructions, } from "@wormhole-foundation/sdk-definitions"; import { deserialize, serialize } from "binary-layout"; -import { concat, fromBytes, fromHex, keccak256 } from "viem"; +import { concat, fromBytes, fromHex, keccak256, toHex } from "viem"; import { sign } from "viem/accounts"; import { ScaledMath } from "./lib/ScaledMath"; +import { EMPTY_ADDRESS } from "./consts"; const SIGNED_QUOTE_DECIMALS = 10; @@ -54,6 +56,45 @@ function totalGasLimitAndMsgValue(relayInstructions: RelayInstructions): { return { gasLimit, msgValue }; } +export function getTotalMsgValueFromGasInstructions( + relayInstructions: RelayInstructions, + msgValueLimit: bigint, +): bigint { + let msgValue = 0n; + for (const relayInstruction of relayInstructions.requests) { + const type = relayInstruction.request.type; + if (type === "GasInstruction") { + msgValue += relayInstruction.request.msgValue; + } + } + return msgValue > msgValueLimit ? msgValueLimit : msgValue; +} + +export function getFirstDropOffInstruction( + relayInstructions: RelayInstructions, + gasDropOffLimit: bigint, +): { + dropOff: bigint; + recipient: UniversalAddress; +} { + for (const relayInstruction of relayInstructions.requests) { + const type = relayInstruction.request.type; + if (type === "GasDropOffInstruction") { + if (relayInstruction.request.dropOff > gasDropOffLimit) { + return { + dropOff: gasDropOffLimit, + recipient: relayInstruction.request.recipient, + }; + } + return relayInstruction.request; + } + } + return { + dropOff: 0n, + recipient: new UniversalAddress(EMPTY_ADDRESS), + }; +} + export function estimateQuote( { quote }: Quote, gasLimit: bigint,