diff --git a/src/le_agent_sdk/models/agreement.py b/src/le_agent_sdk/models/agreement.py index c0988b0..ca96c74 100644 --- a/src/le_agent_sdk/models/agreement.py +++ b/src/le_agent_sdk/models/agreement.py @@ -25,9 +25,15 @@ 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 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" + # Agreement lifecycle status: proposed, active, completed, disputed, expired + status: str = "proposed" # Set by relay / event parsing event_id: str = "" pubkey: str = "" @@ -72,6 +78,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 @@ -113,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]]: @@ -143,4 +157,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..f1eed3a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -381,6 +381,113 @@ 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 + + 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 --- @@ -400,6 +507,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 +543,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):