From c592a58148e3bb8f98e9c3decf07825a58f5190e Mon Sep 17 00:00:00 2001 From: Cobb Hayes Date: Wed, 27 May 2026 11:15:03 -0700 Subject: [PATCH] Public-flip audit: drop audit-ticket prefixes + topology refs + AI scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cardano-api: strip 'Fix #N:' audit-ticket prefixes from inline comments (was 50+ in main.py), drop hardening-pass changelog blocks from module docstring, rewrite README to drop deploy paths + marketing sections, keep tier/auth/TTL + policy IDs. cardano-checkout-py: drop TradeCraft lineage refs, swap chromaticcraft/tradecraft test fixtures for acme/globex, repository URL → git.sulkta.com. --- README.md | 108 +++++++--------------- cardano_checkout/__init__.py | 14 ++- cardano_checkout/invoice.py | 4 +- cardano_checkout/monitor.py | 40 +++----- cardano_checkout/scheduler.py | 21 +---- cardano_checkout/store.py | 12 +-- pyproject.toml | 2 +- tests/test_invoice.py | 2 +- tests/test_monitor_with_inmemory_store.py | 2 +- tests/test_store_protocol.py | 8 +- 10 files changed, 72 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index ecd3cf2..d20dcf9 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,16 @@ # cardano-checkout -Merchant-side Cardano payment lifecycle in Python. Zero-custody by design. +Merchant-side Cardano payment lifecycle in Python. Zero-custody. -**What we ship:** the invoice state machine + UTxO watcher + reprice -loop. Per-invoice HD-derived receive addresses, Koios polling, confirm -/ underpay / overpay classification, time-windowed repricing against +Ships the invoice state machine + UTxO watcher + reprice loop. +Per-invoice HD-derived receive addresses, Koios polling, confirm / +underpay / overpay classification, time-windowed repricing against your own oracle. -**What we don't ship:** Cardano primitives. Address derivation, chain -context, transaction building, native-script minting, signing — those -are all [pycardano](https://github.com/Python-Cardano/pycardano)'s job. -pycardano is mature, actively maintained (0.19.x as of 2026), and -covers every primitive cleanly. This library slots next to it — no -wrapping, no leaky abstraction, no second API to learn. - -## Why this exists - -Nothing else in the Python ecosystem (or any ecosystem — we checked) -packages zero-custody merchant Cardano payments as a reusable library. -Closest adjacents are all one of: - -- CIP-30 browser-wallet plugins (customer-signs, not server-watches) -- cardano-cli vending machines that watch a single static address (no - xpub, no per-invoice derivation) -- SaaS APIs (NMKR) — not libraries -- Dormant / pre-1.0 grabs from the 2021-2023 era - -The merchant state machine — "derive an address, watch for payment, -confirm within tolerance, reprice if the quote lapses, emit a confirmed -callback" — is what we package. You keep full control of everything -else by using pycardano directly. +Does NOT ship Cardano primitives. Address derivation, chain context, +transaction building, native-script minting, signing — use +[pycardano](https://github.com/Python-Cardano/pycardano) directly. +This library slots next to it. ## Quick start @@ -42,7 +23,7 @@ from cardano_checkout import ( ) -# Your oracle — we don't ship one. Anything async returning int lovelace works. +# Your oracle. Anything async returning int lovelace works. async def my_price_fn(usd: float) -> int: rate = await fetch_ada_usd_somewhere() # CoinGecko, Koios, fixed rate, etc. return int(round(usd / rate * 1_000_000)) @@ -51,20 +32,17 @@ async def my_price_fn(usd: float) -> int: async def main() -> None: store = InMemoryStore() # swap for your SQLAlchemy / asyncpg / sqlite adapter - # Create an invoice. In production you'd derive the receive address from - # your wallet xpub via pycardano — see the "Deriving addresses" section. invoice = Invoice( id="ord-0042", - merchant_id="chromaticcraft", + merchant_id="my-shop", derivation_index=42, - receive_address="addr1q...", # derived via pycardano — your code + receive_address="addr1q...", # derive via pycardano expected_lovelace=5_000_000, usd_amount=2.50, expires_at=datetime.now(timezone.utc) + timedelta(minutes=15), ) await store.create(invoice) - # Run the background scheduler — Koios poll every 15s + reprice every 60s. scheduler = InvoiceScheduler(store=store, price_fn=my_price_fn) await scheduler.start() @@ -77,12 +55,10 @@ asyncio.run(main()) ## Deriving addresses with pycardano -We used to wrap this. You don't need the wrapper. - ```python from pycardano import HDWallet, Address, Network -# Your merchant's account-level xpub — the xpub is public, not a secret. +# Account-level xpub — public, not a secret. xpub_hex = "..." account = HDWallet.from_xpub(bytes.fromhex(xpub_hex)) @@ -100,16 +76,9 @@ def derive_address(account: HDWallet, index: int, network=Network.MAINNET) -> st addr = derive_address(account, index=42) ``` -Six lines of pycardano that read cleanly against -[their docs](https://pycardano.readthedocs.io/). Our old wrapper would -have added one function call but made you learn our API instead of -pycardano's. Skip it. +## NFT cert: CIP-25 v2 metadata -## NFT cert-of-authenticity: CIP-25 v2 metadata - -If you want each paid order to ship with an on-chain NFT cert, here's -the CIP-25 v2 metadata builder as a copy-paste. It fits in your own -code — no dep, no wrapper. +Copy-paste builder for an on-chain cert per paid order. No dep. ```python def build_cip25_metadata( @@ -150,20 +119,18 @@ def build_cip25_metadata( } ``` -Hand that dict to pycardano's `AuxiliaryData(Metadata({...}))` when you -build the mint tx. Straight pycardano from there on. +Hand the dict to pycardano's `AuxiliaryData(Metadata({...}))` when +building the mint tx. ## Implementing your own InvoiceStore -The SDK's `InvoiceStore` is a Protocol — implement the six methods -against whatever backend you want (SQLAlchemy, asyncpg, SQLite, -in-memory for tests). +`InvoiceStore` is a Protocol — implement six methods against whatever +backend you want (SQLAlchemy, asyncpg, SQLite, in-memory). ```python from cardano_checkout import Invoice, InvoiceStatus, InvoiceStore class MySqliteStore: - # Implement these six methods and you're a valid InvoiceStore. async def create(self, invoice: Invoice) -> None: ... async def get(self, invoice_id: str) -> Invoice | None: ... async def list_by_status(self, status: InvoiceStatus, limit: int = 100) -> list[Invoice]: ... @@ -172,38 +139,29 @@ class MySqliteStore: async def record_tx(self, invoice_id: str, tx_hash: str, lovelace_delta: int) -> None: ... ``` -See `InMemoryStore` in `cardano_checkout/store.py` for a 90-line -reference implementation. +See `InMemoryStore` in `cardano_checkout/store.py` for a reference impl. -## Status (1.0.0-dev) +## Modules | Module | Purpose | |---|---| -| `invoice.py` | `Invoice` dataclass + `InvoiceStatus` enum — payment lifecycle states | +| `invoice.py` | `Invoice` dataclass + `InvoiceStatus` enum | | `store.py` | `InvoiceStore` Protocol + `InMemoryStore` reference impl | -| `monitor.py` | `check_address_utxos` (Koios), `evaluate_utxos` (ADA matching + tolerance), `check_pending_invoices`, `reprice_expired_invoices` (takes your `price_fn`) | -| `scheduler.py` | `InvoiceScheduler` — APScheduler wrapper, runs check/reprice on the same 15s/60s cadence TradeCraft's used in production since 2025 | +| `monitor.py` | `check_address_utxos` (Koios), `evaluate_utxos`, `check_pending_invoices`, `reprice_expired_invoices` | +| `scheduler.py` | `InvoiceScheduler` — APScheduler wrapper, 15s check + 60s reprice | -All tests offline, 26/26 green. Two direct deps: `httpx` (Koios calls), -`apscheduler` (background scheduling). No pycardano dep — that's the -consumer's pairing. +Two direct deps: `httpx`, `apscheduler`. No pycardano dep. -## Design principles +## Design -1. **Protocol-first.** Persistence, pricing, and any other side-effect - concern goes through a consumer-supplied interface. The SDK has no - opinion about your database, your oracle, or your ORM. -2. **Use pycardano directly.** We don't wrap primitives. If you need - address derivation, chain context, or transaction building, import - pycardano. Our package sits next to it, not on top. -3. **Zero-custody.** The merchant's keys never touch this code. We - handle xpub-derived addresses (public), UTxO observation (chain), - and state transitions (the store). Funds flow directly between - customer and merchant wallets. We are not a custodian. -4. **Offline-first tests.** Koios HTTP and price oracles are stubbed - or swapped via fixture. No network in CI. Live tests (preprod mint - round-trips, real Koios) are a consumer-side concern. +1. **Protocol-first.** Persistence, pricing, side-effects through + consumer-supplied interfaces. +2. **Use pycardano directly.** No wrapping of primitives. +3. **Zero-custody.** Merchant keys never touch this code. xpub-derived + addresses, UTxO observation, state transitions. Funds flow directly + between customer and merchant wallets. +4. **Offline-first tests.** Koios + price oracles stubbed via fixture. ## License -Apache-2.0 — matches the broader Cardano tooling ecosystem. +Apache-2.0. diff --git a/cardano_checkout/__init__.py b/cardano_checkout/__init__.py index acc27f1..7c8747c 100644 --- a/cardano_checkout/__init__.py +++ b/cardano_checkout/__init__.py @@ -1,17 +1,16 @@ """cardano-checkout — merchant-side Cardano payment lifecycle in Python. -Zero-custody by design: the merchant brings a wallet xpub and an +Zero-custody: the merchant brings a wallet xpub and an :class:`~cardano_checkout.store.InvoiceStore` implementation. The SDK owns the payment lifecycle — per-invoice receive-address bookkeeping, Koios UTxO polling, confirm / underpay / overpay classification, and time-windowed repricing against a consumer-supplied oracle. -**The SDK deliberately does NOT ship Cardano primitives.** Address -derivation, transaction building, chain context, and native-script -minting all live in `pycardano `_ -and are consumer concerns. See the README for the pairing pattern and -for the CIP-25 v2 metadata-builder snippet (a 60-line helper that fits -anywhere in your own code without needing a separate dep). +Does NOT ship Cardano primitives. Address derivation, transaction +building, chain context, and native-script minting live in +`pycardano `_. See the +README for the pairing pattern and the CIP-25 v2 metadata-builder +snippet. Quick start:: @@ -20,7 +19,6 @@ Quick start:: store = InMemoryStore() # or your SQLAlchemy / asyncpg / sqlite adapter async def my_price_fn(usd: float) -> int: - # your oracle — CoinGecko / Koios ticker / fixed rate in tests rate = await fetch_ada_usd_rate() return int(round(usd / rate * 1_000_000)) diff --git a/cardano_checkout/invoice.py b/cardano_checkout/invoice.py index 9b51a97..64b1e53 100644 --- a/cardano_checkout/invoice.py +++ b/cardano_checkout/invoice.py @@ -5,8 +5,8 @@ derived from the merchant's xpub, an expected amount in lovelace, a USD-denominated label, and a lifecycle state that transitions as the chain confirms payment. -The Invoice is deliberately framework-agnostic — persistence is -delegated to an :class:`InvoiceStore` (see :mod:`cardano_checkout.store`). +Persistence is delegated to an :class:`InvoiceStore` +(see :mod:`cardano_checkout.store`). """ from __future__ import annotations diff --git a/cardano_checkout/monitor.py b/cardano_checkout/monitor.py index bffb8c4..6a3bd2b 100644 --- a/cardano_checkout/monitor.py +++ b/cardano_checkout/monitor.py @@ -13,17 +13,12 @@ Koios endpoint used:: POST https://api.koios.rest/api/v1/address_utxos Body: {"_addresses": ["addr1..."]} -Status transitions applied here:: +Status transitions:: PENDING ──► CONFIRMED (received >= expected * CONFIRM_TOLERANCE) PENDING ──► UNDERPAID (received > 0 but below tolerance) PENDING ──► OVERPAID (received >= expected * OVERPAY_THRESHOLD) PENDING ──► EXPIRED (after reprice_count exhausts — see reprice_expired_invoices) - -Behavioral shape is identical to the original TradeCraft ``services/cardano_monitor.py``: -same polling intervals, same Koios URL, same 2% confirm / overpay tolerances. -The only change is that persistence is now delegated to the store Protocol -instead of being welded to SQLAlchemy + the ``CardanoPayment`` model. """ from __future__ import annotations @@ -41,10 +36,6 @@ from cardano_checkout.store import InvoiceStore # returns the current-market lovelace equivalent as int. Invoked by # :func:`reprice_expired_invoices` to generate fresh quotes when an # invoice's quote window lapses without payment. -# -# SDK intentionally does NOT ship an oracle. Consumers wire whatever -# price source they trust (CoinGecko, Koios ticker, their own DEX feed, -# or a constant for tests). PriceFn = Callable[[float], Awaitable[int]] logger = logging.getLogger(__name__) @@ -52,11 +43,11 @@ logger = logging.getLogger(__name__) KOIOS_URL = "https://api.koios.rest/api/v1/address_utxos" KOIOS_TIMEOUT = 15 # seconds -# Tolerance for confirming payment (2%) — unchanged from v0.1 / TradeCraft. +# Tolerance for confirming payment (2%). CONFIRM_TOLERANCE = 0.98 OVERPAY_THRESHOLD = 1.02 -# Default reprice cap + window (matches TradeCraft defaults). +# Default reprice cap + window. DEFAULT_MAX_REPRICINGS = 3 DEFAULT_PAYMENT_WINDOW_MINUTES = 15 @@ -108,8 +99,7 @@ async def check_address_utxos( return [] -# Backwards-compatible alias — monitor.py in TradeCraft imports the private name. -# Keeping a leading-underscore alias so the TradeCraft shim can still reach it. +# Leading-underscore alias kept for callers that imported the private name. _check_address_utxos = check_address_utxos @@ -133,7 +123,7 @@ async def evaluate_utxos( - ``received_assets`` — ``{policy_id.asset_name_hex: quantity}``. - ``latest_tx_hash`` — most recent observed tx hash, or None if no UTXOs. - Status rules mirror TradeCraft exactly: + Status rules: - No UTXOs → ``PENDING`` (no change) - ``total_value >= expected * OVERPAY_THRESHOLD`` → ``OVERPAID`` (treated as confirmed) @@ -168,11 +158,10 @@ async def evaluate_utxos( if qty > 0: received_assets[asset_id] = received_assets.get(asset_id, 0) + qty - # ADA-only matching. Any native tokens landed in the same UTxOs are - # recorded in received_assets for visibility but do NOT contribute to - # the payment-matched total. Consumers who want to accept stablecoins - # or other native tokens wrap this function with their own asset-to- - # lovelace converter before comparing against expected_lovelace. + # ADA-only matching. Native tokens in the same UTxOs are recorded in + # received_assets for visibility but do NOT contribute to the + # payment-matched total. Wrap this function with your own + # asset-to-lovelace converter to accept native tokens. total_value = raw_lovelace if expected_lovelace == 0: @@ -190,7 +179,7 @@ async def evaluate_utxos( return new_status, raw_lovelace, total_value, received_assets, latest_tx_hash -# Backwards-compatible alias. +# Leading-underscore alias for callers that imported the private name. _evaluate_utxos = evaluate_utxos @@ -313,18 +302,17 @@ async def reprice_expired_invoices( Args: store: Persistence backend. price_fn: Async callable that takes a USD amount and returns the - current lovelace equivalent. Consumer-supplied — the SDK does - not ship an oracle. A simple wiring looks like:: + current lovelace equivalent. Example:: from cardano_checkout.monitor import reprice_expired_invoices async def my_price_fn(usd: float) -> int: - rate = await coingecko_fetch_ada_usd() # your code + rate = await coingecko_fetch_ada_usd() return int(round(usd / rate * 1_000_000)) await reprice_expired_invoices(store, price_fn=my_price_fn) - window_minutes: New expiry window per reprice. TradeCraft default 15. - max_repricings: Give-up threshold. TradeCraft default 3. + window_minutes: New expiry window per reprice. Default 15. + max_repricings: Give-up threshold. Default 3. limit: Max pending invoices to process per call. Returns: diff --git a/cardano_checkout/scheduler.py b/cardano_checkout/scheduler.py index 557d6d9..72b34f5 100644 --- a/cardano_checkout/scheduler.py +++ b/cardano_checkout/scheduler.py @@ -6,13 +6,6 @@ The scheduler drives two jobs against a consumer-supplied - :func:`cardano_checkout.monitor.check_pending_invoices` — every 15 seconds - :func:`cardano_checkout.monitor.reprice_expired_invoices` — every 60 seconds -That's the *full* SDK job surface. The subscription-level + grace-period -jobs that the original TradeCraft scheduler shipped are TradeCraft-specific -(they touch ``Company``, ``Subscription``, ``SubscriptionPayment`` models -that are merchant-specific) — those live in -:mod:`cardano_checkout.tradecraft_compat` so TradeCraft can still import the -exact wrappers it has always used, without polluting the generic SDK. - Usage:: from cardano_checkout.scheduler import InvoiceScheduler @@ -54,7 +47,7 @@ class InvoiceScheduler: store: Persistence backend. Required. koios_url: Chain-query endpoint. Override for testnet / custom gateways. check_interval_seconds: How often to poll Koios for pending invoices. - Defaults to 15 — identical to TradeCraft's production cadence. + Defaults to 15. reprice_interval_seconds: How often to sweep for expired invoices. Defaults to 60. payment_window_minutes: Re-expiry window when repricing. @@ -87,9 +80,8 @@ class InvoiceScheduler: async def _job_reprice_expired(self) -> None: if self.price_fn is None: - # No oracle wired — skip repricing silently. Consumers that - # don't care about the USD-lock workflow (e.g. fixed-ADA - # invoices) will never configure a price_fn; that's fine. + # No oracle wired — skip repricing. Fixed-ADA invoices don't + # need one. return try: await reprice_expired_invoices( @@ -149,12 +141,9 @@ class InvoiceScheduler: # --------------------------------------------------------------------------- -# Backwards-compatible free-function API +# Free-function API around a module-level default instance. +# Prefer the InvoiceScheduler class for anything nontrivial. # --------------------------------------------------------------------------- -# -# Early adopters may have imported ``start_cardano_scheduler`` / ``stop_cardano_scheduler`` -# directly. Provide those as thin wrappers around a module-level default instance. -# Using the InvoiceScheduler class is preferred for anything nontrivial. _default: Optional[InvoiceScheduler] = None diff --git a/cardano_checkout/store.py b/cardano_checkout/store.py index fea94c2..f3750f1 100644 --- a/cardano_checkout/store.py +++ b/cardano_checkout/store.py @@ -1,17 +1,15 @@ """Persistence abstraction for Invoice objects. The SDK does not prescribe a database. Consumers implement -:class:`InvoiceStore` against whatever backend suits them — SQLAlchemy -(TradeCraft pattern), SQLite (chromaticcraft pattern), Postgres raw -(ADAMaps pattern), in-memory dict (tests). +:class:`InvoiceStore` against whatever backend suits them — SQLAlchemy, +asyncpg, SQLite, in-memory dict. -All methods are async so the same Protocol works cleanly for both +All methods are async so the same Protocol works for both asyncpg/asyncio-sqlalchemy backends and synchronous backends wrapped with ``asyncio.to_thread``. -This module also ships :class:`InMemoryStore` — a reference implementation -used by the test suite and useful as a drop-in for local development or -ephemeral workflows that don't need durability. +Also ships :class:`InMemoryStore` — a reference implementation used by +the test suite and useful for local development. """ from __future__ import annotations diff --git a/pyproject.toml b/pyproject.toml index c3ab77a..4495700 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ test = ["pytest>=7", "pytest-asyncio>=0.23"] dev = ["pytest>=7", "pytest-asyncio>=0.23", "ruff", "mypy"] [project.urls] -Repository = "http://192.168.0.5:3001/Sulkta-Coop/cardano-checkout-py" +Repository = "https://git.sulkta.com/Sulkta-Coop/cardano-checkout-py" [tool.setuptools.packages.find] include = ["cardano_checkout*"] diff --git a/tests/test_invoice.py b/tests/test_invoice.py index c341b7e..757d715 100644 --- a/tests/test_invoice.py +++ b/tests/test_invoice.py @@ -10,7 +10,7 @@ from cardano_checkout.invoice import Invoice, InvoiceStatus def _make() -> Invoice: return Invoice( id="inv_001", - merchant_id="chromaticcraft", + merchant_id="acme", derivation_index=0, receive_address="addr1...", expected_lovelace=5_000_000, # 5 ADA diff --git a/tests/test_monitor_with_inmemory_store.py b/tests/test_monitor_with_inmemory_store.py index ae72ed2..c6db6d7 100644 --- a/tests/test_monitor_with_inmemory_store.py +++ b/tests/test_monitor_with_inmemory_store.py @@ -32,7 +32,7 @@ def _make( now = datetime.now(timezone.utc) return Invoice( id=id_, - merchant_id="chromaticcraft", + merchant_id="acme", derivation_index=0, receive_address="addr1testreceive", expected_lovelace=expected_lovelace, diff --git a/tests/test_store_protocol.py b/tests/test_store_protocol.py index 8a94bdd..b89475e 100644 --- a/tests/test_store_protocol.py +++ b/tests/test_store_protocol.py @@ -17,7 +17,7 @@ from cardano_checkout import InMemoryStore, Invoice, InvoiceStatus, InvoiceStore def _make_invoice( id_: str = "inv_001", - merchant: str = "chromaticcraft", + merchant: str = "acme", index: int = 0, status: InvoiceStatus = InvoiceStatus.PENDING, ) -> Invoice: @@ -135,8 +135,8 @@ async def test_list_by_status_honours_limit() -> None: async def test_next_derivation_index_is_monotonic_per_merchant() -> None: store = InMemoryStore() - m1 = "chromaticcraft" - m2 = "tradecraft" + m1 = "acme" + m2 = "globex" assert await store.next_derivation_index(m1) == 0 assert await store.next_derivation_index(m1) == 1 @@ -149,7 +149,7 @@ async def test_create_bumps_index_cursor_if_higher() -> None: store = InMemoryStore() await store.create(_make_invoice(id_="manual", index=7)) - nxt = await store.next_derivation_index("chromaticcraft") + nxt = await store.next_derivation_index("acme") assert nxt == 8