Skip to content

Commit 1ce6ef8

Browse files
committed
feat: default new bots to RFQ-only behind a fleet flag
- Add `book_enabled` root config key (default true) to switch between public ladder and RFQ-only modes - Introduce `panel.toml` fleet-wide flags file read from `{bots_dir}/panel.toml` with `[experimental] rfq_default` gate - Stamp fresh configs as RFQ-only when fleet flag is set or an existing bot already has RFQ-only enabled - Skip public ladder quoting when `book_enabled = false`; RFQ responder, spreads, and liquidity sizing remain active - Make `rfq_panel_unlocked()` true for either the old `rfq_panel` or new `rfq_default` gate token - Add `rfq_default_unlocked()` to check the exact `"enable-rfq"` token per-config - Update Connect (enroll) to turn off the public ladder when RFQ-default is active and write RFQ-only settings - Remove ladder-sizing validation from Connect; zero max orders no longer errors on Reconnect - Add corridor switch logic to preserve RFQ-only mode when switching to a new venue - Update panel UI to show `rfqDefaultUnlocked` and `bookEnabled` settings, add migrate nudge copy - Update example configs and docs to reflect the new modes
1 parent 856b624 commit 1ce6ef8

21 files changed

Lines changed: 844 additions & 141 deletions

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
022496c45a1c72d4b72de17587a35696fc9c0de8
1+
826269357f22f9d96a200f4ffb234cba974f46ae

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.201
1+
0.1.202

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stitch-bot"
3-
version = "0.1.201"
3+
version = "0.1.202"
44
edition = "2021"
55
description = "Stitch — Textile filler-network operator bot; market-makes the filler order book with signed UniswapX limit orders."
66
license = "AGPL-3.0-or-later"

panel.example.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Fleet flags for the Stitch panel. Copy this file next to the per-bot
2+
# folders as `{bots_dir}/panel.toml` and restart the panel.
3+
#
4+
# Tokens are exact. A typo fails closed — the feature stays off.
5+
6+
[experimental]
7+
# RFQ as the default quoting mode: new bots start RFQ-only, existing
8+
# public-ladder bots see a migrate nudge, and Connect writes RFQ-only.
9+
# The Settings RFQ card unlocks for every bot in this fleet.
10+
rfq_default = "enable-rfq"

src/config.rs

