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.
Files changed (20) hide show
  1. opentrust_payment_contracts-1.0.0/PKG-INFO +22 -0
  2. opentrust_payment_contracts-1.0.0/README.md +7 -0
  3. opentrust_payment_contracts-1.0.0/opentrust_payment_contracts.egg-info/PKG-INFO +22 -0
  4. opentrust_payment_contracts-1.0.0/opentrust_payment_contracts.egg-info/SOURCES.txt +18 -0
  5. opentrust_payment_contracts-1.0.0/opentrust_payment_contracts.egg-info/dependency_links.txt +1 -0
  6. opentrust_payment_contracts-1.0.0/opentrust_payment_contracts.egg-info/requires.txt +2 -0
  7. opentrust_payment_contracts-1.0.0/opentrust_payment_contracts.egg-info/top_level.txt +1 -0
  8. opentrust_payment_contracts-1.0.0/payment_contracts/__init__.py +53 -0
  9. opentrust_payment_contracts-1.0.0/payment_contracts/interfaces/__init__.py +6 -0
  10. opentrust_payment_contracts-1.0.0/payment_contracts/interfaces/escrow_interface.py +29 -0
  11. opentrust_payment_contracts-1.0.0/payment_contracts/interfaces/payment_gateway.py +21 -0
  12. opentrust_payment_contracts-1.0.0/payment_contracts/interfaces/subscription_manager.py +16 -0
  13. opentrust_payment_contracts-1.0.0/payment_contracts/interfaces/verification_pricing.py +16 -0
  14. opentrust_payment_contracts-1.0.0/payment_contracts/models.py +281 -0
  15. opentrust_payment_contracts-1.0.0/pyproject.toml +22 -0
  16. opentrust_payment_contracts-1.0.0/setup.cfg +4 -0
  17. opentrust_payment_contracts-1.0.0/tests/test_interfaces.py +58 -0
  18. opentrust_payment_contracts-1.0.0/tests/test_models.py +52 -0
  19. opentrust_payment_contracts-1.0.0/tests/test_quote_production_safety.py +57 -0
  20. 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,2 @@
1
+ pydantic>=2.7
2
+ cryptography>=42
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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