Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: const sub expr underflow validation #3197

Closed
wants to merge 1 commit into from

Conversation

petar-dambovaliev
Copy link
Contributor

Closes this

@github-actions github-actions bot added the 📦 🤖 gnovm Issues or PRs gnovm related label Nov 26, 2024
Copy link

codecov bot commented Nov 26, 2024

Codecov Report

Attention: Patch coverage is 78.82353% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
gnovm/pkg/gnolang/op_binary.go 78.57% 17 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use github.com/gnolang/overflow, which we already use elsewhere, instead?

Copy link
Contributor

@mvertes mvertes Nov 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is a better way. I'm working on an alternative approach, which is to handle overflows directly at gnovm level.

It is important for a smart contract runtime to always handle overflows. Gno would be superior to the standard Go runtime which, like C and most other languages, doesn't address this (to preserve the best possible native performances), and rely on external user code.

As we are interpreted, and got full control of the runtime and opcodes, we are perfectly placed to implement it the right way. It would provide strong guarantees on all numeric ops and would simplify the user code (no extra lib needed). We have to handle overflows internally anyway for constant validation, so this kind of code is already necessary in gnovm.

I did some preliminary benchmarks on an overflow aware OpAdd and such. (with specific optimisations to avoid bigints most of time, even for int64) and the overhead is negligible. And I expect it to be same also on a pure bytecode VM.

I will submit the proposal + PR + benchmarks tomorrow or the next day.

@petar-dambovaliev
Copy link
Contributor Author

Closed in favor of @mvertes proposal

@Kouteki Kouteki removed the in focus label Dec 16, 2024
mvertes added a commit that referenced this pull request Jan 9, 2025
I propose that we implement overflow checking directly in gnovm opcodes,
and that gnovm always enforces overflow checking. Overflow checking
becomes a capacity of the Gno language and the Gno virtual machine.

It's important for a smart contract platform to offer by default, and
without user or developer effort, the strongest guarantees on numerical
operations.

In that topic, Gno would be superior to the standard Go runtime which,
like C and most other languages, don't address this internally beside
constants (to preserve the best possible native performances), and rely
on external user code.

It would also simplify the user code and avoid to use specific
libraries.
For example, in `gnovm/stdlibs/std/coins.go`, for the `Coin.Add` method:

Before:

```go
import "math/overflow"

func (c Coin) Add(other Coin) Coin {
    mustMatchDenominations(c.Denom, other.Denom)
 
    sum, ok := overflow.Add64(c.Amount, other.Amount)
    if !ok {
        panic("coin add overflow/underflow: " +
              strconv.Itoa(int(c.Amount)) + " +/- " +
              strconv.Itoa(int(other.Amount)))
    }

    c.Amount = sum
    return c
}
```

After:

```go
func (c Coin) Add(other Coin) Coin {
    mustMatchDenominations(c.Denom, other.Denom)
    c.Amount += other.Amount
    return c
} 
```
with the same behaviour for overflow checking. Note also that the new
version, is not only simpler, but also faster, because overflow checking
is performed natively, and not interpreted.

Integer overflow handling is only implemented for signed integers.
Unsigned integers, on purpose, just wrap around when reaching their
maximum or minimum values. This is intended to support all crypto, hash
and bitwise operations which may rely on that wrap around property.
Division by zero is still handled both in signed and unsigned integers.

Note: from now, on security level, the use of unsigned integers for
standard numeric operations should be probably considered suspicious.

## Benchmark

To measure the impact of overflow, I execute the following benchmarks:

First a micro benchmark comparing an addition of 2 ints, with and
without overflow:


```go
//go:noinline
func AddNoOverflow(x, y int) int { return x + y }

func BenchmarkAddNoOverflow(b *testing.B) {
    x, y := 4, 3
    c := 0
    for range b.N {
        c = AddNoOverflow(x, y)
    }
    if c != 7 {
        b.Error("invalid result")
    }
}

func BenchmarkAddOverflow(b *testing.B) {
    x, y := 4, 3
    c := 0
    for range b.N {
        c = overflow.Addp(x, y)
    }
    if c != 7 {
        b.Error("invalid result")
    }
}
```

