mirror of
https://github.com/michaelshimeles/terminal.git
synced 2026-11-12 18:47:56 -10:00
fix(hl): size dollar amounts at the mid and let KILL skip stalled requests
Fixes from Greptile's fourth review of #37 (at f636b7c):
- A dollar amount on the Hyperliquid ticket (BUY xyz:INTC $10) became
that many contracts. The ticket now sizes it at the testnet mid,
rounded down to the market's size step. With no mid yet, or under one
step, it stays a dollar amount and the ticket can't send it. Changing
the market sizes the amount again.
- Every /info and /exchange request has its own 10 s deadline. The
browser's fetch had none, so a stalled request could hold the
worker's only turn forever.
- KILL no longer waits for that turn. It cancels at once, and an order
confirmed before it gives up, even one still reading the market. If
an order was already being sent, KILL cancels again once its answer
comes, or once its expiresAfter has passed if the send got no clear
answer. Single cancels and the dead man's switch don't wait for
orders either. Orders, modifies and leverage changes still go one at
a time, and an order with no clear answer still blocks new ones until
it is found or expires.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f636b7c7fa
commit
f1ff1b3a75
6 files changed
+584
-75
No files matched your search
+94
-2
@@ -24,7 +24,7 @@ use std::time::Duration;
|
||||
use tr_broker::secrets::AgentKeyStore;
|
||||
use tr_broker::{Account, Activity, InstrumentKind, Order, Portfolio, Position};
|
||||
use tr_hl::actions::Tif;
|
||||
use tr_hl::assets::{AssetRegistry, app_key};
|
||||
use tr_hl::assets::{Asset, AssetRegistry, app_key};
|
||||
use tr_hl::model::AssetData;
|
||||
use tr_hl::nonce::{NonceManager, now_ms};
|
||||
use tr_hl::stream::UserState;
|
||||
@@ -72,11 +72,13 @@ pub struct HlTicket {
|
||||
pub tif: Tif,
|
||||
pub reduce_only: bool,
|
||||
pub trigger: String,
|
||||
/// The dollar amount the size came from, as in `BUY xyz:AAPL $50`. Editing the size clears it.
|
||||
pub from_dollars: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for HlTicket {
|
||||
fn default() -> Self {
|
||||
Self { kind: HlKind::Limit, tif: Tif::Gtc, reduce_only: false, trigger: String::new() }
|
||||
Self { kind: HlKind::Limit, tif: Tif::Gtc, reduce_only: false, trigger: String::new(), from_dollars: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,9 +449,48 @@ fn parse_num(s: &str) -> Option<f64> {
|
||||
s.trim().trim_start_matches('$').replace(',', "").parse::<f64>().ok()
|
||||
}
|
||||
|
||||
/// A dollar amount on a Hyperliquid ticket, as in `BUY xyz:AAPL $50`, becomes a size at the
|
||||
/// testnet mid, rounded down to the market's size step so the order is never worth more than
|
||||
/// the amount. Until there is a mid, or when the amount is under one step, it stays a dollar
|
||||
/// amount, which [`intent`] refuses. Returns why it isn't a size yet.
|
||||
pub fn settle_size(t: &mut super::trading::Ticket, mid: Option<f64>, asset: Option<&Asset>) -> Option<String> {
|
||||
if !t.dollars {
|
||||
return None;
|
||||
}
|
||||
let amount = parse_num(&t.qty).filter(|a| a.is_finite() && *a > 0.0)?;
|
||||
let dollars = tr_hl::rounding::to_wire(amount).unwrap_or_else(|_| format!("{amount:.2}"));
|
||||
let (Some(a), Some(mid)) = (asset, mid.filter(|m| m.is_finite() && *m > 0.0)) else {
|
||||
return Some(format!("Waiting for the testnet mid to size ${dollars}…"));
|
||||
};
|
||||
let units = tr_hl::rounding::round_size(amount / mid, a.sz_decimals);
|
||||
let Some(size) = tr_hl::rounding::to_wire(units).ok().filter(|_| units > 0.0) else {
|
||||
let step = 10f64.powi(-(a.sz_decimals as i32));
|
||||
return Some(format!(
|
||||
"${dollars} is less than the smallest {} size, {}, which is about ${:.2} at the testnet mid.",
|
||||
a.coin,
|
||||
tr_hl::rounding::to_wire(step).unwrap_or_default(),
|
||||
step * mid
|
||||
));
|
||||
};
|
||||
t.qty = size;
|
||||
t.dollars = false;
|
||||
t.hl.from_dollars = Some(amount);
|
||||
None
|
||||
}
|
||||
|
||||
/// The order a Hyperliquid ticket describes.
|
||||
pub fn intent(t: &super::trading::Ticket, reg: &AssetRegistry) -> Result<OrderIntent, String> {
|
||||
let coin = resolve_coin(&t.symbol, reg)?;
|
||||
// Never read a dollar figure as a number of coins or contracts.
|
||||
if t.dollars {
|
||||
return Err(match parse_num(&t.qty) {
|
||||
Some(a) => format!(
|
||||
"${} is a dollar amount, not a size. The ticket sizes it at the testnet mid first.",
|
||||
tr_hl::rounding::to_wire(a).unwrap_or_else(|_| t.qty.trim().to_string())
|
||||
),
|
||||
None => "Enter an amount in dollars.".into(),
|
||||
});
|
||||
}
|
||||
let size =
|
||||
parse_num(&t.qty).filter(|s| s.is_finite() && *s > 0.0).ok_or("Enter a size (in coins or contracts).")?;
|
||||
let price = |s: &str, what: &str| parse_num(s).filter(|p| *p > 0.0).ok_or(format!("Enter a {what} price."));
|
||||
@@ -1265,6 +1306,57 @@ mod tests {
|
||||
assert!(intent(&t, ®).unwrap_err().contains("limit price"));
|
||||
}
|
||||
|
||||
/// Greptile #37 (f636b7c): `BUY xyz:INTC $10` put 10 in the size, and the Hyperliquid
|
||||
/// ticket read it as 10 contracts. At a $2 mid that is $20, which the $100 cap lets through.
|
||||
#[test]
|
||||
fn a_dollar_amount_becomes_units_at_the_testnet_mid() {
|
||||
use super::super::trading::Ticket;
|
||||
let reg = registry();
|
||||
let intc = reg.get("xyz:INTC").unwrap().clone();
|
||||
let dollars = |coin: &str, amount: f64| {
|
||||
let mut t = Ticket::new(1);
|
||||
t.apply(&crate::command::TradeArgs {
|
||||
buy: Some(true),
|
||||
symbol: Some(coin.into()),
|
||||
notional: Some(amount),
|
||||
..Default::default()
|
||||
});
|
||||
t
|
||||
};
|
||||
// With no testnet mid yet, the amount stays in dollars and intent() refuses it.
|
||||
let mut t = dollars("xyz:INTC", 10.0);
|
||||
let note = settle_size(&mut t, None, Some(&intc));
|
||||
let e = intent(&t, ®).expect_err("$10 is never read as 10 contracts");
|
||||
assert!(e.contains("$10") && e.contains("dollar"), "{e}");
|
||||
assert!(t.dollars);
|
||||
assert_eq!(note.as_deref(), Some("Waiting for the testnet mid to size $10…"));
|
||||
// At a $2 mid, $10 is 5 contracts.
|
||||
assert_eq!(settle_size(&mut t, Some(2.0), Some(&intc)), None);
|
||||
assert!(!t.dollars);
|
||||
assert_eq!((t.qty.as_str(), t.hl.from_dollars), ("5", Some(10.0)));
|
||||
assert_eq!(intent(&t, ®).unwrap().size, 5.0);
|
||||
// Rounded down to the size step (2 decimals), so it is never worth more than $10.
|
||||
let mut t = dollars("xyz:INTC", 10.0);
|
||||
settle_size(&mut t, Some(3.0), Some(&intc));
|
||||
assert_eq!(intent(&t, ®).unwrap().size, 3.33);
|
||||
// BTC has five size decimals, so $10 at $76,000 is 0.00013 BTC.
|
||||
let btc = reg.get("BTC").unwrap().clone();
|
||||
let mut t = dollars("BTC", 10.0);
|
||||
settle_size(&mut t, Some(76_000.0), Some(&btc));
|
||||
assert_eq!(intent(&t, ®).unwrap().size, 0.00013);
|
||||
// An amount under one step stays in dollars. It never rounds to zero or turns into units.
|
||||
let mut t = dollars("BTC", 0.5);
|
||||
let why = settle_size(&mut t, Some(76_000.0), Some(&btc)).expect("too small");
|
||||
assert!(why.contains("smallest BTC size") && t.dollars, "{why}");
|
||||
assert!(intent(&t, ®).is_err());
|
||||
// A size typed in units is left alone.
|
||||
let mut t = Ticket::new(2);
|
||||
t.symbol = "BTC".into();
|
||||
t.qty = "0.001".into();
|
||||
assert_eq!(settle_size(&mut t, Some(76_000.0), Some(&btc)), None);
|
||||
assert_eq!((t.qty.as_str(), t.hl.from_dollars), ("0.001", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_uncertain_order_blocks_until_its_window_passes() {
|
||||
let u = Uncertain { cloid: tr_hl::actions::cloid(1), coin: "BTC".into(), sent: 1_000_000, asked: None };
|
||||
|
||||
+26
-6
@@ -11,7 +11,7 @@ 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, resolve_coin};
|
||||
use super::hl::{HlConfirm, HlKind, intent, is_hl_account, resolve_coin, settle_size};
|
||||
use super::portfolio_view::{money, price, qty};
|
||||
use super::settings_view::SettingsCmd;
|
||||
use super::trading::TicketAct;
|
||||
@@ -129,6 +129,11 @@ impl TerminalApp {
|
||||
);
|
||||
if r.changed() {
|
||||
t.error = None;
|
||||
// If the size came from dollars, size the amount again for the new market.
|
||||
if let Some(d) = t.hl.from_dollars.take() {
|
||||
t.dollars = true;
|
||||
t.qty = to_wire(d).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
match ctx.mid {
|
||||
Some(m) => {
|
||||
@@ -157,23 +162,38 @@ impl TerminalApp {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut unsized_note = None;
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Size");
|
||||
label(ui, if t.dollars { "Amount" } else { "Size" });
|
||||
let r = ui.add_enabled(
|
||||
!sending,
|
||||
egui::TextEdit::singleline(&mut t.qty).hint_text("units").font(mono(13.0)).desired_width(dp(84.0)),
|
||||
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();
|
||||
}
|
||||
}
|
||||
t.dollars = false;
|
||||
if let (Some(m), Ok(q)) = (ctx.mid, t.qty.trim().parse::<f64>()) {
|
||||
ui.label(RichText::new(format!("≈ {}", money(q * m, "USD"))).font(mono(11.5)).color(muted()));
|
||||
// `BUY xyz:AAPL $50` gets its size from the testnet mid. It is never 50 contracts.
|
||||
unsized_note = settle_size(t, 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();
|
||||
|
||||
@@ -130,6 +130,9 @@ 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);
|
||||
|
||||
+411
-59
@@ -6,6 +6,7 @@
|
||||
//! the moment of signing, not the one the confirmation showed. User data comes straight from
|
||||
//! Hyperliquid's websocket, never through Terminal's server.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
@@ -14,25 +15,167 @@ use serde_json::Value;
|
||||
use tr_broker::secrets::AgentKeyStore;
|
||||
use tr_data::rt;
|
||||
use tr_hl::assets::{Asset, AssetRegistry};
|
||||
use tr_hl::caps::Refusal;
|
||||
use tr_hl::client::{Client, ExchangeResponse, SendError};
|
||||
use tr_hl::model::{AssetData, ExtraAgent};
|
||||
use tr_hl::nonce::{NonceManager, now_ms};
|
||||
use tr_hl::stream::{PING, UserState, subscriptions};
|
||||
use tr_hl::trader::{self, MarketCtx, OrderPlan};
|
||||
use tr_hl::trader::{self, MarketCtx, OrderPlan, Signed};
|
||||
use tr_hl::{AgentKey, SigningKey};
|
||||
|
||||
use crate::prefs::HlAgent;
|
||||
|
||||
/// The stream generation that may run; a new `Stream` or `StopStream` ends older ones.
|
||||
static STREAM: AtomicU64 = AtomicU64::new(0);
|
||||
/// The newest kill switch seen. An order confirmed before it, not yet signed, is refused.
|
||||
static LATEST_KILL: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Signed actions run one at a time, in the order they were sent, so a kill switch waits for
|
||||
/// an order already being signed and then reads the open orders after it.
|
||||
fn act_lock() -> &'static tokio::sync::Mutex<()> {
|
||||
static LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
/// How long past its `expiresAfter` an order with no clear answer still counts as possibly on
|
||||
/// Hyperliquid, since the clocks may differ. The app's own check allows the same.
|
||||
pub const LAND_MARGIN_MS: u64 = 5_000;
|
||||
|
||||
const KILLED: &str = "KILL was pressed after this was confirmed, so it was not sent.";
|
||||
|
||||
/// The longest KILL waits for an order that was being sent before it sweeps again. The
|
||||
/// order's requests have deadlines and Hyperliquid drops it after its `expiresAfter`, so this
|
||||
/// cap only matters if something else goes wrong.
|
||||
const FOLLOW_UP_MAX: Duration = Duration::from_millis(
|
||||
trader::EXPIRES_AFTER_MS + LAND_MARGIN_MS + 2 * tr_hl::client::REQUEST_TIMEOUT.as_millis() as u64,
|
||||
);
|
||||
|
||||
/// When signed actions may run. Orders, modifies and leverage changes take turns, one at a
|
||||
/// time in the order sent. Cancels and the dead man's switch never wait for them. KILL never
|
||||
/// waits either. It cancels everything open at once, refuses any order confirmed before it
|
||||
/// that hasn't been signed, even one still reading the market, and sweeps again once an
|
||||
/// order that was already being sent can no longer land.
|
||||
pub struct Turns {
|
||||
turn: tokio::sync::Mutex<()>,
|
||||
/// The newest kill switch seen. `place` refuses an order confirmed before it that isn't
|
||||
/// signed yet.
|
||||
kills: tokio::sync::watch::Sender<u64>,
|
||||
/// An order holding the turn has been signed and is being sent.
|
||||
sending: std::sync::atomic::AtomicBool,
|
||||
/// An order sent without a clear answer may still land on Hyperliquid until then (ms).
|
||||
may_land_until: AtomicU64,
|
||||
}
|
||||
|
||||
impl Default for Turns {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
turn: tokio::sync::Mutex::new(()),
|
||||
kills: tokio::sync::watch::Sender::new(0),
|
||||
sending: Default::default(),
|
||||
may_land_until: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears [`Turns::sending`] however the send ends.
|
||||
struct Sending<'a>(&'a std::sync::atomic::AtomicBool);
|
||||
|
||||
impl Drop for Sending<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Turns {
|
||||
fn global() -> &'static Turns {
|
||||
static TURNS: std::sync::OnceLock<Turns> = std::sync::OnceLock::new();
|
||||
TURNS.get_or_init(Turns::default)
|
||||
}
|
||||
|
||||
fn latest_kill(&self) -> u64 {
|
||||
*self.kills.borrow()
|
||||
}
|
||||
|
||||
/// Runs an order, modify or leverage change. It waits for the turn and `read`s the market,
|
||||
/// then `go` signs and sends. `kill_epoch` is the kill switch count when an order or modify
|
||||
/// was confirmed. It is `None` for a leverage change, which KILL doesn't stop. If the send
|
||||
/// got no clear answer, `go` also returns the time until which the order may still land.
|
||||
pub async fn place<T, G, GF>(
|
||||
&self,
|
||||
kill_epoch: Option<u64>,
|
||||
read: impl Future<Output = Result<T, String>>,
|
||||
go: G,
|
||||
) -> Outcome
|
||||
where
|
||||
G: FnOnce(T) -> GF,
|
||||
GF: Future<Output = (Outcome, Option<u64>)>,
|
||||
{
|
||||
let killed = |k: &u64| kill_epoch.is_some_and(|e| *k > e);
|
||||
let mut kills = self.kills.subscribe();
|
||||
// A KILL that comes while this waits for its turn or the market ends it unsigned.
|
||||
let _turn = tokio::select! {
|
||||
biased;
|
||||
_ = kills.wait_for(killed) => return Outcome::Refused(KILLED.into()),
|
||||
turn = self.turn.lock() => turn,
|
||||
};
|
||||
let ctx = tokio::select! {
|
||||
biased;
|
||||
_ = kills.wait_for(killed) => return Outcome::Refused(KILLED.into()),
|
||||
ctx = read => ctx,
|
||||
};
|
||||
let ctx = match ctx {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Outcome::Refused(format!("Could not read the market before signing: {e}")),
|
||||
};
|
||||
// Marked before the last check, so a KILL that comes after it knows to sweep again.
|
||||
let _sending = kill_epoch.map(|_| {
|
||||
self.sending.store(true, Ordering::SeqCst);
|
||||
Sending(&self.sending)
|
||||
});
|
||||
if killed(&self.latest_kill()) {
|
||||
return Outcome::Refused(KILLED.into());
|
||||
}
|
||||
let (outcome, may_land_until) = go(ctx).await;
|
||||
if let Some(t) = may_land_until {
|
||||
self.may_land_until.fetch_max(t, Ordering::SeqCst);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// A cancel or the dead man's switch. These only take risk off, so they never wait for an
|
||||
/// order. Hyperliquid accepts nonces that arrive out of order.
|
||||
pub async fn cancel(&self, go: impl Future<Output = Outcome>) -> Outcome {
|
||||
go.await
|
||||
}
|
||||
|
||||
/// The kill switch. `sweep` reads every open order and cancels it; `report` gets each
|
||||
/// sweep's result. The first sweep runs at once. If an order was already being sent, KILL
|
||||
/// takes the turn once that order has its answer, or once its `expiresAfter` has passed if
|
||||
/// the send got no clear answer, and sweeps again. It keeps the turn through that sweep,
|
||||
/// so it never cancels an order confirmed after it.
|
||||
pub async fn kill<S, SF>(&self, epoch: u64, sweep: S, report: impl Fn(String, Outcome))
|
||||
where
|
||||
S: Fn() -> SF,
|
||||
SF: Future<Output = (String, Outcome)>,
|
||||
{
|
||||
self.kills.send_if_modified(|k| {
|
||||
let newer = epoch > *k;
|
||||
if newer {
|
||||
*k = epoch;
|
||||
}
|
||||
newer
|
||||
});
|
||||
let in_flight = self.sending.load(Ordering::SeqCst) || self.may_land_until.load(Ordering::SeqCst) > now_ms();
|
||||
let (label, outcome) = sweep().await;
|
||||
if !in_flight {
|
||||
return report(label, outcome);
|
||||
}
|
||||
report(format!("{label}; an order was still being sent, checking again after it"), outcome);
|
||||
let settled = async {
|
||||
let turn = self.turn.lock().await;
|
||||
loop {
|
||||
let (until, now) = (self.may_land_until.load(Ordering::SeqCst), now_ms());
|
||||
if until <= now {
|
||||
break turn;
|
||||
}
|
||||
rt::sleep(Duration::from_millis(until - now)).await;
|
||||
}
|
||||
};
|
||||
let _turn = rt::timeout(FOLLOW_UP_MAX, settled).await.ok();
|
||||
let (label, outcome) = sweep().await;
|
||||
report(format!("{label}, after the order that was being sent"), outcome);
|
||||
}
|
||||
}
|
||||
|
||||
pub enum HlRequest {
|
||||
@@ -299,18 +442,51 @@ pub async fn handle(req: HlRequest, send: impl Fn(HlResponse) + Clone + 'static)
|
||||
send(HlResponse::Revoked(result));
|
||||
}
|
||||
HlRequest::Act { id, key, nonces, user, act, kill_epoch } => {
|
||||
let mut label = act.label();
|
||||
if matches!(act, HlAct::CancelAll { .. }) {
|
||||
LATEST_KILL.fetch_max(kill_epoch, Ordering::SeqCst);
|
||||
}
|
||||
let _turn = act_lock().lock().await;
|
||||
let placing = matches!(act, HlAct::Order(_) | HlAct::Modify { .. });
|
||||
let outcome = if placing && LATEST_KILL.load(Ordering::SeqCst) > kill_epoch {
|
||||
Outcome::Refused("KILL was pressed after this was confirmed, so it was not sent.".into())
|
||||
} else {
|
||||
act_now(&client, &key, &nonces, &user, act, &mut label).await
|
||||
let turns = Turns::global();
|
||||
let label = act.label();
|
||||
let done = |label: String, outcome: Outcome| send(HlResponse::Done { id, label, outcome });
|
||||
let (c, k, n) = (&client, &*key, &*nonces);
|
||||
let outcome = match act {
|
||||
HlAct::Order(plan) => {
|
||||
let read = market_now(c, &user, plan.asset());
|
||||
let go = |ctx: MarketCtx| {
|
||||
send_signed(c, k, trader::sign_order(k, n, &plan, &ctx, now_ms()).map(|s| vec![s]))
|
||||
};
|
||||
turns.place(Some(kill_epoch), read, go).await
|
||||
}
|
||||
HlAct::Modify { oid, plan } => {
|
||||
let read = market_now(c, &user, plan.asset());
|
||||
let go = |ctx: MarketCtx| {
|
||||
send_signed(c, k, trader::sign_modify(k, n, oid, &plan, &ctx, now_ms()).map(|s| vec![s]))
|
||||
};
|
||||
turns.place(Some(kill_epoch), read, go).await
|
||||
}
|
||||
HlAct::Leverage { asset, cross, leverage } => {
|
||||
let go = |()| {
|
||||
let s = trader::sign_update_leverage(k, n, &asset, cross, leverage, now_ms());
|
||||
send_signed(c, k, s.map(|s| vec![s]))
|
||||
};
|
||||
turns.place(None, async { Ok(()) }, go).await
|
||||
}
|
||||
HlAct::Cancel { asset, oid } => {
|
||||
turns
|
||||
.cancel(async {
|
||||
send_signed(c, k, trader::sign_cancel(k, n, &asset, oid, now_ms()).map(|s| vec![s])).await.0
|
||||
})
|
||||
.await
|
||||
}
|
||||
HlAct::ScheduleCancel(at) => {
|
||||
turns
|
||||
.cancel(async {
|
||||
send_signed(c, k, trader::sign_schedule_cancel(k, n, at, now_ms()).map(|s| vec![s])).await.0
|
||||
})
|
||||
.await
|
||||
}
|
||||
HlAct::CancelAll { registry } => {
|
||||
return turns.kill(kill_epoch, || kill_all(c, k, n, &user, ®istry), done).await;
|
||||
}
|
||||
};
|
||||
send(HlResponse::Done { id, label, outcome });
|
||||
done(label, outcome);
|
||||
}
|
||||
HlRequest::OrderStatus { user, cloid } => {
|
||||
let result = match tr_hl::key::parse_address(&user) {
|
||||
@@ -342,33 +518,13 @@ async fn open_orders_now(client: &Client, user: &str, reg: &AssetRegistry) -> Re
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn act_now(
|
||||
client: &Client,
|
||||
key: &AgentKey,
|
||||
nonces: &NonceManager,
|
||||
user: &str,
|
||||
act: HlAct,
|
||||
label: &mut String,
|
||||
) -> Outcome {
|
||||
let signed = match act {
|
||||
HlAct::Order(plan) => match market_now(client, user, plan.asset()).await {
|
||||
Ok(ctx) => trader::sign_order(key, nonces, &plan, &ctx, now_ms()).map(|s| vec![s]),
|
||||
Err(e) => return Outcome::Refused(format!("Could not read the market before signing: {e}")),
|
||||
},
|
||||
HlAct::Modify { oid, plan } => match market_now(client, user, plan.asset()).await {
|
||||
Ok(ctx) => trader::sign_modify(key, nonces, oid, &plan, &ctx, now_ms()).map(|s| vec![s]),
|
||||
Err(e) => return Outcome::Refused(format!("Could not read the market before signing: {e}")),
|
||||
},
|
||||
HlAct::Cancel { asset, oid } => trader::sign_cancel(key, nonces, &asset, oid, now_ms()).map(|s| vec![s]),
|
||||
HlAct::CancelAll { registry } => return kill_all(client, key, nonces, user, ®istry, label).await,
|
||||
HlAct::Leverage { asset, cross, leverage } => {
|
||||
trader::sign_update_leverage(key, nonces, &asset, cross, leverage, now_ms()).map(|s| vec![s])
|
||||
}
|
||||
HlAct::ScheduleCancel(at) => trader::sign_schedule_cancel(key, nonces, at, now_ms()).map(|s| vec![s]),
|
||||
};
|
||||
/// Send signed actions in order, stopping at the first that isn't a clean success. When a
|
||||
/// send gets no clear answer, it also returns the time until which that action may still land,
|
||||
/// its `expiresAfter` plus [`LAND_MARGIN_MS`].
|
||||
async fn send_signed(client: &Client, key: &AgentKey, signed: Result<Vec<Signed>, Refusal>) -> (Outcome, Option<u64>) {
|
||||
let signed = match signed {
|
||||
Ok(s) => s,
|
||||
Err(refusal) => return Outcome::Refused(refusal.to_string()),
|
||||
Err(refusal) => return (Outcome::Refused(refusal.to_string()), None),
|
||||
};
|
||||
tracing::debug!(
|
||||
"hyperliquid testnet: {} action(s) signed by agent {}",
|
||||
@@ -379,30 +535,34 @@ async fn act_now(
|
||||
for s in &signed {
|
||||
match client.send(s).await {
|
||||
Ok(r) if r.ok && r.first_error().is_none() => last = Some(Outcome::Answered(r)),
|
||||
Ok(r) => return Outcome::Answered(r),
|
||||
Err(e) => return Outcome::Failed(e),
|
||||
Ok(r) => return (Outcome::Answered(r), None),
|
||||
Err(e) => {
|
||||
let until = matches!(e, SendError::Uncertain(_))
|
||||
.then(|| s.expires_after.unwrap_or(s.nonce + trader::EXPIRES_AFTER_MS) + LAND_MARGIN_MS);
|
||||
return (Outcome::Failed(e), until);
|
||||
}
|
||||
}
|
||||
}
|
||||
last.unwrap_or_else(|| Outcome::Refused("Nothing to send.".into()))
|
||||
(last.unwrap_or_else(|| Outcome::Refused("Nothing to send.".into())), None)
|
||||
}
|
||||
|
||||
/// The kill switch: read every open order now, cancel them, and read again, up to three
|
||||
/// rounds (an order placed elsewhere meanwhile is caught too). Reports what is left.
|
||||
/// One kill switch sweep. It reads every open order now, cancels them, and reads again, up to
|
||||
/// three rounds, so it also catches an order placed elsewhere meanwhile. Reports what is left.
|
||||
async fn kill_all(
|
||||
client: &Client,
|
||||
key: &AgentKey,
|
||||
nonces: &NonceManager,
|
||||
user: &str,
|
||||
registry: &AssetRegistry,
|
||||
label: &mut String,
|
||||
) -> Outcome {
|
||||
) -> (String, Outcome) {
|
||||
let mut cancelled = 0;
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
let mut last = None;
|
||||
let failed = |o: Outcome| ("cancel all".to_string(), o);
|
||||
for _ in 0..3 {
|
||||
let orders = match open_orders_now(client, user, registry).await {
|
||||
Ok(o) => o,
|
||||
Err(e) => return Outcome::Refused(format!("Could not read the open orders: {e}")),
|
||||
Err(e) => return failed(Outcome::Refused(format!("Could not read the open orders: {e}"))),
|
||||
};
|
||||
let open: Vec<(String, u64)> = orders.into_iter().filter(|(c, _)| !skipped.contains(c)).collect();
|
||||
if open.is_empty() {
|
||||
@@ -410,7 +570,7 @@ async fn kill_all(
|
||||
}
|
||||
let (signed, unknown) = match trader::sign_cancel_all(key, nonces, registry, &open, now_ms()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Outcome::Refused(e.to_string()),
|
||||
Err(e) => return failed(Outcome::Refused(e.to_string())),
|
||||
};
|
||||
cancelled += open.iter().filter(|(c, _)| !unknown.contains(c)).count();
|
||||
skipped.extend(unknown);
|
||||
@@ -420,24 +580,25 @@ async fn kill_all(
|
||||
for s in &signed {
|
||||
match client.send(s).await {
|
||||
Ok(r) if r.ok && r.first_error().is_none() => last = Some(Outcome::Answered(r)),
|
||||
Ok(r) => return Outcome::Answered(r),
|
||||
Err(e) => return Outcome::Failed(e),
|
||||
Ok(r) => return failed(Outcome::Answered(r)),
|
||||
Err(e) => return failed(Outcome::Failed(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
let left = open_orders_now(client, user, registry).await.map(|o| o.len());
|
||||
*label = match (cancelled, &left) {
|
||||
let mut label = match (cancelled, &left) {
|
||||
(0, Ok(0)) => "cancel all (no open orders)".into(),
|
||||
(n, Ok(0)) => format!("cancel all ({n} cancelled, none left)"),
|
||||
(n, Ok(k)) => format!("cancel all ({n} cancelled, {k} STILL OPEN: check ORD)"),
|
||||
(n, Err(_)) => format!("cancel all ({n} cancelled; couldn't read what's left)"),
|
||||
};
|
||||
if !skipped.is_empty() {
|
||||
*label += &format!("; not cancelled, unknown market: {}", skipped.join(", "));
|
||||
label += &format!("; not cancelled, unknown market: {}", skipped.join(", "));
|
||||
}
|
||||
last.unwrap_or_else(|| {
|
||||
let outcome = last.unwrap_or_else(|| {
|
||||
Outcome::Answered(ExchangeResponse { ok: true, kind: Some("cancel".into()), statuses: Vec::new(), error: None })
|
||||
})
|
||||
});
|
||||
(label, outcome)
|
||||
}
|
||||
|
||||
/// The user stream: subscribe, ping, and hand the UI a fresh [`UserState`] at most four
|
||||
@@ -492,3 +653,194 @@ async fn stream(generation: u64, user: String, send: impl Fn(HlResponse) + Clone
|
||||
backoff = (backoff * 2).min(Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use super::*;
|
||||
|
||||
const QUICK: Duration = Duration::from_secs(2);
|
||||
|
||||
fn ok() -> Outcome {
|
||||
Outcome::Answered(ExchangeResponse { ok: true, kind: Some("order".into()), statuses: Vec::new(), error: None })
|
||||
}
|
||||
|
||||
/// A stand-in for Hyperliquid's open orders, and what each KILL sweep reported.
|
||||
#[derive(Clone, Default)]
|
||||
struct Book {
|
||||
open: Arc<Mutex<Vec<u64>>>,
|
||||
reports: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Book {
|
||||
fn sweep(&self) -> impl Future<Output = (String, Outcome)> + use<> {
|
||||
let open = self.open.clone();
|
||||
async move {
|
||||
let n = std::mem::take(&mut *open.lock().unwrap()).len();
|
||||
(format!("cancel all ({n} cancelled, none left)"), ok())
|
||||
}
|
||||
}
|
||||
fn report(&self) -> impl Fn(String, Outcome) + use<> {
|
||||
let reports = self.reports.clone();
|
||||
move |label, _| reports.lock().unwrap().push(label)
|
||||
}
|
||||
fn reports(&self) -> Vec<String> {
|
||||
self.reports.lock().unwrap().clone()
|
||||
}
|
||||
fn open(&self) -> Vec<u64> {
|
||||
self.open.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
async fn eventually(what: &str, f: impl Fn() -> bool) {
|
||||
let t0 = std::time::Instant::now();
|
||||
while !f() {
|
||||
assert!(t0.elapsed() < QUICK, "{what}");
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Greptile #37 (f636b7c): an order whose market read never answered held the one turn,
|
||||
/// so KILL couldn't cancel anything until it did. In the browser that was never.
|
||||
#[tokio::test]
|
||||
async fn kill_goes_ahead_while_an_order_is_stuck_reading_the_market() {
|
||||
let turns = Arc::new(Turns::default());
|
||||
let book = Book::default();
|
||||
book.open.lock().unwrap().push(1);
|
||||
let signed = Arc::new(AtomicBool::new(false));
|
||||
let order = tokio::spawn({
|
||||
let (turns, signed) = (turns.clone(), signed.clone());
|
||||
async move {
|
||||
let read = std::future::pending::<Result<(), String>>();
|
||||
let go = |()| async move {
|
||||
signed.store(true, Ordering::SeqCst);
|
||||
(ok(), None)
|
||||
};
|
||||
turns.place(Some(0), read, go).await
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let book2 = book.clone();
|
||||
tokio::time::timeout(QUICK, turns.kill(1, move || book2.sweep(), book.report()))
|
||||
.await
|
||||
.expect("KILL waited behind an order stuck reading the market");
|
||||
assert_eq!(book.reports(), ["cancel all (1 cancelled, none left)"], "nothing was in flight: one sweep");
|
||||
let o = tokio::time::timeout(QUICK, order).await.expect("the stuck order gave up").unwrap();
|
||||
assert_eq!(o, Outcome::Refused(KILLED.into()));
|
||||
assert!(!signed.load(Ordering::SeqCst), "nothing was signed after KILL");
|
||||
}
|
||||
|
||||
/// The order was already sent and Hyperliquid had it, but the answer was slow. KILL cancels
|
||||
/// everything else at once, then sweeps again once the order is answered.
|
||||
#[tokio::test]
|
||||
async fn kill_sweeps_again_once_an_order_being_sent_is_answered() {
|
||||
let turns = Arc::new(Turns::default());
|
||||
let book = Book::default();
|
||||
book.open.lock().unwrap().push(1);
|
||||
let (answer, answered) = tokio::sync::oneshot::channel::<()>();
|
||||
let order = tokio::spawn({
|
||||
let (turns, open) = (turns.clone(), book.open.clone());
|
||||
async move {
|
||||
let go = |()| async move {
|
||||
open.lock().unwrap().push(7); // it reached Hyperliquid…
|
||||
let _ = answered.await; // …and the answer is on its way
|
||||
(ok(), None)
|
||||
};
|
||||
turns.place(Some(0), async { Ok(()) }, go).await
|
||||
}
|
||||
});
|
||||
eventually("the order was sent", || book.open().contains(&7)).await;
|
||||
let kill = tokio::spawn({
|
||||
let (turns, book) = (turns.clone(), book.clone());
|
||||
async move { turns.kill(1, || book.sweep(), book.report()).await }
|
||||
});
|
||||
eventually("KILL waited behind an order being sent", || !book.reports().is_empty()).await;
|
||||
let first = book.reports()[0].clone();
|
||||
assert!(first.starts_with("cancel all (2 cancelled") && first.contains("checking again"), "{first}");
|
||||
// The second sweep also catches an order placed elsewhere in the meantime.
|
||||
book.open.lock().unwrap().push(8);
|
||||
answer.send(()).unwrap();
|
||||
tokio::time::timeout(QUICK, kill).await.expect("the second sweep ran").unwrap();
|
||||
assert_eq!(order.await.unwrap(), ok());
|
||||
let r = book.reports();
|
||||
assert_eq!(r.len(), 2, "{r:?}");
|
||||
assert!(r[1].starts_with("cancel all (1 cancelled, none left)"), "{r:?}");
|
||||
assert!(book.open().is_empty());
|
||||
}
|
||||
|
||||
/// The send got no clear answer, so the order may still land until its `expiresAfter`
|
||||
/// passes. KILL sweeps once more after that, and an order confirmed after KILL waits until
|
||||
/// that sweep is done, so KILL never cancels it.
|
||||
#[tokio::test]
|
||||
async fn an_order_with_no_clear_answer_is_swept_again_after_its_window() {
|
||||
let turns = Arc::new(Turns::default());
|
||||
let book = Book::default();
|
||||
let window = 300;
|
||||
let until = now_ms() + window;
|
||||
let sent = turns
|
||||
.place(Some(0), async { Ok(()) }, |()| async move {
|
||||
(Outcome::Failed(SendError::Uncertain("timed out".into())), Some(until))
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(sent, Outcome::Failed(SendError::Uncertain(_))));
|
||||
let t0 = std::time::Instant::now();
|
||||
let kill = tokio::spawn({
|
||||
let (turns, book) = (turns.clone(), book.clone());
|
||||
async move { turns.kill(1, || book.sweep(), book.report()).await }
|
||||
});
|
||||
eventually("the first sweep ran at once", || !book.reports().is_empty()).await;
|
||||
assert!(t0.elapsed() < Duration::from_millis(window / 2), "{:?}", t0.elapsed());
|
||||
book.open.lock().unwrap().push(9); // the uncertain order lands late
|
||||
let after = tokio::spawn({
|
||||
let (turns, open) = (turns.clone(), book.open.clone());
|
||||
async move {
|
||||
let go = |()| async move {
|
||||
open.lock().unwrap().push(10);
|
||||
(ok(), None)
|
||||
};
|
||||
turns.place(Some(1), async { Ok(()) }, go).await
|
||||
}
|
||||
});
|
||||
tokio::time::timeout(QUICK, kill).await.expect("KILL finished").unwrap();
|
||||
assert!(t0.elapsed() >= Duration::from_millis(window - 20), "swept again only after the window");
|
||||
let r = book.reports();
|
||||
assert_eq!(r.len(), 2, "{r:?}");
|
||||
assert!(r[1].starts_with("cancel all (1 cancelled, none left)"), "the late order was cancelled: {r:?}");
|
||||
assert_eq!(after.await.unwrap(), ok());
|
||||
assert_eq!(book.open(), [10], "the order confirmed after KILL was placed, not swept");
|
||||
}
|
||||
|
||||
/// Cancels and the dead man's switch only take risk off, so they never wait for an order.
|
||||
#[tokio::test]
|
||||
async fn cancels_never_wait_for_an_order() {
|
||||
let turns = Arc::new(Turns::default());
|
||||
let _order = tokio::spawn({
|
||||
let turns = turns.clone();
|
||||
async move {
|
||||
let read = std::future::pending::<Result<(), String>>();
|
||||
turns.place(Some(0), read, |()| async { (ok(), None) }).await
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let o = tokio::time::timeout(QUICK, turns.cancel(async { ok() })).await;
|
||||
assert_eq!(o.expect("a cancel waited behind an order"), ok());
|
||||
}
|
||||
|
||||
/// An order confirmed before KILL is never signed; one confirmed after it goes ahead.
|
||||
#[tokio::test]
|
||||
async fn an_order_confirmed_before_kill_is_never_signed() {
|
||||
let turns = Turns::default();
|
||||
let book = Book::default();
|
||||
turns.kill(1, || book.sweep(), book.report()).await;
|
||||
let signed = AtomicBool::new(false);
|
||||
let go = |()| async {
|
||||
signed.store(true, Ordering::SeqCst);
|
||||
(ok(), None)
|
||||
};
|
||||
assert_eq!(turns.place(Some(0), async { Ok(()) }, go).await, Outcome::Refused(KILLED.into()));
|
||||
assert!(!signed.load(Ordering::SeqCst));
|
||||
assert_eq!(turns.place(Some(1), async { Ok(()) }, |()| async { (ok(), None) }).await, ok());
|
||||
}
|
||||
}
|
||||
+47
-5
@@ -153,11 +153,15 @@ impl std::fmt::Display for SendError {
|
||||
|
||||
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 {
|
||||
@@ -174,15 +178,20 @@ impl Client {
|
||||
fn with_urls(info_url: String, exchange_url: String) -> Result<Self> {
|
||||
let b = reqwest::Client::builder();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let b =
|
||||
b.timeout(std::time::Duration::from_secs(15)).user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION")));
|
||||
Ok(Self { http: b.build()?, info_url, exchange_url })
|
||||
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.http.post(&self.info_url).json(&body).send().await.with_context(|| format!("info {kind}"))?;
|
||||
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() {
|
||||
@@ -270,7 +279,7 @@ impl Client {
|
||||
}
|
||||
|
||||
async fn post_exchange(&self, body: &Value) -> Result<ExchangeResponse, SendError> {
|
||||
let r = self.http.post(&self.exchange_url).json(body).send().await.map_err(classify)?;
|
||||
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) {
|
||||
@@ -299,6 +308,39 @@ fn classify(e: reqwest::Error) -> SendError {
|
||||
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(
|
||||
|
||||
@@ -263,10 +263,10 @@ The rules from `server.md` §7 carry over to trading [repo]:
|
||||
| Testnet only (v1) | `Network` has one variant, `Testnet`. The endpoints are the testnet hosts, the phantom agent's `source` is always `"b"` and `hyperliquidChain` always `"Testnet"`; a stored `"network": "mainnet"` reads as testnet. Tests grep `tr-hl` for mainnet hosts. Mainnet needs a later build after §12, with its own acknowledgement. The amber TESTNET badge is on the toolbar, the account, the ticket and every confirmation. |
|
||||
| Confirmation | One per order, cancel-all and leverage change (§6.3). Enter confirms; Escape or a click outside cancels, as today [repo]. |
|
||||
| Hard caps | Constants in `tr-hl`, not settings: **$100** per order (size × the higher of the order price and the mid), **3×** leverage, **1%** market slippage. `tr-hl` checks them when the confirm opens, and the worker reads the testnet mid and leverage again and re-checks right before signing. An order is refused while the account's leverage on that market is over 3× (Hyperliquid's default is 20×), so a confirmed "Set 3×" comes first; reduce-only orders are not blocked by leverage. A breach refuses before anything is signed. |
|
||||
| Kill switch | `KILL` or the red KILL button in `ORD` (and in Settings): one confirmation, then Hyperliquid orders turn off in prefs and every open order is cancelled, one `cancel` action per dex (cancels have a higher rate-limit allowance [doc: RL]). "And flatten" is not in v1. |
|
||||
| Kill switch | `KILL` or the red KILL button in `ORD` (and in Settings): one confirmation, then Hyperliquid orders turn off in prefs and every open order is cancelled, one `cancel` action per dex (cancels have a higher rate-limit allowance [doc: RL]). KILL never waits for another request. It cancels at once and refuses any order confirmed before it that hasn't been signed, even one still reading the market. If an order was already being sent, KILL cancels again once that order can no longer land. That is when its answer comes, or when its `expiresAfter` has passed if the send got no clear answer. Single cancels and the dead man's switch never wait for orders either. "And flatten" is not in v1. |
|
||||
| Dead man's switch | **Opt-in, off by default** (Q7). While on and trading is enabled, Terminal keeps a `scheduleCancel` about 60 s ahead and refreshes it every 30 s [doc: EXCH]; turning it off clears the schedule. Constraints: at least 5 s ahead, and at most 10 triggers per day, reset 00:00 UTC [doc: EXCH]. |
|
||||
| Nonces | One agent per device and network (nonces are tracked per signer; "use a separate private key" per session [doc: NONCE]). The nonce is an atomic `max(last + 1, now_ms)`, within (T − 2 days, T + 1 day) [doc: NONCE]. A noop (`{"type": "noop"}`) can invalidate an in-flight nonce [doc: EXCH]. |
|
||||
| Uncertain results | A network error after send means "uncertain", never "failed". Reconcile by `cloid` via `orderStatus` and `orderUpdates` before allowing a resend (same idea as `PlaceError::uncertain` [repo]). |
|
||||
| Uncertain results | A network error after send means "uncertain", never "failed". Reconcile by `cloid` via `orderStatus` and `orderUpdates` before allowing a resend (same idea as `PlaceError::uncertain` [repo]). Every `/info` and `/exchange` request has its own 10 s deadline, in the browser too (`fetch` has no default timeout); an `/exchange` request that runs out of time is uncertain. |
|
||||
| Never log keys | `AgentKey` is a newtype with redacted `Debug`/`Display` (like `Credentials` and `mask()` [repo]) and is zeroized on drop. It is never serialized into prefs, layouts or the demo JSON. Signed request bodies are not logged; logs carry `cloid`, `oid`, asset and status only. Unit test: a `tracing` capture of a full place/cancel cycle contains no 64-hex-digit secrets. |
|
||||
| Rate limits | Address-based limit: 1 request per 1 USDC traded since inception, plus an initial buffer of 10,000; when limited, 1 request every 10 s [doc: RL]. A human-driven terminal stays far below this. Terminal reads `userRateLimit` [doc: INFO] and surfaces it in Settings, and backs off quietly on 429s, as the SnapTrade capabilities code does [repo]. |
|
||||
|
||||
@@ -464,6 +464,6 @@ The questions as first asked:
|
||||
|
||||
- **`crates/hl` (`tr-hl`):** `network` (testnet only), `key` (`AgentKey`, `MainKey`: redacted, zeroized on drop), `signing` (k256 directly, with `alloy-primitives` and `alloy-sol-types`; no alloy signer crates, which pull in the RPC stack), `actions`, `assets`, `rounding`, `caps`, `nonce`, `trader` (the only way to an L1 signature), `client` (reqwest, native and wasm), `model`, `stream` (subscriptions and a reducer; the socket is the app's). It builds for wasm32.
|
||||
- **`tr-broker`:** `AgentKeyStore` (Keychain, Secret Service or memory; never a file), and `market: Option<String>` on `Position`, `Order` and `Activity`, which `market_key()` prefers.
|
||||
- **App:** `app/src/hl_worker.rs` (the user stream, reads, signing after the re-check, the key store off the UI thread), `app/src/app/hl.rs` (state, flows, rows, testnet quotes), `hl_view.rs` (ticket body, confirmations, Settings), `hl_web.rs` (EIP-1193 wallet, IndexedDB key). The ticket's Hyperliquid variant covers market (IOC, 1% cap), limit (GTC, IOC, post-only), stop loss and take profit (reduce-only market triggers), reduce-only and "Set 3×".
|
||||
- **App:** `app/src/hl_worker.rs` (the user stream, reads, signing after the re-check, the key store off the UI thread), `app/src/app/hl.rs` (state, flows, rows, testnet quotes), `hl_view.rs` (ticket body, confirmations, Settings), `hl_web.rs` (EIP-1193 wallet, IndexedDB key). The ticket's Hyperliquid variant covers market (IOC, 1% cap), limit (GTC, IOC, post-only), stop loss and take profit (reduce-only market triggers), reduce-only and "Set 3×". A dollar amount (`BUY xyz:AAPL $50`) becomes a size at the testnet mid, rounded down to the market's size step. Until the mid is in, or when the amount is under one step, the ticket can't send it.
|
||||
- **Checked on testnet** with throwaway keys and no funds: every signature recovers its signer, and a corrupted one recovers someone else. `approveAgent` needs a deposit first ("Must deposit before performing actions"). The PR that added this build lists the responses.
|
||||
- **Not done in v1:** modify is in `tr-hl` and the worker but has no `ORD` control yet; brackets (`normalTpsl`), "and flatten" on KILL, the desktop browser handoff, spot, other HIP-3 dexes, the daily notional cap, and the expiry warning's one-click renew. Resting orders, fills, positions and P&L can only be checked once the testnet account has test USDC.
|
||||
Reference in new issue
Block a user