From c9a9719569c2b9b596bfb13f88f0d8ea34a7fdb4 Mon Sep 17 00:00:00 2001 From: Makabeez <155258247+Makabeez@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:56:22 +0200 Subject: [PATCH 1/2] fix(wallet): marshal FlexibleString as a number when numeric Implements the decision on #87: --json now emits chainId as a bare number, matching kh chain list --json and closing the jq footgun documented in docs/kh_chain_list.md. Non-numeric values still marshal as strings. Uses a digit check rather than strconv.ParseFloat, which accepts NaN and Inf and would emit invalid JSON, and which would cap chain ids at int64. --- cmd/wallet/balance.go | 32 ++++++++++++++++++++++++++++++++ cmd/wallet/flexstring_test.go | 21 +++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/cmd/wallet/balance.go b/cmd/wallet/balance.go index af26eca..c08e397 100644 --- a/cmd/wallet/balance.go +++ b/cmd/wallet/balance.go @@ -44,6 +44,38 @@ func (f *FlexibleString) UnmarshalJSON(b []byte) error { return nil } +// MarshalJSON emits the value as a bare JSON number when it holds one, so +// --json output matches kh chain list --json instead of diverging from it. +// Value receiver, so it applies to non-pointer struct fields. +func (f FlexibleString) MarshalJSON() ([]byte, error) { + if isJSONInteger(string(f)) { + return []byte(f), nil + } + return json.Marshal(string(f)) +} + +// isJSONInteger reports whether s is a bare JSON integer literal. Deliberately +// stricter than strconv.ParseFloat, which accepts "NaN" and "Inf" and would +// emit invalid JSON. Digit-checking also preserves arbitrary precision, so +// there is no int64 ceiling on chain ids. +func isJSONInteger(s string) bool { + if s == "" { + return false + } + if s[0] == '-' { + s = s[1:] + } + if s == "" || (len(s) > 1 && s[0] == '0') { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + // ChainBalance holds the balance for a single chain. type ChainBalance struct { ChainID FlexibleString `json:"chainId"` diff --git a/cmd/wallet/flexstring_test.go b/cmd/wallet/flexstring_test.go index feedad2..914a845 100644 --- a/cmd/wallet/flexstring_test.go +++ b/cmd/wallet/flexstring_test.go @@ -47,3 +47,24 @@ func TestFlexibleStringAcceptsBothShapes(t *testing.T) { } } } + +func TestFlexibleStringMarshalsNumericAsNumber(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {`{"chainId":11155111}`, `11155111`}, // numeric in -> numeric out + {`{"chainId":"11155111"}`, `11155111`}, // legacy string in -> numeric out + {`{"chainId":"mainnet"}`, `"mainnet"`}, // non-numeric stays a string + {`{"chainId":null}`, `""`}, // null -> zero value + } { + var cb wallet.ChainBalance + if err := json.Unmarshal([]byte(tc.in), &cb); err != nil { + t.Fatalf("%s: %v", tc.in, err) + } + got, err := json.Marshal(cb.ChainID) + if err != nil { + t.Fatalf("%s: marshal: %v", tc.in, err) + } + if string(got) != tc.want { + t.Fatalf("%s: got %s want %s", tc.in, got, tc.want) + } + } +} From 473046c0aa9d3c1213113b53989a251fc33889b3 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 18 Aug 2026 09:09:10 +1000 Subject: [PATCH 2/2] test(wallet): cover Token.ChainID and non-numeric marshal edge cases Assert the marshal round-trip on wallet.Token.ChainID alongside ChainBalance.ChainID, mirroring TestFlexibleStringAcceptsBothShapes. Token is the field in the reported reproduction and was untested. Add rows for "NaN", "Inf", "007" and "0x1a", each asserting the emitted JSON. These pin the cases that motivated iterating digits instead of calling strconv.ParseFloat: with ParseFloat, "NaN" and "Inf" marshal to bare NaN and Inf, and without the leading-zero guard "007" marshals to bare 007 - all invalid JSON. --- cmd/wallet/flexstring_test.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/cmd/wallet/flexstring_test.go b/cmd/wallet/flexstring_test.go index 914a845..ce263c3 100644 --- a/cmd/wallet/flexstring_test.go +++ b/cmd/wallet/flexstring_test.go @@ -54,17 +54,33 @@ func TestFlexibleStringMarshalsNumericAsNumber(t *testing.T) { {`{"chainId":"11155111"}`, `11155111`}, // legacy string in -> numeric out {`{"chainId":"mainnet"}`, `"mainnet"`}, // non-numeric stays a string {`{"chainId":null}`, `""`}, // null -> zero value + {`{"chainId":"NaN"}`, `"NaN"`}, // ParseFloat would accept it; bare NaN is not valid JSON + {`{"chainId":"Inf"}`, `"Inf"`}, // ParseFloat would accept it; bare Inf is not valid JSON + {`{"chainId":"007"}`, `"007"`}, // leading zeros are not a JSON number + {`{"chainId":"0x1a"}`, `"0x1a"`}, // hex is not a JSON number } { var cb wallet.ChainBalance if err := json.Unmarshal([]byte(tc.in), &cb); err != nil { - t.Fatalf("%s: %v", tc.in, err) + t.Fatalf("ChainBalance %s: %v", tc.in, err) } got, err := json.Marshal(cb.ChainID) if err != nil { - t.Fatalf("%s: marshal: %v", tc.in, err) + t.Fatalf("ChainBalance %s: marshal: %v", tc.in, err) + } + if string(got) != tc.want { + t.Fatalf("ChainBalance %s: got %s want %s", tc.in, got, tc.want) + } + + var tok wallet.Token + if err := json.Unmarshal([]byte(tc.in), &tok); err != nil { + t.Fatalf("Token %s: %v", tc.in, err) + } + got, err = json.Marshal(tok.ChainID) + if err != nil { + t.Fatalf("Token %s: marshal: %v", tc.in, err) } if string(got) != tc.want { - t.Fatalf("%s: got %s want %s", tc.in, got, tc.want) + t.Fatalf("Token %s: got %s want %s", tc.in, got, tc.want) } } }