Lines changed: 143 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,14 @@ pub struct Config {
6363
/// set `provider = "turnkey" | "mpcvault"` to sign via an MPC wallet.
6464
#[serde(default)]
6565
pub signer: Option<crate::signer::SignerConfig>,
66-
/// RFQ responder (dual-run pilot). Omitted → the responder never spawns and
67-
/// nothing else changes. See [`RfqConfig`].
66+
/// RFQ responder. Omitted → the responder never spawns. See [`RfqConfig`].
6867
#[serde(default)]
6968
pub rfq: Option<RfqConfig>,
69+
/// Post resting orders on the public ladder. Default true (historical).
70+
/// Set false for RFQ-only: the bot answers private quotes and does not
71+
/// rest orders on the book. Spreads and liquidity still size RFQ.
72+
#[serde(default = "default_true")]
73+
pub book_enabled: bool,
7074
/// Raw experimental gates. Values are uninterpreted strings on purpose:
7175
/// each consumer matches its own exact token, so a typo fails closed
7276
/// instead of enabling something adjacent.
@@ -75,6 +79,10 @@ pub struct Config {
7579
pub pools: Vec<PoolConfig>,
7680
}
7781

82+
fn default_true() -> bool {
83+
true
84+
}
85+
7886
/// The `[rfq]` block: connection details for the venue's maker quote stream.
7987
/// The maker API key is NOT here — it comes from the environment variable
8088
/// named by `api_key_env`, mirroring how the wallet key stays out of the file.
@@ -140,12 +148,24 @@ pub struct ExperimentalConfig {
140148
/// anything else (or omitted) keeps the card hidden.
141149
#[serde(default)]
142150
pub rfq_panel: Option<String>,
151+
/// RFQ-as-default rollout. Exact token [`RFQ_DEFAULT_GATE`] only. When
152+
/// set (here or in the fleet `panel.toml`), new bots start RFQ-only,
153+
/// book bots see a migrate-to-RFQ nudge, and Connect writes RFQ-only.
154+
#[serde(default)]
155+
pub rfq_default: Option<String>,
143156
}
144157

145158
/// The one token that unlocks the panel's RFQ beta card. Exact, case-sensitive
146159
/// match — anything else (including a different case) fails closed.
147160
pub const RFQ_PANEL_GATE: &str = "enable-rfq-beta";
148161

162+
/// The one token that turns RFQ into the default quoting mode. Exact match.
163+
pub const RFQ_DEFAULT_GATE: &str = "enable-rfq";
164+
165+
/// Filename for the fleet-wide flag, dropped next to the per-bot folders
166+
/// (`{bots_dir}/panel.toml`). Same `[experimental]` keys as a bot config.
167+
pub const PANEL_FLAGS_FILE: &str = "panel.toml";
168+
149169
#[derive(Debug, Clone, Deserialize)]
150170
pub struct FeedConfig {
151171
/// HTTP endpoint returning `{ price, timestamp }`.
@@ -551,19 +571,29 @@ impl Config {
551571
}
552572

553573
/// True when the RFQ responder should run: the master switch is on and
554-
/// there is at least one pool to quote. Anything less is a no-op — the
555-
/// ladder never changes either way.
574+
/// there is at least one pool to quote. The public ladder is a separate
575+
/// switch ([`Self::book_enabled`]).
556576
pub fn rfq_active(&self) -> bool {
557577
self.rfq.as_ref().is_some_and(|r| r.enabled) && !self.pools.is_empty()
558578
}
559579

560-
/// True only for the exact [`RFQ_PANEL_GATE`] token. Read-only surface for
561-
/// the panel; nothing in the bot keys off it.
580+
/// True only for the exact [`RFQ_PANEL_GATE`] or [`RFQ_DEFAULT_GATE`]
581+
/// token. Read-only surface for the panel; nothing in the bot keys off it.
562582
pub fn rfq_panel_unlocked(&self) -> bool {
583+
self.rfq_default_unlocked()
584+
|| self
585+
.experimental
586+
.as_ref()
587+
.and_then(|e| e.rfq_panel.as_deref())
588+
== Some(RFQ_PANEL_GATE)
589+
}
590+
591+
/// True only for the exact [`RFQ_DEFAULT_GATE`] token on this config.
592+
pub fn rfq_default_unlocked(&self) -> bool {
563593
self.experimental
564594
.as_ref()
565-
.and_then(|e| e.rfq_panel.as_deref())
566-
== Some(RFQ_PANEL_GATE)
595+
.and_then(|e| e.rfq_default.as_deref())
596+
== Some(RFQ_DEFAULT_GATE)
567597
}
568598

569599
fn validate(&self) -> anyhow::Result<()> {
@@ -696,6 +726,34 @@ impl Config {
696726
}
697727
}
698728

729+
/// True when `toml` sets `[experimental] rfq_default` to the exact gate token.
730+
/// Used for a fleet `panel.toml` that is not a full bot config.
731+
pub fn toml_has_rfq_default_gate(toml: &str) -> bool {
732+
let Ok(value) = toml::from_str::<toml::Value>(toml) else {
733+
return false;
734+
};
735+
value
736+
.get("experimental")
737+
.and_then(|e| e.get("rfq_default"))
738+
.and_then(|v| v.as_str())
739+
== Some(RFQ_DEFAULT_GATE)
740+
}
741+
742+
/// Fleet-wide RFQ-default switch: `{dir}/panel.toml` with the exact token.
743+
pub fn rfq_default_flag_in_dir(dir: &std::path::Path) -> bool {
744+
let path = dir.join(PANEL_FLAGS_FILE);
745+
std::fs::read_to_string(path)
746+
.ok()
747+
.is_some_and(|text| toml_has_rfq_default_gate(&text))
748+
}
749+
750+
/// Stamp a fresh corridor template as RFQ-only: fleet flag, or this bot
751+
/// already left the public ladder / carries the per-bot token.
752+
pub fn rfq_default_preset_applies(cfg: Option<&Config>, bots_dir: &std::path::Path) -> bool {
753+
rfq_default_flag_in_dir(bots_dir)
754+
|| cfg.is_some_and(|c| !c.book_enabled || c.rfq_default_unlocked())
755+
}
756+
699757
#[cfg(test)]
700758
mod tests {
701759
use super::*;
@@ -992,6 +1050,14 @@ mod tests {
9921050
assert!(!cfg.rfq_active());
9931051
assert!(!cfg.rfq_panel_unlocked());
9941052
assert!(cfg.pools.iter().all(|p| p.rfq_corridor.is_none()));
1053+
let book = EXAMPLE
1054+
.find("# book_enabled = false")
1055+
.expect("example must document book_enabled");
1056+
let feed = EXAMPLE.find("\n[feed]\n").expect("example has [feed]");
1057+
assert!(
1058+
book < feed,
1059+
"book_enabled must be a root key, before [feed] — after [[pools]] it is ignored"
1060+
);
9951061
}
9961062

9971063
#[test]
@@ -1011,6 +1077,75 @@ mod tests {
10111077
assert!(Config::from_toml(&toml).unwrap().rfq_panel_unlocked());
10121078
}
10131079

1080+
#[test]
1081+
fn book_enabled_defaults_on_and_can_be_turned_off() {
1082+
let cfg = Config::from_toml(LEAN_POOL_BASE).unwrap();
1083+
assert!(cfg.book_enabled, "omitted book_enabled must stay on");
1084+
1085+
// Root-level key — appending after [[pools]] would land on the pool.
1086+
let off = LEAN_POOL_BASE.replace(
1087+
"tick_interval_secs = 5",
1088+
"tick_interval_secs = 5\nbook_enabled = false",
1089+
);
1090+
assert!(!Config::from_toml(&off).unwrap().book_enabled);
1091+
}
1092+
1093+
#[test]
1094+
fn rfq_default_gate_only_opens_on_the_exact_token() {
1095+
let cfg = Config::from_toml(LEAN_POOL_BASE).unwrap();
1096+
assert!(!cfg.rfq_default_unlocked());
1097+
assert!(!cfg.rfq_panel_unlocked());
1098+
1099+
for wrong in ["true", "1", "enable-rfq-beta", "ENABLE-RFQ"] {
1100+
let toml = format!("{LEAN_POOL_BASE}\n[experimental]\nrfq_default = \"{wrong}\"\n");
1101+
let cfg = Config::from_toml(&toml).unwrap();
1102+
assert!(!cfg.rfq_default_unlocked(), "{wrong:?} must not unlock");
1103+
}
1104+
1105+
let toml = format!("{LEAN_POOL_BASE}\n[experimental]\nrfq_default = \"enable-rfq\"\n");
1106+
let cfg = Config::from_toml(&toml).unwrap();
1107+
assert!(cfg.rfq_default_unlocked());
1108+
assert!(
1109+
cfg.rfq_panel_unlocked(),
1110+
"the default gate also unlocks the RFQ card"
1111+
);
1112+
}
1113+
1114+
#[test]
1115+
fn a_panel_toml_is_not_a_bot_config_but_still_trips_the_gate() {
1116+
assert!(!toml_has_rfq_default_gate(""));
1117+
assert!(!toml_has_rfq_default_gate(
1118+
"[experimental]\nrfq_panel = \"enable-rfq-beta\"\n"
1119+
));
1120+
assert!(toml_has_rfq_default_gate(
1121+
"[experimental]\nrfq_default = \"enable-rfq\"\n"
1122+
));
1123+
}
1124+
1125+
#[test]
1126+
fn rfq_default_preset_applies_for_fleet_or_an_already_rfq_only_bot() {
1127+
let empty = std::env::temp_dir().join("stitch-rfq-preset-applies-empty");
1128+
let _ = std::fs::create_dir_all(&empty);
1129+
let book = Config::from_toml(LEAN_POOL_BASE).unwrap();
1130+
assert!(!rfq_default_preset_applies(Some(&book), &empty));
1131+
1132+
let off = LEAN_POOL_BASE.replace(
1133+
"tick_interval_secs = 5",
1134+
"tick_interval_secs = 5\nbook_enabled = false",
1135+
);
1136+
let rfq_only = Config::from_toml(&off).unwrap();
1137+
assert!(rfq_default_preset_applies(Some(&rfq_only), &empty));
1138+
1139+
let fleet = std::env::temp_dir().join("stitch-rfq-preset-applies-fleet");
1140+
let _ = std::fs::create_dir_all(&fleet);
1141+
std::fs::write(
1142+
fleet.join(PANEL_FLAGS_FILE),
1143+
"[experimental]\nrfq_default = \"enable-rfq\"\n",
1144+
)
1145+
.unwrap();
1146+
assert!(rfq_default_preset_applies(Some(&book), &fleet));
1147+
}
1148+
10141149
const RFQ_BLOCK: &str = r#"
10151150
[rfq]
10161151
enabled = true

src/main.rs

Lines changed: 75 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,24 @@ fn run_init(dir: Option<String>) -> anyhow::Result<()> {
344344
let paths = setup::write_config(&target, corridor, &key)?;
345345
key.zeroize();
346346

347+
// Same fleet flag the panel reads: `{dir}/panel.toml` or a parent
348+
// `panel.toml` (bots-dir / bot-dir layouts).
349+
let flag_dir = paths
350+
.dir
351+
.parent()
352+
.filter(|p| stitch_bot::config::rfq_default_flag_in_dir(p))
353+
.map(|p| p.to_path_buf())
354+
.or_else(|| {
355+
stitch_bot::config::rfq_default_flag_in_dir(&paths.dir).then(|| paths.dir.clone())
356+
});
357+
if flag_dir.is_some() {
358+
let current = std::fs::read_to_string(&paths.toml)?;
359+
let next = setup::apply_rfq_default_preset(&current)?;
360+
setup::write_toml_atomic(&paths.toml, &next)?;
361+
println!("RFQ-default is on — this bot will not rest orders on the public book.");
362+
println!("Connect it to Textile (Stitch panel → Settings → RFQ) before going live.");
363+
}
364+
347365
println!("\nConfig written to {}", paths.dir.display());
348366
println!(" {}", paths.toml.display());
349367
println!(" {}", paths.env.display());
@@ -484,9 +502,13 @@ async fn run(config_path: String, dry_run: bool) -> anyhow::Result<()> {
484502
dry_run,
485503
};
486504

487-
// RFQ responder (dual-run pilot): spawned only when `[rfq].enabled`.
488-
// It runs beside the ladder on its own cadence and WebSocket; with the
489-
// config off this is a no-op and every tick below behaves exactly as before.
505+
if !cfg.book_enabled {
506+
info!("public ladder off — this bot will not rest orders on the book");
507+
}
508+
509+
// RFQ responder: spawned only when `[rfq].enabled`. With the config
510+
// off this is a no-op. The public ladder is a separate `book_enabled`
511+
// switch.
490512
let rfq_task = stitch_bot::rfq::maybe_spawn(
491513
&cfg,
492514
signer.clone(),
@@ -706,53 +728,58 @@ async fn run(config_path: String, dry_run: bool) -> anyhow::Result<()> {
706728

707729
let mut posted_bid = 0usize;
708730
let mut posted_ask = 0usize;
709-
for side in [Side::Bid, Side::Ask] {
710-
let price_override = match lean {
711-
Some((LeanMode::Live, decision)) => {
712-
let lean_price = match side {
713-
Side::Bid => decision.bid,
714-
Side::Ask => decision.ask,
715-
};
716-
match lean_price {
717-
Some(price) => Some(price),
718-
// The lean pulled this side (critical inventory or
719-
// a fair jump): stop reposting and let the live
720-
// orders age out through their TTL.
721-
None => continue,
722-
}
723-
}
724-
_ => None,
725-
};
726-
let outcome = quote_side(
727-
&ctx,
728-
&mut state,
729-
&mut budgets,
730-
pool,
731-
&pair,
732-
debt,
733-
collateral,
734-
side,
735-
mid,
736-
price_override,
737-
guard,
738-
now,
739-
)
740-
.await;
741-
match outcome {
742-
// The nonce ledger could not be persisted; never post on a
743-
// stale ledger. Skip the rest of this pool's tick.
744-
SideOutcome::AbortPool => continue 'pools,
745-
SideOutcome::Done { posted, fills } => {
746-
if fills > 0 {
747-
// Orders of ours filled since the last post: the
748-
// inventory moved, so re-read the share next tick.
749-
if let Some(lean_state) = lean_states.get_mut(&pair) {
750-
lean_state.note_fill(now);
731+
// RFQ-only (`book_enabled = false`): do not rest orders on the
732+
// public book. Spreads and liquidity still size the RFQ responder;
733+
// the taker/closer legs below are unchanged.
734+
if cfg.book_enabled {
735+
for side in [Side::Bid, Side::Ask] {
736+
let price_override = match lean {
737+
Some((LeanMode::Live, decision)) => {
738+
let lean_price = match side {
739+
Side::Bid => decision.bid,
740+
Side::Ask => decision.ask,
741+
};
742+
match lean_price {
743+
Some(price) => Some(price),
744+
// The lean pulled this side (critical inventory or
745+
// a fair jump): stop reposting and let the live
746+
// orders age out through their TTL.
747+
None => continue,
751748
}
752749
}
753-
match side {
754-
Side::Bid => posted_bid = posted,
755-
Side::Ask => posted_ask = posted,
750+
_ => None,
751+
};
752+
let outcome = quote_side(
753+
&ctx,
754+
&mut state,
755+
&mut budgets,
756+
pool,
757+
&pair,
758+
debt,
759+
collateral,
760+
side,
761+
mid,
762+
price_override,
763+
guard,
764+
now,
765+
)
766+
.await;
767+
match outcome {
768+
// The nonce ledger could not be persisted; never post on a
769+
// stale ledger. Skip the rest of this pool's tick.
770+
SideOutcome::AbortPool => continue 'pools,
771+
SideOutcome::Done { posted, fills } => {
772+
if fills > 0 {
773+
// Orders of ours filled since the last post: the
774+
// inventory moved, so re-read the share next tick.
775+
if let Some(lean_state) = lean_states.get_mut(&pair) {
776+
lean_state.note_fill(now);
777+
}
778+
}
779+
match side {
780+
Side::Bid => posted_bid = posted,
781+
Side::Ask => posted_ask = posted,
782+
}
756783
}
757784
}
758785
}

0 commit comments

Comments
 (0)