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 +32 -0
- k402/addresses.py +59 -0
- k402/backend.py +92 -0
- k402/client.py +88 -0
- k402/schemes.py +151 -0
- k402/server.py +128 -0
- k402/store.py +101 -0
- k402/wallet.py +56 -0
- k402-0.1.0.dist-info/METADATA +101 -0
- k402-0.1.0.dist-info/RECORD +12 -0
- k402-0.1.0.dist-info/WHEEL +4 -0
- k402-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|