From 1d8b6d01f303e02efdc81b9919fd233f2697749f Mon Sep 17 00:00:00 2001 From: LSUDOKO Date: Fri, 14 Aug 2026 01:19:55 +0530 Subject: [PATCH 1/2] feat: FDC cross-chain triggers, multi-oracle consensus, and gasless orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the three remaining roadmap items. All four suites pass: 36 Solidity, 80 Go, 24 keeper, 20 frontend. Cross-chain triggers (kind 4) ----------------------------- FDC is a Flare system application the enclave cannot call, so the proof takes a different route: the keeper fetches it, `tickAttested` verifies it on-chain against a finalized round, and only the verified reading crosses inward. By the time the extension sees `verified`, it reflects a Merkle check rather than the keeper's word — which is what lets the enclave refuse an unverified attestation outright instead of trusting whoever relayed it. The instruction grew four slots for the reading. Their absence means "no proof offered", which is deliberately distinct from "a proof was offered and the chain rejected it" — only the second says anything about the keeper, so collapsing them into a zero-valued attestation would have discarded the signal. The watched address never appears on-chain. It travels as the FDC standard address hash in the sealed terms and in the tick alike, and a test pins that hash to Flare's published XRPL vector: matching a documented value proves it is the hash FDC computes, not merely one both halves of this repo agree on. Multi-oracle consensus (kind 5) ------------------------------- Privacy stops someone aiming at a trigger they cannot see. It does not stop them walking a single price feed until something fires. A consensus order settles only when FTSO and an FDC-attested off-chain price both cross the threshold. A deviation tolerance sits on top and does the opposite of firing: when the two sources disagree beyond it, the order refuses to act at all, because a wide gap between two honest sources means one is wrong and acting on either is worse than waiting. Rounds take 90-180s, so the keeper requests one attestation and reuses it across every order ticked in a ten-minute window. An attested tick is a strict superset of a plain one, so kinds that need no second oracle ignore the attached reading. The tolerance shares the trail-distance wire slot. The kinds are mutually exclusive, so the two never coexist in one order, and a second basis-point field would have cost every order the bytes for nothing. Gasless orders -------------- A user who minted FXRP holds no FLR, so they cannot pay for the transaction that would escrow it: the asset they want to protect is the one they cannot act on. ERC-4337 was the wrong tool — an EntryPoint, a bundler and an account abstraction the user does not otherwise need, for a problem one signature wide. Instead the user signs an EIP-712 intent and any relayer submits it, reimbursing itself in the escrowed token rather than in native gas. Settlement was already permissionless, so the user needs no FLR at any point. The relayer is trusted with nothing: every field is covered by the signature, so it cannot retarget the escrow, substitute sealed terms, or raise its own fee. A per-signer nonce makes each intent single-use, consumed before any transfer so a token with a callback cannot re-enter and spend it twice. Tests cover a forged signature, a replay, an expired intent and an inflated fee. One honest limit: the ERC-20 allowance still needs one funded transaction unless the token supports EIP-2612, so the gasless path takes a standing allowance and skips approval entirely when one already covers the order. Also refactors `Evaluate` and `EvaluateConsensus` onto a shared `crosses` helper so the single-oracle and consensus paths cannot drift apart on what "triggered" means, and splits `tick` into `_prepareTick`/`_send` so the three tick flavours share one set of liveness and rate-limit checks. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 22 +- contracts/src/WraithOrders.sol | 238 +++++++++++++++- contracts/src/interfaces/IFdc.sol | 92 ++++++ contracts/test/WraithOrders.t.sol | 257 ++++++++++++++++- docs/DEPLOY.md | 31 ++ docs/ROADMAP.md | 99 ++++++- docs/TRUST.md | 22 +- extension/README.md | 31 +- extension/internal/enclave/abi.go | 91 ++++-- extension/internal/enclave/enclave_test.go | 151 ++++++++++ extension/internal/enclave/handler.go | 24 +- extension/internal/trigger/trigger.go | 209 +++++++++++--- extension/internal/trigger/trigger_test.go | 106 +++++++ frontend/.env.example | 10 + frontend/app/api/relay/route.ts | 120 ++++++++ frontend/app/app/page.tsx | 317 +++++++++++++++++++-- frontend/lib/wraith.test.ts | 19 +- frontend/lib/wraith.ts | 39 ++- keeper/README.md | 30 ++ keeper/src/attest.js | 152 ++++++++++ keeper/src/index.js | 154 +++++++++- keeper/test/attest.test.js | 81 ++++++ 22 files changed, 2169 insertions(+), 126 deletions(-) create mode 100644 contracts/src/interfaces/IFdc.sol create mode 100644 frontend/app/api/relay/route.ts create mode 100644 keeper/src/attest.js create mode 100644 keeper/test/attest.test.js diff --git a/README.md b/README.md index 2c40230..f9fd8c8 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ The no-op and the fired path are deliberately **indistinguishable by status**, s | --- | --- | | **Private stop-loss / take-profit** | Trigger price is never published, so it cannot be hunted | | **OCO brackets** | Stop and take-profit share one escrow; whichever fires first settles, the other dies with it | +| **Trailing stop** | The stop follows price up and never back down; the peak is public, the trail distance is not | +| **Stealth TWAP** | A large order splits into tranches at times and sizes derived from a sealed seed | +| **FAssets Shield** | Escape an FAssets agent whose collateral is falling, on a threshold nobody can see | +| **Cross-chain triggers** | Fire on an FDC-attested XRPL payment; the watched address travels only as its FDC hash | +| **Multi-oracle consensus** | Settles only when FTSO *and* an attested off-chain price agree — one feed alone cannot move a stop | +| **Gasless orders** | Sign an intent, a sponsor pays the gas and takes its fee in the escrowed token | | **Cross-chain settlement** | Swap FXRP, or redeem it to native XRP on the XRPL | | **Owner-only recall** | Read your own condition back, from a device-local copy — the chain still holds only ciphertext | | **Live system status** | Enclave key and TEE machine count read straight from the FCC registry | @@ -94,7 +100,7 @@ Full sequence: [`docs/DEPLOY.md`](docs/DEPLOY.md). | --- | --- | | **FCC** (Confidential Compute) | Runs the private condition evaluation inside a TEE. An order's plaintext exists nowhere else. | | **FTSO** | Price triggers, read from block-latency feeds *inside the enclave* — which puts the keeper outside the trust path entirely. | -| **FDC** | Cross-chain triggers: XRPL `Payment` attestation, and Web2 JSON attestation for off-chain data. | +| **FDC** | Cross-chain triggers via the XRPL `Payment` attestation, and `Web2Json` as the second oracle in a consensus order. Verified on-chain before the reading reaches the enclave, because FDC is a system application the enclave cannot call. | | **FAssets / FXRP** | Settlement. A fired trigger swaps FXRP or redeems it to native XRP on the XRPL. | `WraithOrders` is its own FCC `InstructionSender`, so the contract that holds escrow is the same one that dispatches instructions and verifies results. @@ -163,19 +169,21 @@ Routes: `/` explains the product, `/app` is the order composer and live order bo ## Verification -81 tests across four languages, all run in CI on every push: +160 tests across four languages, all run in CI on every push: | Suite | Count | Covers | | --- | --- | --- | -| `contracts` | 15 | Escrow, settlement, forged signatures, replay, cross-deployment reuse, expiry, cancellation, rate limiting | -| `extension` | 32 | Trigger evaluation and boundaries, decimal normalization, stale-price refusal, ABI round-trips, no-op indistinguishability | -| `keeper` | 16 | Proxy response handling, relay decisions, notification privacy | -| `frontend` | 18 | Price parsing, cipher rendering, analytics scrubbing | +| `contracts` | 36 | Escrow, settlement, forged signatures, replay, cross-deployment reuse, expiry, cancellation, rate limiting, partial fills, peak tracking, FDC proof rejection, gasless intent forgery and replay | +| `extension` | 80 | Trigger evaluation and boundaries for all six kinds, decimal normalization, stale-price and stale-attestation refusal, oracle disagreement, ABI round-trips, no-op indistinguishability | +| `keeper` | 24 | Proxy response handling, relay decisions, notification privacy, attestation encoding and reuse windows | +| `frontend` | 20 | Price parsing, cipher rendering, analytics scrubbing, FDC address hashing | -Two properties are enforced by test rather than convention: +Four properties are enforced by test rather than convention: - **The settlement payload carries no trace of the threshold or direction.** A test greps the encoded result for the secret bytes. - **Analytics can never carry order terms.** Term-bearing keys are dropped at any nesting depth, because a trigger price is a plain decimal that no value-level pattern can distinguish from a legitimate metric. +- **The contract owner cannot authorize a signer of their choosing.** Settlement authority comes from the TEE machine registry, and a test asserts the owner has no way to add to it. +- **The FDC address hash matches Flare's published vector.** Hashing the documented XRPL example proves both halves compute the hash FDC computes, rather than agreeing on the same mistake. ## Status diff --git a/contracts/src/WraithOrders.sol b/contracts/src/WraithOrders.sol index af6dc0d..b8f7aa5 100644 --- a/contracts/src/WraithOrders.sol +++ b/contracts/src/WraithOrders.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.27; import { ITeeExtensionRegistry } from "./interfaces/ITeeExtensionRegistry.sol"; import { ITeeMachineRegistry } from "./interfaces/ITeeMachineRegistry.sol"; import { IERC20, IUniswapV2Router, IAssetManager } from "./interfaces/IWraithExternal.sol"; +import { IFdcVerification, IPayment, IWeb2Json } from "./interfaces/IFdc.sol"; /// @title WraithOrders /// @notice Private conditional orders on Flare. A user escrows an asset and stores @@ -97,6 +98,16 @@ contract WraithOrders { bytes encrypted; // ECIES ciphertext of the trigger terms — the secret } + /// @notice A gasless order the owner signed off-chain for a relayer to submit. + struct CreateIntent { + address owner; // signer, and owner of the resulting order + address tokenIn; + uint256 amountIn; + uint64 expiry; + uint256 relayerFee; // paid to the submitter in tokenIn + uint256 deadline; // unix seconds; the intent is dead after this + } + /// @notice Message handed to the TEE on each tick. struct EvalMessage { uint256 orderId; @@ -112,6 +123,24 @@ contract WraithOrders { /// @notice FAssets AssetManager used for the redeem action. IAssetManager public assetManager; + /// @notice On-chain FDC verifier, resolved from the Flare contract registry + /// under `FdcVerification`. Required for cross-chain and consensus orders. + IFdcVerification public fdcVerification; + + /// @notice EIP-712 type hash for a gasless order intent. + /// + /// The ciphertext is committed to by hash rather than by value: the intent + /// must bind the exact sealed terms a relayer submits, but hashing keeps the + /// signed struct a fixed size. + bytes32 public constant CREATE_ORDER_TYPEHASH = keccak256( + "CreateOrder(address owner,bytes32 encryptedHash,address tokenIn,uint256 amountIn,uint64 expiry,uint256 relayerFee,uint256 nonce,uint256 deadline)" + ); + + /// @notice Per-signer counter that makes each gasless intent single-use. + /// Without it, one signature could be replayed until the signer's whole + /// balance had been escrowed. + mapping(address => uint256) public nonces; + /// @dev There is deliberately no owner-controlled set of accepted signers. /// Authority to settle an order comes from `TeeMachineRegistry`, so the /// contract owner cannot grant it to an address of their choosing. @@ -129,6 +158,8 @@ contract WraithOrders { event PeakTracked(uint256 indexed orderId, uint256 peakE18); event RouterSet(address indexed router); event AssetManagerSet(address indexed assetManager); + event FdcVerificationSet(address indexed fdcVerification); + event OrderRelayed(uint256 indexed orderId, address indexed relayer, uint256 fee); modifier onlyOwner() { require(msg.sender == owner, "not owner"); @@ -174,6 +205,12 @@ contract WraithOrders { emit AssetManagerSet(_assetManager); } + function setFdcVerification(address _fdcVerification) external onlyOwner { + require(_fdcVerification != address(0), "zero fdc verification"); + fdcVerification = IFdcVerification(_fdcVerification); + emit FdcVerificationSet(_fdcVerification); + } + // --- Order lifecycle --- /// @notice Create a private conditional order. @@ -196,10 +233,105 @@ contract WraithOrders { require(IERC20(_tokenIn).transferFrom(msg.sender, address(this), _amountIn), "escrow transfer failed"); + orderId = _push(msg.sender, _encrypted, _tokenIn, _amountIn, _expiry); + } + + /// @notice Open an order on behalf of a user who signed for it off-chain. + /// + /// @dev This is the gasless path. A user who minted FXRP holds no FLR, so + /// they cannot pay for the transaction that would escrow it — the asset they + /// want to protect is exactly the one they cannot act on. Here they sign an + /// EIP-712 intent (free, no chain interaction) and any relayer submits it, + /// reimbursing itself with `_relayerFee` **in the escrowed token** rather + /// than in native gas. The user never needs FLR at any point: settlement is + /// already permissionless, so `execute()` costs them nothing either. + /// + /// Every parameter is covered by the signature, so a relayer cannot raise + /// its own fee, retarget the escrow, or swap in different sealed terms — + /// changing any of them simply makes the signature recover to another + /// address. + /// + /// @param _intent What the user signed. Every field is covered by the + /// signature. + /// @param _encrypted The sealed terms, committed to by hash in the intent. + /// @param _signature 65-byte EIP-712 signature over CREATE_ORDER_TYPEHASH. + function createOrderFor(CreateIntent calldata _intent, bytes calldata _encrypted, bytes calldata _signature) + external + returns (uint256 orderId) + { + require(_encrypted.length > 0, "empty ciphertext"); + require(_intent.tokenIn != address(0), "zero token"); + require(_intent.amountIn > 0, "zero amount"); + require(_intent.expiry > block.timestamp, "expiry in the past"); + require(block.timestamp <= _intent.deadline, "intent expired"); + + require(_recover(_intentDigest(_intent, keccak256(_encrypted)), _signature) == _intent.owner, "bad intent signature"); + + // Consumed before any transfer, so a token with a callback cannot + // re-enter and spend the same intent twice. + nonces[_intent.owner] += 1; + + IERC20 token = IERC20(_intent.tokenIn); + require( + token.transferFrom(_intent.owner, address(this), _intent.amountIn + _intent.relayerFee), + "escrow transfer failed" + ); + if (_intent.relayerFee > 0) { + require(token.transfer(msg.sender, _intent.relayerFee), "relayer fee failed"); + } + + orderId = _push(_intent.owner, _encrypted, _intent.tokenIn, _intent.amountIn, _intent.expiry); + emit OrderRelayed(orderId, msg.sender, _intent.relayerFee); + } + + /// @notice EIP-712 digest a user must sign to authorize a relayed order. + /// Exposed so a wallet or relayer can verify what it is about to submit. + function intentDigest(CreateIntent calldata _intent, bytes calldata _encrypted) external view returns (bytes32) { + return _intentDigest(_intent, keccak256(_encrypted)); + } + + function _intentDigest(CreateIntent calldata _intent, bytes32 _encryptedHash) private view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + CREATE_ORDER_TYPEHASH, + _intent.owner, + _encryptedHash, + _intent.tokenIn, + _intent.amountIn, + _intent.expiry, + _intent.relayerFee, + nonces[_intent.owner], + _intent.deadline + ) + ); + return keccak256(abi.encodePacked("\x19\x01", domainSeparator(), structHash)); + } + + /// @notice EIP-712 domain separator for gasless intents. + /// @dev Computed rather than cached so a fork of the chain cannot replay + /// intents signed for the original. + function domainSeparator() public view returns (bytes32) { + return keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("Wraith"), + keccak256("1"), + block.chainid, + address(this) + ) + ); + } + + /// @dev Shared by the direct and relayed creation paths so the two cannot + /// drift apart on what an order looks like. + function _push(address _orderOwner, bytes calldata _encrypted, address _tokenIn, uint256 _amountIn, uint64 _expiry) + private + returns (uint256 orderId) + { orderId = _orders.length; _orders.push( Order({ - owner: msg.sender, + owner: _orderOwner, tokenIn: _tokenIn, amountIn: _amountIn, remaining: _amountIn, @@ -212,7 +344,7 @@ contract WraithOrders { }) ); - emit OrderCreated(orderId, msg.sender, _tokenIn, _amountIn, _expiry); + emit OrderCreated(orderId, _orderOwner, _tokenIn, _amountIn, _expiry); } /// @notice Ask the TEE to evaluate an order's private condition. @@ -220,7 +352,88 @@ contract WraithOrders { /// it forwards ciphertext and cannot tell a "not triggered" reply from silence. /// Native value is forwarded to the registry as the instruction fee. function tick(uint256 _orderId) external payable { - Order storage o = _orders[_orderId]; + Order storage o = _prepareTick(_orderId); + _send(_orderId, abi.encode(_orderId, address(this), o.encrypted, o.peakE18, o.remaining)); + } + + /// @notice Drops per XRP. FDC reports XRPL amounts in drops (1e-6 XRP); the + /// enclave compares everything at 1e18. + uint256 private constant XRP_DROPS_TO_E18 = 1e12; + + /// @notice Tick an order with an FDC-attested XRPL payment attached. + /// + /// @dev The enclave cannot reach FDC: the TEE-based FDC is a Flare *system* + /// application with no interface a third-party extension can call. So the + /// proof is verified here, on-chain, and only the verified reading crosses + /// into the enclave. By the time the extension sees it, `verified` reflects + /// a Merkle check against a finalized attestation round rather than the + /// keeper's word. + /// + /// What this leaks is the *observed* fact — that some XRPL payment landed — + /// which is public on XRPL anyway. What it does not leak is the threshold + /// that fact is being compared against, which stays in the ciphertext. That + /// asymmetry is the whole design. + /// + /// Note the source travels as the FDC address *hash*, never the r-address: + /// the address the order watches is part of the secret, and the sealed terms + /// carry the same hash so the enclave can match them without either side + /// publishing it. + function tickAttested(uint256 _orderId, IPayment.Proof calldata _proof) external payable { + require(address(fdcVerification) != address(0), "FDC verification not set"); + require(fdcVerification.verifyPayment(_proof), "FDC rejected the proof"); + + IPayment.ResponseBody calldata body = _proof.data.responseBody; + require(body.status == 0, "payment did not succeed"); + require(body.receivedAmount >= 0, "negative received amount"); + + Order storage o = _prepareTick(_orderId); + _send( + _orderId, + abi.encode( + _orderId, + address(this), + o.encrypted, + o.peakE18, + o.remaining, + uint256(1), + uint256(body.receivedAmount) * XRP_DROPS_TO_E18, + uint256(body.blockTimestamp), + _toHexString(body.sourceAddressHash) + ) + ); + } + + /// @notice Tick an order with an FDC-attested Web2 reading attached. + /// + /// @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. + /// + /// Requiring two independent sources to agree is what defends a private stop + /// against the one attack privacy alone does not stop: an adversary who + /// cannot see the trigger can still walk a single price feed down until + /// *something* fires. They cannot walk two. + function tickAttestedWeb2(uint256 _orderId, IWeb2Json.Proof calldata _proof) external payable { + 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)); + require(bytes(source).length > 0, "attestation names no source"); + + Order storage o = _prepareTick(_orderId); + _send( + _orderId, + abi.encode( + _orderId, address(this), o.encrypted, o.peakE18, o.remaining, uint256(1), valueE18, observedAt, source + ) + ); + } + + /// @dev The liveness and rate-limit checks every tick shares. + function _prepareTick(uint256 _orderId) private returns (Order storage o) { + o = _orders[_orderId]; require(o.owner != address(0), "no such order"); require(!o.executed, "already executed"); require(!o.cancelled, "cancelled"); @@ -228,14 +441,16 @@ contract WraithOrders { require(block.timestamp >= o.nextTickAt, "ticked too recently"); o.nextTickAt = uint64(block.timestamp) + MIN_TICK_INTERVAL; + } + function _send(uint256 _orderId, bytes memory _message) private { address[] memory teeIds = TEE_MACHINE_REGISTRY.getRandomTeeIds(_getExtensionId(), 1); address[] memory cosigners = new address[](0); ITeeExtensionRegistry.TeeInstructionParams memory params = ITeeExtensionRegistry.TeeInstructionParams({ opType: OP_TYPE_WRAITH, opCommand: OP_COMMAND_EVAL_ORDER, - message: abi.encode(_orderId, address(this), o.encrypted, o.peakE18, o.remaining), + message: _message, cosigners: cosigners, cosignersThreshold: 0, claimBackAddress: msg.sender @@ -245,6 +460,21 @@ contract WraithOrders { emit OrderTicked(_orderId, instructionId); } + /// @dev Lowercase `0x`-prefixed hex of a bytes32, matching `vm.toString` + /// and JavaScript's `toHex`. The enclave compares sources as strings, so + /// both sides must agree on this rendering exactly. + function _toHexString(bytes32 _value) private pure returns (string memory) { + bytes memory alphabet = "0123456789abcdef"; + bytes memory out = new bytes(66); + out[0] = "0"; + out[1] = "x"; + for (uint256 i = 0; i < 32; ++i) { + out[2 + i * 2] = alphabet[uint8(_value[i]) >> 4]; + out[3 + i * 2] = alphabet[uint8(_value[i]) & 0x0f]; + } + return string(out); + } + /// @notice Settle an order using a TEE-signed result proving its condition fired. /// @dev The TEE node signs `keccak256(abi.encode(TEE_ACTION_RESULT_PREFIX, chainId, /// ActionResult.Hash()))` with its registered key under the EIP-191 personal-sign diff --git a/contracts/src/interfaces/IFdc.sol b/contracts/src/interfaces/IFdc.sol new file mode 100644 index 0000000..9df4985 --- /dev/null +++ b/contracts/src/interfaces/IFdc.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.27; + +/// @notice The subset of Flare's FDC interfaces Wraith needs, transcribed from +/// `flare-smart-contracts-v2` (`contracts/userInterfaces/fdc/`). +/// +/// These are declared locally rather than pulled from +/// `@flarenetwork/flare-periphery-contracts` on purpose: the structs are +/// consensus-critical calldata layouts, and vendoring exactly the two +/// attestation types Wraith consumes keeps the dependency surface — and the +/// audit surface — to what is actually used. Field order and types must match +/// the upstream interfaces byte for byte or the external call will decode +/// garbage. + +/// @notice Payment attestation (`0x01`) — a native-currency transfer on an +/// external chain. Wraith uses this for XRPL triggers. +interface IPayment { + struct RequestBody { + bytes32 transactionId; + uint256 inUtxo; + uint256 utxo; + } + + struct ResponseBody { + uint64 blockNumber; + uint64 blockTimestamp; + bytes32 sourceAddressHash; + bytes32 sourceAddressesRoot; + bytes32 receivingAddressHash; + bytes32 intendedReceivingAddressHash; + int256 spentAmount; + int256 intendedSpentAmount; + int256 receivedAmount; + int256 intendedReceivedAmount; + bytes32 standardPaymentReference; + bool oneToOne; + uint8 status; + } + + struct Response { + bytes32 attestationType; + bytes32 sourceId; + uint64 votingRound; + uint64 lowestUsedTimestamp; + RequestBody requestBody; + ResponseBody responseBody; + } + + struct Proof { + bytes32[] merkleProof; + Response data; + } +} + +/// @notice Web2Json attestation — an attested, jq-post-processed HTTP response. +/// Wraith uses this as the second oracle in a consensus order. +interface IWeb2Json { + struct RequestBody { + string url; + string httpMethod; + string headers; + string queryParams; + string body; + string postProcessJq; + string abiSignature; + } + + struct ResponseBody { + bytes abiEncodedData; + } + + struct Response { + bytes32 attestationType; + bytes32 sourceId; + uint64 votingRound; + uint64 lowestUsedTimestamp; + RequestBody requestBody; + ResponseBody responseBody; + } + + struct Proof { + bytes32[] merkleProof; + Response data; + } +} + +/// @notice On-chain verifier for FDC attestations. Resolved from the Flare +/// contract registry under the name `FdcVerification`. +interface IFdcVerification { + function verifyPayment(IPayment.Proof calldata _proof) external view returns (bool); + function verifyWeb2Json(IWeb2Json.Proof calldata _proof) external view returns (bool); +} diff --git a/contracts/test/WraithOrders.t.sol b/contracts/test/WraithOrders.t.sol index 6f99432..21ea9df 100644 --- a/contracts/test/WraithOrders.t.sol +++ b/contracts/test/WraithOrders.t.sol @@ -5,6 +5,7 @@ import { Test } from "forge-std/Test.sol"; import { WraithOrders } from "../src/WraithOrders.sol"; import { ITeeExtensionRegistry } from "../src/interfaces/ITeeExtensionRegistry.sol"; import { ITeeMachineRegistry } from "../src/interfaces/ITeeMachineRegistry.sol"; +import { IPayment, IWeb2Json } from "../src/interfaces/IFdc.sol"; contract MockERC20 { mapping(address => uint256) public balanceOf; @@ -50,6 +51,7 @@ contract MockExtensionRegistry { function sendInstructions(address[] calldata, ITeeExtensionRegistry.TeeInstructionParams calldata) external payable + virtual returns (bytes32) { return keccak256(abi.encodePacked("instruction", block.timestamp, msg.sender)); @@ -123,7 +125,7 @@ contract WraithOrdersTest is Test { uint64 internal constant EXPIRY = 1_000_000; uint256 internal constant ESCROW = 100 ether; - function setUp() public { + function setUp() public virtual { vm.warp(1000); teeAddr = vm.addr(TEE_PK); @@ -521,3 +523,256 @@ contract WraithPartialFillTest is WraithOrdersTest { assertEq(fxrp.balanceOf(alice), 70 ether, "refund must cover only the unspent escrow"); } } + +/// @notice Stands in for the on-chain FDC verifier. A real proof is a Merkle +/// branch against a finalized round; here the answer is set directly, so tests +/// can exercise both the accepting and the rejecting path. +contract MockFdcVerification { + bool public answer = true; + + function setAnswer(bool _answer) external { + answer = _answer; + } + + function verifyPayment(IPayment.Proof calldata) external view returns (bool) { + return answer; + } + + function verifyWeb2Json(IWeb2Json.Proof calldata) external view returns (bool) { + return answer; + } +} + +/// @notice Records the message handed to the TEE so tests can assert on what +/// the enclave will actually see. +contract RecordingExtensionRegistry is MockExtensionRegistry { + bytes public lastMessage; + + function sendInstructions(address[] calldata, ITeeExtensionRegistry.TeeInstructionParams calldata _params) + external + payable + override + returns (bytes32) + { + lastMessage = _params.message; + return keccak256(abi.encodePacked("instruction", block.timestamp, msg.sender)); + } +} + +contract WraithAttestedTickTest is WraithOrdersTest { + MockFdcVerification internal fdc; + RecordingExtensionRegistry internal recorder; + WraithOrders internal attested; + + bytes32 internal constant SOURCE_HASH = keccak256("rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe"); + + function setUp() public override { + super.setUp(); + + fdc = new MockFdcVerification(); + recorder = new RecordingExtensionRegistry(); + attested = + new WraithOrders(ITeeExtensionRegistry(address(recorder)), ITeeMachineRegistry(address(machineRegistry))); + recorder.setSender(address(attested)); + attested.setExtensionId(); + attested.setFdcVerification(address(fdc)); + + // A separate funder, so the inherited base-suite balance assertions + // still describe a wallet holding exactly one escrow. + fxrp.mint(bob, ESCROW); + } + + address internal bob = address(0xB0B); + + function _order() internal returns (uint256 orderId) { + vm.startPrank(bob); + fxrp.approve(address(attested), ESCROW); + orderId = attested.createOrder(hex"deadbeef", address(fxrp), ESCROW, EXPIRY); + vm.stopPrank(); + } + + function _paymentProof(int256 receivedDrops, uint64 ts) internal pure returns (IPayment.Proof memory p) { + p.data.responseBody.sourceAddressHash = SOURCE_HASH; + p.data.responseBody.receivedAmount = receivedDrops; + p.data.responseBody.blockTimestamp = ts; + p.data.responseBody.status = 0; + } + + function test_AttestedTickCarriesTheVerifiedReadingToTheEnclave() public { + uint256 orderId = _order(); + + // 3 XRP, in drops. + attested.tickAttested(orderId, _paymentProof(3_000_000, uint64(block.timestamp))); + + (,,,,, 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, "attestation not marked verified"); + assertEq(amountE18, 3 ether, "drops not scaled to 1e18"); + assertEq(at, block.timestamp, "attestation timestamp lost"); + assertEq(source, vm.toString(SOURCE_HASH), "source hash not relayed"); + } + + /// @dev The whole point of verifying on-chain is that the keeper cannot + /// assert a fact the FDC never attested. + function test_RevertWhen_ProofDoesNotVerify() public { + uint256 orderId = _order(); + fdc.setAnswer(false); + + vm.expectRevert("FDC rejected the proof"); + attested.tickAttested(orderId, _paymentProof(3_000_000, uint64(block.timestamp))); + } + + function test_RevertWhen_PaymentFailedOnTheSourceChain() public { + uint256 orderId = _order(); + IPayment.Proof memory p = _paymentProof(3_000_000, uint64(block.timestamp)); + p.data.responseBody.status = 1; // failed by sender + + vm.expectRevert("payment did not succeed"); + attested.tickAttested(orderId, p); + } + + function test_RevertWhen_NoVerifierIsConfigured() public { + uint256 orderId = _createOrder(); // the base fixture, which has no verifier + vm.expectRevert("FDC verification not set"); + wraith.tickAttested(orderId, _paymentProof(3_000_000, uint64(block.timestamp))); + } + + function test_Web2JsonTickRelaysThePostProcessedReading() public { + uint256 orderId = _order(); + + IWeb2Json.Proof memory p; + p.data.responseBody.abiEncodedData = + abi.encode("coingecko:flare", uint256(2.5 ether), uint256(block.timestamp)); + + attested.tickAttestedWeb2(orderId, p); + + (,,,,, 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(at, block.timestamp); + assertEq(source, "coingecko:flare"); + } + + /// @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 { + uint256 orderId = _order(); + attested.tickAttested(orderId, _paymentProof(3_000_000, uint64(block.timestamp))); + + vm.expectRevert("ticked too recently"); + attested.tickAttested(orderId, _paymentProof(3_000_000, uint64(block.timestamp))); + } +} + +contract WraithGaslessTest is WraithOrdersTest { + uint256 internal constant USER_PK = 0xBEEF; + address internal user; + address internal relayer = address(0xDEAD01); + + uint256 internal constant FEE = 1 ether; + + function setUp() public override { + super.setUp(); + user = vm.addr(USER_PK); + fxrp.mint(user, ESCROW + FEE); + vm.prank(user); + fxrp.approve(address(wraith), type(uint256).max); + } + + function _intent(address who, uint256 fee, uint256 deadline) + internal + view + returns (WraithOrders.CreateIntent memory) + { + return WraithOrders.CreateIntent({ + owner: who, + tokenIn: address(fxrp), + amountIn: ESCROW, + expiry: EXPIRY, + relayerFee: fee, + deadline: deadline + }); + } + + function _signIntent(uint256 pk, WraithOrders.CreateIntent memory intent) internal view returns (bytes memory) { + bytes32 structHash = keccak256( + abi.encode( + wraith.CREATE_ORDER_TYPEHASH(), + intent.owner, + keccak256(hex"deadbeef"), + intent.tokenIn, + intent.amountIn, + intent.expiry, + intent.relayerFee, + wraith.nonces(intent.owner), + intent.deadline + ) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", wraith.domainSeparator(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + return abi.encodePacked(r, s, v); + } + + /// @dev The point of the whole mechanism: a user holding FXRP but no FLR + /// can still open an order. + function test_RelayerOpensAnOrderForAUserWithNoGas() public { + WraithOrders.CreateIntent memory intent = _intent(user, FEE, block.timestamp + 3600); + bytes memory sig = _signIntent(USER_PK, intent); + + vm.prank(relayer); + uint256 orderId = wraith.createOrderFor(intent, hex"deadbeef", sig); + + (address o,, uint256 amountIn,,,,) = wraith.getOrder(orderId); + assertEq(o, user, "order not owned by the signer"); + assertEq(amountIn, ESCROW); + assertEq(fxrp.balanceOf(relayer), FEE, "relayer not reimbursed"); + assertEq(fxrp.balanceOf(address(wraith)), ESCROW, "escrow wrong"); + } + + function test_RevertWhen_SignatureIsFromSomeoneElse() public { + WraithOrders.CreateIntent memory intent = _intent(user, FEE, block.timestamp + 3600); + bytes memory sig = _signIntent(TEE_PK, intent); + + vm.prank(relayer); + vm.expectRevert("bad intent signature"); + wraith.createOrderFor(intent, hex"deadbeef", sig); + } + + /// @dev Without a nonce a relayer could replay one signature until the + /// user's whole balance was escrowed. + function test_RevertWhen_IntentIsReplayed() public { + WraithOrders.CreateIntent memory intent = _intent(user, FEE, block.timestamp + 3600); + bytes memory sig = _signIntent(USER_PK, intent); + + vm.startPrank(relayer); + wraith.createOrderFor(intent, hex"deadbeef", sig); + vm.expectRevert("bad intent signature"); + wraith.createOrderFor(intent, hex"deadbeef", sig); + vm.stopPrank(); + } + + function test_RevertWhen_IntentHasExpired() public { + WraithOrders.CreateIntent memory intent = _intent(user, FEE, block.timestamp - 1); + bytes memory sig = _signIntent(USER_PK, intent); + + vm.prank(relayer); + vm.expectRevert("intent expired"); + wraith.createOrderFor(intent, hex"deadbeef", sig); + } + + /// @dev A relayer must not be able to raise its own fee after the fact. + function test_RevertWhen_RelayerInflatesTheFee() public { + WraithOrders.CreateIntent memory intent = _intent(user, FEE, block.timestamp + 3600); + bytes memory sig = _signIntent(USER_PK, intent); + + intent.relayerFee = FEE * 10; + vm.prank(relayer); + vm.expectRevert("bad intent signature"); + wraith.createOrderFor(intent, hex"deadbeef", sig); + } +} diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 1087623..6ce2e7f 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -48,6 +48,19 @@ cast send $WRAITH 'setAssetManager(address)' $AM --rpc-url $COSTON2_RPC - cast send $WRAITH 'setRouter(address)' $ROUTER --rpc-url $COSTON2_RPC --private-key $DEPLOYER_KEY ``` +Cross-chain and consensus orders additionally need the FDC verifier. It lives in +the registry, so resolve it rather than pasting an address: + +```bash +FDC=$(cast call $REG 'getContractAddressByName(string)(address)' FdcVerification --rpc-url $COSTON2_RPC) +cast send $WRAITH 'setFdcVerification(address)' $FDC --rpc-url $COSTON2_RPC --private-key $DEPLOYER_KEY +``` + +Without it, `tickAttested` and `tickAttestedWeb2` revert with +`FDC verification not set` and the two attested kinds never fire — deliberately, +since an order that asked for a verified second source must not settle without +one. + `contracts/.env.deploy` holds these for local use and is gitignored. Source it with `set -a && . ./.env.deploy && set +a`. ## 2. Graft the extension onto the scaffold @@ -84,6 +97,12 @@ cast call $TEE_MACHINE_REGISTRY "getActiveTeeMachines(uint256)(address[],string[ ```bash cd keeper && npm install export WRAITH_ADDRESS=$WRAITH KEEPER_PRIVATE_KEY=0x... EXT_PROXY_URL=https:// + +# Optional: the second oracle consensus orders need. Unset, they never fire. +export FDC_API_URL=https://api.coingecko.com/api/v3/simple/price +export FDC_QUERY_PARAMS='{"ids":"flare-networks","vs_currencies":"usd","include_last_updated_at":"true"}' +export FDC_VERIFIER_API_KEY=... # issued by Flare, same channel as the indexer credentials + npm start ``` @@ -95,6 +114,18 @@ cp .env.example .env.local # fill in WRAITH_ADDRESS, FXRP, TOKEN_OUT, proxy npm run dev ``` +To offer gasless order creation, fund a separate key with C2FLR and set both +halves — the server key that signs, and the public flag that reveals the option: + +``` +RELAYER_PRIVATE_KEY=0x... # server-side only, never NEXT_PUBLIC_ +NEXT_PUBLIC_RELAYER_ENABLED=true +``` + +The relayer reimburses itself out of the escrowed token, so it needs only enough +C2FLR for gas. Leaving the flag unset hides the option, which is the right +default: offering a gasless path that then fails is worse than not offering one. + ## Smoke test 1. Create an order in the UI with a trigger that is currently false. Confirm the explorer shows only ciphertext. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4a044a7..3df0110 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -97,23 +97,96 @@ so the schedule cannot overdraw however the randomness falls. - [x] ABI: seed, chunks and window sealed; chunk amount in the result - [x] Frontend: Stealth tab with a fresh per-order seed -### Gasless via paymaster — planned - -Users who minted FXRP hold no FLR for gas. Needs either ERC-4337 plumbing or a -Wraith-sponsored relay that reclaims cost from the escrow. +### Gasless via paymaster — done + +A user who minted FXRP holds no FLR, so they cannot pay for the transaction that +would escrow it: the asset they want to protect is the one asset they cannot act +on. + +ERC-4337 was the wrong tool here. It would mean an EntryPoint, a bundler and an +account abstraction the user does not otherwise need, to solve a problem that is +one signature wide. Instead the user signs an EIP-712 intent — free, no chain +interaction — and any relayer submits it, reimbursing itself **in the escrowed +token** rather than in native gas. Settlement was already permissionless, so the +user never needs FLR at any point in an order's life. + +The relayer is trusted with nothing. Every field is covered by the signature, so +it cannot retarget the escrow, substitute different sealed terms, or raise its +own fee; the worst it can do is refuse to submit, and anyone else can then take +the same intent. A per-signer nonce makes each intent single-use, and it is +consumed before any transfer so a token with a callback cannot re-enter and +spend it twice. + +One honest limit: the ERC-20 allowance still needs one funded transaction from +the user the first time, unless the token supports EIP-2612. The UI therefore +takes a standing allowance on the gasless path and skips the approval entirely +when one already covers the order. + +- [x] Contract: `CreateIntent`, `createOrderFor`, EIP-712 domain, nonces +- [x] Contract tests: forged signature, replay, expiry, inflated fee +- [x] Relayer route that simulates before it spends +- [x] Frontend: gasless toggle, allowance reuse ## Tier 2 — deep Flare integration -### FDC cross-chain triggers — planned +### FDC cross-chain triggers — done FDC is **not callable from inside the enclave** (`docs/TRUST.md` §7), so the -Merkle proof must arrive via the on-chain instruction. This leaks the observed -data but never the threshold, which is the property that matters. - -### Multi-oracle consensus — planned - -Requires FDC Web2Json attestations alongside FTSO. Attestation rounds take -90–180s, so this cannot be a per-tick check; it needs a slower cadence. +proof arrives by a different route: the keeper fetches it, `tickAttested` +verifies it on-chain against a finalized attestation round, and only the +verified reading crosses into the enclave. By the time the extension sees it, +`verified` reflects a Merkle check rather than the keeper's word — which is what +lets the enclave refuse an unverified attestation outright instead of having to +trust whoever relayed it. + +What this publishes is the *observed* fact: that some XRPL payment landed. XRPL +already published that. What it does not publish is the amount that fires the +order, which stays in the ciphertext. + +The watched address does not appear on-chain either. It travels as the FDC +standard address hash, in the sealed terms and in the tick alike, so the two +sides can match without either publishing the account. The hash is pinned to +Flare's own published XRPL vector in a test — matching a documented value proves +it is the hash FDC computes, not merely one both halves of this repo agree on. + +The instruction grew four slots for this, and the enclave distinguishes "no proof +offered" from "a proof was offered and the chain rejected it" by whether the +message carries them at all. Only the second is evidence of a hostile keeper, so +collapsing them into a zero-valued attestation would have thrown the signal away. + +- [x] Contract: `IPayment` proof verified on-chain, drops scaled to 1e18 +- [x] Instruction carries the verified reading; enclave refuses a plain tick +- [x] `trigger`: source match, verification and freshness checked before threshold +- [x] Frontend: Cross-chain tab, address hashed in-browser + +### Multi-oracle consensus — done + +Privacy stops someone aiming at a trigger they cannot see. It does not stop them +walking a single price feed until *something* fires. A consensus order closes +that: it settles only when FTSO **and** an FDC-attested off-chain price both +cross the threshold. Forcing two independent sources in the same direction at +the same moment is a different and much harder problem than nudging one. + +A deviation tolerance sits on top, and deliberately does the opposite of firing: +when the two sources disagree beyond it, the order refuses to act at all. A wide +gap between two honest sources means one of them is wrong, and acting on either +reading is worse than waiting. + +Rounds take 90–180s, so this cannot be a per-tick request. The keeper requests +one attestation and reuses it across every order ticked in a ten-minute window — +the same reading either way, so per-order requests would cost more and assure +nothing extra. An attested tick is a strict superset of a plain one, so orders +that need no second oracle ignore the attached reading. + +The tolerance shares a wire slot with the trailing stop's trail distance. The +kinds are mutually exclusive, so the two never coexist in one order, and +widening the sealed layout for a second basis-point field would have cost every +order the bytes for nothing. + +- [x] Contract: `tickAttestedWeb2`, `(source, valueE18, timestamp)` decode +- [x] `trigger`: both-cross agreement, deviation circuit breaker, staleness +- [x] Keeper: request, wait for finalization, fetch proof, reuse within a window +- [x] Frontend: Consensus tab ## Tier 3 — stretch @@ -126,6 +199,8 @@ Requires FDC Web2Json attestations alongside FTSO. Attestation rounds take ## Shipped - Private stop-loss / take-profit with TEE evaluation +- Gasless order creation via signed intents and a sponsored relay +- FDC cross-chain triggers and multi-oracle consensus - OCO brackets (one escrow, two legs, first to fire settles) - Registry-backed signer verification, replay guards, expiry, cancellation - Owner-only local recall of a sealed condition diff --git a/docs/TRUST.md b/docs/TRUST.md index 259d2ce..40870ae 100644 --- a/docs/TRUST.md +++ b/docs/TRUST.md @@ -54,17 +54,31 @@ Wraith therefore does not have a TEE sign XRPL transactions directly. Cross-chai ### 7. FDC is not callable from inside the enclave -The TEE-based FDC is likewise a system application with no developer SDK surface. Cross-chain triggers take their Merkle proof from the on-chain instruction payload rather than fetching it in-enclave. +The TEE-based FDC is likewise a system application with no developer SDK surface. Cross-chain and consensus triggers therefore take their proof from outside: the keeper fetches it, `tickAttested` / `tickAttestedWeb2` verify it on-chain against a finalized attestation round, and only the verified reading crosses into the enclave. -This makes the *observed data* public — but never the threshold it is compared against. Knowing "an XRPL payment of 100 XRP landed" tells an observer nothing about which orders, if any, care. +This is what lets the enclave refuse an unverified attestation outright rather than having to trust whoever relayed it — by the time the extension sees `verified`, it reflects a Merkle check, not the keeper's claim. The enclave also distinguishes "no proof was offered" from "a proof was offered and the chain rejected it", because only the second is evidence of a hostile keeper. -### 8. The keeper can censor, but cannot lie +This makes the *observed data* public — but never the threshold it is compared against. Knowing "an XRPL payment of 100 XRP landed" tells an observer nothing about which orders, if any, care. The watched address is not published either: it travels as the FDC standard address hash on both sides. + +### 7a. A consensus order needs two sources to agree + +Privacy stops someone aiming at a trigger they cannot see; it does not stop them walking a single price feed until something fires. A consensus order settles only when FTSO and an FDC-attested off-chain price both cross the threshold, and refuses to act at all when the two disagree beyond a sealed tolerance. + +The residual assumption is the attested source itself. A consensus order trusts that the Web2 endpoint behind the attestation is not controlled by the same adversary as the FTSO feed — FDC proves the API *said* something, not that it was right. + +### 8. A relayer can refuse, but cannot alter + +Gasless creation has the user sign an EIP-712 intent that a relayer submits. Every field is covered by the signature, so the relayer cannot retarget the escrow, substitute different sealed terms, or raise its own fee. A per-signer nonce makes each intent single-use. + +The relayer can decline to submit — at which point anyone else can take the same intent, exactly as with keepers. The one thing a gasless user still needs a funded transaction for is the initial ERC-20 allowance, unless the token supports EIP-2612. + +### 9. The keeper can censor, but cannot lie The keeper sees ciphertext it cannot read, and the TEE reads FTSO itself rather than accepting a price from the keeper. A hostile keeper cannot forge a trigger or feed a false price. It *can* refuse to tick — which is why ticking is permissionless and any keeper can cover for another. A keeper does learn whether a tick fired, since it must relay the result to settle it. It never learns the threshold or how close an order was to it. -### 9. Coston2 only +### 10. Coston2 only Flare Confidential Compute is "in the final stages of development and is not yet a fully public production system." Wraith targets Coston2. Do not put real funds behind this. diff --git a/extension/README.md b/extension/README.md index 34f4b9d..2a531d2 100644 --- a/extension/README.md +++ b/extension/README.md @@ -38,15 +38,40 @@ The `OPType` and `OPCommand` strings must be identical in Solidity and Go. A mis Do not use an `OPType` beginning with `F_` — that prefix is reserved for Flare system operations. +## Condition kinds + +`trigger.Kind` decides which sealed fields are meaningful and which evaluator +judges the order. Keeping them apart matters: evaluating one kind as another +would read uninitialised state. + +| Kind | Secret it carries | Judged against | +| --- | --- | --- | +| `0` price | threshold, direction, optional bracket leg | FTSO | +| `1` agent health | collateral floor, agent | `AssetManager.getAgentInfo` | +| `2` trailing | trail distance | FTSO + the on-chain peak | +| `3` TWAP | schedule seed | derived schedule + remaining escrow | +| `4` cross-chain | payment size, watched address (hashed) | FDC-attested payment | +| `5` consensus | threshold, deviation tolerance | FTSO **and** an FDC-attested price | + +Kinds 4 and 5 require an attested tick. The instruction carries four extra slots +for the verified reading, and their absence is treated as *no proof offered* — +distinct from a proof the chain rejected, because only the latter says anything +about the keeper. + +Trailing and consensus share the `trailBIPS` wire slot: the kinds are mutually +exclusive, so a trail distance and a deviation tolerance never coexist in one +order, and widening the sealed layout for a second basis-point field would cost +every order the bytes for nothing. + ## Handler shape ``` POST /action (WRAITH / EVAL_ORDER) │ - ├─ decode instruction → (orderId, contractAddr, ciphertext) + ├─ decode instruction → (orderId, contractAddr, ciphertext, peak, remaining, attestation?) ├─ decrypt ciphertext via the TEE node /decrypt endpoint on SIGN_PORT ├─ read the FTSO feed over RPC, from inside the enclave - ├─ trigger.Evaluate(terms, observation, now) + ├─ dispatch on terms.Kind → the matching evaluator │ ├─ not fired → status 1, no-op result (indistinguishable from any other tick) └─ fired → status 1, ABI-encoded result for WraithOrders.execute() @@ -58,4 +83,4 @@ Reading FTSO from inside the enclave rather than accepting a price from the keep **State is volatile.** The enclave has no sealed storage, so nothing here may be treated as durable. The on-chain ciphertext is the canonical copy of an order and is re-decrypted on every tick. -**FDC is not callable from inside the enclave.** The TEE-based FDC is a Flare *system* application with no developer SDK surface. Cross-chain triggers therefore take their attestation proof from the instruction payload rather than fetching it here. Only the *observed data* is public that way — the threshold it is compared against stays secret, which is the property that matters. +**FDC is not callable from inside the enclave.** The TEE-based FDC is a Flare *system* application with no developer SDK surface. Cross-chain and consensus triggers therefore take their attestation from the instruction payload: the keeper fetches the proof, `WraithOrders` verifies it on-chain, and only the verified reading arrives here. That is what lets this code refuse an unverified attestation outright instead of trusting whoever relayed it. Only the *observed data* is public that way — the threshold it is compared against stays secret, which is the property that matters. diff --git a/extension/internal/enclave/abi.go b/extension/internal/enclave/abi.go index a4d0da3..bf91e37 100644 --- a/extension/internal/enclave/abi.go +++ b/extension/internal/enclave/abi.go @@ -13,6 +13,7 @@ import ( "fmt" "math/big" "strings" + "time" "github.com/LSUDOKO/Wraith/extension/internal/trigger" ) @@ -117,9 +118,23 @@ type Instruction struct { // RemainingE18 is escrow the order has not spent yet. A chunked order // infers its progress from this rather than from anything remembered. RemainingE18 *big.Int + // Attestation is an FDC-verified reading relayed in by tickAttested(), or + // nil when the order was poked by a plain tick(). + // + // Nil and "present but unverified" are deliberately different: the enclave + // must be able to tell "no proof was offered" from "a proof was offered and + // the chain rejected it", because only the second is evidence of a hostile + // keeper. + Attestation *trigger.Attestation } -// DecodeInstruction parses the message from WraithOrders.tick(). +// attestedHeadSlots is the head length of a message from tickAttested(). A +// plain tick() sends five slots; anything shorter than this carries no +// attestation. +const attestedHeadSlots = 9 + +// DecodeInstruction parses the message from WraithOrders.tick() or +// tickAttested(). func DecodeInstruction(data []byte) (*Instruction, error) { orderID, err := slotUint64(data, 0) if err != nil { @@ -153,12 +168,51 @@ func DecodeInstruction(data []byte) (*Instruction, error) { remaining = new(big.Int) } - return &Instruction{ - OrderID: orderID, - Contract: contract, - Ciphertext: data[offset+word : offset+word+length.Uint64()], + inst := &Instruction{ + OrderID: orderID, + Contract: contract, + Ciphertext: data[offset+word : offset+word+length.Uint64()], PeakE18: peak, RemainingE18: remaining, + } + + // The ciphertext offset is the message's own statement of how long its head + // is, so it distinguishes a plain tick from an attested one without a flag. + if offset >= attestedHeadSlots*word { + att, aerr := decodeAttestation(data) + if aerr != nil { + return nil, aerr + } + inst.Attestation = att + } + + return inst, nil +} + +// decodeAttestation reads the four attestation slots tickAttested() appends. +func decodeAttestation(data []byte) (*trigger.Attestation, error) { + verified, err := slotUint64(data, 5) + if err != nil { + return nil, err + } + amount, err := slotBig(data, 6) + if err != nil { + return nil, err + } + at, err := slotUint64(data, 7) + if err != nil { + return nil, err + } + source, err := slotString(data, 8) + if err != nil { + return nil, fmt.Errorf("attestation source: %w", err) + } + + return &trigger.Attestation{ + Verified: verified != 0, + Source: source, + AmountE18: amount, + At: time.Unix(int64(at), 0).UTC(), }, nil } @@ -260,13 +314,13 @@ func DecodeTerms(data []byte) (*trigger.Terms, error) { } return &trigger.Terms{ - Contract: contract, - FeedID: feedID, - Direction: trigger.Direction(direction), - ThresholdE18: threshold, - Action: trigger.Action(action), - MinOutOrLots: minOutOrLots, - TokenOut: tokenOut, + Contract: contract, + FeedID: feedID, + Direction: trigger.Direction(direction), + ThresholdE18: threshold, + Action: trigger.Action(action), + MinOutOrLots: minOutOrLots, + TokenOut: tokenOut, UnderlyingAddress: underlying, Expiry: expiry, SecondThresholdE18: secondThreshold, @@ -274,10 +328,15 @@ func DecodeTerms(data []byte) (*trigger.Terms, error) { Agent: agent, MinCollateralBIPS: minCollateral, TrailBIPS: trail, - Seed: seed, - Chunks: chunks, - StartAt: startAt, - EndAt: endAt, + // One wire slot, two meanings — the kinds are mutually exclusive, so a + // trailing stop never reads the tolerance and a consensus order never + // reads the trail. Widening the sealed layout for a second bips field + // would cost every order the bytes and give nothing back. + MaxDeviationBIPS: trail, + Seed: seed, + Chunks: chunks, + StartAt: startAt, + EndAt: endAt, }, nil } diff --git a/extension/internal/enclave/enclave_test.go b/extension/internal/enclave/enclave_test.go index 674b78c..4b4ff21 100644 --- a/extension/internal/enclave/enclave_test.go +++ b/extension/internal/enclave/enclave_test.go @@ -594,3 +594,154 @@ func TestEncodeResult_CarriesTheChunkAmount(t *testing.T) { t.Fatalf("chunk = %s, want %s", got, chunk) } } + +// --- attested instructions: cross-chain and consensus --- + +// encodeInstructionAttested mirrors tickAttested(): the message carries an +// FDC-verified reading alongside the ciphertext, because the enclave cannot +// reach FDC itself. +func encodeInstructionAttested( + t *testing.T, orderID uint64, contract string, ciphertext []byte, + verified bool, amountE18 *big.Int, at uint64, source string, +) []byte { + t.Helper() + + cipherTail := uintWord(big.NewInt(int64(len(ciphertext)))) + padded := make([]byte, (len(ciphertext)+31)/32*32) + copy(padded, ciphertext) + cipherTail = append(cipherTail, padded...) + + verifiedWord := big.NewInt(0) + if verified { + verifiedWord = big.NewInt(1) + } + + head := uintWord(new(big.Int).SetUint64(orderID)) + head = append(head, addrWord(t, contract)...) + head = append(head, uintWord(big.NewInt(9*32))...) // ciphertext offset + head = append(head, uintWord(big.NewInt(0))...) // peak + head = append(head, uintWord(big.NewInt(0))...) // remaining + head = append(head, uintWord(verifiedWord)...) + head = append(head, uintWord(amountE18)...) + head = append(head, uintWord(new(big.Int).SetUint64(at))...) + head = append(head, uintWord(big.NewInt(9*32+int64(len(cipherTail))))...) // source offset + + out := append(head, cipherTail...) + return append(out, stringTail(source)...) +} + +func TestDecodeInstruction_CarriesAttestation(t *testing.T) { + cipher := []byte("ciphertext") + msg := encodeInstructionAttested( + t, 4, wraithAddr, cipher, true, big.NewInt(7e18), uint64(now.Unix()), "rSourceAddress") + + got, err := DecodeInstruction(msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got.Ciphertext) != string(cipher) { + t.Errorf("ciphertext = %q, want %q", got.Ciphertext, cipher) + } + if got.Attestation == nil { + t.Fatal("Attestation = nil, want a reading") + } + if !got.Attestation.Verified { + t.Error("Verified = false, want true") + } + if got.Attestation.AmountE18.Cmp(big.NewInt(7e18)) != 0 { + t.Errorf("AmountE18 = %s, want 7e18", got.Attestation.AmountE18) + } + if got.Attestation.Source != "rSourceAddress" { + t.Errorf("Source = %q, want %q", got.Attestation.Source, "rSourceAddress") + } + if !got.Attestation.At.Equal(now) { + t.Errorf("At = %s, want %s", got.Attestation.At, now) + } +} + +// A plain tick() carries no attestation. Decoding must say so rather than +// inventing a zero-valued one, which would read as "verified: false" and be +// indistinguishable from a rejected proof. +func TestDecodeInstruction_PlainTickHasNoAttestation(t *testing.T) { + got, err := DecodeInstruction(encodeInstruction(t, 1, wraithAddr, []byte("cipher"))) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Attestation != nil { + t.Errorf("Attestation = %+v, want nil", got.Attestation) + } +} + +// crossChainTerms watches the XRPL address the terms encoder hardcodes. +func crossChainTerms(t *testing.T) []byte { + t.Helper() + return encodeTermsAll(t, "above", big.NewInt(5e18), 0, uint64(now.Unix())+3600, big.NewInt(0), + 4, "0x0000000000000000000000000000000000000000", 0, 0) +} + +func TestHandler_CrossChainFiresOnAttestedPayment(t *testing.T) { + h, cleanup := harness(t, crossChainTerms(t), big.NewInt(1_000_000)) + defer cleanup() + + msg := encodeInstructionAttested(t, 1, wraithAddr, []byte("cipher"), + true, big.NewInt(9e18), uint64(now.Unix()), "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe") + + out := h.Evaluate(context.Background(), msg) + if out.Status != 1 { + t.Fatalf("status = %d, log %q", out.Status, out.Log) + } + if len(out.Data) == 0 { + t.Fatalf("no result data: the attested amount cleared the threshold. log %q", out.Log) + } +} + +func TestHandler_CrossChainWithoutAttestationIsAnError(t *testing.T) { + h, cleanup := harness(t, crossChainTerms(t), big.NewInt(1_000_000)) + defer cleanup() + + out := h.Evaluate(context.Background(), encodeInstruction(t, 1, wraithAddr, []byte("cipher"))) + if out.Status != 0 { + t.Fatalf("status = %d, want 0: a cross-chain order cannot be judged without a proof. log %q", + out.Status, out.Log) + } +} + +// consensusTermsBytes is a stop-loss at $2 that needs both oracles to agree, +// with a 10% deviation tolerance carried in the trail slot. +func consensusTermsBytes(t *testing.T) []byte { + t.Helper() + return encodeTermsAll(t, "below", big.NewInt(2e18), 0, uint64(now.Unix())+3600, big.NewInt(0), + 5, "0x0000000000000000000000000000000000000000", 0, 1000) +} + +func TestHandler_ConsensusFiresWhenBothOraclesCross(t *testing.T) { + h, cleanup := harness(t, consensusTermsBytes(t), big.NewInt(1_900_000)) // FTSO $1.90 + defer cleanup() + + msg := encodeInstructionAttested(t, 1, wraithAddr, []byte("cipher"), + true, big.NewInt(1_950_000_000_000_000_000), uint64(now.Unix()), "consensus") // attested $1.95 + + out := h.Evaluate(context.Background(), msg) + if out.Status != 1 { + t.Fatalf("status = %d, log %q", out.Status, out.Log) + } + if len(out.Data) == 0 { + t.Fatalf("no result data: both oracles are below the $2 stop. log %q", out.Log) + } +} + +func TestHandler_ConsensusHoldsWhenOnlyFtsoCrosses(t *testing.T) { + h, cleanup := harness(t, consensusTermsBytes(t), big.NewInt(1_950_000)) // FTSO $1.95 + defer cleanup() + + msg := encodeInstructionAttested(t, 1, wraithAddr, []byte("cipher"), + true, big.NewInt(2_050_000_000_000_000_000), uint64(now.Unix()), "consensus") // attested $2.05 + + out := h.Evaluate(context.Background(), msg) + if out.Status != 1 { + t.Fatalf("status = %d, log %q", out.Status, out.Log) + } + if len(out.Data) != 0 { + t.Fatal("fired on one oracle alone: the second source is still above the stop") + } +} diff --git a/extension/internal/enclave/handler.go b/extension/internal/enclave/handler.go index 1f4657c..c40821f 100644 --- a/extension/internal/enclave/handler.go +++ b/extension/internal/enclave/handler.go @@ -2,9 +2,9 @@ package enclave import ( "context" - "math/big" "errors" "fmt" + "math/big" "strings" "time" @@ -107,6 +107,28 @@ func (h *Handler) Evaluate(ctx context.Context, message []byte) Outcome { decision, newPeak = trailing.Decision, trailing.NewPeakE18 } + case trigger.KindCrossChain: + // FDC is a Flare system application with no interface the enclave can + // call, so the proof is verified on-chain and relayed in. No proof means + // no answer — refusing is the only safe reply, since firing would mean + // trusting the keeper. + if inst.Attestation == nil { + return Outcome{Status: 0, Log: fmt.Sprintf( + "order %d: cross-chain orders need an attested tick", inst.OrderID)} + } + decision, err = trigger.EvaluateCrossChain(terms, inst.Attestation, now()) + + case trigger.KindConsensus: + if inst.Attestation == nil { + return Outcome{Status: 0, Log: fmt.Sprintf( + "order %d: consensus orders need a second attested oracle", inst.OrderID)} + } + obs, oerr := h.Ftso.Read(ctx, terms.FeedID) + if oerr != nil { + return Outcome{Status: 0, Log: fmt.Sprintf("order %d: price read failed: %v", inst.OrderID, oerr)} + } + decision, err = trigger.EvaluateConsensus(terms, obs, inst.Attestation, now()) + default: obs, oerr := h.Ftso.Read(ctx, terms.FeedID) if oerr != nil { diff --git a/extension/internal/trigger/trigger.go b/extension/internal/trigger/trigger.go index e5e3523..39beff2 100644 --- a/extension/internal/trigger/trigger.go +++ b/extension/internal/trigger/trigger.go @@ -43,6 +43,9 @@ const ( // KindCrossChain fires on an FDC-attested fact from another chain or a // Web2 source. KindCrossChain Kind = 4 + // KindConsensus is a price order that requires two independent oracles — + // FTSO and an FDC-attested off-chain source — to agree before it fires. + KindConsensus Kind = 5 ) // AgentStatus mirrors AgentInfo.Status in the FAssets AssetManager. @@ -110,6 +113,17 @@ type Terms struct { // tells an observer nothing without it. TrailBIPS uint64 + // --- KindConsensus --- + + // MaxDeviationBIPS is how far the two oracles may disagree before the + // order refuses to act at all. 500 is 5%. Zero disables the check, leaving + // only the requirement that both sources cross the threshold. + // + // This is a circuit breaker rather than a trigger: a wide gap between two + // honest sources means one of them is wrong, and acting on either is worse + // than waiting. + MaxDeviationBIPS uint64 + // --- KindTWAP --- // Seed drives the schedule's randomization. It is the secret that makes a @@ -170,27 +184,28 @@ type Decision struct { } var ( - ErrNilTerms = errors.New("nil terms") - ErrNilObservation = errors.New("nil observation") - ErrNoThreshold = errors.New("terms missing threshold") - ErrBadDirection = errors.New("unknown direction") - ErrBadAction = errors.New("unknown action") - ErrExpired = errors.New("order expired") - ErrStalePrice = errors.New("price observation is stale") - ErrBadDecimals = errors.New("feed decimals out of range") - ErrContractMissing = errors.New("terms missing contract address") - ErrBadRedeem = errors.New("redeem requires lots and an underlying address") - ErrBadSwap = errors.New("swap requires a positive minimum output and a token out") - ErrBadBracket = errors.New("bracket legs overlap: the take-profit must sit beyond the stop") - ErrBadShield = errors.New("shield requires an agent and a sane collateral threshold") - ErrNoAgentHealth = errors.New("no agent health reading") - ErrWrongKind = errors.New("order evaluated against the wrong condition kind") - ErrBadTrail = errors.New("trail distance must be above zero and below 100%") - ErrBadTWAP = errors.New("twap needs a sane chunk count and a window that moves forward") - ErrBadCrossChain = errors.New("cross-chain trigger needs a source and a positive threshold") - ErrUnverified = errors.New("attestation is not FDC-verified") - ErrSourceMismatch = errors.New("attestation concerns a different source") - ErrStaleAttestation = errors.New("attestation is stale") + ErrNilTerms = errors.New("nil terms") + ErrNilObservation = errors.New("nil observation") + ErrNoThreshold = errors.New("terms missing threshold") + ErrBadDirection = errors.New("unknown direction") + ErrBadAction = errors.New("unknown action") + ErrExpired = errors.New("order expired") + ErrStalePrice = errors.New("price observation is stale") + ErrBadDecimals = errors.New("feed decimals out of range") + ErrContractMissing = errors.New("terms missing contract address") + ErrBadRedeem = errors.New("redeem requires lots and an underlying address") + ErrBadSwap = errors.New("swap requires a positive minimum output and a token out") + ErrBadBracket = errors.New("bracket legs overlap: the take-profit must sit beyond the stop") + ErrBadShield = errors.New("shield requires an agent and a sane collateral threshold") + ErrNoAgentHealth = errors.New("no agent health reading") + ErrWrongKind = errors.New("order evaluated against the wrong condition kind") + ErrBadTrail = errors.New("trail distance must be above zero and below 100%") + ErrBadTWAP = errors.New("twap needs a sane chunk count and a window that moves forward") + ErrBadCrossChain = errors.New("cross-chain trigger needs a source and a positive threshold") + ErrUnverified = errors.New("attestation is not FDC-verified") + ErrSourceMismatch = errors.New("attestation concerns a different source") + ErrStaleAttestation = errors.New("attestation is stale") + ErrOracleDisagreement = errors.New("oracles disagree beyond the permitted deviation") ) // maxAttestationAge bounds how old an FDC observation may be. Rounds take @@ -277,6 +292,12 @@ func (t *Terms) Validate() error { return fmt.Errorf("%w: %q", ErrBadDirection, t.Direction) } + // A tolerance at or beyond 100% would let any two readings count as + // agreement, which is the same as having no second oracle at all. + if t.MaxDeviationBIPS >= bipsDenominator { + return fmt.Errorf("%w: deviation tolerance %d bips", ErrOracleDisagreement, t.MaxDeviationBIPS) + } + if t.SecondThresholdE18 != nil { if t.SecondThresholdE18.Sign() <= 0 { return ErrNoThreshold @@ -363,6 +384,127 @@ func EvaluateCrossChain(t *Terms, att *Attestation, now time.Time) (Decision, er }, nil } +// crosses reports whether a price has reached either leg of the order's +// threshold. Shared by the single-oracle and consensus paths so the two can +// never drift apart on what "triggered" means. +func crosses(priceE18 *big.Int, t *Terms) (bool, error) { + cmp := priceE18.Cmp(t.ThresholdE18) + + // Both boundaries are inclusive: a stop set at exactly the traded price + // should fire, which is what a trader expects from "stop at X". + var fire bool + switch t.Direction { + case Below: + fire = cmp <= 0 + case Above: + fire = cmp >= 0 + default: + return false, fmt.Errorf("%w: %q", ErrBadDirection, t.Direction) + } + + // The bracket's opposite leg. + if !fire && t.SecondThresholdE18 != nil { + second := priceE18.Cmp(t.SecondThresholdE18) + if t.Direction == Below { + fire = second >= 0 // stop below, take-profit above + } else { + fire = second <= 0 // take-profit above, stop below + } + } + + return fire, nil +} + +// EvaluateConsensus fires a price order only when two independent oracles agree +// the level has been crossed: Flare's own FTSO feed, and an FDC-attested +// off-chain price relayed in with the instruction. +// +// This closes the single-oracle attack on a private stop. An adversary who can +// nudge one price source can force a stop to fire early; forcing two +// independent sources in the same direction at the same moment is a different +// and much harder problem. Requiring both to cross is the whole mechanism — +// the deviation guard on top only stops the order acting during an outage, +// where the sources disagree so widely that neither can be trusted. +func EvaluateConsensus(t *Terms, obs *Observation, att *Attestation, now time.Time) (Decision, error) { + if t == nil { + return Decision{}, ErrNilTerms + } + if t.Kind != KindConsensus { + return Decision{}, fmt.Errorf("%w: want consensus, got kind %d", ErrWrongKind, t.Kind) + } + if err := t.Validate(); err != nil { + return Decision{}, err + } + if obs == nil || obs.Value == nil { + return Decision{}, ErrNilObservation + } + if att == nil || att.AmountE18 == nil { + return Decision{}, ErrNilObservation + } + if t.Expiry != 0 && uint64(now.Unix()) >= t.Expiry { + return Decision{}, ErrExpired + } + + if age := absAge(now, obs.Time); age > maxPriceAge { + return Decision{}, fmt.Errorf("%w: %s old", ErrStalePrice, age) + } + + // An unverified attestation is the keeper's claim, not FDC's finding — + // accepting it would collapse the two oracles back into one. + if !att.Verified { + return Decision{}, ErrUnverified + } + if age := absAge(now, att.At); age > maxAttestationAge { + return Decision{}, fmt.Errorf("%w: %s old", ErrStaleAttestation, age) + } + + ftsoE18, err := NormalizeE18(obs.Value, obs.Decimals) + if err != nil { + return Decision{}, err + } + if ftsoE18.Sign() <= 0 { + return Decision{}, ErrNilObservation + } + + // deviation = |ftso - attested| / ftso, in basis points. + if t.MaxDeviationBIPS > 0 { + gap := new(big.Int).Sub(ftsoE18, att.AmountE18) + gap.Abs(gap) + gap.Mul(gap, big.NewInt(bipsDenominator)) + gap.Quo(gap, ftsoE18) + if gap.Cmp(new(big.Int).SetUint64(t.MaxDeviationBIPS)) > 0 { + return Decision{}, fmt.Errorf( + "%w: %s bips apart, tolerance %d", ErrOracleDisagreement, gap, t.MaxDeviationBIPS) + } + } + + ftsoFired, err := crosses(ftsoE18, t) + if err != nil { + return Decision{}, err + } + attFired, err := crosses(att.AmountE18, t) + if err != nil { + return Decision{}, err + } + + return Decision{ + Fire: ftsoFired && attFired, + Reason: fmt.Sprintf( + "ftso %s (fired %v) / attested %s (fired %v) vs threshold %s (%s)", + ftsoE18, ftsoFired, att.AmountE18, attFired, t.ThresholdE18, t.Direction), + }, nil +} + +// absAge is the distance between two instants, regardless of order. Clocks in +// the enclave and on the source can disagree in either direction. +func absAge(now, then time.Time) time.Duration { + age := now.Sub(then) + if age < 0 { + return -age + } + return age +} + // TWAPDecision is a Decision plus the size of the chunk to release now. type TWAPDecision struct { Decision @@ -637,28 +779,9 @@ func Evaluate(t *Terms, obs *Observation, now time.Time) (Decision, error) { return Decision{}, err } - cmp := priceE18.Cmp(t.ThresholdE18) - - // Both boundaries are inclusive: a stop set at exactly the traded price - // should fire, which is what a trader expects from "stop at X". - var fire bool - switch t.Direction { - case Below: - fire = cmp <= 0 - case Above: - fire = cmp >= 0 - default: - return Decision{}, fmt.Errorf("%w: %q", ErrBadDirection, t.Direction) - } - - // The bracket's opposite leg. - if !fire && t.SecondThresholdE18 != nil { - second := priceE18.Cmp(t.SecondThresholdE18) - if t.Direction == Below { - fire = second >= 0 // stop below, take-profit above - } else { - fire = second <= 0 // take-profit above, stop below - } + fire, err := crosses(priceE18, t) + if err != nil { + return Decision{}, err } return Decision{ diff --git a/extension/internal/trigger/trigger_test.go b/extension/internal/trigger/trigger_test.go index d05a0c5..df0def5 100644 --- a/extension/internal/trigger/trigger_test.go +++ b/extension/internal/trigger/trigger_test.go @@ -762,3 +762,109 @@ func TestEvaluateCrossChain_RefusesAPriceOrder(t *testing.T) { t.Fatalf("got %v, want ErrWrongKind", err) } } + +// --- Multi-oracle consensus --- + +// consensusTerms is a stop-loss that only fires when both FTSO and an +// FDC-attested off-chain price agree the level has been crossed. +func consensusTerms() *Terms { + t := validTerms() + t.Kind = KindConsensus + t.MaxDeviationBIPS = 500 // 5% + return t +} + +// attested builds a verified Web2Json-style reading at `price` dollars. +func attested(price int64, at time.Time) *Attestation { + return &Attestation{ + Verified: true, + Source: "consensus", + AmountE18: e18(price), + At: at, + } +} + +func TestEvaluateConsensus_FiresOnlyWhenBothOraclesCross(t *testing.T) { + tests := []struct { + name string + ftsoPrice int64 + attPrice int64 + want bool + }{ + {"both above the stop", 3, 3, false}, + {"both at the stop", 2, 2, true}, + {"both below the stop", 1, 1, true}, + {"only ftso crossed", 2, 3, false}, + {"only the attestation crossed", 3, 2, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + terms := consensusTerms() + terms.MaxDeviationBIPS = 0 // deviation guard off; this test is about agreement + got, err := EvaluateConsensus(terms, obs(tc.ftsoPrice, 6, now), attested(tc.attPrice, now), now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Fire != tc.want { + t.Errorf("ftso %d, attested %d: Fire = %v, want %v", tc.ftsoPrice, tc.attPrice, got.Fire, tc.want) + } + }) + } +} + +func TestEvaluateConsensus_RefusesWhenOraclesDisagreeTooFar(t *testing.T) { + // Both cross the $2 stop, but the sources are 50% apart — a data-quality + // incident, not a market move. Refusing is safer than acting. + _, err := EvaluateConsensus(consensusTerms(), obs(2, 6, now), attested(1, now), now) + if !errors.Is(err, ErrOracleDisagreement) { + t.Fatalf("err = %v, want ErrOracleDisagreement", err) + } +} + +func TestEvaluateConsensus_AcceptsDeviationInsideTolerance(t *testing.T) { + terms := consensusTerms() + // FTSO $2.00 vs attested $2.04 — 2% apart, inside the 5% tolerance. + att := &Attestation{Verified: true, Source: "consensus", AmountE18: big.NewInt(2_040_000_000_000_000_000), At: now} + got, err := EvaluateConsensus(terms, obs(2, 6, now), att, now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Only FTSO crossed, so it must not fire — but it must not error either. + if got.Fire { + t.Errorf("Fire = true, want false: the attested price is still above the stop") + } +} + +func TestEvaluateConsensus_RefusesUnverifiedAttestation(t *testing.T) { + att := attested(1, now) + att.Verified = false + if _, err := EvaluateConsensus(consensusTerms(), obs(1, 6, now), att, now); !errors.Is(err, ErrUnverified) { + t.Fatalf("err = %v, want ErrUnverified", err) + } +} + +func TestEvaluateConsensus_RejectsStaleAttestation(t *testing.T) { + att := attested(1, now.Add(-2*maxAttestationAge)) + if _, err := EvaluateConsensus(consensusTerms(), obs(1, 6, now), att, now); !errors.Is(err, ErrStaleAttestation) { + t.Fatalf("err = %v, want ErrStaleAttestation", err) + } +} + +func TestEvaluateConsensus_RejectsMissingAttestation(t *testing.T) { + if _, err := EvaluateConsensus(consensusTerms(), obs(1, 6, now), nil, now); !errors.Is(err, ErrNilObservation) { + t.Fatalf("err = %v, want ErrNilObservation", err) + } +} + +func TestEvaluateConsensus_RefusesAPriceOrder(t *testing.T) { + if _, err := EvaluateConsensus(validTerms(), obs(1, 6, now), attested(1, now), now); !errors.Is(err, ErrWrongKind) { + t.Fatalf("err = %v, want ErrWrongKind", err) + } +} + +func TestEvaluate_RefusesToEvaluateAConsensusOrderAsAPriceOrder(t *testing.T) { + if _, err := Evaluate(consensusTerms(), obs(1, 6, now), now); !errors.Is(err, ErrWrongKind) { + t.Fatalf("err = %v, want ErrWrongKind", err) + } +} diff --git a/frontend/.env.example b/frontend/.env.example index 39768c6..bbfe47c 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -30,3 +30,13 @@ NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com # WalletConnect project id (https://cloud.reown.com). Unset hides the option. NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= + +# Gasless relayer. Server-side only, and never NEXT_PUBLIC_ — this key pays for +# other people's transactions. Unset disables the gasless option in the UI. +# The relayer reimburses itself out of the escrowed token, so it needs only +# enough C2FLR for gas. +RELAYER_PRIVATE_KEY= +NEXT_PUBLIC_RELAYER_ENABLED= + +# RPC the relayer submits through. Defaults to the public Coston2 endpoint. +COSTON2_RPC_URL=https://coston2-api.flare.network/ext/C/rpc diff --git a/frontend/app/api/relay/route.ts b/frontend/app/api/relay/route.ts new file mode 100644 index 0000000..ce1092a --- /dev/null +++ b/frontend/app/api/relay/route.ts @@ -0,0 +1,120 @@ +import { NextResponse } from "next/server"; +import { createPublicClient, createWalletClient, http, isAddress, type Address, type Hex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { WRAITH_ABI, coston2 } from "@/lib/wraith"; + +/** + * Sponsored order creation. + * + * A user who minted FXRP holds no FLR, so they cannot pay for the transaction + * that would escrow it — the asset they want to protect is the one asset they + * cannot act on. Here the browser sends an EIP-712 signature and this route + * pays the gas, reimbursing itself out of the escrowed token via the intent's + * `relayerFee`. + * + * The relayer is not trusted with anything: every field of the intent is + * covered by the user's signature, so this route cannot retarget the escrow, + * raise its own fee, or substitute different sealed terms. The worst it can do + * is refuse to submit. + */ +const RELAYER_KEY = process.env.RELAYER_PRIVATE_KEY; +const RPC_URL = process.env.COSTON2_RPC_URL ?? coston2.rpcUrls.default.http[0]; + +export const dynamic = "force-dynamic"; + +type Body = { + wraith?: string; + encrypted?: string; + signature?: string; + intent?: { + owner?: string; + tokenIn?: string; + amountIn?: string; + expiry?: string; + relayerFee?: string; + deadline?: string; + }; +}; + +export async function POST(request: Request) { + if (!RELAYER_KEY) { + return NextResponse.json({ error: "no relayer configured" }, { status: 503 }); + } + + let body: Body; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "malformed request" }, { status: 400 }); + } + + const { wraith, encrypted, signature, intent } = body; + if ( + !wraith || + !isAddress(wraith) || + !encrypted?.startsWith("0x") || + !signature?.startsWith("0x") || + !intent?.owner || + !isAddress(intent.owner) || + !intent.tokenIn || + !isAddress(intent.tokenIn) + ) { + return NextResponse.json({ error: "malformed intent" }, { status: 400 }); + } + + let amountIn: bigint; + let expiry: bigint; + let relayerFee: bigint; + let deadline: bigint; + try { + amountIn = BigInt(intent.amountIn ?? "0"); + expiry = BigInt(intent.expiry ?? "0"); + relayerFee = BigInt(intent.relayerFee ?? "0"); + deadline = BigInt(intent.deadline ?? "0"); + } catch { + return NextResponse.json({ error: "intent carries a non-numeric field" }, { status: 400 }); + } + + const account = privateKeyToAccount(RELAYER_KEY as Hex); + const transport = http(RPC_URL); + const publicClient = createPublicClient({ chain: coston2, transport }); + const wallet = createWalletClient({ account, chain: coston2, transport }); + + const args = [ + { + owner: intent.owner as Address, + tokenIn: intent.tokenIn as Address, + amountIn, + expiry, + relayerFee, + deadline, + }, + encrypted as Hex, + signature as Hex, + ] as const; + + try { + // Simulating first turns a revert into a readable message instead of a + // burnt transaction: the relayer pays for failures, so it should not pay + // for ones it can see coming. + await publicClient.simulateContract({ + account, + address: wraith as Address, + abi: WRAITH_ABI, + functionName: "createOrderFor", + args, + }); + + const hash = await wallet.writeContract({ + address: wraith as Address, + abi: WRAITH_ABI, + functionName: "createOrderFor", + args, + }); + + return NextResponse.json({ hash, relayer: account.address }); + } catch (error) { + const message = error instanceof Error ? error.message.split("\n")[0] : String(error); + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/frontend/app/app/page.tsx b/frontend/app/app/page.tsx index 7b7f11d..a747e1f 100644 --- a/frontend/app/app/page.tsx +++ b/frontend/app/app/page.tsx @@ -7,6 +7,7 @@ import { custom, formatUnits, http, + keccak256, parseUnits, type Address, type Hex, @@ -29,7 +30,18 @@ import { Ticker } from "@/app/components/Ticker"; import { ActivityLog } from "@/app/components/ActivityLog"; import { SystemStatus } from "@/app/components/SystemStatus"; import { AgentWatchlist } from "@/app/components/AgentWatchlist"; -import { KIND_PRICE, KIND_AGENT_HEALTH, KIND_TRAILING, KIND_TWAP, newSeed } from "@/lib/wraith"; +import { + KIND_PRICE, + KIND_AGENT_HEALTH, + KIND_TRAILING, + KIND_TWAP, + KIND_CROSSCHAIN, + KIND_CONSENSUS, + newSeed, + sourceAddressHash, + CREATE_ORDER_TYPES, + createOrderDomain, +} from "@/lib/wraith"; import { remember, recall, describe } from "@/lib/recall"; import { trackEvent, setPersonProperties, trackError } from "@/lib/analytics"; @@ -41,6 +53,21 @@ const ESCROW_ADDRESS = (process.env.NEXT_PUBLIC_ESCROW_ADDRESS || WC2FLR_ADDRESS const IS_WRAPPED_NATIVE = ESCROW_ADDRESS.toLowerCase() === WC2FLR_ADDRESS.toLowerCase(); const TOKEN_OUT = (process.env.NEXT_PUBLIC_TOKEN_OUT ?? "") as Address; const FEED_ID = (process.env.NEXT_PUBLIC_FEED_ID ?? "0x01464c522f55534400000000000000000000000000") as Hex; +// Only shown when an operator has actually funded a relayer. Offering a +// gasless path that then fails is worse than not offering one. +const RELAYER_ENABLED = process.env.NEXT_PUBLIC_RELAYER_ENABLED === "true"; + +/** Which sealed condition each composer tab produces. */ +const KIND_BY_MODE = { + price: KIND_PRICE, + trailing: KIND_TRAILING, + stealth: KIND_TWAP, + shield: KIND_AGENT_HEALTH, + crosschain: KIND_CROSSCHAIN, + consensus: KIND_CONSENSUS, +} as const; + +const MAX_UINT256 = (1n << 256n) - 1n; type OrderState = "sealed" | "executed" | "cancelled" | "expired"; @@ -108,7 +135,14 @@ export default function Home() { const [direction, setDirection] = useState("below"); const [threshold, setThreshold] = useState("2.00"); const [takeProfit, setTakeProfit] = useState(""); - const [mode, setMode] = useState<"price" | "trailing" | "stealth" | "shield">("price"); + const [mode, setMode] = useState< + "price" | "trailing" | "stealth" | "shield" | "crosschain" | "consensus" + >("price"); + const [watchAddress, setWatchAddress] = useState(""); + const [watchAmount, setWatchAmount] = useState("100"); + const [deviationPct, setDeviationPct] = useState("2"); + const [gasless, setGasless] = useState(false); + const [relayerFee, setRelayerFee] = useState("0.5"); const [trailPct, setTrailPct] = useState("5"); const [chunks, setChunks] = useState("6"); const [hours, setHours] = useState("4"); @@ -361,58 +395,90 @@ export default function Home() { return; } + if (mode === "crosschain" && !watchAddress.trim()) { + say("Name the XRPL address to watch before sealing.", "error"); + setBusy(false); + return; + } + say("Encrypting your condition in this browser…"); const encrypted = await sealTerms( { contract: WRAITH_ADDRESS, feedId: FEED_ID, direction, - kind: - mode === "shield" - ? KIND_AGENT_HEALTH - : mode === "trailing" - ? KIND_TRAILING - : mode === "stealth" - ? KIND_TWAP - : KIND_PRICE, + kind: KIND_BY_MODE[mode], agent: (agent || "0x0000000000000000000000000000000000000000") as Address, // Percent in the UI, BIPS on the wire — 120% becomes 12000. minCollateralBIPS: mode === "shield" ? BigInt(Math.round(Number(collateralFloor) * 100)) : 0n, - // Percent in the UI, BIPS on the wire — 5% becomes 500. - trailBIPS: mode === "trailing" ? BigInt(Math.round(Number(trailPct) * 100)) : 0n, + // Percent in the UI, BIPS on the wire — 5% becomes 500. Trailing and + // consensus share this slot: the kinds are mutually exclusive, so a + // trail distance and a deviation tolerance never coexist in one order. + trailBIPS: + mode === "trailing" + ? BigInt(Math.round(Number(trailPct) * 100)) + : mode === "consensus" + ? BigInt(Math.round(Number(deviationPct) * 100)) + : 0n, // A fresh seed per order, so two orders never share a schedule. seed: mode === "stealth" ? newSeed() : undefined, chunks: mode === "stealth" ? BigInt(chunks) : 0n, startAt: mode === "stealth" ? BigInt(Math.floor(Date.now() / 1000)) : 0n, endAt: mode === "stealth" ? BigInt(Math.floor(Date.now() / 1000) + Number(hours) * 3600) : 0n, - thresholdE18: priceToE18(threshold), + // A cross-chain order's "threshold" is the payment size that fires it, + // not a price — the same slot, a different unit. + thresholdE18: mode === "crosschain" ? priceToE18(watchAmount) : priceToE18(threshold), // Empty means a plain single-leg order; the enclave treats 0 as unset. secondThresholdE18: takeProfit.trim() ? priceToE18(takeProfit) : 0n, action, minOutOrLots: action === "swap" ? parseUnits(minOut, decimals) : BigInt(minOut), tokenOut: TOKEN_OUT, - underlyingAddress: xrplAddress, + // For a cross-chain order this slot names the source being watched, and + // it travels as the FDC address hash so the account itself never + // appears on-chain — not in the ciphertext's shadow, not in the tick. + underlyingAddress: mode === "crosschain" ? sourceAddressHash(watchAddress) : xrplAddress, expiry, }, teeKey, ); - say("Approving escrow…"); - const approveHash = await wallet.writeContract({ + const fee = gasless ? parseUnits(relayerFee, decimals) : 0n; + + // Skipping a redundant approval is what makes the gasless path actually + // gasless on the second order: the allowance is the one thing the user + // must still sign a transaction for, so it is worth only doing once. + const allowance = await publicClient.readContract({ address: ESCROW_ADDRESS, abi: ERC20_ABI, - functionName: "approve", - args: [WRAITH_ADDRESS, amountIn], + functionName: "allowance", + args: [account, WRAITH_ADDRESS], }); - await publicClient.waitForTransactionReceipt({ hash: approveHash }); + if (allowance < amountIn + fee) { + say("Approving escrow…"); + const approveHash = await wallet.writeContract({ + address: ESCROW_ADDRESS, + abi: ERC20_ABI, + functionName: "approve", + // A gasless user opts into a standing allowance, because a per-order + // approval would put a funded transaction back in front of every + // order and defeat the point. + args: [WRAITH_ADDRESS, gasless ? MAX_UINT256 : amountIn + fee], + }); + await publicClient.waitForTransactionReceipt({ hash: approveHash }); + } - say("Sealing the order on Coston2…"); - const hash = await wallet.writeContract({ - address: WRAITH_ADDRESS, - abi: WRAITH_ABI, - functionName: "createOrder", - args: [encrypted, ESCROW_ADDRESS, amountIn, expiry], - }); + let hash: Hex; + if (gasless) { + hash = await relayOrder(wallet, encrypted, amountIn, expiry, fee); + } else { + say("Sealing the order on Coston2…"); + hash = await wallet.writeContract({ + address: WRAITH_ADDRESS, + abi: WRAITH_ABI, + functionName: "createOrder", + args: [encrypted, ESCROW_ADDRESS, amountIn, expiry], + }); + } await publicClient.waitForTransactionReceipt({ hash }); setLastTx(hash); @@ -428,6 +494,73 @@ export default function Home() { } } + /** + * Hand a signed order to the sponsor. + * + * The user signs an EIP-712 intent — free, no chain interaction — and the + * relayer pays the gas, reimbursing itself in the escrowed token. Every field + * is covered by the signature, so the relayer cannot change where the escrow + * goes, what the sealed terms are, or what it charges. + */ + async function relayOrder( + wallet: ReturnType, + encrypted: Hex, + amountIn: bigint, + expiry: bigint, + fee: bigint, + ): Promise { + if (!account) throw new Error("connect a wallet first"); + + const nonce = await publicClient.readContract({ + address: WRAITH_ADDRESS, + abi: WRAITH_ABI, + functionName: "nonces", + args: [account], + }); + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + say("Sign the order — this costs you nothing…"); + const signature = await wallet.signTypedData({ + account, + domain: createOrderDomain(WRAITH_ADDRESS), + types: CREATE_ORDER_TYPES, + primaryType: "CreateOrder", + message: { + owner: account, + encryptedHash: keccak256(encrypted), + tokenIn: ESCROW_ADDRESS, + amountIn, + expiry, + relayerFee: fee, + nonce, + deadline, + }, + }); + + say("Relaying your order — the sponsor pays the gas…"); + const response = await fetch("/api/relay", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + wraith: WRAITH_ADDRESS, + encrypted, + signature, + intent: { + owner: account, + tokenIn: ESCROW_ADDRESS, + amountIn: amountIn.toString(), + expiry: expiry.toString(), + relayerFee: fee.toString(), + deadline: deadline.toString(), + }, + }), + }); + + const relayed = await response.json(); + if (!response.ok) throw new Error(relayed?.error ?? "the relayer refused the order"); + return relayed.hash as Hex; + } + async function wrapNative() { if (!account) return; setBusy(true); @@ -581,8 +714,106 @@ export default function Home() { > FAssets Shield + + + {mode === "crosschain" && ( + <> + + + + +

