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 +5 -0
- k402-0.1.0/LICENSE +21 -0
- k402-0.1.0/PKG-INFO +101 -0
- k402-0.1.0/PROTOCOL.md +134 -0
- k402-0.1.0/README.md +77 -0
- k402-0.1.0/k402/__init__.py +32 -0
- k402-0.1.0/k402/addresses.py +59 -0
- k402-0.1.0/k402/backend.py +92 -0
- k402-0.1.0/k402/client.py +88 -0
- k402-0.1.0/k402/schemes.py +151 -0
- k402-0.1.0/k402/server.py +128 -0
- k402-0.1.0/k402/store.py +101 -0
- k402-0.1.0/k402/wallet.py +56 -0
- k402-0.1.0/pyproject.toml +31 -0
- k402-0.1.0/tests/test_client.py +100 -0
- k402-0.1.0/tests/test_schemes.py +55 -0
- k402-0.1.0/tests/test_server.py +122 -0
k402-0.1.0/.gitignore
ADDED
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()
|