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); +});