Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
M-05·

The schedule has no notion of a redemption request cutoff, so fills after the filing deadline are charged for a payout the maker can no longer reach

Fixedrwarfqoracles
TL;DR

For a CYCLICAL asset, _secondsFor charges the discount over the time until nextRedemptionAt, and the contract describes that anchor as the moment the buyer's RWA redeems: "Seconds until asset redeems … an asset one hour from redemption prices at an hour of spread, not at a whole day of it"…

Severity
MEDIUM
Method
MManual review
CAT.
02Section · Affected files

Affected files

src/octarine-rfq/OctarineRFQ.sol#L110-L124 src/octarine-rfq/OctarineRFQ.sol#L127-L146 src/octarine-rfq/interfaces/IOctarineRFQ.sol#L199-L204

03Section · Description

Description

For a CYCLICAL asset, _secondsFor charges the discount over the time until nextRedemptionAt, and the contract describes that anchor as the moment the buyer's RWA redeems: "Seconds until asset redeems … an asset one hour from redemption prices at an hour of spread, not at a whole day of it" (IOctarineRFQ.sol L199-L204).

You told us the anchor will be the payout date, "because the payout is when the maker actually receives cash". You also told us that before the issuer's request cutoff passes, the keeper moves the anchor to the next payout date. The two CYCLICAL assets planned are Pareto AA (filing takes one block) and ACRED (filing takes up to a day).

The contract has no notion of that cutoff:

  • Who is caught. A maker that buys the RWA too late to file before the cutoff, i.e. after the cutoff minus its filing time, cannot get into that payout's batch. It is paid at the next payout, a full cycle later.
  • What it is charged. Until the anchor moves, the contract charges that maker for the wait to the current payout.
  • When the price is right. Only if the anchor moves to the next payout at least the filing time before the cutoff, every cycle.

Two things make that move hard to land on time:

  • The keeper cannot make it. Moving the anchor from one payout date to the next is a full-cycle shift. The keeper's shift bound defaults to 2 days, so a SCHEDULE_KEEPER_ROLE call reverts ScheduleShiftTooLarge (L127-L146). Only the admin can make the move in one call; the keeper would need about fifteen batched calls.
  • Every cycle depends on it. If the move lands late, every fill between the filing deadline and the move is mispriced. If it does not land before the cutoff, every fill until the payout is.

The taker picks the moment of the fill: it submits the fill itself, or anyone holding both signatures does when sender is unset. Orders from the app run up to a month, so an order on a monthly asset almost always spans a cutoff.

Vulnerable Scenario (an ACRED-like asset: request cutoff 5 days before payout, filing within a day):

  1. A maker signs a 10.00 bps/day bid for 1,000 RWA, 29 days before the payout (quote 971 USDC, cap 1,000 USDC), with a one-month expiry.
  2. Two days before the cutoff, the keeper's move to the next payout reverts. The admin makes it an hour before the cutoff.
  3. Twelve hours before the cutoff, a taker fills. The maker pays 994.50 USDC, i.e. 5.5 days of discount. Its request, filed within a day, lands after the cutoff and is paid at the next payout.
  4. Right after the move, the contract quotes the same lot at 964.96 USDC.
04Section · Impact

Impact

The maker pays for a payout it can no longer reach, and holds the RWA for another full cycle without being paid for the wait.

  • Size per struck fill. About rate × cycle: 0.6% of notional at 2 bps/day, 3% at 10 and 6% at 20, on a monthly cycle.
  • No principal loss. The maker keeps an asset worth its NAV, but carries a cycle of exposure for free.
  • The cap does not stop it. Late in a cycle, honest fills already cost close to the full NAV, so a cap that allows them also allows the strike.
  • How often. It repeats every cycle in which the move is late or missed. While the keeper cannot make the move, every cycle depends on a manual admin transaction landing inside the window.
05Section · Recommendations

Recommendations

