Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
21 changes: 17 additions & 4 deletions contracts/src/WraithOrders.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
39 changes: 32 additions & 7 deletions contracts/test/WraithOrders.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions keeper/.env.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion keeper/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
45 changes: 35 additions & 10 deletions keeper/src/attest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
Expand All @@ -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 {
Expand All @@ -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 = [
Expand Down
16 changes: 14 additions & 2 deletions keeper/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
fetchProof,
calculateRoundId,
isAttestationFresh,
shouldRetryAttestation,
FDC_PROTOCOL_ID,
} from "./attest.js";

Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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();
Comment thread
LSUDOKO marked this conversation as resolved.
const abiEncodedRequest = await prepareRequest(process.env);

const [fdcHub, feeConfig, systemsManager, relay] = await Promise.all([
Expand 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" }),
Expand All @@ -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.
Expand Down
38 changes: 31 additions & 7 deletions keeper/test/attest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, "{}");
Expand Down Expand Up @@ -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);
});