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
3 changes: 2 additions & 1 deletion keeper/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 8 additions & 17 deletions keeper/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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];
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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;
}

Expand All @@ -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}`);
Expand Down
101 changes: 101 additions & 0 deletions keeper/src/lib.js
Original file line number Diff line number Diff line change
@@ -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<any | null>} 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;
}
}
106 changes: 106 additions & 0 deletions keeper/test/lib.test.js
Original file line number Diff line number Diff line change
@@ -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);
});