k402 0.1.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.
k402-0.1.0/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ *.db
k402-0.1.0/LICENSE ADDED
@@ -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.
k402-0.1.0/PKG-INFO ADDED
@@ -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.
k402-0.1.0/PROTOCOL.md ADDED
@@ -0,0 +1,134 @@
1
+ # k402 protocol — v0.1
2
+
3
+ **HTTP 402 payments on Kaspa.** An open convention any HTTP service can
4
+ implement to charge KAS per call — no accounts, no API keys, no card rails.
5
+ What [x402](https://www.x402.org/) is for EVM stablecoin payments, k402 is for
6
+ Kaspa, whose ~1-second confirmations at 10 blocks/sec make direct, per-call,
7
+ non-custodial payment practical without channels or invoices.
8
+
9
+ This document is the protocol. It is intentionally small: one response body,
10
+ one request header, and per-scheme verification rules. Anything not specified
11
+ here (pricing, discovery, catalogs, dashboards) is a service concern, not a
12
+ protocol concern.
13
+
14
+ ## 1. Flow
15
+
16
+ 1. Client calls a paid endpoint with no payment attached.
17
+ 2. Server responds `402 Payment Required` with an **offer body** (§2).
18
+ 3. Client satisfies one offered scheme (§4) and retries the request with a
19
+ **payment header** (§3) — or, for `kaspa-session`, an `X-Session` header.
20
+ 4. Server verifies (§5) and serves the request.
21
+
22
+ ## 2. The 402 offer body
23
+
24
+ ```json
25
+ {
26
+ "k402": "0.1",
27
+ "accepts": [
28
+ {
29
+ "scheme": "kaspa-utxo",
30
+ "network": "mainnet",
31
+ "amount_sompi": "1500000",
32
+ "pay_to": "kaspa:qr...",
33
+ "payment_id": "p_8f3ab2c4...",
34
+ "expires": 1784074500,
35
+ "description": "summarize, ~150 words",
36
+ "finality": 1,
37
+ "facilitator_fee": { "sompi": "2000", "to": "kaspa:qq...", "by": "example.dev" }
38
+ },
39
+ { "scheme": "kaspa-session", "open": "/onboard/request" }
40
+ ],
41
+ "reason": "optional human-readable string when re-402ing a failed payment"
42
+ }
43
+ ```
44
+
45
+ Rules:
46
+
47
+ - `k402` (required): protocol version. This document defines `"0.1"`.
48
+ - `accepts` (required): one entry per acceptable scheme. Clients MUST ignore
49
+ entries whose `scheme` they do not recognize.
50
+ - All amounts are **sompi, as strings of integers**. Float KAS never crosses
51
+ the wire. (1 KAS = 100,000,000 sompi.)
52
+ - `pay_to` MUST be a **fresh address per payment_id** (see §5 for why).
53
+ - `expires` (unix seconds): after this the server MAY refuse the quote and
54
+ MUST respond with a fresh 402 offer.
55
+ - `finality` (optional, default 1): DAA-score depth the server requires before
56
+ serving. 1 means "accepted" (~1 s on mainnet).
57
+ - `facilitator_fee` (optional): a transparent service fee the payer adds as a
58
+ second output. See §6.
59
+ - `description`, `reason` (optional): human/agent-readable strings.
60
+
61
+ ## 3. The payment header
62
+
63
+ ```
64
+ X-K402-Payment: kaspa-utxo <txid> <payment_id>
65
+ ```
66
+
67
+ Three space-separated tokens: scheme, the id of the paying transaction, and
68
+ the `payment_id` from the offer being satisfied.
69
+
70
+ ## 4. Schemes
71
+
72
+ ### `kaspa-utxo` — non-custodial per-call payment
73
+
74
+ The client sends `amount_sompi` to `pay_to` on `network`, plus
75
+ `facilitator_fee.sompi` to `facilitator_fee.to` if present, then retries with
76
+ the payment header. Overpayment is the server's to keep; underpayment fails
77
+ verification.
78
+
79
+ ### `kaspa-session` — prepaid metered balance
80
+
81
+ The offer's `open` field is a URL (absolute or relative to the service) that
82
+ mints `{"session": "...", "depositAddress": "kaspa:..."}`. The client funds
83
+ the deposit address; confirmed deposits become spendable balance; subsequent
84
+ requests carry `X-Session: <session>` and the server meters against the
85
+ balance. Zero added latency per call; the merchant holds the float. Session
86
+ lifecycle beyond `open` is service-defined.
87
+
88
+ ### `kaspa-channel` — reserved
89
+
90
+ Covenant-based unidirectional payment channels (per-call granularity with
91
+ zero per-call chain latency). Reserved for a future protocol version.
92
+
93
+ ## 5. Verification (`kaspa-utxo`)
94
+
95
+ On receiving the payment header the server:
96
+
97
+ 1. Looks up `payment_id`. Unknown, already-used, or expired → fresh 402.
98
+ 2. Checks the chain: total sompi received by `pay_to` ≥ `amount_sompi`, at the
99
+ offer's `finality` depth. Because `pay_to` is fresh per payment, "balance of
100
+ the address" is the whole check — any node with a UTXO index can answer it,
101
+ and no transaction parsing or payload inspection is required.
102
+ 3. Atomically marks `payment_id` used (replay protection), then serves.
103
+
104
+ A payment that has not yet landed is a normal race at 1-second block times:
105
+ servers SHOULD answer with a 402 whose `reason` says so, and clients SHOULD
106
+ retry for a few seconds before treating payment as failed.
107
+
108
+ ## 6. Fees
109
+
110
+ **The protocol itself extracts no fee.** There is no protocol-level fee
111
+ output, no routed settlement, no percentage. Payments go client → merchant.
112
+
113
+ Services layered on the rail (facilitators, hosted checkouts, channel
114
+ operators) MAY charge for their work by quoting `facilitator_fee` in offers
115
+ they produce. The fee is a visible line item the client pays as an explicit
116
+ extra output — never hidden in `amount_sompi`. Clients MUST count it toward
117
+ any per-call spending guard they enforce.
118
+
119
+ ## 7. Security notes
120
+
121
+ - **Merchants:** derive `pay_to` watch-only (xpub) — the web server should
122
+ never hold spending keys. Persist payment_ids durably; `mark used` must be
123
+ atomic under concurrency.
124
+ - **Clients:** enforce a per-call spend ceiling before paying any offer;
125
+ treat `expires` as hard; never pay the same offer twice.
126
+ - **Both:** amounts are integers end-to-end. A server MUST NOT serve on a
127
+ partial payment; a client SHOULD overpay dust rather than round down.
128
+
129
+ ## 8. Reference implementation
130
+
131
+ `pip install k402` — Python client (`k402.Client`), FastAPI server middleware
132
+ (`k402.K402`), watch-only xpub derivation, and chain verification via your own
133
+ node or the community [Public Node Network](https://kaspa.aspectron.org/rpc/pnn.html)
134
+ (dev/test). MIT-licensed; this spec may be implemented by anyone in anything.
k402-0.1.0/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # k402
2
+
3
+ **HTTP 402 payments on Kaspa.** Charge (or pay) KAS per API call — no
4
+ accounts, no API keys, no card rails. Kaspa confirms in ~1 second, so a
5
+ non-custodial payment adds about a second to the first request and nothing
6
+ after that.
7
+
8
+ The wire protocol is [PROTOCOL.md](PROTOCOL.md) — one 402 body, one header,
9
+ implementable in any language. This package is the Python reference
10
+ implementation: client, FastAPI server middleware, and chain verification.
11
+
12
+ ```
13
+ pip install 'k402[all]' # client + server + kaspa SDK
14
+ pip install k402 # protocol types + client only (httpx)
15
+ ```
16
+
17
+ ## Sell: gate a FastAPI endpoint
18
+
19
+ ```python
20
+ from fastapi import FastAPI, Depends
21
+ from k402 import K402, XpubAddressProvider, PnnBackend, SqliteStore
22
+
23
+ k402 = K402(
24
+ address_provider=XpubAddressProvider("kpub..."), # watch-only: server holds no keys
25
+ backend=PnnBackend(), # dev/test: community Public Node Network
26
+ # prod: NodeBackend("ws://your-node:17110")
27
+ store=SqliteStore("payments.db"),
28
+ )
29
+
30
+ app = FastAPI()
31
+ k402.install(app)
32
+
33
+ @app.post("/summarize")
34
+ async def summarize(body: dict, payment=Depends(k402.paid(sompi=1_500_000))):
35
+ return {"summary": ..., "paid_by_tx": payment.meta["txid"]}
36
+ ```
37
+
38
+ Unpaid calls get a protocol 402 with a fresh payment address; paid calls run.
39
+ Replay, expiry, and double-spend-of-the-quote are handled for you.
40
+
41
+ ## Buy: a client that pays as it goes
42
+
43
+ ```python
44
+ from k402 import Client, HotWallet
45
+
46
+ client = Client(payer=HotWallet(private_key_hex), max_kas_per_call=0.1)
47
+ r = await client.post("https://api.example.com/summarize", json={"text": ...})
48
+ ```
49
+
50
+ The client hits the endpoint, gets the 402, pays the exact quoted sompi from
51
+ its wallet, retries with proof, and returns the real response. The
52
+ `max_kas_per_call` guard caps what it will ever pay (facilitator fees
53
+ included) without asking you.
54
+
55
+ No wallet? Services may also offer `kaspa-session` (prepaid balance):
56
+ `Client(session="s_...")`.
57
+
58
+ ## Chain backends
59
+
60
+ | Backend | Use | Notes |
61
+ |---|---|---|
62
+ | `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 |
63
+ | `NodeBackend("ws://host:17110")` | production | your own node (`kaspad --utxoindex`), wRPC Borsh endpoint |
64
+
65
+ ## Design in one paragraph
66
+
67
+ Every payment gets a **fresh watch-only address**, so verification is just
68
+ "has this address received N sompi" — answerable by any UTXO-indexed node, no
69
+ tx parsing, no payloads, no custody anywhere. Payment ids are single-use and
70
+ marked atomically (replay protection). The protocol takes **no fee**; services
71
+ built on it (facilitators, hosted checkout) quote theirs as a transparent
72
+ `facilitator_fee` line item. Amounts are integer sompi strings end-to-end.
73
+
74
+ ## Status
75
+
76
+ v0.1.0 — wire protocol stable enough to build against; API may move.
77
+ MIT license.
@@ -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
@@ -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
@@ -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)
@@ -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()