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
20 changes: 20 additions & 0 deletions src/le_agent_sdk/models/agreement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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", "<id>", "", "request"])
# Fall back to order-based parsing if markers not present
Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions src/le_agent_sdk/models/attestation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
109 changes: 109 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand All @@ -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",
Expand Down Expand Up @@ -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):
Expand Down
Loading