Tell the contract where the filing deadline is, instead of relying on when the anchor is moved. Add an admin-held, per-asset redemption lead: the gap between the issuer's request cutoff and the payout, plus the time a buyer needs to file (6 days in the scenario above). The schedule then counts as expired from anchor − lead:

  • Inside that window fills are refused rather than mispriced.
  • The keeper can then publish the next payout date in one call. An expired schedule's next date is not shift-bounded, but the cycle-length check still applies.

The diff applies on top of the fix recommended for [M-02], including the cycle-length correction described there:

diff
--- a/src/octarine-rfq/OctarineRFQ.sol
+++ b/src/octarine-rfq/OctarineRFQ.sol
@@ -54,6 +54,10 @@
//////////////////////////////////////////////////////////////*/
mapping(address asset => RedemptionSchedule) private _schedules;
+ /// @notice CYCLICAL: seconds before the anchor (payout) after which a buyer can no longer get into that
+ /// payout's batch — the issuer's request cutoff plus the time to file. Admin-set, so a keeper
+ /// update cannot drop it.
+ mapping(address asset => uint32) public redemptionLead;
mapping(address base => mapping(address quote => IOctarineOracle)) public oracles;
mapping(address base => mapping(address quote => uint64)) public oracleMaxStaleness;
@@ -95,7 +99,7 @@
function secondsToRedemption(address asset) public view returns (uint32) {
RedemptionSchedule memory s = _schedules[asset];
if (!s.configured) revert ScheduleNotConfigured(asset);
- return _secondsFor(s);
+ return _secondsFor(s, redemptionLead[asset]);
}
function scheduleOf(address asset) external view returns (RedemptionSchedule memory) {
@@ -104,16 +108,22 @@
function _optionalSeconds(address asset) internal view returns (uint32) {
RedemptionSchedule memory s = _schedules[asset];
- return s.configured ? _secondsFor(s) : 0;
+ return s.configured ? _secondsFor(s, redemptionLead[asset]) : 0;
}
- function _secondsFor(RedemptionSchedule memory s) internal view returns (uint32) {
+ function setRedemptionLead(address asset, uint32 lead) external onlyRole(DEFAULT_ADMIN_ROLE) {
+ RedemptionSchedule memory s = _schedules[asset];
+ if (s.mode == ScheduleMode.CYCLICAL && s.configured && lead >= s.cycleSeconds) revert InvalidSchedule();
+ redemptionLead[asset] = lead;
+ }
+
+ function _secondsFor(RedemptionSchedule memory s, uint256 lead) internal view returns (uint32) {
if (s.mode == ScheduleMode.ROLLING) return s.rollingSeconds;
// A calendar schedule cannot be extrapolated by a fixed cycle: once the anchor passes, refuse
// to price until the keeper publishes the next redemption date.
uint256 anchor = s.nextRedemptionAt;
- if (block.timestamp >= anchor) revert InvalidSchedule();
+ if (block.timestamp + lead >= anchor) revert InvalidSchedule();
// Exact to the second: the last hours before redemption price as hours, not as a whole day.
uint256 remaining = anchor - block.timestamp;
@@ -126,14 +136,15 @@
bool isAdmin = hasRole(DEFAULT_ADMIN_ROLE, msg.sender);
if (!isAdmin && !hasRole(SCHEDULE_KEEPER_ROLE, msg.sender)) revert Unauthorized();
if (asset == address(0)) revert ZeroAddress();
- _validateSchedule(s);
+ uint256 lead = redemptionLead[asset];
+ _validateSchedule(s, lead);
RedemptionSchedule memory prev = _schedules[asset];
// An expired CYCLICAL schedule has no current horizon: measure no shift, but keep the cycle-length check.
- bool expired = prev.mode == ScheduleMode.CYCLICAL && block.timestamp >= prev.nextRedemptionAt;
+ bool expired = prev.mode == ScheduleMode.CYCLICAL && block.timestamp + lead >= prev.nextRedemptionAt;
if (!isAdmin && prev.configured && maxScheduleShiftSeconds != 0) {
- uint256 after_ = _secondsFor(s);
- uint256 before_ = expired ? after_ : _secondsFor(prev);
+ uint256 after_ = _secondsFor(s, lead);
+ uint256 before_ = expired ? after_ : _secondsFor(prev, lead);
uint256 shift = before_ > after_ ? before_ - after_ : after_ - before_;
uint256 pAmp = prev.mode == ScheduleMode.ROLLING ? before_ : prev.cycleSeconds;
@@ -153,7 +164,7 @@
emit ScheduleUpdated(asset, s.mode, s.rollingSeconds, s.nextRedemptionAt, s.cycleSeconds);
}
- function _validateSchedule(RedemptionSchedule calldata s) internal view {
+ function _validateSchedule(RedemptionSchedule calldata s, uint256 lead) internal view {
if (s.maxBpsPerDay == 0) revert InvalidSchedule();
if (s.mode == ScheduleMode.ROLLING) {
@@ -165,7 +176,8 @@
if (s.cycleSeconds < MIN_HORIZON_SECONDS || s.cycleSeconds > MAX_REDEMPTION_SECONDS) {
revert InvalidSchedule();
}
- if (s.nextRedemptionAt <= block.timestamp) revert InvalidSchedule();
+ if (lead >= s.cycleSeconds) revert InvalidSchedule();
+ if (s.nextRedemptionAt <= block.timestamp + lead) revert InvalidSchedule();
// Until the first anchor passes, the horizon is not yet capped by `cycleSeconds`.
if (s.nextRedemptionAt > block.timestamp + MAX_REDEMPTION_SECONDS) revert InvalidSchedule();
}

The lead is held by the admin, outside the schedule struct, so a routine keeper update cannot drop it.

We checked this with the lead set to 6 days:

  • A fill inside the window is refused with InvalidSchedule.
  • Once the window opens, the keeper publishes the next payout in one call, and a fill is then priced to the next payout at the fair amount.
  • A fill before the window is still priced to the current payout, which the maker makes.

With the [M-02] fix and the lead applied, the client test suite gives the same result as the [M-02] fix alone. With the lead at its default of zero, it adds no failures of its own.

Costs:

  • The lead must be set for each CYCLICAL asset. Until it is, the asset behaves as it does under the [M-02] fix alone.
  • A refusal window every cycle. Fills are refused between the filing deadline and the keeper's next update. That is the same per-date refusal the [M-02] fix already introduces, moved earlier by the lead.
06Section · Remediation

Remediation

Fixed in ad5a529 and bdb4c1e. Each CYCLICAL asset now carries a redemptionLead. Once payout − redemptionLead passes, fills are priced to the next payout. With the lead set to the issuer's cutoff plus a day of filing time, the maker pays the fair 964.50 USDC instead of 994.50.

bdb4c1e adds the follow-up hardening:

  • only the admin sets the lead, and a keeper update that changes it reverts Unauthorized;
  • ScheduleUpdated carries the lead;
  • setSchedule refuses a lead on a ROLLING schedule, an anchor at or below the lead, and a cycleSeconds + redemptionLead above MAX_REDEMPTION_SECONDS.

Two operating points remain, both documented in the README:

  • The lead must be set for every CYCLICAL asset. At zero, the original behaviour returns.
  • The lead's value decides who bears a filing-time error, so set it from the issuer's real cutoff.

A runnable Foundry proof of concept for this finding is in the corresponding issue on the project tracker.

07Section · Report provenance

Report provenance

Source: the final report dated 28 October 2026. Original finding identifiers are retained, including M-03 and M-04, which were regraded Low. Final dispositions and residual conditions are preserved in this write-up. Read the full report.

Status
Fixed
M-05