diff --git a/.gitignore b/.gitignore index 4c8ff1b..c7958b4 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,11 @@ contracts/.env.deploy # TypeScript incremental build artifact *.tsbuildinfo + +# Telegram alert subscriptions: wallet addresses and chat ids, written by the +# frontend and read by the keeper. Personal data, never committed. +.wraith-alerts.json + +# Generated by next.config.mjs on every build, not authored. +frontend/AGENTS.md +frontend/CLAUDE.md diff --git a/README.md b/README.md index f9fd8c8..2348ad0 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ The no-op and the fired path are deliberately **indistinguishable by status**, s | **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 | | **Browser notifications** | Alerts when your order executes or is cancelled | -| **Telegram alerts** | Operator-level notification from the keeper when an order fires | +| **Telegram alerts** | Subscribe your wallet in-app; the keeper messages you when your order fires, tab open or not | | **Wallet support** | Injected wallets plus WalletConnect for mobile and hardware | ## Deployment @@ -79,8 +79,9 @@ The no-op and the fired path are deliberately **indistinguishable by status**, s | | | | --- | --- | | **Network** | Flare Coston2 (chain 114) | -| **WraithOrders** | [`0x77B843De799557370c5c5a438cd1Fb23E3a79103`](https://coston2.testnet.flarescan.com/address/0x77B843De799557370c5c5a438cd1Fb23E3a79103) | -| **FCC extension ID** | `0x102b1` (66225) | +| **WraithOrders** | [`0xaD53864967e6Aa0090ee6609F481E7F09Ce753B3`](https://coston2.testnet.flarescan.com/address/0xaD53864967e6Aa0090ee6609F481E7F09Ce753B3) | +| **FCC extension ID** | `0x102b5` (66229) | +| **FdcVerification** | `0x906507E0B64bcD494Db73bd0459d1C667e14B933` | | **FCC registry** | `0x1a9C4A0f9D76c0b1D91d22E24E573a9b377618aE` — FlareTeeManager diamond | | **FtsoV2** | `0x3d893C53D9e8056135C26C8c638B76C8b60Df726` | | **AssetManagerFXRP** | `0xc1Ca88b937d0b528842F95d5731ffB586f4fbDFA` | diff --git a/contracts/script/Deploy.s.sol b/contracts/script/Deploy.s.sol index 1becef9..ba5cbaa 100644 --- a/contracts/script/Deploy.s.sol +++ b/contracts/script/Deploy.s.sol @@ -22,18 +22,21 @@ import { ITeeMachineRegistry } from "../src/interfaces/ITeeMachineRegistry.sol"; /// export TEE_MACHINE_REGISTRY=0x1a9C4A0f9D76c0b1D91d22E24E573a9b377618aE /// export BLAZESWAP_ROUTER=0x... # optional, enables the swap action /// export FXRP_ASSET_MANAGER=0x... # optional, enables the redeem action +/// export FDC_VERIFICATION=0x... # optional, enables attested triggers /// forge script script/Deploy.s.sol --rpc-url $COSTON2_RPC --broadcast \ /// --private-key $DEPLOYER_KEY /// /// After deployment, register the extension (scripts/pre-build.sh in the -/// scaffold), then call setExtensionId() and setTeeAddress() — see the runbook -/// in docs/DEPLOY.md. +/// scaffold), then call setExtensionId() — see the runbook in docs/DEPLOY.md. +/// There is no TEE address to register: execute() reads the active machine set +/// from the registry directly. contract Deploy is Script { function run() external { address extRegistry = vm.envAddress("TEE_EXTENSION_REGISTRY"); address machineRegistry = vm.envAddress("TEE_MACHINE_REGISTRY"); address router = vm.envOr("BLAZESWAP_ROUTER", address(0)); address assetManager = vm.envOr("FXRP_ASSET_MANAGER", address(0)); + address fdcVerification = vm.envOr("FDC_VERIFICATION", address(0)); vm.startBroadcast(); @@ -46,6 +49,9 @@ contract Deploy is Script { if (assetManager != address(0)) { wraith.setAssetManager(assetManager); } + if (fdcVerification != address(0)) { + wraith.setFdcVerification(fdcVerification); + } vm.stopBroadcast(); @@ -53,6 +59,6 @@ contract Deploy is Script { console.log("Next steps:"); console.log(" 1. Register the extension (scaffold pre-build.sh) with this address as sender"); console.log(" 2. Call setExtensionId() once registration is confirmed"); - console.log(" 3. Call setTeeAddress(, true) with the registered TEE signer"); + console.log(" 3. Nothing else: execute() reads active TEEs from the registry"); } } diff --git a/frontend/.env.example b/frontend/.env.example index bbfe47c..3c24f12 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -40,3 +40,8 @@ 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 + +# Where the Alerts panel records `owner address -> Telegram chat id`. The keeper +# reads the same file, so both must point at it. Gitignored: it holds wallet +# addresses and chat ids. +WRAITH_ALERTS_FILE=../.wraith-alerts.json diff --git a/frontend/app/api/alerts/route.ts b/frontend/app/api/alerts/route.ts new file mode 100644 index 0000000..b680561 --- /dev/null +++ b/frontend/app/api/alerts/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import { readFileSync, writeFileSync } from "node:fs"; +import { isAddress } from "viem"; + +/** + * Telegram subscriptions: wallet address -> chat id. + * + * Browser notifications only reach someone with the tab open, which is exactly + * the wrong moment — an order fires while you are asleep or away. Telegram + * closes that gap, but only the keeper is awake when it happens, so the two + * processes have to agree on who wants what. + * + * They agree through this file. It is deliberately the smallest possible + * mechanism for a single-box deployment: no database, no queue, no service to + * keep running. The keeper re-reads it on every notification, so subscribing + * takes effect immediately rather than at the next keeper restart. + * + * ponytail: a shared file assumes keeper and frontend share a filesystem. Move + * this to the keeper's own HTTP surface if they are ever deployed apart. + */ +const ALERTS_FILE = process.env.WRAITH_ALERTS_FILE ?? "../.wraith-alerts.json"; + +export const dynamic = "force-dynamic"; + +type Subscriptions = Record; + +function load(): Subscriptions { + try { + return JSON.parse(readFileSync(ALERTS_FILE, "utf8")) as Subscriptions; + } catch { + return {}; + } +} + +export async function GET(request: Request) { + const address = new URL(request.url).searchParams.get("address"); + if (!address || !isAddress(address)) { + return NextResponse.json({ error: "bad address" }, { status: 400 }); + } + // Only ever reports whether *this* address is subscribed. Returning the whole + // file would hand any visitor every user's chat id. + const chatId = load()[address.toLowerCase()] ?? null; + return NextResponse.json({ subscribed: Boolean(chatId) }); +} + +export async function POST(request: Request) { + let body: { address?: string; chatId?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "malformed request" }, { status: 400 }); + } + + const { address, chatId } = body; + if (!address || !isAddress(address)) { + return NextResponse.json({ error: "bad address" }, { status: 400 }); + } + + // Telegram chat ids are integers, negative for groups. Rejecting anything + // else keeps arbitrary text out of the file the keeper trusts. + const trimmed = (chatId ?? "").trim(); + if (trimmed && !/^-?\d{1,20}$/.test(trimmed)) { + return NextResponse.json({ error: "a Telegram chat id is a number" }, { status: 400 }); + } + + try { + const subscriptions = load(); + if (trimmed) { + subscriptions[address.toLowerCase()] = trimmed; + } else { + // An empty chat id is how the UI unsubscribes. + delete subscriptions[address.toLowerCase()]; + } + writeFileSync(ALERTS_FILE, `${JSON.stringify(subscriptions, null, 2)}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: `could not save: ${message}` }, { status: 500 }); + } + + return NextResponse.json({ subscribed: Boolean(trimmed) }); +} diff --git a/frontend/app/app/page.tsx b/frontend/app/app/page.tsx index a747e1f..bc896ef 100644 --- a/frontend/app/app/page.tsx +++ b/frontend/app/app/page.tsx @@ -30,6 +30,7 @@ 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 { Alerts } from "@/app/components/Alerts"; import { KIND_PRICE, KIND_AGENT_HEALTH, @@ -42,7 +43,7 @@ import { CREATE_ORDER_TYPES, createOrderDomain, } from "@/lib/wraith"; -import { remember, recall, describe } from "@/lib/recall"; +import { remember, recall, describe, MODE_LABEL } from "@/lib/recall"; import { trackEvent, setPersonProperties, trackError } from "@/lib/analytics"; const WRAITH_ADDRESS = (process.env.NEXT_PUBLIC_WRAITH_ADDRESS ?? "") as Address; @@ -482,8 +483,40 @@ export default function Home() { await publicClient.waitForTransactionReceipt({ hash }); setLastTx(hash); + + // The ciphertext is encrypted to the enclave, not to the user, so without + // a local copy nobody — including the owner — can ever read the condition + // back. Kept in this browser only, so it changes nothing an observer sees. + const newId = Number( + await publicClient.readContract({ + address: WRAITH_ADDRESS, + abi: WRAITH_ABI, + functionName: "orderCount", + }), + ) - 1; + if (newId >= 0) { + remember(WRAITH_ADDRESS, newId, { + mode, + direction, + threshold, + takeProfit: takeProfit.trim() || undefined, + action, + minOutOrLots: minOut, + escrow: amount, + sealedAt: Date.now(), + trailPct: mode === "trailing" ? trailPct : undefined, + chunks: mode === "stealth" ? chunks : undefined, + hours: mode === "stealth" ? hours : undefined, + agent: mode === "shield" ? agent : undefined, + collateralFloor: mode === "shield" ? collateralFloor : undefined, + watchAddress: mode === "crosschain" ? watchAddress : undefined, + watchAmount: mode === "crosschain" ? watchAmount : undefined, + deviationPct: mode === "consensus" ? deviationPct : undefined, + }); + } + say("Sealed. Your trigger never touched the chain in the clear."); - trackEvent("order_sealed", { wallet_connected: true }); + trackEvent("order_sealed", { order_mode: mode, gasless, wallet_connected: true }); await loadOrders(); } catch (error) { trackError(error); @@ -661,7 +694,7 @@ export default function Home() { {loadingOrders ? "—" : Number(formatUnits(stats.escrowed, 18)).toLocaleString()} - FXRP in escrow + {symbol || "Tokens"} in escrow @@ -673,7 +706,7 @@ export default function Home() {
@@ -1107,7 +1140,21 @@ export default function Home() { {visible.map((order, i) => (
- Order {order.id} + + Order {order.id} + {(() => { + const known = + account?.toLowerCase() === order.owner.toLowerCase() + ? recall(WRAITH_ADDRESS, order.id) + : undefined; + // Only the owner's own device knows the kind. To + // everyone else the order is opaque, which is the + // point — so no badge is shown rather than a guess. + return known ? ( + {MODE_LABEL[known.mode ?? "price"]} + ) : null; + })()} + {order.state === "sealed" && account?.toLowerCase() === order.owner.toLowerCase() && (
@@ -1208,6 +1255,8 @@ export default function Home() {
+ + diff --git a/frontend/app/components/Alerts.tsx b/frontend/app/components/Alerts.tsx new file mode 100644 index 0000000..775a9dc --- /dev/null +++ b/frontend/app/components/Alerts.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +/** + * Telegram alerts for your own orders. + * + * A browser notification only arrives if the tab is open, which is precisely + * the wrong moment: an order fires while you are asleep, or away, or have moved + * on. Telegram reaches you either way, and the keeper — which is awake when a + * trigger fires — is what sends it. + * + * Only the order id, the action and the transaction go out. The condition never + * leaves the enclave, so an alert cannot leak it even if the chat is later + * compromised. + */ +export function Alerts({ address }: { address?: string }) { + const [chatId, setChatId] = useState(""); + const [subscribed, setSubscribed] = useState(false); + const [busy, setBusy] = useState(false); + const [note, setNote] = useState(""); + + useEffect(() => { + if (!address) { + setSubscribed(false); + return; + } + fetch(`/api/alerts?address=${address}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => setSubscribed(Boolean(data?.subscribed))) + .catch(() => {}); + }, [address]); + + const save = useCallback( + async (nextChatId: string) => { + if (!address) return; + setBusy(true); + setNote(""); + try { + const response = await fetch("/api/alerts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address, chatId: nextChatId }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data?.error ?? "could not save"); + setSubscribed(Boolean(data.subscribed)); + setNote(data.subscribed ? "Alerts on. Test it by cancelling an order." : "Alerts off."); + if (!data.subscribed) setChatId(""); + } catch (error) { + setNote(error instanceof Error ? error.message : String(error)); + } finally { + setBusy(false); + } + }, + [address], + ); + + if (!address) return null; + + return ( +
+

+ Alerts +

+ + {subscribed ? ( + <> +

+

+ + + ) : ( + { + e.preventDefault(); + save(chatId); + }} + > + + + + )} + + {note && ( +

+ {note} +

+ )} + +

+ Start a chat with the bot first, or Telegram will refuse to deliver. Alerts carry the order id, the action + and the transaction — never the condition, which never leaves the enclave. +

+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index e290a30..d95f759 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1496,6 +1496,60 @@ input:invalid:not(:placeholder-shown) { color: var(--muted); } +/* --- Telegram alerts --- */ + +.alerts { + margin-top: 2rem; + padding: 1.4rem 1.5rem; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-raised); +} + +.alerts-form { + display: flex; + flex-direction: column; + gap: 0.85rem; + max-width: 32rem; +} + +.alerts-state { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.9rem; + font-size: 0.9rem; + color: var(--text); +} + +.alerts-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--amber); +} + +.alerts-note { + margin: 0.8rem 0 0; + font-size: 0.85rem; + color: var(--muted); +} + +/* Badge naming an order's kind. Shown only on the owner's own device, because + only that device knows the kind — to anyone else the order is opaque. */ +.order-mode { + display: inline-block; + margin-left: 0.55rem; + padding: 0.1rem 0.45rem; + border: 1px solid var(--amber-dim); + border-radius: 999px; + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--amber); + vertical-align: middle; +} + /* --- Order mode tabs --- */ .modes { diff --git a/frontend/lib/recall.test.ts b/frontend/lib/recall.test.ts new file mode 100644 index 0000000..0d39d91 --- /dev/null +++ b/frontend/lib/recall.test.ts @@ -0,0 +1,92 @@ +import test from "node:test"; +import assert from "node:assert"; +import { describe as describeTerms, type RecalledTerms } from "./recall.ts"; + +// An order's ciphertext is encrypted to the enclave, not to the user, so this +// local description is the only way someone can see what they set. Describing +// the wrong kind is therefore worse than describing nothing: it tells a trader +// a confident lie about where their money exits. + +function base(): RecalledTerms { + return { + mode: "price", + direction: "below", + threshold: "2.00", + action: "swap", + minOutOrLots: "150", + escrow: "100", + sealedAt: 0, + }; +} + +test("describes a stop-loss", () => { + const text = describeTerms(base()); + assert.match(text, /falls to \$2\.00/); + assert.match(text, /swap/); +}); + +test("describes a take-profit on the other side", () => { + const text = describeTerms({ ...base(), direction: "above" }); + assert.match(text, /rises to \$2\.00/); +}); + +test("describes a bracket's second leg", () => { + const text = describeTerms({ ...base(), takeProfit: "5.00" }); + assert.match(text, /\$5\.00/); +}); + +test("describes a redeem action as going to XRP", () => { + assert.match(describeTerms({ ...base(), action: "redeem" }), /XRP/); +}); + +test("describes a trailing stop by its trail, not by a price", () => { + const text = describeTerms({ ...base(), mode: "trailing", trailPct: "5" }); + assert.match(text, /5%/); + assert.match(text, /peak/i); + assert.doesNotMatch(text, /falls to \$2\.00/); +}); + +test("describes a stealth order by its schedule", () => { + const text = describeTerms({ ...base(), mode: "stealth", chunks: "6", hours: "4" }); + assert.match(text, /6/); + assert.match(text, /4/); + assert.doesNotMatch(text, /falls to \$2\.00/); +}); + +test("describes a shield by the agent and the floor", () => { + const text = describeTerms({ + ...base(), + mode: "shield", + agent: "0x55c81526aFF1A9EFcCB1FE64B5E85bF3F6A02b6E", + collateralFloor: "120", + }); + assert.match(text, /120%/); + assert.match(text, /0x55c815/); + assert.doesNotMatch(text, /falls to \$2\.00/); +}); + +test("describes a cross-chain trigger by the payment it waits for", () => { + const text = describeTerms({ + ...base(), + mode: "crosschain", + watchAddress: "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe", + watchAmount: "100", + }); + assert.match(text, /100/); + assert.match(text, /rPT1Sjq/); + assert.doesNotMatch(text, /falls to \$2\.00/); +}); + +test("describes a consensus order as needing both oracles", () => { + const text = describeTerms({ ...base(), mode: "consensus", deviationPct: "2" }); + assert.match(text, /falls to \$2\.00/); + assert.match(text, /both|two/i); + assert.match(text, /2%/); +}); + +// An order sealed before the mode was recorded must still read sensibly rather +// than crashing or claiming to be something it is not. +test("treats a recall with no mode as a price order", () => { + const { mode: _drop, ...legacy } = base(); + assert.match(describeTerms(legacy as RecalledTerms), /falls to \$2\.00/); +}); diff --git a/frontend/lib/recall.ts b/frontend/lib/recall.ts index bd403c7..4490ffd 100644 --- a/frontend/lib/recall.ts +++ b/frontend/lib/recall.ts @@ -14,7 +14,12 @@ * on-chain and still fires; you just stop being reminded what it says. */ +export type OrderMode = "price" | "trailing" | "stealth" | "shield" | "crosschain" | "consensus"; + export type RecalledTerms = { + /** Which composer tab produced this order. Absent on orders sealed before + * the mode was recorded; those are all price orders. */ + mode?: OrderMode; direction: "below" | "above"; threshold: string; takeProfit?: string; @@ -22,6 +27,16 @@ export type RecalledTerms = { minOutOrLots: string; escrow: string; sealedAt: number; + + // Kind-specific, each set only by the tab that owns it. + trailPct?: string; + chunks?: string; + hours?: string; + agent?: string; + collateralFloor?: string; + watchAddress?: string; + watchAmount?: string; + deviationPct?: string; }; const KEY = "wraith.recall.v1"; @@ -57,11 +72,55 @@ export function recall(contract: string, orderId: number | bigint): RecalledTerm return read()[slot(contract, orderId)]; } -/** Human phrasing of a remembered condition, for the order card. */ +/** + * Human phrasing of a remembered condition, for the order card. + * + * Each kind gets its own sentence rather than a shared template. A trailing + * stop has no trigger price and a shield has no price at all, so describing + * either as "sell when price falls to $X" would tell the owner a confident lie + * about where their money exits — worse than saying nothing. + */ export function describe(terms: RecalledTerms): string { - const side = terms.direction === "below" ? "falls to" : "rises to"; - const base = `Sell when price ${side} $${terms.threshold}`; - const bracket = terms.takeProfit ? `, or reaches $${terms.takeProfit}` : ""; const how = terms.action === "swap" ? "swap" : "redeem to XRP"; - return `${base}${bracket} — then ${how}.`; + + switch (terms.mode) { + case "trailing": + return `Follow the price up, then sell ${terms.trailPct}% below its peak — then ${how}.`; + + case "stealth": + return `Release in ${terms.chunks} unpredictable tranches over ${terms.hours} hours — each one a ${how}.`; + + case "shield": + return `Escape agent ${short(terms.agent)} if its collateral falls to ${terms.collateralFloor}%, or it leaves normal status — then ${how}.`; + + case "crosschain": + return `Wait for a payment of at least ${terms.watchAmount} XRP from ${short(terms.watchAddress)} — then ${how}.`; + + case "consensus": { + const side = terms.direction === "below" ? "falls to" : "rises to"; + return `Sell when both oracles agree price ${side} $${terms.threshold}, refusing if they differ by over ${terms.deviationPct}% — then ${how}.`; + } + + default: { + const side = terms.direction === "below" ? "falls to" : "rises to"; + const bracket = terms.takeProfit ? `, or reaches $${terms.takeProfit}` : ""; + return `Sell when price ${side} $${terms.threshold}${bracket} — then ${how}.`; + } + } } + +/** A long address, shortened for a one-line summary. */ +function short(address?: string): string { + if (!address) return "an unnamed source"; + return address.length > 14 ? `${address.slice(0, 8)}…${address.slice(-4)}` : address; +} + +/** Short label for the order card's badge. */ +export const MODE_LABEL: Record = { + price: "Price", + trailing: "Trailing", + stealth: "Stealth", + shield: "Shield", + crosschain: "Cross-chain", + consensus: "Consensus", +}; diff --git a/keeper/README.md b/keeper/README.md index c17ba67..4412812 100644 --- a/keeper/README.md +++ b/keeper/README.md @@ -32,6 +32,9 @@ npm start | `POLL_INTERVAL_MS` | `15000` | Loop interval | | `INSTRUCTION_FEE_WEI` | `0` | Native fee per instruction | | `SUBMISSION_TAG` | `submit` | Fallback if the proxy omits the tag | +| `TELEGRAM_BOT_TOKEN` | — | Enables alerts when set | +| `TELEGRAM_CHAT_ID` | — | Operator chat; hears about every order | +| `WRAITH_ALERTS_FILE` | `../.wraith-alerts.json` | Per-owner subscriptions from the app | The keeper pays for ticks, so `MIN_TICK_INTERVAL` in the contract also protects it from being drained by a tight loop against a single order. @@ -70,6 +73,24 @@ One attestation serves every order ticked inside a ten-minute window. Rounds tak The source API must be on Flare's Web2Json allowlist, and `FDC_JQ` must emit exactly `source`, `valueE18` and `timestamp`, in that order — that is the tuple `tickAttestedWeb2` decodes. +## Telegram alerts + +Two audiences, kept apart so one owner's fill can never land in another's chat: + +- **Operator** — `TELEGRAM_CHAT_ID` hears about every order. +- **Order owners** — each subscribes their own wallet in the app's Alerts panel, which writes `owner address -> chat id` into `WRAITH_ALERTS_FILE` (default `../.wraith-alerts.json`). The keeper re-reads that file on every notification, so subscribing takes effect immediately rather than at the next restart. + +```bash +export TELEGRAM_BOT_TOKEN=... # from @BotFather +export TELEGRAM_CHAT_ID=... # optional: the operator firehose +``` + +A browser notification only reaches someone with the tab open, which is exactly the wrong moment — an order fires while its owner is asleep. The keeper is the only part of the system awake when a trigger fires, which is why it sends these rather than the frontend. + +Alerts carry the order id, the action and the transaction. Never the condition: that never leaves the enclave, so a compromised chat cannot leak it. + +The subscription file holds wallet addresses and chat ids. It is gitignored, and it assumes the keeper and frontend share a filesystem — move the subscription surface onto the keeper's own HTTP endpoint if they are ever deployed apart. + ## Scaling State is an in-memory map of instructions awaiting results, so a restart forgets in-flight instructions; they are re-ticked on the next pass once `MIN_TICK_INTERVAL` elapses. That is the right trade for a keeper — it is a poller, not a source of truth, and the chain holds everything that matters. diff --git a/keeper/src/index.js b/keeper/src/index.js index 375eddb..e764d2d 100644 --- a/keeper/src/index.js +++ b/keeper/src/index.js @@ -10,12 +10,13 @@ import { createPublicClient, createWalletClient, http, parseAbi, parseEventLogs, formatEther } from "viem"; import { privateKeyToAccount } from "viem/accounts"; +import { readFileSync } from "node:fs"; import { handleFetchResultResponse, determineRelayAction, - shouldNotify, decodeAction, - sendTelegramNotification + sendTelegramNotification, + recipientsFor } from "./lib.js"; import { prepareRequest, @@ -37,6 +38,19 @@ const SUBMISSION_TAG = process.env.SUBMISSION_TAG ?? "submit"; // order that needs two sources must not settle on one. const FDC_ENABLED = Boolean(process.env.FDC_API_URL); const FLARE_CONTRACT_REGISTRY = "0xaD67FE66660Fb8dFE9d6b1b4240d8650e30F6019"; +// Where the frontend writes owner -> Telegram chat id. Read fresh on each +// notification rather than cached at boot, so a user subscribing does not have +// to wait for the keeper to be restarted. +const ALERTS_FILE = process.env.WRAITH_ALERTS_FILE ?? "../.wraith-alerts.json"; + +function loadSubscriptions() { + try { + return JSON.parse(readFileSync(ALERTS_FILE, "utf8")); + } catch { + // No file yet is the normal state before anyone subscribes. + return {}; + } +} const coston2 = { id: 114, @@ -51,6 +65,7 @@ const abi = parseAbi([ "function tick(uint256 orderId) payable", "function tickAttestedWeb2(uint256 orderId, (bytes32[] merkleProof, (bytes32 attestationType, bytes32 sourceId, uint64 votingRound, uint64 lowestUsedTimestamp, (string url, string httpMethod, string headers, string queryParams, string body, string postProcessJq, string abiSignature) requestBody, (bytes abiEncodedData) responseBody) data) proof) payable", "function execute(bytes resultData, bytes32 actionId, string submissionTag, uint8 status, bytes signature)", + "function getOrder(uint256 orderId) view returns (address owner, address tokenIn, uint256 amountIn, uint64 expiry, bool executed, bool cancelled, bytes encrypted)", "event OrderTicked(uint256 indexed orderId, bytes32 instructionId)", ]); @@ -263,12 +278,19 @@ async function relayResults() { await publicClient.waitForTransactionReceipt({ hash }); console.log(`order ${orderId} executed in ${hash}`); - if (shouldNotify(process.env)) { + if (process.env.TELEGRAM_BOT_TOKEN) { const action = decodeAction(relayAction.data); - try { - await sendTelegramNotification(process.env, orderId, action, hash); - } catch (notifyError) { - console.error(`Telegram notification error for order ${orderId}: ${notifyError.message}`); + const owner = await publicClient + .readContract({ address: WRAITH_ADDRESS, abi, functionName: "getOrder", args: [orderId] }) + .then((order) => order[0]) + .catch(() => null); + const chats = recipientsFor(process.env, loadSubscriptions(), owner); + if (chats.length > 0) { + try { + await sendTelegramNotification(process.env, orderId, action, hash, chats); + } catch (notifyError) { + console.error(`Telegram notification error for order ${orderId}: ${notifyError.message}`); + } } } } catch (error) { diff --git a/keeper/src/lib.js b/keeper/src/lib.js index d699b5d..0de2360 100644 --- a/keeper/src/lib.js +++ b/keeper/src/lib.js @@ -111,6 +111,35 @@ export function buildTelegramMessage(orderId, action, txHash) { return `Order executed!\nID: ${orderId}\nAction: ${action}\nTransaction: ${txLink}`; } +/** + * Which Telegram chats should hear about one order firing. + * + * Two audiences with different needs: the operator chat wants every order, and + * an order's owner wants only their own. Keeping them apart here — rather than + * at the call site — is what makes it structurally impossible to route one + * owner's fill into another owner's chat. + * + * @param {Record} env + * @param {Record|null} subscriptions owner address -> chat id + * @param {string} owner + * @returns {string[]} chat ids, deduplicated + */ +export function recipientsFor(env, subscriptions, owner) { + if (!env?.TELEGRAM_BOT_TOKEN) return []; + + const chats = []; + if (env.TELEGRAM_CHAT_ID) chats.push(env.TELEGRAM_CHAT_ID); + + // Addresses arrive from a wallet, a config file and a chain event, and each + // casing them differently is normal — so the lookup is case-insensitive. + const wanted = String(owner ?? "").toLowerCase(); + for (const [address, chatId] of Object.entries(subscriptions ?? {})) { + if (address.toLowerCase() === wanted && chatId) chats.push(chatId); + } + + return [...new Set(chats)]; +} + /** * Sends a notification message to the configured Telegram Bot. * @@ -120,31 +149,38 @@ export function buildTelegramMessage(orderId, action, txHash) { * @param {string} txHash * @returns {Promise} */ -export async function sendTelegramNotification(env, orderId, action, txHash) { - if (!shouldNotify(env)) { +export async function sendTelegramNotification(env, orderId, action, txHash, chats) { + const recipients = chats ?? (shouldNotify(env) ? [env.TELEGRAM_CHAT_ID] : []); + if (recipients.length === 0) { return false; } + const message = buildTelegramMessage(orderId, action, txHash); const url = `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`; - try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - chat_id: env.TELEGRAM_CHAT_ID, - text: message, - }), - }); - if (!response.ok) { - const text = await response.text(); - console.error(`Telegram Bot API error: ${response.status} ${text}`); - return false; + + let delivered = false; + for (const chatId of recipients) { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + chat_id: chatId, + text: message, + }), + }); + if (!response.ok) { + const text = await response.text(); + console.error(`Telegram Bot API error: ${response.status} ${text}`); + continue; + } + delivered = true; + } catch (error) { + // One unreachable chat must not silence the others. + console.error(`Telegram network/fetch error: ${error.message}`); } - return true; - } catch (error) { - console.error(`Telegram network/fetch error: ${error.message}`); - return false; } + return delivered; } diff --git a/keeper/test/lib.test.js b/keeper/test/lib.test.js index 2ec8c56..05484bc 100644 --- a/keeper/test/lib.test.js +++ b/keeper/test/lib.test.js @@ -7,7 +7,8 @@ import { decodeAction, decodeOrderId, buildTelegramMessage, - sendTelegramNotification + sendTelegramNotification, + recipientsFor } from "../src/lib.js"; test("handleFetchResultResponse - proxy 404 -> null", async () => { @@ -200,3 +201,45 @@ test("sendTelegramNotification - skipped when env unset", async () => { globalThis.fetch = originalFetch; } }); + +// --- per-owner Telegram routing --- +// +// The operator chat is a firehose of every order; an order owner wants only +// their own. Routing has to keep those apart, and must never send one owner's +// fill to another owner's chat. + +test("recipientsFor - operator chat receives every order", () => { + const env = { TELEGRAM_BOT_TOKEN: "t", TELEGRAM_CHAT_ID: "ops" }; + assert.deepStrictEqual(recipientsFor(env, {}, "0xAbC"), ["ops"]); +}); + +test("recipientsFor - the owner's own chat is added", () => { + const env = { TELEGRAM_BOT_TOKEN: "t", TELEGRAM_CHAT_ID: "ops" }; + const subs = { "0xabc": "owner-chat" }; + assert.deepStrictEqual(recipientsFor(env, subs, "0xAbC"), ["ops", "owner-chat"]); +}); + +test("recipientsFor - matches the owner case-insensitively", () => { + const env = { TELEGRAM_BOT_TOKEN: "t" }; + assert.deepStrictEqual(recipientsFor(env, { "0XABC": "c" }, "0xabc"), ["c"]); +}); + +test("recipientsFor - never routes one owner's order to another owner", () => { + const env = { TELEGRAM_BOT_TOKEN: "t" }; + const subs = { "0xaaa": "alice", "0xbbb": "bob" }; + assert.deepStrictEqual(recipientsFor(env, subs, "0xbbb"), ["bob"]); +}); + +test("recipientsFor - no bot token means no recipients at all", () => { + assert.deepStrictEqual(recipientsFor({}, { "0xabc": "c" }, "0xabc"), []); +}); + +test("recipientsFor - the same chat is not messaged twice", () => { + const env = { TELEGRAM_BOT_TOKEN: "t", TELEGRAM_CHAT_ID: "same" }; + assert.deepStrictEqual(recipientsFor(env, { "0xabc": "same" }, "0xabc"), ["same"]); +}); + +test("recipientsFor - works with no subscriptions loaded", () => { + const env = { TELEGRAM_BOT_TOKEN: "t", TELEGRAM_CHAT_ID: "ops" }; + assert.deepStrictEqual(recipientsFor(env, null, "0xabc"), ["ops"]); +});