diff --git a/keeper/package.json b/keeper/package.json index d08564a..1f14584 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -6,7 +6,8 @@ "description": "Pokes live Wraith orders and relays TEE-signed results on-chain.", "license": "Apache-2.0", "scripts": { - "start": "node src/index.js" + "start": "node src/index.js", + "test": "node --test test/*.test.js" }, "dependencies": { "viem": "^2.21.0" diff --git a/keeper/src/index.js b/keeper/src/index.js index 4047d9c..42b2e84 100644 --- a/keeper/src/index.js +++ b/keeper/src/index.js @@ -10,6 +10,7 @@ import { createPublicClient, createWalletClient, http, parseAbi, parseEventLogs, formatEther } from "viem"; import { privateKeyToAccount } from "viem/accounts"; +import { handleFetchResultResponse, determineRelayAction, PendingBookkeeper } from "./lib.js"; const RPC_URL = process.env.RPC_URL ?? "https://coston2-api.flare.network/ext/C/rpc"; const WRAITH_ADDRESS = required("WRAITH_ADDRESS"); @@ -39,7 +40,7 @@ const publicClient = createPublicClient({ chain: coston2, transport: http(RPC_UR const walletClient = createWalletClient({ account, chain: coston2, transport: http(RPC_URL) }); /** instructionId -> orderId, for instructions whose result has not arrived yet. */ -const pending = new Map(); +const pending = new PendingBookkeeper(); function required(name) { const value = process.env[name]; @@ -58,15 +59,7 @@ function required(name) { */ async function fetchResult(instructionId) { const response = await fetch(`${EXT_PROXY_URL}/action/result?id=${instructionId}`); - if (response.status === 404) return null; - if (!response.ok) { - throw new Error(`proxy returned ${response.status} for ${instructionId}`); - } - - const body = await response.json(); - // Status >= 2 means the extension is still processing. - if (body?.status === undefined || body.status >= 2) return null; - return body; + return handleFetchResultResponse(response, instructionId); } async function tickOrders() { @@ -94,7 +87,7 @@ async function tickOrders() { // OrderTicked carries the instruction id the proxy will key the result by. const events = parseEventLogs({ abi, logs: receipt.logs, eventName: "OrderTicked" }); for (const event of events) { - pending.set(event.args.instructionId, orderId); + pending.add(event.args.instructionId, orderId); console.log(`ticked order ${orderId} -> instruction ${event.args.instructionId}`); } } catch (error) { @@ -105,7 +98,7 @@ async function tickOrders() { } async function relayResults() { - for (const [instructionId, orderId] of [...pending]) { + for (const [instructionId, orderId] of pending.entries()) { let result; try { result = await fetchResult(instructionId); @@ -122,10 +115,8 @@ async function relayResults() { continue; } - // A no-op reply — the condition did not fire. This is the expected outcome - // for almost every tick, and it is the whole point: an observer, this keeper - // included, learns only "not yet", never how far away the trigger is. - if (!result.data || result.data === "0x") { + const relayAction = determineRelayAction(result, SUBMISSION_TAG); + if (!relayAction) { continue; } @@ -134,7 +125,7 @@ async function relayResults() { address: WRAITH_ADDRESS, abi, functionName: "execute", - args: [result.data, instructionId, result.submissionTag ?? SUBMISSION_TAG, result.status, result.signature], + args: [relayAction.data, instructionId, relayAction.submissionTag, relayAction.status, relayAction.signature], }); await publicClient.waitForTransactionReceipt({ hash }); console.log(`order ${orderId} executed in ${hash}`); diff --git a/keeper/src/lib.js b/keeper/src/lib.js new file mode 100644 index 0000000..99ac2f9 --- /dev/null +++ b/keeper/src/lib.js @@ -0,0 +1,101 @@ +/** + * Processes the HTTP response from the proxy for a result fetch. + * + * @param {Response} response - The Fetch Response object. + * @param {string} [instructionId] - Optional instruction ID for logging/errors. + * @returns {Promise} The parsed response body or null. + * @throws {Error} If the response status is not OK (except 404). + */ +export async function handleFetchResultResponse(response, instructionId) { + if (response.status === 404) return null; + if (!response.ok) { + const suffix = instructionId ? ` for ${instructionId}` : ""; + throw new Error(`proxy returned ${response.status}${suffix}`); + } + + const body = await response.json(); + // Status >= 2 means the extension is still processing. + if (body?.status === undefined || body.status >= 2) return null; + return body; +} + +/** + * Determines whether a fetched result should be relayed on-chain. + * + * @param {any} result - The result from fetchResult. + * @param {string} [defaultSubmissionTag="submit"] - Default submission tag to use if result has none. + * @returns {any | null} The relayed shape containing transaction args, or null if skipped/not relayable. + */ +export function determineRelayAction(result, defaultSubmissionTag = "submit") { + if (!result) return null; + if (result.status !== 1) return null; + if (!result.data || result.data === "0x") return null; + + return { + data: result.data, + submissionTag: result.submissionTag ?? defaultSubmissionTag, + status: result.status, + signature: result.signature, + }; +} + +/** + * Bookkeeper for the pending map tracking instructionId -> orderId. + */ +export class PendingBookkeeper { + constructor() { + this.pending = new Map(); + } + + /** + * Adds an instruction to the pending map. + * @param {string} instructionId + * @param {bigint|number} orderId + */ + add(instructionId, orderId) { + this.pending.set(instructionId, orderId); + } + + /** + * Removes an instruction from the pending map. + * @param {string} instructionId + * @returns {boolean} True if the element existed and was removed, false otherwise. + */ + delete(instructionId) { + return this.pending.delete(instructionId); + } + + /** + * Checks if an instruction exists in the pending map. + * @param {string} instructionId + * @returns {boolean} + */ + has(instructionId) { + return this.pending.has(instructionId); + } + + /** + * Gets the orderId for a given instruction. + * @param {string} instructionId + * @returns {bigint|number|undefined} + */ + get(instructionId) { + return this.pending.get(instructionId); + } + + /** + * Returns a copy of the pending entries as an array of [instructionId, orderId] pairs. + * @returns {Array<[string, bigint|number]>} + */ + entries() { + return [...this.pending.entries()]; + } + + /** + * Gets the number of pending instructions. + * @returns {number} + */ + get size() { + return this.pending.size; + } +} diff --git a/keeper/test/lib.test.js b/keeper/test/lib.test.js new file mode 100644 index 0000000..809d3a6 --- /dev/null +++ b/keeper/test/lib.test.js @@ -0,0 +1,106 @@ +import test from "node:test"; +import assert from "node:assert"; +import { handleFetchResultResponse, determineRelayAction, PendingBookkeeper } from "../src/lib.js"; + +test("handleFetchResultResponse - proxy 404 -> null", async () => { + const response = new Response(null, { status: 404 }); + const result = await handleFetchResultResponse(response, "inst-123"); + assert.strictEqual(result, null); +}); + +test("handleFetchResultResponse - status >= 2 -> null", async () => { + const response = Response.json({ status: 2 }); + const result = await handleFetchResultResponse(response, "inst-123"); + assert.strictEqual(result, null); +}); + +test("handleFetchResultResponse - status undefined -> null", async () => { + const response = Response.json({ somethingElse: "hello" }); + const result = await handleFetchResultResponse(response, "inst-123"); + assert.strictEqual(result, null); +}); + +test("handleFetchResultResponse - proxy not ok throwing error", async () => { + const response = new Response("Internal Server Error", { status: 500, statusText: "Internal Server Error" }); + await assert.rejects( + async () => { + await handleFetchResultResponse(response, "inst-123"); + }, + { + message: "proxy returned 500 for inst-123" + } + ); +}); + +test("handleFetchResultResponse - status < 2 -> returns body", async () => { + const body = { status: 1, data: "0x123", signature: "0xabc" }; + const response = Response.json(body); + const result = await handleFetchResultResponse(response, "inst-123"); + assert.deepStrictEqual(result, body); +}); + +test("determineRelayAction - status 1 with data -> relayed shape", () => { + const result = { status: 1, data: "0x123", signature: "0xabc", submissionTag: "my-tag" }; + const action = determineRelayAction(result, "default-tag"); + assert.deepStrictEqual(action, { + status: 1, + data: "0x123", + signature: "0xabc", + submissionTag: "my-tag", + }); +}); + +test("determineRelayAction - status 1 with data (missing submissionTag) -> default tag", () => { + const result = { status: 1, data: "0x123", signature: "0xabc" }; + const action = determineRelayAction(result, "default-tag"); + assert.deepStrictEqual(action, { + status: 1, + data: "0x123", + signature: "0xabc", + submissionTag: "default-tag", + }); +}); + +test("determineRelayAction - status != 1 -> skipped (null)", () => { + const result1 = { status: 0, data: "0x123", signature: "0xabc" }; + const result2 = { status: 2, data: "0x123", signature: "0xabc" }; + + assert.strictEqual(determineRelayAction(result1), null); + assert.strictEqual(determineRelayAction(result2), null); +}); + +test("determineRelayAction - status 1 with no data or 0x -> skipped (null)", () => { + assert.strictEqual(determineRelayAction({ status: 1, data: null }), null); + assert.strictEqual(determineRelayAction({ status: 1, data: "" }), null); + assert.strictEqual(determineRelayAction({ status: 1, data: "0x" }), null); + assert.strictEqual(determineRelayAction(null), null); +}); + +test("PendingBookkeeper - add/delete flow", () => { + const bookkeeper = new PendingBookkeeper(); + assert.strictEqual(bookkeeper.size, 0); + + // Add flow + bookkeeper.add("inst-1", 100n); + assert.strictEqual(bookkeeper.size, 1); + assert.strictEqual(bookkeeper.has("inst-1"), true); + assert.strictEqual(bookkeeper.get("inst-1"), 100n); + + bookkeeper.add("inst-2", 200n); + assert.strictEqual(bookkeeper.size, 2); + assert.deepStrictEqual(bookkeeper.entries(), [ + ["inst-1", 100n], + ["inst-2", 200n], + ]); + + // Delete flow + const deleted1 = bookkeeper.delete("inst-1"); + assert.strictEqual(deleted1, true); + assert.strictEqual(bookkeeper.size, 1); + assert.strictEqual(bookkeeper.has("inst-1"), false); + assert.strictEqual(bookkeeper.get("inst-1"), undefined); + + const deletedUnknown = bookkeeper.delete("nonexistent"); + assert.strictEqual(deletedUnknown, false); + assert.strictEqual(bookkeeper.size, 1); +});