Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 48 additions & 14 deletions core/felt/felt.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package felt

import (
"encoding/hex"
"errors"
"fmt"
"math/big"
"strings"
"sync"

"github.com/consensys/gnark-crypto/ecc/stark-curve/fp"
Expand Down Expand Up @@ -46,27 +46,61 @@ func (z *Felt) Impl() *fp.Element {
return (*fp.Element)(z)
}

// UnmarshalJSON accepts only 0x-prefixed hexadecimal strings.
// UnmarshalJSON accepts a quoted, 0x-prefixed hex string and sets z.
// Zero-alloc: pads hex into a fixed [64]byte and lets hex.Decode do the parsing.
func (z *Felt) UnmarshalJSON(data []byte) error {
if len(data) > fp.Bits*3 {
return errors.New("value too large (max = Element.Bits * 3)")
if len(data) < 5 || data[0] != '"' || data[len(data)-1] != '"' {
return errors.New("felt: expected quoted 0x hex string")
}
if data[1] != '0' || (data[2] != 'x' && data[2] != 'X') {
return errors.New("felt: missing 0x prefix")
}

src := data[3 : len(data)-1] // hex digits after "0x
// maxHexDigits is the maximum number of hex digits in a felt value (32 bytes = 64 hex chars).
const maxHexDigits = Bytes * 2
if len(src) > maxHexDigits {
return errors.New("felt: value exceeds field size")
}

// remove leading and trailing quotes if any, normalise to lowercase
s := strings.ToLower(strings.Trim(string(data), `"`))
if !strings.HasPrefix(s, "0x") {
return errors.New("felt value must be a 0x-prefixed hex string")
// Left-pad with '0' to 64 hex chars so hex.Decode produces exactly 32 bytes.
var padded [maxHexDigits]byte
for i := range padded {
padded[i] = '0'
}
Comment thread
rodrodros marked this conversation as resolved.
copy(padded[maxHexDigits-len(src):], src)

_, err := z.SetString(s)
return err
var buf [Bytes]byte
if _, err := hex.Decode(buf[:], padded[:]); err != nil {
Comment thread
rodrodros marked this conversation as resolved.
return fmt.Errorf("felt: couldn't decode hex value: %w", err)
}
return (*fp.Element)(z).SetBytesCanonical(buf[:])
}

// MarshalJSON forwards the call to underlying field element implementation.
// Uses a value receiver so encoding/json can call it on non-addressable values
// (e.g. struct fields inside a value stored in an `any` interface).
// MarshalJSON returns the felt as a quoted 0x hex string with no
// unnecessary leading zeros. Uses a value receiver so encoding/json
// can call it on non-addressable values (struct fields in `any`).
func (z Felt) MarshalJSON() ([]byte, error) {
return []byte("\"" + z.String() + "\""), nil
var raw [Bytes]byte
fp.BigEndian.PutElement(&raw, fp.Element(z))

// Find first significant byte.
i := 0
for i < Bytes-1 && raw[i] == 0 {
i++
}

out := make([]byte, 3, 4+(Bytes-i)*2)
out[0], out[1], out[2] = '"', '0', 'x'

// First byte may need a single hex digit (e.g. 0x3, not 0x03).
if raw[i] < Base16 {
out = append(out, "0123456789abcdef"[raw[i]])
i++
}
out = hex.AppendEncode(out, raw[i:])

return append(out, '"'), nil
}

// SetBytes forwards the call to underlying field element implementation
Expand Down
61 changes: 61 additions & 0 deletions core/felt/felt_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package felt_test

import (
"testing"

"github.com/NethermindEth/juno/core/felt"
)

// Sinks prevent the compiler from optimising benchmark work away.
var (
benchBytesSink []byte
benchFeltSink felt.Felt
)

func benchJSONInputs(b *testing.B) []struct {
name string
hex string
} {
b.Helper()
random := felt.Random[felt.Felt]()
return []struct {
name string
hex string
}{
{"zero", "0x0"},
{"small", "0xdeadbeef"},
{"address", "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"},
{"max_felt", "0x800000000000011000000000000000000000000000000000000000000000000"},
// Full-width (64-hex-char) input exercises the leading-zero-pad path on unmarshal.
{"padded_address", "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"},
{"random", random.String()},
}
}

func BenchmarkMarshalJSON(b *testing.B) {
for _, tc := range benchJSONInputs(b) {
f := felt.UnsafeFromString[felt.Felt](tc.hex)
b.Run(tc.name, func(b *testing.B) {
b.ReportAllocs()
var out []byte
for b.Loop() {
out, _ = f.MarshalJSON()
}
benchBytesSink = out
})
}
}

func BenchmarkUnmarshalJSON(b *testing.B) {
for _, tc := range benchJSONInputs(b) {
input := []byte(`"` + tc.hex + `"`)
b.Run(tc.name, func(b *testing.B) {
b.ReportAllocs()
var f felt.Felt
for b.Loop() {
_ = f.UnmarshalJSON(input)
}
benchFeltSink = f
})
}
}
154 changes: 154 additions & 0 deletions core/felt/felt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,157 @@ func TestFeltMarshalAndUnmarshal(t *testing.T) {

assert.True(t, f2.Equal(f))
}

func TestMarshalJSON(t *testing.T) {
tests := []struct {
name string
hex string
expect string
}{
{"zero", "0x0", `"0x0"`},
{"one", "0x1", `"0x1"`},
{"single digit", "0xf", `"0xf"`},
{"two digits", "0xff", `"0xff"`},
{"leading nibble < 0x10", "0xa", `"0xa"`},
{"leading nibble 0x0a boundary", "0xa0", `"0xa0"`},
{"small value", "0xdeadbeef", `"0xdeadbeef"`},
{
"typical address", "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
`"0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"`,
},
{
"max 252-bit value", "0x7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
`"0x7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"`,
},
{"power of two", "0x100", `"0x100"`},
{"0x10 boundary", "0x10", `"0x10"`},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
f := felt.UnsafeFromString[felt.Felt](tc.hex)
got, err := f.MarshalJSON()
require.NoError(t, err)
assert.Equal(t, tc.expect, string(got))
})
}
}

func TestUnmarshalJSON(t *testing.T) {
t.Run("valid values", func(t *testing.T) {
tests := []struct {
name string
input string
hex string
}{
{"zero", `"0x0"`, "0x0"},
{"zero with leading zeros", `"0x0000"`, "0x0"},
{"one", `"0x1"`, "0x1"},
{"small", `"0xdeadbeef"`, "0xdeadbeef"},
{"uppercase X prefix", `"0Xdeadbeef"`, "0xdeadbeef"},
{"mixed case hex digits", `"0xDeAdBeEf"`, "0xdeadbeef"},
{
"64 hex digits with leading zero (padded address)",
`"0x041d5da3fe4b9a6c0aef883cfec0d45436b51128e1adae80340a941b5f905db6"`,
"0x41d5da3fe4b9a6c0aef883cfec0d45436b51128e1adae80340a941b5f905db6",
},
{
"max valid felt (P-1)",
`"0x800000000000011000000000000000000000000000000000000000000000000"`,
"0x800000000000011000000000000000000000000000000000000000000000000",
},
{"odd number of hex digits", `"0xabc"`, "0xabc"},
{"single digit", `"0x5"`, "0x5"},
{"leading zero nibble value", `"0x0a"`, "0xa"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var f felt.Felt
err := f.UnmarshalJSON([]byte(tc.input))
require.NoError(t, err)
assert.Equal(t, tc.hex, f.String())
})
}
})

t.Run("invalid values", func(t *testing.T) {
tests := []struct {
name string
input string
}{
{"no quotes", `0xdeadbeef`},
{"no prefix", `"deadbeef"`},
{"no hex prefix", `"4437ab"`},
{"empty hex", `"0x"`},
{"only quotes", `""`},
{
"65 hex digits (exceeds 32 bytes)",
`"0x10000000000000000000000000000000000000000000000000000000000000000"`,
},
{
"field modulus P (invalid canonical)",
`"0x0800000000000011000000000000000000000000000000000000000000000001"`,
},
{
"above modulus with leading zero",
`"0x0fb01012100000000000000000000000000000000000000000000000000000000"`,
},
{"invalid hex character", `"0xdeadgbeef"`},
{"spaces in hex", `"0xdead beef"`},
{"negative", `"-0x1"`},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var f felt.Felt
assert.Error(t, f.UnmarshalJSON([]byte(tc.input)))
})
}
})
}

func TestJSONRoundTrip(t *testing.T) {
values := []string{
"0x0",
"0x1",
"0xa",
"0xf",
"0x10",
"0xff",
"0x100",
"0xdeadbeef",
"0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"0x800000000000011000000000000000000000000000000000000000000000000",
}

for _, hex := range values {
t.Run(hex, func(t *testing.T) {
original := felt.UnsafeFromString[felt.Felt](hex)

marshalled, err := json.Marshal(&original)
require.NoError(t, err)

var decoded felt.Felt
require.NoError(t, json.Unmarshal(marshalled, &decoded))

assert.True(t, original.Equal(&decoded),
"round-trip failed: %s → %s → %s", hex, string(marshalled), decoded.String())
})
}
}

func TestJSONRoundTripRandom(t *testing.T) {
for range 1000 {
original := felt.NewRandom[felt.Felt]()

marshalled, err := json.Marshal(&original)
require.NoError(t, err)

var decoded felt.Felt
require.NoError(t, json.Unmarshal(marshalled, &decoded))

assert.True(t, original.Equal(&decoded),
"round-trip failed for random felt: marshalled=%s", string(marshalled))
}
}
Loading