|
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() |