nyen-sdk 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.
- nyen_sdk-0.1.0/LICENSE +21 -0
- nyen_sdk-0.1.0/PKG-INFO +103 -0
- nyen_sdk-0.1.0/README.md +83 -0
- nyen_sdk-0.1.0/nyen_sdk/__init__.py +47 -0
- nyen_sdk-0.1.0/nyen_sdk/amounts.py +69 -0
- nyen_sdk-0.1.0/nyen_sdk/client.py +246 -0
- nyen_sdk-0.1.0/nyen_sdk/errors.py +65 -0
- nyen_sdk-0.1.0/nyen_sdk/events.py +86 -0
- nyen_sdk-0.1.0/nyen_sdk/payments.py +291 -0
- nyen_sdk-0.1.0/nyen_sdk.egg-info/PKG-INFO +103 -0
- nyen_sdk-0.1.0/nyen_sdk.egg-info/SOURCES.txt +13 -0
- nyen_sdk-0.1.0/nyen_sdk.egg-info/dependency_links.txt +1 -0
- nyen_sdk-0.1.0/nyen_sdk.egg-info/top_level.txt +1 -0
- nyen_sdk-0.1.0/pyproject.toml +30 -0
- nyen_sdk-0.1.0/setup.cfg +4 -0
nyen_sdk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NYEN
|
|
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.
|
nyen_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nyen-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the NYEN /v1 public API — reads, satoshi-safe amounts, NTS token ops, SSE events, HMAC webhooks, and the non-custodial invoice/webhook payment flow.
|
|
5
|
+
Author: NYEN
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://docs.nyen.cc/api
|
|
8
|
+
Project-URL: Documentation, https://docs.nyen.cc/api
|
|
9
|
+
Keywords: nyen,nts,blockchain,utxo,wallet,payments
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# nyen-sdk (Python)
|
|
22
|
+
|
|
23
|
+
Official Python SDK for the **NYEN `/v1` public API** — the same versioned edge
|
|
24
|
+
the TypeScript [`@nyen/sdk`](../nyen-sdk) wraps. Reads, satoshi-safe amounts, NTS
|
|
25
|
+
token ops, SSE events, HMAC webhooks, Login-with-NyenID, and a non-custodial
|
|
26
|
+
**invoice/webhook payment flow**. **Zero third-party dependencies** (pure
|
|
27
|
+
stdlib). Signing stays in the user's wallet — this SDK is keyless read + broadcast.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install nyen-sdk # or: pip install -e Website/packages/nyen-sdk-py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Read a balance (5 lines)
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from nyen_sdk import NyenClient
|
|
37
|
+
|
|
38
|
+
nyen = NyenClient("https://api.nyen.cc/v1", api_key="…")
|
|
39
|
+
info = nyen.address("R…") # native NYEN + every NTS token, one call
|
|
40
|
+
print(info["native"]["balance"], [f'{t["balance"]} {t["name"]}' for t in info["tokens"]])
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Amounts are **satoshi-exact** — never `float()` a balance:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from nyen_sdk import to_sat, from_sat
|
|
47
|
+
to_sat("1.5") # "150000000"
|
|
48
|
+
from_sat("150000000") # "1.50000000"
|
|
49
|
+
nyen.balance("R…") # int satoshis, exact
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Accept deposits with no node (merchant)
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from nyen_sdk import NyenClient, NyenPayments
|
|
56
|
+
|
|
57
|
+
pay = NyenPayments(NyenClient("https://api.nyen.cc/v1", api_key="…"))
|
|
58
|
+
|
|
59
|
+
# 1. create an invoice against YOUR OWN receive address + register a webhook
|
|
60
|
+
inv = pay.create_invoice(address="R…", amount="10",
|
|
61
|
+
callback_url="https://shop.example/nyen-hook")
|
|
62
|
+
print(pay.payment_uri(inv)) # nyen:R…?amount=10&req=inv_… (QR / deep-link)
|
|
63
|
+
|
|
64
|
+
# 2. in your webhook endpoint (e.g. Flask):
|
|
65
|
+
res = pay.handle_webhook(request.headers, request.get_data(as_text=True))
|
|
66
|
+
if res.outcome == "paid":
|
|
67
|
+
fulfill_order(res.invoice.metadata) # HMAC verified, amount checked
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`handle_webhook` verifies the Stripe-style HMAC (`x-nyen-signature`), matches the
|
|
71
|
+
confirmed deposit to the open invoice by address, checks the amount, and settles
|
|
72
|
+
it — you never run a node or poll the chain.
|
|
73
|
+
|
|
74
|
+
## Live-watch (no webhook endpoint)
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
for evt in nyen.subscribe(channels=["address:R…"]):
|
|
78
|
+
if evt["type"] == "address":
|
|
79
|
+
print("deposit", evt["data"]["value"], evt["data"]["txid"])
|
|
80
|
+
break
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Verify a webhook signature yourself
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
ok = NyenClient.verify_webhook_signature(secret, ts, raw_body, sig_header)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Surface
|
|
90
|
+
|
|
91
|
+
`NyenClient`: `health status chain block tx address balance address_utxos
|
|
92
|
+
address_history tokens token holders token_history identity fee_mint fee_estimate
|
|
93
|
+
decode_tx build_tx broadcast rpc verify_message subscribe events_stats
|
|
94
|
+
create_webhook list_webhooks get_webhook delete_webhook verify_webhook_signature`.
|
|
95
|
+
|
|
96
|
+
`NyenPayments`: `create_invoice payment_uri handle_webhook cancel_invoice
|
|
97
|
+
expire_stale sign_request verify_request` + `parse_payment_uri`.
|
|
98
|
+
|
|
99
|
+
Errors raise a typed `NyenError` with a **stable** `.code` (`"tx-rejected"`,
|
|
100
|
+
`"forbidden"`, …), `.rpc_code`, `.reason`, `.hint`, `.retryable` — mirroring the
|
|
101
|
+
`/v1` structured-error contract. See the [error index](https://docs.nyen.cc/api/errors).
|
|
102
|
+
|
|
103
|
+
MIT.
|
nyen_sdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# nyen-sdk (Python)
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the **NYEN `/v1` public API** — the same versioned edge
|
|
4
|
+
the TypeScript [`@nyen/sdk`](../nyen-sdk) wraps. Reads, satoshi-safe amounts, NTS
|
|
5
|
+
token ops, SSE events, HMAC webhooks, Login-with-NyenID, and a non-custodial
|
|
6
|
+
**invoice/webhook payment flow**. **Zero third-party dependencies** (pure
|
|
7
|
+
stdlib). Signing stays in the user's wallet — this SDK is keyless read + broadcast.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install nyen-sdk # or: pip install -e Website/packages/nyen-sdk-py
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Read a balance (5 lines)
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from nyen_sdk import NyenClient
|
|
17
|
+
|
|
18
|
+
nyen = NyenClient("https://api.nyen.cc/v1", api_key="…")
|
|
19
|
+
info = nyen.address("R…") # native NYEN + every NTS token, one call
|
|
20
|
+
print(info["native"]["balance"], [f'{t["balance"]} {t["name"]}' for t in info["tokens"]])
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Amounts are **satoshi-exact** — never `float()` a balance:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from nyen_sdk import to_sat, from_sat
|
|
27
|
+
to_sat("1.5") # "150000000"
|
|
28
|
+
from_sat("150000000") # "1.50000000"
|
|
29
|
+
nyen.balance("R…") # int satoshis, exact
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Accept deposits with no node (merchant)
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from nyen_sdk import NyenClient, NyenPayments
|
|
36
|
+
|
|
37
|
+
pay = NyenPayments(NyenClient("https://api.nyen.cc/v1", api_key="…"))
|
|
38
|
+
|
|
39
|
+
# 1. create an invoice against YOUR OWN receive address + register a webhook
|
|
40
|
+
inv = pay.create_invoice(address="R…", amount="10",
|
|
41
|
+
callback_url="https://shop.example/nyen-hook")
|
|
42
|
+
print(pay.payment_uri(inv)) # nyen:R…?amount=10&req=inv_… (QR / deep-link)
|
|
43
|
+
|
|
44
|
+
# 2. in your webhook endpoint (e.g. Flask):
|
|
45
|
+
res = pay.handle_webhook(request.headers, request.get_data(as_text=True))
|
|
46
|
+
if res.outcome == "paid":
|
|
47
|
+
fulfill_order(res.invoice.metadata) # HMAC verified, amount checked
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`handle_webhook` verifies the Stripe-style HMAC (`x-nyen-signature`), matches the
|
|
51
|
+
confirmed deposit to the open invoice by address, checks the amount, and settles
|
|
52
|
+
it — you never run a node or poll the chain.
|
|
53
|
+
|
|
54
|
+
## Live-watch (no webhook endpoint)
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
for evt in nyen.subscribe(channels=["address:R…"]):
|
|
58
|
+
if evt["type"] == "address":
|
|
59
|
+
print("deposit", evt["data"]["value"], evt["data"]["txid"])
|
|
60
|
+
break
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Verify a webhook signature yourself
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
ok = NyenClient.verify_webhook_signature(secret, ts, raw_body, sig_header)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Surface
|
|
70
|
+
|
|
71
|
+
`NyenClient`: `health status chain block tx address balance address_utxos
|
|
72
|
+
address_history tokens token holders token_history identity fee_mint fee_estimate
|
|
73
|
+
decode_tx build_tx broadcast rpc verify_message subscribe events_stats
|
|
74
|
+
create_webhook list_webhooks get_webhook delete_webhook verify_webhook_signature`.
|
|
75
|
+
|
|
76
|
+
`NyenPayments`: `create_invoice payment_uri handle_webhook cancel_invoice
|
|
77
|
+
expire_stale sign_request verify_request` + `parse_payment_uri`.
|
|
78
|
+
|
|
79
|
+
Errors raise a typed `NyenError` with a **stable** `.code` (`"tx-rejected"`,
|
|
80
|
+
`"forbidden"`, …), `.rpc_code`, `.reason`, `.hint`, `.retryable` — mirroring the
|
|
81
|
+
`/v1` structured-error contract. See the [error index](https://docs.nyen.cc/api/errors).
|
|
82
|
+
|
|
83
|
+
MIT.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""nyen_sdk — official Python SDK for the NYEN /v1 public API.
|
|
2
|
+
|
|
3
|
+
Mirrors @nyen/sdk (TypeScript). Read + broadcast over the gateway edge
|
|
4
|
+
(NyenClient), satoshi-safe amounts, NTS token reads, SSE events, HMAC webhooks,
|
|
5
|
+
Login-with-NyenID, and the non-custodial invoice/webhook payment flow
|
|
6
|
+
(NyenPayments). Zero third-party deps — pure stdlib. Signing stays in the user's
|
|
7
|
+
wallet; this SDK is keyless read + broadcast.
|
|
8
|
+
|
|
9
|
+
Quickstart:
|
|
10
|
+
from nyen_sdk import NyenClient
|
|
11
|
+
nyen = NyenClient("https://api.nyen.cc/v1", api_key="…")
|
|
12
|
+
info = nyen.address("R…")
|
|
13
|
+
print(info["native"]["balance"], [t["name"] for t in info["tokens"]])
|
|
14
|
+
|
|
15
|
+
Merchant deposits with no node:
|
|
16
|
+
from nyen_sdk import NyenClient, NyenPayments
|
|
17
|
+
pay = NyenPayments(NyenClient("https://api.nyen.cc/v1", api_key="…"))
|
|
18
|
+
inv = pay.create_invoice(address="R…", amount="10", callback_url="https://shop/hook")
|
|
19
|
+
print(pay.payment_uri(inv))
|
|
20
|
+
# in your webhook handler:
|
|
21
|
+
res = pay.handle_webhook(request.headers, request.get_data(as_text=True))
|
|
22
|
+
if res.outcome == "paid": fulfill(res.invoice)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .amounts import (
|
|
26
|
+
SATOSHIS_PER_COIN, add_sat, amount, amount_from_sat, cmp_sat, from_sat, to_sat,
|
|
27
|
+
)
|
|
28
|
+
from .client import NyenClient
|
|
29
|
+
from .errors import NYEN_ERROR_CODES, NyenError
|
|
30
|
+
from .events import subscribe_events
|
|
31
|
+
from .payments import (
|
|
32
|
+
Invoice, InvoiceStore, MemoryInvoiceStore, NyenPayments, WebhookResult,
|
|
33
|
+
parse_payment_uri,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"NyenClient",
|
|
40
|
+
"NyenError", "NYEN_ERROR_CODES",
|
|
41
|
+
"NyenPayments", "Invoice", "InvoiceStore", "MemoryInvoiceStore",
|
|
42
|
+
"WebhookResult", "parse_payment_uri",
|
|
43
|
+
"subscribe_events",
|
|
44
|
+
"to_sat", "from_sat", "amount", "amount_from_sat", "add_sat", "cmp_sat",
|
|
45
|
+
"SATOSHIS_PER_COIN",
|
|
46
|
+
"__version__",
|
|
47
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""amounts.py — satoshi-safe amount helpers (RPC_PLAN G9).
|
|
2
|
+
|
|
3
|
+
NYEN amounts are 8-decimal fixed point. The daemon prints them as decimal
|
|
4
|
+
strings (a float hazard). This SDK NEVER round-trips an amount through a Python
|
|
5
|
+
float: every conversion is integer arithmetic, exactly mirroring server/v1.js
|
|
6
|
+
`toSat` / `balanceFromSat` and the TS SDK's amounts.ts. Use these instead of
|
|
7
|
+
`float()` on any balance/fee you read from the API.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Union
|
|
14
|
+
|
|
15
|
+
SATOSHIS_PER_COIN = 100_000_000
|
|
16
|
+
|
|
17
|
+
Number = Union[str, int, float]
|
|
18
|
+
|
|
19
|
+
_DEC_RE = re.compile(r"^-?\d*(\.\d*)?$")
|
|
20
|
+
_INT_RE = re.compile(r"^-?\d+$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_sat(dec: Number) -> str:
|
|
24
|
+
"""Decimal string/number -> satoshi integer string, exact. Mirrors v1.js.
|
|
25
|
+
|
|
26
|
+
A number/int is read as DECIMAL COINS (to_sat(1) == '100000000'), matching
|
|
27
|
+
the TS SDK's number path — not as a raw satoshi count.
|
|
28
|
+
"""
|
|
29
|
+
s = str(dec if dec is not None else "0").strip()
|
|
30
|
+
if not _DEC_RE.match(s) or s in ("", "-"):
|
|
31
|
+
return "0"
|
|
32
|
+
neg = s.startswith("-")
|
|
33
|
+
body = s[1:] if neg else s
|
|
34
|
+
i, _, f = body.partition(".")
|
|
35
|
+
frac = (f + "00000000")[:8]
|
|
36
|
+
digits = ((i or "0") + frac).lstrip("0") or "0"
|
|
37
|
+
return ("-" if neg and digits != "0" else "") + digits
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def from_sat(sat: Number) -> str:
|
|
41
|
+
"""Satoshi integer -> canonical decimal string (8 places)."""
|
|
42
|
+
s = str(sat if sat is not None else "0").strip()
|
|
43
|
+
if not _INT_RE.match(s):
|
|
44
|
+
return "0.00000000"
|
|
45
|
+
neg = s.startswith("-")
|
|
46
|
+
d = (s[1:] if neg else s).rjust(9, "0")
|
|
47
|
+
return f"{'-' if neg else ''}{d[:-8]}.{d[-8:]}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def amount(dec: Number) -> dict:
|
|
51
|
+
"""{ 'balance', 'balancesat' } from a decimal amount."""
|
|
52
|
+
return {"balance": str(dec if dec is not None else "0"), "balancesat": to_sat(dec)}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def amount_from_sat(sat: Number) -> dict:
|
|
56
|
+
"""{ 'balance', 'balancesat' } from a satoshi integer."""
|
|
57
|
+
bal = str(sat if sat is not None else "0")
|
|
58
|
+
return {"balance": from_sat(bal), "balancesat": bal}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def add_sat(a: Number, b: Number) -> str:
|
|
62
|
+
"""Add two satoshi amounts -> satoshi integer string, exact."""
|
|
63
|
+
return str(int(str(a)) + int(str(b)))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmp_sat(a: Number, b: Number) -> int:
|
|
67
|
+
"""Compare two satoshi amounts: -1 | 0 | 1."""
|
|
68
|
+
x, y = int(str(a)), int(str(b))
|
|
69
|
+
return -1 if x < y else (1 if x > y else 0)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""client.py — NyenClient: the typed wrapper over the NYEN /v1 public API.
|
|
2
|
+
|
|
3
|
+
Mirrors the TS SDK's client.ts, for Python exchanges / bots / payment backends.
|
|
4
|
+
One class for every read/write on the gateway edge (server/v1.js):
|
|
5
|
+
reads health/status/chain/block/tx/address(+utxos/history)/token(+holders
|
|
6
|
+
/history)/tokens/fee/id
|
|
7
|
+
writes tx decode / tx build (UNSIGNED) / broadcast (signed) / rpc passthrough
|
|
8
|
+
events subscribe (SSE) / events_stats / webhooks CRUD + signature verify
|
|
9
|
+
Every call unwraps the { ok, data, meta } envelope and raises a typed NyenError
|
|
10
|
+
on { ok:false }. Amounts stay satoshi-exact (see amounts.py). Signing is NEVER
|
|
11
|
+
done here — keys stay in the user's wallet; this class is keyless read+broadcast.
|
|
12
|
+
|
|
13
|
+
Zero third-party deps: pure stdlib (urllib, hmac, hashlib, json).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import hmac
|
|
20
|
+
import json
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
25
|
+
|
|
26
|
+
from .amounts import amount_from_sat, from_sat, to_sat
|
|
27
|
+
from .errors import NyenError
|
|
28
|
+
from .events import subscribe_events
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NyenClient:
|
|
32
|
+
"""Typed client for the NYEN /v1 REST API."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, base_url: str, api_key: Optional[str] = None, timeout: float = 15.0):
|
|
35
|
+
if not base_url:
|
|
36
|
+
raise NyenError.local("bad-request", "NyenClient needs a base_url (…/v1)")
|
|
37
|
+
self.base_url = base_url.rstrip("/")
|
|
38
|
+
self.api_key = api_key
|
|
39
|
+
self.timeout = timeout
|
|
40
|
+
|
|
41
|
+
# ── core request → unwrap envelope → data or raise NyenError ────────────────
|
|
42
|
+
def _request(
|
|
43
|
+
self, path: str, method: str = "GET",
|
|
44
|
+
query: Optional[Dict[str, Any]] = None, body: Optional[Any] = None,
|
|
45
|
+
) -> Any:
|
|
46
|
+
url = self.base_url + path
|
|
47
|
+
if query:
|
|
48
|
+
q = {k: v for k, v in query.items() if v is not None}
|
|
49
|
+
if q:
|
|
50
|
+
url += "?" + urllib.parse.urlencode(q)
|
|
51
|
+
headers = {"accept": "application/json"}
|
|
52
|
+
if self.api_key:
|
|
53
|
+
headers["x-api-key"] = self.api_key
|
|
54
|
+
data = None
|
|
55
|
+
if body is not None:
|
|
56
|
+
headers["content-type"] = "application/json"
|
|
57
|
+
data = json.dumps(body).encode("utf-8")
|
|
58
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
59
|
+
try:
|
|
60
|
+
resp = urllib.request.urlopen(req, timeout=self.timeout) # noqa: S310
|
|
61
|
+
status = resp.getcode()
|
|
62
|
+
raw = resp.read()
|
|
63
|
+
except urllib.error.HTTPError as e:
|
|
64
|
+
status = e.code
|
|
65
|
+
raw = e.read()
|
|
66
|
+
except urllib.error.URLError as e:
|
|
67
|
+
raise NyenError.local("network-error", f"network error: {e.reason}")
|
|
68
|
+
except TimeoutError:
|
|
69
|
+
raise NyenError.local("timeout", f"request timed out after {self.timeout}s")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
env = json.loads(raw.decode("utf-8"))
|
|
73
|
+
except Exception:
|
|
74
|
+
raise NyenError({"code": "internal", "message": f"non-JSON response (HTTP {status})"}, status)
|
|
75
|
+
if isinstance(env, dict) and env.get("ok") is True:
|
|
76
|
+
return env.get("data")
|
|
77
|
+
err = env.get("error") if isinstance(env, dict) else None
|
|
78
|
+
if isinstance(err, dict):
|
|
79
|
+
raise NyenError(err, status)
|
|
80
|
+
raise NyenError({"code": "internal", "message": f"unexpected response (HTTP {status})"}, status)
|
|
81
|
+
|
|
82
|
+
# ── satoshi-safe helpers on the instance for ergonomics ────────────────────
|
|
83
|
+
to_sat = staticmethod(to_sat)
|
|
84
|
+
from_sat = staticmethod(from_sat)
|
|
85
|
+
amount_from_sat = staticmethod(amount_from_sat)
|
|
86
|
+
|
|
87
|
+
# ── unauthenticated ────────────────────────────────────────────────────────
|
|
88
|
+
def health(self) -> Dict[str, Any]:
|
|
89
|
+
"""GET /v1/health — no key. Returns {height,synced,peers,version}."""
|
|
90
|
+
try:
|
|
91
|
+
return self._request("/health")
|
|
92
|
+
except NyenError:
|
|
93
|
+
return {"height": 0, "synced": False, "peers": 0, "version": "v1"}
|
|
94
|
+
|
|
95
|
+
def openapi(self) -> Any:
|
|
96
|
+
return self._request("/openapi.json")
|
|
97
|
+
|
|
98
|
+
# ── reads ──────────────────────────────────────────────────────────────────
|
|
99
|
+
def status(self) -> Dict[str, Any]:
|
|
100
|
+
return self._request("/status")
|
|
101
|
+
|
|
102
|
+
def chain(self) -> Dict[str, Any]:
|
|
103
|
+
return self._request("/chain")
|
|
104
|
+
|
|
105
|
+
def block(self, id_or_height) -> Dict[str, Any]:
|
|
106
|
+
return self._request(f"/block/{urllib.parse.quote(str(id_or_height), safe='')}")
|
|
107
|
+
|
|
108
|
+
def tx(self, txid: str) -> Dict[str, Any]:
|
|
109
|
+
return self._request(f"/tx/{urllib.parse.quote(txid, safe='')}")
|
|
110
|
+
|
|
111
|
+
def address(self, addr: str) -> Dict[str, Any]:
|
|
112
|
+
return self._request(f"/address/{urllib.parse.quote(addr, safe='')}")
|
|
113
|
+
|
|
114
|
+
def balance(self, addr: str) -> int:
|
|
115
|
+
"""The native satoshi balance of an address, as an int (exact)."""
|
|
116
|
+
info = self.address(addr)
|
|
117
|
+
return int(info["native"]["balancesat"])
|
|
118
|
+
|
|
119
|
+
def address_utxos(self, addr: str) -> Dict[str, Any]:
|
|
120
|
+
return self._request(f"/address/{urllib.parse.quote(addr, safe='')}/utxos")
|
|
121
|
+
|
|
122
|
+
def address_history(self, addr: str, cursor: Optional[str] = None, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
123
|
+
return self._request(
|
|
124
|
+
f"/address/{urllib.parse.quote(addr, safe='')}/history",
|
|
125
|
+
query={"cursor": cursor, "limit": limit},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def tokens(self, metadata: bool = False) -> Any:
|
|
129
|
+
return self._request("/tokens", query={"metadata": "true" if metadata else None})
|
|
130
|
+
|
|
131
|
+
def token(self, token_id: str) -> Dict[str, Any]:
|
|
132
|
+
return self._request(f"/token/{urllib.parse.quote(token_id, safe='')}")
|
|
133
|
+
|
|
134
|
+
def holders(self, token_id: str, minbalance=None, start=None, count=None) -> Any:
|
|
135
|
+
return self._request(
|
|
136
|
+
f"/token/{urllib.parse.quote(token_id, safe='')}/holders",
|
|
137
|
+
query={"minbalance": minbalance, "start": start, "count": count},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def token_history(self, token_id: str, from_block=None, to_block=None, count=None) -> Any:
|
|
141
|
+
# R6: served by the read-only `gettokenhistory` daemon RPC (block-walk in
|
|
142
|
+
# the node) — `count` caps the returned events.
|
|
143
|
+
return self._request(
|
|
144
|
+
f"/token/{urllib.parse.quote(token_id, safe='')}/history",
|
|
145
|
+
query={"from": from_block, "to": to_block, "count": count},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def identity(self, name: str) -> Dict[str, Any]:
|
|
149
|
+
return self._request(f"/id/{urllib.parse.quote(name, safe='')}")
|
|
150
|
+
|
|
151
|
+
# ── fees ────────────────────────────────────────────────────────────────────
|
|
152
|
+
def fee_mint(self, height: Optional[int] = None) -> Dict[str, Any]:
|
|
153
|
+
return self._request("/fee/mint", query={"height": height})
|
|
154
|
+
|
|
155
|
+
def fee_estimate(self, op: str = "send", outputs: int = 1) -> Dict[str, Any]:
|
|
156
|
+
return self._request("/fee/estimate", query={"op": op, "outputs": outputs})
|
|
157
|
+
|
|
158
|
+
# ── tx tooling (keyless build/decode; signed broadcast) ────────────────────
|
|
159
|
+
def decode_tx(self, hex_str: str) -> Dict[str, Any]:
|
|
160
|
+
return self._request("/tx/decode", method="POST", body={"hex": hex_str})
|
|
161
|
+
|
|
162
|
+
def build_tx(self, inputs: List[dict], outputs: Dict[str, Any]) -> Dict[str, Any]:
|
|
163
|
+
return self._request("/tx/build", method="POST", body={"inputs": inputs, "outputs": outputs})
|
|
164
|
+
|
|
165
|
+
def broadcast(self, signed_hex: str) -> str:
|
|
166
|
+
"""POST /v1/tx/broadcast { hex } — submit an ALREADY-SIGNED tx. Returns txid."""
|
|
167
|
+
return self._request("/tx/broadcast", method="POST", body={"hex": signed_hex})["txid"]
|
|
168
|
+
|
|
169
|
+
def rpc(self, method: str, params: Optional[list] = None) -> Any:
|
|
170
|
+
"""POST /v1/rpc — raw JSON-RPC passthrough (allowlisted methods only).
|
|
171
|
+
|
|
172
|
+
Returns the RAW daemon result; raises NyenError on a gateway rejection or
|
|
173
|
+
a daemon error.
|
|
174
|
+
"""
|
|
175
|
+
url = self.base_url + "/rpc"
|
|
176
|
+
headers = {"accept": "application/json", "content-type": "application/json"}
|
|
177
|
+
if self.api_key:
|
|
178
|
+
headers["x-api-key"] = self.api_key
|
|
179
|
+
payload = json.dumps({"jsonrpc": "1.0", "id": "nyen-sdk-py", "method": method, "params": params or []}).encode()
|
|
180
|
+
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
|
181
|
+
try:
|
|
182
|
+
resp = urllib.request.urlopen(req, timeout=self.timeout) # noqa: S310
|
|
183
|
+
status, raw = resp.getcode(), resp.read()
|
|
184
|
+
except urllib.error.HTTPError as e:
|
|
185
|
+
status, raw = e.code, e.read()
|
|
186
|
+
except urllib.error.URLError as e:
|
|
187
|
+
raise NyenError.local("network-error", f"network error: {e.reason}")
|
|
188
|
+
obj = json.loads(raw.decode("utf-8"))
|
|
189
|
+
if isinstance(obj, dict) and obj.get("ok") is False: # gateway-level (403) envelope
|
|
190
|
+
raise NyenError(obj.get("error") or {}, status)
|
|
191
|
+
if isinstance(obj, dict) and obj.get("error"):
|
|
192
|
+
e = obj["error"]
|
|
193
|
+
raise NyenError({"code": "internal", "message": e.get("message"), "rpcCode": e.get("code")}, status)
|
|
194
|
+
return obj.get("result") if isinstance(obj, dict) else obj
|
|
195
|
+
|
|
196
|
+
def verify_message(self, address: str, signature: str, message: str) -> bool:
|
|
197
|
+
"""Verify a Login-with-NyenID signature (D30) via the verifymessage passthrough."""
|
|
198
|
+
return bool(self.rpc("verifymessage", [address, signature, message]))
|
|
199
|
+
|
|
200
|
+
# ── events (SSE) ────────────────────────────────────────────────────────────
|
|
201
|
+
def subscribe(self, channels: Optional[List[str]] = None, last_event_id: Optional[str] = None) -> Iterator[dict]:
|
|
202
|
+
"""Yield pushed block/tx/address/token events (blocks; run in a thread).
|
|
203
|
+
|
|
204
|
+
channels: ["block","tx","address:{a}","token:{id}"]; None = all.
|
|
205
|
+
"""
|
|
206
|
+
return subscribe_events(self.base_url, self.api_key, channels, last_event_id, self.timeout)
|
|
207
|
+
|
|
208
|
+
def events_stats(self) -> Dict[str, Any]:
|
|
209
|
+
return self._request("/events/stats")
|
|
210
|
+
|
|
211
|
+
# ── webhooks ────────────────────────────────────────────────────────────────
|
|
212
|
+
def create_webhook(
|
|
213
|
+
self, url: str, events: Optional[List[str]] = None,
|
|
214
|
+
secret: Optional[str] = None, address: Optional[str] = None, token: Optional[str] = None,
|
|
215
|
+
) -> Dict[str, Any]:
|
|
216
|
+
"""POST /v1/webhooks — register an HMAC-signed delivery endpoint. The
|
|
217
|
+
`secret` is returned ONCE; store it to verify each delivery."""
|
|
218
|
+
body = {"url": url}
|
|
219
|
+
if events:
|
|
220
|
+
body["events"] = events
|
|
221
|
+
if secret:
|
|
222
|
+
body["secret"] = secret
|
|
223
|
+
if address:
|
|
224
|
+
body["address"] = address
|
|
225
|
+
if token:
|
|
226
|
+
body["token"] = token
|
|
227
|
+
return self._request("/webhooks", method="POST", body=body)
|
|
228
|
+
|
|
229
|
+
def list_webhooks(self) -> List[dict]:
|
|
230
|
+
return self._request("/webhooks").get("webhooks", [])
|
|
231
|
+
|
|
232
|
+
def get_webhook(self, webhook_id: str) -> Dict[str, Any]:
|
|
233
|
+
return self._request(f"/webhooks/{urllib.parse.quote(webhook_id, safe='')}")
|
|
234
|
+
|
|
235
|
+
def delete_webhook(self, webhook_id: str) -> bool:
|
|
236
|
+
self._request(f"/webhooks/{urllib.parse.quote(webhook_id, safe='')}", method="DELETE")
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def verify_webhook_signature(secret: str, timestamp: str, raw_body: str, signature_header: str) -> bool:
|
|
241
|
+
"""Verify a webhook delivery signature (Stripe-style, matches events.js):
|
|
242
|
+
x-nyen-signature == "sha256=" + HMAC-SHA256(secret, f"{timestamp}.{raw_body}").
|
|
243
|
+
Constant-time compare."""
|
|
244
|
+
mac = hmac.new(secret.encode("utf-8"), f"{timestamp}.{raw_body}".encode("utf-8"), hashlib.sha256).hexdigest()
|
|
245
|
+
expected = f"sha256={mac}"
|
|
246
|
+
return hmac.compare_digest(expected, (signature_header or "").strip())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""errors.py — the SDK error type, mirroring server/errors.js (RPC_PLAN G3).
|
|
2
|
+
|
|
3
|
+
Every /v1 failure arrives as { ok:false, error:{ code, message, rpcCode, reason,
|
|
4
|
+
hint, docs } }. The SDK raises a single typed `NyenError` so callers can branch
|
|
5
|
+
on a STABLE string contract (`err.code == "tx-rejected"`) — never a raw daemon
|
|
6
|
+
integer. Mirrors the TS SDK's errors.ts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
# The stable /v1 error codes. This set IS the contract (mirror errors.js).
|
|
14
|
+
NYEN_ERROR_CODES = (
|
|
15
|
+
"bad-request",
|
|
16
|
+
"unauthorized",
|
|
17
|
+
"forbidden",
|
|
18
|
+
"not-found",
|
|
19
|
+
"invalid-params",
|
|
20
|
+
"insufficient-funds",
|
|
21
|
+
"tx-rejected",
|
|
22
|
+
"tx-error",
|
|
23
|
+
"rate-limited",
|
|
24
|
+
"in-warmup",
|
|
25
|
+
"upstream-timeout",
|
|
26
|
+
"upstream-unreachable",
|
|
27
|
+
"internal",
|
|
28
|
+
# SDK-local codes (never sent by the server):
|
|
29
|
+
"network-error",
|
|
30
|
+
"timeout",
|
|
31
|
+
"aborted",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_RETRYABLE = {
|
|
35
|
+
"rate-limited", "upstream-timeout", "upstream-unreachable",
|
|
36
|
+
"in-warmup", "network-error", "timeout",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NyenError(Exception):
|
|
41
|
+
"""One typed error for every /v1 and transport failure."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, body: dict, http_status: Optional[int] = None):
|
|
44
|
+
self.code = body.get("code") or "internal"
|
|
45
|
+
self.message = body.get("message") or self.code or "nyen error"
|
|
46
|
+
self.rpc_code = body.get("rpcCode")
|
|
47
|
+
self.reason = body.get("reason")
|
|
48
|
+
self.hint = body.get("hint")
|
|
49
|
+
self.docs = body.get("docs")
|
|
50
|
+
self.http_status = http_status
|
|
51
|
+
self.retry_after = body.get("retryAfter")
|
|
52
|
+
super().__init__(self.message)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def retryable(self) -> bool:
|
|
56
|
+
"""True for errors worth retrying (rate-limit, upstream, warmup, transport)."""
|
|
57
|
+
return self.code in _RETRYABLE
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def local(code: str, message: str) -> "NyenError":
|
|
61
|
+
"""Build from a wrapped transport/SDK failure (no server envelope)."""
|
|
62
|
+
return NyenError({"code": code, "message": message})
|
|
63
|
+
|
|
64
|
+
def __repr__(self) -> str:
|
|
65
|
+
return f"NyenError(code={self.code!r}, rpc_code={self.rpc_code!r}, message={self.message!r})"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""events.py — SSE subscription to GET /v1/events (RPC_PLAN R3).
|
|
2
|
+
|
|
3
|
+
Push, not poll. Yields NyenEvent dicts {id, type, data} as the gateway pushes
|
|
4
|
+
them. Pure stdlib (urllib streaming SSE parse). The browser EventSource can't set
|
|
5
|
+
headers, so the /v1 route also accepts the key as ?key= (G32) — this server-side
|
|
6
|
+
client uses the x-api-key header, but passes ?key= too for symmetry.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
for evt in subscribe_events(base_url, api_key, channels=["block", "tx"]):
|
|
10
|
+
print(evt["type"], evt["data"])
|
|
11
|
+
# break to stop; the connection closes on generator close.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from typing import Iterator, List, Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def subscribe_events(
|
|
23
|
+
base_url: str,
|
|
24
|
+
api_key: Optional[str] = None,
|
|
25
|
+
channels: Optional[List[str]] = None,
|
|
26
|
+
last_event_id: Optional[str] = None,
|
|
27
|
+
timeout: Optional[float] = None,
|
|
28
|
+
) -> Iterator[dict]:
|
|
29
|
+
"""Yield pushed SSE events. Blocks; iterate in a thread for concurrency.
|
|
30
|
+
|
|
31
|
+
channels: e.g. ["block", "tx", "address:R…", "token:i…"]; None/[] = ALL.
|
|
32
|
+
"""
|
|
33
|
+
base = base_url.rstrip("/")
|
|
34
|
+
q = {}
|
|
35
|
+
if channels:
|
|
36
|
+
q["channels"] = ",".join(channels)
|
|
37
|
+
if api_key:
|
|
38
|
+
q["key"] = api_key # G32: query-key path (EventSource parity)
|
|
39
|
+
url = base + "/events" + ("?" + urllib.parse.urlencode(q) if q else "")
|
|
40
|
+
headers = {"accept": "text/event-stream"}
|
|
41
|
+
if api_key:
|
|
42
|
+
headers["x-api-key"] = api_key
|
|
43
|
+
if last_event_id:
|
|
44
|
+
headers["Last-Event-ID"] = last_event_id
|
|
45
|
+
|
|
46
|
+
req = urllib.request.Request(url, headers=headers)
|
|
47
|
+
resp = urllib.request.urlopen(req, timeout=timeout) # noqa: S310 (trusted base)
|
|
48
|
+
try:
|
|
49
|
+
event_type = None
|
|
50
|
+
data_lines: List[str] = []
|
|
51
|
+
eid = None
|
|
52
|
+
for raw in resp:
|
|
53
|
+
line = raw.decode("utf-8", "replace").rstrip("\n").rstrip("\r")
|
|
54
|
+
if line == "":
|
|
55
|
+
# dispatch on a blank line
|
|
56
|
+
if data_lines:
|
|
57
|
+
payload = "\n".join(data_lines)
|
|
58
|
+
parsed = _parse_data(payload)
|
|
59
|
+
yield {"id": eid, "type": event_type or "message", "data": parsed}
|
|
60
|
+
event_type, data_lines, eid = None, [], eid
|
|
61
|
+
continue
|
|
62
|
+
if line.startswith(":"):
|
|
63
|
+
continue # comment / heartbeat
|
|
64
|
+
field, _, value = line.partition(":")
|
|
65
|
+
if value.startswith(" "):
|
|
66
|
+
value = value[1:]
|
|
67
|
+
if field == "event":
|
|
68
|
+
event_type = value
|
|
69
|
+
elif field == "data":
|
|
70
|
+
data_lines.append(value)
|
|
71
|
+
elif field == "id":
|
|
72
|
+
eid = value
|
|
73
|
+
finally:
|
|
74
|
+
try:
|
|
75
|
+
resp.close()
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_data(payload: str):
|
|
81
|
+
try:
|
|
82
|
+
obj = json.loads(payload)
|
|
83
|
+
# server frames "data:" as the event's `data` field JSON directly
|
|
84
|
+
return obj
|
|
85
|
+
except Exception:
|
|
86
|
+
return payload
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""payments.py — NyenPayments: non-custodial invoice + webhook deposit flow
|
|
2
|
+
(RPC_PLAN §8 payments, Phase R5). Mirrors the TS SDK's payments.ts.
|
|
3
|
+
|
|
4
|
+
A merchant integrates NYEN deposits with NO node of their own:
|
|
5
|
+
1. create_invoice(address=…, amount=…) — merchant supplies their OWN receive
|
|
6
|
+
address (keys stay in the merchant's wallet). Optionally registers a scoped
|
|
7
|
+
`deposit.confirmed` webhook on that address.
|
|
8
|
+
2. show payment_uri(inv) as a QR / nyen: link.
|
|
9
|
+
3. on each webhook POST -> handle_webhook(headers, raw_body) verifies the HMAC,
|
|
10
|
+
matches the deposit to an open invoice by address, checks the amount, and
|
|
11
|
+
marks it paid.
|
|
12
|
+
Rides the existing /v1 edge (create_webhook + deposit.confirmed/token.transfer +
|
|
13
|
+
verify_webhook_signature). No node, no key at the edge. Amounts satoshi-exact.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import hmac
|
|
20
|
+
import json
|
|
21
|
+
import secrets
|
|
22
|
+
import time
|
|
23
|
+
import urllib.parse
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
from .amounts import amount_from_sat, cmp_sat, from_sat, to_sat
|
|
28
|
+
from .client import NyenClient
|
|
29
|
+
from .errors import NyenError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Invoice:
|
|
34
|
+
id: str
|
|
35
|
+
address: str
|
|
36
|
+
currency: str
|
|
37
|
+
amount: str
|
|
38
|
+
amountsat: str
|
|
39
|
+
status: str # pending | underpaid | paid | expired | canceled
|
|
40
|
+
created_at: int
|
|
41
|
+
expires_at: Optional[int]
|
|
42
|
+
memo: Optional[str] = None
|
|
43
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
44
|
+
paid_at: Optional[int] = None
|
|
45
|
+
paid_amountsat: Optional[str] = None
|
|
46
|
+
txid: Optional[str] = None
|
|
47
|
+
webhook_id: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InvoiceStore:
|
|
51
|
+
"""Pluggable persistence. Default is in-memory; a real merchant swaps a DB."""
|
|
52
|
+
|
|
53
|
+
def put(self, inv: Invoice) -> None: raise NotImplementedError
|
|
54
|
+
def get(self, invoice_id: str) -> Optional[Invoice]: raise NotImplementedError
|
|
55
|
+
def by_address(self, address: str) -> Optional[Invoice]: raise NotImplementedError
|
|
56
|
+
def list(self) -> List[Invoice]: raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MemoryInvoiceStore(InvoiceStore):
|
|
60
|
+
def __init__(self):
|
|
61
|
+
self._by_id: Dict[str, Invoice] = {}
|
|
62
|
+
|
|
63
|
+
def put(self, inv: Invoice) -> None:
|
|
64
|
+
self._by_id[inv.id] = inv
|
|
65
|
+
|
|
66
|
+
def get(self, invoice_id: str) -> Optional[Invoice]:
|
|
67
|
+
return self._by_id.get(invoice_id)
|
|
68
|
+
|
|
69
|
+
def by_address(self, address: str) -> Optional[Invoice]:
|
|
70
|
+
for inv in self._by_id.values():
|
|
71
|
+
if inv.address == address and inv.status in ("pending", "underpaid"):
|
|
72
|
+
return inv
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def list(self) -> List[Invoice]:
|
|
76
|
+
return list(self._by_id.values())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class WebhookResult:
|
|
81
|
+
verified: bool
|
|
82
|
+
event: Optional[str]
|
|
83
|
+
invoice: Optional[Invoice]
|
|
84
|
+
outcome: str # paid | underpaid | already-paid | no-match | ignored | bad-signature
|
|
85
|
+
data: Optional[Dict[str, Any]]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _now() -> int:
|
|
89
|
+
return int(time.time())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class NyenPayments:
|
|
93
|
+
"""The merchant-side payment processor. Owns no keys, needs no node."""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self, client: NyenClient, store: Optional[InvoiceStore] = None,
|
|
97
|
+
on_paid: Optional[Callable[[Invoice], None]] = None,
|
|
98
|
+
on_underpaid: Optional[Callable[[Invoice], None]] = None,
|
|
99
|
+
):
|
|
100
|
+
if client is None:
|
|
101
|
+
raise NyenError.local("bad-request", "NyenPayments needs a NyenClient")
|
|
102
|
+
self.client = client
|
|
103
|
+
self.store = store or MemoryInvoiceStore()
|
|
104
|
+
self.on_paid = on_paid
|
|
105
|
+
self.on_underpaid = on_underpaid
|
|
106
|
+
self._secrets: Dict[str, str] = {}
|
|
107
|
+
|
|
108
|
+
def create_invoice(
|
|
109
|
+
self, address: str, amount=None, amountsat=None, currency: str = "NYEN",
|
|
110
|
+
memo: Optional[str] = None, metadata: Optional[dict] = None,
|
|
111
|
+
expires_in_sec: Optional[int] = 3600,
|
|
112
|
+
callback_url: Optional[str] = None, webhook_secret: Optional[str] = None,
|
|
113
|
+
) -> Invoice:
|
|
114
|
+
if not address:
|
|
115
|
+
raise NyenError.local("bad-request", "create_invoice needs a receive address")
|
|
116
|
+
sat = str(int(str(amountsat))) if amountsat is not None else (to_sat(amount) if amount is not None else "0")
|
|
117
|
+
if cmp_sat(sat, "0") <= 0:
|
|
118
|
+
raise NyenError.local("bad-request", "create_invoice needs a positive amount")
|
|
119
|
+
created = _now()
|
|
120
|
+
inv = Invoice(
|
|
121
|
+
id="inv_" + secrets.token_hex(12),
|
|
122
|
+
address=address,
|
|
123
|
+
currency=currency,
|
|
124
|
+
amount=from_sat(sat),
|
|
125
|
+
amountsat=sat,
|
|
126
|
+
status="pending",
|
|
127
|
+
created_at=created,
|
|
128
|
+
expires_at=(created + expires_in_sec) if expires_in_sec else None,
|
|
129
|
+
memo=memo,
|
|
130
|
+
metadata=metadata,
|
|
131
|
+
)
|
|
132
|
+
if callback_url:
|
|
133
|
+
is_token = currency != "NYEN"
|
|
134
|
+
wh = self.client.create_webhook(
|
|
135
|
+
url=callback_url,
|
|
136
|
+
events=["token.transfer" if is_token else "deposit.confirmed"],
|
|
137
|
+
secret=webhook_secret,
|
|
138
|
+
address=None if is_token else address,
|
|
139
|
+
token=currency if is_token else None,
|
|
140
|
+
)
|
|
141
|
+
inv.webhook_id = wh["id"]
|
|
142
|
+
self._secrets[inv.id] = wh["secret"]
|
|
143
|
+
self.store.put(inv)
|
|
144
|
+
return inv
|
|
145
|
+
|
|
146
|
+
def payment_uri(self, inv: Invoice) -> str:
|
|
147
|
+
"""A BIP21-style payment URI for a QR / deep-link."""
|
|
148
|
+
q: Dict[str, str] = {}
|
|
149
|
+
if cmp_sat(inv.amountsat, "0") > 0:
|
|
150
|
+
q["amount"] = inv.amount
|
|
151
|
+
if inv.memo:
|
|
152
|
+
q["label"] = inv.memo
|
|
153
|
+
if inv.currency and inv.currency != "NYEN":
|
|
154
|
+
q["currency"] = inv.currency
|
|
155
|
+
q["req"] = inv.id
|
|
156
|
+
qs = urllib.parse.urlencode(q)
|
|
157
|
+
return f"nyen:{inv.address}" + (f"?{qs}" if qs else "")
|
|
158
|
+
|
|
159
|
+
def bind_secret(self, invoice_id: str, secret: str) -> None:
|
|
160
|
+
"""Record the webhook secret for an invoice created out-of-band."""
|
|
161
|
+
self._secrets[invoice_id] = secret
|
|
162
|
+
|
|
163
|
+
def handle_webhook(
|
|
164
|
+
self, headers: Dict[str, str], raw_body: str, secret: Optional[str] = None,
|
|
165
|
+
) -> WebhookResult:
|
|
166
|
+
"""Feed one webhook POST here. Verifies HMAC, matches an OPEN invoice by
|
|
167
|
+
address, checks the amount, settles it, and fires on_paid/on_underpaid."""
|
|
168
|
+
def h(name: str) -> str:
|
|
169
|
+
for k, v in headers.items():
|
|
170
|
+
if k.lower() == name.lower():
|
|
171
|
+
return v if isinstance(v, str) else (v[0] if v else "")
|
|
172
|
+
return ""
|
|
173
|
+
|
|
174
|
+
ts = h("x-nyen-timestamp")
|
|
175
|
+
sig = h("x-nyen-signature")
|
|
176
|
+
event_hdr = h("x-nyen-event") or None
|
|
177
|
+
try:
|
|
178
|
+
body = json.loads(raw_body)
|
|
179
|
+
except Exception:
|
|
180
|
+
return WebhookResult(False, event_hdr, None, "bad-signature", None)
|
|
181
|
+
event = body.get("event") or event_hdr
|
|
182
|
+
data = body.get("data") or None
|
|
183
|
+
|
|
184
|
+
address = ""
|
|
185
|
+
if data:
|
|
186
|
+
address = str(data.get("address") or (data.get("addresses") or [""])[0] or "")
|
|
187
|
+
invoice = self.store.by_address(address) if address else None
|
|
188
|
+
|
|
189
|
+
use_secret = (self._secrets.get(invoice.id) if invoice else None) or secret
|
|
190
|
+
if not use_secret:
|
|
191
|
+
return WebhookResult(False, event, invoice, "bad-signature", data)
|
|
192
|
+
if not NyenClient.verify_webhook_signature(use_secret, ts, raw_body, sig):
|
|
193
|
+
return WebhookResult(False, event, invoice, "bad-signature", data)
|
|
194
|
+
|
|
195
|
+
if event not in ("deposit.confirmed", "token.transfer"):
|
|
196
|
+
return WebhookResult(True, event, invoice, "ignored", data)
|
|
197
|
+
if not invoice:
|
|
198
|
+
return WebhookResult(True, event, None, "no-match", data)
|
|
199
|
+
if invoice.status == "paid":
|
|
200
|
+
return WebhookResult(True, event, invoice, "already-paid", data)
|
|
201
|
+
|
|
202
|
+
raw_amt = data.get("amount") if event == "token.transfer" else data.get("value")
|
|
203
|
+
paidsat = to_sat(raw_amt) if raw_amt is not None else "0"
|
|
204
|
+
invoice.paid_amountsat = paidsat
|
|
205
|
+
invoice.txid = data.get("txid") or invoice.txid
|
|
206
|
+
settled = amount_from_sat(paidsat)
|
|
207
|
+
if cmp_sat(paidsat, invoice.amountsat) >= 0:
|
|
208
|
+
invoice.status = "paid"
|
|
209
|
+
invoice.paid_at = _now()
|
|
210
|
+
self.store.put(invoice)
|
|
211
|
+
if self.on_paid:
|
|
212
|
+
self.on_paid(invoice)
|
|
213
|
+
return WebhookResult(True, event, invoice, "paid", {**(data or {}), "settled": settled})
|
|
214
|
+
invoice.status = "underpaid"
|
|
215
|
+
self.store.put(invoice)
|
|
216
|
+
if self.on_underpaid:
|
|
217
|
+
self.on_underpaid(invoice)
|
|
218
|
+
return WebhookResult(True, event, invoice, "underpaid", {**(data or {}), "settled": settled})
|
|
219
|
+
|
|
220
|
+
def cancel_invoice(self, invoice_id: str) -> Optional[Invoice]:
|
|
221
|
+
inv = self.store.get(invoice_id)
|
|
222
|
+
if not inv:
|
|
223
|
+
return None
|
|
224
|
+
if inv.status in ("pending", "underpaid"):
|
|
225
|
+
inv.status = "canceled"
|
|
226
|
+
if inv.webhook_id:
|
|
227
|
+
try:
|
|
228
|
+
self.client.delete_webhook(inv.webhook_id)
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
self.store.put(inv)
|
|
232
|
+
return inv
|
|
233
|
+
|
|
234
|
+
def expire_stale(self) -> List[Invoice]:
|
|
235
|
+
t = _now()
|
|
236
|
+
changed: List[Invoice] = []
|
|
237
|
+
for inv in self.store.list():
|
|
238
|
+
if inv.status in ("pending", "underpaid") and inv.expires_at and t > inv.expires_at:
|
|
239
|
+
inv.status = "expired"
|
|
240
|
+
if inv.webhook_id:
|
|
241
|
+
try:
|
|
242
|
+
self.client.delete_webhook(inv.webhook_id)
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
self.store.put(inv)
|
|
246
|
+
changed.append(inv)
|
|
247
|
+
return changed
|
|
248
|
+
|
|
249
|
+
# ── Signed payment request (app-layer HMAC) ────────────────────────────────
|
|
250
|
+
# The merchant SIGNS the request so the payer's wallet can trust the
|
|
251
|
+
# address+amount weren't tampered with. Pragmatic no-daemon-change version
|
|
252
|
+
# (shared secret). RECOMMENDED R6 upgrade: sign with a NyenID identity so any
|
|
253
|
+
# wallet verifies against the chain (verifymessage), no pre-shared secret.
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _canonical(inv: Invoice) -> str:
|
|
256
|
+
return json.dumps({
|
|
257
|
+
"address": inv.address,
|
|
258
|
+
"amountsat": inv.amountsat,
|
|
259
|
+
"currency": inv.currency,
|
|
260
|
+
"memo": inv.memo or "",
|
|
261
|
+
"expiresAt": inv.expires_at,
|
|
262
|
+
"id": inv.id,
|
|
263
|
+
}, separators=(",", ":"), sort_keys=False)
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def sign_request(secret: str, inv: Invoice) -> str:
|
|
267
|
+
return hmac.new(secret.encode(), NyenPayments._canonical(inv).encode(), hashlib.sha256).hexdigest()
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def verify_request(secret: str, inv: Invoice, signature_hex: str) -> bool:
|
|
271
|
+
expected = NyenPayments.sign_request(secret, inv)
|
|
272
|
+
return hmac.compare_digest(expected, (signature_hex or "").strip().lower())
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def parse_payment_uri(uri: str) -> Optional[dict]:
|
|
276
|
+
"""Parse a `nyen:<addr>?amount=&label=¤cy=&req=` URI (payer side)."""
|
|
277
|
+
s = (uri or "").strip()
|
|
278
|
+
if not s.lower().startswith("nyen:"):
|
|
279
|
+
return None
|
|
280
|
+
rest = s[5:]
|
|
281
|
+
addr, _, qs = rest.partition("?")
|
|
282
|
+
q = urllib.parse.parse_qs(qs)
|
|
283
|
+
amount = q.get("amount", [None])[0]
|
|
284
|
+
return {
|
|
285
|
+
"address": urllib.parse.unquote(addr),
|
|
286
|
+
"amount": amount,
|
|
287
|
+
"amountsat": to_sat(amount) if amount is not None else None,
|
|
288
|
+
"memo": q.get("label", q.get("message", [None]))[0],
|
|
289
|
+
"currency": q.get("currency", ["NYEN"])[0],
|
|
290
|
+
"req": q.get("req", [None])[0],
|
|
291
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nyen-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the NYEN /v1 public API — reads, satoshi-safe amounts, NTS token ops, SSE events, HMAC webhooks, and the non-custodial invoice/webhook payment flow.
|
|
5
|
+
Author: NYEN
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://docs.nyen.cc/api
|
|
8
|
+
Project-URL: Documentation, https://docs.nyen.cc/api
|
|
9
|
+
Keywords: nyen,nts,blockchain,utxo,wallet,payments
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# nyen-sdk (Python)
|
|
22
|
+
|
|
23
|
+
Official Python SDK for the **NYEN `/v1` public API** — the same versioned edge
|
|
24
|
+
the TypeScript [`@nyen/sdk`](../nyen-sdk) wraps. Reads, satoshi-safe amounts, NTS
|
|
25
|
+
token ops, SSE events, HMAC webhooks, Login-with-NyenID, and a non-custodial
|
|
26
|
+
**invoice/webhook payment flow**. **Zero third-party dependencies** (pure
|
|
27
|
+
stdlib). Signing stays in the user's wallet — this SDK is keyless read + broadcast.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install nyen-sdk # or: pip install -e Website/packages/nyen-sdk-py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Read a balance (5 lines)
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from nyen_sdk import NyenClient
|
|
37
|
+
|
|
38
|
+
nyen = NyenClient("https://api.nyen.cc/v1", api_key="…")
|
|
39
|
+
info = nyen.address("R…") # native NYEN + every NTS token, one call
|
|
40
|
+
print(info["native"]["balance"], [f'{t["balance"]} {t["name"]}' for t in info["tokens"]])
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Amounts are **satoshi-exact** — never `float()` a balance:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from nyen_sdk import to_sat, from_sat
|
|
47
|
+
to_sat("1.5") # "150000000"
|
|
48
|
+
from_sat("150000000") # "1.50000000"
|
|
49
|
+
nyen.balance("R…") # int satoshis, exact
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Accept deposits with no node (merchant)
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from nyen_sdk import NyenClient, NyenPayments
|
|
56
|
+
|
|
57
|
+
pay = NyenPayments(NyenClient("https://api.nyen.cc/v1", api_key="…"))
|
|
58
|
+
|
|
59
|
+
# 1. create an invoice against YOUR OWN receive address + register a webhook
|
|
60
|
+
inv = pay.create_invoice(address="R…", amount="10",
|
|
61
|
+
callback_url="https://shop.example/nyen-hook")
|
|
62
|
+
print(pay.payment_uri(inv)) # nyen:R…?amount=10&req=inv_… (QR / deep-link)
|
|
63
|
+
|
|
64
|
+
# 2. in your webhook endpoint (e.g. Flask):
|
|
65
|
+
res = pay.handle_webhook(request.headers, request.get_data(as_text=True))
|
|
66
|
+
if res.outcome == "paid":
|
|
67
|
+
fulfill_order(res.invoice.metadata) # HMAC verified, amount checked
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`handle_webhook` verifies the Stripe-style HMAC (`x-nyen-signature`), matches the
|
|
71
|
+
confirmed deposit to the open invoice by address, checks the amount, and settles
|
|
72
|
+
it — you never run a node or poll the chain.
|
|
73
|
+
|
|
74
|
+
## Live-watch (no webhook endpoint)
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
for evt in nyen.subscribe(channels=["address:R…"]):
|
|
78
|
+
if evt["type"] == "address":
|
|
79
|
+
print("deposit", evt["data"]["value"], evt["data"]["txid"])
|
|
80
|
+
break
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Verify a webhook signature yourself
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
ok = NyenClient.verify_webhook_signature(secret, ts, raw_body, sig_header)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Surface
|
|
90
|
+
|
|
91
|
+
`NyenClient`: `health status chain block tx address balance address_utxos
|
|
92
|
+
address_history tokens token holders token_history identity fee_mint fee_estimate
|
|
93
|
+
decode_tx build_tx broadcast rpc verify_message subscribe events_stats
|
|
94
|
+
create_webhook list_webhooks get_webhook delete_webhook verify_webhook_signature`.
|
|
95
|
+
|
|
96
|
+
`NyenPayments`: `create_invoice payment_uri handle_webhook cancel_invoice
|
|
97
|
+
expire_stale sign_request verify_request` + `parse_payment_uri`.
|
|
98
|
+
|
|
99
|
+
Errors raise a typed `NyenError` with a **stable** `.code` (`"tx-rejected"`,
|
|
100
|
+
`"forbidden"`, …), `.rpc_code`, `.reason`, `.hint`, `.retryable` — mirroring the
|
|
101
|
+
`/v1` structured-error contract. See the [error index](https://docs.nyen.cc/api/errors).
|
|
102
|
+
|
|
103
|
+
MIT.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
nyen_sdk/__init__.py
|
|
5
|
+
nyen_sdk/amounts.py
|
|
6
|
+
nyen_sdk/client.py
|
|
7
|
+
nyen_sdk/errors.py
|
|
8
|
+
nyen_sdk/events.py
|
|
9
|
+
nyen_sdk/payments.py
|
|
10
|
+
nyen_sdk.egg-info/PKG-INFO
|
|
11
|
+
nyen_sdk.egg-info/SOURCES.txt
|
|
12
|
+
nyen_sdk.egg-info/dependency_links.txt
|
|
13
|
+
nyen_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nyen_sdk
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nyen-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the NYEN /v1 public API — reads, satoshi-safe amounts, NTS token ops, SSE events, HMAC webhooks, and the non-custodial invoice/webhook payment flow."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "NYEN" }]
|
|
14
|
+
keywords = ["nyen", "nts", "blockchain", "utxo", "wallet", "payments"]
|
|
15
|
+
dependencies = [] # zero third-party deps — pure stdlib
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
"Topic :: Office/Business :: Financial",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://docs.nyen.cc/api"
|
|
27
|
+
Documentation = "https://docs.nyen.cc/api"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
packages = ["nyen_sdk"]
|
nyen_sdk-0.1.0/setup.cfg
ADDED