1 """Deterministic local proof creation; no wallet, chain, or value transfer."""
2
3 from __future__ import annotations
4
5 import hashlib
6 import json
7 from dataclasses import dataclass
8
9 from pydantic import ValidationError
10
11 from .codec import decode_json, encode_model
12 from .errors import IdempotencyConflict, ProtocolError
13 from .models import (
14 PaymentAuthorization,
15 PaymentPayload,
16 PaymentReceipt,
17 PaymentRequirement,
18 PurchaseRequest,
19 SettlementResponse,
20 )
21
22
23 @dataclass(frozen=True)
24 class _StoredAuthorization:
25 fingerprint: str
26 proof_header: str
27 receipt: PaymentReceipt
28
29
30 class LocalPaymentProcessor:
31 """A fake local payment adapter with idempotent proof generation."""
32
33 payer = "synthetic-test-payer"
34
35 def __init__(self) -> None:
36 self._authorizations: dict[str, _StoredAuthorization] = {}
37
38 @property
39 def charge_count(self) -> int:
40 return len(self._authorizations)
41
42 def authorize(
43 self,
44 request: PurchaseRequest,
45 requirement: PaymentRequirement,
46 ) -> PaymentAuthorization:
47 fingerprint = self._fingerprint(request, requirement)
48 stored = self._authorizations.get(request.idempotency_key)
49 if stored is not None:
50 if stored.fingerprint != fingerprint:
51 raise IdempotencyConflict(
52 "idempotency_conflict",
53 "The idempotency key is already bound to another purchase.",
54 )
55 return PaymentAuthorization(
56 proof_header=stored.proof_header,
57 receipt=stored.receipt.model_copy(update={"reused": True}),
58 )
59
60 digest = hashlib.sha256(
61 f"{request.idempotency_key}:{fingerprint}".encode()
62 ).hexdigest()
63 receipt = PaymentReceipt(
64 receipt_id=f"receipt-{digest[:16]}",
65 request_id=request.request_id,
66 idempotency_key=request.idempotency_key,
67 merchant_domain=requirement.merchant_domain,
68 resource_url=request.resource_url,
69 amount=requirement.decimal_amount,
70 currency=requirement.currency,
71 network=requirement.network,
72 transaction=f"synthetic-{digest[16:40]}",
73 )
74 payload = PaymentPayload(
75 accepted=requirement,
76 payload={
77 "proof": f"synthetic-proof-{digest[40:]}",
78 "receiptId": receipt.receipt_id,
79 },
80 )
81 proof_header = encode_model(payload)
82 self._authorizations[request.idempotency_key] = _StoredAuthorization(
83 fingerprint=fingerprint,
84 proof_header=proof_header,
85 receipt=receipt,
86 )
87 return PaymentAuthorization(
88 proof_header=proof_header,
89 receipt=receipt,
90 )
91
92 def verify(
93 self,
94 proof_header: str,
95 requirement: PaymentRequirement,
96 ) -> SettlementResponse:
97 payload_dict = decode_json(
98 proof_header,
99 header_name="PAYMENT-SIGNATURE",
100 )
101 try:
102 payload = PaymentPayload.model_validate(payload_dict)
103 except ValidationError as exc:
104 raise ProtocolError(
105 "invalid_payment_payload",
106 "PAYMENT-SIGNATURE does not match the expected payload.",
107 ) from exc
108
109 if payload.accepted != requirement:
110 raise ProtocolError(
111 "payment_requirement_mismatch",
112 "The proof does not match the merchant requirement.",
113 )
114
115 receipt_id = payload.payload.get("receiptId")
116 proof = payload.payload.get("proof")
117 stored = next(
118 (
119 candidate
120 for candidate in self._authorizations.values()
121 if candidate.receipt.receipt_id == receipt_id
122 ),
123 None,
124 )
125 if (
126 stored is None
127 or stored.proof_header != proof_header
128 or not proof
129 or not proof.startswith("synthetic-proof-")
130 ):
131 raise ProtocolError(
132 "unrecognized_synthetic_proof",
133 "The local payment proof is not recognized.",
134 )
135
136 return SettlementResponse(
137 success=True,
138 transaction=stored.receipt.transaction,
139 network=stored.receipt.network,
140 payer=self.payer,
141 )
142
143 @staticmethod
144 def _fingerprint(
145 request: PurchaseRequest,
146 requirement: PaymentRequirement,
147 ) -> str:
148 stable = {
149 "resource_url": str(request.resource_url),
150 "purpose": request.purpose,
151 "challenge_id": requirement.challenge_id,
152 "amount": requirement.amount,
153 "asset": requirement.asset,
154 "network": requirement.network,
155 "pay_to": requirement.pay_to,
156 }
157 raw = json.dumps(stable, sort_keys=True, separators=(",", ":"))
158 return hashlib.sha256(raw.encode()).hexdigest()