From e3f51ffa8ca6b73f5e101bb61bed78bcfc10b4bf Mon Sep 17 00:00:00 2001 From: Mike Rahel Date: Sat, 28 Mar 2026 02:48:28 -0400 Subject: [PATCH 1/4] Add NIP-A5 interop: attestation domain labels and payment_hash on agreements - Add commerce.service_completion domain label to kind 38403 attestations for NIP-32 namespace-scoped reputation scoring - Serialize payment_hash tag on kind 38402 when status is completed, enabling third-party payment verification - Parse payment_hash and status from Nostr event tags - Add 6 tests for status/payment_hash serialization, parsing, roundtrip Co-Authored-By: Claude Opus 4.6 (1M context) --- src/le_agent_sdk/models/agreement.py | 12 ++++ src/le_agent_sdk/models/attestation.py | 1 + tests/test_models.py | 89 ++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/src/le_agent_sdk/models/agreement.py b/src/le_agent_sdk/models/agreement.py index c0988b0..28de0b6 100644 --- a/src/le_agent_sdk/models/agreement.py +++ b/src/le_agent_sdk/models/agreement.py @@ -28,6 +28,8 @@ class AgentServiceAgreement: payment_hash: Optional[str] = None # Settlement mode: "proxy" (static L402 proxy) or "producer" (dynamic via Producer API) settlement_mode: str = "proxy" + # Agreement lifecycle status: proposed, active, completed, disputed, expired + status: str = "proposed" # Set by relay / event parsing event_id: str = "" pubkey: str = "" @@ -72,6 +74,10 @@ def from_nostr_event(cls, event: dict[str, Any]) -> AgentServiceAgreement: agr.expires_at = int(tag[1]) except (ValueError, TypeError): agr.expires_at = None + elif key == "status" and len(tag) > 1: + agr.status = tag[1] + elif key == "payment_hash" and len(tag) > 1: + agr.payment_hash = tag[1] # Parse e-tags: prefer marker hints (e.g. ["e", "", "", "request"]) # Fall back to order-based parsing if markers not present @@ -143,4 +149,10 @@ def to_nostr_tags(self) -> list[list[str]]: if self.expires_at is not None: tags.append(["expiration", str(self.expires_at)]) + if self.status: + tags.append(["status", self.status]) + + if self.status == "completed" and self.payment_hash: + tags.append(["payment_hash", self.payment_hash]) + return tags diff --git a/src/le_agent_sdk/models/attestation.py b/src/le_agent_sdk/models/attestation.py index f183756..f2b69ea 100644 --- a/src/le_agent_sdk/models/attestation.py +++ b/src/le_agent_sdk/models/attestation.py @@ -88,6 +88,7 @@ def to_nostr_tags(self) -> list[list[str]]: # NIP-32 label namespace tags.append(["L", "nostr.agent.attestation"]) tags.append(["l", "completed", "nostr.agent.attestation"]) + tags.append(["l", "commerce.service_completion", "nostr.agent.attestation"]) if self.proof: tags.append(["proof", self.proof]) diff --git a/tests/test_models.py b/tests/test_models.py index c5f3bf9..b763dc1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -381,6 +381,93 @@ def test_no_expiration(self): exp_tags = [t for t in tags if t[0] == "expiration"] assert len(exp_tags) == 0 + def test_status_default(self): + agr = AgentServiceAgreement() + assert agr.status == "proposed" + tags = agr.to_nostr_tags() + assert ["status", "proposed"] in tags + + def test_status_completed_with_payment_hash(self): + agr = AgentServiceAgreement( + request_event_id="r1", + capability_event_id="c1", + provider_pubkey="prov", + requester_pubkey="req", + agreed_price_sats=100, + status="completed", + payment_hash="a" * 64, + ) + tags = agr.to_nostr_tags() + assert ["status", "completed"] in tags + assert ["payment_hash", "a" * 64] in tags + + def test_status_proposed_no_payment_hash(self): + """payment_hash tag should only appear when status is completed.""" + agr = AgentServiceAgreement( + agreed_price_sats=50, + status="proposed", + payment_hash="b" * 64, + ) + tags = agr.to_nostr_tags() + assert ["status", "proposed"] in tags + ph_tags = [t for t in tags if t[0] == "payment_hash"] + assert len(ph_tags) == 0 + + def test_status_completed_no_payment_hash_value(self): + """No payment_hash tag when status is completed but hash is None.""" + agr = AgentServiceAgreement( + agreed_price_sats=50, + status="completed", + ) + tags = agr.to_nostr_tags() + assert ["status", "completed"] in tags + ph_tags = [t for t in tags if t[0] == "payment_hash"] + assert len(ph_tags) == 0 + + def test_parse_status_and_payment_hash(self): + event = { + "id": "agr_completed", + "pubkey": "prov_pub", + "created_at": 1700000006, + "kind": 38402, + "content": "", + "tags": [ + ["e", "req1", "", "request"], + ["e", "cap1", "", "capability"], + ["p", "prov_pub", "", "provider"], + ["p", "req_pub", "", "requester"], + ["price", "200"], + ["status", "completed"], + ["payment_hash", "c" * 64], + ], + } + agr = AgentServiceAgreement.from_nostr_event(event) + assert agr.status == "completed" + assert agr.payment_hash == "c" * 64 + + def test_roundtrip_completed_with_payment_hash(self): + agr = AgentServiceAgreement( + request_event_id="req_rt2", + capability_event_id="cap_rt2", + provider_pubkey="prov_rt2", + requester_pubkey="req_pub_rt2", + agreed_price_sats=150, + status="completed", + payment_hash="d" * 64, + ) + tags = agr.to_nostr_tags() + event = { + "id": "rt_agr2", + "pubkey": "rt_pub2", + "created_at": 1700000007, + "kind": 38402, + "content": "", + "tags": tags, + } + restored = AgentServiceAgreement.from_nostr_event(event) + assert restored.status == "completed" + assert restored.payment_hash == "d" * 64 + # --- AgentAttestation --- @@ -400,6 +487,7 @@ def _sample_event(self) -> dict: ["rating", "5"], ["L", "nostr.agent.attestation"], ["l", "completed", "nostr.agent.attestation"], + ["l", "commerce.service_completion", "nostr.agent.attestation"], ["proof", "abc123hash"], ], "sig": "sig_att", @@ -435,6 +523,7 @@ def test_to_nostr_tags(self): assert ["rating", "4"] in tags assert ["L", "nostr.agent.attestation"] in tags assert ["l", "completed", "nostr.agent.attestation"] in tags + assert ["l", "commerce.service_completion", "nostr.agent.attestation"] in tags assert ["proof", "proof_hash"] in tags def test_roundtrip(self): From 79a231b6e7823899a7854dbe1fe19ffc02389125 Mon Sep 17 00:00:00 2001 From: Mike Rahel Date: Sat, 28 Mar 2026 03:00:04 -0400 Subject: [PATCH 2/4] Clarify payment_hash field comment for dual L402/NIP-A5 usage --- src/le_agent_sdk/models/agreement.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/le_agent_sdk/models/agreement.py b/src/le_agent_sdk/models/agreement.py index 28de0b6..4d2f41a 100644 --- a/src/le_agent_sdk/models/agreement.py +++ b/src/le_agent_sdk/models/agreement.py @@ -25,6 +25,10 @@ class AgentServiceAgreement: # L402 Producer API challenge fields (set after create_challenge) invoice: Optional[str] = None macaroon: Optional[str] = None + # Dual-purpose field: + # 1. L402 Producer API: populated by create_challenge() with the Lightning payment hash. + # 2. NIP-A5 event tag: emitted as a ["payment_hash", ...] tag on completed + # agreements (kind 38402, status="completed") to provide on-chain proof of settlement. payment_hash: Optional[str] = None # Settlement mode: "proxy" (static L402 proxy) or "producer" (dynamic via Producer API) settlement_mode: str = "proxy" From 78439c4f419e87323add424ba51c1d38bd195296 Mon Sep 17 00:00:00 2001 From: Mike Rahel Date: Sat, 28 Mar 2026 03:31:43 -0400 Subject: [PATCH 3/4] Fix payment_hash comment: Lightning settlement is off-chain --- src/le_agent_sdk/models/agreement.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/le_agent_sdk/models/agreement.py b/src/le_agent_sdk/models/agreement.py index 4d2f41a..f18e42d 100644 --- a/src/le_agent_sdk/models/agreement.py +++ b/src/le_agent_sdk/models/agreement.py @@ -28,7 +28,7 @@ class AgentServiceAgreement: # Dual-purpose field: # 1. L402 Producer API: populated by create_challenge() with the Lightning payment hash. # 2. NIP-A5 event tag: emitted as a ["payment_hash", ...] tag on completed - # agreements (kind 38402, status="completed") to provide on-chain proof of settlement. + # agreements (kind 38402, status="completed") to provide proof of Lightning settlement. payment_hash: Optional[str] = None # Settlement mode: "proxy" (static L402 proxy) or "producer" (dynamic via Producer API) settlement_mode: str = "proxy" From db836256ba91071c8f6366a4efd9c448e344b684 Mon Sep 17 00:00:00 2001 From: Mike Rahel Date: Sat, 28 Mar 2026 03:34:45 -0400 Subject: [PATCH 4/4] Enforce payment_hash invariant: only retain when status is completed --- src/le_agent_sdk/models/agreement.py | 4 ++++ tests/test_models.py | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/le_agent_sdk/models/agreement.py b/src/le_agent_sdk/models/agreement.py index f18e42d..ca96c74 100644 --- a/src/le_agent_sdk/models/agreement.py +++ b/src/le_agent_sdk/models/agreement.py @@ -123,6 +123,10 @@ def from_nostr_event(cls, event: dict[str, Any]) -> AgentServiceAgreement: if len(p_tags) > 1: agr.requester_pubkey = p_tags[1][1] + # Enforce invariant: payment_hash only valid when status is completed + if agr.status != "completed": + agr.payment_hash = None + return agr def to_nostr_tags(self) -> list[list[str]]: diff --git a/tests/test_models.py b/tests/test_models.py index b763dc1..f1eed3a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -468,6 +468,26 @@ def test_roundtrip_completed_with_payment_hash(self): assert restored.status == "completed" assert restored.payment_hash == "d" * 64 + def test_parse_drops_payment_hash_when_not_completed(self): + """payment_hash should be dropped when status is not completed (invariant).""" + event = { + "id": "inv_agr", + "pubkey": "inv_pub", + "created_at": 1700000008, + "kind": 38402, + "content": "", + "tags": [ + ["d", "inv-test"], + ["status", "active"], + ["payment_hash", "e" * 64], + ["p", "provider_pub", "", "provider"], + ["p", "requester_pub", "", "requester"], + ], + } + agr = AgentServiceAgreement.from_nostr_event(event) + assert agr.status == "active" + assert agr.payment_hash is None + # --- AgentAttestation ---