The implementation of overflow checking is taken from
http://github.com/gnolang/overflow, already used in tm2.

It gives the following results:

```console
$ go test -v- run=^# -benchmem -bench=Overflow
goos: darwin
goarch: arm64
pkg: github.com/gnolang/gno/gnovm/pkg/gnolang
cpu: Apple M1
BenchmarkAddNoOverflow
BenchmarkAddNoOverflow-8    1000000000           0.9392 ns/op          0 B/op          0 allocs/op
BenchmarkAddOverflow
BenchmarkAddOverflow-8      568881582            2.101 ns/op           0 B/op          0 allocs/op
PASS
ok      github.com/gnolang/gno/gnovm/pkg/gnolang    2.640s
```

Checking overflow doubles the execution time of an addition from 1 ns/op
to 2 ns/op.

But at 2 ns, the total time is still an order of magnitude lower than
the cost of running the VM.
The impact of overflow check doesn't even appear when benchmarking at VM
level with the following:

```go
func BenchmarkOpAdd(b *testing.B) {
    m := NewMachine("bench", nil)
    x := TypedValue{T: IntType}
    x.SetInt(4)
    y := TypedValue{T: IntType}
    y.SetInt(3)

    b.ResetTimer()

    for range b.N {
        m.PushOp(OpHalt)
        m.PushExpr(&BinaryExpr{})
        m.PushValue(x)
        m.PushValue(y)
        m.PushOp(OpAdd)
        m.Run()
    }
}
```

Which gives something like:

```console
$ go test -v -benchmem -bench=OpAdd -run=^#
goos: darwin
goarch: arm64
pkg: github.com/gnolang/gno/gnovm/pkg/gnolang
cpu: Apple M1
BenchmarkOpAdd
BenchmarkOpAdd-8    16069832            74.41 ns/op      163 B/op          1 allocs/op
PASS
ok      github.com/gnolang/gno/gnovm/pkg/gnolang    1.526
```

Where the execution time varie from 60 ns/op to 100 ns/op for both
versions of addition, with or without overflow.

## Related PRs and issues

- PRs: 
    - #3197 
    - #3192
    - #3117
    - #2983
    - #2905 
    - #2698
- Issues: 
    - #2873
    - #1844
    - #1729


<!-- please provide a detailed description of the changes made in this
pull request. -->

<details><summary>Contributors' checklist...</summary>

- [ ] Added new tests, or not needed, or not feasible
- [ ] Provided an example (e.g. screenshot) to aid review or the PR is
self-explanatory
- [ ] Updated the official documentation or not needed
- [ ] No breaking changes were made, or a `BREAKING CHANGE: xxx` message
was included in the description
- [ ] Added references to related issues and PRs
- [ ] Provided any useful hints for running manual tests
</details>
albttx pushed a commit that referenced this pull request Jan 10, 2025
I propose that we implement overflow checking directly in gnovm opcodes,
and that gnovm always enforces overflow checking. Overflow checking
becomes a capacity of the Gno language and the Gno virtual machine.

It's important for a smart contract platform to offer by default, and
without user or developer effort, the strongest guarantees on numerical
operations.

In that topic, Gno would be superior to the standard Go runtime which,
like C and most other languages, don't address this internally beside
constants (to preserve the best possible native performances), and rely
on external user code.

It would also simplify the user code and avoid to use specific
libraries.
For example, in `gnovm/stdlibs/std/coins.go`, for the `Coin.Add` method:

Before:

```go
import "math/overflow"

func (c Coin) Add(other Coin) Coin {
    mustMatchDenominations(c.Denom, other.Denom)
 
    sum, ok := overflow.Add64(c.Amount, other.Amount)
    if !ok {
        panic("coin add overflow/underflow: " +
              strconv.Itoa(int(c.Amount)) + " +/- " +
              strconv.Itoa(int(other.Amount)))
    }

    c.Amount = sum
    return c
}
```

After:

```go
func (c Coin) Add(other Coin) Coin {
    mustMatchDenominations(c.Denom, other.Denom)
    c.Amount += other.Amount
    return c
} 
```
with the same behaviour for overflow checking. Note also that the new
version, is not only simpler, but also faster, because overflow checking
is performed natively, and not interpreted.

