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-02·

CYCLICAL schedules roll by a fixed cycle, so after a missed keeper update fills near each month-end are charged the wrong side of the redemption date

Mitigatedrwarfqoracles
TL;DR

CYCLICAL is documented as a calendar schedule: "redemption lands on a calendar date" (IOctarineRFQ.sol L41-L42).

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

Affected files

src/octarine-rfq/OctarineRFQ.sol#L110-L124

03Section · Description

Description

CYCLICAL is documented as a calendar schedule: "redemption lands on a calendar date" (IOctarineRFQ.sol L41-L42). The README gives "Fixed calendar date (e.g. the 30th)", and in practice that means month-ends. Once the anchor passes, _secondsFor rolls it forward by a fixed cycleSeconds (L113-L118):

solidity
uint256 cycle = s.cycleSeconds;
uint256 anchor = s.nextRedemptionAt;
if (block.timestamp >= anchor) {
uint256 periods = ((block.timestamp - anchor) / cycle) + 1;
anchor += periods * cycle;
}

Calendar months are 28 to 31 days long, so no fixed cycleSeconds lands on them. The interface treats the roll as a safe fallback: "a missed bot update degrades to a stale-but-sane schedule rather than a broken one" (IOctarineRFQ.sol L200-L202). It is not sane near a redemption date. The horizon is a sawtooth that drops to one second and jumps back to a full cycle at the anchor, so an anchor one day early turns a one-day date error into a horizon error of almost a full cycle:

  • Rolled anchor early (cycle shorter than the month): between the rolled anchor and the real date, the horizon has already wrapped to about cycleSeconds while redemption is hours away. The taker is charged a month of discount for a wait of hours.
  • Rolled anchor late (cycle longer than the month): between the real date and the rolled anchor, the horizon is near zero while the real wait is a whole new cycle. The maker pays almost no discount for a month's wait.

Neither side's signed bound helps. minReceivedAmount and maxMakerAmount are set from quoteRFQOrder, which returns the same wrong horizon.

Vulnerable Scenario:

  1. An asset redeems on the last day of each month. The keeper sets nextRedemptionAt = 31 Oct 2026 with cycleSeconds = 30 days and then misses its next update.
  2. The roll gives 30 Nov (correct), then 30 Dec, one day before the real 31 Dec.
  3. At 30 Dec 12:00 UTC redemption is 12 hours away, but secondsToRedemption returns 29.5 days.
  4. A taker sells 10,000 RWA at 10.00 bps/day, signing a floor 0.5% under the contract's quote. The fill charges 29.5 days of discount.
04Section · Impact

Impact

Inside each mismatch window, every fill prices the wrong side of the redemption date. With a 30-day cycle, after one missed keeper update the windows in the next five months are 30–31 Dec, 29–31 Jan and 30–31 Mar. The loss per fill is up to makerBpsPerDay × cycle: the taker overpays the discount, or the maker underpays it. In the PoC a taker selling 10,000 RWA at 10.00 bps/day receives 9,705 USDC instead of the 9,995 USDC the same rate gives over the real 12-hour horizon, a loss of 290 USDC (2.9%). The windows fall on predictable dates, so a maker can bid into them deliberately.

05Section · Recommendations

Recommendations

Do not extrapolate a calendar schedule by a fixed cycle. Once the anchor passes, refuse to price until the keeper publishes the next redemption date, and let the keeper publish it after the anchor has passed:

