From c7a232c3e455292968639eb5dd53040bd9b20b33 Mon Sep 17 00:00:00 2001 From: Santiago Date: Thu, 30 Jul 2026 10:01:24 -0300 Subject: [PATCH] fix(tii): accept native byte arrays in the bytes arg encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native `[]byte` values (and integer arrays — the JSON shape other SDKs' native byte arrays serialize to) were rejected by the encoder, so byte params could only be supplied as hex strings. Canonicalize them to 0x-prefixed hex, per SDK spec §3.9 value marshalling — mirroring the rust-sdk/web-sdk change. Covers the Hydra `init` participants/parties/head_id and Asteria create_ship pilot/ship-name shapes with regression tests (TRP `(-32005) value is not bytes`). Co-Authored-By: Claude Fable 5 --- sdk/tii/encode.go | 51 +++++++++++++++++- sdk/tii/encode_test.go | 120 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) diff --git a/sdk/tii/encode.go b/sdk/tii/encode.go index 5396475..319e9e4 100644 --- a/sdk/tii/encode.go +++ b/sdk/tii/encode.go @@ -1,6 +1,7 @@ package tii import ( + "encoding/hex" "fmt" "sort" ) @@ -95,9 +96,14 @@ func marshal(param ParamType, value interface{}, nested bool) (interface{}, erro switch value.(type) { case string, map[string]interface{}: return leaf("bytes", value, nested), nil - default: - return nil, wrongShape("bytes", "hex string or bytes envelope", value) } + // A native byte array (`[]byte`, or an integer array — the JSON shape + // other SDKs' native byte arrays serialize to) canonicalizes to + // 0x-prefixed hex, the wire form the resolver coerces (SDK spec §3.9). + if raw, ok := asByteArray(value); ok { + return leaf("bytes", "0x"+hex.EncodeToString(raw), nested), nil + } + return nil, wrongShape("bytes", "hex string, bytes envelope, or byte array", value) case KindAddress: switch value.(type) { case string: @@ -192,6 +198,47 @@ func marshal(param ParamType, value interface{}, nested bool) (interface{}, erro } } +// asByteArray interprets a value as a raw byte array: a `[]byte`, or an array +// whose every element is an integer in 0..=255. The second return is false if +// it is neither. +func asByteArray(value interface{}) ([]byte, bool) { + switch v := value.(type) { + case []byte: + return v, true + case []interface{}: + out := make([]byte, len(v)) + for i, item := range v { + b, ok := asByteValue(item) + if !ok { + return nil, false + } + out[i] = b + } + return out, true + default: + return nil, false + } +} + +// asByteValue interprets one array element as a byte (an integer in 0..=255). +func asByteValue(item interface{}) (byte, bool) { + switch n := item.(type) { + case float64: + if n == float64(int64(n)) && n >= 0 && n <= 255 { + return byte(n), true + } + case int: + if n >= 0 && n <= 255 { + return byte(n), true + } + case int64: + if n >= 0 && n <= 255 { + return byte(n), true + } + } + return 0, false +} + // leaf renders a scalar leaf: bare at the top level (the resolver knows the // param's flat type), tagged when nested inside an aggregate (it doesn't). func leaf(tag string, value interface{}, nested bool) interface{} { diff --git a/sdk/tii/encode_test.go b/sdk/tii/encode_test.go index 1bb9de6..cc9a6c3 100644 --- a/sdk/tii/encode_test.go +++ b/sdk/tii/encode_test.go @@ -194,3 +194,123 @@ func TestNestedScalarIsTagged(t *testing.T) { t.Errorf("nested int: got %s, want tagged {\"list\":[{\"int\":5}]}", gotJSON) } } + +const bytesSchema = `{"$ref":"https://tx3.land/specs/v1beta0/tii#/$defs/Bytes"}` +const listOfBytesSchema = `{"type":"array","items":{"$ref":"https://tx3.land/specs/v1beta0/tii#/$defs/Bytes"}}` + +// TestNativeByteArraysCanonicalizeToHex verifies that a native byte array +// (`[]byte`, or an integer array — the JSON shape other SDKs' native byte +// arrays serialize to) canonicalizes to 0x-prefixed hex, per SDK spec §3.9 +// (regression: TRP `(-32005) value is not bytes: [1,1]`). +func TestNativeByteArraysCanonicalizeToHex(t *testing.T) { + bytesParam := ParamTypeFromSchema(parse(t, bytesSchema), nil) + + got, err := Encode(bytesParam, []byte{1, 1}) + if err != nil { + t.Fatalf("encode []byte failed: %v", err) + } + if !jsonEqual(t, got, "0x0101") { + t.Errorf("[]byte: got %#v, want \"0x0101\"", got) + } + + got, err = Encode(bytesParam, []interface{}{float64(1), float64(1)}) + if err != nil { + t.Fatalf("encode integer array failed: %v", err) + } + if !jsonEqual(t, got, "0x0101") { + t.Errorf("integer array: got %#v, want \"0x0101\"", got) + } + + listParam := ParamTypeFromSchema(parse(t, listOfBytesSchema), nil) + got, err = Encode(listParam, []interface{}{[]byte{1, 2}}) + if err != nil { + t.Fatalf("encode list of []byte failed: %v", err) + } + want := map[string]interface{}{"list": []interface{}{ + map[string]interface{}{"bytes": "0x0102"}, + }} + if !jsonEqual(t, got, want) { + gotJSON, _ := json.Marshal(got) + t.Errorf("nested []byte: got %s, want {\"list\":[{\"bytes\":\"0x0102\"}]}", gotJSON) + } +} + +// TestRejectsNonByteArraysForBytes pins the reject pass for byte params. +func TestRejectsNonByteArraysForBytes(t *testing.T) { + bytesParam := ParamTypeFromSchema(parse(t, bytesSchema), nil) + for _, bad := range []interface{}{ + []interface{}{float64(1), float64(256)}, + []interface{}{float64(1), float64(-1)}, + []interface{}{"aa", float64(1)}, + []interface{}{true}, + true, + } { + if _, err := Encode(bytesParam, bad); err == nil { + t.Errorf("value %#v should have been rejected", bad) + } + } +} + +// TestHydraInitArgShapes covers the Hydra `init` argument shapes: +// `participants` / `parties` are `List`, `head_id` is `Bytes` +// (regression: `(-32005) target type not supported: List` / +// `value is not bytes: [1,2]`). +func TestHydraInitArgShapes(t *testing.T) { + listParam := ParamTypeFromSchema(parse(t, listOfBytesSchema), nil) + + got, err := Encode(listParam, []interface{}{"0102", "0304"}) + if err != nil { + t.Fatalf("encode hex strings failed: %v", err) + } + want := map[string]interface{}{"list": []interface{}{ + map[string]interface{}{"bytes": "0102"}, + map[string]interface{}{"bytes": "0304"}, + }} + if !jsonEqual(t, got, want) { + gotJSON, _ := json.Marshal(got) + t.Errorf("participants (hex): got %s, want tagged list of bytes", gotJSON) + } + + got, err = Encode(listParam, []interface{}{[]byte{1, 2}}) + if err != nil { + t.Fatalf("encode native byte arrays failed: %v", err) + } + want = map[string]interface{}{"list": []interface{}{ + map[string]interface{}{"bytes": "0x0102"}, + }} + if !jsonEqual(t, got, want) { + gotJSON, _ := json.Marshal(got) + t.Errorf("participants (native): got %s, want tagged list of bytes", gotJSON) + } + + bytesParam := ParamTypeFromSchema(parse(t, bytesSchema), nil) + got, err = Encode(bytesParam, "abcd0123") + if err != nil { + t.Fatalf("encode head_id failed: %v", err) + } + if !jsonEqual(t, got, "abcd0123") { + t.Errorf("head_id: got %#v, want bare \"abcd0123\"", got) + } +} + +// TestAsteriaNameArgShapes covers the Asteria `create_ship` `ship_name` / +// `pilot_name` `Bytes` params (regression: `(-32005) value is not bytes: [1,1]`). +func TestAsteriaNameArgShapes(t *testing.T) { + bytesParam := ParamTypeFromSchema(parse(t, bytesSchema), nil) + + got, err := Encode(bytesParam, "53484950313233") + if err != nil { + t.Fatalf("encode hex name failed: %v", err) + } + if !jsonEqual(t, got, "53484950313233") { + t.Errorf("hex name: got %#v, want bare passthrough", got) + } + + got, err = Encode(bytesParam, []byte("SHIP")) + if err != nil { + t.Fatalf("encode native name failed: %v", err) + } + if !jsonEqual(t, got, "0x53484950") { + t.Errorf("native name: got %#v, want \"0x53484950\"", got) + } +}