opentrust-payment-contracts 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opentrust_payment_contracts-1.0.0/PKG-INFO +22 -0
- opentrust_payment_contracts-1.0.0/README.md +7 -0
- opentrust_payment_contracts-1.0.0/opentrust_payment_contracts.egg-info/PKG-INFO +22 -0
- opentrust_payment_contracts-1.0.0/opentrust_payment_contracts.egg-info/SOURCES.txt +18 -0
- opentrust_payment_contracts-1.0.0/opentrust_payment_contracts.egg-info/dependency_links.txt +1 -0
- opentrust_payment_contracts-1.0.0/opentrust_payment_contracts.egg-info/requires.txt +2 -0
- opentrust_payment_contracts-1.0.0/opentrust_payment_contracts.egg-info/top_level.txt +1 -0
- opentrust_payment_contracts-1.0.0/payment_contracts/__init__.py +53 -0
- opentrust_payment_contracts-1.0.0/payment_contracts/interfaces/__init__.py +6 -0
- opentrust_payment_contracts-1.0.0/payment_contracts/interfaces/escrow_interface.py +29 -0
- opentrust_payment_contracts-1.0.0/payment_contracts/interfaces/payment_gateway.py +21 -0
- opentrust_payment_contracts-1.0.0/payment_contracts/interfaces/subscription_manager.py +16 -0
- opentrust_payment_contracts-1.0.0/payment_contracts/interfaces/verification_pricing.py +16 -0
- opentrust_payment_contracts-1.0.0/payment_contracts/models.py +281 -0
- opentrust_payment_contracts-1.0.0/pyproject.toml +22 -0
- opentrust_payment_contracts-1.0.0/setup.cfg +4 -0
- opentrust_payment_contracts-1.0.0/tests/test_interfaces.py +58 -0
- opentrust_payment_contracts-1.0.0/tests/test_models.py +52 -0
- opentrust_payment_contracts-1.0.0/tests/test_quote_production_safety.py +57 -0
- opentrust_payment_contracts-1.0.0/tests/test_quote_validation.py +445 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opentrust-payment-contracts
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Abstract payment contracts for OpenTrust payment providers
|
|
5
|
+
Author-email: Novel Hut Studios <founder@novelhut.net>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Costder/opentrust
|
|
8
|
+
Project-URL: Repository, https://github.com/Costder/opentrust
|
|
9
|
+
Project-URL: Issues, https://github.com/Costder/opentrust/issues
|
|
10
|
+
Keywords: opentrust,payments,escrow,ai-agents,tool-marketplace
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: pydantic>=2.7
|
|
14
|
+
Requires-Dist: cryptography>=42
|
|
15
|
+
|
|
16
|
+
# Payment Contracts
|
|
17
|
+
|
|
18
|
+
This package defines the abstract payment interface for OpenTrust registries.
|
|
19
|
+
|
|
20
|
+
Registry operators who want to enable paid tool access implement the `PaymentGateway`, `EscrowProvider`, and `SubscriptionManager` interfaces against the OpenTrust schema. The reference registry ships a mock checkout provider for demos and keeps production secrets out of source control.
|
|
21
|
+
|
|
22
|
+
The schema driving these interfaces is in `passport-schema/commercial-status.schema.json` and `passport-schema/escrow.schema.json`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Payment Contracts
|
|
2
|
+
|
|
3
|
+
This package defines the abstract payment interface for OpenTrust registries.
|
|
4
|
+
|
|
5
|
+
Registry operators who want to enable paid tool access implement the `PaymentGateway`, `EscrowProvider`, and `SubscriptionManager` interfaces against the OpenTrust schema. The reference registry ships a mock checkout provider for demos and keeps production secrets out of source control.
|
|
6
|
+
|
|
7
|
+
The schema driving these interfaces is in `passport-schema/commercial-status.schema.json` and `passport-schema/escrow.schema.json`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opentrust-payment-contracts
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Abstract payment contracts for OpenTrust payment providers
|
|
5
|
+
Author-email: Novel Hut Studios <founder@novelhut.net>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Costder/opentrust
|
|
8
|
+
Project-URL: Repository, https://github.com/Costder/opentrust
|
|
9
|
+
Project-URL: Issues, https://github.com/Costder/opentrust/issues
|
|
10
|
+
Keywords: opentrust,payments,escrow,ai-agents,tool-marketplace
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: pydantic>=2.7
|
|
14
|
+
Requires-Dist: cryptography>=42
|
|
15
|
+
|
|
16
|
+
# Payment Contracts
|
|
17
|
+
|
|
18
|
+
This package defines the abstract payment interface for OpenTrust registries.
|
|
19
|
+
|
|
20
|
+
Registry operators who want to enable paid tool access implement the `PaymentGateway`, `EscrowProvider`, and `SubscriptionManager` interfaces against the OpenTrust schema. The reference registry ships a mock checkout provider for demos and keeps production secrets out of source control.
|
|
21
|
+
|
|
22
|
+
The schema driving these interfaces is in `passport-schema/commercial-status.schema.json` and `passport-schema/escrow.schema.json`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
opentrust_payment_contracts.egg-info/PKG-INFO
|
|
4
|
+
opentrust_payment_contracts.egg-info/SOURCES.txt
|
|
5
|
+
opentrust_payment_contracts.egg-info/dependency_links.txt
|
|
6
|
+
opentrust_payment_contracts.egg-info/requires.txt
|
|
7
|
+
opentrust_payment_contracts.egg-info/top_level.txt
|
|
8
|
+
payment_contracts/__init__.py
|
|
9
|
+
payment_contracts/models.py
|
|
10
|
+
payment_contracts/interfaces/__init__.py
|
|
11
|
+
payment_contracts/interfaces/escrow_interface.py
|
|
12
|
+
payment_contracts/interfaces/payment_gateway.py
|
|
13
|
+
payment_contracts/interfaces/subscription_manager.py
|
|
14
|
+
payment_contracts/interfaces/verification_pricing.py
|
|
15
|
+
tests/test_interfaces.py
|
|
16
|
+
tests/test_models.py
|
|
17
|
+
tests/test_quote_production_safety.py
|
|
18
|
+
tests/test_quote_validation.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
payment_contracts
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from payment_contracts.models import (
|
|
2
|
+
BillingPlan,
|
|
3
|
+
CheckoutSession,
|
|
4
|
+
DisputeCase,
|
|
5
|
+
EscrowId,
|
|
6
|
+
FeeKind,
|
|
7
|
+
FeeSchedule,
|
|
8
|
+
InMemoryNonceStore,
|
|
9
|
+
MarketplaceListing,
|
|
10
|
+
MarketplaceOrder,
|
|
11
|
+
NonceStore,
|
|
12
|
+
OpenTrustProduct,
|
|
13
|
+
PaymentQuote,
|
|
14
|
+
PaymentResult,
|
|
15
|
+
RefundResult,
|
|
16
|
+
RepoVerification,
|
|
17
|
+
Resolution,
|
|
18
|
+
Subscription,
|
|
19
|
+
WalletAccount,
|
|
20
|
+
WalletMode,
|
|
21
|
+
validate_quote,
|
|
22
|
+
validate_quote_expiration,
|
|
23
|
+
validate_quote_nonce,
|
|
24
|
+
validate_quote_signature,
|
|
25
|
+
validate_quote_wallet,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BillingPlan",
|
|
30
|
+
"CheckoutSession",
|
|
31
|
+
"DisputeCase",
|
|
32
|
+
"EscrowId",
|
|
33
|
+
"FeeKind",
|
|
34
|
+
"FeeSchedule",
|
|
35
|
+
"InMemoryNonceStore",
|
|
36
|
+
"MarketplaceListing",
|
|
37
|
+
"MarketplaceOrder",
|
|
38
|
+
"NonceStore",
|
|
39
|
+
"OpenTrustProduct",
|
|
40
|
+
"PaymentQuote",
|
|
41
|
+
"PaymentResult",
|
|
42
|
+
"RefundResult",
|
|
43
|
+
"RepoVerification",
|
|
44
|
+
"Resolution",
|
|
45
|
+
"Subscription",
|
|
46
|
+
"WalletAccount",
|
|
47
|
+
"WalletMode",
|
|
48
|
+
"validate_quote",
|
|
49
|
+
"validate_quote_expiration",
|
|
50
|
+
"validate_quote_nonce",
|
|
51
|
+
"validate_quote_signature",
|
|
52
|
+
"validate_quote_wallet",
|
|
53
|
+
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from .escrow_interface import EscrowProvider
|
|
2
|
+
from .payment_gateway import PaymentGateway
|
|
3
|
+
from .subscription_manager import SubscriptionManager
|
|
4
|
+
from .verification_pricing import VerificationPricing
|
|
5
|
+
|
|
6
|
+
__all__ = ["EscrowProvider", "PaymentGateway", "SubscriptionManager", "VerificationPricing"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from payment_contracts.models import DisputeCase, EscrowId, Resolution
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EscrowProvider(ABC):
|
|
7
|
+
@abstractmethod
|
|
8
|
+
def create_escrow(self, buyer_id: str, seller_id: str, amount: Decimal) -> EscrowId:
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def deposit_address(self, escrow_id: str) -> str:
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def release_funds(self, escrow_id: str) -> bool:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def refund_buyer(self, escrow_id: str) -> Resolution:
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def dispute(self, escrow_id: str, reason: str) -> DisputeCase:
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def resolve_dispute(self, case_id: str, winner: str) -> Resolution:
|
|
29
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from payment_contracts.models import CheckoutSession, PaymentResult, RefundResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PaymentGateway(ABC):
|
|
7
|
+
@abstractmethod
|
|
8
|
+
def create_checkout(self, tool_id: str, plan: str, amount_usdc: Decimal) -> CheckoutSession:
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def verify_payment(self, session_id: str) -> PaymentResult:
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def process_refund(self, payment_id: str, amount: Decimal) -> RefundResult:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def get_balance(self) -> float:
|
|
21
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from payment_contracts.models import BillingPlan, Subscription
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SubscriptionManager(ABC):
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def create_subscription(self, tool_id: str, plan: BillingPlan, customer: str) -> Subscription:
|
|
8
|
+
raise NotImplementedError
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def cancel(self, subscription_id: str) -> bool:
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def get_active_subscriptions(self, tool_id: str) -> list[Subscription]:
|
|
16
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class VerificationPricing(ABC):
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def get_verification_fee(self, tool_type: str) -> Decimal:
|
|
8
|
+
raise NotImplementedError
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def get_listing_fee(self, category: str) -> Decimal:
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def calculate_reviewer_payout(self, verification_id: str) -> Decimal:
|
|
16
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Payment contracts models including signed payment quote validation and nonce replay protection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Legacy models (existing)
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
class FeeKind(str, Enum):
|
|
19
|
+
free = "free"
|
|
20
|
+
flat_fee = "flat_fee"
|
|
21
|
+
percentage = "percentage"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FeeSchedule(BaseModel):
|
|
25
|
+
kind: FeeKind
|
|
26
|
+
amount_usdc: Decimal | None = Field(default=None, ge=0)
|
|
27
|
+
percentage: Decimal | None = Field(default=None, ge=0, le=100)
|
|
28
|
+
notes: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BillingPlan(BaseModel):
|
|
32
|
+
tier: str
|
|
33
|
+
interval: str = "one_time"
|
|
34
|
+
amount_usdc: Decimal = Field(ge=0)
|
|
35
|
+
fee_schedule: FeeSchedule | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CheckoutSession(BaseModel):
|
|
39
|
+
session_id: str
|
|
40
|
+
tool_id: str
|
|
41
|
+
checkout_url: str
|
|
42
|
+
amount_usdc: Decimal = Field(ge=0)
|
|
43
|
+
status: str = "created"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PaymentResult(BaseModel):
|
|
47
|
+
payment_id: str
|
|
48
|
+
session_id: str
|
|
49
|
+
verified: bool
|
|
50
|
+
amount_usdc: Decimal = Field(ge=0)
|
|
51
|
+
status: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RefundResult(BaseModel):
|
|
55
|
+
refund_id: str
|
|
56
|
+
payment_id: str
|
|
57
|
+
amount_usdc: Decimal = Field(ge=0)
|
|
58
|
+
status: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Subscription(BaseModel):
|
|
62
|
+
subscription_id: str
|
|
63
|
+
tool_id: str
|
|
64
|
+
customer: str
|
|
65
|
+
plan: BillingPlan
|
|
66
|
+
active: bool = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class EscrowId(BaseModel):
|
|
70
|
+
escrow_id: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DisputeCase(BaseModel):
|
|
74
|
+
case_id: str
|
|
75
|
+
escrow_id: str
|
|
76
|
+
reason: str
|
|
77
|
+
status: str = "open"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Resolution(BaseModel):
|
|
81
|
+
case_id: str
|
|
82
|
+
winner: str
|
|
83
|
+
released: bool
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class OpenTrustProduct(str, Enum):
|
|
87
|
+
trust_report = "trust_report"
|
|
88
|
+
verified_badge = "verified_badge"
|
|
89
|
+
monitoring_monthly = "monitoring_monthly"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class WalletMode(str, Enum):
|
|
93
|
+
byo = "byo"
|
|
94
|
+
embedded = "embedded"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RepoVerification(BaseModel):
|
|
98
|
+
repo_id: str
|
|
99
|
+
installation_id: int
|
|
100
|
+
repo_full_name: str
|
|
101
|
+
branch: str
|
|
102
|
+
commit_sha: str
|
|
103
|
+
verified: bool = True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class WalletAccount(BaseModel):
|
|
107
|
+
wallet_id: str
|
|
108
|
+
owner: str
|
|
109
|
+
address: str
|
|
110
|
+
mode: WalletMode
|
|
111
|
+
custody: str = "customer"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class MarketplaceListing(BaseModel):
|
|
115
|
+
listing_id: str
|
|
116
|
+
seller_wallet_id: str
|
|
117
|
+
repo_id: str
|
|
118
|
+
title: str
|
|
119
|
+
price_usdc: Decimal = Field(gt=0)
|
|
120
|
+
currency: str = "USDC"
|
|
121
|
+
custody: str = "none"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class MarketplaceOrder(BaseModel):
|
|
125
|
+
order_id: str
|
|
126
|
+
listing_id: str
|
|
127
|
+
buyer_wallet_id: str
|
|
128
|
+
seller_wallet_id: str
|
|
129
|
+
amount_usdc: Decimal = Field(gt=0)
|
|
130
|
+
currency: str = "USDC"
|
|
131
|
+
transaction_hash: str | None = None
|
|
132
|
+
custody: str = "none"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# PaymentQuote model (new)
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
class PaymentQuote(BaseModel):
|
|
140
|
+
"""A cryptographically signed payment quote.
|
|
141
|
+
|
|
142
|
+
A tool operator signs a quote containing price, recipient, and a nonce.
|
|
143
|
+
The agent verifies the signature, checks expiration, and uses the nonce
|
|
144
|
+
to prevent replay attacks.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
quote_id: str
|
|
148
|
+
passport_slug: str
|
|
149
|
+
version_hash: str
|
|
150
|
+
amount: Decimal = Field(ge=0)
|
|
151
|
+
currency: str = "USDC"
|
|
152
|
+
chain: str = "base"
|
|
153
|
+
recipient_wallet: str
|
|
154
|
+
expires_at: datetime
|
|
155
|
+
nonce: str
|
|
156
|
+
terms_hash: str | None = None
|
|
157
|
+
proof_requirement: str = "hash_match"
|
|
158
|
+
signature: str = ""
|
|
159
|
+
|
|
160
|
+
def signing_payload(self) -> str:
|
|
161
|
+
"""Return the canonical string that gets signed.
|
|
162
|
+
|
|
163
|
+
Excludes the signature field itself. Uses a sorted JSON serialization
|
|
164
|
+
so the payload is deterministic regardless of field order.
|
|
165
|
+
"""
|
|
166
|
+
data = self.model_dump(mode="json", exclude={"signature"})
|
|
167
|
+
data["amount"] = str(self.amount)
|
|
168
|
+
data["expires_at"] = self.expires_at.isoformat()
|
|
169
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Nonce store (replay protection)
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
class NonceStore(Protocol):
|
|
177
|
+
"""Protocol for nonce storage backends."""
|
|
178
|
+
|
|
179
|
+
def seen(self, nonce: str) -> bool: ...
|
|
180
|
+
|
|
181
|
+
def mark_seen(self, nonce: str) -> None: ...
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class InMemoryNonceStore:
|
|
185
|
+
"""Thread-safe in-memory nonce store for replay protection."""
|
|
186
|
+
|
|
187
|
+
def __init__(self) -> None:
|
|
188
|
+
self._seen: set[str] = set()
|
|
189
|
+
|
|
190
|
+
def seen(self, nonce: str) -> bool:
|
|
191
|
+
return nonce in self._seen
|
|
192
|
+
|
|
193
|
+
def mark_seen(self, nonce: str) -> None:
|
|
194
|
+
self._seen.add(nonce)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Validation functions
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def validate_quote_signature(
|
|
202
|
+
quote: PaymentQuote,
|
|
203
|
+
public_key: Ed25519PublicKey,
|
|
204
|
+
) -> bool:
|
|
205
|
+
"""Verify the Ed25519 signature on a PaymentQuote.
|
|
206
|
+
|
|
207
|
+
Returns True if the signature is valid, False otherwise.
|
|
208
|
+
"""
|
|
209
|
+
if not quote.signature:
|
|
210
|
+
return False
|
|
211
|
+
try:
|
|
212
|
+
sig = bytes.fromhex(quote.signature)
|
|
213
|
+
message = quote.signing_payload().encode("utf-8")
|
|
214
|
+
public_key.verify(sig, message)
|
|
215
|
+
return True
|
|
216
|
+
except Exception:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def validate_quote_expiration(quote: PaymentQuote) -> bool:
|
|
221
|
+
"""Check that the quote has not expired.
|
|
222
|
+
|
|
223
|
+
Returns True if expires_at is in the future, False if expired.
|
|
224
|
+
"""
|
|
225
|
+
return quote.expires_at > datetime.now(timezone.utc)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def validate_quote_nonce(
|
|
229
|
+
quote: PaymentQuote,
|
|
230
|
+
nonce_store: NonceStore,
|
|
231
|
+
) -> bool:
|
|
232
|
+
"""Check that the nonce hasn't been used before.
|
|
233
|
+
|
|
234
|
+
Returns True if the nonce is fresh (first use), False if replayed.
|
|
235
|
+
Marks the nonce as seen on first use.
|
|
236
|
+
"""
|
|
237
|
+
if nonce_store.seen(quote.nonce):
|
|
238
|
+
return False
|
|
239
|
+
nonce_store.mark_seen(quote.nonce)
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def validate_quote_wallet(
|
|
244
|
+
quote: PaymentQuote,
|
|
245
|
+
expected_wallet: str,
|
|
246
|
+
) -> bool:
|
|
247
|
+
"""Check that the quote's recipient_wallet matches the expected wallet.
|
|
248
|
+
|
|
249
|
+
Returns True if wallets match, False otherwise.
|
|
250
|
+
"""
|
|
251
|
+
return quote.recipient_wallet == expected_wallet
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def validate_quote(
|
|
255
|
+
quote: PaymentQuote,
|
|
256
|
+
public_key: Ed25519PublicKey,
|
|
257
|
+
nonce_store: NonceStore,
|
|
258
|
+
expected_wallet: str,
|
|
259
|
+
) -> list[str]:
|
|
260
|
+
"""Run all validation checks on a signed PaymentQuote.
|
|
261
|
+
|
|
262
|
+
Returns a list of error messages. An empty list means the quote is valid.
|
|
263
|
+
The nonce is burned only after signature, expiration, and wallet checks pass;
|
|
264
|
+
otherwise an attacker could submit a malformed quote first and DoS the real
|
|
265
|
+
quote by consuming its nonce.
|
|
266
|
+
"""
|
|
267
|
+
errors: list[str] = []
|
|
268
|
+
|
|
269
|
+
if not validate_quote_signature(quote, public_key):
|
|
270
|
+
errors.append("invalid signature")
|
|
271
|
+
|
|
272
|
+
if not validate_quote_expiration(quote):
|
|
273
|
+
errors.append("quote expired")
|
|
274
|
+
|
|
275
|
+
if not validate_quote_wallet(quote, expected_wallet):
|
|
276
|
+
errors.append("wallet mismatch")
|
|
277
|
+
|
|
278
|
+
if not errors and not validate_quote_nonce(quote, nonce_store):
|
|
279
|
+
errors.append("nonce replay detected")
|
|
280
|
+
|
|
281
|
+
return errors
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "opentrust-payment-contracts"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Abstract payment contracts for OpenTrust payment providers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Novel Hut Studios", email = "founder@novelhut.net" }]
|
|
13
|
+
keywords = ["opentrust", "payments", "escrow", "ai-agents", "tool-marketplace"]
|
|
14
|
+
dependencies = ["pydantic>=2.7", "cryptography>=42"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/Costder/opentrust"
|
|
18
|
+
Repository = "https://github.com/Costder/opentrust"
|
|
19
|
+
Issues = "https://github.com/Costder/opentrust/issues"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
packages = ["payment_contracts", "payment_contracts.interfaces"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from payment_contracts.interfaces.escrow_interface import EscrowProvider
|
|
4
|
+
from payment_contracts.interfaces.payment_gateway import PaymentGateway
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DummyGateway(PaymentGateway):
|
|
8
|
+
def create_checkout(self, tool_id, plan, amount_usdc):
|
|
9
|
+
return super().create_checkout(tool_id, plan, amount_usdc)
|
|
10
|
+
|
|
11
|
+
def verify_payment(self, session_id):
|
|
12
|
+
return super().verify_payment(session_id)
|
|
13
|
+
|
|
14
|
+
def process_refund(self, payment_id, amount):
|
|
15
|
+
return super().process_refund(payment_id, amount)
|
|
16
|
+
|
|
17
|
+
def get_balance(self):
|
|
18
|
+
return super().get_balance()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DummyEscrowProvider(EscrowProvider):
|
|
22
|
+
def create_escrow(self, buyer_id, seller_id, amount):
|
|
23
|
+
return super().create_escrow(buyer_id, seller_id, amount)
|
|
24
|
+
|
|
25
|
+
def deposit_address(self, escrow_id):
|
|
26
|
+
return super().deposit_address(escrow_id)
|
|
27
|
+
|
|
28
|
+
def release_funds(self, escrow_id):
|
|
29
|
+
return super().release_funds(escrow_id)
|
|
30
|
+
|
|
31
|
+
def refund_buyer(self, escrow_id):
|
|
32
|
+
return super().refund_buyer(escrow_id)
|
|
33
|
+
|
|
34
|
+
def dispute(self, escrow_id, reason):
|
|
35
|
+
return super().dispute(escrow_id, reason)
|
|
36
|
+
|
|
37
|
+
def resolve_dispute(self, case_id, winner):
|
|
38
|
+
return super().resolve_dispute(case_id, winner)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_payment_gateway_methods_raise_not_implemented():
|
|
42
|
+
gateway = DummyGateway()
|
|
43
|
+
with pytest.raises(NotImplementedError):
|
|
44
|
+
gateway.create_checkout("tool", "verification", Decimal("10"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_escrow_provider_methods_raise_not_implemented():
|
|
48
|
+
provider = DummyEscrowProvider()
|
|
49
|
+
with pytest.raises(NotImplementedError):
|
|
50
|
+
provider.create_escrow("buyer", "seller", Decimal("10"))
|
|
51
|
+
with pytest.raises(NotImplementedError):
|
|
52
|
+
provider.deposit_address("escrow_1")
|
|
53
|
+
with pytest.raises(NotImplementedError):
|
|
54
|
+
provider.release_funds("escrow_1")
|
|
55
|
+
with pytest.raises(NotImplementedError):
|
|
56
|
+
provider.refund_buyer("escrow_1")
|
|
57
|
+
with pytest.raises(NotImplementedError):
|
|
58
|
+
provider.dispute("escrow_1", "missing delivery")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
from payment_contracts.models import (
|
|
3
|
+
BillingPlan,
|
|
4
|
+
CheckoutSession,
|
|
5
|
+
FeeSchedule,
|
|
6
|
+
MarketplaceListing,
|
|
7
|
+
MarketplaceOrder,
|
|
8
|
+
RepoVerification,
|
|
9
|
+
WalletAccount,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_checkout_session_validation():
|
|
14
|
+
session = CheckoutSession(session_id="s1", tool_id="tool", checkout_url="https://example.com", amount_usdc=Decimal("10"))
|
|
15
|
+
assert session.status == "created"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_billing_plan_accepts_fee_schedule():
|
|
19
|
+
plan = BillingPlan(tier="verification", amount_usdc=Decimal("25"), fee_schedule=FeeSchedule(kind="flat_fee", amount_usdc=Decimal("25")))
|
|
20
|
+
assert plan.fee_schedule is not None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_marketplace_contract_models_are_non_custodial():
|
|
24
|
+
repo = RepoVerification(
|
|
25
|
+
repo_id="repo_1",
|
|
26
|
+
installation_id=123,
|
|
27
|
+
repo_full_name="octo/tool",
|
|
28
|
+
branch="main",
|
|
29
|
+
commit_sha="abc1234567",
|
|
30
|
+
)
|
|
31
|
+
wallet = WalletAccount(
|
|
32
|
+
wallet_id="wallet_1",
|
|
33
|
+
owner="seller",
|
|
34
|
+
address="0x1111111111111111111111111111111111111111",
|
|
35
|
+
mode="byo",
|
|
36
|
+
)
|
|
37
|
+
listing = MarketplaceListing(
|
|
38
|
+
listing_id="listing_1",
|
|
39
|
+
seller_wallet_id=wallet.wallet_id,
|
|
40
|
+
repo_id=repo.repo_id,
|
|
41
|
+
title="Verified package",
|
|
42
|
+
price_usdc=Decimal("10"),
|
|
43
|
+
)
|
|
44
|
+
order = MarketplaceOrder(
|
|
45
|
+
order_id="order_1",
|
|
46
|
+
listing_id=listing.listing_id,
|
|
47
|
+
buyer_wallet_id="wallet_2",
|
|
48
|
+
seller_wallet_id=wallet.wallet_id,
|
|
49
|
+
amount_usdc=Decimal("10"),
|
|
50
|
+
)
|
|
51
|
+
assert listing.custody == "none"
|
|
52
|
+
assert order.custody == "none"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
|
|
4
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
5
|
+
|
|
6
|
+
from payment_contracts.models import InMemoryNonceStore, PaymentQuote, validate_quote
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _signed_quote(private_key, **overrides):
|
|
10
|
+
data = {
|
|
11
|
+
"quote_id": "quote-1",
|
|
12
|
+
"passport_slug": "demo-tool",
|
|
13
|
+
"version_hash": "sha256:abc123",
|
|
14
|
+
"amount": Decimal("0.25"),
|
|
15
|
+
"currency": "USDC",
|
|
16
|
+
"chain": "base",
|
|
17
|
+
"recipient_wallet": "0x1111111111111111111111111111111111111111",
|
|
18
|
+
"expires_at": datetime.now(timezone.utc) + timedelta(minutes=5),
|
|
19
|
+
"nonce": "nonce-1",
|
|
20
|
+
"terms_hash": "sha256:terms",
|
|
21
|
+
"proof_requirement": "hash_match",
|
|
22
|
+
}
|
|
23
|
+
data.update(overrides)
|
|
24
|
+
quote = PaymentQuote(**data)
|
|
25
|
+
signature = private_key.sign(quote.signing_payload().encode("utf-8")).hex()
|
|
26
|
+
return quote.model_copy(update={"signature": signature})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_payment_quote_requires_delivery_proof_requirement():
|
|
30
|
+
private_key = Ed25519PrivateKey.generate()
|
|
31
|
+
quote = _signed_quote(private_key)
|
|
32
|
+
|
|
33
|
+
assert quote.proof_requirement == "hash_match"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_invalid_quote_does_not_burn_nonce():
|
|
37
|
+
private_key = Ed25519PrivateKey.generate()
|
|
38
|
+
public_key = private_key.public_key()
|
|
39
|
+
nonce_store = InMemoryNonceStore()
|
|
40
|
+
quote = _signed_quote(private_key, nonce="nonce-dos")
|
|
41
|
+
bad_quote = quote.model_copy(update={"signature": "00"})
|
|
42
|
+
|
|
43
|
+
errors = validate_quote(
|
|
44
|
+
bad_quote,
|
|
45
|
+
public_key,
|
|
46
|
+
nonce_store,
|
|
47
|
+
expected_wallet="0x1111111111111111111111111111111111111111",
|
|
48
|
+
)
|
|
49
|
+
assert "invalid signature" in errors
|
|
50
|
+
assert nonce_store.seen("nonce-dos") is False
|
|
51
|
+
|
|
52
|
+
assert validate_quote(
|
|
53
|
+
quote,
|
|
54
|
+
public_key,
|
|
55
|
+
nonce_store,
|
|
56
|
+
expected_wallet="0x1111111111111111111111111111111111111111",
|
|
57
|
+
) == []
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Tests for signed payment quote validation and nonce replay protection.
|
|
2
|
+
|
|
3
|
+
Strict TDD: Write failing tests first, then implement minimal code.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
13
|
+
|
|
14
|
+
from payment_contracts.models import PaymentQuote
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Fixtures
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def signer_keys():
|
|
23
|
+
"""Generate a fresh Ed25519 key pair for each test."""
|
|
24
|
+
private_key = Ed25519PrivateKey.generate()
|
|
25
|
+
public_key = private_key.public_key()
|
|
26
|
+
return private_key, public_key
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def valid_quote_dict(signer_keys):
|
|
31
|
+
"""Return a valid unsigned quote dict (no signature yet)."""
|
|
32
|
+
_, public_key = signer_keys
|
|
33
|
+
return {
|
|
34
|
+
"quote_id": "qt_abc123",
|
|
35
|
+
"passport_slug": "github-search-mcp",
|
|
36
|
+
"version_hash": "v1.0.0",
|
|
37
|
+
"amount": Decimal("19.00"),
|
|
38
|
+
"currency": "USDC",
|
|
39
|
+
"chain": "base",
|
|
40
|
+
"recipient_wallet": "0x1234567890abcdef1234567890abcdef12345678",
|
|
41
|
+
"expires_at": datetime.now(timezone.utc) + timedelta(hours=1),
|
|
42
|
+
"nonce": "nonce-001",
|
|
43
|
+
"terms_hash": "sha256:abc123def456",
|
|
44
|
+
"signature": "",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def signed_quote(valid_quote_dict, signer_keys):
|
|
50
|
+
"""Create a fully signed PaymentQuote."""
|
|
51
|
+
private_key, public_key = signer_keys
|
|
52
|
+
quote = PaymentQuote(**valid_quote_dict)
|
|
53
|
+
message = quote.signing_payload().encode("utf-8")
|
|
54
|
+
sig = private_key.sign(message)
|
|
55
|
+
quote.signature = sig.hex()
|
|
56
|
+
return quote, public_key
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Model tests
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
class TestPaymentQuoteModel:
|
|
64
|
+
"""PaymentQuote Pydantic model construction."""
|
|
65
|
+
|
|
66
|
+
def test_minimal_quote_construction(self, valid_quote_dict):
|
|
67
|
+
"""A quote with all required fields can be constructed."""
|
|
68
|
+
quote = PaymentQuote(**valid_quote_dict)
|
|
69
|
+
assert quote.quote_id == "qt_abc123"
|
|
70
|
+
assert quote.passport_slug == "github-search-mcp"
|
|
71
|
+
assert quote.amount == Decimal("19.00")
|
|
72
|
+
assert quote.currency == "USDC"
|
|
73
|
+
assert quote.chain == "base"
|
|
74
|
+
|
|
75
|
+
def test_default_values(self):
|
|
76
|
+
"""Defaults: currency='USDC', chain='base'."""
|
|
77
|
+
now = datetime.now(timezone.utc)
|
|
78
|
+
quote = PaymentQuote(
|
|
79
|
+
quote_id="qt_1",
|
|
80
|
+
passport_slug="slug",
|
|
81
|
+
version_hash="v1",
|
|
82
|
+
amount=Decimal("10"),
|
|
83
|
+
recipient_wallet="0xabc",
|
|
84
|
+
expires_at=now,
|
|
85
|
+
nonce="n1",
|
|
86
|
+
)
|
|
87
|
+
assert quote.currency == "USDC"
|
|
88
|
+
assert quote.chain == "base"
|
|
89
|
+
assert quote.terms_hash is None
|
|
90
|
+
assert quote.signature == ""
|
|
91
|
+
|
|
92
|
+
def test_amount_must_be_positive(self):
|
|
93
|
+
"""Amount must be >= 0."""
|
|
94
|
+
now = datetime.now(timezone.utc)
|
|
95
|
+
with pytest.raises(ValueError):
|
|
96
|
+
PaymentQuote(
|
|
97
|
+
quote_id="qt_1",
|
|
98
|
+
passport_slug="slug",
|
|
99
|
+
version_hash="v1",
|
|
100
|
+
amount=Decimal("-1"),
|
|
101
|
+
recipient_wallet="0xabc",
|
|
102
|
+
expires_at=now,
|
|
103
|
+
nonce="n1",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def test_expires_at_must_be_future(self, valid_quote_dict):
|
|
107
|
+
"""Model allows past expires_at — validation is separate."""
|
|
108
|
+
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
109
|
+
quote = PaymentQuote(
|
|
110
|
+
quote_id="qt_1",
|
|
111
|
+
passport_slug="slug",
|
|
112
|
+
version_hash="v1",
|
|
113
|
+
amount=Decimal("10"),
|
|
114
|
+
recipient_wallet="0xabc",
|
|
115
|
+
expires_at=past,
|
|
116
|
+
nonce="n1",
|
|
117
|
+
)
|
|
118
|
+
assert quote.expires_at < datetime.now(timezone.utc)
|
|
119
|
+
|
|
120
|
+
def test_signing_payload_is_deterministic(self, valid_quote_dict):
|
|
121
|
+
"""Same data produces same signing payload."""
|
|
122
|
+
q1 = PaymentQuote(**valid_quote_dict)
|
|
123
|
+
q2 = PaymentQuote(**valid_quote_dict)
|
|
124
|
+
assert q1.signing_payload() == q2.signing_payload()
|
|
125
|
+
|
|
126
|
+
def test_signing_payload_excludes_signature(self, valid_quote_dict):
|
|
127
|
+
"""signing_payload must NOT include the signature field."""
|
|
128
|
+
q1 = PaymentQuote(**valid_quote_dict)
|
|
129
|
+
q1.signature = ""
|
|
130
|
+
q2 = PaymentQuote(**valid_quote_dict)
|
|
131
|
+
q2.signature = "DEADBEEF"
|
|
132
|
+
assert q1.signing_payload() == q2.signing_payload()
|
|
133
|
+
|
|
134
|
+
def test_signing_payload_format(self, valid_quote_dict):
|
|
135
|
+
"""signing_payload returns a canonical JSON string."""
|
|
136
|
+
quote = PaymentQuote(**valid_quote_dict)
|
|
137
|
+
payload = quote.signing_payload()
|
|
138
|
+
assert isinstance(payload, str)
|
|
139
|
+
assert len(payload) > 0
|
|
140
|
+
# Should contain key fields
|
|
141
|
+
assert "qt_abc123" in payload
|
|
142
|
+
assert "github-search-mcp" in payload
|
|
143
|
+
assert "nonce-001" in payload
|
|
144
|
+
|
|
145
|
+
def test_sign_and_verify_roundtrip(self, valid_quote_dict, signer_keys):
|
|
146
|
+
"""Sign a quote and verify it using the signer's public key."""
|
|
147
|
+
private_key, public_key = signer_keys
|
|
148
|
+
quote = PaymentQuote(**valid_quote_dict)
|
|
149
|
+
message = quote.signing_payload().encode("utf-8")
|
|
150
|
+
sig = private_key.sign(message)
|
|
151
|
+
quote.signature = sig.hex()
|
|
152
|
+
public_key.verify(sig, message)
|
|
153
|
+
assert quote.signature == sig.hex()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Quote validation tests
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
class TestQuoteSignatureValidation:
|
|
161
|
+
"""Verify Ed25519 signature on PaymentQuote."""
|
|
162
|
+
|
|
163
|
+
def test_valid_signature_passes(self, signed_quote):
|
|
164
|
+
"""A correctly signed quote passes signature validation."""
|
|
165
|
+
quote, public_key = signed_quote
|
|
166
|
+
from payment_contracts.models import validate_quote_signature
|
|
167
|
+
result = validate_quote_signature(quote, public_key)
|
|
168
|
+
assert result is True
|
|
169
|
+
|
|
170
|
+
def test_invalid_signature_fails(self, signed_quote):
|
|
171
|
+
"""A quote with a tampered signature fails."""
|
|
172
|
+
quote, public_key = signed_quote
|
|
173
|
+
quote.signature = "deadbeef" * 8 # garbage
|
|
174
|
+
from payment_contracts.models import validate_quote_signature
|
|
175
|
+
result = validate_quote_signature(quote, public_key)
|
|
176
|
+
assert result is False
|
|
177
|
+
|
|
178
|
+
def test_tampered_payload_fails(self, signed_quote):
|
|
179
|
+
"""A quote whose data was altered after signing fails validation."""
|
|
180
|
+
quote, public_key = signed_quote
|
|
181
|
+
quote.amount = Decimal("999.99") # tamper
|
|
182
|
+
from payment_contracts.models import validate_quote_signature
|
|
183
|
+
result = validate_quote_signature(quote, public_key)
|
|
184
|
+
assert result is False
|
|
185
|
+
|
|
186
|
+
def test_missing_signature_fails(self, valid_quote_dict, signer_keys):
|
|
187
|
+
"""A quote with empty signature fails."""
|
|
188
|
+
_, public_key = signer_keys
|
|
189
|
+
quote = PaymentQuote(**valid_quote_dict)
|
|
190
|
+
quote.signature = ""
|
|
191
|
+
from payment_contracts.models import validate_quote_signature
|
|
192
|
+
result = validate_quote_signature(quote, public_key)
|
|
193
|
+
assert result is False
|
|
194
|
+
|
|
195
|
+
def test_wrong_public_key_fails(self, signed_quote):
|
|
196
|
+
"""Verifying with a different public key fails."""
|
|
197
|
+
quote, _ = signed_quote
|
|
198
|
+
wrong_key = Ed25519PrivateKey.generate().public_key()
|
|
199
|
+
from payment_contracts.models import validate_quote_signature
|
|
200
|
+
result = validate_quote_signature(quote, wrong_key)
|
|
201
|
+
assert result is False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class TestQuoteExpirationValidation:
|
|
205
|
+
"""Reject expired quotes."""
|
|
206
|
+
|
|
207
|
+
def test_future_quote_passes(self, signed_quote):
|
|
208
|
+
"""A quote expiring in the future passes."""
|
|
209
|
+
quote, _ = signed_quote
|
|
210
|
+
from payment_contracts.models import validate_quote_expiration
|
|
211
|
+
result = validate_quote_expiration(quote)
|
|
212
|
+
assert result is True
|
|
213
|
+
|
|
214
|
+
def test_expired_quote_fails(self, valid_quote_dict, signer_keys):
|
|
215
|
+
"""A quote whose expires_at is in the past fails."""
|
|
216
|
+
private_key, _ = signer_keys
|
|
217
|
+
past = datetime.now(timezone.utc) - timedelta(seconds=1)
|
|
218
|
+
valid_quote_dict["expires_at"] = past
|
|
219
|
+
quote = PaymentQuote(**valid_quote_dict)
|
|
220
|
+
message = quote.signing_payload().encode("utf-8")
|
|
221
|
+
sig = private_key.sign(message)
|
|
222
|
+
quote.signature = sig.hex()
|
|
223
|
+
from payment_contracts.models import validate_quote_expiration
|
|
224
|
+
result = validate_quote_expiration(quote)
|
|
225
|
+
assert result is False
|
|
226
|
+
|
|
227
|
+
def test_just_expired_quote_fails(self, valid_quote_dict, signer_keys):
|
|
228
|
+
"""A quote that expired 1 second ago fails."""
|
|
229
|
+
private_key, _ = signer_keys
|
|
230
|
+
just_past = datetime.now(timezone.utc) - timedelta(seconds=1)
|
|
231
|
+
valid_quote_dict["expires_at"] = just_past
|
|
232
|
+
quote = PaymentQuote(**valid_quote_dict)
|
|
233
|
+
message = quote.signing_payload().encode("utf-8")
|
|
234
|
+
sig = private_key.sign(message)
|
|
235
|
+
quote.signature = sig.hex()
|
|
236
|
+
from payment_contracts.models import validate_quote_expiration
|
|
237
|
+
result = validate_quote_expiration(quote)
|
|
238
|
+
assert result is False
|
|
239
|
+
|
|
240
|
+
def test_near_future_quote_passes(self, valid_quote_dict, signer_keys):
|
|
241
|
+
"""A quote expiring just slightly in the future passes."""
|
|
242
|
+
private_key, _ = signer_keys
|
|
243
|
+
near_future = datetime.now(timezone.utc) + timedelta(seconds=5)
|
|
244
|
+
valid_quote_dict["expires_at"] = near_future
|
|
245
|
+
quote = PaymentQuote(**valid_quote_dict)
|
|
246
|
+
message = quote.signing_payload().encode("utf-8")
|
|
247
|
+
sig = private_key.sign(message)
|
|
248
|
+
quote.signature = sig.hex()
|
|
249
|
+
from payment_contracts.models import validate_quote_expiration
|
|
250
|
+
result = validate_quote_expiration(quote)
|
|
251
|
+
assert result is True
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class TestNonceReplayProtection:
|
|
255
|
+
"""Reject replayed nonces."""
|
|
256
|
+
|
|
257
|
+
@pytest.fixture
|
|
258
|
+
def nonce_store(self):
|
|
259
|
+
"""Simple in-memory nonce store."""
|
|
260
|
+
from payment_contracts.models import InMemoryNonceStore
|
|
261
|
+
return InMemoryNonceStore()
|
|
262
|
+
|
|
263
|
+
def test_fresh_nonce_passes(self, signed_quote, nonce_store):
|
|
264
|
+
"""A never-before-seen nonce passes."""
|
|
265
|
+
quote, _ = signed_quote
|
|
266
|
+
from payment_contracts.models import validate_quote_nonce
|
|
267
|
+
result = validate_quote_nonce(quote, nonce_store)
|
|
268
|
+
assert result is True
|
|
269
|
+
|
|
270
|
+
def test_replayed_nonce_fails(self, signed_quote, nonce_store):
|
|
271
|
+
"""The same nonce used twice fails."""
|
|
272
|
+
quote, _ = signed_quote
|
|
273
|
+
from payment_contracts.models import validate_quote_nonce
|
|
274
|
+
# First use — should pass
|
|
275
|
+
assert validate_quote_nonce(quote, nonce_store) is True
|
|
276
|
+
# Second use — should fail (replay)
|
|
277
|
+
result = validate_quote_nonce(quote, nonce_store)
|
|
278
|
+
assert result is False
|
|
279
|
+
|
|
280
|
+
def test_different_nonces_pass(self, valid_quote_dict, signer_keys, nonce_store):
|
|
281
|
+
"""Different nonces are allowed."""
|
|
282
|
+
private_key, _ = signer_keys
|
|
283
|
+
from payment_contracts.models import validate_quote_nonce
|
|
284
|
+
|
|
285
|
+
for i in range(3):
|
|
286
|
+
d = dict(valid_quote_dict)
|
|
287
|
+
d["nonce"] = f"nonce-{i:03d}"
|
|
288
|
+
quote = PaymentQuote(**d)
|
|
289
|
+
message = quote.signing_payload().encode("utf-8")
|
|
290
|
+
sig = private_key.sign(message)
|
|
291
|
+
quote.signature = sig.hex()
|
|
292
|
+
assert validate_quote_nonce(quote, nonce_store) is True
|
|
293
|
+
|
|
294
|
+
def test_nonce_store_persistence(self, signed_quote, nonce_store):
|
|
295
|
+
"""Nonce remains seen across calls."""
|
|
296
|
+
quote, _ = signed_quote
|
|
297
|
+
from payment_contracts.models import validate_quote_nonce
|
|
298
|
+
assert validate_quote_nonce(quote, nonce_store) is True
|
|
299
|
+
assert validate_quote_nonce(quote, nonce_store) is False
|
|
300
|
+
|
|
301
|
+
def test_seen_method_on_store(self, signed_quote, nonce_store):
|
|
302
|
+
"""NonceStore.seen() correctly reports nonce state."""
|
|
303
|
+
quote, _ = signed_quote
|
|
304
|
+
from payment_contracts.models import validate_quote_nonce
|
|
305
|
+
assert nonce_store.seen(quote.nonce) is False
|
|
306
|
+
validate_quote_nonce(quote, nonce_store)
|
|
307
|
+
assert nonce_store.seen(quote.nonce) is True
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class TestQuoteWalletValidation:
|
|
311
|
+
"""Wallet must match expected recipient."""
|
|
312
|
+
|
|
313
|
+
def test_wallet_matches_passes(self, signed_quote):
|
|
314
|
+
"""When recipient_wallet matches expected, passes."""
|
|
315
|
+
quote, _ = signed_quote
|
|
316
|
+
from payment_contracts.models import validate_quote_wallet
|
|
317
|
+
result = validate_quote_wallet(quote, quote.recipient_wallet)
|
|
318
|
+
assert result is True
|
|
319
|
+
|
|
320
|
+
def test_wallet_mismatch_fails(self, signed_quote):
|
|
321
|
+
"""When recipient_wallet differs from expected, fails."""
|
|
322
|
+
quote, _ = signed_quote
|
|
323
|
+
from payment_contracts.models import validate_quote_wallet
|
|
324
|
+
result = validate_quote_wallet(quote, "0x9999999999999999999999999999999999999999")
|
|
325
|
+
assert result is False
|
|
326
|
+
|
|
327
|
+
def test_case_sensitive_wallet_check(self, signed_quote):
|
|
328
|
+
"""Wallet check is case-sensitive."""
|
|
329
|
+
quote, _ = signed_quote
|
|
330
|
+
from payment_contracts.models import validate_quote_wallet
|
|
331
|
+
wrong_case = quote.recipient_wallet.upper()
|
|
332
|
+
if wrong_case != quote.recipient_wallet:
|
|
333
|
+
result = validate_quote_wallet(quote, wrong_case)
|
|
334
|
+
assert result is False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
# Full validation pipeline tests
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
class TestFullQuoteValidation:
|
|
342
|
+
"""End-to-end validation combining all checks."""
|
|
343
|
+
|
|
344
|
+
@pytest.fixture
|
|
345
|
+
def nonce_store(self):
|
|
346
|
+
from payment_contracts.models import InMemoryNonceStore
|
|
347
|
+
return InMemoryNonceStore()
|
|
348
|
+
|
|
349
|
+
def test_complete_valid_quote_passes(self, signed_quote, nonce_store):
|
|
350
|
+
"""A fully valid quote passes all checks."""
|
|
351
|
+
quote, public_key = signed_quote
|
|
352
|
+
from payment_contracts.models import validate_quote
|
|
353
|
+
errors = validate_quote(
|
|
354
|
+
quote=quote,
|
|
355
|
+
public_key=public_key,
|
|
356
|
+
nonce_store=nonce_store,
|
|
357
|
+
expected_wallet=quote.recipient_wallet,
|
|
358
|
+
)
|
|
359
|
+
assert errors == []
|
|
360
|
+
|
|
361
|
+
def test_expired_quote_rejected(self, valid_quote_dict, signer_keys, nonce_store):
|
|
362
|
+
"""Expired quote fails full validation."""
|
|
363
|
+
private_key, public_key = signer_keys
|
|
364
|
+
past = datetime.now(timezone.utc) - timedelta(minutes=5)
|
|
365
|
+
valid_quote_dict["expires_at"] = past
|
|
366
|
+
quote = PaymentQuote(**valid_quote_dict)
|
|
367
|
+
message = quote.signing_payload().encode("utf-8")
|
|
368
|
+
sig = private_key.sign(message)
|
|
369
|
+
quote.signature = sig.hex()
|
|
370
|
+
|
|
371
|
+
from payment_contracts.models import validate_quote
|
|
372
|
+
errors = validate_quote(
|
|
373
|
+
quote=quote,
|
|
374
|
+
public_key=public_key,
|
|
375
|
+
nonce_store=nonce_store,
|
|
376
|
+
expected_wallet=quote.recipient_wallet,
|
|
377
|
+
)
|
|
378
|
+
assert "expired" in " ".join(errors).lower()
|
|
379
|
+
|
|
380
|
+
def test_bad_signature_rejected(self, valid_quote_dict, signer_keys, nonce_store):
|
|
381
|
+
"""Tampered signature fails full validation."""
|
|
382
|
+
_, public_key = signer_keys
|
|
383
|
+
quote = PaymentQuote(**valid_quote_dict)
|
|
384
|
+
quote.signature = "bad" * 20
|
|
385
|
+
from payment_contracts.models import validate_quote
|
|
386
|
+
errors = validate_quote(
|
|
387
|
+
quote=quote,
|
|
388
|
+
public_key=public_key,
|
|
389
|
+
nonce_store=nonce_store,
|
|
390
|
+
expected_wallet=quote.recipient_wallet,
|
|
391
|
+
)
|
|
392
|
+
assert "signature" in " ".join(errors).lower()
|
|
393
|
+
|
|
394
|
+
def test_replayed_nonce_rejected(self, signed_quote, nonce_store):
|
|
395
|
+
"""Replayed nonce fails full validation."""
|
|
396
|
+
quote, public_key = signed_quote
|
|
397
|
+
from payment_contracts.models import validate_quote
|
|
398
|
+
# First use
|
|
399
|
+
errors1 = validate_quote(
|
|
400
|
+
quote=quote,
|
|
401
|
+
public_key=public_key,
|
|
402
|
+
nonce_store=nonce_store,
|
|
403
|
+
expected_wallet=quote.recipient_wallet,
|
|
404
|
+
)
|
|
405
|
+
assert errors1 == []
|
|
406
|
+
# Second use (replay)
|
|
407
|
+
errors2 = validate_quote(
|
|
408
|
+
quote=quote,
|
|
409
|
+
public_key=public_key,
|
|
410
|
+
nonce_store=nonce_store,
|
|
411
|
+
expected_wallet=quote.recipient_wallet,
|
|
412
|
+
)
|
|
413
|
+
assert "nonce" in " ".join(errors2).lower()
|
|
414
|
+
|
|
415
|
+
def test_wallet_mismatch_rejected(self, signed_quote, nonce_store):
|
|
416
|
+
"""Wrong wallet fails full validation."""
|
|
417
|
+
quote, public_key = signed_quote
|
|
418
|
+
from payment_contracts.models import validate_quote
|
|
419
|
+
errors = validate_quote(
|
|
420
|
+
quote=quote,
|
|
421
|
+
public_key=public_key,
|
|
422
|
+
nonce_store=nonce_store,
|
|
423
|
+
expected_wallet="0x0000000000000000000000000000000000000000",
|
|
424
|
+
)
|
|
425
|
+
assert "wallet" in " ".join(errors).lower()
|
|
426
|
+
|
|
427
|
+
def test_multiple_failures_reported(self, valid_quote_dict, signer_keys, nonce_store):
|
|
428
|
+
"""Multiple validation errors are reported together."""
|
|
429
|
+
private_key, public_key = signer_keys
|
|
430
|
+
past = datetime.now(timezone.utc) - timedelta(hours=2)
|
|
431
|
+
valid_quote_dict["expires_at"] = past
|
|
432
|
+
quote = PaymentQuote(**valid_quote_dict)
|
|
433
|
+
message = quote.signing_payload().encode("utf-8")
|
|
434
|
+
sig = private_key.sign(message)
|
|
435
|
+
quote.signature = sig.hex()
|
|
436
|
+
quote.signature = "bad" * 20 # Also tamper signature
|
|
437
|
+
|
|
438
|
+
from payment_contracts.models import validate_quote
|
|
439
|
+
errors = validate_quote(
|
|
440
|
+
quote=quote,
|
|
441
|
+
public_key=public_key,
|
|
442
|
+
nonce_store=nonce_store,
|
|
443
|
+
expected_wallet="wrong_wallet",
|
|
444
|
+
)
|
|
445
|
+
assert len(errors) >= 2
|