Banked: Cycle 199 (Rheo / Size Credit, Cantina $50K bounty, commit a8e5d174). WALK-CLEAN target.
Relationship: this is the defensive mirror / detection-refinement of
fixed-maturity-boundary-and-fungible-credit-risk-transfer.md Pattern 1 (the Midnight t==maturity fill-vs-liquidatable seam).
Status: NEW. Not previously in the bank. It explains WHY some fixed-maturity protocols are immune to the boundary seam and gives a fast classification step to avoid wasting effort.
The Midnight boundary seam (Blackthorn M-2) is exploitable because maturity is an independent, separately-validated input:
- take() validates block.timestamp <= offer.expiry (fill path).
- isLiquidatable() validates block.timestamp > obligation.maturity (liquidation path, STRICT).
- When expiry == maturity (the intuitive choice), a fill AT t==maturity creates new debt that is solvent at fill-time but overdue/liquidatable one block later. Two inequalities on two independently-supplied values disagree at the boundary.
Size/Rheo immunizes the SAME seam structurally, without a special boundary check, by making tenor a DERIVED quantity funneled through ONE shared rate-curve that reverts at the boundary:
- New debt (RESERVED_ID path): minTenor <= tenor <= maxTenor is enforced (SellCreditMarket/BuyCreditMarket validate), so tenor-0 debt cannot be created at all.
- Existing position (transfer/sell/buy): tenor = debtPosition.dueDate - block.timestamp is COMPUTED, never user-supplied.
- Transferability requires getLoanStatus == ACTIVE, i.e. block.timestamp <= dueDate (OVERDUE needs strict >). So the position is fillable up to and including t==dueDate.
- But at t==dueDate, derived tenor == 0, and the shared rate resolution OfferLibrary.getUserDefinedLimitOrderAPR reverts NULL_TENOR on tenor == 0. The collection path (CollectionsManagerView.getLimitOrderAPR -> market.getUserDefined*OfferAPR) flows into the identical revert.
- Net: every fill at the exact boundary reverts in the rate math. The "create unconsented debt at t==maturity, liquidate at t==maturity+1" primitive is impossible. There is no second, independent inequality to disagree with the first.
The seam is NOT a missing require(block.timestamp + epsilon < maturity). It is an architectural property:
- INPUT maturity + two separate inequalities (fill vs liquidate) = live seam (test it).
- DERIVED tenor + single shared rate function that reverts at tenor==0 (or tenor<minTenor) = closed seam (pivot elsewhere).
A protocol that derives tenor and reuses ONE pricing function for both new and existing fills cannot have the fill-path and liquidation-path inequalities silently diverge, because the fill path self-disables (reverts) exactly where the loan becomes liquidatable.
maturity/expiry/dueDate and the contract validates it), or is tenor DERIVED (dueDate - block.timestamp) and re-validated against min/maxTenor every fill?fixed-maturity-boundary-and-fungible-credit-risk-transfer.md - it tells you whether Pattern 1 is even reachable on this target.Before hunting the maturity-boundary seam, classify the maturity model. A protocol that DERIVES tenor from dueDate - block.timestamp and routes both new and existing fills through a SINGLE rate function that reverts at tenor==0 has structurally closed the seam: the fill path self-disables exactly at the maturity boundary, so it can never disagree with the liquidation inequality. The seam is a property of INDEPENDENT maturity inputs with TWO inequalities, not of fixed-maturity instruments in general. This classification turns a multi-hour empirical boundary hunt into a 10-minute structural decision and reallocates effort to surfaces that are actually live.