@@ -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 ) ]
4343pub 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 cap — the inventory loop is then never spawned .
57+ /// Tokens a book needs live wallet reads for. Any RFQ side (Exact or Wallet)
58+ /// needs a reading — Exact is a cap on top of the wallet, not a bypass .
5959pub 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