Compare commits

..
2 Commits
Author SHA1 Message Date
Michael ShimelesandClaude Opus 5.5 6e53585b50 fix(web): fall back to shasum where sha256sum is missing
Older macOS releases ship shasum but not sha256sum, so build-web.sh
stopped with exit 127 before writing index.html. Both commands give the
same digest, so the folder names don't change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 08:35:34 -04:00
Michael ShimelesandClaude Opus 5.5 b5776f2abf fix(web): cache the app's wasm for good and serve /app/ without a redirect chain
scripts/build-web.sh now writes each build's JS and wasm to
static/app/immutable/<hash>/, named after a hash of those files, and
fills the paths into index.html. vercel.json caches /app/immutable/ for
a year and answers a missing file there with a 404 marked no-store.
index.html and the fonts keep Vercel's default of revalidating on every
visit, so a deploy can't leave a page with one build's JS and another
build's wasm.

/app/ used to take two 308s through the SvelteKit function to reach the
page (/app/ to ../app to /app/index.html). vercel.json now sends /app
to /app/ with one 308 and serves /app/ from index.html, so the /app
endpoint is gone. Relative URLs in the page still resolve under /app/.

Both workflows move off the Node 20 majors: actions/checkout@v7,
upload-artifact@v7, download-artifact@v8 and setup-node@v7 run on
Node 24.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 08:20:40 -04:00
191 changed files with 356 additions and 27921 deletions

No files matched your search

+5 -12
View File
@@ -40,9 +40,9 @@ jobs:
TR_PROVIDER: sample
TR_CONGRESS_REFRESH_MIN: "0"
TR_SMOKE_SECS: "8"
TR_DATA_DIR: ${{ runner.temp }}/terminal
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs
run: |
./target/release/terminal > smoke.log 2>&1 &
./target/release/terminal-rs > smoke.log 2>&1 &
pid=$!
sleep 6
screencapture -x screenshot-${{ matrix.arch }}.png || true
@@ -56,9 +56,9 @@ jobs:
env:
TR_SMOKE_SECS: "12"
TR_CONGRESS_REFRESH_MIN: "0"
TR_DATA_DIR: ${{ runner.temp }}/terminal-live
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs-live
run: |
./target/release/terminal > live.log 2>&1 &
./target/release/terminal-rs > live.log 2>&1 &
pid=$!
sleep 10
screencapture -x screenshot-live-${{ matrix.arch }}.png || true
@@ -84,22 +84,15 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- run: sudo apt-get update && sudo apt-get install -y libxkbcommon-x11-0 libxcb-xkb1 mesa-vulkan-drivers xvfb
- run: cargo fmt --all -- --check
- run: cargo build --release --workspace --all-targets
- run: cargo test --release --workspace
- run: cargo clippy --release --workspace --all-targets -- -D warnings
- name: The protocol crate builds for the browser
run: cargo build -p tr-proto --target wasm32-unknown-unknown
- name: The Hyperliquid crate builds for the browser
run: cargo build -p tr-hl --target wasm32-unknown-unknown && cargo clippy -p tr-hl --target wasm32-unknown-unknown -- -D warnings
- name: The wallet crate builds for the browser
run: cargo build -p tr-turnkey --target wasm32-unknown-unknown && cargo clippy -p tr-turnkey --target wasm32-unknown-unknown -- -D warnings
- name: Run the app (smoke test)
env:
TR_PROVIDER: sample
TR_CONGRESS_REFRESH_MIN: "0"
TR_SMOKE_SECS: "6"
run: xvfb-run -a ./target/release/terminal 2>&1 | tee smoke.log && grep -q "smoke:" smoke.log
run: xvfb-run -a ./target/release/terminal-rs 2>&1 | tee smoke.log && grep -q "smoke:" smoke.log
+1 -8
View File
@@ -50,14 +50,7 @@ jobs:
echo "$SHA256 $RUNNER_TEMP/binaryen.tar.gz" | sha256sum -c -
tar xzf "$RUNNER_TEMP/binaryen.tar.gz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/binaryen-$BINARYEN/bin" >> "$GITHUB_PATH"
# The repository variable TR_SERVER_URL, when set, picks the server the site connects to,
# for example after the gateway moves to a custom domain. Unset, build-web.sh's default holds.
- name: Build the browser app
env:
SERVER_URL: ${{ vars.TR_SERVER_URL }}
run: |
if [ -n "$SERVER_URL" ]; then export TR_SERVER_URL="$SERVER_URL"; fi
scripts/build-web.sh
- run: scripts/build-web.sh
- uses: actions/upload-artifact@v7
with:
name: web-app
Generated
+29 -2378
View File
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/core", "crates/broker", "crates/hl", "crates/turnkey", "crates/congress", "crates/data", "crates/chart", "crates/macos", "crates/proto", "app", "server"]
members = ["crates/core", "crates/broker", "crates/congress", "crates/data", "crates/chart", "crates/macos", "app"]
default-members = ["app"]
[workspace.package]
@@ -11,13 +11,10 @@ rust-version = "1.85"
[workspace.dependencies]
tr-core = { path = "crates/core" }
tr-broker = { path = "crates/broker" }
tr-hl = { path = "crates/hl" }
tr-turnkey = { path = "crates/turnkey" }
tr-congress = { path = "crates/congress" }
tr-data = { path = "crates/data" }
tr-chart = { path = "crates/chart" }
tr-macos = { path = "crates/macos" }
tr-proto = { path = "crates/proto" }
anyhow = "1"
thiserror = "2"
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
+9 -22
View File
@@ -1,4 +1,4 @@
# Terminal
# terminal-rs
A fast, keyboard-driven market terminal in pure Rust: TradingView-class GPU charting with a
Bloomberg Terminal look (dense tiled panes, monospace numbers, a command line like `AAPL <GO>`)
@@ -9,7 +9,7 @@ systems** and **your own brokerage accounts through [SnapTrade](https://snaptrad
to Solarized, and a fully dockable
workspace with saved layouts.
![Terminal: AAPL chart with congress trade markers and RSI, the Congress trades feed, world markets and a multi-asset watchlist](docs/img/overview.png)
![terminal-rs: AAPL chart with congress trade markers and RSI, the Congress trades feed, world markets and a multi-asset watchlist](docs/img/overview.png)
> **Built from reverse-engineering research.** Chart UX was studied behaviourally on
> tradingview.com with [REA](https://github.com/morluto/rea) in website mode (passive captures of
@@ -84,11 +84,6 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
| US Treasuries | Treasury.gov daily par yield curve CSV (FRED fallback); Yahoo `^IRX/^FVX/^TNX/^TYX` intraday | Treasury.gov | — |
| FX, indices, futures | Yahoo Finance (`EURUSD=X`, `^GSPC`, `CL=F`) | Yahoo spark (batched) | — |
| Stocks, ETFs | Alpaca when `APCA_API_KEY_ID`/`APCA_API_SECRET_KEY` are set, else Yahoo | Yahoo spark | — |
| Hyperliquid HIP-3 perps (`xyz:AAPL`, `xyz:GOLD`, `xyz:EUR`) | The Terminal data server only (`TR_SERVER_URL`) | Server mids | Server candles, book and trades |
A HIP-3 perp is its own instrument, labelled `PERP · HL xyz` everywhere. `AAPL US Equity` stays the
listed stock. `AAPL PERP` opens the perp linked to it, and `PERP` or ⇧P switches back and forth.
A perp has no other source, so it never gets sample data; without the server it shows why.
No keys are required. `TR_PROVIDER=sample` runs fully offline on deterministic, class-aware sample
data (BTCUSD near 65,000, EURUSD near 1.09, US10Y near 4.2%) **with simulated live ticks**, and
@@ -127,7 +122,7 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
row in a linked watchlist or world-markets pane loads it into the group.
- **Saved layouts**: save, name, rename, duplicate, delete and switch (`LAYOUT <name>`, the
sidebar list, ⌥1…⌥9); the active layout autosaves and is restored on launch. Each layout is a
JSON file in `~/Library/Application Support/terminal/layouts` (`$XDG_DATA_HOME` on Linux)
JSON file in `~/Library/Application Support/terminal-rs/layouts` (`$XDG_DATA_HOME` on Linux)
and can be imported or exported (`LAYOUT EXPORT`/`IMPORT`, or Settings › Layouts). Per-pane
settings (chart type, indicators, drawings, filters, depth, link, tab title) and, optionally,
the theme (`LAYOUT THEME ON`) save with the layout.
@@ -186,12 +181,9 @@ TR_PROVIDER=sample cargo run --release # offline
APCA_API_KEY_ID=… APCA_API_SECRET_KEY=… cargo run --release # Alpaca
```
Data lives in `~/Library/Application Support/terminal` (macOS) or
`$XDG_DATA_HOME/terminal` (Linux), or `TR_DATA_DIR`. Versions from before the project was renamed
used a `terminal-rs` folder in the same place; the first launch moves it to `terminal` and leaves
a link at the old path, so builds that still use the old name find the same files. The first
congress ingest covers the current and previous year (a few minutes; it runs in the background
while you work).
Data lives in `~/Library/Application Support/terminal-rs` (macOS) or
`$XDG_DATA_HOME/terminal-rs` (Linux), or `TR_DATA_DIR`. The first congress ingest covers the
current and previous year (a few minutes; it runs in the background while you work).
`cargo run -p tr-congress --example ingest -- congress.sqlite3` runs one ingest from the CLI.
Appearance: `TR_THEME=light|dark|<theme name>` overrides the saved theme, `TR_MSAA=1|2|4|8` sets
@@ -232,13 +224,9 @@ What the browser build can and can't do yet:
books and websocket bars all come from them. Yahoo, Treasury.gov, Alpaca and CoinDesk don't allow
them, so every other market shows sample data and the toolbar says "Crypto live". A same-origin
proxy can fix that later.
- HIP-3 perps (`xyz:AAPL`) are live through the Terminal data server the page is built with. They
never fall back to sample data: while the server is unreachable they say so.
- Congress disclosures, SnapTrade accounts and live order entry need the desktop app. The paper
trading demo account works.
- Layouts, settings, themes and the demo account are saved in `localStorage` under `terminal/`.
The first load copies anything saved under the old `terminal-rs/` prefix, without overwriting
newer entries.
- Layouts, settings, themes and the demo account are saved in `localStorage` under `terminal-rs/`.
- Charts need WebGPU, since they read bars from storage buffers in the vertex shader. Browsers
without a WebGPU adapter get a second, larger build on WebGL2 where everything except the chart
series works.
@@ -280,9 +268,8 @@ the portal, and **Disconnect** removes a connection and its accounts from SnapTr
explicit confirmation.
**Where credentials live.** On macOS the client ID, consumer key, user ID and user secret are
stored together in the **Keychain** (generic password, service `terminal`, account
`snaptrade`); credentials saved under the old service, `terminal-rs`, move there the first time
the app reads them. Elsewhere they go in `<data dir>/secrets/snaptrade.json` with owner-only permissions
stored together in the **Keychain** (generic password, service `terminal-rs`, account
`snaptrade`). Elsewhere they go in `<data dir>/secrets/snaptrade.json` with owner-only permissions
(0600 in a 0700 directory). They are never written to the repository, layouts, prefs or logs;
request errors are scrubbed of the URL (which carries the user secret), and the settings screen
masks secrets. **Forget Credentials** removes them. `SNAPTRADE_CLIENT_ID`,
+2 -12
View File
@@ -1,9 +1,9 @@
[package]
name = "terminal"
name = "terminal-rs"
description = "Bloomberg-style trading terminal with GPU charts and congressional trade tracking"
version.workspace = true
edition.workspace = true
default-run = "terminal"
default-run = "terminal-rs"
[features]
# Browser build only: add wgpu's WebGL2 backend for browsers without WebGPU (about 1 MB more,
@@ -13,8 +13,6 @@ webgl = ["eframe/wgpu"]
[dependencies]
tr-core.workspace = true
tr-broker.workspace = true
tr-hl.workspace = true
tr-turnkey.workspace = true
tr-congress.workspace = true
tr-data.workspace = true
tr-chart.workspace = true
@@ -25,13 +23,10 @@ serde_json.workspace = true
toml.workspace = true
tracing.workspace = true
futures = "0.3"
zeroize = "1"
egui = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false, features = ["wgpu_no_default_features", "wayland", "x11"] }
tr-macos.workspace = true
web-time = "1"
# The Deposit window's QR code (no image dependencies).
qrcode = { version = "0.14", default-features = false }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
eframe = { version = "0.36", default-features = false, features = ["wgpu", "default_fonts"] }
@@ -65,11 +60,6 @@ web-sys = { version = "0.3", features = [
"Response",
"Storage",
"Url",
"UrlSearchParams",
"Window",
"console",
] }
[dev-dependencies]
async-trait = "0.1"
tr-turnkey = { workspace = true, features = ["mock"] }
+14 -162
View File
@@ -7,11 +7,6 @@
mod broker;
mod chrome;
mod demo;
mod hl;
pub(crate) use hl::store_account as hl_store_account;
mod hl_view;
#[cfg(target_arch = "wasm32")]
mod hl_web;
mod hotkeys_view;
mod options_view;
mod palette_view;
@@ -20,8 +15,6 @@ mod portfolio_view;
mod settings_view;
mod theming;
mod trading;
mod wallet;
mod wallet_view;
mod workspace;
pub use broker::PosSort;
@@ -52,9 +45,6 @@ use crate::worker::{Profile, Request, Response, Worker, data_dir};
use chrome::{SIDEBAR_W, STATUS_H, SidebarCmd};
use settings_view::SettingsTab;
/// How often a visible order book pane asks for its book.
const BOOK_POLL: Duration = Duration::from_millis(400);
pub enum PaneKind {
Chart(Box<ChartState>),
Feed {
@@ -165,50 +155,18 @@ pub struct BookPane {
pub book: Option<OrderBook>,
pub error: Option<String>,
pub fetched: Option<Instant>,
/// A request is out. A REST book can take longer than [`BOOK_POLL`], and requests run
/// concurrently, so the pane waits for the answer before it asks again.
pub pending: bool,
/// Numbers the pane's asks; only the answer to the latest one clears `pending`.
pub asked: u64,
/// The latest trades, oldest first, when the book comes from the Terminal server.
pub trades: Vec<tr_data::TradePrint>,
}
impl BookPane {
pub fn new(symbol: &str, depth: usize, group: f64) -> Self {
Self {
symbol: symbol.into(),
depth,
group,
book: None,
error: None,
fetched: None,
pending: false,
asked: 0,
trades: Vec::new(),
}
}
/// Time to ask for the book again: [`BOOK_POLL`] since the last ask, once it was answered (or
/// went unanswered for 10 s).
fn due(&self) -> bool {
self.fetched.is_none_or(|t| t.elapsed() > BOOK_POLL && (!self.pending || t.elapsed() > Duration::from_secs(10)))
}
/// Whether the book came through the Terminal server, which also has the market's trades.
pub fn served_by_server(&self) -> bool {
self.book.as_ref().is_some_and(|b| b.source.ends_with("Terminal server"))
Self { symbol: symbol.into(), depth, group, book: None, error: None, fetched: None }
}
}
impl PaneKind {
/// Short tab title.
fn tab_title(&self) -> String {
// A perp's tab says so: `xyz:AAPL PERP`.
let short = |s: &str| {
let sec = tr_data::security(s);
if sec.class == tr_core::AssetClass::Perp { format!("{} PERP", sec.ticker) } else { sec.ticker }
};
let short = |s: &str| tr_data::security(s).ticker;
match self {
PaneKind::Chart(c) => short(&c.symbol),
PaneKind::Feed { .. } => "Congress".into(),
@@ -506,10 +464,6 @@ pub struct TerminalApp {
wei_updated: Option<String>,
/// Latest network search results and the query they answer.
net_hits: (String, Vec<SearchHit>),
/// HIP-3 perps the Terminal server lists as live (`None` until it has answered once), and
/// when they were last asked for.
live_perps: Option<Vec<String>>,
last_perps: Option<(Instant, bool)>,
/// Query typed in the command line awaiting a debounced network search.
search_pending: Option<(String, Instant)>,
suggestion: Option<usize>,
@@ -560,26 +514,11 @@ pub struct TerminalApp {
trading: trading::TradingState,
/// The paper-trading demo account (selected by default).
demo: demo::DemoState,
/// Hyperliquid (testnet): the account, the agent key and its confirmations.
hl: hl::HlState,
/// ⌘K command palette.
palette: crate::palette::Palette,
}
/// Resolve a run of words (`BTCUSD CRNCY AAPL US EQUITY MSFT`) into market keys.
/// Lay fresh quotes over the ones shown, and return the keys that got one. A perp that was
/// asked for and left out has no other source, so its old quote goes rather than pass for current.
fn merge_quotes(shown: &mut HashMap<String, Quote>, asked: &[String], quotes: Vec<Quote>) -> Vec<String> {
let keys: Vec<String> = quotes.iter().map(|q| q.symbol.clone()).collect();
for gone in asked.iter().filter(|k| tr_data::is_perp(k) && !keys.contains(k)) {
shown.remove(gone);
}
for q in quotes {
shown.insert(q.symbol.clone(), q);
}
keys
}
fn keys_from_words(words: &[String]) -> Vec<String> {
let tokens: Vec<&str> = words.iter().map(String::as_str).collect();
let mut out = Vec::new();
@@ -637,7 +576,6 @@ impl TerminalApp {
broker,
trading: Default::default(),
demo,
hl: hl::HlState::platform(),
worker,
provider,
panes: Vec::new(),
@@ -674,8 +612,6 @@ impl TerminalApp {
last_curve: None,
wei_updated: None,
net_hits: (String::new(), Vec::new()),
live_perps: None,
last_perps: None,
search_pending: None,
suggestion: None,
cmd: String::new(),
@@ -737,11 +673,6 @@ impl TerminalApp {
app.save_layout();
}
app.refresh_layout_names();
#[cfg(target_arch = "wasm32")]
{
app.hl.ctx = Some(ctx.clone());
}
app.hl_start();
app.worker.send(Request::Latest { limit: 2_000 });
app.worker.send(Request::SearchPoliticians(String::new()));
app.worker.send(Request::Alerts);
@@ -847,7 +778,6 @@ impl TerminalApp {
self.status = format!("{s}: option chain");
}
Command::SymbolFunction(s, Function::Portfolio(view, arg)) => self.portfolio_command(view, &arg, Some(s)),
Command::SymbolFunction(s, Function::Perp) => self.open_perp(Some(s)),
Command::SymbolFunction(s, Function::Trade(mut t)) => {
t.symbol = Some(s);
self.open_ticket(t, None);
@@ -1002,11 +932,6 @@ impl TerminalApp {
}
self.set_account_mode(mode, false);
}
Function::Kill => self.hl_kill(),
Function::Perp => {
let key = symbol.or_else(|| self.focused_symbol());
self.open_perp(key);
}
}
}
@@ -1032,7 +957,6 @@ impl TerminalApp {
prepended,
from_cache,
exhausted,
full,
live,
} => {
let worker = &self.worker;
@@ -1040,7 +964,7 @@ impl TerminalApp {
&& c.symbol == symbol
&& c.tf == tf
{
c.on_history(generation, bars, gpu, prepended, from_cache, exhausted, full, live, worker);
c.on_history(generation, bars, gpu, prepended, from_cache, exhausted, live, worker);
}
}
Response::HistoryError { pane, generation, error } => {
@@ -1068,11 +992,14 @@ impl TerminalApp {
c.on_markers(&symbol, trades);
}
}
Response::Quotes { asked, quotes } => {
if quotes.len() > 20 {
Response::Quotes(q) => {
if q.len() > 20 {
self.wei_updated = Some(format!("updated {}", chrono::Local::now().format("%H:%M:%S")));
}
let keys = merge_quotes(&mut self.quotes, &asked, quotes);
let keys: Vec<String> = q.iter().map(|x| x.symbol.clone()).collect();
for x in q {
self.quotes.insert(x.symbol.clone(), x);
}
self.demo_on_quotes(&keys);
}
Response::Latest(t) => self.latest = t,
@@ -1125,15 +1052,12 @@ impl TerminalApp {
}
}
}
Response::OrderBook { pane, symbol, ask, book } => {
Response::OrderBook { pane, symbol, book } => {
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane)
&& let PaneKind::OrderBook(b) = &mut p.kind
&& b.symbol == symbol
{
if ask == b.asked {
b.pending = false;
}
match book {
_ if b.symbol != symbol => {}
Ok(book) => {
b.book = Some(book);
b.error = None;
@@ -1142,15 +1066,6 @@ impl TerminalApp {
}
}
}
Response::Trades { pane, symbol, trades } => {
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane)
&& let PaneKind::OrderBook(b) = &mut p.kind
&& b.symbol == symbol
{
// A failed fetch clears the list rather than leave old trades up.
b.trades = trades.unwrap_or_default();
}
}
Response::Portfolio(result) => {
let result = self.keep_simulated_trades(result);
self.on_portfolio(result)
@@ -1170,19 +1085,7 @@ impl TerminalApp {
Response::Status(s) => self.status = s,
Response::Curves(c) => self.curves = Some(c),
Response::SearchHits { query, hits } => self.net_hits = (query, hits),
Response::PerpMarkets(result) => {
let ok = result.is_ok();
match result {
Ok(perps) => self.live_perps = Some(perps),
// Keep what the server said last; links to perps it listed stay usable.
Err(e) => tracing::debug!("live perps: {e}"),
}
if let Some((_, answered)) = &mut self.last_perps {
*answered = ok;
}
}
Response::Trade(r) => self.on_trade(*r),
Response::Hl(r) => self.on_hl(*r),
Response::CongressRefreshed(summary) => {
self.congress_status = summary.to_string();
self.congress_checked = Some(chrono::Local::now());
@@ -1385,8 +1288,6 @@ impl TerminalApp {
| A::ResetView
| A::LogScale
| A::Magnet => self.focused_is_chart(),
// Otherwise ⇧P types a P into the command line.
A::TogglePerp => self.focused_symbol().is_some_and(|s| self.switch_target(&s).is_some()),
a => a.layout_slot().is_none_or(|i| i < self.layout_names.len()),
}
}
@@ -1406,7 +1307,6 @@ impl TerminalApp {
A::Orders => self.apply(Function::Portfolio(command::PortView::Orders, String::new())),
A::Buy => self.quick_trade(true),
A::Sell => self.quick_trade(false),
A::TogglePerp => self.apply(Function::Perp),
A::NextPane => self.cycle_focus(1),
A::PrevPane => self.cycle_focus(-1),
A::ToggleSidebar => self.sidebar_open = !self.sidebar_open,
@@ -1458,20 +1358,13 @@ impl TerminalApp {
self.worker.send(Request::Quotes(markets_view::overview_keys()));
self.last_wei = Some(Instant::now());
}
// Which perps are live: every 5 minutes, or every minute while the server isn't answering.
let every = |ok: bool| Duration::from_secs(if ok { 300 } else { 60 });
if self.last_perps.is_none_or(|(t, ok)| t.elapsed() > every(ok)) {
self.worker.send(Request::PerpMarkets);
self.last_perps = Some((Instant::now(), self.last_perps.is_none_or(|(_, ok)| ok)));
}
if shown(&|k| matches!(k, PaneKind::Curve))
&& self.last_curve.is_none_or(|t| t.elapsed() > Duration::from_secs(600))
{
self.worker.send(Request::YieldCurves);
self.last_curve = Some(Instant::now());
}
// News every 5 minutes and order books about twice a second, while visible. The Terminal
// server's books change about every 0.5 s; 400 ms puts a poll on every 500 ms repaint tick.
// News every 5 minutes and order books every second, while visible.
for &i in &visible {
let p = &mut self.panes[i];
match &mut p.kind {
@@ -1487,24 +1380,13 @@ impl TerminalApp {
expiration: o.expiration,
});
}
PaneKind::OrderBook(b) if b.due() => {
PaneKind::OrderBook(b) if b.fetched.is_none_or(|t| t.elapsed() > Duration::from_millis(1000)) => {
b.fetched = Some(Instant::now());
b.pending = true;
b.asked += 1;
self.worker.send(Request::OrderBook {
pane: p.id,
symbol: b.symbol.clone(),
depth: b.depth,
ask: b.asked,
});
if b.served_by_server() {
self.worker.send(Request::Trades { pane: p.id, symbol: b.symbol.clone() });
}
self.worker.send(Request::OrderBook { pane: p.id, symbol: b.symbol.clone(), depth: b.depth });
}
_ => {}
}
}
self.hl_periodic();
self.broker_periodic(&visible);
if let Some((q, at)) = &self.search_pending
&& at.elapsed() > Duration::from_millis(300)
@@ -1725,17 +1607,6 @@ impl eframe::App for TerminalApp {
mod tests {
use super::*;
#[test]
fn a_book_pane_asks_again_once_answered() {
let ago = |ms| Instant::now().checked_sub(Duration::from_millis(ms));
let pane = |fetched, pending| BookPane { fetched, pending, ..BookPane::new("BTCUSD Curncy", 20, 0.0) };
assert!(pane(None, false).due());
assert!(!pane(ago(0), false).due());
assert!(pane(ago(450), false).due(), "a poll on every 500 ms repaint tick");
assert!(!pane(ago(450), true).due(), "one request out at a time");
assert!(pane(ago(11_000), true).due(), "a lost answer doesn't stop the pane");
}
#[test]
fn legacy_settings_migrate_to_a_layout() {
let old = r#"{"layout":"MainRight2","watchlist":["AAPL","BTCUSD Curncy"],"sidebar":false,
@@ -1754,25 +1625,6 @@ mod tests {
assert_eq!(new.active_layout.as_deref(), Some("Desk"));
}
/// When the server can't quote a perp, the watchlist drops its old price instead of showing
/// it as current. Listed instruments keep theirs, as before.
#[test]
fn perps_left_out_of_an_answer_lose_their_old_quote() {
let q = |s: &str, last: f64| Quote { symbol: s.into(), last, prev_close: last, ts: 0, spark: vec![] };
let mut shown: HashMap<String, Quote> =
[q("xyz:AAPL", 336.0), q("xyz:GOLD", 3958.0), q("AAPL US Equity", 336.5)]
.map(|x| (x.symbol.clone(), x))
.into();
let asked: Vec<String> = ["xyz:AAPL", "xyz:GOLD", "AAPL US Equity"].map(String::from).to_vec();
let got = merge_quotes(&mut shown, &asked, vec![q("xyz:GOLD", 3960.0)]);
assert_eq!(got, ["xyz:GOLD"]);
assert!(!shown.contains_key("xyz:AAPL"), "the perp the server didn't quote");
assert_eq!(shown["xyz:GOLD"].last, 3960.0);
assert_eq!(shown["AAPL US Equity"].last, 336.5, "a listed instrument keeps its last quote");
merge_quotes(&mut shown, &["xyz:SP500".into()], vec![]);
assert!(shown.contains_key("xyz:GOLD"), "a perp nobody asked about this time stays");
}
#[test]
fn pane_settings_round_trip_through_kinds() {
let d = prefs::PanelDefaults::default();
+11 -66
View File
@@ -101,11 +101,7 @@ pub struct BrokerState {
impl BrokerState {
pub fn load(offline: bool) -> Self {
Self::with_store(SecretStore::platform(&data_dir()), offline)
}
/// [`Self::load`] with credentials from `store` instead of the platform's.
pub fn with_store(store: SecretStore, offline: bool) -> Self {
let store = SecretStore::platform(&data_dir());
let mut message = None;
// The browser build can't reach SnapTrade, and keys don't belong in page storage.
let stored = if cfg!(target_arch = "wasm32") { Ok(None) } else { store.load() };
@@ -154,27 +150,15 @@ impl BrokerState {
BrokerMode::Setup
}
}
}
/// The terminal's own price for a position, from the network the position is on: testnet
/// mids for Hyperliquid testnet rows, the Terminal server's (mainnet) quotes for everything
/// else. Both use the same market keys (`xyz:AAPL`), so a row never falls back to the other
/// map. Options are never repriced from their underlying.
pub fn live_price(
quotes: &std::collections::HashMap<String, tr_data::Quote>,
testnet: &std::collections::HashMap<String, f64>,
p: &Position,
) -> Option<f64> {
if p.kind == tr_broker::InstrumentKind::Option {
return None;
/// The terminal's own quote for a position, when it maps onto a market key (options are
/// never repriced from their underlying).
pub fn live_price(&self, quotes: &std::collections::HashMap<String, tr_data::Quote>, p: &Position) -> Option<f64> {
if p.kind == tr_broker::InstrumentKind::Option {
return None;
}
quotes.get(&p.market_key()?).map(|q| q.last).filter(|x| x.is_finite() && *x > 0.0)
}
let key = p.market_key()?;
let px = if super::hl::is_testnet_account(&p.account_id) {
testnet.get(&key).copied()
} else {
quotes.get(&key).map(|q| q.last)
};
px.filter(|x| x.is_finite() && *x > 0.0)
}
/// Open a URL or path with the system handler.
@@ -206,7 +190,7 @@ pub fn new_user_id() -> String {
// Browsers have no process ids.
let pid = if cfg!(target_arch = "wasm32") { 0 } else { std::process::id() };
let mix = nanos ^ (u128::from(pid) << 64);
format!("terminal-{:016x}", (mix as u64) ^ ((mix >> 64) as u64))
format!("terminal-rs-{:016x}", (mix as u64) ^ ((mix >> 64) as u64))
}
impl TerminalApp {
@@ -241,13 +225,10 @@ impl TerminalApp {
if let Some(pf) = self.shown_portfolio()
&& self.broker.last_quotes.is_none_or(|t| t.elapsed() > Duration::from_secs(20))
{
// Testnet rows price from testnet mids (the Hyperliquid stream), never the server.
let mut keys: Vec<String> = pf
.positions
.iter()
.filter(|p| {
p.kind != tr_broker::InstrumentKind::Option && !super::hl::is_testnet_account(&p.account_id)
})
.filter(|p| p.kind != tr_broker::InstrumentKind::Option)
.filter_map(Position::market_key)
.collect();
keys.sort();
@@ -821,46 +802,10 @@ mod tests {
let a = new_user_id();
std::thread::sleep(Duration::from_millis(1));
let b = new_user_id();
assert!(a.starts_with("terminal-") && a.len() == "terminal-".len() + 16);
assert!(a.starts_with("terminal-rs-") && a.len() == "terminal-rs-".len() + 16);
assert_ne!(a, b);
}
/// A Hyperliquid testnet row and a mainnet row on the same key (`xyz:AAPL`) with mainnet
/// and testnet updates arriving interleaved: each reads only its own network's price.
#[test]
fn testnet_rows_never_read_mainnet_quotes() {
let quote =
|last: f64| tr_data::Quote { symbol: "xyz:AAPL".into(), last, prev_close: last, ts: 0, spark: Vec::new() };
let row = |account: &str| Position {
account_id: account.into(),
symbol: "xyz:AAPL".into(),
description: String::new(),
kind: tr_broker::InstrumentKind::Future,
units: 1.0,
price: None,
cost_basis: Some(300.0),
multiplier: 1.0,
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
market: Some("xyz:AAPL".into()),
};
let testnet = row("hl:testnet:0x00000000000000000000000000000000000f107e");
let mainnet = row("mainnet-account");
let mut quotes = std::collections::HashMap::new();
let mut mids = std::collections::HashMap::new();
for (main, test) in [(230.0, 322.0), (231.0, 321.5), (229.5, 323.0)] {
quotes.insert("xyz:AAPL".to_string(), quote(main));
assert_eq!(live_price(&quotes, &mids, &testnet).is_some(), mids.contains_key("xyz:AAPL"));
mids.insert("xyz:AAPL".to_string(), test);
assert_eq!(live_price(&quotes, &mids, &testnet), Some(test), "the testnet row reads the testnet mid");
assert_eq!(live_price(&quotes, &mids, &mainnet), Some(main), "the mainnet row reads the gateway quote");
}
// With only the other network's price, neither row borrows it.
assert_eq!(live_price(&quotes, &Default::default(), &testnet), None);
assert_eq!(live_price(&Default::default(), &mids, &mainnet), None);
}
#[test]
fn pane_titles() {
assert_eq!(PortfolioPane::new(PortView::Summary).title(), "Portfolio");
+31 -94
View File
@@ -102,13 +102,13 @@ impl TerminalApp {
resp.context_menu(|ui| self.view_menu(ui));
// Data source chip (the window title itself is redundant in a compact toolbar).
let (label, live) = source_chip(&self.provider);
let (label, live) = self.source_chip();
let chip_text = p.layout_no_wrap(label.into(), ui_font(11.0), muted());
let chip = Rect::from_min_size(pos2(x, cy - 9.0), vec2(chip_text.size().x + 20.0, 18.0));
p.rect_filled(chip, 9.0, theme::control());
p.circle_filled(pos2(chip.left() + 8.0, cy), 3.0, if live { theme::up() } else { amber() });
p.galley(pos2(chip.left() + 14.0, cy - chip_text.size().y / 2.0), chip_text, muted());
ui.interact(chip, Id::new("source-chip"), Sense::hover()).on_hover_text(provider_label(&self.provider));
ui.interact(chip, Id::new("source-chip"), Sense::hover()).on_hover_text(self.provider_label());
x = chip.right() + 8.0;
// Which account trades: DEMO (paper) or LIVE (real money, in red). Click to switch.
@@ -134,17 +134,6 @@ impl TerminalApp {
p.hline(rect.x_range(), rect.bottom() - 1.0, Stroke::new(2.0, theme::down()));
}
x = badge.right() + 10.0;
// A Hyperliquid account in LIVE is on testnet: say so next to the badge.
if live_mode && self.hl_master().is_some() {
let t = p.layout_no_wrap("TESTNET".into(), mono(11.0), Color32::BLACK);
let pill = Rect::from_min_size(pos2(x - 4.0, cy - 10.0), vec2(t.size().x + 18.0, 20.0));
p.rect_filled(pill, 10.0, amber());
p.galley(pill.center() - t.size() / 2.0, t, Color32::BLACK);
ui.interact(pill, Id::new("hl-testnet-badge"), Sense::hover()).on_hover_text(
"The Hyperliquid account in LIVE is on testnet (test funds). This build cannot reach mainnet.",
);
x = pill.right() + 10.0;
}
// Right-hand cluster, laid out from the right edge: one-line clock (UTC on hover).
let now = chrono::Local::now();
@@ -293,6 +282,30 @@ impl TerminalApp {
}
}
/// The data-source chip's label, and whether any of the data is live.
fn source_chip(&self) -> (&'static str, bool) {
let n = self.provider.to_lowercase();
if n == "sample" {
("Offline", false)
} else if n.contains("sample") {
("Crypto live", true)
} else {
("Live", true)
}
}
fn provider_label(&self) -> String {
let n = self.provider.to_lowercase();
if n == "sample" {
"Offline · sample data".into()
} else if n.contains("sample") {
// The browser build (tr_data::web::WebMarkets).
"Live crypto from Binance and Coinbase; every other market is sample data in the browser".into()
} else {
format!("Live · {}", self.provider)
}
}
/// The Bloomberg command line, drawn as a rounded macOS search field.
fn command_field(&mut self, ui: &mut Ui, field: Rect) -> bool {
let suggestions = self.suggestions();
@@ -455,7 +468,7 @@ impl TerminalApp {
return Vec::new();
}
let net: &[SearchHit] = if self.net_hits.0 == q { &self.net_hits.1 } else { &[] };
let mut v = markets_view::merged_hits(q, net, &self.perp_candidates());
let mut v = markets_view::merged_hits(q, net);
v.truncate(8);
v
}
@@ -548,7 +561,7 @@ impl TerminalApp {
let head = Rect::from_min_size(rect.min, vec2(rect.width(), self.toolbar_h()));
window_drag_region(ui, head, "sidebar");
if !cfg!(target_os = "macos") {
p.text(head.left_center() + vec2(14.0, 0.0), Align2::LEFT_CENTER, "TERMINAL", mono(12.5), amber());
p.text(head.left_center() + vec2(14.0, 0.0), Align2::LEFT_CENTER, "TERMINAL-RS", mono(12.5), amber());
}
let body = Rect::from_min_max(pos2(rect.left(), head.bottom()), rect.max);
@@ -566,9 +579,7 @@ impl TerminalApp {
let resp = row_frame(ui, false);
let r = resp.rect;
let pt = ui.painter();
let perp = sec.class == tr_core::AssetClass::Perp;
let tc = if perp { theme::perp() } else { text() };
pt.text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.0), tc);
pt.text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.0), text());
if let Some(q) = self.quotes.get(key) {
let up = q.change() >= 0.0;
let c = if up { theme::up() } else { theme::down() };
@@ -596,15 +607,7 @@ impl TerminalApp {
if resp.clicked() {
out.push(SidebarCmd::Action(Action::OpenChart(key.clone())));
}
let resp = resp.on_hover_text(match perp {
true => format!(
"{} — {} · {}",
sec.key(),
sec.name().unwrap_or("HIP-3 perp"),
tr_core::perps::badge(key)
),
false => format!("{} — {}", sec.key(), sec.name().unwrap_or(&sec.ticker)),
});
let resp = resp.on_hover_text(format!("{} — {}", sec.key(), sec.name().unwrap_or(&sec.ticker)));
resp.context_menu(|ui| {
if ui.button("Open Chart").clicked() {
out.push(SidebarCmd::Action(Action::OpenChart(key.clone())));
@@ -664,7 +667,7 @@ impl TerminalApp {
});
let pf = self.shown_portfolio();
let value_badge = pf.filter(|p| !p.accounts.is_empty()).map(|p| {
let t = p.totals(None, |pos| super::broker::live_price(&self.quotes, &self.hl.mids, pos));
let t = p.totals(None, |pos| self.broker.live_price(&self.quotes, pos));
compact_money(t.value)
});
let open_orders = pf.map_or(0, |p| p.orders.iter().filter(|o| o.is_open()).count());
@@ -985,69 +988,3 @@ fn nav_row(ui: &mut Ui, icon: Icon, label: &str, trailing: Option<&str>, selecte
}
resp
}
/// What the data-source chip says on hover, from the provider's name.
/// The data-source chip's label, and whether any of the data is live.
fn source_chip(provider: &str) -> (&'static str, bool) {
let n = provider.to_lowercase();
if n == "sample" {
("Offline", false)
} else if n.contains("sample") && n.starts_with("server+") {
("Crypto + perps live", true)
} else if n.contains("sample") {
("Crypto live", true)
} else {
("Live", true)
}
}
fn provider_label(provider: &str) -> String {
let n = provider.to_lowercase();
if n == "sample" {
"Offline · sample data".into()
} else if let Some(rest) = provider.strip_prefix("server+") {
// A Terminal server (tr_data::remote::RemoteMarkets), in front of the browser's sources
// (tr_data::web::WebMarkets) or the desktop's.
if rest.contains("sample") {
"Live crypto from Hyperliquid perps through the Terminal server, or from Binance and Coinbase while \
it is unreachable, and HIP-3 perps (xyz:AAPL) from the server only; every other market is sample \
data in the browser"
.into()
} else {
format!(
"Live · crypto from Hyperliquid perps and HIP-3 perps (xyz:AAPL) through the Terminal server; other \
markets from {rest}"
)
}
} else if n.contains("sample") {
// The browser build (tr_data::web::WebMarkets).
"Live crypto from Binance and Coinbase; every other market is sample data in the browser".into()
} else {
format!("Live · {provider}")
}
}
#[cfg(test)]
mod tests {
use super::{provider_label, source_chip};
#[test]
fn the_source_tooltip_names_the_terminal_server() {
assert_eq!(provider_label("sample"), "Offline · sample data");
assert!(provider_label("binance+sample").starts_with("Live crypto from Binance"));
let web = provider_label("server+binance+sample");
assert!(web.contains("Terminal server") && web.contains("sample data in the browser"), "{web}");
let desktop = provider_label("server+markets");
assert!(desktop.contains("Terminal server") && desktop.ends_with("other markets from markets"), "{desktop}");
assert!(web.contains("HIP-3 perps") && desktop.contains("HIP-3 perps"), "{web} / {desktop}");
}
/// With a server in front, the browser build has live perps as well as crypto.
#[test]
fn the_source_chip_says_what_is_live() {
assert_eq!(source_chip("sample"), ("Offline", false));
assert_eq!(source_chip("binance+sample"), ("Crypto live", true));
assert_eq!(source_chip("server+binance+sample"), ("Crypto + perps live", true));
assert_eq!(source_chip("server+yahoo+binance+ust"), ("Live", true));
}
}
-1426
View File
File diff suppressed because it is too large. Load diff
-810
View File
@@ -1,810 +0,0 @@
//! Hyperliquid's windows: the ticket body for a Hyperliquid account, the confirmations,
//! the enable dialog and Settings › Trading › Hyperliquid. Every one shows the amber
//! TESTNET badge.
use egui::{Color32, RichText, Ui};
use tr_broker::trading::Side;
use tr_hl::actions::Tif;
use tr_hl::caps::{MAX_LEVERAGE, MAX_MARKET_SLIPPAGE, MAX_ORDER_NOTIONAL_USD};
use tr_hl::rounding::to_wire;
use tr_hl::trader::{EXPIRES_AFTER_MS, OrderKind};
use super::TerminalApp;
use super::hl::{HlConfirm, HlKind, intent, is_hl_account, market_edited, resolve_coin, settle_size};
use super::portfolio_view::{money, price, qty};
use super::settings_view::SettingsCmd;
use super::trading::TicketAct;
use crate::prefs::AccountMode;
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
use crate::widgets;
/// The amber TESTNET pill.
pub fn testnet_badge(ui: &mut Ui) -> egui::Response {
ui.add(
egui::Button::new(RichText::new("TESTNET").font(mono(11.0)).strong().color(Color32::BLACK))
.fill(amber())
.corner_radius(9.0)
.sense(egui::Sense::hover()),
)
.on_hover_text("Hyperliquid testnet: test funds only. This build cannot reach mainnet.")
}
pub(super) fn masked(addr: &str) -> String {
tr_hl::key::parse_address(addr).map(|a| tr_hl::mask_address(&a)).unwrap_or_else(|_| addr.to_string())
}
const HIP3_RISK: &str = "A leveraged perpetual future with funding, priced by the deployer's oracle. It can diverge from the \
listed stock (off-hours, opens, earnings, corporate actions), and the deployer can halt the market and settle it at mark.";
impl TerminalApp {
/// The ticket's body when a Hyperliquid account is selected.
pub(super) fn hl_ticket_ui(&mut self, ui: &mut Ui) -> Option<TicketAct> {
let accounts = self.trade_accounts_pub();
let blocker = self.hl_blocker();
let reg = self.hl.registry.clone();
let sending = self.hl.sending.is_some();
let coin = self
.trading
.ticket
.as_ref()
.and_then(|t| reg.as_ref().map(|r| resolve_coin(&t.symbol, r)))
.unwrap_or_else(|| Err("Loading Hyperliquid testnet markets…".into()));
let ctx = coin.as_ref().map(|c| self.hl.ctx(c)).unwrap_or_default();
let asset = coin.as_ref().ok().and_then(|c| reg.as_ref()?.get(c).cloned());
let t = self.trading.ticket.as_mut()?;
let mut act = None;
let mut review = false;
let mut leverage = None;
let mut settings = false;
ui.spacing_mut().item_spacing.y = dp(6.0);
ui.horizontal(|ui| {
if let Some(i) = widgets::segmented(ui, "ticket-mode", &["DEMO", "LIVE"], Some(1), 22.0)
&& i == 0
&& !sending
{
act = Some(TicketAct::Mode(AccountMode::Demo));
}
testnet_badge(ui);
ui.label(RichText::new("Hyperliquid · test funds only").font(ui_font(12.0)).color(amber()));
});
let current = t
.account
.as_ref()
.and_then(|id| accounts.iter().find(|a| &a.id == id))
.map_or("Choose an account…".to_string(), |a| format!("{} {}", a.label(), a.masked_number()));
ui.add_enabled_ui(!sending, |ui| {
egui::ComboBox::from_id_salt("hl-ticket-account")
.width(ui.available_width())
.selected_text(current)
.show_ui(ui, |ui| {
for a in &accounts {
let label = if is_hl_account(&a.id) {
format!("{} {} · TESTNET", a.label(), a.masked_number())
} else {
format!("{} · {} {}", a.label(), a.institution, a.masked_number())
};
ui.selectable_value(&mut t.account, Some(a.id.clone()), label);
}
});
});
if let Some(b) = &blocker {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(b).font(ui_font(11.5)).color(amber()));
if ui.small_button("Settings…").clicked() {
settings = true;
}
});
}
ui.horizontal(|ui| {
let w = (ui.available_width() - dp(6.0)) / 2.0;
for (side, color) in [(Side::Buy, theme::up()), (Side::Sell, theme::down())] {
let b = if t.side == side {
egui::Button::new(RichText::new(side.label()).strong().color(Color32::WHITE)).fill(color)
} else {
egui::Button::new(RichText::new(side.label()).color(color))
};
if ui.add_enabled(!sending, b.min_size(egui::vec2(w, dp(26.0)))).clicked() {
t.side = side;
}
}
});
let label = |ui: &mut Ui, s: &str| {
ui.add_sized([dp(64.0), dp(20.0)], egui::Label::new(RichText::new(s).font(ui_font(12.0)).color(muted())));
};
ui.horizontal(|ui| {
label(ui, "Market");
let r = ui.add_enabled(
!sending,
egui::TextEdit::singleline(&mut t.symbol)
.hint_text("BTC, xyz:AAPL")
.font(mono(13.0))
.desired_width(dp(96.0)),
);
if r.changed() {
t.error = None;
market_edited(t);
}
match ctx.mid {
Some(m) => {
ui.label(RichText::new(price(m)).font(mono(12.5)).color(text()))
.on_hover_text("Hyperliquid testnet mid (not the Terminal server's mainnet quote)");
}
None if coin.is_ok() => {
ui.label(RichText::new("testnet mid…").font(ui_font(11.5)).color(muted()));
}
None => {}
}
});
match (&coin, &asset) {
(Ok(c), Some(a)) => {
let kind = if a.is_hip3() { "perpetual, not a share" } else { "perpetual" };
ui.horizontal(|ui| {
ui.add_space(dp(70.0));
ui.label(
RichText::new(format!("{} ({kind})", a.label())).font(mono(11.5)).strong().color(theme::cyan()),
)
.on_hover_text(format!("{c}: asset {} on Hyperliquid testnet", a.id));
});
}
(Err(e), _) if !t.symbol.trim().is_empty() => {
ui.label(RichText::new(e).font(ui_font(11.5)).color(amber()));
}
_ => {}
}
let mut unsized_note = None;
ui.horizontal(|ui| {
label(ui, if t.dollars { "Amount" } else { "Size" });
let r = ui.add_enabled(
!sending,
egui::TextEdit::singleline(&mut t.qty)
.hint_text(if t.dollars { "$" } else { "units" })
.font(mono(13.0))
.desired_width(dp(84.0)),
);
if r.changed() {
t.hl.from_dollars = None;
}
if t.focus_qty > 0 {
t.focus_qty -= 1;
if !r.has_focus() {
r.request_focus();
}
}
// `BUY xyz:AAPL $50` gets its size from the testnet mid. It is never 50 contracts.
unsized_note = reg.as_ref().and_then(|r| settle_size(t, r, ctx.mid, asset.as_ref()));
if let (false, Some(m), Ok(q)) = (t.dollars, ctx.mid, t.qty.trim().parse::<f64>()) {
let from = t.hl.from_dollars.map_or(String::new(), |d| format!(" (from ${d}, at the testnet mid)"));
ui.label(RichText::new(format!("≈ {}{from}", money(q * m, "USD"))).font(mono(11.5)).color(muted()));
}
});
if let Some(n) = unsized_note {
ui.horizontal_wrapped(|ui| {
ui.add_space(dp(70.0));
ui.label(RichText::new(n).font(ui_font(11.5)).color(amber()));
});
}
ui.horizontal(|ui| {
label(ui, "Type");
let labels: Vec<&str> = HlKind::ALL.iter().map(|k| k.label()).collect();
let sel = HlKind::ALL.iter().position(|k| *k == t.hl.kind);
if let Some(i) = widgets::segmented(ui, "hl-ticket-type", &labels, sel, 20.0)
&& !sending
{
t.hl.kind = HlKind::ALL[i];
if t.hl.kind == HlKind::Limit
&& t.limit.trim().is_empty()
&& let Some(m) = ctx.mid
{
t.limit = to_wire(tr_hl::rounding::round_price(
m,
asset.as_ref().map_or(0, |a| a.sz_decimals),
tr_hl::rounding::Round::Nearest,
))
.unwrap_or_default();
}
}
});
match t.hl.kind {
HlKind::Limit => {
ui.horizontal(|ui| {
label(ui, "Limit");
ui.add_enabled(
!sending,
egui::TextEdit::singleline(&mut t.limit)
.hint_text("price")
.font(mono(13.0))
.desired_width(dp(84.0)),
);
if let Some(m) = ctx.mid
&& ui.small_button("Mid").on_hover_text("Use the testnet mid").clicked()
{
t.limit = format!("{m}");
}
});
ui.horizontal(|ui| {
label(ui, "Time");
let tifs = [Tif::Gtc, Tif::Ioc, Tif::Alo];
let labels: Vec<&str> = tifs.iter().map(|x| x.label()).collect();
if let Some(i) =
widgets::segmented(ui, "hl-ticket-tif", &labels, tifs.iter().position(|x| *x == t.hl.tif), 20.0)
&& !sending
{
t.hl.tif = tifs[i];
}
});
}
HlKind::StopLoss | HlKind::TakeProfit => {
ui.horizontal(|ui| {
label(ui, "Trigger");
ui.add_enabled(
!sending,
egui::TextEdit::singleline(&mut t.hl.trigger)
.hint_text("price")
.font(mono(13.0))
.desired_width(dp(84.0)),
);
ui.label(
RichText::new("market order when it trades, reduce-only").font(ui_font(11.0)).color(muted()),
);
});
}
HlKind::Market => {
ui.horizontal(|ui| {
ui.add_space(dp(70.0));
ui.label(
RichText::new(format!("IOC limit {:.0}% through the testnet mid", MAX_MARKET_SLIPPAGE * 100.0))
.font(ui_font(11.0))
.color(muted()),
);
});
}
}
ui.horizontal(|ui| {
ui.add_space(dp(70.0));
let forced = matches!(t.hl.kind, HlKind::StopLoss | HlKind::TakeProfit);
let mut ro = t.hl.reduce_only || forced;
if ui.add_enabled(!forced && !sending, egui::Checkbox::new(&mut ro, "Reduce only")).changed() {
t.hl.reduce_only = ro;
}
});
if let (Ok(c), Some(a)) = (&coin, &asset) {
ui.horizontal_wrapped(|ui| {
label(ui, "Leverage");
match ctx.leverage {
Some(l) => {
let over = l.value > MAX_LEVERAGE;
ui.label(
RichText::new(format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" }))
.font(mono(12.0))
.color(if over { amber() } else { text() }),
);
if over {
ui.label(
RichText::new(format!("over this build's {MAX_LEVERAGE}× limit"))
.font(ui_font(11.0))
.color(amber()),
);
}
}
None => {
ui.label(RichText::new("reading…").font(ui_font(11.5)).color(muted()));
}
}
let target = MAX_LEVERAGE.min(a.max_leverage);
if ctx.leverage.is_none_or(|l| l.value != target)
&& blocker.is_none()
&& ui
.add_enabled(!sending, egui::Button::new(format!("Set {target}×")))
.on_hover_text("Asks for confirmation, then signs updateLeverage")
.clicked()
{
leverage = Some((c.clone(), target));
}
});
}
ui.separator();
// Value it with the market `intent` resolved now, not the one read before an edit.
let hl = &self.hl;
let planned = reg.as_ref().map(|r| {
intent(t, r).and_then(|i| {
tr_hl::trader::plan_order(r, &i, &hl.ctx(&i.coin), &tr_hl::actions::cloid(1)).map_err(|e| e.to_string())
})
});
let row = |ui: &mut Ui, k: &str, v: String, strong: bool| {
ui.horizontal(|ui| {
ui.add_sized(
[dp(110.0), dp(16.0)],
egui::Label::new(RichText::new(k).font(ui_font(11.5)).color(muted())),
);
let rt = RichText::new(v).font(mono(if strong { 13.0 } else { 12.0 })).color(text());
ui.label(if strong { rt.strong() } else { rt });
});
};
if let Some(Ok(p)) = &planned {
row(ui, "Est. value", money(p.notional(), "USD"), true);
row(
ui,
"Sends",
format!("{} @ {}", to_wire(p.size()).unwrap_or_default(), to_wire(p.price()).unwrap_or_default()),
false,
);
}
row(
ui,
"Limits",
format!(
"${MAX_ORDER_NOTIONAL_USD:.0}/order · {MAX_LEVERAGE}× · {:.0}% slippage",
MAX_MARKET_SLIPPAGE * 100.0
),
false,
);
if asset.as_ref().is_some_and(|a| a.is_hip3()) {
let coin = asset.as_ref().map(|a| a.coin.clone()).unwrap_or_default();
let under = coin.split(':').nth(1).unwrap_or_default().to_string();
ui.label(
RichText::new(format!("⚠ Perpetual future on Hyperliquid (xyz). Not a share of {under}."))
.font(ui_font(11.5))
.strong()
.color(amber()),
);
}
if let Some(e) = &t.error {
ui.label(RichText::new(e).font(ui_font(12.0)).color(theme::down()));
} else if let Some(Err(e)) = &planned
&& !t.qty.trim().is_empty()
{
ui.label(RichText::new(e).font(ui_font(11.5)).color(amber()));
}
if let Some(d) = &t.done {
ui.label(RichText::new(d).font(ui_font(12.0)).color(theme::up()));
}
let can = blocker.is_none() && !sending && matches!(&planned, Some(Ok(_)));
let fill = if t.side == Side::Buy { theme::up() } else { theme::down() };
ui.horizontal(|ui| {
let label = format!("Review {}…", t.side.label());
let b = if can {
egui::Button::new(RichText::new(&label).strong().color(Color32::WHITE)).fill(fill)
} else {
egui::Button::new(RichText::new(&label).strong())
};
// Review stays clickable with a refusal, so the cap's reason shows in the ticket.
let clickable = blocker.is_none() && !sending;
if ui
.add_enabled(clickable, b.min_size(egui::vec2(ui.available_width() - dp(24.0), dp(30.0))))
.on_hover_text("Rounds the order, checks the limits, then asks you to confirm")
.clicked()
{
review = true;
}
if sending {
ui.add(egui::Spinner::new().size(14.0));
}
});
ui.label(
RichText::new(format!(
"Signed on this device by the agent key and sent to Hyperliquid testnet; valid {} s.",
EXPIRES_AFTER_MS / 1000
))
.font(ui_font(11.0))
.color(muted()),
);
if review {
self.hl_review();
}
if let Some((c, l)) = leverage {
self.hl_request_leverage(&c, l);
}
if settings {
self.settings_tab = super::settings_view::SettingsTab::Trading;
self.open_panel(super::PaneKind::Settings);
}
act
}
/// The one confirmation for every Hyperliquid action.
pub(super) fn hl_confirm_dialog(&mut self, ctx: &egui::Context) {
let Some(c) = self.hl.confirm.clone() else { return };
// Editing the ticket after Review invalidates the confirmation.
if let HlConfirm::Order { ticket, plan, .. } = &c {
let same = self
.trading
.ticket
.as_ref()
.filter(|t| t.id == *ticket)
.zip(self.hl.registry.as_ref())
.is_some_and(|(t, r)| intent(t, r).is_ok_and(|i| &i == plan.intent()));
if !same {
self.hl.confirm = None;
return;
}
}
let master = self.hl_master().map(|m| masked(&m)).unwrap_or_default();
let agent = self
.prefs
.trading
.hl
.agent
.as_ref()
.map(|a| tr_hl::trader::agent_base_name(&a.name).to_string())
.unwrap_or_default();
let mut choice = None;
let modal = egui::Modal::new(egui::Id::new("hl-confirm")).show(ctx, |ui| {
ui.set_width(dp(430.0));
let grid_row = |ui: &mut Ui, k: &str, v: RichText| {
ui.label(RichText::new(k).font(ui_font(12.0)).color(muted()));
ui.label(v);
ui.end_row();
};
let plain = |s: String| RichText::new(s).font(ui_font(12.5)).color(text());
let (title, go, go_color) = match &c {
HlConfirm::Order { plan, modify, .. } => {
let side = if plan.intent().is_buy { "buy" } else { "sell" };
let t =
if modify.is_some() { format!("Confirm modify ({side})") } else { format!("Confirm {side}") };
let color = if plan.intent().is_buy { theme::up() } else { theme::down() };
(
t,
format!(
"{} {} — Sign & send",
if plan.intent().is_buy { "Buy" } else { "Sell" },
plan.asset().coin
),
color,
)
}
HlConfirm::Cancel { .. } => {
("Cancel order?".to_string(), "Cancel order — Sign & send".to_string(), theme::down())
}
HlConfirm::Kill { .. } => {
("Kill switch".to_string(), "Cancel all and turn orders off".to_string(), theme::down())
}
HlConfirm::Leverage { coin, leverage, .. } => {
(format!("Set {coin} leverage?"), format!("Set {leverage}× — Sign & send"), theme::accent())
}
};
ui.horizontal(|ui| {
testnet_badge(ui);
ui.label(RichText::new(title).font(ui_font(16.0)).strong());
});
ui.add_space(dp(4.0));
egui::Grid::new("hl-confirm-grid").num_columns(2).spacing([dp(14.0), dp(5.0)]).show(ui, |ui| {
grid_row(
ui,
"Network",
RichText::new("Hyperliquid TESTNET (test funds)").font(ui_font(12.5)).strong().color(amber()),
);
grid_row(ui, "Account", plain(format!("{master} · agent {agent}")));
match &c {
HlConfirm::Order { plan, modify, .. } => {
let a = plan.asset();
let kind = if a.is_hip3() { "perpetual, not a share" } else { "perpetual" };
grid_row(
ui,
"Market",
RichText::new(format!("{} · {} ({kind})", a.coin, a.label()))
.font(mono(12.5))
.color(amber()),
);
if let Some(oid) = modify {
grid_row(ui, "Replaces", plain(format!("order #{oid}")));
}
let side = if plan.intent().is_buy { "BUY" } else { "SELL" };
let color = if plan.intent().is_buy { theme::up() } else { theme::down() };
grid_row(
ui,
"Order",
RichText::new(format!("{side} {} {}", qty(plan.size()), a.coin))
.font(mono(13.0))
.strong()
.color(color),
);
grid_row(ui, "Type", plain(plan.kind_label()));
let px = match plan.intent().kind {
OrderKind::Market => format!(
"IOC limit {} (at most {:.0}% from the mid)",
price(plan.price()),
MAX_MARKET_SLIPPAGE * 100.0
),
OrderKind::Trigger { .. } => format!(
"trigger {} · fills down to {}",
plan.trigger().map_or("—".into(), price),
price(plan.price())
),
OrderKind::Limit { .. } => price(plan.price()),
};
grid_row(ui, "Price", RichText::new(px).font(mono(12.5)).color(text()));
grid_row(
ui,
"Value",
RichText::new(money(plan.notional(), "USD")).font(mono(14.0)).strong().color(text()),
);
let lev = self.hl.ctx(&a.coin).leverage.map_or("unknown".into(), |l| {
format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" })
});
grid_row(ui, "Leverage", plain(lev));
grid_row(
ui,
"Reduce only",
plain(if plan.intent().reduce_only { "Yes".into() } else { "No".into() }),
);
grid_row(ui, "Fees", plain("Hyperliquid's maker/taker fee · no builder fee".into()));
grid_row(ui, "Expires", plain(format!("{} s after you confirm", EXPIRES_AFTER_MS / 1000)));
grid_row(ui, "Client id", RichText::new(plan.cloid()).font(mono(11.0)).color(muted()));
}
HlConfirm::Cancel { line, oid, .. } => {
grid_row(ui, "Order", RichText::new(line).font(mono(12.5)).color(text()));
grid_row(ui, "Order id", plain(format!("#{oid}")));
}
HlConfirm::Kill { orders } => {
grid_row(
ui,
"Cancels",
plain(format!("every open order ({} seen; read again before cancelling)", orders.len())),
);
grid_row(ui, "Then", plain("Hyperliquid orders turn off until you enable them again".into()));
}
HlConfirm::Leverage { coin, leverage, cross, current } => {
grid_row(ui, "Market", RichText::new(coin).font(mono(12.5)).color(amber()));
let now = current.map_or("unknown".into(), |l| {
format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" })
});
grid_row(ui, "Now", plain(now));
grid_row(
ui,
"New",
plain(format!("{leverage}× {}", if *cross { "cross" } else { "isolated" })),
);
}
}
});
ui.add_space(dp(6.0));
if let HlConfirm::Order { plan, .. } = &c {
if plan.asset().is_hip3() {
ui.label(RichText::new(HIP3_RISK).font(ui_font(11.5)).color(amber()));
}
ui.label(
RichText::new("Orders on Hyperliquid are on-chain and can't be undone once they fill.")
.font(ui_font(11.5))
.color(muted()),
);
}
if let HlConfirm::Kill { orders } = &c
&& orders.is_empty()
{
ui.label(RichText::new("No open orders seen right now.").font(ui_font(11.5)).color(muted()));
}
ui.add_space(dp(6.0));
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
choice = Some(false);
}
let b = egui::Button::new(RichText::new(go).strong().color(Color32::WHITE)).fill(go_color);
if ui.add(b).clicked() {
choice = Some(true);
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(false);
}
match choice {
Some(true) => self.hl_confirmed(),
Some(false) => self.hl.confirm = None,
None => {}
}
}
pub(super) fn hl_enable_dialog(&mut self, ctx: &egui::Context) {
let mut choice = None;
let modal = egui::Modal::new(egui::Id::new("hl-enable")).show(ctx, |ui| {
ui.set_width(dp(450.0));
ui.horizontal(|ui| {
testnet_badge(ui);
ui.label(RichText::new("Enable Hyperliquid orders?").font(ui_font(16.0)).strong());
});
ui.add_space(dp(4.0));
for line in [
"This build trades on Hyperliquid testnet only, with test funds. It cannot reach mainnet.",
"Orders are on-chain transactions: once they fill they can't be undone.",
"Perps are leveraged and can be liquidated. HIP-3 stock markets (xyz) are perpetual futures with funding, not shares.",
"Hyperliquid's Terms of Use restrict some regions, including Ontario, Canada and the United States. You are responsible for whether you may use it.",
"Fixed limits: $100 per order, 3× leverage, 1% market slippage. Every order, cancel and leverage change asks for confirmation.",
] {
ui.label(RichText::new(format!("• {line}")).font(ui_font(12.0)).color(text()));
}
ui.add_space(dp(6.0));
ui.checkbox(&mut self.hl.ack, "I am not a Restricted Person under Hyperliquid's Terms, and I understand the above");
ui.add_space(dp(6.0));
ui.horizontal(|ui| {
if ui.button("Keep Off").clicked() {
choice = Some(false);
}
let on = self.hl.ack;
let b = if on {
egui::Button::new(RichText::new("Enable Hyperliquid orders").strong().color(Color32::BLACK)).fill(amber())
} else {
egui::Button::new(RichText::new("Enable Hyperliquid orders").strong())
};
if ui.add_enabled(on, b).clicked() {
choice = Some(true);
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(false);
}
if let Some(on) = choice {
self.hl.enable_prompt = false;
self.hl.ack = false;
if on {
self.prefs.trading.hl.enabled = true;
if let Err(e) = self.prefs.save(&super::prefs_path()) {
self.status = format!("Could not save preferences: {e}");
}
self.hl.message = Some(("Hyperliquid testnet orders enabled.".into(), false));
}
}
}
/// Settings › Trading › Hyperliquid.
pub(super) fn hl_settings(&mut self, ui: &mut Ui, cmds: &mut Vec<SettingsCmd>) {
let note = |ui: &mut Ui, s: &str| {
ui.label(RichText::new(s).font(ui_font(11.5)).color(muted()));
};
ui.add_space(dp(10.0));
ui.horizontal(|ui| {
ui.label(RichText::new("Hyperliquid").font(ui_font(13.5)).color(text()));
testnet_badge(ui);
});
ui.separator();
let h = self.prefs.trading.hl.clone();
let on = h.enabled && self.hl_signing_blocker().is_none();
ui.label(
RichText::new(match (h.master.is_some(), h.agent.is_some(), on) {
(false, _, _) => {
"No wallet yet. Create one to trade perps on the core dex and HIP-3 xyz (BTC, xyz:AAPL), testnet only."
.to_string()
}
(true, false, _) if h.wallet.is_some() => {
"Wallet ready. Approve a trading key to trade; until then PORT, POS and ORD are read-only.".into()
}
(true, false, _) => {
"Watching an address (read-only): positions and orders show in PORT, POS and ORD.".into()
}
(true, true, true) => {
"Orders are ON (testnet): confirmed orders are signed here and sent to Hyperliquid testnet.".into()
}
(true, true, false) => "Orders are OFF.".into(),
})
.font(ui_font(12.5))
.color(if on { amber() } else { theme::accent() }),
);
self.wallet_section(ui, &h);
if let Some(e) = &self.hl.store_error {
ui.label(
RichText::new(format!("Trading is off on this device: {e}")).font(ui_font(12.0)).color(theme::down()),
);
}
// Orders on or off.
ui.add_space(dp(6.0));
ui.horizontal(|ui| {
if h.enabled {
if ui.button("Turn Hyperliquid Orders Off").clicked() {
self.prefs.trading.hl.enabled = false;
self.hl.confirm = None;
cmds.push(SettingsCmd::SavePrefs);
}
} else if ui
.add_enabled(h.agent.is_some(), egui::Button::new(RichText::new("Enable Hyperliquid Orders…").strong()))
.on_disabled_hover_text("Approve a trading key first")
.clicked()
{
self.hl.enable_prompt = true;
self.hl.ack = false;
}
if h.master.is_some()
&& ui.button("KILL").on_hover_text("Cancel every open Hyperliquid order and turn orders off").clicked()
{
self.hl_kill();
}
});
note(
ui,
&format!(
"Fixed in this build: ${MAX_ORDER_NOTIONAL_USD:.0} per order, {MAX_LEVERAGE}× leverage, {:.0}% market slippage, no builder fee. KILL cancels every open order and turns orders off.",
MAX_MARKET_SLIPPAGE * 100.0
),
);
let mut dms = self.prefs.trading.hl.dead_mans_switch;
if ui
.checkbox(&mut dms, "Dead man's switch: cancel all open orders if Terminal stops for a minute")
.on_hover_text(
"Keeps a scheduleCancel about 60 s ahead while Terminal runs. It also cancels orders placed elsewhere.",
)
.changed()
{
self.prefs.trading.hl.dead_mans_switch = dms;
cmds.push(SettingsCmd::SavePrefs);
}
if cfg!(target_arch = "wasm32") {
let mut keep = self.prefs.trading.hl.remember_on_browser;
if ui
.checkbox(
&mut keep,
"Remember the agent key on this browser (encrypted with a key the page can't read)",
)
.on_hover_text(
"Off: the key lasts for this tab only, and each visit needs a new approval, which is one paid Turnkey signature.",
)
.changed()
{
self.prefs.trading.hl.remember_on_browser = keep;
cmds.push(SettingsCmd::SavePrefs);
#[cfg(target_arch = "wasm32")]
if keep {
self.hl_web_remember_key();
} else {
self.hl_web_forget_key();
}
}
if !self.prefs.trading.hl.remember_on_browser {
ui.label(
RichText::new("Each visit without it approves a new trading key: one Turnkey signature each time.")
.font(ui_font(11.5))
.color(muted()),
);
}
}
// Roles.
if h.master.is_some() {
ui.horizontal_wrapped(|ui| {
if ui
.add_enabled(!self.hl.roles_busy, egui::Button::new("Check on Hyperliquid"))
.on_hover_text("userRole and extraAgents")
.clicked()
{
self.hl_check_roles();
}
match &self.hl.roles {
Some(Ok(r)) => {
let agents: Vec<String> =
r.agents.iter().map(|a| format!("{} ({})", a.name, masked(&a.address))).collect();
note(
ui,
&format!(
"account role: {}{} · approved agents: {}",
r.master,
r.agent.as_ref().map_or(String::new(), |a| format!(" · agent role: {a}")),
if agents.is_empty() { "none".into() } else { agents.join(", ") }
),
);
}
Some(Err(e)) => {
ui.label(RichText::new(e).font(ui_font(11.5)).color(theme::down()));
}
None => {}
}
});
}
if let Some(b) = self.hl.busy {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(12.0));
note(ui, b);
});
}
if let Some((m, err)) = &self.hl.message {
ui.label(RichText::new(m).font(ui_font(12.0)).color(if *err { theme::down() } else { theme::up() }));
}
}
/// Accounts the ticket can choose from (as `trade_accounts`, for the HL body).
fn trade_accounts_pub(&self) -> Vec<tr_broker::Account> {
self.broker.portfolio.as_ref().map(|p| p.accounts.clone()).unwrap_or_default()
}
}
-206
View File
@@ -1,206 +0,0 @@
//! Hyperliquid in the browser: the built-in wallet's sign-in popup, and the agent key, which
//! lives in memory for the session or, if the user opts in, in IndexedDB encrypted with a
//! non-extractable WebCrypto AES-GCM key (the page can use that key but never read it).
//!
//! Sign-in opens this site's `/wallet/` page in a popup with the session key's public half
//! and a `state`. The page answers with `postMessage`; only messages from this page's own
//! origin, carrying the same `state`, are taken (see `super::wallet`). Browser wallets
//! (EIP-1193) are not used.
use std::cell::RefCell;
use tr_hl::{AgentKey, SigningKey};
use wasm_bindgen::prelude::*;
use super::TerminalApp;
use super::hl::store_account;
#[wasm_bindgen(inline_js = r#"
const DB = 'terminal-hl', STORE = 'keys';
function open() {
return new Promise((res, rej) => {
const r = indexedDB.open(DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore(STORE);
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
function tx(db, mode, f) {
return new Promise((res, rej) => {
const t = db.transaction(STORE, mode);
const req = f(t.objectStore(STORE));
t.oncomplete = () => res(req ? req.result : undefined);
t.onerror = () => rej(t.error);
});
}
// A non-extractable AES-GCM key, kept as a CryptoKey in IndexedDB: usable, never readable.
async function wrapping(db) {
let k = await tx(db, 'readonly', s => s.get('wrap'));
if (!k) {
k = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
await tx(db, 'readwrite', s => s.put(k, 'wrap'));
}
return k;
}
export async function hl_key_save(account, hex) {
const db = await open();
const k = await wrapping(db);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, k, new TextEncoder().encode(hex)));
await tx(db, 'readwrite', s => s.put({ iv, ct }, 'agent:' + account));
}
export async function hl_key_load(account) {
const db = await open();
const rec = await tx(db, 'readonly', s => s.get('agent:' + account));
const k = await tx(db, 'readonly', s => s.get('wrap'));
if (!rec || !k) return null;
return new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: rec.iv }, k, rec.ct));
}
export async function hl_key_delete(account) {
const db = await open();
await tx(db, 'readwrite', s => s.delete('agent:' + account));
}
// The sign-in page's answer, from this origin only. Kept until Rust takes it.
let walletInbox = null;
if (typeof window !== 'undefined') {
window.addEventListener('message', (e) => {
if (e.origin !== window.location.origin) return;
const d = e.data;
if (!d || d.type !== 'terminal-wallet' || typeof d.state !== 'string') return;
walletInbox = JSON.stringify({ state: d.state, session: typeof d.session === 'string' ? d.session : null,
error: typeof d.error === 'string' ? d.error : null });
});
}
export function wallet_popup(url) {
const w = window.open(url, 'terminal-wallet', 'popup,width=480,height=760');
return !!w;
}
export function wallet_take() { const v = walletInbox; walletInbox = null; return v; }
export function page_origin() { return window.location.origin; }
"#)]
extern "C" {
#[wasm_bindgen(catch)]
async fn hl_key_save(account: &str, hex: &str) -> Result<JsValue, JsValue>;
#[wasm_bindgen(catch)]
async fn hl_key_load(account: &str) -> Result<JsValue, JsValue>;
#[wasm_bindgen(catch)]
async fn hl_key_delete(account: &str) -> Result<JsValue, JsValue>;
#[wasm_bindgen(js_name = wallet_popup)]
fn js_wallet_popup(url: &str) -> bool;
fn wallet_take() -> Option<String>;
fn page_origin() -> String;
}
/// Open the sign-in page in a popup; `false` if the browser blocked it.
pub(super) fn wallet_popup(url: &str) -> bool {
js_wallet_popup(url)
}
/// This page's origin (`https://terminal.rasmic.xyz`).
pub(super) fn origin() -> String {
page_origin()
}
#[derive(serde::Deserialize)]
struct PageAnswer {
state: String,
session: Option<String>,
error: Option<String>,
}
/// What the page's promises came back with, for the next frame.
enum Event {
Restored { account: String, hex: Option<zeroize::Zeroizing<String>> },
Note(String, bool),
}
thread_local! {
static EVENTS: RefCell<Vec<Event>> = const { RefCell::new(Vec::new()) };
}
fn post(ctx: &egui::Context, e: Event) {
EVENTS.with(|v| v.borrow_mut().push(e));
ctx.request_repaint();
}
fn js_err(e: JsValue) -> String {
e.as_string()
.or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string()))
.unwrap_or_else(|| format!("{e:?}"))
}
impl TerminalApp {
/// Handle what the page's promises returned.
pub(super) fn hl_web_poll(&mut self) {
if let Some(a) = wallet_take().and_then(|t| serde_json::from_str::<PageAnswer>(&t).ok()) {
self.wallet_page_answered(&a.state, a.session, a.error);
}
let events = EVENTS.with(|v| std::mem::take(&mut *v.borrow_mut()));
for e in events {
match e {
Event::Restored { account, hex } => {
let Some(hex) = hex else { continue };
let expected = self.prefs.trading.hl.agent.as_ref().map(|a| a.address.clone());
if let Ok(k) = AgentKey::from_hex(&hex)
&& Some(tr_hl::key::address_hex(&k.address())) == expected
&& self.hl_master().is_some_and(|m| store_account(&m) == account)
{
let _ = self.hl.store.save(&account, &hex);
self.hl.key = Some(std::sync::Arc::new(k));
}
}
Event::Note(m, err) => {
self.hl.busy = None;
self.hl.message = Some((m, err));
}
}
}
}
/// After loading from memory finds nothing: try the encrypted copy, if the user opted in.
pub(super) fn hl_web_restore_key(&mut self) {
if self.hl.key.is_some() || !self.prefs.trading.hl.remember_on_browser || self.prefs.trading.hl.agent.is_none()
{
return;
}
let Some(account) = self.hl_master().map(|m| store_account(&m)) else { return };
let Some(ctx) = self.hl.ctx.clone() else { return };
wasm_bindgen_futures::spawn_local(async move {
let hex = match hl_key_load(&account).await {
Ok(v) => v.as_string().map(zeroize::Zeroizing::new),
Err(e) => {
tracing::warn!("hyperliquid: reading the remembered key: {}", js_err(e));
None
}
};
post(&ctx, Event::Restored { account, hex });
});
}
/// Keep an encrypted copy in this browser (opted in).
pub(super) fn hl_web_remember_key(&mut self) {
let (Some(key), Some(master)) = (self.hl.key.clone(), self.hl_master()) else { return };
if !self.prefs.trading.hl.remember_on_browser {
return;
}
let account = store_account(&master);
let Some(ctx) = self.hl.ctx.clone() else { return };
wasm_bindgen_futures::spawn_local(async move {
let hex = key.to_hex();
if let Err(e) = hl_key_save(&account, &hex).await {
post(&ctx, Event::Note(format!("Couldn't remember the key on this browser: {}", js_err(e)), true));
}
});
}
/// Delete the encrypted copy.
pub(super) fn hl_web_forget_key(&mut self) {
let Some(master) = self.hl_master() else { return };
let account = store_account(&master);
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = hl_key_delete(&account).await {
tracing::warn!("hyperliquid: deleting the remembered key: {}", js_err(e));
}
});
}
}
-4
View File
@@ -242,10 +242,6 @@ impl TerminalApp {
(&["BTCUSD CRNCY", "EURUSD CRNCY"], "Crypto (live) and FX"),
(&["US10Y GOVT"], "US Treasury yields"),
(&["CL1 CMDTY", "GC1 CMDTY"], "Futures and commodities"),
(
&["xyz:AAPL", "AAPL PERP", "PERP"],
"Hyperliquid HIP-3 perps (needs the Terminal server); PERP or ⇧P switches",
),
(&["SECF gold"], "Search securities"),
],
);
-34
View File
@@ -37,7 +37,6 @@ const COMMANDS: &[(&str, &str, &str, Option<Action>)] = &[
("WL", "", "watchlist monitor", Some(Action::Watchlist)),
("N", "", "news headlines", None),
("OB", "", "order book depth dom", None),
("PERP", "", "hip-3 perpetual hyperliquid xyz perps stock perp", Some(Action::TogglePerp)),
("WEI", "", "world equity indices markets", None),
("GC", "", "yield curve treasury rates", None),
("SECF", "Security search", "search find", None),
@@ -218,19 +217,6 @@ fn indicator_items(active: &[IndicatorSpec]) -> Vec<Item> {
}
fn ticker_item(key: &str) -> Item {
use tr_core::perps;
if perps::is_perp(key) {
// A perp's row says it is one, and finds it by the underlying's ticker and name too.
let link = perps::link_for(key);
let badge = perps::badge(key);
let sub = match link {
Some(l) => format!("{} · {badge}", l.name),
None => badge.clone(),
};
let under = link.and_then(|l| tr_core::Security::parse(l.underlying)).map(|s| s.ticker).unwrap_or_default();
let words = format!("{under} {} perp perpetual hyperliquid hip-3 {}", link.map_or("", |l| l.name), badge);
return Item::new(Kind::Ticker, key, sub, Target::Run(key.into())).keywords(words);
}
let name = tr_core::Security::parse(key).and_then(|s| s.name()).unwrap_or("");
Item::new(
Kind::Ticker,
@@ -292,9 +278,6 @@ impl TerminalApp {
for l in tr_core::catalog::search(q, 12) {
v.push(ticker_item(&l.key()));
}
for key in tr_core::perps::search(q, self.perp_candidates(), 8) {
v.push(ticker_item(key));
}
// Whatever the command line would do with the query, offered as-is.
match command::parse(q) {
// An indicator with custom parameters (`SMA 200`, `BB 20 3`).
@@ -671,23 +654,6 @@ mod tests {
assert!(b.contains(&"GC".to_string()), "{b:?}");
}
/// A perp's row is labelled as one and found by its underlying, after the listed stock.
#[test]
fn perp_rows_say_what_they_are() {
let perp = ticker_item("xyz:AAPL");
assert_eq!((perp.title.as_str(), perp.subtitle.as_str()), ("xyz:AAPL", "Apple Inc perp · PERP · HL xyz"));
assert_eq!(perp.target, Target::Run("xyz:AAPL".into()));
assert_eq!(command::parse("xyz:AAPL"), Some(command::Command::Symbol("xyz:AAPL".into())));
assert_eq!(ticker_item("xyz:SKHX").subtitle, "PERP · HL xyz");
let items = vec![ticker_item("AAPL US Equity"), perp];
let titles = |q: &str| rank(&items, &[], q).into_iter().map(|i| i.title).collect::<Vec<_>>();
assert_eq!(titles("aapl"), ["AAPL US Equity", "xyz:AAPL"], "the listed stock first");
assert_eq!(titles("apple perp"), ["xyz:AAPL"]);
assert_eq!(titles("hyperliquid"), ["xyz:AAPL"]);
let perp_cmd = static_items().into_iter().find(|i| i.title == "PERP").expect("PERP is offered");
assert_eq!(perp_cmd.hotkey, Some(Action::TogglePerp));
}
#[test]
fn indicators_are_found_by_code_name_and_category() {
let active = [IndicatorSpec::new("RSI", &[21.0]).unwrap()];
+15 -107
View File
@@ -18,14 +18,8 @@ impl TerminalApp {
let mut actions = Vec::new();
let id = self.panes[i].id;
let dragging = self.drag.is_some_and(|d| d.active);
let switch_to = self.panes[i].kind.symbol().and_then(|s| self.switch_target(s));
let perps: Vec<String> = match self.panes[i].kind {
PaneKind::Search { .. } => self.perp_candidates().into_iter().map(String::from).collect(),
_ => Vec::new(),
};
if let PaneKind::Chart(c) = &mut self.panes[i].kind {
c.hover_enabled = !dragging;
c.switch_to = switch_to;
let clicked = c.show(ui, rect, focused, &self.worker);
actions.append(&mut c.actions);
return (actions, clicked);
@@ -61,8 +55,7 @@ impl TerminalApp {
PaneKind::Curve => actions.extend(markets_view::curve(ui, self.curves.as_deref())),
PaneKind::Search { query } => {
let hits = if self.net_hits.0 == *query { self.net_hits.1.as_slice() } else { &[] };
let perps: Vec<&str> = perps.iter().map(String::as_str).collect();
let (a, changed) = markets_view::search(ui, query, hits, &perps);
let (a, changed) = markets_view::search(ui, query, hits);
actions.extend(a);
if changed && query.trim().len() >= 2 {
self.search_pending = Some((query.clone(), Instant::now()));
@@ -83,14 +76,14 @@ impl TerminalApp {
}
}
PaneKind::OrderBook(b) => {
if let Some(a) = order_book(ui, b, switch_to) {
if let Some(a) = order_book(ui, b) {
actions.push(a);
}
}
PaneKind::Portfolio(pp) => {
let demo =
(self.prefs.trading.account_mode == crate::prefs::AccountMode::Demo).then_some(&*self.demo.pf);
for a in super::portfolio_view::portfolio(ui, pp, &self.broker, demo, &self.quotes, &self.hl.mids) {
for a in super::portfolio_view::portfolio(ui, pp, &self.broker, demo, &self.quotes) {
use super::portfolio_view::PortAction as P;
match a {
P::Core(a) => actions.push(a),
@@ -110,7 +103,6 @@ impl TerminalApp {
),
P::CancelOrder { account, id } => self.request_cancel(&account, &id),
P::ClosePosition { account, key } => self.close_position(&account, &key),
P::Kill => self.hl_kill(),
P::TradingSettings => {
self.settings_tab = super::settings_view::SettingsTab::Trading;
self.open_panel(PaneKind::Settings);
@@ -157,14 +149,14 @@ impl TerminalApp {
let (r, resp) = ui.allocate_exact_size(vec2(w, row_h), Sense::click());
widgets::hover_wash(ui, r, &resp, false);
let p = ui.painter();
let tick = p.layout_no_wrap(sec.ticker.clone(), mono(12.5), amber());
let label_x = col(r, 0) + tick.size().x.max(66.0) + 8.0;
p.galley(pos2(col(r, 0), r.center().y - tick.size().y / 2.0), tick, amber());
let (label, lc) = match sec.class {
tr_core::AssetClass::Perp => (tr_core::perps::badge(s), theme::perp()),
c => (c.yellow_key().label().to_string(), muted()),
};
p.text(pos2(label_x, r.center().y), Align2::LEFT_CENTER, label, ui_font(11.0), lc);
p.text(pos2(col(r, 0), r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.5), amber());
p.text(
pos2(col(r, 0) + 74.0, r.center().y),
Align2::LEFT_CENTER,
sec.class.yellow_key().label(),
ui_font(11.0),
muted(),
);
match self.quotes.get(s) {
Some(q) => {
let c = if q.change() >= 0.0 { theme::up() } else { theme::down() };
@@ -336,30 +328,17 @@ fn fmt_size(q: f64) -> String {
}
}
/// Price ladder: asks above, bids below, depth bars by cumulative size. `perp` is the live
/// HIP-3 perp linked to the pane's security, whose book it can show instead of a simulated one.
fn order_book(ui: &mut Ui, b: &mut super::BookPane, perp: Option<&'static str>) -> Option<Action> {
/// Price ladder: asks above, bids below, depth bars by cumulative size.
fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
let mut action = None;
let key = tr_data::security(&b.symbol);
let book: Option<OrderBook> = b.book.as_ref().map(|x| x.grouped(b.group));
let mut show_perp = false;
ui.horizontal_wrapped(|ui| {
let r = ui.label(RichText::new(&key.ticker).font(mono(13.0)).color(amber())).interact(Sense::click());
if r.clicked() {
action = Some(Action::OpenChart(b.symbol.clone()));
}
r.on_hover_text("Open chart");
if key.class == tr_core::AssetClass::Perp {
let tip = match tr_core::perps::link_for(&b.symbol) {
Some(l) => format!(
"The perp's own book on Hyperliquid. It references {}; it is not that market's book.",
l.reference
),
None => "The perp's own book on Hyperliquid.".into(),
};
ui.label(RichText::new(tr_core::perps::badge(&b.symbol)).font(ui_font(11.5)).color(theme::perp()))
.on_hover_text(tip);
}
if let Some(bk) = &book {
ui.label(RichText::new(&bk.source).font(ui_font(11.5)).color(muted()));
if bk.simulated {
@@ -370,26 +349,6 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane, perp: Option<&'static str>)
}
}
});
// A listed instrument with no public book, but a live perp on the same underlying.
if let Some(perp) = perp.filter(|p| tr_core::perps::is_perp(p) && book.as_ref().is_none_or(|bk| bk.simulated)) {
ui.horizontal_wrapped(|ui| {
let label = format!("Show the {perp} perp's book");
let tip = format!(
"Load the book of {perp} ({}) here. It is the perp's order book on Hyperliquid, not {}'s.",
tr_core::perps::badge(perp),
key.key()
);
show_perp = ui.small_button(RichText::new(label).color(theme::perp())).on_hover_text(tip).clicked();
});
}
if show_perp && let Some(perp) = perp {
b.symbol = perp.to_string();
b.book = None;
b.error = None;
b.fetched = None;
b.trades.clear();
return action;
}
ui.horizontal_wrapped(|ui| {
let depths = [10usize, 20, 50];
let dl = ["10", "20", "50"];
@@ -417,20 +376,7 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane, perp: Option<&'static str>)
ui.add(egui::Spinner::new());
return action;
};
let full = ui.available_rect_before_wrap();
// The book's trades go beside the ladder in a wide pane, below it in a tall one.
let has_tape = !b.trades.is_empty();
let (rect, tape) = if has_tape && full.width() >= dp(560.0) {
let split = full.left() + (full.width() * 0.6).floor();
let tape = Rect::from_min_max(pos2(split + dp(10.0), full.top()), full.max);
(Rect::from_min_max(full.min, pos2(split, full.bottom())), Some(tape))
} else if has_tape && full.height() >= dp(280.0) {
let split = full.bottom() - (full.height() * 0.36).floor();
let tape = Rect::from_min_max(pos2(full.left(), split + dp(4.0)), full.max);
(Rect::from_min_max(full.min, pos2(full.right(), split)), Some(tape))
} else {
(full, None)
};
let rect = ui.available_rect_before_wrap();
let d = key.decimals(book.mid().unwrap_or(1.0));
let row = dp(18.0);
let rows_each = (((rect.height() - row * 2.0) / row / 2.0).floor() as usize).max(1);
@@ -495,49 +441,11 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane, perp: Option<&'static str>)
for (k, level) in bids.iter().enumerate() {
draw((y + k as f32 * row) as f64, *level, true);
}
if let Some(tape) = tape {
trades_tape(&ui.painter_at(tape), tape, &key, &b.symbol, &b.trades, d);
}
ui.allocate_rect(full, Sense::hover());
ui.allocate_rect(rect, Sense::hover());
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
action
}
/// Recent trades next to the book, newest first: time, price coloured by the aggressor's side,
/// size. A perp's tape is titled with its venue.
fn trades_tape(
p: &egui::Painter,
rect: Rect,
key: &tr_core::Security,
symbol: &str,
trades: &[tr_data::TradePrint],
d: usize,
) {
let row = dp(18.0);
let x_price = rect.left() + (rect.width() * 0.62).clamp(dp(110.0), (rect.width() - dp(60.0)).max(dp(110.0)));
let x_size = rect.right() - 6.0;
let head = Rect::from_min_size(rect.min, vec2(rect.width(), row));
p.rect_filled(head, 3.0, theme::header());
let title = match key.class {
tr_core::AssetClass::Perp => format!("Trades · {} · {}", key.ticker, tr_core::perps::badge(symbol)),
_ => format!("Trades · {}", key.ticker),
};
let tc = if key.class == tr_core::AssetClass::Perp { theme::perp() } else { muted() };
p.text(pos2(rect.left() + 6.0, head.center().y), Align2::LEFT_CENTER, title, ui_font(11.0), tc);
let mut y = head.bottom();
for t in trades.iter().rev().take(((rect.bottom() - y) / row).floor().max(0.0) as usize) {
let c = if t.buy { theme::up() } else { theme::down() };
let when = chrono::DateTime::from_timestamp_millis(t.ts_ms)
.map(|x| x.with_timezone(&chrono::Local).format("%H:%M:%S").to_string())
.unwrap_or_default();
let cy = y + row / 2.0;
p.text(pos2(rect.left() + 6.0, cy), Align2::LEFT_CENTER, when, mono(11.5), muted());
p.text(pos2(x_price, cy), Align2::RIGHT_CENTER, format!("{:.*}", d, t.px), mono(12.0), c);
p.text(pos2(x_size, cy), Align2::RIGHT_CENTER, fmt_size(t.sz), mono(12.0), text());
y += row;
}
}
#[cfg(test)]
mod tests {
use super::*;
+12 -48
View File
@@ -43,8 +43,6 @@ pub enum PortAction {
account: String,
key: String,
},
/// The Hyperliquid kill switch (`KILL`).
Kill,
}
// ---- formatting ---------------------------------------------------------------------------
@@ -296,7 +294,6 @@ pub fn portfolio(
b: &BrokerState,
demo: Option<&Portfolio>,
quotes: &HashMap<String, Quote>,
testnet: &HashMap<String, f64>,
) -> Vec<PortAction> {
let mut out = Vec::new();
toolbar(ui, pane, b, demo, &mut out);
@@ -306,8 +303,7 @@ pub fn portfolio(
None => (b.mode(), b.portfolio.as_deref(), b.error.as_ref()),
};
match (mode, pf) {
// Without SnapTrade the panes still show a Hyperliquid account.
(BrokerMode::Setup, pf) if pf.is_none_or(|p| p.accounts.is_empty()) => setup_screen(ui, b, &mut out),
(BrokerMode::Setup, _) => setup_screen(ui, b, &mut out),
(_, None) if b.loading => {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
@@ -331,7 +327,7 @@ pub fn portfolio(
if pane.account.as_ref().is_some_and(|a| pf.account(a).is_none()) {
pane.account = None;
}
let price_of = |p: &Position| super::broker::live_price(quotes, testnet, p);
let price_of = |p: &Position| b.live_price(quotes, p);
match pane.view {
PortView::Summary => {
egui::ScrollArea::vertical()
@@ -733,27 +729,16 @@ fn position_row(
(None, _) => resp.on_hover_text("Not available as a terminal market key"),
};
if let Some(k) = &key {
// Hyperliquid perps trade on their own account; a short closes too (reduce-only).
let perp = super::hl::is_hl_account(&p.account_id) && p.market.is_some();
let tradable = perp
|| (matches!(p.kind, tr_broker::InstrumentKind::Stock | tr_broker::InstrumentKind::Etf)
&& matches!(tr_data::security(k).class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf));
let tradable = matches!(p.kind, tr_broker::InstrumentKind::Stock | tr_broker::InstrumentKind::Etf)
&& matches!(tr_data::security(k).class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf);
let mut pills = Vec::new();
if tradable {
pills.extend(widgets::buy_sell_pills());
if p.units > 0.0 || (perp && p.units != 0.0) {
if p.units > 0.0 {
pills.push(("Close", theme::control(), text()));
}
}
let tips = if perp {
[
"Buy (Hyperliquid testnet)",
"Sell (Hyperliquid testnet)",
"Close: a reduce-only market order for all of it",
]
} else {
["Buy more (⇧B)", "Sell (⇧S)", "Close: sell the whole position"]
};
let tips = ["Buy more (⇧B)", "Sell (⇧S)", "Close: sell the whole position"];
let pill = widgets::row_pills(ui, r, ("pos-pills", &p.account_id, &p.symbol), &pills, &tips);
match pill {
Some(0) => out.push(PortAction::Trade {
@@ -773,13 +758,7 @@ fn position_row(
None => {}
}
resp.context_menu(|ui| {
if perp {
if ui.button(format!("Close Position ({} {})…", qty(p.units), p.short_label())).clicked() {
out.push(PortAction::ClosePosition { account: p.account_id.clone(), key: k.clone() });
ui.close();
}
ui.separator();
} else if p.kind != tr_broker::InstrumentKind::Option {
if p.kind != tr_broker::InstrumentKind::Option {
trade_items(ui, k, Some(&p.account_id), Some(p.units), out);
if tradable && p.units > 0.0 {
if ui.button(format!("Close Position ({} {})…", qty(p.units), p.short_label())).clicked() {
@@ -965,19 +944,6 @@ fn orders(ui: &mut Ui, pane: &mut PortfolioPane, pf: &Portfolio, out: &mut Vec<P
if let Some(i) = widgets::segmented(ui, "port-orders", &["Open", "All"], sel, 20.0) {
pane.open_only = i == 0;
}
if pf.accounts.iter().any(|a| super::hl::is_hl_account(&a.id)) {
let b = egui::Button::new(RichText::new("KILL").font(mono(11.5)).strong().color(Color32::WHITE))
.fill(theme::down());
if ui
.add(b)
.on_hover_text(
"Kill switch: cancel every open Hyperliquid testnet order and turn Hyperliquid orders off",
)
.clicked()
{
out.push(PortAction::Kill);
}
}
if pf.demo {
note(ui, "Demo orders · hover an open order to cancel it");
} else {
@@ -1208,22 +1174,20 @@ mod tests {
#[test]
fn renders_every_view_without_panicking() {
let ctx = egui::Context::default();
// A store that holds nothing, so the test never reads (or moves) the user's Keychain item.
let none = std::env::temp_dir().join(format!("tr-portfolio-test-{}/snaptrade.json", std::process::id()));
let mut b = BrokerState::with_store(tr_broker::SecretStore::file(none), true);
let mut b = BrokerState::load(true);
b.creds = tr_broker::Credentials::default();
b.portfolio = Some(Box::new(tr_broker::sample::portfolio()));
let quotes = HashMap::new();
for view in PortView::ALL {
let mut pane = PortfolioPane::new(view);
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, None, &quotes, &HashMap::new());
let _ = portfolio(ui, &mut pane, &b, None, &quotes);
});
out.textures_delta.clear();
pane.account = Some("sim-ira".into());
pane.symbol = Some("SPY US Equity".into());
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, None, &quotes, &HashMap::new());
let _ = portfolio(ui, &mut pane, &b, None, &quotes);
});
out.textures_delta.clear();
}
@@ -1232,7 +1196,7 @@ mod tests {
for view in PortView::ALL {
let mut pane = PortfolioPane::new(view);
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, Some(&demo), &quotes, &HashMap::new());
let _ = portfolio(ui, &mut pane, &b, Some(&demo), &quotes);
});
out.textures_delta.clear();
}
@@ -1240,7 +1204,7 @@ mod tests {
b.simulate = false;
let mut pane = PortfolioPane::new(PortView::Summary);
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, None, &quotes, &HashMap::new());
let _ = portfolio(ui, &mut pane, &b, None, &quotes);
});
out.textures_delta.clear();
}
+11 -78
View File
@@ -81,8 +81,6 @@ pub struct Ticket {
pub error: Option<String>,
/// Result of the last placement.
pub done: Option<String>,
/// Hyperliquid's own fields, used when a Hyperliquid account is chosen.
pub hl: super::hl::HlTicket,
}
fn parse_num(s: &str) -> Option<f64> {
@@ -118,7 +116,6 @@ impl Ticket {
placing: false,
error: None,
done: None,
hl: Default::default(),
}
}
@@ -130,9 +127,6 @@ impl Ticket {
if let Some(k) = &args.symbol {
self.symbol = broker_symbol(k);
}
if args.symbol.is_some() || args.units.is_some() || args.notional.is_some() {
self.hl.from_dollars = None;
}
if let Some(u) = args.units {
self.dollars = false;
self.qty = fmt_input(u);
@@ -158,15 +152,6 @@ impl Ticket {
if self.order_type.needs_stop() {
self.advanced = true;
}
self.hl.kind = match (args.stop, args.limit) {
(Some(_), _) => super::hl::HlKind::StopLoss,
(None, Some(_)) => super::hl::HlKind::Limit,
(None, None) if args.symbol.is_some() => super::hl::HlKind::Market,
_ => self.hl.kind,
};
if let Some(s) = args.stop {
self.hl.trigger = fmt_input(s);
}
self.preview = None;
self.review = false;
self.error = None;
@@ -318,12 +303,6 @@ impl TerminalApp {
if account == demo::ACCOUNT_ID {
return Some(everything("Demo account".into()));
}
if super::hl::is_hl_account(account) {
return Some(Capability {
connection_trading: self.hl_signing_blocker().is_none(),
..everything("Hyperliquid testnet".into())
});
}
match self.broker.mode() {
BrokerMode::Simulated => Some(everything(
self.broker
@@ -485,14 +464,8 @@ impl TerminalApp {
ticket.apply(&args);
let accounts = self.trade_accounts();
let known = |id: &str| accounts.iter().any(|a| a.id == id);
// A Hyperliquid perp only trades on Hyperliquid.
let hl_account = self
.hl_master()
.map(|m| super::hl::account_id(&m))
.filter(|id| known(id) && args.symbol.as_deref().is_some_and(tr_data::is_perp));
ticket.account = account
.filter(|a| known(a))
.or(hl_account)
.or(ticket.account.filter(|a| known(a)))
.or(self.prefs.trading.default_account.clone().filter(|a| known(a)))
.or((accounts.len() == 1).then(|| accounts[0].id.clone()));
@@ -568,9 +541,6 @@ impl TerminalApp {
if account == demo::ACCOUNT_ID {
return self.demo_close(&symbol);
}
if super::hl::is_hl_account(account) {
return self.hl_close_position(account, key);
}
self.open_ticket(
TradeArgs { buy: Some(false), symbol: Some(key.into()), all: true, ..Default::default() },
Some(account.into()),
@@ -622,9 +592,6 @@ impl TerminalApp {
}
self.trading.last_submit = Some(Instant::now());
let Some(t) = self.trading.ticket.as_ref() else { return };
if t.account.as_deref().is_some_and(super::hl::is_hl_account) {
return self.hl_review();
}
let quote = self.ticket_quote(t);
let req = match t.request() {
Ok(r) => r,
@@ -780,9 +747,6 @@ impl TerminalApp {
if account == demo::ACCOUNT_ID {
return self.demo_cancel(order_id);
}
if super::hl::is_hl_account(account) {
return self.hl_request_cancel(order_id);
}
if !self.trading_enabled() {
self.status = "Live orders are off: enable them in Settings › Trading to cancel orders here".into();
return;
@@ -962,36 +926,18 @@ impl TerminalApp {
if self.trading.live_prompt {
self.live_dialog(ctx);
}
if self.hl.confirm.is_some() {
self.hl_confirm_dialog(ctx);
}
if self.hl.enable_prompt {
self.hl_enable_dialog(ctx);
}
if self.hl.wallet.deposit || self.hl.wallet.backup_prompt {
self.wallet_windows(ctx);
}
}
fn ticket_window(&mut self, ctx: &egui::Context) {
let mut open = true;
let live = !self.demo_mode();
let hl = live
&& self.trading.ticket.as_ref().and_then(|t| t.account.as_deref()).is_some_and(super::hl::is_hl_account);
let title = match (live, hl) {
(true, true) => "Order Ticket · Hyperliquid TESTNET",
(true, false) => "Order Ticket · LIVE",
_ => "Order Ticket · Demo",
};
let color = match (live, hl) {
(true, true) => amber(),
(true, false) => theme::down(),
_ => theme::border(),
};
let title = if live { "Order Ticket · LIVE" } else { "Order Ticket · Demo" };
let mut act = None;
let frame =
egui::Frame::window(&ctx.global_style()).stroke(egui::Stroke::new(if live { 2.0 } else { 1.0 }, color));
egui::Window::new(RichText::new(title).font(ui_font(13.0)).color(if live { color } else { text() }))
let frame = egui::Frame::window(&ctx.global_style()).stroke(egui::Stroke::new(
if live { 2.0 } else { 1.0 },
if live { theme::down() } else { theme::border() },
));
egui::Window::new(RichText::new(title).font(ui_font(13.0)).color(if live { theme::down() } else { text() }))
.id(egui::Id::new("order-ticket"))
.frame(frame)
.open(&mut open)
@@ -999,12 +945,11 @@ impl TerminalApp {
.resizable(false)
.default_width(dp(320.0))
.default_pos(ctx.content_rect().center() - egui::vec2(dp(160.0), dp(220.0)))
.show(ctx, |ui| act = if hl { self.hl_ticket_ui(ui) } else { self.ticket_ui(ui) });
.show(ctx, |ui| act = self.ticket_ui(ui));
self.sync_sell_all();
if !open
|| (ctx.input(|i| i.key_pressed(egui::Key::Escape))
&& !self.trading.ticket.as_ref().is_some_and(|t| t.confirming)
&& self.hl.confirm.is_none())
&& !self.trading.ticket.as_ref().is_some_and(|t| t.confirming))
{
self.trading.ticket = None;
return;
@@ -1891,9 +1836,7 @@ impl TerminalApp {
}
});
self.hl_settings(ui, cmds);
section(ui, "Safety (SnapTrade)");
section(ui, "Safety");
ui.horizontal(|ui| {
let mut limited = self.prefs.trading.max_order_value.is_some();
if ui.checkbox(&mut limited, "Maximum live order value").changed() {
@@ -1908,13 +1851,7 @@ impl TerminalApp {
}
});
note(ui, "Live orders whose estimated value is above the limit are refused before they are sent.");
// SnapTrade's accounts only: Hyperliquid has its own section above.
let accounts: Vec<Account> = self
.broker
.portfolio
.as_ref()
.map(|p| p.accounts.iter().filter(|a| !super::hl::is_hl_account(&a.id)).cloned().collect())
.unwrap_or_default();
let accounts: Vec<Account> = self.broker.portfolio.as_ref().map(|p| p.accounts.clone()).unwrap_or_default();
if !accounts.is_empty() {
ui.horizontal(|ui| {
ui.label(RichText::new("Default live account").font(ui_font(12.5)));
@@ -2037,14 +1974,10 @@ impl TerminalApp {
),
);
note(ui, "Commands (or ⌘K): BUY AAPL 10 · SELL ALL TSLA · BUY NVDA $500 · SELL MSFT 5 LMT 410 · DEMO · LIVE");
note(
ui,
"Hyperliquid testnet: BUY xyz:AAPL 0.05 @250 · SELL BTC 0.0002 MKT · KILL cancels every open Hyperliquid order.",
);
}
}
pub(super) enum TicketAct {
enum TicketAct {
Submit,
EnableLive,
Mode(AccountMode),
-464
View File
@@ -1,464 +0,0 @@
//! The built-in wallet (Turnkey), testnet only: sign-in through the browser, the short Turnkey
//! session, approving and revoking the trading (agent) key with the wallet, Deposit and backup.
//!
//! The wallet's key never leaves Turnkey. Turnkey signs only `approveAgent` (see
//! `crate::wallet_worker`); orders are signed by the agent key on this device, as before.
//! See `docs/architecture/trading-hyperliquid.md` §4.5.
use std::sync::Arc;
use std::time::Duration;
use tr_hl::model::SpotUsdc;
use tr_turnkey::session::random_state;
use tr_turnkey::{Session, SessionKey};
use web_time::Instant;
use super::TerminalApp;
use crate::prefs::HlWallet;
use crate::wallet_worker::{SignedIn, WalletRequest, WalletResponse, session_account};
use crate::worker::Request;
/// A session with less than this left isn't used to sign: the user signs in again first.
const SESSION_MARGIN_SECS: u64 = 60;
/// How often the Deposit window re-reads spot USDC while it is open.
const BALANCES_EVERY: Duration = Duration::from_secs(15);
/// What to do once a sign-in finishes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum After {
Approve,
Revoke,
}
/// A sign-in waiting for the browser.
pub struct SignIn {
id: u64,
state: String,
/// The device key's public half (the page asks Turnkey for a session bound to it).
#[cfg(not(target_arch = "wasm32"))]
public_key: String,
/// The page to open (again), once known.
pub url: Option<String>,
pub started: Instant,
then: Option<After>,
/// The wallet the pending action is for. A sign-in to any other wallet is refused.
expected: Option<String>,
/// Web: the key waits here for the page's message. Desktop: the worker holds it.
#[cfg(target_arch = "wasm32")]
key: Option<SessionKey>,
}
impl SignIn {
fn new(id: u64, state: String, then: Option<After>, expected: Option<String>) -> Self {
Self {
id,
state,
#[cfg(not(target_arch = "wasm32"))]
public_key: String::new(),
url: None,
started: Instant::now(),
then,
expected,
#[cfg(target_arch = "wasm32")]
key: None,
}
}
/// Past the sign-in's deadline ([`tr_turnkey::SIGN_IN_TIMEOUT`]).
pub fn expired(&self) -> bool {
self.started.elapsed() > tr_turnkey::SIGN_IN_TIMEOUT
}
/// Refuse a wallet other than the one the pending action is for.
fn check_account(&self, address: &str) -> Result<(), String> {
match &self.expected {
Some(want) if !want.eq_ignore_ascii_case(address) => Err(format!(
"You signed in to wallet {}, but this was for wallet {}. Nothing changed; sign in with that wallet's email or passkey.",
super::hl_view::masked(address),
super::hl_view::masked(want)
)),
_ => Ok(()),
}
}
}
#[derive(Default)]
pub struct WalletState {
/// The live Turnkey session (at most 15 minutes), if any.
pub session: Option<Arc<Session>>,
/// The secret store item already read, so it isn't read again every frame.
session_tried: Option<String>,
pub signing_in: Option<SignIn>,
next_id: u64,
pub deposit: bool,
pub backup_prompt: bool,
pub backup_ack: bool,
/// Spot USDC for the Deposit window: `(address, result)`.
pub spot: Option<(String, Result<SpotUsdc, String>)>,
spot_at: Option<Instant>,
/// A revoke is with the wallet: the agent is forgotten only once it really happens.
pub revoking: bool,
}
impl WalletState {
/// A new id for a sign-in, never one used before in this run.
fn next_request_id(&mut self) -> u64 {
self.next_id += 1;
self.next_id
}
/// Forget the wallet's state but keep counting request ids, so a late answer to an old
/// sign-in can never match a new one.
fn reset(&mut self) {
*self = Self { next_id: self.next_id, ..Default::default() };
}
/// Minutes left on the session, if it can still sign.
pub fn session_minutes(&self) -> Option<u64> {
let s = self.session.as_ref()?;
let left = s.remaining(now_secs());
(left > SESSION_MARGIN_SECS).then(|| left.div_ceil(60))
}
}
pub(crate) fn now_secs() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// The sign-in page in the browser build: this site's own `/wallet/`.
#[cfg(target_arch = "wasm32")]
fn web_page() -> String {
format!("{}/wallet", super::hl_web::origin())
}
impl TerminalApp {
fn wallet_send(&self, r: WalletRequest) {
self.worker.send(Request::Wallet(r));
}
/// The signed-in wallet, if any.
pub(super) fn wallet(&self) -> Option<&HlWallet> {
self.prefs.trading.hl.wallet.as_ref()
}
/// Open the sign-in page. `then` runs once the session is back.
pub(super) fn wallet_sign_in(&mut self, then: Option<After>) {
let id = self.hl.wallet.next_request_id();
let key = SessionKey::generate();
let state = random_state();
let public_key = key.public_hex();
// An approve or revoke is for the wallet shown now, and only that one.
let expected = then.and(self.wallet().map(|w| w.address.clone()));
#[allow(unused_mut)]
let mut sign_in = SignIn::new(id, state.clone(), then, expected);
self.hl.message = None;
#[cfg(not(target_arch = "wasm32"))]
{
sign_in.public_key = public_key;
self.hl.wallet.signing_in = Some(sign_in);
self.wallet_send(WalletRequest::SignIn { id, store: self.hl.store.clone(), key, state });
}
#[cfg(target_arch = "wasm32")]
{
let url = tr_turnkey::web_sign_in_url(&web_page(), &public_key, &state);
let opened = super::hl_web::wallet_popup(&url);
sign_in.url = Some(url);
sign_in.key = Some(key);
self.hl.wallet.signing_in = Some(sign_in);
if !opened {
self.hl.message = Some((
"The browser blocked the sign-in window: allow pop-ups, then press Open Again.".into(),
true,
));
}
}
}
/// Stop waiting for the browser.
pub(super) fn wallet_cancel_sign_in(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
crate::wallet_worker::cancel_sign_in();
self.hl.wallet.signing_in = None;
}
/// Open the sign-in page again (the browser tab was closed).
pub(super) fn wallet_reopen(&mut self) {
let Some(url) = self.hl.wallet.signing_in.as_ref().and_then(|s| s.url.clone()) else { return };
#[cfg(not(target_arch = "wasm32"))]
if let Err(e) = super::broker::open_external(&url) {
self.hl.message = Some((format!("Couldn't open the browser: {e}"), true));
}
#[cfg(target_arch = "wasm32")]
if !super::hl_web::wallet_popup(&url) {
self.hl.message = Some(("The browser blocked the sign-in window: allow pop-ups.".into(), true));
}
}
/// Web: the page's message for the sign-in with `state`.
#[cfg(target_arch = "wasm32")]
pub(super) fn wallet_page_answered(&mut self, state: &str, session: Option<String>, error: Option<String>) {
let Some(sign_in) = self.hl.wallet.signing_in.as_mut() else { return };
if !tr_turnkey::session::same_secret(&sign_in.state, state) {
return;
}
match (session, sign_in.key.take()) {
(Some(token), Some(key)) => {
let id = sign_in.id;
self.wallet_send(WalletRequest::Finish { id, store: self.hl.store.clone(), key, token });
}
(_, _) => {
self.hl.wallet.signing_in = None;
self.hl.message =
Some((format!("Not signed in: {}", error.unwrap_or_else(|| "no answer".into())), true));
}
}
}
/// Approve a trading key with the wallet (signing in first if there's no session).
pub(super) fn wallet_approve(&mut self) {
let Some(w) = self.wallet().cloned() else { return };
let Some(session) = self.hl.wallet.session.clone().filter(|_| self.hl.wallet.session_minutes().is_some())
else {
return self.wallet_sign_in(Some(After::Approve));
};
self.hl.busy = Some("Approving the trading key: the wallet signs approveAgent on Turnkey…");
self.hl.message = None;
self.hl.pending_approval = Some(w.address.clone());
self.wallet_send(WalletRequest::Approve {
store: self.hl.store.clone(),
session,
master: w.address,
device: super::hl::device(),
});
}
/// Replace the agent on Hyperliquid with one nobody holds (the wallet signs), and delete its
/// key here.
pub(super) fn wallet_revoke(&mut self) {
let Some(w) = self.wallet().cloned() else { return };
let Some(session) = self.hl.wallet.session.clone().filter(|_| self.hl.wallet.session_minutes().is_some())
else {
return self.wallet_sign_in(Some(After::Revoke));
};
let base = self
.prefs
.trading
.hl
.agent
.as_ref()
.map(|a| tr_hl::trader::agent_base_name(&a.name).to_string())
.unwrap_or_else(|| format!("terminal-{}", super::hl::device()));
// Orders stop now; the agent itself is forgotten when the revoke happens.
self.prefs.trading.hl.enabled = false;
self.save_hl_prefs();
self.hl.wallet.revoking = true;
self.hl.busy = Some("Revoking: the wallet signs the replacement on Turnkey…");
self.wallet_send(WalletRequest::Revoke { store: self.hl.store.clone(), session, master: w.address, base });
}
/// Forget the Turnkey session on this device (trading keeps working with the agent key).
pub(super) fn wallet_end_session(&mut self) {
let Some(w) = self.wallet() else { return };
let account = session_account(&w.address);
self.hl.wallet.session = None;
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
}
/// Back up the wallet: the sign-in page's export mode, where Turnkey's own iframe shows
/// the recovery phrase. Terminal never sees it.
pub(super) fn wallet_backup(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
let url = tr_turnkey::export_url(&tr_turnkey::sign_in_page());
#[cfg(target_arch = "wasm32")]
let url = tr_turnkey::export_url(&web_page());
if let Err(e) = super::broker::open_external(&url) {
self.hl.message = Some((format!("Couldn't open the browser: {e}"), true));
}
}
pub(super) fn wallet_open_deposit(&mut self) {
self.hl.wallet.deposit = true;
self.hl.wallet.spot_at = None;
}
/// Read the session kept in the store (once), drop an expired one, refresh Deposit.
pub(super) fn wallet_periodic(&mut self) {
let Some(w) = self.wallet().cloned() else { return };
let account = session_account(&w.address);
if self.hl.wallet.session.is_none() && self.hl.wallet.session_tried.as_deref() != Some(&account) {
self.hl.wallet.session_tried = Some(account.clone());
self.wallet_send(WalletRequest::LoadSession { store: self.hl.store.clone(), account: account.clone() });
}
if self.hl.wallet.session.as_ref().is_some_and(|s| s.expired(now_secs())) {
self.hl.wallet.session = None;
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
}
if self.hl.wallet.deposit && self.hl.wallet.spot_at.is_none_or(|t| t.elapsed() > BALANCES_EVERY) {
self.hl.wallet.spot_at = Some(Instant::now());
self.wallet_send(WalletRequest::Balances { user: w.address });
}
}
pub(super) fn on_wallet(&mut self, r: WalletResponse) {
match r {
#[cfg(not(target_arch = "wasm32"))]
WalletResponse::Listening { id, port } => {
let Some(s) = self.hl.wallet.signing_in.as_mut().filter(|s| s.id == id) else { return };
let url = tr_turnkey::desktop_sign_in_url(&tr_turnkey::sign_in_page(), &s.public_key, &s.state, port);
s.url = Some(url);
self.wallet_reopen();
}
WalletResponse::SignedIn { id, result } => {
let Some(s) = self.hl.wallet.signing_in.take() else { return };
if s.id != id {
self.hl.wallet.signing_in = Some(s);
return;
}
match result {
Ok(signed) => self.wallet_signed_in(*signed, &s),
Err(e) => self.hl.message = Some((tr_hl::mask_addresses(&e), true)),
}
}
WalletResponse::SessionLoaded { account, session } => {
if self.wallet().is_some_and(|w| session_account(&w.address) == account)
&& self.hl.wallet.session.is_none()
{
self.hl.wallet.session = session;
}
}
WalletResponse::ApproveNotSigned(e) => {
self.hl.busy = None;
self.hl.pending_approval = None;
let e = tr_hl::mask_addresses(&e);
self.hl.message = Some((format!("Nothing was approved: {e}"), true));
}
WalletResponse::RevokeNotSigned(e) => {
self.hl.wallet.revoking = false;
self.hl.busy = None;
let e = tr_hl::mask_addresses(&e);
self.hl.message = Some((format!("Nothing was revoked; the trading key still works: {e}"), true));
}
WalletResponse::Balances { user, result } => {
if self.wallet().is_some_and(|w| w.address == user) {
self.hl.wallet.spot = Some((user, result));
}
}
}
}
fn wallet_signed_in(&mut self, s: SignedIn, sign_in: &SignIn) {
let SignedIn { session, address, user, saved } = s;
if let Err(e) = sign_in.check_account(&address) {
// Not the wallet the action was for: drop that session and change nothing.
let account = session_account(&address);
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
self.hl.message = Some((e, true));
return;
}
let then = sign_in.then;
let organization_id = session.organization_id().to_string();
if self.hl_master().as_deref() != Some(&address) {
// Another account: its agent (if any) is not this one's.
self.prefs.trading.hl.agent = None;
self.prefs.trading.hl.enabled = false;
self.hl.key = None;
self.hl.reset_key_lookup();
}
self.prefs.trading.hl.master = Some(address.clone());
self.prefs.trading.hl.wallet = Some(HlWallet { address: address.clone(), organization_id, user: user.clone() });
self.save_hl_prefs();
self.hl.wallet.session_tried = Some(session_account(&address));
self.hl.wallet.session = Some(Arc::new(session));
let masked = super::hl_view::masked(&address);
let mut note = format!("Signed in: wallet {masked} (Turnkey user {user}).");
if let Err(e) = saved {
note.push_str(&format!(" The session couldn't be kept in {} ({e}).", self.hl.store.describe()));
}
self.hl.message = Some((note, false));
self.hl_start();
match then {
Some(After::Approve) => self.wallet_approve(),
Some(After::Revoke) => self.wallet_revoke(),
None => {}
}
}
/// Stop showing the wallet here and forget its session (its agent key stays unless revoked).
pub(super) fn wallet_remove(&mut self) {
self.wallet_cancel_sign_in();
self.wallet_end_session();
self.prefs.trading.hl.wallet = None;
self.hl.wallet.reset();
}
/// Give up on a sign-in the browser never finished (the desktop's listener stops at the
/// same deadline; the browser build has no listener to time out).
pub(super) fn wallet_sign_in_deadline(&mut self) {
if self.hl.wallet.signing_in.as_ref().is_some_and(SignIn::expired) {
self.wallet_cancel_sign_in();
let mins = tr_turnkey::SIGN_IN_TIMEOUT.as_secs() / 60;
self.hl.message =
Some((format!("The sign-in wasn't finished within {mins} minutes. Start it again."), true));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Greptile #38: removing the wallet reset the request counter, so a cancelled sign-in's
/// late answer carried the same id as the next one and ended it.
#[test]
fn request_ids_stay_unique_when_the_wallet_is_removed() {
let mut w = WalletState::default();
let first = w.next_request_id();
w.reset();
assert_ne!(w.next_request_id(), first);
}
/// Greptile #38: a revoke (or approval) that needed a sign-in must not act on whichever
/// wallet the user happened to sign in to.
#[test]
fn a_sign_in_for_an_action_accepts_only_that_wallet() {
let a = "0x00000000000000000000000000000000000000aa";
let b = "0x00000000000000000000000000000000000000bb";
let pending = SignIn::new(1, "s".into(), Some(After::Revoke), Some(a.into()));
assert!(pending.check_account(a).is_ok());
assert!(pending.check_account(&a.to_ascii_uppercase().replace("0X", "0x")).is_ok());
assert!(pending.check_account(b).unwrap_err().contains("0x0000…00aa"));
let plain = SignIn::new(2, "s".into(), None, None);
assert!(plain.check_account(b).is_ok(), "a plain sign-in takes any wallet");
}
/// Greptile #38: the browser build's sign-in had no deadline at all.
#[test]
fn a_sign_in_expires_after_ten_minutes() {
let mut s = SignIn::new(1, "s".into(), None, None);
assert!(!s.expired());
s.started = Instant::now() - (tr_turnkey::SIGN_IN_TIMEOUT + Duration::from_secs(1));
assert!(s.expired());
}
#[test]
fn a_session_close_to_expiry_does_not_sign() {
let key = SessionKey::generate();
let claims = |exp| tr_turnkey::session::Claims {
organization_id: "org".into(),
user_id: "u".into(),
public_key: key.public_hex(),
exp,
session_type: String::new(),
};
let mut w = WalletState::default();
assert_eq!(w.session_minutes(), None);
let token = tr_turnkey::session::unsigned_token(&claims(now_secs() + 600));
w.session =
Some(Arc::new(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &token, now_secs()).unwrap()));
assert_eq!(w.session_minutes(), Some(10));
let token = tr_turnkey::session::unsigned_token(&claims(now_secs() + 30));
w.session =
Some(Arc::new(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &token, now_secs()).unwrap()));
assert_eq!(w.session_minutes(), None, "under a minute left: sign in again first");
}
}
-374
View File
@@ -1,374 +0,0 @@
//! The built-in wallet's views: its part of Settings › Trading › Hyperliquid, the Deposit
//! window (address, QR code, testnet balances) and the warning before a backup. Each shows
//! the amber TESTNET badge.
use egui::{Color32, RichText, Ui};
use tr_hl::nonce::now_ms;
use super::TerminalApp;
use super::hl_view::{masked, testnet_badge};
use super::portfolio_view::money;
use crate::prefs::HlPrefs;
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
/// A QR code of `data`, `side` points square, dark modules on white with a 4-module margin.
pub fn qr_code(ui: &mut Ui, data: &str, side: f32) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::hover());
let Ok(code) = qrcode::QrCode::new(data.as_bytes()) else { return resp };
let n = code.width();
let colors = code.to_colors();
let cell = side / (n + 8) as f32;
let p = ui.painter_at(rect);
p.rect_filled(rect, 4.0, Color32::WHITE);
for (i, c) in colors.iter().enumerate() {
if *c == qrcode::Color::Dark {
let (x, y) = ((i % n) as f32 + 4.0, (i / n) as f32 + 4.0);
let min = rect.min + egui::vec2(x * cell, y * cell);
p.rect_filled(egui::Rect::from_min_size(min, egui::vec2(cell, cell)), 0.0, Color32::BLACK);
}
}
resp
}
impl TerminalApp {
/// Settings › Trading › Hyperliquid: the wallet (or a watched address) and the trading key.
pub(super) fn wallet_section(&mut self, ui: &mut Ui, h: &HlPrefs) {
let note = |ui: &mut Ui, s: &str| {
ui.label(RichText::new(s).font(ui_font(11.5)).color(muted()));
};
let busy = self.hl.busy.is_some();
let signing_in = self.hl.wallet.signing_in.is_some();
match (&h.wallet, &h.master) {
(Some(w), _) => {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(format!("Wallet {}", masked(&w.address))).font(mono(12.5)).color(text()));
self.stream_dot(ui);
if !w.user.is_empty() {
note(ui, &format!("· Turnkey · {}", w.user));
}
});
ui.horizontal_wrapped(|ui| {
if ui
.button("Deposit…")
.on_hover_text("The wallet's address, a QR code and its testnet balances")
.clicked()
{
self.wallet_open_deposit();
}
if ui
.button("Back Up Wallet…")
.on_hover_text("Show the recovery phrase in Turnkey's own window")
.clicked()
{
self.hl.wallet.backup_prompt = true;
self.hl.wallet.backup_ack = false;
}
if ui
.add_enabled(!busy, egui::Button::new("Remove").small())
.on_hover_text("Stop showing this wallet here (the trading key stays unless revoked)")
.clicked()
{
self.hl_disconnect();
}
});
ui.horizontal_wrapped(|ui| match self.hl.wallet.session_minutes() {
Some(m) => {
note(ui, &format!("Signed in to Turnkey for {m} more min."));
if ui.add(egui::Button::new("End Session").small()).clicked() {
self.wallet_end_session();
}
}
None => {
note(ui, "Not signed in to Turnkey. You only need to be to approve or revoke a trading key.")
}
});
}
(None, Some(m)) => {
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!("Watching {} (read-only)", masked(m))).font(mono(12.5)).color(text()),
);
self.stream_dot(ui);
if ui
.add_enabled(!busy, egui::Button::new("Remove").small())
.on_hover_text("Stop showing this account")
.clicked()
{
self.hl_disconnect();
}
});
self.sign_in_button(ui, signing_in, "Create Wallet / Sign In…");
}
(None, None) => {
self.sign_in_button(ui, signing_in, "Create Wallet / Sign In…");
note(
ui,
"Opens Terminal's sign-in page in your browser. Sign in with an email code or a passkey; a new account gets a wallet held by Turnkey. Terminal never sees the wallet's key, and you never paste one.",
);
egui::CollapsingHeader::new(RichText::new("Watch an address (read-only)").font(ui_font(12.0)))
.id_salt("hl-watch")
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.hl.connect_form)
.hint_text("0x… (testnet address)")
.font(mono(12.0))
.desired_width(dp(330.0)),
);
if ui.button("Show Account").on_hover_text("Read-only: no key involved").clicked() {
let a = self.hl.connect_form.clone();
self.hl_connect(&a);
}
});
note(ui, "Fills PORT, POS, ORD and ACTV from Hyperliquid testnet. Nothing is signed.");
});
}
}
if let Some(s) = &self.hl.wallet.signing_in {
let waited = s.started.elapsed().as_secs() / 60;
let reopen = s.url.is_some();
ui.horizontal_wrapped(|ui| {
ui.add(egui::Spinner::new().size(12.0));
note(
ui,
&format!("Finish signing in in your browser (terminal.rasmic.xyz/wallet). {waited} min so far, 10 at most."),
);
if reopen && ui.add(egui::Button::new("Open Again").small()).clicked() {
self.wallet_reopen();
}
if ui.add(egui::Button::new("Cancel").small()).clicked() {
self.wallet_cancel_sign_in();
}
});
}
// The trading (agent) key.
ui.add_space(dp(6.0));
match &h.agent {
Some(a) => {
let days = (a.valid_until.saturating_sub(now_ms())) / (24 * 3600 * 1000);
let expiry = chrono::DateTime::from_timestamp_millis(a.valid_until as i64)
.map(|t| t.with_timezone(&chrono::Local).format("%d %b %Y").to_string())
.unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!(
"Trading key {} · {}",
tr_hl::trader::agent_base_name(&a.name),
masked(&a.address)
))
.font(mono(12.0))
.color(text()),
);
note(ui, &format!("· expires {expiry} · key in {}", self.hl.store.describe()));
});
if days < 7 {
ui.label(
RichText::new(format!(
"The trading key expires in {days} days: revoke it and approve a new one."
))
.font(ui_font(12.0))
.color(amber()),
);
}
if self.hl.key.is_none() && !self.hl.key_loading {
ui.label(
RichText::new("This device has no key for this trading key.")
.font(ui_font(12.0))
.color(amber()),
);
}
ui.horizontal_wrapped(|ui| {
if h.wallet.is_some()
&& ui
.add_enabled(!busy && !signing_in, egui::Button::new("Revoke on Hyperliquid"))
.on_hover_text(
"The wallet replaces this key on Hyperliquid with one nobody holds: one Turnkey signature, after a sign-in if needed",
)
.clicked()
{
self.wallet_revoke();
}
if ui
.add_enabled(!busy, egui::Button::new("Delete Key Here"))
.on_hover_text("Terminal can't sign with it any more; it stays approved on Hyperliquid until it expires or is revoked")
.clicked()
{
self.hl_delete_agent_key();
}
});
}
None if h.wallet.is_some() => {
let go = ui
.add_enabled(
!busy && !signing_in && self.hl.store_error.is_none(),
egui::Button::new(RichText::new("Approve Trading Key").strong()),
)
.on_hover_text("Signs in to Turnkey first if there's no session")
.clicked();
if go {
self.wallet_approve();
}
note(
ui,
"Terminal makes a trading key on this device, and your wallet signs approveAgent for it once on Turnkey (one signature). It is named terminal-<device> and valid 180 days. It can trade but not withdraw. The wallet itself never signs orders.",
);
}
None => {}
}
}
fn sign_in_button(&mut self, ui: &mut Ui, signing_in: bool, label: &str) {
let b = egui::Button::new(RichText::new(label).strong().color(Color32::BLACK)).fill(amber());
if ui.add_enabled(!signing_in && self.hl.busy.is_none(), b).clicked() {
self.wallet_sign_in(None);
}
}
fn stream_dot(&self, ui: &mut Ui) {
let (dot, status) = if self.hl.connected {
(theme::up(), self.hl.stream_status.clone())
} else if self.hl.stream_status.is_empty() {
(amber(), "Connecting…".to_string())
} else {
(amber(), self.hl.stream_status.clone())
};
let (r, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 4.0, dot);
ui.label(RichText::new(status).font(ui_font(11.5)).color(muted()));
if let Some(u) = &self.hl.user {
ui.label(
RichText::new(format!("· value {}", money(u.account_value(), "USD")))
.font(ui_font(11.5))
.color(muted()),
);
}
}
/// The Deposit window and the backup warning, when open.
pub(super) fn wallet_windows(&mut self, ctx: &egui::Context) {
if self.hl.wallet.deposit {
self.deposit_window(ctx);
}
if self.hl.wallet.backup_prompt {
self.backup_dialog(ctx);
}
}
fn deposit_window(&mut self, ctx: &egui::Context) {
let Some(w) = self.wallet().cloned() else {
self.hl.wallet.deposit = false;
return;
};
let mut close = false;
let modal = egui::Modal::new(egui::Id::new("wallet-deposit")).show(ctx, |ui| {
ui.set_width(dp(440.0));
ui.horizontal(|ui| {
testnet_badge(ui);
ui.label(RichText::new("Deposit").font(ui_font(16.0)).strong());
});
ui.add_space(dp(4.0));
ui.label(
RichText::new(
"Hyperliquid testnet only. Send test USDC on Hyperliquid testnet to this address. Never send real funds: this build can't reach mainnet.",
)
.font(ui_font(12.0))
.color(amber()),
);
ui.add_space(dp(8.0));
ui.vertical_centered(|ui| {
qr_code(ui, &w.address, dp(196.0)).on_hover_text("Your wallet's address");
});
ui.add_space(dp(8.0));
ui.horizontal(|ui| {
ui.label(RichText::new(&w.address).font(mono(12.0)).color(text()));
if ui.button("Copy").clicked() {
ui.ctx().copy_text(w.address.clone());
self.trade_toast("Wallet address copied", true);
}
});
ui.add_space(dp(6.0));
let user = self.hl.user.clone();
egui::Grid::new("wallet-balances").num_columns(2).spacing([dp(24.0), dp(4.0)]).show(ui, |ui| {
let row = |ui: &mut Ui, k: &str, v: String| {
ui.label(RichText::new(k).font(ui_font(12.0)).color(muted()));
ui.label(RichText::new(v).font(mono(12.5)).color(text()));
ui.end_row();
};
let perp = |f: fn(&tr_hl::stream::UserState) -> f64| {
user.as_deref().map(|u| money(f(u), "USD")).unwrap_or_else(|| "loading…".into())
};
row(ui, "Perp account value", perp(|u| u.account_value()));
row(ui, "Withdrawable", perp(|u| u.withdrawable()));
let spot = match &self.hl.wallet.spot {
Some((_, Ok(s))) if s.hold > 0.0 => format!("{} ({} on hold)", money(s.total, "USD"), money(s.hold, "USD")),
Some((_, Ok(s))) => money(s.total, "USD"),
Some((_, Err(e))) => format!("unavailable ({})", tr_hl::mask_addresses(e)),
None => "loading…".into(),
};
row(ui, "Spot USDC", spot);
});
ui.add_space(dp(6.0));
ui.label(
RichText::new("Test USDC comes from Hyperliquid's testnet faucet or a transfer from another testnet account.")
.font(ui_font(11.5))
.color(muted()),
);
ui.add_space(dp(6.0));
if ui.button("Close").clicked() {
close = true;
}
});
if close || modal.should_close() {
self.hl.wallet.deposit = false;
}
}
fn backup_dialog(&mut self, ctx: &egui::Context) {
let mut choice = None;
let modal = egui::Modal::new(egui::Id::new("wallet-backup")).show(ctx, |ui| {
ui.set_width(dp(450.0));
ui.horizontal(|ui| {
testnet_badge(ui);
ui.label(RichText::new("Back up your wallet?").font(ui_font(16.0)).strong());
});
ui.add_space(dp(4.0));
for line in [
"Your recovery phrase controls the wallet. Anyone who has it can take everything in it.",
"You'll sign in again on terminal.rasmic.xyz, and Turnkey shows the phrase in its own secure frame there. Terminal and Goshen never see it and can't recover it for you.",
"Write it down offline. Never type it into a website, and never share it, not even with support.",
] {
ui.label(RichText::new(format!("• {line}")).font(ui_font(12.0)).color(text()));
}
ui.add_space(dp(6.0));
ui.checkbox(&mut self.hl.wallet.backup_ack, "I'm somewhere private, and I understand the above");
ui.add_space(dp(6.0));
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
choice = Some(false);
}
let on = self.hl.wallet.backup_ack;
let b = if on {
egui::Button::new(RichText::new("Open the Backup Page").strong().color(Color32::BLACK)).fill(amber())
} else {
egui::Button::new(RichText::new("Open the Backup Page").strong())
};
if ui.add_enabled(on, b).clicked() {
choice = Some(true);
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(false);
}
if let Some(go) = choice {
self.hl.wallet.backup_prompt = false;
self.hl.wallet.backup_ack = false;
if go {
self.wallet_backup();
}
}
}
}
-48
View File
@@ -319,54 +319,6 @@ impl TerminalApp {
})
}
/// HIP-3 perps search can offer: those the server lists as live, or the linked ones until it
/// has answered.
pub(super) fn perp_candidates(&self) -> Vec<&str> {
match &self.live_perps {
Some(v) => v.iter().map(String::as_str).collect(),
None => tr_core::perps::LINKS.iter().map(|l| l.coin).collect(),
}
}
/// Whether the Terminal server lists the HIP-3 perp `coin` as live.
pub(super) fn perp_is_live(&self, coin: &str) -> bool {
self.live_perps.as_ref().is_some_and(|v| v.iter().any(|x| x == coin))
}
/// Where a pane on `key` can switch to: a perp's linked instrument, or an instrument's live
/// perp. Links to perps show only once the server has listed them live.
pub(super) fn switch_target(&self, key: &str) -> Option<&'static str> {
tr_core::perps::switch_target(key, |c| self.perp_is_live(c))
}
/// `PERP`: from a security to its HIP-3 perp, or from a perp back to its security.
pub(super) fn open_perp(&mut self, key: Option<String>) {
use tr_core::perps;
let Some(key) = key.map(|k| tr_data::security(&k).key()) else {
self.status = "PERP needs a security, for example AAPL PERP".into();
return;
};
if perps::is_perp(&key) {
match perps::link_for(&key) {
Some(l) => self.open_security(l.underlying),
None => self.status = format!("{key} is a HIP-3 perp; no listed instrument is linked to it"),
}
return;
}
let Some(l) = perps::perp_for(&key) else {
self.status = format!("No HIP-3 perp is linked to {key}");
return;
};
// Until the server has answered, try anyway: the pane says why if it can't load.
if self.live_perps.is_some() && !self.perp_is_live(l.coin) {
self.status = format!("{} isn't live on the Terminal server right now", l.coin);
return;
}
self.open_security(l.coin);
self.status =
format!("{} ({}) references {}. It is a perp, not {key}.", l.coin, perps::badge(l.coin), l.reference);
}
/// The focused security if it has listed options (US equity, ETF, index), else AAPL.
pub(super) fn optionable_symbol(&self) -> String {
use tr_core::AssetClass as C;
+17 -119
View File
@@ -97,8 +97,6 @@ pub struct ChartState {
paging: bool,
exhausted: bool,
error: Option<String>,
/// Why the bars shown, from the disk cache, couldn't be refreshed.
stale: Option<String>,
built: Vec<BuiltIndicator>,
compare_line: Option<LineRef>,
compare_scale: f64,
@@ -117,9 +115,6 @@ pub struct ChartState {
/// The workspace drew this chart's controls in the pane's tab strip, so skip the
/// header row and give the plot the space.
pub controls_in_strip: bool,
/// What `PERP` would switch this chart to (its live HIP-3 perp, or a perp's listed
/// instrument), set by the app each frame.
pub switch_to: Option<&'static str>,
}
impl ChartState {
@@ -153,7 +148,6 @@ impl ChartState {
paging: false,
exhausted: false,
error: None,
stale: None,
built: Vec::new(),
compare_line: None,
compare_scale: 1.0,
@@ -167,7 +161,6 @@ impl ChartState {
actions: Vec::new(),
hover_enabled: true,
controls_in_strip: false,
switch_to: None,
}
}
@@ -182,7 +175,6 @@ impl ChartState {
self.paging = false;
self.exhausted = false;
self.error = None;
self.stale = None;
self.viewport = None;
self.manual_scale = None;
self.glide = None;
@@ -405,7 +397,6 @@ impl ChartState {
prepended: usize,
from_cache: bool,
exhausted: bool,
full: bool,
live: bool,
w: &Worker,
) {
@@ -430,10 +421,9 @@ impl ChartState {
self.bars = bars;
self.gpu = Some(gpu);
self.paging = false;
self.exhausted = end_of_history(self.exhausted, exhausted, prepended, full);
self.exhausted = exhausted || self.exhausted && prepended == 0;
if !from_cache {
self.loading = false;
self.stale = None;
}
self.error = None;
match self.viewport.as_mut() {
@@ -449,7 +439,7 @@ impl ChartState {
}
self.apply_range();
// Live ticks only move the last bar: refresh indicators at most every 1.5 s and
// leave the compare series alone. (A live feed filling a gap arrives as a refresh.)
// leave the compare series alone.
let due = self.last_indicator_req.is_none_or(|t| t.elapsed().as_millis() >= 1500);
if !live || due {
self.request_indicators(w);
@@ -460,33 +450,13 @@ impl ChartState {
}
}
/// The range row's status: errors, loading, or how many bars are shown and from where.
fn status_text(&self) -> String {
match (&self.error, self.loading, self.paging) {
(Some(e), _, _) => format!("ERR {e}"),
(_, true, _) => "loading…".into(),
(_, _, true) => "loading history…".into(),
_ => format!(
"{} {}bars{}{}{}",
self.bars.len(),
if self.stale.is_some() { "cached " } else { "" },
self.stale.as_deref().map(|e| format!(" · {e}")).unwrap_or_default(),
if self.log { " LOG" } else { "" },
if self.magnet { " MAGNET" } else { "" }
),
}
}
pub fn on_history_error(&mut self, generation: u64, error: String) {
if generation == self.generation {
let paging = std::mem::take(&mut self.paging);
self.loading = false;
self.paging = false;
self.exhausted = true;
if self.bars.is_empty() {
self.error = Some(error);
} else if !paging {
// The refresh after painting from the disk cache failed.
self.stale = Some(error);
}
}
}
@@ -591,9 +561,6 @@ impl ChartState {
);
hui.spacing_mut().item_spacing.x = 6.0;
hui.label(RichText::new(&self.sec.ticker).font(mono(12.0)).color(amber()));
if self.sec.class == AssetClass::Perp {
hui.label(RichText::new(tr_core::perps::badge(&self.symbol)).font(ui_font(11.0)).color(theme::perp()));
}
if hui.available_width() > Self::controls_width(&hui) {
clicked |= self.controls(&mut hui, w);
} else {
@@ -646,18 +613,24 @@ impl ChartState {
}
rx = lr.right();
}
let status_color = if self.error.is_some() || self.stale.is_some() { theme::down() } else { muted() };
let status = painter.text(
let status = match (&self.error, self.loading, self.paging) {
(Some(e), _, _) => format!("ERR {e}"),
(_, true, _) => "loading…".into(),
(_, _, true) => "loading history…".into(),
_ => format!(
"{} bars{}{}",
self.bars.len(),
if self.log { " LOG" } else { "" },
if self.magnet { " MAGNET" } else { "" }
),
};
painter.text(
pos2(range_row.right() - 6.0, range_row.center().y),
Align2::RIGHT_CENTER,
self.status_text(),
status,
mono(10.5),
status_color,
muted(),
);
if let Some(target) = self.switch_to {
let room = status.left() - 12.0 - (rx + 10.0);
clicked |= self.switch_chip(ui, &painter, pos2(rx + 10.0, range_row.center().y), room, target);
}
let body = Rect::from_min_max(pos2(rect.left(), header.bottom()), pos2(rect.right(), range_row.top()));
let plot_all = Rect::from_min_max(body.min, pos2(body.right() - AXIS_W, body.bottom() - TIME_H));
@@ -667,11 +640,6 @@ impl ChartState {
let Some(gpu) = self.gpu.clone() else {
let msg = self.error.clone().unwrap_or_else(|| format!("Loading {} {}…", self.symbol, self.tf));
if self.sec.class == AssetClass::Perp {
let badge = tr_core::perps::badge(&self.symbol);
let below = plot_all.center() + vec2(0.0, 20.0);
painter.text(below, Align2::CENTER_CENTER, badge, ui_font(11.5), theme::perp());
}
painter.text(plot_all.center(), Align2::CENTER_CENTER, msg, mono(13.0), amber_dim());
return clicked;
};
@@ -1117,36 +1085,6 @@ impl ChartState {
}
}
/// The range row's link between a security and its HIP-3 perp, drawn when it fits in
/// `room`. Clicking it (or ⇧P) loads the other one here.
fn switch_chip(&mut self, ui: &Ui, painter: &egui::Painter, left: Pos2, room: f32, target: &str) -> bool {
let to_perp = tr_core::perps::is_perp(target);
let label = if to_perp { format!("⇄ {target} PERP") } else { format!("⇄ {target}") };
let g = painter.layout_no_wrap(label, mono(11.0), theme::perp());
if g.size().x + 10.0 > room {
return false;
}
let r = Rect::from_min_size(pos2(left.x, left.y - RANGE_H / 2.0 + 2.0), vec2(g.size().x + 10.0, RANGE_H - 4.0));
let resp = ui.interact(r, ui.id().with((self.id, "perp-switch")), Sense::click());
let fill = if resp.hovered() { theme::control_hover() } else { theme::control() };
painter.rect_filled(r, 3.0, fill);
painter.galley(pos2(r.left() + 5.0, r.center().y - g.size().y / 2.0), g, theme::perp());
let tip = match tr_core::perps::link_for(if to_perp { target } else { &self.symbol }) {
Some(l) if to_perp => format!(
"Open {target}, the HIP-3 perp on Hyperliquid's {} dex (⇧P). It references {}. A perp's price \
can differ from the listed instrument's, and it trades when that market is closed.",
tr_core::perps::venue(target).unwrap_or("xyz"),
l.reference
),
_ => format!("Open {target}, the listed instrument this perp references (⇧P)"),
};
let resp = resp.on_hover_text(tip);
if resp.clicked() {
self.actions.push(Action::OpenChart(target.to_string()));
}
resp.clicked()
}
fn paint_legend(&self, ui: &Ui, price_rect: Rect, i: usize, sub_rects: &[(Rect, usize, PriceScale)]) {
let Some(b) = self.bars.get(i) else { return };
let p = ui.painter_at(price_rect);
@@ -1162,9 +1100,6 @@ impl ChartState {
*x += g.size().x + 8.0;
};
put(format!("{} · {}", self.sec.ticker, self.tf), amber(), &mut x);
if self.sec.class == AssetClass::Perp {
put(tr_core::perps::badge(&self.symbol), theme::perp(), &mut x);
}
for (k, val) in [("O", b.open), ("H", b.high), ("L", b.low), ("C", b.close)] {
put(format!("{k}{val:.dec$}"), col, &mut x);
}
@@ -1500,47 +1435,10 @@ fn hover_card(ui: &Ui, at: Pos2, t: &Trade) {
}
}
/// Whether the chart has reached the start of its history after a response. A full load decides
/// it, since it may come from another source than the bars before it. A page decides it too. Live
/// updates and refreshes keep what the chart knew.
fn end_of_history(was: bool, exhausted: bool, prepended: usize, full: bool) -> bool {
if full { exhausted } else { exhausted || was && prepended == 0 }
}
#[cfg(test)]
mod tests {
use super::*;
/// Bars from the disk cache whose refresh failed say so, so a perp whose server is down
/// never passes for live. A failed page of older bars leaves the shown ones as they are.
#[test]
fn cached_bars_that_could_not_refresh_say_why() {
let down = "the Terminal server is not reachable";
let mut c = ChartState::new(1, "xyz:AAPL", Timeframe::M15);
c.on_history_error(c.generation, down.into());
assert_eq!(c.status_text(), format!("ERR {down}"), "with nothing to show, the error is the message");
c.error = None;
c.bars = Arc::new((0..3).map(|i| Bar::new(i * 900, 1.0, 1.0, 1.0, 1.0, 1.0)).collect());
c.on_history_error(c.generation, down.into());
assert_eq!(c.status_text(), format!("3 cached bars · {down}"));
c.on_history_error(c.generation + 1, "another load".into());
assert_eq!(c.status_text(), format!("3 cached bars · {down}"), "other generations are ignored");
let mut p = ChartState::new(2, "xyz:AAPL", Timeframe::M15);
p.bars = c.bars.clone();
p.paging = true;
p.on_history_error(p.generation, down.into());
assert_eq!(p.status_text(), "3 bars", "a failed page doesn't make the shown bars stale");
}
#[test]
fn a_full_load_from_a_new_source_can_page_again() {
assert!(!end_of_history(true, false, 0, true), "a reload from a source with more history");
assert!(end_of_history(false, true, 0, true));
assert!(end_of_history(true, false, 0, false), "a live update keeps the end");
assert!(!end_of_history(true, false, 5, false), "a page that added bars");
assert!(end_of_history(false, true, 0, false), "an empty page");
}
#[test]
fn glide_pans_linearly_and_zooms_evenly() {
let from = Viewport { right: 100.0, bar_px: 2.0, width_px: 800.0 };
+3 -55
View File
@@ -2,8 +2,6 @@
//!
//! Examples: `AAPL`, `AAPL US EQUITY <GO>`, `5`, `1W`, `RSI`, `SMA 50`, `ICHIMOKU`, `IND MOMENTUM`,
//! `CMP MSFT`, `AAPL CONG`, `CONG`, `POL PELOSI`, `TRACK PELOSI`, `LAYOUT 4`, `FIB`, `HELP`.
//! Hyperliquid HIP-3 perps go by their venue key (`xyz:AAPL`), and `AAPL PERP` opens the perp
//! linked to a security.
use tr_chart::drawings::Tool;
use tr_chart::scene::ChartType;
@@ -277,11 +275,6 @@ pub enum Function {
Trade(TradeArgs),
/// Switch between the demo account and live accounts (`DEMO`, `MODE LIVE`); `true` = live.
AccountMode(bool),
/// `KILL`: cancel every open Hyperliquid order and turn Hyperliquid orders off (confirmed).
Kill,
/// `AAPL PERP`: open the HIP-3 perp linked to a security. Alone, or on a perp, it switches
/// the focused pane between the two.
Perp,
}
fn num<T: std::str::FromStr>(t: Option<&&str>) -> Option<T> {
@@ -365,7 +358,6 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
"BUY" => Function::Trade(TradeArgs::parse(Some(true), rest)?),
"SELL" => Function::Trade(TradeArgs::parse(Some(false), rest)?),
"TRADE" | "TICKET" | "EMSX" => Function::Trade(TradeArgs::parse(None, rest)?),
"KILL" if rest.is_empty() => Function::Kill,
"DEMO" | "PAPER" if rest.is_empty() => Function::AccountMode(false),
"MODE" => match words().join(" ").as_str() {
"DEMO" | "PAPER" => Function::AccountMode(false),
@@ -373,7 +365,6 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
_ => return None,
},
"RANGE" => Function::Range(Range::parse(rest.first()?)?),
"PERP" if rest.is_empty() => Function::Perp,
// Timeframes win where spellings overlap (`1D`, `1M`); otherwise try a range (`5D`, `YTD`),
// then the indicator catalog (`RSI`, `BB 20 2.5`).
_ => {
@@ -427,20 +418,9 @@ pub fn hint(input: &str) -> String {
match parse(input) {
None if input.trim().is_empty() => "Type a ticker and press <GO> (Enter). HELP for functions.".into(),
None => "Unrecognised command".into(),
Some(Command::Symbol(s)) => {
let badge = tr_core::perps::is_perp(&s).then(|| tr_core::perps::badge(&s));
match (Security::parse(&s).and_then(|x| x.name()), badge) {
(Some(name), Some(b)) => format!("Load {s} — {name} · {b}"),
(None, Some(b)) => format!("Load {s} · {b}"),
(Some(name), None) => format!("Load {s} — {name}"),
(None, None) => format!("Load {s}"),
}
}
Some(Command::SymbolFunction(s, Function::Perp)) => match tr_core::perps::counterpart(&s) {
Some(other) if tr_core::perps::is_perp(&s) => format!("{s}: switch to {other}"),
Some(perp) => format!("{s}: open its HIP-3 perp {perp} ({})", tr_core::perps::badge(perp)),
None if tr_core::perps::is_perp(&s) => format!("{s}: no listed instrument is linked to it"),
None => format!("{s}: no HIP-3 perp is linked to it"),
Some(Command::Symbol(s)) => match Security::parse(&s).and_then(|x| x.name()) {
Some(name) => format!("Load {s} — {name}"),
None => format!("Load {s}"),
},
Some(Command::SymbolFunction(s, f)) => format!("{s}: {}", describe(&f)),
Some(Command::Function(f)) => describe(&f),
@@ -554,10 +534,6 @@ fn describe(f: &Function) -> String {
}
Function::AccountMode(false) => "Switch to the DEMO account (paper trading)".into(),
Function::AccountMode(true) => "Switch to LIVE brokerage accounts (asks first)".into(),
Function::Kill => {
"Kill switch: cancel every open Hyperliquid testnet order, then turn Hyperliquid orders off".into()
}
Function::Perp => "Switch between the security and its HIP-3 perp (PERP · HL xyz)".into(),
}
}
@@ -718,10 +694,6 @@ mod tests {
assert_eq!(t("SELL ALL TSLA 5"), None);
assert_eq!(parse("DEMO"), Some(Command::Function(Function::AccountMode(false))));
assert_eq!(parse("MODE LIVE"), Some(Command::Function(Function::AccountMode(true))));
assert_eq!(parse("KILL"), Some(Command::Function(Function::Kill)));
assert_eq!(parse("kill"), Some(Command::Function(Function::Kill)));
let perp = t("BUY xyz:AAPL 0.5 @226.4").unwrap();
assert_eq!((perp.symbol.as_deref(), perp.units, perp.limit), (Some("xyz:AAPL"), Some(0.5), Some(226.4)));
}
#[test]
@@ -741,30 +713,6 @@ mod tests {
assert_eq!(parse("AAPL WHATEVER"), None);
}
#[test]
fn hip3_perps() {
let sym = |s: &str| Some(Command::Symbol(s.into()));
assert_eq!(parse("xyz:aapl"), sym("xyz:AAPL"));
assert_eq!(parse("XYZ:AAPL <GO>"), sym("xyz:AAPL"));
assert_eq!(parse("aapl"), sym("AAPL US Equity"), "a ticker is still the listed stock");
assert_eq!(
parse("xyz:AAPL 5"),
Some(Command::SymbolFunction("xyz:AAPL".into(), Function::Timeframe(Timeframe::M5)))
);
assert_eq!(parse("xyz:GOLD OB"), Some(Command::SymbolFunction("xyz:GOLD".into(), Function::OrderBook(None))));
// `PERP` after a security asks for its perp; alone it switches the focused pane.
let perp = |s: &str| Some(Command::SymbolFunction(s.into(), Function::Perp));
assert_eq!(parse("AAPL PERP"), perp("AAPL US Equity"));
assert_eq!(parse("aapl <equity> perp"), perp("AAPL US Equity"));
assert_eq!(parse("EURUSD CRNCY PERP"), perp("EURUSD Curncy"));
assert_eq!(parse("PERP"), Some(Command::Function(Function::Perp)));
assert_eq!(hint("xyz:aapl"), "Load xyz:AAPL — Apple Inc perp · PERP · HL xyz");
assert_eq!(hint("xyz:SKHX"), "Load xyz:SKHX · PERP · HL xyz");
assert_eq!(hint("AAPL PERP"), "AAPL US Equity: open its HIP-3 perp xyz:AAPL (PERP · HL xyz)");
assert_eq!(hint("xyz:AAPL PERP"), "xyz:AAPL: switch to AAPL US Equity");
assert_eq!(hint("NDX INDEX PERP"), "NDX Index: no HIP-3 perp is linked to it");
}
#[test]
fn hints() {
assert_eq!(hint("5"), "Interval 5 minutes");
-1028
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
//! Named, saved workspace layouts: the dock tree, each pane's kind and settings, link
//! groups and popped-out windows. Stored one JSON file per layout in
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal/layouts` on macOS),
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal-rs/layouts` on macOS),
//! so they can be shared, hand-edited, imported and exported.
use std::collections::HashSet;
+4 -6
View File
@@ -1,4 +1,4 @@
//! Terminal: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
//! terminal-rs: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
//! congressional (STOCK Act) trade tracking.
mod anim;
@@ -7,7 +7,6 @@ mod chart_view;
mod command;
mod congress_view;
mod dock;
mod hl_worker;
mod layout;
mod layouts;
mod markets_view;
@@ -16,7 +15,6 @@ mod prefs;
mod storage;
mod theme;
mod themes;
mod wallet_worker;
#[cfg(target_arch = "wasm32")]
mod web;
mod widgets;
@@ -33,7 +31,7 @@ fn create_app(
msaa: u32,
t0: Instant,
) -> Result<TerminalApp, Box<dyn std::error::Error + Send + Sync>> {
let rs = cc.wgpu_render_state.as_ref().ok_or("Terminal requires the wgpu renderer")?;
let rs = cc.wgpu_render_state.as_ref().ok_or("terminal-rs requires the wgpu renderer")?;
// Charts read their series from storage buffers in the vertex shader. WebGL2 has none,
// so there chart panes keep their axes and labels but draw no series.
let charts = rs.adapter.get_downlevel_capabilities().flags.contains(eframe::wgpu::DownlevelFlags::VERTEX_STORAGE);
@@ -83,7 +81,7 @@ fn main() -> eframe::Result {
.unwrap_or(if cfg!(target_os = "macos") { 4 } else { 1 });
let mut viewport = egui::ViewportBuilder::default()
.with_title("Terminal")
.with_app_id("terminal")
.with_app_id("terminal-rs")
.with_icon(eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon.png")).expect("app icon"))
.with_inner_size([1600.0, 960.0])
.with_min_inner_size([900.0, 560.0]);
@@ -103,7 +101,7 @@ fn main() -> eframe::Result {
..Default::default()
};
eframe::run_native(
"terminal",
"terminal-rs",
options,
Box::new(move |cc| Ok(Box::new(create_app(cc, tr_data::from_env(), u32::from(msaa), t0)?))),
)
+6 -29
View File
@@ -261,22 +261,12 @@ pub fn curve(ui: &mut Ui, curves: Option<&YieldCurves>) -> Vec<Action> {
out
}
/// A search hit for a HIP-3 perp, labelled with its venue.
pub fn perp_hit(key: &str) -> SearchHit {
let name = tr_core::perps::link_for(key).map_or_else(
|| format!("Hyperliquid {} perp", tr_core::perps::venue(key).unwrap_or("HIP-3")),
|l| l.name.to_string(),
);
SearchHit { key: key.to_string(), name, class: AssetClass::Perp, exchange: Some(tr_core::perps::badge(key)) }
}
/// Merge catalog matches, then HIP-3 perps among `perps`, then network hits, without duplicates.
pub fn merged_hits(query: &str, network: &[SearchHit], perps: &[&str]) -> Vec<SearchHit> {
/// Merge catalog matches with network hits, catalog first, without duplicates.
pub fn merged_hits(query: &str, network: &[SearchHit]) -> Vec<SearchHit> {
let mut out: Vec<SearchHit> = catalog::search(query, 30)
.into_iter()
.map(|l| SearchHit { key: l.key(), name: l.name.to_string(), class: l.class, exchange: None })
.collect();
out.extend(tr_core::perps::search(query, perps.iter().copied(), 10).into_iter().map(perp_hit));
for h in network {
if !out.iter().any(|o| o.key == h.key) {
out.push(h.clone());
@@ -294,12 +284,11 @@ pub fn class_color(c: AssetClass) -> Color32 {
AssetClass::Crypto => Color32::from_rgb(255, 150, 60),
AssetClass::Govt => Color32::from_rgb(190, 160, 255),
AssetClass::Cmdty => Color32::from_rgb(230, 200, 90),
AssetClass::Perp => theme::perp(),
}
}
/// Security search panel (`SECF <query>`). `perps` are the HIP-3 perps it can offer.
pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit], perps: &[&str]) -> (Vec<Action>, bool) {
/// Security search panel (`SECF <query>`).
pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit]) -> (Vec<Action>, bool) {
let mut out = Vec::new();
let mut changed = false;
ui.horizontal(|ui| {
@@ -317,7 +306,7 @@ pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit], perps: &[&
.color(amber())
.background_color(theme::header()),
);
let hits = merged_hits(query, network, perps);
let hits = merged_hits(query, network);
if hits.is_empty() && !query.trim().is_empty() {
ui.label(RichText::new("No matches.").font(mono(12.0)).color(muted()));
}
@@ -385,20 +374,8 @@ mod tests {
exchange: None,
},
];
let m = merged_hits("bitcoin", &net, &[]);
let m = merged_hits("bitcoin", &net);
assert_eq!(m.iter().filter(|h| h.key == "BTCUSD Curncy").count(), 1);
assert!(m.iter().any(|h| h.key == "BITO US Equity"));
}
/// The listed instrument comes first; its perp follows, labelled as one.
#[test]
fn search_offers_perps_after_listed_instruments() {
let m = merged_hits("aapl", &[], &["xyz:AAPL", "xyz:GOLD"]);
let keys: Vec<&str> = m.iter().map(|h| h.key.as_str()).collect();
assert_eq!(keys, ["AAPL US Equity", "xyz:AAPL"]);
assert_eq!((m[1].class, m[1].name.as_str()), (AssetClass::Perp, "Apple Inc perp"));
assert_eq!(m[1].exchange.as_deref(), Some("PERP · HL xyz"));
assert_eq!(perp_hit("xyz:SKHX").name, "Hyperliquid xyz perp");
assert!(merged_hits("aapl", &[], &[]).iter().all(|h| h.class != AssetClass::Perp));
}
}
+1 -80
View File
@@ -216,59 +216,6 @@ pub struct TradingPrefs {
pub max_order_value: Option<f64>,
/// Account the ticket preselects.
pub default_account: Option<String>,
/// Hyperliquid (testnet only in this build), with its own opt-in.
pub hl: HlPrefs,
}
/// Hyperliquid order entry. Separate from SnapTrade's `enabled`: turning one on never turns on
/// the other. Holds only public data: the agent's private key lives in the OS secret store.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct HlPrefs {
/// Orders may be signed and sent (after the acknowledgement dialog). Off by default, and
/// turned off again by the kill switch.
pub enabled: bool,
/// Always testnet: any stored value (`"mainnet"` included) reads as testnet.
pub network: tr_hl::Network,
/// The account (master address) shown in the portfolio panes, read-only until an agent
/// is approved.
pub master: Option<String>,
/// The approved agent's public details (never its key).
pub agent: Option<HlAgent>,
/// The built-in wallet, once signed in (its address is `master`). Public details only:
/// Turnkey holds the wallet's key, and the session lives in the OS secret store.
pub wallet: Option<HlWallet>,
/// Keep a `scheduleCancel` about a minute ahead while the app is open, so open orders are
/// cancelled if Terminal disappears. Opt-in: it cancels orders placed elsewhere too.
pub dead_mans_switch: bool,
/// Web only: keep the agent key in this browser (encrypted with a key the page can't
/// export) instead of only for the session.
pub remember_on_browser: bool,
}
/// The user's built-in wallet on Turnkey, as Terminal remembers it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HlWallet {
/// Lower-case `0x…`: the Hyperliquid account.
pub address: String,
/// The user's Turnkey sub-organization.
pub organization_id: String,
/// Turnkey's name for the user (`whoami`), shown so a wrong account is noticed.
#[serde(default)]
pub user: String,
}
/// An approved API wallet, as Terminal remembers it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HlAgent {
/// Lower-case `0x…`.
pub address: String,
/// `terminal-<device> valid_until <ms>`.
pub name: String,
/// Expiry, Unix milliseconds.
pub valid_until: u64,
#[serde(default)]
pub network: tr_hl::Network,
}
/// Every rebindable action.
@@ -286,7 +233,6 @@ pub enum Action {
Orders,
Buy,
Sell,
TogglePerp,
NextPane,
PrevPane,
ToggleSidebar,
@@ -330,7 +276,7 @@ pub enum Action {
}
impl Action {
pub const ALL: [Action; 53] = [
pub const ALL: [Action; 52] = [
Action::Help,
Action::Congress,
Action::Politicians,
@@ -343,7 +289,6 @@ impl Action {
Action::Orders,
Action::Buy,
Action::Sell,
Action::TogglePerp,
Action::NextPane,
Action::PrevPane,
Action::ToggleSidebar,
@@ -400,7 +345,6 @@ impl Action {
Action::Orders => "Brokerage orders".into(),
Action::Buy => "Buy the current security".into(),
Action::Sell => "Sell the current security".into(),
Action::TogglePerp => "Switch between a security and its HIP-3 perp".into(),
Action::NextPane => "Next pane".into(),
Action::PrevPane => "Previous pane".into(),
Action::ToggleSidebar => "Toggle sidebar".into(),
@@ -641,7 +585,6 @@ impl Default for Keymap {
// Shift keeps plain typing free for the type-anywhere command line.
bind(Action::Buy, "Shift+B");
bind(Action::Sell, "Shift+S");
bind(Action::TogglePerp, "Shift+P");
bind(Action::NextPane, "Tab");
bind(Action::PrevPane, "Shift+Tab");
bind(Action::ToggleSidebar, if mac { "Ctrl+Cmd+S" } else { "Ctrl+Shift+S" });
@@ -898,28 +841,6 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn hyperliquid_prefs_never_select_mainnet() {
// A hand-edited or future file asking for mainnet still loads as testnet, and the rest
// of the preferences survive.
let json = r#"{"appearance":{"theme":"Nord"},"trading":{"enabled":true,"hl":{"enabled":true,"network":"mainnet",
"master":"0xabc","agent":{"address":"0xdef","name":"terminal-mac valid_until 1","valid_until":1,"network":"Mainnet"}}}}"#;
let p: Prefs = serde_json::from_str(json).unwrap();
assert_eq!(p.trading.hl.network, tr_hl::Network::Testnet);
assert_eq!(p.trading.hl.agent.as_ref().unwrap().network, tr_hl::Network::Testnet);
assert!(p.trading.hl.enabled && p.trading.enabled);
assert_eq!(p.appearance.theme, "Nord", "the rest of the file still loads");
for v in ["\"MAINNET\"", "\"a\"", "7", "null", "[]"] {
let p: Prefs = serde_json::from_str(&format!(r#"{{"trading":{{"hl":{{"network":{v}}}}}}}"#)).unwrap();
assert_eq!(p.trading.hl.network, tr_hl::Network::Testnet, "{v}");
}
// Off by default, opt-in switches off, nothing secret in the file.
let d = Prefs::default().trading.hl;
assert!(!d.enabled && !d.dead_mans_switch && !d.remember_on_browser && d.agent.is_none());
let saved = serde_json::to_string(&p).unwrap();
assert!(saved.contains(r#""network":"Testnet""#) && !saved.contains("ainnet"), "{saved}");
}
#[test]
fn v1_prefs_migrate_layout_bindings_to_alt() {
// A v1 file: layouts on ⌘1…, one customised, no panel actions yet.
+1 -297
View File
@@ -11,114 +11,10 @@ use std::path::{Path, PathBuf};
pub use imp::*;
/// Saved documents keyed by path, as the browser keeps them.
#[cfg(any(target_arch = "wasm32", test))]
trait KeyStore {
fn keys(&self) -> Vec<String>;
fn get(&self, key: &str) -> Option<String>;
/// False when the store refuses the write (full or blocked).
fn set(&mut self, key: &str, value: &str) -> bool;
fn remove(&mut self, key: &str);
}
/// Copies every key under `old/` to the same path under `new/`, once. Returns whether to use
/// `new`. Keys already under `new/` are never overwritten, and the old keys stay as they are. A
/// marker key records that the copy finished, so a file deleted afterwards doesn't come back on
/// the next load. If the store refuses a write, the copies made so far are removed and this
/// returns false, so the page keeps its complete old set and the next load tries again.
#[cfg(any(target_arch = "wasm32", test))]
fn copy_keys(store: &mut impl KeyStore, old: &str, new: &str) -> bool {
let marker = marker(new);
if store.get(&marker).is_some() {
return true;
}
let keys: Vec<(String, String)> = store
.keys()
.into_iter()
.filter_map(|k| Some((format!("{new}/{}", k.strip_prefix(old)?.strip_prefix('/')?), k)))
.collect();
if keys.is_empty() {
return true;
}
let mut copied = Vec::new();
let mut ok = true;
for (to, from) in keys {
if store.get(&to).is_some() {
continue;
}
let Some(value) = store.get(&from) else { continue };
if !store.set(&to, &value) {
ok = false;
break;
}
copied.push(to);
}
if ok && store.set(&marker, old) {
tracing::info!("copied {} saved files from {old}/ to {new}/", copied.len());
return true;
}
for k in &copied {
store.remove(k);
}
tracing::warn!("browser storage refused copying saved files from {old}/ to {new}/; using {old}/ for now");
false
}
/// The key [`copy_keys`] sets once everything under `new/` is in place.
#[cfg(any(target_arch = "wasm32", test))]
fn marker(new: &str) -> String {
format!("{new}/.migrated")
}
/// Where a page that kept the old prefix (because [`copy_keys`] failed) should read and write
/// `key`. Once another tab has finished the copy, keys under `old` map to `new`, so this page's
/// saves land where later loads look instead of under keys they no longer read.
#[cfg(any(target_arch = "wasm32", test))]
fn redirect(store: &impl KeyStore, old: &str, new: &str, key: String) -> String {
match key.strip_prefix(old) {
Some(rest) if (rest.is_empty() || rest.starts_with('/')) && store.get(&marker(new)).is_some() => {
format!("{new}{rest}")
}
_ => key,
}
}
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use super::*;
/// Moves the data folder from `old` (its name before the app was renamed) to `new` the first
/// time, and leaves a link at the old path so builds that still use the old name find the
/// same files. Returns the folder to use: `new`, or `old` if it couldn't be moved. Never
/// replaces an existing `new`.
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
if new.exists() || !old.is_dir() {
return new.to_path_buf();
}
if let Err(e) = std::fs::rename(old, new) {
// Another copy of the app may have moved it first.
if new.is_dir() {
return new.to_path_buf();
}
tracing::warn!("could not move {} to {} ({e}); using it where it is", old.display(), new.display());
return old.to_path_buf();
}
tracing::info!("moved {} to {}", old.display(), new.display());
#[cfg(unix)]
{
// Name the sibling rather than repeating `new`, which may be relative (an unset HOME
// or a relative XDG_DATA_HOME) and would then resolve from the link's folder.
let target = match new.file_name() {
Some(name) if old.parent() == new.parent() => PathBuf::from(name),
_ => std::path::absolute(new).unwrap_or_else(|_| new.to_path_buf()),
};
if let Err(e) = std::os::unix::fs::symlink(&target, old) {
tracing::warn!("linking {} to {}: {e}", old.display(), target.display());
}
}
new.to_path_buf()
}
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}
@@ -179,47 +75,8 @@ mod imp {
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "browser storage is unavailable"))
}
thread_local! {
/// The old and new prefixes while this page keeps the old one (see [`migrate`]).
static FALLBACK: std::cell::RefCell<Option<(String, String)>> = const { std::cell::RefCell::new(None) };
}
fn key(path: &Path) -> String {
let key = path.to_string_lossy().into_owned();
let Some((old, new)) = FALLBACK.with_borrow(Clone::clone) else { return key };
match store() {
Ok(s) => redirect(&s, &old, &new, key),
Err(_) => key,
}
}
impl KeyStore for web_sys::Storage {
fn keys(&self) -> Vec<String> {
(0..self.length().unwrap_or(0)).filter_map(|i| self.key(i).ok().flatten()).collect()
}
fn get(&self, key: &str) -> Option<String> {
self.get_item(key).ok().flatten()
}
fn set(&mut self, key: &str, value: &str) -> bool {
self.set_item(key, value).is_ok()
}
fn remove(&mut self, key: &str) {
let _ = self.remove_item(key);
}
}
/// Copies what the page saved under `old/` (its prefix before the app was renamed) to
/// `new/`, once (see [`copy_keys`]). Returns the prefix to use. If the copy fails, the page
/// keeps `old`, and its keys follow [`redirect`] from then on, in case another tab finishes
/// the copy while this one is open.
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
let Ok(mut s) = store() else { return new.to_path_buf() };
let (old, new) = (key(old), key(new));
if copy_keys(&mut s, &old, &new) {
return PathBuf::from(new);
}
FALLBACK.with_borrow_mut(|f| *f = Some((old.clone(), new)));
PathBuf::from(old)
path.to_string_lossy().into_owned()
}
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
@@ -313,157 +170,4 @@ mod tests {
assert!(list(&dir.join("sub")).is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tr-migrate-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn migrate_moves_the_old_folder_and_links_it() {
let root = scratch("move");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("layouts/Desk.json"), b"{}").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("layouts/Desk.json")).unwrap(), b"{}");
#[cfg(unix)]
{
assert_eq!(std::fs::read_link(&old).unwrap(), Path::new("terminal"), "relative to the link's folder");
assert_eq!(read(&old.join("layouts/Desk.json")).unwrap(), b"{}", "old builds still find the files");
}
// Later launches find the new folder and leave everything as it is.
write_atomic(&new.join("settings.json"), b"[]").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("settings.json")).unwrap(), b"[]");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn migrate_never_replaces_an_existing_folder() {
let root = scratch("keep");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("settings.json"), b"old").unwrap();
write_atomic(&new.join("settings.json"), b"new").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("settings.json")).unwrap(), b"new");
assert_eq!(read(&old.join("settings.json")).unwrap(), b"old", "the old folder is left alone");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn migrate_without_an_old_folder_creates_nothing() {
let root = scratch("fresh");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
assert_eq!(migrate(&old, &new), new);
assert!(!old.exists() && !new.exists());
std::fs::remove_dir_all(&root).unwrap();
}
#[cfg(unix)]
#[test]
fn migrate_keeps_using_the_old_folder_when_it_cannot_move() {
use std::os::unix::fs::PermissionsExt;
let root = scratch("stuck");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("settings.json"), b"{}").unwrap();
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).unwrap();
if std::fs::create_dir(root.join("probe")).is_ok() {
// Running as root, where a read-only folder doesn't stop the move.
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
return;
}
let used = migrate(&old, &new);
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(used, old);
assert_eq!(read(&old.join("settings.json")).unwrap(), b"{}");
std::fs::remove_dir_all(&root).unwrap();
}
/// `localStorage` stand-in: a map that refuses writes once it holds `room` keys.
struct Keys {
map: std::collections::BTreeMap<String, String>,
room: usize,
}
impl Keys {
fn new(entries: &[(&str, &str)]) -> Self {
Self { map: entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), room: usize::MAX }
}
}
impl KeyStore for Keys {
fn keys(&self) -> Vec<String> {
self.map.keys().cloned().collect()
}
fn get(&self, key: &str) -> Option<String> {
self.map.get(key).cloned()
}
fn set(&mut self, key: &str, value: &str) -> bool {
if !self.map.contains_key(key) && self.map.len() >= self.room {
return false;
}
self.map.insert(key.into(), value.into());
true
}
fn remove(&mut self, key: &str) {
self.map.remove(key);
}
}
#[test]
fn copy_keys_copies_once_without_overwriting() {
let mut s = Keys::new(&[
("terminal-rs/settings.json", "old settings"),
("terminal-rs/layouts/Desk.json", "desk"),
("terminal/settings.json", "newer settings"),
("other-site-key", "untouched"),
]);
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/settings.json").as_deref(), Some("newer settings"), "newer data wins");
assert_eq!(s.get("terminal/layouts/Desk.json").as_deref(), Some("desk"));
assert_eq!(s.get("terminal-rs/layouts/Desk.json").as_deref(), Some("desk"), "the old keys stay");
assert_eq!(s.get("other-site-key").as_deref(), Some("untouched"));
// A layout deleted after the copy doesn't come back on the next load.
s.remove("terminal/layouts/Desk.json");
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/layouts/Desk.json"), None);
}
#[test]
fn copy_keys_undoes_a_partial_copy() {
let mut s = Keys::new(&[("terminal-rs/a.json", "a"), ("terminal-rs/b.json", "b")]);
s.room = 3;
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"), "storage is full: keep using the old keys");
assert_eq!(s.keys(), ["terminal-rs/a.json", "terminal-rs/b.json"]);
s.room = usize::MAX;
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/b.json").as_deref(), Some("b"));
}
#[test]
fn a_page_left_on_the_old_prefix_follows_another_tabs_copy() {
let mut s = Keys::new(&[("terminal-rs/settings.json", "a")]);
s.room = 1;
// Tab A can't copy, so it keeps the old prefix.
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"));
let key = |s: &Keys, k: &str| redirect(s, "terminal-rs", "terminal", k.into());
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal-rs/settings.json");
// Space frees up and tab B finishes the copy; tab A's saves now go where loads read.
s.room = usize::MAX;
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal/settings.json");
assert_eq!(key(&s, "terminal-rs/layouts"), "terminal/layouts");
assert_eq!(key(&s, "terminal-rs"), "terminal");
assert_eq!(key(&s, "terminal-rsx/a.json"), "terminal-rsx/a.json", "only that prefix");
}
#[test]
fn copy_keys_with_nothing_saved_writes_nothing() {
let mut s = Keys::new(&[]);
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert!(s.keys().is_empty());
}
}
-6
View File
@@ -141,12 +141,6 @@ pub fn accent_wash() -> Color32 {
Color32::from_rgba_unmultiplied(a.r(), a.g(), a.b(), if palette().dark { 70 } else { 45 })
}
/// HIP-3 perp badges and labels: a colour no listed instrument uses, so a perp never reads as
/// the stock, future or FX rate it references.
pub fn perp() -> Color32 {
if palette().dark { Color32::from_rgb(196, 150, 255) } else { Color32::from_rgb(118, 64, 196) }
}
pub fn dem() -> Color32 {
if palette().dark { Color32::from_rgb(64, 156, 255) } else { Color32::from_rgb(0, 102, 214) }
}
-404
View File
@@ -1,404 +0,0 @@
//! The built-in wallet's background work: the desktop sign-in (a loopback listener the
//! browser returns to), turning a Turnkey session into a signed-in wallet, the Turnkey
//! session in the OS secret store, and `approveAgent` signed by the wallet.
//!
//! Turnkey signs only here, and only through `tr_hl::signing::sign_approve_agent_with`, which
//! hands it the digest of a testnet approval and nothing else. Orders never come this way:
//! they are signed by the agent key in `hl_worker`.
use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::atomic::{AtomicU64, Ordering};
use tr_broker::secrets::AgentKeyStore;
use tr_data::rt;
use tr_hl::model::SpotUsdc;
use tr_hl::nonce::now_ms;
use tr_hl::signing::sign_approve_agent_with;
use tr_hl::{AgentKey, SigningKey, trader};
use tr_turnkey::{Session, SessionKey};
use crate::hl_worker::{HlResponse, approve, revoke};
use crate::prefs::HlAgent;
/// The sign-in that may still finish; starting another or cancelling bumps it.
#[cfg(not(target_arch = "wasm32"))]
static SIGN_IN: AtomicU64 = AtomicU64::new(0);
/// Stop waiting for the browser (the listener closes within half a second).
#[cfg(not(target_arch = "wasm32"))]
pub fn cancel_sign_in() {
SIGN_IN.fetch_add(1 << 32, Ordering::SeqCst);
}
pub enum WalletRequest {
/// Desktop: listen on 127.0.0.1, say which port, and wait for the browser's session.
#[cfg(not(target_arch = "wasm32"))]
SignIn { id: u64, store: AgentKeyStore, key: SessionKey, state: String },
/// Web: a session token for `key` from the sign-in page's message. Check it and read the
/// wallet from Turnkey.
#[cfg(target_arch = "wasm32")]
Finish { id: u64, store: AgentKeyStore, key: SessionKey, token: String },
/// The session kept in the secret store, if it hasn't expired.
LoadSession { store: AgentKeyStore, account: String },
/// Forget the session on this device.
EndSession { store: AgentKeyStore, account: String },
/// Approve a fresh agent key, signed by the wallet.
Approve { store: AgentKeyStore, session: Arc<Session>, master: String, device: String },
/// Replace the agent on Hyperliquid (signed by the wallet) and delete its key here.
Revoke { store: AgentKeyStore, session: Arc<Session>, master: String, base: String },
/// Spot USDC for the Deposit window.
Balances { user: String },
}
/// A wallet the user just signed in to.
pub struct SignedIn {
pub session: Session,
/// Lower-case `0x…`.
pub address: String,
pub user: String,
/// `Err`: the session couldn't be kept in the secret store (it lasts until Terminal quits).
pub saved: Result<(), String>,
}
pub enum WalletResponse {
/// The desktop listener is up: open the sign-in page with this port.
#[cfg(not(target_arch = "wasm32"))]
Listening {
id: u64,
port: u16,
},
SignedIn {
id: u64,
result: Result<Box<SignedIn>, String>,
},
SessionLoaded {
account: String,
session: Option<Arc<Session>>,
},
Balances {
user: String,
result: Result<SpotUsdc, String>,
},
/// The wallet didn't sign the approval: nothing was sent, and the new key is dropped.
ApproveNotSigned(String),
/// The wallet didn't sign the revoke, so nothing changed: the agent key is still here.
RevokeNotSigned(String),
}
/// The secret store item for a wallet's Turnkey session.
pub fn session_account(address: &str) -> String {
format!("turnkey:testnet:{}", address.trim().to_ascii_lowercase())
}
fn now_secs() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// Turnkey's API (or the local mock that `TR_TURNKEY_API_URL` names, loopback only).
fn turnkey() -> Result<tr_turnkey::Client, String> {
tr_turnkey::Client::new().map_err(|e| format!("{e:#}"))
}
/// Run one request; answers go through `send`.
pub async fn handle(req: WalletRequest, send: impl Fn(HlResponse) + Clone + 'static) {
let wallet = |r: WalletResponse| HlResponse::Wallet(Box::new(r));
match req {
#[cfg(not(target_arch = "wasm32"))]
WalletRequest::SignIn { id, store, key, state } => {
let this = SIGN_IN.fetch_add(1, Ordering::SeqCst) + 1;
let cb = match tr_turnkey::callback::Callback::bind().await {
Ok(cb) => cb,
Err(e) => return send(wallet(WalletResponse::SignedIn { id, result: Err(format!("{e:#}")) })),
};
send(wallet(WalletResponse::Listening { id, port: cb.port() }));
let cancelled = async {
while SIGN_IN.load(Ordering::SeqCst) == this {
rt::sleep(std::time::Duration::from_millis(400)).await;
}
};
let answer = tokio::select! {
a = cb.wait(&state, tr_turnkey::callback::SIGN_IN_TIMEOUT) => a.map_err(|e| format!("{e:#}")),
_ = cancelled => Err("Sign-in cancelled.".into()),
};
let result = match answer {
Ok(tr_turnkey::callback::Answer::Session(token)) => match turnkey() {
Ok(tk) => finish(&tk, store, key, &token).await,
Err(e) => Err(e),
},
Ok(tr_turnkey::callback::Answer::Failed(e)) => Err(format!("The sign-in page said: {e}")),
Err(e) => Err(e),
};
send(wallet(WalletResponse::SignedIn { id, result }));
}
#[cfg(target_arch = "wasm32")]
WalletRequest::Finish { id, store, key, token } => {
let result = match turnkey() {
Ok(tk) => finish(&tk, store, key, &token).await,
Err(e) => Err(e),
};
send(wallet(WalletResponse::SignedIn { id, result }));
}
WalletRequest::LoadSession { store, account } => {
let acct = account.clone();
let session = rt::blocking(move || {
let text = store.load(&acct).ok().flatten()?;
match Session::from_stored(&text, now_secs()) {
Ok(Some(s)) => Some(Arc::new(s)),
_ => {
// Expired or unreadable: it can't sign anything, so don't keep it.
let _ = store.delete(&acct);
None
}
}
})
.await
.flatten();
send(wallet(WalletResponse::SessionLoaded { account, session }));
}
WalletRequest::EndSession { store, account } => {
let _ = rt::blocking(move || store.delete(&account)).await;
}
WalletRequest::Approve { store, session, master, device } => {
let agent = AgentKey::generate();
let action = trader::approve_agent(&agent.address(), &device, trader::DEFAULT_AGENT_DAYS, now_ms());
let meta = HlAgent {
address: action.agent_address.clone(),
name: action.agent_name.clone(),
valid_until: trader::agent_valid_until(&action.agent_name).unwrap_or(0),
network: tr_hl::Network::Testnet,
};
let key = Arc::new(agent);
let signed = match turnkey() {
Ok(tk) => wallet_sign(&tk, &session, &master, &action).await,
Err(e) => Err(e),
};
let r = match signed {
Ok(body) => match tr_hl::client::Client::new() {
Ok(client) => approve(&client, store, master, key, body, meta).await,
Err(e) => {
HlResponse::Approved { master, agent: meta, key, result: Err(format!("{e:#}")), saved: Ok(()) }
}
},
// The agent key is dropped here; nothing was sent.
Err(e) => wallet(WalletResponse::ApproveNotSigned(e)),
};
send(r);
}
WalletRequest::Revoke { store, session, master, base } => {
let action = trader::revoke_agent(&base, now_ms());
let account = crate::app::hl_store_account(&master);
let signed = match turnkey() {
Ok(tk) => wallet_sign(&tk, &session, &master, &action).await,
Err(e) => Err(e),
};
let r = match (signed, tr_hl::client::Client::new()) {
(Ok(body), Ok(client)) => revoke(&client, store, account, Some(body)).await,
(Err(e), _) => wallet(WalletResponse::RevokeNotSigned(e)),
(_, Err(e)) => wallet(WalletResponse::RevokeNotSigned(format!("{e:#}"))),
};
send(r);
}
WalletRequest::Balances { user } => {
let result = match (tr_hl::client::Client::new(), tr_hl::key::parse_address(&user)) {
(Ok(c), Ok(a)) => c.spot_usdc(&a).await.map_err(|e| format!("{e:#}")),
(Err(e), _) | (_, Err(e)) => Err(format!("{e:#}")),
};
send(wallet(WalletResponse::Balances { user, result }));
}
}
}
/// The wallet signs `action` (a testnet `approveAgent`) through Turnkey: the `/exchange` body.
async fn wallet_sign(
tk: &tr_turnkey::Client,
session: &Session,
master: &str,
action: &tr_hl::actions::ApproveAgentAction,
) -> Result<serde_json::Value, String> {
if session.expired(now_secs()) {
return Err("the wallet session expired; sign in again".into());
}
let main = tr_hl::key::parse_address(master).map_err(|e| format!("{e:#}"))?;
let sig = sign_approve_agent_with(action, &main, |d| async move { tk.sign_digest(session, &main, &d).await })
.await
.map_err(|e| tr_hl::mask_addresses(&format!("{e:#}")))?;
Ok(trader::user_signed_body(action, &sig))
}
/// Accept the session for `key`, read the wallet from Turnkey, and keep the session in the
/// secret store for the rest of its 15 minutes.
async fn finish(
tk: &tr_turnkey::Client,
store: AgentKeyStore,
key: SessionKey,
token: &str,
) -> Result<Box<SignedIn>, String> {
let session = Session::accept(key, token, now_secs()).map_err(|e| format!("{e:#}"))?;
let me = tk.whoami(&session).await.map_err(|e| format!("Turnkey: {e:#}"))?;
let account = tk.ethereum_account(&session).await.map_err(|e| format!("Turnkey: {e:#}"))?;
let address = tr_hl::key::parse_address(&account.address)
.map(|a| tr_hl::key::address_hex(&a))
.map_err(|e| format!("Turnkey's wallet address: {e:#}"))?;
let stored = session.to_stored();
let item = session_account(&address);
let saved = rt::blocking(move || store.save(&item, &stored).map_err(|e| format!("{e:#}")))
.await
.unwrap_or_else(|| Err("the key store task failed".into()));
Ok(Box::new(SignedIn { session, address, user: me.username, saved }))
}
#[cfg(test)]
mod tests {
use tr_hl::signing::recover_approve_agent;
use tr_turnkey::mock::{MockTurnkey, USERNAME};
use super::*;
async fn signed_in(mock: &MockTurnkey, store: &AgentKeyStore) -> Box<SignedIn> {
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 600);
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
finish(&tk, store.clone(), key, &token).await.unwrap()
}
#[tokio::test]
async fn signing_in_finds_the_wallet_and_keeps_the_session_in_the_store() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let store = AgentKeyStore::memory();
let s = signed_in(&mock, &store).await;
assert_eq!(s.address, mock.address());
assert_eq!(s.user, USERNAME);
assert!(s.saved.is_ok());
let kept = store.load(&session_account(&s.address)).unwrap().expect("the session is in the store");
let back = Session::from_stored(&kept, now_secs()).unwrap().unwrap();
assert_eq!(back.key.public_hex(), s.session.key.public_hex());
// A token for another key is refused before Turnkey is asked anything.
let before = mock.requests().len();
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
let theirs = mock.mint_session(&SessionKey::generate().public_hex(), 600);
assert!(finish(&tk, store, SessionKey::generate(), &theirs).await.is_err());
assert_eq!(mock.requests().len(), before);
}
/// The `/exchange` body the wallet's signature goes into is a testnet approval, and the
/// signature recovers the wallet.
#[tokio::test]
async fn the_wallet_signs_a_testnet_approval_that_recovers_it() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let store = AgentKeyStore::memory();
let s = signed_in(&mock, &store).await;
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
let agent = AgentKey::generate();
let action = trader::approve_agent(&agent.address(), "ci", trader::DEFAULT_AGENT_DAYS, now_ms());
let body = wallet_sign(&tk, &s.session, &s.address, &action).await.unwrap();
assert_eq!(body["action"]["type"], "approveAgent");
assert_eq!(body["action"]["hyperliquidChain"], "Testnet");
assert_eq!(body["action"]["signatureChainId"], "0x66eee");
let sig: tr_hl::signing::WireSignature = serde_json::from_value(body["signature"].clone()).unwrap();
assert_eq!(tr_hl::key::address_hex(&recover_approve_agent(&action, &sig).unwrap()), s.address);
// 180 days, the longest Hyperliquid allows, so approvals (paid signatures) are rare.
let until = trader::agent_valid_until(&action.agent_name).unwrap();
assert!(until >= now_ms() + 179 * 86_400_000, "{}", action.agent_name);
}
#[tokio::test]
async fn an_expired_session_asks_turnkey_nothing() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 2);
let session = Session::accept(key, &token, now_secs()).unwrap();
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
let action = trader::approve_agent(&AgentKey::generate().address(), "ci", 180, now_ms());
let late =
Session::accept(SessionKey::from_hex(&session.key.to_hex()).unwrap(), &session.token, now_secs()).unwrap();
// Two seconds later it has expired.
tokio::time::sleep(std::time::Duration::from_millis(2_100)).await;
let err = wallet_sign(&tk, &late, &mock.address(), &action).await.unwrap_err();
assert!(err.contains("expired"), "{err}");
assert!(mock.requests().is_empty());
}
/// An approval the wallet didn't sign is reported as such, not as Hyperliquid's refusal.
#[tokio::test]
async fn an_approval_the_wallet_did_not_sign_says_so() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 1);
let session = Arc::new(Session::accept(key, &token, now_secs()).unwrap());
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
let sink = got.clone();
let req = WalletRequest::Approve {
store: AgentKeyStore::memory(),
session,
master: mock.address(),
device: "ci".into(),
};
handle(req, move |r| {
*sink.lock().unwrap() = Some(match r {
HlResponse::Wallet(w) => match *w {
WalletResponse::ApproveNotSigned(e) => e,
_ => "other".into(),
},
_ => "sent to Hyperliquid".into(),
});
})
.await;
assert!(got.lock().unwrap().as_deref().is_some_and(|e| e.contains("expired")), "{:?}", got.lock().unwrap());
assert!(mock.requests().is_empty());
}
/// A revoke the wallet didn't sign changes nothing: the agent key stays in the store and
/// the app is told so (it keeps the agent until a revoke really happens).
#[tokio::test]
async fn a_revoke_the_wallet_did_not_sign_keeps_the_agent() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let store = AgentKeyStore::memory();
let master = mock.address();
let agent_item = crate::app::hl_store_account(&master);
store.save(&agent_item, &AgentKey::generate().to_hex()).unwrap();
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 1);
let session = Arc::new(Session::accept(key, &token, now_secs()).unwrap());
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
let sink = got.clone();
let req = WalletRequest::Revoke { store: store.clone(), session, master, base: "terminal-ci".into() };
handle(req, move |r| {
*sink.lock().unwrap() = Some(match r {
HlResponse::Wallet(w) if matches!(*w, WalletResponse::RevokeNotSigned(_)) => "not signed",
HlResponse::Revoked(_) => "revoked",
_ => "other",
});
})
.await;
assert_eq!(*got.lock().unwrap(), Some("not signed"));
assert!(store.load(&agent_item).unwrap().is_some(), "the agent key is still here");
assert!(mock.requests().is_empty());
}
#[tokio::test]
async fn an_expired_session_in_the_store_is_deleted_on_load() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let store = AgentKeyStore::memory();
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 1);
let session = Session::accept(key, &token, now_secs()).unwrap();
let account = session_account(&mock.address());
store.save(&account, &session.to_stored()).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
let sink = got.clone();
handle(WalletRequest::LoadSession { store: store.clone(), account: account.clone() }, move |r| {
if let HlResponse::Wallet(w) = r
&& let WalletResponse::SessionLoaded { session, .. } = *w
{
*sink.lock().unwrap() = Some(session.is_some());
}
})
.await;
assert_eq!(*got.lock().unwrap(), Some(false));
assert!(store.load(&account).unwrap().is_none(), "the expired session was deleted");
}
}
+1 -12
View File
@@ -3,10 +3,6 @@
//!
//! Query flags: `?sample` serves offline sample data only (like `TR_PROVIDER=sample`), and
//! `?fps` shows the frame-time readout and repaints continuously (like `TR_FPS`).
//!
//! Crypto markets come from the Terminal server that `TR_SERVER_URL` named when the page was built
//! (scripts/build-web.sh). `?server=off` turns it off, and `?server=ws://localhost:8787/ws` uses a
//! server on this machine instead (see `tr_data::web::server_url`).
use std::cell::Cell;
@@ -23,13 +19,6 @@ pub fn start() {
let query = web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default();
let offline = has_flag(&query, "sample");
let perf = has_flag(&query, "fps");
let requested = web_sys::UrlSearchParams::new_with_str(&query).ok().and_then(|p| p.get("server"));
let server = tr_data::web::server_url(option_env!("TR_SERVER_URL"), requested.as_deref());
if let Some(url) = server.as_deref().filter(|_| !offline) {
tracing::info!(
"crypto markets come from the Terminal server at {url}, or Binance and Coinbase while it's down"
);
}
wasm_bindgen_futures::spawn_local(async move {
let Some(canvas) = canvas() else { return fail("the page has no #terminal canvas") };
let started = eframe::WebRunner::new()
@@ -40,7 +29,7 @@ pub fn start() {
// The first frame needs fonts; set them before it (set_fonts applies from the next pass).
cc.egui_ctx.set_fonts(crate::theme::base_fonts());
wasm_bindgen_futures::spawn_local(load_emoji_fonts(cc.egui_ctx.clone()));
let mut app = crate::create_app(cc, tr_data::for_browser(offline, server.as_deref()), 1, t0)?;
let mut app = crate::create_app(cc, tr_data::for_browser(offline), 1, t0)?;
if perf {
app.show_perf();
}
+50 -415
View File
@@ -34,12 +34,6 @@ pub type PaneId = u64;
/// Minutes between Congress disclosure checks (`TR_CONGRESS_REFRESH_MIN`; 0 disables).
pub const CONGRESS_REFRESH_MIN: u64 = 30;
/// The least time between a chart's reloads after its provider ended the live feed.
const RELOAD_AFTER_FEED_ENDS: Duration = Duration::from_secs(5);
/// How often a chart retries a HIP-3 perp's history while the Terminal server can't serve it.
const PERP_RETRY: Duration = if cfg!(test) { Duration::from_millis(100) } else { Duration::from_secs(10) };
pub enum Request {
/// Load the brokerage portfolio through SnapTrade (`None` = the simulated sample).
Portfolio(Option<tr_broker::Credentials>),
@@ -74,13 +68,6 @@ pub enum Request {
pane: PaneId,
symbol: String,
depth: usize,
/// Echoed in the response, so the pane knows which of its asks was answered.
ask: u64,
},
/// The latest trades, for markets the Terminal server serves.
Trades {
pane: PaneId,
symbol: String,
},
History {
pane: PaneId,
@@ -142,14 +129,8 @@ pub enum Request {
Forget(PaneId),
YieldCurves,
Search(String),
/// The HIP-3 perps the Terminal server lists as live.
PerpMarkets,
/// Order entry (see `app::trading`); only sent once trading is enabled.
Trade(TradeRequest),
/// Hyperliquid testnet: the user stream, reads, the agent key and signed actions.
Hl(crate::hl_worker::HlRequest),
/// The built-in wallet (Turnkey sign-in and approvals).
Wallet(crate::wallet_worker::WalletRequest),
}
/// A trading call through SnapTrade. Previews never place anything; placing takes a preview
@@ -244,14 +225,8 @@ pub enum Response {
OrderBook {
pane: PaneId,
symbol: String,
ask: u64,
book: Result<tr_data::OrderBook, String>,
},
Trades {
pane: PaneId,
symbol: String,
trades: Result<Vec<tr_data::TradePrint>, String>,
},
History {
pane: PaneId,
generation: u64,
@@ -263,9 +238,6 @@ pub enum Response {
prepended: usize,
from_cache: bool,
exhausted: bool,
/// A full load from the provider. Its `exhausted` replaces the chart's, which may have
/// come from the source the chart showed before.
full: bool,
/// A live-feed update of the latest bar.
live: bool,
},
@@ -290,11 +262,7 @@ pub enum Response {
symbol: String,
trades: Vec<Trade>,
},
/// Quotes for the keys `asked`, which may leave some out.
Quotes {
asked: Vec<String>,
quotes: Vec<Quote>,
},
Quotes(Vec<Quote>),
Latest(Vec<Trade>),
TickerTrades {
pane: PaneId,
@@ -319,10 +287,7 @@ pub enum Response {
query: String,
hits: Vec<SearchHit>,
},
/// Live HIP-3 perps (`xyz:AAPL`), or why the server couldn't say.
PerpMarkets(Result<Vec<String>, String>),
Trade(Box<TradeResponse>),
Hl(Box<crate::hl_worker::HlResponse>),
}
pub struct Worker {
@@ -340,9 +305,6 @@ struct Shared {
/// Authoritative bars per chart pane (history, paging and live ticks all merge here),
/// tagged with the pane's load generation.
series: std::sync::Mutex<SeriesMap>,
/// Counts each chart pane's reloads for a changed data source, up once as one starts and again
/// when it's done (odd while it runs), so a page of older bars that spans one is dropped.
sources: std::sync::Mutex<HashMap<PaneId, u64>>,
#[cfg(not(target_arch = "wasm32"))]
cache: std::sync::Mutex<Option<BarCache>>,
db: Mutex<Db>,
@@ -358,30 +320,23 @@ impl Shared {
}
/// Where saved files live. In the browser this is the key prefix in `localStorage` (see
/// [`crate::storage`]). The first call moves files saved under the app's old name, terminal-rs,
/// to the new one ([`crate::storage::migrate`]).
/// [`crate::storage`]).
pub fn data_dir() -> PathBuf {
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
// Unit tests never read, write or move the real folder.
if cfg!(test) {
return std::env::temp_dir().join(format!("tr-test-data-{}", std::process::id()));
}
let parent = if cfg!(target_arch = "wasm32") {
PathBuf::new()
} else if let Some(d) = std::env::var_os("TR_DATA_DIR") {
return PathBuf::from(d);
} else {
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
if cfg!(target_os = "macos") {
home.join("Library/Application Support")
} else {
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".local/share"))
}
};
crate::storage::migrate(&parent.join("terminal-rs"), &parent.join("terminal"))
})
.clone()
if cfg!(target_arch = "wasm32") {
return PathBuf::from("terminal-rs");
}
if let Some(d) = std::env::var_os("TR_DATA_DIR") {
return PathBuf::from(d);
}
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
if cfg!(target_os = "macos") {
home.join("Library/Application Support/terminal-rs")
} else {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".local/share"))
.join("terminal-rs")
}
}
impl Worker {
@@ -405,7 +360,6 @@ impl Worker {
let shared = Arc::new(Shared {
provider,
series: Default::default(),
sources: Default::default(),
cache: std::sync::Mutex::new(cache),
db: Mutex::new(db),
out,
@@ -446,14 +400,8 @@ impl Worker {
pub fn spawn(ctx: egui::Context, provider: Provider) -> anyhow::Result<Self> {
let (tx, mut req_rx) = tokio::sync::mpsc::unbounded_channel::<Request>();
let (out, rx) = mpsc::channel();
let shared = Arc::new(Shared {
provider,
series: Default::default(),
sources: Default::default(),
db: Mutex::new(Db::open_in_memory()?),
out,
ctx,
});
let shared =
Arc::new(Shared { provider, series: Default::default(), db: Mutex::new(Db::open_in_memory()?), out, ctx });
rt::spawn(async move {
while let Some(req) = req_rx.recv().await {
let s = shared.clone();
@@ -616,31 +564,40 @@ async fn handle(s: &Arc<Shared>, req: Request) {
prepended: 0,
from_cache: true,
exhausted: false,
full: false,
live: false,
});
}
match s.provider.history(&symbol, tf, None, limit).await {
Ok(bars) if !bars.is_empty() => loaded(s, pane, generation, symbol, tf, limit, bars).await,
Ok(_) => s.send(Response::HistoryError { pane, generation, error: format!("no data for {symbol}") }),
Err(e) => {
s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") });
if tr_data::is_perp(&symbol) {
retry_perp(s.clone(), pane, generation, symbol, tf, limit);
Ok(bars) if !bars.is_empty() => {
cache_bars(s, &symbol, tf, &bars);
let exhausted = bars.len() < limit / 2;
let bars = Arc::new(bars);
if !store_series(s, pane, generation, bars.clone()) {
return; // superseded while loading
}
let gpu = build_gpu(bars.clone()).await;
s.send(Response::History {
pane,
generation,
symbol: symbol.clone(),
tf,
bars,
gpu,
prepended: 0,
from_cache: false,
exhausted,
live: false,
});
start_live(s.clone(), pane, generation, symbol, tf);
}
Ok(_) => s.send(Response::HistoryError { pane, generation, error: format!("no data for {symbol}") }),
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
}
}
Request::MoreHistory { pane, generation, symbol, tf, existing, limit } => {
// Page from the worker's copy (it may hold newer live bars than the UI's).
let base = current_series(s, pane, generation).filter(|b| !b.is_empty()).unwrap_or(existing);
let Some(first) = base.first().copied() else { return };
// A page that overlaps a reload for another source is dropped. The reload answers the
// pane, and the chart pages again from the new source.
let source = source_switches(s, pane);
if source % 2 == 1 {
return;
}
match s.provider.history(&symbol, tf, Some(first.ts), limit).await {
Ok(older) => {
let older: Vec<Bar> = older.into_iter().filter(|b| b.ts < first.ts).collect();
@@ -649,9 +606,6 @@ async fn handle(s: &Arc<Shared>, req: Request) {
let mut map = s.series.lock().expect("series lock");
let Some((g, cur)) = map.get_mut(&pane).filter(|(g, _)| *g == generation) else { return };
let _ = g;
if source_switches(s, pane) != source {
return;
}
let mut merged = older;
merged.extend_from_slice(cur);
*cur = Arc::new(merged);
@@ -668,14 +622,10 @@ async fn handle(s: &Arc<Shared>, req: Request) {
prepended,
from_cache: false,
exhausted: prepended == 0,
full: false,
live: false,
});
}
Err(e) if source_switches(s, pane) == source => {
s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") })
}
Err(_) => {}
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
}
}
Request::Indicators { pane, generation, bars, tf, specs, palette } => {
@@ -704,11 +654,8 @@ async fn handle(s: &Arc<Shared>, req: Request) {
s.send(Response::Markers { pane, symbol, trades });
}
Request::Quotes(symbols) => match s.provider.quotes(&symbols).await {
Ok(quotes) => s.send(Response::Quotes { asked: symbols, quotes }),
Err(e) => {
s.send(Response::Status(format!("quotes: {e:#}")));
s.send(Response::Quotes { asked: symbols, quotes: Vec::new() });
}
Ok(q) => s.send(Response::Quotes(q)),
Err(e) => s.send(Response::Status(format!("quotes: {e:#}"))),
},
Request::Latest { limit } => {
let t = s.db.lock().await.latest_trades(limit).unwrap_or_default();
@@ -754,10 +701,6 @@ async fn handle(s: &Arc<Shared>, req: Request) {
Request::Forget(pane) => {
s.series.lock().expect("series lock").remove(&pane);
}
Request::PerpMarkets => {
let perps = s.provider.perp_markets().await.map_err(|e| format!("{e:#}"));
s.send(Response::PerpMarkets(perps));
}
Request::YieldCurves => match s.provider.yield_curves().await {
Ok(c) => s.send(Response::Curves(Box::new(c))),
Err(e) => s.send(Response::Status(format!("yield curve: {e:#}"))),
@@ -801,27 +744,15 @@ async fn handle(s: &Arc<Shared>, req: Request) {
let items = s.provider.news(symbol.as_deref()).await.map_err(|e| format!("{e:#}"));
s.send(Response::News { pane, symbol, items });
}
Request::Trades { pane, symbol } => {
let trades = s.provider.trades(&symbol, 40).await.map_err(|e| format!("{e:#}"));
s.send(Response::Trades { pane, symbol, trades });
}
Request::OrderBook { pane, symbol, depth, ask } => {
Request::OrderBook { pane, symbol, depth } => {
let book = s.provider.order_book(&symbol, depth).await.map_err(|e| format!("{e:#}"));
s.send(Response::OrderBook { pane, symbol, ask, book });
s.send(Response::OrderBook { pane, symbol, book });
}
Request::Search(query) => {
let hits = s.provider.search(&query).await.unwrap_or_default();
s.send(Response::SearchHits { query, hits });
}
Request::Trade(t) => s.send(Response::Trade(Box::new(trade(t).await))),
Request::Hl(r) => {
let out = s.clone();
crate::hl_worker::handle(r, move |resp| out.send(Response::Hl(Box::new(resp)))).await
}
Request::Wallet(r) => {
let out = s.clone();
crate::wallet_worker::handle(r, move |resp| out.send(Response::Hl(Box::new(resp)))).await
}
}
}
@@ -890,108 +821,24 @@ fn store_series(s: &Shared, pane: PaneId, generation: u64, bars: Arc<Vec<Bar>>)
}
}
fn source_switches(s: &Shared, pane: PaneId) -> u64 {
s.sources.lock().expect("sources lock").get(&pane).copied().unwrap_or(0)
}
fn current_series(s: &Shared, pane: PaneId, generation: u64) -> Option<Arc<Vec<Bar>>> {
s.series.lock().expect("series lock").get(&pane).filter(|(g, _)| *g == generation).map(|(_, b)| b.clone())
}
/// Merge the provider's live feed (if any) into the pane's series, publishing at most
/// four updates a second. Stops when the pane reloads, closes or the feed ends.
/// What one live bar did to a series.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct Applied {
changed: bool,
/// It went in before the latest bar (a feed filling a gap after an outage), so every
/// series aligned to the bars by index (compare lines) has to be rebuilt.
inserted: bool,
}
fn apply_live_bar(bars: &mut Vec<Bar>, bar: Bar) -> Applied {
let (len, last) = (bars.len(), bars.last().map(|b| b.ts));
let changed = Series::apply_live(bars, bar);
Applied { changed, inserted: bars.len() > len && bars.last().map(|b| b.ts) == last }
}
/// A full load's bars: cache them, show them and start the live feed, unless a newer load for
/// the pane has taken over.
async fn loaded(
s: &Arc<Shared>,
pane: PaneId,
generation: u64,
symbol: String,
tf: Timeframe,
limit: usize,
bars: Vec<Bar>,
) {
cache_bars(s, &symbol, tf, &bars);
let exhausted = bars.len() < limit / 2;
let bars = Arc::new(bars);
if !store_series(s, pane, generation, bars.clone()) {
return; // superseded while loading
}
let gpu = build_gpu(bars.clone()).await;
s.send(Response::History {
pane,
generation,
symbol: symbol.clone(),
tf,
bars,
gpu,
prepended: 0,
from_cache: false,
exhausted,
full: true,
live: false,
});
start_live(s.clone(), pane, generation, symbol, tf, limit);
}
/// A HIP-3 perp has no source but the Terminal server, so nothing else picks up its chart when
/// the server is away. Ask again every [`PERP_RETRY`] while the pane still shows this load; the
/// chart keeps showing the first failure (or its cached bars) until one works.
fn retry_perp(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe, limit: usize) {
rt::spawn(async move {
loop {
rt::sleep(PERP_RETRY).await;
if current_series(&s, pane, generation).is_none() {
return;
}
match s.provider.history(&symbol, tf, None, limit).await {
Ok(bars) if !bars.is_empty() => return loaded(&s, pane, generation, symbol, tf, limit, bars).await,
Ok(_) => {
return s.send(Response::HistoryError { pane, generation, error: format!("no data for {symbol}") });
}
Err(e) => tracing::debug!("{symbol} history, still failing: {e:#}"),
}
}
});
}
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe, limit: usize) {
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe) {
let Some(mut feed) = s.provider.live(&symbol, tf) else { return };
rt::spawn(async move {
let started = web_time::Instant::now();
let mut tick = rt::interval(Duration::from_millis(250));
let mut dirty = false;
let mut inserted = false;
let mut ended = false;
loop {
tokio::select! {
bar = feed.recv() => {
let Some(bar) = bar else {
ended = true;
break;
};
let Some(bar) = bar else { break };
let mut map = s.series.lock().expect("series lock");
match map.get_mut(&pane) {
Some((g, bars)) if *g == generation => {
let a = apply_live_bar(Arc::make_mut(bars), bar);
dirty |= a.changed;
inserted |= a.inserted;
}
Some((g, bars)) if *g == generation => dirty |= Series::apply_live(Arc::make_mut(bars), bar),
_ => break,
}
}
@@ -1015,26 +862,12 @@ fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf:
prepended: 0,
from_cache: false,
exhausted: false,
full: false,
// A bar slotted into a gap shifts everything after it: send it as a
// refresh so the chart rebuilds its compare line and indicators.
live: !std::mem::take(&mut inserted),
live: true,
});
}
}
}
tracing::debug!("live feed for pane {pane} ({symbol}) stopped");
// A provider ends a feed the pane still shows when the chart's source changes (the Terminal
// server went away or came back): reload the chart from wherever its data comes from now.
if ended {
rt::sleep(RELOAD_AFTER_FEED_ENDS.saturating_sub(started.elapsed())).await;
if current_series(&s, pane, generation).is_some() {
let switch = || *s.sources.lock().expect("sources lock").entry(pane).or_default() += 1;
switch();
handle(&s, Request::History { pane, generation, symbol, tf, limit }).await;
switch();
}
}
});
}
@@ -1110,20 +943,6 @@ where
mod tests {
use super::*;
#[test]
fn bars_filling_a_gap_are_reported_as_inserted() {
let b = |ts: i64| Bar::new(ts, 1.0, 1.0, 1.0, 1.0, 1.0);
let mut bars = vec![b(0), b(60), b(240)];
assert_eq!(apply_live_bar(&mut bars, b(300)), Applied { changed: true, inserted: false });
assert_eq!(apply_live_bar(&mut bars, b(300)), Applied { changed: false, inserted: false });
assert_eq!(apply_live_bar(&mut bars, b(120)), Applied { changed: true, inserted: true });
assert_eq!(
apply_live_bar(&mut bars, Bar::new(60, 1.0, 2.0, 1.0, 2.0, 1.0)),
Applied { changed: true, inserted: false }
);
assert_eq!(bars.iter().map(|x| x.ts).collect::<Vec<_>>(), [0, 60, 120, 240, 300]);
}
#[test]
fn compare_alignment_carries_last_close() {
let base: Vec<Bar> = (0..5).map(|i| Bar::new(i * 10, 100.0, 100.0, 100.0, 100.0 + i as f64, 1.0)).collect();
@@ -1160,7 +979,6 @@ mod tests {
let s = Shared {
provider: std::sync::Arc::new(tr_data::sample::SampleProvider::at(0, 10)),
series: Default::default(),
sources: Default::default(),
cache: std::sync::Mutex::new(None),
db: Mutex::new(Db::open_in_memory().unwrap()),
out,
@@ -1175,189 +993,6 @@ mod tests {
assert!(current_series(&s, 1, 1).is_none());
}
/// Bars whose close counts the loads. Reloads wait for `reload`, and older pages for `page`.
/// The first chart's live feed ends at once, the way `RemoteMarkets` ends one when the chart's
/// source changes.
#[derive(Default)]
struct SourceChanges {
loads: std::sync::atomic::AtomicUsize,
open: std::sync::Mutex<Vec<tokio::sync::mpsc::Sender<Bar>>>,
reload: tokio::sync::Notify,
page: tokio::sync::Notify,
}
#[async_trait::async_trait]
impl tr_data::MarketData for SourceChanges {
fn name(&self) -> &str {
"test"
}
async fn history(&self, _: &str, _: Timeframe, end: Option<i64>, _: usize) -> anyhow::Result<Vec<Bar>> {
if end.is_some() {
self.page.notified().await;
return Ok(vec![Bar::new(-60, 1.0, 1.0, 1.0, 1.0, 1.0)]);
}
let n = self.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
if n > 1 {
self.reload.notified().await;
}
Ok(vec![Bar::new(0, 1.0, 1.0, 1.0, n as f64, 1.0)])
}
fn live(&self, _: &str, _: Timeframe) -> Option<tr_data::LiveFeed> {
let (tx, rx) = tokio::sync::mpsc::channel(1);
if self.loads.load(std::sync::atomic::Ordering::SeqCst) > 1 {
self.open.lock().unwrap().push(tx);
}
Some(rx)
}
}
/// A server that's down for the first `down` history requests.
struct ServerBack {
down: usize,
loads: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl tr_data::MarketData for ServerBack {
fn name(&self) -> &str {
"server"
}
async fn history(&self, _: &str, _: Timeframe, _: Option<i64>, _: usize) -> anyhow::Result<Vec<Bar>> {
let n = self.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
anyhow::ensure!(n > self.down, "the Terminal server is not reachable");
Ok(vec![Bar::new(0, 1.0, 1.0, 1.0, 7.0, 1.0)])
}
}
/// A perp has no other source, so a chart whose first load failed keeps asking while it
/// shows the perp, and loads once the server answers. It stops once the pane moves on.
fn worker_on(provider: tr_data::Provider) -> (Arc<Shared>, mpsc::Receiver<Response>) {
let (out, rx) = mpsc::channel();
let s = Shared {
provider,
series: Default::default(),
sources: Default::default(),
cache: std::sync::Mutex::new(None),
db: Mutex::new(Db::open_in_memory().unwrap()),
out,
ctx: egui::Context::default(),
};
(Arc::new(s), rx)
}
#[tokio::test]
async fn perp_charts_retry_until_the_server_answers() {
let provider = Arc::new(ServerBack { down: 2, loads: Default::default() });
let (s, rx) = worker_on(provider.clone());
let load = |symbol: &str| Request::History {
pane: 1,
generation: 1,
symbol: symbol.into(),
tf: Timeframe::D1,
limit: 10,
};
handle(&s, load("xyz:AAPL")).await;
let t0 = std::time::Instant::now();
let mut errors = 0;
let bars = loop {
assert!(t0.elapsed() < Duration::from_secs(10), "no retry after {errors} errors");
match rx.try_recv() {
Ok(Response::HistoryError { .. }) => errors += 1,
Ok(Response::History { bars, full: true, .. }) => break bars,
Ok(_) => {}
Err(_) => tokio::time::sleep(Duration::from_millis(20)).await,
}
};
assert_eq!((errors, bars.last().map(|b| b.close)), (1, Some(7.0)), "one error shown, then the bars");
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 3);
// A chart the user moved on from isn't retried.
let provider = Arc::new(ServerBack { down: usize::MAX, loads: Default::default() });
let (s, _rx) = worker_on(provider.clone());
handle(&s, load("xyz:AAPL")).await;
assert!(claim_series(&s, 1, 2), "the pane loads something else");
tokio::time::sleep(PERP_RETRY * 3).await;
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 1, "no retries for an old load");
}
/// A worker on `SourceChanges` with pane 1 loading `BTCUSD Curncy`, and its responses.
async fn switching_chart() -> (Arc<Shared>, mpsc::Receiver<Response>, Arc<SourceChanges>) {
let (out, rx) = mpsc::channel();
let provider = Arc::new(SourceChanges::default());
let s = Arc::new(Shared {
provider: provider.clone(),
series: Default::default(),
sources: Default::default(),
cache: std::sync::Mutex::new(None),
db: Mutex::new(Db::open_in_memory().unwrap()),
out,
ctx: egui::Context::default(),
});
let symbol = "BTCUSD Curncy".to_string();
handle(&s, Request::History { pane: 1, generation: 1, symbol, tf: Timeframe::D1, limit: 10 }).await;
(s, rx, provider)
}
/// The last close of each chart update, until there are `n`.
async fn closes(rx: &mpsc::Receiver<Response>, n: usize) -> Vec<f64> {
let t0 = std::time::Instant::now();
let mut closes = Vec::new();
while closes.len() < n {
assert!(t0.elapsed() < Duration::from_secs(15), "no reload: {closes:?}");
match rx.try_recv() {
Ok(Response::History { generation: 1, bars, .. }) => closes.push(bars.last().unwrap().close),
Ok(_) => {}
Err(_) => tokio::time::sleep(Duration::from_millis(50)).await,
}
}
closes
}
#[tokio::test]
async fn charts_reload_when_the_provider_ends_their_live_feed() {
let t0 = std::time::Instant::now();
let (_s, rx, provider) = switching_chart().await;
provider.reload.notify_one();
assert_eq!(closes(&rx, 2).await, [1.0, 2.0], "the reload replaced the chart");
assert!(t0.elapsed() >= RELOAD_AFTER_FEED_ENDS, "not before the pause");
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn pages_that_overlap_a_source_switch_are_dropped() {
let (s, rx, provider) = switching_chart().await;
let more = || Request::MoreHistory {
pane: 1,
generation: 1,
symbol: "BTCUSD Curncy".into(),
tf: Timeframe::D1,
existing: Arc::new(Vec::new()),
limit: 10,
};
// One page starts before the switch, and its bars arrive after it.
let before = tokio::spawn({
let (s, req) = (s.clone(), more());
async move { handle(&s, req).await }
});
let t0 = std::time::Instant::now();
while source_switches(&s, 1) != 1 {
assert!(t0.elapsed() < Duration::from_secs(15), "no reload");
tokio::time::sleep(Duration::from_millis(20)).await;
}
// Another starts while the chart reloads, and gives up at once.
let during = tokio::time::timeout(Duration::from_secs(1), handle(&s, more())).await;
assert!(during.is_ok(), "a page started during the reload went ahead");
provider.reload.notify_one();
assert_eq!(closes(&rx, 2).await, [1.0, 2.0]);
provider.page.notify_one();
before.await.unwrap();
let ts: Vec<i64> = current_series(&s, 1, 1).unwrap().iter().map(|b| b.ts).collect();
assert_eq!(ts, [0], "the old source's page joined the new source's bars");
assert!(rx.try_iter().all(|r| !matches!(r, Response::History { prepended: 1.., .. })));
}
#[test]
fn join_all_runs_everything() {
let rt = tokio::runtime::Builder::new_current_thread().enable_time().build().unwrap();
-5
View File
@@ -16,15 +16,10 @@ futures = "0.3"
hmac = "0.12"
sha2 = "0.10"
base64 = "0.23"
zeroize = "1"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
# Hyperliquid agent keys on Linux: the Secret Service over D-Bus (pure Rust, no libdbus).
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", default-features = false, features = ["async-secret-service", "tokio", "crypto-rust"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio.workspace = true
-3
View File
@@ -342,7 +342,6 @@ impl DemoBook {
placed: Some(now),
updated: Some(now),
underlying: None,
market: None,
};
let id = order.id.clone();
self.orders.insert(0, order);
@@ -523,7 +522,6 @@ impl DemoBook {
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
market: None,
}),
}
} else {
@@ -563,7 +561,6 @@ impl DemoBook {
currency: "USD".into(),
trade_date: Some(now),
settlement_date: Some(now),
market: None,
},
);
Ok(())
+5 -88
View File
@@ -46,7 +46,7 @@ pub struct Connection {
pub disabled: bool,
pub created: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
/// Whether the connection allows trading (Terminal only trades when the user enables it).
/// Whether the connection allows trading (terminal-rs only trades when the user enables it).
pub trading: bool,
}
@@ -127,10 +127,6 @@ pub struct Position {
pub cash_equivalent: bool,
/// For options: the underlying ticker.
pub underlying: Option<String>,
/// The terminal market key the position prices and charts by, when the source names one
/// exactly (`xyz:AAPL` for a Hyperliquid HIP-3 perp). It wins over the kind-based mapping.
#[serde(default)]
pub market: Option<String>,
}
impl Position {
@@ -157,13 +153,9 @@ impl Position {
(cost.abs() > 1e-9).then(|| self.pnl_at(price).map(|p| p / cost.abs() * 100.0)).flatten()
}
/// The terminal market key for charting, quotes and Congress lookups: [`Self::market`]
/// when set, else the kind-based mapping (US-listed equities and ETFs, crypto, option
/// underlyings).
/// The terminal market key for charting, quotes and Congress lookups, when the
/// instrument maps onto one (US-listed equities and ETFs, crypto, option underlyings).
pub fn market_key(&self) -> Option<String> {
if let Some(m) = &self.market {
return Some(m.clone());
}
market_key(self.kind, self.underlying.as_deref().unwrap_or(&self.symbol), &self.currency)
}
@@ -269,9 +261,6 @@ pub struct Order {
pub placed: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
pub underlying: Option<String>,
/// As [`Position::market`].
#[serde(default)]
pub market: Option<String>,
}
impl Order {
@@ -296,9 +285,6 @@ impl Order {
}
pub fn market_key(&self) -> Option<String> {
if let Some(m) = &self.market {
return Some(m.clone());
}
market_key(self.kind, self.underlying.as_deref().unwrap_or(&self.symbol), "USD")
}
@@ -322,16 +308,10 @@ pub struct Activity {
pub currency: String,
pub trade_date: Option<DateTime<Utc>>,
pub settlement_date: Option<DateTime<Utc>>,
/// As [`Position::market`].
#[serde(default)]
pub market: Option<String>,
}
impl Activity {
pub fn market_key(&self) -> Option<String> {
if let Some(m) = &self.market {
return Some(m.clone());
}
market_key(InstrumentKind::Stock, self.symbol.as_deref()?, &self.currency)
}
}
@@ -392,16 +372,9 @@ impl Portfolio {
t.cash += a.cash.unwrap_or(0.0);
t.buying_power += a.buying_power.unwrap_or(0.0);
}
// Futures (Hyperliquid perps) are margined: the account's cash backs them, so their value
// is their profit or loss, never their notional.
let mut futures_pnl = 0.0;
for p in self.positions.iter().filter(|p| selected(&p.account_id) && !p.cash_equivalent) {
let live = price_of(p);
if p.kind == InstrumentKind::Future {
futures_pnl += p.pnl_at(live).unwrap_or(0.0);
} else {
t.invested += p.market_value_at(live).unwrap_or(0.0);
}
t.invested += p.market_value_at(live).unwrap_or(0.0);
if let (Some(cost), Some(pnl)) = (p.cost(), p.pnl_at(live)) {
t.cost += cost;
t.pnl += pnl;
@@ -419,7 +392,7 @@ impl Portfolio {
t.value = if reported.len() == n && n > 0 && price_of_is_trivial(&self.positions, &price_of) {
reported.iter().sum()
} else {
t.cash + t.invested + futures_pnl + cash_like
t.cash + t.invested + cash_like
};
t
}
@@ -565,7 +538,6 @@ pub fn parse_positions(account_id: &str, v: &Value) -> Vec<Position> {
.unwrap_or_else(|| text(&inst["currency"])),
cash_equivalent: p["cash_equivalent"].as_bool().unwrap_or(false),
underlying,
market: None,
})
})
.collect()
@@ -625,7 +597,6 @@ pub fn parse_orders(account_id: &str, v: &Value) -> Vec<Order> {
placed: time(&o["time_placed"]),
updated: time(&o["time_updated"]).or_else(|| time(&o["time_executed"])),
underlying,
market: None,
})
})
.collect()
@@ -652,7 +623,6 @@ pub fn parse_activities(account_id: &str, v: &Value) -> Vec<Activity> {
currency: Some(text(&a["currency"]["code"])).filter(|c| !c.is_empty()).unwrap_or("USD".into()),
trade_date: time(&a["trade_date"]),
settlement_date: time(&a["settlement_date"]),
market: None,
}
})
.collect()
@@ -675,7 +645,6 @@ mod tests {
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
market: None,
}
}
@@ -717,24 +686,6 @@ mod tests {
assert_eq!(opt.market_key().as_deref(), Some("AAPL US Equity"), "options chart their underlying");
}
#[test]
fn an_exact_market_key_wins_over_the_kind() {
// A Hyperliquid HIP-3 perp: a future whose key the source names exactly.
let mut perp = pos(InstrumentKind::Future, "xyz:AAPL", -0.5, 320.0, 300.0);
assert_eq!(perp.market_key(), None, "futures have no key of their own");
perp.market = Some("xyz:AAPL".into());
assert_eq!(perp.market_key().as_deref(), Some("xyz:AAPL"), "never AAPL US Equity");
let mut btc = pos(InstrumentKind::Future, "BTC", 0.01, 76_000.0, 70_000.0);
btc.market = Some("BTCUSD Curncy".into());
assert_eq!(btc.market_key().as_deref(), Some("BTCUSD Curncy"));
// SnapTrade rows have none and keep today's mapping.
assert_eq!(pos(InstrumentKind::Stock, "AAPL", 1.0, 1.0, 1.0).market_key().as_deref(), Some("AAPL US Equity"));
// Saved data from before the field existed still loads.
let mut v = serde_json::to_value(pos(InstrumentKind::Stock, "AAPL", 1.0, 1.0, 1.0)).unwrap();
v.as_object_mut().unwrap().remove("market");
assert_eq!(serde_json::from_value::<Position>(v).unwrap().market, None);
}
#[test]
fn totals_reprice_with_live_quotes() {
let mut pf = Portfolio {
@@ -773,40 +724,6 @@ mod tests {
assert_eq!(pf.totals(Some("other"), |_| None), Totals::default());
}
/// Greptile #37: a perp is margined, so repricing it moves equity by its P&L, never by its
/// notional. 10 long at 190 marked 200 (P&L 100) in an account worth 1,000.
#[test]
fn futures_reprice_by_pnl_not_notional() {
let account = |cash: f64| Account {
id: "hl".into(),
name: "Hyperliquid".into(),
number: String::new(),
institution: "Hyperliquid".into(),
currency: "USD".into(),
total: Some(1000.0),
cash: Some(cash),
buying_power: Some(200.0),
kind: "Perps".into(),
status: String::new(),
is_paper: true,
synced: None,
connection_id: String::new(),
};
let mut long = pos(InstrumentKind::Future, "BTC", 10.0, 200.0, 190.0);
long.account_id = "hl".into();
// Cash is the collateral: equity less unrealised P&L.
let pf = Portfolio { accounts: vec![account(900.0)], positions: vec![long.clone()], ..Default::default() };
assert_eq!(pf.totals(None, |_| None).value, 1000.0, "reported equity");
assert_eq!(pf.totals(None, |_| Some(200.0)).value, 1000.0, "the same price leaves equity alone");
assert_eq!(pf.totals(None, |_| Some(210.0)).value, 1100.0, "+10 × 10");
assert_eq!(pf.totals(None, |_| Some(200.0)).invested, 0.0, "perp notional is not invested cash");
let mut short = pos(InstrumentKind::Future, "BTC", -10.0, 200.0, 210.0);
short.account_id = "hl".into();
let pf = Portfolio { accounts: vec![account(900.0)], positions: vec![short], ..Default::default() };
assert_eq!(pf.totals(None, |_| Some(200.0)).value, 1000.0);
assert_eq!(pf.totals(None, |_| Some(190.0)).value, 1100.0, "a short gains as the price falls");
}
#[test]
fn occ_symbols() {
let o = parse_occ("NVDA 261218C00250000").unwrap();
-3
View File
@@ -46,7 +46,6 @@ fn position(
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
market: None,
}
}
@@ -121,7 +120,6 @@ pub fn portfolio() -> Portfolio {
placed: Some(at(days, 14)),
updated: Some(at(days, 15)),
underlying: None,
market: None,
};
let orders = vec![
order("sim-brokerage", "sim-o-1", "NVDA", K::Stock, "BUY", "PENDING", "Limit", 10.0, Some(205.00), None, 0),
@@ -164,7 +162,6 @@ pub fn portfolio() -> Portfolio {
currency: "USD".into(),
trade_date: Some(at(days, 14)),
settlement_date: Some(at(days - 1, 0)),
market: None,
};
let activities = vec![
act(
+20 -321
View File
@@ -2,12 +2,9 @@
//!
//! The partner keys (`clientId`, `consumerKey`) and the per-user `userId`/`userSecret` are
//! stored together as one small JSON document: in the macOS Keychain (generic password,
//! service `terminal`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
//! service `terminal-rs`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
//! the app's data directory. `SNAPTRADE_*` environment variables override stored values
//! field by field. Nothing is ever written inside the repository.
//!
//! Before the app was renamed from terminal-rs, the Keychain service was `terminal-rs`; the
//! first load moves credentials from there.
use std::fmt;
use std::path::{Path, PathBuf};
@@ -15,9 +12,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const KEYCHAIN_SERVICE: &str = "terminal";
/// The service credentials were saved under before the rename.
pub const LEGACY_KEYCHAIN_SERVICE: &str = "terminal-rs";
pub const KEYCHAIN_SERVICE: &str = "terminal-rs";
pub const KEYCHAIN_ACCOUNT: &str = "snaptrade";
/// Environment variables that override stored credentials.
@@ -107,8 +102,8 @@ pub fn mask(secret: &str) -> String {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Backend {
/// macOS Keychain generic password under `service`, moved there from `legacy` on first load.
Keychain { service: String, legacy: Option<String> },
/// macOS Keychain generic password.
Keychain,
/// Owner-only JSON file.
File(PathBuf),
}
@@ -124,19 +119,10 @@ impl SecretStore {
pub fn platform(data_dir: &Path) -> Self {
let file = Backend::File(data_dir.join("secrets").join("snaptrade.json"));
let force_file = std::env::var("TR_SECRET_STORE").is_ok_and(|v| v.eq_ignore_ascii_case("file"));
let backend = if cfg!(target_os = "macos") && !force_file {
Backend::Keychain { service: KEYCHAIN_SERVICE.into(), legacy: Some(LEGACY_KEYCHAIN_SERVICE.into()) }
} else {
file
};
let backend = if cfg!(target_os = "macos") && !force_file { Backend::Keychain } else { file };
Self { backend }
}
/// The Keychain under `service`, taking over an item left under `legacy`.
pub fn keychain(service: &str, legacy: Option<&str>) -> Self {
Self { backend: Backend::Keychain { service: service.into(), legacy: legacy.map(Into::into) } }
}
pub fn file(path: impl Into<PathBuf>) -> Self {
Self { backend: Backend::File(path.into()) }
}
@@ -144,14 +130,14 @@ impl SecretStore {
/// Where credentials live, for the settings screen.
pub fn describe(&self) -> String {
match &self.backend {
Backend::Keychain { service, .. } => format!("macOS Keychain ({service} / {KEYCHAIN_ACCOUNT})"),
Backend::Keychain => format!("macOS Keychain ({KEYCHAIN_SERVICE} / {KEYCHAIN_ACCOUNT})"),
Backend::File(p) => format!("{} (owner-only)", p.display()),
}
}
pub fn load(&self) -> Result<Option<Credentials>> {
let text = match &self.backend {
Backend::Keychain { service, legacy } => load_keychain(service, legacy.as_deref())?,
Backend::Keychain => keychain::get()?,
Backend::File(p) => match std::fs::read_to_string(p) {
Ok(t) => Some(t),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
@@ -169,7 +155,7 @@ impl SecretStore {
pub fn save(&self, creds: &Credentials) -> Result<()> {
let json = serde_json::to_string(&creds.trimmed())?;
match &self.backend {
Backend::Keychain { service, .. } => keychain::set(service, &json),
Backend::Keychain => keychain::set(&json),
Backend::File(p) => write_private(p, &json),
}
}
@@ -177,11 +163,7 @@ impl SecretStore {
/// Remove stored credentials (environment variables are unaffected).
pub fn clear(&self) -> Result<()> {
match &self.backend {
Backend::Keychain { service, legacy } => {
keychain::delete(service)?;
// The old item too, or the credentials would come back from it on the next load.
legacy.as_deref().map_or(Ok(()), keychain::delete)
}
Backend::Keychain => keychain::delete(),
Backend::File(p) => match std::fs::remove_file(p) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
@@ -190,24 +172,6 @@ impl SecretStore {
}
}
/// Reads the Keychain item under `service`. If there is none, the item under `legacy` moves
/// there: it is deleted only after the copy is written, so a failed move leaves it in place.
fn load_keychain(service: &str, legacy: Option<&str>) -> Result<Option<String>> {
if let Some(text) = keychain::get(service)? {
return Ok(Some(text));
}
let Some(legacy) = legacy else { return Ok(None) };
let Some(text) = keychain::get(legacy)? else { return Ok(None) };
match keychain::set(service, &text) {
Ok(()) => match keychain::delete(legacy) {
Ok(()) => tracing::info!("moved SnapTrade credentials from Keychain service {legacy} to {service}"),
Err(e) => tracing::warn!("removing SnapTrade credentials from Keychain service {legacy}: {e:#}"),
},
Err(e) => tracing::warn!("moving SnapTrade credentials to Keychain service {service}: {e:#}"),
}
Ok(Some(text))
}
/// Write `text` to `path` readable only by the owner (0700 directory, 0600 file), replacing it
/// atomically.
fn write_private(path: &Path, text: &str) -> Result<()> {
@@ -240,38 +204,26 @@ mod keychain {
use anyhow::{Context, Result};
use security_framework::passwords::{delete_generic_password, get_generic_password, set_generic_password};
use super::KEYCHAIN_ACCOUNT;
use super::{KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
/// `errSecItemNotFound`.
const NOT_FOUND: i32 = -25300;
pub fn get(service: &str) -> Result<Option<String>> {
get_item(service, KEYCHAIN_ACCOUNT)
}
pub fn set(service: &str, value: &str) -> Result<()> {
set_item(service, KEYCHAIN_ACCOUNT, value)
}
pub fn delete(service: &str) -> Result<()> {
delete_item(service, KEYCHAIN_ACCOUNT)
}
pub fn get_item(service: &str, account: &str) -> Result<Option<String>> {
match get_generic_password(service, account) {
pub fn get() -> Result<Option<String>> {
match get_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
Ok(bytes) => Ok(Some(String::from_utf8(bytes).context("Keychain item is not UTF-8")?)),
Err(e) if e.code() == NOT_FOUND => Ok(None),
Err(e) => Err(anyhow::anyhow!("reading the Keychain: {e}")),
}
}
pub fn set_item(service: &str, account: &str, value: &str) -> Result<()> {
set_generic_password(service, account, value.as_bytes())
pub fn set(value: &str) -> Result<()> {
set_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, value.as_bytes())
.map_err(|e| anyhow::anyhow!("writing the Keychain: {e}"))
}
pub fn delete_item(service: &str, account: &str) -> Result<()> {
match delete_generic_password(service, account) {
pub fn delete() -> Result<()> {
match delete_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
Err(e) if e.code() != NOT_FOUND => Err(anyhow::anyhow!("deleting from the Keychain: {e}")),
_ => Ok(()),
}
@@ -282,203 +234,15 @@ mod keychain {
mod keychain {
use anyhow::{Result, bail};
pub fn get(_: &str) -> Result<Option<String>> {
pub fn get() -> Result<Option<String>> {
bail!("the Keychain is only available on macOS")
}
pub fn set(_: &str, _: &str) -> Result<()> {
pub fn set(_: &str) -> Result<()> {
bail!("the Keychain is only available on macOS")
}
pub fn delete(_: &str) -> Result<()> {
pub fn delete() -> Result<()> {
bail!("the Keychain is only available on macOS")
}
#[allow(dead_code)]
pub fn get_item(_: &str, _: &str) -> Result<Option<String>> {
bail!("the Keychain is only available on macOS")
}
#[allow(dead_code)]
pub fn set_item(_: &str, _: &str, _: &str) -> Result<()> {
bail!("the Keychain is only available on macOS")
}
#[allow(dead_code)]
pub fn delete_item(_: &str, _: &str) -> Result<()> {
bail!("the Keychain is only available on macOS")
}
}
// ---- Hyperliquid agent keys ----------------------------------------------------------------
/// The Keychain service (and Secret Service attribute) for Hyperliquid agent keys, apart
/// from the SnapTrade item.
pub const HL_KEYCHAIN_SERVICE: &str = "terminal.hyperliquid";
/// Where Hyperliquid agent (API wallet) keys live: the macOS Keychain, the Linux Secret
/// Service, or memory (the browser's session-only keys). Never a file: without a system
/// secret store the key has nowhere safe to go, so Hyperliquid trading stays off.
///
/// Items are named by account, `testnet:<master address>`. Values are the key's hex; this
/// type hands them back in buffers that are wiped when dropped, and never logs them.
#[derive(Clone)]
pub struct AgentKeyStore {
backend: KeyBackend,
}
#[derive(Clone)]
enum KeyBackend {
Keychain,
SecretService,
Memory(std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, zeroize::Zeroizing<String>>>>),
Unavailable(String),
}
impl fmt::Debug for AgentKeyStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.describe())
}
}
impl AgentKeyStore {
/// The system store for this platform, or why there is none.
pub fn platform() -> Self {
let backend = if cfg!(target_os = "macos") {
KeyBackend::Keychain
} else if cfg!(target_os = "linux") {
KeyBackend::SecretService
} else {
KeyBackend::Unavailable(
"Hyperliquid trading keys need the macOS Keychain or a Linux Secret Service, and this platform has neither in this build."
.into(),
)
};
Self { backend }
}
/// Keys held only while the app runs (lost on quit or reload).
pub fn memory() -> Self {
Self { backend: KeyBackend::Memory(Default::default()) }
}
pub fn unavailable(why: impl Into<String>) -> Self {
Self { backend: KeyBackend::Unavailable(why.into()) }
}
pub fn describe(&self) -> String {
match &self.backend {
KeyBackend::Keychain => format!("macOS Keychain ({HL_KEYCHAIN_SERVICE})"),
KeyBackend::SecretService => format!("the Secret Service keyring ({HL_KEYCHAIN_SERVICE})"),
KeyBackend::Memory(_) => "memory only (gone when Terminal closes)".into(),
KeyBackend::Unavailable(_) => "no secret store".into(),
}
}
pub fn is_memory(&self) -> bool {
matches!(self.backend, KeyBackend::Memory(_))
}
/// Whether keys can be stored, and why not. On Linux this asks the Secret Service, so
/// call it off the UI thread.
pub fn check(&self) -> Result<(), String> {
match &self.backend {
KeyBackend::Unavailable(why) => Err(why.clone()),
KeyBackend::SecretService => secret_service::probe(),
_ => Ok(()),
}
}
pub fn load(&self, account: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
match &self.backend {
KeyBackend::Keychain => Ok(keychain::get_item(HL_KEYCHAIN_SERVICE, account)?.map(zeroize::Zeroizing::new)),
KeyBackend::SecretService => secret_service::get(account),
KeyBackend::Memory(m) => Ok(m.lock().expect("key store").get(account).cloned()),
KeyBackend::Unavailable(why) => anyhow::bail!("{why}"),
}
}
pub fn save(&self, account: &str, secret: &str) -> Result<()> {
match &self.backend {
KeyBackend::Keychain => keychain::set_item(HL_KEYCHAIN_SERVICE, account, secret),
KeyBackend::SecretService => secret_service::set(account, secret),
KeyBackend::Memory(m) => {
m.lock().expect("key store").insert(account.into(), zeroize::Zeroizing::new(secret.into()));
Ok(())
}
KeyBackend::Unavailable(why) => anyhow::bail!("{why}"),
}
}
/// Remove a key. Removing one that isn't there succeeds.
pub fn delete(&self, account: &str) -> Result<()> {
match &self.backend {
KeyBackend::Keychain => keychain::delete_item(HL_KEYCHAIN_SERVICE, account),
KeyBackend::SecretService => secret_service::delete(account),
KeyBackend::Memory(m) => {
m.lock().expect("key store").remove(account);
Ok(())
}
KeyBackend::Unavailable(_) => Ok(()),
}
}
}
/// The Linux Secret Service (GNOME Keyring, KWallet) through `keyring`.
#[cfg(target_os = "linux")]
mod secret_service {
use anyhow::Result;
use keyring::{Entry, Error};
use super::HL_KEYCHAIN_SERVICE;
fn unavailable(e: Error) -> String {
format!(
"No Secret Service keyring is available ({e}). Hyperliquid trading keys are never written to a file, so trading stays off until a keyring (GNOME Keyring or KWallet) is running."
)
}
pub fn probe() -> Result<(), String> {
let e = Entry::new(HL_KEYCHAIN_SERVICE, "probe").map_err(unavailable)?;
match e.get_password() {
Ok(_) | Err(Error::NoEntry) => Ok(()),
Err(err) => Err(unavailable(err)),
}
}
pub fn get(account: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
match Entry::new(HL_KEYCHAIN_SERVICE, account)?.get_password() {
Ok(s) => Ok(Some(zeroize::Zeroizing::new(s))),
Err(Error::NoEntry) => Ok(None),
Err(e) => Err(anyhow::anyhow!(unavailable(e))),
}
}
pub fn set(account: &str, secret: &str) -> Result<()> {
Entry::new(HL_KEYCHAIN_SERVICE, account)?.set_password(secret).map_err(|e| anyhow::anyhow!(unavailable(e)))
}
pub fn delete(account: &str) -> Result<()> {
match Entry::new(HL_KEYCHAIN_SERVICE, account)?.delete_credential() {
Ok(()) | Err(Error::NoEntry) => Ok(()),
Err(e) => Err(anyhow::anyhow!(unavailable(e))),
}
}
}
#[cfg(not(target_os = "linux"))]
mod secret_service {
use anyhow::{Result, bail};
const WHY: &str = "the Secret Service is only used on Linux";
pub fn probe() -> Result<(), String> {
Err(WHY.into())
}
pub fn get(_: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
bail!(WHY)
}
pub fn set(_: &str, _: &str) -> Result<()> {
bail!(WHY)
}
pub fn delete(_: &str) -> Result<()> {
bail!(WHY)
}
}
#[cfg(test)]
@@ -489,7 +253,7 @@ mod tests {
Credentials {
client_id: "PARTNER-ID".into(),
consumer_key: "consumer-key-0123456789".into(),
user_id: "terminal-1".into(),
user_id: "terminal-rs-1".into(),
user_secret: "user-secret-abcdef".into(),
}
}
@@ -549,69 +313,4 @@ mod tests {
assert_eq!(store.load().unwrap(), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn agent_keys_never_fall_back_to_a_file() {
let none = AgentKeyStore::unavailable("no keyring here");
assert_eq!(none.check(), Err("no keyring here".into()));
assert!(none.save("testnet:0xabc", "secret").is_err(), "refused, not written anywhere");
assert!(none.load("testnet:0xabc").is_err());
assert!(none.delete("testnet:0xabc").is_ok());
let platform = AgentKeyStore::platform();
if cfg!(target_os = "macos") {
assert!(platform.describe().contains("Keychain"));
} else if cfg!(target_os = "linux") {
assert!(platform.describe().contains("Secret Service"));
} else {
assert!(platform.check().is_err(), "other platforms have no store, so trading stays off");
}
let mem = AgentKeyStore::memory();
mem.save("testnet:0xabc", "0x11").unwrap();
assert_eq!(mem.load("testnet:0xabc").unwrap().as_deref().map(String::as_str), Some("0x11"));
mem.delete("testnet:0xabc").unwrap();
assert_eq!(mem.load("testnet:0xabc").unwrap(), None);
assert!(!format!("{mem:?}").contains("0x11"));
}
/// Writes a throwaway agent key to the login Keychain, so it runs only when asked:
/// `cargo test -p tr-broker -- --ignored agent_key`.
#[cfg(target_os = "macos")]
#[test]
#[ignore = "writes to the login Keychain"]
fn agent_key_round_trips_through_the_keychain() {
let store = AgentKeyStore::platform();
let account = format!("test:{}", std::process::id());
store.save(&account, "0x00").unwrap();
assert_eq!(store.load(&account).unwrap().as_deref().map(String::as_str), Some("0x00"));
store.delete(&account).unwrap();
assert_eq!(store.load(&account).unwrap(), None);
store.delete(&account).unwrap();
}
/// Writes throwaway items (dummy credentials) to the login Keychain, so it runs only when
/// asked: `cargo test -p tr-broker -- --ignored keychain`.
#[cfg(target_os = "macos")]
#[test]
#[ignore = "writes to the login Keychain"]
fn keychain_moves_credentials_from_the_old_service() {
let pid = std::process::id();
let (service, legacy) = (format!("terminal-test-{pid}"), format!("terminal-rs-test-{pid}"));
let store = SecretStore::keychain(&service, Some(&legacy));
let new_only = SecretStore::keychain(&service, None);
let old_only = SecretStore::keychain(&legacy, None);
old_only.save(&creds()).unwrap();
assert_eq!(store.load().unwrap(), Some(creds()), "found under the old service");
assert_eq!(new_only.load().unwrap(), Some(creds()), "and moved to the new one");
assert_eq!(old_only.load().unwrap(), None, "the old item is gone");
// A stale item under the old service never replaces what the new one holds.
old_only.save(&Credentials { client_id: "STALE".into(), ..creds() }).unwrap();
assert_eq!(store.load().unwrap(), Some(creds()));
// Forgetting credentials removes both, so the old item can't bring them back.
store.clear().unwrap();
assert_eq!(old_only.load().unwrap(), None);
assert_eq!(store.load().unwrap(), None);
}
}
+1 -1
View File
@@ -262,7 +262,7 @@ impl SnapTrade {
bail!("SnapTrade client ID and consumer key are not set");
}
let http = reqwest::Client::builder()
.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION")))
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.build()?;
let base = Url::parse(base.trim_end_matches('/')).context("invalid SnapTrade base URL")?;
-2
View File
@@ -685,7 +685,6 @@ pub fn simulate_place(pf: &mut Portfolio, preview: &OrderPreview, now: DateTime<
placed: Some(now),
updated: Some(now),
underlying: None,
market: None,
};
if let Some(px) = fill {
let signed = if req.side == Side::Buy { units } else { -units };
@@ -716,7 +715,6 @@ fn apply_fill(pf: &mut Portfolio, account: &str, symbol: &str, signed: f64, px:
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
market: None,
});
return;
};
+4 -4
View File
@@ -93,7 +93,7 @@ async fn mock(seen: Arc<Mutex<Vec<Seen>>>, failures: Arc<Mutex<Vec<String>>>) ->
|| path.starts_with("/connection/")
|| path == "/snapTrade/login";
if user_scoped
&& (param("userId").as_deref() != Some("terminal-test")
&& (param("userId").as_deref() != Some("terminal-rs-test")
|| param("userSecret").as_deref() != Some("s3cr3t/+="))
{
fail("missing or wrong user credentials".into());
@@ -156,7 +156,7 @@ fn creds() -> Credentials {
Credentials {
client_id: "TEST-CLIENT".into(),
consumer_key: CONSUMER_KEY.into(),
user_id: "terminal-test".into(),
user_id: "terminal-rs-test".into(),
user_secret: "s3cr3t/+=".into(),
}
}
@@ -244,8 +244,8 @@ async fn registers_a_user_opens_the_portal_and_disconnects() {
let partner =
SnapTrade::with_base(Credentials { user_id: String::new(), user_secret: String::new(), ..creds() }, &base)
.unwrap();
let (id, secret) = partner.register_user("terminal-test").await.unwrap();
assert_eq!((id.as_str(), secret.as_str()), ("terminal-test", "fresh-secret"));
let (id, secret) = partner.register_user("terminal-rs-test").await.unwrap();
assert_eq!((id.as_str(), secret.as_str()), ("terminal-rs-test", "fresh-secret"));
let client = SnapTrade::with_base(creds(), &base).unwrap();
let url = client.connection_portal_url(true, None).await.unwrap();
+1 -1
View File
@@ -58,7 +58,7 @@ fn client(base: &str) -> SnapTrade {
let creds = Credentials {
client_id: "TEST".into(),
consumer_key: "key".into(),
user_id: "terminal-test".into(),
user_id: "terminal-rs-test".into(),
user_secret: "s".into(),
};
SnapTrade::with_base(creds, base).unwrap()
+2 -2
View File
@@ -80,7 +80,7 @@ async fn mock(seen: Log, failures: Log) -> String {
let body: Option<Value> = (!body.is_empty()).then(|| serde_json::from_slice(&body).unwrap());
let sig = headers.iter().find(|(k, _)| k == "signature").map(|(_, v)| v.clone()).unwrap_or_default();
let ok = sig == signature(CONSUMER_KEY, path, query, body.as_ref())
&& param(query, "userId").as_deref() == Some("terminal-test")
&& param(query, "userId").as_deref() == Some("terminal-rs-test")
&& param(query, "userSecret").as_deref() == Some("s3cr3t");
let entry = Seen { method: method.clone(), path: path.to_string(), body: body.clone() };
if !ok {
@@ -161,7 +161,7 @@ fn client(base: &str) -> SnapTrade {
let creds = Credentials {
client_id: "TEST-CLIENT".into(),
consumer_key: CONSUMER_KEY.into(),
user_id: "terminal-test".into(),
user_id: "terminal-rs-test".into(),
user_secret: "s3cr3t".into(),
};
SnapTrade::with_base(creds, base).unwrap()
+1 -1
View File
@@ -1,4 +1,4 @@
//! GPU chart rendering for Terminal.
//! GPU chart rendering for terminal-rs.
//!
//! - [`viewport`]: bar-index/price <-> pixel math, pan and zoom.
//! - [`lod`]: level-of-detail pyramids that bound per-frame work to the plot width.
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tr-congress"
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for Terminal"
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for terminal-rs"
version.workspace = true
edition.workspace = true
+1 -1
View File
@@ -387,7 +387,7 @@ impl HttpSource {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent(concat!(
"terminal/",
"terminal-rs/",
env!("CARGO_PKG_VERSION"),
" (STOCK Act disclosure reader; +https://github.com/)"
))
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tr-core"
description = "Core market types, timeframes, indicators and sample data for Terminal"
description = "Core market types, timeframes, indicators and sample data for terminal-rs"
version.workspace = true
edition.workspace = true
+10 -24
View File
@@ -89,24 +89,18 @@ impl Series {
i.checked_sub(1)
}
/// Apply one live bar by its `ts`: absorb it into the bar of the same period, append it if
/// newer, or insert it where a period is missing (a feed backfilling a gap after an outage).
/// Bars older than the whole series are ignored; paging loads those. Returns true if the
/// series changed.
/// Apply one live bar: absorb it into the last bar if it is the same period, append it
/// if newer, ignore it if older. Returns true if the series changed.
pub fn apply_live(bars: &mut Vec<Bar>, live: Bar) -> bool {
if bars.last().is_none_or(|last| last.ts < live.ts) {
bars.push(live);
return true;
}
match bars.binary_search_by_key(&live.ts, |b| b.ts) {
Ok(i) => {
let before = bars[i];
bars[i].absorb(&live);
bars[i] != before
match bars.last_mut() {
Some(last) if last.ts == live.ts => {
let before = *last;
last.absorb(&live);
*last != before
}
Err(0) => false,
Err(i) => {
bars.insert(i, live);
Some(last) if last.ts > live.ts => false,
_ => {
bars.push(live);
true
}
}
@@ -154,14 +148,6 @@ mod tests {
assert!(!Series::apply_live(&mut v, Bar::new(0, 1.0, 1.0, 1.0, 1.0, 1.0)));
assert!(Series::apply_live(&mut v, Bar::new(120, 11.5, 11.6, 11.4, 11.6, 5.0)));
assert_eq!(v.len(), 2);
// A feed backfilling after an outage: a missing period is inserted, an older bar it
// still holds is completed, and nothing older than the series is added.
assert!(Series::apply_live(&mut v, Bar::new(240, 12.0, 12.0, 12.0, 12.0, 1.0)));
assert!(Series::apply_live(&mut v, Bar::new(180, 11.6, 11.9, 11.5, 11.8, 7.0)));
assert!(Series::apply_live(&mut v, Bar::new(120, 11.5, 11.7, 11.4, 11.65, 6.0)));
assert_eq!(v.iter().map(|b| b.ts).collect::<Vec<_>>(), [60, 120, 180, 240]);
assert_eq!((v[1].high, v[1].close, v[1].volume), (11.7, 11.65, 6.0));
assert!(!Series::apply_live(&mut v, Bar::new(180, 11.6, 11.9, 11.5, 11.8, 7.0)), "no change");
}
#[test]
+1 -2
View File
@@ -1,11 +1,10 @@
//! Core market types shared across Terminal: bars, timeframes, securities and
//! Core market types shared across terminal-rs: bars, timeframes, securities and
//! market keys, the security catalog, technical indicators and sample data.
pub mod bar;
pub mod catalog;
pub mod indicators;
pub mod options;
pub mod perps;
pub mod sample;
pub mod security;
pub mod timeframe;
-334
View File
@@ -1,334 +0,0 @@
//! Hyperliquid HIP-3 perpetuals, such as `xyz:AAPL`: their market keys, the venue label every
//! surface shows with them, and the reviewed table that links an instrument the app already
//! has (`AAPL US Equity`) to the perp on the same underlying.
//!
//! A perp is its own instrument. `AAPL US Equity` stays the listed stock, with its own quotes
//! and chart; the table only lets that view point at `xyz:AAPL`. Which perps exist, and which
//! are live, comes from the Terminal server at run time, so a row here never makes a perp
//! appear by itself.
//!
//! A row needs all of these (docs/architecture/server.md §7.3):
//! - the venue's own docs say the perp references the underlying the app key means;
//! - the perp is live (not `isDelisted`) on Hyperliquid;
//! - the `xyz` dex, unless xyz has no market on that underlying and another dex's match is
//! unambiguous;
//! - its price tracks the app instrument's.
/// A perp linked to an app instrument with the same underlying.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PerpLink {
/// The perp's market key, as Hyperliquid names the coin.
pub coin: &'static str,
/// The app market key with the same underlying.
pub underlying: &'static str,
/// Display name.
pub name: &'static str,
/// What the perp references, in the venue's words.
pub reference: &'static str,
}
const fn link(coin: &'static str, underlying: &'static str, name: &'static str, reference: &'static str) -> PerpLink {
PerpLink { coin, underlying, name, reference }
}
/// Checked on 2026-11-02 against trade.xyz's specification index, Hyperliquid's `meta` (all
/// live) and the app instrument's price (within 0.6%; oil differs by its contract month).
///
/// Left out on purpose: `xyz:XYZ100` and `xyz:JP225` track the Nasdaq-100 and the Nikkei 225
/// closely, but the venue's docs name neither index. `xyz:HO` tracks HO1, but the docs describe
/// the diesel market under another name and never tie it to `HO`. `xyz:KR200` is the KOSPI 200,
/// which the app doesn't have.
pub const LINKS: &[PerpLink] = &[
link("xyz:AAPL", "AAPL US Equity", "Apple Inc perp", "1 share of Apple Inc. common stock (Nasdaq: AAPL)"),
link(
"xyz:MSFT",
"MSFT US Equity",
"Microsoft Corp perp",
"1 share of Microsoft Corporation common stock (Nasdaq: MSFT)",
),
link("xyz:NVDA", "NVDA US Equity", "NVIDIA Corp perp", "1 share of NVIDIA Corporation common stock (Nasdaq: NVDA)"),
link(
"xyz:AMZN",
"AMZN US Equity",
"Amazon.com Inc perp",
"1 share of Amazon.com, Inc. common stock (Nasdaq: AMZN)",
),
link(
"xyz:GOOGL",
"GOOGL US Equity",
"Alphabet Inc Class A perp",
"1 share of Alphabet Inc. Class A common stock (Nasdaq: GOOGL)",
),
link(
"xyz:META",
"META US Equity",
"Meta Platforms Inc perp",
"1 share of Meta Platforms, Inc. Class A common stock (Nasdaq: META)",
),
link("xyz:TSLA", "TSLA US Equity", "Tesla Inc perp", "1 share of Tesla, Inc. common stock (Nasdaq: TSLA)"),
link(
"xyz:LLY",
"LLY US Equity",
"Eli Lilly & Co perp",
"1 share of Eli Lilly and Company common stock (NYSE: LLY)",
),
link("xyz:AVGO", "AVGO US Equity", "Broadcom Inc perp", "1 share of Broadcom Inc. common stock (Nasdaq: AVGO)"),
link(
"xyz:AMD",
"AMD US Equity",
"Advanced Micro Devices perp",
"1 share of Advanced Micro Devices, Inc. common stock (Nasdaq: AMD)",
),
link("xyz:NFLX", "NFLX US Equity", "Netflix Inc perp", "1 share of Netflix, Inc. common stock (Nasdaq: NFLX)"),
link(
"xyz:COIN",
"COIN US Equity",
"Coinbase Global Inc perp",
"1 share of Coinbase Global, Inc. Class A common stock (Nasdaq: COIN)",
),
link(
"xyz:MSTR",
"MSTR US Equity",
"Strategy Inc perp",
"1 share of Strategy Inc Class A common stock (Nasdaq: MSTR)",
),
link(
"xyz:TLT",
"TLT US Equity",
"iShares 20+ Year Treasury Bond ETF perp",
"1 share of the iShares 20+ Year Treasury Bond ETF (Nasdaq: TLT)",
),
link(
"xyz:SMH",
"SMH US Equity",
"VanEck Semiconductor ETF perp",
"1 share of the VanEck Semiconductor ETF (Nasdaq: SMH)",
),
link(
"xyz:XLE",
"XLE US Equity",
"Energy Select Sector SPDR perp",
"1 share of the Energy Select Sector SPDR ETF (NYSE Arca: XLE)",
),
link("xyz:SP500", "SPX Index", "S&P 500 perp", "the S&P 500 Index"),
link(
"xyz:CL",
"CL1 Comdty",
"WTI crude oil perp",
"1 barrel of WTI Light Sweet Crude Oil, from a futures contract it rolls monthly",
),
link(
"xyz:BRENTOIL",
"CO1 Comdty",
"Brent crude oil perp",
"1 barrel of Brent crude oil, from a futures contract it rolls monthly",
),
link(
"xyz:NATGAS",
"NG1 Comdty",
"Natural gas perp",
"1 MMBtu of Henry Hub natural gas, from a futures contract it rolls monthly",
),
link(
"xyz:COPPER",
"HG1 Comdty",
"Copper perp",
"1 pound of high-grade copper, from a futures contract it rolls monthly",
),
link("xyz:GOLD", "GC1 Comdty", "Gold perp", "the spot price of 1 troy ounce of gold (XAU/USD), not the future"),
link(
"xyz:SILVER",
"SI1 Comdty",
"Silver perp",
"the spot price of 1 troy ounce of silver (XAG/USD), not the future",
),
link(
"xyz:PLATINUM",
"PL1 Comdty",
"Platinum perp",
"the spot price of 1 troy ounce of platinum (XPT/USD), not the future",
),
link("xyz:EUR", "EURUSD Curncy", "EUR/USD perp", "the EUR/USD spot rate, US dollars per euro"),
link("xyz:GBP", "GBPUSD Curncy", "GBP/USD perp", "the GBP/USD spot rate, US dollars per pound"),
link("xyz:JPY", "USDJPY Curncy", "USD/JPY perp", "the USD/JPY spot rate, yen per US dollar"),
];
/// The canonical key for a perp written in any case: `XYZ:aapl` is `xyz:AAPL`. `None` unless
/// it looks like `{dex}:{coin}`: a lowercase-able dex name of letters, then letters and digits.
pub fn normalize(input: &str) -> Option<String> {
let (dex, coin) = input.trim().split_once(':')?;
let ok = |s: &str, digits: bool| {
!s.is_empty() && s.len() <= 16 && s.chars().all(|c| c.is_ascii_alphabetic() || (digits && c.is_ascii_digit()))
};
(ok(dex, false) && ok(coin, true)).then(|| format!("{}:{}", dex.to_ascii_lowercase(), coin.to_ascii_uppercase()))
}
/// Whether `key` names a HIP-3 perp.
pub fn is_perp(key: &str) -> bool {
normalize(key).is_some()
}
/// The dex a perp trades on: `xyz` for `xyz:AAPL`.
pub fn venue(key: &str) -> Option<&str> {
key.split_once(':').map(|(dex, _)| dex).filter(|_| is_perp(key))
}
/// The label every surface shows next to a perp, so it never passes for the listed
/// instrument: `PERP · HL xyz`.
pub fn badge(key: &str) -> String {
match venue(key) {
Some(dex) => format!("PERP · HL {}", dex.to_ascii_lowercase()),
None => "PERP · HL".into(),
}
}
/// The perp linked to an app market key.
pub fn perp_for(key: &str) -> Option<&'static PerpLink> {
LINKS.iter().find(|l| l.underlying == key)
}
/// The link for a perp, by its key in any case.
pub fn link_for(key: &str) -> Option<&'static PerpLink> {
let k = normalize(key)?;
LINKS.iter().find(|l| l.coin == k)
}
/// The perps among `keys` that match every word of `query` in their key, coin or linked name,
/// best first. `PERP` in the query matches every perp; a yellow key (`GOLD CMDTY`) asks for
/// listed instruments, so it matches none.
pub fn search<'a>(query: &str, keys: impl IntoIterator<Item = &'a str>, limit: usize) -> Vec<&'a str> {
let words: Vec<String> = query.split_whitespace().map(str::to_ascii_uppercase).collect();
if words.is_empty() || words.iter().any(|w| crate::YellowKey::parse(w).is_some()) {
return Vec::new();
}
let words: Vec<&String> = words.iter().filter(|w| !matches!(w.as_str(), "PERP" | "PERPS")).collect();
let mut scored: Vec<(i32, &'a str)> = keys
.into_iter()
.filter_map(|key| {
let up = key.to_ascii_uppercase();
let coin = up.split_once(':').map_or(up.as_str(), |(_, c)| c);
let name = link_for(key).map(|l| l.name.to_ascii_uppercase()).unwrap_or_default();
let mut score = 1;
for w in &words {
score += if coin == w.as_str() {
1000
} else if coin.starts_with(w.as_str()) {
600 - 10 * (coin.len() - w.len()) as i32
} else if up.starts_with(w.as_str()) {
500
} else if name.split(|c: char| c.is_whitespace() || c == '/').any(|n| n.starts_with(w.as_str())) {
300
} else {
return None;
};
}
Some((score, key))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
scored.into_iter().take(limit).map(|(_, k)| k).collect()
}
/// Where `PERP` takes a pane on `key`: from a linked perp to its instrument, or from an
/// instrument to its perp while `live` says the server lists that perp.
pub fn switch_target(key: &str, live: impl Fn(&str) -> bool) -> Option<&'static str> {
match link_for(key) {
Some(l) => Some(l.underlying),
None => perp_for(key).map(|l| l.coin).filter(|c| live(c)),
}
}
/// The other side of a link: an instrument's perp, or a perp's instrument.
pub fn counterpart(key: &str) -> Option<&'static str> {
match link_for(key) {
Some(l) => Some(l.underlying),
None => perp_for(key).map(|l| l.coin),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Security, catalog};
#[test]
fn keys_normalise_and_label_their_venue() {
assert_eq!(normalize("XYZ:aapl").as_deref(), Some("xyz:AAPL"));
assert_eq!(normalize(" xyz:XYZ100 ").as_deref(), Some("xyz:XYZ100"));
assert_eq!(normalize("para:10Y").as_deref(), Some("para:10Y"));
for not in ["AAPL", "xyz:", ":AAPL", "x1:AAPL", "xyz:AA-PL", "xyz:AAPL:B", "AAPL US Equity"] {
assert_eq!(normalize(not), None, "{not}");
}
assert_eq!(venue("xyz:AAPL"), Some("xyz"));
assert_eq!(venue("BTC"), None);
assert_eq!(badge("xyz:AAPL"), "PERP · HL xyz");
assert_eq!(badge("para:10Y"), "PERP · HL para");
}
#[test]
fn links_point_at_app_instruments_and_back() {
assert_eq!(perp_for("AAPL US Equity").map(|l| l.coin), Some("xyz:AAPL"));
assert_eq!(perp_for("SPX Index").map(|l| l.coin), Some("xyz:SP500"));
assert_eq!(perp_for("USDJPY Curncy").map(|l| l.coin), Some("xyz:JPY"));
assert_eq!(perp_for("CL1 Comdty").map(|l| l.coin), Some("xyz:CL"));
assert_eq!(link_for("XYZ:eur").map(|l| l.underlying), Some("EURUSD Curncy"));
// Not linked: the docs don't name these indices, or the app lacks the instrument.
for unlinked in ["NDX Index", "NKY Index", "HO1 Comdty", "KOSPI Index", "JPM US Equity", "BTCUSD Curncy"] {
assert_eq!(perp_for(unlinked), None, "{unlinked}");
}
assert_eq!(link_for("xyz:XYZ100"), None);
assert_eq!(counterpart("AAPL US Equity"), Some("xyz:AAPL"));
assert_eq!(counterpart("xyz:aapl"), Some("AAPL US Equity"));
assert_eq!(counterpart("xyz:SKHX"), None);
}
#[test]
fn switching_follows_links_to_live_perps_only() {
let live = |c: &str| c == "xyz:AAPL";
assert_eq!(switch_target("AAPL US Equity", live), Some("xyz:AAPL"));
assert_eq!(switch_target("GC1 Comdty", live), None, "xyz:GOLD isn't listed live here");
assert_eq!(switch_target("xyz:GOLD", live), Some("GC1 Comdty"), "back to the instrument always works");
assert_eq!(switch_target("XYZ:aapl", |_| false), Some("AAPL US Equity"));
assert_eq!(switch_target("xyz:SKHX", |_| true), None, "unlinked");
assert_eq!(switch_target("NDX Index", |_| true), None, "no link");
}
#[test]
fn search_finds_perps_by_coin_key_or_name() {
let live = ["xyz:AAPL", "xyz:GOLD", "xyz:SKHX", "xyz:SP500", "para:10Y", "xyz:AMD", "xyz:AMZN"];
let s = |q: &str| search(q, live, 10);
assert_eq!(s("aapl"), ["xyz:AAPL"]);
assert_eq!(s("xyz:aa"), ["xyz:AAPL"]);
assert_eq!(s("am")[..2], ["xyz:AMD", "xyz:AMZN"], "coin prefixes, shorter first");
assert_eq!(s("apple"), ["xyz:AAPL"], "a linked perp's name");
assert_eq!(s("gold perp"), ["xyz:GOLD"]);
assert_eq!(s("s&p"), ["xyz:SP500"]);
assert_eq!(s("skhx"), ["xyz:SKHX"], "unlinked perps by coin");
assert_eq!(s("10y"), ["para:10Y"]);
assert_eq!(s("perp").len(), live.len());
assert!(s("gold cmdty").is_empty(), "a yellow key asks for the listed instrument");
assert!(s("").is_empty() && s("zzzz").is_empty());
assert_eq!(search("a", live, 2).len(), 2);
}
/// Every row names a real app instrument, by its canonical key, on one side, and a
/// well-formed xyz perp on the other, each at most once.
#[test]
fn the_table_is_well_formed() {
let mut coins: Vec<&str> = LINKS.iter().map(|l| l.coin).collect();
let mut keys: Vec<&str> = LINKS.iter().map(|l| l.underlying).collect();
coins.sort();
coins.dedup();
keys.sort();
keys.dedup();
assert_eq!((coins.len(), keys.len()), (LINKS.len(), LINKS.len()), "one row per perp and per instrument");
for l in LINKS {
assert_eq!(normalize(l.coin).as_deref(), Some(l.coin), "{} is canonical", l.coin);
assert_eq!(venue(l.coin), Some("xyz"), "{} prefers xyz", l.coin);
let sec = Security::parse(l.underlying).expect("parses");
assert_eq!(sec.key(), l.underlying, "{} is a canonical key", l.underlying);
assert!(catalog::by_security(&sec).is_some(), "{} is in the catalog", l.underlying);
assert!(l.name.ends_with(" perp") && !l.reference.is_empty());
}
}
}
+8 -55
View File
@@ -1,12 +1,11 @@
//! Securities across asset classes, addressed with Bloomberg-style market keys:
//! `AAPL US Equity`, `SPX Index`, `BTCUSD Curncy`, `EURUSD Curncy`, `US10Y Govt`, `CL1 Comdty`.
//! Hyperliquid HIP-3 perps keep the venue's own name, `xyz:AAPL` (see [`crate::perps`]).
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::{catalog, perps};
use crate::catalog;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum AssetClass {
@@ -17,8 +16,6 @@ pub enum AssetClass {
Crypto,
Govt,
Cmdty,
/// A Hyperliquid HIP-3 perpetual (`xyz:AAPL`): its own instrument, never the listed one.
Perp,
}
/// The Bloomberg "yellow key" a security lives under.
@@ -29,14 +26,11 @@ pub enum YellowKey {
Curncy,
Govt,
Comdty,
/// HIP-3 perps, which Bloomberg has no key for. Nothing typed parses to it: a perp is named
/// by its venue key (`xyz:AAPL`), and `PERP` after a security is a function.
Perp,
}
impl YellowKey {
pub const ALL: [YellowKey; 6] =
[YellowKey::Equity, YellowKey::Index, YellowKey::Curncy, YellowKey::Govt, YellowKey::Comdty, YellowKey::Perp];
pub const ALL: [YellowKey; 5] =
[YellowKey::Equity, YellowKey::Index, YellowKey::Curncy, YellowKey::Govt, YellowKey::Comdty];
/// Accepts `<CRNCY>`, `CURNCY`, `crncy`, `CMDTY`, `COMDTY`, `GOVT`, `INDEX`, `EQUITY`, `EQ`, `ETF`.
pub fn parse(s: &str) -> Option<Self> {
@@ -58,7 +52,6 @@ impl YellowKey {
YellowKey::Curncy => "Curncy",
YellowKey::Govt => "Govt",
YellowKey::Comdty => "Comdty",
YellowKey::Perp => "Perp",
}
}
}
@@ -71,7 +64,6 @@ impl AssetClass {
AssetClass::Fx | AssetClass::Crypto => YellowKey::Curncy,
AssetClass::Govt => YellowKey::Govt,
AssetClass::Cmdty => YellowKey::Comdty,
AssetClass::Perp => YellowKey::Perp,
}
}
@@ -84,7 +76,6 @@ impl AssetClass {
AssetClass::Crypto => "Crypto",
AssetClass::Govt => "Govt",
AssetClass::Cmdty => "Comdty",
AssetClass::Perp => "Perp",
}
}
@@ -96,14 +87,13 @@ impl AssetClass {
AssetClass::Fx => 0.08,
AssetClass::Crypto => 0.60,
AssetClass::Govt => 0.20,
AssetClass::Cmdty | AssetClass::Perp => 0.30,
AssetClass::Cmdty => 0.30,
}
}
/// Exchanges trade crypto around the clock, and HIP-3 perps too (even while the market they
/// reference is closed); everything else has sessions.
/// Exchanges trade crypto around the clock; everything else has sessions.
pub fn trades_24_7(self) -> bool {
matches!(self, AssetClass::Crypto | AssetClass::Perp)
self == AssetClass::Crypto
}
}
@@ -132,15 +122,9 @@ impl Security {
Self::new(ticker, AssetClass::Equity)
}
/// A HIP-3 perp from its venue key in any case (`XYZ:aapl` is `xyz:AAPL`).
pub fn perp(key: &str) -> Option<Self> {
Some(Self { ticker: perps::normalize(key)?, class: AssetClass::Perp })
}
/// Canonical market key, e.g. `AAPL US Equity`, `BTCUSD Curncy`, `xyz:AAPL`.
/// Canonical market key, e.g. `AAPL US Equity`, `BTCUSD Curncy`.
pub fn key(&self) -> String {
match self.class {
AssetClass::Perp => self.ticker.clone(),
AssetClass::Equity | AssetClass::Etf => format!("{} US Equity", self.ticker),
c => format!("{} {}", self.ticker, c.yellow_key().label()),
}
@@ -150,9 +134,6 @@ impl Security {
/// Returns the security and how many input tokens it consumed.
pub fn resolve_tokens(tokens: &[&str]) -> Option<(Self, usize)> {
let (&head, rest) = tokens.split_first()?;
if let Some(perp) = Self::perp(head) {
return Some((perp, 1));
}
// `BTC/USD` is a pair; `BRK/B` is a share class.
let up = head.trim().to_ascii_uppercase();
let ticker = match up.split_once('/') {
@@ -206,7 +187,7 @@ impl Security {
AssetClass::Crypto
}
}
Some(YellowKey::Equity) | Some(YellowKey::Perp) => AssetClass::Equity,
Some(YellowKey::Equity) => AssetClass::Equity,
None if ticker.len() == 6 && is_fiat(&ticker[..3]) && is_fiat(&ticker[3..]) => AssetClass::Fx,
None => AssetClass::Equity,
};
@@ -215,9 +196,6 @@ impl Security {
/// Display name from the catalog, if known.
pub fn name(&self) -> Option<&'static str> {
if self.class == AssetClass::Perp {
return perps::link_for(&self.ticker).map(|l| l.name);
}
catalog::lookup(&self.ticker, Some(self.class.yellow_key())).filter(|l| l.class == self.class).map(|l| l.name)
}
@@ -240,10 +218,6 @@ impl Security {
AssetClass::Govt => 3,
AssetClass::Fx if self.ticker.ends_with("JPY") => 3,
AssetClass::Fx => 4,
// Hyperliquid quotes five significant figures, at most six decimals.
AssetClass::Perp if price.is_finite() && price != 0.0 => {
(4 - price.abs().log10().floor() as i64).clamp(0, 6) as usize
}
_ => match price.abs() {
p if p >= 1000.0 => 2,
p if p >= 1.0 => 2,
@@ -317,27 +291,6 @@ mod tests {
assert_eq!(Security::resolve_tokens(&t).unwrap().1, 1);
}
#[test]
fn hip3_perps_are_their_own_instruments() {
let p = r("xyz:AAPL");
assert_eq!(p, Security { ticker: "xyz:AAPL".into(), class: AssetClass::Perp });
assert_eq!(r("XYZ:aapl"), p, "any case");
assert_eq!(p.key(), "xyz:AAPL");
assert_eq!(r("AAPL"), Security::new("AAPL", AssetClass::Equity), "AAPL stays the listed stock");
assert_eq!(r("AAPL US Equity").key(), "AAPL US Equity");
assert_eq!(Security::resolve_tokens(&["xyz:AAPL", "5"]).unwrap().1, 1);
assert_eq!(p.name(), Some("Apple Inc perp"));
assert_eq!(r("xyz:SKHX").name(), None, "unlinked perps have no catalog name");
assert_eq!((p.class.label(), p.class.yellow_key().label()), ("Perp", "Perp"));
assert!(p.class.trades_24_7() && !p.has_congress_trades() && p.pair().is_none());
// Prices show the five significant figures Hyperliquid quotes.
assert_eq!(r("xyz:EUR").decimals(1.1208), 4);
assert_eq!(r("xyz:JPY").decimals(157.74), 2);
assert_eq!(r("xyz:GOLD").decimals(3958.05), 1);
assert_eq!(r("xyz:XYZ100").decimals(28390.5), 0);
assert_eq!(r("para:ANSEM").decimals(0.041887), 6);
}
#[test]
fn keys_and_helpers() {
assert_eq!(Security::new("BTCUSD", AssetClass::Crypto).key(), "BTCUSD Curncy");
-1
View File
@@ -6,7 +6,6 @@ edition.workspace = true
[dependencies]
tr-core.workspace = true
tr-proto.workspace = true
anyhow.workspace = true
chrono.workspace = true
serde.workspace = true
+1 -1
View File
@@ -196,7 +196,7 @@ impl CryptoProvider {
let client = reqwest::Client::builder();
// Browsers set their own User-Agent, and fetch takes its timeout per request.
#[cfg(not(target_arch = "wasm32"))]
let client = client.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION"))).timeout(TIMEOUT);
let client = client.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION"))).timeout(TIMEOUT);
Ok(Self { client: client.build()? })
}
+14 -102
View File
@@ -12,14 +12,9 @@
//! The router is wrapped in [`Fallback`], which serves sample data if a source is
//! unreachable, so the terminal always has something to draw.
//!
//! `TR_SERVER_URL` (`ws://` or `wss://`) opts in to the Terminal data server
//! ([`remote::RemoteMarkets`]): crypto markets then come from Hyperliquid perps through the
//! server, and everything else, or anything the server can't serve, from the sources above.
//!
//! The browser build (`wasm32`) can only reach sources that allow cross-origin requests:
//! [`for_browser`] serves live crypto through [`web::WebMarkets`] and sample data for the
//! rest. It has no SQLite bar cache. When the page was built with a Terminal server URL
//! ([`web::server_url`]), crypto comes through the server first, as on the desktop.
//! rest. It has no SQLite bar cache.
#[cfg(not(target_arch = "wasm32"))]
pub mod alpaca;
@@ -28,15 +23,13 @@ pub mod cache;
pub mod crypto;
#[cfg(not(target_arch = "wasm32"))]
pub mod markets;
pub mod remote;
pub mod rss;
pub mod rt;
pub mod sample;
#[cfg(not(target_arch = "wasm32"))]
pub mod treasury;
pub mod web;
/// The websocket client the providers use (the app's Hyperliquid user stream uses it too).
pub mod ws;
mod ws;
#[cfg(not(target_arch = "wasm32"))]
pub mod yahoo;
@@ -157,17 +150,6 @@ impl OrderBook {
}
}
/// One trade on a venue's tape.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TradePrint {
/// The venue's trade time (Unix milliseconds).
pub ts_ms: i64,
pub px: f64,
pub sz: f64,
/// The buyer was the aggressor.
pub buy: bool,
}
/// One listed option.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OptionContract {
@@ -214,9 +196,7 @@ pub struct OptionChain {
}
/// Live bar updates for one symbol and timeframe: each message is the latest state of
/// the current (possibly still forming) bar, or, after an outage, an older bar that fills the
/// gap ([`tr_core::Series::apply_live`] slots it in). The feed stops when the receiver is
/// dropped.
/// the current (possibly still forming) bar. The feed stops when the receiver is dropped.
pub type LiveFeed = mpsc::Receiver<Bar>;
/// Pick curve snapshots from per-date rows: latest, nearest at or before -30d and -365d.
@@ -287,17 +267,6 @@ pub trait MarketData: Send + Sync {
anyhow::bail!("no public order book for {symbol}")
}
/// The latest `n` trades, oldest first, where a public tape exists.
async fn trades(&self, symbol: &str, _n: usize) -> Result<Vec<TradePrint>> {
anyhow::bail!("no public trades for {symbol}")
}
/// The HIP-3 perps (`xyz:AAPL`) the Terminal server lists as live right now. An error
/// when there is no server or it can't be reached.
async fn perp_markets(&self) -> Result<Vec<String>> {
anyhow::bail!("no Terminal server")
}
/// The US Treasury curve. The default assembles it from each tenor's daily history.
async fn yield_curves(&self) -> Result<YieldCurves> {
let tenors = catalog::treasury_curve();
@@ -350,8 +319,8 @@ impl<P: MarketData> MarketData for Fallback<P> {
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
match self.primary.history(symbol, tf, end, limit).await {
Ok(b) => Ok(b),
// Paging older history: never splice sample bars onto real ones. Perps never get them.
Err(e) if end.is_some() || is_perp(symbol) => Err(e),
// Paging older history: never splice sample bars onto real ones.
Err(e) if end.is_some() => Err(e),
Err(e) => {
tracing::warn!("{} history {symbol} {tf} failed, using sample data: {e:#}", self.name);
self.sample.history(symbol, tf, end, limit).await
@@ -362,11 +331,10 @@ impl<P: MarketData> MarketData for Fallback<P> {
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
match self.primary.quotes(symbols).await {
// If the primary produced nothing at all (offline), serve sample quotes rather
// than an empty panel; partial results are never mixed with synthetic prices. Perps
// are never filled in.
// than an empty panel; partial results are never mixed with synthetic prices.
Ok(mut q) => {
let missing: Vec<String> =
symbols.iter().filter(|s| !is_perp(s) && !q.iter().any(|x| &x.symbol == *s)).cloned().collect();
symbols.iter().filter(|s| !q.iter().any(|x| &x.symbol == *s)).cloned().collect();
if !missing.is_empty() && q.is_empty() {
q.extend(self.sample.quotes(&missing).await.unwrap_or_default());
}
@@ -374,8 +342,7 @@ impl<P: MarketData> MarketData for Fallback<P> {
}
Err(e) => {
tracing::warn!("{} quotes failed, using sample data: {e:#}", self.name);
let listed: Vec<String> = symbols.iter().filter(|s| !is_perp(s)).cloned().collect();
self.sample.quotes(&listed).await
self.sample.quotes(symbols).await
}
}
}
@@ -395,7 +362,6 @@ impl<P: MarketData> MarketData for Fallback<P> {
async fn options(&self, symbol: &str, expiration: Option<i64>) -> Result<OptionChain> {
match self.primary.options(symbol, expiration).await {
Ok(c) => Ok(c),
Err(e) if is_perp(symbol) => Err(e),
Err(e) => {
tracing::warn!("{} options {symbol}: {e:#}; simulating", self.name);
self.sample.options(symbol, expiration).await
@@ -407,7 +373,6 @@ impl<P: MarketData> MarketData for Fallback<P> {
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
match self.primary.order_book(symbol, depth).await {
Ok(b) => Ok(b),
Err(e) if is_perp(symbol) => Err(e),
Err(e) => {
tracing::debug!("{} order book {symbol}: {e:#}; simulating", self.name);
self.sample.order_book(symbol, depth).await
@@ -415,15 +380,6 @@ impl<P: MarketData> MarketData for Fallback<P> {
}
}
/// Real trades only: nothing simulates a tape.
async fn trades(&self, symbol: &str, n: usize) -> Result<Vec<TradePrint>> {
self.primary.trades(symbol, n).await
}
async fn perp_markets(&self) -> Result<Vec<String>> {
self.primary.perp_markets().await
}
async fn yield_curves(&self) -> Result<YieldCurves> {
match self.primary.yield_curves().await {
Ok(c) => Ok(c),
@@ -440,17 +396,6 @@ pub fn security(symbol: &str) -> Security {
Security::parse(symbol).unwrap_or_else(|| Security::equity(symbol))
}
/// Whether `symbol` is a HIP-3 perp (`xyz:AAPL`).
pub fn is_perp(symbol: &str) -> bool {
tr_core::perps::is_perp(symbol)
}
/// What a source other than the Terminal server says about a HIP-3 perp. Only the server
/// carries them, and nothing stands in for them, not even sample data.
pub fn perp_needs_server(symbol: &str) -> anyhow::Error {
anyhow::anyhow!("{symbol} is a HIP-3 perp, which only the Terminal server provides")
}
/// Build the provider described in the crate docs from environment variables.
#[cfg(not(target_arch = "wasm32"))]
pub fn from_env() -> Provider {
@@ -467,33 +412,18 @@ pub fn from_env() -> Provider {
}
_ => None,
};
let markets = match markets::Markets::new(alpaca) {
Ok(m) => Fallback::new(m),
Err(_) => return Arc::new(sample::SampleProvider::default().with_live_ticks()),
};
match var("TR_SERVER_URL") {
Some(url) => {
// The URL itself stays out of the logs.
tracing::info!("TR_SERVER_URL is set: crypto markets come through the Terminal server");
Arc::new(remote::RemoteMarkets::new(url.trim(), markets))
}
None => Arc::new(markets),
match markets::Markets::new(alpaca) {
Ok(m) => Arc::new(Fallback::new(m)),
Err(_) => Arc::new(sample::SampleProvider::default().with_live_ticks()),
}
}
/// The browser build's provider: sample data only when `offline`, otherwise live crypto
/// with sample data for everything else. With a Terminal `server` URL, crypto comes through
/// that server, and from Binance and Coinbase whenever the server can't serve it.
pub fn for_browser(offline: bool, server: Option<&str>) -> Provider {
/// with sample data for everything else.
pub fn for_browser(offline: bool) -> Provider {
if !offline {
match web::WebMarkets::new() {
Ok(m) => {
let m = Fallback::new(m);
return match server {
Some(url) => Arc::new(remote::RemoteMarkets::new(url, m)),
None => Arc::new(m),
};
}
Ok(m) => return Arc::new(Fallback::new(m)),
Err(e) => tracing::warn!("crypto sources unavailable, using sample data: {e:#}"),
}
}
@@ -570,24 +500,6 @@ mod tests {
assert_eq!(p.name(), "broken");
}
/// A HIP-3 perp has no source but the Terminal server, so nothing stands in for it: no
/// sample bars, quotes or books when a source fails, and none in offline sample mode.
#[tokio::test]
async fn perps_never_get_stand_in_data() {
assert!(is_perp("xyz:AAPL") && is_perp("XYZ:aapl") && !is_perp("AAPL US Equity") && !is_perp("BTCUSD Curncy"));
let p = Fallback::new(Broken);
assert!(p.history("xyz:AAPL", Timeframe::D1, None, 10).await.is_err());
assert!(p.order_book("xyz:AAPL", 10).await.is_err());
let q = p.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
let s = sample::SampleProvider::default().with_live_ticks();
let e = s.history("xyz:AAPL", Timeframe::D1, None, 10).await.unwrap_err();
assert!(format!("{e:#}").contains("Terminal server"), "{e:#}");
assert!(s.order_book("xyz:AAPL", 10).await.is_err() && s.live("xyz:AAPL", Timeframe::M1).is_none());
let q = s.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
}
#[tokio::test]
async fn default_yield_curve_from_history() {
let c = sample::SampleProvider::at(1_790_000_000, 1_000).yield_curves().await.unwrap();
+1 -13
View File
@@ -6,9 +6,6 @@
//! | Govt | Treasury.gov daily curve (FRED fallback); Yahoo `^TNX` etc. intraday | Treasury.gov | — |
//! | Equity/ETF | Alpaca if keyed, else Yahoo | Yahoo spark | — |
//! | FX/Index/Comdty | Yahoo (`EURUSD=X`, `^GSPC`, `CL=F`) | Yahoo spark | — |
//! | HIP-3 perps (`xyz:AAPL`) | — | — | — |
//!
//! HIP-3 perps come only from the Terminal server ([`crate::remote::RemoteMarkets`]).
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait;
@@ -18,9 +15,7 @@ use crate::alpaca::AlpacaProvider;
use crate::crypto::CryptoProvider;
use crate::treasury::TreasuryProvider;
use crate::yahoo::{YahooProvider, yahoo_symbol};
use crate::{
LiveFeed, MarketData, NewsItem, OptionChain, OrderBook, Quote, SearchHit, YieldCurves, perp_needs_server, security,
};
use crate::{LiveFeed, MarketData, NewsItem, OptionChain, OrderBook, Quote, SearchHit, YieldCurves, security};
pub struct Markets {
yahoo: YahooProvider,
@@ -52,7 +47,6 @@ impl MarketData for Markets {
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
let sec = security(symbol);
match sec.class {
AssetClass::Perp => Err(perp_needs_server(symbol)),
AssetClass::Crypto => self.crypto.history(&sec, tf, end, limit).await,
AssetClass::Govt if tf.is_intraday() => match catalog::by_security(&sec).and_then(|l| l.yahoo) {
Some(y) => self.yahoo.history(y, tf, end, limit).await,
@@ -75,7 +69,6 @@ impl MarketData for Markets {
for key in symbols {
let sec = security(key);
match sec.class {
AssetClass::Perp => {}
AssetClass::Crypto => crypto.push((key.clone(), sec)),
AssetClass::Govt => govt.push((key.clone(), sec)),
_ => other.push((key.clone(), sec)),
@@ -130,11 +123,6 @@ impl MarketData for Markets {
Some(sec) => match sec.class {
// Yahoo has no crypto headlines; use CoinDesk's feed.
AssetClass::Crypto => return self.crypto.news(&sec).await,
// A linked perp shares its underlying's headlines.
AssetClass::Perp => match tr_core::perps::link_for(&sec.ticker) {
Some(l) => yahoo_symbol(&security(l.underlying)),
None => return Ok(Vec::new()),
},
AssetClass::Govt => catalog::by_security(&sec).and_then(|l| l.yahoo).unwrap_or("^TNX").to_string(),
_ => yahoo_symbol(&sec),
},
File diff suppressed because it is too large. Load diff
+8 -51
View File
@@ -37,51 +37,16 @@ pub async fn sleep(d: Duration) {
tokio::time::sleep(d).await;
}
/// Resolves after `d`, through a `setTimeout` set on the first poll and cleared if the sleep is
/// dropped before it fires.
/// Resolves after `d`, through `setTimeout`.
#[cfg(target_arch = "wasm32")]
pub fn sleep(d: Duration) -> impl Future<Output = ()> + Send {
struct Timer(Option<i32>);
impl Drop for Timer {
fn drop(&mut self) {
if let (Some(id), Some(w)) = (self.0, web_sys::window()) {
w.clear_timeout_with_handle(id);
}
}
}
pub async fn sleep(d: Duration) {
let ms = d.as_millis().min(i32::MAX as u128) as i32;
// A promise isn't `Send`. The web build has one thread, so wrapping it is sound, and the provider
// traits' `Send` futures can then wait on it.
send_wrapper::SendWrapper::new(async move {
let mut timer = Timer(None);
let promise = js_sys::Promise::new(&mut |resolve, _| {
timer.0 = web_sys::window()
.and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms).ok());
});
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
timer.0 = None; // it fired
})
}
/// `fut` ran past its deadline.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Elapsed;
impl std::fmt::Display for Elapsed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("deadline has elapsed")
}
}
impl std::error::Error for Elapsed {}
/// Run `fut` for at most `d`.
pub async fn timeout<F: Future>(d: Duration, fut: F) -> Result<F::Output, Elapsed> {
let (fut, deadline) = (std::pin::pin!(fut), std::pin::pin!(sleep(d)));
match futures::future::select(fut, deadline).await {
futures::future::Either::Left((out, _)) => Ok(out),
futures::future::Either::Right(_) => Err(Elapsed),
}
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
}
/// A periodic timer: the first tick completes at once, later ones every `period`. A late
@@ -121,12 +86,4 @@ mod tests {
assert!(t0.elapsed() >= Duration::from_millis(40));
assert_eq!(blocking(|| 6 * 7).await, Some(42));
}
#[tokio::test]
async fn timeout_stops_at_the_deadline() {
assert_eq!(timeout(Duration::from_secs(5), async { 7 }).await, Ok(7));
let t0 = Instant::now();
assert_eq!(timeout(Duration::from_millis(20), std::future::pending::<()>()).await, Err(Elapsed));
assert!(t0.elapsed() >= Duration::from_millis(20));
}
}
+2 -25
View File
@@ -5,7 +5,7 @@ use async_trait::async_trait;
use tokio::sync::mpsc;
use tr_core::{Bar, Timeframe, sample};
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, Quote, is_perp, perp_needs_server, rt, security};
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, rt, security};
/// Deterministic synthetic data anchored at a fixed "now" per process, so paging is
/// consistent: page N+1 is exactly the bars preceding page N. Levels and volatility
@@ -48,48 +48,25 @@ impl MarketData for SampleProvider {
"sample"
}
/// HIP-3 perps have no sample data: only the Terminal server provides them.
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
if is_perp(symbol) {
return Err(perp_needs_server(symbol));
}
Ok(crate::clip(self.bars(symbol, tf), end, limit))
}
/// From daily history, like the default, but leaving out perps instead of failing on them.
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
let mut out = Vec::with_capacity(symbols.len());
for s in symbols.iter().filter(|s| !is_perp(s)) {
let bars = self.history(s, Timeframe::D1, None, 2).await?;
if let Some(last) = bars.last() {
let prev = if bars.len() > 1 { bars[bars.len() - 2].close } else { last.open };
out.push(Quote { symbol: s.clone(), last: last.close, prev_close: prev, ts: last.ts, spark: vec![] });
}
}
Ok(out)
}
/// A synthetic chain priced with Black-Scholes around the last price.
async fn options(&self, symbol: &str, expiration: Option<i64>) -> Result<OptionChain> {
if is_perp(symbol) {
return Err(perp_needs_server(symbol));
}
let spot = self.bars(symbol, Timeframe::D1).last().map(|b| b.close).unwrap_or(100.0);
Ok(simulated_chain(symbol, spot, expiration, chrono::Utc::now().timestamp()))
}
/// A synthetic book around the last price; sizes vary with the clock so it moves.
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
if is_perp(symbol) {
return Err(perp_needs_server(symbol));
}
let last = self.bars(symbol, Timeframe::M1).last().map(|b| b.close).unwrap_or(100.0);
Ok(simulated_book(symbol, last, depth, chrono::Utc::now().timestamp_millis() as u64 / 500))
}
/// Random-walk ticks on the last bar every 400 ms, rolling into new bars on schedule.
fn live(&self, symbol: &str, tf: Timeframe) -> Option<LiveFeed> {
if !self.live_ticks || is_perp(symbol) {
if !self.live_ticks {
return None;
}
let mut bar = *self.bars(symbol, tf).last()?;
+1 -1
View File
@@ -113,7 +113,7 @@ pub struct TreasuryProvider {
impl TreasuryProvider {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (compatible; terminal)")
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
.timeout(Duration::from_secs(30))
.build()?;
Ok(Self { client, years: Mutex::new(HashMap::new()) })
+3 -84
View File
@@ -3,12 +3,7 @@
//! A page can only read sources that allow cross-origin requests. Binance and Coinbase do,
//! so crypto is live: REST history, quotes and order books, and websocket bars. Yahoo,
//! Treasury.gov, Alpaca and CoinDesk do not, so every other asset class comes from the
//! sample generator and there are no headlines or network search. HIP-3 perps come only from
//! the Terminal server, so here they have no data at all.
//!
//! When the page has a Terminal server ([`server_url`]), [`crate::for_browser`] puts
//! [`crate::remote::RemoteMarkets`] in front of these, so crypto comes from the server and
//! from Binance and Coinbase only while the server can't serve it.
//! sample generator and there are no headlines or network search.
//!
//! TODO(web proxy): route equities, FX, indices, futures, Treasuries and news through a
//! same-origin proxy to those sources, then use [`crate::markets::Markets`] here instead.
@@ -19,7 +14,7 @@ use tr_core::{AssetClass, Bar, Timeframe};
use crate::crypto::CryptoProvider;
use crate::sample::SampleProvider;
use crate::{LiveFeed, MarketData, OrderBook, Quote, YieldCurves, is_perp, perp_needs_server, security};
use crate::{LiveFeed, MarketData, OrderBook, Quote, YieldCurves, security};
pub struct WebMarkets {
crypto: CryptoProvider,
@@ -32,38 +27,6 @@ impl WebMarkets {
}
}
/// The Terminal server a page uses for crypto markets: `built`, the URL set when the page was
/// built, unless the page's `?server=` parameter (`requested`) turns it off (`off`) or names a
/// server on the same machine, such as `ws://localhost:8787/ws`. Other servers are refused, so a
/// link can't point someone's page at a server that isn't ours.
pub fn server_url(built: Option<&str>, requested: Option<&str>) -> Option<String> {
let built = built.map(str::trim).filter(|u| !u.is_empty()).map(str::to_string);
let Some(requested) = requested.map(str::trim) else { return built };
if requested.eq_ignore_ascii_case("off") {
return None;
}
if is_local(requested) {
return Some(requested.to_string());
}
tracing::warn!("?server= takes a server on this machine (ws://localhost:8787/ws) or off; ignoring it");
built
}
/// A `ws://` or `wss://` URL whose host is this machine. Anything unusual (user info, other
/// spellings of the address) counts as not local.
fn is_local(url: &str) -> bool {
let Some(rest) = url.strip_prefix("ws://").or_else(|| url.strip_prefix("wss://")) else { return false };
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
if authority.contains('@') {
return false;
}
let host = match authority.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => authority.split(':').next().unwrap_or_default(),
};
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
fn is_crypto(symbol: &str) -> bool {
security(symbol).class == AssetClass::Crypto
}
@@ -75,9 +38,6 @@ impl MarketData for WebMarkets {
}
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
if is_perp(symbol) {
return Err(perp_needs_server(symbol));
}
if is_crypto(symbol) {
self.crypto.history(&security(symbol), tf, end, limit).await
} else {
@@ -86,8 +46,7 @@ impl MarketData for WebMarkets {
}
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
let listed = symbols.iter().filter(|s| !is_perp(s)).cloned();
let (crypto, other): (Vec<String>, Vec<String>) = listed.partition(|s| is_crypto(s));
let (crypto, other): (Vec<String>, Vec<String>) = symbols.iter().cloned().partition(|s| is_crypto(s));
let mut out = self.sample.quotes(&other).await?;
if !crypto.is_empty() {
let secs: Vec<_> = crypto.iter().map(|k| (k.clone(), security(k))).collect();
@@ -134,44 +93,4 @@ mod tests {
assert_eq!(m.yield_curves().await.unwrap().current.points.len(), 11);
assert!(is_crypto("BTCUSD Curncy") && !is_crypto("US10Y Govt"));
}
#[tokio::test]
async fn perps_need_the_server() {
let m = WebMarkets::new().unwrap();
assert!(m.history("xyz:AAPL", Timeframe::D1, None, 50).await.is_err());
assert!(m.order_book("xyz:AAPL", 10).await.is_err());
let q = m.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
}
#[test]
fn server_url_from_the_build_or_a_local_override() {
let prod = Some("wss://terminal-gateway.style.dev/ws");
assert_eq!(server_url(prod, None).as_deref(), prod);
assert_eq!(server_url(Some(" "), None), None, "an empty build setting means no server");
assert_eq!(server_url(None, None), None);
assert_eq!(server_url(prod, Some("off")), None);
for local in ["ws://localhost:8787/ws", "ws://127.0.0.1:9", "wss://[::1]:8787/ws", "ws://LOCALHOST/ws"] {
assert_eq!(server_url(prod, Some(local)).as_deref(), Some(local));
assert_eq!(server_url(None, Some(local)).as_deref(), Some(local));
}
for refused in [
"wss://evil.example/ws",
"ws://localhost.evil.example/ws",
"ws://[email protected]/ws",
"ws://evil.example:80@localhost/ws",
"http://localhost:8787/ws",
"",
] {
assert_eq!(server_url(prod, Some(refused)).as_deref(), prod, "{refused}");
assert_eq!(server_url(None, Some(refused)), None, "{refused}");
}
}
#[tokio::test]
async fn the_server_goes_in_front_of_the_browser_sources() {
assert_eq!(crate::for_browser(false, None).name(), "binance+sample");
assert_eq!(crate::for_browser(false, Some("ws://127.0.0.1:9/ws")).name(), "server+binance+sample");
assert_eq!(crate::for_browser(true, Some("ws://127.0.0.1:9/ws")).name(), "sample");
}
}
+19 -75
View File
@@ -1,45 +1,18 @@
//! A websocket client: tokio-tungstenite on the desktop, the browser's `WebSocket` on the web.
use anyhow::Result;
//! A text-message websocket client: tokio-tungstenite on the desktop, the browser's
//! `WebSocket` on the web.
#[cfg(not(target_arch = "wasm32"))]
pub use native::Socket;
#[cfg(target_arch = "wasm32")]
pub use web::Socket;
/// A data message. Control frames stay inside the socket.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
Text(String),
Binary(Vec<u8>),
}
impl Socket {
pub async fn send(&mut self, text: String) -> Result<()> {
self.send_message(Message::Text(text)).await
}
/// The next text message, skipping binary ones; `None` once the server closes the connection.
pub async fn next(&mut self) -> Option<Result<String>> {
loop {
match self.next_message().await? {
Ok(Message::Text(t)) => return Some(Ok(t)),
Ok(Message::Binary(_)) => {}
Err(e) => return Some(Err(e)),
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
mod native {
use anyhow::{Context, Result};
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use super::Message;
pub struct Socket(WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>);
impl Socket {
@@ -48,32 +21,22 @@ mod native {
Ok(Self(ws))
}
pub async fn send_message(&mut self, m: Message) -> Result<()> {
let m = match m {
Message::Text(t) => WsMessage::Text(t.into()),
Message::Binary(b) => WsMessage::Binary(b.into()),
};
Ok(self.0.send(m).await?)
pub async fn send(&mut self, text: String) -> Result<()> {
Ok(self.0.send(Message::Text(text.into())).await?)
}
/// The next data message; `None` once the server closes the connection.
pub async fn next_message(&mut self) -> Option<Result<Message>> {
/// The next text message; `None` once the server closes the connection.
pub async fn next(&mut self) -> Option<Result<String>> {
while let Some(msg) = self.0.next().await {
match msg {
Ok(WsMessage::Text(t)) => return Some(Ok(Message::Text(t.as_str().to_owned()))),
Ok(WsMessage::Binary(b)) => return Some(Ok(Message::Binary(b.into()))),
Ok(WsMessage::Close(_)) => return None,
Ok(Message::Text(t)) => return Some(Ok(t.as_str().to_owned())),
Ok(Message::Close(_)) => return None,
Ok(_) => {}
Err(e) => return Some(Err(e.into())),
}
}
None
}
/// Send a close frame.
pub async fn close(&mut self) {
let _ = self.0.close(None).await;
}
}
}
@@ -83,13 +46,11 @@ mod web {
use tokio::sync::mpsc;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
use super::Message;
use web_sys::{CloseEvent, MessageEvent, WebSocket};
enum Event {
Open,
Message(Message),
Text(String),
Error,
Closed,
}
@@ -107,8 +68,6 @@ mod web {
impl Socket {
pub async fn connect(url: &str) -> Result<Self> {
let ws = WebSocket::new(url).map_err(|e| anyhow!("websocket {url}: {e:?}"))?;
// Binary frames arrive as ArrayBuffers rather than Blobs, which can only be read asynchronously.
ws.set_binary_type(BinaryType::Arraybuffer);
let (tx, rx) = mpsc::unbounded_channel();
let t = tx.clone();
let on_open = Closure::<dyn FnMut()>::new(move || {
@@ -116,15 +75,9 @@ mod web {
});
let t = tx.clone();
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
let data = e.data();
let msg = if let Some(text) = data.as_string() {
Message::Text(text)
} else if let Some(buf) = data.dyn_ref::<js_sys::ArrayBuffer>() {
Message::Binary(js_sys::Uint8Array::new(buf).to_vec())
} else {
return;
};
let _ = t.send(Event::Message(msg));
if let Some(text) = e.data().as_string() {
let _ = t.send(Event::Text(text));
}
});
let t = tx.clone();
let on_error = Closure::<dyn FnMut()>::new(move || {
@@ -145,27 +98,18 @@ mod web {
}
}
pub async fn send_message(&mut self, m: Message) -> Result<()> {
match m {
Message::Text(t) => self.ws.send_with_str(&t),
Message::Binary(b) => self.ws.send_with_u8_array(&b),
}
.map_err(|e| anyhow!("websocket send: {e:?}"))
pub async fn send(&mut self, text: String) -> Result<()> {
self.ws.send_with_str(&text).map_err(|e| anyhow!("websocket send: {e:?}"))
}
/// The next data message; `None` once the server closes the connection.
pub async fn next_message(&mut self) -> Option<Result<Message>> {
/// The next text message; `None` once the server closes the connection.
pub async fn next(&mut self) -> Option<Result<String>> {
match self.rx.recv().await? {
Event::Message(m) => Some(Ok(m)),
Event::Text(t) => Some(Ok(t)),
Event::Error => Some(Err(anyhow!("websocket error"))),
Event::Open | Event::Closed => None,
}
}
/// Send a close frame.
pub async fn close(&mut self) {
let _ = self.ws.close();
}
}
impl Drop for Socket {
+2 -2
View File
@@ -25,7 +25,7 @@ impl YahooProvider {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
// Yahoo rejects requests without a browser-like user agent.
.user_agent("Mozilla/5.0 (compatible; terminal)")
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
.timeout(Duration::from_secs(20))
.cookie_store(true)
.build()?;
@@ -412,7 +412,7 @@ pub fn yahoo_symbol(sec: &Security) -> String {
Some((b, q)) => format!("{b}-{}", if q == "USDT" { "USD" } else { q }),
None => sec.ticker.clone(),
},
AssetClass::Govt | AssetClass::Cmdty | AssetClass::Perp => sec.ticker.clone(),
AssetClass::Govt | AssetClass::Cmdty => sec.ticker.clone(),
}
}
-31
View File
@@ -1,31 +0,0 @@
[package]
name = "tr-hl"
description = "Hyperliquid order entry on testnet: L1 and user-signed signing, actions, asset ids, rounding, hard caps, info/exchange clients and the user stream"
version.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
tracing.workspace = true
alloy-primitives = { version = "1", default-features = false, features = ["std", "k256"] }
alloy-sol-types = { version = "1", default-features = false, features = ["std", "eip712-serde"] }
k256 = { version = "0.13", default-features = false, features = ["std", "ecdsa"] }
rand_core = { version = "0.6", features = ["getrandom"] }
rmp-serde = "1"
zeroize = "1"
web-time = "1"
futures = "0.3"
# Key generation on the web reads the browser's crypto.getRandomValues.
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
[dev-dependencies]
serde = { workspace = true, features = ["derive"] }
# The built-in wallet's approval, end to end against Turnkey's local stand-in.
tr-turnkey = { workspace = true, features = ["mock"] }
tokio = { workspace = true, features = ["macros", "rt", "net", "io-util"] }
tracing-subscriber.workspace = true
-247
View File
@@ -1,247 +0,0 @@
//! A live check of this crate's signatures against Hyperliquid **testnet**, with throwaway
//! keys. Never run in CI. It only uses the crate's public API, so every order goes through
//! the same caps as the app.
//!
//! ```text
//! # keys: a 0600 JSON file outside the repo, {"main": "0x…", "agent": "0x…"}
//! cargo run -p tr-hl --example testnet_check -- --generate /tmp/hl-throwaway/keys.json
//! cargo run -p tr-hl --example testnet_check -- /tmp/hl-throwaway/keys.json
//! ```
//!
//! It never prints a key or a signature, and masks every address (`0x1234…abcd`). Without
//! test USDC the account doesn't exist on Hyperliquid, so the expected answers are business
//! rejections. Hyperliquid reports a signature it can't match as "User or API Wallet 0x… does
//! not exist", where 0x… is the address it recovered: a match with the signer means the
//! signature is right. A deliberately corrupted signature is sent as the control.
use std::path::Path;
use serde_json::{Value, json};
use tr_hl::SigningKey;
use tr_hl::actions::{Tif, cloid};
use tr_hl::client::{Client, ExchangeResponse, SendError, recovered_address};
use tr_hl::key::{AgentKey, MainKey, mask_address};
use tr_hl::nonce::{NonceManager, now_ms};
use tr_hl::trader::*;
/// Replace every `0x` + 40 hex digits with its masked form.
fn mask(text: &str) -> String {
tr_hl::mask_addresses(text)
}
fn generate(path: &Path) -> anyhow::Result<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let dir = path.parent().unwrap();
std::fs::create_dir_all(dir)?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
let (main, agent) = (AgentKey::generate(), AgentKey::generate());
let text = zeroize::Zeroizing::new(json!({"main": *main.to_hex(), "agent": *agent.to_hex()}).to_string());
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?;
f.write_all(text.as_bytes())?;
println!(
"wrote throwaway keys (0600) for main {} and agent {}",
mask_address(&main.address()),
mask_address(&agent.address())
);
Ok(())
}
struct Check {
client: Client,
out: Vec<String>,
}
impl Check {
fn say(&mut self, s: impl AsRef<str>) {
let line = mask(s.as_ref());
println!("{line}");
self.out.push(line);
}
async fn info(&mut self, label: &str, body: Value) -> Value {
match self.client.info(body).await {
Ok(v) => {
self.say(format!("info {label}: {v}"));
v
}
Err(e) => {
self.say(format!("info {label}: ERROR {e:#}"));
Value::Null
}
}
}
/// Send, and say whom Hyperliquid recovered if it names an address.
fn report(&mut self, label: &str, signer: &alloy_primitives::Address, r: &Result<ExchangeResponse, SendError>) {
match r {
Ok(resp) => {
self.say(format!("{label}: {}", resp.summary()));
if let Some(rec) = resp.first_error().and_then(recovered_address) {
let verdict = if &rec == signer { "MATCHES the signer" } else { "does NOT match the signer" };
self.say(format!(" recovered {} {verdict} {}", mask_address(&rec), mask_address(signer)));
}
}
Err(e) => self.say(format!("{label}: send error: {e}")),
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) == Some("--generate") {
return generate(Path::new(args.get(1).expect("path to the keys file")));
}
let path = args.first().expect("path to the keys file");
let keys: Value = serde_json::from_str(&zeroize::Zeroizing::new(std::fs::read_to_string(path)?))?;
let main = MainKey::from_hex(keys["main"].as_str().unwrap())?;
let agent = AgentKey::from_hex(keys["agent"].as_str().unwrap())?;
drop(keys);
let (m, a) = (main.address(), agent.address());
let mut c = Check { client: Client::new()?, out: Vec::new() };
c.say(format!("testnet check at {} UTC ms {}; main {m:#x}, agent {a:#x}", chrono_like(now_ms()), now_ms()));
// 1. Before approving.
c.info("userRole(main) before", json!({"type": "userRole", "user": format!("{m:#x}")})).await;
c.info("userRole(agent) before", json!({"type": "userRole", "user": format!("{a:#x}")})).await;
c.info("extraAgents(main) before", json!({"type": "extraAgents", "user": format!("{m:#x}")})).await;
// 2. approveAgent, signed by the main key.
let approve = approve_agent(&a, "check", DEFAULT_AGENT_DAYS, now_ms());
c.say(format!(
"approveAgent name {:?}, chain {}, signatureChainId {}",
approve.agent_name, approve.hyperliquid_chain, approve.signature_chain_id
));
let sig = tr_hl::signing::sign_approve_agent(&main, &approve)?;
let r = c.client.send_user_signed(&user_signed_body(&approve, &sig)).await;
c.report("approveAgent (main key)", &m, &r);
// 3. After.
c.info("userRole(main) after", json!({"type": "userRole", "user": format!("{m:#x}")})).await;
c.info("userRole(agent) after", json!({"type": "userRole", "user": format!("{a:#x}")})).await;
c.info("extraAgents(main) after", json!({"type": "extraAgents", "user": format!("{m:#x}")})).await;
// 4. Markets, leverage and testnet mids.
let reg = c.client.registry().await?;
c.say(format!(
"registry: {} markets; xyz is perpDexs index {:?}; BTC id {}; xyz:AAPL id {}",
reg.len(),
reg.dex_index("xyz"),
reg.resolve("BTC").map(|x| x.id).unwrap_or(0),
reg.resolve("xyz:AAPL").map(|x| x.id).unwrap_or(0)
));
let (agent_nonces, main_nonces) = (NonceManager::new(), NonceManager::new());
for coin in ["BTC", "xyz:AAPL"] {
let asset = reg.resolve(coin).map_err(anyhow::Error::msg)?.clone();
let dex = asset.dex.clone();
let mid = c.client.mids(&dex).await?.get(coin).copied();
let data = c.client.asset_data(&m, coin).await?;
let lev = data.as_ref().and_then(|d| d.leverage);
c.say(format!("{coin}: testnet mid {mid:?}, account leverage {lev:?}"));
// 5. Leverage to 3× (agent), as the ticket does before a first order.
let s = sign_update_leverage(&agent, &agent_nonces, &asset, !asset.only_isolated, 3, now_ms())?;
let r = c.client.send(&s).await;
c.report(&format!("updateLeverage {coin} 3× (agent)"), &a, &r);
let lev = c.client.asset_data(&m, coin).await?.and_then(|d| d.leverage);
c.say(format!("{coin}: account leverage now {lev:?}"));
// 6. A small limit far below the market, by the agent and by the main key.
let mid = mid.expect("testnet mid");
let px = mid * 0.6;
let size = 12.0 / px;
let intent = OrderIntent {
coin: coin.into(),
is_buy: true,
size,
kind: OrderKind::Limit { px, tif: Tif::Gtc },
reduce_only: false,
};
let ctx = MarketCtx { mid: Some(mid), leverage: lev };
for (who, signer) in [("agent", a), ("main key", m)] {
let id = cloid(now_ms() as u128);
match plan_order(&reg, &intent, &ctx, &id) {
Ok(plan) => {
c.say(format!(
"{coin} order plan: BUY {} @ {} (notional ${:.2}), cloid {}",
plan.size(),
plan.price(),
plan.notional(),
plan.cloid()
));
let s = if who == "agent" {
sign_order(&agent, &agent_nonces, &plan, &ctx, now_ms())?
} else {
sign_order(&main, &main_nonces, &plan, &ctx, now_ms())?
};
let r = c.client.send(&s).await;
c.report(&format!("order {coin} limit buy (signed by {who})"), &signer, &r);
}
Err(refusal) => {
c.say(format!("{coin} order (signed by {who}): REFUSED before signing: {refusal}"));
// A reduce-only sell far above the market passes the caps whatever the
// leverage, and still exercises the order signature.
let ro = OrderIntent {
is_buy: false,
reduce_only: true,
kind: OrderKind::Limit { px: mid * 1.4, tif: Tif::Gtc },
size: 12.0 / (mid * 1.4),
..intent.clone()
};
let plan = plan_order(&reg, &ro, &ctx, &id).map_err(|e| anyhow::anyhow!("{e}"))?;
c.say(format!(
"{coin} reduce-only plan: SELL {} @ {} (notional ${:.2})",
plan.size(),
plan.price(),
plan.notional()
));
let s = if who == "agent" {
sign_order(&agent, &agent_nonces, &plan, &ctx, now_ms())?
} else {
sign_order(&main, &main_nonces, &plan, &ctx, now_ms())?
};
let r = c.client.send(&s).await;
c.report(&format!("order {coin} reduce-only limit sell (signed by {who})"), &signer, &r);
}
}
}
}
// 7. The control: a damaged agent signature recovers someone else.
let noop = sign_noop(&agent, &agent_nonces, now_ms())?;
let r = c.client.send(&noop.corrupted()).await;
c.report("noop with a CORRUPTED agent signature (control)", &a, &r);
// 8. Cancel an order that doesn't exist, and a noop.
let btc = reg.resolve("BTC").map_err(anyhow::Error::msg)?.clone();
let s = sign_cancel(&agent, &agent_nonces, &btc, 1, now_ms())?;
let r = c.client.send(&s).await;
c.report("cancel BTC oid 1 (agent)", &a, &r);
let r = c.client.send(&sign_noop(&agent, &agent_nonces, now_ms())?).await;
c.report("noop (agent)", &a, &r);
let r = c.client.send(&sign_noop(&main, &main_nonces, now_ms())?).await;
c.report("noop (main key)", &m, &r);
if let Some(out) = args.get(1) {
std::fs::write(out, c.out.join("\n") + "\n")?;
}
Ok(())
}
/// `2026-11-03T01:02:03Z` without a date crate.
fn chrono_like(ms: u64) -> String {
let s = ms / 1000;
let (days, rem) = (s / 86_400, s % 86_400);
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(mo <= 2);
format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
}
-276
View File
@@ -1,276 +0,0 @@
//! Exchange actions as Hyperliquid hashes them.
//!
//! L1 actions are msgpack-encoded with named fields **in declaration order**, and that order
//! is part of the signature: each struct below lists its fields exactly as the Python SDK
//! builds them. Optional fields are left out rather than sent as `null` or `false`
//! (Hyperliquid rejects a cancel hashed with `f: false`, for instance). There is no
//! `builder` field anywhere: this build never charges a builder fee.
use serde::{Deserialize, Serialize};
/// Limit time in force: `Gtc`, `Ioc` or `Alo` (post-only).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Tif {
Gtc,
Ioc,
Alo,
}
impl Tif {
pub fn label(self) -> &'static str {
match self {
Tif::Gtc => "GTC",
Tif::Ioc => "IOC",
Tif::Alo => "Post-only",
}
}
}
/// Trigger kind: take profit or stop loss.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Tpsl {
#[serde(rename = "tp")]
Tp,
#[serde(rename = "sl")]
Sl,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LimitWire {
pub tif: Tif,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TriggerWire {
pub is_market: bool,
pub trigger_px: String,
pub tpsl: Tpsl,
}
/// `{"limit": {...}}` or `{"trigger": {...}}`: one of the two, never both.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrderTypeWire {
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<LimitWire>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trigger: Option<TriggerWire>,
}
impl OrderTypeWire {
pub fn limit(tif: Tif) -> Self {
Self { limit: Some(LimitWire { tif }), trigger: None }
}
pub fn trigger(is_market: bool, trigger_px: String, tpsl: Tpsl) -> Self {
Self { limit: None, trigger: Some(TriggerWire { is_market, trigger_px, tpsl }) }
}
}
/// One order: asset, is buy, price, size, reduce only, type, client order id.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrderWire {
pub a: u32,
pub b: bool,
pub p: String,
pub s: String,
pub r: bool,
pub t: OrderTypeWire,
#[serde(skip_serializing_if = "Option::is_none")]
pub c: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrderAction {
#[serde(rename = "type")]
pub kind: &'static str,
pub orders: Vec<OrderWire>,
pub grouping: &'static str,
}
impl OrderAction {
pub(crate) fn new(orders: Vec<OrderWire>) -> Self {
Self { kind: "order", orders, grouping: "na" }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CancelWire {
pub a: u32,
pub o: u64,
}
/// Cancel by order id. `f` (fast) is left out: hashed as `false` it is rejected, and `true`
/// is refused for trigger orders.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CancelAction {
#[serde(rename = "type")]
pub kind: &'static str,
pub cancels: Vec<CancelWire>,
}
impl CancelAction {
pub fn new(cancels: Vec<CancelWire>) -> Self {
Self { kind: "cancel", cancels }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CancelByCloidWire {
pub asset: u32,
pub cloid: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CancelByCloidAction {
#[serde(rename = "type")]
pub kind: &'static str,
pub cancels: Vec<CancelByCloidWire>,
}
impl CancelByCloidAction {
pub fn new(cancels: Vec<CancelByCloidWire>) -> Self {
Self { kind: "cancelByCloid", cancels }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ModifyAction {
#[serde(rename = "type")]
pub kind: &'static str,
pub oid: u64,
pub order: OrderWire,
}
impl ModifyAction {
pub(crate) fn new(oid: u64, order: OrderWire) -> Self {
Self { kind: "modify", oid, order }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateLeverageAction {
#[serde(rename = "type")]
pub kind: &'static str,
pub asset: u32,
pub is_cross: bool,
pub leverage: u32,
}
impl UpdateLeverageAction {
pub(crate) fn new(asset: u32, is_cross: bool, leverage: u32) -> Self {
Self { kind: "updateLeverage", asset, is_cross, leverage }
}
}
/// The dead man's switch: cancel every open order at `time` (ms), or clear the schedule.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ScheduleCancelAction {
#[serde(rename = "type")]
pub kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<u64>,
}
impl ScheduleCancelAction {
pub fn new(time: Option<u64>) -> Self {
Self { kind: "scheduleCancel", time }
}
}
/// Uses up a nonce without doing anything (invalidates an in-flight action).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NoopAction {
#[serde(rename = "type")]
pub kind: &'static str,
}
impl Default for NoopAction {
fn default() -> Self {
Self { kind: "noop" }
}
}
/// Approve an API wallet. A user-signed action (EIP-712 `HyperliquidTransaction:ApproveAgent`),
/// signed by the main wallet, so it is JSON on the wire and not msgpack-hashed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApproveAgentAction {
#[serde(rename = "type")]
pub kind: String,
pub hyperliquid_chain: String,
/// The chain the wallet signs on, as `0x…` hex: always `0x66eee` (testnet).
pub signature_chain_id: String,
/// Lower-case `0x…`.
pub agent_address: String,
/// `terminal-<device> valid_until <ms>`: always named, never the unnamed slot.
pub agent_name: String,
pub nonce: u64,
}
impl ApproveAgentAction {
pub fn chain_id(&self) -> anyhow::Result<u64> {
let hex = self.signature_chain_id.trim_start_matches("0x");
Ok(u64::from_str_radix(hex, 16)?)
}
}
/// A 128-bit client order id, `0x` and 32 hex digits.
pub fn cloid(n: u128) -> String {
format!("0x{n:032x}")
}
/// Whether `s` is a well-formed client order id.
pub fn valid_cloid(s: &str) -> bool {
s.len() == 34 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_shapes_match_the_exchange_docs() {
let o = OrderWire {
a: 3,
b: true,
p: "50000".into(),
s: "0.0002".into(),
r: false,
t: OrderTypeWire::limit(Tif::Gtc),
c: Some(cloid(1)),
};
let json = serde_json::to_string(&OrderAction::new(vec![o.clone()])).unwrap();
assert_eq!(
json,
r#"{"type":"order","orders":[{"a":3,"b":true,"p":"50000","s":"0.0002","r":false,"t":{"limit":{"tif":"Gtc"}},"c":"0x00000000000000000000000000000001"}],"grouping":"na"}"#
);
assert!(!json.contains("builder"), "no builder fee is ever sent");
let trig = OrderTypeWire::trigger(true, "103".into(), Tpsl::Sl);
assert_eq!(
serde_json::to_string(&trig).unwrap(),
r#"{"trigger":{"isMarket":true,"triggerPx":"103","tpsl":"sl"}}"#
);
let cancel = serde_json::to_string(&CancelAction::new(vec![CancelWire { a: 3, o: 42 }])).unwrap();
assert_eq!(cancel, r#"{"type":"cancel","cancels":[{"a":3,"o":42}]}"#, "no `f` field");
let by = CancelByCloidAction::new(vec![CancelByCloidWire { asset: 750009, cloid: cloid(7) }]);
assert_eq!(
serde_json::to_string(&by).unwrap(),
r#"{"type":"cancelByCloid","cancels":[{"asset":750009,"cloid":"0x00000000000000000000000000000007"}]}"#
);
let lev = serde_json::to_string(&UpdateLeverageAction::new(3, true, 3)).unwrap();
assert_eq!(lev, r#"{"type":"updateLeverage","asset":3,"isCross":true,"leverage":3}"#);
assert_eq!(serde_json::to_string(&ScheduleCancelAction::new(None)).unwrap(), r#"{"type":"scheduleCancel"}"#);
assert_eq!(serde_json::to_string(&NoopAction::default()).unwrap(), r#"{"type":"noop"}"#);
let m = serde_json::to_string(&ModifyAction::new(9, o)).unwrap();
assert!(m.starts_with(r#"{"type":"modify","oid":9,"order":{"a":3,"#), "{m}");
}
#[test]
fn cloids_are_128_bit_hex() {
assert_eq!(cloid(1), "0x00000000000000000000000000000001");
assert!(valid_cloid(&cloid(u128::MAX)));
assert!(!valid_cloid("0x1") && !valid_cloid(&format!("0x{}", "g".repeat(32))));
}
}
-261
View File
@@ -1,261 +0,0 @@
//! Asset ids and trading rules per market, read from the network every session.
//!
//! - A core perp's id is its index in `meta.universe`.
//! - A HIP-3 perp's id is `100000 + dex_index × 10000 + index`, where `dex_index` is the dex's
//! position in `perpDexs` (index 0 is the core dex) and `index` its place in `meta{dex}`.
//!
//! Ids differ between networks and change as dexes are deployed, so none is hard-coded
//! (testnet's `xyz` is dex 65 today, mainnet's is 1). This build trades the core dex and the
//! `xyz` HIP-3 dex only: `para`, `io`, `mkts`, other dexes and spot are refused with a reason.
use std::collections::HashMap;
use anyhow::{Context, Result, bail};
use serde_json::Value;
/// HIP-3 dexes this build trades.
pub const SUPPORTED_DEXES: [&str; 1] = ["xyz"];
/// HIP-3 dexes that exist on mainnet but are refused in this build.
pub const REFUSED_DEXES: [&str; 3] = ["para", "io", "mkts"];
/// One perp market.
#[derive(Debug, Clone, PartialEq)]
pub struct Asset {
/// Exactly as Hyperliquid names it: `BTC`, `kPEPE`, `xyz:AAPL`.
pub coin: String,
pub id: u32,
/// `""` for the core dex, else the HIP-3 dex (`xyz`).
pub dex: String,
pub sz_decimals: u32,
pub max_leverage: u32,
/// Isolated margin only (no cross).
pub only_isolated: bool,
pub delisted: bool,
}
impl Asset {
pub fn is_hip3(&self) -> bool {
!self.dex.is_empty()
}
/// `PERP · HL xyz` for HIP-3, `PERP · HL` for the core dex.
pub fn label(&self) -> String {
if self.is_hip3() { format!("PERP · HL {}", self.dex) } else { "PERP · HL".into() }
}
/// The terminal's market key for charts and quotes: `xyz:AAPL` for a HIP-3 perp, the
/// crypto pair (`BTCUSD Curncy`) for a core perp.
pub fn app_key(&self) -> String {
app_key(&self.coin)
}
}
/// [`Asset::app_key`] for a coin name.
pub fn app_key(coin: &str) -> String {
if coin.contains(':') { coin.to_string() } else { format!("{}USD Curncy", coin.to_ascii_uppercase()) }
}
/// Why a market can't be traded, in words for the ticket.
pub fn refusal(coin: &str) -> Option<String> {
let c = coin.trim();
if c.contains('/') || c.starts_with('@') {
return Some(format!("{c} is a spot market. This build trades Hyperliquid perps only (no spot)."));
}
if let Some((dex, _)) = c.split_once(':') {
let dex = dex.to_ascii_lowercase();
if REFUSED_DEXES.contains(&dex.as_str()) {
return Some(format!(
"{c} is on the {dex} HIP-3 dex. This build trades the core Hyperliquid perps and the xyz dex only."
));
}
if !SUPPORTED_DEXES.contains(&dex.as_str()) {
return Some(format!("{c}: this build trades the core Hyperliquid perps and the xyz dex only."));
}
}
None
}
/// Every tradable perp on the core and `xyz` dexes, by name.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AssetRegistry {
by_name: HashMap<String, Asset>,
/// `(perpDexs index, name)` of the dexes loaded, core first.
dexes: Vec<(usize, String)>,
}
impl AssetRegistry {
/// Build from `perpDexs`, the core `meta`, and `meta{dex}` for each supported dex.
pub fn from_info(perp_dexs: &Value, core_meta: &Value, dex_metas: &[(&str, &Value)]) -> Result<Self> {
let dexes = perp_dexs.as_array().context("perpDexs is not a list")?;
let mut reg = Self::default();
reg.add(0, "", core_meta)?;
for (name, meta) in dex_metas {
if !SUPPORTED_DEXES.contains(name) {
bail!("the {name} dex is not supported in this build");
}
let index = dexes
.iter()
.position(|d| d.get("name").and_then(Value::as_str) == Some(*name))
.with_context(|| format!("perpDexs has no {name} dex"))?;
reg.add(index, name, meta)?;
}
Ok(reg)
}
fn add(&mut self, dex_index: usize, dex: &str, meta: &Value) -> Result<()> {
let universe = meta.get("universe").and_then(Value::as_array).context("meta has no universe")?;
for (i, a) in universe.iter().enumerate() {
let Some(coin) = a.get("name").and_then(Value::as_str) else { continue };
let id = if dex.is_empty() { i } else { 100_000 + dex_index * 10_000 + i };
let int = |k: &str| a.get(k).and_then(Value::as_u64).unwrap_or(0) as u32;
let asset = Asset {
coin: coin.to_string(),
id: u32::try_from(id).context("asset id out of range")?,
dex: dex.to_string(),
sz_decimals: int("szDecimals"),
max_leverage: int("maxLeverage").max(1),
only_isolated: a.get("onlyIsolated").and_then(Value::as_bool).unwrap_or(false),
delisted: a.get("isDelisted").and_then(Value::as_bool).unwrap_or(false),
};
self.by_name.insert(coin.to_ascii_lowercase(), asset);
}
self.dexes.push((dex_index, dex.to_string()));
Ok(())
}
/// The market for `coin` (any case: `btc`, `XYZ:aapl`), or why it can't be traded.
pub fn resolve(&self, coin: &str) -> Result<&Asset, String> {
let c = coin.trim();
if c.is_empty() {
return Err("Enter a market (BTC, xyz:AAPL).".into());
}
if let Some(why) = refusal(c) {
return Err(why);
}
match self.by_name.get(&c.to_ascii_lowercase()) {
Some(a) if a.delisted => Err(format!("{} is delisted on Hyperliquid.", a.coin)),
Some(a) => Ok(a),
None => {
// `AAPL` on Hyperliquid is the xyz perp, never the stock.
let hip3 = format!("xyz:{c}").to_ascii_lowercase();
match self.by_name.get(&hip3).filter(|a| !a.delisted) {
Some(a) => Err(format!(
"{} is not a core Hyperliquid market. Hyperliquid has the perp {} ({}). Trade that instead?",
c.to_ascii_uppercase(),
a.coin,
a.label()
)),
None => Err(format!("{c} is not a Hyperliquid perp on testnet.")),
}
}
}
}
pub fn get(&self, coin: &str) -> Option<&Asset> {
self.by_name.get(&coin.trim().to_ascii_lowercase())
}
/// The loaded dexes, core (`""`) first.
pub fn dexes(&self) -> impl Iterator<Item = &str> {
self.dexes.iter().map(|(_, d)| d.as_str())
}
/// A dex's position in `perpDexs`.
pub fn dex_index(&self, dex: &str) -> Option<usize> {
self.dexes.iter().find(|(_, d)| d == dex).map(|(i, _)| *i)
}
pub fn len(&self) -> usize {
self.by_name.len()
}
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
pub fn assets(&self) -> impl Iterator<Item = &Asset> {
self.by_name.values()
}
}
#[cfg(test)]
pub(crate) mod fixtures {
use super::*;
pub fn json(name: &str) -> Value {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap()
}
/// The registry from testnet responses recorded on 2026-11-02.
pub fn registry() -> AssetRegistry {
AssetRegistry::from_info(
&json("testnet_perp_dexs.json"),
&json("testnet_meta_core.json"),
&[("xyz", &json("testnet_meta_xyz.json"))],
)
.unwrap()
}
}
#[cfg(test)]
mod tests {
use super::fixtures::*;
use super::*;
#[test]
fn ids_come_from_perp_dexs_and_meta() {
let r = registry();
assert_eq!(r.dex_index("xyz"), Some(65), "xyz is dex 65 on testnet (recorded)");
let btc = r.resolve("btc").unwrap();
assert_eq!((btc.id, btc.sz_decimals, btc.max_leverage, btc.dex.as_str()), (3, 5, 40, ""));
let aapl = r.resolve("XYZ:aapl").unwrap();
assert_eq!((aapl.id, aapl.sz_decimals, aapl.coin.as_str()), (750009, 3, "xyz:AAPL"));
assert_eq!(aapl.label(), "PERP · HL xyz");
assert_eq!(aapl.app_key(), "xyz:AAPL");
assert_eq!(btc.app_key(), "BTCUSD Curncy");
assert!(r.resolve("xyz:COIN").unwrap().only_isolated, "isolated-only HIP-3 markets are flagged");
assert_eq!(r.dexes().collect::<Vec<_>>(), ["", "xyz"]);
}
#[test]
fn the_same_market_gets_another_id_on_another_dex_index() {
// perpDexs with xyz at index 1, as on mainnet: ids move with it.
let dexes = serde_json::json!([null, {"name": "xyz"}]);
let r = AssetRegistry::from_info(
&dexes,
&json("testnet_meta_core.json"),
&[("xyz", &json("testnet_meta_xyz.json"))],
)
.unwrap();
assert_eq!(r.resolve("xyz:AAPL").unwrap().id, 110009);
}
#[test]
fn unsupported_markets_are_refused_with_a_reason() {
let r = registry();
for (coin, says) in [
("para:TOTAL2", "para HIP-3 dex"),
("io:OAI", "io HIP-3 dex"),
("mkts:US500", "mkts HIP-3 dex"),
("abc:XYZ", "xyz dex only"),
("HYPE/USDC", "spot"),
("@107", "spot"),
("AAPL", "Hyperliquid has the perp xyz:AAPL (PERP · HL xyz)"),
("NOPE", "not a Hyperliquid perp"),
] {
let e = r.resolve(coin).unwrap_err();
assert!(e.contains(says), "{coin}: {e}");
}
let delisted = r.assets().find(|a| a.delisted).unwrap().coin.clone();
assert!(r.resolve(&delisted).unwrap_err().contains("delisted"));
assert!(
AssetRegistry::from_info(
&json("testnet_perp_dexs.json"),
&json("testnet_meta_core.json"),
&[("para", &json("testnet_meta_xyz.json"))]
)
.is_err()
);
}
}
-83
View File
@@ -1,83 +0,0 @@
//! Hard limits, checked in this crate before anything is signed (the ticket checks them too,
//! but the ticket is not the safety): no order over $100 notional, no leverage above 3×, no
//! market order more than 1% from the mid. They are constants, not settings: nothing in this
//! build can raise them.
use std::fmt;
/// The most an order may be worth: size × the higher of its price and the mid.
pub const MAX_ORDER_NOTIONAL_USD: f64 = 100.0;
/// The most leverage Terminal sets, or places an order under.
pub const MAX_LEVERAGE: u32 = 3;
/// A market order is an IOC limit this far from the mid, at most.
pub const MAX_MARKET_SLIPPAGE: f64 = 0.01;
/// Why an action was refused before signing.
#[derive(Debug, Clone, PartialEq)]
pub enum Refusal {
Notional {
value: f64,
},
Leverage {
value: u32,
},
/// The account's leverage on this market isn't known yet, so an order can't be checked.
LeverageUnknown {
coin: String,
},
Slippage {
pct: f64,
},
/// No mid price, which a market order is priced from.
NoMid {
coin: String,
},
/// Anything else (market not tradable, bad size or price).
Invalid(String),
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Refusal::Notional { value } => write!(
f,
"Order value ${value:.2} is over the ${MAX_ORDER_NOTIONAL_USD:.0} limit for this testnet build."
),
Refusal::Leverage { value } => write!(
f,
"Leverage {value}× is over the {MAX_LEVERAGE}× limit for this testnet build. Set {MAX_LEVERAGE}× or less first."
),
Refusal::LeverageUnknown { coin } => {
write!(f, "Leverage on {coin} isn't known yet; wait for the account to load.")
}
Refusal::Slippage { pct } => {
write!(f, "Slippage {pct:.2}% is over the {:.0}% limit.", MAX_MARKET_SLIPPAGE * 100.0)
}
Refusal::NoMid { coin } => write!(f, "No testnet price for {coin} yet; use a limit order or wait."),
Refusal::Invalid(m) => f.write_str(m),
}
}
}
impl std::error::Error for Refusal {}
pub fn check_notional(value: f64) -> Result<(), Refusal> {
if !value.is_finite() || value > MAX_ORDER_NOTIONAL_USD + 1e-9 {
return Err(Refusal::Notional { value });
}
Ok(())
}
pub fn check_leverage(value: u32) -> Result<(), Refusal> {
if value > MAX_LEVERAGE {
return Err(Refusal::Leverage { value });
}
Ok(())
}
pub fn check_slippage(fraction: f64) -> Result<(), Refusal> {
if !fraction.is_finite() || fraction < 0.0 || fraction > MAX_MARKET_SLIPPAGE + 1e-12 {
return Err(Refusal::Slippage { pct: fraction * 100.0 });
}
Ok(())
}
-386
View File
@@ -1,386 +0,0 @@
//! `/info` and `/exchange` on testnet, over reqwest (native, and `fetch` in the browser).
//!
//! The client has no base-URL setting: it always talks to [`crate::network::API_URL`]. (Unit
//! tests point it at a local mock server through a `cfg(test)` constructor that the app
//! can't call.) Logs carry the action type, nonce, cloid, oid and status, never a request
//! body or a signature.
use alloy_primitives::Address;
use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};
use crate::assets::{AssetRegistry, SUPPORTED_DEXES};
use crate::key::address_hex;
use crate::model::*;
use crate::network::Network;
use crate::trader::Signed;
/// One order's or cancel's result.
#[derive(Debug, Clone, PartialEq)]
pub enum Status {
Resting {
oid: u64,
},
Filled {
oid: u64,
total_sz: f64,
avg_px: f64,
},
/// A cancel, modify or other action that went through.
Success,
Error(String),
Other(String),
}
/// What `/exchange` said.
#[derive(Debug, Clone, PartialEq)]
pub struct ExchangeResponse {
/// `status: "ok"`. Individual orders can still have failed: see `statuses`.
pub ok: bool,
/// `order`, `cancel`, `default`, …
pub kind: Option<String>,
pub statuses: Vec<Status>,
/// The whole request was refused (`status: "err"`).
pub error: Option<String>,
}
impl ExchangeResponse {
pub fn parse(v: &Value) -> Self {
let ok = v["status"].as_str() == Some("ok");
let error = (!ok).then(|| match &v["response"] {
Value::String(s) => s.clone(),
other => other.to_string(),
});
let statuses = v["response"]["data"]["statuses"]
.as_array()
.into_iter()
.flatten()
.map(|s| {
if s.as_str() == Some("success") {
Status::Success
} else if let Some(e) = s["error"].as_str() {
Status::Error(e.to_string())
} else if let Some(oid) = s["resting"]["oid"].as_u64() {
Status::Resting { oid }
} else if let Some(oid) = s["filled"]["oid"].as_u64() {
Status::Filled {
oid,
total_sz: num(&s["filled"]["totalSz"]).unwrap_or(0.0),
avg_px: num(&s["filled"]["avgPx"]).unwrap_or(0.0),
}
} else {
Status::Other(s.to_string())
}
})
.collect();
Self { ok, kind: v["response"]["type"].as_str().map(str::to_string), statuses, error }
}
/// The first error, whether for the request or for an order in it.
pub fn first_error(&self) -> Option<&str> {
self.error.as_deref().or_else(|| {
self.statuses.iter().find_map(|s| match s {
Status::Error(e) => Some(e.as_str()),
_ => None,
})
})
}
/// One line for a toast or a log, addresses masked: `ok (order): resting oid 123`, `err: …`.
pub fn summary(&self) -> String {
crate::key::mask_addresses(&self.summary_raw())
}
fn summary_raw(&self) -> String {
if let Some(e) = &self.error {
return format!("err: {e}");
}
let parts: Vec<String> = self
.statuses
.iter()
.map(|s| match s {
Status::Resting { oid } => format!("resting oid {oid}"),
Status::Filled { oid, total_sz, avg_px } => format!("filled {total_sz} @ {avg_px} (oid {oid})"),
Status::Success => "success".into(),
Status::Error(e) => format!("error: {e}"),
Status::Other(o) => o.clone(),
})
.collect();
format!(
"ok ({}){}",
self.kind.as_deref().unwrap_or("?"),
if parts.is_empty() { String::new() } else { format!(": {}", parts.join("; ")) }
)
}
}
/// `orderStatus`: the order's status, or `None` for `unknownOid`.
pub fn parse_order_status(v: &Value) -> Option<String> {
match v["status"].as_str() {
Some("order") => Some(v["order"]["status"].as_str().unwrap_or("unknown").to_string()),
_ => None,
}
}
/// The address Hyperliquid recovered from a signature it doesn't recognise: "User or API
/// Wallet 0x… does not exist." A match with the signer means the signature is right and the
/// account is just unknown (never funded); a mismatch means the signing is wrong.
pub fn recovered_address(error: &str) -> Option<Address> {
let i = error.find("0x")?;
crate::key::parse_address(error.get(i..i + 42)?).ok()
}
/// Why a send did not clearly succeed.
#[derive(Debug, Clone, PartialEq)]
pub enum SendError {
/// Nothing reached Hyperliquid (couldn't connect).
NotSent(String),
/// Hyperliquid answered with an HTTP error: nothing was done.
Rejected { status: u16, body: String },
/// It may have arrived: check open orders (by cloid) before sending again.
Uncertain(String),
}
impl std::fmt::Display for SendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SendError::NotSent(m) => write!(f, "not sent: {m}"),
SendError::Rejected { status, body } => write!(f, "rejected (HTTP {status}): {body}"),
SendError::Uncertain(m) => write!(f, "status unknown: {m}"),
}
}
}
impl std::error::Error for SendError {}
/// The longest any one request may take, on the desktop and in the browser.
pub const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[derive(Clone)]
pub struct Client {
http: reqwest::Client,
info_url: String,
exchange_url: String,
timeout: std::time::Duration,
}
impl Client {
pub fn new() -> Result<Self> {
Self::with_urls(Network::Testnet.info_url(), Network::Testnet.exchange_url())
}
/// Unit tests only: a local mock server.
#[cfg(test)]
pub(crate) fn mock(base: &str) -> Result<Self> {
Self::with_urls(format!("{base}/info"), format!("{base}/exchange"))
}
fn with_urls(info_url: String, exchange_url: String) -> Result<Self> {
let b = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let b = b.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION")));
Ok(Self { http: b.build()?, info_url, exchange_url, timeout: REQUEST_TIMEOUT })
}
/// Every request has its own deadline. The browser's `fetch` has no client-wide timeout,
/// so a request that never got an answer would otherwise wait forever.
fn post(&self, url: &str) -> reqwest::RequestBuilder {
self.http.post(url).timeout(self.timeout)
}
/// Any `/info` query.
pub async fn info(&self, body: Value) -> Result<Value> {
let kind = body["type"].as_str().unwrap_or("?").to_string();
let r = self.post(&self.info_url).json(&body).send().await.with_context(|| format!("info {kind}"))?;
let status = r.status();
let text = r.text().await.with_context(|| format!("info {kind}"))?;
if !status.is_success() {
return Err(anyhow!("info {kind}: HTTP {status}: {}", text.chars().take(200).collect::<String>()));
}
serde_json::from_str(&text).with_context(|| format!("info {kind}: not JSON"))
}
/// The asset ids and rules for the core and `xyz` dexes, read fresh.
pub async fn registry(&self) -> Result<AssetRegistry> {
let (dexes, core) = futures::join!(self.info(json!({"type": "perpDexs"})), self.info(json!({"type": "meta"})));
let (dexes, core) = (dexes?, core?);
let mut metas = Vec::new();
for dex in SUPPORTED_DEXES {
metas.push((dex, self.info(json!({"type": "meta", "dex": dex})).await?));
}
let refs: Vec<(&str, &Value)> = metas.iter().map(|(d, m)| (*d, m)).collect();
AssetRegistry::from_info(&dexes, &core, &refs)
}
pub async fn clearinghouse(&self, user: &Address, dex: &str) -> Result<Clearinghouse> {
let v = self.info(json!({"type": "clearinghouseState", "user": address_hex(user), "dex": dex})).await?;
Ok(parse_clearinghouse(dex, &v))
}
/// The account's spot USDC (`spotClearinghouseState`), for the Deposit window.
pub async fn spot_usdc(&self, user: &Address) -> Result<crate::model::SpotUsdc> {
let v = self.info(json!({"type": "spotClearinghouseState", "user": address_hex(user)})).await?;
Ok(crate::model::spot_usdc(&v))
}
pub async fn open_orders(&self, user: &Address, dex: &str) -> Result<Vec<OpenOrder>> {
let v = self.info(json!({"type": "frontendOpenOrders", "user": address_hex(user), "dex": dex})).await?;
Ok(parse_open_orders(&v))
}
pub async fn asset_data(&self, user: &Address, coin: &str) -> Result<Option<AssetData>> {
let v = self.info(json!({"type": "activeAssetData", "user": address_hex(user), "coin": coin})).await?;
Ok(parse_asset_data(&v))
}
pub async fn mids(&self, dex: &str) -> Result<std::collections::HashMap<String, f64>> {
let v = self.info(json!({"type": "allMids", "dex": dex})).await?;
Ok(v.as_object().into_iter().flatten().filter_map(|(k, p)| Some((k.clone(), num(p)?))).collect())
}
/// An order's status by client order id: `Some("open" | "filled" | "canceled" | …)`, or
/// `None` when Hyperliquid has never seen it (`unknownOid`).
pub async fn order_status(&self, user: &Address, cloid: &str) -> Result<Option<String>> {
let v = self.info(json!({"type": "orderStatus", "user": address_hex(user), "oid": cloid})).await?;
Ok(parse_order_status(&v))
}
/// `missing`, `user`, `agent`, `vault` or `subAccount`.
pub async fn user_role(&self, user: &Address) -> Result<String> {
let v = self.info(json!({"type": "userRole", "user": address_hex(user)})).await?;
Ok(v["role"].as_str().unwrap_or("unknown").to_string())
}
pub async fn extra_agents(&self, user: &Address) -> Result<Vec<ExtraAgent>> {
Ok(parse_extra_agents(&self.info(json!({"type": "extraAgents", "user": address_hex(user)})).await?))
}
/// Post a signed L1 action.
pub async fn send(&self, signed: &Signed) -> Result<ExchangeResponse, SendError> {
tracing::info!(kind = signed.kind, nonce = signed.nonce, "hyperliquid testnet: sending");
let r = self.post_exchange(&signed.body()).await;
match &r {
Ok(resp) => {
tracing::info!(kind = signed.kind, nonce = signed.nonce, "hyperliquid testnet: {}", resp.summary())
}
Err(e) => tracing::warn!(
kind = signed.kind,
nonce = signed.nonce,
"hyperliquid testnet: {}",
crate::key::mask_addresses(&e.to_string())
),
}
r
}
/// Post a user-signed action body (`approveAgent`).
pub async fn send_user_signed(&self, body: &Value) -> Result<ExchangeResponse, SendError> {
let kind = body["action"]["type"].as_str().unwrap_or("?").to_string();
tracing::info!(kind, "hyperliquid testnet: sending");
let r = self.post_exchange(body).await;
if let Ok(resp) = &r {
tracing::info!(kind, "hyperliquid testnet: {}", resp.summary());
}
r
}
async fn post_exchange(&self, body: &Value) -> Result<ExchangeResponse, SendError> {
let r = self.post(&self.exchange_url).json(body).send().await.map_err(classify)?;
let status = r.status().as_u16();
let text = r.text().await.map_err(|e| SendError::Uncertain(e.to_string()))?;
if (400..500).contains(&status) {
return Err(SendError::Rejected { status, body: text.chars().take(300).collect() });
}
if status >= 500 {
return Err(SendError::Uncertain(format!("HTTP {status}")));
}
let v: Value =
serde_json::from_str(&text).map_err(|_| SendError::Uncertain(format!("unreadable answer: {text}")))?;
Ok(ExchangeResponse::parse(&v))
}
}
/// A failure before any answer: certainly not sent only if the connection never opened.
/// The browser's `fetch` doesn't say, so there every such failure is uncertain.
fn classify(e: reqwest::Error) -> SendError {
#[cfg(not(target_arch = "wasm32"))]
if e.is_connect() || e.is_builder() {
return SendError::NotSent(e.to_string());
}
SendError::Uncertain(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
/// Greptile #37 (f636b7c): the browser's `fetch` has no client-wide timeout, so a request
/// that never got an answer held the worker's turn and KILL waited behind it. Each request
/// now carries its own deadline, which reqwest honours on both targets.
#[test]
fn every_request_carries_its_own_deadline() {
let c = Client::new().unwrap();
for url in [&c.info_url, &c.exchange_url] {
let req = c.post(url).build().unwrap();
assert_eq!(req.timeout(), Some(&REQUEST_TIMEOUT), "{url}");
}
}
/// Against a server that takes the request and never answers, the send ends at the deadline
/// as uncertain, since it may have arrived, and a read ends with an error.
#[tokio::test]
async fn a_request_that_gets_no_answer_ends_at_its_deadline() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((sock, _)) = listener.accept().await {
held.push(sock); // read nothing, answer nothing
}
});
let c = Client { timeout: std::time::Duration::from_millis(300), ..Client::mock(&base).unwrap() };
let t0 = std::time::Instant::now();
let sent = c.post_exchange(&json!({"action": {"type": "noop"}})).await;
assert!(matches!(sent, Err(SendError::Uncertain(_))), "{sent:?}");
assert!(c.user_role(&Address::ZERO).await.is_err());
let took = t0.elapsed();
assert!(took >= std::time::Duration::from_millis(600) && took < std::time::Duration::from_secs(3), "{took:?}");
}
#[test]
fn responses_parse() {
let resting = ExchangeResponse::parse(
&json!({"status": "ok", "response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 77738308}}]}}}),
);
assert_eq!(resting.statuses, [Status::Resting { oid: 77738308 }]);
assert_eq!(resting.summary(), "ok (order): resting oid 77738308");
let min = ExchangeResponse::parse(
&json!({"status": "ok", "response": {"type": "order", "data": {"statuses": [{"error": "Order must have minimum value of $10."}]}}}),
);
assert!(min.ok && min.first_error() == Some("Order must have minimum value of $10."));
let filled = ExchangeResponse::parse(
&json!({"status": "ok", "response": {"type": "order", "data": {"statuses": [{"filled": {"totalSz": "0.02", "avgPx": "1891.4", "oid": 77747314}}]}}}),
);
assert_eq!(filled.statuses, [Status::Filled { oid: 77747314, total_sz: 0.02, avg_px: 1891.4 }]);
let cancel = ExchangeResponse::parse(
&json!({"status": "ok", "response": {"type": "cancel", "data": {"statuses": ["success"]}}}),
);
assert_eq!(cancel.statuses, [Status::Success]);
let err = ExchangeResponse::parse(
&json!({"status": "err", "response": "User or API Wallet 0x5e9ee1089755c3435139848e47e6635505d5a13a does not exist."}),
);
assert!(!err.ok);
assert_eq!(
recovered_address(err.first_error().unwrap()).map(|a| address_hex(&a)).as_deref(),
Some("0x5e9ee1089755c3435139848e47e6635505d5a13a")
);
assert_eq!(
ExchangeResponse::parse(&json!({"status": "ok", "response": {"type": "default"}})).summary(),
"ok (default)"
);
assert_eq!(recovered_address("Insufficient margin"), None);
let found = json!({"status": "order", "order": {"order": {"coin": "BTC", "oid": 1}, "status": "filled", "statusTimestamp": 1}});
assert_eq!(parse_order_status(&found).as_deref(), Some("filled"));
assert_eq!(parse_order_status(&json!({"status": "unknownOid"})), None);
}
}
-230
View File
@@ -1,230 +0,0 @@
//! Private keys that sign: the agent (API wallet) key Terminal generates and keeps, and a
//! main-wallet key for tests and the developer-only `testnet_check` example. The app never
//! holds a main-wallet key: its main wallet is the built-in Turnkey wallet.
//!
//! Neither type is `Clone`, `Serialize` or printable: `Debug` and `Display` show only the
//! masked address. The secret scalar lives in a k256 `SigningKey`, which zeroizes itself on
//! drop, and the only way out of an [`AgentKey`] is [`AgentKey::to_hex`] for the OS secret
//! store, which hands back a buffer that is zeroized when dropped. [`MainKey`] has no way
//! out at all.
use std::fmt;
use alloy_primitives::{Address, B256};
use anyhow::{Result, bail};
use k256::ecdsa::SigningKey as EcdsaKey;
use zeroize::Zeroizing;
mod sealed {
pub trait Sealed {
fn signer(&self) -> &k256::ecdsa::SigningKey;
}
}
/// A key this crate can sign with. Sealed: only [`AgentKey`] and [`MainKey`].
pub trait SigningKey: sealed::Sealed {
fn address(&self) -> Address {
Address::from_private_key(self.signer())
}
}
/// Sign a 32-byte hash (low-s, with the recovery parity), as Ethereum wallets do.
pub(crate) fn sign_hash(key: &impl SigningKey, hash: &B256) -> anyhow::Result<alloy_primitives::Signature> {
let (sig, recid) = key.signer().sign_prehash_recoverable(hash.as_slice())?;
let (r, s) = sig.split_bytes();
Ok(alloy_primitives::Signature::new(
alloy_primitives::U256::from_be_slice(&r),
alloy_primitives::U256::from_be_slice(&s),
recid.is_y_odd(),
))
}
fn parse(hex: &str) -> Result<EcdsaKey> {
let s = Zeroizing::new(hex.trim().trim_start_matches("0x").to_string());
if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
// Never echo the input: it is a secret, or a typo of one.
bail!("a private key is 64 hex digits");
}
let mut bytes = Zeroizing::new([0u8; 32]);
for (i, b) in bytes.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16)?;
}
EcdsaKey::from_slice(bytes.as_slice()).map_err(|_| anyhow::anyhow!("not a valid secp256k1 private key"))
}
/// The API wallet key that signs orders. Generated on this device; approved once by the
/// main wallet; stored only in the OS secret store (or browser memory).
pub struct AgentKey(EcdsaKey);
impl AgentKey {
/// A fresh random key. Never reuse an agent address: rotation always generates one.
pub fn generate() -> Self {
Self(EcdsaKey::random(&mut rand_core::OsRng))
}
/// Load a key the secret store kept (`0x`-prefixed or bare hex).
pub fn from_hex(hex: &str) -> Result<Self> {
parse(hex).map(Self)
}
/// The hex form for the OS secret store. The buffer is wiped when dropped; never log it.
pub fn to_hex(&self) -> Zeroizing<String> {
let bytes = Zeroizing::new(<[u8; 32]>::from(self.0.to_bytes()));
let mut s = Zeroizing::new(String::with_capacity(66));
s.push_str("0x");
for b in bytes.iter() {
s.push_str(&format!("{b:02x}"));
}
s
}
}
impl sealed::Sealed for AgentKey {
fn signer(&self) -> &EcdsaKey {
&self.0
}
}
impl SigningKey for AgentKey {}
/// A main-wallet key for tests and the `testnet_check` example (throwaway testnet keys from
/// a 0600 file). Nothing in the app makes one. It cannot be exported or stored.
pub struct MainKey(EcdsaKey);
impl MainKey {
pub fn from_hex(hex: &str) -> Result<Self> {
parse(hex).map(Self)
}
/// A fresh random key (tests and the testnet check only).
pub fn generate() -> Self {
Self(EcdsaKey::random(&mut rand_core::OsRng))
}
}
impl sealed::Sealed for MainKey {
fn signer(&self) -> &EcdsaKey {
&self.0
}
}
impl SigningKey for MainKey {}
/// `0x1234…abcd`: enough to recognise an address without printing it in full.
pub fn mask_address(a: &Address) -> String {
let s = format!("{a:#x}");
format!("{}…{}", &s[..6], &s[s.len() - 4..])
}
/// `text` with every `0x` + 40 hex digits masked (`0x1234…abcd`), for anything shown or
/// logged: Hyperliquid's errors name full addresses.
pub fn mask_addresses(text: &str) -> String {
let b = text.as_bytes();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < b.len() {
if b[i..].starts_with(b"0x")
&& b.len() >= i + 42
&& b[i + 2..i + 42].iter().all(u8::is_ascii_hexdigit)
&& b.get(i + 42).is_none_or(|c| !c.is_ascii_hexdigit())
&& let Ok(a) = parse_address(&text[i..i + 42])
{
out.push_str(&mask_address(&a));
i += 42;
continue;
}
let ch = text[i..].chars().next().expect("on a char boundary");
out.push(ch);
i += ch.len_utf8();
}
out
}
/// Lower-case `0x…` hex, as Hyperliquid wants addresses.
pub fn address_hex(a: &Address) -> String {
format!("{a:#x}")
}
/// Parse a `0x…` address in any case.
pub fn parse_address(s: &str) -> Result<Address> {
let t = s.trim();
let hex = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")).unwrap_or(t);
if hex.len() != 40 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
bail!("an address is 0x followed by 40 hex digits");
}
Ok(hex.to_ascii_lowercase().parse::<Address>()?)
}
macro_rules! redacted {
($t:ident) => {
impl fmt::Debug for $t {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, concat!(stringify!($t), "({})"), mask_address(&Address::from_private_key(&self.0)))
}
}
impl fmt::Display for $t {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}
};
}
redacted!(AgentKey);
redacted!(MainKey);
#[cfg(test)]
mod tests {
use super::*;
/// The Python SDK's public test key (signing_test.py), never funded.
const SDK_KEY: &str = "0x0123456789012345678901234567890123456789012345678901234567890123";
#[test]
fn keys_round_trip_through_hex() {
let k = AgentKey::generate();
let back = AgentKey::from_hex(&k.to_hex()).unwrap();
assert_eq!(back.address(), k.address());
let bare = AgentKey::from_hex(SDK_KEY.trim_start_matches("0x")).unwrap();
assert_eq!(bare.address(), AgentKey::from_hex(SDK_KEY).unwrap().address());
assert_ne!(AgentKey::generate().address(), AgentKey::generate().address());
}
#[test]
fn debug_and_display_never_show_the_secret() {
let k = AgentKey::from_hex(SDK_KEY).unwrap();
let m = MainKey::from_hex(SDK_KEY).unwrap();
for s in [format!("{k:?}"), format!("{k}"), format!("{m:?}"), format!("{m}")] {
assert!(!s.contains("0123456789012345678901234567890123456789"), "{s}");
assert!(s.contains('…'), "{s}");
}
let addr = k.address();
assert!(format!("{k:?}").contains(&mask_address(&addr)));
}
#[test]
fn bad_keys_are_refused_without_echoing_them() {
for bad in ["", "0x12", "zz23456789012345678901234567890123456789012345678901234567890123"] {
assert_eq!(AgentKey::from_hex(bad).unwrap_err().to_string(), "a private key is 64 hex digits");
}
let zero = AgentKey::from_hex(&format!("0x{}", "0".repeat(64))).unwrap_err().to_string();
assert_eq!(zero, "not a valid secp256k1 private key");
}
#[test]
fn errors_are_masked_for_display() {
let e = "User or API Wallet 0x5E9EE1089755C3435139848E47E6635505D5A13A does not exist. Must deposit… User: 0x5e9ee1089755c3435139848e47e6635505d5a13a";
assert_eq!(mask_addresses(e), "User or API Wallet 0x5e9e…a13a does not exist. Must deposit… User: 0x5e9e…a13a");
let hash = format!("0x{}", "ab".repeat(32));
assert_eq!(mask_addresses(&hash), hash, "64-digit hashes are not addresses");
assert_eq!(
mask_addresses("cloid 0x0000000000000000000000000000002a ×3"),
"cloid 0x0000000000000000000000000000002a ×3"
);
}
#[test]
fn addresses_parse_and_mask() {
let a = parse_address("0x5E9EE1089755C3435139848E47E6635505D5A13A").unwrap();
assert_eq!(address_hex(&a), "0x5e9ee1089755c3435139848e47e6635505d5a13a");
assert_eq!(mask_address(&a), "0x5e9e…a13a");
assert!(parse_address("0x123").is_err() && parse_address("hello").is_err());
}
}
-34
View File
@@ -1,34 +0,0 @@
//! Order entry on Hyperliquid, **testnet only**.
//!
//! - [`network`]: the testnet endpoints, and the only [`Network`] there is.
//! - [`key`]: the agent (API wallet) key, and a main-wallet key for tests and the
//! developer-only `testnet_check` example, both redacted. The app's main wallet is the
//! built-in Turnkey wallet, which signs through [`signing::sign_approve_agent_with`].
//! - [`signing`]: L1 phantom-agent and user-signed EIP-712 signatures.
//! - [`actions`]: order, cancel, cancelByCloid, modify, updateLeverage, scheduleCancel, noop
//! and approveAgent, with the field order Hyperliquid hashes.
//!
//! Mainnet is not reachable from this crate: there is no mainnet URL, the phantom agent's
//! source is always `"b"`, `hyperliquidChain` is always `"Testnet"`, and
//! `tests/mainnet_impossible.rs` greps the source for mainnet hosts.
pub mod actions;
pub mod assets;
pub mod caps;
pub mod client;
pub mod key;
pub mod model;
pub mod network;
pub mod nonce;
pub mod rounding;
pub mod signing;
pub mod stream;
pub mod trader;
#[cfg(test)]
mod logs_test;
#[cfg(test)]
mod vectors;
pub use key::{AgentKey, MainKey, SigningKey, mask_address, mask_addresses};
pub use network::Network;
-121
View File
@@ -1,121 +0,0 @@
//! A whole place-and-cancel cycle against a local mock `/exchange`, with every `tracing`
//! line captured: nothing in the logs may look like a private key, a signature or a signed
//! payload (any run of 64 or more hex digits).
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::assets::fixtures::registry;
use crate::client::{Client, Status};
use crate::key::AgentKey;
use crate::nonce::NonceManager;
use crate::trader::*;
/// Answers `/exchange` like Hyperliquid: a resting order, then a successful cancel.
async fn mock_exchange() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
tokio::spawn(async move {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
// Read headers and the body (Content-Length).
loop {
let n = sock.read(&mut chunk).await.unwrap_or(0);
if n == 0 {
return;
}
buf.extend_from_slice(&chunk[..n]);
let text = String::from_utf8_lossy(&buf).to_string();
if let Some(h) = text.find("\r\n\r\n") {
let len = text[..h]
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
})
.unwrap_or(0);
if buf.len() >= h + 4 + len {
break;
}
}
}
let req = String::from_utf8_lossy(&buf);
let body = if req.contains(r#""type":"cancel""#) {
r#"{"status":"ok","response":{"type":"cancel","data":{"statuses":["success"]}}}"#
} else {
r#"{"status":"ok","response":{"type":"order","data":{"statuses":[{"resting":{"oid":4242}}]}}}"#
};
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
});
}
});
format!("http://{addr}")
}
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Capture {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn longest_hex_run(s: &str) -> usize {
s.split(|c: char| !c.is_ascii_hexdigit()).map(str::len).max().unwrap_or(0)
}
#[tokio::test]
async fn a_place_and_cancel_cycle_logs_no_secrets() {
let cap = Capture::default();
let writer = cap.clone();
let sub =
tracing_subscriber::fmt().with_max_level(tracing::Level::TRACE).with_writer(move || writer.clone()).finish();
let _guard = tracing::subscriber::set_default(sub);
let base = mock_exchange().await;
let client = Client::mock(&base).unwrap();
let reg = registry();
let key = AgentKey::generate();
let nonces = NonceManager::new();
let ctx = MarketCtx { mid: Some(76_662.5), leverage: Some(Leverage { value: 3, cross: true }) };
let intent = OrderIntent {
coin: "BTC".into(),
is_buy: true,
size: 0.0002,
kind: OrderKind::Limit { px: 50_000.0, tif: crate::actions::Tif::Gtc },
reduce_only: false,
};
let plan = plan_order(&reg, &intent, &ctx, &crate::actions::cloid(7)).unwrap();
let signed = sign_order(&key, &nonces, &plan, &ctx, crate::nonce::now_ms()).unwrap();
// Whatever a careless caller might log, too.
tracing::info!("key {key:?} / {key} / signed {signed:?} / plan cloid {}", plan.cloid());
let placed = client.send(&signed).await.unwrap();
assert_eq!(placed.statuses, [Status::Resting { oid: 4242 }]);
let cancel = sign_cancel(&key, &nonces, reg.resolve("BTC").unwrap(), 4242, crate::nonce::now_ms()).unwrap();
let cancelled = client.send(&cancel).await.unwrap();
assert_eq!(cancelled.statuses, [Status::Success]);
let logs = String::from_utf8(cap.0.lock().unwrap().clone()).unwrap();
assert!(logs.contains("resting oid 4242") && logs.contains("success"), "the cycle was logged:\n{logs}");
let secret = key.to_hex();
assert!(!logs.contains(&secret[2..]), "the private key is in the logs");
assert!(
!logs.contains(&signed.signature().r[2..]) && !logs.contains(&signed.signature().s[2..]),
"a signature is in the logs"
);
assert!(longest_hex_run(&logs) < 64, "a 64-hex-digit secret-like run is in the logs:\n{logs}");
}
-365
View File
@@ -1,365 +0,0 @@
//! Account data from `/info` and the user stream: positions and margin per dex, open orders,
//! fills, funding and per-market leverage. Hyperliquid sends decimals as strings; anything
//! missing or malformed becomes `None` or 0 rather than failing the whole message.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::trader::Leverage;
pub(crate) fn num(v: &Value) -> Option<f64> {
match v {
Value::String(s) => s.trim().parse().ok(),
Value::Number(n) => n.as_f64(),
_ => None,
}
.filter(|x: &f64| x.is_finite())
}
fn text(v: &Value) -> String {
v.as_str().unwrap_or_default().to_string()
}
fn leverage(v: &Value) -> Option<Leverage> {
let value = v.get("value")?.as_u64()? as u32;
Some(Leverage { value, cross: v.get("type").and_then(Value::as_str) == Some("cross") })
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Margin {
pub account_value: f64,
pub total_ntl_pos: f64,
pub total_margin_used: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PerpPosition {
pub coin: String,
/// Signed size: negative is short.
pub szi: f64,
pub entry_px: Option<f64>,
pub position_value: f64,
pub unrealized_pnl: f64,
pub return_on_equity: f64,
pub liquidation_px: Option<f64>,
pub margin_used: f64,
pub leverage: Option<Leverage>,
pub cum_funding: f64,
}
impl PerpPosition {
/// The mark implied by value and size.
pub fn mark(&self) -> Option<f64> {
(self.szi.abs() > 0.0).then(|| self.position_value / self.szi.abs())
}
}
/// `clearinghouseState` for one dex.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Clearinghouse {
pub dex: String,
pub margin: Margin,
pub withdrawable: f64,
pub positions: Vec<PerpPosition>,
pub time: u64,
}
pub fn parse_clearinghouse(dex: &str, v: &Value) -> Clearinghouse {
let m = &v["marginSummary"];
let positions = v["assetPositions"]
.as_array()
.into_iter()
.flatten()
.filter_map(|a| {
let p = &a["position"];
let coin = text(&p["coin"]);
let szi = num(&p["szi"])?;
(!coin.is_empty()).then(|| PerpPosition {
coin,
szi,
entry_px: num(&p["entryPx"]),
position_value: num(&p["positionValue"]).unwrap_or(0.0),
unrealized_pnl: num(&p["unrealizedPnl"]).unwrap_or(0.0),
return_on_equity: num(&p["returnOnEquity"]).unwrap_or(0.0),
liquidation_px: num(&p["liquidationPx"]),
margin_used: num(&p["marginUsed"]).unwrap_or(0.0),
leverage: leverage(&p["leverage"]),
cum_funding: num(&p["cumFunding"]["allTime"]).unwrap_or(0.0),
})
})
.collect();
Clearinghouse {
dex: dex.to_string(),
margin: Margin {
account_value: num(&m["accountValue"]).unwrap_or(0.0),
total_ntl_pos: num(&m["totalNtlPos"]).unwrap_or(0.0),
total_margin_used: num(&m["totalMarginUsed"]).unwrap_or(0.0),
},
withdrawable: num(&v["withdrawable"]).unwrap_or(0.0),
positions,
time: v["time"].as_u64().unwrap_or(0),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OpenOrder {
pub coin: String,
pub is_buy: bool,
pub limit_px: f64,
pub sz: f64,
pub orig_sz: f64,
pub oid: u64,
pub timestamp: u64,
pub cloid: Option<String>,
pub reduce_only: bool,
/// `Limit`, `Stop Market`, `Take Profit Market`, …
pub order_type: String,
pub tif: Option<String>,
pub trigger_px: Option<f64>,
}
/// `openOrders` / `frontendOpenOrders` rows (the plain form lacks the type fields).
pub fn parse_open_orders(v: &Value) -> Vec<OpenOrder> {
v.as_array()
.into_iter()
.flatten()
.filter_map(|o| {
Some(OpenOrder {
coin: Some(text(&o["coin"])).filter(|c| !c.is_empty())?,
is_buy: o["side"].as_str() == Some("B"),
limit_px: num(&o["limitPx"])?,
sz: num(&o["sz"]).unwrap_or(0.0),
orig_sz: num(&o["origSz"]).unwrap_or(0.0),
oid: o["oid"].as_u64()?,
timestamp: o["timestamp"].as_u64().unwrap_or(0),
cloid: o["cloid"].as_str().map(str::to_string),
reduce_only: o["reduceOnly"].as_bool().unwrap_or(false),
order_type: Some(text(&o["orderType"])).filter(|t| !t.is_empty()).unwrap_or_else(|| "Limit".into()),
tif: o["tif"].as_str().map(str::to_string),
trigger_px: num(&o["triggerPx"]).filter(|p| *p > 0.0 && o["isTrigger"].as_bool() == Some(true)),
})
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Fill {
pub coin: String,
pub px: f64,
pub sz: f64,
pub is_buy: bool,
pub time: u64,
/// `Open Long`, `Close Short`, …
pub dir: String,
pub closed_pnl: f64,
/// Includes any builder fee (always none from Terminal).
pub fee: f64,
pub oid: u64,
/// The globally unique trade id.
pub tid: u64,
}
pub fn parse_fills(v: &Value) -> Vec<Fill> {
v.as_array()
.into_iter()
.flatten()
.filter_map(|f| {
Some(Fill {
coin: Some(text(&f["coin"])).filter(|c| !c.is_empty())?,
px: num(&f["px"])?,
sz: num(&f["sz"])?,
is_buy: f["side"].as_str() == Some("B"),
time: f["time"].as_u64().unwrap_or(0),
dir: text(&f["dir"]),
closed_pnl: num(&f["closedPnl"]).unwrap_or(0.0),
fee: num(&f["fee"]).unwrap_or(0.0),
oid: f["oid"].as_u64().unwrap_or(0),
tid: f["tid"].as_u64()?,
})
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Funding {
pub time: u64,
pub coin: String,
pub usdc: f64,
pub szi: f64,
pub rate: f64,
}
pub fn parse_fundings(v: &Value) -> Vec<Funding> {
v.as_array()
.into_iter()
.flatten()
.filter_map(|f| {
Some(Funding {
time: f["time"].as_u64()?,
coin: text(&f["coin"]),
usdc: num(&f["usdc"]).unwrap_or(0.0),
szi: num(&f["szi"]).unwrap_or(0.0),
rate: num(&f["fundingRate"]).unwrap_or(0.0),
})
})
.collect()
}
/// `activeAssetData`: the account's leverage and what it can trade on one market.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AssetData {
pub coin: String,
pub leverage: Option<Leverage>,
/// `[buy, sell]`.
pub max_trade_szs: [f64; 2],
pub available_to_trade: [f64; 2],
pub mark_px: Option<f64>,
}
pub fn parse_asset_data(v: &Value) -> Option<AssetData> {
let pair = |k: &str| [num(&v[k][0]).unwrap_or(0.0), num(&v[k][1]).unwrap_or(0.0)];
Some(AssetData {
coin: Some(text(&v["coin"])).filter(|c| !c.is_empty())?,
leverage: leverage(&v["leverage"]),
max_trade_szs: pair("maxTradeSzs"),
available_to_trade: pair("availableToTrade"),
mark_px: num(&v["markPx"]),
})
}
/// A change to one order (`orderUpdates`): `open`, `filled`, `canceled`, `rejected`, …
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OrderUpdate {
pub coin: String,
pub oid: u64,
pub cloid: Option<String>,
pub status: String,
pub is_buy: bool,
pub limit_px: f64,
pub sz: f64,
pub time: u64,
}
pub fn parse_order_updates(v: &Value) -> Vec<OrderUpdate> {
v.as_array()
.into_iter()
.flatten()
.filter_map(|u| {
let o = &u["order"];
Some(OrderUpdate {
coin: text(&o["coin"]),
oid: o["oid"].as_u64()?,
cloid: o["cloid"].as_str().map(str::to_string),
status: text(&u["status"]),
is_buy: o["side"].as_str() == Some("B"),
limit_px: num(&o["limitPx"]).unwrap_or(0.0),
sz: num(&o["sz"]).unwrap_or(0.0),
time: u["statusTimestamp"].as_u64().unwrap_or(0),
})
})
.collect()
}
/// An approved API wallet (`extraAgents`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExtraAgent {
pub address: String,
pub name: String,
pub valid_until: Option<u64>,
}
pub fn parse_extra_agents(v: &Value) -> Vec<ExtraAgent> {
v.as_array()
.into_iter()
.flatten()
.filter_map(|a| {
Some(ExtraAgent {
address: a["address"].as_str()?.to_ascii_lowercase(),
name: text(&a["name"]),
valid_until: a["validUntil"].as_u64(),
})
})
.collect()
}
/// USDC in the account's spot balances (`spotClearinghouseState`): what a deposit lands in.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct SpotUsdc {
pub total: f64,
/// Held by open spot orders.
pub hold: f64,
}
pub fn spot_usdc(v: &Value) -> SpotUsdc {
v["balances"]
.as_array()
.into_iter()
.flatten()
.find(|b| b["coin"] == "USDC")
.map(|b| SpotUsdc { total: num(&b["total"]).unwrap_or(0.0), hold: num(&b["hold"]).unwrap_or(0.0) })
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::fixtures::json;
/// The response shape from Hyperliquid's info-endpoint docs (spot), not a recording.
#[test]
fn spot_usdc_is_read_from_the_spot_balances() {
let v: Value = serde_json::from_str(
r#"{"balances":[{"coin":"USDC","token":0,"hold":"1.5","total":"14.625485","entryNtl":"0.0"},
{"coin":"PURR","token":1,"hold":"0","total":"2000","entryNtl":"1234.56"}]}"#,
)
.unwrap();
assert_eq!(spot_usdc(&v), SpotUsdc { total: 14.625485, hold: 1.5 });
assert_eq!(spot_usdc(&serde_json::json!({"balances": []})), SpotUsdc::default());
assert_eq!(spot_usdc(&serde_json::json!({})), SpotUsdc::default());
}
#[test]
fn recorded_testnet_account_parses() {
let c = parse_clearinghouse("xyz", &json("testnet_clearinghouse_xyz.json"));
assert_eq!(c.positions.len(), 3);
let tsla = c.positions.iter().find(|p| p.coin == "xyz:TSLA").unwrap();
assert_eq!((tsla.szi, tsla.entry_px), (-1.483, Some(404.349)));
assert_eq!(tsla.leverage, Some(Leverage { value: 10, cross: true }));
assert!((tsla.mark().unwrap() - 668.92198 / 1.483).abs() < 1e-9);
assert!(c.margin.account_value > 0.0 && c.withdrawable > 0.0);
let o = parse_open_orders(&json("testnet_frontend_open_orders_xyz.json"));
assert_eq!(o.len(), 1);
assert_eq!(
(o[0].coin.as_str(), o[0].is_buy, o[0].limit_px, o[0].oid),
("xyz:AAPL", false, 346.21, 63919996139)
);
assert_eq!((o[0].tif.as_deref(), o[0].trigger_px), (Some("Alo"), None));
assert!(parse_open_orders(&serde_json::json!([{"coin": "BTC"}])).is_empty(), "incomplete rows are skipped");
}
#[test]
fn streams_and_agents_parse() {
let a = parse_asset_data(&serde_json::json!({
"coin": "BTC", "leverage": {"type": "isolated", "value": 3},
"maxTradeSzs": ["0.1", "0.2"], "availableToTrade": ["10", "20"], "markPx": "76613.0"
}))
.unwrap();
assert_eq!(a.leverage, Some(Leverage { value: 3, cross: false }));
assert_eq!((a.max_trade_szs, a.mark_px), ([0.1, 0.2], Some(76613.0)));
let u = parse_order_updates(&serde_json::json!([{
"order": {"coin": "BTC", "side": "B", "limitPx": "50000", "sz": "0.0002", "oid": 7, "timestamp": 1, "origSz": "0.0002", "cloid": "0x0000000000000000000000000000002a"},
"status": "canceled", "statusTimestamp": 5
}]));
assert_eq!((u[0].status.as_str(), u[0].oid, u[0].is_buy), ("canceled", 7, true));
let agents =
parse_extra_agents(&serde_json::json!([{"address": "0xABC", "name": "terminal-mac", "validUntil": 9}]));
assert_eq!(
agents[0],
ExtraAgent { address: "0xabc".into(), name: "terminal-mac".into(), valid_until: Some(9) }
);
let f = parse_fundings(
&serde_json::json!([{"time": 1, "coin": "BTC", "usdc": "-0.01", "szi": "0.1", "fundingRate": "0.0001"}]),
);
assert_eq!(f[0].usdc, -0.01);
}
}
-97
View File
@@ -1,97 +0,0 @@
//! The one network this build can reach: Hyperliquid's testnet.
//!
//! There is no mainnet variant, constant or switch. Everything that names a network (the
//! endpoints, the phantom agent's `source`, `hyperliquidChain` in user-signed actions)
//! comes from [`Network::Testnet`], so nothing in this crate can address or sign for
//! mainnet. A preference file that says `"mainnet"` still reads as testnet (see the
//! `Deserialize` impl).
use serde::{Deserialize, Deserializer, Serialize};
/// Testnet REST base (`/info`, `/exchange`).
pub const API_URL: &str = "https://api.hyperliquid-testnet.xyz";
/// Testnet websocket.
pub const WS_URL: &str = "wss://api.hyperliquid-testnet.xyz/ws";
/// The chain id user-signed actions are signed under (as the Python SDK does). It only
/// names the EIP-712 domain the wallet signs in; `hyperliquidChain` picks the network.
pub const SIGNATURE_CHAIN_ID: u64 = 0x66eee;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
pub enum Network {
#[default]
Testnet,
}
impl Network {
pub fn info_url(self) -> String {
format!("{API_URL}/info")
}
pub fn exchange_url(self) -> String {
format!("{API_URL}/exchange")
}
pub fn ws_url(self) -> &'static str {
WS_URL
}
/// The phantom agent's `source` for L1 actions: `"b"` is testnet.
pub fn phantom_source(self) -> &'static str {
"b"
}
/// `hyperliquidChain` in user-signed actions such as `approveAgent`.
pub fn hyperliquid_chain(self) -> &'static str {
"Testnet"
}
/// The badge every surface shows.
pub fn label(self) -> &'static str {
"TESTNET"
}
}
/// Any stored value reads as testnet, including `"mainnet"` or garbage: this build has no
/// other network. Failing the whole preferences file instead would reset unrelated settings.
impl<'de> Deserialize<'de> for Network {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
if v != serde_json::Value::String("Testnet".into()) {
tracing::warn!("Hyperliquid network {v} is not available in this build; using testnet");
}
Ok(Network::Testnet)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoints_are_the_testnet_hosts() {
assert_eq!(API_URL, "https://api.hyperliquid-testnet.xyz");
assert_eq!(WS_URL, "wss://api.hyperliquid-testnet.xyz/ws");
let n = Network::default();
assert_eq!(n.info_url(), "https://api.hyperliquid-testnet.xyz/info");
assert_eq!(n.exchange_url(), "https://api.hyperliquid-testnet.xyz/exchange");
assert_eq!(n.ws_url(), WS_URL);
for url in [n.info_url(), n.exchange_url(), n.ws_url().to_string()] {
assert!(url.contains("hyperliquid-testnet.xyz"), "{url}");
}
}
#[test]
fn signing_names_testnet() {
assert_eq!(Network::Testnet.phantom_source(), "b");
assert_eq!(Network::Testnet.hyperliquid_chain(), "Testnet");
}
#[test]
fn no_stored_value_selects_mainnet() {
for json in [r#""Testnet""#, r#""mainnet""#, r#""Mainnet""#, r#""MAINNET""#, r#""a""#, "1", "null", "{}"] {
let n: Network = serde_json::from_str(json).unwrap();
assert_eq!(n, Network::Testnet, "{json}");
}
assert_eq!(serde_json::to_string(&Network::Testnet).unwrap(), r#""Testnet""#);
}
}
-63
View File
@@ -1,63 +0,0 @@
//! Nonces: one per signed action, strictly increasing per signer, close to the clock.
//!
//! Hyperliquid keeps the 100 highest nonces per signer and accepts a new one if it is larger
//! than the smallest of those and within (now − 2 days, now + 1 day). Using
//! `max(last + 1, now_ms)` keeps every nonce unique even when two actions are signed in the
//! same millisecond.
use std::sync::atomic::{AtomicU64, Ordering};
/// Milliseconds since the Unix epoch (the browser's clock on the web).
pub fn now_ms() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
}
/// The next nonce for one signer. Share one per agent key.
#[derive(Debug, Default)]
pub struct NonceManager {
last: AtomicU64,
}
impl NonceManager {
pub fn new() -> Self {
Self::default()
}
/// `max(last + 1, now)`, atomically.
pub fn next(&self, now: u64) -> u64 {
let mut cur = self.last.load(Ordering::Relaxed);
loop {
let n = (cur + 1).max(now);
match self.last.compare_exchange_weak(cur, n, Ordering::AcqRel, Ordering::Relaxed) {
Ok(_) => return n,
Err(seen) => cur = seen,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nonces_follow_the_clock_and_never_repeat() {
let n = NonceManager::new();
assert_eq!(n.next(1_000), 1_000);
assert_eq!(n.next(1_000), 1_001, "same millisecond");
assert_eq!(n.next(900), 1_002, "a clock that steps back never reuses one");
assert_eq!(n.next(5_000), 5_000);
let shared = std::sync::Arc::new(NonceManager::new());
let handles: Vec<_> = (0..8)
.map(|_| {
let s = shared.clone();
std::thread::spawn(move || (0..500).map(|_| s.next(7)).collect::<Vec<_>>())
})
.collect();
let mut all: Vec<u64> = handles.into_iter().flat_map(|h| h.join().unwrap()).collect();
all.sort();
all.dedup();
assert_eq!(all.len(), 4000, "unique across threads");
assert!(now_ms() > 1_700_000_000_000);
}
}
-138
View File
@@ -1,138 +0,0 @@
//! Tick and lot rules for perps: prices have at most five significant figures and at most
//! `6 − szDecimals` decimals (integers are always valid); sizes have at most `szDecimals`
//! decimals. Wire strings carry no trailing zeros, as the Python SDK's `float_to_wire`.
use anyhow::{Result, bail};
/// Which way to round when a value doesn't fit the grid.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Round {
Nearest,
/// Towards zero, e.g. a market buy's price cap, so slippage never exceeds the cap.
Down,
/// Away from zero, e.g. a market sell's price floor.
Up,
}
fn round_to(x: f64, decimals: i32, dir: Round) -> f64 {
let scale = 10f64.powi(decimals);
let v = x * scale;
// A hair of tolerance so 226.4 stays 226.4 when the float is 226.39999999.
let r = match dir {
Round::Nearest => v.round(),
Round::Down => (v + 1e-7).floor(),
Round::Up => (v - 1e-7).ceil(),
};
r / scale
}
/// The most decimals a perp price may have at this level.
pub fn price_decimals(px: f64, sz_decimals: u32) -> i32 {
let max = 6 - sz_decimals.min(6) as i32;
// log10 of zero (or of nothing) is not a number of digits.
if !px.is_finite() || px == 0.0 {
return max;
}
if px.abs() >= 100_000.0 {
return 0;
}
let sig = 4 - px.abs().log10().floor() as i32;
sig.clamp(0, max)
}
/// A valid perp price: five significant figures and `6 − szDecimals` decimals at most.
pub fn round_price(px: f64, sz_decimals: u32, dir: Round) -> f64 {
if !px.is_finite() || px <= 0.0 {
return px;
}
let d = price_decimals(px, sz_decimals);
let r = round_to(px, d, dir);
if r <= 0.0 {
// Smaller than the smallest tick: the caller refuses a zero price.
return 0.0;
}
// Rounding up can add a digit (99999.5 → 100000); one more pass settles it.
round_to(r, price_decimals(r, sz_decimals), dir)
}
/// A valid size: `szDecimals` decimals, rounded down so an order never grows.
pub fn round_size(sz: f64, sz_decimals: u32) -> f64 {
round_to(sz, sz_decimals as i32, Round::Down)
}
/// Whether `px` is already on the price grid.
pub fn price_ok(px: f64, sz_decimals: u32) -> bool {
px.is_finite() && px > 0.0 && (round_price(px, sz_decimals, Round::Nearest) - px).abs() <= px.abs() * 1e-12
}
/// `float_to_wire`: eight decimals at most, no trailing zeros, never `-0`. Refuses a value
/// that would change by rounding.
pub fn to_wire(x: f64) -> Result<String> {
if !x.is_finite() {
bail!("not a number");
}
let s = format!("{x:.8}");
if (s.parse::<f64>()? - x).abs() >= 1e-12 {
bail!("{x} has more than 8 decimals");
}
let t = s.trim_end_matches('0').trim_end_matches('.');
Ok(if t == "-0" || t.is_empty() { "0".into() } else { t.to_string() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prices_keep_five_significant_figures() {
// xyz:AAPL (szDecimals 3): three decimals at most.
assert_eq!(round_price(226.4, 3, Round::Nearest), 226.4);
assert_eq!(round_price(226.456, 3, Round::Nearest), 226.46, "five significant figures");
assert_eq!(round_price(4.56789, 3, Round::Nearest), 4.568, "and 6 − 3 = 3 decimals");
// BTC (szDecimals 5): one decimal at most, integers always valid.
assert_eq!(round_price(76662.57, 5, Round::Nearest), 76663.0);
assert_eq!(round_price(123456.7, 5, Round::Nearest), 123457.0, "integers are valid past five figures");
assert_eq!(round_price(1234.56, 5, Round::Nearest), 1234.6);
// Small prices.
assert_eq!(round_price(0.0123456, 0, Round::Nearest), 0.012346);
assert_eq!(round_price(0.000123456, 0, Round::Nearest), 0.000123, "six decimals at most");
}
#[test]
fn directed_rounding_never_overshoots() {
assert_eq!(round_price(226.456, 3, Round::Down), 226.45);
assert_eq!(round_price(226.451, 3, Round::Up), 226.46);
assert_eq!(round_price(99999.6, 0, Round::Up), 100000.0);
assert_eq!(round_price(226.4, 3, Round::Down), 226.4, "on-grid values stay put");
assert_eq!(round_price(226.4, 3, Round::Up), 226.4);
}
#[test]
fn zero_and_tiny_prices_never_panic() {
assert_eq!(price_decimals(0.0, 5), 1);
assert_eq!(round_price(0.00000001, 5, Round::Nearest), 0.0);
assert_eq!(round_price(0.00000001, 5, Round::Up), 0.0, "within the grid's tolerance of zero");
assert!(round_price(f64::NAN, 5, Round::Nearest).is_nan());
}
#[test]
fn sizes_round_down_to_lot() {
assert_eq!(round_size(0.123456, 5), 0.12345);
assert_eq!(round_size(0.5, 3), 0.5);
assert_eq!(round_size(0.0009, 3), 0.0, "too small rounds to nothing");
assert_eq!(round_size(0.3, 1), 0.3, "0.3 is not 0.2999…");
}
#[test]
fn wire_strings_match_float_to_wire() {
assert_eq!(to_wire(100.0).unwrap(), "100");
assert_eq!(to_wire(1670.1).unwrap(), "1670.1");
assert_eq!(to_wire(0.0147).unwrap(), "0.0147");
assert_eq!(to_wire(0.0002).unwrap(), "0.0002");
assert_eq!(to_wire(-0.0).unwrap(), "0");
assert_eq!(to_wire(0.0).unwrap(), "0");
assert!(to_wire(0.123456789).is_err(), "would round");
assert!(to_wire(f64::NAN).is_err());
assert!(price_ok(226.4, 3) && !price_ok(226.456, 3));
}
}
-241
View File
@@ -1,241 +0,0 @@
//! Signatures, as the Python SDK's `hyperliquid/utils/signing.py` makes them.
//!
//! **L1 actions** (order, cancel, modify, leverage, schedule cancel, noop): the action is
//! msgpack-encoded with named fields, followed by the nonce (8 bytes, big-endian), `0x00`
//! (no vault) and, when set, `0x00 ‖ expiresAfter`. Its keccak-256 is the `connectionId` of
//! a "phantom agent" `Agent(string source, bytes32 connectionId)`, EIP-712-signed under the
//! domain `Exchange` v1, chain 1337. `source` is always testnet's `"b"`
//! ([`Network::phantom_source`]); nothing here can produce mainnet's.
//!
//! **User-signed actions** (`approveAgent`): EIP-712 over the action's own fields under the
//! domain `HyperliquidSignTransaction` v1 at `signatureChainId`, with `hyperliquidChain`
//! always `"Testnet"`.
//!
//! L1 signing is crate-private: callers go through [`crate::Trader`], which enforces the
//! hard caps before anything is signed.
use alloy_primitives::{Address, B256, Signature, U256, keccak256};
use alloy_sol_types::{SolStruct, eip712_domain, sol};
use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize};
use crate::actions::ApproveAgentAction;
use crate::key::{SigningKey, sign_hash};
use crate::network::{Network, SIGNATURE_CHAIN_ID};
sol! {
struct Agent {
string source;
bytes32 connectionId;
}
}
/// `{r, s, v}` as Hyperliquid takes it: `0x` hex without leading zeros (as the SDK's
/// `to_hex`), and `v` 27 or 28.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireSignature {
pub r: String,
pub s: String,
pub v: u8,
}
impl WireSignature {
fn from_alloy(sig: &Signature) -> Self {
Self { r: format!("{:#x}", sig.r()), s: format!("{:#x}", sig.s()), v: 27 + u8::from(sig.v()) }
}
/// From a wallet's 65-byte `0x` r ‖ s ‖ v hex (`eth_signTypedData_v4`); `v` may be 0/1.
pub fn from_rsv_hex(hex: &str) -> Result<Self> {
let h = hex.trim().trim_start_matches("0x");
ensure!(
h.len() == 130 && h.chars().all(|c| c.is_ascii_hexdigit()),
"the wallet returned a malformed signature"
);
let v = u8::from_str_radix(&h[128..130], 16)?;
let num = |s: &str| U256::from_str_radix(s, 16).map(|n| format!("{n:#x}"));
Ok(Self { r: num(&h[..64])?, s: num(&h[64..128])?, v: if v < 27 { v + 27 } else { v } })
}
fn to_alloy(&self) -> Result<Signature> {
let num = |s: &str| U256::from_str_radix(s.trim_start_matches("0x"), 16).context("signature is not hex");
ensure!(self.v == 27 || self.v == 28, "signature v must be 27 or 28");
Ok(Signature::new(num(&self.r)?, num(&self.s)?, self.v == 28))
}
}
/// The L1 `connectionId`: keccak-256 of msgpack(action) ‖ nonce ‖ `0x00` ‖ [`0x00` ‖ expiresAfter].
pub fn action_hash<A: Serialize>(action: &A, nonce: u64, expires_after: Option<u64>) -> Result<B256> {
let mut data = rmp_serde::to_vec_named(action).context("msgpack-encoding the action")?;
data.extend_from_slice(&nonce.to_be_bytes());
// No vault or sub-account: this build only trades the signer's own account.
data.push(0);
if let Some(t) = expires_after {
data.push(0);
data.extend_from_slice(&t.to_be_bytes());
}
Ok(keccak256(data))
}
/// The EIP-712 hash of the testnet phantom agent for `connection_id`.
pub(crate) fn agent_signing_hash(connection_id: B256) -> B256 {
let domain = eip712_domain! {
name: "Exchange",
version: "1",
chain_id: 1337,
verifying_contract: Address::ZERO,
};
let agent = Agent { source: Network::Testnet.phantom_source().into(), connectionId: connection_id };
agent.eip712_signing_hash(&domain)
}
/// Sign an L1 action for testnet. Crate-private: see [`crate::Trader`].
pub(crate) fn sign_l1<A: Serialize>(
key: &impl SigningKey,
action: &A,
nonce: u64,
expires_after: Option<u64>,
) -> Result<WireSignature> {
let hash = agent_signing_hash(action_hash(action, nonce, expires_after)?);
let sig = sign_hash(key, &hash).context("signing")?;
Ok(WireSignature::from_alloy(&sig))
}
/// The address that signed an L1 action, as Hyperliquid recovers it. A wrong signature
/// recovers some other address rather than failing, which is how Hyperliquid reports it
/// ("User or API Wallet 0x… does not exist").
pub fn recover_l1<A: Serialize>(
action: &A,
nonce: u64,
expires_after: Option<u64>,
sig: &WireSignature,
) -> Result<Address> {
let hash = agent_signing_hash(action_hash(action, nonce, expires_after)?);
Ok(sig.to_alloy()?.recover_address_from_prehash(&hash)?)
}
// ---- user-signed actions -------------------------------------------------------------------
/// One EIP-712 field value, encoded to 32 bytes.
pub(crate) enum Field<'a> {
Str(&'a str),
Addr(Address),
U64(u64),
}
impl Field<'_> {
fn encode(&self) -> B256 {
match self {
Field::Str(s) => keccak256(s.as_bytes()),
Field::Addr(a) => a.into_word(),
Field::U64(n) => B256::from(U256::from(*n)),
}
}
}
/// The EIP-712 signing hash of a `HyperliquidTransaction:*` user-signed action.
pub(crate) fn user_signed_hash(type_string: &str, fields: &[Field<'_>], chain_id: u64) -> B256 {
let mut enc = Vec::with_capacity(32 * (fields.len() + 1));
enc.extend_from_slice(keccak256(type_string.as_bytes()).as_slice());
for f in fields {
enc.extend_from_slice(f.encode().as_slice());
}
let struct_hash = keccak256(&enc);
let domain = eip712_domain! {
name: "HyperliquidSignTransaction",
version: "1",
chain_id: chain_id,
verifying_contract: Address::ZERO,
};
let mut msg = Vec::with_capacity(66);
msg.extend_from_slice(&[0x19, 0x01]);
msg.extend_from_slice(domain.separator().as_slice());
msg.extend_from_slice(struct_hash.as_slice());
keccak256(msg)
}
const APPROVE_AGENT_TYPE: &str =
"HyperliquidTransaction:ApproveAgent(string hyperliquidChain,address agentAddress,string agentName,uint64 nonce)";
/// The hash the main wallet signs to approve an agent. Refuses anything not addressed to
/// testnet: `hyperliquidChain` must be `Testnet` and `signatureChainId` the testnet
/// signature chain ([`SIGNATURE_CHAIN_ID`], 0x66eee), never Arbitrum One's 0xa4b1.
pub fn approve_agent_hash(a: &ApproveAgentAction) -> Result<B256> {
ensure!(a.kind == "approveAgent", "not an approveAgent action");
ensure!(a.hyperliquid_chain == Network::Testnet.hyperliquid_chain(), "this build only approves agents on testnet");
ensure!(
a.chain_id().ok() == Some(SIGNATURE_CHAIN_ID),
"this build only signs at the testnet signature chain ({SIGNATURE_CHAIN_ID:#x})"
);
let agent = crate::key::parse_address(&a.agent_address)?;
let fields = [Field::Str(&a.hyperliquid_chain), Field::Addr(agent), Field::Str(&a.agent_name), Field::U64(a.nonce)];
Ok(user_signed_hash(APPROVE_AGENT_TYPE, &fields, a.chain_id()?))
}
/// The main wallet signs `approveAgent`.
pub fn sign_approve_agent(main: &impl SigningKey, a: &ApproveAgentAction) -> Result<WireSignature> {
let sig = sign_hash(main, &approve_agent_hash(a)?).context("signing")?;
Ok(WireSignature::from_alloy(&sig))
}
/// A main wallet held elsewhere (the built-in Turnkey wallet) signs `approveAgent`.
///
/// `sign` gets only the digest of a testnet approval: [`approve_agent_hash`] refuses
/// anything else before `sign` is called. The answer is used only if it recovers `main`,
/// so a wrong or swapped signature is never sent. This is the only way a remote wallet's
/// signature enters this crate; L1 actions (orders, cancels) are signed by the local agent
/// key alone.
pub async fn sign_approve_agent_with<F, Fut>(a: &ApproveAgentAction, main: &Address, sign: F) -> Result<WireSignature>
where
F: FnOnce(B256) -> Fut,
Fut: std::future::Future<Output = Result<Signature>>,
{
let digest = approve_agent_hash(a)?;
let sig = sign(digest).await.context("the wallet didn't sign")?;
let signer = sig.recover_address_from_prehash(&digest).context("the wallet's signature is malformed")?;
ensure!(
&signer == main,
"the signature is not the wallet's ({}); nothing was sent",
crate::key::mask_address(&signer)
);
Ok(WireSignature::from_alloy(&sig))
}
/// Who signed an `approveAgent`.
pub fn recover_approve_agent(a: &ApproveAgentAction, sig: &WireSignature) -> Result<Address> {
Ok(sig.to_alloy()?.recover_address_from_prehash(&approve_agent_hash(a)?)?)
}
/// The EIP-712 typed data of an approval, as a wallet's `eth_signTypedData_v4` takes it
/// (the tests check it names the same domain and message as the digest).
pub fn approve_agent_typed_data(a: &ApproveAgentAction) -> Result<serde_json::Value> {
approve_agent_hash(a)?;
Ok(serde_json::json!({
"domain": {
"name": "HyperliquidSignTransaction",
"version": "1",
"chainId": a.chain_id()?,
"verifyingContract": "0x0000000000000000000000000000000000000000",
},
"types": {
"HyperliquidTransaction:ApproveAgent": [
{"name": "hyperliquidChain", "type": "string"},
{"name": "agentAddress", "type": "address"},
{"name": "agentName", "type": "string"},
{"name": "nonce", "type": "uint64"},
],
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"},
],
},
"primaryType": "HyperliquidTransaction:ApproveAgent",
"message": {
"hyperliquidChain": a.hyperliquid_chain,
"agentAddress": a.agent_address,
"agentName": a.agent_name,
"nonce": a.nonce,
},
}))
}
-239
View File
@@ -1,239 +0,0 @@
//! The user's own data over Hyperliquid's websocket, straight from the client (never through
//! Terminal's server): what to subscribe to, and how each message changes [`UserState`].
//!
//! `clearinghouseState` and `openOrders` arrive whole for one dex each time, so they replace
//! that dex's entry. `userFills` and `userFundings` start with a snapshot (`isSnapshot`) and
//! then stream additions, de-duplicated by trade id so a reconnect's snapshot never doubles
//! a fill. Messages for any other user are ignored.
//!
//! The socket itself lives with the caller (the app's worker uses `tr_data::ws`), which
//! sends [`subscriptions`], [`PING`] about every 30 s, and feeds every text frame to
//! [`UserState::apply`].
use std::collections::{BTreeMap, HashMap};
use serde_json::{Value, json};
use crate::model::*;
/// Keeps the connection open (Hyperliquid closes it after 60 s without traffic).
pub const PING: &str = r#"{"method":"ping"}"#;
/// Fills kept for `ACTV`.
pub const MAX_FILLS: usize = 500;
/// The subscribe messages for `user` on `dexes` (`""` is the core dex), with leverage for
/// `coins` (the ticket's market) and testnet mids per dex.
pub fn subscriptions<'a>(user: &str, dexes: impl IntoIterator<Item = &'a str>, coins: &[&str]) -> Vec<String> {
let user = user.to_ascii_lowercase();
let mut subs = Vec::new();
for dex in dexes {
subs.push(json!({"type": "clearinghouseState", "user": user, "dex": dex}));
subs.push(json!({"type": "openOrders", "user": user, "dex": dex}));
subs.push(json!({"type": "allMids", "dex": dex}));
}
for t in ["orderUpdates", "userFills", "userFundings"] {
subs.push(json!({"type": t, "user": user}));
}
for coin in coins {
subs.push(json!({"type": "activeAssetData", "user": user, "coin": coin}));
}
subs.into_iter().map(|s| json!({"method": "subscribe", "subscription": s}).to_string()).collect()
}
/// One subscription for leverage on another market (the ticket moved on).
pub fn asset_data_subscription(user: &str, coin: &str, subscribe: bool) -> String {
let method = if subscribe { "subscribe" } else { "unsubscribe" };
json!({"method": method, "subscription": {"type": "activeAssetData", "user": user.to_ascii_lowercase(), "coin": coin}})
.to_string()
}
/// What a message changed, for toasts and repaints.
#[derive(Debug, Clone, PartialEq)]
pub enum Event {
Account,
Orders,
Fills(Vec<Fill>),
Fundings,
AssetData(String),
Mids,
OrderUpdates(Vec<OrderUpdate>),
}
/// Everything the panes and the ticket know about one account.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct UserState {
pub clearinghouse: BTreeMap<String, Clearinghouse>,
pub orders: BTreeMap<String, Vec<OpenOrder>>,
/// Newest first.
pub fills: Vec<Fill>,
pub fundings: Vec<Funding>,
pub asset_data: HashMap<String, AssetData>,
/// Testnet mids by coin (`BTC`, `xyz:AAPL`). Never mixed with mainnet quotes.
pub mids: HashMap<String, f64>,
}
impl UserState {
/// Apply one websocket text frame for `user`. Returns what changed (nothing for acks,
/// pongs and other users' data).
pub fn apply(&mut self, user: &str, frame: &str) -> Vec<Event> {
let Ok(m) = serde_json::from_str::<Value>(frame) else { return Vec::new() };
let data = &m["data"];
let mine = |d: &Value| d["user"].as_str().is_some_and(|u| u.eq_ignore_ascii_case(user));
match m["channel"].as_str() {
Some("clearinghouseState") if mine(data) => {
let dex = data["dex"].as_str().unwrap_or_default().to_string();
self.clearinghouse.insert(dex.clone(), parse_clearinghouse(&dex, &data["clearinghouseState"]));
vec![Event::Account]
}
Some("openOrders") if mine(data) => {
let dex = data["dex"].as_str().unwrap_or_default().to_string();
self.orders.insert(dex, parse_open_orders(&data["orders"]));
vec![Event::Orders]
}
Some("userFills") if mine(data) => {
let fills = parse_fills(&data["fills"]);
let snapshot = data["isSnapshot"].as_bool().unwrap_or(false);
let new = self.add_fills(fills, snapshot);
if snapshot || !new.is_empty() {
vec![Event::Fills(if snapshot { Vec::new() } else { new })]
} else {
Vec::new()
}
}
Some("userFundings") if mine(data) => {
let f = parse_fundings(&data["fundings"]);
if data["isSnapshot"].as_bool().unwrap_or(false) {
self.fundings = f;
} else {
self.fundings.extend(f);
}
self.fundings.sort_by(|a, b| b.time.cmp(&a.time).then(a.coin.cmp(&b.coin)));
self.fundings.dedup_by(|a, b| a.time == b.time && a.coin == b.coin);
vec![Event::Fundings]
}
Some("activeAssetData") if mine(data) => match parse_asset_data(data) {
Some(a) => {
let coin = a.coin.clone();
self.asset_data.insert(coin.clone(), a);
vec![Event::AssetData(coin)]
}
None => Vec::new(),
},
Some("allMids") => {
let mut any = false;
for (k, v) in data["mids"].as_object().into_iter().flatten() {
if let Some(px) = num(v).filter(|p| *p > 0.0) {
self.mids.insert(k.clone(), px);
any = true;
}
}
if any { vec![Event::Mids] } else { Vec::new() }
}
Some("orderUpdates") => {
// Order updates carry no user field; the subscription is for this user only.
let u = parse_order_updates(data);
if u.is_empty() { Vec::new() } else { vec![Event::OrderUpdates(u)] }
}
_ => Vec::new(),
}
}
/// Merge fills (a snapshot replaces), newest first, de-duplicated by trade id. Returns
/// the ones that are new.
fn add_fills(&mut self, fills: Vec<Fill>, snapshot: bool) -> Vec<Fill> {
if snapshot {
self.fills.clear();
}
let mut new = Vec::new();
for f in fills {
if !self.fills.iter().any(|x| x.tid == f.tid && x.coin == f.coin && x.time == f.time) {
new.push(f.clone());
self.fills.push(f);
}
}
self.fills.sort_by(|a, b| b.time.cmp(&a.time).then(b.tid.cmp(&a.tid)));
self.fills.truncate(MAX_FILLS);
new
}
/// Every open order on every dex, as `(coin, oid)`: what the kill switch cancels.
pub fn open_order_ids(&self) -> Vec<(String, u64)> {
self.orders.values().flatten().map(|o| (o.coin.clone(), o.oid)).collect()
}
/// Account value over every dex loaded (standard accounts hold one balance per dex).
pub fn account_value(&self) -> f64 {
self.clearinghouse.values().map(|c| c.margin.account_value).sum()
}
pub fn withdrawable(&self) -> f64 {
self.clearinghouse.values().map(|c| c.withdrawable).sum()
}
pub fn positions(&self) -> impl Iterator<Item = &PerpPosition> {
self.clearinghouse.values().flat_map(|c| c.positions.iter())
}
}
#[cfg(test)]
mod tests {
use super::*;
const USER: &str = "0x00000000000000000000000000000000000f107e";
fn recorded() -> Vec<String> {
let path = format!("{}/tests/fixtures/testnet_ws_user_stream.jsonl", env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(path).unwrap().lines().map(str::to_string).collect()
}
#[test]
fn subscriptions_cover_both_dexes() {
let subs = subscriptions("0xABC", ["", "xyz"], &["xyz:AAPL"]);
assert_eq!(subs.len(), 3 * 2 + 3 + 1);
assert!(subs.iter().any(|s| s.contains(r#""type":"clearinghouseState""#) && s.contains(r#""dex":"xyz""#)));
assert!(subs.iter().all(|s| !s.contains("0xABC")), "addresses go lower-case");
assert!(asset_data_subscription("0xabc", "BTC", false).starts_with(r#"{"method":"unsubscribe""#));
}
#[test]
fn a_recorded_testnet_stream_builds_the_account() {
let mut s = UserState::default();
let mut events = Vec::new();
for m in recorded() {
events.extend(s.apply(USER, &m));
}
assert_eq!(s.clearinghouse.len(), 2, "core and xyz");
assert_eq!(s.positions().count(), 3);
assert_eq!(s.open_order_ids(), [("xyz:AAPL".to_string(), 63919996139)]);
assert_eq!(s.fills.len(), 4);
assert!(s.fills.windows(2).all(|w| w[0].time >= w[1].time), "newest first");
assert_eq!(s.asset_data["xyz:AAPL"].leverage.map(|l| l.value), Some(20));
assert!(s.mids.values().all(|p| *p > 0.0) && !s.mids.is_empty());
assert!(s.account_value() > 0.0);
assert!(events.contains(&Event::Account) && events.contains(&Event::Orders));
// The same data for another user changes nothing.
let mut other = UserState::default();
for m in recorded().iter().filter(|m| !m.contains("allMids")) {
other.apply("0x0000000000000000000000000000000000000001", m);
}
assert_eq!(other, UserState::default());
}
#[test]
fn a_reconnect_snapshot_never_doubles_fills() {
let mut s = UserState::default();
let fill =
|tid: u64, time: u64| json!({"coin": "BTC", "px": "1", "sz": "1", "side": "B", "time": time, "tid": tid});
let msg = |snap: bool, fills: Vec<Value>| {
json!({"channel": "userFills", "data": {"isSnapshot": snap, "user": USER, "fills": fills}}).to_string()
};
s.apply(USER, &msg(true, vec![fill(1, 10), fill(2, 20)]));
let ev = s.apply(USER, &msg(false, vec![fill(3, 30), fill(2, 20)]));
assert_eq!(ev.len(), 1);
let Event::Fills(new) = &ev[0] else { panic!("{ev:?}") };
assert_eq!(new.iter().map(|f| f.tid).collect::<Vec<_>>(), [3]);
s.apply(USER, &msg(true, vec![fill(1, 10), fill(2, 20), fill(3, 30)]));
assert_eq!(s.fills.iter().map(|f| f.tid).collect::<Vec<_>>(), [3, 2, 1]);
assert!(s.apply(USER, "not json").is_empty() && s.apply(USER, r#"{"channel":"pong"}"#).is_empty());
}
}
-707
View File
@@ -1,707 +0,0 @@
//! Everything that signs an L1 action goes through here, and every check happens here
//! first: the market must be tradable, sizes and prices are put on the grid, and the hard
//! caps in [`crate::caps`] are enforced before a signature exists.
//!
//! An order is planned ([`plan_order`]: rounded and checked, for the confirmation), then
//! signed ([`sign_order`]: checked again against the market at that moment). A plan can only
//! come from [`plan_order`], so nothing unchecked reaches the signer. Every L1 action carries
//! `expiresAfter` ≈ now + 30 s, so a signed action that is delayed can't land late.
use std::fmt;
use alloy_primitives::Address;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::actions::*;
use crate::assets::{Asset, AssetRegistry};
use crate::caps::{self, MAX_MARKET_SLIPPAGE, Refusal};
use crate::key::{SigningKey, address_hex};
use crate::network::{Network, SIGNATURE_CHAIN_ID};
use crate::nonce::NonceManager;
use crate::rounding::{Round, round_price, round_size, to_wire};
use crate::signing::{WireSignature, sign_l1};
/// How long a signed L1 action stays valid.
pub const EXPIRES_AFTER_MS: u64 = 30_000;
/// Default agent lifetime: the most Hyperliquid allows, because the built-in wallet's
/// approval is a paid Turnkey signature, so it should be rare.
pub const DEFAULT_AGENT_DAYS: u64 = 180;
pub const MAX_AGENT_DAYS: u64 = 180;
const DAY_MS: u64 = 24 * 60 * 60 * 1000;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OrderKind {
Limit {
px: f64,
tif: Tif,
},
/// An IOC limit [`MAX_MARKET_SLIPPAGE`] through the mid.
Market,
/// Take profit or stop loss: a market order once `trigger_px` trades.
Trigger {
trigger_px: f64,
tpsl: Tpsl,
},
}
/// What the user asked for, before rounding.
#[derive(Debug, Clone, PartialEq)]
pub struct OrderIntent {
pub coin: String,
pub is_buy: bool,
pub size: f64,
pub kind: OrderKind,
pub reduce_only: bool,
}
/// The account's leverage on one market (`activeAssetData`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Leverage {
pub value: u32,
pub cross: bool,
}
/// The market as the signer sees it now: the testnet mid and the account's leverage.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct MarketCtx {
pub mid: Option<f64>,
pub leverage: Option<Leverage>,
}
/// An order rounded and checked. Only [`plan_order`] makes one.
#[derive(Debug, Clone, PartialEq)]
pub struct OrderPlan {
asset: Asset,
intent: OrderIntent,
size: f64,
price: f64,
trigger: Option<f64>,
notional: f64,
wire: OrderWire,
}
impl OrderPlan {
pub fn asset(&self) -> &Asset {
&self.asset
}
pub fn intent(&self) -> &OrderIntent {
&self.intent
}
/// Size as sent (rounded down to the lot).
pub fn size(&self) -> f64 {
self.size
}
/// The limit price sent: the order's limit, or a market order's IOC cap.
pub fn price(&self) -> f64 {
self.price
}
pub fn trigger(&self) -> Option<f64> {
self.trigger
}
/// Size × the higher of the price sent and the mid, in USD.
pub fn notional(&self) -> f64 {
self.notional
}
pub fn cloid(&self) -> &str {
self.wire.c.as_deref().unwrap_or_default()
}
pub fn wire(&self) -> &OrderWire {
&self.wire
}
/// `Limit GTC`, `Market (IOC, 1% cap)`, `Stop loss at 70000`.
pub fn kind_label(&self) -> String {
match self.intent.kind {
OrderKind::Limit { tif, .. } => format!("Limit {}", tif.label()),
OrderKind::Market => format!("Market (IOC, {:.0}% cap)", MAX_MARKET_SLIPPAGE * 100.0),
OrderKind::Trigger { tpsl: Tpsl::Tp, .. } => "Take profit (market)".into(),
OrderKind::Trigger { tpsl: Tpsl::Sl, .. } => "Stop loss (market)".into(),
}
}
}
/// A signed action, ready to post. `Debug` shows what it is, never the signature.
#[derive(Clone)]
pub struct Signed {
pub kind: &'static str,
action: Value,
pub nonce: u64,
signature: WireSignature,
pub expires_after: Option<u64>,
}
impl Signed {
/// The `/exchange` request body.
pub fn body(&self) -> Value {
json!({
"action": self.action,
"nonce": self.nonce,
"signature": self.signature,
"vaultAddress": Value::Null,
"expiresAfter": self.expires_after,
})
}
pub fn signature(&self) -> &WireSignature {
&self.signature
}
/// The same action with a damaged signature (the live check's control).
pub fn corrupted(&self) -> Signed {
let mut s = self.clone();
let last = s.signature.s.pop().unwrap_or('0');
s.signature.s.push(if last == '1' { '2' } else { '1' });
s
}
}
impl fmt::Debug for Signed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Signed")
.field("kind", &self.kind)
.field("nonce", &self.nonce)
.field("expires_after", &self.expires_after)
.finish_non_exhaustive()
}
}
/// Round and check an order for the confirmation.
pub fn plan_order(
reg: &AssetRegistry,
intent: &OrderIntent,
ctx: &MarketCtx,
cloid: &str,
) -> Result<OrderPlan, Refusal> {
let asset = reg.resolve(&intent.coin).map_err(Refusal::Invalid)?.clone();
if !valid_cloid(cloid) {
return Err(Refusal::Invalid("internal: malformed client order id".into()));
}
if !intent.size.is_finite() || intent.size <= 0.0 {
return Err(Refusal::Invalid("Enter a size greater than zero.".into()));
}
let size = round_size(intent.size, asset.sz_decimals);
if size <= 0.0 {
return Err(Refusal::Invalid(format!(
"{} {} rounds to zero ({} trades in steps of {}).",
intent.size,
asset.coin,
asset.coin,
10f64.powi(-(asset.sz_decimals as i32))
)));
}
let mid = ctx.mid.filter(|m| m.is_finite() && *m > 0.0);
// A buy's cap rounds down and a sell's floor up, so the slippage never exceeds the cap.
let through = |px: f64| {
let (raw, dir) = if intent.is_buy {
(px * (1.0 + MAX_MARKET_SLIPPAGE), Round::Down)
} else {
(px * (1.0 - MAX_MARKET_SLIPPAGE), Round::Up)
};
round_price(raw, asset.sz_decimals, dir)
};
let positive = |px: f64, what: &str| {
if px.is_finite() && px > 0.0 {
Ok(px)
} else {
Err(Refusal::Invalid(format!("The {what} price must be greater than zero.")))
}
};
let small = |what: &str| {
Refusal::Invalid(format!(
"The {what} price is too small for {} (it rounds to zero at {} decimals).",
asset.coin,
crate::rounding::price_decimals(0.0, asset.sz_decimals)
))
};
let (price, trigger, t) = match intent.kind {
OrderKind::Limit { px, tif } => {
(round_price(positive(px, "limit")?, asset.sz_decimals, Round::Nearest), None, OrderTypeWire::limit(tif))
}
OrderKind::Market => {
let m = mid.ok_or_else(|| Refusal::NoMid { coin: asset.coin.clone() })?;
(through(m), None, OrderTypeWire::limit(Tif::Ioc))
}
OrderKind::Trigger { trigger_px, tpsl } => {
let trig = round_price(positive(trigger_px, "trigger")?, asset.sz_decimals, Round::Nearest);
if trig <= 0.0 {
return Err(small("trigger"));
}
(through(trig), Some(trig), OrderTypeWire::trigger(true, wire(trig)?, tpsl))
}
};
if price <= 0.0 {
return Err(small("limit"));
}
let order = OrderWire {
a: asset.id,
b: intent.is_buy,
p: wire(price)?,
s: wire(size)?,
r: intent.reduce_only,
t,
c: Some(cloid.to_string()),
};
let plan = OrderPlan { asset, intent: intent.clone(), size, price, trigger, notional: 0.0, wire: order };
let notional = check(&plan, ctx)?;
Ok(OrderPlan { notional, ..plan })
}
fn wire(x: f64) -> Result<String, Refusal> {
to_wire(x).map_err(|e| Refusal::Invalid(e.to_string()))
}
/// The caps, against the market in `ctx`. Returns the notional. Every order needs a current
/// mid: a limit through the market fills at the market, so its limit alone can't value it.
fn check(plan: &OrderPlan, ctx: &MarketCtx) -> Result<f64, Refusal> {
let mid = ctx
.mid
.filter(|m| m.is_finite() && *m > 0.0)
.ok_or_else(|| Refusal::NoMid { coin: plan.asset.coin.clone() })?;
// Reducing a position is never blocked by its leverage; anything else needs 3× or less.
if !plan.intent.reduce_only {
let lev = ctx.leverage.ok_or_else(|| Refusal::LeverageUnknown { coin: plan.asset.coin.clone() })?;
caps::check_leverage(lev.value)?;
}
// Value at the highest of the order's price, its trigger and the mid.
let reference = [Some(plan.price), plan.trigger, Some(mid)].into_iter().flatten().fold(0.0, f64::max);
let notional = plan.size * reference;
caps::check_notional(notional)?;
if plan.intent.kind == OrderKind::Market {
caps::check_slippage((plan.price / mid - 1.0).abs())?;
}
Ok(notional)
}
fn signed<A: Serialize>(
kind: &'static str,
key: &impl SigningKey,
nonces: &NonceManager,
action: &A,
now: u64,
) -> Result<Signed, Refusal> {
let nonce = nonces.next(now);
let expires_after = Some(now + EXPIRES_AFTER_MS);
let signature = sign_l1(key, action, nonce, expires_after).map_err(|e| Refusal::Invalid(format!("{e:#}")))?;
let action = serde_json::to_value(action).map_err(|e| Refusal::Invalid(e.to_string()))?;
Ok(Signed { kind, action, nonce, signature, expires_after })
}
/// Sign a planned order, after checking it again against the market now.
pub fn sign_order(
key: &impl SigningKey,
nonces: &NonceManager,
plan: &OrderPlan,
ctx: &MarketCtx,
now: u64,
) -> Result<Signed, Refusal> {
check(plan, ctx)?;
signed("order", key, nonces, &OrderAction::new(vec![plan.wire.clone()]), now)
}
/// Sign a modify of order `oid` into `plan`, with the same checks as a new order.
pub fn sign_modify(
key: &impl SigningKey,
nonces: &NonceManager,
oid: u64,
plan: &OrderPlan,
ctx: &MarketCtx,
now: u64,
) -> Result<Signed, Refusal> {
check(plan, ctx)?;
signed("modify", key, nonces, &ModifyAction::new(oid, plan.wire.clone()), now)
}
/// Cancel one order by id.
pub fn sign_cancel(
key: &impl SigningKey,
nonces: &NonceManager,
asset: &Asset,
oid: u64,
now: u64,
) -> Result<Signed, Refusal> {
signed("cancel", key, nonces, &CancelAction::new(vec![CancelWire { a: asset.id, o: oid }]), now)
}
/// Cancel one order by client order id.
pub fn sign_cancel_by_cloid(
key: &impl SigningKey,
nonces: &NonceManager,
asset: &Asset,
cloid: &str,
now: u64,
) -> Result<Signed, Refusal> {
if !valid_cloid(cloid) {
return Err(Refusal::Invalid("malformed client order id".into()));
}
let action = CancelByCloidAction::new(vec![CancelByCloidWire { asset: asset.id, cloid: cloid.into() }]);
signed("cancelByCloid", key, nonces, &action, now)
}
/// The kill switch: cancel every order in `orders` (`(coin, oid)`), one action per dex.
/// A coin the registry doesn't know (listed after it loaded) is skipped and returned, so one
/// stale entry never stops the other cancels.
pub fn sign_cancel_all(
key: &impl SigningKey,
nonces: &NonceManager,
reg: &AssetRegistry,
orders: &[(String, u64)],
now: u64,
) -> Result<(Vec<Signed>, Vec<String>), Refusal> {
// Delisted markets stay in the registry, so their orders still cancel.
let skipped: Vec<String> = orders.iter().filter(|(c, _)| reg.get(c).is_none()).map(|(c, _)| c.clone()).collect();
let mut out = Vec::new();
for dex in reg.dexes() {
let cancels: Vec<CancelWire> = orders
.iter()
.filter_map(|(coin, oid)| reg.get(coin).filter(|a| a.dex == dex).map(|a| CancelWire { a: a.id, o: *oid }))
.collect();
if !cancels.is_empty() {
out.push(signed("cancel", key, nonces, &CancelAction::new(cancels), now)?);
}
}
Ok((out, skipped))
}
/// Set leverage on one market: 1× to 3× (and no more than the market allows).
pub fn sign_update_leverage(
key: &impl SigningKey,
nonces: &NonceManager,
asset: &Asset,
cross: bool,
leverage: u32,
now: u64,
) -> Result<Signed, Refusal> {
caps::check_leverage(leverage)?;
if leverage == 0 || leverage > asset.max_leverage {
return Err(Refusal::Invalid(format!("{} allows 1× to {}×.", asset.coin, asset.max_leverage)));
}
if cross && asset.only_isolated {
return Err(Refusal::Invalid(format!("{} is isolated margin only.", asset.coin)));
}
signed("updateLeverage", key, nonces, &UpdateLeverageAction::new(asset.id, cross, leverage), now)
}
/// The dead man's switch: cancel all open orders at `at` (at least 5 s ahead), or clear it.
pub fn sign_schedule_cancel(
key: &impl SigningKey,
nonces: &NonceManager,
at: Option<u64>,
now: u64,
) -> Result<Signed, Refusal> {
if at.is_some_and(|t| t < now + 5_000) {
return Err(Refusal::Invalid("A scheduled cancel must be at least 5 seconds ahead.".into()));
}
signed("scheduleCancel", key, nonces, &ScheduleCancelAction::new(at), now)
}
/// Use up a nonce.
pub fn sign_noop(key: &impl SigningKey, nonces: &NonceManager, now: u64) -> Result<Signed, Refusal> {
signed("noop", key, nonces, &NoopAction::default(), now)
}
/// `terminal-<device> valid_until <ms>`, the device reduced to 14 lower-case letters,
/// digits and dashes.
pub fn agent_name(device: &str, valid_until: u64) -> String {
let mut d: String = device
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.map(|c| c.to_ascii_lowercase())
.take(14)
.collect();
if d.is_empty() {
d = "device".into();
}
format!("terminal-{d} valid_until {valid_until}")
}
/// The name an agent was approved under, without its `valid_until` suffix.
pub fn agent_base_name(name: &str) -> &str {
name.split(" valid_until ").next().unwrap_or(name).trim()
}
/// The expiry in an agent name, if it has one.
pub fn agent_valid_until(name: &str) -> Option<u64> {
name.split(" valid_until ").nth(1)?.trim().parse().ok()
}
/// An `approveAgent` for `agent`, named for this device, valid `days` (1 to 180).
pub fn approve_agent(agent: &Address, device: &str, days: u64, now: u64) -> ApproveAgentAction {
let until = now + days.clamp(1, MAX_AGENT_DAYS) * DAY_MS;
approve(agent, agent_name(device, until), now)
}
fn approve(agent: &Address, name: String, now: u64) -> ApproveAgentAction {
ApproveAgentAction {
kind: "approveAgent".into(),
hyperliquid_chain: Network::Testnet.hyperliquid_chain().into(),
signature_chain_id: format!("{SIGNATURE_CHAIN_ID:#x}"),
agent_address: address_hex(agent),
agent_name: name,
nonce: now,
}
}
/// Revoke: approve a fresh address under the same name, then drop its key, so the old agent
/// is replaced and nobody holds a key for the new one.
pub fn revoke_agent(base_name: &str, now: u64) -> ApproveAgentAction {
// The key is dropped (and zeroized) at the end of this statement.
let throwaway = crate::key::AgentKey::generate().address();
approve(&throwaway, format!("{} valid_until {}", agent_base_name(base_name), now + DAY_MS), now)
}
/// A user-signed action's `/exchange` body.
pub fn user_signed_body(action: &ApproveAgentAction, signature: &WireSignature) -> Value {
json!({
"action": action,
"nonce": action.nonce,
"signature": signature,
"vaultAddress": Value::Null,
"expiresAfter": Value::Null,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::fixtures::registry;
use crate::caps::{MAX_LEVERAGE, MAX_ORDER_NOTIONAL_USD};
use crate::key::AgentKey;
use crate::signing::recover_l1;
const NOW: u64 = 1_793_667_000_000;
const CLOID: &str = "0x0000000000000000000000000000002a";
fn lev(value: u32) -> Option<Leverage> {
Some(Leverage { value, cross: true })
}
fn ctx(mid: f64) -> MarketCtx {
MarketCtx { mid: Some(mid), leverage: lev(3) }
}
fn limit(coin: &str, buy: bool, size: f64, px: f64) -> OrderIntent {
OrderIntent {
coin: coin.into(),
is_buy: buy,
size,
kind: OrderKind::Limit { px, tif: Tif::Gtc },
reduce_only: false,
}
}
#[test]
fn a_far_limit_is_rounded_and_signed() {
let reg = registry();
let k = AgentKey::generate();
let n = NonceManager::new();
// BTC (szDecimals 5) far below a 76,662 mid: 0.000256 BTC → 0.00025 at 50,000 = $12.50.
let plan = plan_order(&reg, &limit("btc", true, 0.000256, 50_000.0), &ctx(76_662.5), CLOID).unwrap();
assert_eq!((plan.size(), plan.price(), plan.asset().id), (0.00025, 50_000.0, 3));
assert!((plan.notional() - 76_662.5 * 0.00025).abs() < 1e-9, "valued at the higher of price and mid");
assert_eq!(plan.wire().p, "50000");
assert_eq!(plan.wire().s, "0.00025");
assert_eq!(plan.cloid(), CLOID);
let s = sign_order(&k, &n, &plan, &ctx(76_662.5), NOW).unwrap();
assert_eq!(s.kind, "order");
assert_eq!(s.expires_after, Some(NOW + EXPIRES_AFTER_MS));
let body = s.body();
assert_eq!(body["action"]["orders"][0]["a"], 3);
assert!(body["action"].get("builder").is_none(), "never a builder fee");
let action = OrderAction::new(vec![plan.wire().clone()]);
assert_eq!(recover_l1(&action, s.nonce, s.expires_after, s.signature()).unwrap(), k.address());
// xyz:AAPL (750009, szDecimals 3).
let aapl = plan_order(&reg, &limit("xyz:AAPL", true, 0.0512, 250.123), &ctx(322.15), CLOID).unwrap();
assert_eq!((aapl.asset().id, aapl.size(), aapl.price()), (750009, 0.051, 250.12));
assert!(format!("{s:?}").contains("order") && !format!("{s:?}").contains(&s.signature().r));
}
#[test]
fn orders_over_100_dollars_are_refused_before_signing() {
let reg = registry();
// 0.002 BTC at 76,662 is $153: refused even with a limit far below the market.
let e = plan_order(&reg, &limit("BTC", true, 0.002, 50_000.0), &ctx(76_662.5), CLOID).unwrap_err();
assert!(matches!(e, Refusal::Notional { .. }), "{e}");
assert!(e.to_string().contains("$100"));
// A sell limit below the market fills at the market, so the mid counts.
let e = plan_order(&reg, &limit("xyz:AAPL", false, 0.32, 300.0), &ctx(322.15), CLOID).unwrap_err();
assert!(matches!(e, Refusal::Notional { .. }), "{e}");
// Exactly at the cap is fine.
let ok = plan_order(&reg, &limit("xyz:AAPL", true, 0.4, 250.0), &ctx(250.0), CLOID).unwrap();
assert!((ok.notional() - MAX_ORDER_NOTIONAL_USD).abs() < 1e-9);
// Checked again at signing, against the market then.
let plan = plan_order(&reg, &limit("xyz:AAPL", true, 0.3, 300.0), &ctx(320.0), CLOID).unwrap();
let moved = MarketCtx { mid: Some(400.0), leverage: lev(3) };
let e = sign_order(&AgentKey::generate(), &NonceManager::new(), &plan, &moved, NOW).unwrap_err();
assert!(matches!(e, Refusal::Notional { .. }), "{e}");
}
#[test]
fn leverage_over_3x_is_refused() {
let reg = registry();
let at = |l| MarketCtx { mid: Some(76_662.5), leverage: lev(l) };
let o = limit("BTC", true, 0.0002, 50_000.0);
let e = plan_order(&reg, &o, &at(20), CLOID).unwrap_err();
assert_eq!(e, Refusal::Leverage { value: 20 }, "Hyperliquid's default 20× is over the cap");
let unknown = MarketCtx { mid: Some(76_662.5), leverage: None };
assert!(matches!(plan_order(&reg, &o, &unknown, CLOID).unwrap_err(), Refusal::LeverageUnknown { .. }));
assert!(plan_order(&reg, &o, &at(MAX_LEVERAGE), CLOID).is_ok());
// Reducing a position is never blocked by its leverage.
let close = OrderIntent { reduce_only: true, is_buy: false, ..o.clone() };
assert!(plan_order(&reg, &close, &at(20), CLOID).is_ok());
// And Terminal never sets more than 3×.
let (k, n) = (AgentKey::generate(), NonceManager::new());
let btc = reg.resolve("BTC").unwrap();
assert_eq!(sign_update_leverage(&k, &n, btc, true, 4, NOW).unwrap_err(), Refusal::Leverage { value: 4 });
assert!(sign_update_leverage(&k, &n, btc, true, 0, NOW).is_err());
let s = sign_update_leverage(&k, &n, btc, true, 3, NOW).unwrap();
assert_eq!(s.body()["action"], json!({"type": "updateLeverage", "asset": 3, "isCross": true, "leverage": 3}));
let isolated_only = reg.resolve("xyz:COIN").unwrap();
assert!(sign_update_leverage(&k, &n, isolated_only, true, 2, NOW).is_err(), "cross on an isolated-only market");
assert!(sign_update_leverage(&k, &n, isolated_only, false, 2, NOW).is_ok());
}
#[test]
fn market_orders_are_ioc_within_1_percent() {
let reg = registry();
let m = OrderIntent { kind: OrderKind::Market, ..limit("xyz:AAPL", true, 0.1, 0.0) };
let p = plan_order(&reg, &m, &ctx(322.15), CLOID).unwrap();
assert_eq!(p.wire().t, OrderTypeWire::limit(Tif::Ioc));
assert!(p.price() <= 322.15 * 1.01 && p.price() > 322.15 * 1.009, "{}", p.price());
let sell = plan_order(&reg, &OrderIntent { is_buy: false, ..m.clone() }, &ctx(322.15), CLOID).unwrap();
assert!(sell.price() >= 322.15 * 0.99 && sell.price() < 322.15 * 0.991, "{}", sell.price());
let no_mid = MarketCtx { mid: None, leverage: lev(3) };
assert!(matches!(plan_order(&reg, &m, &no_mid, CLOID).unwrap_err(), Refusal::NoMid { .. }));
// The mid fell more than 1% below the confirmed cap: review again.
let fell = MarketCtx { mid: Some(310.0), leverage: lev(3) };
let e = sign_order(&AgentKey::generate(), &NonceManager::new(), &p, &fell, NOW).unwrap_err();
assert!(matches!(e, Refusal::Slippage { .. }), "{e}");
}
#[test]
fn triggers_are_market_stops_with_a_slippage_cap() {
let reg = registry();
let sl = OrderIntent {
kind: OrderKind::Trigger { trigger_px: 300.0, tpsl: Tpsl::Sl },
reduce_only: true,
..limit("xyz:AAPL", false, 0.1, 0.0)
};
let p = plan_order(&reg, &sl, &ctx(322.15), CLOID).unwrap();
assert_eq!(p.wire().t, OrderTypeWire::trigger(true, "300".into(), Tpsl::Sl));
assert_eq!(p.trigger(), Some(300.0));
assert_eq!(p.price(), 297.0, "the fill limit is 1% through the trigger");
}
#[test]
fn bad_orders_are_refused_with_reasons() {
let reg = registry();
let c = ctx(76_662.5);
for (o, says) in [
(limit("para:TOTAL2", true, 1.0, 1.0), "para"),
(limit("HYPE/USDC", true, 1.0, 1.0), "spot"),
(limit("BTC", true, 0.000001, 50_000.0), "rounds to zero"),
(limit("BTC", true, 0.0002, -1.0), "price"),
(limit("BTC", true, f64::NAN, 50_000.0), "size"),
] {
let e = plan_order(&reg, &o, &c, CLOID).unwrap_err().to_string();
assert!(e.contains(says), "{}: {e}", o.coin);
}
assert!(plan_order(&reg, &limit("BTC", true, 0.0002, 50_000.0), &c, "42").is_err(), "cloid format");
}
#[test]
fn cancels_schedule_cancel_and_noop() {
let reg = registry();
let (k, n) = (AgentKey::generate(), NonceManager::new());
let aapl = reg.resolve("xyz:AAPL").unwrap();
let c = sign_cancel(&k, &n, aapl, 1, NOW).unwrap();
assert_eq!(c.body()["action"], json!({"type": "cancel", "cancels": [{"a": 750009, "o": 1}]}));
let c2 = sign_cancel_by_cloid(&k, &n, aapl, CLOID, NOW).unwrap();
assert_eq!(c2.body()["action"]["cancels"][0]["cloid"], CLOID);
assert!(c2.nonce > c.nonce, "nonces increase");
let (all, skipped) = sign_cancel_all(
&k,
&n,
&reg,
&[
("BTC".into(), 1),
("xyz:AAPL".into(), 2),
("ETH".into(), 3),
("xyz:NEWLY".into(), 9),
("xyz:TSLA".into(), 4),
],
NOW,
)
.unwrap();
assert_eq!(all.len(), 2, "one cancel per dex");
assert_eq!(all[0].body()["action"]["cancels"].as_array().unwrap().len(), 2);
assert_eq!(
all[1].body()["action"]["cancels"].as_array().unwrap().len(),
2,
"the unknown coin doesn't stop the rest"
);
assert_eq!(skipped, ["xyz:NEWLY"]);
assert!(sign_cancel_all(&k, &n, &reg, &[], NOW).unwrap().0.is_empty());
assert!(sign_schedule_cancel(&k, &n, Some(NOW + 1_000), NOW).is_err(), "at least 5 s ahead");
let sc = sign_schedule_cancel(&k, &n, Some(NOW + 60_000), NOW).unwrap();
assert_eq!(sc.body()["action"], json!({"type": "scheduleCancel", "time": NOW + 60_000}));
assert_eq!(
sign_schedule_cancel(&k, &n, None, NOW).unwrap().body()["action"],
json!({"type": "scheduleCancel"})
);
let noop = sign_noop(&k, &n, NOW).unwrap();
assert_eq!(noop.body()["action"], json!({"type": "noop"}));
assert_eq!(noop.body()["expiresAfter"], NOW + EXPIRES_AFTER_MS);
assert_eq!(noop.body()["vaultAddress"], Value::Null);
}
/// Greptile #37: without a mid, a sell limit below the market was valued at its limit.
#[test]
fn orders_need_a_current_mid_to_be_valued() {
let reg = registry();
let no_mid = MarketCtx { mid: None, leverage: lev(3) };
let sell = limit("xyz:AAPL", false, 0.32, 300.0);
assert!(matches!(plan_order(&reg, &sell, &no_mid, CLOID).unwrap_err(), Refusal::NoMid { .. }));
let plan = plan_order(&reg, &limit("xyz:AAPL", true, 0.1, 250.0), &ctx(322.15), CLOID).unwrap();
let e = sign_order(&AgentKey::generate(), &NonceManager::new(), &plan, &no_mid, NOW).unwrap_err();
assert!(matches!(e, Refusal::NoMid { .. }), "checked again at signing: {e}");
}
/// Greptile #37: a price that rounds to zero was signed as "p":"0" (and panicked in debug).
#[test]
fn prices_that_round_to_zero_are_refused() {
let reg = registry();
let e = plan_order(&reg, &limit("BTC", true, 0.001, 0.00000001), &ctx(76_662.5), CLOID).unwrap_err();
assert!(e.to_string().contains("too small"), "{e}");
let stop = OrderIntent {
kind: OrderKind::Trigger { trigger_px: 0.00000001, tpsl: Tpsl::Sl },
reduce_only: true,
..limit("BTC", false, 0.001, 0.0)
};
assert!(plan_order(&reg, &stop, &ctx(76_662.5), CLOID).unwrap_err().to_string().contains("too small"));
}
#[test]
fn agents_are_named_and_expire() {
let agent: Address = "0x5e9ee1089755c3435139848e47e6635505d5a13a".parse().unwrap();
let a = approve_agent(&agent, "Micky's MacBook Pro", DEFAULT_AGENT_DAYS, NOW);
let until = NOW + DEFAULT_AGENT_DAYS * DAY_MS;
assert_eq!(a.agent_name, format!("terminal-mickysmacbookp valid_until {until}"));
assert_eq!((a.kind.as_str(), a.hyperliquid_chain.as_str(), a.nonce), ("approveAgent", "Testnet", NOW));
assert_eq!(a.signature_chain_id, "0x66eee");
assert_eq!(a.agent_address, "0x5e9ee1089755c3435139848e47e6635505d5a13a");
assert_eq!(agent_base_name(&a.agent_name), "terminal-mickysmacbookp");
assert_eq!(agent_valid_until(&a.agent_name), Some(until));
let capped = approve_agent(&agent, "", 999, NOW);
assert_eq!(agent_valid_until(&capped.agent_name), Some(NOW + MAX_AGENT_DAYS * DAY_MS));
assert!(capped.agent_name.starts_with("terminal-device "), "{}", capped.agent_name);
assert_eq!(agent_valid_until(&approve_agent(&agent, "x", 0, NOW).agent_name), Some(NOW + DAY_MS));
// Revoking reuses the name with an address nobody holds a key for.
let r = revoke_agent("terminal-mickysmacbookp", NOW + 5);
assert_eq!(agent_base_name(&r.agent_name), "terminal-mickysmacbookp");
assert_ne!(r.agent_address, a.agent_address);
assert!(crate::key::parse_address(&r.agent_address).is_ok());
let body = user_signed_body(&a, &WireSignature { r: "0x1".into(), s: "0x2".into(), v: 27 });
assert_eq!(body["nonce"], NOW);
assert_eq!(body["action"]["agentName"], a.agent_name);
}
}
-287
View File
@@ -1,287 +0,0 @@
//! Signing pinned to the official Python SDK's published test vectors
//! (`hyperliquid-python-sdk` `tests/signing_test.py`), plus an `approveAgent` vector generated
//! with SDK 0.24.0's `sign_agent` (the SDK publishes none):
//!
//! ```text
//! python3 -m venv /tmp/hl-sdk-venv && /tmp/hl-sdk-venv/bin/pip install hyperliquid-python-sdk
//! /tmp/hl-sdk-venv/bin/python -I /tmp/hl-approve-vector.py # sign_agent(wallet, action, False)
//! ```
//!
//! The library signs only for testnet, so it is checked against the testnet half of each
//! vector. The mainnet half is checked here too, by [`mainnet_signature`], a test-only copy
//! of the phantom agent with `source: "a"`. It exists only in this test module, to show the
//! action hashing (which both networks share) is byte-exact. No library code can build it.
use alloy_primitives::{Address, B256};
use alloy_sol_types::{SolStruct, eip712_domain};
use serde::Serialize;
use crate::actions::*;
use crate::key::{AgentKey, SigningKey, sign_hash};
use crate::signing::*;
/// The SDK's public test key. Never funded; never used on a network.
const SDK_KEY: &str = "0x0123456789012345678901234567890123456789012345678901234567890123";
fn key() -> AgentKey {
AgentKey::from_hex(SDK_KEY).unwrap()
}
/// TEST ONLY: the mainnet phantom agent (`source: "a"`), to check the SDK's mainnet vectors.
fn mainnet_signature(k: &AgentKey, connection_id: B256) -> WireSignature {
let domain = eip712_domain! { name: "Exchange", version: "1", chain_id: 1337, verifying_contract: Address::ZERO, };
let agent = Agent { source: "a".into(), connectionId: connection_id };
let sig = sign_hash(k, &agent.eip712_signing_hash(&domain)).unwrap();
WireSignature { r: format!("{:#x}", sig.r()), s: format!("{:#x}", sig.s()), v: 27 + u8::from(sig.v()) }
}
fn sig(r: &str, s: &str, v: u8) -> WireSignature {
WireSignature { r: r.into(), s: s.into(), v }
}
/// Check one action against both halves of an SDK vector.
fn check<A: Serialize>(action: &A, nonce: u64, mainnet: WireSignature, testnet: WireSignature) {
let k = key();
assert_eq!(sign_l1(&k, action, nonce, None).unwrap(), testnet, "testnet signature (the library's)");
let id = action_hash(action, nonce, None).unwrap();
assert_eq!(mainnet_signature(&k, id), mainnet, "mainnet signature (test-only phantom agent)");
assert_eq!(recover_l1(action, nonce, None, &testnet).unwrap(), k.address(), "recovers the signer");
}
fn order(a: u32, sz: &str, px: &str, t: OrderTypeWire, c: Option<String>) -> OrderAction {
OrderAction::new(vec![OrderWire { a, b: true, p: px.into(), s: sz.into(), r: false, t, c }])
}
#[derive(Serialize)]
struct Dummy {
#[serde(rename = "type")]
kind: &'static str,
num: u64,
}
#[test]
fn test_phantom_agent_creation_matches_production() {
let o = order(4, "0.0147", "1670.1", OrderTypeWire::limit(Tif::Ioc), None);
let h = action_hash(&o, 1677777606040, None).unwrap();
assert_eq!(format!("{h:#x}"), "0x0fcbeda5ae3c4950a548021552a4fea2226858c4453571bf3f24ba017eac2908");
}
#[test]
fn test_l1_action_signing_matches() {
// float_to_int_for_hashing(1000) = 1000 × 10^8
let action = Dummy { kind: "dummy", num: 100_000_000_000 };
check(
&action,
0,
sig(
"0x53749d5b30552aeb2fca34b530185976545bb22d0b3ce6f62e31be961a59298",
"0x755c40ba9bf05223521753995abb2f73ab3229be8ec921f350cb447e384d8ed8",
27,
),
sig(
"0x542af61ef1f429707e3c76c5293c80d01f74ef853e34b76efffcb57e574f9510",
"0x17b8b32f086e8cdede991f1e2c529f5dd5297cbe8128500e00cbaf766204a613",
28,
),
);
}
#[test]
fn test_l1_action_signing_order_matches() {
check(
&order(1, "100", "100", OrderTypeWire::limit(Tif::Gtc), None),
0,
sig(
"0xd65369825a9df5d80099e513cce430311d7d26ddf477f5b3a33d2806b100d78e",
"0x2b54116ff64054968aa237c20ca9ff68000f977c93289157748a3162b6ea940e",
28,
),
sig(
"0x82b2ba28e76b3d761093aaded1b1cdad4960b3af30212b343fb2e6cdfa4e3d54",
"0x6b53878fc99d26047f4d7e8c90eb98955a109f44209163f52d8dc4278cbbd9f5",
27,
),
);
}
#[test]
fn test_l1_action_signing_order_with_cloid_matches() {
check(
&order(1, "100", "100", OrderTypeWire::limit(Tif::Gtc), Some("0x00000000000000000000000000000001".into())),
0,
sig(
"0x41ae18e8239a56cacbc5dad94d45d0b747e5da11ad564077fcac71277a946e3",
"0x3c61f667e747404fe7eea8f90ab0e76cc12ce60270438b2058324681a00116da",
27,
),
sig(
"0xeba0664bed2676fc4e5a743bf89e5c7501aa6d870bdb9446e122c9466c5cd16d",
"0x7f3e74825c9114bc59086f1eebea2928c190fdfbfde144827cb02b85bbe90988",
28,
),
);
}
#[test]
fn test_l1_action_signing_tpsl_order_matches() {
check(
&order(1, "100", "100", OrderTypeWire::trigger(true, "103".into(), Tpsl::Sl), None),
0,
sig(
"0x98343f2b5ae8e26bb2587daad3863bc70d8792b09af1841b6fdd530a2065a3f9",
"0x6b5bb6bb0633b710aa22b721dd9dee6d083646a5f8e581a20b545be6c1feb405",
27,
),
sig(
"0x971c554d917c44e0e1b6cc45d8f9404f32172a9d3b3566262347d0302896a2e4",
"0x206257b104788f80450f8e786c329daa589aa0b32ba96948201ae556d5637eac",
28,
),
);
}
#[test]
fn test_schedule_cancel_action() {
check(
&ScheduleCancelAction::new(None),
0,
sig(
"0x6cdfb286702f5917e76cd9b3b8bf678fcc49aec194c02a73e6d4f16891195df9",
"0x6557ac307fa05d25b8d61f21fb8a938e703b3d9bf575f6717ba21ec61261b2a0",
27,
),
sig(
"0xc75bb195c3f6a4e06b7d395acc20bbb224f6d23ccff7c6a26d327304e6efaeed",
"0x342f8ede109a29f2c0723bd5efb9e9100e3bbb493f8fb5164ee3d385908233df",
28,
),
);
check(
&ScheduleCancelAction::new(Some(123456789)),
0,
sig(
"0x609cb20c737945d070716dcc696ba030e9976fcf5edad87afa7d877493109d55",
"0x16c685d63b5c7a04512d73f183b3d7a00da5406ff1f8aad33f8ae2163bab758b",
28,
),
sig(
"0x4e4f2dbd4107c69783e251b7e1057d9f2b9d11cee213441ccfa2be63516dc5bc",
"0x706c656b23428c8ba356d68db207e11139ede1670481a9e01ae2dfcdb0e1a678",
27,
),
);
}
/// The SDK's testnet `test_sign_usd_transfer_action` checks the hand-built EIP-712 encoding
/// that `approveAgent` uses (this crate has no transfer action; the vector only exercises
/// the encoder).
#[test]
fn user_signed_encoding_matches_test_sign_usd_transfer_action() {
let dest: Address = "0x5e9ee1089755c3435139848e47e6635505d5a13a".parse().unwrap();
let fields = [Field::Str("Testnet"), Field::Str(&format!("{dest:#x}")), Field::Str("1"), Field::U64(1687816341423)];
let hash = user_signed_hash(
"HyperliquidTransaction:UsdSend(string hyperliquidChain,string destination,string amount,uint64 time)",
&fields,
0x66eee,
);
let s = sign_hash(&key(), &hash).unwrap();
assert_eq!(format!("{:#x}", s.r()), "0x637b37dd731507cdd24f46532ca8ba6eec616952c56218baeff04144e4a77073");
assert_eq!(format!("{:#x}", s.s()), "0x11a6a24900e6e314136d2592e2f8d502cd89b7c15b198e1bee043c9589f9fad7");
assert_eq!(27 + u8::from(s.v()), 27);
}
/// Generated with the Python SDK 0.24.0 (`sign_agent(wallet, action, is_mainnet=False)`).
#[test]
fn approve_agent_matches_python_sdk() {
let a = ApproveAgentAction {
kind: "approveAgent".into(),
hyperliquid_chain: "Testnet".into(),
signature_chain_id: "0x66eee".into(),
agent_address: "0x5e9ee1089755c3435139848e47e6635505d5a13a".into(),
agent_name: "terminal-vector valid_until 1700000000000".into(),
nonce: 1700000000000,
};
let main = crate::key::MainKey::from_hex(SDK_KEY).unwrap();
let want = sig(
"0x8138ea721e1fb5afe9239b21b19ffe1e4690dc2c06b0aaf661aeb9859f598a25",
"0x169543c6dbf8c0caa9e6225e6612e5c3b495afbdc840537be74a6fb465cfc321",
28,
);
assert_eq!(sign_approve_agent(&main, &a).unwrap(), want);
assert_eq!(format!("{:#x}", main.address()), "0x14791697260e4c9a71f18484c9f997b308e59325");
assert_eq!(recover_approve_agent(&a, &want).unwrap(), main.address());
// A wallet's r ‖ s ‖ v hex (as eth_signTypedData_v4 returns it) reads as the same signature.
let rsv = format!("0x{:0>64}{:0>64}{:02x}", &want.r[2..], &want.s[2..], want.v);
assert_eq!(WireSignature::from_rsv_hex(&rsv).unwrap(), want);
let zero_one = format!("{}{:02x}", &rsv[..rsv.len() - 2], want.v - 27);
assert_eq!(WireSignature::from_rsv_hex(&zero_one).unwrap(), want, "v as 0/1");
assert!(WireSignature::from_rsv_hex("0x1234").is_err());
// The same fields addressed to mainnet are refused rather than signed.
let mainnet = ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a.clone() };
assert!(sign_approve_agent(&main, &mainnet).is_err());
// The wallet payload names the same domain and message.
let td = approve_agent_typed_data(&a).unwrap();
assert_eq!(td["domain"]["chainId"], 0x66eee);
assert_eq!(td["message"]["hyperliquidChain"], "Testnet");
assert_eq!(td["primaryType"], "HyperliquidTransaction:ApproveAgent");
}
#[test]
fn expires_after_is_part_of_the_hash() {
let n = NoopAction::default();
let a = action_hash(&n, 5, None).unwrap();
let b = action_hash(&n, 5, Some(1_700_000_030_000)).unwrap();
assert_ne!(a, b);
let s = sign_l1(&key(), &n, 5, Some(1_700_000_030_000)).unwrap();
assert_eq!(recover_l1(&n, 5, Some(1_700_000_030_000), &s).unwrap(), key().address());
assert_ne!(recover_l1(&n, 5, None, &s).unwrap(), key().address(), "a different expiry recovers someone else");
// A corrupted signature recovers a different address instead of failing: the control
// for the live check.
let mut bad = s.clone();
bad.s = format!(
"{:#x}",
alloy_primitives::U256::from_str_radix(&bad.s[2..], 16).unwrap() ^ alloy_primitives::U256::from(1)
);
assert_ne!(recover_l1(&n, 5, Some(1_700_000_030_000), &bad).unwrap(), key().address());
}
/// The same approval signed by a main wallet held elsewhere (as Turnkey's `sign_raw_payload`
/// with `HASH_FUNCTION_NO_OP` signs the digest it is given) is the Python SDK's signature.
#[test]
fn a_remote_main_wallet_reproduces_the_approve_agent_vector() {
let a = ApproveAgentAction {
kind: "approveAgent".into(),
hyperliquid_chain: "Testnet".into(),
signature_chain_id: "0x66eee".into(),
agent_address: "0x5e9ee1089755c3435139848e47e6635505d5a13a".into(),
agent_name: "terminal-vector valid_until 1700000000000".into(),
nonce: 1700000000000,
};
let remote = k256::ecdsa::SigningKey::from_slice(&alloy_primitives::hex::decode(SDK_KEY).unwrap()).unwrap();
let master = Address::from_private_key(&remote);
let signed = futures::executor::block_on(sign_approve_agent_with(&a, &master, |digest: B256| {
let (s, recid) = remote.sign_prehash_recoverable(digest.as_slice()).unwrap();
async move { Ok(alloy_primitives::Signature::from_signature_and_parity(s, recid.is_y_odd())) }
}))
.unwrap();
let want = sig(
"0x8138ea721e1fb5afe9239b21b19ffe1e4690dc2c06b0aaf661aeb9859f598a25",
"0x169543c6dbf8c0caa9e6225e6612e5c3b495afbdc840537be74a6fb465cfc321",
28,
);
assert_eq!(signed, want);
}
/// `approveAgent` is only hashed for testnet's signature chain (0x66eee): never Arbitrum One
/// (0xa4b1, the chain Hyperliquid's docs show for mainnet), and never an unparseable id.
#[test]
fn approve_agent_is_hashed_only_at_the_testnet_signature_chain() {
let a = crate::trader::approve_agent(&key().address(), "vector", 30, 1_700_000_000_000);
assert!(approve_agent_hash(&a).is_ok());
for chain in ["0xa4b1", "0x1", "0x66EEE0", "", "zz"] {
let other = ApproveAgentAction { signature_chain_id: chain.into(), ..a.clone() };
assert!(approve_agent_hash(&other).is_err(), "{chain}");
}
// The chain id may be written in either case.
assert!(approve_agent_hash(&ApproveAgentAction { signature_chain_id: "0x66EEE".into(), ..a }).is_ok());
}
-1
View File
@@ -1 +0,0 @@
{"BTC": "76662.5", "ETH": "2259.4", "SOL": "93.5475", "HYPE": "90.8835"}
-1
View File
@@ -1 +0,0 @@
{"xyz:AAPL":"322.15","xyz:ALUMINIUM":"3192.3","xyz:AMD":"518.65","xyz:AMZN":"246.01","xyz:AVGO":"495.0","xyz:BABA":"110.03","xyz:BIRD":"4.1","xyz:BRENTOIL":"99.315","xyz:BX":"120.3","xyz:CL":"94.37","xyz:COIN":"137.51","xyz:COPPER":"6.5408","xyz:CORN":"4.5","xyz:COST":"1040.3","xyz:CRCL":"79.162","xyz:CRWV":"50.63","xyz:DKNG":"21.802","xyz:DXY":"97.15","xyz:EUR":"1.1188","xyz:EWJ":"92.885","xyz:EWY":"152.68","xyz:GME":"24.095","xyz:GOLD":"4251.4","xyz:GOOGL":"308.44","xyz:HIMS":"25.339","xyz:HOOD":"84.06","xyz:HYUNDAI":"205.24","xyz:IBM":"252.49","xyz:INTC":"63.753","xyz:IREN":"43.7","xyz:JP225":"63063.0","xyz:JPY":"157.015","xyz:KIOXIA":"140.0","xyz:KORU":"15.0","xyz:KR200":"883.85","xyz:LITE":"686.0","xyz:LLY":"1402.0","xyz:META":"659.55","xyz:MRVL":"196.4","xyz:MSFT":"508.25","xyz:MSTR":"137.95","xyz:MU":"838.03","xyz:NATGAS":"2.905","xyz:NBIS":"218.0","xyz:NFLX":"68.733","xyz:NOW":"114.0","xyz:NVDA":"207.0","xyz:ORCL":"119.69","xyz:PALLADIUM":"973.05","xyz:PLATINUM":"1452.7","xyz:PLTR":"172.5","xyz:PURRDAT":"9.0","xyz:RIVN":"13.215","xyz:RKLB":"150.0","xyz:SILVER":"49.349","xyz:SKHX":"1036.1","xyz:SMSN":"162.64","xyz:SNDK":"1420.2","xyz:SOFTBANK":"31.0","xyz:SP500":"7544.2","xyz:TSLA":"475.53","xyz:TSM":"400.49","xyz:TTF":"57.0","xyz:URANIUM":"85.0","xyz:URNM":"41.88","xyz:USAR":"8.895","xyz:VIX":"57.344","xyz:WHEAT":"6.33","xyz:XLE":"67.425","xyz:XYZ100":"30368.5"}
@@ -1 +0,0 @@
{"marginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMarginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMaintenanceMarginUsed":"52.105281","withdrawable":"339.171412","assetPositions":[{"type":"oneWay","position":{"coin":"xyz:TSLA","szi":"-1.483","leverage":{"type":"cross","value":10},"entryPx":"404.349","positionValue":"668.92198","unrealizedPnl":"-69.27118","returnOnEquity":"-1.1551919884","liquidationPx":"704.9387849597","marginUsed":"66.892198","maxLeverage":10,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:NVDA","szi":"-3.038","leverage":{"type":"cross","value":20},"entryPx":"218.372","positionValue":"642.71928","unrealizedPnl":"20.696851","returnOnEquity":"0.6239477768","liquidationPx":"338.5136601423","marginUsed":"32.135964","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:AAPL","szi":"0.316","leverage":{"type":"cross","value":20},"entryPx":"344.82","positionValue":"103.648","unrealizedPnl":"-5.31512","returnOnEquity":"-0.9755814628","liquidationPx":null,"marginUsed":"5.1824","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}}],"time":1793667763833}
@@ -1 +0,0 @@
[{"coin":"xyz:AAPL","side":"A","limitPx":"346.21","sz":"0.789","oid":63919996139,"timestamp":1793214466685,"triggerCondition":"N/A","isTrigger":false,"triggerPx":"0.0","children":[],"isPositionTpsl":false,"reduceOnly":false,"orderType":"Limit","origSz":"0.866","tif":"Alo","cloid":null}]
-1
View File
@@ -1 +0,0 @@
{"universe":[{"szDecimals":2,"name":"SOL","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"APT","maxLeverage":3,"marginTableId":3},{"szDecimals":2,"name":"ATOM","maxLeverage":10,"marginTableId":55},{"szDecimals":5,"name":"BTC","maxLeverage":40,"marginTableId":54},{"szDecimals":4,"name":"ETH","maxLeverage":25,"marginTableId":53},{"szDecimals":1,"name":"MATIC","maxLeverage":50,"marginTableId":50,"isDelisted":true},{"szDecimals":3,"name":"BNB","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"AVAX","maxLeverage":10,"marginTableId":55},{"szDecimals":0,"name":"GMT","maxLeverage":2,"marginTableId":2},{"szDecimals":1,"name":"DYDX","maxLeverage":20,"marginTableId":20},{"szDecimals":1,"name":"APE","maxLeverage":5,"marginTableId":5},{"szDecimals":1,"name":"OP","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"kPEPE","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"ARB","maxLeverage":10,"marginTableId":55},{"szDecimals":0,"name":"RLB","maxLeverage":50,"marginTableId":50,"isDelisted":true},{"szDecimals":1,"name":"WLD","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"HPOS","maxLeverage":50,"marginTableId":50,"isDelisted":true},{"szDecimals":2,"name":"UNIBOT","maxLeverage":2,"marginTableId":2,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":2,"name":"COMP","maxLeverage":5,"marginTableId":5},{"szDecimals":1,"name":"FXS","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":4,"name":"MKR","maxLeverage":10,"marginTableId":55,"isDelisted":true},{"szDecimals":2,"name":"AAVE","maxLeverage":10,"marginTableId":52},{"szDecimals":1,"name":"SNX","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"RNDR","maxLeverage":20,"marginTableId":20,"isDelisted":true},{"szDecimals":1,"name":"LDO","maxLeverage":5,"marginTableId":5},{"szDecimals":1,"name":"SUI","maxLeverage":10,"marginTableId":52},{"szDecimals":1,"name":"INJ","maxLeverage":10,"marginTableId":52},{"szDecimals":1,"name":"STX","maxLeverage":10,"marginTableId":52},{"szDecimals":0,"name":"FTM","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":0,"name":"kSHIB","maxLeverage":10,"marginTableId":52},{"szDecimals":0,"name":"OX","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":1,"name":"FRIEND","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":1,"name":"ZRO","maxLeverage":10,"marginTableId":55,"onlyIsolated":true,"marginMode":"strictIsolated"},{"szDecimals":0,"name":"BLZ","maxLeverage":5,"marginTableId":5,"isDelisted":true},{"szDecimals":1,"name":"BANANA","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"marginMode":"strictIsolated"},{"szDecimals":1,"name":"FTT","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":2,"name":"TRB","maxLeverage":5,"marginTableId":5},{"szDecimals":0,"name":"CANTO","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":0,"name":"BIGTIME","maxLeverage":3,"marginTableId":3},{"szDecimals":0,"name":"NTRN","maxLeverage":5,"marginTableId":5,"isDelisted":true},{"szDecimals":0,"name":"KAS","maxLeverage":5,"marginTableId":5},{"szDecimals":0,"name":"BLUR","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"TIA","maxLeverage":10,"marginTableId":52},{"szDecimals":2,"name":"BSV","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"TON","maxLeverage":10,"marginTableId":52,"isDelisted":true},{"szDecimals":0,"name":"ADA","maxLeverage":10,"marginTableId":55},{"szDecimals":0,"name":"MINA","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"POLYX","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"GAS","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"AXL","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":0,"name":"PENDLE","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"STG","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":0,"name":"FET","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"STRAX","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":1,"name":"NEAR","maxLeverage":20,"marginTableId":20},{"szDecimals":0,"name":"MEME","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"ORDI","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"BADGER","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":2,"name":"NEO","maxLeverage":20,"marginTableId":20},{"szDecimals":2,"name":"ZEN","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"FIL","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"PYTH","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"RUNE","maxLeverage":10,"marginTableId":55},{"szDecimals":1,"name":"SUSHI","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"ILV","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":0,"name":"MAV"Line truncated
-1
View File
@@ -1 +0,0 @@
{"universe":[{"szDecimals":4,"name":"xyz:XYZ100","maxLeverage":30,"marginTableId":30,"growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:TSLA","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:NVDA","maxLeverage":20,"marginTableId":20,"growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":4,"name":"xyz:GOLD","maxLeverage":25,"marginTableId":25,"lastFeeScaleChangeTime":"1970-01-01T00:00:00","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:HOOD","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":2,"name":"xyz:INTC","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:PLTR","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:COIN","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:META","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:AAPL","maxLeverage":20,"marginTableId":20,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:MSFT","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:ORCL","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:GOOGL","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:AMZN","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:AMD","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:MU","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-01-09T17:10:11.615872064","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:SNDK","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:MSTR","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","lastFeeScaleChangeTime":"2026-02-20T12:46:19.276033666","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:CRCL","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:NFLX","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":4,"name":"xyz:COST","maxLeverage":20,"marginTableId":20,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":4,"name":"xyz:LLY","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:SKHX","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:TSM","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":2,"name":"xyz:JPY","maxLeverage":50,"marginTableId":50,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},Line truncated
-1
View File
@@ -1 +0,0 @@
[null, {"name": "test", "fullName": "test dex"}, {"name": "unit", "fullName": "unit dex"}, {"name": "scam", "fullName": "Scam DEX"}, {"name": "felix", "fullName": "felix"}, {"name": "volmex", "fullName": "volmex"}, {"name": "angry", "fullName": "angry"}, {"name": "TOON", "fullName": "Platoon"}, {"name": "VOLMEX", "fullName": "Volmex Dex"}, {"name": "test21", "fullName": "test21"}, {"name": "pluto", "fullName": "pluto dex"}, {"name": "zigg", "fullName": "zigg"}, {"name": "i<3fl", "fullName": "i love @frank_liquid"}, {"name": "KNETIQ", "fullName": "Kinetiq Exchange"}, {"name": "knetiq", "fullName": "Kinetiq Exchange - KNTQ"}, {"name": "ddot", "fullName": "ddot link"}, {"name": "venone", "fullName": "VentualsOne"}, {"name": "tstdex", "fullName": "New Coin"}, {"name": "vntls", "fullName": "Ventuals"}, {"name": "oracle", "fullName": "oracle test"}, {"name": "hb", "fullName": "hb test"}, {"name": "sports", "fullName": "Sports Player Perpetuals"}, {"name": "aura", "fullName": "aura.money"}, {"name": "hybet", "fullName": "hyperbet"}, {"name": "orange", "fullName": "ORANGE"}, {"name": "sekai", "fullName": "Sekai Perp DEX"}, {"name": "flow", "fullName": "Flow DEX"}, {"name": "x", "fullName": "XYZ"}, {"name": "t", "fullName": "XYZ"}, {"name": "sekaw", "fullName": "red dex"}, {"name": "gpu", "fullName": "Global Compute Exchange"}, {"name": "qwerty", "fullName": "qwerty dex"}, {"name": "merrli", "fullName": "merrli dex"}, {"name": "dex", "fullName": "dex-full-name"}, {"name": "olvb", "fullName": "onlyvibes"}, {"name": "nq", "fullName": "nq"}, {"name": "fx", "fullName": "hyperfx"}, {"name": "nqz", "fullName": "nqz"}, {"name": "btcx", "fullName": "BTC Perp Markets"}, {"name": "woof", "fullName": "woof perpetuals"}, {"name": "shiok", "fullName": "Shiok Lah"}, {"name": "z", "fullName": "XYZ"}, {"name": "hbtwo", "fullName": "Hyperbolic Finance 2"}, {"name": "tndex", "fullName": "test new dex"}, {"name": "rrrrr", "fullName": "rrrrr"}, {"name": "hbhb", "fullName": "Hyperbolic Finance 3"}, {"name": "stocks", "fullName": "redstocks"}, {"name": "nunchi", "fullName": "nunchi"}, {"name": "sqtwr", "fullName": "Squaretower"}, {"name": "y", "fullName": "XYZ"}, {"name": "hypodd", "fullName": "HyperOdd PEX"}, {"name": "strat", "fullName": "Stratium Crypto DEX"}, {"name": "zdex", "fullName": "zdex"}, {"name": "crayon", "fullName": "crayon dex"}, {"name": "walao", "fullName": "walao dex"}, {"name": "slpy", "fullName": "sleepy dex"}, {"name": "tsts", "fullName": "test dex"}, {"name": "mbx", "fullName": "A good dex"}, {"name": "food", "fullName": "A food dex"}, {"name": "meng", "fullName": "Meong meong meong"}, {"name": "rawr", "fullName": "Rawr >w<"}, {"name": "mmmg", "fullName": "Meong2"}, {"name": "msdx", "fullName": "BTC Perp Markets"}, {"name": "wrld", "fullName": "Hyperworld"}, {"name": "hodd", "fullName": "HyperOdd Leveraged Prediction Markets"}, {"name": "xyz", "fullName": "XYZ"}, {"name": "flx", "fullName": "flx"}, {"name": "nmx", "fullName": "NM USDH DEX"}, {"name": "pyth", "fullName": "Pyth Network test 1"}, {"name": "msda", "fullName": "MSD1 BTC Perp"}, {"name": "ho", "fullName": "HyperOdd Prediction Perps"}, {"name": "flsh", "fullName": "HyperFlash"}, {"name": "hyfl", "fullName": "HyperFlash"}, {"name": "csan", "fullName": "computeshield"}, {"name": "sedx", "fullName": "sekai dex try"}, {"name": "rbbt", "fullName": "Rabbit"}, {"name": "rbte", "fullName": "RabbitE"}, {"name": "demo", "fullName": "demo dex"}, {"name": "flxn", "fullName": "flxn"}, {"name": "bsx", "fullName": "BasisX dex"}, {"name": "loh", "fullName": "Lohcrypto DEX by Tovkes"}, {"name": "xvm", "fullName": "EXCHANGE VIRTUAL MACHINE"}, {"name": "dydx", "fullName": "NEXON DEX"}, {"name": "fzx", "fullName": "Felix Exchange"}, {"name": "abdc", "fullName": "America's Best Dance Crew"}, {"name": "fam", "fullName": "Famous Dex"}, {"name": "gato", "fullName": "EL GATO THE MF"}, {"name": "bart", "fullName": "Bart Simpson Dex"}, {"name": "sss", "fullName": "SSS"}, {"name": "owal", "fullName": "OWALA"}, {"name": "gg", "fullName": "hyperliquid blockchain is my bitch"}, {"name": "fdnk", "fullName": "FDNK"}, {"name": "trve", "fullName": "Trove"}, {"name": "odd", "fullName": "HyperOdd Prediction Perps"}, {"name": "trv", "fullName": "Trove"}, {"name": "qwrn", "fullName": "Qwrn"}, {"name": "zzzz", "fullName": "Felix Exchange"}, {"name": "gass", "fullName": "gass Exchange"}, {"name": "whtm", "fullName": "Whtm"}, {"name": "rip", "fullName": "RIP"}, {"name": "sand", "fullName": "Dune Sand DEX"}, {"name": "hmax", "fullName": "hypermax"}, {"name": "slob", "fullName": "she slob on my blob till i hyperliquid"}, {"name": "hyfi", "fullName": "HYFI"}, {"name": "str", "fullName": "Stratium DEX"}, {"name": "trov", "fullName": "Trove"}, {"name": "erez", "fullName": "Erez"}, {"name": "paef", "fullName": "Paef"}, {"name": "mcd", "fullName": "Old MacDonald's Farm"}, {"name": "birb", "fullName": "BIRB BIRB BIRB BIRB BIRB"}, {"name": "plsh", "fullName": "My beloved plushies"}, {"name": "cnst", "fulLine truncated
-8
View File
@@ -1,8 +0,0 @@
{"channel":"clearinghouseState","data":{"dex":"","user":"0x00000000000000000000000000000000000f107e","clearinghouseState":{"marginSummary":{"accountValue":"0.0","totalNtlPos":"0.0","totalRawUsd":"0.0","totalMarginUsed":"0.0"},"crossMarginSummary":{"accountValue":"0.0","totalNtlPos":"0.0","totalRawUsd":"0.0","totalMarginUsed":"0.0"},"crossMaintenanceMarginUsed":"0.0","withdrawable":"0.0","assetPositions":[],"time":1793667764987}}}
{"channel":"clearinghouseState","data":{"dex":"xyz","user":"0x00000000000000000000000000000000000f107e","clearinghouseState":{"marginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMarginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMaintenanceMarginUsed":"52.105281","withdrawable":"339.171412","assetPositions":[{"type":"oneWay","position":{"coin":"xyz:TSLA","szi":"-1.483","leverage":{"type":"cross","value":10},"entryPx":"404.349","positionValue":"668.92198","unrealizedPnl":"-69.27118","returnOnEquity":"-1.1551919884","liquidationPx":"704.9387849597","marginUsed":"66.892198","maxLeverage":10,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:NVDA","szi":"-3.038","leverage":{"type":"cross","value":20},"entryPx":"218.372","positionValue":"642.71928","unrealizedPnl":"20.696851","returnOnEquity":"0.6239477768","liquidationPx":"338.5136601423","marginUsed":"32.135964","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:AAPL","szi":"0.316","leverage":{"type":"cross","value":20},"entryPx":"344.82","positionValue":"103.648","unrealizedPnl":"-5.31512","returnOnEquity":"-0.9755814628","liquidationPx":null,"marginUsed":"5.1824","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}}],"time":1793667765068}}}
{"channel":"openOrders","data":{"dex":"","user":"0x00000000000000000000000000000000000f107e","orders":[]}}
{"channel":"openOrders","data":{"dex":"xyz","user":"0x00000000000000000000000000000000000f107e","orders":[{"coin":"xyz:AAPL","side":"A","limitPx":"346.21","sz":"0.789","oid":63919996139,"timestamp":1793214466685,"triggerCondition":"N/A","isTrigger":false,"triggerPx":"0.0","children":[],"isPositionTpsl":false,"reduceOnly":false,"orderType":"Limit","origSz":"0.866","tif":"Alo","cloid":null}]}}
{"channel":"userFills","data":{"isSnapshot":true,"user":"0x00000000000000000000000000000000000f107e","fills":[{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207760467,"startPosition":"-1.042","dir":"Open Short","closedPnl":"0.0","hash":"0x3bfad0510cfa9bb33d74042cd1bbfe010900e836a7fdba85dfc37ba3cbfe759d","oid":63912181528,"crossed":false,"fee":"0.000364","tid":1069007623150225,"feeToken":"USDC","twapId":null},{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207763442,"startPosition":"-1.072","dir":"Open Short","closedPnl":"0.0","hash":"0xf5b2dc2fbb4da86bf72c042cd1bc2d010b00f4155640c73e997b87827a418256","oid":63912181528,"crossed":false,"fee":"0.000364","tid":971524471857229,"feeToken":"USDC","twapId":null},{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207766547,"startPosition":"-1.102","dir":"Open Short","closedPnl":"0.0","hash":"0x1e5e84a6252ddbe31fd8042cd1bc570107009c8bc020fab5c2272ff8e421b5cd","oid":63912181528,"crossed":false,"fee":"0.000364","tid":123747502287917,"feeToken":"USDC","twapId":null},{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207769432,"startPosition":"-1.132","dir":"Open Short","closedPnl":"0.0","hash":"0x2ad26b5a41f6e0bc2c4c042cd1bc81010a00833fdcf9ff8ece9b16ad00fabaa6","oid":63912181528,"crossed":false,"fee":"0.000364","tid":1077238072313899,"feeToken":"USDC","twapId":null}]}}
{"channel":"userFundings","data":{"isSnapshot":true,"user":"0x00000000000000000000000000000000000f107e","fundings":[]}}
{"channel":"activeAssetData","data":{"user":"0x00000000000000000000000000000000000f107e","coin":"xyz:AAPL","leverage":{"type":"cross","value":20},"maxTradeSzs":["20.928","20.646"],"availableToTrade":["343.2192","338.5944"],"markPx":"328.0"}}
{"channel":"allMids","data":{"dex":"xyz","mids":{"xyz:AAPL":"322.15","xyz:ALUMINIUM":"3192.3","xyz:AMD":"519.2","xyz:AMZN":"246.33","xyz:AVGO":"495.0","xyz:BABA":"110.03"}}}
-232
View File
@@ -1,232 +0,0 @@
//! This build cannot reach or sign for Hyperliquid mainnet. These tests fail if that changes.
use std::path::Path;
use tr_hl::Network;
use tr_hl::network::{API_URL, WS_URL};
/// Mainnet hosts that must never appear in this crate (the testnet hosts are
/// `api.hyperliquid-testnet.xyz`, which none of these match).
const MAINNET_HOSTS: [&str; 3] = ["api.hyperliquid.xyz", "app.hyperliquid.xyz", "wss://api.hyperliquid.xyz"];
fn sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
sources(&p, out);
} else if p.extension().is_some_and(|x| x == "rs" || x == "toml") {
out.push(p);
}
}
}
#[test]
fn the_crate_names_no_mainnet_host() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut files = vec![root.join("Cargo.toml")];
for d in ["src", "examples", "tests"] {
if root.join(d).exists() {
sources(&root.join(d), &mut files);
}
}
assert!(files.len() > 10, "found the sources: {files:?}");
let me = file!().rsplit('/').next().unwrap();
for f in files {
// This file lists the hosts it looks for.
if f.file_name().is_some_and(|n| n == me) {
continue;
}
let text = std::fs::read_to_string(&f).unwrap();
for host in MAINNET_HOSTS {
assert!(!text.contains(host), "{} names the mainnet host {host}", f.display());
}
}
}
#[test]
fn the_endpoints_are_the_testnet_hosts() {
assert_eq!(API_URL, "https://api.hyperliquid-testnet.xyz");
assert_eq!(WS_URL, "wss://api.hyperliquid-testnet.xyz/ws");
let n = Network::default();
assert!(n.info_url().starts_with("https://api.hyperliquid-testnet.xyz/"));
assert!(n.exchange_url().starts_with("https://api.hyperliquid-testnet.xyz/"));
}
#[test]
fn there_is_one_network_and_it_signs_as_testnet() {
// Adding a variant makes this match non-exhaustive, so the test stops compiling.
match Network::default() {
Network::Testnet => {}
}
assert_eq!(Network::Testnet.phantom_source(), "b");
assert_eq!(Network::Testnet.hyperliquid_chain(), "Testnet");
assert_eq!(Network::Testnet.label(), "TESTNET");
}
#[test]
fn stored_preferences_cannot_select_mainnet() {
#[derive(serde::Deserialize)]
struct Prefs {
network: Network,
}
for v in ["\"mainnet\"", "\"Mainnet\"", "\"MAINNET\"", "\"testnet\"", "\"arbitrum\"", "0", "true", "null"] {
let p: Prefs = serde_json::from_str(&format!(r#"{{"network": {v}}}"#)).unwrap();
assert_eq!(p.network, Network::Testnet, "{v}");
}
}
#[test]
fn signatures_are_testnet_signatures() {
use tr_hl::actions::ApproveAgentAction;
use tr_hl::signing::{recover_approve_agent, sign_approve_agent};
use tr_hl::trader::approve_agent;
let main = tr_hl::MainKey::generate();
let agent = tr_hl::AgentKey::generate();
let a = approve_agent(&tr_hl::SigningKey::address(&agent), "ci", 30, 1_793_667_000_000);
assert_eq!(a.hyperliquid_chain, "Testnet");
let sig = sign_approve_agent(&main, &a).unwrap();
assert_eq!(recover_approve_agent(&a, &sig).unwrap(), tr_hl::SigningKey::address(&main));
// Re-addressed to mainnet, the same action can't be signed, and the testnet signature
// doesn't recover the signer for it.
let mainnet = ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a };
assert!(sign_approve_agent(&main, &mainnet).is_err());
assert!(recover_approve_agent(&mainnet, &sig).is_err());
}
/// A main wallet held elsewhere (Turnkey) signs a digest this crate hands it. It is only
/// ever asked for a testnet `approveAgent` at the testnet signature chain, and its answer is
/// used only if it recovers the main wallet.
#[test]
fn a_remote_main_wallet_signs_only_testnet_approvals() {
use std::cell::Cell;
use tr_hl::actions::ApproveAgentAction;
use tr_hl::signing::{recover_approve_agent, sign_approve_agent_with};
use tr_hl::trader::approve_agent;
// The remote wallet is a plain k256 key here, signing whatever 32 bytes it is given.
let main = k256::ecdsa::SigningKey::random(&mut rand_core::OsRng);
let master = alloy_primitives::Address::from_private_key(&main);
let sign = |key: &k256::ecdsa::SigningKey,
digest: alloy_primitives::B256|
-> anyhow::Result<alloy_primitives::Signature> {
let (sig, recid) = key.sign_prehash_recoverable(digest.as_slice())?;
Ok(alloy_primitives::Signature::from_signature_and_parity(sig, recid.is_y_odd()))
};
let agent = tr_hl::AgentKey::generate();
let a = approve_agent(&tr_hl::SigningKey::address(&agent), "ci", 180, 1_793_667_000_000);
assert_eq!((a.hyperliquid_chain.as_str(), a.signature_chain_id.as_str()), ("Testnet", "0x66eee"));
let asked = Cell::new(0);
let remote = |digest: alloy_primitives::B256| {
asked.set(asked.get() + 1);
let sig = sign(&main, digest);
async move { sig }
};
let sig = futures::executor::block_on(sign_approve_agent_with(&a, &master, remote)).unwrap();
assert_eq!(recover_approve_agent(&a, &sig).unwrap(), master);
assert_eq!(asked.get(), 1);
// Mainnet, or a mainnet signature chain (Arbitrum One, 0xa4b1), never reaches the signer.
for bad in [
ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a.clone() },
ApproveAgentAction { signature_chain_id: "0xa4b1".into(), ..a.clone() },
ApproveAgentAction { kind: "usdSend".into(), ..a.clone() },
] {
assert!(futures::executor::block_on(sign_approve_agent_with(&bad, &master, remote)).is_err(), "{bad:?}");
}
assert_eq!(asked.get(), 1, "the remote signer was asked to sign a non-testnet action");
// A signature from any other key is refused rather than sent.
let other = k256::ecdsa::SigningKey::random(&mut rand_core::OsRng);
let wrong = |digest: alloy_primitives::B256| {
let sig = sign(&other, digest);
async move { sig }
};
let err = futures::executor::block_on(sign_approve_agent_with(&a, &master, wrong)).unwrap_err();
assert!(err.to_string().contains("not the wallet's"), "{err}");
}
/// The built-in wallet (Turnkey's API; here its local stand-in) is never sent anything for
/// mainnet: the request is refused before it is made.
#[tokio::test]
async fn the_built_in_wallet_is_never_asked_to_sign_for_mainnet() {
use tr_hl::actions::ApproveAgentAction;
use tr_hl::signing::sign_approve_agent_with;
use tr_hl::trader::approve_agent;
let mock = tr_turnkey::mock::MockTurnkey::start(0, None).await.unwrap();
let key = tr_turnkey::SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 60);
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let session = tr_turnkey::Session::accept(key, &token, now).unwrap();
let client = tr_turnkey::Client::with_base(&mock.base).unwrap();
let master: alloy_primitives::Address = mock.address().parse().unwrap();
let sign = |a: ApproveAgentAction| {
let (client, session) = (&client, &session);
async move {
sign_approve_agent_with(&a, &master, |d| async move { client.sign_digest(session, &master, &d).await })
.await
}
};
let a = approve_agent(&tr_hl::SigningKey::address(&tr_hl::AgentKey::generate()), "ci", 180, 1_793_667_000_000);
for bad in [
ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a.clone() },
ApproveAgentAction { signature_chain_id: "0xa4b1".into(), ..a.clone() },
] {
assert!(sign(bad).await.is_err());
}
assert!(mock.requests().is_empty(), "the wallet was asked to sign a mainnet action");
// The testnet approval goes through and is the only thing signed.
assert!(sign(a).await.is_ok());
let paths: Vec<String> = mock.requests().into_iter().map(|(p, _)| p).collect();
assert_eq!(paths, ["/public/v1/submit/sign_raw_payload"]);
}
/// Code added for the built-in wallet names no mainnet host either: the Turnkey crate, the
/// app's wallet modules and the sign-in page.
#[test]
fn the_wallet_code_names_no_mainnet_host() {
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let mut files = Vec::new();
for d in [
"crates/turnkey/src",
"crates/turnkey/tests",
"crates/turnkey/examples",
"site/src/routes/wallet",
"site/src/lib/wallet",
] {
if repo.join(d).exists() {
sources_any(&repo.join(d), &mut files);
}
}
files.push(repo.join("crates/turnkey/Cargo.toml"));
for f in [
"app/src/app/wallet.rs",
"app/src/app/wallet_view.rs",
"app/src/app/hl_web.rs",
"app/src/app/hl.rs",
"app/src/app/hl_view.rs",
] {
if repo.join(f).exists() {
files.push(repo.join(f));
}
}
assert!(files.iter().filter(|f| f.starts_with(repo.join("crates/turnkey"))).count() >= 5, "{files:?}");
for f in files {
let text = std::fs::read_to_string(&f).unwrap();
for host in MAINNET_HOSTS {
assert!(!text.contains(host), "{} names the mainnet host {host}", f.display());
}
}
}
fn sources_any(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
sources_any(&p, out);
} else if p.extension().is_some_and(|x| ["rs", "toml", "ts", "svelte", "js", "html"].iter().any(|e| x == *e)) {
out.push(p);
}
}
}
-54
View File
@@ -1,54 +0,0 @@
//! `approveAgent` signed by the built-in wallet, through the Turnkey client, against the
//! local Turnkey stand-in (`tr_turnkey::mock`, not Turnkey). The wallet there is the Python
//! SDK's public test key, so the signature must be the SDK's own vector.
use alloy_primitives::Address;
use tr_hl::actions::ApproveAgentAction;
use tr_hl::signing::{approve_agent_hash, sign_approve_agent_with};
use tr_turnkey::mock::MockTurnkey;
use tr_turnkey::{Client, Session, SessionKey};
/// The SDK's public test key (signing_test.py). Never funded; never used on a network.
const SDK_KEY: &str = "0x0123456789012345678901234567890123456789012345678901234567890123";
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
}
#[tokio::test]
async fn the_built_in_wallet_reproduces_the_python_sdk_vector() {
let mock = MockTurnkey::start(0, Some(SDK_KEY)).await.unwrap();
let key = SessionKey::generate();
let session = Session::accept(
SessionKey::from_hex(&key.to_hex()).unwrap(),
&mock.mint_session(&key.public_hex(), 60),
now_secs(),
)
.unwrap();
let client = Client::with_base(&mock.base).unwrap();
let master: Address = client.ethereum_account(&session).await.unwrap().address.parse().unwrap();
assert_eq!(format!("{master:#x}"), "0x14791697260e4c9a71f18484c9f997b308e59325");
let a = ApproveAgentAction {
kind: "approveAgent".into(),
hyperliquid_chain: "Testnet".into(),
signature_chain_id: "0x66eee".into(),
agent_address: "0x5e9ee1089755c3435139848e47e6635505d5a13a".into(),
agent_name: "terminal-vector valid_until 1700000000000".into(),
nonce: 1700000000000,
};
let sig = sign_approve_agent_with(&a, &master, |d| {
let (client, session) = (&client, &session);
async move { client.sign_digest(session, &master, &d).await }
})
.await
.unwrap();
assert_eq!(sig.r, "0x8138ea721e1fb5afe9239b21b19ffe1e4690dc2c06b0aaf661aeb9859f598a25");
assert_eq!(sig.s, "0x169543c6dbf8c0caa9e6225e6612e5c3b495afbdc840537be74a6fb465cfc321");
assert_eq!(sig.v, 28);
// Turnkey was handed exactly the testnet approval's digest.
let signed: Vec<_> = mock.requests().into_iter().filter(|(p, _)| p.ends_with("/sign_raw_payload")).collect();
assert_eq!(signed.len(), 1);
let digest = approve_agent_hash(&a).unwrap();
assert_eq!(signed[0].1["parameters"]["payload"], alloy_primitives::hex::encode(digest));
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tr-macos"
description = "macOS window chrome for Terminal: unified toolbar, vibrancy, accent colour, updates"
description = "macOS window chrome for terminal-rs: unified toolbar, vibrancy, accent colour, updates"
version.workspace = true
edition.workspace = true
+1 -1
View File
@@ -50,7 +50,7 @@ mod imp {
let Some(window) = view.window() else { return false };
window.setTitleVisibility(NSWindowTitleVisibility::Hidden);
window.setTitlebarAppearsTransparent(true);
let toolbar = NSToolbar::initWithIdentifier(NSToolbar::alloc(mtm), &NSString::from_str("terminal.toolbar"));
let toolbar = NSToolbar::initWithIdentifier(NSToolbar::alloc(mtm), &NSString::from_str("terminal-rs.toolbar"));
window.setToolbar(Some(&toolbar));
// Compact unified toolbar: one slim row shared with the traffic lights.
window.setToolbarStyle(NSWindowToolbarStyle::UnifiedCompact);
+1 -1
View File
@@ -6,7 +6,7 @@
//! [`check_for_updates`] reports that it couldn't check.
/// The project's releases on GitHub.
pub const RELEASES: &str = "https://github.com/michaelshimeles/terminal/releases";
pub const RELEASES: &str = "https://github.com/michaelshimeles/terminal-rs/releases";
/// Starts Sparkle, which checks the feed once a day and shows its update window when a newer
/// release is out, and adds "Check for Updates…" to the app menu. Main thread; later calls do
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "tr-proto"
description = "Client protocol between Terminal clients and the Terminal data server"
version.workspace = true
edition.workspace = true
# Shared by the server, the desktop app and the browser build, so it must build for
# wasm32-unknown-unknown: serde, tr-core and the two codecs only, nothing async or networked.
[dependencies]
tr-core.workspace = true
serde.workspace = true
serde_json.workspace = true
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
-629
View File
@@ -1,629 +0,0 @@
//! The protocol between Terminal clients (desktop, web, CLI) and the Terminal data server
//! (`tr-gateway`), as designed in `docs/architecture/server.md` §4.
//!
//! Each message is one websocket frame. postcard over binary frames is the default encoding;
//! JSON over text frames is a debug encoding a client asks for in [`ClientMsg::Hello`]. The
//! server reads binary frames as postcard and text frames as JSON, and answers in the encoding
//! the client's `Hello` named.
//!
//! # Evolving the protocol
//!
//! postcard is not self-describing: an enum variant travels as its index and a struct as its
//! fields in order. So:
//! - enum variants are only ever appended, never reordered or removed;
//! - a struct never gains or loses a field (a new field means a new message variant);
//! - any other change bumps [`PROTOCOL_VERSION`], and the server keeps serving N and N-1.
//!
//! The `wire_tags_are_stable` test pins every variant's index. The design's later variants
//! (`DocOp`, `DocAck`/`DocConflict`, and the `Ctx`, Congress, Portfolio, Docs and Alerts
//! topics) are not here yet; each is appended after the variants below when a server serves it.
//! [`Bar`] and [`Timeframe`] come from `tr-core`, so their field and variant order is part of
//! the wire format too.
use std::fmt;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub use tr_core::{Bar, Timeframe};
/// Bumped on any change the evolution rules above don't allow.
pub const PROTOCOL_VERSION: u16 = 1;
/// Client-chosen subscription id, unique per connection.
pub type SubId = u32;
/// Client-chosen request id, unique per connection.
pub type ReqId = u32;
/// A market as a client names it: a venue-native key (`BTC`, `xyz:TSLA`, `@107`, `PURR/USDC`)
/// or a market key the server maps to one (`BTCUSD Curncy`).
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct MarketKey(pub String);
impl MarketKey {
pub fn new(key: impl Into<String>) -> Self {
Self(key.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for MarketKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Encoding {
Postcard,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientInfo {
/// For example `terminal-desktop` or `tr-cli`.
pub name: String,
pub version: String,
}
/// Reserved for session resume (§4.3). Servers ignore it for now.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ResumeToken(pub String);
/// Client to server.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ClientMsg {
/// The first message on every connection. `token` is reserved for sign-in (§8) and is
/// ignored for now; `resume` likewise.
Hello {
version: u16,
encoding: Encoding,
client: ClientInfo,
token: Option<String>,
resume: Option<ResumeToken>,
},
Subscribe {
id: SubId,
topic: TopicReq,
},
Unsubscribe {
id: SubId,
},
/// The client saw a gap in `seq`; the server answers with a fresh `Snapshot`.
Resync {
id: SubId,
},
Request {
id: ReqId,
req: Rpc,
},
Ping {
t: u64,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TopicReq {
/// Prices for many markets in one subscription, at most `max_hz` deltas a second.
Quotes { keys: Vec<MarketKey>, max_hz: u8 },
/// Snapshot: the last `history` bars, the forming bar last. Deltas: [`BarDelta`].
Bars { key: MarketKey, tf: Timeframe, history: u32 },
/// The top `depth` levels a side, bucketed to multiples of `group` when set.
Book { key: MarketKey, depth: u8, group: Option<f64> },
/// Snapshot: recent trades. Deltas: new trades, oldest first.
Trades { key: MarketKey },
}
/// One-shot requests.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Rpc {
/// Up to `limit` bars before `end` (Unix seconds; `None` = now), oldest first. The same
/// contract as `MarketData::history` in `tr-data`.
History { key: MarketKey, tf: Timeframe, end: Option<i64>, limit: u32 },
/// Every market the server can stream right now.
Markets,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RpcResult {
History(Vec<Bar>),
Markets(Vec<MarketInfo>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MarketInfo {
pub key: MarketKey,
/// Where it trades: a Hyperliquid perp dex (`""` is the core dex, `xyz`, ...) or `spot`.
pub venue: String,
pub kind: InstrumentKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum InstrumentKind {
Perp,
Spot,
}
/// Opaque per-connection id, for logs and support.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionId(pub u64);
/// Reserved for sign-in (§8). Always `None` for now.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserInfo {
pub id: String,
pub name: String,
}
/// What the server accepts on this connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Limits {
pub max_subscriptions: u32,
pub max_quote_keys: u32,
pub max_book_depth: u8,
pub max_history: u32,
}
/// Server to client.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ServerMsg {
Welcome {
version: u16,
session: SessionId,
user: Option<UserInfo>,
server_time_ms: i64,
limits: Limits,
},
/// Full state. Resets the subscription's `seq`.
Snapshot {
id: SubId,
seq: u64,
data: TopicData,
},
/// `seq` is the previous message's `seq` + 1.
Delta {
id: SubId,
seq: u64,
data: TopicDelta,
},
/// The server dropped deltas for this subscription; a `Snapshot` follows.
Resync {
id: SubId,
reason: ResyncReason,
},
Reply {
id: ReqId,
result: Result<RpcResult, ApiError>,
},
Status {
id: SubId,
status: TopicStatus,
},
Pong {
t: u64,
server_time_ms: i64,
},
Error {
code: ErrorCode,
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TopicData {
Quotes(Vec<Quote>),
Bars(Vec<Bar>),
Book(Book),
Trades(Vec<Trade>),
}
/// For state topics (quotes, book) a delta is the new value, so a lost one is harmless.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TopicDelta {
/// The quotes that changed since the last message.
Quotes(Vec<Quote>),
Bar(BarDelta),
Book(Book),
Trades(Vec<Trade>),
}
/// Bars stream as the forming bar's latest state, and a `Closed` bar with its final values
/// before the first `Forming` of the next bar. The server may drop a `Forming` that a later
/// message for the same `ts` supersedes; it never drops a `Closed`. Clients apply `Closed` by
/// replacing the bar with that `ts` (§4.3).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum BarDelta {
Forming(Bar),
Closed(Bar),
}
impl BarDelta {
pub fn bar(&self) -> &Bar {
match self {
BarDelta::Forming(b) | BarDelta::Closed(b) => b,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Quote {
/// The key as the client sent it.
pub key: MarketKey,
/// The venue market that backs it (`BTC` for `BTCUSD Curncy`).
pub source: MarketKey,
pub px: f64,
pub basis: PriceBasis,
/// The price 24 hours earlier, when the venue publishes one.
pub prev_day: Option<f64>,
/// When the server received this price (Unix milliseconds).
pub ts_ms: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PriceBasis {
Mid,
Mark,
Oracle,
Last,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Book {
/// Best first: bids descending, asks ascending.
pub bids: Vec<Level>,
pub asks: Vec<Level>,
/// The venue's time for this book (Unix milliseconds).
pub time_ms: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Level {
pub px: f64,
pub sz: f64,
/// Orders at this level (0 when grouped levels were merged and the venue gave none).
pub n: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Trade {
pub px: f64,
pub sz: f64,
/// The aggressor's side.
pub side: Side,
/// The venue's trade time (Unix milliseconds).
pub time_ms: i64,
/// Venue trade id; unique together with `time_ms`.
pub tid: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Side {
Buy,
Sell,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ResyncReason {
/// The client fell too far behind an event topic.
Overflow,
/// The server's own upstream reconnected, so its state may have jumped.
Upstream,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TopicStatus {
Live,
/// No fresh data since `since_ms` (Unix milliseconds); the last values may be old.
Stale {
since_ms: i64,
},
/// The server can't serve this subscription. It stays open but gets no data.
Unavailable {
reason: UnavailableReason,
detail: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum UnavailableReason {
UnknownMarket,
/// The server is at its upstream subscription budget.
Capacity,
Unsupported,
Upstream,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ErrorCode {
BadMessage,
UnsupportedVersion,
HelloRequired,
DuplicateId,
UnknownId,
TooManySubscriptions,
SlowConsumer,
UnknownMarket,
Upstream,
Internal,
/// The client sent messages faster than `Limits` allows; the server ignored this one.
RateLimited,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiError {
pub code: ErrorCode,
pub message: String,
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}: {}", self.code, self.message)
}
}
impl std::error::Error for ApiError {}
/// One encoded websocket frame.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Frame {
Binary(Vec<u8>),
Text(String),
}
impl Frame {
pub fn len(&self) -> usize {
match self {
Frame::Binary(b) => b.len(),
Frame::Text(t) => t.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodecError(pub String);
impl fmt::Display for CodecError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "protocol codec: {}", self.0)
}
}
impl std::error::Error for CodecError {}
/// Encode a message as one frame: postcard in a binary frame, or JSON in a text frame.
pub fn encode<T: Serialize>(msg: &T, encoding: Encoding) -> Result<Frame, CodecError> {
match encoding {
Encoding::Postcard => postcard::to_stdvec(msg).map(Frame::Binary).map_err(|e| CodecError(e.to_string())),
Encoding::Json => serde_json::to_string(msg).map(Frame::Text).map_err(|e| CodecError(e.to_string())),
}
}
/// Decode a binary (postcard) frame.
pub fn decode_binary<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, CodecError> {
postcard::from_bytes(bytes).map_err(|e| CodecError(e.to_string()))
}
/// Decode a text (JSON) frame.
pub fn decode_text<T: DeserializeOwned>(text: &str) -> Result<T, CodecError> {
serde_json::from_str(text).map_err(|e| CodecError(e.to_string()))
}
/// Decode either kind of frame.
pub fn decode<T: DeserializeOwned>(frame: &Frame) -> Result<T, CodecError> {
match frame {
Frame::Binary(b) => decode_binary(b),
Frame::Text(t) => decode_text(t),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(s: &str) -> MarketKey {
MarketKey::new(s)
}
fn bar(ts: i64) -> Bar {
Bar::new(ts, 76_612.0, 76_640.5, 76_443.0, 76_521.0, 74.46557)
}
fn book(levels: usize) -> Book {
let side = |sign: f64| {
(0..levels)
.map(|i| Level { px: 76_530.0 + sign * i as f64, sz: 1.5 + i as f64 * 0.1, n: 3 + i as u32 })
.collect()
};
Book { bids: side(-1.0), asks: side(1.0), time_ms: 1_793_325_942_459 }
}
fn quote(k: &str, src: &str) -> Quote {
Quote {
key: key(k),
source: key(src),
px: 76_521.5,
basis: PriceBasis::Mid,
prev_day: Some(75_000.0),
ts_ms: 1_793_325_942_459,
}
}
fn trade(i: u64) -> Trade {
Trade { px: 76_528.0, sz: 0.41745, side: Side::Buy, time_ms: 1_793_325_941_395, tid: 1_005_251_635_753_713 + i }
}
fn client_msgs() -> Vec<ClientMsg> {
vec![
ClientMsg::Hello {
version: PROTOCOL_VERSION,
encoding: Encoding::Postcard,
client: ClientInfo { name: "tr-cli".into(), version: "0.1.1".into() },
token: None,
resume: None,
},
ClientMsg::Subscribe {
id: 1,
topic: TopicReq::Quotes { keys: vec![key("BTC"), key("xyz:TSLA")], max_hz: 4 },
},
ClientMsg::Unsubscribe { id: 1 },
ClientMsg::Resync { id: 2 },
ClientMsg::Request {
id: 7,
req: Rpc::History { key: key("BTCUSD Curncy"), tf: Timeframe::M1, end: None, limit: 500 },
},
ClientMsg::Ping { t: 42 },
]
}
fn server_msgs() -> Vec<ServerMsg> {
let limits = Limits { max_subscriptions: 64, max_quote_keys: 512, max_book_depth: 20, max_history: 5000 };
vec![
ServerMsg::Welcome {
version: PROTOCOL_VERSION,
session: SessionId(9),
user: None,
server_time_ms: 1_793_325_942_000,
limits,
},
ServerMsg::Snapshot { id: 1, seq: 0, data: TopicData::Quotes(vec![quote("BTCUSD Curncy", "BTC")]) },
ServerMsg::Delta { id: 1, seq: 1, data: TopicDelta::Bar(BarDelta::Closed(bar(60))) },
ServerMsg::Resync { id: 1, reason: ResyncReason::Overflow },
ServerMsg::Reply { id: 7, result: Ok(RpcResult::History(vec![bar(0), bar(60)])) },
ServerMsg::Status {
id: 3,
status: TopicStatus::Unavailable { reason: UnavailableReason::Capacity, detail: "budget".into() },
},
ServerMsg::Pong { t: 42, server_time_ms: 1_793_325_942_000 },
ServerMsg::Error { code: ErrorCode::BadMessage, message: "nope".into() },
]
}
#[test]
fn round_trips_in_both_encodings() {
for enc in [Encoding::Postcard, Encoding::Json] {
for m in client_msgs() {
let f = encode(&m, enc).unwrap();
assert_eq!(decode::<ClientMsg>(&f).unwrap(), m, "{enc:?}");
}
for m in server_msgs() {
let f = encode(&m, enc).unwrap();
assert_eq!(decode::<ServerMsg>(&f).unwrap(), m, "{enc:?}");
}
let extra = [
ServerMsg::Snapshot { id: 4, seq: 10, data: TopicData::Book(book(20)) },
ServerMsg::Snapshot { id: 5, seq: 0, data: TopicData::Trades(vec![trade(0), trade(1)]) },
ServerMsg::Snapshot { id: 6, seq: 0, data: TopicData::Bars(vec![bar(0)]) },
ServerMsg::Delta { id: 4, seq: 11, data: TopicDelta::Book(book(5)) },
ServerMsg::Delta { id: 5, seq: 1, data: TopicDelta::Trades(vec![trade(2)]) },
ServerMsg::Delta { id: 1, seq: 2, data: TopicDelta::Quotes(vec![quote("BTC", "BTC")]) },
ServerMsg::Reply {
id: 8,
result: Ok(RpcResult::Markets(vec![MarketInfo {
key: key("xyz:TSLA"),
venue: "xyz".into(),
kind: InstrumentKind::Perp,
}])),
},
ServerMsg::Reply {
id: 9,
result: Err(ApiError { code: ErrorCode::UnknownMarket, message: "x".into() }),
},
ServerMsg::Status { id: 1, status: TopicStatus::Stale { since_ms: 5 } },
];
for m in extra {
assert_eq!(decode::<ServerMsg>(&encode(&m, enc).unwrap()).unwrap(), m);
}
}
}
#[test]
fn encodings_pick_the_frame_kind() {
let m = ClientMsg::Ping { t: 1 };
assert!(matches!(encode(&m, Encoding::Postcard).unwrap(), Frame::Binary(_)));
assert_eq!(encode(&m, Encoding::Json).unwrap(), Frame::Text(r#"{"Ping":{"t":1}}"#.into()));
assert!(decode_binary::<ClientMsg>(&[0xff, 0xff]).is_err());
assert!(decode_text::<ClientMsg>("{}").is_err());
}
/// postcard writes an enum variant as its index. Appending variants keeps these; reordering
/// or removing one changes them and breaks every deployed client, so this test pins them.
#[test]
fn wire_tags_are_stable() {
let tag = |f: Frame| match f {
Frame::Binary(b) => b[0],
Frame::Text(_) => unreachable!(),
};
let client: Vec<u8> = client_msgs().iter().map(|m| tag(encode(m, Encoding::Postcard).unwrap())).collect();
assert_eq!(client, [0, 1, 2, 3, 4, 5]);
let server: Vec<u8> = server_msgs().iter().map(|m| tag(encode(m, Encoding::Postcard).unwrap())).collect();
assert_eq!(server, [0, 1, 2, 3, 4, 5, 6, 7]);
let t = |v: Vec<u8>| v[0];
let topics = [
TopicReq::Quotes { keys: vec![], max_hz: 1 },
TopicReq::Bars { key: key("BTC"), tf: Timeframe::M1, history: 1 },
TopicReq::Book { key: key("BTC"), depth: 1, group: None },
TopicReq::Trades { key: key("BTC") },
];
assert_eq!(topics.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2, 3]);
let data =
[TopicData::Quotes(vec![]), TopicData::Bars(vec![]), TopicData::Book(book(0)), TopicData::Trades(vec![])];
assert_eq!(data.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2, 3]);
let deltas = [
TopicDelta::Quotes(vec![]),
TopicDelta::Bar(BarDelta::Forming(bar(0))),
TopicDelta::Book(book(0)),
TopicDelta::Trades(vec![]),
];
assert_eq!(deltas.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2, 3]);
assert_eq!(
[BarDelta::Forming(bar(0)), BarDelta::Closed(bar(0))].map(|x| t(postcard::to_stdvec(&x).unwrap())),
[0, 1]
);
assert_eq!(
[Rpc::History { key: key("B"), tf: Timeframe::D1, end: None, limit: 1 }, Rpc::Markets]
.map(|x| t(postcard::to_stdvec(&x).unwrap())),
[0, 1]
);
let status = [
TopicStatus::Live,
TopicStatus::Stale { since_ms: 0 },
TopicStatus::Unavailable { reason: UnavailableReason::Upstream, detail: String::new() },
];
assert_eq!(status.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2]);
// tr-core types are part of the wire format: Timeframe's variant order and Bar's fields.
let tfs: Vec<u8> = Timeframe::ALL.iter().map(|tf| t(postcard::to_stdvec(tf).unwrap())).collect();
assert_eq!(tfs, [0, 1, 2, 3, 4, 5, 6, 7, 8]);
let b = postcard::to_stdvec(&Bar::new(1, 2.0, 3.0, 4.0, 5.0, 6.0)).unwrap();
assert_eq!(b.len(), 1 + 5 * 8, "ts as a varint, then five f64 in field order");
assert_eq!(&b[1..9], &2.0f64.to_le_bytes());
}
/// Frame sizes for the messages that dominate traffic (§11 estimates ~30 bytes a quote).
#[test]
fn frame_sizes() {
let size = |m: &ServerMsg, e| encode(m, e).unwrap().len();
let one_quote = ServerMsg::Delta { id: 1, seq: 1234, data: TopicDelta::Quotes(vec![quote("BTC", "BTC")]) };
let book20 = ServerMsg::Delta { id: 2, seq: 1234, data: TopicDelta::Book(book(20)) };
let one_trade = ServerMsg::Delta { id: 3, seq: 1234, data: TopicDelta::Trades(vec![trade(0)]) };
let forming = ServerMsg::Delta { id: 4, seq: 1234, data: TopicDelta::Bar(BarDelta::Forming(bar(60))) };
let rows = [("quote", &one_quote), ("book 20x2", &book20), ("trade", &one_trade), ("bar", &forming)];
for (name, m) in rows {
let (p, j) = (size(m, Encoding::Postcard), size(m, Encoding::Json));
println!("{name:>9}: postcard {p:>4} B, json {j:>5} B ({:.1}x)", j as f64 / p as f64);
assert!(p < j, "{name}: postcard should be smaller");
}
assert!(size(&one_quote, Encoding::Postcard) <= 40);
assert!(size(&one_trade, Encoding::Postcard) <= 40);
assert!(size(&forming, Encoding::Postcard) <= 50);
assert!(size(&book20, Encoding::Postcard) <= 40 * 18 + 16);
}
}
-38
View File
@@ -1,38 +0,0 @@
[package]
name = "tr-turnkey"
description = "Built-in wallets on Turnkey: the device's session key and request stamps, the few Turnkey calls Terminal makes, and the desktop sign-in's loopback callback"
version.workspace = true
edition.workspace = true
[features]
# An in-process stand-in for Turnkey's API and sign-in page, for tests and screenshots.
# Never in a shipped build.
mock = ["dep:k256"]
[dependencies]
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
tracing.workspace = true
alloy-primitives = { version = "1", default-features = false, features = ["std", "k256"] }
turnkey_api_key_stamper = "0.17.1"
p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
base64 = "0.22"
hex = "0.4"
rand_core = { version = "0.6", features = ["getrandom"] }
zeroize = "1"
web-time = "1"
k256 = { version = "0.13", default-features = false, features = ["std", "ecdsa"], optional = true }
# The desktop sign-in listens for the browser's redirect on 127.0.0.1.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { workspace = true, features = ["net", "io-util", "time"] }
# Key generation on the web reads the browser's crypto.getRandomValues.
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "net", "io-util", "time"] }
tr-turnkey = { path = ".", features = ["mock"] }
-215
View File
@@ -1,215 +0,0 @@
//! Desktop sign-in: the browser hands the session back to Terminal by redirecting to
//! `http://127.0.0.1:<port>/callback?state=…&session=…` (a loopback redirect, RFC 8252 §7.3).
//!
//! - The listener binds 127.0.0.1 on a port the OS picks, so nothing off this machine can
//! reach it, and it closes after one good answer or [`SIGN_IN_TIMEOUT`].
//! - `state` is 256 random bits made for this sign-in. Requests without it (another tab,
//! another program poking the port) get an error page and don't end the wait.
//! - The session token is bound to this device's key ([`crate::session::Session::accept`]
//! checks that), so even an intercepted redirect is useless to anyone else.
//! - Only `GET /callback` is answered; requests are read up to 8 KiB.
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::session::same_secret;
pub use crate::SIGN_IN_TIMEOUT;
/// Bad requests tolerated before the sign-in gives up.
const MAX_STRAY: usize = 32;
const MAX_REQUEST: usize = 8 * 1024;
/// What the browser came back with.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
/// The session token for this device's key.
Session(String),
/// The page reported that the user cancelled or sign-in failed.
Failed(String),
}
/// A loopback listener waiting for one sign-in.
pub struct Callback {
listener: TcpListener,
port: u16,
}
impl Callback {
/// Listen on 127.0.0.1 at a port the OS picks.
pub async fn bind() -> Result<Self> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.context("listening on 127.0.0.1 for the sign-in")?;
let port = listener.local_addr()?.port();
Ok(Self { listener, port })
}
pub fn port(&self) -> u16 {
self.port
}
/// Wait for `GET /callback` carrying `state`, up to `timeout`.
pub async fn wait(self, state: &str, timeout: Duration) -> Result<Answer> {
tokio::time::timeout(timeout, self.serve(state))
.await
.map_err(|_| anyhow!("the browser didn't come back within {} minutes; try again", timeout.as_secs() / 60))?
}
async fn serve(self, state: &str) -> Result<Answer> {
let mut stray = 0;
loop {
let (mut sock, _) = self.listener.accept().await?;
match read_request(&mut sock).await {
Ok(Some(target)) => match judge(&target, state) {
Verdict::Done(answer) => {
let page = match &answer {
Answer::Session(_) => PAGE_DONE,
Answer::Failed(_) => PAGE_FAILED,
};
let _ = respond(&mut sock, "200 OK", page).await;
return Ok(answer);
}
Verdict::NotFound => {
let _ = respond(&mut sock, "404 Not Found", PAGE_STRAY).await;
}
Verdict::WrongState => {
let _ = respond(&mut sock, "400 Bad Request", PAGE_STRAY).await;
}
},
Ok(None) | Err(_) => {
let _ = respond(&mut sock, "400 Bad Request", PAGE_STRAY).await;
}
}
stray += 1;
if stray >= MAX_STRAY {
bail!("too many unexpected requests reached the sign-in listener; try again");
}
}
}
}
enum Verdict {
Done(Answer),
WrongState,
NotFound,
}
/// Decide what a request target (`/callback?…`) means for the sign-in with `state`.
fn judge(target: &str, state: &str) -> Verdict {
let (path, query) = target.split_once('?').unwrap_or((target, ""));
if path != "/callback" {
return Verdict::NotFound;
}
let get = |k: &str| query.split('&').find_map(|kv| kv.strip_prefix(k)?.strip_prefix('=')).map(percent_decode);
match get("state") {
Some(s) if same_secret(&s, state) => {}
_ => return Verdict::WrongState,
}
match (get("session"), get("error")) {
(Some(token), None) if !token.is_empty() => Verdict::Done(Answer::Session(token)),
(_, Some(e)) => Verdict::Done(Answer::Failed(e.chars().filter(|c| !c.is_control()).take(200).collect())),
_ => Verdict::WrongState,
}
}
/// The request target of a `GET`, or `None` for anything else.
async fn read_request(sock: &mut TcpStream) -> Result<Option<String>> {
let mut buf = Vec::with_capacity(1024);
let mut chunk = [0u8; 1024];
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
let n = tokio::time::timeout(Duration::from_secs(10), sock.read(&mut chunk)).await??;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if buf.len() > MAX_REQUEST {
return Ok(None);
}
}
let text = String::from_utf8_lossy(&buf);
let mut first = text.lines().next().unwrap_or_default().split(' ');
match (first.next(), first.next()) {
(Some("GET"), Some(target)) => Ok(Some(target.to_string())),
_ => Ok(None),
}
}
async fn respond(sock: &mut TcpStream, status: &str, body: &str) -> Result<()> {
let head = format!(
"HTTP/1.1 {status}\r\ncontent-type: text/html; charset=utf-8\r\ncontent-length: {}\r\ncache-control: no-store\r\nreferrer-policy: no-referrer\r\nconnection: close\r\n\r\n",
body.len()
);
sock.write_all(head.as_bytes()).await?;
sock.write_all(body.as_bytes()).await?;
sock.shutdown().await?;
Ok(())
}
/// `%XX` and `+` decoding for a query value.
pub fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < b.len() => {
match u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16) {
Ok(v) => {
out.push(v);
i += 2;
}
Err(_) => out.push(b'%'),
}
}
c => out.push(c),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
const PAGE_DONE: &str = concat!(
"<!doctype html><meta charset=utf-8><title>Terminal</title><style>",
"body{font:15px -apple-system,system-ui,sans-serif;background:#0b0b0c;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0}main{max-width:420px;text-align:center}b{color:#f5a623}",
"</style><main><h2>Signed in</h2><p>Your wallet is connected to Terminal. You can close this tab.</p><p><b>TESTNET</b></p></main>"
);
const PAGE_FAILED: &str = concat!(
"<!doctype html><meta charset=utf-8><title>Terminal</title><style>",
"body{font:15px -apple-system,system-ui,sans-serif;background:#0b0b0c;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0}main{max-width:420px;text-align:center}",
"</style><main><h2>Not signed in</h2><p>Terminal got the answer. Go back to it to try again.</p></main>"
);
const PAGE_STRAY: &str = "<!doctype html><meta charset=utf-8><title>Terminal</title><p>This sign-in link isn't for this Terminal window.</p>";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_callback_with_this_state_ends_the_sign_in() {
let st = "ab".repeat(32);
let ok = format!("/callback?state={st}&session=h.p.s");
assert!(matches!(judge(&ok, &st), Verdict::Done(Answer::Session(t)) if t == "h.p.s"));
let failed = format!("/callback?error=cancelled+by+user&state={st}");
assert!(matches!(judge(&failed, &st), Verdict::Done(Answer::Failed(e)) if e == "cancelled by user"));
for bad in [
format!("/callback?state={}&session=h.p.s", "cd".repeat(32)),
"/callback?session=h.p.s".to_string(),
format!("/callback?state={}&session=h.p.s", &st[..63]),
format!("/callback?state={st}"),
format!("/callback?state={st}&session="),
] {
assert!(matches!(judge(&bad, &st), Verdict::WrongState), "{bad}");
}
assert!(matches!(judge(&format!("/other?state={st}&session=x"), &st), Verdict::NotFound));
assert!(matches!(judge("/favicon.ico", &st), Verdict::NotFound));
}
#[test]
fn query_values_are_decoded() {
assert_eq!(percent_decode("a%20b+c%2Fd"), "a b c/d");
assert_eq!(percent_decode("100%"), "100%");
assert_eq!(percent_decode("%zz"), "%zz");
}
}
-309
View File
@@ -1,309 +0,0 @@
//! The Turnkey calls Terminal makes, stamped with the device's session key: who am I, which
//! Ethereum account the wallet has, and signing one 32-byte digest.
//!
//! Requests follow Turnkey's public API (`POST /public/v1/query/*` and
//! `/public/v1/submit/*`, a JSON body, and an `X-Stamp` header signed over that exact body),
//! as `turnkey_client` 0.17 builds them. That client isn't used directly: it waits with
//! tokio timers, which don't run in the browser, and pulls a second `reqwest`.
//!
//! [`Client::sign_digest`] sends the digest as `alloy-signer-turnkey` does:
//! `ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2` with `PAYLOAD_ENCODING_HEXADECIMAL` and
//! `HASH_FUNCTION_NO_OP`, so Turnkey signs those 32 bytes as they are.
use std::time::Duration;
use alloy_primitives::{Address, B256, Signature, U256};
use anyhow::{Context, Result, anyhow, bail, ensure};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::session::Session;
/// Turnkey's API.
pub const API_URL: &str = "https://api.turnkey.com";
/// The longest any one request may take, on the desktop and in the browser.
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
/// The wallet account Terminal uses: the first Ethereum account, as the sign-up creates it.
pub const ETHEREUM_PATH: &str = "m/44'/60'/0'/0/0";
/// `url` if it is a plain-HTTP loopback address (`http://127.0.0.1:<port>` or
/// `http://localhost:<port>`): the only kind of override Terminal accepts, for a local mock.
pub fn loopback_only(url: &str) -> Option<String> {
let rest = url.trim().strip_prefix("http://")?;
let host_port = rest.split('/').next()?;
let (host, port) = host_port.rsplit_once(':')?;
let port_ok = port.parse::<u16>().is_ok_and(|p| p != 0);
((host == "127.0.0.1" || host == "localhost") && port_ok).then(|| url.trim().trim_end_matches('/').to_string())
}
/// Turnkey's API, unless `TR_TURNKEY_API_URL` names a local mock (desktop only).
pub fn api_url() -> String {
#[cfg(not(target_arch = "wasm32"))]
if let Some(u) = std::env::var("TR_TURNKEY_API_URL").ok().and_then(|u| loopback_only(&u)) {
tracing::warn!("turnkey: using the local mock API named by TR_TURNKEY_API_URL");
return u;
}
API_URL.to_string()
}
/// `whoami`: the user and organization a session belongs to.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WhoAmI {
pub organization_id: String,
#[serde(default)]
pub organization_name: String,
pub user_id: String,
#[serde(default)]
pub username: String,
}
/// A wallet account (an address Turnkey holds the key for).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WalletAccount {
pub wallet_id: String,
pub address: String,
#[serde(default)]
pub path: String,
#[serde(default)]
pub address_format: String,
}
#[derive(Clone)]
pub struct Client {
http: reqwest::Client,
base: String,
}
impl Client {
/// Turnkey's API (or a local mock, see [`api_url`]).
pub fn new() -> Result<Self> {
Self::with_base(&api_url())
}
/// Any base URL (tests point this at the mock).
pub fn with_base(base: &str) -> Result<Self> {
let b = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let b =
b.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION"))).redirect(reqwest::redirect::Policy::none());
Ok(Self { http: b.build()?, base: base.trim_end_matches('/').to_string() })
}
pub fn base(&self) -> &str {
&self.base
}
/// POST `body` to `path`, stamped with the session's key. The stamp signs these exact
/// bytes, so the body is serialized once and sent as is.
async fn post(&self, s: &Session, path: &str, body: &Value) -> Result<Value> {
let text = serde_json::to_string(body)?;
let (name, value) = s.key.stamp(text.as_bytes())?;
let r = self
.http
.post(format!("{}{path}", self.base))
.timeout(REQUEST_TIMEOUT)
.header("content-type", "application/json")
.header(name, value)
.body(text)
.send()
.await
.with_context(|| format!("turnkey {path}"))?;
let status = r.status();
let text = r.text().await.with_context(|| format!("turnkey {path}"))?;
if !status.is_success() {
let msg = serde_json::from_str::<Value>(&text)
.ok()
.and_then(|v| v["message"].as_str().map(str::to_string))
.unwrap_or_else(|| text.chars().take(200).collect());
bail!("turnkey {path}: HTTP {}: {msg}", status.as_u16());
}
serde_json::from_str(&text).with_context(|| format!("turnkey {path}: not JSON"))
}
pub async fn whoami(&self, s: &Session) -> Result<WhoAmI> {
let v = self.post(s, "/public/v1/query/whoami", &json!({"organizationId": s.organization_id()})).await?;
Ok(serde_json::from_value(v)?)
}
/// The wallet's Ethereum account at [`ETHEREUM_PATH`] (or, failing that, its first
/// Ethereum account). Its address is the user's Hyperliquid account.
pub async fn ethereum_account(&self, s: &Session) -> Result<WalletAccount> {
let org = s.organization_id();
let wallets = self.post(s, "/public/v1/query/list_wallets", &json!({"organizationId": org})).await?;
let mut accounts = Vec::new();
for w in wallets["wallets"].as_array().into_iter().flatten() {
let Some(id) = w["walletId"].as_str() else { continue };
let v = self
.post(s, "/public/v1/query/list_wallet_accounts", &json!({"organizationId": org, "walletId": id}))
.await?;
let list: Vec<WalletAccount> = serde_json::from_value(v["accounts"].clone()).unwrap_or_default();
accounts.extend(list);
}
pick_ethereum(accounts).ok_or_else(|| anyhow!("the Turnkey wallet has no Ethereum account"))
}
/// Have the wallet at `sign_with` sign `digest` as it is (no hashing, no prefix).
pub async fn sign_digest(&self, s: &Session, sign_with: &Address, digest: &B256) -> Result<Signature> {
let body = sign_raw_payload_body(s.organization_id(), sign_with, digest, now_ms());
let activity = self.submit(s, "/public/v1/submit/sign_raw_payload", &body).await?;
let result = &activity["result"]["signRawPayloadResult"];
parse_signature(result)
}
/// Submit an activity and return it once Turnkey has completed it.
async fn submit(&self, s: &Session, path: &str, body: &Value) -> Result<Value> {
let mut v = self.post(s, path, body).await?;
// A single-user organization completes at once; ask again a few times if not.
for _ in 0..3 {
match v["activity"]["status"].as_str() {
Some("ACTIVITY_STATUS_PENDING") | Some("ACTIVITY_STATUS_CREATED") => {
let id = v["activity"]["id"].as_str().unwrap_or_default().to_string();
v = self
.post(
s,
"/public/v1/query/get_activity",
&json!({"organizationId": s.organization_id(), "activityId": id}),
)
.await?;
}
_ => break,
}
}
let a = &v["activity"];
match a["status"].as_str() {
Some("ACTIVITY_STATUS_COMPLETED") => Ok(a.clone()),
Some("ACTIVITY_STATUS_FAILED") => {
bail!("Turnkey refused: {}", a["failure"]["message"].as_str().unwrap_or("no reason given"))
}
Some("ACTIVITY_STATUS_CONSENSUS_NEEDED") | Some("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED") => {
bail!("Turnkey needs another approval for this; nothing was signed")
}
Some(other) => bail!("Turnkey answered {other}"),
None => bail!("Turnkey's answer has no activity"),
}
}
}
fn now_ms() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
/// The Ethereum account Terminal uses from a wallet's accounts.
pub fn pick_ethereum(accounts: Vec<WalletAccount>) -> Option<WalletAccount> {
let eth: Vec<WalletAccount> =
accounts.into_iter().filter(|a| a.address_format == "ADDRESS_FORMAT_ETHEREUM").collect();
eth.iter().find(|a| a.path == ETHEREUM_PATH).cloned().or_else(|| eth.into_iter().next())
}
/// The `sign_raw_payload` request for a 32-byte digest (what `alloy-signer-turnkey`'s
/// `sign_hash` sends): the payload is the digest in lower-case hex without `0x`, and
/// `signWith` the checksummed address.
pub fn sign_raw_payload_body(org: &str, sign_with: &Address, digest: &B256, timestamp_ms: u64) -> Value {
json!({
"type": "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
"timestampMs": timestamp_ms.to_string(),
"organizationId": org,
"parameters": {
"signWith": sign_with.to_string(),
"payload": hex::encode(digest),
"encoding": "PAYLOAD_ENCODING_HEXADECIMAL",
"hashFunction": "HASH_FUNCTION_NO_OP",
},
})
}
/// `{r, s, v}` from `signRawPayloadResult`: 32-byte hex `r` and `s`, and `v` as one hex byte
/// (`00`/`01`, or 27/28).
pub fn parse_signature(result: &Value) -> Result<Signature> {
let field = |k: &str| -> Result<Vec<u8>> {
let s = result[k].as_str().ok_or_else(|| anyhow!("the signature has no {k}"))?;
hex::decode(s.trim_start_matches("0x")).map_err(|_| anyhow!("the signature's {k} is not hex"))
};
let (r, s, v) = (field("r")?, field("s")?, field("v")?);
ensure!(r.len() == 32 && s.len() == 32 && v.len() == 1, "the signature has the wrong length");
let parity = match v[0] {
0 | 27 => false,
1 | 28 => true,
_ => bail!("the signature's v is not 0, 1, 27 or 28"),
};
Ok(Signature::new(U256::from_be_slice(&r), U256::from_be_slice(&s), parity))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_loopback_overrides_are_accepted() {
assert_eq!(loopback_only("http://127.0.0.1:8123").as_deref(), Some("http://127.0.0.1:8123"));
assert_eq!(loopback_only("http://localhost:9/x/").as_deref(), Some("http://localhost:9/x"));
for bad in [
"https://api.turnkey.com",
"http://evil.example:80",
"http://127.0.0.1.evil.example:80",
"http://127.0.0.1",
"http://127.0.0.1:0",
"http://[email protected]:80",
"file:///tmp/x",
"",
] {
assert_eq!(loopback_only(bad), None, "{bad}");
}
}
/// The body `alloy-signer-turnkey` 2.5's `sign_hash` would send for the same digest
/// (field names from `turnkey_client` 0.17's `SignRawPayloadIntentV2`).
#[test]
fn the_sign_request_matches_alloy_signer_turnkey() {
let addr: Address = "0x14791697260e4c9a71f18484c9f997b308e59325".parse().unwrap();
let digest = B256::from([0xab; 32]);
let body = sign_raw_payload_body("org-1", &addr, &digest, 1_700_000_000_000);
assert_eq!(
body,
json!({
"type": "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
"timestampMs": "1700000000000",
"organizationId": "org-1",
"parameters": {
"signWith": "0x14791697260E4c9A71f18484C9f997B308e59325",
"payload": "ab".repeat(32),
"encoding": "PAYLOAD_ENCODING_HEXADECIMAL",
"hashFunction": "HASH_FUNCTION_NO_OP",
},
})
);
}
#[test]
fn signatures_parse_with_either_v() {
let r = "11".repeat(32);
let s = "22".repeat(32);
for (v, odd) in [("00", false), ("01", true), ("1b", false), ("1c", true)] {
let sig = parse_signature(&json!({"r": r, "s": s, "v": v})).unwrap();
assert_eq!(sig.v(), odd, "{v}");
assert_eq!(sig.r(), U256::from_be_slice(&[0x11; 32]));
}
assert!(parse_signature(&json!({"r": r, "s": s, "v": "02"})).is_err());
assert!(parse_signature(&json!({"r": "11", "s": s, "v": "00"})).is_err());
assert!(parse_signature(&json!({"r": r, "v": "00"})).is_err());
}
#[test]
fn the_ethereum_account_is_the_default_path() {
let acct = |path: &str, fmt: &str, addr: &str| WalletAccount {
wallet_id: "w".into(),
address: addr.into(),
path: path.into(),
address_format: fmt.into(),
};
let picked = pick_ethereum(vec![
acct("m/44'/501'/0'/0'", "ADDRESS_FORMAT_SOLANA", "So1"),
acct("m/44'/60'/0'/0/1", "ADDRESS_FORMAT_ETHEREUM", "0x2"),
acct(ETHEREUM_PATH, "ADDRESS_FORMAT_ETHEREUM", "0x1"),
]);
assert_eq!(picked.unwrap().address, "0x1");
assert_eq!(pick_ethereum(vec![acct("m/44'/501'/0'/0'", "ADDRESS_FORMAT_SOLANA", "So1")]), None);
}
}
-62
View File
@@ -1,62 +0,0 @@
//! Built-in wallets on [Turnkey](https://turnkey.com), for Hyperliquid **testnet** trading.
//!
//! Each user has a Turnkey sub-organization with one Ethereum wallet, created when they first
//! sign in on Terminal's sign-in page. That wallet is their Hyperliquid account. Turnkey
//! signs for it only rarely: approving (or replacing) the device's trading key. Orders are
//! signed on the device by that trading key, never by Turnkey.
//!
//! - [`session`]: the device's P-256 key, which stamps every Turnkey request, and the
//! short session Turnkey issues for it.
//! - [`client`]: `whoami`, the wallet's Ethereum account, and signing one digest.
//! - [`callback`] (desktop): the loopback listener the browser returns the session to.
//! - `mock` (feature `mock`): a local stand-in for tests and screenshots.
//!
//! This crate knows nothing about Hyperliquid. What may be signed is decided by `tr-hl`
//! (`signing::sign_approve_agent_with`), which hands it only the digest of a testnet
//! `approveAgent`.
pub mod client;
pub mod session;
#[cfg(not(target_arch = "wasm32"))]
pub mod callback;
#[cfg(all(feature = "mock", not(target_arch = "wasm32")))]
pub mod mock;
pub use client::Client;
pub use session::{Session, SessionKey};
/// How long a sign-in waits for the browser, on the desktop and in the browser build.
pub const SIGN_IN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
/// Terminal's sign-in page.
pub const SIGN_IN_PAGE: &str = "https://terminal.rasmic.xyz/wallet";
/// The sign-in page, unless `TR_WALLET_SIGN_IN_URL` names a local mock's page (desktop only,
/// loopback only, e.g. `http://127.0.0.1:<port>/wallet`).
pub fn sign_in_page() -> String {
#[cfg(not(target_arch = "wasm32"))]
if let Some(u) = std::env::var("TR_WALLET_SIGN_IN_URL").ok().and_then(|u| client::loopback_only(&u)) {
tracing::warn!("turnkey: using the local mock sign-in page named by TR_WALLET_SIGN_IN_URL");
return u;
}
SIGN_IN_PAGE.to_string()
}
/// The page that signs a desktop in: it returns the session for `public_key` to
/// `http://127.0.0.1:<port>/callback` with `state`. Only public values are in the URL, and
/// they are in the fragment, which browsers never send to a server or in a `Referer`.
pub fn desktop_sign_in_url(page: &str, public_key: &str, state: &str, port: u16) -> String {
format!("{page}#flow=desktop&pk={public_key}&state={state}&port={port}")
}
/// The browser build's popup: the page answers with `postMessage` to its opener.
pub fn web_sign_in_url(page: &str, public_key: &str, state: &str) -> String {
format!("{page}#flow=web&pk={public_key}&state={state}")
}
/// The page in export mode: the user signs in again there, and Turnkey's export iframe shows
/// the recovery phrase. Terminal is not involved.
pub fn export_url(page: &str) -> String {
format!("{page}#mode=export")
}
-251
View File
@@ -1,251 +0,0 @@
//! **Test stand-in, not Turnkey.** A local server with the few Turnkey API routes Terminal
//! calls and a pretend sign-in page, for tests and for screenshots. Its JSON follows Turnkey's
//! API reference and `turnkey_client` 0.17's types (`whoami`, `list_wallets`,
//! `list_wallet_accounts`, `sign_raw_payload` V2); nothing here was recorded from Turnkey.
//!
//! It checks requests the way Turnkey does for these routes: the `X-Stamp` must be a valid
//! P-256 signature over the exact body, by a session key it issued and that hasn't expired,
//! for its organization. The wallet is a local secp256k1 key.
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use k256::ecdsa::SigningKey as WalletKey;
use p256::ecdsa::signature::Verifier;
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::callback::percent_decode;
use crate::session::{Claims, SESSION_SECONDS, unsigned_token};
pub const ORG_ID: &str = "00000000-mock-4000-8000-0000000000aa";
pub const USER_ID: &str = "00000000-mock-4000-8000-0000000000bb";
pub const WALLET_ID: &str = "00000000-mock-4000-8000-0000000000cc";
pub const USERNAME: &str = "test user (mock)";
struct State {
wallet: WalletKey,
/// Session public keys this mock issued, with their expiry (Unix seconds).
sessions: Vec<(String, u64)>,
/// Every API request that got past the stamp check: `(path, body)`.
requests: Vec<(String, Value)>,
}
/// A running mock. Dropping it doesn't stop the server task; tests just let it end.
#[derive(Clone)]
pub struct MockTurnkey {
pub base: String,
state: Arc<Mutex<State>>,
}
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
impl MockTurnkey {
/// Start on 127.0.0.1 (port 0: any free port) with a fresh wallet key, or `wallet_hex`.
pub async fn start(port: u16, wallet_hex: Option<&str>) -> Result<Self> {
let wallet = match wallet_hex {
Some(h) => WalletKey::from_slice(&hex::decode(h.trim_start_matches("0x"))?)?,
None => WalletKey::random(&mut rand_core::OsRng),
};
let listener = TcpListener::bind(("127.0.0.1", port)).await?;
let base = format!("http://127.0.0.1:{}", listener.local_addr()?.port());
let state = Arc::new(Mutex::new(State { wallet, sessions: Vec::new(), requests: Vec::new() }));
let me = Self { base, state };
let server = me.clone();
tokio::spawn(async move {
while let Ok((sock, _)) = listener.accept().await {
let s = server.clone();
tokio::spawn(async move {
let _ = s.handle(sock).await;
});
}
});
Ok(me)
}
/// The wallet's address (lower-case `0x…`).
pub fn address(&self) -> String {
let st = self.state.lock().unwrap();
format!("{:#x}", alloy_primitives::Address::from_private_key(&st.wallet))
}
/// Issue a session for `public_key` (what the sign-in page's `stamp_login` does).
pub fn mint_session(&self, public_key: &str, seconds: u64) -> String {
let exp = now_secs() + seconds;
self.state.lock().unwrap().sessions.push((public_key.to_ascii_lowercase(), exp));
unsigned_token(&Claims {
organization_id: ORG_ID.into(),
user_id: USER_ID.into(),
public_key: public_key.to_ascii_lowercase(),
exp,
session_type: "SESSION_TYPE_READ_WRITE".into(),
})
}
/// The API requests it accepted, in order.
pub fn requests(&self) -> Vec<(String, Value)> {
self.state.lock().unwrap().requests.clone()
}
async fn handle(&self, mut sock: TcpStream) -> Result<()> {
let (method, target, headers, body) = read(&mut sock).await?;
let (path, query) = target.split_once('?').unwrap_or((&target, ""));
let q = |k: &str| {
query
.split('&')
.find_map(|kv| kv.strip_prefix(k)?.strip_prefix('='))
.map(percent_decode)
.unwrap_or_default()
};
match (method.as_str(), path) {
("GET", "/wallet/") | ("GET", "/wallet") => {
write(&mut sock, "200 OK", "text/html; charset=utf-8", &[], &sign_in_page()).await
}
("GET", "/wallet/complete") => {
let (pk, state, port) = (q("pk"), q("state"), q("port"));
let ok = pk.len() == 66 && pk.chars().all(|c| c.is_ascii_hexdigit()) && port.parse::<u16>().is_ok();
if !ok {
return write(&mut sock, "400 Bad Request", "text/plain", &[], "bad request").await;
}
let token = self.mint_session(&pk, SESSION_SECONDS);
let to = format!("http://127.0.0.1:{port}/callback?state={state}&session={token}");
write(&mut sock, "302 Found", "text/plain", &[("location", &to)], "").await
}
("POST", p) if p.starts_with("/public/v1/") => {
let (status, reply) = match self.api(p, &headers, &body) {
Ok(v) => ("200 OK", v),
Err(e) => ("401 Unauthorized", json!({"code": 16, "message": format!("{e:#}")})),
};
write(&mut sock, status, "application/json", &[], &reply.to_string()).await
}
_ => write(&mut sock, "404 Not Found", "text/plain", &[], "not found").await,
}
}
fn api(&self, path: &str, headers: &[(String, String)], body: &[u8]) -> Result<Value> {
let stamp = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("x-stamp"))
.map(|(_, v)| v.clone())
.context("missing X-Stamp")?;
let stamp: Value = serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(stamp.trim())?)?;
anyhow::ensure!(stamp["scheme"] == "SIGNATURE_SCHEME_TK_API_P256", "unsupported stamp scheme");
let pk = stamp["publicKey"].as_str().context("stamp has no publicKey")?.to_ascii_lowercase();
let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(&hex::decode(&pk)?)?;
let sig = p256::ecdsa::Signature::from_der(&hex::decode(stamp["signature"].as_str().unwrap_or_default())?)?;
key.verify(body, &sig).context("the stamp doesn't match the body")?;
let v: Value = serde_json::from_slice(body)?;
let mut st = self.state.lock().unwrap();
anyhow::ensure!(
st.sessions.iter().any(|(k, exp)| *k == pk && *exp > now_secs()),
"could not find a valid session for this public key"
);
anyhow::ensure!(v["organizationId"] == ORG_ID, "organization mismatch");
st.requests.push((path.to_string(), v.clone()));
let address = format!("{:#x}", alloy_primitives::Address::from_private_key(&st.wallet));
Ok(match path {
"/public/v1/query/whoami" => {
json!({"organizationId": ORG_ID, "organizationName": "mock sub-organization", "userId": USER_ID, "username": USERNAME})
}
"/public/v1/query/list_wallets" => json!({"wallets": [{"walletId": WALLET_ID, "walletName": "Terminal"}]}),
"/public/v1/query/list_wallet_accounts" => json!({"accounts": [{
"walletAccountId": "00000000-mock-4000-8000-0000000000dd",
"organizationId": ORG_ID,
"walletId": WALLET_ID,
"curve": "CURVE_SECP256K1",
"pathFormat": "PATH_FORMAT_BIP32",
"path": "m/44'/60'/0'/0/0",
"addressFormat": "ADDRESS_FORMAT_ETHEREUM",
"address": alloy_primitives::Address::from_private_key(&st.wallet).to_string(),
}]}),
"/public/v1/submit/sign_raw_payload" => {
let p = &v["parameters"];
anyhow::ensure!(v["type"] == "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2", "unexpected activity type");
anyhow::ensure!(p["encoding"] == "PAYLOAD_ENCODING_HEXADECIMAL", "unexpected encoding");
anyhow::ensure!(p["hashFunction"] == "HASH_FUNCTION_NO_OP", "unexpected hash function");
anyhow::ensure!(
p["signWith"].as_str().unwrap_or_default().eq_ignore_ascii_case(&address),
"signWith is not this organization's wallet"
);
let digest = hex::decode(p["payload"].as_str().unwrap_or_default())?;
anyhow::ensure!(digest.len() == 32, "the payload is not a 32-byte digest");
let (sig, recid) = st.wallet.sign_prehash_recoverable(&digest)?;
let (r, s) = sig.split_bytes();
json!({"activity": {
"id": "00000000-mock-4000-8000-0000000000ee",
"organizationId": ORG_ID,
"status": "ACTIVITY_STATUS_COMPLETED",
"type": "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
"result": {"signRawPayloadResult": {
"r": hex::encode(r),
"s": hex::encode(s),
"v": format!("{:02x}", u8::from(recid.is_y_odd())),
}},
}})
}
_ => anyhow::bail!("this mock doesn't serve {path}"),
})
}
}
type Request = (String, String, Vec<(String, String)>, Vec<u8>);
async fn read(sock: &mut TcpStream) -> Result<Request> {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
let head_end = loop {
let n = sock.read(&mut chunk).await?;
anyhow::ensure!(n > 0, "connection closed");
buf.extend_from_slice(&chunk[..n]);
if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break i + 4;
}
anyhow::ensure!(buf.len() < 64 * 1024, "headers too large");
};
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
let mut lines = head.lines();
let mut first = lines.next().unwrap_or_default().split(' ');
let (method, target) = (first.next().unwrap_or_default().to_string(), first.next().unwrap_or_default().to_string());
let headers: Vec<(String, String)> =
lines.filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_string(), v.trim().to_string())).collect();
let len = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
.and_then(|(_, v)| v.parse::<usize>().ok())
.unwrap_or(0);
let mut body = buf[head_end..].to_vec();
while body.len() < len {
let n = sock.read(&mut chunk).await?;
anyhow::ensure!(n > 0, "connection closed");
body.extend_from_slice(&chunk[..n]);
}
body.truncate(len);
Ok((method, target, headers, body))
}
async fn write(sock: &mut TcpStream, status: &str, ctype: &str, extra: &[(&str, &str)], body: &str) -> Result<()> {
let mut head = format!("HTTP/1.1 {status}\r\ncontent-type: {ctype}\r\ncontent-length: {}\r\n", body.len());
for (k, v) in extra {
head.push_str(&format!("{k}: {v}\r\n"));
}
head.push_str("access-control-allow-origin: *\r\nconnection: close\r\n\r\n");
sock.write_all(head.as_bytes()).await?;
sock.write_all(body.as_bytes()).await?;
sock.shutdown().await?;
Ok(())
}
/// The pretend sign-in page: one button that issues a session for the device's key. Like the
/// real page it reads its parameters from the URL fragment.
fn sign_in_page() -> String {
let style = "body{font:15px -apple-system,system-ui,sans-serif;background:#0b0b0c;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0}main{width:420px}.b{background:#f5a623;color:#000;border-radius:9px;padding:2px 8px;font:600 12px ui-monospace,monospace}.m{color:#9a9a9a;font-size:13px}a.go{display:block;margin-top:18px;padding:10px;background:#2a6df4;color:#fff;text-align:center;border-radius:8px;text-decoration:none}input{width:100%;padding:8px;box-sizing:border-box}";
let script = "const p=new URLSearchParams(location.hash.slice(1));const ok=/^0[23][0-9a-f]{64}$/.test(p.get('pk')||'')&&/^[0-9a-f]{64}$/.test(p.get('state')||'')&&/^[0-9]{1,5}$/.test(p.get('port')||'');if(p.get('mode')==='export'){document.getElementById('f').innerHTML='<p>Export happens in Turnkey\\'s own iframe on the real page. This mock shows no recovery phrase.</p>'}else if(!ok){document.getElementById('f').textContent='Bad sign-in link.'}else{document.getElementById('go').href='/wallet/complete?pk='+p.get('pk')+'&state='+p.get('state')+'&port='+p.get('port');document.getElementById('k').textContent=p.get('pk').slice(0,10)}";
format!(
"<!doctype html><meta charset=utf-8><meta name=referrer content=no-referrer><title>Terminal wallet (mock)</title><style>{style}</style><main><h2>Terminal wallet <span class=b>TESTNET</span></h2><p class=m>MOCK TURNKEY (local test server, not Turnkey)</p><div id=f><p class=m>Sign in or create your wallet. The real page sends a code to your email (Turnkey).</p><p>Email<br><input value=\"[email protected]\"></p><a class=go id=go href=#>Continue</a><p class=m>The session is issued for this device's key <span id=k></span>…</p></div></main><script>{script}</script>"
)
}
-271
View File
@@ -1,271 +0,0 @@
//! The device's Turnkey credential: a P-256 key made here, and the session Turnkey issued for
//! its public key.
//!
//! Sign-in happens in the browser (see `docs/architecture/trading-hyperliquid.md` §4.5).
//! The browser never sees this key: it only asks Turnkey for a session bound to the key's
//! **public** half (`stamp_login` with `publicKey`), and hands back the session token. The
//! token says which organization and user the session belongs to and when it ends; it can't
//! authorize anything by itself, because every request must carry a stamp made with the
//! private key ([`SessionKey::stamp`]).
//!
//! The key is redacted in `Debug`, never serialized except through [`StoredSession`] for
//! the OS secret store, and its buffers are wiped on drop.
use std::fmt;
use anyhow::{Context, Result, bail, ensure};
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use turnkey_api_key_stamper::{Stamp, TurnkeyP256ApiKey};
use zeroize::Zeroizing;
/// How long the sign-in page asks Turnkey to keep a session: 15 minutes, Turnkey's default.
/// Long enough to approve a trading key; nothing else needs a session.
pub const SESSION_SECONDS: u64 = 900;
/// A P-256 key generated on this device. Turnkey knows only its public half.
pub struct SessionKey(TurnkeyP256ApiKey);
impl SessionKey {
pub fn generate() -> Self {
Self(TurnkeyP256ApiKey::generate())
}
/// From the hex the secret store kept.
pub fn from_hex(hex: &str) -> Result<Self> {
let bytes = Zeroizing::new(hex::decode(hex.trim()).map_err(|_| anyhow::anyhow!("a session key is hex"))?);
TurnkeyP256ApiKey::from_bytes(bytes.as_slice(), None)
.map(Self)
.map_err(|_| anyhow::anyhow!("not a valid P-256 private key"))
}
/// The hex form for the secret store. Wiped when dropped; never log it.
pub fn to_hex(&self) -> Zeroizing<String> {
let bytes = Zeroizing::new(self.0.private_key());
Zeroizing::new(hex::encode(bytes.as_slice()))
}
/// The compressed public key as Turnkey names it (66 hex digits).
pub fn public_hex(&self) -> String {
hex::encode(self.0.compressed_public_key())
}
/// The `X-Stamp` header for a request body.
pub fn stamp(&self, body: &[u8]) -> Result<(String, String)> {
let h = self.0.stamp(body).map_err(|e| anyhow::anyhow!("stamping the request: {e}"))?;
Ok((h.name, h.value))
}
}
impl fmt::Debug for SessionKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let p = self.public_hex();
write!(f, "SessionKey({}…{})", &p[..6], &p[p.len() - 4..])
}
}
/// What a Turnkey session token says (its JWT payload). Turnkey signs the token, but
/// Terminal doesn't rely on that: anything the token claims is checked by Turnkey again on
/// every stamped request.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Claims {
/// The user's sub-organization.
pub organization_id: String,
pub user_id: String,
/// The compressed P-256 key the session is bound to.
pub public_key: String,
/// Expiry, Unix seconds.
pub exp: u64,
#[serde(default)]
pub session_type: String,
}
impl Claims {
/// Read a session token's payload. The token's signature is not checked here (see the type).
pub fn parse(token: &str) -> Result<Self> {
let mut parts = token.trim().split('.');
let (Some(_), Some(payload), Some(_), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
bail!("the session token is not a JWT");
};
let json = BASE64_URL_SAFE_NO_PAD
.decode(payload.trim_end_matches('='))
.context("the session token's payload is not base64url")?;
serde_json::from_slice(&json).context("the session token's payload is missing fields")
}
}
/// A session this device can use: its key, the token, and what the token says.
pub struct Session {
pub key: SessionKey,
pub token: String,
pub claims: Claims,
}
impl Session {
/// Accept a token from the sign-in page for `key`. Refuses a token bound to any other
/// key (another device's, or one a page made up), and one that has already expired.
pub fn accept(key: SessionKey, token: &str, now_secs: u64) -> Result<Self> {
let claims = Claims::parse(token)?;
ensure!(
claims.public_key.eq_ignore_ascii_case(&key.public_hex()),
"the sign-in answer is for a different key; nothing was saved"
);
ensure!(claims.exp > now_secs, "the session had already expired; sign in again");
ensure!(!claims.organization_id.is_empty(), "the session names no organization");
Ok(Self { key, token: token.trim().to_string(), claims })
}
/// A long-lived Turnkey API key used as a session (the live integration tests). It has
/// no token and doesn't expire on this side.
pub fn for_api_key(key: SessionKey, organization_id: &str) -> Self {
let claims = Claims {
organization_id: organization_id.to_string(),
user_id: String::new(),
public_key: key.public_hex(),
exp: u64::MAX,
session_type: "API_KEY".into(),
};
Self { key, token: String::new(), claims }
}
pub fn organization_id(&self) -> &str {
&self.claims.organization_id
}
/// Seconds left (0 once expired).
pub fn remaining(&self, now_secs: u64) -> u64 {
self.claims.exp.saturating_sub(now_secs)
}
pub fn expired(&self, now_secs: u64) -> bool {
self.remaining(now_secs) == 0
}
/// The form the OS secret store keeps (JSON with the key's hex). Wiped when dropped.
pub fn to_stored(&self) -> Zeroizing<String> {
let key = self.key.to_hex();
Zeroizing::new(
serde_json::to_string(&StoredSession { key: key.as_str(), token: &self.token }).expect("plain strings"),
)
}
/// Back from the secret store; `None` once expired (the caller deletes it).
pub fn from_stored(text: &str, now_secs: u64) -> Result<Option<Self>> {
let s: StoredSessionOwned = serde_json::from_str(text).context("the stored session is unreadable")?;
let hex = Zeroizing::new(s.key);
let key = SessionKey::from_hex(&hex)?;
match Self::accept(key, &s.token, now_secs) {
Ok(session) => Ok(Some(session)),
Err(_) if Claims::parse(&s.token).is_ok_and(|c| c.exp <= now_secs) => Ok(None),
Err(e) => Err(e),
}
}
}
impl fmt::Debug for Session {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Session").field("key", &self.key).field("org", &self.claims.organization_id).finish()
}
}
#[derive(Serialize)]
struct StoredSession<'a> {
key: &'a str,
token: &'a str,
}
#[derive(Deserialize)]
struct StoredSessionOwned {
key: String,
token: String,
}
/// 32 random bytes as hex: the sign-in's `state`, which the callback must echo.
pub fn random_state() -> String {
let mut b = [0u8; 32];
rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut b);
hex::encode(b)
}
/// Equal in constant time (for `state`).
pub fn same_secret(a: &str, b: &str) -> bool {
a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// A session token for tests and the mock: the claims, unsigned.
#[cfg(any(test, feature = "mock"))]
pub fn unsigned_token(c: &Claims) -> String {
let h = BASE64_URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"JWT"}"#);
let p = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(c).expect("claims"));
format!("{h}.{p}.mock-signature")
}
#[cfg(test)]
mod tests {
use super::*;
fn claims(key: &SessionKey, exp: u64) -> Claims {
Claims {
organization_id: "b0c1e8d6-0000-4000-8000-000000000001".into(),
user_id: "u-1".into(),
public_key: key.public_hex(),
exp,
session_type: "SESSION_TYPE_READ_WRITE".into(),
}
}
#[test]
fn a_session_is_accepted_only_for_this_key_and_before_it_expires() {
let key = SessionKey::generate();
let token = unsigned_token(&claims(&key, 2_000));
let other = SessionKey::generate();
let theirs = unsigned_token(&claims(&other, 2_000));
assert!(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &theirs, 1_000).is_err());
assert!(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &token, 2_000).is_err(), "expired");
let s = Session::accept(key, &token, 1_000).unwrap();
assert_eq!(s.remaining(1_000), 1_000);
assert_eq!(s.organization_id(), "b0c1e8d6-0000-4000-8000-000000000001");
}
#[test]
fn the_stored_form_round_trips_and_lapses() {
let key = SessionKey::generate();
let public = key.public_hex();
let token = unsigned_token(&claims(&key, 2_000));
let s = Session::accept(key, &token, 1_000).unwrap();
let stored = s.to_stored();
let back = Session::from_stored(&stored, 1_500).unwrap().unwrap();
assert_eq!(back.key.public_hex(), public);
assert_eq!(back.token, token);
assert!(Session::from_stored(&stored, 2_000).unwrap().is_none(), "expired sessions come back as none");
}
#[test]
fn debug_never_shows_the_private_key() {
let key = SessionKey::generate();
let hex = key.to_hex();
let token = unsigned_token(&claims(&key, 2_000));
let s = Session::accept(SessionKey::from_hex(&hex).unwrap(), &token, 1_000).unwrap();
for text in [format!("{key:?}"), format!("{s:?}")] {
assert!(!text.contains(hex.as_str()), "{text}");
assert!(!text.contains(&hex[..16]), "{text}");
}
}
#[test]
fn tokens_must_be_jwts_with_the_fields() {
for bad in ["", "a.b", "a.b.c.d", "x.!!!.y", "x.e30.y"] {
assert!(Claims::parse(bad).is_err(), "{bad}");
}
}
#[test]
fn states_are_random_and_compared_in_full() {
let (a, b) = (random_state(), random_state());
assert_eq!(a.len(), 64);
assert_ne!(a, b);
assert!(same_secret(&a, &a.clone()));
assert!(!same_secret(&a, &b) && !same_secret(&a, &a[..63]));
}
}
-153
View File
@@ -1,153 +0,0 @@
//! The desktop sign-in and the Turnkey calls, end to end against the local mock
//! (`tr_turnkey::mock`, a stand-in for Turnkey; see its docs). Live Turnkey checks are at the
//! bottom, `#[ignore]`d and skipped unless the environment names a Turnkey organization.
use std::time::Duration;
use alloy_primitives::{Address, B256};
use tr_turnkey::callback::{Answer, Callback};
use tr_turnkey::mock::{MockTurnkey, USERNAME};
use tr_turnkey::session::{SESSION_SECONDS, random_state};
use tr_turnkey::{Client, Session, SessionKey, desktop_sign_in_url};
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
}
/// What the browser does: open the page's continue link and follow its redirect to the
/// loopback callback.
async fn browser_signs_in(mock: &MockTurnkey, pk: &str, state: &str, port: u16) -> reqwest::Response {
let url = format!("{}/wallet/complete?pk={pk}&state={state}&port={port}", mock.base);
reqwest::get(url).await.unwrap()
}
#[tokio::test]
async fn the_desktop_signs_in_through_the_browser_and_finds_its_wallet() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let key = SessionKey::generate();
let pk = key.public_hex();
let state = random_state();
let cb = Callback::bind().await.unwrap();
let port = cb.port();
let url = desktop_sign_in_url(&format!("{}/wallet/", mock.base), &pk, &state, port);
assert!(url.contains(&format!("pk={pk}")) && url.contains("flow=desktop"), "{url}");
assert!(!url.contains(&key.to_hex()[..]), "the private key never goes in the URL");
let waiter = tokio::spawn({
let state = state.clone();
async move { cb.wait(&state, Duration::from_secs(20)).await }
});
let page = browser_signs_in(&mock, &pk, &state, port).await;
assert_eq!(page.status(), 200, "the loopback answered the redirected browser");
assert!(page.text().await.unwrap().contains("Signed in"));
let Answer::Session(token) = waiter.await.unwrap().unwrap() else { panic!("no session") };
let session = Session::accept(key, &token, now_secs()).unwrap();
assert!(session.remaining(now_secs()) <= SESSION_SECONDS);
let client = Client::with_base(&mock.base).unwrap();
let me = client.whoami(&session).await.unwrap();
assert_eq!(me.username, USERNAME);
let account = client.ethereum_account(&session).await.unwrap();
assert_eq!(account.address.to_ascii_lowercase(), mock.address());
assert_eq!(account.path, "m/44'/60'/0'/0/0");
}
#[tokio::test]
async fn a_request_without_this_sign_ins_state_does_not_end_it() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let key = SessionKey::generate();
let state = random_state();
let cb = Callback::bind().await.unwrap();
let port = cb.port();
let waiter = tokio::spawn({
let state = state.clone();
async move { cb.wait(&state, Duration::from_secs(20)).await }
});
// Another tab (or program) with the wrong state, and a stray path.
let stray = browser_signs_in(&mock, &key.public_hex(), &random_state(), port).await;
assert_eq!(stray.status(), 400);
let favicon = reqwest::get(format!("http://127.0.0.1:{port}/favicon.ico")).await.unwrap();
assert_eq!(favicon.status(), 404);
assert!(!waiter.is_finished());
// The real one still gets through.
browser_signs_in(&mock, &key.public_hex(), &state, port).await;
assert!(matches!(waiter.await.unwrap().unwrap(), Answer::Session(_)));
}
#[tokio::test]
async fn the_sign_in_gives_up_after_its_timeout() {
let cb = Callback::bind().await.unwrap();
let err = cb.wait(&random_state(), Duration::from_millis(200)).await.unwrap_err();
assert!(err.to_string().contains("didn't come back"), "{err}");
}
#[tokio::test]
async fn a_session_for_another_key_or_one_turnkey_never_issued_is_refused() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let mine = SessionKey::generate();
let theirs = SessionKey::generate();
// A page that hands back a session for someone else's key: refused before any request.
let token = mock.mint_session(&theirs.public_hex(), 60);
assert!(Session::accept(SessionKey::from_hex(&mine.to_hex()).unwrap(), &token, now_secs()).is_err());
// Turnkey (here the mock) refuses stamps from keys it issued no session for.
let unknown = SessionKey::generate();
let pk = unknown.public_hex();
let fake = tr_turnkey::session::unsigned_token(&tr_turnkey::session::Claims {
public_key: pk,
..tr_turnkey::session::Claims::parse(&token).unwrap()
});
let s = Session::accept(unknown, &fake, now_secs()).unwrap();
let err = Client::with_base(&mock.base).unwrap().whoami(&s).await.unwrap_err();
assert!(err.to_string().contains("HTTP 401"), "{err}");
assert!(mock.requests().is_empty());
}
/// `sign_digest` sends what `alloy-signer-turnkey` sends, and the signature recovers the
/// wallet.
#[tokio::test]
async fn the_wallet_signs_exactly_the_digest_it_is_given() {
let mock = MockTurnkey::start(0, None).await.unwrap();
let key = SessionKey::generate();
let token = mock.mint_session(&key.public_hex(), 60);
let session = Session::accept(key, &token, now_secs()).unwrap();
let client = Client::with_base(&mock.base).unwrap();
let wallet: Address = mock.address().parse().unwrap();
let digest = B256::from([7u8; 32]);
let sig = client.sign_digest(&session, &wallet, &digest).await.unwrap();
assert_eq!(sig.recover_address_from_prehash(&digest).unwrap(), wallet);
let (path, body) = mock.requests().pop().unwrap();
assert_eq!(path, "/public/v1/submit/sign_raw_payload");
let mut want = tr_turnkey::client::sign_raw_payload_body(tr_turnkey::mock::ORG_ID, &wallet, &digest, 0);
want["timestampMs"] = body["timestampMs"].clone();
assert_eq!(body, want);
// Another address is not this organization's wallet: Turnkey refuses, nothing is signed.
let other = Address::from([1u8; 20]);
assert!(client.sign_digest(&session, &other, &digest).await.is_err());
}
// ---- live Turnkey (needs an organization and an API key from Micky) ------------------------
/// `TURNKEY_ORGANIZATION_ID`, `TURNKEY_API_PRIVATE_KEY` (hex P-256) and
/// `TURNKEY_SIGN_WITH` (an Ethereum address in that organization). Skips when unset.
fn live() -> Option<(Session, Address)> {
let org = std::env::var("TURNKEY_ORGANIZATION_ID").ok()?;
let key = SessionKey::from_hex(&std::env::var("TURNKEY_API_PRIVATE_KEY").ok()?).ok()?;
let with: Address = std::env::var("TURNKEY_SIGN_WITH").ok()?.parse().ok()?;
Some((Session::for_api_key(key, &org), with))
}
#[tokio::test]
#[ignore = "live Turnkey: set TURNKEY_ORGANIZATION_ID, TURNKEY_API_PRIVATE_KEY, TURNKEY_SIGN_WITH"]
async fn live_turnkey_signs_a_digest_that_recovers_the_wallet() {
let Some((session, with)) = live() else {
eprintln!("skipped: no Turnkey organization in the environment");
return;
};
let client = Client::new().unwrap();
client.whoami(&session).await.unwrap();
let digest = B256::from([0x42; 32]);
let sig = client.sign_digest(&session, &with, &digest).await.unwrap();
assert_eq!(sig.recover_address_from_prehash(&digest).unwrap(), with);
}
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more