Skip to content

Commit cb23abd

Browse files
committed
fix(rfq): cap exact stitch inventory on the live wallet
- Exact capacity now caps to minimum of configured size and funded wallet balance, preventing over-signing against the ladder - Exact sides now fail closed without a wallet reading instead of ignoring inventory constraints - Updated inventory refresh logic to apply to all RFQ sides (Exact and Wallet), not just Wallet-type sides - Exact is now treated as a cap on top of the wallet rather than a bypass of wallet constraints - Updated test helpers to use properly funded inventory cache by default - Added tests verifying exact capacity respects wallet balance and fails closed when reading is missing
1 parent 89bb1e9 commit cb23abd

6 files changed

Lines changed: 78 additions & 36 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0dfc0d7d8141f35e0489d62c26d3562bbdb6a2ee
1+
617419b8381594c4ceb224381cee0d7da318bd95

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.192
1+
0.1.193

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.192"
3+
version = "0.1.193"
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: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,8 @@ async fn run(rt: RfqRuntime) {
233233
tokio::spawn(price_loop(url, prices.clone()));
234234
}
235235

236-
// Live-wallet (`max`) sides: refresh funded amounts off the quote path.
237-
// Exact-only configs skip this entirely — no extra RPC.
236+
// Every RFQ side (Exact cap or live wallet) refreshes funded amounts
237+
// off the quote path. Exact is a cap on top of the wallet, not a bypass.
238238
let inventory = InventoryCache::default();
239239
let tokens = wallet_tokens(&rt.books);
240240
if !tokens.is_empty() {
@@ -741,7 +741,17 @@ mod tests {
741741
feed_url: "http://feed".into(),
742742
}],
743743
reservations: Reservations::new(),
744-
inventory: InventoryCache::default(),
744+
inventory: {
745+
let cache = InventoryCache::default();
746+
let now = unix_now();
747+
cache.set(DEBT.parse().unwrap(), U256::from(10_000_000_000u64), now);
748+
cache.set(
749+
COLLATERAL.parse().unwrap(),
750+
U256::from(10_000_000_000u64),
751+
now,
752+
);
753+
cache
754+
},
745755
counter: 0,
746756
chain_id: 8453,
747757
permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3"

src/rfq/responder.rs

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ pub struct CorridorBook {
3636
pub feed_url: String,
3737
}
3838

39-
/// Latest funded amounts (`min(balance, Permit2 allowance)`) keyed by token.
40-
/// Empty / missing entries fail closed for [`RfqCapacity::Wallet`] sides:
41-
/// no quote, no published level. Exact capacities ignore this map.
39+
/// Latest funded amounts (`min(balance, Permit2 allowance)` minus the live
40+
/// book) keyed by token. Empty / missing entries fail closed for every RFQ
41+
/// side — Exact caps still cannot outrun a missing or smaller wallet.
4242
#[derive(Debug, Clone, Default)]
4343
pub struct InventoryView {
4444
funded: HashMap<Address, U256>,
@@ -54,15 +54,16 @@ impl InventoryView {
5454
}
5555
}
5656

57-
/// Tokens a book needs live wallet reads for. Empty when every RFQ side is
58-
/// an exact capthe inventory loop is then never spawned.
57+
/// Tokens a book needs live wallet reads for. Any RFQ side (Exact or Wallet)
58+
/// needs a readingExact is a cap on top of the wallet, not a bypass.
5959
pub fn wallet_tokens(books: &[CorridorBook]) -> Vec<Address> {
6060
let mut tokens: Vec<Address> = books
6161
.iter()
6262
.flat_map(|book| {
6363
[
64-
matches!(book.buy_capacity_debt, Some(RfqCapacity::Wallet)).then_some(book.debt),
65-
matches!(book.sell_capacity_collateral, Some(RfqCapacity::Wallet))
64+
book.buy_capacity_debt.is_some().then_some(book.debt),
65+
book.sell_capacity_collateral
66+
.is_some()
6667
.then_some(book.collateral),
6768
]
6869
})
@@ -79,7 +80,7 @@ fn resolve_capacity(
7980
inv: &InventoryView,
8081
) -> Option<U256> {
8182
match policy? {
82-
RfqCapacity::Exact(v) => Some(v),
83+
RfqCapacity::Exact(v) => inv.funded(token).map(|funded| v.min(funded)),
8384
RfqCapacity::Wallet => inv.funded(token),
8485
}
8586
}
@@ -335,21 +336,21 @@ mod tests {
335336
}
336337
}
337338

