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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed

- **BREAKING (TAIP-9):** Reshaped `ConfirmRelationshipBody` to match the TAIP-9
spec, whose confirmation payload is an `Agent` payload. The body is now flat:
`@context`, `@type` (set to `https://tap.rsvp/schema/1.0#Agent`), `@id` (the
DID of the agent being confirmed, REQUIRED), `for` (the DID of the entity the
agent acts for, REQUIRED), and `role` (OPTIONAL). The non-spec
`relationship` (`Relationship *Relationship`), `status`, `validFrom`,
`validUntil`, and `details` fields were removed. `NewConfirmRelationshipMessage`
now validates `@id` and `for` instead of `relationship` and `status`. Consumers
must read `body.ID` for the confirmed address and `body.For` for the owner
instead of `body.Relationship.Parties` / `body.Status`. Added the `TypeAgent`
constant for the body's JSON-LD `@type`.

### Removed

- The `Relationship` struct (`types.go`), which was used only by the now-reshaped
`ConfirmRelationshipBody`.

## [0.4.0] - 2026-05-05

### Changed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ envelope, _ := dc.PackAuthcrypt(ctx, authorizeMsg)

| Constructor | Body Struct | TAIP | Required Fields |
|------------|-------------|------|-----------------|
| `NewConfirmRelationshipMessage` | `ConfirmRelationshipBody` | [TAIP-9](https://tap.rsvp/TAIPs/taip-9) | `relationship`, `status` |
| `NewConfirmRelationshipMessage` | `ConfirmRelationshipBody` | [TAIP-9](https://tap.rsvp/TAIPs/taip-9) | `@id`, `for` |
| `NewUpdatePoliciesMessage` | `UpdatePoliciesBody` | [TAIP-7](https://tap.rsvp/TAIPs/taip-7) | `policies` |
| `NewConnectMessage` | `ConnectBody` | [TAIP-15](https://tap.rsvp/TAIPs/taip-15) | `requester`, `principal`, `agents`, `constraints` |

Expand Down
2 changes: 1 addition & 1 deletion cmd/tap/message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@ func TestCLI_MessageUpdatePolicies(t *testing.T) {

func TestCLI_MessageConfirmRelationship(t *testing.T) {
bin := buildBinary(t)
body := `{"relationship":{"type":"customer","parties":[{"@id":"did:key:z1"},{"@id":"did:key:z2"}]},"status":"confirmed"}`
body := `{"@id":"did:pkh:eip155:1:0x1234","for":"did:key:z1","role":"SettlementAddress"}`

cmd := exec.Command(bin, "message", "confirm-relationship",
"--from", "did:key:z1",
Expand Down
25 changes: 12 additions & 13 deletions confirm_relationship.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,29 @@ import (
"github.com/google/uuid"
)

// ConfirmRelationshipBody represents the body of a TAP ConfirmRelationship message (TAIP-9).
// ConfirmRelationshipBody is the body of a ConfirmRelationship message (TAIP-9):
// the Agent payload, asserting that @id acts for the entity in "for".
type ConfirmRelationshipBody struct {
Context string `json:"@context"`
Type string `json:"@type"`
Relationship *Relationship `json:"relationship"`
Status string `json:"status"`
ValidFrom string `json:"validFrom,omitempty"`
ValidUntil string `json:"validUntil,omitempty"`
Details any `json:"details,omitempty"`
Context string `json:"@context"`
Type string `json:"@type"`
ID string `json:"@id"` // the agent being confirmed
For ForField `json:"for"` // the entity it acts on behalf of
Role string `json:"role,omitempty"`
}

func (b *ConfirmRelationshipBody) TAPType() string { return TypeConfirmRelationship }

// NewConfirmRelationshipMessage creates a new DIDComm message with a ConfirmRelationship body.
func NewConfirmRelationshipMessage(from string, to []string, thid string, body *ConfirmRelationshipBody) (*didcomm.Message, error) {
if body.Relationship == nil {
return nil, fmt.Errorf("%w: missing relationship", ErrInvalidBody)
if body.ID == "" {
return nil, fmt.Errorf("%w: missing @id", ErrInvalidBody)
}
if body.Status == "" {
return nil, fmt.Errorf("%w: missing status", ErrInvalidBody)
if body.For.IsEmpty() {
return nil, fmt.Errorf("%w: missing for", ErrInvalidBody)
}

body.Context = TAPContext
body.Type = TypeConfirmRelationship
body.Type = TypeAgent

rawBody, err := json.Marshal(body)
if err != nil {
Expand Down
152 changes: 125 additions & 27 deletions confirm_relationship_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,15 @@ package tap
import (
"encoding/json"
"errors"
"slices"
"testing"
)

func TestNewConfirmRelationshipMessage(t *testing.T) {
body := &ConfirmRelationshipBody{
Relationship: &Relationship{
Type: "customer",
Parties: []Party{
{ID: "did:eg:alice"},
{ID: "did:eg:bob"},
},
},
Status: "confirmed",
ID: "did:pkh:eip155:1:0x1234a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb",
For: NewForField("did:web:beneficiary.vasp"),
Role: "SettlementAddress",
}
msg, err := NewConfirmRelationshipMessage("did:web:beneficiary.vasp", []string{"did:web:originator.vasp"}, "thread-1", body)
if err != nil {
Expand All @@ -24,15 +20,18 @@ func TestNewConfirmRelationshipMessage(t *testing.T) {
if msg.Type != TypeConfirmRelationship {
t.Errorf("Type: got %q", msg.Type)
}
if body.Type != TypeAgent {
t.Errorf("body @type: got %q, want %q", body.Type, TypeAgent)
}
}

func TestNewConfirmRelationshipMessage_MissingFields(t *testing.T) {
tests := []struct {
name string
body *ConfirmRelationshipBody
}{
{"missing relationship", &ConfirmRelationshipBody{Status: "confirmed"}},
{"missing status", &ConfirmRelationshipBody{Relationship: &Relationship{Type: "customer", Parties: []Party{{ID: "a"}, {ID: "b"}}}}},
{"missing @id", &ConfirmRelationshipBody{For: NewForField("did:web:beneficiary.vasp")}},
{"missing for", &ConfirmRelationshipBody{ID: "did:pkh:eip155:1:0x1234"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -44,36 +43,121 @@ func TestNewConfirmRelationshipMessage_MissingFields(t *testing.T) {
}
}

func TestConfirmRelationshipBody_JSONRoundTrip(t *testing.T) {
body := ConfirmRelationshipBody{
Context: TAPContext,
Type: TypeConfirmRelationship,
Relationship: &Relationship{
Type: "customer",
Parties: []Party{{ID: "did:eg:alice"}, {ID: "did:eg:bob"}},
},
Status: "confirmed",
ValidFrom: "2024-01-01T00:00:00Z",
// TestConfirmRelationshipBody_SpecCanonical verifies the spec's canonical
// settlement-address example (TAIP-9 test case 1) round-trips with the exact
// JSON field names and casing.
func TestConfirmRelationshipBody_SpecCanonical(t *testing.T) {
raw := []byte(`{
"@context":"https://tap.rsvp/schema/1.0",
"@type":"https://tap.rsvp/schema/1.0#Agent",
"@id":"did:pkh:eip155:1:0x1234a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb",
"for":"did:web:beneficiary.vasp",
"role":"SettlementAddress"
}`)

var got ConfirmRelationshipBody
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.ID != "did:pkh:eip155:1:0x1234a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb" {
t.Errorf("@id: got %q", got.ID)
}
if got.For.String() != "did:web:beneficiary.vasp" {
t.Errorf("for: got %q", got.For.String())
}
if got.Role != "SettlementAddress" {
t.Errorf("role: got %q", got.Role)
}
if got.Type != TypeAgent {
t.Errorf("@type: got %q, want %q", got.Type, TypeAgent)
}

data, err := json.Marshal(body)
// Re-marshal and confirm the spec JSON field names are present.
data, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
t.Fatalf("unmarshal fields: %v", err)
}
for _, key := range []string{"@context", "@type", "@id", "for", "role"} {
if _, ok := fields[key]; !ok {
t.Errorf("marshalled body missing %q field; got keys %v", key, keysOf(fields))
}
}
}

// TestConfirmRelationshipBody_SpecCanonicalNoRole verifies the spec's second
// test case (VASP confirming it acts for its customer), where role is omitted.
func TestConfirmRelationshipBody_SpecCanonicalNoRole(t *testing.T) {
raw := []byte(`{
"@context":"https://tap.rsvp/schema/1.0",
"@type":"https://tap.rsvp/schema/1.0#Agent",
"@id":"did:web:beneficiary.vasp",
"for":"did:eg:bob"
}`)

var got ConfirmRelationshipBody
if err := json.Unmarshal(data, &got); err != nil {
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Status != "confirmed" || got.Relationship.Type != "customer" {
if got.ID != "did:web:beneficiary.vasp" || got.For.String() != "did:eg:bob" {
t.Errorf("mismatch: %+v", got)
}

data, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
t.Fatalf("unmarshal fields: %v", err)
}
if _, ok := fields["role"]; ok {
t.Errorf("role should be omitted when empty; got keys %v", keysOf(fields))
}
}

func TestConfirmRelationshipBody_ForRoundTrip(t *testing.T) {
tests := []struct {
name string
for_ ForField
want []string
}{
{"single owner", NewForField("did:web:beneficiary.vasp"), []string{"did:web:beneficiary.vasp"}},
{"multiple owners", NewForField("did:eg:alice", "did:eg:bob"), []string{"did:eg:alice", "did:eg:bob"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := ConfirmRelationshipBody{
Context: TAPContext,
Type: TypeAgent,
ID: "did:pkh:eip155:1:0x1234",
For: tt.for_,
}

data, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}

var got ConfirmRelationshipBody
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if gotVals := got.For.Values(); !slices.Equal(gotVals, tt.want) {
t.Errorf("For: got %v, want %v", gotVals, tt.want)
}
})
}
}

func TestConfirmRelationshipBody_ParseBody(t *testing.T) {
body := &ConfirmRelationshipBody{
Relationship: &Relationship{Type: "customer", Parties: []Party{{ID: "a"}, {ID: "b"}}},
Status: "pending",
ID: "did:pkh:eip155:1:0x1234",
For: NewForField("did:web:beneficiary.vasp"),
Role: "SettlementAddress",
}
msg, err := NewConfirmRelationshipMessage("from", []string{"to"}, "thid", body)
if err != nil {
Expand All @@ -88,7 +172,21 @@ func TestConfirmRelationshipBody_ParseBody(t *testing.T) {
if !ok {
t.Fatalf("expected *ConfirmRelationshipBody, got %T", parsed)
}
if crb.Status != "pending" {
t.Errorf("Status: got %q", crb.Status)
if crb.ID != "did:pkh:eip155:1:0x1234" {
t.Errorf("@id: got %q", crb.ID)
}
if crb.For.String() != "did:web:beneficiary.vasp" {
t.Errorf("for: got %q", crb.For.String())
}
if crb.Role != "SettlementAddress" {
t.Errorf("role: got %q", crb.Role)
}
}

func keysOf(m map[string]json.RawMessage) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
4 changes: 4 additions & 0 deletions message_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ const (
// TAPContext is the JSON-LD context for all TAP messages.
TAPContext = "https://tap.rsvp/schema/1.0"

// TypeAgent is the JSON-LD type for an Agent payload (TAIP-5). It is not a
// message type; it is the body @type of a ConfirmRelationship message.
TypeAgent = "https://tap.rsvp/schema/1.0#Agent"

// Transaction message types
TypeTransfer = "https://tap.rsvp/schema/1.0#Transfer"
TypePayment = "https://tap.rsvp/schema/1.0#Payment"
Expand Down
2 changes: 1 addition & 1 deletion parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func TestParseBody_AllTypes(t *testing.T) {
{"AddAgents", TypeAddAgents, &AddAgentsBody{Context: TAPContext, Type: TypeAddAgents, Agents: []Agent{{ID: "a"}}}},
{"ReplaceAgent", TypeReplaceAgent, &ReplaceAgentBody{Context: TAPContext, Type: TypeReplaceAgent, Original: "old", Replacement: &Agent{ID: "new"}}},
{"RemoveAgent", TypeRemoveAgent, &RemoveAgentBody{Context: TAPContext, Type: TypeRemoveAgent, Agent: "did:web:old"}},
{"ConfirmRelationship", TypeConfirmRelationship, &ConfirmRelationshipBody{Context: TAPContext, Type: TypeConfirmRelationship, Relationship: &Relationship{Type: "customer", Parties: []Party{{ID: "a"}, {ID: "b"}}}, Status: "confirmed"}},
{"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{}}},
}
Expand Down
8 changes: 0 additions & 8 deletions types.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,6 @@ type DocumentReference struct {
URL string `json:"url,omitempty"`
}

// Relationship represents a relationship between parties for ConfirmRelationship.
type Relationship struct {
Type string `json:"type"`
Parties []Party `json:"parties"`
EstablishedDate string `json:"establishedDate,omitempty"`
Reference string `json:"reference,omitempty"`
}

// SupportedAssetPricing represents an asset with pricing information for payments.
type SupportedAssetPricing struct {
Asset string `json:"asset"`
Expand Down
24 changes: 0 additions & 24 deletions types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,27 +224,3 @@ func TestInvoice_JSONRoundTrip(t *testing.T) {
t.Errorf("LineItems mismatch: got %v", got.LineItems)
}
}

func TestRelationship_JSONRoundTrip(t *testing.T) {
rel := Relationship{
Type: "customer",
Parties: []Party{
{ID: "did:eg:alice"},
{ID: "did:eg:bob"},
},
}

data, err := json.Marshal(rel)
if err != nil {
t.Fatalf("marshal: %v", err)
}

var got Relationship
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}

if got.Type != "customer" || len(got.Parties) != 2 {
t.Errorf("round-trip mismatch: got %+v", got)
}
}
Loading