Skip to content

Commit ba31bde

Browse files
committed
feat(rfq): release inventory when quote expires unaccepted
- Add immediate release mechanism for quotes that expire without taker acceptance, preventing ghost reservations from blocking subsequent requests on the same side - Implement `Reservations::release()` to drop RFQ claims immediately when venue reports `quoteExpired`, complementing the existing time-based expiry logic - Update `QuoteExpired` handler to release inventory and log appropriately, distinguishing from other quote loss scenarios - Add test coverage for inventory release and subsequent request sizing after expiry - Update module documentation to explain the special handling of `quoteExpired` frames
1 parent 62ea9d6 commit ba31bde

6 files changed

Lines changed: 80 additions & 7 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2ff86a428b0ca3562ad5bb4172c7f26044dec4d3
1+
6790dfc0d936d8eb71987d1f45db2ad3deddc75b

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.199
1+
0.1.200

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.199"
3+
version = "0.1.200"
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/rfq/mod.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,14 @@ impl Engine {
491491
None
492492
}
493493
VenueFrame::QuoteExpired(e) => {
494-
debug!(rfq_id = %e.rfq_id, "quote expired unselected");
494+
// The taker's accept window lapsed without a submit. Drop the
495+
// claim now so the next request on this side is not sized
496+
// against a quote the venue has already un-counted.
497+
if self.reservations.release(&e.rfq_id) {
498+
info!(rfq_id = %e.rfq_id, "quote expired unaccepted; inventory released");
499+
} else {
500+
debug!(rfq_id = %e.rfq_id, "quote expired unaccepted; no local reservation");
501+
}
495502
None
496503
}
497504
VenueFrame::Challenge(_) | VenueFrame::SessionAccepted(_) => {
@@ -749,6 +756,7 @@ async fn inventory_loop(
749756

750757
#[cfg(test)]
751758
mod tests {
759+
use super::wire::QuoteExpiredFrame;
752760
use super::*;
753761
use crate::config::RfqCapacity;
754762
use crate::quote::Spread;
@@ -921,6 +929,33 @@ mod tests {
921929
assert_eq!(cache.get("http://feed").unwrap().price, 2.0);
922930
}
923931

932+
#[tokio::test]
933+
async fn quote_expired_releases_inventory_so_the_next_request_can_fill() {
934+
let mut engine = test_engine();
935+
let prices = fresh_prices();
936+
let first = engine.respond(exact_input_request("rfq_1"), &prices).await;
937+
assert!(matches!(first, MakerFrame::QuoteResponse(_)));
938+
assert_eq!(engine.reservations.len(), 1);
939+
940+
let none = engine
941+
.dispatch(
942+
VenueFrame::QuoteExpired(QuoteExpiredFrame {
943+
rfq_id: "rfq_1".into(),
944+
}),
945+
&prices,
946+
)
947+
.await;
948+
assert!(none.is_none());
949+
assert!(engine.reservations.is_empty());
950+
951+
let second = engine.respond(exact_input_request("rfq_2"), &prices).await;
952+
let MakerFrame::QuoteResponse(resp) = second else {
953+
panic!("expected a full-size quote after expiry release, got {second:?}");
954+
};
955+
assert_eq!(resp.buy_amount, "979902009");
956+
assert_eq!(engine.reservations.len(), 1);
957+
}
958+
924959
#[tokio::test]
925960
async fn stale_or_missing_feeds_reject_and_publish_no_levels() {
926961
let mut engine = test_engine();

src/rfq/reserve.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@
66
//! deadline passes — INCLUDING quotes the venue reports as lost
77
//! (`lost_price`): a losing quote is still a valid signed order the winner's
88
//! failure could route to, so its reservation holds until `deadline + skew`,
9-
//! never until the loss notice. Releases are therefore purely time-based; the
10-
//! venue's result frames are informational.
9+
//! never until the loss notice.
10+
//!
11+
//! `quoteExpired` is the exception: the taker was handed the winning quote
12+
//! and its accept window lapsed without a submit. The venue un-counts that
13+
//! order at the same moment, so this ledger must drop it or the next request
14+
//! on the same side keeps seeing a ghost reservation.
1115
1216
use std::collections::HashMap;
1317
use std::path::{Path, PathBuf};
@@ -146,6 +150,17 @@ impl Reservations {
146150
.fold(U256::ZERO, |sum, r| sum.saturating_add(r.input))
147151
}
148152

153+
/// Drop one RFQ's claim immediately. Used when the venue says the
154+
/// winning quote expired unaccepted (`quoteExpired`). Missing id is a
155+
/// no-op so a duplicate or late frame cannot break the ledger.
156+
pub fn release(&mut self, rfq_id: &str) -> bool {
157+
let gone = self.by_rfq.remove(rfq_id).is_some();
158+
if gone {
159+
self.persist();
160+
}
161+
gone
162+
}
163+
149164
/// Drop entries past their release time. Called on the 1s levels tick so
150165
/// the map can't grow unboundedly between quote bursts.
151166
pub fn prune(&mut self, now_secs: u64) {
@@ -249,6 +264,18 @@ mod tests {
249264
assert_eq!(r.reserved("cngn-usdc", true, 0), U256::from(60u64));
250265
}
251266

267+
#[test]
268+
fn quote_expired_releases_immediately_not_at_deadline_plus_skew() {
269+
let mut r = Reservations::new();
270+
r.reserve("rfq_1", "cngn-usdc", true, U256::from(500u64), 1_000);
271+
assert!(r.release("rfq_1"));
272+
assert_eq!(r.reserved("cngn-usdc", true, 0), U256::ZERO);
273+
assert!(!r.release("rfq_1"), "duplicate release is a no-op");
274+
r.reserve("rfq_2", "cngn-usdc", false, U256::from(9u64), 1_000);
275+
assert!(r.release("rfq_2"));
276+
assert_eq!(r.reserved("cngn-usdc", false, 0), U256::ZERO);
277+
}
278+
252279
#[test]
253280
fn prune_reclaims_expired_entries() {
254281
let mut r = Reservations::new();
@@ -314,6 +341,17 @@ mod tests {
314341
let _ = std::fs::remove_dir_all(path.parent().unwrap());
315342
}
316343

344+
#[test]
345+
fn persist_release_drops_the_entry_from_disk() {
346+
let path = tmp_path("release");
347+
let mut live = Reservations::with_persist_path(&path);
348+
live.reserve("rfq_1", "cngn-usdc", true, U256::from(500u64), 1_000);
349+
assert!(live.release("rfq_1"));
350+
let restored = Reservations::load(&path, 0).unwrap();
351+
assert!(restored.is_empty());
352+
let _ = std::fs::remove_dir_all(path.parent().unwrap());
353+
}
354+
317355
#[test]
318356
fn a_missing_reservations_file_is_an_empty_ledger() {
319357
let path = tmp_path("missing");

0 commit comments

Comments
 (0)