diff
@@ -110,12 +110,10 @@ contract OctarineRFQ is IOctarineRFQ, AccessControl, ReentrancyGuard, Pausable,
function _secondsFor(RedemptionSchedule memory s) internal view returns (uint32) {
if (s.mode == ScheduleMode.ROLLING) return s.rollingSeconds;
- uint256 cycle = s.cycleSeconds;
+ // 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) {
- uint256 periods = ((block.timestamp - anchor) / cycle) + 1;
- anchor += periods * cycle;
- }
+ if (block.timestamp >= 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;
@@ -131,9 +129,11 @@ contract OctarineRFQ is IOctarineRFQ, AccessControl, ReentrancyGuard, Pausable,
_validateSchedule(s);
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;
if (!isAdmin && prev.configured && maxScheduleShiftSeconds != 0) {
- uint256 before_ = _secondsFor(prev);
uint256 after_ = _secondsFor(s);
+ uint256 before_ = expired ? after_ : _secondsFor(prev);
uint256 shift = before_ > after_ ? before_ - after_ : after_ - before_;
uint256 pAmp = prev.mode == ScheduleMode.ROLLING ? before_ : prev.cycleSeconds;
@@ -165,7 +165,7 @@ contract OctarineRFQ is IOctarineRFQ, AccessControl, ReentrancyGuard, Pausable,
if (s.cycleSeconds < MIN_HORIZON_SECONDS || s.cycleSeconds > MAX_REDEMPTION_SECONDS) {
revert InvalidSchedule();
}
- if (s.nextRedemptionAt == 0) revert InvalidSchedule();
+ if (s.nextRedemptionAt <= block.timestamp) 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();
}
  1. _secondsFor reverts once a CYCLICAL anchor has passed, so fills are refused rather than mispriced.
  2. setSchedule no longer reads the expired schedule's horizon. Without this, the bound itself calls _secondsFor(prev), which now reverts, and the keeper could not publish the next date at all; only the admin could. An expired schedule has no current horizon to measure a shift from, so the shift is measured from the new date itself and comes to zero. The cycle-length check still applies, so a keeper cannot switch mode or change the cycle length beyond the bound in the same call.
  3. _validateSchedule requires nextRedemptionAt to be in the future, since a past anchor would only make pricing revert.

With the fix, a late keeper publishes the real next date and fills resume on the true horizon. Recovery also works after several missed months, and ROLLING schedules are unaffected. All of these were checked by test.

Costs:

  • Liveness now depends on the keeper after each redemption date. A missed update pauses fills for that asset instead of mispricing them. The keeper already has to update after every month whose length differs from cycleSeconds for the schedule to be correct at all.
  • Fills revert at every redemption date until the keeper publishes the next one. While the current date is live the keeper cannot publish the next: an update an hour early reverts ScheduleShiftTooLarge. So even a punctual keeper leaves a window at each date, including on pairs where the asset is the maker's leg. Queuing the next date in advance (below) is the only full remedy.
  • The keeper's first date after an expiry is not shift-bounded. Only its cycle length is.
  • cycleSeconds no longer affects pricing; it only sizes the keeper's cycle-length check.
  • Tests that need updating. With the fix, 11 tests fail, all asserting the automatic roll. Four are in test/unit/OctarineRFQSchedule.t.sol: test_cyclical_wrapsBackToCycleLength, test_cyclical_subDayCycle, test_cyclical_autoRollsAcrossManyMissedCycles and testFuzz_cyclicalNeverExceedsCycle. Seven are in test/audit/ScheduleAmountVerification.t.sol: test_cyclical_autoRollAcrossMissedCyclesPricesIdentically, test_cyclical_fractionalDayCycle, test_cyclical_quoteLegWrappingCollapsesTheDiscount, test_cyclical_subDayCycle, test_cyclical_wrapAtTheAnchorIsExactToTheSecond, testFuzz_cyclicalAmountTracksTheRemainingSeconds, and test_deadBand_cyclicalOnlyBitesAfterTheWrap (which now hits InvalidSchedule() before the DiscountExceedsPrincipal it expects). Every other test passes (695 passed, 1 skipped). They need the same update: publish the next date instead of relying on the roll.

If some assets genuinely redeem on a fixed-length cycle, keep the roll for those behind an explicit per-schedule opt-in rather than for every CYCLICAL schedule. Alternatively, the keeper can publish the next several redemption dates at once, so that one missed update does not pause fills.

06Section · Remediation

Remediation

Mitigated in 8ba441e and 15bbb56. The roll is unchanged; the fix is operational.

  • The keeper bound now defaults to 7 days. That admits the 31 → 28 → 31 day changes the 2-day bound refused.
  • The README now has a keeper runbook. Every update republishes the anchor together with the coming cycle's length. It runs early in each cycle, before payout − redemptionLead. A missed or reverted update raises an alert, and the admin path is kept ready.

We followed that runbook over a year of month-end payouts: every update was accepted and the horizon was exact. One missed early-cycle update costs at most the month-length difference, 10 to 30 USDC per 10,000 RWA at 10 bps/day. A keeper that runs close to the payout and misses once still causes the full loss (290 USDC per 10,000 RWA), which only the admin can then repair.

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
Mitigated
M-02