Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions add_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion add_agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions agent_validation_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
2 changes: 1 addition & 1 deletion client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
24 changes: 12 additions & 12 deletions cmd/tap/message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions cmd/tap/receive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 3 additions & 3 deletions connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
}

Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 3 additions & 3 deletions lock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")},
},
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading