From c572a775144f5ae456921265f1be90ed55b5ed8f Mon Sep 17 00:00:00 2001 From: Milo Date: Sun, 30 Aug 2026 23:37:32 +0200 Subject: [PATCH 1/3] fix: omit the agent "for" key when no party is named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `omitempty` has no effect on a struct field, so `Agent.For` was always serialised. A zero ForField marshals to `null`, which put `"for": null` on the wire for every ownerless agent — a settlement address with no owner, for instance. Decoding that is worse than emitting it. `encoding/json` documents unmarshalling `null` into a non-pointer as a no-op that returns no error, so ForField.UnmarshalJSON's single-string branch accepted it, leaving `values = [""]` and `IsEmpty() == false`. A receiver reads an agent that declares an owner whose DID is the empty string, when the sender said the agent has none. Two changes: - `json:"for,omitzero"` on Agent.For, plus an IsZero method so a ForField built from an empty non-nil slice omits the key too. omitzero applies to structs, which is what omitempty could never do. - UnmarshalJSON rejects `null` up front, so a receiver that has not upgraded past a sender still emitting `"for": null` decodes it as no owner. TAIP-5 makes `for` optional on an agent, so an absent key is the correct encoding of "no party named". --- types.go | 18 +++++++++++++++++- types_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/types.go b/types.go index 2b802f4..095174b 100644 --- a/types.go +++ b/types.go @@ -24,7 +24,7 @@ type Agent struct { ID string `json:"@id"` Type string `json:"@type,omitempty"` Role string `json:"role,omitempty"` - For ForField `json:"for,omitempty"` + For ForField `json:"for,omitzero"` Name string `json:"name,omitempty"` NameHash string `json:"nameHash,omitempty"` LEICode string `json:"lei:leiCode,omitempty"` @@ -66,6 +66,13 @@ func (f ForField) IsEmpty() bool { return len(f.values) == 0 } +// IsZero reports whether the ForField carries no values, so `json:",omitzero"` +// omits the key. A ForField holding an empty non-nil slice is not the Go zero +// value but means the same thing on the wire. +func (f ForField) IsZero() bool { + return len(f.values) == 0 +} + // MarshalJSON marshals the ForField as a string (single value) or array (multiple values). func (f ForField) MarshalJSON() ([]byte, error) { if len(f.values) == 0 { @@ -79,6 +86,15 @@ func (f ForField) MarshalJSON() ([]byte, error) { // UnmarshalJSON unmarshals the ForField from a string or array of strings. func (f *ForField) UnmarshalJSON(data []byte) error { + // JSON null means the sender named no party. Unmarshalling null into a + // non-pointer string is a no-op that reports no error, so without this the + // single-string branch below would accept it and produce [""] — a ForField + // that claims a party whose DID is the empty string. + if string(data) == "null" { + f.values = nil + return nil + } + // Try single string first var single string if err := json.Unmarshal(data, &single); err == nil { diff --git a/types_test.go b/types_test.go index f292a04..ef09549 100644 --- a/types_test.go +++ b/types_test.go @@ -224,3 +224,54 @@ func TestInvoice_JSONRoundTrip(t *testing.T) { t.Errorf("LineItems mismatch: got %v", got.LineItems) } } + +func TestAgent_OwnerlessOmitsForKey(t *testing.T) { + data, err := json.Marshal(Agent{ID: "did:pkh:eip155:1:0xabc", Role: "SettlementAddress"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if bytes.Contains(data, []byte(`"for"`)) { + t.Errorf("ownerless agent must omit the for key, got %s", data) + } + + var got Agent + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !got.For.IsEmpty() { + t.Errorf("round-tripped ownerless agent declares an owner: %v", got.For.Values()) + } +} + +func TestForField_UnmarshalNull(t *testing.T) { + var f ForField + if err := json.Unmarshal([]byte("null"), &f); err != nil { + t.Fatalf("unmarshal null: %v", err) + } + if !f.IsEmpty() { + t.Errorf("null must decode to no values, got %v", f.Values()) + } + if f.String() != "" { + t.Errorf("null must decode to the empty string, got %q", f.String()) + } +} + +func TestAgent_ForNullDecodesToNoOwner(t *testing.T) { + var got Agent + if err := json.Unmarshal([]byte(`{"@id":"did:pkh:eip155:1:0xabc","for":null}`), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !got.For.IsEmpty() { + t.Errorf(`"for": null must decode to no owner, got %v`, got.For.Values()) + } +} + +func TestForField_EmptySliceOmitsKey(t *testing.T) { + data, err := json.Marshal(Agent{ID: "did:eg:agent", For: NewForField()}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if bytes.Contains(data, []byte(`"for"`)) { + t.Errorf("empty ForField must omit the for key, got %s", data) + } +} From d5b5ddce62d6c64042abaed1bcc1e8a26329e8b1 Mon Sep 17 00:00:00 2001 From: Milo Date: Mon, 31 Aug 2026 18:58:42 +0200 Subject: [PATCH 2/3] chore: raise the go directive to 1.26.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit govulncheck reports nine standard-library vulnerabilities against the toolchain the go directive selects, and CI builds with go-version-file: go.mod, so the whole matrix has been red on main since July. Every one of them is fixed by 1.26.6 — the highest "fixed in" across the set — and none come from a dependency, so the directive is the only thing that needs to move. Reachable from this module rather than theoretical: the traces run through Client.ReceiveUnverified into url.URL.Parse, tls.Conn.HandshakeContext and asn1.Unmarshal. A patch-level bump within the same minor, so consumers need a 1.26.6 toolchain rather than a new language version. --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 918ec29..3cb85ac 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/TransactionAuthorizationProtocol/tap-go -go 1.26.2 +go 1.26.6 require ( github.com/google/uuid v1.6.0 From 5eb7824dc67fe10bb1d01b154b6cf40c3cf11ca3 Mon Sep 17 00:00:00 2001 From: Milo Date: Mon, 31 Aug 2026 19:04:03 +0200 Subject: [PATCH 3/3] chore: trim the comments --- types.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/types.go b/types.go index 095174b..3d64cbc 100644 --- a/types.go +++ b/types.go @@ -66,9 +66,7 @@ func (f ForField) IsEmpty() bool { return len(f.values) == 0 } -// IsZero reports whether the ForField carries no values, so `json:",omitzero"` -// omits the key. A ForField holding an empty non-nil slice is not the Go zero -// value but means the same thing on the wire. +// IsZero reports whether the ForField has no values; omitzero reads it. func (f ForField) IsZero() bool { return len(f.values) == 0 } @@ -86,10 +84,8 @@ func (f ForField) MarshalJSON() ([]byte, error) { // UnmarshalJSON unmarshals the ForField from a string or array of strings. func (f *ForField) UnmarshalJSON(data []byte) error { - // JSON null means the sender named no party. Unmarshalling null into a - // non-pointer string is a no-op that reports no error, so without this the - // single-string branch below would accept it and produce [""] — a ForField - // that claims a party whose DID is the empty string. + // null into a non-pointer is a no-op returning no error, so the branch + // below would take it and produce [""]. if string(data) == "null" { f.values = nil return nil