339+
fn funded_inv() -> InventoryView {
340+
InventoryView::new(HashMap::from([
341+
(DEBT.parse().unwrap(), U256::from(u64::MAX)),
342+
(COLLATERAL.parse().unwrap(), U256::from(u64::MAX)),
343+
]))
344+
}
345+
338346
fn decide(
339347
book: &CorridorBook,
340348
req: &QuoteRequestFrame,
341349
mid: f64,
342350
reserved_bid: U256,
343351
reserved_ask: U256,
344352
) -> Result<QuotePlan, RejectReason> {
345-
decide_quote(
346-
book,
347-
req,
348-
mid,
349-
reserved_bid,
350-
reserved_ask,
351-
&InventoryView::default(),
352-
)
353+
decide_quote(book, req, mid, reserved_bid, reserved_ask, &funded_inv())
353354
}
354355

355356
fn levels(
@@ -359,14 +360,7 @@ mod tests {
359360
reserved_ask: U256,
360361
as_of: String,
361362
) -> LevelsFrame {
362-
levels_for(
363-
book,
364-
mid,
365-
reserved_bid,
366-
reserved_ask,
367-
as_of,
368-
&InventoryView::default(),
369-
)
363+
levels_for(book, mid, reserved_bid, reserved_ask, as_of, &funded_inv())
370364
}
371365

372366
fn request(sell_token: &str, buy_token: &str) -> QuoteRequestFrame {
@@ -619,10 +613,24 @@ mod tests {
619613

620614
// No reading yet — fail closed, don't guess the balance.
621615
assert_eq!(
622-
decide(&wallet_book, &req, 1.0, U256::ZERO, U256::ZERO),
616+
decide_quote(
617+
&wallet_book,
618+
&req,
619+
1.0,
620+
U256::ZERO,
621+
U256::ZERO,
622+
&InventoryView::default()
623+
),
623624
Err(RejectReason::Inventory)
624625
);
625-
let dark = levels(&wallet_book, 1.0, U256::ZERO, U256::ZERO, "t0".into());
626+
let dark = levels_for(
627+
&wallet_book,
628+
1.0,
629+
U256::ZERO,
630+
U256::ZERO,
631+
"t0".into(),
632+
&InventoryView::default(),
633+
);
626634
assert!(dark.bids.is_empty() && dark.asks.is_empty());
627635

628636
let debt: Address = DEBT.parse().unwrap();
@@ -649,15 +657,39 @@ mod tests {
649657
let frame = levels_for(&wallet_book, 1.0, U256::ZERO, U256::ZERO, "t1".into(), &inv);
650658
assert_eq!(frame.asks[0].size, "3000000000");
651659

652-
// An exact cap ignores a smaller wallet reading — the policy is the cap.
660+
// An exact cap used to ignore a smaller wallet and over-sign vs the
661+
// ladder (audit M-03). It now mins with the funded reading.
653662
let exact = book();
663+
assert_eq!(
664+
decide_quote(
665+
&exact,
666+
&req,
667+
1.0,
668+
U256::ZERO,
669+
U256::ZERO,
670+
&InventoryView::default()
671+
),
672+
Err(RejectReason::Inventory),
673+
"exact without a wallet reading fails closed"
674+
);
675+
let thin = InventoryView::new(HashMap::from([
676+
(debt, U256::from(100_000u64)),
677+
(collateral, U256::from(100_000u64)),
678+
]));
679+
assert_eq!(
680+
decide_quote(&exact, &req, 1.0, U256::ZERO, U256::ZERO, &thin),
681+
Err(RejectReason::Size)
682+
);
654683
assert!(decide_quote(&exact, &req, 1.0, U256::ZERO, U256::ZERO, &inv).is_ok());
655684
}
656685

657686
#[test]
658-
fn wallet_tokens_are_only_the_max_sides() {
687+
fn wallet_tokens_cover_every_rfq_side() {
659688
let exact = book();
660-
assert!(wallet_tokens(&[exact.clone()]).is_empty());
689+
let tokens = wallet_tokens(&[exact.clone()]);
690+
assert_eq!(tokens.len(), 2);
691+
assert!(tokens.contains(&COLLATERAL.parse().unwrap()));
692+
assert!(tokens.contains(&DEBT.parse().unwrap()));
661693

662694
let mut both = exact.clone();
663695
both.buy_capacity_debt = Some(RfqCapacity::Wallet);

0 commit comments

Comments
 (0)