Integer overflow handling is only implemented for signed integers.
Unsigned integers, on purpose, just wrap around when reaching their
maximum or minimum values. This is intended to support all crypto, hash
and bitwise operations which may rely on that wrap around property.
Division by zero is still handled both in signed and unsigned integers.

Note: from now, on security level, the use of unsigned integers for
standard numeric operations should be probably considered suspicious.

## Benchmark

To measure the impact of overflow, I execute the following benchmarks:

First a micro benchmark comparing an addition of 2 ints, with and
without overflow:


```go
//go:noinline
func AddNoOverflow(x, y int) int { return x + y }

func BenchmarkAddNoOverflow(b *testing.B) {
    x, y := 4, 3
    c := 0
    for range b.N {
        c = AddNoOverflow(x, y)
    }
    if c != 7 {
        b.Error("invalid result")
    }
}

func BenchmarkAddOverflow(b *testing.B) {
    x, y := 4, 3
    c := 0
    for range b.N {
        c = overflow.Addp(x, y)
    }
    if c != 7 {
        b.Error("invalid result")
    }
}
```

The implementation of overflow checking is taken from
http://github.com/gnolang/overflow, already used in tm2.

It gives the following results:

```console
$ go test -v- run=^# -benchmem -bench=Overflow
goos: darwin
goarch: arm64
pkg: github.com/gnolang/gno/gnovm/pkg/gnolang
cpu: Apple M1
BenchmarkAddNoOverflow
BenchmarkAddNoOverflow-8    1000000000           0.9392 ns/op          0 B/op          0 allocs/op
BenchmarkAddOverflow
BenchmarkAddOverflow-8      568881582            2.101 ns/op           0 B/op          0 allocs/op
PASS
ok      github.com/gnolang/gno/gnovm/pkg/gnolang    2.640s
```

Checking overflow doubles the execution time of an addition from 1 ns/op
to 2 ns/op.

But at 2 ns, the total time is still an order of magnitude lower than
the cost of running the VM.
The impact of overflow check doesn't even appear when benchmarking at VM
level with the following:

```go
func BenchmarkOpAdd(b *testing.B) {
    m := NewMachine("bench", nil)
    x := TypedValue{T: IntType}
    x.SetInt(4)
    y := TypedValue{T: IntType}
    y.SetInt(3)

    b.ResetTimer()

    for range b.N {
        m.PushOp(OpHalt)
        m.PushExpr(&BinaryExpr{})
        m.PushValue(x)
        m.PushValue(y)
        m.PushOp(OpAdd)
        m.Run()
    }
}
```

Which gives something like:

```console
$ go test -v -benchmem -bench=OpAdd -run=^#
goos: darwin
goarch: arm64
pkg: github.com/gnolang/gno/gnovm/pkg/gnolang
cpu: Apple M1
BenchmarkOpAdd
BenchmarkOpAdd-8    16069832            74.41 ns/op      163 B/op          1 allocs/op
PASS
ok      github.com/gnolang/gno/gnovm/pkg/gnolang    1.526
```

Where the execution time varie from 60 ns/op to 100 ns/op for both
versions of addition, with or without overflow.

## Related PRs and issues

- PRs: 
    - #3197 
    - #3192
    - #3117
    - #2983
    - #2905 
    - #2698
- Issues: 
    - #2873
    - #1844
    - #1729


<!-- please provide a detailed description of the changes made in this
pull request. -->

<details><summary>Contributors' checklist...</summary>

- [ ] Added new tests, or not needed, or not feasible
- [ ] Provided an example (e.g. screenshot) to aid review or the PR is
self-explanatory
- [ ] Updated the official documentation or not needed
- [ ] No breaking changes were made, or a `BREAKING CHANGE: xxx` message
was included in the description
- [ ] Added references to related issues and PRs
- [ ] Provided any useful hints for running manual tests
</details>
@thehowl thehowl deleted the const-underflow branch February 10, 2025 18:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
📦 🤖 gnovm Issues or PRs gnovm related
Projects
Development

Successfully merging this pull request may close these issues.

bug: const expression underflow
4 participants