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 +5 -0
- hodlodds/__main__.py +5 -0
- hodlodds/client.py +789 -0
- hodlodds/wallet.py +103 -0
- hodlodds-0.2.0.dist-info/METADATA +177 -0
- hodlodds-0.2.0.dist-info/RECORD +10 -0
- hodlodds-0.2.0.dist-info/WHEEL +5 -0
- hodlodds-0.2.0.dist-info/entry_points.txt +3 -0
- hodlodds-0.2.0.dist-info/licenses/LICENSE +21 -0
- hodlodds-0.2.0.dist-info/top_level.txt +1 -0
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
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,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
|