From 62d48a15d1d55be1d1454a5909629d0eecd7093e Mon Sep 17 00:00:00 2001 From: tomasz Date: Fri, 11 Sep 2026 14:49:16 +0200 Subject: [PATCH] fix(taip-5): require `for` on address agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TAIP-5 marks `for` REQUIRED on every agent, and this library never checked it. Agents went out without one and nothing complained — including settlement addresses, where `for` is the only thing telling a receiver whether a customer holds the keys or their VASP does. Every constructor that carries agents now validates them: Transfer, Payment, Connect, Quote, RFQ, AddAgents, Lock, UpdateAgent and ReplaceAgent. The last three validated nothing at all before. `for` is enforced on the blockchain-address roles rather than on every agent. The spec has no way to say that who owns an agent is not established yet, which is a state real flows pass through — an address can be seen before anybody has resolved who custodies it, and an institution can join a transaction before it says whose behalf it acts on. Demanding `for` there would mean inventing an owner, and an invented one is worse than an absent one: a receiver stores it as fact, and it decides whether a wallet must prove ownership or must not. Whoever supplies an address does know whose it is, so that is where the line sits. An empty DID inside `for` is rejected whatever the role. Validation is send-side only. ParseBody accepts inbound agents without `for` regardless of role, so peers that do not set it keep working. Also retags UpdatePartyBody.Role to `partyType` per TAIP-6. The `role` spelling matched no other implementation, so these messages were silently ignored by conformant peers; inbound bodies still read `role` as a fallback. --- CHANGELOG.md | 33 ++++++++++++++++++ add_agents.go | 3 ++ add_agents_test.go | 2 +- agent_validation_test.go | 57 +++++++++++++++++++++++++++++++ client_test.go | 2 +- cmd/tap/message_test.go | 24 +++++++------- cmd/tap/receive_test.go | 4 +-- connect.go | 3 ++ connect_test.go | 6 ++-- lock.go | 3 ++ lock_test.go | 6 ++-- parse_test.go | 20 +++++------ payment.go | 3 ++ payment_test.go | 10 +++--- quote.go | 3 ++ quote_test.go | 18 +++++----- replace_agent.go | 3 ++ replace_agent_test.go | 16 +++++---- rfq.go | 3 ++ rfq_test.go | 8 ++--- transfer.go | 3 ++ transfer_test.go | 4 +-- types.go | 72 +++++++++++++++++++++++++++++++++++++++- update_agent.go | 3 ++ update_agent_test.go | 2 +- update_party.go | 24 ++++++++++++-- update_party_test.go | 69 ++++++++++++++++++++++++++++++++------ 27 files changed, 330 insertions(+), 74 deletions(-) create mode 100644 agent_validation_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e1b2a..70ca4fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Changed + +- **BREAKING** `Agent.For` (`for`) is enforced on the blockchain-address roles + (`SourceAddress`, `SettlementAddress`). `NewTransferMessage`, + `NewPaymentMessage`, `NewConnectMessage`, `NewQuoteMessage`, `NewRFQMessage`, + `NewAddAgentsMessage`, `NewLockMessage`, `NewUpdateAgentMessage` and + `NewReplaceAgentMessage` reject an address agent that omits it, and reject an + empty DID inside `for` whatever the role. + + [TAIP-5] marks `for` REQUIRED on every agent, but provides no way to express + that who owns an agent is not established yet — a state real flows pass + through, since an address can be seen before anybody has resolved who + custodies it. Enforcing the letter of the spec would mean inventing owners, + and an invented `for` is worse than an absent one: a receiver stores it as + fact, and it decides whether a wallet is treated as self-hosted (must prove + ownership) or custodied (must not). So `for` is enforced where the sender + cannot honestly be unsure — whoever supplies an address knows whose it is — + and institutional agents may still travel without one. + + Validation is send-side only: `ParseBody` accepts inbound agents that omit + `for` regardless of role, so peers that do not set it keep working. +- **BREAKING** `UpdatePartyBody.Role` is renamed to `PartyType` and retagged + `partyType`, per [TAIP-6]. The previous `role` spelling matched no other + implementation, so these messages were silently ignored by conformant peers. + Inbound bodies still accept `role` as a fallback. + +### Added + +- `Agent.Validate()` and `ValidateAgents()` report TAIP-5 violations. + +[TAIP-5]: https://github.com/TransactionAuthorizationProtocol/TAIPs/blob/main/TAIPs/taip-5.md +[TAIP-6]: https://github.com/TransactionAuthorizationProtocol/TAIPs/blob/main/TAIPs/taip-6.md + ## [0.7.0] - 2026-07-23 ### Added diff --git a/add_agents.go b/add_agents.go index 0179376..732629d 100644 --- a/add_agents.go +++ b/add_agents.go @@ -22,6 +22,9 @@ func NewAddAgentsMessage(from string, to []string, thid string, body *AddAgentsB if len(body.Agents) == 0 { return nil, fmt.Errorf("%w: missing agents", ErrInvalidBody) } + if err := ValidateAgents(body.Agents); err != nil { + return nil, err + } body.Context = TAPContext body.Type = TypeAddAgents diff --git a/add_agents_test.go b/add_agents_test.go index e163e68..505f0ce 100644 --- a/add_agents_test.go +++ b/add_agents_test.go @@ -80,7 +80,7 @@ func TestAddAgents_TestVectorValid(t *testing.T) { } func TestAddAgentsBody_ParseBody(t *testing.T) { - body := &AddAgentsBody{Agents: []Agent{{ID: "did:web:new"}}} + body := &AddAgentsBody{Agents: []Agent{{ID: "did:web:new", For: NewForField("did:web:new")}}} msg, err := NewAddAgentsMessage("from", []string{"to"}, "thid", body) if err != nil { t.Fatalf("create: %v", err) diff --git a/agent_validation_test.go b/agent_validation_test.go new file mode 100644 index 0000000..dff918f --- /dev/null +++ b/agent_validation_test.go @@ -0,0 +1,57 @@ +package tap + +import ( + "errors" + "testing" +) + +// TAIP-5 marks `for` REQUIRED on every agent, but gives no way to say that who +// owns an agent is not established yet — a state real flows pass through. This +// package enforces `for` where the sender cannot honestly be unsure: the +// blockchain-address roles, whose owner whoever supplied the address knows. +// +// Inventing an owner is worse than omitting one: a receiver stores it as fact, +// and it is the difference between a self-hosted wallet (must prove ownership) +// and a custodied one (must not). + +func TestAgent_Validate(t *testing.T) { + tests := []struct { + name string + agent Agent + wantErr bool + }{ + {"valid single for", Agent{ID: "did:web:vasp.example", For: NewForField("did:eg:bob")}, false}, + {"valid multiple for", Agent{ID: "did:web:shared.wallet", For: NewForField("did:web:vasp1", "did:web:vasp2")}, false}, + {"missing @id", Agent{For: NewForField("did:eg:bob")}, true}, + {"for with empty string", Agent{ID: "did:web:vasp.example", For: NewForField("")}, true}, + + // An address is supplied by somebody who knows whose it is. + {"settlement address without for", Agent{ID: "did:pkh:eip155:1:0xabc", Role: "SettlementAddress"}, true}, + {"source address without for", Agent{ID: "did:pkh:eip155:1:0xabc", Role: "SourceAddress"}, true}, + {"role casing is ignored", Agent{ID: "did:pkh:eip155:1:0xabc", Role: "settlementaddress"}, true}, + { + name: "settlement address with for", + agent: Agent{ID: "did:pkh:eip155:1:0xabc", Role: "SettlementAddress", For: NewForField("did:web:vasp")}, + }, + + // An institution may legitimately appear before anyone has said whose + // behalf it acts on — an agent added mid-transfer is the common case. + {"VASP without for", Agent{ID: "did:web:vasp.example", Role: "VASP"}, false}, + {"no role and no for", Agent{ID: "did:web:unknown.example"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.agent.Validate() + if tt.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + if tt.wantErr && !errors.Is(err, ErrInvalidBody) { + t.Errorf("error should wrap ErrInvalidBody, got %v", err) + } + }) + } +} diff --git a/client_test.go b/client_test.go index b39b3f8..dc552ee 100644 --- a/client_test.go +++ b/client_test.go @@ -83,7 +83,7 @@ func TestTAPResult_TypeAssertion(t *testing.T) { Amount: "100.00", Currency: "USD", Merchant: &Party{ID: "did:example:merchant"}, - Agents: []Agent{{ID: "did:example:merchant"}}, + Agents: []Agent{{ID: "did:example:merchant", For: NewForField("did:example:merchant")}}, } rawBody, _ := json.Marshal(body) diff --git a/cmd/tap/message_test.go b/cmd/tap/message_test.go index 2df1c4b..85ff449 100644 --- a/cmd/tap/message_test.go +++ b/cmd/tap/message_test.go @@ -137,7 +137,7 @@ func TestCLI_MessageMissingThid(t *testing.T) { func TestCLI_MessageTransfer(t *testing.T) { bin := buildBinary(t) - body := `{"asset":"eip155:1/slip44:60","amount":"1.5","agents":[{"@id":"did:key:z1","role":"OriginatingVASP"}]}` + body := `{"asset":"eip155:1/slip44:60","amount":"1.5","agents":[{"@id":"did:key:z1","for":"did:key:z1","role":"OriginatingVASP"}]}` cmd := exec.Command(bin, "message", "transfer", "--from", "did:key:z1", @@ -190,7 +190,7 @@ func TestCLI_MessageTransfer_FileBody(t *testing.T) { dir := t.TempDir() bodyFile := filepath.Join(dir, "body.json") - body := `{"asset":"eip155:1/slip44:60","amount":"2.0","agents":[{"@id":"did:key:z1","role":"OriginatingVASP"}]}` + body := `{"asset":"eip155:1/slip44:60","amount":"2.0","agents":[{"@id":"did:key:z1","for":"did:key:z1","role":"OriginatingVASP"}]}` if err := os.WriteFile(bodyFile, []byte(body), 0o644); err != nil { t.Fatal(err) } @@ -216,7 +216,7 @@ func TestCLI_MessageTransfer_FileBody(t *testing.T) { func TestCLI_MessagePayment(t *testing.T) { bin := buildBinary(t) - body := `{"amount":"100","currency":"USD","merchant":{"@id":"did:key:z2"},"agents":[{"@id":"did:key:z1","role":"OriginatingVASP"}]}` + body := `{"amount":"100","currency":"USD","merchant":{"@id":"did:key:z2"},"agents":[{"@id":"did:key:z1","for":"did:key:z1","role":"OriginatingVASP"}]}` cmd := exec.Command(bin, "message", "payment", "--from", "did:key:z1", @@ -239,7 +239,7 @@ func TestCLI_MessagePayment(t *testing.T) { func TestCLI_MessageRFQ(t *testing.T) { bin := buildBinary(t) - body := `{"fromAssets":["eip155:1/slip44:60"],"toAssets":["eip155:1/slip44:0"],"fromAmount":"1.0","requester":{"@id":"did:key:z1"},"agents":[{"@id":"did:key:z1","role":"OriginatingVASP"}]}` + body := `{"fromAssets":["eip155:1/slip44:60"],"toAssets":["eip155:1/slip44:0"],"fromAmount":"1.0","requester":{"@id":"did:key:z1"},"agents":[{"@id":"did:key:z1","for":"did:key:z1","role":"OriginatingVASP"}]}` cmd := exec.Command(bin, "message", "rfq", "--from", "did:key:z1", @@ -262,7 +262,7 @@ func TestCLI_MessageRFQ(t *testing.T) { func TestCLI_MessageLock(t *testing.T) { bin := buildBinary(t) - body := `{"asset":"eip155:1/slip44:60","amount":"5.0","originator":{"@id":"did:key:z1"},"beneficiary":{"@id":"did:key:z2"},"expiry":"2025-12-31T23:59:59Z","agents":[{"@id":"did:key:z1","role":"OriginatingVASP"}]}` + body := `{"asset":"eip155:1/slip44:60","amount":"5.0","originator":{"@id":"did:key:z1"},"beneficiary":{"@id":"did:key:z2"},"expiry":"2025-12-31T23:59:59Z","agents":[{"@id":"did:key:z1","for":"did:key:z1","role":"OriginatingVASP"}]}` cmd := exec.Command(bin, "message", "lock", "--from", "did:key:z1", @@ -285,7 +285,7 @@ func TestCLI_MessageLock(t *testing.T) { func TestCLI_MessageConnect(t *testing.T) { bin := buildBinary(t) - body := `{"requester":{"@id":"did:key:z1"},"principal":{"@id":"did:key:z1"},"agents":[{"@id":"did:key:z1","role":"OriginatingVASP"}],"constraints":{"purposes":["travel"]}}` + body := `{"requester":{"@id":"did:key:z1"},"principal":{"@id":"did:key:z1"},"agents":[{"@id":"did:key:z1","for":"did:key:z1","role":"OriginatingVASP"}],"constraints":{"purposes":["travel"]}}` cmd := exec.Command(bin, "message", "connect", "--from", "did:key:z1", @@ -475,7 +475,7 @@ func TestCLI_MessageCapture(t *testing.T) { func TestCLI_MessageQuote(t *testing.T) { bin := buildBinary(t) - body := `{"fromAsset":"eip155:1/slip44:60","toAsset":"eip155:1/slip44:0","fromAmount":"1.0","toAmount":"2000.0","provider":{"@id":"did:key:z1"},"agents":[{"@id":"did:key:z1","role":"Provider"}],"expiresAt":"2025-12-31T23:59:59Z"}` + body := `{"fromAsset":"eip155:1/slip44:60","toAsset":"eip155:1/slip44:0","fromAmount":"1.0","toAmount":"2000.0","provider":{"@id":"did:key:z1"},"agents":[{"@id":"did:key:z1","for":"did:key:z1","role":"Provider"}],"expiresAt":"2025-12-31T23:59:59Z"}` cmd := exec.Command(bin, "message", "quote", "--from", "did:key:z1", @@ -499,7 +499,7 @@ func TestCLI_MessageQuote(t *testing.T) { func TestCLI_MessageAddAgents(t *testing.T) { bin := buildBinary(t) - body := `{"agents":[{"@id":"did:key:z3","role":"IntermediaryVASP"}]}` + body := `{"agents":[{"@id":"did:key:z3","for":"did:key:z1","role":"IntermediaryVASP"}]}` cmd := exec.Command(bin, "message", "add-agents", "--from", "did:key:z1", @@ -547,7 +547,7 @@ func TestCLI_MessageRemoveAgent(t *testing.T) { func TestCLI_MessageReplaceAgent(t *testing.T) { bin := buildBinary(t) - body := `{"original":"did:key:z3","replacement":{"@id":"did:key:z4","role":"IntermediaryVASP"}}` + body := `{"original":"did:key:z3","replacement":{"@id":"did:key:z4","for":"did:key:z1","role":"IntermediaryVASP"}}` cmd := exec.Command(bin, "message", "replace-agent", "--from", "did:key:z1", @@ -571,7 +571,7 @@ func TestCLI_MessageReplaceAgent(t *testing.T) { func TestCLI_MessageUpdateAgent(t *testing.T) { bin := buildBinary(t) - body := `{"agent":{"@id":"did:key:z3","role":"IntermediaryVASP"}}` + body := `{"agent":{"@id":"did:key:z3","for":"did:key:z1","role":"IntermediaryVASP"}}` cmd := exec.Command(bin, "message", "update-agent", "--from", "did:key:z1", @@ -668,7 +668,7 @@ func TestCLI_MessageConfirmRelationship(t *testing.T) { // TestCLI_MessageTransfer_MultipleRecipients tests comma-separated --to flag. func TestCLI_MessageTransfer_MultipleRecipients(t *testing.T) { bin := buildBinary(t) - body := `{"asset":"eip155:1/slip44:60","amount":"1.0","agents":[{"@id":"did:key:z1","role":"OriginatingVASP"}]}` + body := `{"asset":"eip155:1/slip44:60","amount":"1.0","agents":[{"@id":"did:key:z1","for":"did:key:z1","role":"OriginatingVASP"}]}` cmd := exec.Command(bin, "message", "transfer", "--from", "did:key:z1", @@ -700,7 +700,7 @@ func TestCLI_MessageTransfer_MultipleRecipients(t *testing.T) { func TestCLI_MessageTransfer_BodyValidation(t *testing.T) { bin := buildBinary(t) // Missing required "asset" field - body := `{"amount":"1.0","agents":[{"@id":"did:key:z1"}]}` + body := `{"amount":"1.0","agents":[{"@id":"did:key:z1","for":"did:key:z1"}]}` cmd := exec.Command(bin, "message", "transfer", "--from", "did:key:z1", diff --git a/cmd/tap/receive_test.go b/cmd/tap/receive_test.go index 9a0b4a2..b9195c5 100644 --- a/cmd/tap/receive_test.go +++ b/cmd/tap/receive_test.go @@ -98,7 +98,7 @@ func TestCLI_Receive_SignedTransfer(t *testing.T) { msg, err := tap.NewTransferMessage(aliceDoc.ID, []string{aliceDoc.ID}, &tap.TransferBody{ Asset: "eip155:1/slip44:60", Amount: "1.5", - Agents: []tap.Agent{{ID: aliceDoc.ID, Role: "OriginatingVASP"}}, + Agents: []tap.Agent{{ID: aliceDoc.ID, For: tap.NewForField(aliceDoc.ID), Role: "OriginatingVASP"}}, }) if err != nil { t.Fatal(err) @@ -169,7 +169,7 @@ func TestCLI_MessagePipe_Transfer(t *testing.T) { aliceDir := filepath.Join(t.TempDir(), "alice") aliceDoc, aliceKM := generateIdentity(t, aliceDir) - body := `{"asset":"eip155:1/slip44:60","amount":"3.0","agents":[{"@id":"` + aliceDoc.ID + `","role":"OriginatingVASP"}]}` + body := `{"asset":"eip155:1/slip44:60","amount":"3.0","agents":[{"@id":"` + aliceDoc.ID + `","for":"` + aliceDoc.ID + `","role":"OriginatingVASP"}]}` msgOut, err := exec.Command(bin, "message", "transfer", "--from", aliceDoc.ID, "--to", aliceDoc.ID, "--body", body, ).Output() diff --git a/connect.go b/connect.go index bde709c..1e4de73 100644 --- a/connect.go +++ b/connect.go @@ -53,6 +53,9 @@ func NewConnectMessage(from string, to []string, body *ConnectBody) (*didcomm.Me if len(body.Agents) == 0 { return nil, fmt.Errorf("%w: missing agents", ErrInvalidBody) } + if err := ValidateAgents(body.Agents); err != nil { + return nil, err + } if body.Constraints == nil { return nil, fmt.Errorf("%w: missing constraints", ErrInvalidBody) } diff --git a/connect_test.go b/connect_test.go index 2d97dda..f3330c6 100644 --- a/connect_test.go +++ b/connect_test.go @@ -32,7 +32,7 @@ func TestNewConnectMessage_MissingFields(t *testing.T) { base := ConnectBody{ Requester: &Party{ID: "did:web:req"}, Principal: &Party{ID: "did:web:princ"}, - Agents: []Agent{{ID: "did:web:agent"}}, + Agents: []Agent{{ID: "did:web:agent", For: NewForField("did:web:agent")}}, Constraints: &TransactionConstraints{}, } @@ -63,7 +63,7 @@ func TestConnectBody_JSONRoundTrip(t *testing.T) { Type: TypeConnect, Requester: &Party{ID: "did:web:req"}, Principal: &Party{ID: "did:web:princ"}, - Agents: []Agent{{ID: "did:web:agent"}}, + Agents: []Agent{{ID: "did:web:agent", For: NewForField("did:web:agent")}}, Constraints: &TransactionConstraints{ Purposes: []string{"BEXP"}, Limits: &Limits{PerTransaction: "10000.00", Currency: "USD"}, @@ -121,7 +121,7 @@ func TestConnectBody_ParseBody(t *testing.T) { body := &ConnectBody{ Requester: &Party{ID: "did:web:req"}, Principal: &Party{ID: "did:web:princ"}, - Agents: []Agent{{ID: "did:web:agent"}}, + Agents: []Agent{{ID: "did:web:agent", For: NewForField("did:web:agent")}}, Constraints: &TransactionConstraints{Purposes: []string{"BEXP"}}, } msg, err := NewConnectMessage("did:web:req", []string{"did:web:vasp"}, body) diff --git a/lock.go b/lock.go index fd2b630..4a9950c 100644 --- a/lock.go +++ b/lock.go @@ -41,6 +41,9 @@ func NewLockMessage(from string, to []string, body *LockBody) (*didcomm.Message, if len(body.Agents) == 0 { return nil, fmt.Errorf("%w: missing agents", ErrInvalidBody) } + if err := ValidateAgents(body.Agents); err != nil { + return nil, err + } if body.Asset == "" && body.Currency == "" { return nil, fmt.Errorf("%w: missing asset or currency", ErrInvalidBody) } diff --git a/lock_test.go b/lock_test.go index 0a5b1c0..3969959 100644 --- a/lock_test.go +++ b/lock_test.go @@ -15,7 +15,7 @@ func TestNewLockMessage(t *testing.T) { Beneficiary: &Party{ID: "did:web:seller.example"}, Expiry: "2024-03-15T00:00:00Z", Agents: []Agent{ - {ID: "did:web:escrow-service.example", Role: "EscrowAgent"}, + {ID: "did:web:escrow-service.example", Role: "EscrowAgent", For: NewForField("did:web:buyer.example")}, }, } @@ -35,7 +35,7 @@ func TestNewLockMessage_MissingFields(t *testing.T) { Originator: &Party{ID: "did:web:buyer"}, Beneficiary: &Party{ID: "did:web:seller"}, Expiry: "2024-03-15T00:00:00Z", - Agents: []Agent{{ID: "did:web:escrow", Role: "EscrowAgent"}}, + Agents: []Agent{{ID: "did:web:escrow", Role: "EscrowAgent", For: NewForField("did:web:buyer")}}, } tests := []struct { @@ -126,7 +126,7 @@ func TestLockBody_ParseBody(t *testing.T) { Originator: &Party{ID: "did:web:buyer"}, Beneficiary: &Party{ID: "did:web:seller"}, Expiry: "2024-12-31T00:00:00Z", - Agents: []Agent{{ID: "did:web:escrow", Role: "EscrowAgent"}}, + Agents: []Agent{{ID: "did:web:escrow", Role: "EscrowAgent", For: NewForField("did:web:buyer")}}, } msg, err := NewLockMessage("did:web:buyer", []string{"did:web:escrow"}, body) if err != nil { diff --git a/parse_test.go b/parse_test.go index 95af6d6..8723e0d 100644 --- a/parse_test.go +++ b/parse_test.go @@ -51,11 +51,11 @@ func TestParseBody_AllTypes(t *testing.T) { msgType string body any }{ - {"Transfer", TypeTransfer, &TransferBody{Context: TAPContext, Type: TypeTransfer, Asset: "ETH", Agents: []Agent{{ID: "a"}}}}, - {"Payment", TypePayment, &PaymentBody{Context: TAPContext, Type: TypePayment, Amount: "100", Currency: "USD", Merchant: &Party{ID: "m"}, Agents: []Agent{{ID: "a"}}}}, - {"RFQ", TypeRFQ, &RFQBody{Context: TAPContext, Type: TypeRFQ, FromAssets: []string{"ETH"}, ToAssets: []string{"USD"}, FromAmount: "1", Requester: &Party{ID: "r"}, Agents: []Agent{{ID: "a"}}}}, - {"Quote", TypeQuote, &QuoteBody{Context: TAPContext, Type: TypeQuote, FromAsset: "ETH", ToAsset: "USD", FromAmount: "1", ToAmount: "3000", Provider: &Party{ID: "p"}, Agents: []Agent{{ID: "a"}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, - {"Lock", TypeLock, &LockBody{Context: TAPContext, Type: TypeLock, Asset: "ETH", Amount: "100", Originator: &Party{ID: "o"}, Beneficiary: &Party{ID: "b"}, Expiry: "2025-01-01T00:00:00Z", Agents: []Agent{{ID: "a"}}}}, + {"Transfer", TypeTransfer, &TransferBody{Context: TAPContext, Type: TypeTransfer, Asset: "ETH", Agents: []Agent{{ID: "a", For: NewForField("a")}}}}, + {"Payment", TypePayment, &PaymentBody{Context: TAPContext, Type: TypePayment, Amount: "100", Currency: "USD", Merchant: &Party{ID: "m"}, Agents: []Agent{{ID: "a", For: NewForField("a")}}}}, + {"RFQ", TypeRFQ, &RFQBody{Context: TAPContext, Type: TypeRFQ, FromAssets: []string{"ETH"}, ToAssets: []string{"USD"}, FromAmount: "1", Requester: &Party{ID: "r"}, Agents: []Agent{{ID: "a", For: NewForField("a")}}}}, + {"Quote", TypeQuote, &QuoteBody{Context: TAPContext, Type: TypeQuote, FromAsset: "ETH", ToAsset: "USD", FromAmount: "1", ToAmount: "3000", Provider: &Party{ID: "p"}, Agents: []Agent{{ID: "a", For: NewForField("a")}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, + {"Lock", TypeLock, &LockBody{Context: TAPContext, Type: TypeLock, Asset: "ETH", Amount: "100", Originator: &Party{ID: "o"}, Beneficiary: &Party{ID: "b"}, Expiry: "2025-01-01T00:00:00Z", Agents: []Agent{{ID: "a", For: NewForField("a")}}}}, {"Authorize", TypeAuthorize, &AuthorizeBody{Context: TAPContext, Type: TypeAuthorize}}, {"AuthorizationRequired", TypeAuthorizationRequired, &AuthorizationRequiredBody{Context: TAPContext, Type: TypeAuthorizationRequired, AuthorizationURL: "https://example.com", Expires: "2025-01-01T00:00:00Z"}}, {"Settle", TypeSettle, &SettleBody{Context: TAPContext, Type: TypeSettle, SettlementAddress: "eip155:1:0x1234"}}, @@ -63,14 +63,14 @@ func TestParseBody_AllTypes(t *testing.T) { {"Cancel", TypeCancel, &CancelBody{Context: TAPContext, Type: TypeCancel, By: "originator"}}, {"Revert", TypeRevert, &RevertBody{Context: TAPContext, Type: TypeRevert, SettlementAddress: "eip155:1:0x1234", Reason: "test"}}, {"Capture", TypeCapture, &CaptureBody{Context: TAPContext, Type: TypeCapture}}, - {"UpdateAgent", TypeUpdateAgent, &UpdateAgentBody{Context: TAPContext, Type: TypeUpdateAgent, Agent: &Agent{ID: "a"}}}, - {"UpdateParty", TypeUpdateParty, &UpdatePartyBody{Context: TAPContext, Type: TypeUpdateParty, Party: &Party{ID: "p"}, Role: "originator"}}, - {"AddAgents", TypeAddAgents, &AddAgentsBody{Context: TAPContext, Type: TypeAddAgents, Agents: []Agent{{ID: "a"}}}}, - {"ReplaceAgent", TypeReplaceAgent, &ReplaceAgentBody{Context: TAPContext, Type: TypeReplaceAgent, Original: "old", Replacement: &Agent{ID: "new"}}}, + {"UpdateAgent", TypeUpdateAgent, &UpdateAgentBody{Context: TAPContext, Type: TypeUpdateAgent, Agent: &Agent{ID: "a", For: NewForField("a")}}}, + {"UpdateParty", TypeUpdateParty, &UpdatePartyBody{Context: TAPContext, Type: TypeUpdateParty, Party: &Party{ID: "p"}, PartyType: "originator"}}, + {"AddAgents", TypeAddAgents, &AddAgentsBody{Context: TAPContext, Type: TypeAddAgents, Agents: []Agent{{ID: "a", For: NewForField("a")}}}}, + {"ReplaceAgent", TypeReplaceAgent, &ReplaceAgentBody{Context: TAPContext, Type: TypeReplaceAgent, Original: "old", Replacement: &Agent{ID: "new", For: NewForField("new")}}}, {"RemoveAgent", TypeRemoveAgent, &RemoveAgentBody{Context: TAPContext, Type: TypeRemoveAgent, Agent: "did:web:old"}}, {"ConfirmRelationship", TypeConfirmRelationship, &ConfirmRelationshipBody{Context: TAPContext, Type: TypeAgent, ID: "did:pkh:eip155:1:0x1234", For: NewForField("did:web:beneficiary.vasp"), Role: "SettlementAddress"}}, {"UpdatePolicies", TypeUpdatePolicies, &UpdatePoliciesBody{Context: TAPContext, Type: TypeUpdatePolicies, Policies: []Policy{{Type: "RequireAuthorization"}}}}, - {"Connect", TypeConnect, &ConnectBody{Context: TAPContext, Type: TypeConnect, Requester: &Party{ID: "r"}, Principal: &Party{ID: "p"}, Agents: []Agent{{ID: "a"}}, Constraints: &TransactionConstraints{}}}, + {"Connect", TypeConnect, &ConnectBody{Context: TAPContext, Type: TypeConnect, Requester: &Party{ID: "r"}, Principal: &Party{ID: "p"}, Agents: []Agent{{ID: "a", For: NewForField("a")}}, Constraints: &TransactionConstraints{}}}, } for _, tt := range tests { diff --git a/payment.go b/payment.go index 980c917..4264e97 100644 --- a/payment.go +++ b/payment.go @@ -38,6 +38,9 @@ func NewPaymentMessage(from string, to []string, body *PaymentBody) (*didcomm.Me if len(body.Agents) == 0 { return nil, fmt.Errorf("%w: missing agents", ErrInvalidBody) } + if err := ValidateAgents(body.Agents); err != nil { + return nil, err + } if body.Asset == "" && body.Currency == "" { return nil, fmt.Errorf("%w: missing asset or currency", ErrInvalidBody) } diff --git a/payment_test.go b/payment_test.go index f404196..41b94d0 100644 --- a/payment_test.go +++ b/payment_test.go @@ -38,7 +38,7 @@ func TestNewPaymentMessage_MissingAmount(t *testing.T) { body := &PaymentBody{ Currency: "EUR", Merchant: &Party{ID: "did:example:merchant"}, - Agents: []Agent{{ID: "did:example:merchant"}}, + Agents: []Agent{{ID: "did:example:merchant", For: NewForField("did:example:merchant")}}, } _, err := NewPaymentMessage("did:example:merchant", nil, body) if !errors.Is(err, ErrInvalidBody) { @@ -50,7 +50,7 @@ func TestNewPaymentMessage_MissingMerchant(t *testing.T) { body := &PaymentBody{ Amount: "250.00", Currency: "EUR", - Agents: []Agent{{ID: "did:example:merchant"}}, + Agents: []Agent{{ID: "did:example:merchant", For: NewForField("did:example:merchant")}}, } _, err := NewPaymentMessage("did:example:merchant", nil, body) if !errors.Is(err, ErrInvalidBody) { @@ -62,7 +62,7 @@ func TestNewPaymentMessage_MissingAssetAndCurrency(t *testing.T) { body := &PaymentBody{ Amount: "250.00", Merchant: &Party{ID: "did:example:merchant"}, - Agents: []Agent{{ID: "did:example:merchant"}}, + Agents: []Agent{{ID: "did:example:merchant", For: NewForField("did:example:merchant")}}, } _, err := NewPaymentMessage("did:example:merchant", nil, body) if !errors.Is(err, ErrInvalidBody) { @@ -77,7 +77,7 @@ func TestPaymentBody_JSONRoundTrip(t *testing.T) { Amount: "250.00", Currency: "EUR", Merchant: &Party{ID: "did:example:merchant", Name: "Shop"}, - Agents: []Agent{{ID: "did:example:merchant"}}, + Agents: []Agent{{ID: "did:example:merchant", For: NewForField("did:example:merchant")}}, SupportedAssets: []any{ "eip155:1/erc20:0xA0b86991c53D94fa4C0bCBf0C1C4DF2F15F1b7A8", }, @@ -132,7 +132,7 @@ func TestPaymentBody_ParseBody(t *testing.T) { Amount: "100.00", Currency: "USD", Merchant: &Party{ID: "did:example:merchant"}, - Agents: []Agent{{ID: "did:example:merchant"}}, + Agents: []Agent{{ID: "did:example:merchant", For: NewForField("did:example:merchant")}}, } msg, err := NewPaymentMessage("did:example:merchant", []string{"did:example:customer"}, body) if err != nil { diff --git a/quote.go b/quote.go index 90b98ef..d14069e 100644 --- a/quote.go +++ b/quote.go @@ -43,6 +43,9 @@ func NewQuoteMessage(from string, to []string, thid string, body *QuoteBody) (*d if len(body.Agents) == 0 { return nil, fmt.Errorf("%w: missing agents", ErrInvalidBody) } + if err := ValidateAgents(body.Agents); err != nil { + return nil, err + } if body.ExpiresAt == "" { return nil, fmt.Errorf("%w: missing expiresAt", ErrInvalidBody) } diff --git a/quote_test.go b/quote_test.go index 090d00a..857145c 100644 --- a/quote_test.go +++ b/quote_test.go @@ -13,7 +13,7 @@ func TestNewQuoteMessage(t *testing.T) { FromAmount: "1.0", ToAmount: "3000.00", Provider: &Party{ID: "did:web:provider.example"}, - Agents: []Agent{{ID: "did:web:provider.example"}}, + Agents: []Agent{{ID: "did:web:provider.example", For: NewForField("did:web:provider.example")}}, ExpiresAt: "2025-03-22T15:00:00Z", } @@ -34,13 +34,13 @@ func TestNewQuoteMessage_MissingFields(t *testing.T) { name string body *QuoteBody }{ - {"missing fromAsset", &QuoteBody{ToAsset: "USD", FromAmount: "1", ToAmount: "3000", Provider: &Party{ID: "did:eg:p"}, Agents: []Agent{{ID: "a"}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, - {"missing toAsset", &QuoteBody{FromAsset: "ETH", FromAmount: "1", ToAmount: "3000", Provider: &Party{ID: "did:eg:p"}, Agents: []Agent{{ID: "a"}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, - {"missing fromAmount", &QuoteBody{FromAsset: "ETH", ToAsset: "USD", ToAmount: "3000", Provider: &Party{ID: "did:eg:p"}, Agents: []Agent{{ID: "a"}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, - {"missing toAmount", &QuoteBody{FromAsset: "ETH", ToAsset: "USD", FromAmount: "1", Provider: &Party{ID: "did:eg:p"}, Agents: []Agent{{ID: "a"}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, - {"missing provider", &QuoteBody{FromAsset: "ETH", ToAsset: "USD", FromAmount: "1", ToAmount: "3000", Agents: []Agent{{ID: "a"}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, + {"missing fromAsset", &QuoteBody{ToAsset: "USD", FromAmount: "1", ToAmount: "3000", Provider: &Party{ID: "did:eg:p"}, Agents: []Agent{{ID: "a", For: NewForField("a")}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, + {"missing toAsset", &QuoteBody{FromAsset: "ETH", FromAmount: "1", ToAmount: "3000", Provider: &Party{ID: "did:eg:p"}, Agents: []Agent{{ID: "a", For: NewForField("a")}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, + {"missing fromAmount", &QuoteBody{FromAsset: "ETH", ToAsset: "USD", ToAmount: "3000", Provider: &Party{ID: "did:eg:p"}, Agents: []Agent{{ID: "a", For: NewForField("a")}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, + {"missing toAmount", &QuoteBody{FromAsset: "ETH", ToAsset: "USD", FromAmount: "1", Provider: &Party{ID: "did:eg:p"}, Agents: []Agent{{ID: "a", For: NewForField("a")}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, + {"missing provider", &QuoteBody{FromAsset: "ETH", ToAsset: "USD", FromAmount: "1", ToAmount: "3000", Agents: []Agent{{ID: "a", For: NewForField("a")}}, ExpiresAt: "2025-01-01T00:00:00Z"}}, {"missing agents", &QuoteBody{FromAsset: "ETH", ToAsset: "USD", FromAmount: "1", ToAmount: "3000", Provider: &Party{ID: "did:eg:p"}, ExpiresAt: "2025-01-01T00:00:00Z"}}, - {"missing expiresAt", &QuoteBody{FromAsset: "ETH", ToAsset: "USD", FromAmount: "1", ToAmount: "3000", Provider: &Party{ID: "did:eg:p"}, Agents: []Agent{{ID: "a"}}}}, + {"missing expiresAt", &QuoteBody{FromAsset: "ETH", ToAsset: "USD", FromAmount: "1", ToAmount: "3000", Provider: &Party{ID: "did:eg:p"}, Agents: []Agent{{ID: "a", For: NewForField("a")}}}}, } for _, tt := range tests { @@ -62,7 +62,7 @@ func TestQuoteBody_JSONRoundTrip(t *testing.T) { FromAmount: "1.0", ToAmount: "3000.00", Provider: &Party{ID: "did:web:provider.example"}, - Agents: []Agent{{ID: "did:web:provider.example"}}, + Agents: []Agent{{ID: "did:web:provider.example", For: NewForField("did:web:provider.example")}}, ExpiresAt: "2025-03-22T15:00:00Z", } @@ -87,7 +87,7 @@ func TestQuoteBody_ParseBody(t *testing.T) { FromAmount: "1", ToAmount: "3000", Provider: &Party{ID: "did:web:provider"}, - Agents: []Agent{{ID: "did:web:provider"}}, + Agents: []Agent{{ID: "did:web:provider", For: NewForField("did:web:provider")}}, ExpiresAt: "2025-01-01T00:00:00Z", } msg, err := NewQuoteMessage("from", nil, "thid", body) diff --git a/replace_agent.go b/replace_agent.go index 8255071..00abca5 100644 --- a/replace_agent.go +++ b/replace_agent.go @@ -26,6 +26,9 @@ func NewReplaceAgentMessage(from string, to []string, thid string, body *Replace if body.Replacement == nil { return nil, fmt.Errorf("%w: missing replacement", ErrInvalidBody) } + if err := body.Replacement.Validate(); err != nil { + return nil, err + } body.Context = TAPContext body.Type = TypeReplaceAgent diff --git a/replace_agent_test.go b/replace_agent_test.go index ea74ae1..5e66626 100644 --- a/replace_agent_test.go +++ b/replace_agent_test.go @@ -9,8 +9,12 @@ import ( func TestNewReplaceAgentMessage(t *testing.T) { body := &ReplaceAgentBody{ - Original: "did:pkh:eip155:1:0xabcda96D359eC26a11e2C2b3d8f8B8942d5Bfcdb", - Replacement: &Agent{ID: "did:pkh:eip155:1:0x1234a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb", Role: "settlementAddress"}, + Original: "did:pkh:eip155:1:0xabcda96D359eC26a11e2C2b3d8f8B8942d5Bfcdb", + Replacement: &Agent{ + ID: "did:pkh:eip155:1:0x1234a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb", + Role: "SettlementAddress", + For: NewForField("did:web:beneficiary.vasp"), + }, } msg, err := NewReplaceAgentMessage("did:web:beneficiary.vasp", []string{"did:web:originator.vasp"}, "thread-1", body) if err != nil { @@ -26,7 +30,7 @@ func TestNewReplaceAgentMessage_MissingFields(t *testing.T) { name string body *ReplaceAgentBody }{ - {"missing original", &ReplaceAgentBody{Replacement: &Agent{ID: "did:web:new"}}}, + {"missing original", &ReplaceAgentBody{Replacement: &Agent{ID: "did:web:new", For: NewForField("did:web:new")}}}, {"missing replacement", &ReplaceAgentBody{Original: "did:web:old"}}, } for _, tt := range tests { @@ -44,7 +48,7 @@ func TestReplaceAgentBody_JSONRoundTrip(t *testing.T) { Context: TAPContext, Type: TypeReplaceAgent, Original: "did:web:old-agent", - Replacement: &Agent{ID: "did:web:new-agent", Role: "settlementAddress"}, + Replacement: &Agent{ID: "did:web:new-agent", Role: "SettlementAddress"}, } data, err := json.Marshal(body) @@ -84,13 +88,13 @@ func TestReplaceAgent_TestVectorValid(t *testing.T) { if body.Original != "did:pkh:eip155:1:0xabcda96D359eC26a11e2C2b3d8f8B8942d5Bfcdb" { t.Errorf("Original: got %q", body.Original) } - if body.Replacement == nil || body.Replacement.Role != "settlementAddress" { + if body.Replacement == nil || body.Replacement.Role != "SettlementAddress" { t.Errorf("Replacement: got %+v", body.Replacement) } } func TestReplaceAgentBody_ParseBody(t *testing.T) { - body := &ReplaceAgentBody{Original: "did:web:old", Replacement: &Agent{ID: "did:web:new"}} + body := &ReplaceAgentBody{Original: "did:web:old", Replacement: &Agent{ID: "did:web:new", For: NewForField("did:web:new")}} msg, err := NewReplaceAgentMessage("from", []string{"to"}, "thid", body) if err != nil { t.Fatalf("create: %v", err) diff --git a/rfq.go b/rfq.go index 571ab1b..c3b473b 100644 --- a/rfq.go +++ b/rfq.go @@ -38,6 +38,9 @@ func NewRFQMessage(from string, to []string, body *RFQBody) (*didcomm.Message, e if len(body.Agents) == 0 { return nil, fmt.Errorf("%w: missing agents", ErrInvalidBody) } + if err := ValidateAgents(body.Agents); err != nil { + return nil, err + } if body.FromAmount == "" && body.ToAmount == "" { return nil, fmt.Errorf("%w: missing fromAmount or toAmount", ErrInvalidBody) } diff --git a/rfq_test.go b/rfq_test.go index 0aeb9af..567ffeb 100644 --- a/rfq_test.go +++ b/rfq_test.go @@ -37,7 +37,7 @@ func TestNewRFQMessage_MissingFromAssets(t *testing.T) { ToAssets: []string{"USD"}, FromAmount: "1.0", Requester: &Party{ID: "did:eg:alice"}, - Agents: []Agent{{ID: "did:web:exchange.example"}}, + Agents: []Agent{{ID: "did:web:exchange.example", For: NewForField("did:web:exchange.example")}}, } _, err := NewRFQMessage("from", nil, body) if !errors.Is(err, ErrInvalidBody) { @@ -50,7 +50,7 @@ func TestNewRFQMessage_MissingAmounts(t *testing.T) { FromAssets: []string{"ETH"}, ToAssets: []string{"USD"}, Requester: &Party{ID: "did:eg:alice"}, - Agents: []Agent{{ID: "did:web:exchange.example"}}, + Agents: []Agent{{ID: "did:web:exchange.example", For: NewForField("did:web:exchange.example")}}, } _, err := NewRFQMessage("from", nil, body) if !errors.Is(err, ErrInvalidBody) { @@ -66,7 +66,7 @@ func TestRFQBody_JSONRoundTrip(t *testing.T) { ToAssets: []string{"USD"}, FromAmount: "1.0", Requester: &Party{ID: "did:eg:alice"}, - Agents: []Agent{{ID: "did:web:exchange.example"}}, + Agents: []Agent{{ID: "did:web:exchange.example", For: NewForField("did:web:exchange.example")}}, } data, err := json.Marshal(body) @@ -89,7 +89,7 @@ func TestRFQBody_ParseBody(t *testing.T) { ToAssets: []string{"USD"}, ToAmount: "3000.00", Requester: &Party{ID: "did:eg:alice"}, - Agents: []Agent{{ID: "did:web:exchange.example"}}, + Agents: []Agent{{ID: "did:web:exchange.example", For: NewForField("did:web:exchange.example")}}, } msg, err := NewRFQMessage("did:web:exchange.example", nil, body) if err != nil { diff --git a/transfer.go b/transfer.go index 71e899f..7699888 100644 --- a/transfer.go +++ b/transfer.go @@ -42,6 +42,9 @@ func NewTransferMessage(from string, to []string, body *TransferBody) (*didcomm. if len(body.Agents) == 0 { return nil, fmt.Errorf("%w: missing agents", ErrInvalidBody) } + if err := ValidateAgents(body.Agents); err != nil { + return nil, err + } body.Context = TAPContext body.Type = TypeTransfer diff --git a/transfer_test.go b/transfer_test.go index d4b9ef8..950b441 100644 --- a/transfer_test.go +++ b/transfer_test.go @@ -49,7 +49,7 @@ func TestNewTransferMessage(t *testing.T) { func TestNewTransferMessage_MissingAsset(t *testing.T) { body := &TransferBody{ - Agents: []Agent{{ID: "did:web:originator.vasp"}}, + Agents: []Agent{{ID: "did:web:originator.vasp", For: NewForField("did:web:originator.vasp")}}, } _, err := NewTransferMessage("did:web:originator.vasp", []string{"did:web:beneficiary.vasp"}, body) if !errors.Is(err, ErrInvalidBody) { @@ -157,7 +157,7 @@ func TestTransferBody_TransactionValue(t *testing.T) { // transactionValue is optional — confirm omitempty when nil bodyNoValue := &TransferBody{ Asset: "eip155:1/slip44:60", - Agents: []Agent{{ID: "did:web:originator.vasp"}}, + Agents: []Agent{{ID: "did:web:originator.vasp", For: NewForField("did:web:originator.vasp")}}, } msg2, err := NewTransferMessage("did:web:originator.vasp", nil, bodyNoValue) if err != nil { diff --git a/types.go b/types.go index 3d64cbc..95d957f 100644 --- a/types.go +++ b/types.go @@ -1,6 +1,21 @@ package tap -import "encoding/json" +import ( + "encoding/json" + "fmt" + "strings" +) + +// The TAIP-5 roles this package attaches meaning to. Others (CustodialService, +// EscrowAgent, and anything a future TAIP adds) pass through untouched. +// +// TAIP-5 also allows `role` to be an array of strings. Agent.Role is a plain +// string, so such an agent fails to unmarshal rather than validating wrongly — +// supporting it needs a string-or-array type like ForField. +const ( + RoleSourceAddress = "SourceAddress" + RoleSettlementAddress = "SettlementAddress" +) // Party represents a real-world entity (legal or natural person) involved in a transaction. type Party struct { @@ -37,6 +52,61 @@ type Agent struct { ServiceURL string `json:"serviceUrl,omitempty"` } +// Validate checks that the Agent can be placed by a receiver. +// +// TAIP-5 marks two attributes REQUIRED — `@id` and `for` — but has no way to +// say "who owns this agent is not established yet", which is a state a real +// flow passes through: an address may be seen before anybody has resolved who +// custodies it. Rejecting those outright would mean inventing an owner, and an +// invented `for` is worse than an absent one — a receiver stores it as a fact, +// and downstream it is the difference between a self-hosted wallet (which must +// prove ownership) and a custodied one (which must not). +// +// So the line is drawn where the sender cannot honestly be unsure: +// - `@id` is always required; an agent nobody can name is unusable. +// - `for` is required on the blockchain-address roles (`SourceAddress`, +// `SettlementAddress`). Whoever puts an address on a transaction knows +// whose address it is — that is the case TAIP-5 exists to pin down, and +// the one that was silently going out empty. +// - `for`, when present, must not hold empty DIDs, whatever the role. +// +// Agents whose `for` is genuinely not yet known travel without it. +func (a Agent) Validate() error { + if a.ID == "" { + return fmt.Errorf("%w: agent missing @id", ErrInvalidBody) + } + if a.For.IsEmpty() && a.addressRole() { + return fmt.Errorf("%w: agent %s (%s) missing for", ErrInvalidBody, a.ID, a.Role) + } + for _, did := range a.For.Values() { + if did == "" { + return fmt.Errorf("%w: agent %s has an empty did in for", ErrInvalidBody, a.ID) + } + } + return nil +} + +// addressRole reports whether this agent is a blockchain address rather than an +// institution — the roles whose owner the sender necessarily knows. +// +// Matched case-insensitively although TAIP-5 mandates PascalCase: a caller who +// mistypes the casing should still have a missing `for` caught, rather than +// slipping past the check on a technicality. +func (a Agent) addressRole() bool { + return strings.EqualFold(a.Role, RoleSourceAddress) || + strings.EqualFold(a.Role, RoleSettlementAddress) +} + +// ValidateAgents stops at the first violation. +func ValidateAgents(agents []Agent) error { + for _, agent := range agents { + if err := agent.Validate(); err != nil { + return err + } + } + return nil +} + // ForField represents the "for" field on an Agent, which can be a single DID string // or an array of DID strings. type ForField struct { diff --git a/update_agent.go b/update_agent.go index f56f54d..1b4b505 100644 --- a/update_agent.go +++ b/update_agent.go @@ -25,6 +25,9 @@ func NewUpdateAgentMessage(from string, to []string, thid string, body *UpdateAg if body.Agent == nil { return nil, fmt.Errorf("%w: missing agent", ErrInvalidBody) } + if err := body.Agent.Validate(); err != nil { + return nil, err + } body.Context = TAPContext body.Type = TypeUpdateAgent diff --git a/update_agent_test.go b/update_agent_test.go index 573cb7c..190929c 100644 --- a/update_agent_test.go +++ b/update_agent_test.go @@ -52,7 +52,7 @@ func TestUpdateAgentBody_JSONRoundTrip(t *testing.T) { } func TestUpdateAgentBody_ParseBody(t *testing.T) { - body := &UpdateAgentBody{Agent: &Agent{ID: "did:web:new"}} + body := &UpdateAgentBody{Agent: &Agent{ID: "did:web:new", For: NewForField("did:web:new")}} msg, err := NewUpdateAgentMessage("from", []string{"to"}, "thid", body) if err != nil { t.Fatalf("create: %v", err) diff --git a/update_party.go b/update_party.go index c9a5723..16a1c08 100644 --- a/update_party.go +++ b/update_party.go @@ -13,12 +13,30 @@ type UpdatePartyBody struct { Context string `json:"@context"` Type string `json:"@type"` Party *Party `json:"party"` - Role string `json:"role"` + PartyType string `json:"partyType"` PreviousParty *Party `json:"previousParty,omitempty"` Reason string `json:"reason,omitempty"` Effective string `json:"effective,omitempty"` } +// UnmarshalJSON reads partyType, falling back to the "role" key that earlier +// tap-go releases emitted in its place. Peers still on the old spelling keep +// working; everything this library sends uses partyType. +func (b *UpdatePartyBody) UnmarshalJSON(data []byte) error { + type alias UpdatePartyBody + aux := struct { + *alias + LegacyRole string `json:"role"` + }{alias: (*alias)(b)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + if b.PartyType == "" { + b.PartyType = aux.LegacyRole + } + return nil +} + func (b *UpdatePartyBody) TAPType() string { return TypeUpdateParty } // NewUpdatePartyMessage creates a new DIDComm message with an UpdateParty body. @@ -26,8 +44,8 @@ func NewUpdatePartyMessage(from string, to []string, thid string, body *UpdatePa if body.Party == nil { return nil, fmt.Errorf("%w: missing party", ErrInvalidBody) } - if body.Role == "" { - return nil, fmt.Errorf("%w: missing role", ErrInvalidBody) + if body.PartyType == "" { + return nil, fmt.Errorf("%w: missing partyType", ErrInvalidBody) } body.Context = TAPContext diff --git a/update_party_test.go b/update_party_test.go index 3d5b2cb..2f0e691 100644 --- a/update_party_test.go +++ b/update_party_test.go @@ -8,8 +8,8 @@ import ( func TestNewUpdatePartyMessage(t *testing.T) { body := &UpdatePartyBody{ - Party: &Party{ID: "did:eg:bob", Name: "Bob Updated"}, - Role: "originator", + Party: &Party{ID: "did:eg:bob", Name: "Bob Updated"}, + PartyType: "originator", } msg, err := NewUpdatePartyMessage("did:web:originator.vasp", []string{"did:web:beneficiary.vasp"}, "thread-1", body) if err != nil { @@ -25,7 +25,7 @@ func TestNewUpdatePartyMessage_MissingFields(t *testing.T) { name string body *UpdatePartyBody }{ - {"missing party", &UpdatePartyBody{Role: "originator"}}, + {"missing party", &UpdatePartyBody{PartyType: "originator"}}, {"missing role", &UpdatePartyBody{Party: &Party{ID: "did:eg:bob"}}}, } for _, tt := range tests { @@ -40,10 +40,10 @@ func TestNewUpdatePartyMessage_MissingFields(t *testing.T) { func TestUpdatePartyBody_JSONRoundTrip(t *testing.T) { body := UpdatePartyBody{ - Context: TAPContext, - Type: TypeUpdateParty, - Party: &Party{ID: "did:eg:alice", Name: "Alice"}, - Role: "beneficiary", + Context: TAPContext, + Type: TypeUpdateParty, + Party: &Party{ID: "did:eg:alice", Name: "Alice"}, + PartyType: "beneficiary", } data, err := json.Marshal(body) @@ -55,13 +55,13 @@ func TestUpdatePartyBody_JSONRoundTrip(t *testing.T) { if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("unmarshal: %v", err) } - if got.Party.Name != "Alice" || got.Role != "beneficiary" { + if got.Party.Name != "Alice" || got.PartyType != "beneficiary" { t.Errorf("mismatch: %+v", got) } } func TestUpdatePartyBody_ParseBody(t *testing.T) { - body := &UpdatePartyBody{Party: &Party{ID: "did:eg:alice"}, Role: "beneficiary"} + body := &UpdatePartyBody{Party: &Party{ID: "did:eg:alice"}, PartyType: "beneficiary"} msg, err := NewUpdatePartyMessage("from", []string{"to"}, "thid", body) if err != nil { t.Fatalf("create: %v", err) @@ -75,7 +75,54 @@ func TestUpdatePartyBody_ParseBody(t *testing.T) { if !ok { t.Fatalf("expected *UpdatePartyBody, got %T", parsed) } - if upb.Role != "beneficiary" { - t.Errorf("Role: got %q", upb.Role) + if upb.PartyType != "beneficiary" { + t.Errorf("PartyType: got %q", upb.PartyType) + } +} + +// Earlier tap-go releases tagged this field "role". Peers still on that +// spelling must keep working while everything we send uses "partyType". +func TestUpdatePartyBody_UnmarshalAcceptsLegacyRoleKey(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {"partyType", `{"partyType":"beneficiary"}`, "beneficiary"}, + {"legacy role", `{"role":"originator"}`, "originator"}, + {"partyType wins", `{"partyType":"beneficiary","role":"originator"}`, "beneficiary"}, + {"neither", `{}`, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body UpdatePartyBody + if err := json.Unmarshal([]byte(tt.raw), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.PartyType != tt.want { + t.Errorf("PartyType: got %q, want %q", body.PartyType, tt.want) + } + }) + } +} + +func TestUpdatePartyBody_MarshalsPartyType(t *testing.T) { + msg, err := NewUpdatePartyMessage("did:web:a", []string{"did:web:b"}, "thid", &UpdatePartyBody{ + Party: &Party{ID: "did:eg:alice"}, + PartyType: "beneficiary", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(msg.Body, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := raw["role"]; ok { + t.Error("body still emits the legacy role key") + } + if string(raw["partyType"]) != `"beneficiary"` { + t.Errorf("partyType: got %s", raw["partyType"]) } }