From f23c98c468dcd4f040b8641ef6e5771b449c0086 Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Wed, 25 Mar 2026 21:57:36 +0100 Subject: [PATCH] fix(go): preserve uint64 ids during json unmarshal --- packages/core-go/address/detect.go | 12 +++- packages/core-go/address/result.go | 14 +++++ packages/core-go/go.mod | 9 ++- packages/core-go/go.sum | 14 +++++ packages/core-go/muxed/decode.go | 6 +- packages/core-go/muxed/encode.go | 22 +++++--- packages/core-go/routing/extract.go | 39 ++++++++++--- packages/core-go/routing/memo.go | 2 +- packages/core-go/routing/result.go | 39 ++++++++++--- packages/core-go/routing/result_test.go | 75 +++++++++++++++++++++++++ 10 files changed, 199 insertions(+), 33 deletions(-) create mode 100644 packages/core-go/address/result.go create mode 100644 packages/core-go/go.sum create mode 100644 packages/core-go/routing/result_test.go diff --git a/packages/core-go/address/detect.go b/packages/core-go/address/detect.go index fa38f452..4e24875c 100644 --- a/packages/core-go/address/detect.go +++ b/packages/core-go/address/detect.go @@ -5,8 +5,14 @@ import ( ) func Detect(address string) string { - if strkey.IsValidEd25519PublicKey(address) { return "G" } - if strkey.IsValidMed25519PublicKey(address) { return "M" } - if strkey.IsValidContract(address) { return "C" } + if strkey.IsValidEd25519PublicKey(address) { + return "G" + } + if strkey.IsValidMuxedAccountEd25519PublicKey(address) { + return "M" + } + if _, err := strkey.Decode(strkey.VersionByteContract, address); err == nil { + return "C" + } return "invalid" } diff --git a/packages/core-go/address/result.go b/packages/core-go/address/result.go new file mode 100644 index 00000000..2c035a83 --- /dev/null +++ b/packages/core-go/address/result.go @@ -0,0 +1,14 @@ +package address + +type ParseResult struct { + Kind string + Address string + Warnings []Warning + Err *AddressError +} + +type AddressError struct { + Code ErrorCode + Input string + Message string +} diff --git a/packages/core-go/go.mod b/packages/core-go/go.mod index f4d0e4bb..f608b8ab 100644 --- a/packages/core-go/go.mod +++ b/packages/core-go/go.mod @@ -1,7 +1,12 @@ module github.com/stellar-address-kit/core-go -go 1.21 +go 1.22 + +toolchain go1.22.2 + +require github.com/stellar/go v0.0.0-20241220220012-089553bb324a require ( - github.com/stellar/go v0.0.0-20231213180453-6a1e9a2f0e31 + github.com/pkg/errors v0.9.1 // indirect + github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 // indirect ) diff --git a/packages/core-go/go.sum b/packages/core-go/go.sum new file mode 100644 index 00000000..7f87a90e --- /dev/null +++ b/packages/core-go/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stellar/go v0.0.0-20241220220012-089553bb324a h1:DHSzxKJCTX1e0vtXe2pFqvDq2Pn6pENCr2xykWFciy4= +github.com/stellar/go v0.0.0-20241220220012-089553bb324a/go.mod h1:gY4J6cGScn4oPT7lDBurLUEf/ltVJfeMk8prEF6IJKo= +github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 h1:OzCVd0SV5qE3ZcDeSFCmOWLZfEWZ3Oe8KtmSOYKEVWE= +github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2/go.mod h1:yoxyU/M8nl9LKeWIoBrbDPQ7Cy+4jxRcWcOayZ4BMps= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/core-go/muxed/decode.go b/packages/core-go/muxed/decode.go index 4e6915db..7b36f9c6 100644 --- a/packages/core-go/muxed/decode.go +++ b/packages/core-go/muxed/decode.go @@ -7,15 +7,15 @@ import ( ) func DecodeMuxed(mAddress string) (string, string, error) { - pubkey, id, err := strkey.DecodeMuxedAccount(mAddress) + muxedAccount, err := strkey.DecodeMuxedAccount(mAddress) if err != nil { return "", "", err } - baseG, err := strkey.Encode(strkey.VersionByteAccountID, pubkey) + baseG, err := muxedAccount.AccountID() if err != nil { return "", "", err } - return baseG, strconv.FormatUint(id, 10), nil + return baseG, strconv.FormatUint(muxedAccount.ID(), 10), nil } diff --git a/packages/core-go/muxed/encode.go b/packages/core-go/muxed/encode.go index ce5cc9bc..6db9224b 100644 --- a/packages/core-go/muxed/encode.go +++ b/packages/core-go/muxed/encode.go @@ -1,18 +1,26 @@ package muxed import ( + "fmt" "math/big" + "github.com/stellar/go/strkey" ) func EncodeMuxed(baseG string, id string) (string, error) { - pubkey, err := strkey.Decode(strkey.VersionByteAccountID, baseG) - if err != nil { + idInt := new(big.Int) + if _, ok := idInt.SetString(id, 10); !ok { + return "", fmt.Errorf("invalid muxed account id %q", id) + } + if idInt.Sign() < 0 || idInt.BitLen() > 64 { + return "", fmt.Errorf("muxed account id %q exceeds uint64", id) + } + + var muxedAccount strkey.MuxedAccount + if err := muxedAccount.SetAccountID(baseG); err != nil { return "", err } - - idInt := new(big.Int) - idInt.SetString(id, 10) - - return strkey.EncodeMuxedAccount(pubkey, idInt.Uint64()) + + muxedAccount.SetID(idInt.Uint64()) + return muxedAccount.Address() } diff --git a/packages/core-go/routing/extract.go b/packages/core-go/routing/extract.go index d46f597f..5ba525dc 100644 --- a/packages/core-go/routing/extract.go +++ b/packages/core-go/routing/extract.go @@ -38,10 +38,22 @@ func ExtractRouting(input RoutingInput) RoutingResult { } if parsed.Kind == "M" { - baseG, id, _ := muxed.DecodeMuxed(parsed.Address) + baseG, id, err := muxed.DecodeMuxed(parsed.Address) + if err != nil { + return RoutingResult{ + RoutingSource: "none", + Warnings: []address.Warning{}, + DestinationError: &DestinationError{ + Code: address.ErrUnknownPrefix, + Message: err.Error(), + }, + } + } + warnings := append([]address.Warning{}, parsed.Warnings...) + memoValue := stringValue(input.MemoValue) - if input.MemoType == "id" || (input.MemoType == "text" && digitsOnlyRegex.MatchString(input.MemoValue)) { + if input.MemoType == "id" || (input.MemoType == "text" && digitsOnlyRegex.MatchString(memoValue)) { warnings = append(warnings, address.Warning{ Code: address.WarnMemoPresentWithMuxed, Severity: "warn", @@ -57,20 +69,21 @@ func ExtractRouting(input RoutingInput) RoutingResult { return RoutingResult{ DestinationBaseAccount: baseG, - RoutingID: id, + RoutingID: NewRoutingID(id), RoutingSource: "muxed", Warnings: warnings, } } - routingID := "" + var routingID *RoutingID routingSource := "none" warnings := append([]address.Warning{}, parsed.Warnings...) + memoValue := stringValue(input.MemoValue) if input.MemoType == "id" { - norm := NormalizeMemoTextID(input.MemoValue) - routingID = norm.Normalized + norm := NormalizeMemoTextID(memoValue) if norm.Normalized != "" { + routingID = NewRoutingID(norm.Normalized) routingSource = "memo" } warnings = append(warnings, norm.Warnings...) @@ -82,10 +95,10 @@ func ExtractRouting(input RoutingInput) RoutingResult { Message: "MEMO_ID was empty, non-numeric, or exceeded uint64 max.", }) } - } else if input.MemoType == "text" && input.MemoValue != "" { - norm := NormalizeMemoTextID(input.MemoValue) + } else if input.MemoType == "text" && memoValue != "" { + norm := NormalizeMemoTextID(memoValue) if norm.Normalized != "" { - routingID = norm.Normalized + routingID = NewRoutingID(norm.Normalized) routingSource = "memo" warnings = append(warnings, norm.Warnings...) } else { @@ -122,3 +135,11 @@ func ExtractRouting(input RoutingInput) RoutingResult { Warnings: warnings, } } + +func stringValue(s *string) string { + if s == nil { + return "" + } + + return *s +} diff --git a/packages/core-go/routing/memo.go b/packages/core-go/routing/memo.go index e0abf9ac..e76921d2 100644 --- a/packages/core-go/routing/memo.go +++ b/packages/core-go/routing/memo.go @@ -9,7 +9,7 @@ import ( ) var digitsOnly = regexp.MustCompile(`^\d+$`) -var uint64Max = new(big.Int).SetString("18446744073709551615", 10) +var uint64Max, _ = new(big.Int).SetString("18446744073709551615", 10) type NormalizeResult struct { Normalized string diff --git a/packages/core-go/routing/result.go b/packages/core-go/routing/result.go index a3622da0..d0374c44 100644 --- a/packages/core-go/routing/result.go +++ b/packages/core-go/routing/result.go @@ -1,8 +1,10 @@ package routing import ( + "encoding/json" + "fmt" "strconv" - + "github.com/stellar-address-kit/core-go/address" ) @@ -20,6 +22,34 @@ type RoutingID struct { raw string } +func (r *RoutingID) UnmarshalJSON(data []byte) error { + if r == nil { + return fmt.Errorf("routing id: UnmarshalJSON on nil receiver") + } + + if string(data) == "null" { + r.raw = "" + return nil + } + + var id string + if len(data) > 0 && data[0] == '"' { + if err := json.Unmarshal(data, &id); err != nil { + return fmt.Errorf("routing id: invalid quoted value: %w", err) + } + } else { + id = string(data) + } + + parsed, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return fmt.Errorf("routing id: invalid uint64 value %q: %w", id, err) + } + + r.raw = strconv.FormatUint(parsed, 10) + return nil +} + func (r *RoutingID) String() string { if r == nil { return "" @@ -38,13 +68,6 @@ func NewRoutingID(s string) *RoutingID { return &RoutingID{raw: s} } -type RoutingInput struct { - Destination string - MemoType string - MemoValue *string - SourceAccount *string -} - type RoutingResult struct { DestinationBaseAccount string RoutingID *RoutingID diff --git a/packages/core-go/routing/result_test.go b/packages/core-go/routing/result_test.go new file mode 100644 index 00000000..3e047202 --- /dev/null +++ b/packages/core-go/routing/result_test.go @@ -0,0 +1,75 @@ +package routing + +import ( + "encoding/json" + "testing" +) + +func TestRoutingIDUnmarshalJSONPreservesUint64Number(t *testing.T) { + t.Parallel() + + payload := []byte(`{"id":18446744073709551615}`) + var body struct { + ID RoutingID `json:"id"` + } + + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got := body.ID.String(); got != "18446744073709551615" { + t.Fatalf("RoutingID.String() = %q, want %q", got, "18446744073709551615") + } + + gotUint64, err := body.ID.Uint64() + if err != nil { + t.Fatalf("RoutingID.Uint64() error = %v", err) + } + + if gotUint64 != ^uint64(0) { + t.Fatalf("RoutingID.Uint64() = %d, want %d", gotUint64, ^uint64(0)) + } +} + +func TestRoutingIDUnmarshalJSONAcceptsQuotedDecimalString(t *testing.T) { + t.Parallel() + + payload := []byte(`{"id":"18446744073709551615"}`) + var body struct { + ID RoutingID `json:"id"` + } + + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got := body.ID.String(); got != "18446744073709551615" { + t.Fatalf("RoutingID.String() = %q, want %q", got, "18446744073709551615") + } +} + +func TestRoutingIDUnmarshalJSONRejectsInvalidNumbers(t *testing.T) { + t.Parallel() + + testCases := []string{ + `{"id":18446744073709551616}`, + `{"id":-1}`, + `{"id":1.5}`, + `{"id":"not-a-number"}`, + } + + for _, payload := range testCases { + payload := payload + t.Run(payload, func(t *testing.T) { + t.Parallel() + + var body struct { + ID RoutingID `json:"id"` + } + + if err := json.Unmarshal([]byte(payload), &body); err == nil { + t.Fatalf("json.Unmarshal(%s) error = nil, want non-nil", payload) + } + }) + } +}