hodlodds 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hodlodds/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """HodlOdds: pay-per-answer crash odds for Solana memecoins over x402 (USDC on Solana or Base)."""
2
+ from .client import NETWORKS, Answer, HodlOdds, HodlOddsError, PendingPayment, load_pending, save_pending
3
+
4
+ __all__ = ["HodlOdds", "HodlOddsError", "Answer", "NETWORKS", "PendingPayment", "save_pending", "load_pending"]
5
+ __version__ = "0.2.0"
hodlodds/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .client import main
4
+
5
+ sys.exit(main())
hodlodds/client.py ADDED
@@ -0,0 +1,789 @@
1
+ """HodlOdds Python client: x402 v2 exact USDC on Solana or Base.
2
+
3
+ from hodlodds import HodlOdds
4
+ client = HodlOdds("https://api.hodlodds.com", keypair="~/.config/hodlodds/payer.json",
5
+ network="solana", pay_to="<RECIPIENT FROM /v1/meta>")
6
+ answer = client.risk("<MINT>")
7
+ print(answer.data["row"], answer.payment)
8
+
9
+ Free routes (status, meta, "not covered", "feed interrupted", "too old") never sign
10
+ anything. A payment is signed only after the server's 402 offer passes every check:
11
+ x402 v2, the chosen network, exact USDC, the pinned payTo address and a price
12
+ no higher than your limit. A payment is never retried automatically. The key file
13
+ (Solana JSON or EVM hex/JSON, mode 0600) is read locally and never printed or sent anywhere;
14
+ only the SDK-signed USDC transfer authorization leaves the machine.
15
+ """
16
+ import argparse
17
+ import hashlib
18
+ import json
19
+ import math
20
+ import os
21
+ import re
22
+ import stat
23
+ import sys
24
+ import secrets
25
+ from decimal import Decimal, DecimalException
26
+ from importlib.metadata import version
27
+ from pathlib import Path
28
+ from urllib.parse import quote, urlencode, urlparse
29
+
30
+ SDK_VERSION = "2.25.0"
31
+ NETWORKS = ("devnet", "solana", "mainnet", "base", "base-sepolia")
32
+ ADDRESS = re.compile(r"[1-9A-HJ-NP-Za-km-z]{32,44}")
33
+ USDC_ATOMIC = Decimal(10) ** 6
34
+ HARD_MAX_USDC = Decimal("1")
35
+ BOARD_MAX_USDC = Decimal("2.5")
36
+ BOARD_ATOMIC_PER_COIN = 5000
37
+ BOARD_SORTS = ("safest", "riskiest", "liquidity", "newest", "active")
38
+ BOARD_FILTERS = ("max_risk", "min_risk", "min_liquidity_sol", "min_age_min", "max_age_min",
39
+ "min_trades_5", "min_confidence")
40
+ BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
41
+ MAX_PAYMENT_HEADER = 16384
42
+ MAX_SIGNATURE_HEADER = 65536
43
+ MAX_HEADERS = 131072
44
+ MAX_BODY = 4 * 1024 * 1024
45
+ MAX_KEY_BYTES = 4096
46
+
47
+
48
+ class HodlOddsError(Exception):
49
+ """Client/API error. payment may describe an uncertain submitted authorization."""
50
+ def __init__(self, message, status=None, body=None, payment=None):
51
+ super().__init__(message)
52
+ self.status, self.body, self.payment = status, body, payment
53
+
54
+
55
+ class PendingPayment(dict):
56
+ """Public receipt fields; redeem credentials stay out of repr/JSON/logs."""
57
+ def __init__(self, fields, *, signature, url, base):
58
+ super().__init__(fields)
59
+ self._signature, self._url, self._base = signature, url, base
60
+
61
+
62
+ def save_pending(payment, directory=None):
63
+ if not isinstance(payment, PendingPayment):
64
+ raise HodlOddsError("no pending payment to save")
65
+ directory = Path(directory or '~/.config/hodlodds/pending').expanduser().absolute()
66
+ try:
67
+ # Never traverse a symlink, including one on a parent directory.
68
+ if any(p.is_symlink() for p in (directory, *directory.parents)):
69
+ raise ValueError()
70
+ directory.mkdir(parents=True, mode=0o700, exist_ok=True)
71
+ dfd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
72
+ try:
73
+ if os.fstat(dfd).st_uid != os.geteuid():
74
+ raise ValueError()
75
+ os.fchmod(dfd, 0o700)
76
+ name = secrets.token_hex(16) + '.json'
77
+ fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=dfd)
78
+ with os.fdopen(fd, 'w') as target:
79
+ os.fchmod(target.fileno(), 0o600)
80
+ json.dump(dict(schema='hodlodds.pending.v1', payment=dict(payment),
81
+ signature=payment._signature, url=payment._url, base=payment._base), target)
82
+ target.flush(); os.fsync(target.fileno())
83
+ finally:
84
+ os.close(dfd)
85
+ return directory / name
86
+ except Exception:
87
+ raise HodlOddsError("cannot save pending payment securely") from None
88
+
89
+
90
+ def load_pending(filename):
91
+ try:
92
+ path = Path(filename).expanduser().absolute()
93
+ if any(p.is_symlink() for p in (path, *path.parents)):
94
+ raise ValueError()
95
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
96
+ with os.fdopen(fd) as source:
97
+ info = os.fstat(source.fileno())
98
+ if (not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600
99
+ or info.st_uid != os.geteuid() or info.st_size > 100000):
100
+ raise ValueError()
101
+ value = json.load(source)
102
+ if (value['schema'] != 'hodlodds.pending.v1'
103
+ or not isinstance(value['payment'], dict)
104
+ or value['payment'].get('network') not in NETWORKS
105
+ or not isinstance(value['payment'].get('pay_to'), str)
106
+ or not isinstance(value['url'], str) or not isinstance(value['base'], str)
107
+ or not isinstance(value['signature'], str) or not value['signature']
108
+ or len(value['signature']) > MAX_SIGNATURE_HEADER
109
+ or not value['signature'].isascii() or any(c.isspace() for c in value['signature'])):
110
+ raise ValueError()
111
+ return PendingPayment(value['payment'], signature=value['signature'], url=value['url'], base=value['base'])
112
+ except Exception:
113
+ raise HodlOddsError("cannot load pending payment: use its private 0600 non-symlink file") from None
114
+
115
+
116
+ class Answer:
117
+ def __init__(self, status, data, payment=None):
118
+ self.status, self.data, self.payment = status, data, payment
119
+
120
+ @property
121
+ def paid(self):
122
+ return self.payment is not None
123
+
124
+ def __repr__(self):
125
+ return "Answer(status=%r, paid=%r)" % (self.status, self.paid)
126
+
127
+
128
+ def _network_config(network):
129
+ # never silently fall back to a remembered
130
+ # token address or a different chain. Missing pinned SDK constants is fatal.
131
+ try:
132
+ if version("x402") != SDK_VERSION:
133
+ raise ValueError()
134
+ if network in ("devnet", "solana", "mainnet"):
135
+ from x402.mechanisms.svm import constants as svm
136
+ test = network == "devnet"
137
+ return {"family": "svm", "testnet": test,
138
+ "caip2": svm.SOLANA_DEVNET_CAIP2 if test else svm.SOLANA_MAINNET_CAIP2,
139
+ "usdc": svm.USDC_DEVNET_ADDRESS if test else svm.USDC_MAINNET_ADDRESS,
140
+ "rpc": svm.DEVNET_RPC_URL if test else svm.MAINNET_RPC_URL,
141
+ "explorer": "https://explorer.solana.com/tx/", "explorer_suffix": "?cluster=devnet" if test else ""}
142
+ from x402.mechanisms.evm.v1.constants import V1_NETWORK_CHAIN_IDS
143
+ from x402.mechanisms.evm.default_assets import DEFAULT_ASSETS
144
+ caip2 = "eip155:" + str(V1_NETWORK_CHAIN_IDS[network])
145
+ choices = [a for a in DEFAULT_ASSETS[caip2] if a["symbol"] == "USDC" and a["decimals"] == 6]
146
+ if len(choices) != 1 or choices[0].get("asset_transfer_method", "eip3009") != "eip3009":
147
+ raise ValueError()
148
+ asset = choices[0]; test = network == "base-sepolia"
149
+ return {"family": "evm", "testnet": test, "caip2": caip2, "usdc": asset["asset"],
150
+ "name": asset["name"], "version": asset["version"], "rpc": None,
151
+ "explorer": "https://sepolia.basescan.org/tx/" if test else "https://basescan.org/tx/", "explorer_suffix": ""}
152
+ except Exception:
153
+ raise HodlOddsError("pinned SDK network/USDC constants unavailable; install x402[svm,evm]==" + SDK_VERSION) from None
154
+
155
+
156
+ def _evm_address(value):
157
+ try:
158
+ from eth_utils import is_checksum_address
159
+ return isinstance(value, str) and bool(is_checksum_address(value)) and int(value, 16) != 0
160
+ except Exception:
161
+ return False
162
+
163
+
164
+ def _usdc(value, name, maximum=HARD_MAX_USDC):
165
+ message = name + " must be above 0, at most %s USDC, with at most 6 decimals" % maximum
166
+ try:
167
+ text = str(value)
168
+ if len(text) > 128:
169
+ raise ValueError()
170
+ amount = Decimal(text)
171
+ if not amount.is_finite() or not 0 < amount <= maximum:
172
+ raise ValueError()
173
+ # Decimal multiplication can round away
174
+ # fractional atoms. Work on the exact coefficient/exponent instead.
175
+ _, digits, exponent = amount.as_tuple()
176
+ coefficient = int("".join(map(str, digits)))
177
+ power = exponent + 6
178
+ if power < 0:
179
+ if -power > len(digits) or coefficient % (10 ** -power):
180
+ raise ValueError()
181
+ atomic = coefficient // (10 ** -power)
182
+ else:
183
+ atomic = coefficient * 10 ** power
184
+ if not 0 < atomic <= int(maximum * USDC_ATOMIC):
185
+ raise ValueError()
186
+ return atomic
187
+ except (DecimalException, ValueError, TypeError, OverflowError):
188
+ pass
189
+ raise HodlOddsError(message)
190
+
191
+
192
+ def _board_query(limit, sort, filters):
193
+ # validate before HTTP/key access and send a
194
+ # canonical query; the returned offer must describe this same selection.
195
+ if type(limit) is not int or not 10 <= limit <= 500:
196
+ raise HodlOddsError("limit must be an integer between 10 and 500")
197
+ if sort not in BOARD_SORTS:
198
+ raise HodlOddsError("sort must be one of: " + ", ".join(BOARD_SORTS))
199
+ unknown = set(filters) - set(BOARD_FILTERS)
200
+ if unknown:
201
+ raise HodlOddsError("unknown board filter: " + ", ".join(sorted(unknown)))
202
+ normalized = {}
203
+ for name in BOARD_FILTERS:
204
+ if name not in filters:
205
+ continue
206
+ value = filters[name]
207
+ if name == "min_confidence":
208
+ if value not in ("high", "medium", "low"):
209
+ raise HodlOddsError("min_confidence must be high, medium or low")
210
+ elif name == "min_trades_5":
211
+ if type(value) is not int or value < 0:
212
+ raise HodlOddsError("min_trades_5 must be a nonnegative integer")
213
+ else:
214
+ try:
215
+ if isinstance(value, bool) or value is None:
216
+ raise ValueError()
217
+ value = float(value)
218
+ if not math.isfinite(value) or value < 0 or (name in ("min_risk", "max_risk") and value > 1):
219
+ raise ValueError()
220
+ value = 0.0 if value == 0 else value
221
+ except (ValueError, TypeError, OverflowError):
222
+ raise HodlOddsError(name + " must be a finite number " +
223
+ ("between 0 and 1" if name in ("min_risk", "max_risk") else "at least 0")) from None
224
+ normalized[name] = value
225
+ for lo, hi in (("min_risk", "max_risk"), ("min_age_min", "max_age_min")):
226
+ if lo in normalized and hi in normalized and normalized[lo] > normalized[hi]:
227
+ raise HodlOddsError(lo + " must not exceed " + hi)
228
+ return {"limit": limit, "sort": sort, "filters": normalized}
229
+
230
+
231
+ def _base58_size(value):
232
+ if not isinstance(value, str) or not re.fullmatch(r"[1-9A-HJ-NP-Za-km-z]{1,88}", value):
233
+ return 0
234
+ number = 0
235
+ for letter in value:
236
+ number = number * 58 + BASE58.index(letter)
237
+ return len(value) - len(value.lstrip("1")) + (number.bit_length() + 7) // 8
238
+
239
+
240
+ def _address(value):
241
+ return isinstance(value, str) and bool(ADDRESS.fullmatch(value)) and _base58_size(value) == 32
242
+
243
+
244
+ def _base_url(value, *, rpc=False):
245
+ try:
246
+ if not isinstance(value, str) or len(value) > 4096 or any(c.isspace() or ord(c) < 32 for c in value) or "\\" in value:
247
+ raise ValueError()
248
+ parsed = urlparse(value)
249
+ if not parsed.hostname or parsed.username or parsed.password or parsed.fragment or (parsed.query and not rpc):
250
+ raise ValueError()
251
+ if parsed.port is not None and not 0 < parsed.port <= 65535:
252
+ raise ValueError()
253
+ loopback = parsed.hostname in ("127.0.0.1", "localhost", "::1")
254
+ if not (parsed.scheme == "https" or (parsed.scheme == "http" and loopback)):
255
+ raise ValueError()
256
+ except (ValueError, TypeError):
257
+ raise HodlOddsError("use a valid HTTPS endpoint with no credentials/fragment (HTTP only on loopback; base URL has no query)") from None
258
+ return value if rpc else value.rstrip("/")
259
+
260
+
261
+ def _read_key_file(path):
262
+ fd = None
263
+ try:
264
+ p = Path(path).expanduser()
265
+ before = p.lstat()
266
+ if not stat.S_ISREG(before.st_mode):
267
+ raise ValueError()
268
+ fd = os.open(p, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
269
+ current = os.fstat(fd)
270
+ if ((current.st_dev, current.st_ino) != (before.st_dev, before.st_ino)
271
+ or not stat.S_ISREG(current.st_mode) or stat.S_IMODE(current.st_mode) != 0o600
272
+ or current.st_size > MAX_KEY_BYTES):
273
+ raise ValueError()
274
+ f = os.fdopen(fd, "rb"); fd = None
275
+ with f:
276
+ raw = f.read(MAX_KEY_BYTES + 1)
277
+ if len(raw) > MAX_KEY_BYTES:
278
+ raise ValueError()
279
+ return raw
280
+ except Exception:
281
+ pass
282
+ finally:
283
+ if fd is not None:
284
+ try:
285
+ os.close(fd)
286
+ except OSError:
287
+ pass
288
+ raise HodlOddsError("cannot load key: use a regular non-symlink file, mode 0600, at most 4096 bytes")
289
+
290
+
291
+ def _load_keypair(path):
292
+ try:
293
+ data = json.loads(_read_key_file(path))
294
+ if not (isinstance(data, list) and len(data) == 64 and all(type(v) is int and 0 <= v <= 255 for v in data)):
295
+ raise ValueError()
296
+ from solders.keypair import Keypair
297
+ return Keypair.from_bytes(bytes(data))
298
+ except Exception:
299
+ # No parser/SDK error, filename, JSON doc or key bytes escapes this seam.
300
+ pass
301
+ raise HodlOddsError("cannot load key: use a regular non-symlink solana-keygen file, mode 0600, and installed SDK dependencies")
302
+
303
+
304
+ def _load_evm_account(path):
305
+ try:
306
+ raw = _read_key_file(path).decode("ascii").strip()
307
+ # Accept a hex file, JSON hex string, {"private_key": "0x..."}, or a
308
+ # JSON array of exactly 32 bytes. No mnemonics or encrypted-keystore guess.
309
+ if raw.startswith(("[", "{", '"')):
310
+ raw = json.loads(raw)
311
+ if isinstance(raw, dict):
312
+ if set(raw) != {"private_key"}:
313
+ raise ValueError()
314
+ raw = raw["private_key"]
315
+ if isinstance(raw, list):
316
+ if len(raw) != 32 or any(type(v) is not int or not 0 <= v <= 255 for v in raw):
317
+ raise ValueError()
318
+ key = bytes(raw)
319
+ elif isinstance(raw, str) and re.fullmatch(r"(?:0x)?[0-9a-fA-F]{64}", raw):
320
+ key = bytes.fromhex(raw[2:] if raw.startswith("0x") else raw)
321
+ else:
322
+ raise ValueError()
323
+ from eth_account import Account
324
+ return Account.from_key(key)
325
+ except Exception:
326
+ pass
327
+ raise HodlOddsError("cannot load EVM key: use a regular non-symlink 0600 hex/JSON private-key file and installed x402[evm] dependencies")
328
+
329
+
330
+ def _new_http(timeout, headers):
331
+ import httpx
332
+ return httpx.Client(timeout=timeout, follow_redirects=False, trust_env=False, headers=headers)
333
+
334
+
335
+ class _Reply:
336
+ def __init__(self, status, headers, body):
337
+ self.status_code, self.headers, self.content = status, headers, body
338
+
339
+ def json(self):
340
+ return json.loads(self.content)
341
+
342
+ @property
343
+ def text(self):
344
+ return self.content.decode("utf-8", errors="replace")
345
+
346
+
347
+ class HodlOdds:
348
+ def __init__(self, base_url, *, keypair=None, network="devnet", pay_to=None, max_price_usdc="0.01",
349
+ rpc_url=None, timeout=30.0, access_headers=None, paid_timeout=90.0):
350
+ if network not in NETWORKS:
351
+ raise HodlOddsError("network must be solana, devnet, base or base-sepolia (mainnet is a Solana alias)")
352
+ if network in ("mainnet", "solana", "base") and pay_to is None:
353
+ raise HodlOddsError("mainnet requires an explicit pinned pay_to address")
354
+ self.base = _base_url(base_url)
355
+ self.network, self.net = network, _network_config(network)
356
+ self._address_check = _evm_address if self.net["family"] == "evm" else _address
357
+ if pay_to is not None and not self._address_check(pay_to):
358
+ raise HodlOddsError("pay_to must be a nonzero EIP-55 checksum address" if self.net["family"] == "evm" else "pay_to must be a Solana address")
359
+ self.pay_to, self.max_atomic = pay_to, _usdc(max_price_usdc, "max_price_usdc")
360
+ # The paid request waits for verification and on-chain settlement; in rare
361
+ # facilitator trouble the server needs up to ~70 s (retry + on-chain
362
+ # confirmation). Leaving earlier means paying without receiving the answer.
363
+ if type(paid_timeout) not in (int, float) or not 30 <= paid_timeout <= 600:
364
+ raise HodlOddsError("paid_timeout must be between 30 and 600 seconds")
365
+ self.paid_timeout = float(paid_timeout)
366
+ rpc = rpc_url or self.net["rpc"]
367
+ self.rpc_url, self._keypair_path = _base_url(rpc, rpc=True) if rpc else None, keypair
368
+ self._sdk, self._codecs = None, None
369
+ headers = dict(access_headers or {})
370
+ for k, v in headers.items():
371
+ if (not isinstance(k, str) or not isinstance(v, str) or len(k) + len(v) > MAX_PAYMENT_HEADER
372
+ or any(c in k + v for c in "\r\n") or k.lower().startswith(("payment-", "x-payment"))
373
+ or k.lower() in ("host", "content-length", "transfer-encoding")):
374
+ raise HodlOddsError("access_headers contains an invalid or reserved payment/transport header")
375
+ headers["User-Agent"] = "hodlodds-python/0.2"
376
+ try:
377
+ self.http = _new_http(timeout, headers)
378
+ except Exception:
379
+ raise HodlOddsError("cannot initialize HTTP client; install httpx and check configuration") from None
380
+
381
+ # Free routes
382
+ def status(self):
383
+ return self._get_json("/v1/status")
384
+
385
+ def meta(self):
386
+ return self._get_json("/v1/meta")
387
+
388
+ # Paid routes (free when the API cannot sell a fresh, covered answer)
389
+ def risk(self, mint, max_price_usdc=None):
390
+ return self._paid("/v1/risk/" + self._mint(mint), max_price_usdc)
391
+
392
+ def last(self, mint, max_price_usdc=None):
393
+ """Last-known odds (<= 30 min old, labelled stale, confidence downgraded); 0.002 USDC."""
394
+ return self._paid("/v1/risk/" + self._mint(mint) + "/last", max_price_usdc)
395
+
396
+ def board(self, limit=50, sort="safest", max_price_usdc=None, **filters):
397
+ """Current covered SOL ranking; 0.005 USDC per delivered coin, at most 500.
398
+
399
+ Pass a per-call maximum covering the dynamic total (e.g. 0.5 for 100
400
+ coins). An empty match set is free. No pagination or automatic retries.
401
+ """
402
+ query = _board_query(limit, sort, filters)
403
+ params = {"limit": query["limit"], "sort": query["sort"], **query["filters"]}
404
+ return self._paid("/v1/board?" + urlencode(params), max_price_usdc, board_query=query)
405
+
406
+ def close(self):
407
+ try:
408
+ self.http.close()
409
+ except Exception:
410
+ raise HodlOddsError("HTTP cleanup failed; reconcile any previous paid result before repeating it") from None
411
+
412
+ def __enter__(self):
413
+ return self
414
+
415
+ def __exit__(self, *exc):
416
+ try:
417
+ self.close()
418
+ except HodlOddsError:
419
+ if exc[0] is None:
420
+ raise
421
+ # Preserve an existing payment/reconciliation error during cleanup.
422
+
423
+ # Internals
424
+ @staticmethod
425
+ def _mint(mint):
426
+ if not _address(mint):
427
+ raise HodlOddsError("mint must be a Solana address")
428
+ return quote(mint, safe="")
429
+
430
+ def _get_json(self, path):
431
+ r = self._request(self.base + path)
432
+ if r.status_code != 200:
433
+ raise HodlOddsError("HTTP %d on %s" % (r.status_code, path), r.status_code, _body(r))
434
+ return _json_object(r)
435
+
436
+ def _request(self, url, headers=None, timeout=None):
437
+ # bounded streaming also limits malicious
438
+ # decoded gzip bodies. No redirects, environment proxy or automatic retry.
439
+ try:
440
+ with self.http.stream("GET", url, headers=headers or {}, **({"timeout": timeout} if timeout else {})) as r:
441
+ items = list(r.headers.multi_items())
442
+ if sum(len(k.encode()) + len(v.encode()) for k, v in items) > MAX_HEADERS:
443
+ raise HodlOddsError("response headers exceed the client size limit", r.status_code)
444
+ normalized = {}
445
+ for k, v in items:
446
+ key = k.lower()
447
+ if key.startswith(("payment-", "x-payment")):
448
+ if key in normalized or len(v) > MAX_PAYMENT_HEADER or not v.isascii() or "\n" in v or "\r" in v:
449
+ raise HodlOddsError("duplicate, invalid or oversized payment header", r.status_code)
450
+ normalized[key] = v
451
+ if 300 <= r.status_code < 400:
452
+ raise HodlOddsError("redirect refused; use the final endpoint", r.status_code)
453
+ body = bytearray()
454
+ for chunk in r.iter_bytes():
455
+ if len(body) + len(chunk) > MAX_BODY:
456
+ raise HodlOddsError("response body exceeds the client size limit", r.status_code)
457
+ body.extend(chunk)
458
+ return _Reply(r.status_code, normalized, bytes(body))
459
+ except HodlOddsError:
460
+ raise
461
+ except Exception:
462
+ raise HodlOddsError("HTTP request failed; request details are omitted") from None
463
+
464
+ def _protocol(self):
465
+ if self._codecs is None:
466
+ try:
467
+ if version("x402") != SDK_VERSION:
468
+ raise ValueError()
469
+ from x402.http import (decode_payment_required_header, decode_payment_response_header,
470
+ encode_payment_signature_header)
471
+ self._codecs = {"decode": decode_payment_required_header, "settled": decode_payment_response_header,
472
+ "encode": encode_payment_signature_header}
473
+ except Exception:
474
+ raise HodlOddsError("install the pinned x402[svm,evm]==%s SDK" % SDK_VERSION) from None
475
+ return self._codecs
476
+
477
+ def _sdk_client(self):
478
+ if self._sdk is None:
479
+ if self._keypair_path is None:
480
+ raise HodlOddsError("a keypair file is required for paid answers")
481
+ self._protocol()
482
+ try:
483
+ from x402 import x402ClientSync
484
+ if _network_config(self.network) != self.net:
485
+ raise HodlOddsError("pinned SDK network constants changed")
486
+ if self.net["family"] == "svm":
487
+ from x402.mechanisms.svm.exact.client import ExactSvmScheme
488
+ from x402.mechanisms.svm.signers import KeypairSigner
489
+ signer = KeypairSigner(_load_keypair(self._keypair_path))
490
+ scheme = ExactSvmScheme(signer, rpc_url=self.rpc_url)
491
+ else:
492
+ from x402.mechanisms.evm.exact.client import ExactEvmScheme
493
+ from x402.mechanisms.evm.signers import EthAccountSigner
494
+ signer = EthAccountSigner(_load_evm_account(self._keypair_path))
495
+ scheme = ExactEvmScheme(signer)
496
+ client = x402ClientSync().register(self.net["caip2"], scheme)
497
+ self._sdk = dict(self._codecs, payer=signer.address, client=client)
498
+ except HodlOddsError:
499
+ raise
500
+ except Exception:
501
+ raise HodlOddsError("cannot initialize payment SDK; no authorization was sent") from None
502
+ return self._sdk
503
+
504
+ def _same_address(self, left, right):
505
+ return (isinstance(left, str) and isinstance(right, str) and
506
+ (left.lower() == right.lower() if self.net["family"] == "evm" else left == right))
507
+
508
+ def _select_offer(self, data):
509
+ if not isinstance(data, dict) or type(data.get("x402Version")) is not int or data.get("x402Version") != 2:
510
+ raise HodlOddsError("only x402 v2 offers are accepted")
511
+ accepts = data.get("accepts")
512
+ if not isinstance(accepts, list) or not 1 <= len(accepts) <= 16:
513
+ raise HodlOddsError("invalid payment options")
514
+ matches = [(i, row) for i, row in enumerate(accepts) if isinstance(row, dict)
515
+ and row.get("network") == self.net["caip2"] and self.pay_to is not None
516
+ and self._same_address(row.get("payTo"), self.pay_to)]
517
+ if len(matches) != 1:
518
+ raise HodlOddsError("expected one payment option matching the configured network and pinned pay_to")
519
+ return matches[0]
520
+
521
+ def _check_offer(self, data, limit, board_query=None):
522
+ _, row = self._select_offer(data)
523
+ if row.get("scheme") != "exact" or not self._same_address(row.get("asset"), self.net["usdc"]):
524
+ raise HodlOddsError("only exact USDC payments are accepted")
525
+ if not self._address_check(row.get("payTo")) or not self._same_address(row.get("payTo"), self.pay_to):
526
+ raise HodlOddsError("server payTo differs from the pinned pay_to address")
527
+ amount = row.get("amount")
528
+ if not (isinstance(amount, str) and re.fullmatch(r"[1-9][0-9]{0,6}", amount) and int(amount) <= limit):
529
+ raise HodlOddsError("price is invalid or above your USDC limit")
530
+ extra = row.get("extra")
531
+ if not isinstance(extra, dict) or extra.get("paymentFlow") != "authorization":
532
+ raise HodlOddsError("only authorization payment flow is accepted")
533
+ timeout = row.get("maxTimeoutSeconds")
534
+ if type(timeout) is not int or not 0 < timeout <= 300:
535
+ raise HodlOddsError("payment timeout must be between 1 and 300 seconds")
536
+ if self.net["family"] == "svm":
537
+ if not _address(extra.get("feePayer")):
538
+ raise HodlOddsError("a valid facilitator feePayer is required")
539
+ if "memo" in extra and (not isinstance(extra["memo"], str) or len(extra["memo"].encode()) > 32):
540
+ raise HodlOddsError("invalid or oversized payment memo (maximum 32 bytes)")
541
+ if "recentBlockhash" in extra and not _address(extra["recentBlockhash"]):
542
+ raise HodlOddsError("invalid payment blockhash")
543
+ else:
544
+ if extra.get("assetTransferMethod", "eip3009") != "eip3009":
545
+ raise HodlOddsError("only EIP-3009 exact USDC authorizations are accepted; Permit2 is disabled")
546
+ if extra.get("name") != self.net["name"] or extra.get("version") != self.net["version"]:
547
+ raise HodlOddsError("USDC signing domain differs from the pinned SDK")
548
+ if any(name in extra for name in ("memo", "feePayer", "recentBlockhash")):
549
+ raise HodlOddsError("Solana transaction fields are not accepted in a Base offer")
550
+ if board_query is not None:
551
+ board = extra.get("hodloddsBoard")
552
+ if (not isinstance(board, dict) or type(board.get("version")) is not int or board["version"] != 1
553
+ or set(board) != {"version", "query", "priced_n", "nonce"}
554
+ or json.dumps(board.get("query"), sort_keys=True, allow_nan=False)
555
+ != json.dumps(board_query, sort_keys=True, allow_nan=False)
556
+ or type(board.get("priced_n")) is not int
557
+ or not 1 <= board["priced_n"] <= board_query["limit"]
558
+ or not isinstance(board.get("nonce"), str) or not re.fullmatch("[0-9a-f]{32}", board["nonce"])
559
+ or int(amount) != board["priced_n"] * BOARD_ATOMIC_PER_COIN):
560
+ raise HodlOddsError("board offer does not match the requested query and per-coin price")
561
+ binding_signature = extra.get("hodloddsBoardSignature")
562
+ if not isinstance(binding_signature, str) or not re.fullmatch("[0-9a-f]{64}", binding_signature):
563
+ raise HodlOddsError("board offer is missing its server binding signature")
564
+ # 32 hex chars: the SPL Memo instruction must fit the SDK's 20,000 CU limit.
565
+ expected_memo = hashlib.sha256(b"hodlodds.board.v1\0" + json.dumps(
566
+ board, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest()[:32]
567
+ if self.net["family"] == "svm" and extra.get("memo") != expected_memo:
568
+ raise HodlOddsError("board payment memo does not bind the requested query and count")
569
+ return int(amount)
570
+
571
+ def redeem(self, payment):
572
+ """One explicit resend of the original authorization, without signing."""
573
+ if (not isinstance(payment, PendingPayment) or payment._base != self.base
574
+ or payment.get('network') != self.network or payment.get('pay_to') != self.pay_to
575
+ or not isinstance(payment._url, str) or not payment._url.startswith(self.base + '/v1/')
576
+ or not isinstance(payment._signature, str) or not payment._signature
577
+ or len(payment._signature) > MAX_SIGNATURE_HEADER
578
+ or not payment._signature.isascii() or any(c.isspace() for c in payment._signature)):
579
+ raise HodlOddsError("redeem requires the original payment, endpoint, network and recipient")
580
+ parsed = urlparse(payment._url)
581
+ if (parsed.fragment or parsed.username or parsed.password or '\\' in payment._url
582
+ or not (parsed.path == urlparse(self.base).path + '/v1/board'
583
+ or parsed.path.startswith(urlparse(self.base).path + '/v1/risk/'))):
584
+ raise HodlOddsError("invalid redeem route")
585
+ response = None
586
+ try:
587
+ response = self._request(payment._url, headers={'PAYMENT-SIGNATURE': payment._signature, 'HodlOdds-Redeem': '1'})
588
+ tx = response.headers.get('hodlodds-redeemed')
589
+ if response.status_code != 200 or not tx:
590
+ raise HodlOddsError("redeem returned HTTP %d; no new payment was signed; keep the pending payment" % response.status_code)
591
+ if not (_base58_size(tx) == 64 if self.net['family'] == 'svm' else
592
+ bool(re.fullmatch('0x[0-9a-fA-F]{64}', tx))):
593
+ raise HodlOddsError("invalid redeem receipt")
594
+ receipt = dict(payment, success=True, transaction=tx,
595
+ explorer=self.net['explorer'] + tx + self.net['explorer_suffix'], redeemed=True)
596
+ return Answer(200, _json_object(response), receipt)
597
+ except Exception as exc:
598
+ reason = str(exc) if isinstance(exc, HodlOddsError) else 'redeem response unavailable; not retried'
599
+ raise HodlOddsError(reason, response.status_code if response else None, payment=payment) from None
600
+
601
+ def _paid(self, path, max_price_usdc, board_query=None):
602
+ maximum = BOARD_MAX_USDC if board_query is not None else HARD_MAX_USDC
603
+ limit = self.max_atomic if max_price_usdc is None else _usdc(max_price_usdc, "max_price_usdc", maximum)
604
+ url = self.base + path
605
+ first = self._request(url)
606
+ if first.status_code == 200:
607
+ return Answer(200, _json_object(first))
608
+ if first.status_code != 402:
609
+ # Free refusals: not covered (404), feed interrupted (503), too old (410), ...
610
+ raise HodlOddsError("free answer HTTP %d (nothing paid)" % first.status_code, first.status_code, _body(first))
611
+ header = first.headers.get("payment-required")
612
+ if not header:
613
+ raise HodlOddsError("402 without a valid PAYMENT-REQUIRED header; nothing paid", 402)
614
+ protocol = self._protocol()
615
+ try:
616
+ parsed = protocol["decode"](header)
617
+ offer = parsed.model_dump(by_alias=True, exclude_none=True)
618
+ amount = self._check_offer(offer, limit, board_query)
619
+ index, selected = self._select_offer(offer)
620
+ # never let the SDK's default selector
621
+ # choose an unchecked alternative, including another same-chain offer.
622
+ parsed = parsed.model_copy(update={"accepts": [parsed.accepts[index]]}, deep=True)
623
+ except HodlOddsError:
624
+ raise
625
+ except Exception:
626
+ raise HodlOddsError("invalid PAYMENT-REQUIRED offer; nothing signed", 402) from None
627
+ # Read the key only after all checks that do not need the payer address.
628
+ sdk = self._sdk_client()
629
+ if not self._address_check(sdk["payer"]) or self._same_address(sdk["payer"], self.pay_to):
630
+ raise HodlOddsError("payer and payTo must be different wallets")
631
+ if self.net["family"] == "svm" and selected["extra"]["feePayer"] == sdk["payer"]:
632
+ raise HodlOddsError("payer must differ from the facilitator fee payer")
633
+ try:
634
+ # SDK 2.25.0 itself defaults to $1.
635
+ # Permit only this already-validated total; do not disable controls
636
+ # or leave a board's larger allowance on the cached SDK instance.
637
+ sdk["client"].set_spend_controls({"max_amount_per_payment": "$" + _fmt(str(amount))})
638
+ try:
639
+ payload = sdk["client"].create_payment_payload(parsed)
640
+ finally:
641
+ sdk["client"].set_spend_controls({"max_amount_per_payment": "$1"})
642
+ signature = protocol["encode"](payload)
643
+ if (not isinstance(signature, str) or not signature or len(signature) > MAX_SIGNATURE_HEADER
644
+ or not signature.isascii() or any(c.isspace() for c in signature)):
645
+ raise ValueError()
646
+ except Exception:
647
+ raise HodlOddsError("authorization construction failed; nothing sent and not retried") from None
648
+ payment = {"amount_usdc": _fmt(str(amount)), "network": self.network, "success": None,
649
+ "transaction": None, "payer": sdk["payer"], "pay_to": self.pay_to, "explorer": None}
650
+ payment = PendingPayment(payment, signature=signature, url=url, base=self.base)
651
+ paid = None
652
+ try:
653
+ # Exactly one authorized HTTP attempt. A lost/error response does
654
+ # not establish absence of settlement, even on normally free errors.
655
+ paid = self._request(url, headers={"PAYMENT-SIGNATURE": signature}, timeout=self.paid_timeout)
656
+ receipt = paid.headers.get("payment-response")
657
+ if receipt:
658
+ info = protocol["settled"](receipt).model_dump(by_alias=True, exclude_none=True)
659
+ tx = info.get("transaction")
660
+ if (_base58_size(tx) == 64 if self.net["family"] == "svm" else
661
+ isinstance(tx, str) and bool(re.fullmatch("0x[0-9a-fA-F]{64}", tx))):
662
+ payment["transaction"] = tx
663
+ payment["explorer"] = self.net["explorer"] + tx + self.net["explorer_suffix"]
664
+ # Only a 402 carrying a reason is the facilitator declaring
665
+ # failure; any other status leaves the outcome unknown.
666
+ if info.get("success") is False and not tx and paid.status_code == 402 and info.get("errorReason"):
667
+ reason = info.get('errorReason')
668
+ reason = reason if isinstance(reason, str) and re.fullmatch('[a-zA-Z0-9_]{1,100}', reason) else 'unknown'
669
+ payment['success'] = False
670
+ raise HodlOddsError("the facilitator reported settlement failed: %s; usually nothing was charged — if your wallet shows the payment, redeem after ~2 minutes" % reason)
671
+ if (info.get("success") is not True or info.get("network") != self.net["caip2"]
672
+ or payment["transaction"] is None
673
+ or ("payer" in info and not self._same_address(info["payer"], sdk["payer"]))
674
+ or ("amount" in info and info["amount"] != str(amount))):
675
+ raise HodlOddsError("invalid or unsuccessful PAYMENT-RESPONSE")
676
+ payment["success"] = True # Server receipt, not independent RPC finality proof.
677
+ if paid.status_code != 200:
678
+ raise HodlOddsError("authorized request returned HTTP %d" % paid.status_code)
679
+ if not receipt:
680
+ raise HodlOddsError("200 without PAYMENT-RESPONSE")
681
+ return Answer(200, _json_object(paid), dict(payment))
682
+ except Exception as exc:
683
+ reason = str(exc) if isinstance(exc, HodlOddsError) else "invalid payment response"
684
+ if payment["success"] is False:
685
+ # Facilitator-declared failure: say so plainly, keep the reconcile facts.
686
+ raise HodlOddsError(reason + ". Not retried. Check %(amount_usdc)s USDC on %(network)s from %(payer)s "
687
+ "to %(pay_to)s in your wallet history." % payment, paid.status_code if paid else None,
688
+ _body(paid) if paid else None, payment=payment) from None
689
+ message = (reason + ". Settlement may have occurred; not retried. Reconcile %(amount_usdc)s USDC "
690
+ "on %(network)s from %(payer)s to %(pay_to)s against finalized wallet history and "
691
+ "the server payment records before another paid call. Transaction: %(transaction)s." % payment)
692
+ raise HodlOddsError(message, paid.status_code if paid else getattr(exc, "status", None),
693
+ _body(paid) if paid else None, payment=payment) from None
694
+
695
+
696
+ def _fmt(atomic):
697
+ try:
698
+ return format((Decimal(int(atomic)) / USDC_ATOMIC).normalize(), "f")
699
+ except (TypeError, ValueError, DecimalException):
700
+ return "?"
701
+
702
+
703
+ def _body(response):
704
+ try:
705
+ return response.json()
706
+ except Exception:
707
+ return {"text": response.text[:500]}
708
+
709
+
710
+ def _json_object(response):
711
+ try:
712
+ data = response.json()
713
+ if not isinstance(data, dict):
714
+ raise ValueError()
715
+ return data
716
+ except Exception:
717
+ raise HodlOddsError("response is not a JSON object", response.status_code) from None
718
+
719
+
720
+ def main(argv=None):
721
+ p = argparse.ArgumentParser(description="HodlOdds x402 client")
722
+ p.add_argument("command", choices=["status", "meta", "risk", "last", "board", "redeem"])
723
+ p.add_argument("mint", nargs="?")
724
+ p.add_argument("--base-url", default=os.environ.get("HODLODDS_BASE_URL", "https://api.hodlodds.com"))
725
+ p.add_argument("--network", default=os.environ.get("HODLODDS_NETWORK", "devnet"), choices=sorted(NETWORKS))
726
+ p.add_argument("--keypair", default=os.environ.get("HODLODDS_KEYPAIR"))
727
+ p.add_argument("--pay-to", default=os.environ.get("HODLODDS_PAY_TO"))
728
+ p.add_argument("--max-price", default=os.environ.get("HODLODDS_MAX_PRICE", "0.01"),
729
+ help="USDC spending limit (default 0.01; hard cap 2.5 for board, 1 otherwise)")
730
+ p.add_argument("--rpc-url")
731
+ p.add_argument("--limit", type=int, default=50, help="board coins requested, 10..500 (default 50)")
732
+ p.add_argument("--sort", default="safest", choices=BOARD_SORTS)
733
+ p.add_argument("--max-risk")
734
+ p.add_argument("--min-risk")
735
+ p.add_argument("--min-liquidity", dest="min_liquidity_sol")
736
+ p.add_argument("--min-age", dest="min_age_min")
737
+ p.add_argument("--max-age", dest="max_age_min")
738
+ p.add_argument("--min-trades", dest="min_trades_5", type=int)
739
+ p.add_argument("--min-confidence", choices=("high", "medium", "low"))
740
+ args = list(sys.argv[1:] if argv is None else argv)
741
+ a = p.parse_args(args)
742
+ if a.command in ("risk", "last") and not a.mint:
743
+ p.error(a.command + " needs a mint address")
744
+ if a.command == "redeem" and not a.mint:
745
+ p.error("redeem needs a pending payment file")
746
+ if a.command not in ("risk", "last", "redeem") and a.mint:
747
+ p.error(a.command + " does not take a mint")
748
+ explicit = lambda flag: any(v == flag or v.startswith(flag + "=") for v in args)
749
+ board_flags = ("--limit", "--sort", "--max-risk", "--min-risk", "--min-liquidity", "--min-age",
750
+ "--max-age", "--min-trades", "--min-confidence")
751
+ if a.command != "board" and any(explicit(flag) for flag in board_flags):
752
+ p.error("board filters and sorting can be used only with board")
753
+ if a.command != "redeem" and a.network in ("mainnet", "solana", "base") and not (explicit("--network") and explicit("--pay-to")):
754
+ p.error("mainnet requires explicit --network and --pay-to; environment defaults are not enough")
755
+ try:
756
+ pending = load_pending(a.mint) if a.command == 'redeem' else None
757
+ if pending is not None:
758
+ a.base_url, a.network, a.pay_to = pending._base, pending['network'], pending['pay_to']
759
+ with HodlOdds(a.base_url, keypair=a.keypair, network=a.network, pay_to=a.pay_to,
760
+ max_price_usdc="0.01" if a.command == "board" else a.max_price, rpc_url=a.rpc_url) as client:
761
+ if a.command in ("status", "meta"):
762
+ print(json.dumps(getattr(client, a.command)(), indent=2))
763
+ return 0
764
+ if a.command == "redeem":
765
+ answer = client.redeem(pending)
766
+ elif a.command == "board":
767
+ filters = {name: getattr(a, name) for name in BOARD_FILTERS if getattr(a, name) is not None}
768
+ answer = client.board(limit=a.limit, sort=a.sort, max_price_usdc=a.max_price, **filters)
769
+ else:
770
+ answer = getattr(client, a.command)(a.mint)
771
+ print(json.dumps(answer.data, indent=2))
772
+ if answer.payment:
773
+ verb = "Redeemed (no new payment; paid earlier)" if answer.payment.get("redeemed") else "Paid"
774
+ print("\n" + verb + " %(amount_usdc)s USDC on %(network)s from %(payer)s\nTransaction: %(explorer)s" % answer.payment)
775
+ return 0
776
+ except HodlOddsError as e:
777
+ print("HodlOdds: " + str(e), file=sys.stderr)
778
+ if isinstance(e.payment, PendingPayment) and a.command != 'redeem':
779
+ try:
780
+ print("Pending payment saved: " + str(save_pending(e.payment)), file=sys.stderr)
781
+ except HodlOddsError as save_error:
782
+ print(str(save_error), file=sys.stderr)
783
+ if e.body is not None and e.payment is None:
784
+ print(json.dumps(e.body, indent=2), file=sys.stderr)
785
+ return 1
786
+
787
+
788
+ if __name__ == "__main__":
789
+ sys.exit(main())
hodlodds/wallet.py ADDED
@@ -0,0 +1,103 @@
1
+ """Create a throwaway Solana devnet or Base Sepolia wallet, mode 0600.
2
+
3
+ hodlodds-wallet new ~/.config/hodlodds/my-devnet.json
4
+ hodlodds-wallet new --network base-sepolia ~/.config/hodlodds/base-sepolia.json
5
+
6
+ Prints only the public address. Fund it with devnet USDC at https://faucet.circle.com
7
+ (network: Solana Devnet or Base Sepolia). Never put real funds in a test wallet.
8
+ """
9
+ import argparse
10
+ import json
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+
16
+ class WalletError(Exception):
17
+ """A key-generation failure with no SDK exception or secret material attached."""
18
+
19
+
20
+ def _keypair():
21
+ # lazy import keeps offline safety tests local.
22
+ from solders.keypair import Keypair
23
+ return Keypair()
24
+
25
+
26
+ def _evm_account():
27
+ from eth_account import Account
28
+ return Account.create()
29
+
30
+
31
+ def new(path, network="devnet"):
32
+ fd = None
33
+ identity = None
34
+ p = None
35
+ try:
36
+ if network not in ("devnet", "base-sepolia"):
37
+ raise ValueError("test wallet network required")
38
+ p = Path(path).expanduser()
39
+ p.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
40
+ # O_EXCL, including for dangling symlinks, is the overwrite guard.
41
+ fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
42
+ st = os.fstat(fd)
43
+ identity = (st.st_dev, st.st_ino)
44
+ # enforce exactly 0600 even under a strict
45
+ # umask; never print a public address until its private file is durable.
46
+ os.fchmod(fd, 0o600)
47
+ if network == "devnet":
48
+ kp = _keypair(); secret = bytes(kp); address = str(kp.pubkey())
49
+ if len(secret) != 64:
50
+ raise ValueError("invalid keypair")
51
+ stored = list(secret)
52
+ else:
53
+ account = _evm_account(); secret = bytes(account.key); address = str(account.address)
54
+ if len(secret) != 32:
55
+ raise ValueError("invalid EVM key")
56
+ stored = {"private_key": "0x" + secret.hex()}
57
+ f = os.fdopen(fd, "w")
58
+ fd = None
59
+ with f:
60
+ json.dump(stored, f)
61
+ f.flush()
62
+ os.fsync(f.fileno())
63
+ except Exception as exc:
64
+ if fd is not None:
65
+ try:
66
+ os.close(fd)
67
+ except OSError:
68
+ pass
69
+ # Only unlink the file we created, never a replaced path or old wallet.
70
+ if identity is not None:
71
+ try:
72
+ st = p.lstat()
73
+ if (st.st_dev, st.st_ino) == identity:
74
+ p.unlink()
75
+ except OSError:
76
+ pass
77
+ message = "refusing to overwrite an existing wallet path" if isinstance(exc, FileExistsError) else (
78
+ "could not create the wallet; check the destination and installed x402 dependencies")
79
+ # SDK/JSON exceptions are not safe to expose: they may contain key bytes.
80
+ raise WalletError(message) from None
81
+ print(address)
82
+
83
+
84
+ def main(argv=None):
85
+ args = sys.argv[1:] if argv is None else argv
86
+ p = argparse.ArgumentParser(description="Create a local test wallet; prints only its public address")
87
+ p.add_argument("command", choices=("new",))
88
+ p.add_argument("path")
89
+ p.add_argument("--network", choices=("devnet", "base-sepolia"), default="devnet")
90
+ try:
91
+ a = p.parse_args(args)
92
+ except SystemExit as exc:
93
+ return exc.code
94
+ try:
95
+ new(a.path, a.network)
96
+ except WalletError as exc:
97
+ print(str(exc), file=sys.stderr)
98
+ return 1
99
+ return 0
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(main())
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: hodlodds
3
+ Version: 0.2.0
4
+ Summary: Python client for HodlOdds: pay-per-answer crash odds for Solana memecoins over x402 (USDC on Solana or Base).
5
+ License: MIT
6
+ Project-URL: Homepage, https://hodlodds.com
7
+ Project-URL: Source, https://github.com/hodlodds/hodlodds-sdk
8
+ Keywords: x402,solana,base,usdc,memecoin,risk,pump.fun
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: httpx<1,>=0.27
16
+ Requires-Dist: x402[evm,svm]==2.25.0
17
+ Dynamic: license-file
18
+
19
+ # hodlodds — Python client
20
+
21
+ Pay-per-answer **crash odds for Solana memecoins**, paid per request with
22
+ [x402](https://www.x402.org) (USDC on Solana or Base). No accounts, no API keys.
23
+
24
+ > **Status: pre-launch.** The API at `https://api.hodlodds.com` is not yet open for
25
+ > payments. Its model is being evaluated against a pre-registered forward test.
26
+ > A limited paid beta opens only if it passes. Until then this client is published
27
+ > so you can read the code and test the payment flow on **Solana devnet** or **Base Sepolia**.
28
+
29
+ ## What it answers
30
+
31
+ > What are the odds that this token's price falls 90% or more within the next 60 minutes?
32
+
33
+ A crash is a completed one-minute close, priced in SOL, at least 90% below the
34
+ reference price within the next 60 minutes. Coverage: SOL-quoted pump.fun
35
+ graduates. Every answer carries a confidence level (high / medium / low) based on
36
+ how well past predictions in that probability range matched reality.
37
+
38
+ | Route | Price (planned) | Notes |
39
+ |---|---|---|
40
+ | `GET /v1/status` | free | Feed state (`live` / `stale` / `down`), age, incidents, uptime |
41
+ | `GET /v1/meta` | free | Definition, prices, payment details, model card |
42
+ | `GET /v1/risk/{mint}` | $0.01 | Fresh odds for one token |
43
+ | `GET /v1/board?limit=N` | $0.005 per coin delivered | Ranked, filtered board of 10–500 covered tokens; pay only for coins returned, empty is free |
44
+ | `GET /v1/risk/{mint}/last` | $0.002 | Last known odds during a feed interruption, at most 30 minutes old, labelled stale, confidence lowered one level |
45
+
46
+ You only pay for fresh, covered answers. "Not covered", "feed interrupted" and
47
+ "too old" answers are free, and this client never signs for them.
48
+
49
+ ## Install
50
+
51
+ ```sh
52
+ pip install "git+https://github.com/hodlodds/hodlodds-sdk"
53
+ ```
54
+
55
+ Python 3.10+. Dependencies: `httpx` and the official `x402[svm,evm]==2.25.0` SDK (pinned).
56
+
57
+ ## Use
58
+
59
+ Command line:
60
+
61
+ ```sh
62
+ hodlodds status
63
+ hodlodds risk <MINT> --keypair ~/.config/hodlodds/wallet.json --pay-to <RECIPIENT> --network devnet
64
+ hodlodds risk <MINT> --keypair ~/.config/hodlodds/base-wallet.json --pay-to <BASE_RECIPIENT> --network base-sepolia
65
+ hodlodds board --limit 100 --sort safest --max-risk 0.05 --min-liquidity 50 --max-price 0.5 \
66
+ --keypair ... --pay-to ... --network devnet
67
+ ```
68
+
69
+ Python:
70
+
71
+ ```python
72
+ from hodlodds import HodlOdds, HodlOddsError
73
+
74
+ with HodlOdds("https://api.hodlodds.com", keypair="~/.config/hodlodds/wallet.json",
75
+ network="devnet", pay_to="<RECIPIENT>", max_price_usdc="0.01") as client:
76
+ print(client.status()["state"])
77
+ try:
78
+ answer = client.risk("<MINT>")
79
+ row = answer.data["row"]
80
+ print(row["p_crash_60m"], row["confidence"]["level"], answer.payment["explorer"])
81
+ except HodlOddsError as e:
82
+ print(e, e.status, e.body) # free refusals land here; e.payment is set if a payment may have happened
83
+ ```
84
+
85
+ `<RECIPIENT>` is the payment address published in `GET /v1/meta` (and in this
86
+ README at launch). Pin it in your code: the client refuses any offer that pays
87
+ someone else.
88
+
89
+ Networks: `devnet` and `solana` (mainnet; `mainnet` is an alias) take a Solana
90
+ JSON key file; `base-sepolia` and `base` take an EVM key file
91
+ (`{"private_key": "0x..."}`, mode 0600). Prices are the same on both chains, and the
92
+ API offers both; the client signs only on the network you chose.
93
+
94
+ ```python
95
+ base = HodlOdds("https://api.hodlodds.com", keypair="~/.config/hodlodds/base-wallet.json",
96
+ network="base", pay_to="<BASE_RECIPIENT>", max_price_usdc="0.01")
97
+ ```
98
+
99
+ Board options: `limit` 10–500 (default 50); `sort` = `safest` (default), `riskiest`,
100
+ `liquidity`, `newest`, `active`; filters `max_risk`, `min_risk`, `min_liquidity_sol`,
101
+ `min_age_min`, `max_age_min`, `min_trades_5`, `min_confidence` (`high`/`medium`/`low`).
102
+ The total price is `0.005 × coins returned`; pass a `max_price` at least that high
103
+ (the client caps a single board payment at 2.5 USDC).
104
+
105
+ ```python
106
+ board = client.board(limit=100, sort="safest", max_risk=0.05, min_liquidity_sol=50, max_price_usdc="0.5")
107
+ for row in board.data["rows"]:
108
+ print(row["mint"], row["p_crash_60m"], row["confidence"]["level"])
109
+ ```
110
+
111
+ ### If an answer is lost after you paid
112
+
113
+ The paid request waits up to 90 seconds (`paid_timeout`, 30–600): verification and
114
+ on-chain settlement usually take one to three seconds. If the connection still ends
115
+ after the payment settled, nothing is lost:
116
+
117
+ - The server keeps that payment redeemable **once** for 24 hours.
118
+ - In Python, the error carries it: `client.redeem(error.payment)` re-sends the same
119
+ authorization with `HodlOdds-Redeem: 1`. It never signs a new payment and never
120
+ settles a second time.
121
+ - The CLI saves it to `~/.config/hodlodds/pending/<id>.json` (mode 0600) and prints
122
+ the path; run `hodlodds redeem <file>` after about two minutes.
123
+ - While the server is still confirming the payment on-chain, a redeem answers
124
+ `409` with `Retry-After`; if the payment never settled, it says so (nothing was
125
+ charged).
126
+
127
+ Environment variables for the CLI: `HODLODDS_BASE_URL`, `HODLODDS_NETWORK`,
128
+ `HODLODDS_KEYPAIR`, `HODLODDS_PAY_TO`, `HODLODDS_MAX_PRICE`.
129
+ Exit status: `0` success, `1` refusal/API/transport failure, `2` invalid usage.
130
+
131
+ ## Safety rules built into the client
132
+
133
+ - A payment is signed only after the 402 offer passes every check: x402 v2, the
134
+ chosen network, exact USDC, authorization flow, the pinned `payTo`, a valid
135
+ facilitator fee payer distinct from you, a bounded timeout, and a price at or
136
+ below your limit (default 0.01 USDC, hard cap 1 USDC; exact atomic arithmetic).
137
+ - Payments are **never retried automatically**. If anything goes wrong after an
138
+ authorization was sent, the error says so and carries the details
139
+ (`HodlOddsError.payment`) so you can reconcile against your wallet history or
140
+ redeem the answer once (see above).
141
+ - The key file must be a regular, non-symlink file with mode 0600. It is read
142
+ locally and never printed or sent; only the SDK-signed USDC transfer
143
+ authorization leaves your machine. Use a dedicated low-balance wallet.
144
+ - HTTPS only (plain HTTP only for a local test server). No redirects, no
145
+ environment proxies, bounded headers and bodies.
146
+ - Mainnets require an explicit `network="solana"` (or `"base"`) and a pinned
147
+ `pay_to`; the CLI requires `--network solana|base --pay-to ...` on the command line.
148
+
149
+ ## Test wallet (devnet / Base Sepolia)
150
+
151
+ ```sh
152
+ hodlodds-wallet new ~/.config/hodlodds/my-devnet.json # prints the public address only
153
+ hodlodds-wallet new --network base-sepolia ~/.config/hodlodds/base-sepolia.json
154
+ ```
155
+
156
+ Fund it with test USDC at https://faucet.circle.com (network: Solana Devnet or Base Sepolia).
157
+ Never put real funds in a test wallet.
158
+
159
+ ## Tests
160
+
161
+ ```sh
162
+ python -m unittest discover -s tests -v
163
+ ```
164
+
165
+ The tests run offline with a fake server and a fake SDK seam; with the pinned SDK
166
+ installed, one more test builds a real signature with all RPC calls mocked.
167
+
168
+ ## Disclaimer
169
+
170
+ Odds, not guarantees. Not financial advice. Low odds do not mean a token is
171
+ safe; collapses can happen without warning. The operator also runs an
172
+ independent trading system that may trade tokens HodlOdds scores; it never
173
+ receives customer requests or purchases, and nobody can pay to change a score.
174
+
175
+ ## License
176
+
177
+ MIT
@@ -0,0 +1,10 @@
1
+ hodlodds/__init__.py,sha256=d2qVQQOp3qIHVEmztA-lRlkk2cEhz5t7BpNEcUtsre0,340
2
+ hodlodds/__main__.py,sha256=B6Oloujjbkp38ojqD3xC-35enig7XQ9oSTOmK7688io,55
3
+ hodlodds/client.py,sha256=Tb3sNNPfSJR18u7rJEg6kz2wx0WJgtVMigt_o4edU_Q,42015
4
+ hodlodds/wallet.py,sha256=pvgBMTFX9aWcrFBx1ZTbzLC_-8OhUPrPO9aHrexxYrw,3537
5
+ hodlodds-0.2.0.dist-info/licenses/LICENSE,sha256=yFdqULY3veqFf8Zl71TVQ4d4hQoTUQ-uQ1GGg0ghpcQ,1065
6
+ hodlodds-0.2.0.dist-info/METADATA,sha256=aNfy-0uIGK5NQvzyOeU6BJeTnPuIemD4wN5-17jRw_0,7886
7
+ hodlodds-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ hodlodds-0.2.0.dist-info/entry_points.txt,sha256=AyrYewWaMpRcdMpLKCrLqGTl1zl9WsHgCRGTgS6d__A,89
9
+ hodlodds-0.2.0.dist-info/top_level.txt,sha256=0tzq4VPLbON-rZvlmwgMP2WaHi8f4x5NQWuZf-fNaQg,9
10
+ hodlodds-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ hodlodds = hodlodds.client:main
3
+ hodlodds-wallet = hodlodds.wallet:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HodlOdds
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 @@
1
+ hodlodds