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 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.
@@ -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,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=&currency=&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
+ 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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+