Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,17 @@ 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

| | |
| --- | --- |
| **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` |
Expand Down
12 changes: 9 additions & 3 deletions contracts/script/Deploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -46,13 +49,16 @@ contract Deploy is Script {
if (assetManager != address(0)) {
wraith.setAssetManager(assetManager);
}
if (fdcVerification != address(0)) {
wraith.setFdcVerification(fdcVerification);
}

vm.stopBroadcast();

console.log("WraithOrders deployed at:", address(wraith));
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(<tee>, true) with the registered TEE signer");
console.log(" 3. Nothing else: execute() reads active TEEs from the registry");
}
}
5 changes: 5 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
81 changes: 81 additions & 0 deletions frontend/app/api/alerts/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

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) });
}
61 changes: 55 additions & 6 deletions frontend/app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -661,7 +694,7 @@ export default function Home() {
<span className="stat-value">
{loadingOrders ? "—" : Number(formatUnits(stats.escrowed, 18)).toLocaleString()}
</span>
<span className="stat-label">FXRP in escrow</span>
<span className="stat-label">{symbol || "Tokens"} in escrow</span>
</div>
</section>

Expand All @@ -673,7 +706,7 @@ export default function Home() {

<form className="compose" onSubmit={seal}>
<label className="field">
<span className="field-label">Escrow (FXRP)</span>
<span className="field-label">Escrow{symbol ? ` (${symbol})` : ""}</span>
<input value={amount} onChange={(e) => { setAmount(e.target.value); startCompose(); }} inputMode="decimal" required />
</label>

Expand Down Expand Up @@ -1107,7 +1140,21 @@ export default function Home() {
{visible.map((order, i) => (
<article className="order" key={order.id} style={{ animationDelay: `${Math.min(i, 6) * 45}ms` }}>
<div className="order-head">
<span className="order-id">Order {order.id}</span>
<span className="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 ? (
<span className="order-mode">{MODE_LABEL[known.mode ?? "price"]}</span>
) : null;
})()}
</span>
<span className="head-actions">
{order.state === "sealed" && account?.toLowerCase() === order.owner.toLowerCase() && (
<button
Expand Down Expand Up @@ -1137,7 +1184,7 @@ export default function Home() {
<div>
<div className="fact-label">Escrow</div>
<div className="fact-value">
{Number(formatUnits(order.amountIn, 18)).toLocaleString()} FXRP
{Number(formatUnits(order.amountIn, 18)).toLocaleString()} {symbol || ""}
</div>
</div>
<div>
Expand Down Expand Up @@ -1208,6 +1255,8 @@ export default function Home() {
</section>
</div>

<Alerts address={account} />

<ActivityLog address={WRAITH_ADDRESS || undefined} />
</div>

Expand Down
115 changes: 115 additions & 0 deletions frontend/app/components/Alerts.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="alerts" aria-labelledby="alerts-title">
<h2 className="panel-title" id="alerts-title">
Alerts
</h2>

{subscribed ? (
<>
<p className="alerts-state">
<span className="alerts-dot" aria-hidden="true" />
Telegram alerts are on for this wallet.
</p>
<button className="btn btn-ghost" type="button" disabled={busy} onClick={() => save("")}>
Turn off
</button>
</>
) : (
<form
className="alerts-form"
onSubmit={(e) => {
e.preventDefault();
save(chatId);
}}
>
<label className="field">
<span className="field-label">
Telegram chat ID <span className="field-hint">message @userinfobot to find yours</span>
</span>
<input
value={chatId}
onChange={(e) => setChatId(e.target.value)}
inputMode="numeric"
placeholder="123456789"
required
/>
</label>
<button className="btn btn-ghost" type="submit" disabled={busy}>
{busy ? "Saving…" : "Alert me when my orders fire"}
</button>
</form>
)}

{note && (
<p className="alerts-note" role="status">
{note}
</p>
)}

<p className="secret-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.
</p>
</section>
);
}
Loading