test(contracts): add overflow regression coverage - #1352
Merged
K1NGD4VID merged 1 commit intoAug 30, 2026
Merged
Conversation
Adds boundary-value regression tests for the five unchecked arithmetic sites the audit identified, and the checked-arithmetic fix they assert against. LabsCrypt#1297 asks for tests that return a typed Err at each site rather than panic. That typed error did not exist: LabsCrypt#1224 ("Functional Edge Case #22") is still open, StreamError had no overflow variant, and all five sites used plain += / * / +. With overflow-checks on in both the dev profile the tests run under and the release profile the WASM ships with, an overflow aborted the whole transaction. The tests cannot pass without the fix, so both land here. Fix (LabsCrypt#1224): - errors.rs: new ArithmeticOverflow = 15. - collect_fee: checked_mul on amount * fee_rate_bps; returns Result, propagated at its four call sites. - top_up_stream: checked_add on deposited_amount. - apply_withdrawal: checked_add on withdrawn_amount; returns Result. - top_up_stream and resume_stream: new project_end_time helper replaces now + (remaining / rate) as u64, which truncated the quotient and then panicked on the addition. Tests (LabsCrypt#1297): - create_stream at i128::MAX with the maximum fee rate (collect_fee). - top_up parked one unit below i128::MAX (deposited_amount). - withdraw where withdrawn + claimable lands exactly on i128::MAX (apply_withdrawal). - top_up and resume with a balance needing more than u64::MAX seconds to drain (both end-time projections). The apply_withdrawal site is asserted at the boundary rather than past it: calculate_claimable clamps to deposited - withdrawn, making withdrawn + claimable <= deposited <= i128::MAX an invariant of every reachable call, so no input overflows it. That test guards the clamp. The other four fail against the unfixed code with Err(Err(Abort)) instead of Err(Ok(ArithmeticOverflow)). README: fills in the StreamError table, which stopped at code 11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1297
Closes #1224
Summary
Adds boundary-value regression tests for all five unchecked arithmetic sites identified by the audit, plus the checked-arithmetic fix they assert against.
Why both are in one PR. #1297 asks for tests that assert a typed
Errat each site. That typed error did not exist onmain: #1224 ("Functional Edge Case #22") is open with no PR,StreamErrorhad no overflow variant, and all five sites still used plain+=/*/+.overflow-checksis on in both the release profile the WASM ships with and the dev profilecargo testuses, so an overflow at any of them aborted the whole transaction instead of returning an error. Tests written to #1297's spec cannot pass without #1224, and weakening them to#[should_panic]would defeat the point of the issue, so the fix lands here too.The five sites
Issue #1224 cites
lib.rs:309, 317, 425, 647, 707; the file has grown since, so these are the current locations onmain:collect_feeamount * (cfg.fee_rate_bps as i128)checked_multop_up_streamstream.deposited_amount += net_amountchecked_addapply_withdrawalstream.withdrawn_amount += amountchecked_addtop_up_streamnow + (remaining / rate) as u64project_end_timeresume_streamnow + (remaining / rate) as u64project_end_timeProduction changes
errors.rs: newArithmeticOverflow = 15. No new error type was invented for the tests; this is [Audit] Unchecked arithmetic in fee/top-up/withdrawal paths panics instead of returning a typed error #1224's canonical overflow error and every test uses it.collect_feenow returnsResult<i128, StreamError>; the?is propagated at its four call sites (create_stream,batch_create_streams,create_stream_with_cliff,top_up_stream). Theamount - feesubtraction is left as-is:fee_rate_bpsis capped atMAX_FEE_RATE_BPS(10%), sofeeis always well belowamount.apply_withdrawalnow returnsResult<(), StreamError>. The checked add is the first statement, so an overflow returns before any state mutation, storage write or transfer — the CEI ordering is unchanged.project_end_time(now, remaining, rate_per_second). The old expression truncated the quotient withas u64and then panicked on the addition; the helper rejects a quotient that does not fit inu64viau64::try_fromand useschecked_addfor the timestamp.ArithmeticOverflowunder# Errors.Tests
test_create_stream_rejects_fee_multiplication_overflowamount = i128::MAXatMAX_FEE_RATE_BPStest_top_up_rejects_deposited_amount_overflowdeposited_amount = i128::MAX - 1test_withdraw_at_i128_max_withdrawn_boundary_does_not_overflowwithdrawn + claimable == i128::MAXtest_top_up_rejects_end_time_projection_overflow> u64::MAXsecondstest_resume_rejects_end_time_projection_overflow> u64::MAXsecondsThe boundary values differ per site, matching what each operation actually needs to overflow:
i128::MAXfor the fee multiplication and the deposited total, and a remaining balance of3 * 2^64 - 101at one unit per second for the twou64timestamp projections. That constant is chosen so the pre-fixas u64truncation yields2^64 - 101, which then overflowsnow + …for anynow > 100.Tests follow the existing conventions:
client.try_*asserted againstErr(Ok(StreamError::…)), and aforce_streamhelper that parks a stream one step below the ceiling viaenv.as_contract+ persistent storage — the same techniquetest_claimable_max_i128_rate_overflowandtest_calculate_claimable_underflow_returns_zeroalready use.One honest deviation on site 3
apply_withdrawal'swithdrawn_amount +=cannot overflow.calculate_claimableclamps its result todeposited_amount - withdrawn_amount, which makeswithdrawn + claimable <= deposited <= i128::MAXan invariant of every reachable call. Forced state does not open a path either:remainingonly reachesi128::MAXwhenwithdrawnis negative, and then the sum is bounded from above anyway. The audit flagged the site as unchecked, which is correct, but not as reachable.Rather than fabricate an
Errfor it, its test pins the exact state where the sum lands oni128::MAXand asserts the withdrawal succeeds and completes the stream. If a future change to that clamp does let this site overflow, the test fails. The site is still converted tochecked_add, since #1224 names it.So: four of the five tests fail against the unfixed code, one passes either way and guards the invariant. Stated plainly rather than claimed as five.
Testing
All commands run in a
rust:1.85container againstcontracts/(the local Windows toolchain cannot build — Application Control blocks cargo from executing build scripts, os error 4551).Baseline for comparison:
cargo teston this branch with only the production fix and no new tests was 118 passed, 0 failed, so the fix breaks nothing and the five new tests take it to 123.Proof the tests catch the bug
With
contracts/stream_contract/src/lib.rsreverted tomainand the new tests left in place:The failure mode is exactly what the audit describes — the overflow aborts the transaction instead of returning an error:
Scope
Production contract logic was changed — this PR implements #1224. That is a deliberate deviation from #1297 being test-only, because #1297's acceptance criteria are unreachable without it.
Changes are confined to
contracts/stream_contract/:errors.rs(+5),lib.rs(+78/-15),test.rs(+205),README.md(+4). No other audit issue was fixed.The README edit fills in the
StreamErrorcode table, which stopped at 11 and was already missing 12–14; adding only row 15 would have left a visible gap in a table this PR has to touch anyway.Pre-existing issue found, not fixed
transfer_recipientatlib.rs:456has a sixth uncheckedstream.withdrawn_amount += settled_amountof the same class. It is not among #1224's five — it arrived later, in PR #1343. Like site 3 it is bounded by thecalculate_claimableclamp and cannot overflow, so it is reported here rather than changed.Acceptance criteria
i128::MAXfor the amount sites, a> u64::MAXdrain time for the timestamp sites) rather than one constant everywhereErr(ArithmeticOverflow), not a panic — with the documented exception of site 3, which asserts the boundary succeeds because overflow there is unreachabletest_claimable_max_i128_rate_overflow,test_fuzz_large_amount_no_overflow,test_fuzz_claimable_overflow_and_cancel_invariantsall green and unmodifiedcargo fmt,cargo clippy -D warnings, and the release WASM build all pass🤖 Generated with Claude Code