Sulkta Coop's Python SDK for merchant-side Cardano payments + NFT certificate-of-authenticity minting. Zero-custody by design. Extracted from TradeCraft's services/cardano_*.py (2,400+ lines of production Cardano-mainnet code) and restructured as an installable Python package. Package layout (cardano_checkout/): - addresses.py — lifted verbatim: CIP-1852 HD derivation, pure pycardano - oracles.py — lifted from cardano_price.py: Koios ADA/USD feed w/ 5m cache - monitor.py — lifted verbatim (SQLAlchemy-coupled; v0.2 refactors to Store) - scheduler.py — lifted verbatim (same refactor note) - invoice.py — NEW: framework-agnostic Invoice dataclass + lifecycle enum - store.py — NEW: InvoiceStore Protocol for pluggable persistence - mint.py — NEW: CIP-25 v2 metadata builder (works); tx submission stub for v0.2 - ipfs.py — NEW: kubo HTTP client with primary-pin + mirror-pin pattern - txbuild.py — NEW: v0.2 stub for PyCardano / Ogmios tx construction Design: - Consumers provide xpub + InvoiceStore impl. SDK provides everything else. - IPFS: local kubo for upload + serve, optional mirror pins for archival. Chromaticcraft pattern: Rackham kubo primary, Lucy kubo mirror. - NFT: single native-script policy per merchant studio (CIP-25 v2, not CIP-68 — full wallet coverage, no mutability needed for static certs). Policy skey stays under Sulkta cold-custody (Lucy pattern); signing is an external hand-off like ADAMaps payouts. Tests: pure-module smoke tests pass for invoice, store-protocol, CIP-25 metadata envelope, IPFS client import, txbuild stub module. Address derivation tests ship but require pycardano + will exercise in CI. LICENSE: Apache-2.0 (matches upstream Cardano tooling). Next (v0.2 scope): - Refactor monitor + scheduler around InvoiceStore (drop SQLAlchemy coupling) - Wire mint.mint_nft_cert to PyCardano + local Ogmios on Rackham - txbuild: Ogmios chain-context + cold-signer hand-off shape - chromaticcraft Phase 2 imports the SDK as its first external consumer
107 lines
3.9 KiB
Python
107 lines
3.9 KiB
Python
"""Minimal IPFS client — upload + pin via kubo's HTTP API.
|
|
|
|
Designed for the ``chromaticcraft`` shape: a small local kubo daemon
|
|
runs alongside the web app, accepts uploads from end users (e.g. Abby
|
|
uploading a photo of a finished custom order), pins locally for fast
|
|
serving, and optionally mirrors pins to a second remote node
|
|
(Lucy-on-LAN) for archival redundancy.
|
|
|
|
No IPFS libraries are imported — just httpx against the kubo REST API
|
|
(v0). Keeps the SDK surface minimal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class IPFSClient:
|
|
"""Kubo-compatible IPFS client.
|
|
|
|
Attributes:
|
|
api_url: Base URL of the kubo HTTP API (default ``http://127.0.0.1:5001``).
|
|
timeout: Per-request timeout in seconds (default 60 — uploads can be slow).
|
|
mirror_api_urls: Optional list of additional kubo endpoints to
|
|
``pin add`` the CID on after a successful primary pin. Use this
|
|
to mirror to Lucy or any other archival node.
|
|
"""
|
|
|
|
api_url: str = "http://127.0.0.1:5001"
|
|
timeout: float = 60.0
|
|
mirror_api_urls: list[str] = None # type: ignore[assignment]
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.mirror_api_urls is None:
|
|
self.mirror_api_urls = []
|
|
|
|
async def add(self, data: bytes, filename: str = "upload") -> str:
|
|
"""Upload bytes and pin them locally.
|
|
|
|
Args:
|
|
data: Raw bytes to add.
|
|
filename: Logical name used by clients browsing the DAG
|
|
(doesn't affect the CID).
|
|
|
|
Returns:
|
|
CID (base58, v0 or base32 v1 depending on kubo defaults).
|
|
|
|
Raises:
|
|
RuntimeError: If the daemon is unreachable or returns a non-2xx.
|
|
"""
|
|
url = f"{self.api_url.rstrip('/')}/api/v0/add"
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
resp = await client.post(
|
|
url,
|
|
files={"file": (filename, data, "application/octet-stream")},
|
|
params={"pin": "true", "cid-version": "1"},
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise RuntimeError(f"ipfs add {resp.status_code}: {resp.text[:200]}")
|
|
# kubo's /add streams NDJSON; each line is one {Name, Hash, Size}.
|
|
# For a single file upload the last line carries the wrapping CID.
|
|
last_cid: Optional[str] = None
|
|
for line in resp.text.strip().splitlines():
|
|
if '"Hash"' in line:
|
|
import json
|
|
obj = json.loads(line)
|
|
last_cid = obj.get("Hash")
|
|
if not last_cid:
|
|
raise RuntimeError(f"ipfs add: no CID in response: {resp.text[:200]}")
|
|
|
|
# Mirror pins (best effort — a mirror failure should not poison the primary upload).
|
|
for mirror in self.mirror_api_urls:
|
|
try:
|
|
await self._pin_on(mirror, last_cid)
|
|
except Exception as exc:
|
|
logger.warning("[ipfs] mirror pin to %s failed for %s: %s", mirror, last_cid, exc)
|
|
|
|
return last_cid
|
|
|
|
async def _pin_on(self, api_url: str, cid: str) -> None:
|
|
"""Pin an existing CID on a remote kubo node."""
|
|
url = f"{api_url.rstrip('/')}/api/v0/pin/add"
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
resp = await client.post(url, params={"arg": cid})
|
|
if resp.status_code >= 400:
|
|
raise RuntimeError(f"pin/add {resp.status_code}: {resp.text[:200]}")
|
|
|
|
|
|
async def pin_bytes(
|
|
data: bytes,
|
|
api_url: str = "http://127.0.0.1:5001",
|
|
mirror_api_urls: Optional[list[str]] = None,
|
|
filename: str = "upload",
|
|
) -> str:
|
|
"""Convenience wrapper: one-shot upload + pin (+ optional mirror).
|
|
|
|
Returns the CID.
|
|
"""
|
|
client = IPFSClient(api_url=api_url, mirror_api_urls=mirror_api_urls or [])
|
|
return await client.add(data, filename=filename)
|