Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions all_networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<PaymentSettlementJobData>(PAYMENT_QUEUE_NAME, {
connection,
Expand All @@ -47,6 +69,39 @@ const dataSettlementQueue = new Queue<DataSettlementJobData>(DATA_SETTLEMENT_QUE
},
});

function isRecord(value: unknown): value is Record<string, unknown> {
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";
Expand Down Expand Up @@ -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 {
Expand Down
96 changes: 96 additions & 0 deletions all_networks_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions all_networks_types_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions typescript/packages/mechanisms/evm/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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).
Expand Down
Loading