Skip to content

Commit a3c903d

Browse files
committed
feat(config): per-feed RFQ staleness windows and smarter defaults
- Add `rfq_staleness_secs` field to `FeedConfig` to allow per-feed RFQ freshness tuning instead of a global cap, enabling cron-sampled feeds like cNGN to request wider windows without silently widening live-fetched feeds like WETH - Infer appropriate window from feed URL when unset: cNGN pairs default to `RFQ_MAX_STALENESS_SECS`, others to `RFQ_DEFAULT_STALENESS_SECS`, so existing configs without the field keep correct behavior on upgrade - Rename `RFQ_MAX_STALENESS_SECS` concept: introduce `PRICE_FEED_CADENCE_SECS` (60s, Textile's `/price` sample rate) and set cap at 4× cadence to clear cron jitter and sampler write latency - Add `rfq_staleness_secs` field to `PoolConfig` for pools with custom `feed_url`, ensuring multi-feed bots don't inherit the wrong window between corridors - Extract `assert_rfq_stream_url_with` and `assert_feed_url_with` helpers that take allow-cleartext as an argument instead of reading environment, eliminating test pollution where one test setting `STITCH_ALLOW_CLEARTEXT_DOCKER` would widen the gate for concurrently-running tests - Add comprehensive tests: per-feed tuning, inference by pair name, pools on custom feeds, template validation that cNGN templates opt in and live-fetched ones do not, cadence math verification - Update RFQ module docs to reflect per-feed staleness rather than global capping - Bump version to 0.1.207
1 parent 9ead592 commit a3c903d

12 files changed

Lines changed: 627 additions & 58 deletions

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
c4c41ccd27cc21b3504d72856652ca5cf19a6e90
1+
ad2a3601ff291a31a1eeff7c8e3ddfe941d0c1ae

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.206
1+
0.1.207

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.206"
3+
version = "0.1.207"
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"

src/config.rs

Lines changed: 360 additions & 27 deletions
Large diffs are not rendered by default.

src/rfq/mod.rs

Lines changed: 175 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
//! Shared with the ladder: the same feed URLs, the same
1717
//! `quote::bid_price`/`ask_price` spreads, the same [`crate::eip712`] Permit2
1818
//! digest and the same order-bytes encoder — one pricing and signing story,
19-
//! two distribution channels. RFQ caps staleness at
20-
//! [`crate::config::RFQ_MAX_STALENESS_SECS`] (60s) so a 900s ladder template
21-
//! cannot keep firm quotes on a 14-minute-old print; the ladder still uses
19+
//! two distribution channels. RFQ tightens staleness per feed (see
20+
//! [`crate::config::rfq_staleness_secs`]) so a 900s ladder template cannot
21+
//! keep firm quotes on a 14-minute-old print; the ladder still uses
2222
//! `[feed].staleness_secs` as written. Deliberately NOT shared: the tick loop
2323
//! (RFQ runs its own 1 s cadence and its own price cache so a slow ladder tick
2424
//! can't blow the reply budget) and the nonce ledger (RFQ nonces live in a
@@ -44,7 +44,7 @@ use tokio_tungstenite::tungstenite::protocol::Message;
4444
use tracing::{debug, error, info, warn};
4545

4646
use crate::closer::executor::{encode_allowance, encode_balance_of};
47-
use crate::config::{rfq_staleness_secs, Config};
47+
use crate::config::{rfq_staleness_secs_for_pool, Config};
4848
use crate::eip712::permit2_digest;
4949
use crate::feed::{HttpFeed, PriceFeed, Quote};
5050
use crate::rpc::Wallet;
@@ -74,7 +74,6 @@ pub struct RfqRuntime {
7474
permit2: Address,
7575
reactor: Address,
7676
validation_contract: Address,
77-
staleness_secs: u64,
7877
rpc_url: String,
7978
indexer_url: String,
8079
books: Vec<CorridorBook>,
@@ -197,7 +196,7 @@ fn build_runtime(
197196
let books = cfg
198197
.pools
199198
.iter()
200-
.map(|p| book_from_pool(p, &cfg.feed.url))
199+
.map(|p| book_from_pool(p, &cfg.feed.url, rfq_staleness_secs_for_pool(&cfg.feed, p)))
201200
.collect::<anyhow::Result<Vec<_>>>()?
202201
.into_iter()
203202
.flatten()
@@ -214,7 +213,6 @@ fn build_runtime(
214213
.validation_contract
215214
.parse()
216215
.context("invalid [rfq].validation_contract")?,
217-
staleness_secs: rfq_staleness_secs(cfg.feed.staleness_secs),
218216
rpc_url: cfg.rpc_url.clone(),
219217
indexer_url: cfg.indexer_url.clone(),
220218
books,
@@ -340,11 +338,11 @@ async fn run(rt: RfqRuntime) {
340338
Reservations::new()
341339
}
342340
};
343-
let mut backoff_secs = 1u64;
341+
let mut backoff = Backoff::default();
344342
loop {
345343
match session::connect_and_auth(&rt.url, &rt.api_key, &rt.maker_id, &rt.signer).await {
346344
Ok(authed) => {
347-
backoff_secs = 1;
345+
backoff.reset();
348346
let (err, ledger) = session_loop(
349347
&rt,
350348
&prices,
@@ -354,17 +352,92 @@ async fn run(rt: RfqRuntime) {
354352
)
355353
.await;
356354
reservations = ledger;
357-
warn!(
358-
error = %format!("{err:#}"),
359-
"RFQ session ended (superseded, closed, or failed); reconnecting"
360-
);
355+
if session::is_handover(&err) {
356+
info!(
357+
detail = %format!("{err:#}"),
358+
"RFQ venue handed this session over; reconnecting immediately"
359+
);
360+
} else {
361+
warn!(
362+
error = %format!("{err:#}"),
363+
"RFQ session ended (superseded, closed, or failed); reconnecting"
364+
);
365+
}
366+
backoff.note(&err);
361367
}
362368
Err(e) => {
363-
warn!(error = %format!("{e:#}"), "RFQ connect/auth failed");
369+
if session::is_handover(&e) {
370+
debug!(detail = %format!("{e:#}"), "venue redirected us; retrying immediately");
371+
} else {
372+
warn!(error = %format!("{e:#}"), "RFQ connect/auth failed");
373+
}
374+
backoff.note(&e);
364375
}
365376
}
366-
tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
367-
backoff_secs = (backoff_secs * 2).min(30);
377+
tokio::time::sleep(backoff.next_delay()).await;
378+
}
379+
}
380+
381+
/// Reconnect pacing, with a fast lane for handovers.
382+
///
383+
/// Two different failures wear the same shape here. A venue that is genuinely
384+
/// down wants exponential backoff, so a fleet of bots doesn't hammer it back
385+
/// into the ground. A venue that is *moving* — a deploy handing sockets from
386+
/// the outgoing task to the warm incoming one — wants no delay at all: the
387+
/// replacement is already accepting, and every second spent sleeping is a
388+
/// second the corridor reports no makers for no reason. Backing off through a
389+
/// deploy is most of what used to make one cost minutes.
390+
///
391+
/// The fast lane is bounded in attempts rather than time, because the thing it
392+
/// is riding out is "the ALB handed me the wrong one of N tasks", which is a
393+
/// couple of retries, not a wait.
394+
struct Backoff {
395+
secs: u64,
396+
fast_attempts: u32,
397+
}
398+
399+
/// Immediate retries allowed after a handover before normal backoff resumes.
400+
/// Generous for a two-task overlap; still finite, so a venue stuck refusing
401+
/// every socket can't be spun on forever.
402+
const HANDOVER_FAST_RETRIES: u32 = 20;
403+
404+
/// Pause between fast-lane retries. Long enough not to spin, short enough that
405+
/// a handover is invisible next to a WebSocket handshake.
406+
const HANDOVER_RETRY_MS: u64 = 250;
407+
408+
impl Default for Backoff {
409+
fn default() -> Self {
410+
Self {
411+
secs: 1,
412+
fast_attempts: 0,
413+
}
414+
}
415+
}
416+
417+
impl Backoff {
418+
/// A session that ran clears both lanes.
419+
fn reset(&mut self) {
420+
self.secs = 1;
421+
self.fast_attempts = 0;
422+
}
423+
424+
/// Record why the last attempt ended, opening or closing the fast lane.
425+
fn note(&mut self, err: &anyhow::Error) {
426+
if session::is_handover(err) {
427+
self.fast_attempts = self.fast_attempts.saturating_add(1);
428+
} else {
429+
self.fast_attempts = 0;
430+
}
431+
}
432+
433+
/// How long to wait before the next attempt, advancing the backoff.
434+
fn next_delay(&mut self) -> std::time::Duration {
435+
if self.fast_attempts > 0 && self.fast_attempts <= HANDOVER_FAST_RETRIES {
436+
return std::time::Duration::from_millis(HANDOVER_RETRY_MS);
437+
}
438+
let delay = std::time::Duration::from_secs(self.secs);
439+
self.secs = (self.secs * 2).min(30);
440+
delay
368441
}
369442
}
370443

@@ -643,7 +716,6 @@ async fn session_loop_inner(
643716
permit2: rt.permit2,
644717
reactor: rt.reactor,
645718
validation_contract: rt.validation_contract,
646-
staleness_secs: rt.staleness_secs,
647719
signer: rt.signer.clone(),
648720
};
649721

@@ -720,8 +792,14 @@ async fn session_loop_inner(
720792
stream.send(Message::Pong(payload)).await.context("sending pong")?;
721793
}
722794
Message::Close(reason) => {
723-
warn!(?reason, "venue sent close (possibly a superseding session)");
724-
anyhow::bail!("venue closed the session: {reason:?}");
795+
if session::handover_close(
796+
reason.as_ref().map(|f| u16::from(f.code)).unwrap_or(0),
797+
) {
798+
info!(?reason, "venue handing this session over; reconnecting now");
799+
} else {
800+
warn!(?reason, "venue sent close (possibly a superseding session)");
801+
}
802+
return Err(session::close_error(&reason));
725803
}
726804
_ => {}
727805
}
@@ -787,7 +865,6 @@ struct Engine {
787865
permit2: Address,
788866
reactor: Address,
789867
validation_contract: Address,
790-
staleness_secs: u64,
791868
signer: DynSigner,
792869
}
793870

@@ -802,7 +879,7 @@ impl Engine {
802879
.iter()
803880
.filter_map(|book| {
804881
let quote = prices.get(&book.feed_url)?;
805-
if is_stale(quote.timestamp, now_secs, self.staleness_secs)
882+
if is_stale(quote.timestamp, now_secs, book.staleness_secs)
806883
|| !is_price_usable(quote.price)
807884
{
808885
return None;
@@ -900,7 +977,9 @@ impl Engine {
900977
let Some(quote) = prices.get(&book.feed_url) else {
901978
return reject(RejectReason::StaleFeed);
902979
};
903-
if is_stale(quote.timestamp, now_secs, self.staleness_secs) || !is_price_usable(quote.price)
980+
// The book's own window, not the runtime's: this is the firm-quote
981+
// gate, so it must match the one the levels were published under.
982+
if is_stale(quote.timestamp, now_secs, book.staleness_secs) || !is_price_usable(quote.price)
904983
{
905984
return reject(RejectReason::StaleFeed);
906985
}
@@ -1131,6 +1210,78 @@ mod tests {
11311210
const COLLATERAL: &str = "0x0000000000000000000000000000000000000001";
11321211
const DEBT: &str = "0x0000000000000000000000000000000000000002";
11331212

1213+
fn handover(code: u16) -> anyhow::Error {
1214+
anyhow::Error::new(session::VenueHandover {
1215+
code,
1216+
reason: "test".into(),
1217+
})
1218+
}
1219+
1220+
#[test]
1221+
fn only_the_redirect_codes_count_as_a_handover() {
1222+
assert!(session::handover_close(4005), "not-the-engine redirects");
1223+
assert!(session::handover_close(4006), "draining redirects");
1224+
// Everything in 4000-4004 is the maker's problem, not the task's:
1225+
// reconnecting instantly would hammer the venue with a credential it
1226+
// has already rejected.
1227+
for code in [0u16, 1006, 4000, 4001, 4002, 4003, 4004] {
1228+
assert!(
1229+
!session::handover_close(code),
1230+
"{code} must not open the fast lane"
1231+
);
1232+
}
1233+
}
1234+
1235+
#[test]
1236+
fn is_handover_sees_through_added_context() {
1237+
let wrapped = handover(4006).context("connecting to venue");
1238+
assert!(session::is_handover(&wrapped));
1239+
assert!(!session::is_handover(&anyhow::anyhow!(
1240+
"503 Service Unavailable"
1241+
)));
1242+
}
1243+
1244+
#[test]
1245+
fn a_handover_retries_immediately_while_a_failure_backs_off() {
1246+
let mut b = Backoff::default();
1247+
1248+
// A deploy handover: no sleeping. This is the whole point — the warm
1249+
// replacement is already accepting sockets.
1250+
b.note(&handover(4006));
1251+
assert_eq!(b.next_delay(), std::time::Duration::from_millis(250));
1252+
b.note(&handover(4005));
1253+
assert_eq!(b.next_delay(), std::time::Duration::from_millis(250));
1254+
1255+
// A real outage goes back to exponential, and the handover retries it
1256+
// just made must not have eaten the budget.
1257+
b.note(&anyhow::anyhow!("503"));
1258+
assert_eq!(b.next_delay(), std::time::Duration::from_secs(1));
1259+
b.note(&anyhow::anyhow!("503"));
1260+
assert_eq!(b.next_delay(), std::time::Duration::from_secs(2));
1261+
}
1262+
1263+
#[test]
1264+
fn the_fast_lane_is_finite() {
1265+
let mut b = Backoff::default();
1266+
for _ in 0..HANDOVER_FAST_RETRIES {
1267+
b.note(&handover(4005));
1268+
assert_eq!(b.next_delay(), std::time::Duration::from_millis(250));
1269+
}
1270+
// A venue refusing every socket must not be spun on forever.
1271+
b.note(&handover(4005));
1272+
assert_eq!(b.next_delay(), std::time::Duration::from_secs(1));
1273+
}
1274+
1275+
#[test]
1276+
fn a_session_that_ran_clears_both_lanes() {
1277+
let mut b = Backoff::default();
1278+
b.note(&anyhow::anyhow!("503"));
1279+
let _ = b.next_delay();
1280+
let _ = b.next_delay();
1281+
b.reset();
1282+
assert_eq!(b.next_delay(), std::time::Duration::from_secs(1));
1283+
}
1284+
11341285
fn test_engine() -> Engine {
11351286
let key = SigningKey::from_slice(
11361287
&alloy_primitives::hex::decode(
@@ -1151,6 +1302,7 @@ mod tests {
11511302
buy_capacity_debt: Some(RfqCapacity::Exact(U256::from(1_500_000_000u64))),
11521303
sell_capacity_collateral: Some(RfqCapacity::Exact(U256::from(1_500_000_000u64))),
11531304
feed_url: "http://feed".into(),
1305+
staleness_secs: 240,
11541306
}],
11551307
reservations: Reservations::new(),
11561308
inventory: {
@@ -1175,7 +1327,6 @@ mod tests {
11751327
validation_contract: "0x00000000000000000000000000000000000000f1"
11761328
.parse()
11771329
.unwrap(),
1178-
staleness_secs: 900,
11791330
signer: Arc::new(LocalSigner::new(key)),
11801331
}
11811332
}
@@ -1304,6 +1455,7 @@ mod tests {
13041455
buy_capacity_debt: Some(RfqCapacity::Exact(U256::from(1u64))),
13051456
sell_capacity_collateral: Some(RfqCapacity::Exact(U256::from(1u64))),
13061457
feed_url: "http://feed".into(),
1458+
staleness_secs: 240,
13071459
}
13081460
}
13091461

src/rfq/responder.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ pub struct CorridorBook {
3737
pub sell_capacity_collateral: Option<RfqCapacity>,
3838
/// The feed this corridor prices off (pool override or the bot default).
3939
pub feed_url: String,
40+
/// How old a mark this corridor may quote off. Per book, not per bot: one
41+
/// bot can carry pools on differently paced feeds, and a single scalar
42+
/// would apply a cron-sampled corridor's window to a live-priced one.
43+
pub staleness_secs: u64,
4044
}
4145

4246
/// Latest funded amounts (`min(balance, Permit2 allowance)` minus the live
@@ -96,6 +100,7 @@ fn resolve_capacity(
96100
pub fn book_from_pool(
97101
pool: &PoolConfig,
98102
default_feed_url: &str,
103+
staleness_secs: u64,
99104
) -> anyhow::Result<Option<CorridorBook>> {
100105
Ok(Some(CorridorBook {
101106
slug: pool.rfq_corridor.clone().unwrap_or_default(),
@@ -107,6 +112,7 @@ pub fn book_from_pool(
107112
sell_spread: pool.sell_spread(),
108113
buy_capacity_debt: pool.rfq_buy_capacity_debt()?,
109114
sell_capacity_collateral: pool.rfq_sell_capacity_collateral()?,
115+
staleness_secs,
110116
feed_url: pool
111117
.feed_url
112118
.clone()
@@ -350,6 +356,7 @@ mod tests {
350356
buy_capacity_debt: Some(RfqCapacity::Exact(U256::from(5_000_000_000u64))),
351357
sell_capacity_collateral: Some(RfqCapacity::Exact(U256::from(5_000_000_000u64))),
352358
feed_url: "http://feed".into(),
359+
staleness_secs: 240,
353360
}
354361
}
355362

@@ -610,7 +617,7 @@ mod tests {
610617
refresh_threshold_bps = 0
611618
"#;
612619
let cfg = crate::config::Config::from_toml(toml).unwrap();
613-
let book = book_from_pool(&cfg.pools[0], "http://feed")
620+
let book = book_from_pool(&cfg.pools[0], "http://feed", 240)
614621
.unwrap()
615622
.expect("every pool is a book");
616623
assert!(book.slug.is_empty());
@@ -620,7 +627,7 @@ mod tests {
620627
"buy_order_size_debt = \"5000000000\"\nrfq_corridor = \"cngn-usdc\"",
621628
);
622629
let cfg = crate::config::Config::from_toml(&toml).unwrap();
623-
let book = book_from_pool(&cfg.pools[0], "http://feed")
630+
let book = book_from_pool(&cfg.pools[0], "http://feed", 240)
624631
.unwrap()
625632
.expect("book built");
626633
assert_eq!(book.slug, "cngn-usdc");

0 commit comments

Comments
 (0)