Skip to content

fix: enforce the DecimalsTooLarge bound inside LibFtsoCurrentPriceUsd - #137

Open
thedavidmeister wants to merge 11 commits into
mainfrom
fix/issue-78-79-80-guards-reverts
Open

fix: enforce the DecimalsTooLarge bound inside LibFtsoCurrentPriceUsd#137
thedavidmeister wants to merge 11 commits into
mainfrom
fix/issue-78-79-80-guards-reverts

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Moves the DecimalsTooLarge guard from LibOpFtsoCurrentPriceUsd.run (the caller) into LibFtsoCurrentPriceUsd.ftsoCurrentPriceUsd (the library that actually reads the FTSO), and covers it with a test that calls the library directly.

The FTSO is untrusted. Bounding its reported decimals at one call site by authorship accident means any future direct caller of the library silently downcasts an oversized decimals from a malicious or buggy FTSO and mis-scales the price by orders of magnitude. The bound belongs at the trust boundary, so the caller's redundant check is replaced by a comment recording that the library guarantees it.

NatSpec is corrected on both sides of the move: the library now documents that it reverts DecimalsTooLarge and that the returned decimals is always <= type(uint8).max; the op documents DecimalsTooLarge as propagated rather than raised locally.

src/generated/FlareFtsoWords.pointers.sol is regenerated — this changes bytecode.

Scope changed after merge-update

This branch was 16 commits behind main. Two of the three fixes it originally carried landed independently while it sat:

Closes #79

QA

  • Discriminating tests (all in the new test/src/lib/price/LibFtsoCurrentPriceUsd.t.sol, all calling the library directly via an external wrapper so vm.expectRevert monitors the right call frame, all with abi.encodeWithSelector(...) expectations — never bare vm.expectRevert()):

    • testFtsoCurrentPriceUsdDecimalsTooLargeReverts — fuzzed decimals ∈ [256, int32.max] must revert DecimalsTooLarge(decimals). Fails on base: on main the library has no guard at all, so the call returns normally. Verified by mutation M1, which reproduces exactly the pre-PR library.
    • testFtsoCurrentPriceUsdDecimalsBoundaryOverMaxRevertsdecimals == type(uint8).max + 1 exactly must revert DecimalsTooLarge(256). Fails on base (M1).
    • testFtsoCurrentPriceUsdDecimalsBoundaryMaxAccepteddecimals == type(uint8).max exactly must be accepted and returned verbatim. Pins the comparison against being tightened to >= (M2).
    • testFtsoCurrentPriceUsdStaleTakesPrecedenceOverDecimals — a stale price that also reports oversized decimals must report StalePrice, not DecimalsTooLarge. Pins the guard's position after the staleness check so it cannot mask an earlier failure (M3).
    • testFtsoCurrentPriceUsdHappy — fuzzed in-bound case: (price, decimals) returned unchanged. The library bounds, it never rescales (M2, M5).
    • The contract extends FtsoTest, so the inherited registry-failure tests (testRunNoRegistry, testRunRegistryNoFtsoRegistry, testRunInvalidFtso) now also run against the library directly.
  • Mutations applied to src/lib/price/LibFtsoCurrentPriceUsd.sol, each compiled and run against LibFtsoCurrentPriceUsdTest + LibOpFtsoCurrentPriceUsdTest + LibOpFtsoCurrentPricePairTest, then reverted. Baseline verified green immediately before the first mutation and again after the last restore (rc=0, 0 failures both times). No mutant survived:

    # Mutation Killed by
    M1 delete the guard (= the pre-PR library, the state on main) testFtsoCurrentPriceUsdDecimalsTooLargeReverts, testFtsoCurrentPriceUsdDecimalsBoundaryOverMaxReverts, testRunDecimalOverflow, testRunPairDecimalsTooLargeFirstLeg, testRunPairDecimalsTooLargeSecondLeg
    M2 decimals > type(uint8).max>= testFtsoCurrentPriceUsdDecimalsBoundaryMaxAccepted, testFtsoCurrentPriceUsdHappy, testRunDecimalsBoundary, testRunHappy, testRunHappyTrustedAddresses, testRunFtsoNotActiveA
    M3 hoist the guard above the staleness check testFtsoCurrentPriceUsdStaleTakesPrecedenceOverDecimals, testRunStale
    M4 revert DecimalsTooLarge(decimals)DecimalsTooLarge(0) testFtsoCurrentPriceUsdDecimalsTooLargeReverts, testFtsoCurrentPriceUsdDecimalsBoundaryOverMaxReverts, testRunDecimalOverflow, testRunPairDecimalsTooLargeFirstLeg, testRunPairDecimalsTooLargeSecondLeg
    M5 return (price, decimals)return (price, 0) testFtsoCurrentPriceUsdDecimalsBoundaryMaxAccepted, testFtsoCurrentPriceUsdHappy, testRunDecimalsBoundary, testRunHappy, testRunHappyTrustedAddresses, testRunStaleBoundaryNotStale, testRunPairDerivationExact, testRunPairZeroQuoteReverts
  • Oracle: type(uint8).max is the largest value that survives the uint8(decimals) downcast at the only consumer of this return value (LibDecimalFloat.fromFixedDecimalLosslessPacked(price, uint8(decimals))), so 255 must pass and 256 must revert — derived from the downcast width, not from reading the guard. The FTSO-side values (price, timestamp, decimals) are supplied by vm.mockCall, so the expected outputs are fixed by the test, not by the implementation. The two boundary tests are hardcoded, not fuzzed, so they land on the boundary every run.

  • Category check: [F28] [LOW] Library returns un-bounded decimals; DecimalsTooLarge guard is the caller's responsibility and undocumented #79 asks for the DecimalsTooLarge bound to be enforced at the library trust boundary rather than left to callers (its option (a)), and separately notes the risk is undocumented. Both are covered: the guard moved, and the NatSpec on the library now states the revert and the <= type(uint8).max postcondition. Option (b) (document-only) was not taken; (a) is what the issue prefers for an oracle library. The caller path is unchanged in behavior — testRunDecimalOverflow, testRunDecimalsBoundary and the pair op's testRunPairDecimalsTooLarge* still pass, now via propagation.

  • Pointers: src/generated/FlareFtsoWords.pointers.sol was regenerated, not text-merged. Ran nix develop -c rain-flare-prelude (./script/build.sh, the same recipe rainix-copy-artifacts runs) after merging main; it produced zero diff against the committed file. To prove that is a real check and not a silent no-op, BYTECODE_HASH was deliberately corrupted and forge script ./script/Build.sol re-run — it restored the committed value byte-for-byte.

  • Toolchain: everything run through the flake (nix develop … -c), never bare PATH; rc=$? captured directly from each command. forge fmt --check rc=0; full forge test (including fork and prod suites) 93 passed / 0 failed, rc=0.

