diff --git a/all_networks.ts b/all_networks.ts index 170bed6..eca24d8 100644 --- a/all_networks.ts +++ b/all_networks.ts @@ -2,23 +2,30 @@ import { Queue, type Job } from "bullmq"; import express from "express"; import { randomUUID } from "node:crypto"; import { type Server } from "node:http"; +import { getAddress, isAddress } from "viem"; import { incrementMetric } from "./metrics.js"; import { createBullMqConnection, createFacilitator, + createHeartbeatRelayContext, processPrivateSettlement, } from "./all_networks_shared.js"; import { + base64ToBytesCalldata, DATA_SETTLEMENT_QUEUE_NAME, + getRequiredStringField, isSettlementError, + parseUint256Field, normalizeHeaderValue, PAYMENT_QUEUE_NAME, parseSettlementJobDataFromHeaders, PORT, settlementStatusFromBullState, SHUTDOWN_TIMEOUT_MS, + toStrictBytes32, toSerializableResult, type DataSettlementJobData, + type HeartbeatRelayRequest, type PaymentSettlementJobData, type SettlementApiJobResponse, } from "./all_networks_types_helpers.js"; @@ -30,6 +37,21 @@ app.use(express.json()); const facilitator = await createFacilitator(); const connection = createBullMqConnection(); +const heartbeatRelayContext = (() => { + try { + const context = createHeartbeatRelayContext(); + console.info( + `[heartbeat-relay] enabled for contract ${context.registryContractAddress} with signer ${context.signerAddress} on ${context.chainName} (${context.chainId})`, + ); + return context; + } catch (error) { + console.warn( + `[heartbeat-relay] disabled: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + return null; + } +})(); + const paymentQueue = new Queue(PAYMENT_QUEUE_NAME, { connection, @@ -47,6 +69,39 @@ const dataSettlementQueue = new Queue(DATA_SETTLEMENT_QUE }, }); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseHeartbeatRequestBody(value: unknown): HeartbeatRelayRequest { + if (!isRecord(value)) { + throw new Error("Heartbeat body must be a JSON object"); + } + + const teeId = toStrictBytes32( + getRequiredStringField(value, ["teeId", "tee_id", "tee id", "tee-id"]), + "teeId", + ); + const timestamp = parseUint256Field(value, ["timestamp", "timeStamp", "time_stamp"]); + const signature = getRequiredStringField(value, ["signature", "teeSignature", "tee_signature"]); + + let contractAddress: `0x${string}` | undefined; + const rawContractAddress = value.contractAddress ?? value.registry ?? value.contract_address; + if (typeof rawContractAddress === "string" && rawContractAddress.trim().length > 0) { + if (!isAddress(rawContractAddress)) { + throw new Error("Invalid contractAddress: expected EVM address"); + } + contractAddress = getAddress(rawContractAddress); + } + + return { + teeId, + timestamp, + signature, + contractAddress, + }; +} + function queueNameFromJobId(jobId: string): "payment" | "settlement" | null { if (jobId.startsWith("payment-") || jobId.startsWith("payment:")) { return "payment"; @@ -228,6 +283,60 @@ app.post("/settle_data", async (req, res) => { } }); +app.post("/heartbeat", async (req, res) => { + incrementMetric("api.request.count", ["route:/heartbeat", "method:POST"]); + try { + if (!heartbeatRelayContext) { + return res.status(503).json({ + error: "Heartbeat relay is not configured on this facilitator", + }); + } + + const heartbeatRequest = parseHeartbeatRequestBody(req.body); + if ( + heartbeatRequest.contractAddress && + heartbeatRequest.contractAddress.toLowerCase() !== + heartbeatRelayContext.registryContractAddress.toLowerCase() + ) { + return res.status(400).json({ + error: "contractAddress does not match facilitator heartbeat relay contract", + }); + } + + const txHash = await heartbeatRelayContext.submitHeartbeat({ + teeId: heartbeatRequest.teeId, + timestamp: heartbeatRequest.timestamp, + signature: base64ToBytesCalldata(heartbeatRequest.signature), + }); + incrementMetric("heartbeat.relay.success.count", ["route:/heartbeat"]); + + return res.json({ + success: true, + txHash, + teeId: heartbeatRequest.teeId, + timestamp: heartbeatRequest.timestamp, + relayer: heartbeatRelayContext.signerAddress, + contract: heartbeatRelayContext.registryContractAddress, + }); + } catch (error) { + incrementMetric("heartbeat.relay.failure.count", ["route:/heartbeat"]); + const message = error instanceof Error ? error.message : "Unknown error"; + console.error("Heartbeat relay error:", error); + + const lower = message.toLowerCase(); + if ( + lower.includes("invalid") || + lower.includes("missing") || + lower.includes("heartbeat body") || + lower.includes("expected") + ) { + return res.status(400).json({ error: message }); + } + + return res.status(500).json({ error: message }); + } +}); + app.get("/settle/:jobId", async (req, res) => { incrementMetric("api.request.count", ["route:/settle/:jobId", "method:GET"]); try { diff --git a/all_networks_shared.ts b/all_networks_shared.ts index 5540054..a5fb477 100644 --- a/all_networks_shared.ts +++ b/all_networks_shared.ts @@ -19,12 +19,15 @@ import { DATA_SETTLEMENT_BATCH_MAX_AGE_MS, DATA_WORKER_EVM_PRIVATE_KEY_ENV, DATA_WORKER_SETTLEMENT_CONTRACT_ENV, + HEARTBEAT_RELAY_EVM_PRIVATE_KEY_ENV, + HEARTBEAT_RELAY_REGISTRY_CONTRACT_ENV, REDIS_URL, teeSignatureLeafValue, toBytesCalldata, toStrictBytes32, type DataSettlementJobData, type DataWorkerContext, + type HeartbeatRelayContext, type SettlementBatchData, type SettlementHandlerResult, type SettlementIndividualData, @@ -61,6 +64,10 @@ const DATA_WORKER_SETTLEMENT_GAS_LIMIT = BigInt( const DATA_WORKER_TX_RECEIPT_TIMEOUT_MS = Number( process.env.DATA_WORKER_TX_RECEIPT_TIMEOUT_MS || 120_000, ); +const HEARTBEAT_RELAY_GAS_LIMIT = BigInt(process.env.HEARTBEAT_RELAY_GAS_LIMIT || "500000"); +const HEARTBEAT_RELAY_TX_RECEIPT_TIMEOUT_MS = Number( + process.env.HEARTBEAT_RELAY_TX_RECEIPT_TIMEOUT_MS || 120_000, +); type BatchFlushReason = "buffer-full" | "idle-timeout" | "max-age-timeout"; @@ -174,6 +181,29 @@ const settlementContractAbi = [ }, ] as const; +const teeRegistryHeartbeatAbi = [ + { + type: "function", + name: "heartbeat", + stateMutability: "nonpayable", + inputs: [ + { + name: "teeId", + type: "bytes32", + }, + { + name: "timestamp", + type: "uint256", + }, + { + name: "signature", + type: "bytes", + }, + ], + outputs: [], + }, +] as const; + function scheduleBatchFlush(context: DataWorkerContext): void { if (batchSettlementFlushTimer) { clearTimeout(batchSettlementFlushTimer); @@ -596,6 +626,72 @@ export function createDataWorkerContext(): DataWorkerContext { }; } +export function createHeartbeatRelayContext(): HeartbeatRelayContext { + const privateKey = (process.env[HEARTBEAT_RELAY_EVM_PRIVATE_KEY_ENV] || + process.env[DATA_WORKER_EVM_PRIVATE_KEY_ENV] || + process.env.EVM_PRIVATE_KEY) as `0x${string}` | undefined; + if (!privateKey) { + throw new Error( + `${HEARTBEAT_RELAY_EVM_PRIVATE_KEY_ENV} (or ${DATA_WORKER_EVM_PRIVATE_KEY_ENV}/EVM_PRIVATE_KEY) is required for heartbeat relay`, + ); + } + + const registryContractAddress = (process.env[HEARTBEAT_RELAY_REGISTRY_CONTRACT_ENV] || + process.env.HEARTBEAT_CONTRACT_ADDRESS) as `0x${string}` | undefined; + if (!registryContractAddress) { + throw new Error( + `${HEARTBEAT_RELAY_REGISTRY_CONTRACT_ENV} (or HEARTBEAT_CONTRACT_ADDRESS) is required for heartbeat relay`, + ); + } + + const account = privateKeyToAccount(privateKey); + const ogEvmWalletClient = createWalletClient({ + account, + chain: ogEvm, + transport: http(), + }).extend(publicActions); + + return { + signerAddress: account.address, + chainId: ogEvm.id, + chainName: ogEvm.name, + registryContractAddress, + submitHeartbeat: async ({ + teeId, + timestamp, + signature, + }): Promise<`0x${string}`> => { + let stage: "broadcast" | "receipt" = "broadcast"; + try { + const txHash = await ogEvmWalletClient.writeContract({ + address: registryContractAddress, + abi: teeRegistryHeartbeatAbi, + functionName: "heartbeat", + args: [teeId, BigInt(timestamp), signature], + gas: HEARTBEAT_RELAY_GAS_LIMIT, + maxFeePerGas: parseGwei("0.002"), + maxPriorityFeePerGas: parseGwei("0.001"), + }); + + stage = "receipt"; + const receipt = await withTimeout( + ogEvmWalletClient.waitForTransactionReceipt({ hash: txHash }), + HEARTBEAT_RELAY_TX_RECEIPT_TIMEOUT_MS, + "Heartbeat relay receipt wait", + ); + if (receipt.status !== "success") { + throw new Error(`Heartbeat relay transaction reverted: ${txHash}`); + } + + return txHash; + } catch (error) { + incrementMetric("heartbeat.tx.failure.count", [`stage:${stage}`]); + throw error; + } + }, + }; +} + export function createBullMqConnection(): RedisOptions { const parsed = new URL(REDIS_URL); const dbPath = parsed.pathname.startsWith("/") ? parsed.pathname.slice(1) : parsed.pathname; diff --git a/all_networks_types_helpers.ts b/all_networks_types_helpers.ts index 5b2ed82..de88f47 100644 --- a/all_networks_types_helpers.ts +++ b/all_networks_types_helpers.ts @@ -36,6 +36,8 @@ export const DATA_SETTLEMENT_QUEUE_NAME = resolveQueueName( export const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS || 10_000); export const DATA_WORKER_EVM_PRIVATE_KEY_ENV = "DATA_WORKER_EVM_PRIVATE_KEY"; export const DATA_WORKER_SETTLEMENT_CONTRACT_ENV = "DATA_WORKER_SETTLEMENT_CONTRACT"; +export const HEARTBEAT_RELAY_EVM_PRIVATE_KEY_ENV = "HEARTBEAT_RELAY_EVM_PRIVATE_KEY"; +export const HEARTBEAT_RELAY_REGISTRY_CONTRACT_ENV = "HEARTBEAT_RELAY_REGISTRY_CONTRACT"; export const DATA_SETTLEMENT_BATCH_BUFFER_SIZE = Number( process.env.DATA_SETTLEMENT_BATCH_BUFFER_SIZE || 20, ); @@ -107,6 +109,25 @@ export type DataWorkerContext = { }) => Promise<`0x${string}`>; }; +export type HeartbeatRelayRequest = { + teeId: `0x${string}`; + timestamp: string; + signature: string; + contractAddress?: `0x${string}`; +}; + +export type HeartbeatRelayContext = { + signerAddress: `0x${string}`; + chainId: number; + chainName: string; + registryContractAddress: `0x${string}`; + submitHeartbeat: (args: { + teeId: `0x${string}`; + timestamp: string; + signature: `0x${string}`; + }) => Promise<`0x${string}`>; +}; + export type SettlementApiJobResponse = { jobId: string; queue: string; diff --git a/typescript/packages/mechanisms/evm/src/constants.ts b/typescript/packages/mechanisms/evm/src/constants.ts index 41ce891..ef91fa9 100644 --- a/typescript/packages/mechanisms/evm/src/constants.ts +++ b/typescript/packages/mechanisms/evm/src/constants.ts @@ -92,7 +92,7 @@ export const eip3009ABI = [ */ // TODO: revert after precompile upgrade -export const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3" as const; +export const PERMIT2_ADDRESS = "0xA2820a4d4F3A8c5Fa4eaEBF45B093173105a8f8F" as const; /** * x402ExactPermit2Proxy contract address. @@ -102,7 +102,7 @@ export const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3" as c * - Vanity-mined salt for prefix 0x4020 and suffix 0001 * - Contract bytecode + constructor args (PERMIT2_ADDRESS) */ -export const x402ExactPermit2ProxyAddress = "0x5b0c02d331ad156Ad9CEc247ABee72fBd125d139" as const; +export const x402ExactPermit2ProxyAddress = "0xdB9F7863C9E06Daf21aD43663a06a2f43d303Fa7" as const; /** * x402UptoPermit2Proxy contract address. @@ -112,7 +112,7 @@ export const x402ExactPermit2ProxyAddress = "0x5b0c02d331ad156Ad9CEc247ABee72fBd * - Vanity-mined salt for prefix 0x4020 and suffix 0002 * - Contract bytecode + constructor args (PERMIT2_ADDRESS) */ -export const x402UptoPermit2ProxyAddress = "0x5b0c02d331ad156Ad9CEc247ABee72fBd125d139" as const; +export const x402UptoPermit2ProxyAddress = "0xdB9F7863C9E06Daf21aD43663a06a2f43d303Fa7" as const; /** * x402UptoPermit2Proxy ABI - settle function for upto payment scheme (variable amounts).