From 1b0c4cbe54bf1dd70b1e90571ecf00da72be7a15 Mon Sep 17 00:00:00 2001 From: Ehsan-saradar Date: Tue, 16 Jun 2026 13:03:50 +0330 Subject: [PATCH] Fix GrindKey() key derivation in Go implementation GrindKey() diverged from the reference grind_key (paradex-py / paradex.js) in several ways, producing keys that did not match other SDKs: 1. Inverted range check (issue #102): the loop used `< 0`, looping while the key was already in range and accepting out-of-range keys. Rejection sampling requires grinding *while* the key is out of range, so the condition is now `>= 0`. 2. SHA256_EC_MAX_DIGEST was built from a string literal containing spaces, which big.Int.SetString rejects (ok=false) while the unchecked return left the value at 0. That 0 silently masked bug #1 (a hash is never < 0, so the loop never ran) and would have caused an infinite loop once the condition was flipped. Compute 2^256 directly instead. 3. The index was hex-encoded with `%02x`, which stops keeping the length even past 255 (e.g. 256 -> "100" vs the reference "0100"). Pad to an even number of hex digits, matching padded_hex. 4. The seed was not padded to even length, so an odd-length seed panicked in hex.DecodeString. Pad it the same way. Add utils_test.go pinning GrindKey output to golden values generated from the paradex-py reference, plus range and hex-prefix checks. Fixes #102 --- go/utils.go | 25 +++++++++------ go/utils_test.go | 79 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 go/utils_test.go diff --git a/go/utils.go b/go/utils.go index 68fed27..b51879f 100644 --- a/go/utils.go +++ b/go/utils.go @@ -72,8 +72,8 @@ func ComputeAddress(config SystemConfigResponse, publicKey string) string { } func GrindKey(keySeed string, keyValLimit *big.Int) string { - sha256EcMaxDigest := new(big.Int) - sha256EcMaxDigest.SetString("1 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000", 16) + // SHA256_EC_MAX_DIGEST is 2^256, the size of the SHA-256 output space. + sha256EcMaxDigest := new(big.Int).Lsh(big.NewInt(1), 256) maxAllowedVal := new(big.Int).Sub(sha256EcMaxDigest, new(big.Int).Mod(sha256EcMaxDigest, keyValLimit)) i := 0 @@ -81,8 +81,8 @@ func GrindKey(keySeed string, keyValLimit *big.Int) string { i++ // Make sure the produced key is divided by the Stark EC order, and falls within the range - // [0, maxAllowedVal). - for key.Cmp(maxAllowedVal) < 0 { + // [0, maxAllowedVal). Keep grinding while the key is out of range (>= maxAllowedVal). + for key.Cmp(maxAllowedVal) >= 0 { key = hashKeyWithIndex(keySeed, i) i++ } @@ -92,15 +92,22 @@ func GrindKey(keySeed string, keyValLimit *big.Int) string { return fmt.Sprintf("0x%x", result) } +// paddedHex returns the hex string with a leading '0' prepended when its length +// is odd, ensuring it decodes to a whole number of bytes. +func paddedHex(h string) string { + if len(h)%2 != 0 { + return "0" + h + } + return h +} + func hashKeyWithIndex(keySeed string, index int) *big.Int { // Remove '0x' prefix if present key := strings.TrimPrefix(keySeed, "0x") - // Convert index to hex and pad to 2 bytes - indexHex := fmt.Sprintf("%02x", index) - - // Combine key and index - data := key + indexHex + // Combine key and index, each padded to an even number of hex digits so the + // concatenation decodes cleanly to bytes (matching the reference padded_hex). + data := paddedHex(key) + paddedHex(fmt.Sprintf("%x", index)) // Decode hex string to bytes dataBytes, err := hex.DecodeString(data) diff --git a/go/utils_test.go b/go/utils_test.go new file mode 100644 index 0000000..ad997c9 --- /dev/null +++ b/go/utils_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +// starkEcOrder is the Stark curve order (EC_ORDER) used as the key value limit, +// matching the constant used by paradex-py and paradex.js. +const starkEcOrder = "0800000000000010ffffffffffffffffb781126dcae7b2321e66a241adc64d2f" + +func ecOrder(t *testing.T) *big.Int { + t.Helper() + n, ok := new(big.Int).SetString(starkEcOrder, 16) + require.True(t, ok, "failed to parse Stark EC order") + return n +} + +// TestGrindKey pins GrindKey's output to golden values generated from the +// reference implementations (paradex-py / paradex.js grind_key). Any divergence +// in the loop condition, the SHA256_EC_MAX_DIGEST constant, or the seed/index +// hex padding changes the derived key and fails here. +func TestGrindKey(t *testing.T) { + n := ecOrder(t) + + cases := []struct { + name string + seed string + want string + }{ + // Typical 64-hex-char seed (the real path derives the seed from the + // first 32 bytes of an Ethereum signature, i.e. always 64 chars). + { + name: "all_a_64", + seed: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + want: "0x20c20907b449bacb0a16d9a297ea531aa98eadd80424215dc17da8604868ebf", + }, + { + name: "typical", + seed: "1b2e3d4c5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f70819a2b3c4d5e6", + want: "0x520e4af362957fe86d08d4524b84de5c1d061b3673320863382d95c19365753", + }, + // Odd-length seed: the reference pads it to even length before hashing. + // A naive Go port (no padding) panics in hex.DecodeString. + { + name: "odd_len", + seed: "abc", + want: "0x5fe775158c41e99ecc67b2ebf8d33da7cd3666e6b50a7b5ae761dd5abf993a2", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := GrindKey(tc.seed, n) + require.Equal(t, tc.want, got) + }) + } +} + +// TestGrindKeyAcceptsHexPrefix ensures a "0x"-prefixed seed grinds to the same +// key as the bare seed. +func TestGrindKeyAcceptsHexPrefix(t *testing.T) { + n := ecOrder(t) + seed := "1b2e3d4c5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f70819a2b3c4d5e6" + require.Equal(t, GrindKey(seed, n), GrindKey("0x"+seed, n)) +} + +// TestGrindKeyInRange checks the produced key is a valid Stark private key, +// i.e. it lies in [0, EC_ORDER). +func TestGrindKeyInRange(t *testing.T) { + n := ecOrder(t) + out := GrindKey("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", n) + key, ok := new(big.Int).SetString(out[2:], 16) // strip "0x" + require.True(t, ok) + require.Equal(t, -1, key.Cmp(n), "key must be < EC_ORDER") + require.GreaterOrEqual(t, key.Sign(), 0, "key must be >= 0") +}