k402 0.1.0__py3-none-any.whl

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.
k402/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ # k402 — HTTP 402 payments on Kaspa. See PROTOCOL.md for the wire protocol.
2
+ from .addresses import (AddressProvider, CallbackAddressProvider,
3
+ StaticAddressProvider, XpubAddressProvider)
4
+ from .backend import ChainBackend, NodeBackend, PnnBackend
5
+ from .client import Client, Payer, PaymentFailed
6
+ from .schemes import (K402_VERSION, PAYMENT_HEADER, SESSION_HEADER,
7
+ FacilitatorFee, ProtocolError, SessionOffer, UtxoOffer,
8
+ format_payment_header, parse_offers, parse_payment_header,
9
+ payment_required_body)
10
+ from .server import K402, PaymentRequired
11
+ from .store import MemoryStore, PaymentRecord, PaymentStore, SqliteStore
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "K402", "Client", "PaymentRequired", "PaymentFailed",
17
+ "UtxoOffer", "SessionOffer", "FacilitatorFee", "ProtocolError",
18
+ "parse_offers", "payment_required_body",
19
+ "format_payment_header", "parse_payment_header",
20
+ "PAYMENT_HEADER", "SESSION_HEADER", "K402_VERSION",
21
+ "PnnBackend", "NodeBackend", "ChainBackend",
22
+ "AddressProvider", "XpubAddressProvider", "CallbackAddressProvider",
23
+ "StaticAddressProvider",
24
+ "PaymentStore", "MemoryStore", "SqliteStore", "PaymentRecord",
25
+ "Payer",
26
+ ]
27
+
28
+ try: # optional: requires the kaspa SDK extra
29
+ from .wallet import HotWallet # noqa: F401
30
+ __all__.append("HotWallet")
31
+ except ImportError:
32
+ pass
k402/addresses.py ADDED
@@ -0,0 +1,59 @@
1
+ # Address providers hand the server a fresh pay_to address per payment_id.
2
+ # The recommended provider is watch-only xpub derivation: the web server never
3
+ # holds a private key, so a compromised server can lose at most unswept revenue.
4
+ from __future__ import annotations
5
+
6
+ import itertools
7
+ import threading
8
+ from typing import Callable, Protocol
9
+
10
+
11
+ class AddressProvider(Protocol):
12
+ def next_address(self, payment_id: str) -> str: ...
13
+
14
+
15
+ class CallbackAddressProvider:
16
+ """Bring your own derivation: fn(payment_id) -> address."""
17
+
18
+ def __init__(self, fn: Callable[[str], str]):
19
+ self._fn = fn
20
+
21
+ def next_address(self, payment_id: str) -> str:
22
+ return self._fn(payment_id)
23
+
24
+
25
+ class XpubAddressProvider:
26
+ """Watch-only HD derivation from an account xpub (kaspa SDK required).
27
+
28
+ NOTE: the index counter is in-memory. Across restarts pass start_index
29
+ higher than any previously issued index (persist it next to your payment
30
+ store) — reusing an index only risks correlating two payments to one
31
+ address, not losing funds, but fresh-per-payment is the protocol's intent.
32
+ """
33
+
34
+ def __init__(self, xpub: str, network: str = "mainnet", start_index: int = 0):
35
+ try:
36
+ from kaspa import PublicKeyGenerator
37
+ except ImportError as e:
38
+ raise ImportError(
39
+ "XpubAddressProvider needs the kaspa SDK: pip install 'k402[kaspa]'") from e
40
+ self._gen = PublicKeyGenerator.from_xpub(xpub)
41
+ self._network = network
42
+ self._counter = itertools.count(start_index)
43
+ self._lock = threading.Lock()
44
+
45
+ def next_address(self, payment_id: str) -> str:
46
+ with self._lock:
47
+ index = next(self._counter)
48
+ return self._gen.receive_address_as_string(self._network, index)
49
+
50
+
51
+ class StaticAddressProvider:
52
+ """A fixed address for every payment. Dev/demo ONLY: concurrent payments to
53
+ one address can satisfy each other's verification. Never use in production."""
54
+
55
+ def __init__(self, address: str):
56
+ self._address = address
57
+
58
+ def next_address(self, payment_id: str) -> str:
59
+ return self._address
k402/backend.py ADDED
@@ -0,0 +1,92 @@
1
+ # Chain backends answer one question for the verifier: how many sompi has an
2
+ # address received? Fresh-address-per-payment makes that equal to its balance.
3
+ #
4
+ # PnnBackend resolves a public node via the Kaspa Resolver (PNN,
5
+ # https://kaspa.aspectron.org/rpc/pnn.html). PNN is dev/test-grade by its own
6
+ # docs — point production at your own node with NodeBackend(url=...).
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from typing import Optional, Protocol
12
+
13
+
14
+ class ChainBackend(Protocol):
15
+ async def address_received_sompi(self, address: str) -> int: ...
16
+ async def close(self) -> None: ...
17
+
18
+
19
+ def _require_kaspa():
20
+ try:
21
+ import kaspa
22
+ return kaspa
23
+ except ImportError as e:
24
+ raise ImportError(
25
+ "chain backends need the kaspa SDK: pip install 'k402[kaspa]'") from e
26
+
27
+
28
+ class _RpcBackend:
29
+ """Shared wRPC client lifecycle for resolver- and url-based backends."""
30
+
31
+ def __init__(self, network: str = "mainnet", url: Optional[str] = None):
32
+ self.network = network
33
+ self.url = url
34
+ self._client = None
35
+ self._lock = asyncio.Lock()
36
+
37
+ async def _rpc(self):
38
+ kaspa = _require_kaspa()
39
+ async with self._lock:
40
+ if self._client is None or not self._client.is_connected():
41
+ if self.url:
42
+ self._client = kaspa.RpcClient(url=self.url)
43
+ else:
44
+ self._client = kaspa.RpcClient(
45
+ resolver=kaspa.Resolver(), network_id=self.network)
46
+ await self._client.connect()
47
+ return self._client
48
+
49
+ async def address_received_sompi(self, address: str) -> int:
50
+ rpc = await self._rpc()
51
+ resp = await rpc.get_balance_by_address({"address": address})
52
+ return int(resp["balance"])
53
+
54
+ async def utxos(self, address: str) -> list:
55
+ rpc = await self._rpc()
56
+ resp = await rpc.get_utxos_by_addresses({"addresses": [address]})
57
+ return resp["entries"]
58
+
59
+ async def submit_transaction(self, pending) -> str:
60
+ rpc = await self._rpc()
61
+ return await pending.submit(rpc)
62
+
63
+ async def wait_for_payment(self, address: str, amount_sompi: int,
64
+ timeout: float = 120.0, poll: float = 1.0) -> bool:
65
+ """Poll until `address` has received `amount_sompi` (Kaspa confirms ~1s,
66
+ so polling at 1s is adequate; a utxos-changed subscription can replace
67
+ this later without changing callers)."""
68
+ deadline = time.monotonic() + timeout
69
+ while time.monotonic() < deadline:
70
+ if await self.address_received_sompi(address) >= amount_sompi:
71
+ return True
72
+ await asyncio.sleep(poll)
73
+ return False
74
+
75
+ async def close(self) -> None:
76
+ if self._client is not None and self._client.is_connected():
77
+ await self._client.disconnect()
78
+ self._client = None
79
+
80
+
81
+ class PnnBackend(_RpcBackend):
82
+ """Public Node Network via the Kaspa Resolver. Dev/test-grade."""
83
+
84
+ def __init__(self, network: str = "mainnet"):
85
+ super().__init__(network=network)
86
+
87
+
88
+ class NodeBackend(_RpcBackend):
89
+ """Your own node's wRPC endpoint (e.g. ws://127.0.0.1:17110). Production."""
90
+
91
+ def __init__(self, url: str, network: str = "mainnet"):
92
+ super().__init__(network=network, url=url)
k402/client.py ADDED
@@ -0,0 +1,88 @@
1
+ # Client side of k402: an httpx wrapper that turns HTTP 402 into payment.
2
+ #
3
+ # client = Client(payer=HotWallet(private_key)) # kaspa-utxo, non-custodial
4
+ # client = Client(session="s_...") # kaspa-session, prepaid
5
+ # r = await client.post("https://api.example/summarize", json={...})
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional, Protocol
9
+
10
+ import httpx
11
+
12
+ from .schemes import (PAYMENT_HEADER, SESSION_HEADER, ProtocolError,
13
+ SessionOffer, UtxoOffer, format_payment_header,
14
+ parse_offers)
15
+
16
+
17
+ class Payer(Protocol):
18
+ async def pay(self, offer: UtxoOffer) -> str:
19
+ """Pay the offer on-chain; return the txid."""
20
+ ...
21
+
22
+
23
+ class PaymentFailed(Exception):
24
+ """The 402 could not be satisfied. `offers` holds what the server accepts."""
25
+
26
+ def __init__(self, message: str, offers: Optional[list] = None):
27
+ self.offers = offers or []
28
+ super().__init__(message)
29
+
30
+
31
+ class Client:
32
+ def __init__(self, payer: Optional[Payer] = None, session: Optional[str] = None,
33
+ max_kas_per_call: float = 1.0, confirm_retries: int = 5,
34
+ http: Optional[httpx.AsyncClient] = None):
35
+ self.payer = payer
36
+ self.session = session
37
+ self.max_sompi_per_call = int(max_kas_per_call * 100_000_000)
38
+ self.confirm_retries = confirm_retries
39
+ self.http = http or httpx.AsyncClient(timeout=180)
40
+
41
+ async def request(self, method: str, url: str, **kwargs) -> httpx.Response:
42
+ headers = dict(kwargs.pop("headers", {}) or {})
43
+ if self.session:
44
+ headers[SESSION_HEADER] = self.session
45
+
46
+ r = await self.http.request(method, url, headers=headers, **kwargs)
47
+ if r.status_code != 402:
48
+ return r
49
+
50
+ try:
51
+ offers = parse_offers(r.json())
52
+ except (ProtocolError, ValueError) as e:
53
+ raise PaymentFailed(f"server sent 402 but not a k402 body: {e}")
54
+
55
+ utxo = next((o for o in offers if isinstance(o, UtxoOffer)), None)
56
+ if utxo is None or self.payer is None:
57
+ hint = next((f"open a session at {o.open}" for o in offers
58
+ if isinstance(o, SessionOffer)), "no payable offer")
59
+ raise PaymentFailed(
60
+ f"payment required and no payer configured ({hint})", offers)
61
+
62
+ if utxo.total_sompi > self.max_sompi_per_call:
63
+ raise PaymentFailed(
64
+ f"offer wants {utxo.total_sompi} sompi, over the "
65
+ f"max_kas_per_call guard of {self.max_sompi_per_call}", offers)
66
+
67
+ txid = await self.payer.pay(utxo)
68
+ headers[PAYMENT_HEADER] = format_payment_header(txid, utxo.payment_id)
69
+
70
+ # Kaspa accepts in ~1s; retry briefly in case we beat the node to it.
71
+ import asyncio
72
+ for attempt in range(self.confirm_retries):
73
+ r = await self.http.request(method, url, headers=headers, **kwargs)
74
+ if r.status_code != 402:
75
+ return r
76
+ await asyncio.sleep(1.0 + attempt)
77
+ raise PaymentFailed(
78
+ f"paid tx {txid} but server still returns 402 after "
79
+ f"{self.confirm_retries} retries", offers)
80
+
81
+ async def get(self, url: str, **kw) -> httpx.Response:
82
+ return await self.request("GET", url, **kw)
83
+
84
+ async def post(self, url: str, **kw) -> httpx.Response:
85
+ return await self.request("POST", url, **kw)
86
+
87
+ async def aclose(self) -> None:
88
+ await self.http.aclose()
k402/schemes.py ADDED
@@ -0,0 +1,151 @@
1
+ # Wire format for the k402 protocol: 402 offer bodies and payment headers.
2
+ # This module is dependency-free and is the normative reference implementation
3
+ # of PROTOCOL.md — keep the two in lockstep.
4
+ from __future__ import annotations
5
+
6
+ import secrets
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Optional
9
+
10
+ K402_VERSION = "0.1"
11
+ PAYMENT_HEADER = "X-K402-Payment"
12
+ SESSION_HEADER = "X-Session"
13
+
14
+ SCHEME_UTXO = "kaspa-utxo"
15
+ SCHEME_SESSION = "kaspa-session"
16
+
17
+
18
+ class ProtocolError(ValueError):
19
+ """Malformed k402 wire data (offer body or payment header)."""
20
+
21
+
22
+ def new_payment_id() -> str:
23
+ return "p_" + secrets.token_hex(8)
24
+
25
+
26
+ @dataclass
27
+ class FacilitatorFee:
28
+ """Optional, transparent service fee quoted inside an offer (never a rail toll)."""
29
+ sompi: str
30
+ to: str
31
+ by: str = ""
32
+
33
+ def to_dict(self) -> dict:
34
+ d = {"sompi": self.sompi, "to": self.to}
35
+ if self.by:
36
+ d["by"] = self.by
37
+ return d
38
+
39
+ @classmethod
40
+ def from_dict(cls, d: dict) -> "FacilitatorFee":
41
+ return cls(sompi=str(d["sompi"]), to=d["to"], by=d.get("by", ""))
42
+
43
+
44
+ @dataclass
45
+ class UtxoOffer:
46
+ """kaspa-utxo: non-custodial per-call payment to a fresh address."""
47
+ network: str
48
+ amount_sompi: str
49
+ pay_to: str
50
+ payment_id: str
51
+ expires: int
52
+ description: str = ""
53
+ finality: int = 1
54
+ facilitator_fee: Optional[FacilitatorFee] = None
55
+ scheme: str = field(default=SCHEME_UTXO, init=False)
56
+
57
+ def to_dict(self) -> dict:
58
+ d: dict[str, Any] = {
59
+ "scheme": self.scheme,
60
+ "network": self.network,
61
+ "amount_sompi": str(self.amount_sompi),
62
+ "pay_to": self.pay_to,
63
+ "payment_id": self.payment_id,
64
+ "expires": self.expires,
65
+ }
66
+ if self.description:
67
+ d["description"] = self.description
68
+ if self.finality != 1:
69
+ d["finality"] = self.finality
70
+ if self.facilitator_fee:
71
+ d["facilitator_fee"] = self.facilitator_fee.to_dict()
72
+ return d
73
+
74
+ @classmethod
75
+ def from_dict(cls, d: dict) -> "UtxoOffer":
76
+ try:
77
+ amount = str(d["amount_sompi"])
78
+ int(amount) # must be an integer string; float KAS never crosses the wire
79
+ return cls(
80
+ network=d["network"],
81
+ amount_sompi=amount,
82
+ pay_to=d["pay_to"],
83
+ payment_id=d["payment_id"],
84
+ expires=int(d["expires"]),
85
+ description=d.get("description", ""),
86
+ finality=int(d.get("finality", 1)),
87
+ facilitator_fee=FacilitatorFee.from_dict(d["facilitator_fee"])
88
+ if d.get("facilitator_fee") else None,
89
+ )
90
+ except (KeyError, TypeError, ValueError) as e:
91
+ raise ProtocolError(f"invalid kaspa-utxo offer: {e}") from e
92
+
93
+ @property
94
+ def total_sompi(self) -> int:
95
+ """Amount the payer sends in total, including any facilitator fee output."""
96
+ total = int(self.amount_sompi)
97
+ if self.facilitator_fee:
98
+ total += int(self.facilitator_fee.sompi)
99
+ return total
100
+
101
+
102
+ @dataclass
103
+ class SessionOffer:
104
+ """kaspa-session: prepaid metered balance (deposit address minted by `open`)."""
105
+ open: str
106
+ scheme: str = field(default=SCHEME_SESSION, init=False)
107
+
108
+ def to_dict(self) -> dict:
109
+ return {"scheme": self.scheme, "open": self.open}
110
+
111
+ @classmethod
112
+ def from_dict(cls, d: dict) -> "SessionOffer":
113
+ try:
114
+ return cls(open=d["open"])
115
+ except KeyError as e:
116
+ raise ProtocolError(f"invalid kaspa-session offer: {e}") from e
117
+
118
+
119
+ Offer = UtxoOffer | SessionOffer
120
+
121
+ _SCHEME_TYPES = {SCHEME_UTXO: UtxoOffer, SCHEME_SESSION: SessionOffer}
122
+
123
+
124
+ def payment_required_body(offers: list[Offer]) -> dict:
125
+ """The JSON body of a k402 HTTP 402 response."""
126
+ return {"k402": K402_VERSION, "accepts": [o.to_dict() for o in offers]}
127
+
128
+
129
+ def parse_offers(body: dict) -> list[Offer]:
130
+ """Parse a 402 body; unknown schemes are skipped (forward compatibility)."""
131
+ if not isinstance(body, dict) or "k402" not in body:
132
+ raise ProtocolError("not a k402 402 body (missing 'k402' version key)")
133
+ offers: list[Offer] = []
134
+ for entry in body.get("accepts", []):
135
+ typ = _SCHEME_TYPES.get(entry.get("scheme"))
136
+ if typ is not None:
137
+ offers.append(typ.from_dict(entry))
138
+ return offers
139
+
140
+
141
+ def format_payment_header(txid: str, payment_id: str, scheme: str = SCHEME_UTXO) -> str:
142
+ return f"{scheme} {txid} {payment_id}"
143
+
144
+
145
+ def parse_payment_header(value: str) -> tuple[str, str, str]:
146
+ """-> (scheme, txid, payment_id)"""
147
+ parts = value.strip().split()
148
+ if len(parts) != 3:
149
+ raise ProtocolError(
150
+ f"malformed {PAYMENT_HEADER} header (want '<scheme> <txid> <payment_id>')")
151
+ return parts[0], parts[1], parts[2]
k402/server.py ADDED
@@ -0,0 +1,128 @@
1
+ # Server side of k402: issue offers, verify payments, gate endpoints.
2
+ #
3
+ # k402 = K402(address_provider=XpubAddressProvider(xpub),
4
+ # backend=NodeBackend("ws://127.0.0.1:17110")) # PnnBackend() for dev
5
+ # app = FastAPI()
6
+ # k402.install(app)
7
+ #
8
+ # @app.post("/summarize")
9
+ # async def summarize(req: Req, payment=Depends(k402.paid(sompi=1_500_000))):
10
+ # ...
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from typing import Optional
15
+
16
+ from .addresses import AddressProvider
17
+ from .backend import ChainBackend
18
+ from .schemes import (PAYMENT_HEADER, SCHEME_UTXO, FacilitatorFee, Offer,
19
+ ProtocolError, UtxoOffer, new_payment_id,
20
+ parse_payment_header, payment_required_body)
21
+ from .store import MemoryStore, PaymentRecord, PaymentStore
22
+
23
+
24
+ class PaymentRequired(Exception):
25
+ """Raised when a request must (re)pay. Carries the 402 body to send."""
26
+
27
+ def __init__(self, offers: list[Offer], reason: str = ""):
28
+ self.body = payment_required_body(offers)
29
+ if reason:
30
+ self.body["reason"] = reason
31
+ super().__init__(reason or "payment required")
32
+
33
+
34
+ class K402:
35
+ def __init__(self, address_provider: AddressProvider, backend: ChainBackend,
36
+ store: Optional[PaymentStore] = None, network: str = "mainnet",
37
+ quote_ttl: int = 600,
38
+ facilitator_fee: Optional[FacilitatorFee] = None,
39
+ extra_offers: Optional[list[Offer]] = None):
40
+ self.address_provider = address_provider
41
+ self.backend = backend
42
+ self.store = store or MemoryStore()
43
+ self.network = network
44
+ self.quote_ttl = quote_ttl
45
+ self.facilitator_fee = facilitator_fee
46
+ self.extra_offers = extra_offers or [] # e.g. a SessionOffer
47
+
48
+ # -------------------------------------------------------------- protocol core
49
+ def create_offer(self, sompi: int, description: str = "") -> UtxoOffer:
50
+ payment_id = new_payment_id()
51
+ offer = UtxoOffer(
52
+ network=self.network,
53
+ amount_sompi=str(sompi),
54
+ pay_to=self.address_provider.next_address(payment_id),
55
+ payment_id=payment_id,
56
+ expires=int(time.time()) + self.quote_ttl,
57
+ description=description,
58
+ facilitator_fee=self.facilitator_fee,
59
+ )
60
+ self.store.create(PaymentRecord(
61
+ payment_id=payment_id, address=offer.pay_to,
62
+ amount_sompi=sompi, expires=offer.expires))
63
+ return offer
64
+
65
+ def _demand(self, sompi: int, description: str, reason: str = "") -> PaymentRequired:
66
+ return PaymentRequired(
67
+ [self.create_offer(sompi, description), *self.extra_offers], reason)
68
+
69
+ async def verify(self, header_value: str, sompi: int,
70
+ description: str = "") -> PaymentRecord:
71
+ """Verify an X-K402-Payment header; returns the consumed PaymentRecord.
72
+ Raises PaymentRequired (with a fresh offer) on any failure."""
73
+ try:
74
+ scheme, txid, payment_id = parse_payment_header(header_value)
75
+ except ProtocolError as e:
76
+ raise self._demand(sompi, description, str(e))
77
+ if scheme != SCHEME_UTXO:
78
+ raise self._demand(sompi, description, f"unsupported scheme '{scheme}'")
79
+
80
+ rec = self.store.get(payment_id)
81
+ if rec is None:
82
+ raise self._demand(sompi, description, "unknown payment_id")
83
+ if rec.used:
84
+ raise self._demand(sompi, description, "payment_id already used")
85
+ if rec.expired:
86
+ raise self._demand(sompi, description, "quote expired")
87
+
88
+ received = await self.backend.address_received_sompi(rec.address)
89
+ if received < rec.amount_sompi:
90
+ raise self._demand(
91
+ sompi, description,
92
+ f"address has received {received} of {rec.amount_sompi} sompi "
93
+ f"(tx {txid} not accepted yet? retry in ~1s)")
94
+
95
+ if not self.store.mark_used(payment_id): # lost the race to a parallel request
96
+ raise self._demand(sompi, description, "payment_id already used")
97
+ rec.used = True
98
+ rec.meta["txid"] = txid
99
+ return rec
100
+
101
+ # -------------------------------------------------------------- FastAPI glue
102
+ def paid(self, sompi: int, description: str = ""):
103
+ """FastAPI dependency: `payment = Depends(k402.paid(sompi=...))`."""
104
+ try:
105
+ from fastapi import Request
106
+ except ImportError as e:
107
+ raise ImportError("k402.paid() needs fastapi: pip install 'k402[server]'") from e
108
+
109
+ async def dependency(request) -> PaymentRecord:
110
+ header = request.headers.get(PAYMENT_HEADER)
111
+ if not header:
112
+ raise self._demand(sompi, description)
113
+ return await self.verify(header, sompi, description)
114
+
115
+ # `from __future__ import annotations` stringifies closure annotations,
116
+ # and FastAPI can't resolve "Request" from a function-local import —
117
+ # attach the real class so dependency injection sees it.
118
+ dependency.__annotations__["request"] = Request
119
+ return dependency
120
+
121
+ def install(self, app) -> None:
122
+ """Register the 402 exception handler so PaymentRequired renders as a
123
+ protocol-compliant HTTP 402 body (not FastAPI's {'detail': ...})."""
124
+ from fastapi.responses import JSONResponse
125
+
126
+ @app.exception_handler(PaymentRequired)
127
+ async def _handler(request, exc: PaymentRequired):
128
+ return JSONResponse(status_code=402, content=exc.body)
k402/store.py ADDED
@@ -0,0 +1,101 @@
1
+ # Payment stores persist issued offers and enforce single-use payment_ids.
2
+ # mark_used() must be atomic — it is the replay-protection primitive.
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional, Protocol
11
+
12
+
13
+ @dataclass
14
+ class PaymentRecord:
15
+ payment_id: str
16
+ address: str
17
+ amount_sompi: int
18
+ expires: int
19
+ used: bool = False
20
+ meta: dict = field(default_factory=dict)
21
+
22
+ @property
23
+ def expired(self) -> bool:
24
+ return time.time() > self.expires
25
+
26
+
27
+ class PaymentStore(Protocol):
28
+ def create(self, record: PaymentRecord) -> None: ...
29
+ def get(self, payment_id: str) -> Optional[PaymentRecord]: ...
30
+ def mark_used(self, payment_id: str) -> bool:
31
+ """True iff this call transitioned the record from unused to used."""
32
+ ...
33
+
34
+
35
+ class MemoryStore:
36
+ """Process-local store. Fine for a single-worker dev server."""
37
+
38
+ def __init__(self):
39
+ self._records: dict[str, PaymentRecord] = {}
40
+ self._lock = threading.Lock()
41
+
42
+ def create(self, record: PaymentRecord) -> None:
43
+ with self._lock:
44
+ self._records[record.payment_id] = record
45
+
46
+ def get(self, payment_id: str) -> Optional[PaymentRecord]:
47
+ with self._lock:
48
+ return self._records.get(payment_id)
49
+
50
+ def mark_used(self, payment_id: str) -> bool:
51
+ with self._lock:
52
+ rec = self._records.get(payment_id)
53
+ if rec is None or rec.used:
54
+ return False
55
+ rec.used = True
56
+ return True
57
+
58
+
59
+ class SqliteStore:
60
+ """Durable store; safe across restarts and multiple workers on one host."""
61
+
62
+ def __init__(self, path: str):
63
+ self._path = path
64
+ self._local = threading.local()
65
+ with self._conn() as c:
66
+ c.execute("""CREATE TABLE IF NOT EXISTS k402_payments (
67
+ payment_id TEXT PRIMARY KEY,
68
+ address TEXT NOT NULL,
69
+ amount_sompi INTEGER NOT NULL,
70
+ expires INTEGER NOT NULL,
71
+ used INTEGER NOT NULL DEFAULT 0,
72
+ meta TEXT NOT NULL DEFAULT '{}')""")
73
+
74
+ def _conn(self) -> sqlite3.Connection:
75
+ conn = getattr(self._local, "conn", None)
76
+ if conn is None:
77
+ conn = sqlite3.connect(self._path, isolation_level=None)
78
+ conn.execute("PRAGMA journal_mode=WAL")
79
+ self._local.conn = conn
80
+ return conn
81
+
82
+ def create(self, record: PaymentRecord) -> None:
83
+ self._conn().execute(
84
+ "INSERT INTO k402_payments VALUES (?,?,?,?,?,?)",
85
+ (record.payment_id, record.address, record.amount_sompi,
86
+ record.expires, int(record.used), json.dumps(record.meta)))
87
+
88
+ def get(self, payment_id: str) -> Optional[PaymentRecord]:
89
+ row = self._conn().execute(
90
+ "SELECT payment_id, address, amount_sompi, expires, used, meta "
91
+ "FROM k402_payments WHERE payment_id=?", (payment_id,)).fetchone()
92
+ if row is None:
93
+ return None
94
+ return PaymentRecord(payment_id=row[0], address=row[1], amount_sompi=row[2],
95
+ expires=row[3], used=bool(row[4]), meta=json.loads(row[5]))
96
+
97
+ def mark_used(self, payment_id: str) -> bool:
98
+ cur = self._conn().execute(
99
+ "UPDATE k402_payments SET used=1 WHERE payment_id=? AND used=0",
100
+ (payment_id,))
101
+ return cur.rowcount == 1
k402/wallet.py ADDED
@@ -0,0 +1,56 @@
1
+ # HotWallet: a minimal single-address payer for the kaspa-utxo scheme.
2
+ # Meant for agent/service wallets holding small working balances — not vaults.
3
+ # Keys stay in-process and are never sent anywhere.
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+
8
+ from .backend import _RpcBackend, NodeBackend, PnnBackend
9
+ from .schemes import UtxoOffer
10
+
11
+
12
+ class HotWallet:
13
+ def __init__(self, private_key_hex: str, network: str = "mainnet",
14
+ backend: Optional[_RpcBackend] = None, priority_fee_sompi: int = 0):
15
+ try:
16
+ from kaspa import PrivateKey
17
+ except ImportError as e:
18
+ raise ImportError("HotWallet needs the kaspa SDK: pip install 'k402[kaspa]'") from e
19
+ self._key = PrivateKey(private_key_hex)
20
+ self.network = network
21
+ self.address = self._key.to_keypair().to_address(network).to_string()
22
+ self.backend = backend or PnnBackend(network=network)
23
+ self.priority_fee_sompi = priority_fee_sompi
24
+
25
+ async def balance_sompi(self) -> int:
26
+ return await self.backend.address_received_sompi(self.address)
27
+
28
+ async def pay(self, offer: UtxoOffer) -> str:
29
+ """Payer protocol: pay offer.pay_to (and any facilitator fee), return txid."""
30
+ from kaspa import PaymentOutput, Address, create_transactions
31
+
32
+ if offer.network != self.network:
33
+ raise ValueError(
34
+ f"offer is for {offer.network} but this wallet is {self.network}")
35
+
36
+ outputs = [PaymentOutput(Address(offer.pay_to), int(offer.amount_sompi))]
37
+ if offer.facilitator_fee:
38
+ outputs.append(PaymentOutput(
39
+ Address(offer.facilitator_fee.to), int(offer.facilitator_fee.sompi)))
40
+
41
+ entries = await self.backend.utxos(self.address)
42
+ if not entries:
43
+ raise ValueError(f"wallet {self.address} has no UTXOs to spend")
44
+
45
+ bundle = create_transactions(
46
+ network_id=self.network,
47
+ entries=entries,
48
+ outputs=outputs,
49
+ change_address=Address(self.address),
50
+ priority_fee=self.priority_fee_sompi,
51
+ )
52
+ txid = ""
53
+ for pending in bundle["transactions"]:
54
+ pending.sign([self._key])
55
+ txid = await self.backend.submit_transaction(pending)
56
+ return txid
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: k402
3
+ Version: 0.1.0
4
+ Summary: k402 — HTTP 402 payments on Kaspa. Protocol library: client, server middleware, and PNN-backed verification. No accounts, no API keys.
5
+ Project-URL: Homepage, https://github.com/Kali123411/k402
6
+ Project-URL: Repository, https://github.com/Kali123411/k402
7
+ Author: Kaspa Lab
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent-payments,http-402,k402,kaspa,micropayments,payments,x402
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: httpx>=0.27
16
+ Provides-Extra: all
17
+ Requires-Dist: fastapi>=0.110; extra == 'all'
18
+ Requires-Dist: kaspa>=2.0; extra == 'all'
19
+ Provides-Extra: kaspa
20
+ Requires-Dist: kaspa>=2.0; extra == 'kaspa'
21
+ Provides-Extra: server
22
+ Requires-Dist: fastapi>=0.110; extra == 'server'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # k402
26
+
27
+ **HTTP 402 payments on Kaspa.** Charge (or pay) KAS per API call — no
28
+ accounts, no API keys, no card rails. Kaspa confirms in ~1 second, so a
29
+ non-custodial payment adds about a second to the first request and nothing
30
+ after that.
31
+
32
+ The wire protocol is [PROTOCOL.md](PROTOCOL.md) — one 402 body, one header,
33
+ implementable in any language. This package is the Python reference
34
+ implementation: client, FastAPI server middleware, and chain verification.
35
+
36
+ ```
37
+ pip install 'k402[all]' # client + server + kaspa SDK
38
+ pip install k402 # protocol types + client only (httpx)
39
+ ```
40
+
41
+ ## Sell: gate a FastAPI endpoint
42
+
43
+ ```python
44
+ from fastapi import FastAPI, Depends
45
+ from k402 import K402, XpubAddressProvider, PnnBackend, SqliteStore
46
+
47
+ k402 = K402(
48
+ address_provider=XpubAddressProvider("kpub..."), # watch-only: server holds no keys
49
+ backend=PnnBackend(), # dev/test: community Public Node Network
50
+ # prod: NodeBackend("ws://your-node:17110")
51
+ store=SqliteStore("payments.db"),
52
+ )
53
+
54
+ app = FastAPI()
55
+ k402.install(app)
56
+
57
+ @app.post("/summarize")
58
+ async def summarize(body: dict, payment=Depends(k402.paid(sompi=1_500_000))):
59
+ return {"summary": ..., "paid_by_tx": payment.meta["txid"]}
60
+ ```
61
+
62
+ Unpaid calls get a protocol 402 with a fresh payment address; paid calls run.
63
+ Replay, expiry, and double-spend-of-the-quote are handled for you.
64
+
65
+ ## Buy: a client that pays as it goes
66
+
67
+ ```python
68
+ from k402 import Client, HotWallet
69
+
70
+ client = Client(payer=HotWallet(private_key_hex), max_kas_per_call=0.1)
71
+ r = await client.post("https://api.example.com/summarize", json={"text": ...})
72
+ ```
73
+
74
+ The client hits the endpoint, gets the 402, pays the exact quoted sompi from
75
+ its wallet, retries with proof, and returns the real response. The
76
+ `max_kas_per_call` guard caps what it will ever pay (facilitator fees
77
+ included) without asking you.
78
+
79
+ No wallet? Services may also offer `kaspa-session` (prepaid balance):
80
+ `Client(session="s_...")`.
81
+
82
+ ## Chain backends
83
+
84
+ | Backend | Use | Notes |
85
+ |---|---|---|
86
+ | `PnnBackend()` | development, testing | resolves a community [PNN](https://kaspa.aspectron.org/rpc/pnn.html) node via the Kaspa Resolver; dev/test-grade by PNN's own guidance |
87
+ | `NodeBackend("ws://host:17110")` | production | your own node (`kaspad --utxoindex`), wRPC Borsh endpoint |
88
+
89
+ ## Design in one paragraph
90
+
91
+ Every payment gets a **fresh watch-only address**, so verification is just
92
+ "has this address received N sompi" — answerable by any UTXO-indexed node, no
93
+ tx parsing, no payloads, no custody anywhere. Payment ids are single-use and
94
+ marked atomically (replay protection). The protocol takes **no fee**; services
95
+ built on it (facilitators, hosted checkout) quote theirs as a transparent
96
+ `facilitator_fee` line item. Amounts are integer sompi strings end-to-end.
97
+
98
+ ## Status
99
+
100
+ v0.1.0 — wire protocol stable enough to build against; API may move.
101
+ MIT license.
@@ -0,0 +1,12 @@
1
+ k402/__init__.py,sha256=eNcWGANcL6KLmgmwAWeLJCPnmahDVA2UYFqJPxXir5U,1413
2
+ k402/addresses.py,sha256=2un7ATthDMtF8JhS4nvgldaSjs9Jd_YgYtvqitxeIBk,2131
3
+ k402/backend.py,sha256=N7lFbR9CK9q_9MfJwXYoTIQ67zEg5nodSPJXJRur56A,3341
4
+ k402/client.py,sha256=DU3ZN0BhqWnKWGk9egUmUiLYQVkqohP0x0Q13L4ugio,3500
5
+ k402/schemes.py,sha256=Sb3iBmf5RuIffmjpAvCdhNTGyGlX3Hi-U0aTNEisYMA,4867
6
+ k402/server.py,sha256=Sbj2KRUAO9ORWFPRtbCbDyjlz1qOd_VNDS8WGSLziw4,5568
7
+ k402/store.py,sha256=5sCtxJMkf90y44Flzltr0EXwj-45ckAvEkvBBtPbn8I,3465
8
+ k402/wallet.py,sha256=a2HJZ3bI63_78eydOQbtYAjTW3tIyBVRj3yBbPiaH2M,2305
9
+ k402-0.1.0.dist-info/METADATA,sha256=DbIKeFSwBELBXx5wqFCNpLbZSbbBXYI5bN2hQKEow7g,3845
10
+ k402-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ k402-0.1.0.dist-info/licenses/LICENSE,sha256=nYGFAzdYK_UkNEE3zlFW5avyV0M25U4BMFjzURV4FlA,1066
12
+ k402-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaspa Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.