openlang

Solidity reverts on overflow, Rust release builds wrap

Since Solidity 0.8, arithmetic reverts on overflow and underflow. Turning that off takes an explicit unchecked { ... } block. Rust goes the other way. A debug build panics, but build with --release and the default is two's complement wrapping. You only get checks in release if the Cargo profile sets overflow-checks = true. In C signed overflow is undefined behavior, and the compiler may simply delete a check written after the fact.

For code that moves balances I think Solidity has the right default. A wrapped result gets stored as a wrong balance. A revert rolls back the whole transaction. A failure beats a wrong number that stays forever.

Both have the same hole, though. In Solidity an explicit conversion truncates even in checked mode, so uint128(x) does not revert. Rust's as truncates silently too. A checked addition followed by a narrowing cast is unchecked in practice.

So the rule I've settled on. No unchecked in amount arithmetic. Checks on in the Rust release profile. Amount casts only through try_from. Has anyone kept overflow-checks on in release builds? I'm curious whether the cost is noticeable.

The conversation continues 3

Follow the reasoning
  1. Permalink

    The two languages put the check in different places. In Solidity the syntax sets the default, and the exception shows up in the code as an unchecked block. Rust's overflow-checks is a profile setting, so from the line a + b alone you can't tell whether that operation panics or wraps. The same source becomes a different program depending on build settings.

    For the few lines that move an amount, checked_add beats leaning on the profile. The path where None comes back is visible at the call site, and the behavior stays the same whoever edits the release profile. The extra length is a cost I'd accept for code this narrow. Profile checks can sit underneath as a safety net.

    Casts, as han_seo says, get past either setting. Every as in the amount path should turn up in one search. Count what's left and you can see at once whether the rule holds.

  2. Permalink

    To a Zig user, unchecked reads as an assertion, not a performance switch. It claims nothing in the block can overflow, and without a reason it has the same problem as Zig's catch unreachable. The outcomes differ. In Zig's ReleaseFast an overflow is unchecked illegal behavior, while Solidity's unchecked leaves a wrapped value. Either way the value is wrong.

    Revert isn't automatically safe either. The Solidity security considerations page says that if an overflow can't be avoided, a contract can end up stuck in a certain state. Once an accumulator that only grows hits its limit, every function that adds to it reverts. Handing the failure to the caller is correct. If the caller can do nothing with it, it hasn't been handled.

    So unchecked needs its reason written next to it. A loop counter bounded by an array length is one, and balances rarely come with anything like it.

  3. Permalink

    Two tests for anyone who wants to see the profile line in action, with overflow-checks = true under [profile.release].

    use std::hint::black_box;
    
    #[test]
    #[should_panic(expected = "attempt to add with overflow")]
    fn add_overflows() {
        let x: u64 = black_box(u64::MAX);
        let _ = x + 1;
    }
    
    #[test]
    fn cast_truncates() {
        let x: u64 = black_box(1 << 32);
        assert_eq!(x as u32, 0);
    }

    Without black_box it doesn't build. Two constants, and the arithmetic_overflow lint rejects the addition at compile time. With it, both tests pass in release. Remove the profile line and the first test fails, because nothing panics.

    So the profile line does exactly what alden described, and the cast truncates as the root said. The amount path now uses checked_add and try_from anyway, with the profile line left in as the backstop.

Other sides of the same question

  1. A typed merge helper still walks into `__proto__`

    The settings import uses a small recursive merge. The parameter types look careful, but they only describe what the program e…

  2. Read the axioms of a dependency's theorem before you cite it

    A theorem that compiles is not yet a theorem with a proof. sorry closes any goal, and Lean accepts the file with a warning. I…

    Replies 1lean-4
  3. Testing a Go channel handoff without sleep

    Most handoff tests I run into end with a sleep and a hopeful assertion. This one can do without. A request carries an identif…

    Replies 23go