+ The enclave cannot reach FDC, so a keeper fetches the attestation proof and the contract + verifies it onchain before relaying the reading inward. What that publishes is a payment + XRPL already made public. The address travels as its FDC hash and the amount that fires the + order stays encrypted, so neither is readable onchain. +

+ + )} + + {mode === "consensus" && ( + <> +
+ Trigger +
+ + { setThreshold(e.target.value); startCompose(); }} + inputMode="decimal" + aria-label="Trigger price" + required + /> +
+
+ + + +

+ Privacy stops someone aiming at your trigger; it does not stop them walking a single price + feed until something fires. A consensus order settles only when FTSO and an FDC-attested + off-chain price both cross the level, and refuses to act at all when they disagree too + widely to trust either. +

+ + )} + {mode === "shield" && ( <>
@@ -735,6 +966,40 @@ export default function Home() { )} + {RELAYER_ENABLED && ( +
+ + {gasless && ( + <> +
+ setRelayerFee(e.target.value)} + inputMode="decimal" + aria-label="Relayer fee" + /> +
+

+ paid to the sponsor in {symbol || "escrow"}, on top of the amount escrowed +

+ + )} +

+ You sign the order; a sponsor pays the gas and takes its fee out of the same token you are + escrowing. The signature covers every field, so the sponsor cannot change where your funds + go or what it charges. One approval transaction is still needed the first time. +

+
+ )} +