cryptochief-crypto-processing-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cryptochief/__init__.py +336 -0
- cryptochief/_models.py +93 -0
- cryptochief/_version.py +3 -0
- cryptochief/amount.py +97 -0
- cryptochief/assets.py +32 -0
- cryptochief/chains.py +115 -0
- cryptochief/client.py +189 -0
- cryptochief/contract/__init__.py +69 -0
- cryptochief/contract/base58.py +40 -0
- cryptochief/contract/borsh.py +141 -0
- cryptochief/contract/evm_abi.py +321 -0
- cryptochief/contract/keccak.py +16 -0
- cryptochief/contract/tron_address.py +60 -0
- cryptochief/errors.py +111 -0
- cryptochief/pagination.py +32 -0
- cryptochief/poll.py +56 -0
- cryptochief/rsa.py +70 -0
- cryptochief/services/__init__.py +1 -0
- cryptochief/services/base.py +24 -0
- cryptochief/services/blockchain.py +76 -0
- cryptochief/services/currencies.py +55 -0
- cryptochief/services/payins.py +145 -0
- cryptochief/services/payouts.py +175 -0
- cryptochief/services/static_deposits.py +77 -0
- cryptochief/services/sweeps.py +85 -0
- cryptochief/services/transactions.py +470 -0
- cryptochief/services/wallets.py +86 -0
- cryptochief/services/withdrawals.py +51 -0
- cryptochief/sign.py +112 -0
- cryptochief/ton/__init__.py +19 -0
- cryptochief/ton/address.py +109 -0
- cryptochief/ton/messages.py +105 -0
- cryptochief/ton/rpc.py +159 -0
- cryptochief/transport.py +48 -0
- cryptochief/webhook.py +191 -0
- cryptochief_crypto_processing_python-0.1.0.dist-info/METADATA +346 -0
- cryptochief_crypto_processing_python-0.1.0.dist-info/RECORD +39 -0
- cryptochief_crypto_processing_python-0.1.0.dist-info/WHEEL +4 -0
- cryptochief_crypto_processing_python-0.1.0.dist-info/licenses/LICENSE +21 -0
cryptochief/sign.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Canonical JSON + request signing.
|
|
2
|
+
|
|
3
|
+
Crypto Chief signs the *canonical* serialization of a request body. The
|
|
4
|
+
canonical form is fully deterministic:
|
|
5
|
+
|
|
6
|
+
* object keys sorted lexicographically by their UTF-8 bytes, recursively;
|
|
7
|
+
* compact (no insignificant whitespace);
|
|
8
|
+
* the HTML-sensitive characters ``<``, ``>``, ``&`` and the U+2028 / U+2029
|
|
9
|
+
line / paragraph separators emitted as their JSON unicode escapes;
|
|
10
|
+
* standard JSON escapes for ``"``, ``\\``, and control characters (``\\n``,
|
|
11
|
+
``\\r``, ``\\t`` short forms; everything else below 0x20 as ``\\u00XX``,
|
|
12
|
+
lowercase hex).
|
|
13
|
+
|
|
14
|
+
The gateway re-derives this canonical form from the bytes it receives and
|
|
15
|
+
checks the signature against it, so the client must emit byte-identical output.
|
|
16
|
+
The regression vectors in ``tests/test_sign.py`` lock this down.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
from typing import Any, Tuple
|
|
24
|
+
|
|
25
|
+
from .errors import CryptoChiefError
|
|
26
|
+
|
|
27
|
+
_SHORT_ESCAPES = {
|
|
28
|
+
0x22: '\\"',
|
|
29
|
+
0x5C: "\\\\",
|
|
30
|
+
0x0A: "\\n",
|
|
31
|
+
0x0D: "\\r",
|
|
32
|
+
0x09: "\\t",
|
|
33
|
+
0x3C: "\\u003c",
|
|
34
|
+
0x3E: "\\u003e",
|
|
35
|
+
0x26: "\\u0026",
|
|
36
|
+
0x2028: "\\u2028",
|
|
37
|
+
0x2029: "\\u2029",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _encode_string(s: str) -> str:
|
|
42
|
+
out = ['"']
|
|
43
|
+
for ch in s:
|
|
44
|
+
code = ord(ch)
|
|
45
|
+
esc = _SHORT_ESCAPES.get(code)
|
|
46
|
+
if esc is not None:
|
|
47
|
+
out.append(esc)
|
|
48
|
+
elif code < 0x20:
|
|
49
|
+
out.append("\\u%04x" % code)
|
|
50
|
+
else:
|
|
51
|
+
out.append(ch)
|
|
52
|
+
out.append('"')
|
|
53
|
+
return "".join(out)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _encode_number(n: "int | float") -> str:
|
|
57
|
+
if isinstance(n, bool): # bool is a subclass of int - guard first
|
|
58
|
+
return "true" if n else "false"
|
|
59
|
+
if isinstance(n, int):
|
|
60
|
+
return str(n)
|
|
61
|
+
# Floats are not expected in signed bodies (amounts travel as strings), but
|
|
62
|
+
# match the gateway's shortest-form rendering: integral floats drop the dot.
|
|
63
|
+
if n != n or n in (float("inf"), float("-inf")):
|
|
64
|
+
raise CryptoChiefError(f"cryptochief: cannot canonicalize non-finite number {n}")
|
|
65
|
+
if n == int(n) and abs(n) < 1e21:
|
|
66
|
+
return str(int(n))
|
|
67
|
+
return repr(n)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _encode_value(v: Any) -> str:
|
|
71
|
+
if v is None:
|
|
72
|
+
return "null"
|
|
73
|
+
if isinstance(v, bool):
|
|
74
|
+
return "true" if v else "false"
|
|
75
|
+
if isinstance(v, str):
|
|
76
|
+
return _encode_string(v)
|
|
77
|
+
if isinstance(v, (int, float)):
|
|
78
|
+
return _encode_number(v)
|
|
79
|
+
if isinstance(v, dict):
|
|
80
|
+
keys = [k for k, val in v.items() if val is not None]
|
|
81
|
+
keys.sort(key=lambda k: str(k).encode("utf-8"))
|
|
82
|
+
parts = [_encode_string(str(k)) + ":" + _encode_value(v[k]) for k in keys]
|
|
83
|
+
return "{" + ",".join(parts) + "}"
|
|
84
|
+
if isinstance(v, (list, tuple)):
|
|
85
|
+
return "[" + ",".join(_encode_value(el) for el in v) + "]"
|
|
86
|
+
raise CryptoChiefError(f"cryptochief: cannot canonicalize value of type {type(v).__name__}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def canonical_json(value: Any) -> str:
|
|
90
|
+
"""Produce the canonical JSON string for a value.
|
|
91
|
+
|
|
92
|
+
``None`` collapses to an empty body, which signs as ``md5(api_key)``.
|
|
93
|
+
"""
|
|
94
|
+
if value is None:
|
|
95
|
+
return ""
|
|
96
|
+
return _encode_value(value)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sign(canonical_body: str, api_key: str) -> str:
|
|
100
|
+
"""Compute the ``Signature`` header for an already-canonical body.
|
|
101
|
+
|
|
102
|
+
``hex(md5(base64(canonical_body) + api_key))``. An empty body signs as
|
|
103
|
+
``md5(api_key)``.
|
|
104
|
+
"""
|
|
105
|
+
b64 = base64.b64encode(canonical_body.encode("utf-8")).decode("ascii")
|
|
106
|
+
return hashlib.md5((b64 + api_key).encode("utf-8")).hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sign_value(value: Any, api_key: str) -> Tuple[str, str]:
|
|
110
|
+
"""Canonicalize then sign a value, returning ``(canonical, signature)``."""
|
|
111
|
+
canonical = canonical_json(value)
|
|
112
|
+
return canonical, sign(canonical, api_key)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""TON helpers: offline address parsing (public) and cell builders (used internally)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .address import (
|
|
6
|
+
TonAddress,
|
|
7
|
+
crc16_xmodem,
|
|
8
|
+
parse_ton_address,
|
|
9
|
+
ton_address_to_raw,
|
|
10
|
+
ton_address_to_string,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"TonAddress",
|
|
15
|
+
"crc16_xmodem",
|
|
16
|
+
"parse_ton_address",
|
|
17
|
+
"ton_address_to_raw",
|
|
18
|
+
"ton_address_to_string",
|
|
19
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Offline parsing / validation of TON addresses.
|
|
2
|
+
|
|
3
|
+
TON addresses come in three skins, all wrapping the same 33 bytes (1 tag +
|
|
4
|
+
1 workchain + 32 hash):
|
|
5
|
+
|
|
6
|
+
* user-friendly bounceable ``EQ...`` (mainnet) / ``kQ...`` (testnet)
|
|
7
|
+
* user-friendly non-bounceable ``UQ...`` (mainnet) / ``0Q...`` (testnet)
|
|
8
|
+
* raw ``<workchain>:<32-byte-hex>``
|
|
9
|
+
|
|
10
|
+
The user-friendly forms add a 2-byte CRC16-XMODEM checksum, which this parser
|
|
11
|
+
validates. No network access - this is purely for local validation / display.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from ..errors import CryptoChiefError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TonAddress:
|
|
24
|
+
workchain: int
|
|
25
|
+
hash: bytes # 32 bytes
|
|
26
|
+
bounceable: bool
|
|
27
|
+
testnet: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def crc16_xmodem(data: bytes) -> int:
|
|
31
|
+
"""CRC-16/XMODEM (poly 0x1021, init 0x0000, non-reflected) - TON's checksum."""
|
|
32
|
+
crc = 0
|
|
33
|
+
for b in data:
|
|
34
|
+
crc ^= b << 8
|
|
35
|
+
for _ in range(8):
|
|
36
|
+
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
|
|
37
|
+
return crc & 0xFFFF
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_raw(s: str, colon: int) -> TonAddress:
|
|
41
|
+
try:
|
|
42
|
+
wc = int(s[:colon], 10)
|
|
43
|
+
except ValueError as err:
|
|
44
|
+
raise CryptoChiefError(f"cryptochief/ton: bad raw workchain {s[:colon]!r}") from err
|
|
45
|
+
if wc < -128 or wc > 127:
|
|
46
|
+
raise CryptoChiefError(f"cryptochief/ton: bad raw workchain {s[:colon]!r}")
|
|
47
|
+
hash_hex = s[colon + 1 :]
|
|
48
|
+
if len(hash_hex) != 64:
|
|
49
|
+
raise CryptoChiefError(f"cryptochief/ton: hash hex length {len(hash_hex)}, want 64")
|
|
50
|
+
try:
|
|
51
|
+
h = bytes.fromhex(hash_hex)
|
|
52
|
+
except ValueError as err:
|
|
53
|
+
raise CryptoChiefError("cryptochief/ton: bad hash hex") from err
|
|
54
|
+
return TonAddress(workchain=wc, hash=h, bounceable=True, testnet=False)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_friendly(s: str) -> TonAddress:
|
|
58
|
+
if len(s) != 48:
|
|
59
|
+
raise CryptoChiefError(f"cryptochief/ton: user-friendly address length {len(s)}, want 48")
|
|
60
|
+
# TON uses URL-safe base64; accept the standard alphabet too.
|
|
61
|
+
try:
|
|
62
|
+
raw = base64.b64decode(s.replace("-", "+").replace("_", "/"))
|
|
63
|
+
except (ValueError, base64.binascii.Error) as err: # type: ignore[attr-defined]
|
|
64
|
+
raise CryptoChiefError(f"cryptochief/ton: bad base64 address: {err}") from err
|
|
65
|
+
if len(raw) != 36:
|
|
66
|
+
raise CryptoChiefError(f"cryptochief/ton: decoded length {len(raw)}, want 36")
|
|
67
|
+
want = crc16_xmodem(raw[:34])
|
|
68
|
+
got = (raw[34] << 8) | raw[35]
|
|
69
|
+
if want != got:
|
|
70
|
+
raise CryptoChiefError("cryptochief/ton: CRC mismatch")
|
|
71
|
+
tag = raw[0]
|
|
72
|
+
workchain = raw[1] - 256 if raw[1] > 127 else raw[1] # sign-extend to int8
|
|
73
|
+
return TonAddress(
|
|
74
|
+
workchain=workchain,
|
|
75
|
+
hash=raw[2:34],
|
|
76
|
+
bounceable=(tag & 0x40) == 0,
|
|
77
|
+
testnet=(tag & 0x80) != 0,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_ton_address(value: str) -> TonAddress:
|
|
82
|
+
"""Parse any of the three TON address forms; raises on CRC / length errors."""
|
|
83
|
+
s = value.strip()
|
|
84
|
+
if s == "":
|
|
85
|
+
raise CryptoChiefError("cryptochief/ton: empty address")
|
|
86
|
+
colon = s.find(":")
|
|
87
|
+
if colon > 0:
|
|
88
|
+
return _parse_raw(s, colon)
|
|
89
|
+
return _parse_friendly(s)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ton_address_to_string(a: TonAddress) -> str:
|
|
93
|
+
"""Render the user-friendly form (URL-safe base64, no padding)."""
|
|
94
|
+
tag = 0x11 if a.bounceable else 0x51
|
|
95
|
+
if a.testnet:
|
|
96
|
+
tag |= 0x80
|
|
97
|
+
buf = bytearray(36)
|
|
98
|
+
buf[0] = tag
|
|
99
|
+
buf[1] = a.workchain & 0xFF
|
|
100
|
+
buf[2:34] = a.hash[:32]
|
|
101
|
+
crc = crc16_xmodem(bytes(buf[:34]))
|
|
102
|
+
buf[34] = crc >> 8
|
|
103
|
+
buf[35] = crc & 0xFF
|
|
104
|
+
return base64.urlsafe_b64encode(bytes(buf)).decode("ascii").rstrip("=")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def ton_address_to_raw(a: TonAddress) -> str:
|
|
108
|
+
"""Render the raw ``workchain:hex`` form."""
|
|
109
|
+
return f"{a.workchain}:{a.hash.hex()}"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""TON message-body builders.
|
|
2
|
+
|
|
3
|
+
BoC (de)serialization is delegated to ``pytoniq-core``; the SDK does not encode
|
|
4
|
+
cells by hand. These helpers produce the raw BoC bytes the contract-call
|
|
5
|
+
``data`` field expects (base64-encoded by the caller).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from pytoniq_core import Address, Cell, begin_cell
|
|
13
|
+
|
|
14
|
+
from ..errors import CryptoChiefError
|
|
15
|
+
|
|
16
|
+
# TON internal-message op codes from the public TEP standards.
|
|
17
|
+
OP_JETTON_TRANSFER = 0x0F8A7EA5 # TEP-74
|
|
18
|
+
OP_NFT_TRANSFER = 0x5FCC3D14 # TEP-62
|
|
19
|
+
OP_TEXT_COMMENT = 0x00000000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_ton_addr(s: str) -> Address:
|
|
23
|
+
"""Parse any TON address form (``EQ`` / ``UQ`` or raw ``workchain:hex``)."""
|
|
24
|
+
try:
|
|
25
|
+
return Address(s)
|
|
26
|
+
except Exception as err: # noqa: BLE001 - library raises various types
|
|
27
|
+
raise CryptoChiefError(
|
|
28
|
+
f"cryptochief/ton: invalid TON address {s!r} "
|
|
29
|
+
f"(expected EQ/UQ or workchain:hex): {err}"
|
|
30
|
+
) from err
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_jetton_transfer_body(
|
|
34
|
+
*,
|
|
35
|
+
query_id: int,
|
|
36
|
+
amount: int,
|
|
37
|
+
destination: Address,
|
|
38
|
+
response_dest: Optional[Address],
|
|
39
|
+
custom_payload: Optional[Cell] = None,
|
|
40
|
+
forward_ton: int,
|
|
41
|
+
forward_payload: Optional[Cell] = None,
|
|
42
|
+
) -> bytes:
|
|
43
|
+
"""Standard Jetton "transfer" body (TEP-74, op ``0x0f8a7ea5``).
|
|
44
|
+
|
|
45
|
+
``destination`` is the recipient's *main* TON wallet; the network handles the
|
|
46
|
+
wallet-to-wallet hop.
|
|
47
|
+
"""
|
|
48
|
+
if amount < 0:
|
|
49
|
+
raise CryptoChiefError("cryptochief/ton: jetton amount must be non-negative")
|
|
50
|
+
b = (
|
|
51
|
+
begin_cell()
|
|
52
|
+
.store_uint(OP_JETTON_TRANSFER, 32)
|
|
53
|
+
.store_uint(query_id, 64)
|
|
54
|
+
.store_coins(amount)
|
|
55
|
+
.store_address(destination)
|
|
56
|
+
.store_address(response_dest)
|
|
57
|
+
.store_maybe_ref(custom_payload)
|
|
58
|
+
.store_coins(max(forward_ton, 0))
|
|
59
|
+
)
|
|
60
|
+
# forward_payload: Either Cell ^Cell - ref when supplied, empty-inline otherwise.
|
|
61
|
+
if forward_payload is not None:
|
|
62
|
+
b = b.store_bit(1).store_ref(forward_payload)
|
|
63
|
+
else:
|
|
64
|
+
b = b.store_bit(0)
|
|
65
|
+
return bytes(b.end_cell().to_boc())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_nft_transfer_body(
|
|
69
|
+
*,
|
|
70
|
+
query_id: int,
|
|
71
|
+
new_owner: Address,
|
|
72
|
+
response_dest: Optional[Address],
|
|
73
|
+
custom_payload: Optional[Cell] = None,
|
|
74
|
+
forward_ton: int,
|
|
75
|
+
forward_payload: Optional[Cell] = None,
|
|
76
|
+
) -> bytes:
|
|
77
|
+
"""Standard NFT "transfer" body (TEP-62, op ``0x5fcc3d14``)."""
|
|
78
|
+
b = (
|
|
79
|
+
begin_cell()
|
|
80
|
+
.store_uint(OP_NFT_TRANSFER, 32)
|
|
81
|
+
.store_uint(query_id, 64)
|
|
82
|
+
.store_address(new_owner)
|
|
83
|
+
.store_address(response_dest)
|
|
84
|
+
.store_maybe_ref(custom_payload)
|
|
85
|
+
.store_coins(max(forward_ton, 0))
|
|
86
|
+
)
|
|
87
|
+
if forward_payload is not None:
|
|
88
|
+
b = b.store_bit(1).store_ref(forward_payload)
|
|
89
|
+
else:
|
|
90
|
+
b = b.store_bit(0)
|
|
91
|
+
return bytes(b.end_cell().to_boc())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_text_comment_cell(text: str) -> Cell:
|
|
95
|
+
"""A standalone text-comment cell (op ``0`` + UTF-8 snake string).
|
|
96
|
+
|
|
97
|
+
Used both as a top-level body and as a Jetton transfer's ``forward_payload``
|
|
98
|
+
ref when a memo is supplied.
|
|
99
|
+
"""
|
|
100
|
+
return begin_cell().store_uint(OP_TEXT_COMMENT, 32).store_snake_string(text).end_cell()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_text_comment_body(text: str) -> bytes:
|
|
104
|
+
"""Simple text-comment body (what wallets show as the transfer note)."""
|
|
105
|
+
return bytes(build_text_comment_cell(text).to_boc())
|
cryptochief/ton/rpc.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Internal TON RPC client.
|
|
2
|
+
|
|
3
|
+
Exists only to feed parameters (the sender's Jetton wallet address; whether a
|
|
4
|
+
recipient already has a Jetton wallet) into the high-level TON sign helpers - it
|
|
5
|
+
is not part of the public API surface.
|
|
6
|
+
|
|
7
|
+
URL pattern: ``<base_url>/ton-v3/<merchant_id>/<endpoint>``. The merchant ID is
|
|
8
|
+
the same credential used by the processing API; no separate token.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
from urllib.parse import urlencode
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from pytoniq_core import Cell, begin_cell
|
|
19
|
+
|
|
20
|
+
from ..errors import CryptoChiefError
|
|
21
|
+
from .messages import parse_ton_addr
|
|
22
|
+
|
|
23
|
+
DEFAULT_TON_RPC_BASE_URL = "https://rpc.crypto-chief.com"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TonRpc:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
merchant_id: str,
|
|
31
|
+
http: httpx.AsyncClient,
|
|
32
|
+
base_url: Optional[str] = None,
|
|
33
|
+
user_agent: str,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._merchant_id = merchant_id
|
|
36
|
+
self._http = http
|
|
37
|
+
self._base_url = (base_url or DEFAULT_TON_RPC_BASE_URL).rstrip("/")
|
|
38
|
+
self._user_agent = user_agent
|
|
39
|
+
self._cache: dict[str, str] = {}
|
|
40
|
+
|
|
41
|
+
def _url(self, path: str, query: Optional[dict[str, str]] = None) -> str:
|
|
42
|
+
u = f"{self._base_url}/ton-v3/{self._merchant_id}/{path.lstrip('/')}"
|
|
43
|
+
if query:
|
|
44
|
+
u += "?" + urlencode(query)
|
|
45
|
+
return u
|
|
46
|
+
|
|
47
|
+
async def _get(self, path: str, query: dict[str, str], timeout: float) -> Any:
|
|
48
|
+
resp = await self._http.get(
|
|
49
|
+
self._url(path, query),
|
|
50
|
+
headers={"Accept": "application/json", "User-Agent": self._user_agent},
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
)
|
|
53
|
+
return self._handle(resp, path)
|
|
54
|
+
|
|
55
|
+
async def _post(self, path: str, body: Any, timeout: float) -> Any:
|
|
56
|
+
resp = await self._http.post(
|
|
57
|
+
self._url(path),
|
|
58
|
+
json=body,
|
|
59
|
+
headers={
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"Accept": "application/json",
|
|
62
|
+
"User-Agent": self._user_agent,
|
|
63
|
+
},
|
|
64
|
+
timeout=timeout,
|
|
65
|
+
)
|
|
66
|
+
return self._handle(resp, path)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _handle(resp: httpx.Response, path: str) -> Any:
|
|
70
|
+
text = resp.text
|
|
71
|
+
if resp.status_code >= 400:
|
|
72
|
+
raise CryptoChiefError(
|
|
73
|
+
f"cryptochief/ton: {path}: HTTP {resp.status_code}: {text[:256]}"
|
|
74
|
+
)
|
|
75
|
+
if not text:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return resp.json()
|
|
79
|
+
except ValueError as err:
|
|
80
|
+
raise CryptoChiefError(f"cryptochief/ton: decode {path}: {err}") from err
|
|
81
|
+
|
|
82
|
+
async def lookup_jetton_wallet(self, jetton_master: str, owner: str) -> str:
|
|
83
|
+
"""Resolve the Jetton wallet holding ``owner``'s balance of ``jetton_master``.
|
|
84
|
+
|
|
85
|
+
Primary path: the deterministic ``get_wallet_address`` get-method on the
|
|
86
|
+
master (works even for an owner that never received the Jetton). Fallback:
|
|
87
|
+
the indexer. Cached for the client's lifetime.
|
|
88
|
+
"""
|
|
89
|
+
if not jetton_master or not owner:
|
|
90
|
+
raise CryptoChiefError("cryptochief/ton: jetton_master and owner are required")
|
|
91
|
+
cache_key = f"{owner}|{jetton_master}"
|
|
92
|
+
cached = self._cache.get(cache_key)
|
|
93
|
+
if cached:
|
|
94
|
+
return cached
|
|
95
|
+
|
|
96
|
+
resolved = ""
|
|
97
|
+
try:
|
|
98
|
+
resolved = await self._via_run_method(jetton_master, owner)
|
|
99
|
+
except Exception: # noqa: BLE001 - fall back to the indexer
|
|
100
|
+
resolved = ""
|
|
101
|
+
if not resolved:
|
|
102
|
+
resolved = await self._via_index(jetton_master, owner)
|
|
103
|
+
self._cache[cache_key] = resolved
|
|
104
|
+
return resolved
|
|
105
|
+
|
|
106
|
+
async def _via_run_method(self, jetton_master: str, owner: str) -> str:
|
|
107
|
+
owner_cell = begin_cell().store_address(parse_ton_addr(owner)).end_cell()
|
|
108
|
+
owner_boc = base64.b64encode(bytes(owner_cell.to_boc())).decode("ascii")
|
|
109
|
+
out = await self._post(
|
|
110
|
+
"/runGetMethod",
|
|
111
|
+
{
|
|
112
|
+
"address": jetton_master,
|
|
113
|
+
"method": "get_wallet_address",
|
|
114
|
+
"stack": [{"type": "slice", "value": owner_boc}],
|
|
115
|
+
},
|
|
116
|
+
15.0,
|
|
117
|
+
)
|
|
118
|
+
exit_code = out.get("exit_code") if isinstance(out, dict) else None
|
|
119
|
+
if exit_code not in (0, None):
|
|
120
|
+
raise CryptoChiefError(f"cryptochief/ton: get_wallet_address: exit_code={exit_code}")
|
|
121
|
+
stack = out.get("stack") if isinstance(out, dict) else None
|
|
122
|
+
if not stack:
|
|
123
|
+
raise CryptoChiefError("cryptochief/ton: get_wallet_address: empty stack")
|
|
124
|
+
value = stack[0].get("value")
|
|
125
|
+
result_cell = Cell.one_from_boc(base64.b64decode(value))
|
|
126
|
+
return result_cell.begin_parse().load_address().to_str()
|
|
127
|
+
|
|
128
|
+
async def _via_index(self, jetton_master: str, owner: str) -> str:
|
|
129
|
+
out = await self._get(
|
|
130
|
+
"/jetton/wallets",
|
|
131
|
+
{"owner_address": owner, "jetton_address": jetton_master, "limit": "1"},
|
|
132
|
+
15.0,
|
|
133
|
+
)
|
|
134
|
+
wallets = (out or {}).get("jetton_wallets") or []
|
|
135
|
+
if not wallets:
|
|
136
|
+
raise CryptoChiefError(
|
|
137
|
+
f"cryptochief/ton: no Jetton wallet found for owner {owner} on master "
|
|
138
|
+
f"{jetton_master} - owner has never received this Jetton"
|
|
139
|
+
)
|
|
140
|
+
wallet = wallets[0]
|
|
141
|
+
address_book = (out or {}).get("address_book") or {}
|
|
142
|
+
friendly = (address_book.get(wallet["address"]) or {}).get("user_friendly")
|
|
143
|
+
return friendly or wallet["address"]
|
|
144
|
+
|
|
145
|
+
async def has_jetton_wallet(self, jetton_master: str, owner: str) -> bool:
|
|
146
|
+
"""Whether ``owner`` already holds an initialized Jetton wallet for ``jetton_master``.
|
|
147
|
+
|
|
148
|
+
Used to size the attached gas budget on transfers. Returns ``False`` (the
|
|
149
|
+
conservative answer) on any RPC error.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
out = await self._get(
|
|
153
|
+
"/jetton/wallets",
|
|
154
|
+
{"owner_address": owner, "jetton_address": jetton_master, "limit": "1"},
|
|
155
|
+
5.0,
|
|
156
|
+
)
|
|
157
|
+
return len((out or {}).get("jetton_wallets") or []) > 0
|
|
158
|
+
except Exception: # noqa: BLE001 - conservative default
|
|
159
|
+
return False
|
cryptochief/transport.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Transport helpers: error-envelope parsing and retry backoff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
from .errors import APIError, ErrorCode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_api_error(status: int, body: str) -> APIError:
|
|
12
|
+
"""Parse a non-2xx response body into an :class:`APIError` with a stable code.
|
|
13
|
+
|
|
14
|
+
The code is ``msg or error or HTTP_<status>``, and the message prefers
|
|
15
|
+
``msg`` when it differs from ``error``.
|
|
16
|
+
"""
|
|
17
|
+
env: dict = {}
|
|
18
|
+
try:
|
|
19
|
+
parsed = json.loads(body)
|
|
20
|
+
if isinstance(parsed, dict):
|
|
21
|
+
env = parsed
|
|
22
|
+
except ValueError:
|
|
23
|
+
pass # non-JSON error body -> fall back to HTTP_<status>
|
|
24
|
+
code = env.get("msg") or env.get("error") or f"HTTP_{status}"
|
|
25
|
+
message = env.get("error") or ""
|
|
26
|
+
if env.get("msg") and env.get("msg") != env.get("error"):
|
|
27
|
+
message = env.get("msg")
|
|
28
|
+
return APIError(code, http_status=status, message=message, raw=body)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def backoff_delay(attempt: int, base_ms: float, max_ms: float) -> float:
|
|
32
|
+
"""Exponential backoff with full jitter, capped at ``max_ms``.
|
|
33
|
+
|
|
34
|
+
``attempt`` is 1-indexed (first retry = 1). Returns seconds.
|
|
35
|
+
"""
|
|
36
|
+
if base_ms <= 0:
|
|
37
|
+
base_ms = 200
|
|
38
|
+
if max_ms <= 0:
|
|
39
|
+
max_ms = 5000
|
|
40
|
+
d = base_ms * (2 ** (attempt - 1))
|
|
41
|
+
if d <= 0 or d > max_ms:
|
|
42
|
+
d = max_ms
|
|
43
|
+
return random.uniform(0, d) / 1000.0 # full jitter, uniform in [0, d] ms
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def network_error(message: str) -> APIError:
|
|
47
|
+
"""Build an :class:`APIError` for a transport-level (network) failure."""
|
|
48
|
+
return APIError(ErrorCode.NETWORK_ERROR, message=message)
|