From 961a8aad354a42e4d65bbdda878316ca41e087c2 Mon Sep 17 00:00:00 2001 From: LSUDOKO Date: Fri, 14 Aug 2026 09:14:25 +0530 Subject: [PATCH] fix: make the Web2Json second oracle actually work on Coston2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three faults found by running it against the live verifier rather than by reading the spec. The jq used `floor`, which FDC rejects -------------------------------------- FDC permits a restricted jq subset and `floor` is not in it, so every request came back `INVALID JQ FILTER` — an error that names the category and nothing else. Rounding a float to 1e18 inside jq is not merely awkward without `floor`: the string-truncation workaround operates on a number large enough to render in scientific notation, where `split(".") | .[0]` silently returns the leading digit alone. A price wrong by sixteen orders of magnitude would then be handed to the enclave as fact. So the reading now carries its own scale — `(source, value, decimals, timestamp)` — and the contract widens it to 1e18. That is exact at a modest scale (FLR near $0.006 gives a six-digit integer that jq never renders in exponent form), and it is the shape FTSO already reports in. Decimals above 18 are rejected rather than truncated, because scaling down would discard the very precision the comparison depends on. The round id came from a stale block ------------------------------------ `getBlock({blockNumber})` against a load-balanced public RPC can be answered by a lagging node. One returned a block 6.7 hours old, so the derived round id was 6.7 hours in the past — already finalized, but containing no proof, so the wait timed out having polled a round that could never produce anything. The lookup is now by block hash, which either returns that exact block or fails loudly. Retrying a rate-limited API every 15 seconds -------------------------------------------- The public price APIs rate-limit the verifier's shared IP, so a rejection is usually transient — and retrying every poll interval is the one response guaranteed to keep it rejected. Failed attempts now back off for a minute. Also adds `keeper/.env.example` and `--env-file-if-exists` to `npm start`, so the keeper's configuration lives in a gitignored file rather than in whatever the operator last exported. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 +-- contracts/src/WraithOrders.sol | 21 ++++++++++++--- contracts/test/WraithOrders.t.sol | 39 ++++++++++++++++++++++----- keeper/.env.example | 24 +++++++++++++++++ keeper/package.json | 2 +- keeper/src/attest.js | 45 ++++++++++++++++++++++++------- keeper/src/index.js | 16 +++++++++-- keeper/test/attest.test.js | 38 +++++++++++++++++++++----- 8 files changed, 156 insertions(+), 33 deletions(-) create mode 100644 keeper/.env.example diff --git a/README.md b/README.md index 2348ad0..f827dda 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,8 @@ The no-op and the fired path are deliberately **indistinguishable by status**, s | | | | --- | --- | | **Network** | Flare Coston2 (chain 114) | -| **WraithOrders** | [`0xaD53864967e6Aa0090ee6609F481E7F09Ce753B3`](https://coston2.testnet.flarescan.com/address/0xaD53864967e6Aa0090ee6609F481E7F09Ce753B3) | -| **FCC extension ID** | `0x102b5` (66229) | +| **WraithOrders** | [`0xd5A5322F3D9bB9b2Ee73d006383BB03f61A04eCD`](https://coston2.testnet.flarescan.com/address/0xd5A5322F3D9bB9b2Ee73d006383BB03f61A04eCD) | +| **FCC extension ID** | `0x102b7` (66231) | | **FdcVerification** | `0x906507E0B64bcD494Db73bd0459d1C667e14B933` | | **FCC registry** | `0x1a9C4A0f9D76c0b1D91d22E24E573a9b377618aE` — FlareTeeManager diamond | | **FtsoV2** | `0x3d893C53D9e8056135C26C8c638B76C8b60Df726` | diff --git a/contracts/src/WraithOrders.sol b/contracts/src/WraithOrders.sol index b8f7aa5..86aecf2 100644 --- a/contracts/src/WraithOrders.sol +++ b/contracts/src/WraithOrders.sol @@ -407,8 +407,16 @@ contract WraithOrders { /// /// @dev This is the second oracle in a consensus order. The attestation's /// `abiEncodedData` is whatever the request's `abiSignature` declared; Wraith - /// requires `(string source, uint256 valueE18, uint256 timestamp)`, which a - /// one-line `postProcessJq` produces from most price APIs. + /// requires `(string source, uint256 value, uint256 decimals, uint256 + /// timestamp)`, which a one-line `postProcessJq` produces from most price + /// APIs. + /// + /// The reading arrives at the source's own scale rather than pre-scaled to + /// 1e18, and is normalized here. That is not a stylistic choice: the jq + /// subset FDC permits has no `floor`, so an attestation cannot round a + /// float to 1e18 without risking a truncation that silently changes the + /// price by orders of magnitude. Carrying `decimals` and scaling on-chain + /// is exact, and it is the shape FTSO already reports in. /// /// Requiring two independent sources to agree is what defends a private stop /// against the one attack privacy alone does not stop: an adversary who @@ -418,9 +426,14 @@ contract WraithOrders { require(address(fdcVerification) != address(0), "FDC verification not set"); require(fdcVerification.verifyWeb2Json(_proof), "FDC rejected the proof"); - (string memory source, uint256 valueE18, uint256 observedAt) = - abi.decode(_proof.data.responseBody.abiEncodedData, (string, uint256, uint256)); + (string memory source, uint256 value, uint256 decimals, uint256 observedAt) = + abi.decode(_proof.data.responseBody.abiEncodedData, (string, uint256, uint256, uint256)); require(bytes(source).length > 0, "attestation names no source"); + // Scaling down would discard precision the enclave is about to compare + // against a 1e18 threshold, so a reading finer than 1e18 is malformed + // rather than merely inconvenient. + require(decimals <= 18, "attested decimals out of range"); + uint256 valueE18 = value * (10 ** (18 - decimals)); Order storage o = _prepareTick(_orderId); _send( diff --git a/contracts/test/WraithOrders.t.sol b/contracts/test/WraithOrders.t.sol index 21ea9df..9683191 100644 --- a/contracts/test/WraithOrders.t.sol +++ b/contracts/test/WraithOrders.t.sol @@ -639,25 +639,50 @@ contract WraithAttestedTickTest is WraithOrdersTest { wraith.tickAttested(orderId, _paymentProof(3_000_000, uint64(block.timestamp))); } - function test_Web2JsonTickRelaysThePostProcessedReading() public { - uint256 orderId = _order(); - - IWeb2Json.Proof memory p; + function _web2Proof(uint256 value, uint256 decimals) internal view returns (IWeb2Json.Proof memory p) { p.data.responseBody.abiEncodedData = - abi.encode("coingecko:flare", uint256(2.5 ether), uint256(block.timestamp)); + abi.encode("coingecko:flare", value, decimals, uint256(block.timestamp)); + } + + /// @dev The attested reading arrives at whatever scale the source reports, + /// because the jq subset FDC allows has no `floor` — so an attestation + /// cannot round a price to 1e18 itself without risking float truncation. + /// Normalizing here is both exact and the shape FTSO already uses. + function test_Web2JsonTickNormalizesTheReadingTo1e18() public { + uint256 orderId = _order(); - attested.tickAttestedWeb2(orderId, p); + // FLR at $0.00600315, as CoinGecko reports it scaled by 1e8. + attested.tickAttestedWeb2(orderId, _web2Proof(600_315, 8)); (,,,,, uint256 verified, uint256 amountE18, uint256 at, string memory source) = abi.decode( recorder.lastMessage(), (uint256, address, bytes, uint256, uint256, uint256, uint256, uint256, string) ); assertEq(verified, 1); - assertEq(amountE18, 2.5 ether); + assertEq(amountE18, 6_003_150_000_000_000, "reading not scaled to 1e18"); assertEq(at, block.timestamp); assertEq(source, "coingecko:flare"); } + function test_Web2JsonTickAcceptsAReadingAlreadyAt1e18() public { + uint256 orderId = _order(); + attested.tickAttestedWeb2(orderId, _web2Proof(2.5 ether, 18)); + + (,,,,,, uint256 amountE18,,) = abi.decode( + recorder.lastMessage(), (uint256, address, bytes, uint256, uint256, uint256, uint256, uint256, string) + ); + assertEq(amountE18, 2.5 ether); + } + + /// @dev Scaling up from more than 18 decimals is not a rounding problem, it + /// is a malformed attestation — and silently truncating it would hand the + /// enclave a price that is wrong by orders of magnitude. + function test_RevertWhen_ReadingClaimsMoreThan18Decimals() public { + uint256 orderId = _order(); + vm.expectRevert("attested decimals out of range"); + attested.tickAttestedWeb2(orderId, _web2Proof(1, 19)); + } + /// @dev An attested tick is still a tick: it must not become a way around /// the rate limit that protects the order owner's instruction fees. function test_AttestedTickIsRateLimitedLikeAPlainTick() public { diff --git a/keeper/.env.example b/keeper/.env.example new file mode 100644 index 0000000..1c90eed --- /dev/null +++ b/keeper/.env.example @@ -0,0 +1,24 @@ +# Copy to .env. Gitignored — this file holds funded keys and a bot token. + +RPC_URL=https://coston2-api.flare.network/ext/C/rpc +WRAITH_ADDRESS=0xd5A5322F3D9bB9b2Ee73d006383BB03f61A04eCD +EXT_PROXY_URL=http://127.0.0.1:6674 + +# Funded Coston2 key. Pays gas, instruction fees, and FDC attestation fees. +KEEPER_PRIVATE_KEY= + +# Native fee forwarded to TeeExtensionRegistry.sendInstructions per tick. +INSTRUCTION_FEE_WEI=500000000000000000 + +# --- Second oracle (consensus orders). Unset, they never fire. --- +# No API key needed on Coston2: the verifier and DA Layer both accept Flare's +# published key, and the keeper defaults to it. +FDC_API_URL=https://api.coingecko.com/api/v3/simple/price +FDC_QUERY_PARAMS={"ids":"flare-networks","vs_currencies":"usd","include_last_updated_at":"true"} + +# --- Telegram alerts --- +# Token from @BotFather. TELEGRAM_CHAT_ID is the optional operator firehose; +# order owners subscribe their own wallet in the app's Alerts panel. +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +WRAITH_ALERTS_FILE=../.wraith-alerts.json diff --git a/keeper/package.json b/keeper/package.json index 1f14584..4b6ceb2 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -6,7 +6,7 @@ "description": "Pokes live Wraith orders and relays TEE-signed results on-chain.", "license": "Apache-2.0", "scripts": { - "start": "node src/index.js", + "start": "node --env-file-if-exists=.env src/index.js", "test": "node --test test/*.test.js" }, "dependencies": { diff --git a/keeper/src/attest.js b/keeper/src/attest.js index d5888b4..c93d36a 100644 --- a/keeper/src/attest.js +++ b/keeper/src/attest.js @@ -37,6 +37,18 @@ export function calculateRoundId(blockTimestamp, firstVotingRoundStartTs, voting return Number((BigInt(blockTimestamp) - BigInt(firstVotingRoundStartTs)) / BigInt(votingEpochDurationSeconds)); } +/** How long to wait after a failed attestation before trying again. + * The public price APIs rate-limit the verifier's shared IP, so a rejection is + * usually transient — and retrying every poll interval is the one response + * guaranteed to keep it rejected. */ +const RETRY_BACKOFF_MS = 60 * 1000; + +/** Whether enough time has passed since the last failed attempt. */ +export function shouldRetryAttestation(lastAttemptAt, nowMs) { + if (!lastAttemptAt) return true; + return nowMs - lastAttemptAt >= RETRY_BACKOFF_MS; +} + /** Whether a cached attestation may still be reused. */ export function isAttestationFresh(cached, nowMs) { if (!cached) return false; @@ -46,14 +58,20 @@ export function isAttestationFresh(cached, nowMs) { /** * The shape Wraith asks the attestation to be post-processed into. * - * `tickAttestedWeb2` decodes exactly this tuple, and the enclave compares - * `valueE18` against a threshold at the same 1e18 scale, so the jq has to - * produce all three fields in this order. + * `tickAttestedWeb2` decodes exactly this tuple, in this order, and scales + * `value` by `decimals` to reach the 1e18 the enclave compares against. + * + * The reading carries its own scale rather than arriving pre-scaled because the + * jq subset FDC permits has no `floor`: turning a float price into a 1e18 + * integer inside jq means string-truncating a number large enough to render in + * scientific notation, which fails silently and by orders of magnitude. A small + * integer plus its decimals is exact, and it is the shape FTSO already uses. */ const READING_SIGNATURE = JSON.stringify({ components: [ { internalType: "string", name: "source", type: "string" }, - { internalType: "uint256", name: "valueE18", type: "uint256" }, + { internalType: "uint256", name: "value", type: "uint256" }, + { internalType: "uint256", name: "decimals", type: "uint256" }, { internalType: "uint256", name: "timestamp", type: "uint256" }, ], internalType: "struct WraithReading", @@ -64,10 +82,10 @@ const READING_SIGNATURE = JSON.stringify({ /** * Build the Web2Json request body. * - * Note the jq runs in double precision, so scaling to 1e18 loses the low digits. - * That is harmless here: a consensus order compares two sources within a - * tolerance measured in basis points, which is many orders of magnitude wider - * than the rounding. + * `postProcessJq` must emit `source`, `value`, `decimals` and `timestamp` in + * that order — the tuple `tickAttestedWeb2` decodes. Keep the scale modest + * enough that the multiplication stays an exact integer in jq's doubles; the + * contract does the widening to 1e18. */ export function buildWeb2JsonRequestBody(env) { return { @@ -81,9 +99,16 @@ export function buildWeb2JsonRequestBody(env) { }; } -/** CoinGecko's FLR spot price, shaped into the reading tuple. */ +/** + * CoinGecko's FLR spot price, shaped into the reading tuple. + * + * `tostring | split(".") | .[0] | tonumber` is the truncation, because FDC's jq + * subset has no `floor`. It is safe at 1e8 — FLR near $0.006 gives a six-digit + * integer that jq never renders in scientific notation — and would not be at + * 1e18, where it would silently return the leading digit alone. + */ const DEFAULT_JQ = - '{source: "coingecko:flare", valueE18: (."flare-networks".usd * 1000000000000000000 | floor), timestamp: (."flare-networks".last_updated_at)}'; + '{source: "coingecko:flare", value: (.["flare-networks"].usd * 100000000 | tostring | split(".") | .[0] | tonumber), decimals: 8, timestamp: .["flare-networks"].last_updated_at}'; /** The IWeb2Json.Response tuple, for decoding the DA layer's raw hex. */ const WEB2JSON_RESPONSE = [ diff --git a/keeper/src/index.js b/keeper/src/index.js index e764d2d..df818b5 100644 --- a/keeper/src/index.js +++ b/keeper/src/index.js @@ -23,6 +23,7 @@ import { fetchProof, calculateRoundId, isAttestationFresh, + shouldRetryAttestation, FDC_PROTOCOL_ID, } from "./attest.js"; @@ -80,6 +81,8 @@ const pending = new Map(); * Rounds take 90–180s and cost a fee; requesting one per order would be slower * and dearer for no extra assurance, since it is the same reading either way. */ let attestation = null; +/** When the last attestation attempt was made, successful or not. */ +let lastAttestationAttempt = null; const registryAbi = parseAbi([ "function getContractAddressByName(string name) view returns (address)", @@ -115,7 +118,9 @@ function registryLookup(name) { async function refreshAttestation() { if (!FDC_ENABLED) return null; if (isAttestationFresh(attestation, Date.now())) return attestation; + if (!shouldRetryAttestation(lastAttestationAttempt, Date.now())) return attestation; + lastAttestationAttempt = Date.now(); const abiEncodedRequest = await prepareRequest(process.env); const [fdcHub, feeConfig, systemsManager, relay] = await Promise.all([ @@ -140,7 +145,12 @@ async function refreshAttestation() { value: fee, }); const receipt = await publicClient.waitForTransactionReceipt({ hash }); - const block = await publicClient.getBlock({ blockNumber: receipt.blockNumber }); + // By hash, not by number. A load-balanced public RPC can answer a + // number lookup from a lagging node, and a block timestamp hours stale + // yields a round id hours in the past — one that is already finalized but + // contains no proof, so the wait below times out with nothing to show for + // it. A hash lookup either returns that exact block or fails loudly. + const block = await publicClient.getBlock({ blockHash: receipt.blockHash }); const [firstStart, epochSeconds] = await Promise.all([ publicClient.readContract({ address: systemsManager, abi: systemsAbi, functionName: "firstVotingRoundStartTs" }), @@ -151,7 +161,9 @@ async function refreshAttestation() { }), ]); const roundId = calculateRoundId(block.timestamp, firstStart, epochSeconds); - console.log(`requested attestation for round ${roundId} (fee ${formatEther(fee)} C2FLR)`); + console.log( + `requested attestation for round ${roundId} (fee ${formatEther(fee)} C2FLR, block ts ${block.timestamp})`, + ); // Rounds take 90-180s. Waiting here blocks ticking, so the wait is bounded and // the loop simply retries on the next pass if the round is slow. diff --git a/keeper/test/attest.test.js b/keeper/test/attest.test.js index 55bd6f6..0c1a890 100644 --- a/keeper/test/attest.test.js +++ b/keeper/test/attest.test.js @@ -6,6 +6,7 @@ import { isAttestationFresh, buildWeb2JsonRequestBody, prepareRequest, + shouldRetryAttestation, } from "../src/attest.js"; // The verifier rejects a request whose attestation type is not a 32-byte, @@ -48,32 +49,40 @@ test("isAttestationFresh rejects a missing attestation", () => { assert.strictEqual(isAttestationFresh(null, 1_000), false); }); -// The enclave compares the attested value against a threshold scaled to 1e18, -// so the jq the keeper sends must produce that scale — and must name the three -// fields the contract decodes, in order. +// The contract scales the attested reading to 1e18 using the decimals it +// carries, so the jq must name all four fields the contract decodes, in order. +// A renamed or reordered field decodes as garbage rather than failing loudly. test("buildWeb2JsonRequestBody carries url, jq and the reading signature", () => { const body = buildWeb2JsonRequestBody({ FDC_API_URL: "https://api.example/price", FDC_QUERY_PARAMS: '{"ids":"flare"}', - FDC_JQ: "{source: \"x\", valueE18: 1, timestamp: 2}", + FDC_JQ: "{source: \"x\", value: 1, decimals: 8, timestamp: 2}", }); assert.strictEqual(body.url, "https://api.example/price"); assert.strictEqual(body.httpMethod, "GET"); assert.strictEqual(body.queryParams, '{"ids":"flare"}'); - assert.strictEqual(body.postProcessJq, "{source: \"x\", valueE18: 1, timestamp: 2}"); + assert.strictEqual(body.postProcessJq, "{source: \"x\", value: 1, decimals: 8, timestamp: 2}"); const signature = JSON.parse(body.abiSignature); assert.deepStrictEqual( signature.components.map((c) => c.name), - ["source", "valueE18", "timestamp"], + ["source", "value", "decimals", "timestamp"], ); assert.deepStrictEqual( signature.components.map((c) => c.type), - ["string", "uint256", "uint256"], + ["string", "uint256", "uint256", "uint256"], ); }); +// FDC's jq subset has no `floor`, so the default filter must not use one — a +// rejected filter fails at the verifier with only "INVALID JQ FILTER" to go on. +test("the default jq avoids builtins FDC does not allow", () => { + const jq = buildWeb2JsonRequestBody({ FDC_API_URL: "https://api.example/price" }).postProcessJq; + assert.doesNotMatch(jq, /\bfloor\b/); + assert.match(jq, /decimals:/); +}); + test("buildWeb2JsonRequestBody defaults the optional JSON fields to {}", () => { const body = buildWeb2JsonRequestBody({ FDC_API_URL: "https://api.example/price" }); assert.strictEqual(body.headers, "{}"); @@ -105,3 +114,18 @@ test("prepareRequest prefers a configured key over the public one", async () => await prepareRequest({ FDC_API_URL: "https://api.example/price", FDC_VERIFIER_API_KEY: "mine" }, fetchImpl); assert.strictEqual(seen, "mine"); }); + +// The public price API rate-limits the verifier's shared IP, so a rejection is +// usually transient. Retrying every poll interval makes that worse rather than +// better: the fix for being rate-limited is to ask less often. +test("shouldRetryAttestation waits after a failure", () => { + assert.strictEqual(shouldRetryAttestation(1_000, 1_000 + 5_000), false); +}); + +test("shouldRetryAttestation retries once the backoff has passed", () => { + assert.strictEqual(shouldRetryAttestation(1_000, 1_000 + 120_000), true); +}); + +test("shouldRetryAttestation allows the very first attempt", () => { + assert.strictEqual(shouldRetryAttestation(null, 1_000), true); +});