Co-Authored-By: Claude noreply@anthropic.com

Summary by CodeRabbit

  • Bug Fixes

    • Added validation for FTSO price decimal values that exceed the supported range.
    • Preserved clear error reporting when invalid decimal values are returned.
    • Ensured stale-price errors take precedence over decimal-range errors.
    • Clarified that zero sFLR rates are rejected.
  • Tests

    • Added coverage for valid values, maximum boundaries, oversized decimals, and stale-price handling.

…tOfFunds revert

Closes #78
Closes #79
Closes #80

Co-Authored-By: Claude <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this Jun 16, 2026
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 06320458-d42e-439e-8f3c-c2acbf071b19

📥 Commits

Reviewing files that changed from the base of the PR and between cf16723 and 44ed38b.

⛔ Files ignored due to path filters (1)
  • src/generated/FlareFtsoWords.pointers.sol is excluded by !**/generated/**
📒 Files selected for processing (4)
  • src/lib/op/LibOpFtsoCurrentPriceUsd.sol
  • src/lib/price/LibFtsoCurrentPriceUsd.sol
  • src/lib/sflr/LibSceptreStakedFlare.sol
  • test/src/lib/price/LibFtsoCurrentPriceUsd.t.sol

Walkthrough

The FTSO price library now rejects decimals above uint8 capacity, allowing the opcode to rely on that validation before downcasting. Tests cover normal, boundary, oversized, and stale-price cases. sFLR rate documentation now records the zero-rate revert.

Changes

Oracle Validation Updates

Layer / File(s) Summary
FTSO decimals trust boundary and opcode integration
src/lib/price/LibFtsoCurrentPriceUsd.sol, src/lib/op/LibOpFtsoCurrentPriceUsd.sol
LibFtsoCurrentPriceUsd reverts with DecimalsTooLarge for oversized decimals, while the opcode removes its duplicate check and relies on the library guarantee.
FTSO decimals validation tests
test/src/lib/price/LibFtsoCurrentPriceUsd.t.sol
Adds wrappers, FTSO mocks, and coverage for successful reads, uint8 boundaries, oversized decimals, and stale-price precedence.
sFLR rate revert documentation
src/lib/sflr/LibSceptreStakedFlare.sol
Documents the ZeroSFLRRate behavior for zero sFLR rates.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: moving DecimalsTooLarge enforcement into LibFtsoCurrentPriceUsd.
Linked Issues check ✅ Passed The PR satisfies #79 by moving the decimal bound into LibFtsoCurrentPriceUsd, and the context says #78/#80 were already closed elsewhere.
Out of Scope Changes check ✅ Passed The edits stay within the oracle-boundary fix, related docs, and focused tests; no unrelated changes are evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-78-79-80-guards-reverts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

thedavidmeister and others added 4 commits June 16, 2026 22:22
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update BYTECODE_HASH after ZeroSFLRRate guard changes compiled bytecode.
BuildPointers.sol produces 0x20a972f9f2c50d92ba72839450586e0bcdfdcaf8400f2f987c3bf9783075b7fc.

Co-Authored-By: Claude <noreply@anthropic.com>
Use vm.envString("FLARE_RPC_URL") — CI maps the secret to FLARE_RPC_URL,
not RPC_URL_FLARE_FORK; the old envOr fell back to Ankr causing rate
limit failures on fork tests.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@thedavidmeister

Copy link
Copy Markdown
Collaborator Author

Rework note (human reject, 2026-07-04): all three fixes are substantively right (bytes("") OutOfFunds pin per the audit rule; DecimalsTooLarge relocated into the library; ZeroSFLRRate fail-loud guard) but the PR cannot land as-is: (1) it ADDS an @custom:error ZeroSFLRRate tag — banned org-wide by the no-custom-natspec static gate (rainlanguage/rainix#254) merged today; its green checks predate the gate (June 24 head) and the next CI run reds. Convert to @dev prose per the #197/#198 pattern. (2) The whole head is stale vs a main that moved ~11 times today: merge current main, regen pointers (BYTECODE_HASH must stay consistent with #191's fresh-deploy pin and #173's length pins), drop the now-redundant LibFork hunk (byte-identical to #151's landed change), and get FRESH CI. Then Closes #78 #79 #80 all hold.

@thedavidmeister thedavidmeister added the human:needs-work Human reviewer: needs rework label Jul 6, 2026
thedavidmeister and others added 3 commits July 10, 2026 01:20
Clean merge of origin/main; pointer/meta regen against the merged
source produced no drift (committed artifacts already consistent).
The LibFork hunk from this branch is byte-identical to the change
landed via #151 and dissolves into main.

Co-Authored-By: Claude <noreply@anthropic.com>
The @Custom:error NatSpec tag is banned org-wide by the
no-custom-natspec static gate; document the revert condition as
prose instead.

Co-Authored-By: Claude <noreply@anthropic.com>
The earlier merge in this branch used a stale single-branch fetch of
main; this merges the current main tip. The generated pointers and
meta artifacts are resolved by regenerating against the merged source
via script/build.sh (BuildAuthoringMeta + rain meta build +
BuildPointers, run to convergence).

Co-Authored-By: Claude <noreply@anthropic.com>
Union-preserving resolution of ErrFtso.sol comment wording, equivalent
test spellings in LibFtsoV2LTS.t.sol and LibSceptreStakedFlare.t.sol
(both sides implement identical coverage; main's stricter mock kept),
and regenerated src/generated/FlareFtsoWords.pointers.sol via
rain-flare-prelude to its fixed point on the merged tree.

Co-Authored-By: Claude <noreply@anthropic.com>
@thedavidmeister thedavidmeister added ai:ready AI vetter: passes review, ready for human decision and removed human:needs-work Human reviewer: needs rework labels Jul 19, 2026
@thedavidmeister

Copy link
Copy Markdown
Collaborator Author

🤖 ai:vetter
Reviewed e6105ec: ready — closes #79 (live; #78 #80 already closed by landed PRs) — DecimalsTooLarge guard moved to the library trust boundary per the issue's preferred option, fail-closed, pinned by existing specific-revert tests through both op paths; pointers regenerated; human rework-note conditions (prose docs, merge-update, fresh CI) all met
cost 370 — oracle guard relocation, pinned by tests

claude added 2 commits July 28, 2026 08:28
… bound

Adds test/src/lib/price/LibFtsoCurrentPriceUsd.t.sol, which calls the library
directly rather than through LibOpFtsoCurrentPriceUsd, so the bound is asserted
at the trust boundary that now enforces it:

- happy path returns (price, decimals) unchanged for decimals <= uint8 max
- decimals > uint8 max reverts DecimalsTooLarge(decimals)
- decimals == type(uint8).max is accepted
- decimals == type(uint8).max + 1 reverts
- a stale price with oversized decimals still reports StalePrice

Updates the NatSpec on both sides of the move: the library now documents that
it reverts DecimalsTooLarge and that the returned decimals are bounded, and the
op documents DecimalsTooLarge as propagated rather than raised locally.

Co-Authored-By: Claude <noreply@anthropic.com>
@thedavidmeister thedavidmeister changed the title fix: add ZeroSFLRRate guard, move DecimalsTooLarge to library, pin OutOfFunds revert fix: enforce the DecimalsTooLarge bound inside LibFtsoCurrentPriceUsd Jul 28, 2026
@thedavidmeister

Copy link
Copy Markdown
Collaborator Author

CI state at head 44ed38ba7d086501df1304c54e39b78caf8eadc9:

check result
copy-artifacts / copy-artifacts pass — the generated-artifact currency gate; confirms src/generated/FlareFtsoWords.pointers.sol matches a clean rebuild
rainix-sol / static / static pass (slither, forge fmt --check, no-custom-natspec, single-contract)
rainix-sol / legal / legal pass
CodeRabbit pass — "No actionable comments were generated in the recent review", ASSERTIVE profile, reviewed through 44ed38b. Not rate-limited, not queued.
rainix-sol / test / test fail — Flare fork RPC outage, not this diff (below)

The test job's 19 failures are all the same non-code fault: the fork provider behind FLARE_RPC_URL returned HTTP 500 {"message":"Temporary internal error. Please retry","code":19} on eth_getStorageAt / eth_getAccount, so every fork-backed test aborted with EVM error; database error. Zero failures have any other cause — grep '\[FAIL' | grep -v 'Temporary internal error' over the job log is empty. main's own latest rainix-sol run (30342068663, ~20 min earlier) is red with the identical signature and zero non-RPC failures, so this predates and is independent of this branch.

Every non-fork test passed, including all 8 in the new suite:

Ran 8 tests for test/src/lib/price/LibFtsoCurrentPriceUsd.t.sol:LibFtsoCurrentPriceUsdTest
[PASS] testFtsoCurrentPriceUsdDecimalsBoundaryMaxAccepted()
[PASS] testFtsoCurrentPriceUsdDecimalsBoundaryOverMaxReverts()
[PASS] testFtsoCurrentPriceUsdDecimalsTooLargeReverts(...)
[PASS] testFtsoCurrentPriceUsdHappy(...)
[PASS] testFtsoCurrentPriceUsdStaleTakesPrecedenceOverDecimals()
[PASS] testRunInvalidFtso(...) testRunNoRegistry(...) testRunRegistryNoFtsoRegistry(...)
Suite result: ok. 8 passed; 0 failed; 0 skipped

Locally, against a working Flare RPC, the full suite including the fork and prod tests is 93 passed / 0 failed (rc=0), and forge fmt --check is rc=0 — both through nix develop.

No rerun was triggered and no retrigger commit was pushed; the red needs the provider to recover, not a code change.

This PR changes bytecode (BYTECODE_HASH, opcode/integrity/operand pointers). Nothing has been deployed for this head — deploy-before-merge still applies.

@thedavidmeister

Copy link
Copy Markdown
Collaborator Author

🤖 ai:vetter
Reviewed 44ed38b: ready — re-vet at 44ed38b after merge-update scope cut: closes #79 only (#78/#80 landed independently) — DecimalsTooLarge guard moved into LibFtsoCurrentPriceUsd at the trust boundary per the issue's option (a), NatSpec documents revert + <=uint8.max postcondition, pair op propagates via the USD op, direct-library boundary tests (255 pass / 256 revert) with specific selectors, pointers regenerated
cost 365 — oracle trust-boundary guard relocation

@thedavidmeister thedavidmeister added ai:blocked-on AI producer: blocked on a dependency PR and removed ai:ready AI vetter: passes review, ready for human decision labels Jul 28, 2026
@thedavidmeister

Copy link
Copy Markdown
Collaborator Author

🤖 ai:producer
Blocked-on: Blocked on rainlanguage/rainix#289 (fork-RPC failover preflight), which is now ai:design pending a human ruling. A 3b fix attempt was already pushed to this branch and the check is still red, so this is a hand-off rather than another push. Today's failures are all upstream RPC faults, not this diff: EVM error; database error: failed to get account/storage with HTTP error 500, Temporary internal error. Please retry, code 19, against the shared Flare endpoint -- e.g. testFlareFtsoWordsFtsoCurrentExchangeRateHappyFork and testRunCurrentPricePairForkHappy. The archive-aware, 3-consecutive-pass preflight in rainix#289 is what routes around an unhealthy endpoint. Goes green once that lands on rainix main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai:blocked-on AI producer: blocked on a dependency PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[F28] [LOW] Library returns un-bounded decimals; DecimalsTooLarge guard is the caller's responsibility and undocumented

2 participants