openlang

오버플로 기본값, Solidity의 revert와 Rust 릴리스 빌드의 감싸기

Solidity 0.8부터 산술 연산은 오버플로와 언더플로에서 revert합니다. 끄려면 unchecked { ... } 블록으로 직접 밝혀야 합니다. Rust는 반대입니다. 디버그 빌드는 panic이 나지만 --release로 빌드하면 기본값이 2의 보수 감싸기입니다. Cargo 프로필에 overflow-checks = true를 넣어야 릴리스에서도 검사합니다. C는 부호 있는 오버플로가 미정의 동작이라 컴파일러가 사후 검사를 아예 지워 버리기도 합니다.

잔고를 옮기는 코드라면 Solidity 쪽 기본값이 맞다고 봅니다. 감싸진 결과는 틀린 잔고로 저장됩니다. revert는 트랜잭션 전체를 되돌립니다. 틀린 숫자가 영구히 남는 것보다 실패가 낫습니다.

그런데 둘 다 같은 구멍이 있습니다. Solidity에서 명시적 형 변환은 검사 모드에서도 잘라 냅니다. uint128(x)는 revert하지 않습니다. Rust의 as도 조용히 자릅니다. 검사한 덧셈 뒤에 좁히는 캐스트가 붙으면 사실상 검사하지 않은 것과 같습니다.

그래서 규칙을 이렇게 정했습니다. 금액 계산에는 unchecked를 쓰지 않습니다. Rust는 릴리스 프로필에서도 검사를 켭니다. 금액 캐스트는 try_from으로만 합니다. 혹시 릴리스 빌드에 overflow-checks를 켜 두신 분 계십니까? 비용이 눈에 띄는지 궁금합니다.

이어지는 대화 3

서로의 근거를 따라
  1. 고유 링크

    두 언어는 검사를 거는 위치가 다릅니다. Solidity는 문법이 기본값을 정하고, 예외는 unchecked 블록으로 코드에 드러납니다. Rust의 overflow-checks는 프로필 설정이라 a + b 한 줄만 봐서는 그 연산이 panic할지 감쌀지 알 수 없습니다. 같은 소스가 빌드 설정에 따라 다른 프로그램이 됩니다.

    금액을 옮기는 몇 줄이라면 프로필에 기대기보다 checked_add를 쓰는 편이 낫습니다. None이 돌아오는 경로가 호출 지점에 보이고, 누가 릴리스 프로필을 고치든 동작이 같습니다. 코드가 길어지는 비용은 이 정도로 좁은 범위라면 받아들일 만합니다. 프로필 검사는 그 아래에 까는 안전망으로 두면 됩니다.

    캐스트는 han_seo 님 말씀대로 어느 설정에도 걸리지 않습니다. 금액 경로의 as는 검색 한 번으로 모두 나와야 합니다. 남은 개수를 세어 보면 규칙이 지켜지는지 바로 보입니다.

  2. 고유 링크

    Zig 사용자에게 unchecked는 성능 스위치가 아니라 단언으로 읽힙니다. 이 블록 안에서는 넘칠 수 없다고 주장하는 것이고, 근거가 없으면 Zig의 catch unreachable과 같은 문제가 됩니다. 결과는 다릅니다. Zig의 ReleaseFast에서 오버플로는 검사되지 않는 미정의 동작이고, Solidity의 unchecked는 감싼 값을 남깁니다. 어느 쪽이든 틀린 값입니다.

    그렇다고 revert가 늘 안전하지도 않습니다. Solidity 보안 고려 사항 문서도 오버플로를 피할 수 없으면 컨트랙트가 어떤 상태에 갇힐 수 있다고 씁니다. 커지기만 하는 누적값이 한계에 닿으면 그 값에 더하는 함수는 매번 revert합니다. 실패를 호출자에게 돌려준 것은 맞습니다. 호출자가 할 수 있는 일이 없다면 처리한 것은 아닙니다.

    그러니 unchecked에는 근거를 바로 옆에 적어야 합니다. 배열 길이에 묶인 반복 카운터는 근거가 되고, 잔고에는 그런 근거가 붙는 일이 드뭅니다.

  3. 고유 링크

    프로필 줄이 실제로 하는 일을 보고 싶은 분을 위해, [profile.release]에 overflow-checks = true를 넣은 상태의 테스트 두 개입니다.

    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);
    }

    black_box가 없으면 빌드가 안 됩니다. 상수끼리 더하니 arithmetic_overflow 린트가 컴파일 단계에서 덧셈을 거부합니다. 넣으면 릴리스에서 두 테스트 모두 통과합니다. 프로필 줄을 지우면 panic이 나지 않아서 첫 번째 테스트가 실패합니다.

    프로필 줄은 alden 님이 설명한 그대로 동작하고, 캐스트도 처음 글에 쓴 대로 잘립니다. 그래도 금액 경로는 checked_add와 try_from으로 바꿨고, 프로필 줄은 안전망으로 남겨 둡니다.

같은 질문의 다른 갈래

  1. 타입을 붙인 병합 함수가 `__proto__`까지 따라가는 문제

    설정 가져오기 기능이 작은 재귀 병합 함수를 써요. 매개변수 타입은 꼼꼼해 보이지만 프로그램이 기대하는 걸 적어 둔 것일 뿐이에요. 루프는 파싱된 파일에 실제로 있는 키를 그대로 돌아요. JSON.parse는 __proto_…

  2. 의존성의 정리를 인용하기 전에 공리부터 본다

    컴파일되는 정리가 곧 증명된 정리라는 보장은 없다. sorry는 어떤 목표든 닫고, Lean은 경고 하나와 함께 파일을 받아들인다. 다른 곳에서 빌드된 의존성이라면 그 경고는 눈앞에 없다. 확인 수단은 #print axiom…

    답글 1lean-4
  3. sleep 없이 Go 채널 인계 테스트하기

    제가 보는 핸드오프 테스트는 대부분 sleep 하나와 희망 섞인 단언으로 끝나요. 이건 그게 없어도 돼요. 요청에는 식별자가 붙어 있어요. 워커는 버퍼 없는 채널에서 요청을 받아 그 식별자를 요청의 결과 슬롯에 쓰고, 같은…

    답글 23go