diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5083f0e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,121 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Least privilege: the workflow only reads the repo. +permissions: + contents: read + +# Cancel superseded runs on rapid pushes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & typecheck + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Install Python + run: uv python install 3.12 + + - name: Sync dependencies (locked) + run: uv sync --locked --group dev --python 3.12 + + - name: Ruff lint + run: uv run ruff check . + + # Scoped to Python paths: repo-wide format would also reformat the + # illustrative snippets inside docs/*.md and README.md. + - name: Ruff format + run: uv run ruff format --check mintlayer/ tests/ examples/ + + - name: Mypy + run: uv run mypy mintlayer/ + + test: + name: Test (Python ${{ matrix.python }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Install Python ${{ matrix.python }} + run: uv python install ${{ matrix.python }} + + - name: Sync dependencies (locked) + run: uv sync --locked --group dev --python ${{ matrix.python }} + + # WASM init costs ~400 ms per Client; the session-scoped fixture keeps + # the suite to ~2 min. Coverage gate: total must stay above 80%. + - name: Run tests with coverage + run: > + uv run pytest tests/ -q + --cov --cov-report=term-missing + --cov-fail-under=80 + + build: + name: Build wheel + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Install Python + run: uv python install 3.12 + + - name: Build sdist and wheel + run: uv build + + - name: Smoke-test wheel contents + run: | + python3 - <<'EOF' + import zipfile, glob + wheels = glob.glob("dist/*.whl") + assert wheels, "no wheel produced by uv build" + wheel = wheels[0] + names = zipfile.ZipFile(wheel).namelist() + assert any(n.endswith("wasm_wrappers_bg.wasm") for n in names), "wasm binary missing" + assert any(n.endswith("wasm_wrappers_bg.wasm.sha256") for n in names), "sha256 pin missing" + assert any(n == "mintlayer/__init__.py" for n in names), "package missing" + print(f"{wheel}: {len(names)} entries OK") + EOF + + all-green: + name: All checks passed + runs-on: ubuntu-latest + needs: [lint, test, build] + steps: + - run: echo "All CI jobs green" diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml new file mode 100644 index 0000000..b2802e3 --- /dev/null +++ b/.github/workflows/code-review.yml @@ -0,0 +1,36 @@ +name: AI Code Review + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: ocr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + # Fork PRs cannot read secrets; skip them cleanly. + if: github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: alibaba/open-code-review@494bf1c8d7a19196ab166960a06fef38d69a1d16 # v1.12.0 + with: + llm_url: https://api.z.ai/api/coding/paas/v4 + llm_auth_token: ${{ secrets.OCR_LLM_TOKEN }} + llm_model: glm-5.3-flash + llm_use_anthropic: false + # GLM-5.3 family rejects thinking.type=disabled, which is the + # action's default extra_body — this override is required. + llm_extra_body: '{"thinking": {"type": "enabled"}}' + llm_reasoning_effort: low + incremental: 'true' + route_severity_below: 'low' + max_tokens_budget: '500000' + review_task_timeout: '15' + stream_progress: 'true' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..c62899c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,196 @@ +name: Publish + +# Release flow: +# 1. Push a tag (git tag v0.1.0 && git push origin v0.1.0) +# -> build + verify -> publish to TestPyPI (dry-run). +# 2. Verify the TestPyPI install, then cut a GitHub Release for the +# SAME, already-existing tag -> publish to PyPI. +# +# Note: creating a Release for a NEW tag fires both events (GitHub creates +# the tag first); the event guards below keep each target correct in that +# case too, but the intended flow above keeps TestPyPI as a manual gate. + +on: + push: + tags: ["v*"] + release: + types: [published] + +# id-token / attestations intentionally omitted: publishing authenticates +# with an API token stored in the environment secrets. PEP 740 attestations +# require OIDC trusted publishing; re-enable both if the project migrates +# to trusted publishing (which would also eliminate the long-lived token). +permissions: + contents: read + +# Never cancel a publish in flight. +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build & verify distribution + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Install Python + run: uv python install 3.12 + + - name: Sync dependencies (locked) + run: uv sync --locked --group dev --python 3.12 + + - name: Ruff lint + run: uv run ruff check . + + - name: Ruff format + run: uv run ruff format --check mintlayer/ tests/ examples/ + + - name: Mypy + run: uv run mypy mintlayer/ + + - name: Run tests with coverage + run: > + uv run pytest tests/ -q + --cov --cov-report=term-missing + --cov-fail-under=80 + + - name: Verify tag matches package version + run: | + version="$(uv run --no-sync python -c 'import mintlayer; print(mintlayer.__version__)')" + echo "package version: $version" + if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match package version v${version}" + exit 1 + fi + + - name: Build sdist and wheel + run: uv build + + - name: Twine metadata check + run: uvx twine check dist/* + + - name: Smoke-test wheel in a clean venv + run: | + uv venv /tmp/smoke-venv + uv pip install --python /tmp/smoke-venv/bin/python dist/*.whl + /tmp/smoke-venv/bin/python - <<'EOF' + import mintlayer + from mintlayer.wasm import Client as WasmClient, MAINNET + + assert mintlayer.__version__ + w = WasmClient() + # Keys carry a one-byte WASM ABI status prefix: priv=33, pub=34. + priv = w.make_private_key() + assert len(priv) == 33 and any(priv), f"bad private key length {len(priv)}" + pub = w.public_key_from_private_key(priv) + assert pub[1:2] in (b"\x02", b"\x03"), "pubkey not compressed SEC1" + addr = w.pubkey_to_pubkeyhash_address(pub, MAINNET) + assert isinstance(addr, str) and addr.startswith("mtc1"), f"bad address {addr!r}" + print(f"wheel OK: version={mintlayer.__version__} address={addr[:12]}...") + EOF + + - name: Upload distributions + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dist + path: dist/ + if-no-files-found: error + + publish-testpypi: + name: Publish to TestPyPI + if: github.event_name == 'push' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: testpypi + steps: + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: dist + path: dist/ + + - name: Publish + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + repository-url: https://test.pypi.org/legacy/ + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + attestations: false + + publish-pypi: + name: Publish to PyPI + if: github.event_name == 'release' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: pypi + # Only this job needs cross-run artifact download access; build runs + # project code and must not carry it. + permissions: + contents: read + actions: read + steps: + # Rebuilds are not guaranteed byte-reproducible (wheel zip timestamps), + # so instead of building again we publish the EXACT dist/ artifact that + # the tag-push run verified and uploaded to TestPyPI. + - name: Locate the tag-push publish run + id: tagrun + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF_NAME#refs/tags/}" + run_id="$(gh run list -R "$GITHUB_REPOSITORY" --workflow publish.yml --event push \ + --json databaseId,headBranch,conclusion \ + --jq ".[] | select(.headBranch == \"$tag\" and .conclusion == \"success\") | .databaseId" | head -1)" + if [ -z "$run_id" ]; then + echo "::error::no successful tag-push publish run for $tag - push the tag first and let the TestPyPI dry-run finish" + exit 1 + fi + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + + - name: Download the TestPyPI-verified artifacts + env: + GH_TOKEN: ${{ github.token }} + run: | + rm -rf dist + gh run download "${{ steps.tagrun.outputs.run_id }}" -n dist -D dist -R "$GITHUB_REPOSITORY" + ls -la dist/ + + # Integrity check for the cross-run download: only publish bytes that + # are provably identical to what TestPyPI publicly serves for this + # version (the dry-run's uploads are the trust anchor). + - name: Verify artifacts match TestPyPI + run: | + python3 - <<'EOF' + import hashlib, json, os, pathlib, sys, urllib.request + + version = os.environ["GITHUB_REF_NAME"].lstrip("v") + url = f"https://test.pypi.org/pypi/mintlayer/{version}/json" + with urllib.request.urlopen(url, timeout=30) as resp: + remote = {u["filename"]: u["digests"]["sha256"] for u in json.load(resp)["urls"]} + if not remote: + sys.exit(f"version {version} not found on TestPyPI - cut the tag first") + for dist in sorted(pathlib.Path("dist").iterdir()): + digest = hashlib.sha256(dist.read_bytes()).hexdigest() + if dist.name not in remote: + sys.exit(f"{dist.name} is not published on TestPyPI for version {version}") + if remote[dist.name] != digest: + sys.exit(f"{dist.name} does not match the TestPyPI dry-run artifact") + print("artifacts match the TestPyPI dry-run") + EOF + + - name: Publish + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + attestations: false diff --git a/.gitignore b/.gitignore index f4c91a2..a71a9a1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,20 @@ build/ .mypy_cache/ .ruff_cache/ .pytest_cache/ -.coverage +htmlcov/ .wrangler/ -uv.lock + +# secrets +.env* +!.env.example +*.pem +*.key +*.seed +*.env +*.p12 +*.pfx +id_rsa* +id_ed25519* +id_ecdsa* +secrets/ +.coverage* diff --git a/README.md b/README.md index 7d37a53..3e49b00 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,35 @@ # Mintlayer Python SDK -A Python SDK for the [Mintlayer](https://www.mintlayer.org/) blockchain. +A Python SDK for the [Mintlayer](https://www.mintlayer.org/) blockchain, +ported from the [Mintlayer Go SDK](https://github.com/mintlayer/go-sdk). ``` pip install mintlayer ``` -Requires Python 3.10+. The WASM cryptography runtime is bundled with the package. +Requires Python 3.10+. The WASM cryptography runtime (wasmtime) is bundled +with the package. -> Work in progress — ported from the [Mintlayer Go SDK](https://github.com/mintlayer/go-sdk). +--- ## Documentation | Guide | Description | |-------|-------------| -| [docs/indexer.md](docs/indexer.md) | Full indexer client reference | -| [docs/node.md](docs/node.md) | Full node client reference | -| [docs/wallet.md](docs/wallet.md) | Full wallet client reference | -| [docs/wasm.md](docs/wasm.md) | Full WASM client reference | -| [docs/transactions.md](docs/transactions.md) | Building and signing transactions without the wallet daemon | -| [docs/staking.md](docs/staking.md) | Staking pools and delegations | -| [docs/tokens.md](docs/tokens.md) | Fungible token and NFT lifecycle | +| [docs/indexer.md](docs/indexer.md) | Full indexer client reference: chain, blocks, transactions, addresses, pools, tokens, orders, statistics | +| [docs/node.md](docs/node.md) | Full node client reference: chainstate, mempool, P2P, block submission | +| [docs/wallet.md](docs/wallet.md) | Full wallet client reference: lifecycle, accounts, balances, transactions | +| [docs/wasm.md](docs/wasm.md) | Full WASM client reference: keys, addresses, inputs, outputs, signing, fees | +| [docs/transactions.md](docs/transactions.md) | Step-by-step guide to building and signing transactions without the wallet daemon | +| [docs/staking.md](docs/staking.md) | Staking pools and delegations: creation, funding, withdrawal, and read queries | +| [docs/tokens.md](docs/tokens.md) | Fungible token and NFT lifecycle: issuance, minting, freezing, authority, manual encoding | + +--- ## Overview The SDK is organised as four independent sub-clients plus a top-level `Client` -that wires them together: +that wires them together. | Module | Purpose | Default port | |---|---|---| @@ -34,24 +38,351 @@ that wires them together: | `mintlayer.wallet` | JSON-RPC 2.0 client for the wallet daemon | 3034 (mainnet) | | `mintlayer.wasm` | Cryptography & transaction-building via WASM | — | +Use the top-level client when you need multiple sub-clients, or import +sub-modules directly when you only need one. + +--- + ## Quick start ```python import mintlayer -client = mintlayer.Client(mintlayer.Config( - node_url="http://127.0.0.1:3030", - indexer_url="http://127.0.0.1:3000", - wallet_url="http://127.0.0.1:3034", -)) +client = mintlayer.Client( + mintlayer.Config( + node_url="http://127.0.0.1:3030", + indexer_url="http://127.0.0.1:3000", + wallet_url="http://127.0.0.1:3034", + ) +) +# Query the chain tip from the indexer. tip = client.indexer.get_tip() print(f"chain tip: height={tip.block_height} id={tip.block_id}") +# Optionally initialise the embedded WASM cryptography runtime (~400 ms). +# The first access triggers lazy construction; init_wasm() makes the cost +# explicit and is a no-op afterwards. client.init_wasm() + priv_key = client.wasm.make_private_key() +pub_key = client.wasm.public_key_from_private_key(priv_key) +addr = client.wasm.pubkey_to_pubkeyhash_address(pub_key, mintlayer.MAINNET) +print("address:", addr) + +client.close() # or use `with mintlayer.Client(cfg) as client:` +``` + +`Config` only constructs the sub-clients whose URL field is non-empty +(`node_url`, `indexer_url`, `wallet_url`, plus shared `username`, `password`, +`timeout`). + +--- + +## Node client (`mintlayer.node`) + +JSON-RPC 2.0 client for the Mintlayer node daemon. Supports Basic Auth for +nodes with authentication enabled. + +```python +from mintlayer.node import Client + +c = Client( + "http://127.0.0.1:3030", + username="user", # optional + password="pass", # optional + timeout=10.0, # optional, seconds +) + +# Chain state +info = c.chainstate_info() +height = c.best_block_height() +block_id = c.best_block_id() + +# Look up a block +block_hex = c.get_block(block_id) +block_json = c.get_block_json(block_id) + +# Token / order info +token_info = c.token_info("ttml1...") +order_info = c.order_info("mordr1...") + +# Mempool +c.mempool_submit_transaction(signed_tx_hex, "Untrusted") +fee_rate = c.get_fee_rate(1) + +# P2P +peer_count = c.get_peer_count() +peers = c.get_connected_peers() +``` + +Errors from the daemon are raised as `RPCError` with a numeric `code` and +`message`. + +--- + +## Indexer client (`mintlayer.indexer`) + +REST client for `api-web-server`. All paths are relative to `/api/v2`. + +```python +from mintlayer.indexer import Client, PageOpts, PoolListOpts + +c = Client("http://127.0.0.1:3000", timeout=15.0) + +# Chain +tip = c.get_tip() +block_id_at_height = c.get_block_id_at_height(100_000) + +# Block +block = c.get_block("00000000...") +tx_ids = c.get_block_transaction_ids("00000000...") + +# Transaction +tx = c.get_transaction("aabbcc...") +tx_id = c.submit_transaction(signed_tx_hex) # requires --enable-post-routes + +# Address +utxos = c.get_spendable_utxos("mtc1q...") +info = c.get_address_info("mtc1q...") + +# Pool / staking +pools = c.list_pools(PoolListOpts(sort="by_pledge")) +pool = c.get_pool("mpool1...") + +# Tokens +token = c.get_token("ttml1...") +tokens = c.find_tokens_by_ticker("MYTOKEN", PageOpts(items=10)) + +# Orders +orders = c.list_orders(PageOpts(offset=0, items=20)) +order = c.get_order("mordr1...") + +# Statistics +stats = c.get_coin_statistics() +``` + +Non-2xx responses are raised as `HTTPError` with a `status_code` and `body`. + +--- + +## Wallet client (`mintlayer.wallet`) + +JSON-RPC 2.0 client for `wallet-rpc-daemon`. The wallet daemon manages key +storage, signing, and broadcasting. + +```python +from mintlayer.wallet import ( + Amount, + ComposeParams, + Client, + IssueTokenParams, + MintParams, + SendParams, + TokenMetadata, + TokenSupply, +) + +c = Client("http://127.0.0.1:3034", username="user", password="pass") + +# Wallet lifecycle +c.open_wallet("/path/to/wallet.dat", password="") +c.sync_wallet() + +# Accounts and addresses +info = c.get_wallet_info() +addr = c.new_address(0) # account 0 +balance = c.get_balance(0) + +# Send coins (account 0, auto fee) +result = c.address_send( + SendParams( + account=0, + address="mtc1q...", + amount=Amount(atoms="100000000000"), # 1 ML + ) +) +print("tx id:", result.tx_id) + +# Token operations +issue_result = c.issue_token( + IssueTokenParams( + account=0, + destination_address=addr, + metadata=TokenMetadata( + token_ticker="MYTOKEN", + number_of_decimals=2, + metadata_uri="https://example.com/token", + token_supply=TokenSupply(type="Lockable"), + is_freezable=False, + ), + ) +) +mint_result = c.mint_tokens( + MintParams( + account=0, + token_id=issue_result.token_id, + address=addr, + amount=Amount(atoms="1000"), + ) +) + +# Staking +c.start_staking(0) +pools = c.list_owned_pools(0) + +# Compose and sign a raw transaction (cold wallet flow) +composed = c.compose_transaction(ComposeParams(...)) +signed = c.sign_raw_transaction(0, composed.hex) +submit_result = c.submit_transaction(signed.hex) + +c.close_wallet() +``` + +--- + +## WASM client (`mintlayer.wasm`) + +Cryptographic primitives and binary transaction encoding via an embedded +WebAssembly module. Instantiation takes ~400 ms; create the client once per +process. + +```python +from mintlayer.wasm import ( + Amount, + SOURCE_TRANSACTION, + SIGHASH_ALL, + Network, + TxAdditionalInfo, +) +from mintlayer.wasm import Client as WasmClient + +c = WasmClient() + +# Key derivation (BIP-44 path 44'/mintlayer_coin_type'/0') +mnemonic = ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +) +account_key = c.make_default_account_privkey(mnemonic, Network.MAINNET) +recv_key = c.make_receiving_address(account_key, 0) +pub_key = c.public_key_from_private_key(recv_key) +addr = c.pubkey_to_pubkeyhash_address(pub_key, Network.MAINNET) + +# Transaction building +src_id = c.encode_outpoint_source_id(tx_id_bytes, SOURCE_TRANSACTION) +input_ = c.encode_input_for_utxo(src_id, output_index) + +output = c.encode_output_transfer( + Amount(atoms="100000000000"), # 1 ML + dest_addr, + Network.MAINNET, +) + +tx = c.encode_transaction(input_, output, 0) # flags = 0 +tx_id = c.get_transaction_id(tx, True) + +# Signing +witness = c.encode_witness( + SIGHASH_ALL, + recv_key, + addr, + tx, + utxo_bytes, # see docs/transactions.md for the encoding details + 0, # input index + TxAdditionalInfo(), + block_height, + Network.MAINNET, +) +signed_tx = c.encode_signed_transaction(tx, witness) + +c.close() +``` + +See [examples/send_coins.py](examples/send_coins.py) for a complete end-to-end +transaction flow, and [docs/transactions.md](docs/transactions.md) for the full +walkthrough. + +--- + +## Top-level client + +`mintlayer.Client` constructs only the sub-clients whose URL is non-empty. + +```python +# Node + WASM only — no wallet or indexer client is created. +client = mintlayer.Client( + mintlayer.Config( + node_url="http://127.0.0.1:3030", + username="user", + password="pass", + ) +) + +# Call init_wasm() before using client.wasm (raises WasmError otherwise). +client.init_wasm() ``` +Convenience re-exports (`mintlayer.Amount`, `mintlayer.Network`, +`mintlayer.MAINNET`, …) mirror the Go SDK's aliases, so callers that only +import the top-level package do not need to also import `mintlayer.wasm`. + +--- + +## Examples + +| Example | Description | +|---|---| +| [examples/send_coins.py](examples/send_coins.py) | Derive key → fetch UTXOs → build, sign, and submit a transaction | +| [examples/issue_token.py](examples/issue_token.py) | Issue a fungible token and mint an initial supply via the wallet daemon | + +--- + +## Amounts + +All coin and token amounts use the `Amount` type, which stores the value as a +decimal string of _atoms_ — the smallest indivisible unit. **1 ML = +100,000,000,000 atoms** (11 decimal places). + +```python +from mintlayer.wasm import Amount + +one = Amount.from_atoms("100000000000") # 1 ML +zero = Amount.zero() +print(one.atoms) # "100000000000" +``` + +The indexer and wallet clients use their own `Amount` dataclass with both +`atoms` and `decimal` fields populated by the server. + +--- + +## Networks + +| Constant | Value | Use | +|---|---|---| +| `MAINNET` | 0 | Production network | +| `TESTNET` | 1 | Public test network | +| `REGTEST` | 2 | Local regression testing | +| `SIGNET` | 3 | Signet | + +Pass the network constant (`mintlayer.Network` enum members or the +module-level aliases) to any function that derives addresses or encodes +transactions. + +--- + +## Error handling + +- `mintlayer.node.RPCError` — JSON-RPC error from the node daemon (`code`, + `message`); transport failures raise `JSONRPCError` +- `mintlayer.wallet.RPCError` — JSON-RPC error from the wallet daemon +- `mintlayer.indexer.HTTPError` — non-2xx HTTP response from the indexer + (`status_code`, `body`); transport failures raise `IndexerError` +- `mintlayer.wasm.WasmError` — WASM operation failures, with a message + prefixed by `mintlayer:` + +--- + ## License MIT — see [LICENSE](LICENSE). diff --git a/docs/DELETION_LOG.md b/docs/DELETION_LOG.md new file mode 100644 index 0000000..6c1d7b8 --- /dev/null +++ b/docs/DELETION_LOG.md @@ -0,0 +1,60 @@ +# Code Deletion Log + +## 2026-09-17 Refactor Session (DRY cleanup of `mintlayer/`) + +### Duplicates Consolidated + +- `mintlayer/node/_core.py` + `mintlayer/wallet/_core.py` — the `_call`, + `_call_ignore` and `close` helpers were duplicated across both packages. + Hoisted into a shared `BaseJSONRPCClient` mixin base in + `mintlayer/_jsonrpc.py:112`. `_NodeCore` and `_WalletCore` now subclass it + and keep only their package-specific typed decoders (`_call_str` / + `_call_opt_int` / ... vs `_call_model` / `_call_model_list`). +- `mintlayer/indexer/_core.py` (deleted) — `IndexerCore` was a pure + pass-through over `_http.IndexerHTTP` (`_get` → `get`, `_post_text` → + `post_text`). The nine endpoint mixins now subclass `IndexerHTTP` + directly and call the transport methods; the `_seg` path-segment encoder + moved to `mintlayer/indexer/_http.py:31` next to the URL construction it + serves. + +### Unused Files Deleted + +- `mintlayer/indexer/_core.py` — replaced by direct `IndexerHTTP` base class + (see above). + +### Unused Exports Removed + +- `mintlayer/indexer/block.py` — dropped the `Transaction` re-export + (`# noqa: F401`). Nothing imported `Transaction` from this module; the + public surface path `mintlayer.indexer.Transaction` (via `__init__.py`) + is unchanged. Go parity check: `go-sdk/indexer/block.go` never touches + `Transaction`. +- `mintlayer/node/client.py` / `mintlayer/wallet/client.py` — removed the + redundant `# noqa: F401` markers on the `JSONRPCError` imports; both + modules list it in `__all__`, so the suppression was dead (RUF100 not + enabled, hence undetected by lint). +- `mintlayer/node/_core.py` — replaced the function-local + `from .types import Amount` inside `_call_opt_amount` with a module-level + import (no cycle: `node/types.py` has no intra-package imports), and + tightened the return annotation `Any` → `Amount | None`. + +### Deliberately Left Alone + +- `from_json`/`to_json` boilerplate in `node|indexer|wallet/types.py` — + explicit field-by-field wire mapping mirrors the Go structs; wire-format + fidelity beats DRY here. +- WASM mixins (`mintlayer/wasm/`) — 1:1 mapping to Go files/exports is + intentional; audited for dead branches, none found. + +### Impact + +- Files deleted: 1 +- Net lines removed: 33 (6133 → 6100; +93/−126) +- Public API surface: unchanged (all `__init__.py` re-exports intact) + +### Testing + +- `uv run pytest tests/ -q` → 238 passed (zero test modifications) +- `uv run ruff check .` → All checks passed +- `uv run ruff format mintlayer/` → 50 files already formatted +- `uv run mypy mintlayer/` → no issues found in 50 source files diff --git a/docs/indexer.md b/docs/indexer.md new file mode 100644 index 0000000..1522a36 --- /dev/null +++ b/docs/indexer.md @@ -0,0 +1,569 @@ +# Indexer client + +The `mintlayer.indexer` module is a REST client for the Mintlayer indexer +(`api-web-server`). All paths are relative to the `/api/v2` base appended to +the configured URL. + +```python +from mintlayer.indexer import Client + +c = Client( + "http://127.0.0.1:3000", + timeout=15.0, # optional, seconds (default 30.0) + session=None, # optional requests.Session (client owns it otherwise) +) +``` + +**Default port:** 3000 (mainnet), 13000 (testnet). + +Non-2xx HTTP responses raise `HTTPError`; transport/decode failures raise +`IndexerError`: + +```python +from mintlayer.indexer import HTTPError, IndexerError + +try: + info = c.get_address_info("mtc1q...") +except HTTPError as e: + print(e.status_code, e.body) # e.g. 404 "address not found" +except IndexerError as e: + ... # connection failure or malformed JSON body +``` + +The client is safe for concurrent use from multiple threads and supports the +context-manager protocol (`with Client(...) as c: ...`). + +--- + +## Pagination + +List endpoints accept a `PageOpts` dataclass: + +```python +@dataclass(frozen=True) +class PageOpts: + offset: int = 0 # default: 0 + items: int = 0 # default: 10 (server-side default) +``` + +**Zero-omission rule:** zero values are omitted from the query string entirely, +so the server defaults apply. Only positive values are sent: + +```python +from mintlayer.indexer import PageOpts + +c.list_transactions(PageOpts(offset=20, items=50)) +c.list_orders() # server defaults +``` + +--- + +## Chain + +### `get_tip` + +```python +def get_tip(self) -> ChainTip: ... +``` + +Returns the highest confirmed block. + +```python +@dataclass(frozen=True) +class ChainTip: + block_height: int + block_id: str +``` + +### `get_genesis` + +```python +def get_genesis(self) -> GenesisInfo: ... +``` + +Returns genesis block information (`block_id`, `genesis_message`, +`timestamp`, `utxos`). + +### `get_block_id_at_height` + +```python +def get_block_id_at_height(self, height: int) -> str | None: ... +``` + +Returns the block ID at a given height, or `None` if the indexer responds +with JSON null. Raises a 404 `HTTPError` if no block exists at that height +(for example, when querying a height beyond the current tip). + +--- + +## Blocks + +### `get_block` + +```python +def get_block(self, block_id: str) -> Block: ... +``` + +Returns the full block: `height`, `header` (`BlockHeader`) and `body` +(`BlockBody` with `reward` and `transactions`). + +### `get_block_header` + +```python +def get_block_header(self, block_id: str) -> BlockHeader: ... +``` + +Returns only the block header (`previous_block_id`, `timestamp`, `merkle_root`, +`witness_merkle_root`, `consensus_data`). Cheaper than `get_block` when you do +not need transaction data. + +### `get_block_reward` + +```python +def get_block_reward(self, block_id: str) -> list: ... +``` + +Returns the reward outputs of a block as raw decoded JSON values. + +### `get_block_transaction_ids` + +```python +def get_block_transaction_ids(self, block_id: str) -> list[str]: ... +``` + +Returns the transaction IDs included in a block. Use this to page through block +contents without fetching full transaction data. + +--- + +## Transactions + +### `list_transactions` + +```python +def list_transactions(self, opts: PageOpts | None = None) -> list[Transaction]: ... +``` + +Returns a paginated list of confirmed transactions across the entire chain. + +### `get_transaction` + +```python +def get_transaction(self, tx_id: str) -> Transaction: ... +``` + +Returns a transaction by ID. The `block_id`, `timestamp`, and `confirmations` +fields are empty strings for unconfirmed transactions. + +```python +@dataclass(frozen=True) +class Transaction: + id: str + inputs: Any # raw decoded JSON + outputs: Any # raw decoded JSON + block_id: str + timestamp: str + confirmations: str +``` + +### `get_transaction_merkle_path` + +```python +def get_transaction_merkle_path(self, tx_id: str) -> MerklePath: ... +``` + +Returns the Merkle inclusion proof for a transaction (`block_id`, +`transaction_index`, `merkle_root`, `path`). Raises a 404 `HTTPError` if the +transaction is not yet in a block. + +### `get_transaction_output` + +```python +def get_transaction_output(self, tx_id: str, output_index: int) -> Any: ... +``` + +Returns a single output from a transaction as raw decoded JSON. The shape is +determined by the `"type"` field. Common types: `"Transfer"`, +`"LockThenTransfer"`, `"Burn"`, `"CreateStakePool"`, `"CreateDelegationId"`, +`"DelegateStaking"`, `"IssueFungibleToken"`, `"IssueNft"`, `"DataDeposit"`, +`"Htlc"`, `"CreateOrder"`. + +### `submit_transaction` + +```python +def submit_transaction(self, signed_tx_hex: str) -> str: ... +``` + +Submits a hex-encoded signed transaction to the network. Returns the +transaction ID on success. The hex string is POSTed verbatim as +`text/plain` — the one non-GET route in the client. + +**Requires** the indexer to be started with `--enable-post-routes`. + +--- + +## Addresses + +### `get_address_info` + +```python +def get_address_info(self, address: str) -> AddressInfo: ... +``` + +Returns balance and transaction history for a bech32m address. Raises a 404 +`HTTPError` if the address has no on-chain history. + +```python +@dataclass(frozen=True) +class AddressInfo: + coin_balance: Amount + locked_coin_balance: Amount + transaction_history: list[str] + tokens: list[TokenBalance] # TokenBalance(token_id, amount) +``` + +### `get_spendable_utxos` + +```python +def get_spendable_utxos(self, address: str) -> list[UTXO]: ... +``` + +Returns confirmed, unspent UTXOs that can be spent immediately. + +### `get_all_utxos` + +```python +def get_all_utxos(self, address: str) -> list[UTXO]: ... +``` + +Returns all UTXOs including those that are locked or otherwise unspendable. + +```python +@dataclass(frozen=True) +class UTXO: + outpoint: UTXOOutpoint # UTXOOutpoint(source_id: str, index: int) + output: Any # raw JSON; note the payload key on the wire is "utxo" +``` + +### `get_delegations` + +```python +def get_delegations(self, address: str) -> list[DelegationInfo]: ... +``` + +Returns all staking delegations owned by an address. + +```python +@dataclass(frozen=True) +class DelegationInfo: + delegation_id: str + pool_id: str + next_nonce: int + spend_destination: str + balance: Amount +``` + +### `get_token_authority` + +```python +def get_token_authority(self, address: str) -> list[str]: ... +``` + +Returns the IDs (bech32m) of fungible tokens for which the address holds +authority (can mint, freeze, etc.). + +--- + +## Pools and delegations + +### `list_pools` + +```python +def list_pools(self, opts: PoolListOpts | None = None) -> list[Pool]: ... +``` + +Returns staking pools with optional pagination. The `sort` field accepts: + +- `"by_height"` (server default): newest pools first +- `"by_pledge"`: largest staker balance first + +```python +@dataclass(frozen=True) +class PoolListOpts: + offset: int = 0 + items: int = 0 + sort: str = "" # omitted from the query when empty (zero-omission rule) +``` + +```python +c.list_pools(PoolListOpts(sort="by_pledge", items=20)) +``` + +### `get_pool` + +```python +def get_pool(self, pool_id: str) -> Pool: ... +``` + +Returns a single staking pool by its bech32m pool ID. + +```python +@dataclass(frozen=True) +class Pool: + pool_id: str + decommission_destination: str + staker_balance: Amount + margin_ratio_per_thousand: float + cost_per_block: Amount + vrf_public_key: str + delegations_balance: Amount +``` + +### `get_pool_block_stats` + +```python +def get_pool_block_stats(self, pool_id: str, from_time: datetime, to_time: datetime) -> int: ... +``` + +Returns the number of blocks produced by a pool in the half-open interval +`[from_time, to_time)`. Datetimes are converted to Unix-seconds query +parameters. + +```python +from datetime import datetime, timedelta + +count = c.get_pool_block_stats( + "mpool1...", + datetime.now() - timedelta(hours=24), + datetime.now(), +) +``` + +### `get_delegation` + +```python +def get_delegation(self, delegation_id: str) -> Delegation: ... +``` + +Returns a single delegation by its bech32m delegation ID. + +```python +@dataclass(frozen=True) +class Delegation: + delegation_id: str + pool_id: str + next_nonce: int + spend_destination: str + balance: Amount + creation_block_height: int +``` + +### `get_pool_delegations` + +```python +def get_pool_delegations(self, pool_id: str) -> list[PoolDelegation]: ... +``` + +Returns all delegations in a pool. Each entry includes the +`creation_block_height` in addition to the standard delegation fields (but no +`pool_id`, since it is implied by the query). + +--- + +## Tokens and NFTs + +### `list_tokens` + +```python +def list_tokens(self, opts: PageOpts | None = None) -> list[str]: ... +``` + +Returns a paginated list of fungible token IDs (bech32m). + +### `get_token` + +```python +def get_token(self, token_id: str) -> TokenInfo: ... +``` + +Returns full information about a fungible token. + +```python +@dataclass(frozen=True) +class TokenInfo: + authority: str + is_locked: bool + circulating_supply: Amount + token_ticker: str + metadata_uri: str + number_of_decimals: int + total_supply: Any # raw JSON + frozen: bool + is_token_unfreezable: bool | None # non-None only when frozen + is_token_freezable: bool | None # non-None only when not frozen + next_nonce: int +``` + +### `get_token_transactions` + +```python +def get_token_transactions(self, token_id: str, opts: PageOpts | None = None) -> list[TokenTx]: ... +``` + +Returns the transaction history for a token (issuance, mints, transfers, +burns) as `TokenTx(tx_global_index, tx_id)` entries. + +### `find_tokens_by_ticker` + +```python +def find_tokens_by_ticker(self, ticker: str, opts: PageOpts | None = None) -> list[str]: ... +``` + +Returns token IDs whose ticker matches the given string. Tickers are not +unique, so this may return multiple results. + +### `get_nft` + +```python +def get_nft(self, token_id: str) -> NFTInfo: ... +``` + +Returns information about an NFT: `owner`, `token_id`, and `metadata` +(`NFTMetadata` with `creator`, `name`, `description`, `ticker`, `icon_uri`, +`additional_metadata_uri`, `media_uri`, `media_hash` — the URI/creator fields +are `None` when unset). + +--- + +## Orders + +### `list_orders` + +```python +def list_orders(self, opts: PageOpts | None = None) -> list[Order]: ... +``` + +Returns active orders. + +### `get_order` + +```python +def get_order(self, order_id: str) -> Order: ... +``` + +Returns a single order by its bech32m order ID. + +```python +@dataclass(frozen=True) +class Order: + order_id: str + conclude_destination: str + give_currency: Any # raw JSON, "type" of "Coin" or "Token" + initially_given: Amount + give_balance: Amount + ask_currency: Any # raw JSON, "type" of "Coin" or "Token" + initially_asked: Amount + ask_balance: Amount + nonce: int +``` + +### `list_orders_by_pair` + +```python +def list_orders_by_pair( + self, ask_currency: str, give_currency: str, opts: PageOpts | None = None +) -> list[Order]: ... +``` + +Returns orders filtered by a trading pair. Pass `"ML"` (the coin ticker) or a +bech32m token ID for each currency; the request path is +`/order/pair/{ask}_{give}`. + +--- + +## Statistics + +### `get_coin_statistics` + +```python +def get_coin_statistics(self) -> CoinStats: ... +``` + +Returns supply statistics for the native ML coin. + +```python +@dataclass(frozen=True) +class CoinStats: + circulating_supply: Amount + preminted: Amount + burned: Amount + staked: Amount +``` + +### `get_token_statistics` + +```python +def get_token_statistics(self, token_id: str) -> CoinStats: ... +``` + +Returns the same statistics for a fungible token. + +### `get_fee_rate` + +```python +def get_fee_rate(self, in_top_x_mb: int = 0) -> str: ... +``` + +Returns the current fee rate in atoms per kilobyte (a decimal string) needed to +place a transaction in the top `in_top_x_mb` megabytes of the mempool priority +queue. + +**Default-parameter quirk:** when `in_top_x_mb` is `0` (the default) the query +parameter is omitted entirely and the server default (5 MB) applies. + +```python +rate = int(c.get_fee_rate(1)) # atoms per KB, top 1 MB of the mempool +``` + +--- + +## Lenient numeric parsing + +The indexer documents several fields as integers but the server sometimes +serialises them as strings — and `margin_ratio_per_thousand` even arrives as a +string with a trailing `%` (e.g. `"10.0%"`). The client parses these +transparently (`mintlayer.indexer.number`): + +- `parse_uint64` — accepts a bare JSON number or a decimal string + (`block_height`, `next_nonce`, `number_of_decimals`, …), returns `int`. +- `parse_per_thousand` — accepts a bare number, a decimal string, or a string + with a trailing `%`; returns `float` (used for `Pool.margin_ratio_per_thousand`). + +Malformed values raise `IndexerError`; malformed payloads in `from_json` are +wrapped as `IndexerError` too rather than leaking `KeyError`/`TypeError`. + +--- + +## Amounts + +The `Amount` type carries both raw atoms and a human-readable decimal: + +```python +@dataclass(frozen=True) +class Amount: + atoms: str + decimal: str +``` + +All values populated by the server include both fields. When constructing +amounts to send to the server, you only need to set `atoms` +(`Amount(atoms="100000000000")`). + +--- + +## Related + +- [node.md](node.md) — node daemon JSON-RPC client +- [transactions.md](transactions.md) — building and signing transactions to submit here +- [staking.md](staking.md) — pools and delegations +- [tokens.md](tokens.md) — token and NFT lifecycle diff --git a/docs/node.md b/docs/node.md new file mode 100644 index 0000000..194e331 --- /dev/null +++ b/docs/node.md @@ -0,0 +1,542 @@ +# Node client + +The `mintlayer.node` module is a JSON-RPC 2.0 client for the Mintlayer node +daemon. Every method is synchronous and thread-safe. + +```python +from mintlayer.node import Client + +c = Client( + "http://127.0.0.1:3030", + username="user", # optional; Basic Auth applied only when set + password="pass", # optional + timeout=10.0, # optional, seconds (default 30.0) + session=None, # optional requests.Session (client owns it otherwise) +) +``` + +**Default ports:** 3030 (mainnet), 13030 (testnet). + +> **Transport security:** Basic Auth credentials travel in cleartext over +> plain `http://`. Bind the daemon to localhost or front it with an HTTPS +> reverse proxy / SSH tunnel when connecting over a network. + + +There is no per-call cancellation: configure `timeout` on the client (Go's +per-call `context` has no direct `requests` equivalent). Errors from the daemon +raise `RPCError`; transport and decode failures raise `JSONRPCError`: + +```python +from mintlayer.node import RPCError, JSONRPCError + +try: + height = c.best_block_height() +except RPCError as e: + print(e.code, e.message) +except JSONRPCError as e: + ... # HTTP failure or malformed response body +``` + +The HTTP status code is never inspected — a JSON-RPC `error` object in the body +is the error contract. JSON `null` results map to `None` for every +"not-found"-style method (`block_id_at_height`, `stake_pool_balance`, …). + +--- + +## Chain state + +### `chainstate_info` + +```python +def chainstate_info(self) -> ChainstateInfo: ... +``` + +Returns a summary of the current chain state. + +```python +@dataclass(frozen=True) +class ChainstateInfo: + best_block_height: int + best_block_id: str + best_block_timestamp: Timestamp # Timestamp(timestamp: int), Unix seconds + median_time: Timestamp + is_initial_block_download: bool +``` + +### `best_block_id` + +```python +def best_block_id(self) -> str: ... +``` + +Returns the hex block ID of the current tip. + +### `best_block_height` + +```python +def best_block_height(self) -> int: ... +``` + +Returns the height of the current tip. + +### `block_id_at_height` + +```python +def block_id_at_height(self, height: int) -> str | None: ... +``` + +Returns the block ID at a given height, or `None` if no block exists at that +height. + +### `block_height_in_main_chain` + +```python +def block_height_in_main_chain(self, block_id: str) -> int | None: ... +``` + +Returns the mainchain height for a block ID, or `None` if the block is not on +the main chain. + +### `get_block` + +```python +def get_block(self, block_id: str) -> str | None: ... +``` + +Returns the hex-encoded raw block (`None` if unknown; the genesis block is not +retrievable). + +### `get_block_json` + +```python +def get_block_json(self, block_id: str) -> Any: ... +``` + +Returns the block as decoded JSON. Useful for inspection without custom +deserialization. + +### `get_mainchain_blocks` + +```python +def get_mainchain_blocks(self, from_height: int, max_count: int) -> list[str]: ... +``` + +Returns up to `max_count` mainchain block IDs starting at `from_height`. + +### `get_utxo` + +```python +def get_utxo(self, outpoint: Outpoint) -> Any: ... +``` + +Returns the output at a given outpoint as decoded JSON (`None` if +spent/unknown). + +```python +@dataclass(frozen=True) +class Outpoint: + source_id: OutpointSourceID + index: int + + +@dataclass +class OutpointSourceID: + type: str # "Transaction" or "BlockReward" + content: Any # {"tx_id": ""} or {"block_id": ""} +``` + +Build the `content` payloads with the helpers: + +```python +from mintlayer.node import Outpoint, OutpointSourceID, tx_source_content, block_source_content + +op = Outpoint( + source_id=OutpointSourceID(type="Transaction", content=tx_source_content(tx_id)), + index=0, +) +utxo = c.get_utxo(op) +``` + +### `submit_block` + +```python +def submit_block(self, block_hex: str) -> None: ... +``` + +Submits a hex-encoded block. Used by block producers. + +--- + +## Pool and delegation queries + +### `stake_pool_balance` + +```python +def stake_pool_balance(self, pool_address: str) -> Amount | None: ... +``` + +Returns the total balance of a pool (staker pledge plus all delegations). +Returns `None` if the pool is not found. + +### `staker_balance` + +```python +def staker_balance(self, pool_address: str) -> Amount | None: ... +``` + +Returns the staker's own balance, excluding delegations. Returns `None` if the +pool is not found. + +### `pool_decommission_destination` + +```python +def pool_decommission_destination(self, pool_address: str) -> str | None: ... +``` + +Returns the address that receives funds when the pool is decommissioned. + +### `delegation_share` + +```python +def delegation_share(self, pool_address: str, delegation_address: str) -> Amount | None: ... +``` + +Returns the amount owned by a specific delegation in a pool. + +--- + +## Token and order info + +Amounts are decimal atom strings — **1 ML = 100,000,000,000 atoms** (11 +decimal places). `Amount` is a frozen dataclass with a single `atoms: str` +field. + +### `token_info` + +```python +def token_info(self, token_id: str) -> TokenInfo | None: ... +``` + +Returns on-chain token metadata (`None` if unknown). + +```python +@dataclass +class TokenInfo: + type: str # "FungibleToken" or "NonFungibleToken" + content: Any # raw decoded JSON +``` + +### `tokens_info` + +```python +def tokens_info(self, token_ids: list[str]) -> list[TokenInfo]: ... +``` + +Batch version of `token_info`. More efficient than calling `token_info` in a +loop. + +### `order_info` + +```python +def order_info(self, order_id: str) -> OrderInfo | None: ... +``` + +Returns the current state of an order. + +```python +@dataclass +class OrderInfo: + conclude_key: str + initially_asked: Any + initially_given: Any + ask_balance: Amount + give_balance: Amount + nonce: int | None # None for active orders (daemon sends null) + is_frozen: bool +``` + +Quirk: `nonce` is `None` for active orders — the daemon sends JSON `null` for +the field. This fixes a known Go SDK incompatibility, where the `null` broke +`uint64` decoding. + +### `orders_info_by_currencies` + +```python +def orders_info_by_currencies( + self, ask: Currency | None, give: Currency | None +) -> dict[str, OrderInfo]: ... +``` + +Returns all orders matching the given currency pair, as a dict from order ID to +`OrderInfo`. Pass `None` for either currency to match any (both keys are always +sent; `None` serialises as JSON `null`). + +```python +@dataclass(frozen=True) +class Currency: + type: str # "Coin" or "Token" + content: str | None # bech32 token ID when type is "Token" +``` + +Construct with the helpers: + +```python +from mintlayer.node import Currency + +orders = c.orders_info_by_currencies(Currency.coin(), Currency.token("ttml1...")) +any_coin = c.orders_info_by_currencies(None, Currency.coin()) +``` + +--- + +## Mempool + +### `contains_tx` + +```python +def contains_tx(self, tx_id: str) -> bool: ... +``` + +Returns `True` if the mempool contains the transaction. + +### `contains_orphan_tx` + +```python +def contains_orphan_tx(self, tx_id: str) -> bool: ... +``` + +Returns `True` if the orphan pool contains the transaction. + +### `get_transaction` + +```python +def get_transaction(self, tx_id: str) -> MempoolTx | None: ... +``` + +Returns a mempool transaction (`None` if not present). + +```python +@dataclass(frozen=True) +class MempoolTx: + id: str + status: str + transaction: str +``` + +### `mempool_submit_transaction` + +```python +def mempool_submit_transaction(self, tx_hex: str, trust_policy: TrustPolicy | str) -> None: ... +``` + +Submits a transaction to the local mempool only, without broadcasting to peers. +Use `TrustPolicy.UNTRUSTED` for transactions you constructed yourself; use +`TrustPolicy.TRUSTED` to skip some fee checks. A plain string is accepted too: + +```python +from mintlayer.node import TrustPolicy + +c.mempool_submit_transaction(signed_hex, TrustPolicy.UNTRUSTED) +``` + +```python +class TrustPolicy(str, enum.Enum): + TRUSTED = "Trusted" + UNTRUSTED = "Untrusted" +``` + +### `get_fee_rate` + +```python +def get_fee_rate(self, in_top_x_mb: int) -> FeeRate | None: ... +``` + +Returns the fee rate needed to land in the top `in_top_x_mb` megabytes of the +mempool. + +```python +@dataclass(frozen=True) +class FeeRate: + amount_per_kb: Amount # atoms per kilobyte +``` + +### `get_fee_rate_points` + +```python +def get_fee_rate_points(self) -> list[FeeRatePoint]: ... +``` + +Returns the mempool fee-rate curve as a list of (size, rate) pairs. + +Wire quirk: the daemon sends each point as a two-element array +`[size, {"amount_per_kb": {...}}]`, decoded into: + +```python +@dataclass(frozen=True) +class FeeRatePoint: + size: int + rate: FeeRate +``` + +### `memory_usage` + +```python +def memory_usage(self) -> int: ... +``` + +Returns the current mempool memory usage in bytes. + +--- + +## P2P + +### `get_peer_count` + +```python +def get_peer_count(self) -> int: ... +``` + +Returns the number of currently connected peers. + +### `get_connected_peers` + +```python +def get_connected_peers(self) -> list[PeerInfo]: ... +``` + +Returns details about all connected peers. + +```python +@dataclass(frozen=True) +class PeerInfo: + peer_id: int + address: str + peer_role: str + ban_score: int + user_agent: str + software_version: str + ping_wait: int | None = None + ping_last: int | None = None + ping_min: int | None = None + last_tip_block_time: int | None = None +``` + +### `get_bind_addresses` + +```python +def get_bind_addresses(self) -> list[str]: ... +``` + +Returns the addresses the node is listening on for P2P connections. + +### `add_reserved_node` + +```python +def add_reserved_node(self, addr: str) -> None: ... +``` + +Adds a persistent peer that the node always attempts to reconnect to. + +### `remove_reserved_node` + +```python +def remove_reserved_node(self, addr: str) -> None: ... +``` + +Removes a persistent peer. + +### `connect` + +```python +def connect(self, addr: str) -> None: ... +``` + +Makes a one-time connection attempt to a peer address. + +### `disconnect` + +```python +def disconnect(self, peer_id: int) -> None: ... +``` + +Closes the connection to a peer by ID. + +### `list_banned` + +```python +def list_banned(self) -> list[BannedPeer]: ... +``` + +Returns the list of banned peers. + +Wire quirk: each entry is a two-element array +`["
", {"time": [secs, nanos]}]`, decoded into: + +```python +@dataclass(frozen=True) +class BannedPeer: + address: str + ban_time: tuple[int, int] # (seconds, nanoseconds) +``` + +### `ban` + +```python +def ban(self, address: str, duration: timedelta) -> None: ... +``` + +Bans a peer for the specified duration. Durations are `datetime.timedelta` +values, split into the daemon's `[seconds, nanoseconds]` wire form: + +```python +from datetime import timedelta + +c.ban("192.0.2.1", timedelta(hours=24)) +``` + +### `unban` + +```python +def unban(self, address: str) -> None: ... +``` + +Removes a peer from the ban list. + +### `p2p_submit_transaction` + +```python +def p2p_submit_transaction(self, tx_hex: str, trust_policy: TrustPolicy | str) -> None: ... +``` + +Submits a transaction to the mempool and broadcasts it to peers. This is the +normal path for publishing a transaction to the network (the alternative — the +indexer's `submit_transaction` — requires `--enable-post-routes`, see +[indexer.md](indexer.md)). + +--- + +## Node management + +### `node_version` + +```python +def node_version(self) -> str: ... +``` + +Returns the node software version string. + +### `node_shutdown` + +```python +def node_shutdown(self) -> None: ... +``` + +Initiates a graceful node shutdown. + +--- + +## Related + +- [indexer.md](indexer.md) — read-only chain queries and `submit_transaction` +- [transactions.md](transactions.md) — building and signing transactions +- [wallet.md](wallet.md) — the wallet daemon client diff --git a/docs/staking.md b/docs/staking.md new file mode 100644 index 0000000..a95e374 --- /dev/null +++ b/docs/staking.md @@ -0,0 +1,317 @@ +# Staking and delegations + +Mintlayer uses Proof of Stake. ML holders can earn staking rewards by either: + +- Running a **staking pool** directly (requires a node, VRF key, and pledge) +- **Delegating** to an existing pool (no node required) + +The wallet daemon manages both flows. The indexer provides read-only access to +pool and delegation state. The `mintlayer.wasm` module provides low-level +primitives for manual transaction flows. + +--- + +## Staking pools (wallet daemon) + +### Creating a pool + +```python +from mintlayer.wallet import Amount, Client, CreatePoolParams + +wc = Client("http://127.0.0.1:3034") + +result = wc.create_stake_pool( + CreatePoolParams( + account=0, + amount=Amount(atoms="40000000000000"), # minimum pledge + cost_per_block=Amount(atoms="100000000"), # flat fee per block + margin_ratio_per_thousand="100", # 10% staker cut + decommission_address=decommission_addr, + # staker_address and vrf_public_key default to wallet-managed keys + # when None (sent as JSON null) + ) +) +print(f"tx id: {result.tx_id}") +``` + +`margin_ratio_per_thousand` is the staker's cut of block rewards expressed in +thousandths, as a **string**: `"100"` = 10%, `"50"` = 5%, `"1000"` = 100%. + +`cost_per_block` is a flat atom amount deducted from rewards before the margin +split. Delegators receive the remainder proportionally to their stake. + +The result is a `SendResult(tx_id, fees, broadcasted)` — the pool ID can be +predicted before broadcasting with `get_pool_id` (see below). + +### Starting and stopping block production + +```python +wc.start_staking(0) # account 0 + +status = wc.get_staking_status(0) +print(status) # StakingStatus.ACTIVE ("Staking") or StakingStatus.INACTIVE ("NotStaking") + +wc.stop_staking(0) +``` + +`stop_staking` stops block production but does not decommission the pool. +Delegations and staked funds remain untouched. + +### Listing owned pools + +```python +pools = wc.list_owned_pools(0) +for p in pools: + print(f"pool {p.pool_id} pledge={p.pledge.atoms} balance={p.balance.atoms}") +``` + +### Pool balance + +```python +balance = wc.get_pool_balance(0, "mpool1...") # Amount +``` + +Quirk: matching the daemon route, the `account` argument is accepted for API +consistency but **not sent on the wire** — the route only uses `pool_id`. + +### Decommissioning a pool + +```python +from mintlayer.wallet import DecommissionParams + +result = wc.decommission_stake_pool( + DecommissionParams( + account=0, + pool_id="mpool1...", + output_address=return_addr, + ) +) +``` + +After decommissioning, the pledge is returned to `output_address` after the +maturity period. Delegators must withdraw their funds separately. + +### Reading pool state from the indexer + +```python +from datetime import datetime, timedelta + +from mintlayer.indexer import Client, PoolListOpts + +idx = Client("http://127.0.0.1:3000") + +# All pools sorted by pledge size +pools = idx.list_pools(PoolListOpts(sort="by_pledge")) + +# Single pool +pool = idx.get_pool("mpool1...") +print(f"staker balance: {pool.staker_balance.decimal}") +print(f"delegations: {pool.delegations_balance.decimal}") + +# Blocks produced in the last 24 hours +count = idx.get_pool_block_stats( + "mpool1...", + datetime.now() - timedelta(hours=24), + datetime.now(), +) + +# All delegations in the pool +delegations = idx.get_pool_delegations("mpool1...") +``` + +--- + +## Delegations (wallet daemon) + +### Creating a delegation + +A delegation ID is tied to a pool and an owner address. You create the +delegation record first, then fund it separately. + +```python +from mintlayer.wallet import Amount, CreateDelegationParams, DelegateParams + +# Step 1: create the delegation +create_result = wc.create_delegation( + CreateDelegationParams( + account=0, + address=owner_addr, # address that can withdraw funds + pool_id="mpool1...", + ) +) +print(f"delegation id: {create_result.delegation_id}") + +# Step 2: fund the delegation (wait for the creation tx to confirm first) +delegate_result = wc.delegate_staking( + DelegateParams( + account=0, + amount=Amount(atoms="10000000000000"), # 100 ML + delegation_id=create_result.delegation_id, + ) +) +``` + +You can send multiple `delegate_staking` transactions to the same delegation to +increase your stake. + +### Withdrawing from a delegation + +```python +from mintlayer.wallet import WithdrawParams + +result = wc.withdraw_from_delegation( + WithdrawParams( + account=0, + address=recipient_addr, + amount=Amount(atoms="5000000000000"), # 50 ML + delegation_id="mdelg1...", + ) +) +``` + +Withdrawn funds arrive at `address` after the lock period (determined by +consensus rules). + +### Listing delegations + +```python +delegations = wc.list_delegations(0) +for d in delegations: + print(f"delegation {d.delegation_id} pool={d.pool_id} balance={d.balance.atoms}") +``` + +### Reading delegation state from the indexer + +```python +# All delegations owned by an address +delegation_infos = idx.get_delegations("mtc1qowner...") + +# Single delegation by ID +delegation = idx.get_delegation("mdelg1...") +print(f"pool: {delegation.pool_id}") +print(f"balance: {delegation.balance.decimal}") +print(f"nonce: {delegation.next_nonce}") +``` + +--- + +## Building delegation transactions manually + +The `mintlayer.wasm` module provides the low-level primitives when you need to +build delegation transactions without the wallet daemon. + +### Create a delegation + +```python +from mintlayer.wasm import Client, Network + +c = Client() +try: + # Predict the delegation ID before broadcasting + delegation_id_str = c.get_delegation_id(encoded_inputs, Network.MAINNET) + + # Encode the CreateDelegationId output + create_deleg_output = c.encode_output_create_delegation( + "mpool1...", + owner_address, + Network.MAINNET, + ) +finally: + c.close() +``` + +### Fund a delegation + +```python +delegate_output = c.encode_output_delegate_staking( + Amount.from_atoms("10000000000000"), + "mdelg1...", + Network.MAINNET, +) +``` + +### Withdraw from a delegation + +```python +# The nonce must match the current next_nonce from the indexer +delegation = idx.get_delegation("mdelg1...") + +withdraw_input = c.encode_input_for_withdraw_from_delegation( + "mdelg1...", + Amount.from_atoms("5000000000000"), + delegation.next_nonce, + Network.MAINNET, +) + +# The output receives the withdrawn coins after the lock period +withdraw_output = c.encode_output_lock_then_transfer( + Amount.from_atoms("5000000000000"), + recipient_address, + lock, # from encode_lock_for_block_count or similar + Network.MAINNET, +) +``` + +Assemble, sign, and submit the resulting inputs/outputs exactly as in +[transactions.md](transactions.md). + +--- + +## Manual pool creation + +For full-custody pool creation, encode the pool parameters and wrap them in a +`CreateStakePool` output. `encode_stake_pool_data` takes the pool value +(pledge), the staker/VRF/decommission keys, the margin ratio, and the per-block +cost: + +```python +from mintlayer.wasm import Amount, Network + +pool_id = c.get_pool_id(encoded_inputs, Network.MAINNET) + +pool_data = c.encode_stake_pool_data( + value=Amount.from_atoms("40000000000000"), + staker=staker_addr, + vrf_public_key=vrf_key, + decommission_key=decommission_addr, + margin_ratio_per_thousand=100, # int here (wallet daemon takes a string) + cost_per_block=Amount.from_atoms("100000000"), + network=Network.MAINNET, +) + +pool_output = c.encode_output_create_stake_pool(pool_id, pool_data, Network.MAINNET) +``` + +Two helpers are useful when planning a pool: + +```python +# Effective balance used in the slot lottery (diminishing returns for big pools) +eff = c.effective_pool_balance(Network.MAINNET, pledge_amount, pool_balance) + +# Blocks a pool output must mature after decommission before funds are spendable +maturity = c.staking_pool_spend_maturity_block_count(tip.block_height, Network.MAINNET) +``` + +--- + +## Checking rewards + +The indexer does not expose a rewards endpoint directly. To calculate staking +rewards: + +1. Get pool block stats for a time range (`get_pool_block_stats`) +2. Get the pool's cost per block and margin ratio (`get_pool`) +3. Get your delegation's share of the total pool balance (`get_delegation`) + +The staker receives `cost_per_block + margin_ratio_per_thousand/1000 * +(block_reward - cost_per_block)`. Delegators split the remainder +proportionally to their stake. + +--- + +## Related + +- [wallet.md](wallet.md) — wallet client reference +- [indexer.md](indexer.md) — pool/delegation read queries +- [wasm.md](wasm.md) — WASM encoding reference +- [transactions.md](transactions.md) — assembling and signing manual transactions diff --git a/docs/tokens.md b/docs/tokens.md new file mode 100644 index 0000000..919e4ea --- /dev/null +++ b/docs/tokens.md @@ -0,0 +1,377 @@ +# Tokens and NFTs + +Mintlayer supports on-chain fungible tokens and NFTs. The wallet daemon manages +the full lifecycle. The `mintlayer.wasm` module provides low-level encoding for +manual transaction flows. + +--- + +## Fungible tokens (wallet daemon) + +### Supply policies + +When issuing a token you choose one of three supply policies +(`TokenSupply` dataclass, wire field `type`): + +| Policy | `TokenSupply.type` | Description | +|--------|--------------------|-------------| +| Lockable | `"Lockable"` | Unlimited minting until `lock_token_supply` is called, after which the supply is frozen permanently | +| Unlimited | `"Unlimited"` | Minting is always allowed with no cap | +| Fixed | `"Fixed"` | A cap is set at issuance; supply cannot exceed it | + +For a `Fixed` cap, set the `content` field to an `Amount`: + +```python +TokenSupply(type="Fixed", content=Amount(atoms="1000000")) # hard cap +``` + +`content` is omitted from the wire for the other two policies. + +### Issuing a token + +```python +from mintlayer.wallet import ( + Amount, + Client, + IssueTokenParams, + TokenMetadata, + TokenSupply, +) + +wc = Client("http://127.0.0.1:3034") + +authority_addr = wc.new_address(0) + +result = wc.issue_token( + IssueTokenParams( + account=0, + destination_address=authority_addr, + metadata=TokenMetadata( + token_ticker="MYTOKEN", + number_of_decimals=2, + metadata_uri="https://example.com/token-metadata.json", + token_supply=TokenSupply(type="Lockable"), + is_freezable=True, + ), + ) +) +print(f"token id: {result.token_id}\ntx id: {result.tx_id}") +``` + +The `destination_address` becomes the **authority address**: the key that +controls future token operations (minting, freezing, authority transfer). Keep +it secure. + +### Minting + +Wait for the issuance transaction to confirm before minting. + +```python +from mintlayer.wallet import MintParams + +mint_result = wc.mint_tokens( + MintParams( + account=0, + token_id=result.token_id, + address=recipient_addr, + amount=Amount(atoms="100000"), # in smallest token units + ) +) +print(f"minted tx id: {mint_result.tx_id}") +``` + +### Unminting + +Returns tokens to an unminted state (removes them from circulation without +burning). + +```python +from mintlayer.wallet import UnmintParams + +unmint_result = wc.unmint_tokens( + UnmintParams( + account=0, + token_id="ttml1...", + amount=Amount(atoms="50000"), + ) +) +``` + +### Locking supply + +After locking, the supply policy becomes `Fixed` at the current circulating +supply. This operation is irreversible. + +```python +from mintlayer.wallet import LockSupplyParams + +lock_result = wc.lock_token_supply( + LockSupplyParams( + account_index=0, + token_id="ttml1...", + ) +) +``` + +Quirk: the field is `account_index`, not `account` — the daemon route expects +the wire key `account_index` (every other token method uses `account`). + +### Freezing and unfreezing + +Freezing prevents all transfers. If `is_unfreezable` is `True`, the authority +can unfreeze later. + +```python +from mintlayer.wallet import FreezeParams, UnfreezeParams + +freeze_result = wc.freeze_token( + FreezeParams( + account=0, + token_id="ttml1...", + is_unfreezable=True, + ) +) + +unfreeze_result = wc.unfreeze_token( + UnfreezeParams( + account=0, + token_id="ttml1...", + ) +) +``` + +### Transferring authority + +```python +from mintlayer.wallet import ChangeAuthorityParams + +change_result = wc.change_token_authority( + ChangeAuthorityParams( + account=0, + token_id="ttml1...", + address=new_authority_addr, + ) +) +``` + +### Sending tokens + +```python +from mintlayer.wallet import TokenSendParams + +send_result = wc.send_token( + TokenSendParams( + account=0, + token_id="ttml1...", + address=recipient_addr, + amount=Amount(atoms="10000"), + ) +) +``` + +`send_token` is an alias of `token_send` on the transactions mixin — both call +the daemon's `token_send` route. + +--- + +## NFTs (wallet daemon) + +NFTs are non-fungible tokens. Each has unique on-chain metadata. + +### Issuing an NFT + +```python +from mintlayer.wallet import IssueNFTParams, NFTMetadata + +owner_addr = wc.new_address(0) + +result = wc.issue_nft( + IssueNFTParams( + account=0, + destination_address=owner_addr, + metadata=NFTMetadata( + name="My NFT", + description="A unique digital collectible", + ticker="MYNFT", + media_hash="sha256hexhash...", + media_uri="https://example.com/media.png", + icon_uri="https://example.com/icon.png", + # creator and additional_metadata_uri default to None + ), + ) +) +print(f"nft id: {result.token_id}") +``` + +NFTs cannot be minted after issuance: each issuance transaction creates exactly +one NFT. + +--- + +## Reading token state from the indexer + +```python +from mintlayer.indexer import Client, PageOpts + +idx = Client("http://127.0.0.1:3000") + +# Find by ticker +ids = idx.find_tokens_by_ticker("MYTOKEN", PageOpts(items=10)) + +# Full token info +token = idx.get_token("ttml1...") +print(f"ticker: {token.token_ticker}") +print(f"supply: {token.circulating_supply.decimal}") +print(f"locked: {token.is_locked}") +print(f"frozen: {token.frozen}") + +# Transaction history +txs = idx.get_token_transactions("ttml1...", PageOpts(items=20)) + +# NFT +nft = idx.get_nft("nftid1...") +print(f"owner: {nft.owner}") +print(f"name: {nft.metadata.name}") + +# Tokens where address is authority +token_ids = idx.get_token_authority("mtc1qauthority...") +``` + +--- + +## Building token transactions manually + +Use the `mintlayer.wasm` module for full control over token transactions. + +### Issue a token + +```python +from mintlayer.wasm import Amount, Client, FreezableToken, Network, TotalSupply + +c = Client() +try: + tip = idx.get_tip() + + issuance_fee = c.fungible_token_issuance_fee(tip.block_height, Network.MAINNET) + + # Predict the token ID + token_id_str = c.get_token_id(encoded_inputs, tip.block_height, Network.MAINNET) + + issue_output = c.encode_output_issue_fungible_token( + authority_addr, + "MYTOKEN", + "https://example.com/metadata.json", + 2, # decimals + TotalSupply.LOCKABLE, + None, # supply_amount: only required for TotalSupply.FIXED + FreezableToken.YES, + tip.block_height, + Network.MAINNET, + ) +finally: + c.close() +``` + +For a capped supply, pass the cap as the `supply_amount`: + +```python +issue_output = c.encode_output_issue_fungible_token( + authority_addr, + "MYTOKEN", + "https://example.com/metadata.json", + 2, + TotalSupply.FIXED, + Amount.from_atoms("1000000"), # required exactly for TotalSupply.FIXED + FreezableToken.NO, + tip.block_height, + Network.MAINNET, +) +``` + +Include the issuance fee as a separate output or subtract it from an input +UTXO. + +### Issue an NFT + +```python +issue_nft_output = c.encode_output_issue_nft( + token_id=token_id_str, # derived with get_token_id (same scheme as FTs) + authority=owner_addr, + name="My NFT", + ticker="MYNFT", + description="A unique digital collectible", + media_hash=bytes.fromhex("..."), # 32-byte sha256 of the media + creator=bytes.fromhex("..."), # or None + media_uri="https://example.com/media.png", + icon_uri=None, + additional_metadata_uri=None, + current_block_height=tip.block_height, + network=Network.MAINNET, +) +``` + +### Mint tokens + +```python +# Get the current nonce from the indexer +token_info = idx.get_token(token_id_str) + +mint_input = c.encode_input_for_mint_tokens( + token_id_str, + Amount.from_atoms("100000"), + token_info.next_nonce, + Network.MAINNET, +) + +mint_output = c.encode_output_token_transfer( + Amount.from_atoms("100000"), + recipient_addr, + token_id_str, + Network.MAINNET, +) +``` + +### Freeze a token + +```python +from mintlayer.wasm import TokenUnfreezable + +freeze_input = c.encode_input_for_freeze_token( + token_id_str, + TokenUnfreezable.YES, # can be unfrozen later + token_info.next_nonce, + Network.MAINNET, +) +``` + +Assemble, sign, and submit manual token transactions exactly as in +[transactions.md](transactions.md). + +--- + +## Token fees + +Protocol fees apply to many token operations. Query them from the WASM client +before building transactions: + +```python +tip = idx.get_tip() + +issuance_fee = c.fungible_token_issuance_fee(tip.block_height, Network.MAINNET) +nft_fee = c.nft_issuance_fee(tip.block_height, Network.MAINNET) +mint_fee = c.token_supply_change_fee(tip.block_height, Network.MAINNET) +freeze_fee = c.token_freeze_fee(tip.block_height, Network.MAINNET) +authority_fee = c.token_change_authority_fee(tip.block_height, Network.MAINNET) +``` + +These fees must be included as coin inputs in the transaction (or deducted +from change). The `data_deposit_fee` applies to `DataDeposit` outputs. + +--- + +## Related + +- [wallet.md](wallet.md) — wallet client reference +- [indexer.md](indexer.md) — token read queries +- [wasm.md](wasm.md) — WASM encoding reference +- [transactions.md](transactions.md) — assembling and signing manual transactions diff --git a/docs/transactions.md b/docs/transactions.md new file mode 100644 index 0000000..7fe23b2 --- /dev/null +++ b/docs/transactions.md @@ -0,0 +1,276 @@ +# Building transactions manually + +The wallet daemon handles transaction building automatically for most use +cases. Use the `mintlayer.wasm` module directly when you need: + +- Full custody (no wallet daemon) +- Custom output types or complex spending conditions +- Integration testing or tooling + +The [examples/send_coins.py](../examples/send_coins.py) program demonstrates +this flow end to end. + +--- + +## Overview + +Building a transaction manually requires these steps: + +1. Derive the spending key and address from a mnemonic +2. Fetch spendable UTXOs from the indexer +3. Encode each input as binary +4. Encode each output as binary +5. Build the unsigned transaction +6. Sign each input to produce witness bytes +7. Assemble the signed transaction +8. Submit to the network + +--- + +## Step 1: Key derivation + +```python +from mintlayer.wasm import Client, Network + +c = Client() +try: + mnemonic = "word1 word2 ... word12" + + account_key = c.make_default_account_privkey(mnemonic, Network.MAINNET) + + # key index 0 = first receiving address + spend_key = c.make_receiving_address(account_key, 0) + pub_key = c.public_key_from_private_key(spend_key) + from_addr = c.pubkey_to_pubkeyhash_address(pub_key, Network.MAINNET) +finally: + c.close() +``` + +--- + +## Step 2: Fetch spendable UTXOs + +```python +from mintlayer.indexer import Client as IndexerClient + +idx = IndexerClient("http://127.0.0.1:3000") + +utxos = idx.get_spendable_utxos(from_addr) +if not utxos: + raise RuntimeError("no spendable UTXOs") +``` + +Each `UTXO` carries `outpoint` (`source_id` hex string + `index`) and `output` +(raw JSON). + +--- + +## Step 3: Encode inputs + +Each input requires: + +1. Hex-decode the source transaction ID +2. Encode the outpoint source ID (`encode_outpoint_source_id`) +3. Encode the input (`encode_input_for_utxo`) + +**Concatenation semantics:** inputs (and outputs, and witnesses) are plain +binary blobs — the concatenation of every per-item encoding **is** the +transaction field. There is no count prefix or separator; just append with +`+=`. + +```python +from mintlayer.wasm import SOURCE_TRANSACTION + +encoded_inputs = b"" +for u in utxos: + tx_id_bytes = bytes.fromhex(u.outpoint.source_id) + + src_id = c.encode_outpoint_source_id(tx_id_bytes, SOURCE_TRANSACTION) + inp = c.encode_input_for_utxo(src_id, u.outpoint.index) + encoded_inputs += inp +``` + +--- + +## Step 4: Encode outputs + +```python +from mintlayer.wasm import Amount + +output = c.encode_output_transfer( + Amount(atoms="100000000000"), # 1 ML in atoms + "mtc1qrecipient...", + Network.MAINNET, +) +``` + +For multiple outputs, concatenate them: + +```python +change_output = c.encode_output_transfer( + Amount(atoms=str(change_atoms)), + from_addr, # send change back to sender + Network.MAINNET, +) + +all_outputs = output + change_output +``` + +--- + +## Step 5: Build the unsigned transaction + +```python +tx = c.encode_transaction(encoded_inputs, output, 0) # flags = 0 + +tx_id = c.get_transaction_id(tx, True) +print(f"unsigned tx id: {tx_id}") +``` + +--- + +## Step 6: Prepare UTXO bytes for signing + +The sighash computation requires access to the UTXO being spent. Build a +per-input blob where each entry is prefixed with either: + +- `0x01` followed by the re-encoded output bytes (recommended for coin + transfers) +- `0x00` alone (acceptable for some output types) + +```python +def encode_utxo_entry(c: Client, utxo_json: dict, network: Network) -> bytes: + try: + if utxo_json.get("type") == "Transfer": + value = utxo_json["value"] + if value.get("type") == "Coin": + encoded = c.encode_output_transfer( + Amount(atoms=value["amount"]["atoms"]), + value["destination"], + network, + ) + return b"\x01" + encoded + except (KeyError, TypeError, ValueError): + pass + return b"\x00" + + +all_utxo_bytes = b"" +for u in utxos: + all_utxo_bytes += encode_utxo_entry(c, u.output, Network.MAINNET) +``` + +The order of these entries must match the input order exactly — witness `i` is +validated against entry `i`. + +--- + +## Step 7: Sign each input + +Call `encode_witness` once per input. Concatenate results. + +```python +from mintlayer.wasm import SignatureHashType, TxAdditionalInfo + +witness_bytes = b"" +for i in range(len(utxos)): + w = c.encode_witness( + SignatureHashType.SIGHASH_ALL, + spend_key, + from_addr, + tx, + all_utxo_bytes, + i, # input index + TxAdditionalInfo(), # empty for standard transfers + 0, # block height (0 when no timelock constraint) + Network.MAINNET, + ) + witness_bytes += w +``` + +--- + +## Step 8: Assemble and submit + +```python +signed_tx = c.encode_signed_transaction(tx, witness_bytes) +signed_hex = signed_tx.hex() + +# Submit via the indexer (requires --enable-post-routes) +submitted_tx_id = idx.submit_transaction(signed_hex) +print(f"submitted: {submitted_tx_id}") + +# Alternative: broadcast via the node daemon +# node_client.p2p_submit_transaction(signed_hex, TrustPolicy.UNTRUSTED) +``` + +See [indexer.md](indexer.md) for the submit route requirements and +[node.md](node.md) for the P2P alternative. + +--- + +## Fee estimation + +Compute the fee before constructing outputs so you can deduct it from the +change: + +```python +# Collect destination addresses (one per input, in input order) +dest_addresses = [from_addr] * len(utxos) + +estimated_size = c.estimate_transaction_size( + encoded_inputs, dest_addresses, all_outputs, Network.MAINNET +) + +# get_fee_rate returns atoms per KB for the top 1 MB of the mempool +fee_rate = int(idx.get_fee_rate(1)) + +fee = estimated_size * fee_rate // 1000 + +# Subtract fee from the amount going to the recipient or from the change output. +``` + +--- + +## Lock-then-transfer outputs + +To send coins that cannot be spent for a period of time: + +```python +# Unlock after 1000 blocks +lock = c.encode_lock_for_block_count(1000) + +output = c.encode_output_lock_then_transfer( + Amount(atoms="100000000000"), + "mtc1qrecipient...", + lock, + Network.MAINNET, +) +``` + +--- + +## Token transfers + +Sending fungible tokens uses the same flow, with a different output encoder: + +```python +token_output = c.encode_output_token_transfer( + Amount(atoms="1000"), # token amount in smallest units + "mtc1qrecipient...", + "ttml1tokenid...", + Network.MAINNET, +) +``` + +Note that a token transfer transaction must also include a coin output (or +coin inputs) to cover the network fee. + +--- + +## Related + +- [wasm.md](wasm.md) — full WASM client reference +- [staking.md](staking.md) — manual delegation/pool transactions +- [tokens.md](tokens.md) — manual token issuance/minting +- [wallet.md](wallet.md) — let the wallet daemon do all of this for you diff --git a/docs/wallet.md b/docs/wallet.md new file mode 100644 index 0000000..6a1fe06 --- /dev/null +++ b/docs/wallet.md @@ -0,0 +1,627 @@ +# Wallet client + +The `mintlayer.wallet` module is a JSON-RPC 2.0 client for the Mintlayer wallet +daemon (`wallet-rpc-daemon`). The daemon manages key storage, address +derivation, signing, and broadcasting. + +```python +from mintlayer.wallet import Client + +c = Client( + "http://127.0.0.1:3034", + username="user", # optional; Basic Auth applied only when set + password="pass", # optional + timeout=30.0, # optional, seconds (default 30.0) + session=None, # optional requests.Session (client owns it otherwise) +) +``` + +**Default ports:** 3034 (mainnet), 13034 (testnet). + +Errors returned by the daemon raise `RPCError`; transport/decode failures raise +`JSONRPCError`: + +```python +from mintlayer.wallet import RPCError, JSONRPCError + +try: + balance = c.get_balance(0) +except RPCError as e: + print(e.code, e.message) +except JSONRPCError as e: + ... +``` + +The client is safe for concurrent use from multiple threads and supports the +context-manager protocol (`with Client(...) as c: ...`). + +> **Security:** the daemon has no transport encryption. If it listens beyond +> localhost, put it behind an HTTPS reverse proxy or an SSH tunnel; only talk +> to `https://…` endpoints (or plain `http://127.0.0.1`/localhost) — never to a +> bare `http://` host over a network. Mnemonic and passphrase values are +> **redacted from `repr()`**: `CreateWalletParams`, `RecoverWalletParams` and +> the returned `MnemonicResult` print `` instead of the secret, so +> logging a params object never leaks the seed phrase. + +--- + +## Wallet lifecycle + +Typical flow: **create or recover → open → sync → use**. + +```python +from mintlayer.wallet import Client, CreateWalletParams + +c = Client("http://127.0.0.1:3034") + +# 1. Create a wallet file (a fresh 24-word BIP-39 phrase is generated +# when params.mnemonic is None). +result = c.create_wallet( + CreateWalletParams( + path="/path/to/wallet.dat", + store_seed_phrase=False, # don't persist the phrase to disk + ) +) +if result.mnemonic: + print(result.mnemonic.content.mnemonic) # store it securely NOW + +# 2. (next session) Open the wallet. Empty password → sent as JSON null. +c.open_wallet("/path/to/wallet.dat", password="") + +# 3. Sync to the chain tip. +c.sync_wallet() + +# 4. ... accounts, addresses, sends ... + +c.close_wallet() +``` + +### `create_wallet` + +```python +def create_wallet(self, params: CreateWalletParams) -> CreateWalletResult: ... +``` + +Creates a new wallet file. If `mnemonic` is `None`, the daemon generates a +fresh 24-word BIP-39 phrase. + +```python +@dataclass(frozen=True) +class CreateWalletParams: + path: str + store_seed_phrase: bool + mnemonic: str | None = None + passphrase: str | None = None + hardware_wallet: str | None = None + + +@dataclass(frozen=True) +class CreateWalletResult: + mnemonic: MnemonicResult | None = None +``` + +When `store_seed_phrase` is `False`, the mnemonic is returned in +`CreateWalletResult.mnemonic` and not persisted to disk. Store it securely. +Optional pointer fields are always sent, serialising as JSON `null` when unset +(wire-fidelity with the Go structs). + +### `recover_wallet` + +```python +def recover_wallet(self, params: RecoverWalletParams) -> None: ... +``` + +Recreates a wallet from an existing mnemonic (`RecoverWalletParams` has the +same fields as `CreateWalletParams` but `mnemonic` is required). The daemon +will rescan the chain to recover the balance. + +### `open_wallet` + +```python +def open_wallet(self, path: str, password: str = "") -> None: ... +``` + +Opens an existing wallet file. An empty `password` is sent as JSON `null` +(unencrypted wallets). + +### `close_wallet` + +```python +def close_wallet(self) -> None: ... +``` + +Closes the currently open wallet. + +### `get_wallet_info` + +```python +def get_wallet_info(self) -> WalletInfo: ... +``` + +Returns wallet metadata (`wallet_id`, `account_names`, `extra_info` — a +`WalletExtraInfo` carrying the software/hardware wallet type in its `type` +field). + +### `sync_wallet` + +```python +def sync_wallet(self) -> None: ... +``` + +Syncs the wallet to the current chain tip. + +### `rescan_wallet` + +```python +def rescan_wallet(self) -> None: ... +``` + +Rescans the entire chain from genesis. Use this after importing a wallet or +when balances appear incorrect. + +### `best_block` + +```python +def best_block(self) -> BestBlock: ... +``` + +Returns the block (`height`, `id`) the wallet is currently synced to. + +--- + +## Accounts + +### `create_account` + +```python +def create_account(self, name: str) -> AccountInfo: ... +``` + +Creates a new BIP-44 account within the wallet. Returns the account index +(`AccountInfo(account, name)`). + +### `rename_account` + +```python +def rename_account(self, account: int, name: str = "") -> None: ... +``` + +Renames an existing account. An empty `name` is sent as JSON `null`, which +removes the name. + +--- + +## Balances and addresses + +### `get_balance` + +```python +def get_balance(self, account: int) -> Balance: ... +``` + +Returns the confirmed coin and token balances for an account (the request pins +`utxo_states` to `["Confirmed"]`). + +```python +@dataclass(frozen=True) +class Balance: + coins: Amount + tokens: dict[str, Amount] # keyed by token ID +``` + +### `new_address` + +```python +def new_address(self, account: int) -> str: ... +``` + +Derives a fresh receiving address and marks it as used. + +### `show_receive_addresses` + +```python +def show_receive_addresses(self, account: int) -> list[AddressWithUsage]: ... +``` + +Lists all receiving addresses that have been derived for an account, with +their usage status and coin balance +(`AddressWithUsage(address, used, coins)`). Change addresses are excluded. + +### `reveal_public_key` + +```python +def reveal_public_key(self, account: int, address: str) -> str: ... +``` + +Returns the hex-encoded public key for an address controlled by the wallet. + +--- + +## Key encryption + +### `encrypt_private_keys` + +```python +def encrypt_private_keys(self, password: str) -> None: ... +``` + +Encrypts the wallet's private keys with a password. + +### `unlock_private_keys` + +```python +def unlock_private_keys(self, password: str) -> None: ... +``` + +Unlocks an encrypted wallet for signing. The keys remain unlocked until +`lock_private_keys` is called or the daemon restarts. + +### `lock_private_keys` + +```python +def lock_private_keys(self) -> None: ... +``` + +Locks the private keys without closing the wallet. + +--- + +## Transactions + +### `address_send` + +```python +def address_send(self, params: SendParams) -> SendResult: ... +``` + +Sends coins to an address. The wallet selects UTXOs, computes fees, and +broadcasts. + +```python +@dataclass(frozen=True) +class SendParams: + account: int + address: str + amount: Amount + selected_utxos: list[Outpoint] | None = None + options: TxOptions = field(default_factory=TxOptions) + + +@dataclass(frozen=True) +class SendResult: + tx_id: str + fees: FeesBreakdown + broadcasted: bool +``` + +**Wire rules** (all params dataclasses serialise via `to_json()`): + +- `Amount(atoms="...", decimal="...")` omits empty fields; at least one must be + set when sending. +- `selected_utxos` is `omitempty`: `None` **or an empty list** is omitted from + the wire. +- `options` always serialises both keys (`null` when unset). +- Optional `str | None` fields (e.g. `htlc_secret`, `output_address`) always + serialise, as JSON `null` when unset. + +```python +from mintlayer.wallet import Amount, SendParams, TxOptions + +result = c.address_send( + SendParams( + account=0, + address="mtc1q...", + amount=Amount(atoms="100000000000"), # 1 ML + options=TxOptions(in_top_x_mb=1), # high fee priority + ) +) +print(result.tx_id, result.fees.coins.atoms, result.broadcasted) +``` + +Set `options.broadcast_to_mempool=False` to build and sign without +broadcasting; the transaction is still returned via the wallet history. + +### `token_send` + +```python +def token_send(self, params: TokenSendParams) -> SendResult: ... +``` + +Sends tokens from the account to an address +(`TokenSendParams(account, token_id, address, amount, options)`). + +### `sweep_spendable` + +```python +def sweep_spendable(self, params: SweepParams) -> SendResult: ... +``` + +Sweeps all spendable funds to a destination. Set `all=True` to sweep the +entire account; set `from_addresses` to sweep specific addresses only +(`SweepParams(account, destination_address, from_addresses, all, options)`). + +### `spend_utxo` + +```python +def spend_utxo(self, params: UTXOSpendParams) -> SendResult: ... +``` + +Spends a specific UTXO, optionally providing an HTLC secret +(`UTXOSpendParams(account, utxo: Outpoint, output_address, htlc_secret, +options)`). + +### `compose_transaction` + +```python +def compose_transaction(self, params: ComposeParams) -> ComposedTx: ... +``` + +Composes an unsigned transaction from explicit inputs and outputs. Returns a +hex-encoded `PartiallySignedTransaction` (`ComposedTx(hex, fees)`). Use this +for advanced flows where you construct outputs manually. + +```python +@dataclass(frozen=True) +class ComposeParams: + inputs: list[Outpoint] = field(default_factory=list) + outputs: list[Any] = field(default_factory=list) # raw output objects + htlc_secrets: Any = None + only_transaction: bool = False +``` + +### `sign_raw_transaction` + +```python +def sign_raw_transaction(self, account: int, raw_tx: str) -> SignedTx: ... +``` + +Signs a hex-encoded transaction using keys from the given account +(`SignedTx(hex, current_signatures)`). Used in cold-wallet flows where +composition and signing happen separately. + +```python +composed = c.compose_transaction(ComposeParams(...)) +signed = c.sign_raw_transaction(0, composed.hex) +submit = c.submit_transaction(signed.hex) +``` + +### `inspect_transaction` + +```python +def inspect_transaction(self, tx_hex: str) -> TxInspection: ... +``` + +Inspects a hex-encoded transaction without broadcasting. Returns input count, +signature count, and fees (`TxInspection(stats: TxStats, fees: +FeesBreakdown | None)`). + +### `submit_transaction` + +```python +def submit_transaction(self, tx_hex: str, do_not_store: bool = False) -> SubmitResult: ... +``` + +Broadcasts a signed transaction. Set `do_not_store=True` to broadcast without +saving the transaction in the wallet history. + +Quirk: the daemon route hardcodes the trust policy to `"Trusted"` — the client +always sends `{"trust_policy": "Trusted"}`. + +### `list_transactions_by_address` + +```python +def list_transactions_by_address( + self, account: int, address: str | None, limit: int +) -> list[WalletTx]: ... +``` + +Lists confirmed transactions for an account. Pass `address=None` to list +across all addresses (sent as JSON `null`). The most recent transactions are +returned first (`WalletTx(id, height, timestamp)`). + +### `list_pending_transactions` + +```python +def list_pending_transactions(self, account: int) -> list[str]: ... +``` + +Lists transaction IDs that are in the mempool but not yet confirmed. + +### `get_transaction` + +```python +def get_transaction(self, account: int, tx_id: str) -> Any: ... +``` + +Returns a transaction as raw decoded JSON. + +### `abandon_transaction` + +```python +def abandon_transaction(self, account: int, tx_id: str) -> None: ... +``` + +Removes an unconfirmed transaction from the wallet. The transaction will no +longer be rebroadcast. The UTXOs it spent are returned to the available +balance. + +### `deposit_data` + +```python +def deposit_data(self, account: int, data_hex: str) -> SendResult: ... +``` + +Embeds arbitrary hex-encoded data in a transaction output (`DataDeposit` +output type). + +--- + +## Transaction options + +Most transaction methods embed a `TxOptions` in their params: + +```python +@dataclass(frozen=True) +class TxOptions: + in_top_x_mb: int | None = None + broadcast_to_mempool: bool | None = None +``` + +`in_top_x_mb` controls fee priority. Setting it to `1` targets the top 1 MB of +the mempool (highest priority). The default (`None`) lets the daemon choose. + +Setting `broadcast_to_mempool=False` builds and signs the transaction without +broadcasting it. The transaction hex is still returned in the result. + +Unlike the optional params fields, `TxOptions` **always** serialises both keys +on the wire (`null` when unset) — this matches the Go struct exactly. + +--- + +## Staking + +See [staking.md](staking.md) for a complete guide. Quick reference: + +| Method | Description | +|--------|-------------| +| `create_stake_pool` | Create and fund a new staking pool | +| `decommission_stake_pool` | Wind down a pool and recover the pledge | +| `list_owned_pools` | List pools owned by an account | +| `get_pool_balance` | Get pool balance | +| `start_staking` | Start block production | +| `stop_staking` | Stop block production | +| `get_staking_status` | Check whether staking is active | +| `create_delegation` | Create a delegation to a pool | +| `delegate_staking` | Send coins into a delegation | +| `withdraw_from_delegation` | Withdraw from a delegation | +| `list_delegations` | List delegations owned by an account | + +--- + +## Tokens and NFTs + +See [tokens.md](tokens.md) for a complete guide. Quick reference: + +| Method | Description | +|--------|-------------| +| `issue_token` | Issue a new fungible token | +| `issue_nft` | Issue a new NFT | +| `mint_tokens` | Mint additional supply | +| `unmint_tokens` | Remove supply (return to unminted state) | +| `lock_token_supply` | Permanently lock supply | +| `freeze_token` | Freeze all transfers | +| `unfreeze_token` | Unfreeze (if allowed) | +| `change_token_authority` | Transfer the authority key | +| `send_token` | Send tokens to an address (alias of `token_send`) | + +--- + +## DEX orders + +### `create_order` + +```python +def create_order(self, params: CreateOrderParams) -> OrderCreated: ... +``` + +Creates a new DEX order (`OrderCreated(order_id, tx_id, broadcasted)`). + +```python +@dataclass(frozen=True) +class CreateOrderParams: + account: int + ask: OutputValue + give: OutputValue + conclude_address: str + options: TxOptions = field(default_factory=TxOptions) +``` + +The two sides of the order use `OutputValue`, which has a fully custom wire +encoding. Construct with the helper constructors: + +```python +from mintlayer.wallet import CreateOrderParams, OutputValue, coin_filter, token_filter + +params = CreateOrderParams( + account=0, + ask=OutputValue.coins(atoms="500000000000"), # asking 5 ML + give=OutputValue.tokens("ttml1...", atoms="1000"), # giving 1000 tokens + conclude_address="mtc1q...", +) +created = c.create_order(params) +``` + +### `conclude_order` + +```python +def conclude_order(self, params: ConcludeOrderParams) -> SendResult: ... +``` + +Concludes an order owned by the account +(`ConcludeOrderParams(account, order_id, output_address=None, options)`). + +### `fill_order` + +```python +def fill_order(self, params: FillOrderParams) -> SendResult: ... +``` + +Fills (partially or fully) an existing order +(`FillOrderParams(account, order_id, fill_amount_in_ask_currency, +output_address=None, options)`). + +### `freeze_order` + +```python +def freeze_order(self, params: FreezeOrderParams) -> SendResult: ... +``` + +Freezes an order (orders V1 fork only) +(`FreezeOrderParams(account, order_id, options)`). + +### `list_own_orders` + +```python +def list_own_orders(self, account: int) -> list[OwnOrder]: ... +``` + +Lists the account's own orders (`OwnOrder(order_id, initially_asked, +initially_given, existing_order_data, is_marked_as_frozen_in_wallet, +is_marked_as_concluded_in_wallet)`). + +### `list_all_active_orders` + +```python +def list_all_active_orders(self, params: ListOrdersParams) -> list[ActiveOrder]: ... +``` + +Lists all active orders, optionally filtered by currency pair — `None` filters +match any (`ActiveOrder(order_id, initially_asked, initially_given, +ask_balance, give_balance, is_own)`). + +```python +from mintlayer.wallet import ListOrdersParams, coin_filter, token_filter + +# module-level helpers (same as CurrencyFilter.coin_filter() / .token_filter(id)) +orders = c.list_all_active_orders( + ListOrdersParams( + account=0, + ask_currency=coin_filter(), + give_currency=token_filter("ttml1..."), + ) +) +``` + +`CurrencyFilter` (wire: `{"type":"Coin"}` with no content key, or +`{"type":"Token","content":""}`) and `OutputValue` raise `ValueError` +before any HTTP request is sent when constructed invalidly (missing token id / +missing amount). + +--- + +## Related + +- [transactions.md](transactions.md) — manual transaction building (no daemon) +- [staking.md](staking.md) — staking pools and delegations +- [tokens.md](tokens.md) — token and NFT lifecycle +- [node.md](node.md) — node daemon client diff --git a/docs/wasm.md b/docs/wasm.md new file mode 100644 index 0000000..238939d --- /dev/null +++ b/docs/wasm.md @@ -0,0 +1,990 @@ +# WASM client + +The `mintlayer.wasm` module exposes cryptographic primitives and binary +transaction encoding via an embedded WebAssembly module (instantiated with +wasmtime, sha256-pinned at import). + +```python +from mintlayer.wasm import Client + +c = Client() # instantiation compiles the WASM binary: ~400 ms +... +c.close() # or use `with Client() as c:` +``` + +Call it once per process and reuse it. The `Client` is **safe for concurrent +use from multiple threads** — every public method serialises access to the +single WASM instance with a lock. + +All errors raise `WasmError` with a message prefixed `mintlayer: `: + +```python +from mintlayer.wasm import WasmError + +try: + addr = c.pubkey_to_pubkeyhash_address(pubkey, Network.MAINNET) +except WasmError as e: + print(e) # e.g. "mintlayer: invalid public key" +``` + +> **Key material in memory:** result buffers carrying private keys, derived +> keys and signatures are zeroed before being released, but input buffers and +> WASM-side intermediate copies of secrets persist in WASM linear memory until +> the allocator reuses them (inherited from the wasm-bindgen design; the host +> cannot reach them). Treat the process memory of a long-lived `Client` as +> sensitive. + +--- + +## Amounts + +```python +from mintlayer.wasm import Amount + +one = Amount.from_atoms("100000000000") # 1 ML +zero = Amount.zero() +print(one.atoms) # "100000000000" +print(str(one)) # "100000000000" +``` + +All amounts are decimal strings of atoms. **1 ML = 100,000,000,000 atoms** +(11 decimal places). + +--- + +## Networks and enums + +Enums are `enum.IntEnum` subclasses passed to WASM as their integer +discriminant: + +```python +from mintlayer.wasm import ( + Network, # MAINNET=0, TESTNET=1, REGTEST=2, SIGNET=3 + SignatureHashType, # SIGHASH_ALL=0, SIGHASH_NONE=1, SIGHASH_SINGLE=2, SIGHASH_ANYONECANPAY=3 + SourceId, # SOURCE_TRANSACTION=0, SOURCE_BLOCK_REWARD=1 + TotalSupply, # LOCKABLE=0, UNLIMITED=1, FIXED=2 + FreezableToken, # NO=0, YES=1 + TokenUnfreezable, # NO=0, YES=1 +) +``` + +Module-level aliases (`MAINNET`, `TESTNET`, `REGTEST`, `SIGNET`, +`SIGHASH_ALL`, …, `SOURCE_TRANSACTION`, `SOURCE_BLOCK_REWARD`) match the Go +constant names; `mintlayer.Mainnet` in Go is `mintlayer.MAINNET` (or +`Network.MAINNET`) in Python. + +Pass the appropriate constant to any function that generates addresses or +encodes transactions. + +--- + +## Key derivation + +### `make_private_key` + +```python +def make_private_key(self) -> bytes: ... +``` + +Generates a random private key. + +### `make_default_account_privkey` + +```python +def make_default_account_privkey(self, mnemonic: str, network: Network) -> bytes: ... +``` + +Derives the default account extended private key from a BIP-39 mnemonic. The +derivation path is `m/44'/coin_type'/0'` where `coin_type` depends on the +network. + +### `public_key_from_private_key` + +```python +def public_key_from_private_key(self, privkey: bytes) -> bytes: ... +``` + +Derives the compressed public key from a private key. + +### `extended_public_key_from_extended_private_key` + +```python +def extended_public_key_from_extended_private_key(self, privkey: bytes) -> bytes: ... +``` + +Derives the extended public key from an extended private key. Useful for +watch-only wallets. + +### `make_receiving_address` + +```python +def make_receiving_address(self, account_privkey: bytes, key_index: int) -> bytes: ... +``` + +Derives the private key for receiving address `key_index` within an account. + +### `make_change_address` + +```python +def make_change_address(self, account_privkey: bytes, key_index: int) -> bytes: ... +``` + +Derives the private key for change address `key_index` within an account. + +### `make_receiving_address_public_key` + +```python +def make_receiving_address_public_key(self, account_pubkey: bytes, key_index: int) -> bytes: ... +``` + +Derives the public key for receiving address `key_index` from an extended +public key. Does not require the private key. + +### `make_change_address_public_key` + +```python +def make_change_address_public_key(self, account_pubkey: bytes, key_index: int) -> bytes: ... +``` + +Derives the public key for change address `key_index` from an extended public +key. + +**Example: derive a receiving address from a mnemonic** + +```python +mnemonic = "abandon abandon abandon ... about" + +account_key = c.make_default_account_privkey(mnemonic, Network.MAINNET) +recv_key = c.make_receiving_address(account_key, 0) +pub_key = c.public_key_from_private_key(recv_key) +addr = c.pubkey_to_pubkeyhash_address(pub_key, Network.MAINNET) +``` + +--- + +## Addresses + +### `encode_destination` + +```python +def encode_destination(self, address: str, network: Network) -> bytes: ... +``` + +Encodes a bech32m address into its binary representation for use in +transaction outputs. + +### `pubkey_to_pubkeyhash_address` + +```python +def pubkey_to_pubkeyhash_address(self, pubkey: bytes, network: Network) -> str: ... +``` + +Derives a P2PKH bech32m address from a compressed public key. + +### `encode_multisig_challenge` + +```python +def encode_multisig_challenge( + self, pubkeys: bytes, min_required_signatures: int, network: Network +) -> bytes: ... +``` + +Encodes a multisig challenge (script) from a concatenation of compressed +public keys. + +### `multisig_challenge_to_address` + +```python +def multisig_challenge_to_address(self, challenge: bytes, network: Network) -> str: ... +``` + +Derives a bech32m address from a multisig challenge. + +--- + +## IDs + +These functions derive chain-assigned IDs from transaction inputs. The ID is +deterministic: the same inputs always produce the same ID. + +### `get_pool_id` + +```python +def get_pool_id(self, inputs: bytes, network: Network) -> str: ... +``` + +Derives the pool ID that will be assigned to a `CreateStakePool` transaction. + +### `get_token_id` + +```python +def get_token_id(self, inputs: bytes, current_block_height: int, network: Network) -> str: ... +``` + +Derives the token ID that will be assigned to an `IssueFungibleToken` (or NFT) +transaction. `current_block_height` selects the ID scheme for the active +network upgrade. + +### `get_delegation_id` + +```python +def get_delegation_id(self, inputs: bytes, network: Network) -> str: ... +``` + +Derives the delegation ID that will be assigned to a `CreateDelegationId` +transaction. + +### `get_order_id` + +```python +def get_order_id(self, inputs: bytes, network: Network) -> str: ... +``` + +Derives the order ID that will be assigned to a `CreateOrder` transaction. + +--- + +## Inputs + +Each input is a binary blob. Concatenate all input blobs to form the `inputs` +bytes passed to `encode_transaction`. + +### `encode_input_for_utxo` + +```python +def encode_input_for_utxo(self, outpoint_source_id: bytes, output_index: int) -> bytes: ... +``` + +Encodes a UTXO spending input. `outpoint_source_id` is the result of +`encode_outpoint_source_id`. + +### `encode_input_for_withdraw_from_delegation` + +```python +def encode_input_for_withdraw_from_delegation( + self, delegation_id: str, amount: Amount, nonce: int, network: Network +) -> bytes: ... +``` + +Encodes a delegation withdrawal input. `nonce` must match the current +`next_nonce` from the indexer's delegation record. + +### `encode_input_for_mint_tokens` + +```python +def encode_input_for_mint_tokens( + self, token_id: str, amount: Amount, nonce: int, network: Network +) -> bytes: ... +``` + +### `encode_input_for_unmint_tokens` + +```python +def encode_input_for_unmint_tokens(self, token_id: str, nonce: int, network: Network) -> bytes: ... +``` + +### `encode_input_for_lock_token_supply` + +```python +def encode_input_for_lock_token_supply( + self, token_id: str, nonce: int, network: Network +) -> bytes: ... +``` + +### `encode_input_for_freeze_token` + +```python +def encode_input_for_freeze_token( + self, token_id: str, is_token_unfreezable: TokenUnfreezable, nonce: int, network: Network +) -> bytes: ... +``` + +### `encode_input_for_unfreeze_token` + +```python +def encode_input_for_unfreeze_token(self, token_id: str, nonce: int, network: Network) -> bytes: ... +``` + +### `encode_input_for_change_token_authority` + +```python +def encode_input_for_change_token_authority( + self, token_id: str, new_authority: str, nonce: int, network: Network +) -> bytes: ... +``` + +### `encode_input_for_change_token_metadata_uri` + +```python +def encode_input_for_change_token_metadata_uri( + self, token_id: str, new_metadata_uri: str, nonce: int, network: Network +) -> bytes: ... +``` + +### `encode_input_for_conclude_order` + +```python +def encode_input_for_conclude_order( + self, order_id: str, nonce: int, current_block_height: int, network: Network +) -> bytes: ... +``` + +### `encode_input_for_fill_order` + +```python +def encode_input_for_fill_order( + self, + order_id: str, + fill_amount: Amount, + destination: str, + nonce: int, + current_block_height: int, + network: Network, +) -> bytes: ... +``` + +FillOrder inputs are not signed — use `encode_witness_no_signature`. + +### `encode_input_for_freeze_order` + +```python +def encode_input_for_freeze_order( + self, order_id: str, current_block_height: int, network: Network +) -> bytes: ... +``` + +Order freezing is available only after the orders V1 fork. + +--- + +## Outputs + +Each output is a binary blob. Concatenate all output blobs to form the +`outputs` bytes passed to `encode_transaction`. + +### `encode_output_transfer` + +```python +def encode_output_transfer(self, amount: Amount, address: str, network: Network) -> bytes: ... +``` + +Coin transfer to an address. This is the standard output type for sending ML. + +### `encode_output_token_transfer` + +```python +def encode_output_token_transfer( + self, amount: Amount, address: str, token_id: str, network: Network +) -> bytes: ... +``` + +Fungible token transfer to an address. + +### `encode_output_lock_then_transfer` + +```python +def encode_output_lock_then_transfer( + self, amount: Amount, address: str, lock: bytes, network: Network +) -> bytes: ... +``` + +Coin transfer with a timelock. The coins are sent to `address` but cannot be +spent until the lock expires. `lock` is the result of one of the +`encode_lock_*` functions. + +### `encode_output_token_lock_then_transfer` + +```python +def encode_output_token_lock_then_transfer( + self, amount: Amount, address: str, token_id: str, lock: bytes, network: Network +) -> bytes: ... +``` + +Token transfer with a timelock. + +### `encode_output_coin_burn` + +```python +def encode_output_coin_burn(self, amount: Amount) -> bytes: ... +``` + +Permanently destroys ML coins. + +### `encode_output_token_burn` + +```python +def encode_output_token_burn(self, amount: Amount, token_id: str, network: Network) -> bytes: ... +``` + +Permanently destroys fungible tokens. + +### `encode_output_create_delegation` + +```python +def encode_output_create_delegation( + self, pool_id: str, owner_address: str, network: Network +) -> bytes: ... +``` + +Creates a delegation for `owner_address` in `pool_id`. Use `get_delegation_id` +to predict the delegation ID before broadcasting. + +### `encode_output_delegate_staking` + +```python +def encode_output_delegate_staking( + self, amount: Amount, delegation_id: str, network: Network +) -> bytes: ... +``` + +Sends coins into an existing delegation. + +### `encode_output_create_stake_pool` + +```python +def encode_output_create_stake_pool( + self, pool_id: str, pool_data: bytes, network: Network +) -> bytes: ... +``` + +Creates a staking pool. `pool_data` is the result of `encode_stake_pool_data`. + +### `encode_output_produce_block_from_stake` + +```python +def encode_output_produce_block_from_stake( + self, pool_id: str, staker: str, network: Network +) -> bytes: ... +``` + +Reward output used in blocks produced by a pool. This UTXO is consumed when +decommissioning a pool (if the pool has staked at least once). Only relevant +for block producers. + +### `encode_output_data_deposit` + +```python +def encode_output_data_deposit(self, data: bytes) -> bytes: ... +``` + +Embeds arbitrary bytes on-chain. + +### `encode_output_htlc` + +```python +def encode_output_htlc( + self, + amount: Amount, + token_id: str | None, + secret_hash: str, + spend_address: str, + refund_address: str, + refund_timelock: bytes, + network: Network, +) -> bytes: ... +``` + +Hashed Time-Lock Contract output. Pass `None` for `token_id` to use coins. The +receiver can spend with the preimage; the sender can refund after the timelock +expires. + +### `encode_output_issue_fungible_token` + +```python +def encode_output_issue_fungible_token( + self, + authority: str, + token_ticker: str, + metadata_uri: str, + number_of_decimals: int, + total_supply: TotalSupply, + supply_amount: Amount | None, + is_token_freezable: FreezableToken, + current_block_height: int, + network: Network, +) -> bytes: ... +``` + +Issues a new fungible token. `supply_amount` is required only when +`total_supply` is `TotalSupply.FIXED` (pass `None` otherwise). + +### `encode_output_issue_nft` + +```python +def encode_output_issue_nft( + self, + token_id: str, + authority: str, + name: str, + ticker: str, + description: str, + media_hash: bytes, + creator: bytes | None, + media_uri: str | None, + icon_uri: str | None, + additional_metadata_uri: str | None, + current_block_height: int, + network: Network, +) -> bytes: ... +``` + +Issues a new NFT. `creator`, `media_uri`, `icon_uri` and +`additional_metadata_uri` may be `None`. The `token_id` is derived with +`get_token_id` (NFTs share the fungible-token ID scheme). + +### `encode_create_order_output` + +```python +def encode_create_order_output( + self, + ask_amount: Amount, + ask_token_id: str | None, + give_amount: Amount, + give_token_id: str | None, + conclude_address: str, + network: Network, +) -> bytes: ... +``` + +Creates a DEX order. Pass `None` for `ask_token_id` or `give_token_id` to use +the native coin. + +--- + +## Timelocks + +Timelocks are binary blobs passed to `encode_output_lock_then_transfer` and +`encode_output_token_lock_then_transfer`. + +```python +def encode_lock_for_block_count(self, block_count: int) -> bytes: ... +def encode_lock_for_seconds(self, seconds: int) -> bytes: ... +def encode_lock_until_height(self, block_height: int) -> bytes: ... +def encode_lock_until_time(self, timestamp_seconds: int) -> bytes: ... +``` + +| Function | Unlocks when | +|----------|-------------| +| `encode_lock_for_block_count(n)` | `n` blocks have been confirmed after the output | +| `encode_lock_for_seconds(s)` | `s` seconds have elapsed since the output was confirmed | +| `encode_lock_until_height(h)` | the chain tip reaches height `h` | +| `encode_lock_until_time(t)` | the median block time exceeds Unix timestamp `t` | + +--- + +## Transactions + +### `encode_outpoint_source_id` + +```python +def encode_outpoint_source_id(self, id_: bytes, source_id: SourceId) -> bytes: ... +``` + +Encodes an outpoint source. `id_` is the raw transaction or block hash +(hex-decoded); `source_id` is `SourceId.SOURCE_TRANSACTION` or +`SourceId.SOURCE_BLOCK_REWARD`. + +### `encode_transaction` + +```python +def encode_transaction(self, inputs: bytes, outputs: bytes, flags: int) -> bytes: ... +``` + +Encodes an unsigned transaction from concatenated input and output blobs. +`flags` should be `0` for standard transactions. + +### `get_transaction_id` + +```python +def get_transaction_id(self, transaction: bytes, strict_byte_size: bool) -> str: ... +``` + +Returns the hex transaction ID without broadcasting. Set +`strict_byte_size=True` to require the bytes to represent exactly one +Transaction object. + +### `estimate_transaction_size` + +```python +def estimate_transaction_size( + self, inputs: bytes, input_utxos_dests: list[str], outputs: bytes, network: Network +) -> int: ... +``` + +Estimates the byte size of the transaction after signing. +`input_utxos_dests` must contain one address string per input (the spending +destination of each UTXO), in input order. Use this to compute fees before +constructing the final output set — see [transactions.md](transactions.md). + +### `encode_signed_transaction` + +```python +def encode_signed_transaction(self, transaction: bytes, signatures: bytes) -> bytes: ... +``` + +Assembles a fully signed transaction from the unsigned transaction bytes and +the concatenated witness bytes. + +### `encode_partially_signed_transaction` + +```python +def encode_partially_signed_transaction( + self, + transaction: bytes, + signatures: bytes, + input_utxos: bytes, + input_destinations: bytes, + htlc_secrets: bytes, + additional_info: TxAdditionalInfo, + network: Network, +) -> bytes: ... +``` + +Creates a Partially Signed Transaction (PSBT)-style structure. Use this for +multi-party signing flows. + +### `decode_signed_transaction_to_js` + +```python +def decode_signed_transaction_to_js(self, transaction: bytes, network: Network) -> bytes: ... +``` + +Decodes a signed transaction to JSON (raw JSON bytes) for inspection. +`decode_partially_signed_transaction_to_js` does the same for partially signed +transactions. + +### `extract_htlc_secret` + +```python +def extract_htlc_secret( + self, + signed_tx: bytes, + strict_byte_size: bool, + htlc_outpoint_source_id: bytes, + htlc_output_index: int, +) -> bytes: ... +``` + +Extracts the HTLC preimage from a transaction that spends an HTLC output. Use +this to learn the secret after the counterparty reveals it on-chain. + +### `internal_verify_witness` + +```python +def internal_verify_witness( + self, + sighash_type: int, + input_owner_dest: str | None, + witness: bytes, + transaction: bytes, + input_utxos: bytes, + input_index: int, + additional_info: TxAdditionalInfo, + block_height: int, + network: Network, +) -> None: ... +``` + +Verifies an input witness against the transaction (`input_owner_dest` may be +`None` where the destination is not required). Useful for testing signing +flows. + +--- + +## Signing + +### `encode_witness` + +```python +def encode_witness( + self, + sighash_type: SignatureHashType, + private_key: bytes, + input_owner_dest: str, + transaction: bytes, + input_utxos: bytes, + input_index: int, + additional_info: TxAdditionalInfo, + block_height: int, + network: Network, +) -> bytes: ... +``` + +Signs one input and returns the witness bytes. Call once per input and +concatenate results. + +`input_owner_dest` is the bech32m address that owns the UTXO being spent. +`input_utxos` is a concatenation of per-input UTXO entries (see +[transactions.md](transactions.md) for the `0x00`/`0x01` prefix encoding). +`block_height` is the current block height (pass `0` for outputs without +time-lock constraints). + +Use `SignatureHashType.SIGHASH_ALL` for standard transactions. + +### `encode_witness_no_signature` + +```python +def encode_witness_no_signature(self) -> bytes: ... +``` + +Returns an empty witness. Required for `FillOrder` inputs, which do not need a +signature. + +### `encode_witness_htlc_spend` + +```python +def encode_witness_htlc_spend( + self, + sighash_type: SignatureHashType, + private_key: bytes, + input_owner_dest: str, + transaction: bytes, + input_utxos: bytes, + input_index: int, + secret: bytes, + additional_info: TxAdditionalInfo, + block_height: int, + network: Network, +) -> bytes: ... +``` + +Signs an HTLC spend input, embedding the preimage. + +### `encode_witness_htlc_refund_single_sig` + +```python +def encode_witness_htlc_refund_single_sig( + self, + sighash_type: SignatureHashType, + private_key: bytes, + input_owner_dest: str, + transaction: bytes, + input_utxos: bytes, + input_index: int, + additional_info: TxAdditionalInfo, + block_height: int, + network: Network, +) -> bytes: ... +``` + +Signs an HTLC refund for a single-signature refund address. + +### `encode_witness_htlc_refund_multisig` + +```python +def encode_witness_htlc_refund_multisig( + self, + sighash_type: SignatureHashType, + private_key: bytes, + key_index: int, + input_witness: bytes, + multisig_challenge: bytes, + transaction: bytes, + input_utxos: bytes, + input_index: int, + additional_info: TxAdditionalInfo, + block_height: int, + network: Network, +) -> bytes: ... +``` + +Adds a partial signature to an HTLC refund witness for a multisig refund +address. `key_index` is the index of `private_key` within the multisig +challenge; `input_witness` may be empty (first signer) or a previous partial +result. + +### `sign_challenge` + +```python +def sign_challenge(self, private_key: bytes, message: bytes) -> bytes: ... +``` + +Signs an arbitrary message for use in challenge-response authentication. + +### `verify_challenge` + +```python +def verify_challenge( + self, address: str, network: Network, signed_challenge: bytes, message: bytes +) -> bool: ... +``` + +Verifies a challenge signature against a bech32m (pubkeyhash) address. + +### `sign_message_for_spending` + +```python +def sign_message_for_spending(self, private_key: bytes, message: bytes) -> bytes: ... +``` + +Signs a spending message (used in transaction intents). + +### `verify_signature_for_spending` + +```python +def verify_signature_for_spending( + self, public_key: bytes, signature: bytes, message: bytes +) -> bool: ... +``` + +Verifies a spending message signature against a public key. + +--- + +## Additional info for signing + +Some transaction types (pool operations, orders) require extra data not +present in the UTXO itself: + +```python +from mintlayer.wasm import ( + Amount, + OrderBalance, + OrderInfo, + PoolInfo, + SimpleCurrencyAmount, + TxAdditionalInfo, +) + +info = TxAdditionalInfo( + pool_info={"mpool1...": PoolInfo(staker_balance=Amount.from_atoms("40000000000000"))}, + order_info={ + "mordr1...": OrderInfo( + initially_asked=SimpleCurrencyAmount.coins("500000000000"), + initially_given=SimpleCurrencyAmount.tokens("1000", "ttml1..."), + ask_balance=OrderBalance(atoms="500000000000"), + give_balance=OrderBalance(atoms="800", token_id="ttml1..."), + ) + }, +) +``` + +- Maps are keyed by the bech32m pool/order ID. Pass an empty `TxAdditionalInfo()` + for standard coin transfers. +- `SimpleCurrencyAmount` serialises as the externally tagged + `CurrencyAmount` enum — `{"coins":{"atoms":...}}` or + `{"tokens":{"amount":{"atoms":...},"token_id":...}}` — built with the + `.coins(atoms)` / `.tokens(atoms, token_id)` constructors. +- `OrderBalance` uses the redundant-but-required wire shape + `{"atoms":...,"amount":{"atoms":...},"token_id":null|"..."}`. +- The WASM module rejects `null` maps, so the `TxAdditionalInfo` defaults are + empty dicts rather than `None`. + +--- + +## Staking + +### `encode_stake_pool_data` + +```python +def encode_stake_pool_data( + self, + value: Amount, + staker: str, + vrf_public_key: str, + decommission_key: str, + margin_ratio_per_thousand: int, + cost_per_block: Amount, + network: Network, +) -> bytes: ... +``` + +Encodes the pool parameters for use in `encode_output_create_stake_pool`. +`staker` is the bech32m address allowed to produce blocks; `vrf_public_key` is +the bech32m VRF public key; `decommission_key` is the address that can +decommission the pool; `margin_ratio_per_thousand` is the staker's cut per +thousand (e.g. `100` = 10%); `cost_per_block` is a flat amount deducted from +rewards before the margin split. + +### `effective_pool_balance` + +```python +def effective_pool_balance( + self, network: Network, pledge_amount: Amount, pool_balance: Amount +) -> Amount: ... +``` + +Computes the effective balance used in the slot lottery, which applies +diminishing returns to large pools. + +### `staking_pool_spend_maturity_block_count` + +```python +def staking_pool_spend_maturity_block_count( + self, current_block_height: int, network: Network +) -> int: ... +``` + +Returns the number of blocks a pool output must mature before it can be spent +(after decommission). + +--- + +## Fees + +These functions return the minimum protocol fee for various operations at a +given block height: + +```python +def fungible_token_issuance_fee(self, current_block_height: int, network: Network) -> Amount: ... +def nft_issuance_fee(self, current_block_height: int, network: Network) -> Amount: ... +def data_deposit_fee(self, current_block_height: int, network: Network) -> Amount: ... +def token_supply_change_fee(self, current_block_height: int, network: Network) -> Amount: ... +def token_freeze_fee(self, current_block_height: int, network: Network) -> Amount: ... +def token_change_authority_fee(self, current_block_height: int, network: Network) -> Amount: ... +``` + +Add these fees to the transaction outputs when building the relevant +transaction types manually — see [tokens.md](tokens.md). + +--- + +## Transaction intents + +Transaction intents provide a signed declaration of what a transaction is +intended to do, independent of the transaction bytes themselves. + +### `make_transaction_intent_message_to_sign` + +```python +def make_transaction_intent_message_to_sign(self, intent: str, transaction_id: str) -> bytes: ... +``` + +Creates the message bytes that should be signed to bind an intent string to a +transaction ID (`transaction_id` is the hex ID from `get_transaction_id`). + +### `encode_signed_transaction_intent` + +```python +def encode_signed_transaction_intent( + self, signed_message: bytes, signatures: list[bytes] +) -> bytes: ... +``` + +Encodes a signed intent along with its signatures (one raw signature per +transaction input, each produced by `sign_challenge`). + +### `verify_transaction_intent` + +```python +def verify_transaction_intent( + self, + expected_signed_message: bytes, + encoded_signed_intent: bytes, + input_destinations: list[str], + network: Network, +) -> None: ... +``` + +Verifies that a signed intent matches the expected message and that the +signatures are valid for the given input destinations (one bech32m address per +transaction input). + +--- + +## Related + +- [transactions.md](transactions.md) — end-to-end manual transaction flow +- [staking.md](staking.md) — staking via wallet daemon and manual encoding +- [tokens.md](tokens.md) — token lifecycle via wallet daemon and manual encoding +- [wallet.md](wallet.md) — the wallet daemon client (does the encoding for you) diff --git a/examples/issue_token.py b/examples/issue_token.py new file mode 100644 index 0000000..4a108fd --- /dev/null +++ b/examples/issue_token.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Mintlayer Institutional FZCO +# Contact: hello@mintlayer.org +# +# Use of this source code is governed by an MIT license +# that can be found in the LICENSE file. + +"""issue-token: issue a new fungible token and mint initial supply via the +Mintlayer wallet daemon. + +The flow: + +1. Open the wallet (or connect to an already-open one). +2. Sync the wallet with the chain. +3. Derive a new receiving address to be the token authority. +4. Issue the token — the wallet signs, pays fees, and broadcasts the tx. +5. Mint an initial supply to the same address. + +Usage: + + uv run python examples/issue_token.py \ + --wallet /path/to/wallet.dat \ + --ticker MYTOKEN \ + --decimals 2 \ + --supply 1000000 \ + --uri "https://example.com/token-metadata.json" \ + --wallet-rpc http://127.0.0.1:3034 + +Requirements: +- wallet-rpc-daemon must be running (or pass --wallet to open one). +- The wallet account must hold enough ML to pay issuance fees. +""" + +from __future__ import annotations + +import argparse +import getpass +import logging +import os +import sys +import time + +from mintlayer._jsonrpc import JSONRPCError +from mintlayer.wallet import ( + Amount, + IssueTokenParams, + MintParams, + RPCError, + TokenMetadata, + TokenSupply, +) +from mintlayer.wallet import ( + Client as WalletClient, +) + +log = logging.getLogger("issue-token") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wallet", default="", help="path to wallet file (skip if already open)") + parser.add_argument( + "--password", + default="", + help="wallet password; omit to use $WALLET_PASSWORD or a hidden prompt", + ) + parser.add_argument("--ticker", required=True, help="token ticker symbol, e.g. MYTOKEN") + parser.add_argument("--decimals", type=int, default=2, help="number of decimal places (0-18)") + parser.add_argument("--supply", default="1000000", help="initial mint supply in smallest unit") + parser.add_argument("--uri", default="", help="URL pointing to token metadata JSON") + parser.add_argument("--wallet-rpc", default="http://127.0.0.1:3034", help="wallet RPC endpoint") + parser.add_argument("--indexer", default="http://127.0.0.1:3000", help="indexer base URL") + parser.add_argument("--account", type=int, default=0, help="wallet account index") + parser.add_argument( + "--wait-timeout", + type=int, + default=300, + help="max seconds to wait for issuance confirmation", + ) + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if not args.password: + args.password = os.environ.get("WALLET_PASSWORD", "") + if not args.password and sys.stdin.isatty(): + args.password = getpass.getpass("wallet password (input hidden, empty if none): ") + + # ── 1. Connect to the wallet daemon ────────────────────────────────────── + wallet = WalletClient(args.wallet_rpc) + + # Open the wallet if a path was provided. Skip if the daemon already has + # a wallet open (e.g. from a previous session). + if args.wallet: + try: + wallet.open_wallet(args.wallet, args.password) + except Exception as exc: + log.fatal("open wallet: %s", exc) + sys.exit(1) + log.info("wallet opened: %s", args.wallet) + + # ── 2. Sync the wallet ─────────────────────────────────────────────────── + try: + wallet.sync_wallet() + except (RPCError, JSONRPCError) as exc: + # Non-fatal: the daemon may already be syncing. Deliberately narrow — + # programming errors must not be silenced here. + log.info("sync wallet: %s (continuing)", exc) + + # ── 3. Derive a fresh address to act as the token authority ────────────── + # + # The authority address is the address whose private key can later mint, + # burn, freeze, or transfer the authority of the token. + try: + authority_addr = wallet.new_address(args.account) + except Exception as exc: + log.fatal("new address: %s", exc) + sys.exit(1) + log.info("authority address: %s", authority_addr) + + # Show the current balance so the user can confirm there are enough funds. + try: + balance = wallet.get_balance(args.account) + log.info("account balance: %s atoms (%s ML)", balance.coins.atoms, balance.coins.decimal) + except Exception as exc: + log.info("get balance: %s (continuing)", exc) + + # ── 4. Issue the token ─────────────────────────────────────────────────── + # + # Token supply type "Lockable" means the supply is unlimited until you + # explicitly call lock_token_supply. Use "Fixed" (with a cap) or + # "Unlimited" to change the supply policy. + try: + issue_result = wallet.issue_token( + IssueTokenParams( + account=args.account, + destination_address=authority_addr, + metadata=TokenMetadata( + token_ticker=args.ticker, + number_of_decimals=args.decimals, + metadata_uri=args.uri, + token_supply=TokenSupply(type="Lockable"), + is_freezable=False, + ), + ) + ) + except Exception as exc: + log.fatal("issue token: %s", exc) + sys.exit(1) + + print("token issued") + print(f" token id: {issue_result.token_id}") + print(f" tx id: {issue_result.tx_id}") + + # ── 5. Mint initial supply ─────────────────────────────────────────────── + # + # MintTokens creates new tokens and sends them to the given address. + # The wallet must control the authority key. + # + # Minting requires the issuance transaction to be confirmed first, so poll + # the indexer until it is (bounded by --wait-timeout). + from mintlayer.indexer import Client as IndexerClient + from mintlayer.indexer import HTTPError + + indexer = IndexerClient(args.indexer) + deadline = time.monotonic() + args.wait_timeout + while True: + try: + info = indexer.get_transaction(issue_result.tx_id) + except HTTPError as exc: + if exc.status_code != 404: + raise # transport/5xx failures are not "not indexed yet" + info = None # not indexed yet — keep polling + # confirmations is a string; treat "" and "0" as unconfirmed. + if info and info.confirmations not in ("", "0"): + log.info("issuance confirmed (%s confirmations)", info.confirmations) + break + if time.monotonic() >= deadline: + log.warning( + "issuance tx %s not confirmed within %ds; skipping mint (re-run once it confirms)", + issue_result.tx_id, + args.wait_timeout, + ) + return + log.info("waiting for issuance tx %s to confirm...", issue_result.tx_id) + time.sleep(5) + + try: + mint_result = wallet.mint_tokens( + MintParams( + account=args.account, + token_id=issue_result.token_id, + address=authority_addr, + amount=Amount(atoms=args.supply), + ) + ) + except Exception as exc: + log.fatal( + "mint tokens: %s\n\n" + "Tip: the issuance tx may not have confirmed yet.\n" + "Wait for confirmation and re-run with the token id.", + exc, + ) + sys.exit(1) + + print("tokens minted") + print(f" tx id: {mint_result.tx_id}") + print(f" fees: {mint_result.fees.coins.atoms} atoms") + + +if __name__ == "__main__": + main() diff --git a/examples/send_coins.py b/examples/send_coins.py new file mode 100644 index 0000000..f4a1bd2 --- /dev/null +++ b/examples/send_coins.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Mintlayer Institutional FZCO +# Contact: hello@mintlayer.org +# +# Use of this source code is governed by an MIT license +# that can be found in the LICENSE file. + +"""send-coins: the full manual transaction flow using the Mintlayer Python SDK. + +1. Derive an account key and receiving address from a BIP-39 mnemonic. +2. Fetch spendable UTXOs for that address from the indexer. +3. Build an unsigned transaction (encode inputs, recipient output, change). +4. Sign each input with encode_witness. +5. Submit the signed transaction to the indexer. + +Usage: + + uv run python examples/send_coins.py \\ + --to mtc1qrecipient... \\ + --amount 100000000000 \\ + --indexer http://127.0.0.1:3000 + +The mnemonic can be passed via ``--mnemonic``, the ``MNEMONIC`` environment +variable, or a hidden interactive prompt — avoiding shell history and ``ps`` +exposure. + +NOTE: This is a teaching example: fees are estimated from the indexer fee +rate, the remainder is returned to the source address as change, and only +plain Transfer/Coin UTXOs are selected. Production code should use the wallet +daemon or a proper coin-selection and fee-bumping strategy. +""" + +from __future__ import annotations + +import argparse +import getpass +import logging +import os +import sys + +from mintlayer.indexer import Client as IndexerClient +from mintlayer.wasm import ( + SOURCE_TRANSACTION, + Amount, + Network, + SignatureHashType, + TxAdditionalInfo, +) +from mintlayer.wasm import ( + Client as WasmClient, +) + +log = logging.getLogger("send-coins") + +FEE_RATE_PER_KB_FALLBACK = 100_000 # atoms/KB used when the indexer has no fee data + +# Change outputs below this many atoms are dropped (the remainder goes to +# fees): a dust output may be rejected by nodes and costs more to spend +# than it is worth. +DUST_THRESHOLD_ATOMS = 1_000_000 # 0.00001 ML + + +def is_coin_transfer(output: object) -> bool: + """Whether a decoded UTXO output is a plain Transfer of native coins. + + Indexer wire shape (tagged union, see tests/test_indexer_address.py): + ``{"Transfer": {"destination": ..., "amount": {"atoms": ...}}}``. Native + coins carry a bare ``amount``; token transfers additionally carry a + ``token_id``. + """ + if not isinstance(output, dict): + return False + transfer = output.get("Transfer") + if not isinstance(transfer, dict): + return False + amount = transfer.get("amount") + return ( + isinstance(amount, dict) + and "atoms" in amount + and "token_id" not in amount + and "tokenId" not in amount + ) + + +def output_atoms(output: dict) -> int: + """Atom count of a Transfer/Coin output (pre-validated by is_coin_transfer).""" + return int(output["Transfer"]["amount"]["atoms"]) + + +def encode_utxo_entry(wasm: WasmClient, utxo_json: dict, network: Network) -> bytes: + """Re-encode a JSON UTXO output into the binary form encode_witness expects. + + Format: ``0x01 + ``. Signatures only verify on-chain + if the sighash covers the real output, so a re-encoding failure is fatal + rather than silently downgraded to a non-UTXO (``0x00``) entry. + """ + if is_coin_transfer(utxo_json): + transfer = utxo_json["Transfer"] + encoded = wasm.encode_output_transfer( + Amount(atoms=transfer["amount"]["atoms"]), + transfer["destination"], + network, + ) + return b"\x01" + encoded + raise ValueError(f"unsupported UTXO output type for minimal send: {list(utxo_json)!r}") + + +def resolve_mnemonic(cli_value: str) -> str: + """CLI argument, then the ``MNEMONIC`` env var, then a hidden prompt.""" + if cli_value: + return cli_value + env = os.environ.get("MNEMONIC", "") + if env: + log.info("using mnemonic from the MNEMONIC environment variable") + return env + return getpass.getpass("BIP-39 mnemonic (input hidden): ") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--mnemonic", + default="", + help="BIP-39 mnemonic (insecure: visible in ps/shell history); prefer $MNEMONIC or prompt", + ) + parser.add_argument("--to", required=True, help="recipient bech32m address") + parser.add_argument("--amount", required=True, help="amount to send in atoms (1 ML = 1e11)") + parser.add_argument("--indexer", default="http://127.0.0.1:3000", help="indexer base URL") + parser.add_argument("--key-index", type=int, default=0, help="receiving address key index") + parser.add_argument( + "--network", type=int, default=0, help="0=mainnet 1=testnet 2=regtest 3=signet" + ) + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + network = Network(args.network) + + # ── 1. Initialise the WASM cryptography runtime ────────────────────────── + wasm = WasmClient() + + # ── 2. Derive the spending key and address ─────────────────────────────── + mnemonic = resolve_mnemonic(args.mnemonic) + account_key = wasm.make_default_account_privkey(mnemonic, network) + spend_key = wasm.make_receiving_address(account_key, args.key_index) + pub_key = wasm.public_key_from_private_key(spend_key) + from_addr = wasm.pubkey_to_pubkeyhash_address(pub_key, network) + log.info("spending from: %s", from_addr) + + # ── 3. Fetch spendable UTXOs (this minimal send only handles Coin) ─────── + indexer = IndexerClient(args.indexer) + all_utxos = indexer.get_spendable_utxos(from_addr) + + utxos = [u for u in all_utxos if is_coin_transfer(u.output)] + for u in all_utxos: + if not is_coin_transfer(u.output): + output_type = next(iter(u.output)) if isinstance(u.output, dict) and u.output else None + log.warning("skipping non-Coin UTXO (type=%s)", output_type) + + if not utxos: + log.fatal("no spendable Coin UTXOs for %s", from_addr) + sys.exit(1) + log.info("found %d spendable UTXO(s)", len(utxos)) + + total = sum(output_atoms(u.output) for u in utxos) + send_amt = int(args.amount.strip()) + if send_amt <= 0: + log.fatal("amount must be positive") + sys.exit(1) + if total < send_amt: + log.fatal("insufficient balance: have %d atoms, need %d atoms", total, send_amt) + sys.exit(1) + + # ── 4. Encode inputs and collect per-input UTXO bytes ──────────────────── + encoded_inputs = b"" + all_utxo_bytes = b"" + for u in utxos: + tx_id_bytes = bytes.fromhex(u.outpoint.source_id) + src_id = wasm.encode_outpoint_source_id(tx_id_bytes, SOURCE_TRANSACTION) + encoded_inputs += wasm.encode_input_for_utxo(src_id, u.outpoint.index) + all_utxo_bytes += encode_utxo_entry(wasm, u.output, network) + + # ── 5. Fee rate, then build the transaction with change ────────────────── + try: + fee_rate = int(indexer.get_fee_rate()) # atoms per kilobyte + except Exception as exc: + log.warning( + "fee rate lookup failed (%s); using fallback %d atoms/KB", + exc, + FEE_RATE_PER_KB_FALLBACK, + ) + fee_rate = FEE_RATE_PER_KB_FALLBACK + + def build(fee: int) -> tuple[bytes, int]: + """Recipient output + change output; return (tx, estimated size).""" + change = total - send_amt - fee + if change < 0: + raise ValueError(f"insufficient balance for fee: have {total}, need {send_amt} + {fee}") + outputs = wasm.encode_output_transfer(Amount(atoms=str(send_amt)), args.to, network) + if change > DUST_THRESHOLD_ATOMS: + outputs += wasm.encode_output_transfer(Amount(atoms=str(change)), from_addr, network) + elif change > 0: + log.warning( + "dropping dust change of %d atoms (below %d); remainder goes to fees", + change, + DUST_THRESHOLD_ATOMS, + ) + tx = wasm.encode_transaction(encoded_inputs, outputs, 0) + # The size estimate needs the encoded INPUTS blob (one address per input). + size = wasm.estimate_transaction_size( + encoded_inputs, [from_addr] * len(utxos), outputs, network + ) + return tx, size + + # Start from a zero fee so the first build always succeeds (it only needs + # balance >= send_amt); the loop then converges from the size estimate. + # Starting from the full 1 KB rate would abort builds that are actually + # affordable. + fee = 0 + try: + tx, size = build(fee) + for _ in range(4): + new_fee = max(1, -(-size // 1000) * fee_rate) # ceil(size / 1000) * rate + if new_fee == fee: + break + fee = new_fee + tx, size = build(fee) + else: + # Not converged in 4 passes: make sure the fee still covers the + # final size instead of submitting an underpriced transaction. + needed = max(1, -(-size // 1000) * fee_rate) + if needed > fee: + log.warning("fee loop did not converge; rebuilding with %d atoms", needed) + fee = needed + tx, size = build(fee) + except ValueError as exc: + log.fatal("%s", exc) + sys.exit(1) + + tx_id = wasm.get_transaction_id(tx, True) + log.info("unsigned tx id: %s (fee: %d atoms)", tx_id, fee) + + # ── 6. Sign each input and collect witnesses ───────────────────────────── + witness_bytes = b"" + for i in range(len(utxos)): + witness_bytes += wasm.encode_witness( + SignatureHashType.SIGHASH_ALL, + spend_key, + from_addr, + tx, + all_utxo_bytes, + i, + TxAdditionalInfo(), + 0, # block height (0 = no lock-time constraint) + network, + ) + + # ── 7. Assemble the signed transaction ─────────────────────────────────── + signed_tx = wasm.encode_signed_transaction(tx, witness_bytes) + log.info("signed tx (%d bytes)", len(signed_tx)) + + # ── 8. Submit ──────────────────────────────────────────────────────────── + submitted_tx_id = indexer.submit_transaction(signed_tx.hex()) + print(f"submitted: {submitted_tx_id}") + + +if __name__ == "__main__": + main() diff --git a/mintlayer/__init__.py b/mintlayer/__init__.py new file mode 100644 index 0000000..c85b095 --- /dev/null +++ b/mintlayer/__init__.py @@ -0,0 +1,89 @@ +"""Mintlayer Python SDK. + +A Python SDK for the Mintlayer blockchain, ported from the +`Mintlayer Go SDK `_. + + import mintlayer + + client = mintlayer.Client(mintlayer.Config( + node_url="http://127.0.0.1:3030", + indexer_url="http://127.0.0.1:3000", + wallet_url="http://127.0.0.1:3034", + )) + + tip = client.indexer.get_tip() + client.init_wasm() + priv = client.wasm.make_private_key() + +The four sub-clients are also importable directly: +``mintlayer.node``, ``mintlayer.indexer``, ``mintlayer.wallet`` and +``mintlayer.wasm``. +""" + +from __future__ import annotations + +from . import indexer, node, wallet, wasm +from .client import Client, Config +from .wasm import ( + MAINNET, + REGTEST, + SIGHASH_ALL, + SIGHASH_ANYONECANPAY, + SIGHASH_NONE, + SIGHASH_SINGLE, + SIGNET, + SOURCE_BLOCK_REWARD, + SOURCE_TRANSACTION, + TESTNET, + Amount, + CurrencyAmountKind, + FreezableToken, + Network, + OrderBalance, + OrderInfo, + PoolInfo, + SignatureHashType, + SimpleCurrencyAmount, + SourceId, + TokenUnfreezable, + TotalSupply, + TxAdditionalInfo, + WasmError, +) + +__version__ = "0.1.0" + +__all__ = [ + "__version__", + "Client", + "Config", + "node", + "indexer", + "wallet", + "wasm", + # convenience re-exports (mirrors the Go SDK root package) + "Amount", + "Network", + "MAINNET", + "TESTNET", + "REGTEST", + "SIGNET", + "SignatureHashType", + "SIGHASH_ALL", + "SIGHASH_NONE", + "SIGHASH_SINGLE", + "SIGHASH_ANYONECANPAY", + "SourceId", + "SOURCE_TRANSACTION", + "SOURCE_BLOCK_REWARD", + "TotalSupply", + "FreezableToken", + "TokenUnfreezable", + "CurrencyAmountKind", + "SimpleCurrencyAmount", + "OrderBalance", + "PoolInfo", + "OrderInfo", + "TxAdditionalInfo", + "WasmError", +] diff --git a/mintlayer/_jsonrpc.py b/mintlayer/_jsonrpc.py new file mode 100644 index 0000000..c5cfdd9 --- /dev/null +++ b/mintlayer/_jsonrpc.py @@ -0,0 +1,172 @@ +"""Shared JSON-RPC 2.0 transport for the node and wallet clients. + +Mirrors the (duplicated) transports in go-sdk/node/client.go and +go-sdk/wallet/client.go: + +* one HTTP POST per call, no batching, params are always a JSON object + (``{}`` for no-arg methods), +* monotonically increasing integer request IDs starting at 1, +* HTTP Basic Auth applied only when a username is set (and never sourced + from the environment — ``.netrc`` lookup is suppressed for sessions the + client creates itself; caller-supplied sessions keep their own + ``trust_env`` behavior), +* no per-call cancellation: timeouts are configured on the client + (Go's per-call ``context`` has no direct requests equivalent), +* the HTTP status code is never inspected — a JSON-RPC ``error`` object in the + body is the error contract, +* JSON ``null`` results signal "not found" for pointer-returning methods. +""" + +from __future__ import annotations + +import threading +from typing import Any +from urllib.parse import urlsplit + +import requests + +__all__ = ["JSONRPCError", "RPCError", "JSONRPCClient"] + +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) + + +class _NoAuth(requests.auth.AuthBase): + """Explicit no-auth marker; prevents requests' .netrc environment fallback.""" + + def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest: + return r + + +class JSONRPCError(Exception): + """Transport or codec failure (not a JSON-RPC error object).""" + + +class RPCError(Exception): + """JSON-RPC error returned by the daemon.""" + + def __init__(self, code: int, message: str) -> None: + super().__init__(f"RPC error {code}: {message}") + self.code = code + self.message = message + + +def _assert_credential_safety(endpoint: str) -> None: + """Refuse basic-auth credentials over cleartext http to non-loopback hosts. + + The node/wallet daemons hold wallet-signing credentials; a misconfigured + remote ``http://`` URL would transmit them in cleartext. Loopback http is + allowed (local daemons with auth enabled). + """ + parsed = urlsplit(endpoint) + if parsed.scheme != "http": + return + host = (parsed.hostname or "").lower() + if host in _LOOPBACK_HOSTS: + return + raise ValueError( + f"refusing to send basic-auth credentials over cleartext http:// to " + f"non-loopback host {host!r}; use https:// or a loopback address" + ) + + +class JSONRPCClient: + """Minimal JSON-RPC 2.0 over HTTP POST client.""" + + def __init__( + self, + endpoint: str, + username: str = "", + password: str = "", + timeout: float = 30.0, + session: requests.Session | None = None, + ) -> None: + if username: + _assert_credential_safety(endpoint) + self.endpoint = endpoint + self.username = username + self.password = password + self.timeout = timeout + self._owns_session = session is None + self._session = session if session is not None else requests.Session() + self._lock = threading.Lock() + self._id = 0 + + def call(self, method: str, params: Any) -> Any: + """Execute a JSON-RPC call and return the parsed result. + + ``params`` must be a JSON-serialisable object (dict); no-arg methods + pass ``{}``. A JSON-RPC error object raises :class:`RPCError`. + + Thread-safety: the id counter and per-call flow are synchronised, and + the daemons are cookie-less, so concurrent calls share the session's + connection pool safely (urllib3 pool is thread-safe). Sessions carry + no per-call state here; a caller-supplied session that mutates state + (cookies, hooks) is the caller's responsibility to synchronise. + """ + with self._lock: + self._id += 1 + request_id = self._id + payload = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} + if self.username: + auth: requests.auth.AuthBase | tuple[str, str] | None = (self.username, self.password) + elif self._owns_session: + auth = _NoAuth() + else: + auth = None + try: + resp = self._session.post( + self.endpoint, + json=payload, + timeout=self.timeout, + auth=auth, + headers={"Content-Type": "application/json"}, + ) + except requests.RequestException as exc: + raise JSONRPCError(f"http request: {exc}") from exc + try: + body = resp.json() + except ValueError as exc: + raise JSONRPCError(f"decode response: {exc}") from exc + if not isinstance(body, dict): + raise JSONRPCError("decode response: unexpected JSON-RPC response shape") + # The daemon must echo the request id. JSON-RPC 2.0 reserves a null id + # for server-side error notifications; a success payload with a null + # or mismatched id cannot be attributed to this call — fail loudly. + resp_id = body.get("id") + if resp_id != request_id: + raise JSONRPCError(f"response id mismatch: expected {request_id}, got {resp_id!r}") + error = body.get("error") + if error is not None: + if not isinstance(error, dict): + raise JSONRPCError("decode response: JSON-RPC error object has unexpected shape") + raise RPCError(error.get("code", 0), error.get("message", "")) + return body.get("result") + + def close(self) -> None: + """Close the HTTP session (only if the client created it).""" + if self._owns_session: + self._session.close() + + +class BaseJSONRPCClient: + """Mixin base shared by the node and wallet clients. + + Both clients wrap a :class:`JSONRPCClient` as ``self._rpc`` and share the + raw-call and session-lifecycle helpers below. Typed result decoding stays + in each package's ``_core`` module (mirroring the separate Go node/wallet + packages). + """ + + _rpc: JSONRPCClient + + def _call(self, method: str, params: Any) -> Any: + """Call and return the decoded JSON result (None for JSON null).""" + return self._rpc.call(method, params) + + def _call_ignore(self, method: str, params: Any) -> None: + """Call and discard the result (void methods).""" + self._rpc.call(method, params) + + def close(self) -> None: + """Close the underlying HTTP session.""" + self._rpc.close() diff --git a/mintlayer/client.py b/mintlayer/client.py new file mode 100644 index 0000000..b433a40 --- /dev/null +++ b/mintlayer/client.py @@ -0,0 +1,178 @@ +"""Top-level Mintlayer SDK client (mirrors go-sdk/client.go). + +Constructs only the sub-clients whose URL is configured, wires the WASM +cryptography runtime lazily via :meth:`Client.init_wasm`, and re-exports the +common types so callers importing just ``mintlayer`` get the full surface. +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field + +from .indexer import Client as IndexerClient +from .node import Client as NodeClient +from .wallet import Client as WalletClient +from .wasm import ( + MAINNET, + REGTEST, + SIGHASH_ALL, + SIGHASH_ANYONECANPAY, + SIGHASH_NONE, + SIGHASH_SINGLE, + SIGNET, + SOURCE_BLOCK_REWARD, + SOURCE_TRANSACTION, + TESTNET, + Amount, + CurrencyAmountKind, + FreezableToken, + Network, + OrderBalance, + OrderInfo, + PoolInfo, + SignatureHashType, + SimpleCurrencyAmount, + SourceId, + TokenUnfreezable, + TotalSupply, + TxAdditionalInfo, + WasmError, +) +from .wasm import ( + Client as WASMClient, +) + +__all__ = [ + "Config", + "Client", + # re-exports (mirrors the Go SDK's convenience aliases) + "Amount", + "Network", + "MAINNET", + "TESTNET", + "REGTEST", + "SIGNET", + "SignatureHashType", + "SIGHASH_ALL", + "SIGHASH_NONE", + "SIGHASH_SINGLE", + "SIGHASH_ANYONECANPAY", + "SourceId", + "SOURCE_TRANSACTION", + "SOURCE_BLOCK_REWARD", + "TotalSupply", + "FreezableToken", + "TokenUnfreezable", + "CurrencyAmountKind", + "SimpleCurrencyAmount", + "OrderBalance", + "PoolInfo", + "OrderInfo", + "TxAdditionalInfo", + "WasmError", +] + + +@dataclass(frozen=True) +class Config: + """Top-level SDK configuration. + + Only sub-clients whose URL field is non-empty are constructed. + ``password`` is redacted from :func:`repr` to keep credentials out of logs. + """ + + node_url: str = "" + indexer_url: str = "" + wallet_url: str = "" + username: str = "" + password: str = field(default="", repr=False) + timeout: float = 30.0 + + def __repr__(self) -> str: + return ( + f"Config(node_url={self.node_url!r}, indexer_url={self.indexer_url!r}, " + f"wallet_url={self.wallet_url!r}, username={self.username!r}, " + f"password='***', timeout={self.timeout!r})" + ) + + +class Client: + """The top-level Mintlayer SDK client. + + ``node``, ``indexer`` and ``wallet`` are ``None`` when their URL was not + set in the :class:`Config`; ``wasm`` is ``None`` until + :meth:`init_wasm` is called (~400 ms one-time cost). + """ + + def __init__(self, cfg: Config) -> None: + self._mu = threading.Lock() + self._wasm: WASMClient | None = None + + self.node: NodeClient | None = None + self.indexer: IndexerClient | None = None + self.wallet: WalletClient | None = None + try: + if cfg.node_url: + self.node = NodeClient( + cfg.node_url, + username=cfg.username, + password=cfg.password, + timeout=cfg.timeout, + ) + + if cfg.indexer_url: + self.indexer = IndexerClient(cfg.indexer_url, timeout=cfg.timeout) + + if cfg.wallet_url: + self.wallet = WalletClient( + cfg.wallet_url, + username=cfg.username, + password=cfg.password, + timeout=cfg.timeout, + ) + except BaseException: + # A later constructor failing (e.g. the cleartext-credential guard) + # must not leak the sessions of already-created sub-clients. + self.close() + raise + + def init_wasm(self) -> None: + """Initialise the embedded WASM cryptography runtime (~400 ms). + + Subsequent calls are no-ops. Safe for concurrent use. + + The lock is deliberately held for the whole construction: a + concurrent ``wasm`` access must wait for a fully-built client rather + than observe a half-initialised one (or trigger duplicate builds). + """ + with self._mu: + if self._wasm is None: + self._wasm = WASMClient() + + @property + def wasm(self) -> WASMClient: + """The WASM cryptography client; :meth:`init_wasm` must be called first.""" + with self._mu: + if self._wasm is None: + raise WasmError("mintlayer: init_wasm() must be called before using client.wasm") + return self._wasm + + def close(self) -> None: + """Release WASM resources and close sessions owned by sub-clients.""" + with self._mu: + if self.node is not None: + self.node.close() + if self.indexer is not None: + self.indexer.close() + if self.wallet is not None: + self.wallet.close() + if self._wasm is not None: + self._wasm.close() + self._wasm = None + + def __enter__(self) -> Client: + return self + + def __exit__(self, *_exc: object) -> None: + self.close() diff --git a/mintlayer/indexer/__init__.py b/mintlayer/indexer/__init__.py new file mode 100644 index 0000000..6ae7342 --- /dev/null +++ b/mintlayer/indexer/__init__.py @@ -0,0 +1,117 @@ +"""REST client for the Mintlayer indexer. + +Mirrors go-sdk/indexer (Go package ``indexer``). Default port: 3000. +All paths are relative to ``/api/v2``. + + from mintlayer.indexer import Client + + c = Client("http://127.0.0.1:3000") + tip = c.get_tip() + print(tip.block_height, tip.block_id) +""" + +from __future__ import annotations + +import requests + +from ._http import HTTPError, IndexerError, IndexerHTTP +from .address import AddressMixin +from .block import BlockMixin +from .chain import ChainMixin +from .delegation import DelegationMixin +from .order import OrderMixin +from .pool import PoolMixin +from .statistics import StatisticsMixin +from .token import TokenMixin +from .transaction import TransactionMixin +from .types import ( + UTXO, + AddressInfo, + Amount, + Block, + BlockHeader, + ChainTip, + CoinStats, + Delegation, + DelegationInfo, + GenesisInfo, + MerklePath, + NFTInfo, + NFTMetadata, + Order, + PageOpts, + Pool, + PoolDelegation, + PoolListOpts, + Timestamp, + TokenBalance, + TokenInfo, + TokenTx, + Transaction, + UTXOOutpoint, +) + +__all__ = [ + "Client", + "HTTPError", + "IndexerError", + "Amount", + "AddressInfo", + "Block", + "BlockHeader", + "ChainTip", + "CoinStats", + "Delegation", + "DelegationInfo", + "GenesisInfo", + "MerklePath", + "Timestamp", + "NFTInfo", + "NFTMetadata", + "Order", + "PageOpts", + "Pool", + "PoolDelegation", + "PoolListOpts", + "TokenBalance", + "TokenInfo", + "TokenTx", + "Transaction", + "UTXO", + "UTXOOutpoint", +] + + +class Client( + ChainMixin, + BlockMixin, + TransactionMixin, + AddressMixin, + DelegationMixin, + PoolMixin, + TokenMixin, + OrderMixin, + StatisticsMixin, +): + """REST client for the indexer (api-web-server). + + Requests are thread-safe in the sense that call results are independent; + the underlying ``requests.Session`` is shared, and its cookie jar is not + synchronised (the indexer API is cookie-less, so this is benign here). + Any HTTP status >= 400 raises :class:`HTTPError`. + """ + + def __init__( + self, + base_url: str, + timeout: float = 30.0, + session: requests.Session | None = None, + ) -> None: + """Create an indexer client; trailing slashes on ``base_url`` are trimmed.""" + IndexerHTTP.__init__(self, base_url=base_url, timeout=timeout, session=session) + + def __enter__(self) -> Client: + return self + + def __exit__(self, *_exc: object) -> None: + self.close() diff --git a/mintlayer/indexer/_http.py b/mintlayer/indexer/_http.py new file mode 100644 index 0000000..92e4e4c --- /dev/null +++ b/mintlayer/indexer/_http.py @@ -0,0 +1,89 @@ +"""REST client for the Mintlayer indexer (mirrors go-sdk/indexer). + +All paths are relative to the ``/api/v2`` base appended to the configured URL. +Errors: any HTTP status >= 400 raises :class:`HTTPError` with the response +body; transport/decode failures raise :class:`IndexerError`. +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +import requests + +__all__ = ["HTTPError", "IndexerError", "IndexerHTTP"] + + +class IndexerError(Exception): + """Transport or codec failure.""" + + +class HTTPError(Exception): + """Non-2xx HTTP response from the indexer.""" + + def __init__(self, status_code: int, body: str) -> None: + super().__init__(f"HTTP {status_code}: {body}") + self.status_code = status_code + self.body = body + + +def _seg(value: Any) -> str: + """URL-encode a path segment (defense against path traversal/injection).""" + return quote(str(value), safe="") + + +class IndexerHTTP: + """HTTP layer for the indexer client (GET + one text/plain POST route).""" + + def __init__( + self, + base_url: str, + timeout: float = 30.0, + session: requests.Session | None = None, + ) -> None: + self.api_base = base_url.rstrip("/") + "/api/v2" + self.timeout = timeout + self._owns_session = session is None + self._session = session if session is not None else requests.Session() + + def get(self, path: str, query: dict[str, Any] | None = None) -> Any: + """GET ``path`` and return the decoded JSON body.""" + url = self.api_base + path + try: + resp = self._session.get( + url, + params=query or None, + timeout=self.timeout, + headers={"Accept": "application/json"}, + ) + except requests.RequestException as exc: + raise IndexerError(f"http request: {exc}") from exc + return self._decode(path, resp) + + def post_text(self, path: str, body: str) -> Any: + """POST ``body`` verbatim as ``text/plain`` and return decoded JSON.""" + url = self.api_base + path + try: + resp = self._session.post( + url, + data=body, + timeout=self.timeout, + headers={"Content-Type": "text/plain", "Accept": "application/json"}, + ) + except requests.RequestException as exc: + raise IndexerError(f"http request: {exc}") from exc + return self._decode(path, resp) + + def _decode(self, path: str, resp: requests.Response) -> Any: + if resp.status_code >= 400: + raise HTTPError(resp.status_code, resp.text.strip()) + try: + return resp.json() + except ValueError as exc: + raise IndexerError(f"decode response for {path}: {exc}") from exc + + def close(self) -> None: + """Close the HTTP session (only if the client created it).""" + if self._owns_session: + self._session.close() diff --git a/mintlayer/indexer/address.py b/mintlayer/indexer/address.py new file mode 100644 index 0000000..76d6745 --- /dev/null +++ b/mintlayer/indexer/address.py @@ -0,0 +1,31 @@ +"""Address endpoints (mirrors go-sdk/indexer/address.go).""" + +from __future__ import annotations + +from ._http import IndexerHTTP, _seg +from .types import UTXO, AddressInfo, DelegationInfo + + +class AddressMixin(IndexerHTTP): + def get_address_info(self, address: str) -> AddressInfo: + """Return address balances and history (404 if the address has none).""" + return AddressInfo.from_json(self.get(f"/address/{_seg(address)}")) + + def get_spendable_utxos(self, address: str) -> list[UTXO]: + """Return the address's spendable UTXOs.""" + data = self.get(f"/address/{_seg(address)}/spendable-utxos") + return [UTXO.from_json(u) for u in data or []] + + def get_all_utxos(self, address: str) -> list[UTXO]: + """Return all UTXOs for the address (including timelocked).""" + data = self.get(f"/address/{_seg(address)}/all-utxos") + return [UTXO.from_json(u) for u in data or []] + + def get_delegations(self, address: str) -> list[DelegationInfo]: + """Return the delegations created by the address.""" + data = self.get(f"/address/{_seg(address)}/delegations") + return [DelegationInfo.from_json(d) for d in data or []] + + def get_token_authority(self, address: str) -> list[str]: + """Return the bech32 token IDs the address has authority over.""" + return self.get(f"/address/{_seg(address)}/token-authority") or [] diff --git a/mintlayer/indexer/block.py b/mintlayer/indexer/block.py new file mode 100644 index 0000000..2b6dcac --- /dev/null +++ b/mintlayer/indexer/block.py @@ -0,0 +1,24 @@ +"""Block endpoints (mirrors go-sdk/indexer/block.go).""" + +from __future__ import annotations + +from ._http import IndexerHTTP, _seg +from .types import Block, BlockHeader + + +class BlockMixin(IndexerHTTP): + def get_block(self, block_id: str) -> Block: + """Return a block with header and body.""" + return Block.from_json(self.get(f"/block/{_seg(block_id)}")) + + def get_block_header(self, block_id: str) -> BlockHeader: + """Return a block header.""" + return BlockHeader.from_json(self.get(f"/block/{_seg(block_id)}/header")) + + def get_block_reward(self, block_id: str) -> list: + """Return the block reward outputs (raw JSON list).""" + return self.get(f"/block/{_seg(block_id)}/reward") or [] + + def get_block_transaction_ids(self, block_id: str) -> list[str]: + """Return the transaction IDs included in a block.""" + return self.get(f"/block/{_seg(block_id)}/transaction-ids") or [] diff --git a/mintlayer/indexer/chain.py b/mintlayer/indexer/chain.py new file mode 100644 index 0000000..dc00409 --- /dev/null +++ b/mintlayer/indexer/chain.py @@ -0,0 +1,25 @@ +"""Chain endpoints (mirrors go-sdk/indexer/chain.go).""" + +from __future__ import annotations + +from ._http import IndexerHTTP, _seg +from .types import ChainTip, GenesisInfo + + +class ChainMixin(IndexerHTTP): + def get_tip(self) -> ChainTip: + """Return the current chain tip.""" + return ChainTip.from_json(self.get("/chain/tip")) + + def get_genesis(self) -> GenesisInfo: + """Return genesis block info.""" + return GenesisInfo.from_json(self.get("/chain/genesis")) + + def get_block_id_at_height(self, height: int) -> str | None: + """Return the block ID at ``height``. + + ``None`` when the indexer responds with JSON null (rare: the endpoint + normally raises HTTPError 404 for unknown heights). + """ + result = self.get(f"/chain/{_seg(height)}") + return None if result is None else str(result) diff --git a/mintlayer/indexer/delegation.py b/mintlayer/indexer/delegation.py new file mode 100644 index 0000000..eed6731 --- /dev/null +++ b/mintlayer/indexer/delegation.py @@ -0,0 +1,12 @@ +"""Delegation endpoints (mirrors go-sdk/indexer/delegation.go).""" + +from __future__ import annotations + +from ._http import IndexerHTTP, _seg +from .types import Delegation + + +class DelegationMixin(IndexerHTTP): + def get_delegation(self, delegation_id: str) -> Delegation: + """Return a delegation by ID.""" + return Delegation.from_json(self.get(f"/delegation/{_seg(delegation_id)}")) diff --git a/mintlayer/indexer/number.py b/mintlayer/indexer/number.py new file mode 100644 index 0000000..68803d5 --- /dev/null +++ b/mintlayer/indexer/number.py @@ -0,0 +1,65 @@ +"""Lenient numeric decoding for indexer payloads (mirrors go-sdk/indexer/number.go). + +The indexer documents several fields as integers/floats but the server +sometimes serialises them as strings (and even with a trailing ``%``). +""" + +from __future__ import annotations + +import math +import re +from typing import Any + +from ._http import IndexerError + +_DIGITS = re.compile(r"\d+") + +__all__ = ["parse_uint64", "parse_per_thousand"] + + +def parse_uint64(data: Any) -> int: + """Accept a bare JSON number or a decimal string; return an int.""" + if isinstance(data, bool): + raise IndexerError(f"Uint64: invalid value {data!r}") + if isinstance(data, int): + if data < 0: + raise IndexerError(f"Uint64: negative value {data!r}") + return data + if isinstance(data, str): + if not _DIGITS.fullmatch(data): + raise IndexerError(f"Uint64: invalid value {data!r}") + if len(data) > 20 or (len(data) == 20 and data > "18446744073709551615"): + # Cannot be a uint64 (max 18446744073709551615, 20 digits). Also + # avoids the 3.11+ int-to-str digit limit raising a bare ValueError + # for hostile oversized payloads. + raise IndexerError(f"Uint64: value out of range {data!r}") + return int(data, 10) + raise IndexerError(f"Uint64: invalid value {data!r}") + + +def parse_per_thousand(data: Any) -> float: + """Accept a bare number, a decimal string, or a string with a trailing %.""" + if isinstance(data, bool): + raise IndexerError(f"PerThousand: invalid value {data!r}") + if isinstance(data, (int, float)): + try: + value = float(data) + except OverflowError as exc: + # float(10**400) raises OverflowError, not ValueError. + raise IndexerError(f"PerThousand: value out of range {data!r}") from exc + if not math.isfinite(value): + raise IndexerError(f"PerThousand: non-finite value {data!r}") + return value + if isinstance(data, str): + stripped = data.strip('"') + if stripped.endswith("%"): + stripped = stripped[:-1] + try: + value = float(stripped) + except ValueError as exc: + raise IndexerError(f"PerThousand: {exc}") from exc + else: + raise IndexerError(f"PerThousand: invalid value {data!r}") + if not math.isfinite(value): + raise IndexerError(f"PerThousand: non-finite value {data!r}") + return value diff --git a/mintlayer/indexer/order.py b/mintlayer/indexer/order.py new file mode 100644 index 0000000..a90560f --- /dev/null +++ b/mintlayer/indexer/order.py @@ -0,0 +1,30 @@ +"""Order endpoints (mirrors go-sdk/indexer/order.go).""" + +from __future__ import annotations + +from ._http import IndexerHTTP, _seg +from .types import Order, PageOpts + + +class OrderMixin(IndexerHTTP): + def list_orders(self, opts: PageOpts | None = None) -> list[Order]: + """List DEX orders with pagination.""" + data = self.get("/order", (opts or PageOpts()).query()) + return [Order.from_json(o) for o in data or []] + + def get_order(self, order_id: str) -> Order: + """Return a DEX order by ID.""" + return Order.from_json(self.get(f"/order/{_seg(order_id)}")) + + def list_orders_by_pair( + self, ask_currency: str, give_currency: str, opts: PageOpts | None = None + ) -> list[Order]: + """List orders for a currency pair. + + Currencies are the coin ticker (e.g. ``"ML"``) or a bech32 token ID; + the path is ``/order/pair/{ask}_{give}``. + """ + data = self.get( + f"/order/pair/{_seg(ask_currency)}_{_seg(give_currency)}", (opts or PageOpts()).query() + ) + return [Order.from_json(o) for o in data or []] diff --git a/mintlayer/indexer/pool.py b/mintlayer/indexer/pool.py new file mode 100644 index 0000000..d2e0322 --- /dev/null +++ b/mintlayer/indexer/pool.py @@ -0,0 +1,44 @@ +"""Pool endpoints (mirrors go-sdk/indexer/pool.go).""" + +from __future__ import annotations + +from datetime import datetime + +from ._http import IndexerError, IndexerHTTP, _seg +from .types import Pool, PoolDelegation, PoolListOpts + + +class PoolMixin(IndexerHTTP): + def list_pools(self, opts: PoolListOpts | None = None) -> list[Pool]: + """List staking pools with pagination and optional sort.""" + data = self.get("/pool", (opts or PoolListOpts()).query()) + return [Pool.from_json(p) for p in data or []] + + def get_pool(self, pool_id: str) -> Pool: + """Return a staking pool by ID.""" + return Pool.from_json(self.get(f"/pool/{_seg(pool_id)}")) + + def get_pool_block_stats(self, pool_id: str, from_time: datetime, to_time: datetime) -> int: + """Return the block count produced in the half-open interval [from, to). + + Naive datetimes are interpreted in the system's local timezone + (standard ``datetime.timestamp()`` semantics); pass tz-aware + datetimes for unambiguous absolute times. + """ + data = self.get( + f"/pool/{_seg(pool_id)}/block-stats", + {"from": int(from_time.timestamp()), "to": int(to_time.timestamp())}, + ) + if not isinstance(data, dict) or "block_count" not in data: + raise IndexerError(f"get_pool_block_stats: unexpected response {data!r}") + try: + return int(data["block_count"]) + except (TypeError, ValueError) as exc: + raise IndexerError( + f"get_pool_block_stats: invalid block_count {data['block_count']!r}" + ) from exc + + def get_pool_delegations(self, pool_id: str) -> list[PoolDelegation]: + """Return the delegations to a pool.""" + data = self.get(f"/pool/{_seg(pool_id)}/delegations") + return [PoolDelegation.from_json(d) for d in data or []] diff --git a/mintlayer/indexer/statistics.py b/mintlayer/indexer/statistics.py new file mode 100644 index 0000000..2769b7c --- /dev/null +++ b/mintlayer/indexer/statistics.py @@ -0,0 +1,25 @@ +"""Statistics endpoints (mirrors go-sdk/indexer/statistics.go).""" + +from __future__ import annotations + +from ._http import IndexerHTTP, _seg +from .types import CoinStats + + +class StatisticsMixin(IndexerHTTP): + def get_coin_statistics(self) -> CoinStats: + """Return circulating/preminted/burned/staked coin totals.""" + return CoinStats.from_json(self.get("/statistics/coin")) + + def get_token_statistics(self, token_id: str) -> CoinStats: + """Return the same statistics for a token.""" + return CoinStats.from_json(self.get(f"/statistics/token/{_seg(token_id)}")) + + def get_fee_rate(self, in_top_x_mb: int = 0) -> str: + """Return the fee rate (atoms per KB) as a decimal string. + + ``in_top_x_mb`` is omitted when zero, using the server default (5 MB). + """ + query = {"in_top_x_mb": in_top_x_mb} if in_top_x_mb > 0 else None + result = self.get("/feerate", query) + return "" if result is None else str(result) diff --git a/mintlayer/indexer/token.py b/mintlayer/indexer/token.py new file mode 100644 index 0000000..7406387 --- /dev/null +++ b/mintlayer/indexer/token.py @@ -0,0 +1,29 @@ +"""Token / NFT endpoints (mirrors go-sdk/indexer/token.go).""" + +from __future__ import annotations + +from ._http import IndexerHTTP, _seg +from .types import NFTInfo, PageOpts, TokenInfo, TokenTx + + +class TokenMixin(IndexerHTTP): + def list_tokens(self, opts: PageOpts | None = None) -> list[str]: + """List token IDs with pagination.""" + return self.get("/token", (opts or PageOpts()).query()) or [] + + def get_token(self, token_id: str) -> TokenInfo: + """Return token info by ID.""" + return TokenInfo.from_json(self.get(f"/token/{_seg(token_id)}")) + + def get_token_transactions(self, token_id: str, opts: PageOpts | None = None) -> list[TokenTx]: + """List transactions involving the token.""" + data = self.get(f"/token/{_seg(token_id)}/transactions", (opts or PageOpts()).query()) + return [TokenTx.from_json(t) for t in data or []] + + def find_tokens_by_ticker(self, ticker: str, opts: PageOpts | None = None) -> list[str]: + """Find token IDs by ticker symbol.""" + return self.get(f"/token/ticker/{_seg(ticker)}", (opts or PageOpts()).query()) or [] + + def get_nft(self, token_id: str) -> NFTInfo: + """Return NFT info by token ID.""" + return NFTInfo.from_json(self.get(f"/nft/{_seg(token_id)}")) diff --git a/mintlayer/indexer/transaction.py b/mintlayer/indexer/transaction.py new file mode 100644 index 0000000..b1c0ded --- /dev/null +++ b/mintlayer/indexer/transaction.py @@ -0,0 +1,34 @@ +"""Transaction endpoints (mirrors go-sdk/indexer/transaction.go).""" + +from __future__ import annotations + +from typing import Any + +from ._http import IndexerError, IndexerHTTP, _seg +from .types import MerklePath, PageOpts, Transaction + + +class TransactionMixin(IndexerHTTP): + def list_transactions(self, opts: PageOpts | None = None) -> list[Transaction]: + """List transactions with pagination.""" + data = self.get("/transaction", (opts or PageOpts()).query()) + return [Transaction.from_json(t) for t in data or []] + + def get_transaction(self, tx_id: str) -> Transaction: + """Return a transaction by ID.""" + return Transaction.from_json(self.get(f"/transaction/{_seg(tx_id)}")) + + def get_transaction_merkle_path(self, tx_id: str) -> MerklePath: + """Return the merkle path of a transaction (404 until it is in a block).""" + return MerklePath.from_json(self.get(f"/transaction/{_seg(tx_id)}/merkle-path")) + + def get_transaction_output(self, tx_id: str, output_index: int) -> Any: + """Return a transaction output as raw JSON (includes spent_at_block_height).""" + return self.get(f"/transaction/{_seg(tx_id)}/output/{output_index}") + + def submit_transaction(self, signed_tx_hex: str) -> str: + """Submit a signed transaction (hex) — requires ``--enable-post-routes``.""" + data = self.post_text("/transaction", signed_tx_hex) + if not isinstance(data, dict) or "tx_id" not in data: + raise IndexerError(f"submit_transaction: unexpected response {data!r}") + return str(data["tx_id"]) diff --git a/mintlayer/indexer/types.py b/mintlayer/indexer/types.py new file mode 100644 index 0000000..35f2938 --- /dev/null +++ b/mintlayer/indexer/types.py @@ -0,0 +1,540 @@ +"""Types for the indexer REST client (mirrors go-sdk/indexer/types.go). + +Amounts carry both ``atoms`` and ``decimal`` as plain strings. Fields the +server serialises leniently (numbers-as-strings) are parsed via +:mod:`mintlayer.indexer.number`. Raw JSON passthroughs are typed ``Any``. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, TypeVar + +from ._http import IndexerError +from .number import parse_per_thousand, parse_uint64 + +_T = TypeVar("_T") + +__all__ = [ + "Amount", + "Timestamp", + "ChainTip", + "GenesisInfo", + "BlockHeader", + "BlockBody", + "Block", + "Transaction", + "MerklePath", + "TokenBalance", + "AddressInfo", + "UTXOOutpoint", + "UTXO", + "DelegationInfo", + "Pool", + "Delegation", + "PoolDelegation", + "TokenInfo", + "TokenTx", + "NFTMetadata", + "NFTInfo", + "Order", + "CoinStats", + "PageOpts", + "PoolListOpts", +] + + +@dataclass(frozen=True) +class Amount: + """Coin/token amount in atoms and decimal form (both plain strings).""" + + atoms: str + decimal: str + + @classmethod + def from_json(cls, data: dict) -> Amount: + # Required keys: a truncated payload must surface as IndexerError via + # _safe_from_json, not silently decode to a zero-value Amount. + return cls(atoms=data["atoms"], decimal=data["decimal"]) + + +@dataclass(frozen=True) +class Timestamp: + """Unix seconds.""" + + timestamp: int + + @classmethod + def from_json(cls, data: dict) -> Timestamp: + return cls(timestamp=int(data["timestamp"])) + + +@dataclass(frozen=True) +class ChainTip: + block_height: int + block_id: str + + @classmethod + def from_json(cls, data: dict) -> ChainTip: + return cls( + block_height=parse_uint64(data["block_height"]), + block_id=data["block_id"], + ) + + +@dataclass(frozen=True) +class GenesisInfo: + block_id: str + genesis_message: str + timestamp: Timestamp + utxos: Any + + @classmethod + def from_json(cls, data: dict) -> GenesisInfo: + return cls( + block_id=data["block_id"], + genesis_message=data["genesis_message"], + timestamp=Timestamp.from_json(data["timestamp"]), + utxos=data.get("utxos"), + ) + + +@dataclass(frozen=True) +class BlockHeader: + previous_block_id: str + timestamp: Timestamp + merkle_root: str + witness_merkle_root: str + consensus_data: Any + + @classmethod + def from_json(cls, data: dict) -> BlockHeader: + return cls( + previous_block_id=data["previous_block_id"], + timestamp=Timestamp.from_json(data["timestamp"]), + merkle_root=data["merkle_root"], + witness_merkle_root=data["witness_merkle_root"], + consensus_data=data.get("consensus_data"), + ) + + +@dataclass(frozen=True) +class BlockBody: + reward: Any + transactions: list[Transaction] + + @classmethod + def from_json(cls, data: dict) -> BlockBody: + return cls( + reward=data.get("reward"), + transactions=[Transaction.from_json(t) for t in data.get("transactions") or []], + ) + + +@dataclass(frozen=True) +class Block: + height: int + header: BlockHeader + body: BlockBody + + @classmethod + def from_json(cls, data: dict) -> Block: + return cls( + height=parse_uint64(data["height"]), + header=BlockHeader.from_json(data["header"]), + body=BlockBody.from_json(data["body"]), + ) + + +@dataclass(frozen=True) +class Transaction: + """Block ID / timestamp / confirmations are empty strings when unconfirmed.""" + + id: str + inputs: Any + outputs: Any + block_id: str + timestamp: str + confirmations: str + + @classmethod + def from_json(cls, data: dict) -> Transaction: + return cls( + id=data["id"], + inputs=data.get("inputs"), + outputs=data.get("outputs"), + block_id=data.get("block_id", ""), + timestamp=data.get("timestamp", ""), + confirmations=data.get("confirmations", ""), + ) + + +@dataclass(frozen=True) +class MerklePath: + block_id: str + transaction_index: int + merkle_root: str + path: list[str] + + @classmethod + def from_json(cls, data: dict) -> MerklePath: + return cls( + block_id=data["block_id"], + merkle_root=data["merkle_root"], + transaction_index=parse_uint64(data["transaction_index"]), + path=data.get("merkle_path") or [], + ) + + +@dataclass(frozen=True) +class TokenBalance: + token_id: str + amount: Amount + + @classmethod + def from_json(cls, data: dict) -> TokenBalance: + return cls(token_id=data["token_id"], amount=Amount.from_json(data["amount"])) + + +@dataclass(frozen=True) +class AddressInfo: + coin_balance: Amount + locked_coin_balance: Amount + transaction_history: list[str] + tokens: list[TokenBalance] + + @classmethod + def from_json(cls, data: dict) -> AddressInfo: + return cls( + coin_balance=Amount.from_json(data["coin_balance"]), + locked_coin_balance=Amount.from_json(data["locked_coin_balance"]), + transaction_history=data.get("transaction_history") or [], + tokens=[TokenBalance.from_json(t) for t in data.get("tokens") or []], + ) + + +@dataclass(frozen=True) +class UTXOOutpoint: + source_id: str + index: int + + @classmethod + def from_json(cls, data: dict) -> UTXOOutpoint: + return cls( + source_id=data["source_id"], + index=parse_uint64(data["index"]), + ) + + +@dataclass(frozen=True) +class UTXO: + """Field/JSON-tag note: the output payload lives under the key ``utxo``.""" + + outpoint: UTXOOutpoint + output: Any + + @classmethod + def from_json(cls, data: dict) -> UTXO: + return cls( + outpoint=UTXOOutpoint.from_json(data["outpoint"]), + output=data.get("utxo"), + ) + + +@dataclass(frozen=True) +class DelegationInfo: + """Address-scoped delegation (no creation height).""" + + delegation_id: str + pool_id: str + next_nonce: int + spend_destination: str + balance: Amount + + @classmethod + def from_json(cls, data: dict) -> DelegationInfo: + return cls( + delegation_id=data["delegation_id"], + pool_id=data["pool_id"], + next_nonce=parse_uint64(data["next_nonce"]), + spend_destination=data["spend_destination"], + balance=Amount.from_json(data["balance"]), + ) + + +@dataclass(frozen=True) +class Pool: + pool_id: str + decommission_destination: str + staker_balance: Amount + margin_ratio_per_thousand: float + cost_per_block: Amount + vrf_public_key: str + delegations_balance: Amount + + @classmethod + def from_json(cls, data: dict) -> Pool: + return cls( + pool_id=data["pool_id"], + decommission_destination=data["decommission_destination"], + staker_balance=Amount.from_json(data["staker_balance"]), + margin_ratio_per_thousand=parse_per_thousand(data["margin_ratio_per_thousand"]), + cost_per_block=Amount.from_json(data["cost_per_block"]), + vrf_public_key=data["vrf_public_key"], + delegations_balance=Amount.from_json(data["delegations_balance"]), + ) + + +@dataclass(frozen=True) +class Delegation: + delegation_id: str + pool_id: str + next_nonce: int + spend_destination: str + balance: Amount + creation_block_height: int + + @classmethod + def from_json(cls, data: dict) -> Delegation: + return cls( + delegation_id=data["delegation_id"], + pool_id=data["pool_id"], + next_nonce=parse_uint64(data["next_nonce"]), + spend_destination=data["spend_destination"], + balance=Amount.from_json(data["balance"]), + creation_block_height=parse_uint64(data["creation_block_height"]), + ) + + +@dataclass(frozen=True) +class PoolDelegation: + """Delegation as seen from a pool (no pool ID field).""" + + delegation_id: str + next_nonce: int + spend_destination: str + balance: Amount + creation_block_height: int + + @classmethod + def from_json(cls, data: dict) -> PoolDelegation: + return cls( + delegation_id=data["delegation_id"], + next_nonce=parse_uint64(data["next_nonce"]), + spend_destination=data["spend_destination"], + balance=Amount.from_json(data["balance"]), + creation_block_height=parse_uint64(data["creation_block_height"]), + ) + + +@dataclass(frozen=True) +class TokenInfo: + """Indexer token info. + + ``is_token_unfreezable`` is set only when frozen; ``is_token_freezable`` + only when not frozen (mirrors the Go pointer semantics). + """ + + authority: str + is_locked: bool + circulating_supply: Amount + token_ticker: str + metadata_uri: str + number_of_decimals: int + total_supply: Any + frozen: bool + is_token_unfreezable: bool | None + is_token_freezable: bool | None + next_nonce: int + + @classmethod + def from_json(cls, data: dict) -> TokenInfo: + return cls( + authority=data["authority"], + is_locked=data["is_locked"], + circulating_supply=Amount.from_json(data["circulating_supply"]), + token_ticker=data["token_ticker"], + metadata_uri=data["metadata_uri"], + number_of_decimals=parse_uint64(data["number_of_decimals"]), + total_supply=data.get("total_supply"), + frozen=data["frozen"], + is_token_unfreezable=data.get("is_token_unfreezable"), + is_token_freezable=data.get("is_token_freezable"), + next_nonce=parse_uint64(data["next_nonce"]), + ) + + +@dataclass(frozen=True) +class TokenTx: + tx_global_index: int + tx_id: str + + @classmethod + def from_json(cls, data: dict) -> TokenTx: + return cls( + tx_global_index=parse_uint64(data["tx_global_index"]), + tx_id=data["tx_id"], + ) + + +@dataclass(frozen=True) +class NFTMetadata: + creator: str | None + name: str + description: str + ticker: str + icon_uri: str | None + additional_metadata_uri: str | None + media_uri: str | None + media_hash: str + + @classmethod + def from_json(cls, data: dict) -> NFTMetadata: + return cls( + creator=data.get("creator"), + name=data["name"], + description=data["description"], + ticker=data["ticker"], + icon_uri=data.get("icon_uri"), + additional_metadata_uri=data.get("additional_metadata_uri"), + media_uri=data.get("media_uri"), + media_hash=data["media_hash"], + ) + + +@dataclass(frozen=True) +class NFTInfo: + owner: str + token_id: str + metadata: NFTMetadata + + @classmethod + def from_json(cls, data: dict) -> NFTInfo: + return cls( + owner=data["owner"], + token_id=data["token_id"], + metadata=NFTMetadata.from_json(data["metadata"]), + ) + + +@dataclass(frozen=True) +class Order: + order_id: str + conclude_destination: str + give_currency: Any + initially_given: Amount + give_balance: Amount + ask_currency: Any + initially_asked: Amount + ask_balance: Amount + nonce: int + + @classmethod + def from_json(cls, data: dict) -> Order: + return cls( + order_id=data["order_id"], + conclude_destination=data["conclude_destination"], + give_currency=data.get("give_currency"), + initially_given=Amount.from_json(data["initially_given"]), + give_balance=Amount.from_json(data["give_balance"]), + ask_currency=data.get("ask_currency"), + initially_asked=Amount.from_json(data["initially_asked"]), + ask_balance=Amount.from_json(data["ask_balance"]), + nonce=parse_uint64(data["nonce"]), + ) + + +@dataclass(frozen=True) +class CoinStats: + circulating_supply: Amount + preminted: Amount + burned: Amount + staked: Amount + + @classmethod + def from_json(cls, data: dict) -> CoinStats: + return cls( + circulating_supply=Amount.from_json(data["circulating_supply"]), + preminted=Amount.from_json(data["preminted"]), + burned=Amount.from_json(data["burned"]), + staked=Amount.from_json(data["staked"]), + ) + + +@dataclass(frozen=True) +class PageOpts: + """Pagination; zero values use the server defaults (offset=0, items=10).""" + + offset: int = 0 + items: int = 0 + + def query(self) -> dict[str, Any]: + q: dict[str, Any] = {} + if self.offset > 0: + q["offset"] = self.offset + if self.items > 0: + q["items"] = self.items + return q + + +@dataclass(frozen=True) +class PoolListOpts: + """Pool listing options: pagination plus an optional sort order. + + ``sort`` is ``"by_height"`` (default, newest first) or ``"by_pledge"`` + (largest staker balance first); omitted when empty. + """ + + offset: int = 0 + items: int = 0 + sort: str = "" + + def query(self) -> dict[str, Any]: + q = PageOpts(offset=self.offset, items=self.items).query() + if self.sort: + q["sort"] = self.sort + return q + + +def _safe_from_json(cls: type[_T]) -> classmethod: + """Wrap a from_json classmethod so malformed payloads raise IndexerError.""" + original: Callable[[type[_T], Any], _T] = cls.from_json.__func__ # type: ignore[attr-defined] + + def from_json(cls_: type[_T], data: Any) -> _T: + try: + return original(cls_, data) + except IndexerError: + raise + except (AttributeError, KeyError, TypeError, ValueError, OverflowError) as exc: + raise IndexerError(f"{cls_.__name__}: malformed payload ({exc!r})") from exc + + return classmethod(from_json) + + +for _cls in ( + Amount, + Timestamp, + ChainTip, + GenesisInfo, + BlockHeader, + BlockBody, + Block, + Transaction, + MerklePath, + TokenBalance, + AddressInfo, + UTXOOutpoint, + UTXO, + DelegationInfo, + Pool, + Delegation, + PoolDelegation, + TokenInfo, + TokenTx, + NFTMetadata, + NFTInfo, + Order, + CoinStats, +): + _cls.from_json = _safe_from_json(_cls) # type: ignore[assignment,method-assign] diff --git a/mintlayer/node/__init__.py b/mintlayer/node/__init__.py new file mode 100644 index 0000000..e072814 --- /dev/null +++ b/mintlayer/node/__init__.py @@ -0,0 +1,56 @@ +"""JSON-RPC client for the Mintlayer node daemon. + +Mirrors go-sdk/node (Go package ``node``). Default ports: 3030 (mainnet), +13030 (testnet). + + from mintlayer.node import Client + + c = Client("http://127.0.0.1:3030") + print(c.best_block_height()) +""" + +from __future__ import annotations + +from mintlayer._jsonrpc import JSONRPCError + +from .client import Client, RPCError +from .types import ( + Amount, + BannedPeer, + ChainstateInfo, + Currency, + FeeRate, + FeeRatePoint, + MempoolTx, + OrderInfo, + Outpoint, + OutpointSourceID, + PeerInfo, + Timestamp, + TokenInfo, + TrustPolicy, + block_source_content, + tx_source_content, +) + +__all__ = [ + "Client", + "RPCError", + "JSONRPCError", + "Amount", + "Timestamp", + "ChainstateInfo", + "OutpointSourceID", + "Outpoint", + "TokenInfo", + "OrderInfo", + "Currency", + "TrustPolicy", + "MempoolTx", + "FeeRate", + "FeeRatePoint", + "PeerInfo", + "BannedPeer", + "tx_source_content", + "block_source_content", +] diff --git a/mintlayer/node/_core.py b/mintlayer/node/_core.py new file mode 100644 index 0000000..1f408e7 --- /dev/null +++ b/mintlayer/node/_core.py @@ -0,0 +1,79 @@ +"""Shared result-decoding helpers for the node client mixins.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, TypeVar + +from mintlayer._jsonrpc import BaseJSONRPCClient, JSONRPCError + +from .types import Amount + +_T = TypeVar("_T") + + +def _decode_model(method: str, factory: Callable[[Any], _T], data: Any) -> _T: + """Run a ``from_json`` decoder, converting malformed payloads to the + documented JSONRPCError contract (mirrors the indexer's _safe_from_json). + """ + try: + return factory(data) + except JSONRPCError: + raise + except (KeyError, TypeError, ValueError) as exc: + raise JSONRPCError(f"{method}: malformed result ({exc!r})") from exc + + +class _NodeCore(BaseJSONRPCClient): + """Node-specific typed result helpers over the shared JSON-RPC base.""" + + def _call_str(self, method: str, params: Any) -> str: + result = self._rpc.call(method, params) + if not isinstance(result, str): + raise JSONRPCError(f"{method}: expected string result, got {result!r}") + return result + + def _call_opt_str(self, method: str, params: Any) -> str | None: + result = self._rpc.call(method, params) + if result is None: + return None + if not isinstance(result, str): + # Coercing a dict/number to str would return garbage silently. + raise JSONRPCError(f"{method}: expected string result, got {result!r}") + return result + + def _call_int(self, method: str, params: Any) -> int: + result = self._rpc.call(method, params) + if isinstance(result, bool) or not isinstance(result, int): + raise JSONRPCError(f"{method}: expected integer result, got {result!r}") + return result + + def _call_opt_int(self, method: str, params: Any) -> int | None: + result = self._rpc.call(method, params) + if result is None: + return None + if isinstance(result, bool) or not isinstance(result, int): + raise JSONRPCError(f"{method}: expected integer result, got {result!r}") + return result + + def _call_bool(self, method: str, params: Any) -> bool: + result = self._rpc.call(method, params) + if not isinstance(result, bool): + raise JSONRPCError(f"{method}: expected boolean result, got {result!r}") + return result + + def _call_str_list(self, method: str, params: Any) -> list[str]: + result = self._rpc.call(method, params) + if result is None: + return [] + if not isinstance(result, list) or not all(isinstance(item, str) for item in result): + raise JSONRPCError(f"{method}: expected list of strings, got {result!r}") + return result + + def _call_opt_amount(self, method: str, params: Any) -> Amount | None: + result = self._rpc.call(method, params) + if result is None: + return None + # Amount.from_json raises bare ValueError/KeyError; decoding failures + # must surface as the documented JSONRPCError contract. + return _decode_model(method, Amount.from_json, result) diff --git a/mintlayer/node/chainstate.py b/mintlayer/node/chainstate.py new file mode 100644 index 0000000..91c466e --- /dev/null +++ b/mintlayer/node/chainstate.py @@ -0,0 +1,128 @@ +"""Chainstate methods (mirrors go-sdk/node/chainstate.go). + +Not-found results (JSON ``null``) map to ``None``. +""" + +from __future__ import annotations + +from typing import Any + +from ._core import _decode_model, _NodeCore +from .types import Amount, ChainstateInfo, Currency, OrderInfo, Outpoint, TokenInfo + + +class ChainstateMixin(_NodeCore): + def chainstate_info(self) -> ChainstateInfo: + """Return the current chainstate summary.""" + return _decode_model( + "chainstate_info", + ChainstateInfo.from_json, + self._call("chainstate_info", {}), + ) + + def best_block_id(self) -> str: + """Return the best block ID (hex, no 0x prefix).""" + return self._call_str("chainstate_best_block_id", {}) + + def best_block_height(self) -> int: + """Return the best block height.""" + return self._call_int("chainstate_best_block_height", {}) + + def block_id_at_height(self, height: int) -> str | None: + """Return the block ID at ``height`` (None if the height is unknown).""" + return self._call_opt_str("chainstate_block_id_at_height", {"height": height}) + + def block_height_in_main_chain(self, block_id: str) -> int | None: + """Return the height of ``block_id`` in the mainchain (None if orphaned).""" + return self._call_opt_int("chainstate_block_height_in_main_chain", {"block_id": block_id}) + + def get_block(self, block_id: str) -> str | None: + """Return the hex-encoded block (None if unknown; genesis not retrievable).""" + return self._call_opt_str("chainstate_get_block", {"id": block_id}) + + def get_block_json(self, block_id: str) -> Any: + """Return the block as decoded JSON (None if unknown).""" + return self._call("chainstate_get_block_json", {"id": block_id}) + + def get_mainchain_blocks(self, from_height: int, max_count: int) -> list[str]: + """Return up to ``max_count`` mainchain block IDs starting at ``from_height``.""" + return self._call_str_list( + "chainstate_get_mainchain_blocks", + {"from": from_height, "max_count": max_count}, + ) + + def get_utxo(self, outpoint: Outpoint) -> Any: + """Return the UTXO at ``outpoint`` as decoded JSON (None if spent/unknown).""" + return self._call("chainstate_get_utxo", {"outpoint": outpoint.to_json()}) + + def stake_pool_balance(self, pool_address: str) -> Amount | None: + """Return the pledge balance of the pool (None if the pool is unknown).""" + return self._call_opt_amount( + "chainstate_stake_pool_balance", {"pool_address": pool_address} + ) + + def staker_balance(self, pool_address: str) -> Amount | None: + """Return the staker's balance of the pool (None if the pool is unknown).""" + return self._call_opt_amount("chainstate_staker_balance", {"pool_address": pool_address}) + + def pool_decommission_destination(self, pool_address: str) -> str | None: + """Return the address that receives funds on decommission.""" + return self._call_opt_str( + "chainstate_pool_decommission_destination", {"pool_address": pool_address} + ) + + def delegation_share(self, pool_address: str, delegation_address: str) -> Amount | None: + """Return the delegation share held by ``delegation_address``.""" + return self._call_opt_amount( + "chainstate_delegation_share", + {"pool_address": pool_address, "delegation_address": delegation_address}, + ) + + def token_info(self, token_id: str) -> TokenInfo | None: + """Return token info (tagged union; None if unknown).""" + data = self._call("chainstate_token_info", {"token_id": token_id}) + return ( + _decode_model("chainstate_token_info", TokenInfo.from_json, data) + if data is not None + else None + ) + + def tokens_info(self, token_ids: list[str]) -> list[TokenInfo]: + """Return info for multiple token IDs.""" + data = self._call("chainstate_tokens_info", {"token_ids": token_ids}) + return [ + _decode_model("chainstate_tokens_info", TokenInfo.from_json, item) + for item in data or [] + ] + + def order_info(self, order_id: str) -> OrderInfo | None: + """Return order info (None if unknown).""" + data = self._call("chainstate_order_info", {"order_id": order_id}) + return ( + _decode_model("chainstate_order_info", OrderInfo.from_json, data) + if data is not None + else None + ) + + def orders_info_by_currencies( + self, ask: Currency | None, give: Currency | None + ) -> dict[str, OrderInfo]: + """Return orders matching the ask/give currency filters (None = any). + + Both keys are always sent; ``None`` serialises as JSON ``null``. + """ + data = self._call( + "chainstate_orders_info_by_currencies", + { + "ask_currency": ask.to_json() if ask is not None else None, + "give_currency": give.to_json() if give is not None else None, + }, + ) + return { + k: _decode_model("chainstate_orders_info_by_currencies", OrderInfo.from_json, v) + for k, v in (data or {}).items() + } + + def submit_block(self, block_hex: str) -> None: + """Submit a fully serialized block (hex).""" + self._call_ignore("chainstate_submit_block", {"block_hex": block_hex}) diff --git a/mintlayer/node/client.py b/mintlayer/node/client.py new file mode 100644 index 0000000..af3bce8 --- /dev/null +++ b/mintlayer/node/client.py @@ -0,0 +1,54 @@ +"""JSON-RPC client for the Mintlayer node daemon (mirrors go-sdk/node). + +Default ports: 3030 (mainnet), 13030 (testnet). Errors from the daemon raise +:class:`RPCError` with a numeric ``code`` and ``message``. +""" + +from __future__ import annotations + +from requests import Session + +from mintlayer._jsonrpc import JSONRPCClient, JSONRPCError, RPCError + +from ._core import _NodeCore +from .chainstate import ChainstateMixin +from .mempool import MempoolMixin +from .node import NodeMixin +from .p2p import P2PMixin + +__all__ = ["Client", "RPCError", "JSONRPCError"] + + +class Client( + NodeMixin, + ChainstateMixin, + MempoolMixin, + P2PMixin, + _NodeCore, +): + """JSON-RPC 2.0 client for the Mintlayer node daemon. + + Safe for concurrent use from multiple threads. + """ + + def __init__( + self, + endpoint: str, + username: str = "", + password: str = "", + timeout: float = 30.0, + session: Session | None = None, + ) -> None: + """Create a node client. + + ``endpoint`` is the base URL of the daemon (e.g. + ``"http://127.0.0.1:3030"``); requests POST to it directly with no path + appended. Basic auth is applied only when ``username`` is non-empty. + """ + self._rpc = JSONRPCClient( + endpoint=endpoint, + username=username, + password=password, + timeout=timeout, + session=session, + ) diff --git a/mintlayer/node/mempool.py b/mintlayer/node/mempool.py new file mode 100644 index 0000000..d94939e --- /dev/null +++ b/mintlayer/node/mempool.py @@ -0,0 +1,58 @@ +"""Mempool methods (mirrors go-sdk/node/mempool.go).""" + +from __future__ import annotations + +from ._core import _decode_model, _NodeCore +from .types import FeeRate, FeeRatePoint, MempoolTx, TrustPolicy +from .types import policy_value as _policy_value + + +class MempoolMixin(_NodeCore): + def contains_tx(self, tx_id: str) -> bool: + """Return whether the transaction is in the mempool.""" + return self._call_bool("mempool_contains_tx", {"tx_id": tx_id}) + + def contains_orphan_tx(self, tx_id: str) -> bool: + """Return whether the transaction is in the orphan pool.""" + return self._call_bool("mempool_contains_orphan_tx", {"tx_id": tx_id}) + + def get_transaction(self, tx_id: str) -> MempoolTx | None: + """Return the mempool transaction (None if not present).""" + data = self._call("mempool_get_transaction", {"tx_id": tx_id}) + return ( + _decode_model("mempool_get_transaction", MempoolTx.from_json, data) + if data is not None + else None + ) + + def mempool_submit_transaction(self, tx_hex: str, trust_policy: TrustPolicy | str) -> None: + """Submit a transaction to the local mempool only (no P2P broadcast). + + Use ``TrustPolicy.UNTRUSTED`` (recommended) for full validation; + ``TrustPolicy.TRUSTED`` skips some fee checks. + """ + self._call_ignore( + "mempool_submit_transaction", + {"tx": tx_hex, "options": {"trust_policy": _policy_value(trust_policy)}}, + ) + + def get_fee_rate(self, in_top_x_mb: int) -> FeeRate | None: + """Return the fee rate to land in the top ``in_top_x_mb`` MB of the mempool.""" + data = self._call("mempool_get_fee_rate", {"in_top_x_mb": in_top_x_mb}) + return ( + _decode_model("mempool_get_fee_rate", FeeRate.from_json, data) + if data is not None + else None + ) + + def get_fee_rate_points(self) -> list[FeeRatePoint]: + """Return the mempool fee rate histogram.""" + data = self._call("mempool_get_fee_rate_points", {}) + return [ + _decode_model("mempool_get_fee_rate_points", FeeRatePoint.from_json, item) + for item in data or [] + ] + + def memory_usage(self) -> int: + """Return the mempool memory usage in bytes.""" + return self._call_int("mempool_memory_usage", {}) diff --git a/mintlayer/node/node.py b/mintlayer/node/node.py new file mode 100644 index 0000000..73039f9 --- /dev/null +++ b/mintlayer/node/node.py @@ -0,0 +1,15 @@ +"""Node module methods (mirrors go-sdk/node/node.go).""" + +from __future__ import annotations + +from ._core import _NodeCore + + +class NodeMixin(_NodeCore): + def node_version(self) -> str: + """Return the node daemon version string.""" + return self._call_str("node_version", {}) + + def node_shutdown(self) -> None: + """Request a graceful node shutdown.""" + self._call_ignore("node_shutdown", {}) diff --git a/mintlayer/node/p2p.py b/mintlayer/node/p2p.py new file mode 100644 index 0000000..135bdad --- /dev/null +++ b/mintlayer/node/p2p.py @@ -0,0 +1,71 @@ +"""P2P methods (mirrors go-sdk/node/p2p.go).""" + +from __future__ import annotations + +from datetime import timedelta + +from ._core import _NodeCore +from .types import BannedPeer, PeerInfo, TrustPolicy +from .types import policy_value as _policy_value + + +def _duration_to_wire(duration: timedelta) -> list[int]: + """Split a duration into the daemon's [seconds, nanoseconds] wire form.""" + if duration.total_seconds() < 0: + # Python normalises negative timedeltas into (days=-1, seconds=86399); + # encoding that naively would ban for ~364 days instead of -1s. + raise ValueError(f"ban duration must not be negative, got {duration!r}") + secs = duration.days * 86_400 + duration.seconds + nanos = duration.microseconds * 1_000 + return [secs, nanos] + + +class P2PMixin(_NodeCore): + def get_peer_count(self) -> int: + """Return the number of connected peers.""" + return self._call_int("p2p_get_peer_count", {}) + + def get_connected_peers(self) -> list[PeerInfo]: + """Return info about connected peers.""" + data = self._call("p2p_get_connected_peers", {}) + return [PeerInfo.from_json(item) for item in data or []] + + def get_bind_addresses(self) -> list[str]: + """Return the node's bind addresses.""" + return self._call_str_list("p2p_get_bind_addresses", {}) + + def add_reserved_node(self, addr: str) -> None: + """Add a reserved node (host:port).""" + self._call_ignore("p2p_add_reserved_node", {"addr": addr}) + + def remove_reserved_node(self, addr: str) -> None: + """Remove a reserved node (host:port).""" + self._call_ignore("p2p_remove_reserved_node", {"addr": addr}) + + def connect(self, addr: str) -> None: + """Connect to a peer (host:port).""" + self._call_ignore("p2p_connect", {"addr": addr}) + + def disconnect(self, peer_id: int) -> None: + """Disconnect the peer with the given ID.""" + self._call_ignore("p2p_disconnect", {"peer_id": peer_id}) + + def list_banned(self) -> list[BannedPeer]: + """Return banned addresses with their ban expiry times.""" + data = self._call("p2p_list_banned", {}) + return [BannedPeer.from_json(item) for item in data or []] + + def ban(self, address: str, duration: timedelta) -> None: + """Ban an address for the given duration.""" + self._call_ignore("p2p_ban", {"address": address, "duration": _duration_to_wire(duration)}) + + def unban(self, address: str) -> None: + """Remove an address ban.""" + self._call_ignore("p2p_unban", {"address": address}) + + def p2p_submit_transaction(self, tx_hex: str, trust_policy: TrustPolicy | str) -> None: + """Submit a transaction to the mempool AND broadcast it via P2P.""" + self._call_ignore( + "p2p_submit_transaction", + {"tx": tx_hex, "options": {"trust_policy": _policy_value(trust_policy)}}, + ) diff --git a/mintlayer/node/types.py b/mintlayer/node/types.py new file mode 100644 index 0000000..e1ef26e --- /dev/null +++ b/mintlayer/node/types.py @@ -0,0 +1,269 @@ +"""Types for the node JSON-RPC client (mirrors go-sdk/node/types.go). + +Amounts are decimal atom strings (1 ML = 1e11 atoms) — never JSON numbers. +Tagged unions (``OutpointSourceID``, ``TokenInfo``) keep their ``content`` as +raw decoded JSON. Custom wire shapes (``FeeRatePoint``, ``BannedPeer``) use +tuple encodings decoded in ``from_json``. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import Any + + +class TrustPolicy(str, enum.Enum): + """Mempool submission trust policy.""" + + TRUSTED = "Trusted" + UNTRUSTED = "Untrusted" + + +def policy_value(trust_policy: TrustPolicy | str) -> str: + """Normalise a TrustPolicy enum or plain string to its wire value.""" + if isinstance(trust_policy, TrustPolicy): + return trust_policy.value + return TrustPolicy(trust_policy).value + + +@dataclass(frozen=True) +class Amount: + """A coin or token quantity as a decimal atom string.""" + + atoms: str + + @classmethod + def from_json(cls, data: Any) -> Amount: + if not isinstance(data, dict) or not isinstance(data.get("atoms"), str): + # "atoms" must be a decimal string; a JSON number would silently + # corrupt round-trips (the wire contract is strings only). + raise ValueError(f"invalid amount payload: {data!r}") + return cls(atoms=data["atoms"]) + + def to_json(self) -> dict: + return {"atoms": self.atoms} + + +def _require_int(value: Any, field: str) -> int: + """Reject bool/float/str where the daemon wire contract says integer.""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"invalid {field}: {value!r}") + return value + + +@dataclass(frozen=True) +class Timestamp: + """Unix seconds.""" + + timestamp: int + + @classmethod + def from_json(cls, data: Any) -> Timestamp: + return cls(timestamp=_require_int(data["timestamp"], "timestamp")) + + +@dataclass(frozen=True) +class ChainstateInfo: + best_block_height: int + best_block_id: str + best_block_timestamp: Timestamp + median_time: Timestamp + is_initial_block_download: bool + + @classmethod + def from_json(cls, data: dict) -> ChainstateInfo: + return cls( + best_block_height=_require_int(data["best_block_height"], "best_block_height"), + best_block_id=data["best_block_id"], + best_block_timestamp=Timestamp.from_json(data["best_block_timestamp"]), + median_time=Timestamp.from_json(data["median_time"]), + is_initial_block_download=data["is_initial_block_download"], + ) + + +@dataclass +class OutpointSourceID: + """Tagged union: ``type`` is ``"Transaction"`` or ``"BlockReward"``. + + ``content`` is raw JSON: ``{"tx_id": ""}`` or ``{"block_id": ""}`` + (see :func:`tx_source_content` / :func:`block_source_content`). Mirrors the + Go struct's missing ``omitempty``: an unset content serialises as ``null``. + """ + + type: str + content: Any = None + + def to_json(self) -> dict: + return {"type": self.type, "content": self.content} + + @classmethod + def from_json(cls, data: dict) -> OutpointSourceID: + return cls(type=data["type"], content=data.get("content")) + + +def tx_source_content(tx_id: str) -> dict: + """Content payload for a transaction outpoint source.""" + return {"tx_id": tx_id} + + +def block_source_content(block_id: str) -> dict: + """Content payload for a block-reward outpoint source.""" + return {"block_id": block_id} + + +@dataclass(frozen=True) +class Outpoint: + source_id: OutpointSourceID + index: int + + def to_json(self) -> dict: + return {"source_id": self.source_id.to_json(), "index": self.index} + + +@dataclass +class TokenInfo: + """Tagged union: ``type`` is ``"FungibleToken"`` or ``"NonFungibleToken"``. + + ``content`` is left as raw decoded JSON. + """ + + type: str + content: Any = None + + @classmethod + def from_json(cls, data: dict) -> TokenInfo: + return cls(type=data["type"], content=data.get("content")) + + +@dataclass +class OrderInfo: + """Node-side order info. + + Note: ``nonce`` is ``None`` for active orders (the daemon sends ``null``), + fixing a known Go SDK incompatibility. + """ + + conclude_key: str + initially_asked: Any + initially_given: Any + ask_balance: Amount + give_balance: Amount + nonce: int | None + is_frozen: bool + + @classmethod + def from_json(cls, data: dict) -> OrderInfo: + return cls( + conclude_key=data["conclude_key"], + initially_asked=data.get("initially_asked"), + initially_given=data.get("initially_given"), + ask_balance=Amount.from_json(data["ask_balance"]), + give_balance=Amount.from_json(data["give_balance"]), + nonce=data.get("nonce"), + is_frozen=data["is_frozen"], + ) + + +@dataclass(frozen=True) +class Currency: + """Tagged union query parameter: ``"Coin"`` or ``"Token"``. + + ``content`` carries the bech32 token ID for tokens and is omitted for coins + (mirrors the Go struct's ``omitempty``). + """ + + type: str + content: str | None = None + + def to_json(self) -> dict: + out: dict = {"type": self.type} + if self.content is not None: + out["content"] = self.content + return out + + @classmethod + def coin(cls) -> Currency: + return cls(type="Coin") + + @classmethod + def token(cls, token_id: str) -> Currency: + return cls(type="Token", content=token_id) + + +@dataclass(frozen=True) +class MempoolTx: + id: str + status: str + transaction: str + + @classmethod + def from_json(cls, data: dict) -> MempoolTx: + return cls(id=data["id"], status=data["status"], transaction=data["transaction"]) + + +@dataclass(frozen=True) +class FeeRate: + """Atoms per kilobyte.""" + + amount_per_kb: Amount + + @classmethod + def from_json(cls, data: dict) -> FeeRate: + return cls(amount_per_kb=Amount.from_json(data["amount_per_kb"])) + + +@dataclass(frozen=True) +class FeeRatePoint: + """Wire shape: ``[size, {"amount_per_kb": {...}}]``.""" + + size: int + rate: FeeRate + + @classmethod + def from_json(cls, data: Any) -> FeeRatePoint: + size, rate = data + return cls(size=size, rate=FeeRate.from_json(rate)) + + +@dataclass(frozen=True) +class PeerInfo: + peer_id: int + address: str + peer_role: str + ban_score: int + user_agent: str + software_version: str + ping_wait: int | None = None + ping_last: int | None = None + ping_min: int | None = None + last_tip_block_time: int | None = None + + @classmethod + def from_json(cls, data: dict) -> PeerInfo: + return cls( + peer_id=data["peer_id"], + address=data["address"], + peer_role=data["peer_role"], + ban_score=data["ban_score"], + user_agent=data["user_agent"], + software_version=data["software_version"], + ping_wait=data.get("ping_wait"), + ping_last=data.get("ping_last"), + ping_min=data.get("ping_min"), + last_tip_block_time=data.get("last_tip_block_time"), + ) + + +@dataclass(frozen=True) +class BannedPeer: + """Wire shape: ``["", {"time": [secs, nanos]}]``.""" + + address: str + ban_time: tuple[int, int] + + @classmethod + def from_json(cls, data: Any) -> BannedPeer: + address, payload = data + secs, nanos = payload["time"] + return cls(address=address, ban_time=(secs, nanos)) diff --git a/mintlayer/wallet/__init__.py b/mintlayer/wallet/__init__.py new file mode 100644 index 0000000..e58fba4 --- /dev/null +++ b/mintlayer/wallet/__init__.py @@ -0,0 +1,151 @@ +"""JSON-RPC client for the Mintlayer wallet daemon. + +Mirrors go-sdk/wallet (Go package ``wallet``). Default ports: 3034 (mainnet), +13034 (testnet). + + from mintlayer.wallet import Client, SendParams, Amount + + c = Client("http://127.0.0.1:3034") + c.open_wallet("/path/to/wallet.dat") + result = c.address_send(SendParams( + account=0, address="mtc1q...", amount=Amount(atoms="100000000000"), + )) + print(result.tx_id) +""" + +from __future__ import annotations + +from mintlayer._jsonrpc import JSONRPCError + +from .client import Client, RPCError +from .orders import coin_filter, token_filter +from .types import ( + AccountInfo, + ActiveOrder, + AddressWithUsage, + Amount, + Balance, + BestBlock, + ChangeAuthorityParams, + ComposedTx, + ComposeParams, + ConcludeOrderParams, + CreateDelegationParams, + CreateDelegationResult, + CreateOrderParams, + CreatePoolParams, + CreateWalletParams, + CreateWalletResult, + CurrencyFilter, + DecommissionParams, + DelegateParams, + DelegationInfo, + FeesBreakdown, + FillOrderParams, + FreezeOrderParams, + FreezeParams, + IssueNFTParams, + IssueTokenParams, + IssueTokenResult, + ListOrdersParams, + LockSupplyParams, + MintParams, + MnemonicResult, + NFTMetadata, + OrderCreated, + OrderState, + Outpoint, + OutpointSourceID, + OutputValue, + OwnedPool, + OwnOrder, + RecoverWalletParams, + RevealPublicKeyResult, + SendParams, + SendResult, + SignedTx, + StakingStatus, + SubmitResult, + SweepParams, + Timestamp, + TokenMetadata, + TokenSendParams, + TokenSupply, + TxInspection, + TxOptions, + TxStats, + UnfreezeParams, + UnmintParams, + UTXOSpendParams, + WalletInfo, + WalletTx, + WithdrawParams, +) + +__all__ = [ + "Client", + "RPCError", + "JSONRPCError", + "coin_filter", + "token_filter", + "Amount", + "AccountInfo", + "ActiveOrder", + "AddressWithUsage", + "Balance", + "BestBlock", + "ChangeAuthorityParams", + "ComposeParams", + "ComposedTx", + "ConcludeOrderParams", + "CreateDelegationParams", + "CreateDelegationResult", + "CreateOrderParams", + "CreatePoolParams", + "CreateWalletParams", + "CreateWalletResult", + "CurrencyFilter", + "DelegateParams", + "DelegationInfo", + "DecommissionParams", + "FillOrderParams", + "FreezeOrderParams", + "FreezeParams", + "FeesBreakdown", + "IssueNFTParams", + "IssueTokenParams", + "IssueTokenResult", + "ListOrdersParams", + "LockSupplyParams", + "MintParams", + "MnemonicResult", + "NFTMetadata", + "OrderCreated", + "OrderState", + "Outpoint", + "OutpointSourceID", + "OutputValue", + "OwnedPool", + "OwnOrder", + "RecoverWalletParams", + "RevealPublicKeyResult", + "SendParams", + "SendResult", + "SignedTx", + "StakingStatus", + "SubmitResult", + "SweepParams", + "Timestamp", + "TokenMetadata", + "TokenSendParams", + "TokenSupply", + "TxInspection", + "TxOptions", + "TxStats", + "UTXOSpendParams", + "UnfreezeParams", + "UnmintParams", + "WalletInfo", + "WalletTx", + "WithdrawParams", +] diff --git a/mintlayer/wallet/_core.py b/mintlayer/wallet/_core.py new file mode 100644 index 0000000..c3ffe78 --- /dev/null +++ b/mintlayer/wallet/_core.py @@ -0,0 +1,27 @@ +"""Shared result-decoding helpers for the wallet client mixins.""" + +from __future__ import annotations + +from typing import Any, TypeVar + +from mintlayer._jsonrpc import BaseJSONRPCClient, JSONRPCError + +_T = TypeVar("_T") + + +class _WalletCore(BaseJSONRPCClient): + """Wallet-specific typed result helpers over the shared JSON-RPC base.""" + + def _call_model(self, method: str, params: Any, cls: type[_T]) -> _T: + data = self._rpc.call(method, params) + if data is None: + raise JSONRPCError(f"{method}: expected object result, got null") + return cls.from_json(data) # type: ignore[attr-defined] + + def _call_model_list(self, method: str, params: Any, cls: type[_T]) -> list[_T]: + data = self._rpc.call(method, params) + if data is None: + return [] # JSON null == empty list (matches Go's nil-slice decode) + if not isinstance(data, list): + raise JSONRPCError(f"{method}: expected list result, got {data!r}") + return [cls.from_json(item) for item in data] # type: ignore[attr-defined] diff --git a/mintlayer/wallet/client.py b/mintlayer/wallet/client.py new file mode 100644 index 0000000..15e9ac2 --- /dev/null +++ b/mintlayer/wallet/client.py @@ -0,0 +1,58 @@ +"""JSON-RPC client for the Mintlayer wallet daemon (mirrors go-sdk/wallet).""" + +from __future__ import annotations + +import requests + +from mintlayer._jsonrpc import JSONRPCClient, JSONRPCError, RPCError + +from ._core import _WalletCore +from .management import ManagementMixin +from .orders import OrdersMixin +from .staking import StakingMixin +from .tokens import TokensMixin +from .transactions import TransactionsMixin + +__all__ = ["Client", "RPCError", "JSONRPCError"] + + +class Client( + ManagementMixin, + TransactionsMixin, + TokensMixin, + StakingMixin, + OrdersMixin, + _WalletCore, +): + """JSON-RPC 2.0 client for the wallet-rpc-daemon. + + Default ports: 3034 (mainnet), 13034 (testnet). Safe for concurrent use + from multiple threads. + """ + + def __init__( + self, + endpoint: str, + username: str = "", + password: str = "", + timeout: float = 30.0, + session: requests.Session | None = None, + ) -> None: + """Create a wallet client; requests POST directly to ``endpoint``. + + Basic auth is applied only when ``username`` is non-empty. Per-call + cancellation is not supported; use ``timeout`` for deadlines. + """ + self._rpc = JSONRPCClient( + endpoint=endpoint, + username=username, + password=password, + timeout=timeout, + session=session, + ) + + def __enter__(self) -> Client: + return self + + def __exit__(self, *_exc: object) -> None: + self.close() diff --git a/mintlayer/wallet/management.py b/mintlayer/wallet/management.py new file mode 100644 index 0000000..27fb703 --- /dev/null +++ b/mintlayer/wallet/management.py @@ -0,0 +1,109 @@ +"""Wallet lifecycle management (mirrors go-sdk/wallet/management.go).""" + +from __future__ import annotations + +from .._jsonrpc import JSONRPCError +from ._core import _WalletCore +from .types import ( + AccountInfo, + AddressWithUsage, + Balance, + BestBlock, + CreateWalletParams, + CreateWalletResult, + RecoverWalletParams, + WalletInfo, +) + + +class ManagementMixin(_WalletCore): + def create_wallet(self, params: CreateWalletParams) -> CreateWalletResult: + """Create a new wallet file (optionally returning a generated mnemonic).""" + data = self._call("wallet_create", params.to_json()) + return CreateWalletResult.from_json(data or {}) + + def recover_wallet(self, params: RecoverWalletParams) -> None: + """Recover a wallet from an existing mnemonic.""" + self._call_ignore("wallet_recover", params.to_json()) + + def open_wallet(self, path: str, password: str = "") -> None: + """Open a wallet file; an empty password is sent as JSON null.""" + self._call_ignore( + "wallet_open", + { + "path": path, + "password": password if password else None, + "force_migrate_wallet_type": None, + "hardware_wallet": None, + }, + ) + + def close_wallet(self) -> None: + """Close the currently open wallet.""" + self._call_ignore("wallet_close", {}) + + def get_wallet_info(self) -> WalletInfo: + """Return info about the open wallet.""" + return self._call_model("wallet_info", {}, WalletInfo) + + def sync_wallet(self) -> None: + """Trigger a wallet sync.""" + self._call_ignore("wallet_sync", {}) + + def rescan_wallet(self) -> None: + """Trigger a full chain rescan for the wallet.""" + self._call_ignore("wallet_rescan", {}) + + def best_block(self) -> BestBlock: + """Return the best block the wallet is aware of.""" + return self._call_model("wallet_best_block", {}, BestBlock) + + def create_account(self, name: str) -> AccountInfo: + """Create a new account.""" + return self._call_model("account_create", {"name": name}, AccountInfo) + + def rename_account(self, account: int, name: str = "") -> None: + """Rename an account; an empty name is sent as null (removes the name).""" + self._call_ignore("account_rename", {"account": account, "name": name if name else None}) + + def get_balance(self, account: int) -> Balance: + """Return the confirmed balance of an account.""" + data = self._call( + "account_balance", + {"account": account, "utxo_states": ["Confirmed"], "with_locked": None}, + ) + return Balance.from_json(data or {}) + + def new_address(self, account: int) -> str: + """Derive a new receiving address for the account.""" + data = self._call("address_new", {"account": account}) + if not data: + raise JSONRPCError("address_new: expected object result, got null") + return str(data["address"]) + + def show_receive_addresses(self, account: int) -> list[AddressWithUsage]: + """Return the account's receive addresses with usage info.""" + data = self._call( + "address_show", + {"account": account, "include_change_addresses": False}, + ) + return [AddressWithUsage.from_json(a) for a in data or []] + + def reveal_public_key(self, account: int, address: str) -> str: + """Reveal the hex public key backing an address.""" + data = self._call("address_reveal_public_key", {"account": account, "address": address}) + if not data: + raise JSONRPCError("address_reveal_public_key: expected object result, got null") + return str(data["public_key_hex"]) + + def encrypt_private_keys(self, password: str) -> None: + """Encrypt the wallet's private keys with a password.""" + self._call_ignore("wallet_encrypt_private_keys", {"password": password}) + + def unlock_private_keys(self, password: str) -> None: + """Unlock the wallet's private keys.""" + self._call_ignore("wallet_unlock_private_keys", {"password": password}) + + def lock_private_keys(self) -> None: + """Re-lock the wallet's private keys.""" + self._call_ignore("wallet_lock_private_keys", {}) diff --git a/mintlayer/wallet/orders.py b/mintlayer/wallet/orders.py new file mode 100644 index 0000000..9f42cd0 --- /dev/null +++ b/mintlayer/wallet/orders.py @@ -0,0 +1,54 @@ +"""DEX order methods (mirrors go-sdk/wallet/orders.go).""" + +from __future__ import annotations + +from ._core import _WalletCore +from .types import ( + ActiveOrder, + ConcludeOrderParams, + CreateOrderParams, + CurrencyFilter, + FillOrderParams, + FreezeOrderParams, + ListOrdersParams, + OrderCreated, + OwnOrder, + SendResult, +) + + +class OrdersMixin(_WalletCore): + def create_order(self, params: CreateOrderParams) -> OrderCreated: + """Create a new DEX order.""" + return self._call_model("order_create", params.to_json(), OrderCreated) + + def conclude_order(self, params: ConcludeOrderParams) -> SendResult: + """Conclude an order owned by the account.""" + return self._call_model("order_conclude", params.to_json(), SendResult) + + def fill_order(self, params: FillOrderParams) -> SendResult: + """Fill (partially or fully) an existing order.""" + return self._call_model("order_fill", params.to_json(), SendResult) + + def freeze_order(self, params: FreezeOrderParams) -> SendResult: + """Freeze an order (orders V1 fork only).""" + return self._call_model("order_freeze", params.to_json(), SendResult) + + def list_own_orders(self, account: int) -> list[OwnOrder]: + """List the account's own orders.""" + return self._call_model_list("order_list_own", {"account": account}, OwnOrder) + + def list_all_active_orders(self, params: ListOrdersParams) -> list[ActiveOrder]: + """List all active orders, optionally filtered by currency pair + (``None`` filters match any).""" + return self._call_model_list("order_list_all_active", params.to_json(), ActiveOrder) + + +def coin_filter() -> CurrencyFilter: + """Currency filter matching the native coin (wire: ``{"type":"Coin"}``).""" + return CurrencyFilter.coin_filter() + + +def token_filter(token_id: str) -> CurrencyFilter: + """Currency filter matching a token (wire: ``{"type":"Token","content":id}``).""" + return CurrencyFilter.token_filter(token_id) diff --git a/mintlayer/wallet/staking.py b/mintlayer/wallet/staking.py new file mode 100644 index 0000000..a137e66 --- /dev/null +++ b/mintlayer/wallet/staking.py @@ -0,0 +1,78 @@ +"""Staking and delegation methods (mirrors go-sdk/wallet/staking.go).""" + +from __future__ import annotations + +from mintlayer._jsonrpc import JSONRPCError + +from ._core import _WalletCore +from .types import ( + Amount, + CreateDelegationParams, + CreateDelegationResult, + CreatePoolParams, + DecommissionParams, + DelegateParams, + DelegationInfo, + OwnedPool, + SendResult, + StakingStatus, + WithdrawParams, +) + + +class StakingMixin(_WalletCore): + def create_stake_pool(self, params: CreatePoolParams) -> SendResult: + """Create a new staking pool.""" + return self._call_model("staking_create_pool", params.to_json(), SendResult) + + def decommission_stake_pool(self, params: DecommissionParams) -> SendResult: + """Decommission a stake pool the account owns.""" + return self._call_model("staking_decommission_pool", params.to_json(), SendResult) + + def list_owned_pools(self, account: int) -> list[OwnedPool]: + """List the pools owned by the account.""" + return self._call_model_list("staking_list_pools", {"account": account}, OwnedPool) + + def get_pool_balance(self, account: int, pool_id: str) -> Amount: + """Return a pool's balance. + + Note: matching the daemon route, ``account`` is accepted for API + consistency but NOT sent on the wire. + """ + data = self._call("staking_pool_balance", {"pool_id": pool_id}) + return Amount.from_json((data or {}).get("balance", {})) + + def start_staking(self, account: int) -> None: + """Start staking with all of the account's pools.""" + self._call_ignore("staking_start", {"account": account}) + + def stop_staking(self, account: int) -> None: + """Stop staking for the account.""" + self._call_ignore("staking_stop", {"account": account}) + + def get_staking_status(self, account: int) -> StakingStatus: + """Return whether the account is currently staking.""" + result = self._call("staking_status", {"account": account}) + try: + return StakingStatus(result) + except ValueError as exc: + raise JSONRPCError( + f"staking_status: unexpected status {result!r} " + f"(expected one of {[m.value for m in StakingStatus]})" + ) from exc + + def create_delegation(self, params: CreateDelegationParams) -> CreateDelegationResult: + """Create a delegation ID for delegating to a pool.""" + return self._call_model("delegation_create", params.to_json(), CreateDelegationResult) + + def delegate_staking(self, params: DelegateParams) -> SendResult: + """Delegate coins to a pool via a delegation ID.""" + return self._call_model("delegation_stake", params.to_json(), SendResult) + + def withdraw_from_delegation(self, params: WithdrawParams) -> SendResult: + """Withdraw from a delegation to an address.""" + return self._call_model("delegation_withdraw", params.to_json(), SendResult) + + def list_delegations(self, account: int) -> list[DelegationInfo]: + """List the account's delegation IDs and balances.""" + return self._call_model_list("delegation_list_ids", {"account": account}, DelegationInfo) diff --git a/mintlayer/wallet/tokens.py b/mintlayer/wallet/tokens.py new file mode 100644 index 0000000..9212b01 --- /dev/null +++ b/mintlayer/wallet/tokens.py @@ -0,0 +1,56 @@ +"""Token methods (mirrors go-sdk/wallet/tokens.go).""" + +from __future__ import annotations + +from ._core import _WalletCore +from .types import ( + ChangeAuthorityParams, + FreezeParams, + IssueNFTParams, + IssueTokenParams, + IssueTokenResult, + LockSupplyParams, + MintParams, + SendResult, + TokenSendParams, + UnfreezeParams, + UnmintParams, +) + + +class TokensMixin(_WalletCore): + def issue_token(self, params: IssueTokenParams) -> IssueTokenResult: + """Issue a new fungible token.""" + return self._call_model("token_issue_new", params.to_json(), IssueTokenResult) + + def issue_nft(self, params: IssueNFTParams) -> IssueTokenResult: + """Issue a new NFT (returns the token ID).""" + return self._call_model("token_nft_issue_new", params.to_json(), IssueTokenResult) + + def mint_tokens(self, params: MintParams) -> SendResult: + """Mint additional supply of a token.""" + return self._call_model("token_mint", params.to_json(), SendResult) + + def unmint_tokens(self, params: UnmintParams) -> SendResult: + """Burn token supply.""" + return self._call_model("token_unmint", params.to_json(), SendResult) + + def lock_token_supply(self, params: LockSupplyParams) -> SendResult: + """Permanently lock a token's supply (wire key: ``account_index``).""" + return self._call_model("token_lock_supply", params.to_json(), SendResult) + + def freeze_token(self, params: FreezeParams) -> SendResult: + """Freeze a token, choosing whether it can later be unfrozen.""" + return self._call_model("token_freeze", params.to_json(), SendResult) + + def unfreeze_token(self, params: UnfreezeParams) -> SendResult: + """Unfreeze a token.""" + return self._call_model("token_unfreeze", params.to_json(), SendResult) + + def change_token_authority(self, params: ChangeAuthorityParams) -> SendResult: + """Transfer a token's authority to a new address.""" + return self._call_model("token_change_authority", params.to_json(), SendResult) + + def send_token(self, params: TokenSendParams) -> SendResult: + """Send tokens (alias of :meth:`TransactionsMixin.token_send`).""" + return self._call_model("token_send", params.to_json(), SendResult) diff --git a/mintlayer/wallet/transactions.py b/mintlayer/wallet/transactions.py new file mode 100644 index 0000000..ea4a4ed --- /dev/null +++ b/mintlayer/wallet/transactions.py @@ -0,0 +1,99 @@ +"""Transaction methods (mirrors go-sdk/wallet/transactions.go).""" + +from __future__ import annotations + +from typing import Any + +from ._core import _WalletCore +from .types import ( + ComposedTx, + ComposeParams, + SendParams, + SendResult, + SignedTx, + SubmitResult, + SweepParams, + TokenSendParams, + TxInspection, + TxOptions, + UTXOSpendParams, + WalletTx, +) + + +class TransactionsMixin(_WalletCore): + def address_send(self, params: SendParams) -> SendResult: + """Send coins from the account to an address.""" + return self._call_model("address_send", params.to_json(), SendResult) + + def token_send(self, params: TokenSendParams) -> SendResult: + """Send tokens from the account to an address.""" + return self._call_model("token_send", params.to_json(), SendResult) + + def sweep_spendable(self, params: SweepParams) -> SendResult: + """Sweep all spendable UTXOs to a destination address.""" + return self._call_model("address_sweep_spendable", params.to_json(), SendResult) + + def spend_utxo(self, params: UTXOSpendParams) -> SendResult: + """Spend a specific UTXO.""" + return self._call_model("utxo_spend", params.to_json(), SendResult) + + def compose_transaction(self, params: ComposeParams) -> ComposedTx: + """Compose (but do not sign) a transaction from inputs and raw outputs.""" + return self._call_model("transaction_compose", params.to_json(), ComposedTx) + + def sign_raw_transaction(self, account: int, raw_tx: str) -> SignedTx: + """Sign a composed raw transaction with the account's keys.""" + return self._call_model( + "account_sign_raw_transaction", + {"account": account, "raw_tx": raw_tx, "options": TxOptions().to_json()}, + SignedTx, + ) + + def inspect_transaction(self, tx_hex: str) -> TxInspection: + """Inspect a raw transaction (input/signature counts, estimated fees).""" + return self._call_model("transaction_inspect", {"transaction": tx_hex}, TxInspection) + + def submit_transaction(self, tx_hex: str, do_not_store: bool = False) -> SubmitResult: + """Submit a signed transaction to the node (trust policy is hardcoded + to ``"Trusted"`` by the daemon route).""" + return self._call_model( + "node_submit_transaction", + { + "tx": tx_hex, + "do_not_store": do_not_store, + "options": {"trust_policy": "Trusted"}, + }, + SubmitResult, + ) + + def list_transactions_by_address( + self, account: int, address: str | None, limit: int + ) -> list[WalletTx]: + """List account transactions; ``address=None`` filters to all addresses.""" + data = self._call( + "transaction_list_by_address", + {"account": account, "address": address, "limit": limit}, + ) + return [WalletTx.from_json(t) for t in data or []] + + def list_pending_transactions(self, account: int) -> list[str]: + """List the account's pending (unconfirmed) transaction IDs.""" + data = self._call("transaction_list_pending", {"account": account}) + return [str(t) for t in data or []] + + def get_transaction(self, account: int, tx_id: str) -> Any: + """Return a wallet transaction as raw decoded JSON.""" + return self._call("transaction_get", {"account": account, "transaction_id": tx_id}) + + def abandon_transaction(self, account: int, tx_id: str) -> None: + """Abandon a pending transaction.""" + self._call_ignore("transaction_abandon", {"account": account, "transaction_id": tx_id}) + + def deposit_data(self, account: int, data_hex: str) -> SendResult: + """Create a DataDeposit output carrying arbitrary hex data.""" + return self._call_model( + "address_deposit_data", + {"account": account, "data": data_hex, "options": TxOptions().to_json()}, + SendResult, + ) diff --git a/mintlayer/wallet/types.py b/mintlayer/wallet/types.py new file mode 100644 index 0000000..b74b344 --- /dev/null +++ b/mintlayer/wallet/types.py @@ -0,0 +1,1107 @@ +"""Types for the wallet JSON-RPC client (mirrors go-sdk/wallet/types.go and friends). + +Wire-fidelity notes (all verified against the Go structs): + +* pointer fields WITHOUT ``omitempty`` serialise as JSON ``null`` when unset — + the dominant pattern for optional strings; +* ``omitempty`` applies only to: ``Amount.atoms``/``Amount.decimal``, + ``SendParams.selected_utxos``, ``MnemonicResult.content``, + ``CreateWalletResult.mnemonic``, ``TokenSupply.content``, ``TxInspection.fees``; +* ``TxOptions`` always serialises both keys (null when unset); +* ``OutputValue`` and ``CurrencyFilter`` use fully custom encodings. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field +from typing import Any + +__all__ = [ + "Amount", + "Timestamp", + "TxOptions", + "OutpointSourceID", + "Outpoint", + "FeesBreakdown", + "SendResult", + "SubmitResult", + "ComposedTx", + "SignedTx", + "TxStats", + "TxInspection", + "WalletTx", + "MnemonicContent", + "MnemonicResult", + "CreateWalletResult", + "CreateWalletParams", + "RecoverWalletParams", + "WalletExtraInfo", + "WalletInfo", + "BestBlock", + "AccountInfo", + "Balance", + "AddressWithUsage", + "RevealPublicKeyResult", + "SendParams", + "SweepParams", + "UTXOSpendParams", + "ComposeParams", + "StakingStatus", + "CreatePoolParams", + "DecommissionParams", + "OwnedPool", + "CreateDelegationParams", + "CreateDelegationResult", + "DelegateParams", + "WithdrawParams", + "DelegationInfo", + "TokenSupply", + "TokenMetadata", + "IssueTokenParams", + "IssueTokenResult", + "NFTMetadata", + "IssueNFTParams", + "MintParams", + "UnmintParams", + "LockSupplyParams", + "FreezeParams", + "UnfreezeParams", + "ChangeAuthorityParams", + "TokenSendParams", + "OutputValue", + "CurrencyFilter", + "OrderState", + "OwnOrder", + "ActiveOrder", + "OrderCreated", + "CreateOrderParams", + "ConcludeOrderParams", + "FillOrderParams", + "FreezeOrderParams", + "ListOrdersParams", +] + + +@dataclass(frozen=True) +class Amount: + """Coin/token amount; at least one of atoms/decimal must be set when sending.""" + + atoms: str = "" + decimal: str = "" + + def to_json(self) -> dict: + out: dict[str, str] = {} + if self.atoms: + out["atoms"] = self.atoms + if self.decimal: + out["decimal"] = self.decimal + return out + + @classmethod + def from_json(cls, data: dict) -> Amount: + return cls(atoms=data.get("atoms", ""), decimal=data.get("decimal", "")) + + +@dataclass(frozen=True) +class Timestamp: + timestamp: int + + @classmethod + def from_json(cls, data: dict) -> Timestamp: + return cls(timestamp=int(data["timestamp"])) + + +@dataclass(frozen=True) +class TxOptions: + """Fee/broadcast options; both keys are always present on the wire.""" + + in_top_x_mb: int | None = None + broadcast_to_mempool: bool | None = None + + def to_json(self) -> dict: + return {"in_top_x_mb": self.in_top_x_mb, "broadcast_to_mempool": self.broadcast_to_mempool} + + +@dataclass +class OutpointSourceID: + """Tagged union: ``"Transaction"`` (content ``{"tx_id": hex}``) or + ``"BlockReward"`` (content ``{"block_id": hex}``). Content is raw JSON and + always serialised (null when unset), mirroring the Go struct.""" + + type: str + content: Any = None + + def to_json(self) -> dict: + return {"type": self.type, "content": self.content} + + @classmethod + def from_json(cls, data: dict) -> OutpointSourceID: + return cls(type=data["type"], content=data.get("content")) + + +@dataclass(frozen=True) +class Outpoint: + source_id: OutpointSourceID + index: int + + def to_json(self) -> dict: + return {"source_id": self.source_id.to_json(), "index": self.index} + + @classmethod + def from_json(cls, data: dict) -> Outpoint: + return cls( + source_id=OutpointSourceID.from_json(data["source_id"]), + index=data["index"], + ) + + +@dataclass(frozen=True) +class FeesBreakdown: + coins: Amount + tokens: dict[str, Amount] + + @classmethod + def from_json(cls, data: dict) -> FeesBreakdown: + return cls( + coins=Amount.from_json(data.get("coins", {})), + tokens={k: Amount.from_json(v) for k, v in (data.get("tokens") or {}).items()}, + ) + + +@dataclass(frozen=True) +class SendResult: + tx_id: str + fees: FeesBreakdown + broadcasted: bool + + @classmethod + def from_json(cls, data: dict) -> SendResult: + return cls( + tx_id=data["tx_id"], + fees=FeesBreakdown.from_json(data["fees"]), + broadcasted=data["broadcasted"], + ) + + +@dataclass(frozen=True) +class SubmitResult: + tx_id: str + + @classmethod + def from_json(cls, data: dict) -> SubmitResult: + return cls(tx_id=data["tx_id"]) + + +@dataclass(frozen=True) +class ComposedTx: + """Hex-encoded PartiallySignedTransaction plus its fee breakdown.""" + + hex: str + fees: FeesBreakdown + + @classmethod + def from_json(cls, data: dict) -> ComposedTx: + return cls(hex=data["hex"], fees=FeesBreakdown.from_json(data["fees"])) + + +@dataclass(frozen=True) +class SignedTx: + hex: str + current_signatures: Any + + @classmethod + def from_json(cls, data: dict) -> SignedTx: + return cls(hex=data["hex"], current_signatures=data.get("current_signatures")) + + +@dataclass(frozen=True) +class TxStats: + num_inputs: int + total_signatures: int + + @classmethod + def from_json(cls, data: dict) -> TxStats: + return cls(num_inputs=data["num_inputs"], total_signatures=data["total_signatures"]) + + +@dataclass(frozen=True) +class TxInspection: + stats: TxStats + fees: FeesBreakdown | None = None + + @classmethod + def from_json(cls, data: dict) -> TxInspection: + return cls( + stats=TxStats.from_json(data["stats"]), + fees=FeesBreakdown.from_json(data["fees"]) if data.get("fees") else None, + ) + + +@dataclass(frozen=True) +class WalletTx: + id: str + height: int + timestamp: Timestamp + + @classmethod + def from_json(cls, data: dict) -> WalletTx: + return cls( + id=data["id"], + height=data["height"], + timestamp=Timestamp.from_json(data["timestamp"]), + ) + + +# ── wallet management ──────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class MnemonicContent: + mnemonic: str + + def __repr__(self) -> str: # pragma: no cover - trivial + return "MnemonicContent(mnemonic='')" + + +@dataclass(frozen=True) +class MnemonicResult: + type: str + content: MnemonicContent | None = None + + @classmethod + def from_json(cls, data: dict) -> MnemonicResult: + content = data.get("content") + if content is not None and not isinstance(content, dict): + raise ValueError(f"MnemonicResult: invalid content {content!r}") + try: + # An empty dict is malformed too: the daemon claimed a mnemonic + # result without the required `mnemonic` key. + decoded = MnemonicContent(**content) if content is not None else None + except TypeError as exc: + # Unexpected/missing keys in the daemon payload must not surface + # as a bare kwargs TypeError. + raise ValueError(f"MnemonicContent: malformed payload ({exc})") from exc + return cls( + type=data["type"], + content=decoded, + ) + + +@dataclass(frozen=True) +class CreateWalletResult: + mnemonic: MnemonicResult | None = None + + @classmethod + def from_json(cls, data: dict) -> CreateWalletResult: + mnemonic = data.get("mnemonic") + return cls(mnemonic=MnemonicResult.from_json(mnemonic) if mnemonic else None) + + +@dataclass(frozen=True) +class CreateWalletParams: + path: str + store_seed_phrase: bool + mnemonic: str | None = None + passphrase: str | None = None + hardware_wallet: str | None = None + + def to_json(self) -> dict: + return { + "path": self.path, + "store_seed_phrase": self.store_seed_phrase, + "mnemonic": self.mnemonic, + "passphrase": self.passphrase, + "hardware_wallet": self.hardware_wallet, + } + + def __repr__(self) -> str: + return ( + f"CreateWalletParams(path={self.path!r}, " + f"store_seed_phrase={self.store_seed_phrase!r}, " + f"mnemonic={'' if self.mnemonic else None}, " + f"passphrase={'' if self.passphrase else None}, " + f"hardware_wallet={self.hardware_wallet!r})" + ) + + +@dataclass(frozen=True) +class RecoverWalletParams: + path: str + store_seed_phrase: bool + mnemonic: str + passphrase: str | None = None + hardware_wallet: str | None = None + + def to_json(self) -> dict: + return { + "path": self.path, + "store_seed_phrase": self.store_seed_phrase, + "mnemonic": self.mnemonic, + "passphrase": self.passphrase, + "hardware_wallet": self.hardware_wallet, + } + + def __repr__(self) -> str: + return ( + f"RecoverWalletParams(path={self.path!r}, " + f"store_seed_phrase={self.store_seed_phrase!r}, " + f"mnemonic='', " + f"passphrase={'' if self.passphrase else None}, " + f"hardware_wallet={self.hardware_wallet!r})" + ) + + +@dataclass(frozen=True) +class WalletExtraInfo: + type: str + + @classmethod + def from_json(cls, data: dict) -> WalletExtraInfo: + return cls(type=data["type"]) + + +@dataclass(frozen=True) +class WalletInfo: + wallet_id: str + account_names: list[str] + extra_info: WalletExtraInfo + + @classmethod + def from_json(cls, data: dict) -> WalletInfo: + return cls( + wallet_id=data["wallet_id"], + account_names=data.get("account_names") or [], + extra_info=WalletExtraInfo.from_json(data["extra_info"]), + ) + + +@dataclass(frozen=True) +class BestBlock: + height: int + id: str + + @classmethod + def from_json(cls, data: dict) -> BestBlock: + return cls(height=data["height"], id=data["id"]) + + +@dataclass(frozen=True) +class AccountInfo: + account: int + name: str + + @classmethod + def from_json(cls, data: dict) -> AccountInfo: + return cls(account=data["account"], name=data["name"]) + + +@dataclass(frozen=True) +class Balance: + coins: Amount + tokens: dict[str, Amount] + + @classmethod + def from_json(cls, data: dict) -> Balance: + return cls( + coins=Amount.from_json(data.get("coins", {})), + tokens={k: Amount.from_json(v) for k, v in (data.get("tokens") or {}).items()}, + ) + + +@dataclass(frozen=True) +class AddressWithUsage: + address: str + used: bool + coins: Amount + + @classmethod + def from_json(cls, data: dict) -> AddressWithUsage: + return cls( + address=data["address"], + used=data["used"], + coins=Amount.from_json(data["coins"]), + ) + + +@dataclass(frozen=True) +class RevealPublicKeyResult: + public_key_hex: str + public_key_address: str + + @classmethod + def from_json(cls, data: dict) -> RevealPublicKeyResult: + return cls( + public_key_hex=data["public_key_hex"], public_key_address=data["public_key_address"] + ) + + +# ── transactions ───────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class SendParams: + account: int + address: str + amount: Amount + selected_utxos: list[Outpoint] | None = None + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + out: dict[str, Any] = { + "account": self.account, + "address": self.address, + "amount": self.amount.to_json(), + "options": self.options.to_json(), + } + # omitempty: nil OR empty selected_utxos is omitted from the wire. + if self.selected_utxos: + out["selected_utxos"] = [u.to_json() for u in self.selected_utxos] + return out + + +@dataclass(frozen=True) +class SweepParams: + account: int + destination_address: str + from_addresses: list[str] = field(default_factory=list) + all: bool = False + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "destination_address": self.destination_address, + "from_addresses": self.from_addresses, + "all": self.all, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class UTXOSpendParams: + account: int + utxo: Outpoint + output_address: str + htlc_secret: str | None = None + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "utxo": self.utxo.to_json(), + "output_address": self.output_address, + "htlc_secret": self.htlc_secret, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class ComposeParams: + inputs: list[Outpoint] = field(default_factory=list) + outputs: list[Any] = field(default_factory=list) + htlc_secrets: Any = None + only_transaction: bool = False + + def to_json(self) -> dict: + return { + "inputs": [i.to_json() for i in self.inputs], + "outputs": self.outputs, + "htlc_secrets": self.htlc_secrets, + "only_transaction": self.only_transaction, + } + + +# ── staking ────────────────────────────────────────────────────────────────── + + +class StakingStatus(str, enum.Enum): + ACTIVE = "Staking" + INACTIVE = "NotStaking" + + +@dataclass(frozen=True) +class CreatePoolParams: + account: int + amount: Amount + cost_per_block: Amount + margin_ratio_per_thousand: str + decommission_address: str + staker_address: str | None = None + vrf_public_key: str | None = None + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "amount": self.amount.to_json(), + "cost_per_block": self.cost_per_block.to_json(), + "margin_ratio_per_thousand": self.margin_ratio_per_thousand, + "decommission_address": self.decommission_address, + "staker_address": self.staker_address, + "vrf_public_key": self.vrf_public_key, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class DecommissionParams: + account: int + pool_id: str + output_address: str + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "pool_id": self.pool_id, + "output_address": self.output_address, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class OwnedPool: + pool_id: str + pledge: Amount + balance: Amount + margin_ratio_per_thousand: str + cost_per_block: Amount + + @classmethod + def from_json(cls, data: dict) -> OwnedPool: + return cls( + pool_id=data["pool_id"], + pledge=Amount.from_json(data["pledge"]), + balance=Amount.from_json(data["balance"]), + margin_ratio_per_thousand=data["margin_ratio_per_thousand"], + cost_per_block=Amount.from_json(data["cost_per_block"]), + ) + + +@dataclass(frozen=True) +class CreateDelegationParams: + account: int + address: str + pool_id: str + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "address": self.address, + "pool_id": self.pool_id, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class CreateDelegationResult: + delegation_id: str + tx_id: str + + @classmethod + def from_json(cls, data: dict) -> CreateDelegationResult: + return cls(delegation_id=data["delegation_id"], tx_id=data["tx_id"]) + + +@dataclass(frozen=True) +class DelegateParams: + account: int + amount: Amount + delegation_id: str + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "amount": self.amount.to_json(), + "delegation_id": self.delegation_id, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class WithdrawParams: + account: int + address: str + amount: Amount + delegation_id: str + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "address": self.address, + "amount": self.amount.to_json(), + "delegation_id": self.delegation_id, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class DelegationInfo: + delegation_id: str + pool_id: str + balance: Amount + + @classmethod + def from_json(cls, data: dict) -> DelegationInfo: + return cls( + delegation_id=data["delegation_id"], + pool_id=data["pool_id"], + balance=Amount.from_json(data["balance"]), + ) + + +# ── tokens ─────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class TokenSupply: + """``"Fixed"`` | ``"Lockable"`` | ``"Unlimited"``; content only for Fixed.""" + + type: str + content: Amount | None = None + + def to_json(self) -> dict: + out: dict[str, Any] = {"type": self.type} + if self.content is not None: + out["content"] = self.content.to_json() + return out + + +@dataclass(frozen=True) +class TokenMetadata: + token_ticker: str + number_of_decimals: int + metadata_uri: str + token_supply: TokenSupply + is_freezable: bool + + def to_json(self) -> dict: + return { + "token_ticker": self.token_ticker, + "number_of_decimals": self.number_of_decimals, + "metadata_uri": self.metadata_uri, + "token_supply": self.token_supply.to_json(), + "is_freezable": self.is_freezable, + } + + +@dataclass(frozen=True) +class IssueTokenParams: + account: int + destination_address: str + metadata: TokenMetadata + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "destination_address": self.destination_address, + "metadata": self.metadata.to_json(), + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class IssueTokenResult: + token_id: str + tx_id: str + + @classmethod + def from_json(cls, data: dict) -> IssueTokenResult: + return cls(token_id=data["token_id"], tx_id=data["tx_id"]) + + +@dataclass(frozen=True) +class NFTMetadata: + media_hash: str + name: str + description: str + ticker: str + creator: str | None = None + icon_uri: str | None = None + media_uri: str | None = None + additional_metadata_uri: str | None = None + + def to_json(self) -> dict: + return { + "media_hash": self.media_hash, + "name": self.name, + "description": self.description, + "ticker": self.ticker, + "creator": self.creator, + "icon_uri": self.icon_uri, + "media_uri": self.media_uri, + "additional_metadata_uri": self.additional_metadata_uri, + } + + +@dataclass(frozen=True) +class IssueNFTParams: + account: int + destination_address: str + metadata: NFTMetadata + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "destination_address": self.destination_address, + "metadata": self.metadata.to_json(), + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class MintParams: + account: int + token_id: str + address: str + amount: Amount + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "token_id": self.token_id, + "address": self.address, + "amount": self.amount.to_json(), + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class UnmintParams: + account: int + token_id: str + amount: Amount + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "token_id": self.token_id, + "amount": self.amount.to_json(), + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class LockSupplyParams: + """Wire key note: the account field is sent as ``account_index``.""" + + account_index: int + token_id: str + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account_index": self.account_index, + "token_id": self.token_id, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class FreezeParams: + account: int + token_id: str + is_unfreezable: bool + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "token_id": self.token_id, + "is_unfreezable": self.is_unfreezable, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class UnfreezeParams: + account: int + token_id: str + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "token_id": self.token_id, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class ChangeAuthorityParams: + account: int + token_id: str + address: str + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "token_id": self.token_id, + "address": self.address, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class TokenSendParams: + account: int + token_id: str + address: str + amount: Amount + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "token_id": self.token_id, + "address": self.address, + "amount": self.amount.to_json(), + "options": self.options.to_json(), + } + + +# ── DEX orders ─────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class OutputValue: + """Custom wire encoding for one side of an order. + + * Coin: ``{"type":"Coin","content":{"amount":{"atoms":...,"decimal":...}}}`` + * Token: ``{"type":"Token","content":{"id":"","amount":{...}}}`` + + Validation errors (missing amount / missing token id) are raised before + any HTTP request is sent. + """ + + coin: bool + token_id: str + amount: Amount + + @classmethod + def coins(cls, atoms: str, decimal: str = "") -> OutputValue: + return cls(coin=True, token_id="", amount=Amount(atoms=atoms, decimal=decimal)) + + @classmethod + def tokens(cls, token_id: str, atoms: str, decimal: str = "") -> OutputValue: + return cls(coin=False, token_id=token_id, amount=Amount(atoms=atoms, decimal=decimal)) + + def to_json(self) -> dict: + if not self.amount.atoms and not self.amount.decimal: + raise ValueError("wallet: output value requires an amount") + if self.coin: + return {"type": "Coin", "content": {"amount": self.amount.to_json()}} + if not self.token_id: + raise ValueError("wallet: token OutputValue requires TokenID") + return { + "type": "Token", + "content": {"id": self.token_id, "amount": self.amount.to_json()}, + } + + @classmethod + def from_json(cls, data: dict) -> OutputValue: + kind = data.get("type") + if not kind: + raise ValueError("wallet: output value requires a type") + content = data.get("content") or {} + amount = Amount.from_json(content.get("amount") or {}) + if not amount.atoms and not amount.decimal: + raise ValueError("wallet: output value requires an amount") + if kind == "Coin": + return cls(coin=True, token_id="", amount=amount) + if kind == "Token": + token_id = content.get("id", "") + if not token_id: + raise ValueError("wallet: token OutputValue requires TokenID") + return cls(coin=False, token_id=token_id, amount=amount) + raise ValueError(f"wallet: unknown OutputValue type {kind!r}") + + +@dataclass(frozen=True) +class CurrencyFilter: + """Custom wire encoding: ``CoinFilter()`` → ``{"type":"Coin"}`` (no content + key); ``TokenFilter(id)`` → ``{"type":"Token","content":""}``.""" + + coin: bool + content: str = "" + + @classmethod + def coin_filter(cls) -> CurrencyFilter: + return cls(coin=True) + + @classmethod + def token_filter(cls, token_id: str) -> CurrencyFilter: + if not token_id: + raise ValueError( + "wallet: TokenFilter requires a token id (use CoinFilter for the native coin)" + ) + return cls(coin=False, content=token_id) + + def to_json(self) -> dict: + if self.coin: + return {"type": "Coin"} + return {"type": "Token", "content": self.content} + + +@dataclass(frozen=True) +class OrderState: + ask_balance: Amount + give_balance: Amount + is_frozen: bool + creation_timestamp: Timestamp + + @classmethod + def from_json(cls, data: dict) -> OrderState: + return cls( + ask_balance=Amount.from_json(data["ask_balance"]), + give_balance=Amount.from_json(data["give_balance"]), + is_frozen=data["is_frozen"], + creation_timestamp=Timestamp.from_json(data["creation_timestamp"]), + ) + + +@dataclass(frozen=True) +class OwnOrder: + order_id: str + initially_asked: OutputValue + initially_given: OutputValue + existing_order_data: OrderState | None + is_marked_as_frozen_in_wallet: bool + is_marked_as_concluded_in_wallet: bool + + @classmethod + def from_json(cls, data: dict) -> OwnOrder: + existing = data.get("existing_order_data") + return cls( + order_id=data["order_id"], + initially_asked=OutputValue.from_json(data["initially_asked"]), + initially_given=OutputValue.from_json(data["initially_given"]), + existing_order_data=OrderState.from_json(existing) if existing else None, + is_marked_as_frozen_in_wallet=data["is_marked_as_frozen_in_wallet"], + is_marked_as_concluded_in_wallet=data["is_marked_as_concluded_in_wallet"], + ) + + +@dataclass(frozen=True) +class ActiveOrder: + order_id: str + initially_asked: OutputValue + initially_given: OutputValue + ask_balance: Amount + give_balance: Amount + is_own: bool + + @classmethod + def from_json(cls, data: dict) -> ActiveOrder: + return cls( + order_id=data["order_id"], + initially_asked=OutputValue.from_json(data["initially_asked"]), + initially_given=OutputValue.from_json(data["initially_given"]), + ask_balance=Amount.from_json(data["ask_balance"]), + give_balance=Amount.from_json(data["give_balance"]), + is_own=data["is_own"], + ) + + +@dataclass(frozen=True) +class OrderCreated: + order_id: str + tx_id: str + broadcasted: bool + + @classmethod + def from_json(cls, data: dict) -> OrderCreated: + return cls( + order_id=data["order_id"], + tx_id=data["tx_id"], + broadcasted=data["broadcasted"], + ) + + +@dataclass(frozen=True) +class CreateOrderParams: + account: int + ask: OutputValue + give: OutputValue + conclude_address: str + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "ask": self.ask.to_json(), + "give": self.give.to_json(), + "conclude_address": self.conclude_address, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class ConcludeOrderParams: + account: int + order_id: str + output_address: str | None = None + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "order_id": self.order_id, + "output_address": self.output_address, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class FillOrderParams: + account: int + order_id: str + fill_amount_in_ask_currency: Amount + output_address: str | None = None + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "order_id": self.order_id, + "fill_amount_in_ask_currency": self.fill_amount_in_ask_currency.to_json(), + "output_address": self.output_address, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class FreezeOrderParams: + account: int + order_id: str + options: TxOptions = field(default_factory=TxOptions) + + def to_json(self) -> dict: + return { + "account": self.account, + "order_id": self.order_id, + "options": self.options.to_json(), + } + + +@dataclass(frozen=True) +class ListOrdersParams: + account: int + ask_currency: CurrencyFilter | None = None + give_currency: CurrencyFilter | None = None + + def to_json(self) -> dict: + return { + "account": self.account, + "ask_currency": self.ask_currency.to_json() if self.ask_currency else None, + "give_currency": self.give_currency.to_json() if self.give_currency else None, + } diff --git a/mintlayer/wasm/__init__.py b/mintlayer/wasm/__init__.py new file mode 100644 index 0000000..212b082 --- /dev/null +++ b/mintlayer/wasm/__init__.py @@ -0,0 +1,68 @@ +"""Mintlayer WASM cryptography and transaction-building client. + +Mirrors go-sdk/wasm (Go package ``mintlayer``). Instantiates the embedded +wasm-bindgen module via wasmtime and exposes key derivation, address encoding, +transaction building, signing and fee queries. +""" + +from __future__ import annotations + +from .client import Client +from .types import ( + Amount, + CurrencyAmountKind, + FreezableToken, + Network, + OrderBalance, + OrderInfo, + PoolInfo, + SignatureHashType, + SimpleCurrencyAmount, + SourceId, + TokenUnfreezable, + TotalSupply, + TxAdditionalInfo, + WasmError, +) + +# Convenience aliases matching the Go SDK's constant names. +MAINNET = Network.MAINNET +TESTNET = Network.TESTNET +REGTEST = Network.REGTEST +SIGNET = Network.SIGNET + +SIGHASH_ALL = SignatureHashType.SIGHASH_ALL +SIGHASH_NONE = SignatureHashType.SIGHASH_NONE +SIGHASH_SINGLE = SignatureHashType.SIGHASH_SINGLE +SIGHASH_ANYONECANPAY = SignatureHashType.SIGHASH_ANYONECANPAY + +SOURCE_TRANSACTION = SourceId.SOURCE_TRANSACTION +SOURCE_BLOCK_REWARD = SourceId.SOURCE_BLOCK_REWARD + +__all__ = [ + "Client", + "Amount", + "Network", + "MAINNET", + "TESTNET", + "REGTEST", + "SIGNET", + "SignatureHashType", + "SIGHASH_ALL", + "SIGHASH_NONE", + "SIGHASH_SINGLE", + "SIGHASH_ANYONECANPAY", + "SourceId", + "SOURCE_TRANSACTION", + "SOURCE_BLOCK_REWARD", + "TotalSupply", + "FreezableToken", + "TokenUnfreezable", + "CurrencyAmountKind", + "SimpleCurrencyAmount", + "OrderBalance", + "OrderInfo", + "PoolInfo", + "TxAdditionalInfo", + "WasmError", +] diff --git a/mintlayer/wasm/_core.py b/mintlayer/wasm/_core.py new file mode 100644 index 0000000..74659b3 --- /dev/null +++ b/mintlayer/wasm/_core.py @@ -0,0 +1,493 @@ +"""Core WASM machinery shared by all method mixins. + +:class:`~mintlayer.wasm.client.Client` combines this core with the per-area +method mixins (keys, addresses, transactions, ...). + +Memory ownership protocol (verified against the wasm-bindgen JS glue that +ships with the vendored binary — see the ``web-gui`` repository's +``app/wasm-wrappers`` for the byte-identical build): + +* **Plain inputs** (``_write_string``/``_write_bytes``): the WASM callee takes + ownership (Rust ``String``/``Vec`` parameters) and frees them when the + call returns. The glue never frees them host-side; neither must we — a + host-side free is a double free that corrupts the allocator. +* **String-array arguments** (``_write_string_array``): the callee owns the + table slots and the index array; the host releases nothing after the call. + Only a pre-call write failure is rolled back. +* **Byte-array-array arguments** (``_write_uint8_array_array``): the callee + owns the table slots and the index array, but the per-slice backing buffers + are host-malloc'd and only copied (``to_vec``) — the host frees the backing + buffers after the call (see ``intent.py``). +* **Result buffers**: host-owned; read, zeroed and freed by the + ``_call_return_*`` helpers. + +Treat the process memory of a long-lived ``Client`` as sensitive: key +material passes through WASM linear memory. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import threading +from pathlib import Path +from typing import Any + +from wasmtime import Engine, Instance, Linker, Memory, Module, Store, Table + +from .types import Amount, WasmError + +_WASM_PATH = Path(__file__).parent / "wasm_wrappers_bg.wasm" + + +def _load_and_verify_wasm() -> bytes: + """Load the vendored WASM binary, failing closed if it misses its pin. + + The read and verification live in this single function so that any + packaging mistake raises a descriptive :class:`WasmError` — at import + time, which is intentional: a broken install must fail fast, not at + first use deep inside a wallet operation. + """ + if not _WASM_PATH.is_file(): + raise WasmError(f"mintlayer: WASM binary missing: {_WASM_PATH}") + wasm_bytes = _WASM_PATH.read_bytes() + pin_path = _WASM_PATH.with_suffix(".wasm.sha256") + try: + pin = pin_path.read_text().split() + except FileNotFoundError as exc: + raise WasmError(f"mintlayer: WASM integrity pin file missing: {pin_path}") from exc + if not pin: + raise WasmError(f"mintlayer: WASM integrity pin file is empty: {pin_path}") + expected = pin[0].strip() + actual = hashlib.sha256(wasm_bytes).hexdigest() + if actual != expected: + raise WasmError( + f"mintlayer: WASM binary integrity check failed " + f"(expected sha256 {expected}, got {actual})" + ) + return wasm_bytes + + +_WASM_BYTES = _load_and_verify_wasm() + + +class _CallState: + """Per-call side channel populated by host functions.""" + + __slots__ = ("err_msg", "last_json") + + def __init__(self) -> None: + self.err_msg: str = "" + self.last_json: bytearray | None = None + + def reset(self) -> None: + self.err_msg = "" + self.last_json = None + + +class _WasmCore: + """Core WASM machinery: lifecycle, call conventions and memory helpers.""" + + def __init__(self) -> None: + self.lock = threading.RLock() + self.call_state = _CallState() + self._last_err_msg = "" + self._last_json: bytearray | None = None + + engine = Engine() + self._engine = engine + self.store = Store(engine) + self._module = Module(engine, _WASM_BYTES) + linker = Linker(engine) + + from .host import register_host_functions + + register_host_functions(self, linker) + + instance = linker.instantiate(self.store, self._module) + self._instance: Instance | None = instance + + raw_exports = instance.exports(self.store) + exports = {name: raw_exports[name] for name in raw_exports} + self._exports = exports + memory = exports.get("memory") + if not isinstance(memory, Memory): + raise WasmError("mintlayer: memory export not found") + self.memory: Memory = memory + table = exports.get("__wbindgen_externrefs") + if not isinstance(table, Table): + raise WasmError("mintlayer: __wbindgen_externrefs table not found") + self.table: Table = table + + # ── lifecycle ──────────────────────────────────────────────────────────── + + def close(self) -> None: + """Release WASM resources. Subsequent calls raise.""" + with self.lock: + self._instance = None + self._exports = {} + self.memory = None # type: ignore[assignment] + self.table = None # type: ignore[assignment] + + def __enter__(self) -> _WasmCore: + return self + + def __exit__(self, *_exc: object) -> None: + self.close() + + # ── export lookup ──────────────────────────────────────────────────────── + + def get_export(self, name: str) -> Any: + return self._exports.get(name) + + def _fn(self, name: str) -> Any: + fn = self._exports.get(name) + if fn is None: + if self._instance is None: + raise WasmError("mintlayer: client is closed") + raise WasmError(f'mintlayer: function "{name}" not found') + return fn + + # ── low-level call ─────────────────────────────────────────────────────── + + def _call(self, name: str, *params: Any) -> list: + """Execute a WASM export and return the raw results.""" + self._last_err_msg = "" + self._last_json = None + self.call_state.reset() + fn = self._fn(name) + try: + res = fn(self.store, *params) + except Exception as exc: + # WasmThrow (from the host's __wbindgen_throw) and traps land here. + # The cause chain is preserved: wasmtime trap traces are noisy but + # invaluable for diagnosing memory/ABI failures. + if self.call_state.err_msg: + raise WasmError(f"mintlayer: {self.call_state.err_msg}") from exc + raise WasmError(f"mintlayer: call {name}: {exc}") from exc + self._last_err_msg = self.call_state.err_msg + self._last_json = self.call_state.last_json + # Func.__call__ returns None / scalar / list depending on result count. + if res is None: + return [] + if not isinstance(res, list): + return [res] + return res + + def _extract_error(self, err_idx: int) -> WasmError: + if self._last_err_msg: + return WasmError(f"mintlayer: {self._last_err_msg}") + return WasmError(f"mintlayer: wasm returned error (ref={err_idx})") + + # ── call-return conventions ────────────────────────────────────────────── + + def _call_return_bytes(self, name: str, *params: Any) -> bytes: + """fn expects [ptr, len, errRef, errFlag].""" + ret = self._call(name, *params) + if len(ret) >= 4 and ret[3] != 0: + # No buffer cleanup on the error path, matching the reference + # glue: it zeroes the pointer instead of freeing, because ret[0] + # is not a valid allocation once the error flag is set (the Rust + # Err variant allocates no result) - freeing it could corrupt + # the allocator. + raise self._extract_error(ret[2]) + if len(ret) < 2: + raise WasmError(f"mintlayer: unexpected return count from {name}") + ptr, length = ret[0], ret[1] + data = self._read_bytes(ptr, length) if length else bytearray() + if data is None: + raise WasmError("mintlayer: memory read failed") + with contextlib.suppress(Exception): + self.memory.write(self.store, b"\x00" * length, ptr) + self._free_wasm(ptr, length) + return bytes(data) + + def _call_return_bytes_no_err(self, name: str, *params: Any) -> bytes: + """fn expects [ptr, len] (infallible).""" + ret = self._call(name, *params) + if len(ret) < 2: + raise WasmError(f"mintlayer: unexpected return count from {name}") + ptr, length = ret[0], ret[1] + data = self._read_bytes(ptr, length) if length else bytearray() + if data is None: + raise WasmError("mintlayer: memory read failed") + with contextlib.suppress(Exception): + self.memory.write(self.store, b"\x00" * length, ptr) + self._free_wasm(ptr, length) + return bytes(data) + + def _call_return_string(self, name: str, *params: Any) -> str: + """fn expects [ptr, len, errRef, errFlag]; result is UTF-8.""" + ret = self._call(name, *params) + if len(ret) >= 4 and ret[3] != 0: + # Error-path buffers are not freed: see _call_return_bytes. + raise self._extract_error(ret[2]) + if len(ret) < 2: + raise WasmError(f"mintlayer: unexpected return count from {name}") + ptr, length = ret[0], ret[1] + data = self._read_bytes(ptr, length) + if data is None: + raise WasmError("mintlayer: memory read failed") + with contextlib.suppress(Exception): + self.memory.write(self.store, b"\x00" * length, ptr) + self._free_wasm(ptr, length) + return bytes(data).decode("utf-8") + + def _call_return_bool(self, name: str, *params: Any) -> bool: + """fn expects [bool, errRef, errFlag].""" + ret = self._call(name, *params) + if len(ret) >= 3 and ret[2] != 0: + raise self._extract_error(ret[1]) + if len(ret) < 1: + raise WasmError(f"mintlayer: unexpected return count from {name}") + return ret[0] != 0 + + def _call_return_u32(self, name: str, *params: Any) -> int: + """fn expects [u32, errRef, errFlag].""" + ret = self._call(name, *params) + if len(ret) >= 3 and ret[2] != 0: + raise self._extract_error(ret[1]) + if len(ret) < 1: + raise WasmError(f"mintlayer: unexpected return count from {name}") + return ret[0] & 0xFFFFFFFF + + def _call_return_u64(self, name: str, *params: Any) -> int: + """fn expects [u64, errRef, errFlag].""" + ret = self._call(name, *params) + if len(ret) >= 3 and ret[2] != 0: + raise self._extract_error(ret[1]) + if len(ret) < 1: + raise WasmError(f"mintlayer: unexpected return count from {name}") + return ret[0] & 0xFFFFFFFFFFFFFFFF + + def _call_return_amount(self, name: str, *params: Any) -> Amount: + """fn returns a single Amount pointer (infallible).""" + ret = self._call(name, *params) + if len(ret) == 0: + raise WasmError(f"mintlayer: no return value from {name}") + return self._read_amount(ret[0]) + + def _call_return_amount_fallible(self, name: str, *params: Any) -> Amount: + """fn expects [amtPtr, errRef, errFlag].""" + ret = self._call(name, *params) + if len(ret) >= 3 and ret[2] != 0: + raise self._extract_error(ret[1]) + if len(ret) == 0: + raise WasmError(f"mintlayer: no return value from {name}") + return self._read_amount(ret[0]) + + def _call_void_fallible(self, name: str, *params: Any) -> None: + """fn expects [errRef, errFlag] (void on success).""" + ret = self._call(name, *params) + if len(ret) >= 2 and ret[1] != 0: + raise self._extract_error(ret[0]) + + def _call_return_json(self, name: str, *params: Any) -> bytes: + """fn returns a JSON object captured by the host's JSON.parse.""" + ret = self._call(name, *params) + if len(ret) >= 3 and ret[2] != 0: + raise self._extract_error(ret[1]) + if self._last_json is not None: + return bytes(self._last_json) + raise WasmError(f"mintlayer: no JSON result from {name}") + + # ── memory helpers ─────────────────────────────────────────────────────── + + def _read_bytes(self, ptr: int, length: int) -> bytearray | None: + return self.memory.read(self.store, ptr, ptr + length) + + def _write_bytes(self, data: bytes) -> tuple[int, int]: + """Copy ``data`` into WASM heap; returns (ptr, len). + + The buffer becomes callee-owned: the WASM function takes the bytes by + value and frees them when it returns. Do NOT free host-side — that is + a double free. (``amount_from_atoms`` is likewise callee-owned; the + error path in :meth:`_new_wasm_amount` frees only because ownership + was never transferred.) + """ + if not data: + return 0, 0 + ptr = self._invoke1("__wbindgen_malloc", len(data), 1) + if self.memory.write(self.store, data, ptr) is None: + raise WasmError("mintlayer: memory write failed") + return ptr, len(data) + + def _write_string(self, s: str) -> tuple[int, int]: + return self._write_bytes(s.encode("utf-8")) + + def _free_wasm(self, ptr: int, size: int, align: int = 1) -> None: + """Free a host-owned WASM allocation. + + Failures are deliberately swallowed: a cleanup error must not mask + the call's outcome, and if the allocator is genuinely corrupted the + NEXT wasm call traps loudly anyway (surfaces as WasmError). + """ + if ptr == 0: + return + with contextlib.suppress(Exception): + self._fn("__wbindgen_free")(self.store, ptr, size, align) + + def _write_optional_string(self, s: str | None) -> tuple[int, int]: + if s is None: + return 0, 0 + return self._write_string(s) + + def _write_optional_bytes(self, b: bytes | None) -> tuple[int, int]: + if b is None: + return 0, 0 + return self._write_bytes(b) + + def _new_wasm_amount(self, amount: Amount) -> int: + """Allocate an Amount in the WASM heap and return its handle. + + ``amount_from_atoms`` takes ownership of the string allocation, so the + string is NOT freed here. + """ + str_ptr, str_len = self._write_string(amount.atoms) + try: + return self._invoke1("amount_from_atoms", str_ptr, str_len) + except WasmError: + self._free_wasm(str_ptr, str_len) + raise + + def _read_amount(self, wasm_ptr: int) -> Amount: + """Read the atom string from a WASM Amount handle (consumes it). + + Deliberately uses ``_call_export`` rather than ``_call`` (mirrors + go-sdk's readAmount): ``amount_atoms`` decodes after the parent call + already extracted its error state, and routing it through ``_call`` + would reset the per-call ``err_msg``/``last_json`` the parent + captured. + """ + ret = self._call_export("amount_atoms", wasm_ptr) + if len(ret) < 2: + raise WasmError("mintlayer: amount_atoms failed") + ptr, length = ret[0], ret[1] + data = self._read_bytes(ptr, length) + if data is None: + raise WasmError("mintlayer: memory read for amount failed") + atoms = data.decode("utf-8") + self._free_wasm(ptr, length) + return Amount.from_atoms(atoms) + + # ── externref-index arrays (passArrayJsValueToWasm0 pattern) ───────────── + # + # Ownership: during the call the WASM callee takes ownership of the index + # array, the externref table slots, and (for Uint8Array entries) reads and + # copies the backing buffers. The callee itself releases the index array + # and deallocs the table slots, so the host must never re-read the array + # or dealloc the slots after the call — doing so double-frees free-list + # entries and corrupts later allocations ("array contains a value of the + # wrong type" on subsequent multi-element calls). The Uint8Array backing + # buffers, however, are malloc'd host-side and only copied by the callee + # (to_vec): the host frees those after the call. + # + # Known wasm-bindgen trait: a FAILING array call leaks a few slots (the + # callee's error path skips part of its cleanup). Not soundly fixable + # host-side — never "compensate" by dealloc'ing, which is worse. + + def _write_string_array(self, strs: list[str]) -> tuple[int, list[int]]: + """Write ``[string]`` as an array of externref table indices. + + Returns (array_ptr, table_indices). The index array and the slots + become callee-owned once the call is made — never call + :meth:`_dealloc_indices` on them afterwards (only pre-call rollback, + which this method already handles internally). + """ + indices: list[int] = [] + if not strs: + return 0, indices + arr_ptr = self._malloc_array(len(strs)) + try: + for i, s in enumerate(strs): + idx = self._invoke1("__externref_table_alloc") + indices.append(idx) + self.table.set(self.store, idx, s) + if ( + self.memory.write(self.store, idx.to_bytes(4, "little"), arr_ptr + i * 4) + is None + ): + raise WasmError("mintlayer: memory write failed") + except BaseException: + self._dealloc_indices(indices) + with contextlib.suppress(Exception): + self._fn("__wbindgen_free")(self.store, arr_ptr, len(strs) * 4, 4) + raise + return arr_ptr, indices + + def _write_uint8_array_array( + self, slices: list[bytes] + ) -> tuple[int, list[int], list[tuple[int, int]]]: + """Write ``[bytes]`` as an array of externref table indices. + + Each slice is copied into WASM heap and wrapped as a Uint8Array. + Returns (array_ptr, table_indices, backing_buffers). After the call: + the table slots and the index array are CALLEE-OWNED — never dealloc + them (doing so corrupts the table free list, observed as + ``unreachable`` traps on subsequent calls). The backing buffers, + however, are malloc'd host-side and only copied by the callee + (``to_vec``): the host frees those after the call (see + ``intent.encode_signed_transaction_intent``). + """ + from .host import Uint8ArrayRef + + indices: list[int] = [] + buffers: list[tuple[int, int]] = [] + if not slices: + return 0, indices, buffers + arr_ptr = self._malloc_array(len(slices)) + try: + for i, b in enumerate(slices): + wasm_ptr, wasm_len = self._write_bytes(b) + buffers.append((wasm_ptr, wasm_len)) + idx = self._invoke1("__externref_table_alloc") + indices.append(idx) + self.table.set(self.store, idx, Uint8ArrayRef(wasm_ptr, wasm_len)) + if ( + self.memory.write(self.store, idx.to_bytes(4, "little"), arr_ptr + i * 4) + is None + ): + raise WasmError("mintlayer: memory write failed") + except BaseException: + self._dealloc_indices(indices) + for ptr, length in buffers: + self._free_wasm(ptr, length) + with contextlib.suppress(Exception): + self._fn("__wbindgen_free")(self.store, arr_ptr, len(slices) * 4, 4) + raise + return arr_ptr, indices, buffers + + def _malloc_array(self, count: int) -> int: + return self._invoke1("__wbindgen_malloc", count * 4, 4) + + def _call_export(self, name: str, *params: Any) -> list: + """Call a WASM export, normalising the result to a list.""" + res = self._fn(name)(self.store, *params) + if res is None: + return [] + if not isinstance(res, list): + return [res] + return res + + def _invoke1(self, name: str, *params: Any) -> int: + """Call a WASM export returning exactly one i32/i64 result.""" + ret = self._call_export(name, *params) + if len(ret) != 1: + raise WasmError(f"mintlayer: {name} returned {len(ret)} results") + return ret[0] + + def _dealloc_indices(self, indices: list[int]) -> None: + """Release externref table slots. + + ONLY for pre-call rollback (a write failed before the callee ever ran). + After a call, the slots are callee-owned — deallocating them again + corrupts the table free list. + """ + if not indices: + return + dealloc = self.get_export("__externref_table_dealloc") + if dealloc is None: + return + for idx in indices: + with contextlib.suppress(Exception): + dealloc(self.store, idx) diff --git a/mintlayer/wasm/_util.py b/mintlayer/wasm/_util.py new file mode 100644 index 0000000..0b5d48b --- /dev/null +++ b/mintlayer/wasm/_util.py @@ -0,0 +1,25 @@ +"""Shared helpers for public WASM client methods.""" + +from __future__ import annotations + +import functools +from collections.abc import Callable +from typing import Any, TypeVar + +F = TypeVar("F", bound=Callable[..., Any]) + + +def synchronized(method: F) -> F: + """Serialise a public method so the single WASM instance is never shared.""" + + @functools.wraps(method) + def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + with self.lock: + return method(self, *args, **kwargs) + + return wrapper # type: ignore[return-value] + + +def bool_to_int(b: bool) -> int: + """Encode a Python bool as the WASM ABI's 0/1.""" + return 1 if b else 0 diff --git a/mintlayer/wasm/addresses.py b/mintlayer/wasm/addresses.py new file mode 100644 index 0000000..07ebefc --- /dev/null +++ b/mintlayer/wasm/addresses.py @@ -0,0 +1,40 @@ +"""Address encoding (mirrors go-sdk/wasm/addresses.go).""" + +from __future__ import annotations + +from ._core import _WasmCore +from ._util import synchronized +from .types import Network + + +class AddressesMixin(_WasmCore): + @synchronized + def encode_destination(self, address: str, network: Network) -> bytes: + """Encode a bech32m address string into a binary destination.""" + ptr, length = self._write_string(address) + return self._call_return_bytes("encode_destination", ptr, length, int(network)) + + @synchronized + def pubkey_to_pubkeyhash_address(self, pubkey: bytes, network: Network) -> str: + """Derive a pay-to-public-key-hash bech32m address.""" + ptr, length = self._write_bytes(pubkey) + return self._call_return_string("pubkey_to_pubkeyhash_address", ptr, length, int(network)) + + @synchronized + def encode_multisig_challenge( + self, pubkeys: bytes, min_required_signatures: int, network: Network + ) -> bytes: + """Encode a multisig challenge (script) into binary. + + ``pubkeys`` is the concatenation of encoded public keys. + """ + ptr, length = self._write_bytes(pubkeys) + return self._call_return_bytes( + "encode_multisig_challenge", ptr, length, min_required_signatures, int(network) + ) + + @synchronized + def multisig_challenge_to_address(self, challenge: bytes, network: Network) -> str: + """Convert a binary multisig challenge into its bech32m address.""" + ptr, length = self._write_bytes(challenge) + return self._call_return_string("multisig_challenge_to_address", ptr, length, int(network)) diff --git a/mintlayer/wasm/client.py b/mintlayer/wasm/client.py new file mode 100644 index 0000000..c0134a2 --- /dev/null +++ b/mintlayer/wasm/client.py @@ -0,0 +1,41 @@ +"""WASM cryptography and transaction-building client. + +Mirrors go-sdk/wasm/client.go: instantiates the embedded ``wasm_wrappers_bg`` +module with a wasm-bindgen host shim and exposes the full API surface as +methods. The core machinery lives in :mod:`mintlayer.wasm._core`; the per-area +method groups (keys, addresses, …) are mixins, combined here into ``Client``. +""" + +from __future__ import annotations + +from .addresses import AddressesMixin +from .fees import FeesMixin +from .ids import IdsMixin +from .inputs import InputsMixin +from .intent import IntentMixin +from .keys import KeysMixin +from .outputs import OutputsMixin +from .signing import SigningMixin +from .staking import StakingMixin +from .timelocks import TimelocksMixin +from .transactions import TransactionsMixin + + +class Client( + KeysMixin, + AddressesMixin, + IdsMixin, + InputsMixin, + OutputsMixin, + TimelocksMixin, + FeesMixin, + TransactionsMixin, + SigningMixin, + StakingMixin, + IntentMixin, +): + """Provides access to all Mintlayer WASM functions. + + A ``Client`` is safe for concurrent use from multiple threads; every + public method serialises access to the single WASM instance. + """ diff --git a/mintlayer/wasm/fees.py b/mintlayer/wasm/fees.py new file mode 100644 index 0000000..0be9059 --- /dev/null +++ b/mintlayer/wasm/fees.py @@ -0,0 +1,45 @@ +"""Fee queries (mirrors go-sdk/wasm/fees.go).""" + +from __future__ import annotations + +from ._core import _WasmCore +from ._util import synchronized +from .types import Amount, Network + + +class FeesMixin(_WasmCore): + @synchronized + def fungible_token_issuance_fee(self, current_block_height: int, network: Network) -> Amount: + """Fee required to issue a new fungible token at the given block height.""" + return self._call_return_amount( + "fungible_token_issuance_fee", current_block_height, int(network) + ) + + @synchronized + def nft_issuance_fee(self, current_block_height: int, network: Network) -> Amount: + """Fee required to issue a new NFT at the given block height.""" + return self._call_return_amount("nft_issuance_fee", current_block_height, int(network)) + + @synchronized + def data_deposit_fee(self, current_block_height: int, network: Network) -> Amount: + """Fee required to create a DataDeposit output at the given block height.""" + return self._call_return_amount("data_deposit_fee", current_block_height, int(network)) + + @synchronized + def token_supply_change_fee(self, current_block_height: int, network: Network) -> Amount: + """Fee required to mint or unmint tokens at the given block height.""" + return self._call_return_amount( + "token_supply_change_fee", current_block_height, int(network) + ) + + @synchronized + def token_freeze_fee(self, current_block_height: int, network: Network) -> Amount: + """Fee required to freeze or unfreeze a token at the given block height.""" + return self._call_return_amount("token_freeze_fee", current_block_height, int(network)) + + @synchronized + def token_change_authority_fee(self, current_block_height: int, network: Network) -> Amount: + """Fee required to change a token's authority at the given block height.""" + return self._call_return_amount( + "token_change_authority_fee", current_block_height, int(network) + ) diff --git a/mintlayer/wasm/host.py b/mintlayer/wasm/host.py new file mode 100644 index 0000000..b88159e --- /dev/null +++ b/mintlayer/wasm/host.py @@ -0,0 +1,356 @@ +"""Host module for the embedded WASM module. + +The WASM binary imports all host functions from the module +``"./wasm_wrappers_bg.js"`` (the wasm-bindgen JS glue). This module implements +those 31 imports natively for wasmtime, mirroring go-sdk/wasm/host.go: + +* random number generation (``os.urandom``) feeds key generation and signing, +* JSON round-tripping implements the tsify/serde ``TxAdditionalInfo`` path, +* error strings are captured per call via a side channel (``cast_...2`` / + ``__wbindgen_throw``) instead of being read from the externref table. + +Externref values are real Python objects; ``None`` represents the null/undefined +externref (registry handle 0 in the Go SDK). +""" + +from __future__ import annotations + +import json +import os +from typing import TYPE_CHECKING, Any + +from wasmtime import Func, FuncType, Linker, ValType + +if TYPE_CHECKING: + from ._core import _WasmCore + +HOST_MODULE = "./wasm_wrappers_bg.js" + +_I32 = ValType.i32() +_ANYREF = ValType.externref() + + +class _GlobalSentinel: + """Represents JavaScript ``globalThis``.""" + + +class _CryptoSentinel: + """Represents the ``crypto`` object on globalThis.""" + + +class Uint8ArrayRef: + """A JS Uint8Array whose backing store lives in WASM linear memory.""" + + __slots__ = ("ptr", "length") + + def __init__(self, ptr: int, length: int) -> None: + self.ptr = ptr + self.length = length + + +class FunctionSentinel: + """A JS function created via ``new Function(code)``.""" + + __slots__ = ("code",) + + def __init__(self, code: str) -> None: + self.code = code + + +GLOBAL = _GlobalSentinel() +CRYPTO = _CryptoSentinel() + + +class WasmThrow(Exception): + """Raised by ``__wbindgen_throw`` to abort the current WASM invocation.""" + + +def _json_dumps(value: Any) -> str: + """Serialise a host value to JSON, honouring SDK types (serde shape).""" + if hasattr(value, "to_json_value"): + value = value.to_json_value() + return json.dumps(value, separators=(",", ":")) + + +def register_host_functions(client: _WasmCore, linker: Linker) -> None: + """Define the 31 host imports on ``linker`` for ``client``'s store.""" + store = client.store + state = client.call_state + + def invoke(fn_name: str, *args: Any) -> list: + """Call a WASM export from within a host function; results as list.""" + fn = client.get_export(fn_name) + if fn is None: + raise WasmThrow(f"{fn_name} not found") + res = fn(store, *args) + if res is None: + return [] + if not isinstance(res, list): + return [res] + return res + + def alloc_table_slot(value: Any) -> int: + """Allocate an externref-table slot, store ``value`` in it, return the index.""" + idx = invoke("__externref_table_alloc")[0] + client.table.set(store, idx, value) + return idx + + def signal_exception(msg: str) -> None: + """Mirror JS ``handleError``: stash the exception so WASM returns Err. + + The message is recorded in the per-call side channel so host-side + failures surface even if the WASM error path never calls + ``__wbindgen_cast_...2`` (a later cast still overwrites it with the + richer Rust-side message). + + ``__wbindgen_exn_store`` receives the externref holding the exception + value (JS stores the ``Error`` object); we store the message string so + the WASM unwind path reads this call's payload instead of a stale slot. + + Like the JS glue and go-sdk's host, the host function itself returns + normally after stashing — whether the callee converts the stored + exception into an Err is decided by the module's generated code + (wasm-bindgen contract); the host cannot force an Err return. + """ + if not state.err_msg: + state.err_msg = msg + if client.get_export("__wbindgen_exn_store") is None: + return + invoke("__wbindgen_exn_store", alloc_table_slot(msg)) + + def write_string_to_mem(out_ptr: int, s: str) -> None: + """Write (ptr, len) of ``s`` as two LE u32s at ``out_ptr``.""" + zeros = (0).to_bytes(4, "little") + data = s.encode() + if not data: + client.memory.write(store, zeros, out_ptr) + client.memory.write(store, zeros, out_ptr + 4) + return + ptr = invoke("__wbindgen_malloc", len(data), 1)[0] + client.memory.write(store, data, ptr) + client.memory.write(store, ptr.to_bytes(4, "little"), out_ptr) + client.memory.write(store, len(data).to_bytes(4, "little"), out_ptr + 4) + + def fill_random(buf: Any) -> None: + if not isinstance(buf, Uint8ArrayRef): + signal_exception("fillRandom: expected Uint8Array") + return + data = os.urandom(buf.length) + # A failed write must never pass silently: the WASM side would proceed + # with stale/zero bytes as key material. + if client.memory.write(store, data, buf.ptr) is None: + signal_exception("fillRandom: memory write failed") + + def debug_string(value: Any) -> str: + if value is None: + return "undefined" + if isinstance(value, str): + return json.dumps(value) + if isinstance(value, Uint8ArrayRef): + return f"Uint8Array({value.length})" + if isinstance(value, _GlobalSentinel): + return "[object global]" + if isinstance(value, _CryptoSentinel): + return "[object Crypto]" + if isinstance(value, FunctionSentinel): + return f"function {value.code}" + return _json_dumps(value) + + def def_func(name: str, params: list, results: list, func: Any) -> None: + linker.define(store, HOST_MODULE, name, Func(store, FuncType(params, results), func)) + + # ── global accessors: () -> i32 (externref table index) ────────────────── + def _global_accessor() -> int: + return alloc_table_slot(GLOBAL) + + def _null_accessor() -> int: + return 0 + + def_func("__wbg_static_accessor_GLOBAL_12837167ad935116", [], [_I32], _global_accessor) + def_func("__wbg_static_accessor_GLOBAL_THIS_e628e89ab3b1c95f", [], [_I32], _global_accessor) + def_func("__wbg_static_accessor_SELF_a621d3dfbb60d0ce", [], [_I32], _null_accessor) + def_func("__wbg_static_accessor_WINDOW_f8727f0cf888e0bd", [], [_I32], _null_accessor) + + # ── crypto property access: (anyref) -> anyref ─────────────────────────── + def _crypto(value: Any) -> Any: + if isinstance(value, _GlobalSentinel): + return CRYPTO + return None + + def _null_value(value: Any) -> Any: + return None + + def_func("__wbg_crypto_86f2631e91b51511", [_ANYREF], [_ANYREF], _crypto) + def_func("__wbg_msCrypto_d562bbe83e0d4b91", [_ANYREF], [_ANYREF], _null_value) + def_func("__wbg_process_3975fd6c72f520aa", [_ANYREF], [_ANYREF], _null_value) + def_func("__wbg_node_e1f24f89a7336c2e", [_ANYREF], [_ANYREF], _null_value) + def_func("__wbg_versions_4e31226f5e8dc909", [_ANYREF], [_ANYREF], _null_value) + + # ── RNG: (anyref, anyref) -> () ────────────────────────────────────────── + def_func( + "__wbg_getRandomValues_b3f15fcbfabb0f8b", + [_ANYREF, _ANYREF], + [], + lambda _obj, buf: fill_random(buf), + ) + def_func( + "__wbg_randomFillSync_f8c153b79f285817", + [_ANYREF, _ANYREF], + [], + lambda _obj, buf: fill_random(buf), + ) + + # ── require: () -> anyref (null so WASM skips the Node.js crypto path) ─── + def_func("__wbg_require_b74f47fc2d022fd6", [], [_ANYREF], lambda: None) + + # ── function call stubs ────────────────────────────────────────────────── + def _call2(fn: Any, _this: Any) -> Any: + if isinstance(fn, FunctionSentinel) and fn.code == "return this": + return GLOBAL + signal_exception("unsupported call/2") + return None + + def _call3(_fn: Any, _this: Any, _arg: Any) -> Any: + signal_exception("unsupported call (one arg)") + return None + + def_func("__wbg_call_389efe28435a9388", [_ANYREF, _ANYREF], [_ANYREF], _call2) + def_func("__wbg_call_4708e0c13bdc8e95", [_ANYREF, _ANYREF, _ANYREF], [_ANYREF], _call3) + + # ── Uint8Array operations ──────────────────────────────────────────────── + def _length(value: Any) -> int: + if isinstance(value, Uint8ArrayRef): + return value.length + return 0 + + def _new_with_length(size: int) -> Any: + # NOTE: the backing store is deliberately NOT freed host-side. The WASM + # module treats these Uint8Arrays as GC-managed JS values: it caches + # them in externref table slots (e.g. the RNG scratch buffer) and + # reuses them across calls. Freeing on call end would be a use-after- + # free; the cost is a bounded, one-time allocation per cached buffer. + ptr = invoke("__wbindgen_malloc", size, 1)[0] + if client.memory.write(store, b"\x00" * size, ptr) is None: + signal_exception("new Uint8Array: memory write failed") + return None + return Uint8ArrayRef(ptr, size) + + def _prototypesetcall(dst_ptr: int, dst_len: int, src: Any) -> None: + if isinstance(src, Uint8ArrayRef): + n = min(src.length, dst_len) + data = client.memory.read(store, src.ptr, src.ptr + n) + if data: + client.memory.write(store, data, dst_ptr) + + def _subarray(value: Any, start: int, end: int) -> Any: + # JS spec: TypedArray.prototype.subarray clamps the range silently. + # (Divergence: JS also accepts negative indices as from-the-end; this + # host clamps them to 0 — wasm-bindgen only ever passes u32 offsets.) + if isinstance(value, Uint8ArrayRef): + start = max(0, min(start, value.length)) + end = max(start, min(end, value.length)) + return Uint8ArrayRef(value.ptr + start, end - start) + return None + + def_func("__wbg_length_32ed9a279acd054c", [_ANYREF], [_I32], _length) + def_func("__wbg_new_with_length_a2c39cbe88fd8ff1", [_I32], [_ANYREF], _new_with_length) + def_func( + "__wbg_prototypesetcall_bdcdcc5842e4d77d", [_I32, _I32, _ANYREF], [], _prototypesetcall + ) + def_func("__wbg_subarray_a96e1fef17ed23cb", [_ANYREF, _I32, _I32], [_ANYREF], _subarray) + + # ── JSON: JSON.parse captures the raw bytes for decode_*_to_js results ─── + def _parse(ptr: int, length: int) -> Any: + data = client.memory.read(store, ptr, ptr + length) + if data is None: + signal_exception("JSON.parse: memory read failed") + return None + try: + value = json.loads(data.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + signal_exception(f"JSON.parse: {exc}") + return None + state.last_json = data + return value + + def _stringify(value: Any) -> Any: + if value is None: + signal_exception("JSON.stringify: unknown reference") + return None + try: + return _json_dumps(value) + except (TypeError, ValueError) as exc: + signal_exception(f"JSON.stringify: {exc}") + return None + + def_func("__wbg_parse_708461a1feddfb38", [_I32, _I32], [_ANYREF], _parse) + def_func("__wbg_stringify_8d1cc6ff383e8bae", [_ANYREF], [_ANYREF], _stringify) + + # ── new Function(code): (i32, i32) -> anyref ───────────────────────────── + def _new_no_args(ptr: int, length: int) -> Any: + data = client.memory.read(store, ptr, ptr + length) + return FunctionSentinel(data.decode("utf-8", errors="replace") if data else "") + + def_func("__wbg_new_no_args_1c7c842f08d00ebb", [_I32, _I32], [_ANYREF], _new_no_args) + + # ── cast intrinsics: (i32, i32) -> anyref ──────────────────────────────── + def _cast_uint8array(ptr: int, length: int) -> Any: + return Uint8ArrayRef(ptr, length) + + def _cast_string(ptr: int, length: int) -> Any: + data = client.memory.read(store, ptr, ptr + length) + s = data.decode("utf-8", errors="replace") if data else "" + # Rust errors cross here as their Display string; capture for the caller. + state.err_msg = s + return s + + def_func("__wbindgen_cast_0000000000000001", [_I32, _I32], [_ANYREF], _cast_uint8array) + def_func("__wbindgen_cast_0000000000000002", [_I32, _I32], [_ANYREF], _cast_string) + + # ── debug / type predicates: (anyref) -> i32 ───────────────────────────── + def _debug_string(out_ptr: int, value: Any) -> None: + write_string_to_mem(out_ptr, debug_string(value)) + + def _is_function(value: Any) -> int: + return 1 if isinstance(value, FunctionSentinel) else 0 + + def _is_object(value: Any) -> int: + return 1 if isinstance(value, (_GlobalSentinel, _CryptoSentinel, Uint8ArrayRef)) else 0 + + def _is_string(value: Any) -> int: + return 1 if isinstance(value, str) else 0 + + def _is_undefined(value: Any) -> int: + return 1 if value is None else 0 + + def_func("__wbg___wbindgen_debug_string_0bc8482c6e3508ae", [_I32, _ANYREF], [], _debug_string) + def_func("__wbg___wbindgen_is_function_0095a73b8b156f76", [_ANYREF], [_I32], _is_function) + def_func("__wbg___wbindgen_is_object_5ae8e5880f2c1fbd", [_ANYREF], [_I32], _is_object) + def_func("__wbg___wbindgen_is_string_cd444516edc5b180", [_ANYREF], [_I32], _is_string) + def_func("__wbg___wbindgen_is_undefined_9e4d92534c42d778", [_ANYREF], [_I32], _is_undefined) + + # ── string_get: (i32, anyref) -> () ────────────────────────────────────── + def _string_get(out_ptr: int, value: Any) -> None: + if isinstance(value, str): + write_string_to_mem(out_ptr, value) + return + client.memory.write(store, (0).to_bytes(4, "little"), out_ptr) + client.memory.write(store, (0).to_bytes(4, "little"), out_ptr + 4) + + def_func("__wbg___wbindgen_string_get_72fb696202c56729", [_I32, _ANYREF], [], _string_get) + + # ── throw: (i32, i32) -> () — aborts the WASM invocation ───────────────── + def _throw(ptr: int, length: int) -> None: + data = client.memory.read(store, ptr, ptr + length) + msg = data.decode("utf-8", errors="replace") if data else "" + state.err_msg = msg + raise WasmThrow(f"wasm throw: {msg}") + + def_func("__wbg___wbindgen_throw_be289d5034ed271b", [_I32, _I32], [], _throw) + + # ── externref table init: () -> () ─────────────────────────────────────── + def _init_externref_table() -> None: + for _ in range(4): + invoke("__externref_table_alloc") + + def_func("__wbindgen_init_externref_table", [], [], _init_externref_table) diff --git a/mintlayer/wasm/ids.py b/mintlayer/wasm/ids.py new file mode 100644 index 0000000..cd54a19 --- /dev/null +++ b/mintlayer/wasm/ids.py @@ -0,0 +1,39 @@ +"""Object ID derivation (mirrors go-sdk/wasm/ids.go).""" + +from __future__ import annotations + +from ._core import _WasmCore +from ._util import synchronized +from .types import Network + + +class IdsMixin(_WasmCore): + @synchronized + def get_pool_id(self, inputs: bytes, network: Network) -> str: + """Return the pool ID derived from a transaction's inputs.""" + ptr, length = self._write_bytes(inputs) + return self._call_return_string("get_pool_id", ptr, length, int(network)) + + @synchronized + def get_token_id(self, inputs: bytes, current_block_height: int, network: Network) -> str: + """Return the fungible or NFT token ID derived from a transaction's inputs. + + ``current_block_height`` selects the token ID scheme for the active + network upgrade. + """ + ptr, length = self._write_bytes(inputs) + return self._call_return_string( + "get_token_id", ptr, length, current_block_height, int(network) + ) + + @synchronized + def get_delegation_id(self, inputs: bytes, network: Network) -> str: + """Return the delegation ID derived from a transaction's inputs.""" + ptr, length = self._write_bytes(inputs) + return self._call_return_string("get_delegation_id", ptr, length, int(network)) + + @synchronized + def get_order_id(self, inputs: bytes, network: Network) -> str: + """Return the DEX order ID derived from a transaction's inputs.""" + ptr, length = self._write_bytes(inputs) + return self._call_return_string("get_order_id", ptr, length, int(network)) diff --git a/mintlayer/wasm/inputs.py b/mintlayer/wasm/inputs.py new file mode 100644 index 0000000..bd32f87 --- /dev/null +++ b/mintlayer/wasm/inputs.py @@ -0,0 +1,178 @@ +"""Transaction input encoding (mirrors go-sdk/wasm/inputs.go).""" + +from __future__ import annotations + +from ._core import _WasmCore +from ._util import synchronized +from .types import Amount, Network, TokenUnfreezable + + +class InputsMixin(_WasmCore): + @synchronized + def encode_input_for_utxo(self, outpoint_source_id: bytes, output_index: int) -> bytes: + """Encode a UTXO input from an outpoint source ID and output index.""" + ptr, length = self._write_bytes(outpoint_source_id) + return self._call_return_bytes("encode_input_for_utxo", ptr, length, output_index) + + @synchronized + def encode_input_for_withdraw_from_delegation( + self, delegation_id: str, amount: Amount, nonce: int, network: Network + ) -> bytes: + """Create an input that withdraws from a delegation.""" + id_ptr, id_len = self._write_string(delegation_id) + amt_ptr = self._new_wasm_amount(amount) + return self._call_return_bytes( + "encode_input_for_withdraw_from_delegation", + id_ptr, + id_len, + amt_ptr, + nonce, + int(network), + ) + + @synchronized + def encode_input_for_mint_tokens( + self, token_id: str, amount: Amount, nonce: int, network: Network + ) -> bytes: + """Create an input to mint tokens.""" + id_ptr, id_len = self._write_string(token_id) + amt_ptr = self._new_wasm_amount(amount) + return self._call_return_bytes( + "encode_input_for_mint_tokens", id_ptr, id_len, amt_ptr, nonce, int(network) + ) + + @synchronized + def encode_input_for_unmint_tokens(self, token_id: str, nonce: int, network: Network) -> bytes: + """Create an input to unmint tokens.""" + id_ptr, id_len = self._write_string(token_id) + return self._call_return_bytes( + "encode_input_for_unmint_tokens", id_ptr, id_len, nonce, int(network) + ) + + @synchronized + def encode_input_for_lock_token_supply( + self, token_id: str, nonce: int, network: Network + ) -> bytes: + """Create an input to lock the token supply.""" + id_ptr, id_len = self._write_string(token_id) + return self._call_return_bytes( + "encode_input_for_lock_token_supply", id_ptr, id_len, nonce, int(network) + ) + + @synchronized + def encode_input_for_freeze_token( + self, + token_id: str, + is_token_unfreezable: TokenUnfreezable, + nonce: int, + network: Network, + ) -> bytes: + """Create an input to freeze a token.""" + id_ptr, id_len = self._write_string(token_id) + return self._call_return_bytes( + "encode_input_for_freeze_token", + id_ptr, + id_len, + int(is_token_unfreezable), + nonce, + int(network), + ) + + @synchronized + def encode_input_for_unfreeze_token(self, token_id: str, nonce: int, network: Network) -> bytes: + """Create an input to unfreeze a token.""" + id_ptr, id_len = self._write_string(token_id) + return self._call_return_bytes( + "encode_input_for_unfreeze_token", id_ptr, id_len, nonce, int(network) + ) + + @synchronized + def encode_input_for_change_token_authority( + self, token_id: str, new_authority: str, nonce: int, network: Network + ) -> bytes: + """Create an input to change the token authority.""" + id_ptr, id_len = self._write_string(token_id) + auth_ptr, auth_len = self._write_string(new_authority) + return self._call_return_bytes( + "encode_input_for_change_token_authority", + id_ptr, + id_len, + auth_ptr, + auth_len, + nonce, + int(network), + ) + + @synchronized + def encode_input_for_change_token_metadata_uri( + self, token_id: str, new_metadata_uri: str, nonce: int, network: Network + ) -> bytes: + """Create an input to change the token metadata URI.""" + id_ptr, id_len = self._write_string(token_id) + uri_ptr, uri_len = self._write_string(new_metadata_uri) + return self._call_return_bytes( + "encode_input_for_change_token_metadata_uri", + id_ptr, + id_len, + uri_ptr, + uri_len, + nonce, + int(network), + ) + + @synchronized + def encode_input_for_conclude_order( + self, order_id: str, nonce: int, current_block_height: int, network: Network + ) -> bytes: + """Create an input that concludes an order.""" + id_ptr, id_len = self._write_string(order_id) + return self._call_return_bytes( + "encode_input_for_conclude_order", + id_ptr, + id_len, + nonce, + current_block_height, + int(network), + ) + + @synchronized + def encode_input_for_fill_order( + self, + order_id: str, + fill_amount: Amount, + destination: str, + nonce: int, + current_block_height: int, + network: Network, + ) -> bytes: + """Create an input that fills an order. + + FillOrder inputs should not be signed (use ``encode_witness_no_signature``). + """ + id_ptr, id_len = self._write_string(order_id) + amt_ptr = self._new_wasm_amount(fill_amount) + dest_ptr, dest_len = self._write_string(destination) + return self._call_return_bytes( + "encode_input_for_fill_order", + id_ptr, + id_len, + amt_ptr, + dest_ptr, + dest_len, + nonce, + current_block_height, + int(network), + ) + + @synchronized + def encode_input_for_freeze_order( + self, order_id: str, current_block_height: int, network: Network + ) -> bytes: + """Create an input that freezes an order. + + Order freezing is available only after the orders V1 fork. + """ + id_ptr, id_len = self._write_string(order_id) + return self._call_return_bytes( + "encode_input_for_freeze_order", id_ptr, id_len, current_block_height, int(network) + ) diff --git a/mintlayer/wasm/intent.py b/mintlayer/wasm/intent.py new file mode 100644 index 0000000..5af111b --- /dev/null +++ b/mintlayer/wasm/intent.py @@ -0,0 +1,71 @@ +"""Transaction intents (mirrors go-sdk/wasm/intent.go).""" + +from __future__ import annotations + +from ._core import _WasmCore +from ._util import synchronized +from .types import Network + + +class IntentMixin(_WasmCore): + @synchronized + def make_transaction_intent_message_to_sign(self, intent: str, transaction_id: str) -> bytes: + """Return the canonical message that must be signed for a transaction intent. + + ``transaction_id`` should be a hex-encoded transaction ID returned by + ``get_transaction_id``. + """ + int_ptr, int_len = self._write_string(intent) + txid_ptr, txid_len = self._write_string(transaction_id) + return self._call_return_bytes( + "make_transaction_intent_message_to_sign", int_ptr, int_len, txid_ptr, txid_len + ) + + @synchronized + def encode_signed_transaction_intent( + self, signed_message: bytes, signatures: list[bytes] + ) -> bytes: + """Combine a signed message with per-input signatures into a SignedTransactionIntent. + + ``signed_message`` must be produced by + ``make_transaction_intent_message_to_sign``. ``signatures`` is one raw + signature per transaction input, each produced by ``sign_challenge``. + """ + msg_ptr, msg_len = self._write_bytes(signed_message) + sigs_ptr, sigs_indices, sigs_buffers = self._write_uint8_array_array(signatures) + try: + return self._call_return_bytes( + "encode_signed_transaction_intent", msg_ptr, msg_len, sigs_ptr, len(sigs_indices) + ) + finally: + # Slots and the index array are callee-owned; the backing buffers + # were host-malloc'd and only copied by the callee (to_vec). + for ptr, length in sigs_buffers: + self._free_wasm(ptr, length) + + @synchronized + def verify_transaction_intent( + self, + expected_signed_message: bytes, + encoded_signed_intent: bytes, + input_destinations: list[str], + network: Network, + ) -> None: + """Verify a signed transaction intent. + + ``input_destinations`` contains one bech32m address per transaction input. + """ + msg_ptr, msg_len = self._write_bytes(expected_signed_message) + intent_ptr, intent_len = self._write_bytes(encoded_signed_intent) + dests_ptr, dests_indices = self._write_string_array(input_destinations) + # Index array and slots are callee-owned; nothing to release. + self._call_void_fallible( + "verify_transaction_intent", + msg_ptr, + msg_len, + intent_ptr, + intent_len, + dests_ptr, + len(dests_indices), + int(network), + ) diff --git a/mintlayer/wasm/keys.py b/mintlayer/wasm/keys.py new file mode 100644 index 0000000..9f3a803 --- /dev/null +++ b/mintlayer/wasm/keys.py @@ -0,0 +1,59 @@ +"""Key derivation (mirrors go-sdk/wasm/keys.go).""" + +from __future__ import annotations + +from ._core import _WasmCore +from ._util import synchronized +from .types import Network + + +class KeysMixin(_WasmCore): + @synchronized + def make_private_key(self) -> bytes: + """Generate a new random private key.""" + return self._call_return_bytes_no_err("make_private_key") + + @synchronized + def make_default_account_privkey(self, mnemonic: str, network: Network) -> bytes: + """Derive the extended private key for the default account (account 0). + + Derivation path: 44'/mintlayer_coin_type'/0' + """ + ptr, length = self._write_string(mnemonic) + return self._call_return_bytes("make_default_account_privkey", ptr, length, int(network)) + + @synchronized + def public_key_from_private_key(self, privkey: bytes) -> bytes: + """Derive the compressed public key for a private key.""" + ptr, length = self._write_bytes(privkey) + return self._call_return_bytes("public_key_from_private_key", ptr, length) + + @synchronized + def extended_public_key_from_extended_private_key(self, privkey: bytes) -> bytes: + """Derive the extended public key from an extended private key.""" + ptr, length = self._write_bytes(privkey) + return self._call_return_bytes("extended_public_key_from_extended_private_key", ptr, length) + + @synchronized + def make_receiving_address(self, account_privkey: bytes, key_index: int) -> bytes: + """Derive a receiving (external) address key at the given index.""" + ptr, length = self._write_bytes(account_privkey) + return self._call_return_bytes("make_receiving_address", ptr, length, key_index) + + @synchronized + def make_change_address(self, account_privkey: bytes, key_index: int) -> bytes: + """Derive a change (internal) address key at the given index.""" + ptr, length = self._write_bytes(account_privkey) + return self._call_return_bytes("make_change_address", ptr, length, key_index) + + @synchronized + def make_receiving_address_public_key(self, account_pubkey: bytes, key_index: int) -> bytes: + """Derive the receiving address public key from an extended public key.""" + ptr, length = self._write_bytes(account_pubkey) + return self._call_return_bytes("make_receiving_address_public_key", ptr, length, key_index) + + @synchronized + def make_change_address_public_key(self, account_pubkey: bytes, key_index: int) -> bytes: + """Derive the change address public key from an extended public key.""" + ptr, length = self._write_bytes(account_pubkey) + return self._call_return_bytes("make_change_address_public_key", ptr, length, key_index) diff --git a/mintlayer/wasm/outputs.py b/mintlayer/wasm/outputs.py new file mode 100644 index 0000000..f812afd --- /dev/null +++ b/mintlayer/wasm/outputs.py @@ -0,0 +1,328 @@ +"""Transaction output encoding (mirrors go-sdk/wasm/outputs.go).""" + +from __future__ import annotations + +from ._core import _WasmCore +from ._util import synchronized +from .types import Amount, FreezableToken, Network, TotalSupply + + +class OutputsMixin(_WasmCore): + @synchronized + def encode_output_transfer(self, amount: Amount, address: str, network: Network) -> bytes: + """Create a Transfer output sending coins to an address.""" + amt_ptr = self._new_wasm_amount(amount) + addr_ptr, addr_len = self._write_string(address) + return self._call_return_bytes( + "encode_output_transfer", amt_ptr, addr_ptr, addr_len, int(network) + ) + + @synchronized + def encode_output_token_transfer( + self, amount: Amount, address: str, token_id: str, network: Network + ) -> bytes: + """Create a Transfer output sending tokens to an address.""" + amt_ptr = self._new_wasm_amount(amount) + addr_ptr, addr_len = self._write_string(address) + tid_ptr, tid_len = self._write_string(token_id) + return self._call_return_bytes( + "encode_output_token_transfer", + amt_ptr, + addr_ptr, + addr_len, + tid_ptr, + tid_len, + int(network), + ) + + @synchronized + def encode_output_lock_then_transfer( + self, amount: Amount, address: str, lock: bytes, network: Network + ) -> bytes: + """Create a LockThenTransfer output for coins. + + ``lock`` is an encoded timelock (see ``encode_lock_for_*``). + """ + amt_ptr = self._new_wasm_amount(amount) + addr_ptr, addr_len = self._write_string(address) + lock_ptr, lock_len = self._write_bytes(lock) + return self._call_return_bytes( + "encode_output_lock_then_transfer", + amt_ptr, + addr_ptr, + addr_len, + lock_ptr, + lock_len, + int(network), + ) + + @synchronized + def encode_output_token_lock_then_transfer( + self, amount: Amount, address: str, token_id: str, lock: bytes, network: Network + ) -> bytes: + """Create a LockThenTransfer output for tokens.""" + amt_ptr = self._new_wasm_amount(amount) + addr_ptr, addr_len = self._write_string(address) + tid_ptr, tid_len = self._write_string(token_id) + lock_ptr, lock_len = self._write_bytes(lock) + return self._call_return_bytes( + "encode_output_token_lock_then_transfer", + amt_ptr, + addr_ptr, + addr_len, + tid_ptr, + tid_len, + lock_ptr, + lock_len, + int(network), + ) + + @synchronized + def encode_output_coin_burn(self, amount: Amount) -> bytes: + """Create a Burn output for coins.""" + amt_ptr = self._new_wasm_amount(amount) + return self._call_return_bytes("encode_output_coin_burn", amt_ptr) + + @synchronized + def encode_output_token_burn(self, amount: Amount, token_id: str, network: Network) -> bytes: + """Create a Burn output for tokens.""" + amt_ptr = self._new_wasm_amount(amount) + tid_ptr, tid_len = self._write_string(token_id) + return self._call_return_bytes( + "encode_output_token_burn", amt_ptr, tid_ptr, tid_len, int(network) + ) + + @synchronized + def encode_output_create_delegation( + self, pool_id: str, owner_address: str, network: Network + ) -> bytes: + """Create an output that creates a staking delegation.""" + pid_ptr, pid_len = self._write_string(pool_id) + addr_ptr, addr_len = self._write_string(owner_address) + return self._call_return_bytes( + "encode_output_create_delegation", pid_ptr, pid_len, addr_ptr, addr_len, int(network) + ) + + @synchronized + def encode_output_delegate_staking( + self, amount: Amount, delegation_id: str, network: Network + ) -> bytes: + """Create an output that delegates coins to a staking pool.""" + amt_ptr = self._new_wasm_amount(amount) + did_ptr, did_len = self._write_string(delegation_id) + return self._call_return_bytes( + "encode_output_delegate_staking", amt_ptr, did_ptr, did_len, int(network) + ) + + @synchronized + def encode_output_create_stake_pool( + self, pool_id: str, pool_data: bytes, network: Network + ) -> bytes: + """Create an output that creates a staking pool. + + ``pool_data`` is encoded stake pool data (see ``encode_stake_pool_data``). + """ + pid_ptr, pid_len = self._write_string(pool_id) + pd_ptr, pd_len = self._write_bytes(pool_data) + return self._call_return_bytes( + "encode_output_create_stake_pool", pid_ptr, pid_len, pd_ptr, pd_len, int(network) + ) + + @synchronized + def encode_output_produce_block_from_stake( + self, pool_id: str, staker: str, network: Network + ) -> bytes: + """Create a ProduceBlockFromStake output. + + This UTXO is consumed when decommissioning a pool (if the pool has + staked at least once). + """ + pid_ptr, pid_len = self._write_string(pool_id) + stk_ptr, stk_len = self._write_string(staker) + return self._call_return_bytes( + "encode_output_produce_block_from_stake", + pid_ptr, + pid_len, + stk_ptr, + stk_len, + int(network), + ) + + @synchronized + def encode_output_data_deposit(self, data: bytes) -> bytes: + """Create a DataDeposit output for arbitrary on-chain data.""" + ptr, length = self._write_bytes(data) + return self._call_return_bytes("encode_output_data_deposit", ptr, length) + + @synchronized + def encode_output_htlc( + self, + amount: Amount, + token_id: str | None, + secret_hash: str, + spend_address: str, + refund_address: str, + refund_timelock: bytes, + network: Network, + ) -> bytes: + """Create a hash time-lock contract (HTLC) output for coins or tokens. + + ``token_id`` may be ``None`` for coin HTLCs. ``refund_timelock`` is an + encoded timelock. + """ + amt_ptr = self._new_wasm_amount(amount) + tid_ptr, tid_len = self._write_optional_string(token_id) + sh_ptr, sh_len = self._write_string(secret_hash) + sa_ptr, sa_len = self._write_string(spend_address) + ra_ptr, ra_len = self._write_string(refund_address) + tl_ptr, tl_len = self._write_bytes(refund_timelock) + return self._call_return_bytes( + "encode_output_htlc", + amt_ptr, + tid_ptr, + tid_len, + sh_ptr, + sh_len, + sa_ptr, + sa_len, + ra_ptr, + ra_len, + tl_ptr, + tl_len, + int(network), + ) + + @synchronized + def encode_output_issue_fungible_token( + self, + authority: str, + token_ticker: str, + metadata_uri: str, + number_of_decimals: int, + total_supply: TotalSupply, + supply_amount: Amount | None, + is_token_freezable: FreezableToken, + current_block_height: int, + network: Network, + ) -> bytes: + """Create an output that issues a new fungible token. + + ``supply_amount`` is required when ``total_supply`` is + ``TotalSupply.FIXED`` and must be ``None`` otherwise. + """ + if (total_supply == TotalSupply.FIXED) != (supply_amount is not None): + raise ValueError( + "supply_amount is required for TotalSupply.FIXED " + "and must be None otherwise " + f"(total_supply={total_supply!r}, supply_amount={supply_amount!r})" + ) + auth_ptr, auth_len = self._write_string(authority) + tkr_ptr, tkr_len = self._write_string(token_ticker) + uri_ptr, uri_len = self._write_string(metadata_uri) + sa_ptr = 0 + if supply_amount is not None: + sa_ptr = self._new_wasm_amount(supply_amount) + return self._call_return_bytes( + "encode_output_issue_fungible_token", + auth_ptr, + auth_len, + tkr_ptr, + tkr_len, + uri_ptr, + uri_len, + number_of_decimals, + int(total_supply), + sa_ptr, + int(is_token_freezable), + current_block_height, + int(network), + ) + + @synchronized + def encode_output_issue_nft( + self, + token_id: str, + authority: str, + name: str, + ticker: str, + description: str, + media_hash: bytes, + creator: bytes | None, + media_uri: str | None, + icon_uri: str | None, + additional_metadata_uri: str | None, + current_block_height: int, + network: Network, + ) -> bytes: + """Create an output that issues a new NFT. + + ``creator``, ``media_uri``, ``icon_uri`` and ``additional_metadata_uri`` + may be ``None``. + """ + tid_ptr, tid_len = self._write_string(token_id) + auth_ptr, auth_len = self._write_string(authority) + name_ptr, name_len = self._write_string(name) + tkr_ptr, tkr_len = self._write_string(ticker) + desc_ptr, desc_len = self._write_string(description) + mh_ptr, mh_len = self._write_bytes(media_hash) + cr_ptr, cr_len = self._write_optional_bytes(creator) + mu_ptr, mu_len = self._write_optional_string(media_uri) + iu_ptr, iu_len = self._write_optional_string(icon_uri) + am_ptr, am_len = self._write_optional_string(additional_metadata_uri) + return self._call_return_bytes( + "encode_output_issue_nft", + tid_ptr, + tid_len, + auth_ptr, + auth_len, + name_ptr, + name_len, + tkr_ptr, + tkr_len, + desc_ptr, + desc_len, + mh_ptr, + mh_len, + cr_ptr, + cr_len, + mu_ptr, + mu_len, + iu_ptr, + iu_len, + am_ptr, + am_len, + current_block_height, + int(network), + ) + + @synchronized + def encode_create_order_output( + self, + ask_amount: Amount, + ask_token_id: str | None, + give_amount: Amount, + give_token_id: str | None, + conclude_address: str, + network: Network, + ) -> bytes: + """Create an output that creates an order for token exchange. + + ``ask_token_id`` and ``give_token_id`` may be ``None`` for coin amounts. + """ + ask_amt_ptr = self._new_wasm_amount(ask_amount) + ask_tid_ptr, ask_tid_len = self._write_optional_string(ask_token_id) + give_amt_ptr = self._new_wasm_amount(give_amount) + give_tid_ptr, give_tid_len = self._write_optional_string(give_token_id) + ca_ptr, ca_len = self._write_string(conclude_address) + return self._call_return_bytes( + "encode_create_order_output", + ask_amt_ptr, + ask_tid_ptr, + ask_tid_len, + give_amt_ptr, + give_tid_ptr, + give_tid_len, + ca_ptr, + ca_len, + int(network), + ) diff --git a/mintlayer/wasm/signing.py b/mintlayer/wasm/signing.py new file mode 100644 index 0000000..0cfb8b0 --- /dev/null +++ b/mintlayer/wasm/signing.py @@ -0,0 +1,231 @@ +"""Witness and signature encoding (mirrors go-sdk/wasm/signing.go).""" + +from __future__ import annotations + +from ._core import _WasmCore +from ._util import synchronized +from .types import Network, SignatureHashType, TxAdditionalInfo + + +class SigningMixin(_WasmCore): + @synchronized + def encode_witness( + self, + sighash_type: SignatureHashType, + private_key: bytes, + input_owner_dest: str, + transaction: bytes, + input_utxos: bytes, + input_index: int, + additional_info: TxAdditionalInfo, + block_height: int, + network: Network, + ) -> bytes: + """Sign a transaction input and return the encoded InputWitness. + + ``private_key`` is the raw encoded private key. + ``input_owner_dest`` is the bech32m address that owns the input being signed. + ``input_utxos`` is a concatenated set of optional UTXO outputs (one per + input; prefix 0 for non-UTXO, 1+encoded-output for UTXO). + """ + pk_ptr, pk_len = self._write_bytes(private_key) + dest_ptr, dest_len = self._write_string(input_owner_dest) + tx_ptr, tx_len = self._write_bytes(transaction) + utxos_ptr, utxos_len = self._write_bytes(input_utxos) + return self._call_return_bytes( + "encode_witness", + int(sighash_type), + pk_ptr, + pk_len, + dest_ptr, + dest_len, + tx_ptr, + tx_len, + utxos_ptr, + utxos_len, + input_index, + additional_info, + block_height, + int(network), + ) + + @synchronized + def encode_witness_no_signature(self) -> bytes: + """Return an InputWitness that carries no signature (for FillOrder inputs).""" + return self._call_return_bytes_no_err("encode_witness_no_signature") + + @synchronized + def encode_witness_htlc_spend( + self, + sighash_type: SignatureHashType, + private_key: bytes, + input_owner_dest: str, + transaction: bytes, + input_utxos: bytes, + input_index: int, + secret: bytes, + additional_info: TxAdditionalInfo, + block_height: int, + network: Network, + ) -> bytes: + """Sign an HTLC input for spending (revealing the secret).""" + pk_ptr, pk_len = self._write_bytes(private_key) + dest_ptr, dest_len = self._write_string(input_owner_dest) + tx_ptr, tx_len = self._write_bytes(transaction) + utxos_ptr, utxos_len = self._write_bytes(input_utxos) + sec_ptr, sec_len = self._write_bytes(secret) + return self._call_return_bytes( + "encode_witness_htlc_spend", + int(sighash_type), + pk_ptr, + pk_len, + dest_ptr, + dest_len, + tx_ptr, + tx_len, + utxos_ptr, + utxos_len, + input_index, + sec_ptr, + sec_len, + additional_info, + block_height, + int(network), + ) + + @synchronized + def encode_witness_htlc_refund_single_sig( + self, + sighash_type: SignatureHashType, + private_key: bytes, + input_owner_dest: str, + transaction: bytes, + input_utxos: bytes, + input_index: int, + additional_info: TxAdditionalInfo, + block_height: int, + network: Network, + ) -> bytes: + """Sign an HTLC input for refunding via a single-sig address.""" + pk_ptr, pk_len = self._write_bytes(private_key) + dest_ptr, dest_len = self._write_string(input_owner_dest) + tx_ptr, tx_len = self._write_bytes(transaction) + utxos_ptr, utxos_len = self._write_bytes(input_utxos) + return self._call_return_bytes( + "encode_witness_htlc_refund_single_sig", + int(sighash_type), + pk_ptr, + pk_len, + dest_ptr, + dest_len, + tx_ptr, + tx_len, + utxos_ptr, + utxos_len, + input_index, + additional_info, + block_height, + int(network), + ) + + @synchronized + def encode_witness_htlc_refund_multisig( + self, + sighash_type: SignatureHashType, + private_key: bytes, + key_index: int, + input_witness: bytes, + multisig_challenge: bytes, + transaction: bytes, + input_utxos: bytes, + input_index: int, + additional_info: TxAdditionalInfo, + block_height: int, + network: Network, + ) -> bytes: + """Add a partial signature to an HTLC refund witness for a multisig refund address. + + ``key_index`` is the index of ``private_key`` within the multisig + challenge. ``input_witness`` may be empty (first signer) or a previous + partial result. + """ + pk_ptr, pk_len = self._write_bytes(private_key) + wit_ptr, wit_len = self._write_bytes(input_witness) + chal_ptr, chal_len = self._write_bytes(multisig_challenge) + tx_ptr, tx_len = self._write_bytes(transaction) + utxos_ptr, utxos_len = self._write_bytes(input_utxos) + return self._call_return_bytes( + "encode_witness_htlc_refund_multisig", + int(sighash_type), + pk_ptr, + pk_len, + key_index, + wit_ptr, + wit_len, + chal_ptr, + chal_len, + tx_ptr, + tx_len, + utxos_ptr, + utxos_len, + input_index, + additional_info, + block_height, + int(network), + ) + + @synchronized + def sign_challenge(self, private_key: bytes, message: bytes) -> bytes: + """Sign an arbitrary message with the given private key. + + Use ``verify_challenge`` to verify the result. + """ + pk_ptr, pk_len = self._write_bytes(private_key) + msg_ptr, msg_len = self._write_bytes(message) + return self._call_return_bytes("sign_challenge", pk_ptr, pk_len, msg_ptr, msg_len) + + @synchronized + def verify_challenge( + self, address: str, network: Network, signed_challenge: bytes, message: bytes + ) -> bool: + """Verify a challenge signature produced by ``sign_challenge``. + + ``address`` must be a pubkeyhash bech32m address. + """ + addr_ptr, addr_len = self._write_string(address) + sig_ptr, sig_len = self._write_bytes(signed_challenge) + msg_ptr, msg_len = self._write_bytes(message) + return self._call_return_bool( + "verify_challenge", + addr_ptr, + addr_len, + int(network), + sig_ptr, + sig_len, + msg_ptr, + msg_len, + ) + + @synchronized + def sign_message_for_spending(self, private_key: bytes, message: bytes) -> bytes: + """Sign a message for use as a transaction input witness. + + Use ``verify_signature_for_spending`` to verify the result. + """ + pk_ptr, pk_len = self._write_bytes(private_key) + msg_ptr, msg_len = self._write_bytes(message) + return self._call_return_bytes( + "sign_message_for_spending", pk_ptr, pk_len, msg_ptr, msg_len + ) + + @synchronized + def verify_signature_for_spending( + self, public_key: bytes, signature: bytes, message: bytes + ) -> bool: + """Verify a spending signature produced by ``sign_message_for_spending``.""" + pk_ptr, pk_len = self._write_bytes(public_key) + sig_ptr, sig_len = self._write_bytes(signature) + msg_ptr, msg_len = self._write_bytes(message) + return self._call_return_bool( + "verify_signature_for_spending", pk_ptr, pk_len, sig_ptr, sig_len, msg_ptr, msg_len + ) diff --git a/mintlayer/wasm/staking.py b/mintlayer/wasm/staking.py new file mode 100644 index 0000000..54887d0 --- /dev/null +++ b/mintlayer/wasm/staking.py @@ -0,0 +1,71 @@ +"""Staking helpers (mirrors go-sdk/wasm/staking.go).""" + +from __future__ import annotations + +from ._core import _WasmCore +from ._util import synchronized +from .types import Amount, Network + + +class StakingMixin(_WasmCore): + @synchronized + def encode_stake_pool_data( + self, + value: Amount, + staker: str, + vrf_public_key: str, + decommission_key: str, + margin_ratio_per_thousand: int, + cost_per_block: Amount, + network: Network, + ) -> bytes: + """Encode the parameters of a staking pool into binary form. + + Suitable for use in ``encode_output_create_stake_pool``. + + ``staker`` is the bech32m address allowed to produce blocks. + ``vrf_public_key`` is the bech32m-encoded VRF public key for the pool. + ``decommission_key`` is the bech32m address that can decommission the pool. + ``margin_ratio_per_thousand`` is the share of block rewards kept by the + staker (0-1000). + ``cost_per_block`` is a fixed amount subtracted from block rewards + before margin calculation. + """ + val_ptr = self._new_wasm_amount(value) + staker_ptr, staker_len = self._write_string(staker) + vrf_ptr, vrf_len = self._write_string(vrf_public_key) + decomm_ptr, decomm_len = self._write_string(decommission_key) + cpb_ptr = self._new_wasm_amount(cost_per_block) + return self._call_return_bytes( + "encode_stake_pool_data", + val_ptr, + staker_ptr, + staker_len, + vrf_ptr, + vrf_len, + decomm_ptr, + decomm_len, + margin_ratio_per_thousand, + cpb_ptr, + int(network), + ) + + @synchronized + def effective_pool_balance( + self, network: Network, pledge_amount: Amount, pool_balance: Amount + ) -> Amount: + """Compute the effective balance of a staking pool used for stake selection.""" + pledge_ptr = self._new_wasm_amount(pledge_amount) + pool_ptr = self._new_wasm_amount(pool_balance) + return self._call_return_amount_fallible( + "effective_pool_balance", int(network), pledge_ptr, pool_ptr + ) + + @synchronized + def staking_pool_spend_maturity_block_count( + self, current_block_height: int, network: Network + ) -> int: + """Blocks that must pass after a pool decommissions before funds are spendable.""" + return self._call_return_u64( + "staking_pool_spend_maturity_block_count", current_block_height, int(network) + ) diff --git a/mintlayer/wasm/timelocks.py b/mintlayer/wasm/timelocks.py new file mode 100644 index 0000000..6a6853c --- /dev/null +++ b/mintlayer/wasm/timelocks.py @@ -0,0 +1,28 @@ +"""Timelock encoding (mirrors go-sdk/wasm/timelocks.go).""" + +from __future__ import annotations + +from ._core import _WasmCore +from ._util import synchronized + + +class TimelocksMixin(_WasmCore): + @synchronized + def encode_lock_for_block_count(self, block_count: int) -> bytes: + """Encode a "lock until N more blocks have passed" timelock.""" + return self._call_return_bytes_no_err("encode_lock_for_block_count", block_count) + + @synchronized + def encode_lock_for_seconds(self, seconds: int) -> bytes: + """Encode a "lock for N more seconds" timelock.""" + return self._call_return_bytes_no_err("encode_lock_for_seconds", seconds) + + @synchronized + def encode_lock_until_height(self, block_height: int) -> bytes: + """Encode a "lock until absolute block height" timelock.""" + return self._call_return_bytes_no_err("encode_lock_until_height", block_height) + + @synchronized + def encode_lock_until_time(self, timestamp_seconds: int) -> bytes: + """Encode a "lock until absolute UNIX timestamp" timelock.""" + return self._call_return_bytes_no_err("encode_lock_until_time", timestamp_seconds) diff --git a/mintlayer/wasm/transactions.py b/mintlayer/wasm/transactions.py new file mode 100644 index 0000000..8aedc45 --- /dev/null +++ b/mintlayer/wasm/transactions.py @@ -0,0 +1,193 @@ +"""Transaction encoding and decoding (mirrors go-sdk/wasm/transactions.go).""" + +from __future__ import annotations + +from ._core import _WasmCore +from ._util import bool_to_int, synchronized +from .types import Network, SourceId, TxAdditionalInfo + + +class TransactionsMixin(_WasmCore): + @synchronized + def encode_transaction(self, inputs: bytes, outputs: bytes, flags: int) -> bytes: + """Encode an unsigned transaction from its inputs and outputs. + + ``inputs`` and ``outputs`` must be concatenated encoded bytes from the + respective ``encode_*`` functions. + """ + in_ptr, in_len = self._write_bytes(inputs) + out_ptr, out_len = self._write_bytes(outputs) + return self._call_return_bytes( + "encode_transaction", in_ptr, in_len, out_ptr, out_len, flags + ) + + @synchronized + def encode_outpoint_source_id(self, id_: bytes, source_id: SourceId) -> bytes: + """Encode a source ID (transaction hash or block reward ID) into binary form.""" + ptr, length = self._write_bytes(id_) + return self._call_return_bytes_no_err( + "encode_outpoint_source_id", ptr, length, int(source_id) + ) + + @synchronized + def get_transaction_id(self, transaction: bytes, strict_byte_size: bool) -> str: + """Return the transaction ID (hex string) for the given encoded transaction. + + Set ``strict_byte_size`` to require the bytes to represent exactly one + Transaction object. + """ + ptr, length = self._write_bytes(transaction) + return self._call_return_string( + "get_transaction_id", ptr, length, bool_to_int(strict_byte_size) + ) + + @synchronized + def estimate_transaction_size( + self, inputs: bytes, input_utxos_dests: list[str], outputs: bytes, network: Network + ) -> int: + """Estimate the encoded size of a signed transaction in bytes. + + ``input_utxos_dests`` must contain one address string per input (the + spending destination of each UTXO). + """ + in_ptr, in_len = self._write_bytes(inputs) + dests_ptr, dests_indices = self._write_string_array(input_utxos_dests) + try: + out_ptr, out_len = self._write_bytes(outputs) + except BaseException: + # The callee never ran, so the host still owns the slots. + self._dealloc_indices(dests_indices) + raise + # From here the callee owns the index array and the table slots (it + # deallocs both); no post-call cleanup is needed. + return self._call_return_u32( + "estimate_transaction_size", + in_ptr, + in_len, + dests_ptr, + len(dests_indices), + out_ptr, + out_len, + int(network), + ) + + @synchronized + def encode_signed_transaction(self, transaction: bytes, signatures: bytes) -> bytes: + """Combine an unsigned transaction with its witness signatures.""" + tx_ptr, tx_len = self._write_bytes(transaction) + sig_ptr, sig_len = self._write_bytes(signatures) + return self._call_return_bytes( + "encode_signed_transaction", tx_ptr, tx_len, sig_ptr, sig_len + ) + + @synchronized + def encode_partially_signed_transaction( + self, + transaction: bytes, + signatures: bytes, + input_utxos: bytes, + input_destinations: bytes, + htlc_secrets: bytes, + additional_info: TxAdditionalInfo, + network: Network, + ) -> bytes: + """Create a PartiallySignedTransaction object. + + ``additional_info`` provides pool/order data required for signing. + """ + tx_ptr, tx_len = self._write_bytes(transaction) + sig_ptr, sig_len = self._write_bytes(signatures) + utxos_ptr, utxos_len = self._write_bytes(input_utxos) + dests_ptr, dests_len = self._write_bytes(input_destinations) + htlc_ptr, htlc_len = self._write_bytes(htlc_secrets) + return self._call_return_bytes( + "encode_partially_signed_transaction", + tx_ptr, + tx_len, + sig_ptr, + sig_len, + utxos_ptr, + utxos_len, + dests_ptr, + dests_len, + htlc_ptr, + htlc_len, + additional_info, + int(network), + ) + + @synchronized + def decode_partially_signed_transaction_to_js( + self, transaction: bytes, network: Network + ) -> bytes: + """Decode a partially signed transaction into a JSON object (raw JSON bytes).""" + ptr, length = self._write_bytes(transaction) + return self._call_return_json( + "decode_partially_signed_transaction_to_js", ptr, length, int(network) + ) + + @synchronized + def decode_signed_transaction_to_js(self, transaction: bytes, network: Network) -> bytes: + """Decode a signed transaction into a JSON object (raw JSON bytes).""" + ptr, length = self._write_bytes(transaction) + return self._call_return_json("decode_signed_transaction_to_js", ptr, length, int(network)) + + @synchronized + def extract_htlc_secret( + self, + signed_tx: bytes, + strict_byte_size: bool, + htlc_outpoint_source_id: bytes, + htlc_output_index: int, + ) -> bytes: + """Extract the pre-image secret from a signed HTLC-spend transaction.""" + tx_ptr, tx_len = self._write_bytes(signed_tx) + src_ptr, src_len = self._write_bytes(htlc_outpoint_source_id) + return self._call_return_bytes( + "extract_htlc_secret", + tx_ptr, + tx_len, + bool_to_int(strict_byte_size), + src_ptr, + src_len, + htlc_output_index, + ) + + @synchronized + def internal_verify_witness( + self, + sighash_type: int, + input_owner_dest: str | None, + witness: bytes, + transaction: bytes, + input_utxos: bytes, + input_index: int, + additional_info: TxAdditionalInfo, + block_height: int, + network: Network, + ) -> None: + """Verify an input witness against the transaction. + + ``input_owner_dest`` may be ``None`` for inputs where the destination is + not required. + """ + dest_ptr, dest_len = self._write_optional_string(input_owner_dest) + wit_ptr, wit_len = self._write_bytes(witness) + tx_ptr, tx_len = self._write_bytes(transaction) + utxos_ptr, utxos_len = self._write_bytes(input_utxos) + self._call_void_fallible( + "internal_verify_witness", + int(sighash_type), + dest_ptr, + dest_len, + wit_ptr, + wit_len, + tx_ptr, + tx_len, + utxos_ptr, + utxos_len, + input_index, + additional_info, + block_height, + int(network), + ) diff --git a/mintlayer/wasm/types.py b/mintlayer/wasm/types.py new file mode 100644 index 0000000..6b927fb --- /dev/null +++ b/mintlayer/wasm/types.py @@ -0,0 +1,199 @@ +"""Types shared across the WASM cryptography client. + +Mirrors go-sdk/wasm/types.go: enums are passed to WASM as their integer +discriminant, and JSON shapes match the Rust serde structures exactly. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field + + +class WasmError(Exception): + """Error raised by WASM operations. Messages are prefixed with ``mintlayer: ``.""" + + +class Network(enum.IntEnum): + """Mintlayer blockchain network.""" + + MAINNET = 0 + TESTNET = 1 + REGTEST = 2 + SIGNET = 3 + + +class SignatureHashType(enum.IntEnum): + """Controls which parts of a transaction are covered by a signature.""" + + SIGHASH_ALL = 0 + SIGHASH_NONE = 1 + SIGHASH_SINGLE = 2 + SIGHASH_ANYONECANPAY = 3 + + +class SourceId(enum.IntEnum): + """Whether a UTXO comes from a transaction output or a block reward.""" + + SOURCE_TRANSACTION = 0 + SOURCE_BLOCK_REWARD = 1 + + +class TotalSupply(enum.IntEnum): + """Supply policy of a fungible token.""" + + LOCKABLE = 0 + UNLIMITED = 1 + FIXED = 2 + + +class FreezableToken(enum.IntEnum): + """Whether a token can be frozen after issuance.""" + + NO = 0 + YES = 1 + + +class TokenUnfreezable(enum.IntEnum): + """Whether a frozen token can later be unfrozen.""" + + NO = 0 + YES = 1 + + +class CurrencyAmountKind(enum.IntEnum): + """Selects the coins/tokens variant of a SimpleCurrencyAmount.""" + + COINS = 0 + TOKENS = 1 + + +@dataclass(frozen=True) +class Amount: + """A coin or token quantity as a decimal atom count. + + Atoms are the smallest indivisible unit; 1 ML = 100000000000 (1e11) atoms. + """ + + atoms: str + + @classmethod + def from_atoms(cls, atoms: str) -> Amount: + """Create an Amount from a decimal atom string (e.g. ``"100000000000"``).""" + return cls(atoms=atoms) + + @classmethod + def zero(cls) -> Amount: + """An Amount representing zero atoms.""" + return cls(atoms="0") + + def to_json_value(self) -> dict: + return {"atoms": self.atoms} + + def __str__(self) -> str: + return self.atoms + + +@dataclass(frozen=True) +class SimpleCurrencyAmount: + """Ask/give balances inside TxAdditionalInfo. + + Serialised as the externally tagged CurrencyAmount enum: + + * ``{"coins":{"atoms":""}}`` + * ``{"tokens":{"amount":{"atoms":""},"token_id":""}}`` + """ + + atoms: str + kind: CurrencyAmountKind = CurrencyAmountKind.COINS + token_id: str | None = None + + def __post_init__(self) -> None: + if self.kind == CurrencyAmountKind.TOKENS and not self.token_id: + raise ValueError("token_id is required for TOKENS amounts") + if self.kind == CurrencyAmountKind.COINS and self.token_id is not None: + raise ValueError("token_id must be None for COINS amounts") + + @classmethod + def coins(cls, atoms: str) -> SimpleCurrencyAmount: + return cls(atoms=atoms, kind=CurrencyAmountKind.COINS, token_id=None) + + @classmethod + def tokens(cls, atoms: str, token_id: str) -> SimpleCurrencyAmount: + return cls(atoms=atoms, kind=CurrencyAmountKind.TOKENS, token_id=token_id) + + def to_json_value(self) -> dict: + if self.kind == CurrencyAmountKind.TOKENS: + return { + "tokens": { + "amount": {"atoms": self.atoms}, + "token_id": self.token_id, + } + } + return {"coins": {"atoms": self.atoms}} + + +@dataclass(frozen=True) +class OrderBalance: + """One side of a DEX order's remaining balance. + + Serialised with the redundant-but-required shape + ``{"atoms":"...","amount":{"atoms":"..."},"token_id":null|"..."}``. + """ + + atoms: str + token_id: str | None = None + + def to_json_value(self) -> dict: + return { + "atoms": self.atoms, + "amount": {"atoms": self.atoms}, + "token_id": self.token_id, + } + + +@dataclass(frozen=True) +class PoolInfo: + """Pool-related data required for signing pool UTXOs.""" + + staker_balance: Amount + + def to_json_value(self) -> dict: + return {"staker_balance": self.staker_balance.to_json_value()} + + +@dataclass(frozen=True) +class OrderInfo: + """DEX order data required for signing order UTXOs.""" + + initially_asked: SimpleCurrencyAmount + initially_given: SimpleCurrencyAmount + ask_balance: OrderBalance + give_balance: OrderBalance + + def to_json_value(self) -> dict: + return { + "initially_asked": self.initially_asked.to_json_value(), + "initially_given": self.initially_given.to_json_value(), + "ask_balance": self.ask_balance.to_json_value(), + "give_balance": self.give_balance.to_json_value(), + } + + +@dataclass +class TxAdditionalInfo: + """Out-of-band pool/order state needed when signing pool or order UTXOs. + + Maps are keyed by the bech32m pool/order ID. An empty dict serialises as an + empty JSON object (no pool/order data); the WASM module rejects ``null`` + maps, so defaults are empty dicts rather than ``None``. + """ + + pool_info: dict[str, PoolInfo] = field(default_factory=dict) + order_info: dict[str, OrderInfo] = field(default_factory=dict) + + def to_json_value(self) -> dict: + return { + "pool_info": {k: v.to_json_value() for k, v in self.pool_info.items()}, + "order_info": {k: v.to_json_value() for k, v in self.order_info.items()}, + } diff --git a/mintlayer/wasm/wasm_wrappers_bg.wasm b/mintlayer/wasm/wasm_wrappers_bg.wasm new file mode 100644 index 0000000..c24e04f Binary files /dev/null and b/mintlayer/wasm/wasm_wrappers_bg.wasm differ diff --git a/mintlayer/wasm/wasm_wrappers_bg.wasm.sha256 b/mintlayer/wasm/wasm_wrappers_bg.wasm.sha256 new file mode 100644 index 0000000..a2954a6 --- /dev/null +++ b/mintlayer/wasm/wasm_wrappers_bg.wasm.sha256 @@ -0,0 +1 @@ +0c5411e345b7969cf867da54cd20374b65bb249b9253fc5c028123ec207bdcd8 wasm_wrappers_bg.wasm diff --git a/pyproject.toml b/pyproject.toml index e697f57..cd14b20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.26"] build-backend = "hatchling.build" [project] @@ -8,18 +8,22 @@ version = "0.1.0" description = "Python SDK for the Mintlayer blockchain: node, indexer, and wallet RPC clients plus WASM cryptography and transaction building" readme = "README.md" requires-python = ">=3.10" -license = { file = "LICENSE" } +license = "MIT" +license-files = ["LICENSE"] authors = [{ name = "Mintlayer Institutional FZCO", email = "hello@mintlayer.org" }] keywords = ["mintlayer", "blockchain", "sdk", "wallet", "crypto"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries", ] dependencies = [ - "requests>=2.31", + "requests>=2.32.4", "wasmtime>=25.0", ] @@ -28,6 +32,7 @@ dev = [ "pytest>=8.0", "ruff>=0.6", "mypy>=1.11", + "pytest-cov>=7.1.0", # keep floor in sync with pytest>=8.0 ] [tool.hatch.build.targets.wheel] @@ -45,3 +50,20 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM"] [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.mypy] +python_version = "3.10" +warn_unused_ignores = false +check_untyped_defs = true + +[[tool.mypy.overrides]] +module = "wasmtime.*" +ignore_missing_imports = true + +[tool.coverage.run] +source = ["mintlayer"] +omit = ["mintlayer/wasm/wasm_wrappers_bg.wasm"] + +[tool.coverage.report] +show_missing = true +precision = 1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e4e1f24 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,406 @@ +"""In-process JSON-RPC test servers for the Mintlayer node client tests. + +Mirrors the httptest helpers in go-sdk/node/client_test.go, using the stdlib +``http.server`` + threading instead of httptest: + +* :func:`make_rpc_server` -- answers every JSON-RPC POST with + ``{"jsonrpc": "2.0", "id": , "result": }``. + ``result=None`` produces a JSON ``null`` result (void / not-found methods). +* :func:`make_rpc_error_server` -- answers with a JSON-RPC error object. +* :func:`make_raw_rpc_server` -- embeds raw JSON *text* verbatim as the + result, pinning exact wire shapes (mirrors Go's ``json.RawMessage``). +* :class:`Capture` -- records the last request (method name, params, headers, + path, raw body, request count) plus every echoed request id, so tests can + pin exact wire shapes. +* :func:`make_rest_server` -- plain REST server for the indexer client: + answers GET *and* POST with a canned JSON payload (or verbatim raw text), + any HTTP status, and records the HTTP verb, path, query string, headers, + and raw body on a :class:`RESTCapture`. + +Prefer the :func:`rpc_server` fixture: it is a factory that starts servers +(threading daemons) and guarantees shutdown in teardown. The indexer REST +tests use the :func:`rest_server` factory fixture, which works the same way. +""" + +from __future__ import annotations + +import contextlib +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +import pytest + +_HOST = "127.0.0.1" + + +class Capture: + """Thread-safe recorder for the requests hitting a test server.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + # Last request seen. + self.method: str | None = None + self.params: Any = None + self.headers: dict[str, str] = {} + self.path: str | None = None + self.raw_body: bytes | None = None + # All requests seen. + self.request_count = 0 + self.request_ids: list[Any] = [] + self.payloads: list[dict[str, Any]] = [] + self.protocol_errors: list[str] = [] + + def record( + self, + *, + method: str | None, + params: Any, + headers: dict[str, str], + path: str | None, + raw_body: bytes, + request_id: Any, + payload: dict[str, Any], + ) -> None: + with self._lock: + self.method = method + self.params = params + self.headers = dict(headers) + self.path = path + self.raw_body = raw_body + self.request_count += 1 + self.request_ids.append(request_id) + self.payloads.append(payload) + + def record_protocol_error(self, message: str) -> None: + with self._lock: + self.protocol_errors.append(message) + + +def _result_responder(result: Any): + def respond(payload: dict[str, Any]) -> tuple[int, Any]: + return 200, {"jsonrpc": "2.0", "id": payload.get("id"), "result": result} + + return respond + + +def _error_responder(code: int, message: str): + def respond(payload: dict[str, Any]) -> tuple[int, Any]: + return 200, { + "jsonrpc": "2.0", + "id": payload.get("id"), + "error": {"code": code, "message": message}, + } + + return respond + + +def _raw_responder(raw_result: str): + def respond(payload: dict[str, Any]) -> tuple[int, Any]: + body = f'{{"jsonrpc":"2.0","id":{json.dumps(payload.get("id"))},"result":{raw_result}}}' + return 200, body.encode("utf-8") + + return respond + + +class ServerHandle: + """A running in-process JSON-RPC server plus its request capture.""" + + def __init__( + self, + httpd: ThreadingHTTPServer, + capture: Capture, + thread: threading.Thread, + ) -> None: + self.httpd = httpd + self.capture = capture + self._thread = thread + + @property + def url(self) -> str: + host, port = self.httpd.server_address[:2] + return f"http://{host}:{port}" + + def stop(self) -> None: + self.httpd.shutdown() + self.httpd.server_close() + self._thread.join(timeout=5) + + +def _make_handler(capture: Capture, responder): + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length") or 0) + raw_body = self.rfile.read(length) + try: + payload = json.loads(raw_body.decode("utf-8")) + except ValueError: + capture.record_protocol_error("request body is not valid JSON") + self._respond(400, b"bad request") + return + if payload.get("jsonrpc") != "2.0": + capture.record_protocol_error( + f"expected jsonrpc 2.0, got {payload.get('jsonrpc')!r}" + ) + self._respond(400, b"bad request") + return + capture.record( + method=payload.get("method"), + params=payload.get("params"), + headers={str(k).lower(): str(v) for k, v in self.headers.items()}, + path=self.path, + raw_body=raw_body, + request_id=payload.get("id"), + payload=payload, + ) + status, body = responder(payload) + encoded = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8") + self._respond(status, encoded) + + def _respond(self, status: int, body: bytes) -> None: + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: + pass # keep test output clean + + return Handler + + +def _start_server(capture: Capture, responder) -> ServerHandle: + httpd = ThreadingHTTPServer((_HOST, 0), _make_handler(capture, responder)) + thread = threading.Thread( + target=httpd.serve_forever, + daemon=True, + name="json-rpc-test-server", + ) + thread.start() + return ServerHandle(httpd=httpd, capture=capture, thread=thread) + + +def make_rpc_server(result: Any = None) -> ServerHandle: + """Start a server answering every JSON-RPC POST with ``result``. + + ``result=None`` produces a JSON ``null`` result (void / not-found methods). + Prefer the :func:`rpc_server` fixture so servers are always stopped. + """ + return _start_server(Capture(), _result_responder(result)) + + +def make_rpc_error_server(code: int, message: str) -> ServerHandle: + """Start a server answering every JSON-RPC POST with an error object.""" + return _start_server(Capture(), _error_responder(code, message)) + + +def make_raw_rpc_server(raw_result: str) -> ServerHandle: + """Start a server embedding ``raw_result`` verbatim as the JSON-RPC result.""" + return _start_server(Capture(), _raw_responder(raw_result)) + + +@pytest.fixture +def rpc_server(): + """Factory fixture that starts/stops in-process JSON-RPC test servers. + + Usage:: + + srv = rpc_server(result={"atoms": "1"}) + client = Client(srv.url) # srv.url is the base URL + ... + srv.capture.method # last request's method name + + Keyword alternatives: ``error=(code, message)`` for a JSON-RPC error + server and ``raw=""`` to pin an exact wire shape. Every server + started through the factory is shut down in teardown. + """ + handles: list[ServerHandle] = [] + + def _start( + *, + result: Any = None, + error: tuple[int, str] | None = None, + raw: str | None = None, + ) -> ServerHandle: + if error is not None: + handle = make_rpc_error_server(*error) + elif raw is not None: + handle = make_raw_rpc_server(raw) + else: + handle = make_rpc_server(result) + handles.append(handle) + return handle + + yield _start + + for handle in handles: + # One failing shutdown must not abort the loop and leak the + # remaining servers' sockets/threads. + with contextlib.suppress(Exception): + handle.stop() + + +# --- REST servers (indexer client) ------------------------------------------- + + +class RESTCapture(Capture): + """Capture for the REST servers. + + The inherited ``method`` field holds the *HTTP verb* (``"GET"`` / + ``"POST"``), ``path`` the path *without* the query string, and ``query`` + the raw query string (``""`` when absent). ``raw_body`` carries the + verbatim request body (empty for GET). + """ + + def __init__(self) -> None: + super().__init__() + self.query: str | None = None + self.verbs: list[str] = [] + self.paths: list[str] = [] + self.queries: list[str] = [] + + def record_rest( + self, + *, + verb: str, + path: str, + query: str, + headers: dict[str, str], + raw_body: bytes, + ) -> None: + with self._lock: + self.method = verb + self.params = None + self.headers = dict(headers) + self.path = path + self.query = query + self.raw_body = raw_body + self.request_count += 1 + self.request_ids.append(None) + self.payloads.append({}) + self.verbs.append(verb) + self.paths.append(path) + self.queries.append(query) + + +def _make_rest_handler(capture: RESTCapture, body: bytes, status: int, content_type: str): + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _handle(self) -> None: + length = int(self.headers.get("Content-Length") or 0) + raw_body = self.rfile.read(length) if length else b"" + path, _, query = self.path.partition("?") + capture.record_rest( + verb=self.command, + path=path, + query=query, + headers={str(k).lower(): str(v) for k, v in self.headers.items()}, + raw_body=raw_body, + ) + self._respond(status, body) + + do_GET = _handle + do_POST = _handle + + def _respond(self, status: int, body: bytes) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: + pass # keep test output clean + + return Handler + + +def make_rest_server( + payload: Any = None, + *, + raw: str | bytes | None = None, + status: int = 200, + content_type: str = "application/json", +) -> ServerHandle: + """Start a REST server answering every GET/POST identically. + + ``payload`` is JSON-encoded; ``raw`` (string or bytes) is served verbatim + instead, pinning exact wire shapes. ``status`` may be any code, e.g. 404 + or 500, to exercise the error paths. Prefer the :func:`rest_server` + fixture so servers are always stopped. + """ + if raw is not None: + body = raw if isinstance(raw, bytes) else str(raw).encode("utf-8") + else: + body = json.dumps(payload).encode("utf-8") + capture = RESTCapture() + httpd = ThreadingHTTPServer((_HOST, 0), _make_rest_handler(capture, body, status, content_type)) + thread = threading.Thread( + target=httpd.serve_forever, + daemon=True, + name="rest-test-server", + ) + thread.start() + return ServerHandle(httpd=httpd, capture=capture, thread=thread) + + +@pytest.fixture +def rest_server(): + """Factory fixture that starts/stops in-process REST test servers. + + Usage:: + + srv = rest_server(payload={"block_height": 1}) + client = indexer.Client(srv.url) + client.get_tip() + assert srv.capture.path == "/api/v2/chain/tip" + + Keyword alternatives: ``raw=""`` to serve a verbatim body, ``status`` + for arbitrary HTTP status codes (404/500), and ``content_type`` to pin the + response Content-Type. Every server started through the factory is shut + down in teardown. + """ + handles: list[ServerHandle] = [] + + def _start( + payload: Any = None, + *, + raw: str | bytes | None = None, + status: int = 200, + content_type: str = "application/json", + ) -> ServerHandle: + handle = make_rest_server(payload, raw=raw, status=status, content_type=content_type) + handles.append(handle) + return handle + + yield _start + + for handle in handles: + # One failing shutdown must not abort the loop and leak the + # remaining servers' sockets/threads. + with contextlib.suppress(Exception): + handle.stop() + + +# --- WASM client (fully offline) ---------------------------------------------- + + +@pytest.fixture(scope="session") +def wasm(): + """A session-wide offline WASM :class:`~mintlayer.wasm.Client`. + + Instantiating the client compiles the embedded WASM module, which is + relatively expensive, so one instance is shared across the whole session. + Every public method serialises on the client's own lock. Tests that need + to close a client (lifecycle tests) must instantiate their own. + """ + from mintlayer.wasm import Client as WasmClient + + client = WasmClient() + yield client + client.close() diff --git a/tests/test_chainstate.py b/tests/test_chainstate.py new file mode 100644 index 0000000..2a41916 --- /dev/null +++ b/tests/test_chainstate.py @@ -0,0 +1,284 @@ +"""Tests for the chainstate module methods. + +Mirrors the "chainstate module" section of go-sdk/node/client_test.go, plus +the order-info tests covering the deliberate Go-bug fix (``nonce: null``). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from mintlayer.node import ( + Amount, + ChainstateInfo, + Client, + Currency, + JSONRPCError, + OrderInfo, + Outpoint, + OutpointSourceID, + Timestamp, + tx_source_content, +) + + +def test_chainstate_info(rpc_server) -> None: + result = { + "best_block_height": 123456, + "best_block_id": "aabbccdd", + "best_block_timestamp": {"timestamp": 1700000000}, + "median_time": {"timestamp": 1699999500}, + "is_initial_block_download": False, + } + srv = rpc_server(result=result) + client = Client(srv.url) + info = client.chainstate_info() + assert isinstance(info, ChainstateInfo) + assert info.best_block_height == 123456 + assert info.best_block_id == "aabbccdd" + assert info.best_block_timestamp == Timestamp(timestamp=1700000000) + assert info.median_time == Timestamp(timestamp=1699999500) + assert info.is_initial_block_download is False + assert srv.capture.method == "chainstate_info" + client.close() + + +def test_best_block_id(rpc_server) -> None: + srv = rpc_server(result="deadbeef01020304") + client = Client(srv.url) + assert client.best_block_id() == "deadbeef01020304" + assert srv.capture.method == "chainstate_best_block_id" + client.close() + + +def test_best_block_id_null_result_raises(rpc_server) -> None: + """A JSON null result for a non-optional str method raises JSONRPCError. + + The null guard must raise a type error mentioning "expected string result" + rather than silently returning the string "None". + """ + srv = rpc_server(result=None) + client = Client(srv.url) + with pytest.raises(JSONRPCError, match="expected string result"): + client.best_block_id() + client.close() + + +def test_best_block_height(rpc_server) -> None: + srv = rpc_server(result=999) + client = Client(srv.url) + assert client.best_block_height() == 999 + assert srv.capture.method == "chainstate_best_block_height" + client.close() + + +def test_block_id_at_height_null(rpc_server) -> None: + """A JSON null result maps to None (unknown height).""" + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.block_id_at_height(9999999) is None + assert srv.capture.method == "chainstate_block_id_at_height" + assert srv.capture.params == {"height": 9999999} + client.close() + + +def test_block_id_at_height_non_string_result_raises(rpc_server) -> None: + """A non-string, non-null result raises instead of being str()-coerced. + + Coercing a JSON number/dict to str would return garbage silently; the + optional-string decoder must surface it as the JSONRPCError contract. + """ + srv = rpc_server(result=123456) + client = Client(srv.url) + with pytest.raises(JSONRPCError, match="chainstate_block_id_at_height: expected string result"): + client.block_id_at_height(7) + client.close() + + +def test_stake_pool_balance(rpc_server) -> None: + srv = rpc_server(result={"atoms": "100000000000"}) + client = Client(srv.url) + balance = client.stake_pool_balance("mpool1abc") + assert balance == Amount(atoms="100000000000") + assert balance.atoms == "100000000000" + assert srv.capture.method == "chainstate_stake_pool_balance" + assert srv.capture.params == {"pool_address": "mpool1abc"} + client.close() + + +def test_stake_pool_balance_not_found(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.stake_pool_balance("mpool1missing") is None + client.close() + + +def test_get_utxo_serializes_outpoint(rpc_server) -> None: + """The Outpoint serialises to the daemon's tagged-union wire shape.""" + tx_id = "beefcafe01" + outpoint = Outpoint( + source_id=OutpointSourceID(type="Transaction", content=tx_source_content(tx_id)), + index=0, + ) + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.get_utxo(outpoint) is None # not found -> None + assert srv.capture.method == "chainstate_get_utxo" + assert srv.capture.params == { + "outpoint": { + "source_id": {"type": "Transaction", "content": {"tx_id": tx_id}}, + "index": 0, + } + } + client.close() + + +def _order_payload(nonce: Any) -> dict[str, Any]: + return { + "conclude_key": "tql1conclude", + "initially_asked": {"Coin": {"amount": {"atoms": "100"}}}, + "initially_given": {"Token": {"token_id": "tok1", "amount": {"atoms": "200"}}}, + "ask_balance": {"atoms": "900"}, + "give_balance": {"atoms": "700"}, + "nonce": nonce, + "is_frozen": False, + } + + +def test_order_info_nonce_null(rpc_server) -> None: + """The daemon sends ``nonce: null`` for active orders -> None (Go SDK bug fix).""" + srv = rpc_server(result=_order_payload(nonce=None)) + client = Client(srv.url) + info = client.order_info("ord1xyz") + assert info is not None + assert info.nonce is None + assert info.conclude_key == "tql1conclude" + assert info.ask_balance == Amount(atoms="900") + assert info.give_balance == Amount(atoms="700") + assert info.is_frozen is False + # Tagged-union payloads pass through as raw decoded JSON. + assert info.initially_asked == {"Coin": {"amount": {"atoms": "100"}}} + assert info.initially_given == {"Token": {"token_id": "tok1", "amount": {"atoms": "200"}}} + client.close() + + +def test_order_info_nonce_present(rpc_server) -> None: + srv = rpc_server(result=_order_payload(nonce=7)) + client = Client(srv.url) + info = client.order_info("ord1xyz") + assert isinstance(info, OrderInfo) + assert info.nonce == 7 + client.close() + + +def test_order_info_not_found(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.order_info("ord1missing") is None + client.close() + + +def test_orders_info_by_currencies_none_filters(rpc_server) -> None: + """Both filter keys are always sent, as JSON null, when filters are None.""" + srv = rpc_server(result={}) + client = Client(srv.url) + assert client.orders_info_by_currencies(None, None) == {} + assert srv.capture.method == "chainstate_orders_info_by_currencies" + assert srv.capture.params == {"ask_currency": None, "give_currency": None} + client.close() + + +def test_orders_info_by_currencies_with_filters(rpc_server) -> None: + srv = rpc_server(result={"ord1": _order_payload(nonce=None)}) + client = Client(srv.url) + orders = client.orders_info_by_currencies(Currency.coin(), Currency.token("tok1")) + assert set(orders) == {"ord1"} + assert isinstance(orders["ord1"], OrderInfo) + assert orders["ord1"].nonce is None + # Coin omits content; Token carries the token id. + assert srv.capture.params == { + "ask_currency": {"type": "Coin"}, + "give_currency": {"type": "Token", "content": "tok1"}, + } + client.close() + + +def test_amount_from_json_valid_string() -> None: + """A decimal atom string is the only accepted wire shape.""" + assert Amount.from_json({"atoms": "100"}) == Amount(atoms="100") + + +# ── strict integer decoding (node wire contract) ───────────────────────────── + + +@pytest.mark.parametrize( + "bad", + [ + pytest.param(1.9, id="float"), + pytest.param("123", id="numeric-string"), + pytest.param(True, id="bool"), + ], +) +def test_timestamp_from_json_rejects_non_int(bad: object) -> None: + """Timestamp seconds must be a JSON integer, not float/str/bool.""" + with pytest.raises(ValueError, match="invalid timestamp"): + Timestamp.from_json({"timestamp": bad}) # type: ignore[dict-item] + + +def test_chainstate_info_best_block_height_rejects_non_int(rpc_server) -> None: + """A float ``best_block_height`` is rejected instead of truncated.""" + payload = { + "best_block_height": 100.5, + "best_block_id": "aabbccdd", + "best_block_timestamp": {"timestamp": 1700000000}, + "median_time": {"timestamp": 1699999500}, + "is_initial_block_download": False, + } + srv = rpc_server(result=payload) + client = Client(srv.url) + with pytest.raises(JSONRPCError, match="invalid best_block_height"): + client.chainstate_info() + client.close() + + +def test_chainstate_info_valid_int_payload_still_decodes(rpc_server) -> None: + """Real integer payloads keep decoding unchanged (no over-tightening).""" + payload = { + "best_block_height": 123456, + "best_block_id": "aabbccdd", + "best_block_timestamp": {"timestamp": 1700000000}, + "median_time": {"timestamp": 1699999500}, + "is_initial_block_download": False, + } + srv = rpc_server(result=payload) + client = Client(srv.url) + info = client.chainstate_info() + assert info.best_block_height == 123456 + assert info.best_block_timestamp == Timestamp(timestamp=1700000000) + client.close() + + +def test_timestamp_from_json_valid_int() -> None: + """A genuine JSON int still decodes (bool is excluded by isinstance checks).""" + assert Timestamp.from_json({"timestamp": 1700000000}) == Timestamp(timestamp=1700000000) + assert Timestamp.from_json({"timestamp": 0}) == Timestamp(timestamp=0) + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param({"atoms": 100}, id="atoms-int"), + pytest.param({"atoms": None}, id="atoms-null"), + pytest.param("notadict", id="non-dict-payload"), + ], +) +def test_amount_from_json_invalid_raises(payload: object) -> None: + """A JSON number (or any non-string atoms / non-dict payload) is rejected. + + Amounts are decimal atom strings on the wire; accepting a JSON number + would silently corrupt round-trips. + """ + with pytest.raises(ValueError, match="invalid amount payload"): + Amount.from_json(payload) # type: ignore[arg-type] diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..3702b14 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,186 @@ +"""Tests for the top-level SDK client (mintlayer.client). + +Mirrors the construction/config sections of go-sdk/client.go usage: which +sub-clients get built from a Config, basic-auth wiring, the lazy WASM +runtime, and the package-level convenience re-exports. +""" + +from __future__ import annotations + +import base64 +import importlib + +import pytest + +import mintlayer +from mintlayer.client import Client, Config +from mintlayer.indexer import Client as IndexerClient +from mintlayer.node import Client as NodeClient +from mintlayer.wallet import BestBlock +from mintlayer.wallet import Client as WalletClient +from mintlayer.wasm import Client as WasmClient +from mintlayer.wasm import WasmError + +_BLOCK_RESULT = {"height": 100, "id": "aabbccdd"} + + +class TestConfigConstruction: + def test_node_only_config(self) -> None: + client = Client(Config(node_url="http://127.0.0.1:3030")) + assert client.node is not None + assert client.indexer is None + assert client.wallet is None + client.close() + + def test_full_config_constructs_sub_clients(self) -> None: + cfg = Config( + node_url="http://127.0.0.1:3030", + indexer_url="http://127.0.0.1:3000", + wallet_url="http://127.0.0.1:3034", + username="alice", + password="secret", + timeout=5.0, + ) + client = Client(cfg) + assert isinstance(client.node, NodeClient) + assert isinstance(client.indexer, IndexerClient) + assert isinstance(client.wallet, WalletClient) + # Basic-auth credentials flow into the JSON-RPC sub-clients. + assert client.node._rpc.endpoint == "http://127.0.0.1:3030" + assert client.node._rpc.username == "alice" + assert client.node._rpc.password == "secret" + assert client.node._rpc.timeout == 5.0 + assert client.wallet._rpc.endpoint == "http://127.0.0.1:3034" + assert client.wallet._rpc.username == "alice" + assert client.wallet._rpc.password == "secret" + # The indexer is plain REST (IndexerHTTP base): /api/v2 prefix + timeout. + assert client.indexer.api_base == "http://127.0.0.1:3000/api/v2" + assert client.indexer.timeout == 5.0 + client.close() + + def test_empty_config_constructs_nothing(self) -> None: + client = Client(Config()) + assert client.node is None + assert client.indexer is None + assert client.wallet is None + client.close() + + +def test_basic_auth_reaches_the_wire(rpc_server) -> None: + """A wallet call through the top-level client sends the Basic auth header.""" + srv = rpc_server(result=_BLOCK_RESULT) + client = Client( + Config(node_url=srv.url, wallet_url=srv.url, username="alice", password="secret") + ) + assert client.wallet.best_block() == BestBlock(height=100, id="aabbccdd") + expected = "Basic " + base64.b64encode(b"alice:secret").decode("ascii") + assert srv.capture.headers.get("authorization") == expected + client.close() + + +class TestWasmLifecycle: + def test_wasm_raises_before_init(self) -> None: + client = Client(Config(node_url="http://127.0.0.1:3030")) + with pytest.raises(WasmError, match="init_wasm"): + _ = client.wasm + client.close() + + def test_init_wasm_creates_client_and_close_releases_it(self) -> None: + """init_wasm() builds a real WasmClient; close() resets the property.""" + client = Client(Config(node_url="http://127.0.0.1:3030")) + client.init_wasm() + wasm = client.wasm + assert isinstance(wasm, WasmClient) + # Subsequent init_wasm calls are no-ops (same instance). + client.init_wasm() + assert client.wasm is wasm + client.close() + assert client._wasm is None + with pytest.raises(WasmError, match="init_wasm"): + _ = client.wasm + + +class TestConfigRedaction: + def test_repr_redacts_password(self) -> None: + got = repr( + Config( + node_url="http://127.0.0.1:3030", + indexer_url="http://127.0.0.1:3000", + wallet_url="http://127.0.0.1:3034", + username="alice", + password="hunter2", + timeout=5.0, + ) + ) + assert "password='***'" in got + assert "hunter2" not in got + # The non-secret fields stay visible for debugging. + assert "node_url='http://127.0.0.1:3030'" in got + assert "indexer_url='http://127.0.0.1:3000'" in got + assert "wallet_url='http://127.0.0.1:3034'" in got + assert "username='alice'" in got + assert "timeout=5.0" in got + + def test_password_field_has_repr_disabled(self) -> None: + """The dataclass field itself is marked repr=False (belt to the braces).""" + assert Config.__dataclass_fields__["password"].repr is False + + +class TestCloseClosesSubClients: + def test_close_closes_each_sub_clients_session( + self, rpc_server, rest_server, monkeypatch + ) -> None: + """Top-level close() must reach the HTTP session each sub-client owns.""" + node_srv = rpc_server(result=_BLOCK_RESULT) + indexer_srv = rest_server(payload={}) + wallet_srv = rpc_server(result=None) + client = Client( + Config( + node_url=node_srv.url, + indexer_url=indexer_srv.url, + wallet_url=wallet_srv.url, + ) + ) + closed: list[str] = [] + monkeypatch.setattr(client.node._rpc._session, "close", lambda: closed.append("node")) + monkeypatch.setattr(client.indexer._session, "close", lambda: closed.append("indexer")) + monkeypatch.setattr(client.wallet._rpc._session, "close", lambda: closed.append("wallet")) + client.close() + # Node, indexer and wallet are all released, in construction order. + assert closed == ["node", "indexer", "wallet"] + + def test_close_is_idempotent_with_sub_clients(self, rpc_server) -> None: + """A second close() with real sub-clients must not raise.""" + client = Client(Config(node_url=rpc_server(result=_BLOCK_RESULT).url)) + client.close() + client.close() + + +class TestContextManager: + def test_with_block_and_idempotent_close(self) -> None: + with mintlayer.Client(mintlayer.Config(node_url="http://127.0.0.1:3030")) as c: + assert isinstance(c, Client) + assert c.node is not None + # close() is idempotent -- no WASM runtime, nothing to release. + c.close() + c.close() + + +class TestPackageReExports: + def test_amount_reexport(self) -> None: + assert mintlayer.Amount.from_atoms("5").atoms == "5" + + def test_network_constants(self) -> None: + assert mintlayer.MAINNET == mintlayer.Network.MAINNET + assert mintlayer.TESTNET == mintlayer.Network.TESTNET + + def test_sighash_and_source_reexports(self) -> None: + assert mintlayer.SIGHASH_ALL == mintlayer.wasm.SignatureHashType.SIGHASH_ALL + assert mintlayer.SOURCE_TRANSACTION == mintlayer.wasm.SourceId.SOURCE_TRANSACTION + + def test_wasm_error_reexport(self) -> None: + assert mintlayer.WasmError is WasmError + + @pytest.mark.parametrize("module_name", ["node", "indexer", "wallet", "wasm"]) + def test_submodules_importable(self, module_name: str) -> None: + assert importlib.import_module(f"mintlayer.{module_name}") is not None diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 0000000..95e1bec --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,40 @@ +"""Concurrent-use test: one client shared by many threads. + +Mirrors the "concurrent ID generation" section of go-sdk/node/client_test.go, +additionally asserting that request ids stay unique and increasing. +""" + +from __future__ import annotations + +import threading + +from mintlayer.node import Client + + +def test_concurrent_calls_unique_increasing_ids(rpc_server) -> None: + srv = rpc_server(result="1.0.0") + client = Client(srv.url) + results: list[str] = [] + errors: list[Exception] = [] + + def call() -> None: + try: + results.append(client.node_version()) + except Exception as exc: # noqa: BLE001 - collected and asserted below + errors.append(exc) + + threads = [threading.Thread(target=call) for _ in range(10)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + + assert errors == [] + assert results == ["1.0.0"] * 10 + assert srv.capture.request_count == 10 + assert srv.capture.protocol_errors == [] + + ids = srv.capture.request_ids + assert len(set(ids)) == 10, f"expected 10 unique request ids, got {ids}" + assert sorted(ids) == list(range(1, 11)), f"ids not 1..10: {sorted(ids)}" + client.close() diff --git a/tests/test_indexer_address.py b/tests/test_indexer_address.py new file mode 100644 index 0000000..577e436 --- /dev/null +++ b/tests/test_indexer_address.py @@ -0,0 +1,104 @@ +"""Tests for the indexer address endpoints. + +Mirrors the "Address (3e)" section of go-sdk/indexer/client_test.go. Note the +wire quirk: a UTXO's output payload lives under the key ``utxo``. +""" + +from __future__ import annotations + +from mintlayer.indexer import UTXO, AddressInfo, Client, DelegationInfo, TokenBalance + + +def test_get_address_info(rest_server) -> None: + srv = rest_server( + payload={ + "coin_balance": {"atoms": "100000000000", "decimal": "1.0"}, + "locked_coin_balance": {"atoms": "0", "decimal": "0"}, + "transaction_history": ["tx1", "tx2"], + "tokens": [ + { + "token_id": "mmltk1tok", + "amount": {"atoms": "700", "decimal": "0.0000007"}, + } + ], + } + ) + client = Client(srv.url) + info = client.get_address_info("mtc1abc") + assert isinstance(info, AddressInfo) + assert info.coin_balance.atoms == "100000000000" + assert info.coin_balance.decimal == "1.0" + assert info.locked_coin_balance.atoms == "0" + assert len(info.transaction_history) == 2 + assert isinstance(info.tokens[0], TokenBalance) + assert info.tokens[0].token_id == "mmltk1tok" + assert info.tokens[0].amount.atoms == "700" + assert srv.capture.path == "/api/v2/address/mtc1abc" + client.close() + + +def _utxo(source_id: str, index: int) -> dict: + return { + "outpoint": {"source_id": source_id, "index": index}, + "utxo": {"Transfer": {"destination": "mtc1abc", "amount": {"atoms": "100"}}}, + } + + +def test_get_spendable_utxos(rest_server) -> None: + """UTXOs decode with the output payload taken from the wire key ``utxo``.""" + srv = rest_server(payload=[_utxo("tx1", 0), _utxo("tx2", 1)]) + client = Client(srv.url) + utxos = client.get_spendable_utxos("mtc1abc") + assert len(utxos) == 2 + assert all(isinstance(u, UTXO) for u in utxos) + assert utxos[0].outpoint.source_id == "tx1" + assert utxos[0].outpoint.index == 0 + assert utxos[1].outpoint.source_id == "tx2" + assert utxos[1].outpoint.index == 1 + assert utxos[0].output == {"Transfer": {"destination": "mtc1abc", "amount": {"atoms": "100"}}} + assert srv.capture.path == "/api/v2/address/mtc1abc/spendable-utxos" + client.close() + + +def test_get_all_utxos(rest_server) -> None: + srv = rest_server(payload=[_utxo("tx1", 0)]) + client = Client(srv.url) + utxos = client.get_all_utxos("mtc1abc") + assert len(utxos) == 1 + assert utxos[0].outpoint.source_id == "tx1" + assert srv.capture.path == "/api/v2/address/mtc1abc/all-utxos" + client.close() + + +def test_get_delegations(rest_server) -> None: + srv = rest_server( + payload=[ + { + "delegation_id": "mdelg1abc", + "pool_id": "mpool1xyz", + "next_nonce": 3, + "spend_destination": "mtc1dest", + "balance": {"atoms": "500000000000", "decimal": "5.0"}, + } + ] + ) + client = Client(srv.url) + delegations = client.get_delegations("mtc1abc") + assert len(delegations) == 1 + assert isinstance(delegations[0], DelegationInfo) + assert delegations[0].delegation_id == "mdelg1abc" + assert delegations[0].pool_id == "mpool1xyz" + assert delegations[0].next_nonce == 3 + assert delegations[0].balance.atoms == "500000000000" + assert srv.capture.path == "/api/v2/address/mtc1abc/delegations" + client.close() + + +def test_get_token_authority(rest_server) -> None: + srv = rest_server(payload=["mmltk1aaa", "mmltk1bbb"]) + client = Client(srv.url) + token_ids = client.get_token_authority("mtc1abc") + assert len(token_ids) == 2 + assert token_ids[0] == "mmltk1aaa" + assert srv.capture.path == "/api/v2/address/mtc1abc/token-authority" + client.close() diff --git a/tests/test_indexer_block.py b/tests/test_indexer_block.py new file mode 100644 index 0000000..4049617 --- /dev/null +++ b/tests/test_indexer_block.py @@ -0,0 +1,100 @@ +"""Tests for the indexer block endpoints. + +Mirrors the "Block (3c)" section of go-sdk/indexer/client_test.go, plus a +full Block/BlockHeader/BlockBody/Transaction decode pin. +""" + +from __future__ import annotations + +from mintlayer.indexer import Block, BlockHeader, Client, Transaction + + +def _block_payload() -> dict: + return { + "height": 5, + "header": { + "previous_block_id": "prevblock01", + "timestamp": {"timestamp": 1700000000}, + "merkle_root": "merkleroot01", + "witness_merkle_root": "witnessroot01", + "consensus_data": {"PoS": {"vrf_output": "vrf01"}}, + }, + "body": { + "reward": [{"Transfer": {"amount": {"atoms": "100"}}}], + "transactions": [ + { + "id": "tx01", + "inputs": [], + "outputs": [], + "block_id": "block01", + "timestamp": "1700000000", + "confirmations": "3", + } + ], + }, + } + + +def test_get_block_full_decode(rest_server) -> None: + srv = rest_server(payload=_block_payload()) + client = Client(srv.url) + block = client.get_block("aabbccdd") + assert isinstance(block, Block) + assert block.height == 5 + assert isinstance(block.header, BlockHeader) + assert block.header.previous_block_id == "prevblock01" + assert block.header.timestamp.timestamp == 1700000000 + assert block.header.merkle_root == "merkleroot01" + assert block.header.witness_merkle_root == "witnessroot01" + assert block.header.consensus_data == {"PoS": {"vrf_output": "vrf01"}} + assert isinstance(block.body.transactions[0], Transaction) + assert block.body.transactions[0].id == "tx01" + assert block.body.transactions[0].confirmations == "3" + assert srv.capture.path == "/api/v2/block/aabbccdd" + client.close() + + +def test_get_block_header(rest_server) -> None: + srv = rest_server( + payload={ + "previous_block_id": "prevblock", + "timestamp": {"timestamp": 1700000000}, + "merkle_root": "merkleroot", + "witness_merkle_root": "witnessroot", + } + ) + client = Client(srv.url) + header = client.get_block_header("blockid01") + assert isinstance(header, BlockHeader) + assert header.previous_block_id == "prevblock" + assert header.merkle_root == "merkleroot" + assert srv.capture.path == "/api/v2/block/blockid01/header" + client.close() + + +def test_get_block_reward_raw_list(rest_server) -> None: + """The reward is returned as raw JSON (no typed decode).""" + reward = [{"Transfer": {"amount": {"atoms": "1000000000000"}}}] + srv = rest_server(payload=reward) + client = Client(srv.url) + assert client.get_block_reward("aabbccdd") == reward + assert srv.capture.path == "/api/v2/block/aabbccdd/reward" + client.close() + + +def test_get_block_transaction_ids(rest_server) -> None: + srv = rest_server(payload=["tx1", "tx2", "tx3"]) + client = Client(srv.url) + ids = client.get_block_transaction_ids("aabbccdd") + assert len(ids) == 3 + assert ids[0] == "tx1" + assert srv.capture.path == "/api/v2/block/aabbccdd/transaction-ids" + client.close() + + +def test_get_block_transaction_ids_null_result(rest_server) -> None: + """A JSON null result maps to an empty list.""" + srv = rest_server(payload=None) + client = Client(srv.url) + assert client.get_block_transaction_ids("aabbccdd") == [] + client.close() diff --git a/tests/test_indexer_chain.py b/tests/test_indexer_chain.py new file mode 100644 index 0000000..385f482 --- /dev/null +++ b/tests/test_indexer_chain.py @@ -0,0 +1,67 @@ +"""Tests for the indexer chain endpoints. + +Mirrors the "Chain (3b)" section of go-sdk/indexer/client_test.go. +""" + +from __future__ import annotations + +from mintlayer.indexer import ChainTip, Client, GenesisInfo +from mintlayer.indexer.types import Timestamp # not re-exported by the package + + +def test_get_tip(rest_server) -> None: + srv = rest_server(payload={"block_height": 123456, "block_id": "aabbccdd"}) + client = Client(srv.url) + tip = client.get_tip() + assert isinstance(tip, ChainTip) + assert tip.block_height == 123456 + assert tip.block_id == "aabbccdd" + assert srv.capture.path == "/api/v2/chain/tip" + client.close() + + +def test_get_genesis(rest_server) -> None: + """The genesis timestamp is a nested ``{"timestamp": }``.""" + srv = rest_server( + payload={ + "block_id": "genesisid", + "genesis_message": "mintlayer", + "timestamp": {"timestamp": 1700000000}, + } + ) + client = Client(srv.url) + genesis = client.get_genesis() + assert isinstance(genesis, GenesisInfo) + assert genesis.block_id == "genesisid" + assert genesis.genesis_message == "mintlayer" + assert genesis.timestamp == Timestamp(timestamp=1700000000) + assert genesis.timestamp.timestamp == 1700000000 + assert srv.capture.path == "/api/v2/chain/genesis" + client.close() + + +def test_get_block_id_at_height(rest_server) -> None: + """The endpoint returns a bare JSON string block ID.""" + srv = rest_server(payload="deadbeef0102") + client = Client(srv.url) + assert client.get_block_id_at_height(100000) == "deadbeef0102" + assert srv.capture.path == "/api/v2/chain/100000" + client.close() + + +def test_get_block_id_at_height_null(rest_server) -> None: + """A JSON null body maps to None (optional str, not the empty string).""" + srv = rest_server(payload=None) + client = Client(srv.url) + assert client.get_block_id_at_height(100000) is None + assert srv.capture.path == "/api/v2/chain/100000" + client.close() + + +def test_get_block_id_at_height_path_contains_height(rest_server) -> None: + srv = rest_server(payload="aabb") + client = Client(srv.url) + client.get_block_id_at_height(12345) + assert srv.capture.path.endswith("/chain/12345") + assert "12345" in srv.capture.path + client.close() diff --git a/tests/test_indexer_delegation.py b/tests/test_indexer_delegation.py new file mode 100644 index 0000000..7c5f9ce --- /dev/null +++ b/tests/test_indexer_delegation.py @@ -0,0 +1,55 @@ +"""Tests for the indexer delegation endpoint. + +Mirrors the lenient-numeric raw-wire cases of go-sdk/indexer/client_test.go +(TestDelegation_NextNonce_StringForm): the server serialises several numeric +fields as strings. +""" + +from __future__ import annotations + +import json + +from mintlayer.indexer import Client, Delegation + + +def _delegation_payload(**overrides: object) -> dict: + payload = { + "delegation_id": "mdelg1abc", + "pool_id": "mpool1xyz", + "next_nonce": 7, + "spend_destination": "mtc1dest", + "balance": {"atoms": "500000000000", "decimal": "5.0"}, + "creation_block_height": 10000, + } + payload.update(overrides) + return payload + + +def test_get_delegation(rest_server) -> None: + """Plain numeric wire values decode to ints.""" + srv = rest_server(payload=_delegation_payload()) + client = Client(srv.url) + delegation = client.get_delegation("mdelg1abc") + assert isinstance(delegation, Delegation) + assert delegation.delegation_id == "mdelg1abc" + assert delegation.pool_id == "mpool1xyz" + assert delegation.next_nonce == 7 + assert delegation.spend_destination == "mtc1dest" + assert delegation.balance.atoms == "500000000000" + assert delegation.creation_block_height == 10000 + assert srv.capture.path == "/api/v2/delegation/mdelg1abc" + client.close() + + +def test_get_delegation_string_encoded_numerics(rest_server) -> None: + """``next_nonce``/``creation_block_height`` as strings parse to ints.""" + srv = rest_server( + raw=json.dumps(_delegation_payload(next_nonce="7", creation_block_height="10000")) + ) + client = Client(srv.url) + delegation = client.get_delegation("mdelg1abc") + assert delegation.next_nonce == 7 + assert delegation.creation_block_height == 10000 + assert isinstance(delegation.next_nonce, int) + assert isinstance(delegation.creation_block_height, int) + client.close() diff --git a/tests/test_indexer_http.py b/tests/test_indexer_http.py new file mode 100644 index 0000000..00c132c --- /dev/null +++ b/tests/test_indexer_http.py @@ -0,0 +1,106 @@ +"""Transport-level tests for the indexer REST client. + +Mirrors the "Transport" and "HTTP error path" sections of +go-sdk/indexer/client_test.go, plus base-URL handling. +""" + +from __future__ import annotations + +import pytest +import requests + +from mintlayer.indexer import Client, HTTPError, IndexerError + + +class TestClientConstruction: + def test_defaults(self) -> None: + client = Client("http://127.0.0.1:3000") + assert client.api_base == "http://127.0.0.1:3000/api/v2" + assert client.timeout == 30.0 + client.close() + + def test_timeout_parameter(self) -> None: + client = Client("http://127.0.0.1:3000", timeout=5.0) + assert client.timeout == 5.0 + client.close() + + def test_custom_session(self, rest_server) -> None: + """A caller-supplied session is used for requests.""" + session = requests.Session() + srv = rest_server(payload={"block_height": 1, "block_id": "x"}) + client = Client(srv.url, session=session) + assert client._session is session + assert client.get_tip().block_height == 1 + client.close() + session.close() + + +def test_trailing_slash_trimmed(rest_server) -> None: + """A trailing slash on the base URL must not produce a double slash.""" + srv = rest_server(payload="deadbeef0102") + client = Client(srv.url + "/") + assert client.get_block_id_at_height(1) == "deadbeef0102" + assert srv.capture.path == "/api/v2/chain/1" + client.close() + + +def test_paths_land_under_api_v2(rest_server) -> None: + srv = rest_server(payload={"block_height": 123456, "block_id": "aabbccdd"}) + client = Client(srv.url) + client.get_tip() + assert srv.capture.path == "/api/v2/chain/tip" + assert srv.capture.method == "GET" + client.close() + + +def test_http_error_404(rest_server) -> None: + srv = rest_server(raw='{"error":"NotFound"}\n', status=404) + client = Client(srv.url) + with pytest.raises(HTTPError) as excinfo: + client.get_tip() + err = excinfo.value + assert err.status_code == 404 + assert err.body == '{"error":"NotFound"}' # trailing newline trimmed + client.close() + + +def test_http_error_500(rest_server) -> None: + srv = rest_server(raw="internal error\n", status=500, content_type="text/plain; charset=utf-8") + client = Client(srv.url) + with pytest.raises(HTTPError) as excinfo: + client.get_pool("mpool1abc") + err = excinfo.value + assert err.status_code == 500 + assert err.body == "internal error" + assert str(err) == "HTTP 500: internal error" + client.close() + + +def test_block_id_at_height_404(rest_server) -> None: + """Unknown height -> HTTPError 404 (no JSON body).""" + srv = rest_server(raw="not found\n", status=404) + client = Client(srv.url) + with pytest.raises(HTTPError) as excinfo: + client.get_block_id_at_height(9999999) + assert excinfo.value.status_code == 404 + assert excinfo.value.body == "not found" + client.close() + + +def test_transaction_merkle_path_404(rest_server) -> None: + """Merkle path is 404 until the transaction is included in a block.""" + srv = rest_server(raw='{"error":"NotFound"}', status=404) + client = Client(srv.url) + with pytest.raises(HTTPError) as excinfo: + client.get_transaction_merkle_path("tx01") + assert excinfo.value.status_code == 404 + client.close() + + +def test_invalid_json_raises_indexer_error(rest_server) -> None: + """A 200 response with a non-JSON body raises IndexerError, not HTTPError.""" + srv = rest_server(raw="this is not json", status=200) + client = Client(srv.url) + with pytest.raises(IndexerError, match="decode response"): + client.get_tip() + client.close() diff --git a/tests/test_indexer_number.py b/tests/test_indexer_number.py new file mode 100644 index 0000000..65e450e --- /dev/null +++ b/tests/test_indexer_number.py @@ -0,0 +1,51 @@ +"""Tests for the lenient numeric decoders (mintlayer.indexer.number). + +Mirrors go-sdk/indexer/number.go semantics: out-of-contract payload shapes +must raise IndexerError (the library's codec failure), never a bare +TypeError from float()/str conversion. +""" + +from __future__ import annotations + +import pytest + +from mintlayer.indexer import IndexerError +from mintlayer.indexer.number import parse_per_thousand, parse_uint64 + + +@pytest.mark.parametrize( + "data", + [ + pytest.param(None, id="null"), + pytest.param([1], id="list"), + ], +) +def test_parse_per_thousand_non_scalar_raises_indexer_error(data: object) -> None: + """None/list payloads raise IndexerError instead of a bare TypeError.""" + with pytest.raises(IndexerError, match="PerThousand: invalid value"): + parse_per_thousand(data) + + +@pytest.mark.parametrize( + "data", + [ + pytest.param("1" * 21, id="21_digits"), + pytest.param("18446744073709551616", id="uint64_max_plus_one"), + ], +) +def test_parse_uint64_overlong_numeric_string_raises_indexer_error(data: str) -> None: + """Numeric strings longer than 20 digits cannot be uint64 values.""" + with pytest.raises(IndexerError, match="out of range"): + parse_uint64(data) + + +@pytest.mark.parametrize( + ("data", "expected"), + [ + pytest.param("0", 0, id="zero"), + pytest.param("18446744073709551615", 18446744073709551615, id="uint64_max"), + ], +) +def test_parse_uint64_boundary_string_parses(data: str, expected: int) -> None: + """20-digit values up to the uint64 maximum must still parse fine.""" + assert parse_uint64(data) == expected diff --git a/tests/test_indexer_order.py b/tests/test_indexer_order.py new file mode 100644 index 0000000..a501fd9 --- /dev/null +++ b/tests/test_indexer_order.py @@ -0,0 +1,74 @@ +"""Tests for the indexer DEX order endpoints. + +Mirrors the "Order (3h)" section of go-sdk/indexer/client_test.go, including +the string-encoded nonce forms. +""" + +from __future__ import annotations + +import json + +from mintlayer.indexer import Client, Order, PageOpts + + +def _order_payload(**overrides: object) -> dict: + payload = { + "order_id": "mord1abc", + "conclude_destination": "mtc1dest", + "give_currency": {}, + "initially_given": {"atoms": "100000000000", "decimal": "1.0"}, + "give_balance": {"atoms": "90000000000", "decimal": "0.9"}, + "ask_currency": {}, + "initially_asked": {"atoms": "80000000000", "decimal": "0.8"}, + "ask_balance": {"atoms": "70000000000", "decimal": "0.7"}, + "nonce": 5, + } + payload.update(overrides) + return payload + + +def test_list_orders(rest_server) -> None: + """Orders decode, including a nonce serialised as a string.""" + srv = rest_server(raw=json.dumps([_order_payload(nonce="5")])) + client = Client(srv.url) + orders = client.list_orders(PageOpts()) + assert len(orders) == 1 + assert isinstance(orders[0], Order) + assert orders[0].order_id == "mord1abc" + assert orders[0].nonce == 5 + assert orders[0].initially_given.atoms == "100000000000" + assert orders[0].give_balance.decimal == "0.9" + assert orders[0].ask_balance.atoms == "70000000000" + assert srv.capture.path == "/api/v2/order" + assert srv.capture.query == "" + client.close() + + +def test_get_order(rest_server) -> None: + srv = rest_server(payload=_order_payload()) + client = Client(srv.url) + order = client.get_order("mord1abc") + assert isinstance(order, Order) + assert order.order_id == "mord1abc" + assert order.conclude_destination == "mtc1dest" + assert order.nonce == 5 + assert srv.capture.path == "/api/v2/order/mord1abc" + client.close() + + +def test_get_order_string_encoded_nonce(rest_server) -> None: + srv = rest_server(raw=json.dumps(_order_payload(nonce="5"))) + client = Client(srv.url) + assert client.get_order("mord1abc").nonce == 5 + client.close() + + +def test_list_orders_by_pair_path_and_query(rest_server) -> None: + """The pair route is /order/pair/{ask}_{give} and forwards PageOpts.""" + srv = rest_server(payload=[]) + client = Client(srv.url) + assert client.list_orders_by_pair("ML", "mmltk1abc", PageOpts(offset=10, items=20)) == [] + assert srv.capture.path == "/api/v2/order/pair/ML_mmltk1abc" + assert "offset=10" in srv.capture.query + assert "items=20" in srv.capture.query + client.close() diff --git a/tests/test_indexer_pool.py b/tests/test_indexer_pool.py new file mode 100644 index 0000000..9fb79fc --- /dev/null +++ b/tests/test_indexer_pool.py @@ -0,0 +1,160 @@ +"""Tests for the indexer pool endpoints. + +Mirrors the "Pool (3f)" section of go-sdk/indexer/client_test.go, including +the margin-ratio lenient forms ("3.5%", "10%", bare numbers) and the block +stats query-parameter pin. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import pytest + +from mintlayer.indexer import Client, IndexerError, Pool, PoolDelegation, PoolListOpts + + +def _pool_payload(**margin_overrides: object) -> dict: + payload = { + "pool_id": "mpool1abc", + "decommission_destination": "mtc1dest", + "staker_balance": {"atoms": "40000000000000", "decimal": "400000.0"}, + "margin_ratio_per_thousand": 100, + "cost_per_block": {"atoms": "1000000000", "decimal": "10.0"}, + "vrf_public_key": "vrf01", + "delegations_balance": {"atoms": "500000000000", "decimal": "5.0"}, + } + payload.update(margin_overrides) + return payload + + +def test_list_pools(rest_server) -> None: + srv = rest_server(payload=[_pool_payload()]) + client = Client(srv.url) + pools = client.list_pools(PoolListOpts()) + assert len(pools) == 1 + assert isinstance(pools[0], Pool) + assert pools[0].pool_id == "mpool1abc" + assert pools[0].staker_balance.atoms == "40000000000000" + assert pools[0].margin_ratio_per_thousand == 100.0 + assert isinstance(pools[0].margin_ratio_per_thousand, float) + assert srv.capture.path == "/api/v2/pool" + client.close() + + +def test_list_pools_sort_param(rest_server) -> None: + """sort=by_pledge and items=20 are sent; zero offset is omitted.""" + srv = rest_server(payload=[]) + client = Client(srv.url) + assert client.list_pools(PoolListOpts(sort="by_pledge", offset=0, items=20)) == [] + assert "sort=by_pledge" in srv.capture.query + assert "items=20" in srv.capture.query + assert "offset" not in srv.capture.query + client.close() + + +def test_get_pool(rest_server) -> None: + srv = rest_server(payload=_pool_payload()) + client = Client(srv.url) + pool = client.get_pool("mpool1abc") + assert pool.pool_id == "mpool1abc" + assert pool.decommission_destination == "mtc1dest" + assert pool.cost_per_block.atoms == "1000000000" + assert pool.delegations_balance.decimal == "5.0" + assert srv.capture.path == "/api/v2/pool/mpool1abc" + client.close() + + +@pytest.mark.parametrize( + ("margin_value", "expected"), + [ + pytest.param("3.5%", 3.5, id="float-percent"), + pytest.param("10%", 10.0, id="integer-percent"), + pytest.param(35, 35.0, id="bare-number"), + pytest.param("10", 10.0, id="string-form"), + ], +) +def test_get_pool_margin_ratio_lenient_forms( + rest_server, margin_value: str | int, expected: float +) -> None: + """The margin ratio accepts bare numbers, strings, and trailing %.""" + srv = rest_server(payload=_pool_payload(margin_ratio_per_thousand=margin_value)) + client = Client(srv.url) + pool = client.get_pool("mpool1abc") + assert pool.margin_ratio_per_thousand == expected + assert isinstance(pool.margin_ratio_per_thousand, float) + client.close() + + +def test_list_pools_margin_ratio_float_percent_wire(rest_server) -> None: + """Go-parity check: percent-encoded margins decode through list_pools.""" + srv = rest_server(raw=json.dumps([_pool_payload(margin_ratio_per_thousand="3.5%")])) + client = Client(srv.url) + pools = client.list_pools(PoolListOpts()) + assert pools[0].margin_ratio_per_thousand == 3.5 + client.close() + + +_FROM = datetime.fromtimestamp(1700000000, tz=timezone.utc) +_TO = datetime.fromtimestamp(1700086400, tz=timezone.utc) + + +def test_get_pool_block_stats(rest_server) -> None: + srv = rest_server(payload={"block_count": 42}) + client = Client(srv.url) + assert client.get_pool_block_stats("mpool1abc", _FROM, _TO) == 42 + assert srv.capture.path == "/api/v2/pool/mpool1abc/block-stats" + client.close() + + +def test_get_pool_block_stats_query_params(rest_server) -> None: + """from/to unix seconds are always sent.""" + srv = rest_server(payload={"block_count": 0}) + client = Client(srv.url) + client.get_pool_block_stats("mpool1abc", _FROM, _TO) + assert "from=1700000000" in srv.capture.query + assert "to=1700086400" in srv.capture.query + client.close() + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param({"nope": 1}, id="missing-block-count"), + pytest.param(["nope"], id="list"), + pytest.param("astring", id="bare-string"), + ], +) +def test_get_pool_block_stats_malformed_payload_raises_indexer_error( + rest_server, payload: object +) -> None: + """Non-dict payloads and dicts without block_count raise IndexerError.""" + srv = rest_server(payload=payload) + client = Client(srv.url) + with pytest.raises(IndexerError, match="get_pool_block_stats: unexpected response"): + client.get_pool_block_stats("mpool1abc", _FROM, _TO) + client.close() + + +def test_get_pool_delegations(rest_server) -> None: + srv = rest_server( + payload=[ + { + "delegation_id": "mdelg1abc", + "next_nonce": 5, + "spend_destination": "mtc1dest", + "balance": {"atoms": "500000000000", "decimal": "5.0"}, + "creation_block_height": 10000, + } + ] + ) + client = Client(srv.url) + delegations = client.get_pool_delegations("mpool1abc") + assert len(delegations) == 1 + assert isinstance(delegations[0], PoolDelegation) + assert delegations[0].delegation_id == "mdelg1abc" + assert delegations[0].next_nonce == 5 + assert delegations[0].creation_block_height == 10000 + assert srv.capture.path == "/api/v2/pool/mpool1abc/delegations" + client.close() diff --git a/tests/test_indexer_statistics.py b/tests/test_indexer_statistics.py new file mode 100644 index 0000000..08770e7 --- /dev/null +++ b/tests/test_indexer_statistics.py @@ -0,0 +1,75 @@ +"""Tests for the indexer statistics endpoints. + +Mirrors the "Statistics (3i)" section of go-sdk/indexer/client_test.go, +including the fee-rate query-parameter behaviour and bare-string decode. +""" + +from __future__ import annotations + +from mintlayer.indexer import Client, CoinStats + +_COIN_STATS = { + "circulating_supply": {"atoms": "1000000000000000", "decimal": "10000000.0"}, + "preminted": {"atoms": "400000000000000", "decimal": "4000000.0"}, + "burned": {"atoms": "0", "decimal": "0"}, + "staked": {"atoms": "500000000000000", "decimal": "5000000.0"}, +} + + +def test_get_coin_statistics(rest_server) -> None: + srv = rest_server(payload=_COIN_STATS) + client = Client(srv.url) + stats = client.get_coin_statistics() + assert isinstance(stats, CoinStats) + assert stats.circulating_supply.atoms == "1000000000000000" + assert stats.preminted.decimal == "4000000.0" + assert stats.burned.atoms == "0" + assert stats.staked.decimal == "5000000.0" + assert srv.capture.path == "/api/v2/statistics/coin" + client.close() + + +def test_get_token_statistics(rest_server) -> None: + srv = rest_server(payload=_COIN_STATS) + client = Client(srv.url) + stats = client.get_token_statistics("mmltk1abc") + assert isinstance(stats, CoinStats) + assert stats.circulating_supply.atoms == "1000000000000000" + assert srv.capture.path == "/api/v2/statistics/token/mmltk1abc" + client.close() + + +def test_get_token_statistics_percent_encodes_reserved_characters(rest_server) -> None: + """A token id containing '/' or '?' must be percent-encoded in the path.""" + srv = rest_server(payload=_COIN_STATS) + client = Client(srv.url) + try: + stats = client.get_token_statistics("tok/en?x") + assert isinstance(stats, CoinStats) + assert srv.capture.path == "/api/v2/statistics/token/tok%2Fen%3Fx" + # Nothing may leak out of the path into the query string. + assert srv.capture.query == "" + finally: + client.close() + + +def test_get_fee_rate_in_top_x_mb(rest_server) -> None: + """in_top_x_mb=5 is sent and the bare JSON string result is returned.""" + srv = rest_server(payload="1000") + client = Client(srv.url) + rate = client.get_fee_rate(5) + assert rate == "1000" + assert isinstance(rate, str) + assert "in_top_x_mb=5" in srv.capture.query + assert srv.capture.path == "/api/v2/feerate" + client.close() + + +def test_get_fee_rate_default_omits_param(rest_server) -> None: + """The default call sends no in_top_x_mb param (server default 5 MB).""" + srv = rest_server(payload="500") + client = Client(srv.url) + assert client.get_fee_rate() == "500" + assert "in_top_x_mb" not in srv.capture.query + assert srv.capture.query == "" + client.close() diff --git a/tests/test_indexer_token.py b/tests/test_indexer_token.py new file mode 100644 index 0000000..85452e1 --- /dev/null +++ b/tests/test_indexer_token.py @@ -0,0 +1,126 @@ +"""Tests for the indexer token / NFT endpoints. + +Mirrors the "Token / NFT (3g)" section of go-sdk/indexer/client_test.go, +plus wire-shape pins for the freeze-flag pointer semantics and the +string-encoded tx_global_index. +""" + +from __future__ import annotations + +from mintlayer.indexer import Client, NFTInfo, TokenInfo + + +def test_list_tokens(rest_server) -> None: + srv = rest_server(payload=["mmltk1aaa", "mmltk1bbb"]) + client = Client(srv.url) + assert client.list_tokens() == ["mmltk1aaa", "mmltk1bbb"] + assert srv.capture.path == "/api/v2/token" + assert srv.capture.query == "" # zero PageOpts -> no params + client.close() + + +def _token_payload(**overrides: object) -> dict: + payload = { + "authority": "mtc1abc", + "is_locked": False, + "circulating_supply": {"atoms": "1000000", "decimal": "0.001"}, + "token_ticker": "TKN", + "metadata_uri": "https://example.com/token.json", + "number_of_decimals": 8, + "total_supply": None, + "frozen": False, + "is_token_freezable": True, + "next_nonce": 2, + } + payload.update(overrides) + return payload + + +def test_get_token_not_frozen(rest_server) -> None: + """total_supply may be null; is_token_freezable present when not frozen.""" + srv = rest_server(payload=_token_payload()) + client = Client(srv.url) + info = client.get_token("mmltk1abc") + assert isinstance(info, TokenInfo) + assert info.authority == "mtc1abc" + assert info.token_ticker == "TKN" + assert info.number_of_decimals == 8 + assert info.total_supply is None + assert info.circulating_supply.atoms == "1000000" + assert info.frozen is False + assert info.is_token_freezable is True + assert info.is_token_unfreezable is None + assert info.next_nonce == 2 + assert srv.capture.path == "/api/v2/token/mmltk1abc" + client.close() + + +def test_get_token_frozen_flags_are_exclusive(rest_server) -> None: + """Frozen tokens carry is_token_unfreezable and no is_token_freezable.""" + payload = _token_payload(frozen=True, is_token_unfreezable=False) + del payload["is_token_freezable"] # absent on the wire when frozen + srv = rest_server(payload=payload) + client = Client(srv.url) + info = client.get_token("mmltk1abc") + assert info.frozen is True + assert info.is_token_freezable is None + assert info.is_token_unfreezable is False + client.close() + + +def test_get_token_transactions_string_encoded_index(rest_server) -> None: + """tx_global_index arrives as a string and parses to an int.""" + srv = rest_server( + payload=[ + {"tx_global_index": "12345", "tx_id": "tx01"}, + {"tx_global_index": "12346", "tx_id": "tx02"}, + ] + ) + client = Client(srv.url) + txs = client.get_token_transactions("mmltk1abc") + assert len(txs) == 2 + assert txs[0].tx_global_index == 12345 + assert txs[0].tx_id == "tx01" + assert txs[1].tx_global_index == 12346 + assert srv.capture.path == "/api/v2/token/mmltk1abc/transactions" + client.close() + + +def test_find_tokens_by_ticker(rest_server) -> None: + srv = rest_server(payload=["mmltk1aaa"]) + client = Client(srv.url) + assert client.find_tokens_by_ticker("TKN") == ["mmltk1aaa"] + assert srv.capture.path == "/api/v2/token/ticker/TKN" + client.close() + + +def test_get_nft(rest_server) -> None: + """NFT metadata decodes with null creator / media_uri fields.""" + srv = rest_server( + payload={ + "owner": "mtc1abc", + "token_id": "mmltk1nft", + "metadata": { + "creator": None, + "name": "My NFT", + "description": "A test NFT", + "ticker": "NFT", + "icon_uri": None, + "additional_metadata_uri": None, + "media_uri": None, + "media_hash": "mediahash01", + }, + } + ) + client = Client(srv.url) + nft = client.get_nft("mmltk1nft") + assert isinstance(nft, NFTInfo) + assert nft.owner == "mtc1abc" + assert nft.token_id == "mmltk1nft" + assert nft.metadata.name == "My NFT" + assert nft.metadata.ticker == "NFT" + assert nft.metadata.creator is None + assert nft.metadata.media_uri is None + assert nft.metadata.media_hash == "mediahash01" + assert srv.capture.path == "/api/v2/nft/mmltk1nft" + client.close() diff --git a/tests/test_indexer_transaction.py b/tests/test_indexer_transaction.py new file mode 100644 index 0000000..9aa7526 --- /dev/null +++ b/tests/test_indexer_transaction.py @@ -0,0 +1,150 @@ +"""Tests for the indexer transaction endpoints. + +Mirrors the "Transaction (3d)" section of go-sdk/indexer/client_test.go, +including pagination, zero-value PageOpts, and the text/plain submit route. +""" + +from __future__ import annotations + +import pytest + +from mintlayer.indexer import Client, IndexerError, MerklePath, PageOpts, Transaction + + +def test_list_transactions_pagination(rest_server) -> None: + srv = rest_server(payload=[]) + client = Client(srv.url) + assert client.list_transactions(PageOpts(offset=10, items=20)) == [] + assert srv.capture.path == "/api/v2/transaction" + assert "offset=10" in srv.capture.query + assert "items=20" in srv.capture.query + client.close() + + +def test_list_transactions_zero_page_opts_no_params(rest_server) -> None: + """Zero PageOpts sends NO offset/items params (server defaults apply).""" + srv = rest_server(payload=[]) + client = Client(srv.url) + client.list_transactions(PageOpts()) + assert "offset" not in srv.capture.query + assert "items" not in srv.capture.query + assert srv.capture.query == "" + client.close() + + +def test_list_transactions_decodes_entries(rest_server) -> None: + srv = rest_server( + payload=[ + { + "id": "tx01", + "block_id": "block01", + "timestamp": "1700000000", + "confirmations": "10", + } + ] + ) + client = Client(srv.url) + txs = client.list_transactions(PageOpts(offset=0, items=5)) + assert len(txs) == 1 + assert isinstance(txs[0], Transaction) + assert txs[0].id == "tx01" + assert "items=5" in srv.capture.query + assert "offset" not in srv.capture.query # zero offset omitted + client.close() + + +def test_get_transaction(rest_server) -> None: + srv = rest_server( + payload={ + "id": "aabbccdd", + "block_id": "blockid01", + "timestamp": "1700000000", + "confirmations": "100", + } + ) + client = Client(srv.url) + tx = client.get_transaction("aabbccdd") + assert isinstance(tx, Transaction) + assert tx.id == "aabbccdd" + assert tx.block_id == "blockid01" + assert tx.timestamp == "1700000000" + assert tx.confirmations == "100" + assert srv.capture.path == "/api/v2/transaction/aabbccdd" + client.close() + + +def test_get_transaction_unconfirmed(rest_server) -> None: + """Block ID / timestamp / confirmations default to empty strings.""" + srv = rest_server(payload={"id": "pending01"}) + client = Client(srv.url) + tx = client.get_transaction("pending01") + assert tx.block_id == "" + assert tx.timestamp == "" + assert tx.confirmations == "" + client.close() + + +def test_get_transaction_merkle_path(rest_server) -> None: + srv = rest_server( + payload={ + "block_id": "block01", + "transaction_index": 3, + "merkle_root": "root01", + "merkle_path": ["hash1", "hash2"], + } + ) + client = Client(srv.url) + mp = client.get_transaction_merkle_path("tx01") + assert isinstance(mp, MerklePath) + assert mp.block_id == "block01" + assert mp.transaction_index == 3 + assert mp.merkle_root == "root01" + assert mp.path == ["hash1", "hash2"] + assert srv.capture.path == "/api/v2/transaction/tx01/merkle-path" + client.close() + + +def test_get_transaction_output_raw(rest_server) -> None: + """Transaction outputs pass through as raw JSON.""" + output = { + "output": {"Transfer": {"destination": "mtc1abc", "amount": {"atoms": "100"}}}, + "spent_at_block_height": 42, + } + srv = rest_server(payload=output) + client = Client(srv.url) + assert client.get_transaction_output("tx01", 0) == output + assert srv.capture.path == "/api/v2/transaction/tx01/output/0" + client.close() + + +def test_submit_transaction(rest_server) -> None: + """Submit POSTs the hex verbatim as text/plain and returns the tx id.""" + srv = rest_server(payload={"tx_id": "newtxid"}) + client = Client(srv.url) + tx_id = client.submit_transaction("cafebabe") + assert tx_id == "newtxid" + assert srv.capture.method == "POST" + assert srv.capture.path == "/api/v2/transaction" + assert srv.capture.raw_body == b"cafebabe" + assert srv.capture.headers.get("content-type", "").startswith("text/plain") + client.close() + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param({"nope": 1}, id="missing-tx-id"), + pytest.param("astring", id="bare-string"), + ], +) +def test_submit_transaction_malformed_payload_raises_indexer_error( + rest_server, payload: object +) -> None: + """A response without a tx_id is a codec failure, not KeyError/TypeError.""" + srv = rest_server(payload=payload) + client = Client(srv.url) + try: + with pytest.raises(IndexerError): + client.submit_transaction("cafebabe") + finally: + client.close() diff --git a/tests/test_indexer_types.py b/tests/test_indexer_types.py new file mode 100644 index 0000000..017cb58 --- /dev/null +++ b/tests/test_indexer_types.py @@ -0,0 +1,88 @@ +"""Tests for indexer payload decoding (mintlayer.indexer.types). + +Every ``from_json`` classmethod is wrapped so a malformed server payload +raises IndexerError — including payloads of the wrong JSON *shape* (a list +or scalar where an object is expected), which surface as AttributeError +inside the original decoders and must not leak out uncaught. +""" + +from __future__ import annotations + +import pytest + +from mintlayer.indexer import Amount, IndexerError, Transaction +from mintlayer.indexer.types import BlockBody + + +@pytest.mark.parametrize( + ("cls", "payload"), + [ + pytest.param(Amount, ["list"], id="amount-list"), + pytest.param(BlockBody, None, id="block-body-null"), + ], +) +def test_from_json_non_object_payload_raises_indexer_error(cls: type, payload: object) -> None: + """AttributeError from ``data.get`` on a non-dict is wrapped as IndexerError.""" + with pytest.raises(IndexerError, match=f"{cls.__name__}: malformed payload"): + cls.from_json(payload) # type: ignore[attr-defined] + + +def test_indexer_error_from_json_still_lists_transaction_fields() -> None: + """Sanity: well-formed payloads keep decoding through the same wrapper.""" + tx = Transaction.from_json({"id": "tx01"}) + assert tx.id == "tx01" + + +# ── Amount: both wire keys are now required ────────────────────────────────── + + +def _raw_amount_from_json(): + """The unwrapped ``Amount.from_json`` decoder (before _safe_from_json). + + The module wraps every registered class at import time; the original + function is kept in the wrapper's closure so the raw KeyError semantics + stay observable. + """ + wrapper = Amount.from_json.__func__ + for cell in wrapper.__closure__ or (): + candidate = cell.cell_contents + if callable(candidate) and candidate is not wrapper: + return candidate + raise AssertionError("unwrapped Amount.from_json not found in closure") + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param({}, id="both-keys-missing"), + pytest.param({"atoms": "100"}, id="decimal-missing"), + pytest.param({"decimal": "0.000001"}, id="atoms-missing"), + ], +) +def test_amount_from_json_missing_key_raises_key_error(payload: dict) -> None: + """A truncated payload raises KeyError in the raw decoder — no silent defaults.""" + raw = _raw_amount_from_json() + with pytest.raises(KeyError): + raw(Amount, payload) + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param({}, id="both-keys-missing"), + pytest.param({"atoms": "100"}, id="decimal-missing"), + pytest.param({"decimal": "0.000001"}, id="atoms-missing"), + ], +) +def test_amount_from_json_missing_key_raises_indexer_error(payload: dict) -> None: + """Through the _safe_from_json wrapper the same payload is an IndexerError.""" + with pytest.raises(IndexerError, match="Amount: malformed payload") as excinfo: + Amount.from_json(payload) + assert isinstance(excinfo.value.__cause__, KeyError) + + +def test_amount_from_json_both_keys_present_still_decodes() -> None: + """A payload carrying both keys keeps decoding to a frozen Amount.""" + assert Amount.from_json({"atoms": "100", "decimal": "0.000001"}) == Amount( + atoms="100", decimal="0.000001" + ) diff --git a/tests/test_mempool.py b/tests/test_mempool.py new file mode 100644 index 0000000..b4f531f --- /dev/null +++ b/tests/test_mempool.py @@ -0,0 +1,140 @@ +"""Tests for the mempool module methods. + +Mirrors the "mempool module" section of go-sdk/node/client_test.go. +""" + +from __future__ import annotations + +import pytest + +from mintlayer.node import Amount, Client, FeeRate, FeeRatePoint, MempoolTx, TrustPolicy + +_FEE_RATE_POINTS_WIRE = ( + '[[1024,{"amount_per_kb":{"atoms":"500"}}],[2048,{"amount_per_kb":{"atoms":"750"}}]]' +) + + +def test_contains_tx(rpc_server) -> None: + srv = rpc_server(result=True) + client = Client(srv.url) + assert client.contains_tx("aabb1234") is True + assert srv.capture.method == "mempool_contains_tx" + assert srv.capture.params == {"tx_id": "aabb1234"} + client.close() + + +def test_contains_orphan_tx(rpc_server) -> None: + srv = rpc_server(result=False) + client = Client(srv.url) + assert client.contains_orphan_tx("aabb1234") is False + assert srv.capture.method == "mempool_contains_orphan_tx" + assert srv.capture.params == {"tx_id": "aabb1234"} + client.close() + + +def test_get_transaction_found(rpc_server) -> None: + srv = rpc_server(result={"id": "aabb1234", "status": "InMempool", "transaction": "cafebabe"}) + client = Client(srv.url) + tx = client.get_transaction("aabb1234") + assert tx == MempoolTx(id="aabb1234", status="InMempool", transaction="cafebabe") + assert tx is not None + assert tx.status == "InMempool" + assert srv.capture.method == "mempool_get_transaction" + assert srv.capture.params == {"tx_id": "aabb1234"} + client.close() + + +def test_get_transaction_not_found(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.get_transaction("deadbeef") is None + client.close() + + +def test_mempool_submit_transaction_untrusted(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.mempool_submit_transaction("cafebabe", TrustPolicy.UNTRUSTED) is None + assert srv.capture.method == "mempool_submit_transaction" + assert srv.capture.params == { + "tx": "cafebabe", + "options": {"trust_policy": "Untrusted"}, + } + client.close() + + +def test_mempool_submit_transaction_trusted(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.mempool_submit_transaction("cafebabe", TrustPolicy.TRUSTED) + assert srv.capture.params == { + "tx": "cafebabe", + "options": {"trust_policy": "Trusted"}, + } + client.close() + + +def test_mempool_submit_transaction_accepts_plain_string(rpc_server) -> None: + """A plain "Untrusted" string is normalised to the enum's wire value.""" + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.mempool_submit_transaction("cafebabe", "Untrusted") is None + assert srv.capture.method == "mempool_submit_transaction" + assert srv.capture.params == { + "tx": "cafebabe", + "options": {"trust_policy": "Untrusted"}, + } + client.close() + + +def test_mempool_submit_transaction_rejects_invalid_string(rpc_server) -> None: + """An invalid policy string raises ValueError before any request is sent.""" + srv = rpc_server(result=None) + client = Client(srv.url) + with pytest.raises(ValueError): + client.mempool_submit_transaction("cafebabe", "Bogus") + assert srv.capture.request_count == 0 + client.close() + + +def test_get_fee_rate(rpc_server) -> None: + srv = rpc_server(result={"amount_per_kb": {"atoms": "1000"}}) + client = Client(srv.url) + rate = client.get_fee_rate(5) + assert rate == FeeRate(amount_per_kb=Amount(atoms="1000")) + assert rate is not None + assert rate.amount_per_kb.atoms == "1000" + assert srv.capture.method == "mempool_get_fee_rate" + assert srv.capture.params == {"in_top_x_mb": 5} + client.close() + + +def test_get_fee_rate_not_found(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.get_fee_rate(5) is None + client.close() + + +def test_get_fee_rate_points(rpc_server) -> None: + """Wire format: array of [size, feeRate] pairs (raw JSON result).""" + srv = rpc_server(raw=_FEE_RATE_POINTS_WIRE) + client = Client(srv.url) + points = client.get_fee_rate_points() + assert points == [ + FeeRatePoint(size=1024, rate=FeeRate(amount_per_kb=Amount(atoms="500"))), + FeeRatePoint(size=2048, rate=FeeRate(amount_per_kb=Amount(atoms="750"))), + ] + assert points[0].size == 1024 + assert points[0].rate.amount_per_kb.atoms == "500" + assert points[1].rate.amount_per_kb.atoms == "750" + assert srv.capture.method == "mempool_get_fee_rate_points" + client.close() + + +def test_memory_usage(rpc_server) -> None: + srv = rpc_server(result=4096) + client = Client(srv.url) + assert client.memory_usage() == 4096 + assert srv.capture.method == "mempool_memory_usage" + client.close() diff --git a/tests/test_node_module.py b/tests/test_node_module.py new file mode 100644 index 0000000..cf23fc8 --- /dev/null +++ b/tests/test_node_module.py @@ -0,0 +1,27 @@ +"""Tests for the node module methods (node_version, node_shutdown). + +Mirrors the "node module" section of go-sdk/node/client_test.go. +""" + +from __future__ import annotations + +from mintlayer.node import Client + + +def test_node_version(rpc_server) -> None: + srv = rpc_server(result="v0.9.7") + client = Client(srv.url) + assert client.node_version() == "v0.9.7" + assert srv.capture.method == "node_version" + assert srv.capture.params == {} + client.close() + + +def test_node_shutdown(rpc_server) -> None: + """A null result (void method) returns None.""" + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.node_shutdown() is None + assert srv.capture.method == "node_shutdown" + assert srv.capture.params == {} + client.close() diff --git a/tests/test_p2p.py b/tests/test_p2p.py new file mode 100644 index 0000000..38df4c1 --- /dev/null +++ b/tests/test_p2p.py @@ -0,0 +1,129 @@ +"""Tests for the p2p module methods. + +Mirrors the "p2p module" section of go-sdk/node/client_test.go. +""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest + +from mintlayer.node import BannedPeer, Client, PeerInfo, TrustPolicy +from mintlayer.node.p2p import _duration_to_wire + +_LIST_BANNED_WIRE = '[["1.2.3.4",{"time":[1700000000,0]}]]' + + +def test_get_peer_count(rpc_server) -> None: + srv = rpc_server(result=7) + client = Client(srv.url) + assert client.get_peer_count() == 7 + assert srv.capture.method == "p2p_get_peer_count" + client.close() + + +def test_get_connected_peers(rpc_server) -> None: + """PeerInfo decodes with a null ping_wait (optional fields absent/null).""" + srv = rpc_server( + result=[ + { + "peer_id": 42, + "address": "1.2.3.4:3031", + "peer_role": "OutboundFullRelay", + "ban_score": 0, + "user_agent": "mintlayer-node/1.3.0", + "software_version": "1.3.0", + "ping_wait": None, + } + ] + ) + client = Client(srv.url) + peers = client.get_connected_peers() + assert peers == [ + PeerInfo( + peer_id=42, + address="1.2.3.4:3031", + peer_role="OutboundFullRelay", + ban_score=0, + user_agent="mintlayer-node/1.3.0", + software_version="1.3.0", + ping_wait=None, + ) + ] + assert len(peers) == 1 + assert peers[0].peer_id == 42 + assert peers[0].ping_wait is None + assert srv.capture.method == "p2p_get_connected_peers" + client.close() + + +def test_list_banned(rpc_server) -> None: + """Wire format: [["addr", {"time": [secs, nanos]}], ...] (raw JSON result).""" + srv = rpc_server(raw=_LIST_BANNED_WIRE) + client = Client(srv.url) + banned = client.list_banned() + assert banned == [BannedPeer(address="1.2.3.4", ban_time=(1700000000, 0))] + assert banned[0].address == "1.2.3.4" + assert banned[0].ban_time == (1700000000, 0) + assert banned[0].ban_time[0] == 1700000000 + assert srv.capture.method == "p2p_list_banned" + client.close() + + +def test_ban_duration_wire_format(rpc_server) -> None: + """Durations are sent as the daemon's two-element [seconds, nanos] array.""" + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.ban("5.6.7.8", timedelta(hours=24)) is None + assert srv.capture.method == "p2p_ban" + duration = srv.capture.params["duration"] + assert isinstance(duration, list) + assert len(duration) == 2 + assert srv.capture.params == {"address": "5.6.7.8", "duration": [86400, 0]} + client.close() + + +def test_ban_sub_second_duration(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.ban("5.6.7.8", timedelta(seconds=1, microseconds=500_000)) + assert srv.capture.params == {"address": "5.6.7.8", "duration": [1, 500000000]} + client.close() + + +def test_duration_to_wire_rejects_negative_durations() -> None: + """Negative durations fail closed before they can corrupt the wire form. + + Python normalises ``timedelta(seconds=-1)`` to ``(days=-1, seconds=86399)``; + encoding that naively would send [86399, 0] (~a 24h ban) instead of -1s. + """ + with pytest.raises(ValueError, match="must not be negative"): + _duration_to_wire(timedelta(seconds=-1)) + with pytest.raises(ValueError, match="must not be negative"): + _duration_to_wire(timedelta(days=-2)) + + +@pytest.mark.parametrize( + ("duration", "wire"), + [ + pytest.param(timedelta(days=1), [86_400, 0], id="one_day"), + pytest.param(timedelta(seconds=1), [1, 0], id="one_second"), + pytest.param(timedelta(microseconds=1500), [0, 1_500_000], id="sub_second"), + ], +) +def test_duration_to_wire_positive_durations(duration: timedelta, wire: list[int]) -> None: + """Positive durations split into the daemon's [seconds, nanoseconds] pair.""" + assert _duration_to_wire(duration) == wire + + +def test_p2p_submit_transaction(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.p2p_submit_transaction("cafebabe", TrustPolicy.UNTRUSTED) is None + assert srv.capture.method == "p2p_submit_transaction" + assert srv.capture.params == { + "tx": "cafebabe", + "options": {"trust_policy": "Untrusted"}, + } + client.close() diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..e0a15d9 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,155 @@ +"""Transport-level tests: client construction, auth, errors, and wire shape. + +Mirrors the "Transport" and "Error path" sections of go-sdk/node/client_test.go. +""" + +from __future__ import annotations + +import base64 +import json + +import pytest + +from mintlayer._jsonrpc import JSONRPCClient, JSONRPCError +from mintlayer.node import Client, RPCError + +_DUMMY_ENDPOINT = "http://127.0.0.1:3030" + + +class TestClientConstruction: + def test_defaults(self) -> None: + client = Client(_DUMMY_ENDPOINT) + assert client._rpc.endpoint == _DUMMY_ENDPOINT + assert client._rpc.username == "" + assert client._rpc.password == "" + assert client._rpc.timeout == 30.0 + client.close() + + def test_timeout_parameter(self) -> None: + client = Client(_DUMMY_ENDPOINT, timeout=5.0) + assert client._rpc.timeout == 5.0 + client.close() + + +def test_basic_auth_header(rpc_server) -> None: + """The Authorization header is sent when a username is set.""" + srv = rpc_server(result="1.0.0") + client = Client(srv.url, username="alice", password="secret") + assert client.node_version() == "1.0.0" + expected = "Basic " + base64.b64encode(b"alice:secret").decode("ascii") + assert srv.capture.headers.get("authorization") == expected + client.close() + + +def test_no_auth_header_without_username(rpc_server) -> None: + """No Authorization header when the username is empty.""" + srv = rpc_server(result="1.0.0") + client = Client(srv.url) + assert client.node_version() == "1.0.0" + assert "authorization" not in srv.capture.headers + client.close() + + +def test_rpc_error(rpc_server) -> None: + srv = rpc_server(error=(-32601, "method not found")) + client = Client(srv.url) + with pytest.raises(RPCError) as excinfo: + client.node_version() + err = excinfo.value + assert err.code == -32601 + assert err.message == "method not found" + assert str(err) == "RPC error -32601: method not found" + client.close() + + +def test_request_wire_shape(rpc_server) -> None: + """Requests POST the full JSON-RPC 2.0 envelope straight to the endpoint.""" + srv = rpc_server(result="ok") + client = Client(srv.url) + client.node_version() + assert srv.capture.method == "node_version" + assert srv.capture.params == {} + assert srv.capture.path == "/" + assert srv.capture.request_count == 1 + assert json.loads(srv.capture.raw_body) == { + "jsonrpc": "2.0", + "id": 1, + "method": "node_version", + "params": {}, + } + client.close() + + +def test_request_ids_increment(rpc_server) -> None: + srv = rpc_server(result="ok") + client = Client(srv.url) + client.node_version() + client.node_version() + assert [payload["id"] for payload in srv.capture.payloads] == [1, 2] + client.close() + + +def test_response_id_mismatch_raises(rpc_server) -> None: + """A response belonging to another call is not silently misattributed. + + ``rpc_server(raw=...)`` -> ``make_raw_rpc_server`` splices its text + verbatim after ``"result":``, so the text ``1,"id":999`` adds a second + id key that ``json`` keeps (last one wins). The parsed response id is + 999 while this fresh client's first request id is 1. + """ + srv = rpc_server(raw='1,"id":999') + client = JSONRPCClient(srv.url) + with pytest.raises(JSONRPCError, match="response id mismatch: expected 1, got 999"): + client.call("node_version", {}) + client.close() + + +def test_response_null_id_raises(rpc_server) -> None: + """A JSON null response id (server-side notification) cannot be + attributed to this call and must fail loudly. + + ``rpc_server(raw=...)`` -> ``make_raw_rpc_server`` splices its text + verbatim after ``"result":``, so the text ``1,"id":null`` adds a second + id key that ``json`` keeps (last one wins). The parsed response id is + ``None`` while this fresh client's first request id is 1. + """ + srv = rpc_server(raw='1,"id":null') + client = JSONRPCClient(srv.url) + with pytest.raises(JSONRPCError, match="response id mismatch: expected 1, got None"): + client.call("node_version", {}) + client.close() + + +class TestCredentialSafetyGuard: + """Basic-auth credentials must not go over cleartext http to remote hosts. + + The guard fires in ``JSONRPCClient.__init__``, before any network I/O. + """ + + def test_cleartext_http_nonloopback_with_username_raises(self) -> None: + with pytest.raises(ValueError, match="basic-auth") as excinfo: + JSONRPCClient("http://example.com:7103", username="user") + assert "example.com" in str(excinfo.value) + + def test_cleartext_http_loopback_ipv4_allowed(self) -> None: + client = JSONRPCClient("http://127.0.0.1:7103", username="user", password="pw") + client.close() + + @pytest.mark.parametrize( + "endpoint", + [ + pytest.param("http://localhost:7103", id="localhost"), + pytest.param("http://[::1]:7103", id="ipv6_loopback"), + ], + ) + def test_cleartext_http_loopback_hosts_allowed(self, endpoint: str) -> None: + client = JSONRPCClient(endpoint, username="user") + client.close() + + def test_https_nonloopback_with_username_allowed(self) -> None: + client = JSONRPCClient("https://example.com", username="user") + client.close() + + def test_cleartext_http_without_username_allowed(self) -> None: + client = JSONRPCClient("http://example.com") + client.close() diff --git a/tests/test_wallet_concurrency.py b/tests/test_wallet_concurrency.py new file mode 100644 index 0000000..edce565 --- /dev/null +++ b/tests/test_wallet_concurrency.py @@ -0,0 +1,41 @@ +"""Concurrent-use test: one wallet client shared by many threads. + +Mirrors the "Concurrent ID generation" section of +go-sdk/wallet/client_test.go, additionally asserting that request ids stay +unique and increasing. +""" + +from __future__ import annotations + +import threading + +from mintlayer.wallet import BestBlock, Client + + +def test_concurrent_calls_unique_increasing_ids(rpc_server) -> None: + srv = rpc_server(result={"height": 1, "id": "aa"}) + client = Client(srv.url) + results: list[BestBlock] = [] + errors: list[Exception] = [] + + def call() -> None: + try: + results.append(client.best_block()) + except Exception as exc: # noqa: BLE001 - collected and asserted below + errors.append(exc) + + threads = [threading.Thread(target=call) for _ in range(10)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + + assert errors == [] + assert results == [BestBlock(height=1, id="aa")] * 10 + assert srv.capture.request_count == 10 + assert srv.capture.protocol_errors == [] + + ids = srv.capture.request_ids + assert len(set(ids)) == 10, f"expected 10 unique request ids, got {ids}" + assert sorted(ids) == list(range(1, 11)), f"ids not 1..10: {sorted(ids)}" + client.close() diff --git a/tests/test_wallet_management.py b/tests/test_wallet_management.py new file mode 100644 index 0000000..f2c361d --- /dev/null +++ b/tests/test_wallet_management.py @@ -0,0 +1,353 @@ +"""Wallet lifecycle management tests. + +Mirrors the "Wallet management" section of go-sdk/wallet/client_test.go, +pinning the exact RPC method names and params shapes on the wire. +""" + +from __future__ import annotations + +import pytest + +from mintlayer.wallet import ( + AccountInfo, + AddressWithUsage, + Amount, + Balance, + BestBlock, + Client, + CreateWalletParams, + CreateWalletResult, + JSONRPCError, + MnemonicResult, + RecoverWalletParams, + WalletInfo, +) +from mintlayer.wallet.types import RevealPublicKeyResult + +_MNEMONIC = "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12" + + +def test_create_wallet_newly_generated(rpc_server) -> None: + result = {"mnemonic": {"type": "NewlyGenerated", "content": {"mnemonic": _MNEMONIC}}} + srv = rpc_server(result=result) + client = Client(srv.url) + got = client.create_wallet(CreateWalletParams(path="/tmp/test.db", store_seed_phrase=True)) + assert srv.capture.method == "wallet_create" + assert got.mnemonic is not None + assert got.mnemonic.type == "NewlyGenerated" + assert got.mnemonic.content is not None + assert got.mnemonic.content.mnemonic == _MNEMONIC + client.close() + + +def test_create_wallet_user_provided_content_null(rpc_server) -> None: + srv = rpc_server(result={"mnemonic": {"type": "UserProvided", "content": None}}) + client = Client(srv.url) + got = client.create_wallet(CreateWalletParams(path="/tmp/test.db", store_seed_phrase=False)) + assert got.mnemonic is not None + assert got.mnemonic.type == "UserProvided" + assert got.mnemonic.content is None + client.close() + + +def test_create_wallet_without_mnemonic(rpc_server) -> None: + srv = rpc_server(result={}) + client = Client(srv.url) + got = client.create_wallet(CreateWalletParams(path="/tmp/test.db", store_seed_phrase=True)) + assert got == CreateWalletResult(mnemonic=None) + client.close() + + +# ── MnemonicResult.from_json content validation (direct decode tests) ──────── + + +def test_mnemonic_result_from_json_valid_content() -> None: + got = MnemonicResult.from_json({"type": "NewlyGenerated", "content": {"mnemonic": _MNEMONIC}}) + assert got.type == "NewlyGenerated" + assert got.content is not None + assert got.content.mnemonic == _MNEMONIC + + +def test_mnemonic_result_from_json_null_content() -> None: + got = MnemonicResult.from_json({"type": "UserProvided", "content": None}) + assert got.type == "UserProvided" + assert got.content is None + + +def test_mnemonic_result_from_json_non_dict_content_raises() -> None: + """A non-dict content raises ValueError, never a str()-coerced field.""" + with pytest.raises(ValueError, match="invalid content"): + MnemonicResult.from_json({"type": "NewlyGenerated", "content": "oops"}) # type: ignore[dict-item] + + +def test_mnemonic_result_from_json_missing_mnemonic_key_raises() -> None: + """A payload without the required ``mnemonic`` key raises ValueError + ("malformed payload") instead of a bare kwargs TypeError.""" + with pytest.raises(ValueError, match="malformed payload"): + MnemonicResult.from_json({"type": "NewlyGenerated", "content": {"seed": "x"}}) + + +def test_create_wallet_wire_shape(rpc_server) -> None: + srv = rpc_server(result={}) + client = Client(srv.url) + client.create_wallet(CreateWalletParams(path="/tmp/test.db", store_seed_phrase=True)) + assert srv.capture.params == { + "path": "/tmp/test.db", + "store_seed_phrase": True, + "mnemonic": None, + "passphrase": None, + "hardware_wallet": None, + } + client.close() + + +def test_recover_wallet(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.recover_wallet( + RecoverWalletParams( + path="/tmp/recovered.db", + store_seed_phrase=False, + mnemonic=_MNEMONIC, + ) + ) + assert srv.capture.method == "wallet_recover" + assert srv.capture.params == { + "path": "/tmp/recovered.db", + "store_seed_phrase": False, + "mnemonic": _MNEMONIC, + "passphrase": None, + "hardware_wallet": None, + } + client.close() + + +@pytest.mark.parametrize( + ("password", "wire_password"), + [("", None), ("hunter2", "hunter2")], + ids=["empty_password_null", "password_sent_verbatim"], +) +def test_open_wallet(rpc_server, password: str, wire_password: str | None) -> None: + """An empty password is sent as JSON null; a real one verbatim.""" + srv = rpc_server(result=None) + client = Client(srv.url) + client.open_wallet("/tmp/test.db", password) + assert srv.capture.method == "wallet_open" + assert srv.capture.params == { + "path": "/tmp/test.db", + "password": wire_password, + "force_migrate_wallet_type": None, + "hardware_wallet": None, + } + client.close() + + +def test_close_wallet(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.close_wallet() + assert srv.capture.method == "wallet_close" + assert srv.capture.params == {} + client.close() + + +def test_get_wallet_info(rpc_server) -> None: + srv = rpc_server( + result={ + "wallet_id": "aabb1234", + "account_names": ["Main", "Savings"], + "extra_info": {"type": "SoftwareWallet"}, + } + ) + client = Client(srv.url) + got = client.get_wallet_info() + assert isinstance(got, WalletInfo) + assert got.wallet_id == "aabb1234" + assert got.account_names == ["Main", "Savings"] + assert got.extra_info.type == "SoftwareWallet" + assert srv.capture.method == "wallet_info" + assert srv.capture.params == {} + client.close() + + +def test_get_wallet_info_null_result_raises(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + with pytest.raises(JSONRPCError, match="expected object result"): + client.get_wallet_info() + client.close() + + +def test_sync_wallet(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.sync_wallet() + assert srv.capture.method == "wallet_sync" + assert srv.capture.params == {} + client.close() + + +def test_rescan_wallet(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.rescan_wallet() + assert srv.capture.method == "wallet_rescan" + assert srv.capture.params == {} + client.close() + + +def test_best_block(rpc_server) -> None: + srv = rpc_server(result={"height": 42000, "id": "deadbeef"}) + client = Client(srv.url) + got = client.best_block() + assert got == BestBlock(height=42000, id="deadbeef") + assert srv.capture.method == "wallet_best_block" + assert srv.capture.params == {} + client.close() + + +def test_create_account(rpc_server) -> None: + srv = rpc_server(result={"account": 1, "name": "Savings"}) + client = Client(srv.url) + got = client.create_account("Savings") + assert got == AccountInfo(account=1, name="Savings") + assert srv.capture.method == "account_create" + assert srv.capture.params == {"name": "Savings"} + client.close() + + +def test_rename_account(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.rename_account(0, "Main") + assert srv.capture.method == "account_rename" + assert srv.capture.params == {"account": 0, "name": "Main"} + client.close() + + +def test_rename_account_empty_name_sent_as_null(rpc_server) -> None: + """An empty name is sent as null (clears the account name).""" + srv = rpc_server(result=None) + client = Client(srv.url) + client.rename_account(0) + assert srv.capture.method == "account_rename" + assert srv.capture.params == {"account": 0, "name": None} + client.close() + + +def test_get_balance(rpc_server) -> None: + result = { + "coins": {"atoms": "1000000000000", "decimal": "10000.0"}, + "tokens": {"tok1abc": {"atoms": "500000000"}}, + } + srv = rpc_server(result=result) + client = Client(srv.url) + got = client.get_balance(0) + assert isinstance(got, Balance) + assert got.coins == Amount(atoms="1000000000000", decimal="10000.0") + assert got.tokens == {"tok1abc": Amount(atoms="500000000")} + assert srv.capture.method == "account_balance" + # Pinned option keys: confirmed-only UTXO states, locked UTXOs not merged. + assert srv.capture.params == { + "account": 0, + "utxo_states": ["Confirmed"], + "with_locked": None, + } + client.close() + + +def test_new_address(rpc_server) -> None: + srv = rpc_server(result={"address": "tmltool1abc"}) + client = Client(srv.url) + assert client.new_address(0) == "tmltool1abc" + assert srv.capture.method == "address_new" + assert srv.capture.params == {"account": 0} + client.close() + + +def test_new_address_null_result_raises(rpc_server) -> None: + """A JSON null result raises JSONRPCError, not an opaque TypeError.""" + srv = rpc_server(result=None) + client = Client(srv.url) + with pytest.raises(JSONRPCError, match="expected object result, got null"): + client.new_address(0) + client.close() + + +def test_show_receive_addresses(rpc_server) -> None: + result = [ + {"address": "tmltool1abc", "used": False, "coins": {"atoms": "0"}}, + {"address": "tmltool1def", "used": True, "coins": {"atoms": "5000000000"}}, + ] + srv = rpc_server(result=result) + client = Client(srv.url) + got = client.show_receive_addresses(0) + assert got == [ + AddressWithUsage(address="tmltool1abc", used=False, coins=Amount(atoms="0")), + AddressWithUsage(address="tmltool1def", used=True, coins=Amount(atoms="5000000000")), + ] + assert srv.capture.method == "address_show" + # Change addresses are never included. + assert srv.capture.params == {"account": 0, "include_change_addresses": False} + client.close() + + +def test_show_receive_addresses_null_result_returns_empty(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.show_receive_addresses(0) == [] + client.close() + + +def test_reveal_public_key(rpc_server) -> None: + result = {"public_key_hex": "02aabbccdd", "public_key_address": "tmltool1pubkey"} + srv = rpc_server(result=result) + client = Client(srv.url) + assert client.reveal_public_key(0, "tmltool1abc") == "02aabbccdd" + assert srv.capture.method == "address_reveal_public_key" + assert srv.capture.params == {"account": 0, "address": "tmltool1abc"} + client.close() + + +def test_reveal_public_key_result_decode() -> None: + got = RevealPublicKeyResult.from_json( + {"public_key_hex": "02aabbccdd", "public_key_address": "tmltool1pubkey"} + ) + assert got.public_key_hex == "02aabbccdd" + assert got.public_key_address == "tmltool1pubkey" + + +def test_reveal_public_key_null_result_raises(rpc_server) -> None: + """A JSON null result raises JSONRPCError, not an opaque TypeError.""" + srv = rpc_server(result=None) + client = Client(srv.url) + with pytest.raises(JSONRPCError, match="expected object result, got null"): + client.reveal_public_key(0, "tmltool1abc") + client.close() + + +def test_encrypt_private_keys(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.encrypt_private_keys("s3cr3t") + assert srv.capture.method == "wallet_encrypt_private_keys" + assert srv.capture.params == {"password": "s3cr3t"} + client.close() + + +def test_unlock_private_keys(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.unlock_private_keys("s3cr3t") + assert srv.capture.method == "wallet_unlock_private_keys" + assert srv.capture.params == {"password": "s3cr3t"} + client.close() + + +def test_lock_private_keys(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.lock_private_keys() + assert srv.capture.method == "wallet_lock_private_keys" + assert srv.capture.params == {} + client.close() diff --git a/tests/test_wallet_orders.py b/tests/test_wallet_orders.py new file mode 100644 index 0000000..96c1a2a --- /dev/null +++ b/tests/test_wallet_orders.py @@ -0,0 +1,396 @@ +"""DEX order tests for the wallet client. + +Mirrors go-sdk/wallet/orders_test.go: OutputValue wire shapes, the client-side +validation that never reaches the HTTP server, the order action params shapes, +and the CurrencyFilter encodings for list_all_active_orders. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from mintlayer.wallet import ( + Amount, + Client, + ConcludeOrderParams, + CreateOrderParams, + FillOrderParams, + FreezeOrderParams, + ListOrdersParams, + OrderCreated, + OutputValue, + RPCError, + coin_filter, + token_filter, +) + +_ZERO_OPTIONS = {"in_top_x_mb": None, "broadcast_to_mempool": None} + + +def _send_result(tx_id: str) -> dict: + return { + "tx_id": tx_id, + "fees": {"coins": {"atoms": "10000", "decimal": "0.0001"}, "tokens": {}}, + "broadcasted": True, + } + + +_OWN_ORDERS = [ + { + "order_id": "ord1owned", + "initially_asked": { + "type": "Token", + "content": {"id": "tok1askedabc", "amount": {"atoms": "100000000", "decimal": "1.0"}}, + }, + "initially_given": { + "type": "Coin", + "content": {"amount": {"atoms": "5000000000", "decimal": "50.0"}}, + }, + "existing_order_data": { + "ask_balance": {"atoms": "90000000", "decimal": "0.9"}, + "give_balance": {"atoms": "4500000000", "decimal": "45.0"}, + "is_frozen": True, + "creation_timestamp": {"timestamp": 1700000000}, + }, + "is_marked_as_frozen_in_wallet": True, + "is_marked_as_concluded_in_wallet": False, + }, + { + "order_id": "ord2owned", + "initially_asked": {"type": "Coin", "content": {"amount": {"atoms": "10"}}}, + "initially_given": { + "type": "Token", + "content": {"id": "tok2given", "amount": {"atoms": "20"}}, + }, + "existing_order_data": None, + "is_marked_as_frozen_in_wallet": False, + "is_marked_as_concluded_in_wallet": True, + }, +] + +_ACTIVE_ORDERS = [ + { + "order_id": "ord1active", + "initially_asked": {"type": "Coin", "content": {"amount": {"atoms": "1000000000000"}}}, + "initially_given": { + "type": "Token", + "content": {"id": "tok1givenabc", "amount": {"atoms": "2500000000"}}, + }, + "ask_balance": {"atoms": "1000000000000"}, + "give_balance": {"atoms": "2500000000"}, + "is_own": True, + } +] + + +# --- OutputValue wire encoding ------------------------------------------------ + + +def test_output_value_coin_wire_shape() -> None: + value = OutputValue.coins(atoms="1000000000000") + assert value.to_json() == {"type": "Coin", "content": {"amount": {"atoms": "1000000000000"}}} + # Coin values carry no token id key at all. + assert "id" not in value.to_json()["content"] + + +def test_output_value_token_wire_shape() -> None: + value = OutputValue.tokens("tok1abc", atoms="250000000") + assert value.to_json() == { + "type": "Token", + "content": {"id": "tok1abc", "amount": {"atoms": "250000000"}}, + } + + +def test_output_value_token_without_id_raises() -> None: + value = OutputValue.tokens("", atoms="2") + with pytest.raises(ValueError, match="requires TokenID"): + value.to_json() + + +def test_output_value_without_amount_raises() -> None: + with pytest.raises(ValueError, match="requires an amount"): + OutputValue.coins(atoms="").to_json() + + +def test_output_value_unknown_type_from_json_raises() -> None: + with pytest.raises(ValueError, match="unknown OutputValue type"): + OutputValue.from_json({"type": "NFT", "content": {"amount": {"atoms": "1"}}}) + + +@pytest.mark.parametrize( + "value", + [ + OutputValue.coins(atoms="1000000000000", decimal="10000.0"), + OutputValue.tokens("tok1roundtrip", atoms="999", decimal="0.000000999"), + ], + ids=["coin", "token"], +) +def test_output_value_round_trip(value: OutputValue) -> None: + assert OutputValue.from_json(value.to_json()) == value + + +# --- CreateOrder --------------------------------------------------------------- + + +def test_create_order_wire_shape(rpc_server) -> None: + srv = rpc_server(result={"order_id": "ord1created", "tx_id": "tx1created", "broadcasted": True}) + client = Client(srv.url) + got = client.create_order( + CreateOrderParams( + account=0, + ask=OutputValue.coins(atoms="1000000000000"), + give=OutputValue.tokens("tok1giveabc", atoms="250000000"), + conclude_address="tmltool1conclude", + ) + ) + assert got == OrderCreated(order_id="ord1created", tx_id="tx1created", broadcasted=True) + assert srv.capture.method == "order_create" + assert srv.capture.params == { + "account": 0, + "ask": {"type": "Coin", "content": {"amount": {"atoms": "1000000000000"}}}, + "give": { + "type": "Token", + "content": {"id": "tok1giveabc", "amount": {"atoms": "250000000"}}, + }, + "conclude_address": "tmltool1conclude", + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_create_order_token_side_without_id_no_request(rpc_server) -> None: + """A token side missing its TokenID fails before any HTTP request.""" + srv = rpc_server(result=None) + client = Client(srv.url) + params = CreateOrderParams( + account=0, + ask=OutputValue.coins(atoms="1"), + give=OutputValue.tokens("", atoms="2"), + conclude_address="tmltool1conclude", + ) + with pytest.raises(ValueError, match="requires TokenID"): + client.create_order(params) + assert srv.capture.request_count == 0 + client.close() + + +def test_create_order_missing_amount_no_request(rpc_server) -> None: + """An OutputValue with no amount fails before any HTTP request.""" + srv = rpc_server(result=None) + client = Client(srv.url) + params = CreateOrderParams( + account=0, + ask=OutputValue.coins(atoms=""), + give=OutputValue.coins(atoms="2"), + conclude_address="tmltool1conclude", + ) + with pytest.raises(ValueError, match="requires an amount"): + client.create_order(params) + assert srv.capture.request_count == 0 + client.close() + + +# --- ConcludeOrder / FillOrder / FreezeOrder ----------------------------------- + + +def test_conclude_order_output_address_null(rpc_server) -> None: + srv = rpc_server(result=_send_result("tx1c2")) + client = Client(srv.url) + got = client.conclude_order(ConcludeOrderParams(account=0, order_id="ord1c2")) + assert got.tx_id == "tx1c2" + assert got.broadcasted is True + assert srv.capture.method == "order_conclude" + # The key stays on the wire with an explicit null (never omitted). + assert srv.capture.params == { + "account": 0, + "order_id": "ord1c2", + "output_address": None, + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_conclude_order_with_output_address(rpc_server) -> None: + srv = rpc_server(result=_send_result("tx1conclude")) + client = Client(srv.url) + got = client.conclude_order( + ConcludeOrderParams(account=0, order_id="ord1conclude", output_address="tmltool1remainder") + ) + assert got.tx_id == "tx1conclude" + assert srv.capture.params["output_address"] == "tmltool1remainder" + client.close() + + +def test_fill_order_wire_shape(rpc_server) -> None: + srv = rpc_server(result=_send_result("tx1fill")) + client = Client(srv.url) + got = client.fill_order( + FillOrderParams( + account=0, + order_id="ord1fill", + fill_amount_in_ask_currency=Amount(atoms="100000000", decimal="1.0"), + ) + ) + assert got.tx_id == "tx1fill" + assert srv.capture.method == "order_fill" + # Key name "fill_amount_in_ask_currency" is pinned; output_address null. + assert srv.capture.params == { + "account": 0, + "order_id": "ord1fill", + "fill_amount_in_ask_currency": {"atoms": "100000000", "decimal": "1.0"}, + "output_address": None, + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_freeze_order_wire_shape(rpc_server) -> None: + srv = rpc_server(result=_send_result("tx1freeze")) + client = Client(srv.url) + got = client.freeze_order(FreezeOrderParams(account=0, order_id="ord1freeze")) + assert got.tx_id == "tx1freeze" + assert srv.capture.method == "order_freeze" + assert srv.capture.params == { + "account": 0, + "order_id": "ord1freeze", + "options": _ZERO_OPTIONS, + } + client.close() + + +# --- Listings ------------------------------------------------------------------ + + +def test_list_own_orders_decoding(rpc_server) -> None: + srv = rpc_server(result=_OWN_ORDERS) + client = Client(srv.url) + got = client.list_own_orders(0) + assert srv.capture.method == "order_list_own" + assert srv.capture.params == {"account": 0} + assert len(got) == 2 + + first = got[0] + assert first.order_id == "ord1owned" + # initially_asked: Token carrying both atoms and decimal. + assert first.initially_asked == OutputValue.tokens( + "tok1askedabc", atoms="100000000", decimal="1.0" + ) + # initially_given: Coin with no token id. + assert first.initially_given == OutputValue.coins(atoms="5000000000", decimal="50.0") + existing = first.existing_order_data + assert existing is not None + assert existing.ask_balance == Amount(atoms="90000000", decimal="0.9") + assert existing.give_balance == Amount(atoms="4500000000", decimal="45.0") + assert existing.is_frozen is True + assert existing.creation_timestamp.timestamp == 1700000000 + assert first.is_marked_as_frozen_in_wallet is True + assert first.is_marked_as_concluded_in_wallet is False + + second = got[1] + assert second.order_id == "ord2owned" + # A null existing_order_data decodes to None. + assert second.existing_order_data is None + assert second.is_marked_as_concluded_in_wallet is True + client.close() + + +def test_list_own_orders_null_result_returns_empty(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.list_own_orders(0) == [] + client.close() + + +def test_list_all_active_orders_nil_filters(rpc_server) -> None: + srv = rpc_server(result=[]) + client = Client(srv.url) + got = client.list_all_active_orders(ListOrdersParams(account=3)) + assert got == [] + assert srv.capture.method == "order_list_all_active" + # Nil filters are encoded as JSON null on both keys. + assert srv.capture.params == {"account": 3, "ask_currency": None, "give_currency": None} + client.close() + + +def test_list_all_active_orders_with_filters(rpc_server) -> None: + srv = rpc_server(result=_ACTIVE_ORDERS) + client = Client(srv.url) + got = client.list_all_active_orders( + ListOrdersParams( + account=0, + ask_currency=coin_filter(), + give_currency=token_filter("tok1givenabc"), + ) + ) + assert len(got) == 1 + order = got[0] + assert order.order_id == "ord1active" + assert order.initially_asked == OutputValue.coins(atoms="1000000000000") + assert order.initially_given == OutputValue.tokens("tok1givenabc", atoms="2500000000") + assert order.ask_balance == Amount(atoms="1000000000000") + assert order.give_balance == Amount(atoms="2500000000") + assert order.is_own is True + # Coin filters carry no content; token filters carry the id as content. + assert srv.capture.params == { + "account": 0, + "ask_currency": {"type": "Coin"}, + "give_currency": {"type": "Token", "content": "tok1givenabc"}, + } + client.close() + + +def test_token_filter_empty_id_raises() -> None: + with pytest.raises(ValueError, match="token id"): + token_filter("") + + +# --- Error propagation --------------------------------------------------------- + + +@pytest.mark.parametrize( + "invoke", + [ + pytest.param( + lambda c: c.create_order( + CreateOrderParams( + account=0, + ask=OutputValue.coins(atoms="1"), + give=OutputValue.coins(atoms="2"), + conclude_address="tmltool1conclude", + ) + ), + id="create_order", + ), + pytest.param( + lambda c: c.conclude_order(ConcludeOrderParams(account=0, order_id="ord1")), + id="conclude_order", + ), + pytest.param( + lambda c: c.fill_order( + FillOrderParams( + account=0, order_id="ord1", fill_amount_in_ask_currency=Amount(atoms="1") + ) + ), + id="fill_order", + ), + pytest.param( + lambda c: c.freeze_order(FreezeOrderParams(account=0, order_id="ord1")), + id="freeze_order", + ), + pytest.param(lambda c: c.list_own_orders(0), id="list_own_orders"), + pytest.param( + lambda c: c.list_all_active_orders(ListOrdersParams(account=0)), + id="list_all_active_orders", + ), + ], +) +def test_order_methods_rpc_error_propagates(rpc_server, invoke: Callable[[Client], object]) -> None: + srv = rpc_server(error=(-32000, "order failure")) + client = Client(srv.url) + with pytest.raises(RPCError) as excinfo: + invoke(client) + assert excinfo.value.code == -32000 + assert excinfo.value.message == "order failure" + client.close() diff --git a/tests/test_wallet_redaction.py b/tests/test_wallet_redaction.py new file mode 100644 index 0000000..16e723e --- /dev/null +++ b/tests/test_wallet_redaction.py @@ -0,0 +1,105 @@ +"""Security regression tests: repr() must never leak mnemonic/passphrase. + +These types carry secrets (BIP-39 mnemonics, wallet passphrases) that end up in +logs via repr()/str() (tracebacks, debug prints, repr of containing objects). +The custom ``__repr__`` implementations render ```` placeholders; +``to_json()`` still carries the real values for the JSON-RPC wire. +""" + +from __future__ import annotations + +from mintlayer.wallet import ( + CreateWalletParams, + CreateWalletResult, + MnemonicResult, + RecoverWalletParams, +) +from mintlayer.wallet.types import MnemonicContent + +_SECRET_MNEMONIC = "secret words" +_SECRET_PASSPHRASE = "pw" + + +def test_create_wallet_params_repr_redacts_secrets() -> None: + got = repr( + CreateWalletParams( + path="x", + store_seed_phrase=False, + mnemonic=_SECRET_MNEMONIC, + passphrase=_SECRET_PASSPHRASE, + ) + ) + assert "" in got + assert _SECRET_MNEMONIC not in got + assert _SECRET_PASSPHRASE not in got + + +def test_recover_wallet_params_repr_redacts_secrets() -> None: + got = repr( + RecoverWalletParams( + path="x", + store_seed_phrase=False, + mnemonic=_SECRET_MNEMONIC, + passphrase=_SECRET_PASSPHRASE, + ) + ) + assert got.count("") >= 2 + assert _SECRET_MNEMONIC not in got + assert _SECRET_PASSPHRASE not in got + + +def test_mnemonic_content_repr_is_fully_redacted() -> None: + assert repr(MnemonicContent(mnemonic="secret")) == "MnemonicContent(mnemonic='')" + + +def test_create_wallet_result_repr_redacts_nested_mnemonic() -> None: + """repr of a plain-dataclass container must redact transitively.""" + got = repr( + CreateWalletResult( + mnemonic=MnemonicResult( + type="Bip39", + content=MnemonicContent(mnemonic=_SECRET_MNEMONIC), + ) + ) + ) + assert "" in got + assert "MnemonicContent(mnemonic='')" in got + assert _SECRET_MNEMONIC not in got + + +def test_to_json_still_carries_real_values() -> None: + """The wire path is unaffected by repr redaction.""" + create = CreateWalletParams( + path="x", + store_seed_phrase=False, + mnemonic=_SECRET_MNEMONIC, + passphrase=_SECRET_PASSPHRASE, + ) + recover = RecoverWalletParams( + path="x", + store_seed_phrase=False, + mnemonic=_SECRET_MNEMONIC, + passphrase=_SECRET_PASSPHRASE, + ) + assert create.to_json() == { + "path": "x", + "store_seed_phrase": False, + "mnemonic": _SECRET_MNEMONIC, + "passphrase": _SECRET_PASSPHRASE, + "hardware_wallet": None, + } + assert recover.to_json() == { + "path": "x", + "store_seed_phrase": False, + "mnemonic": _SECRET_MNEMONIC, + "passphrase": _SECRET_PASSPHRASE, + "hardware_wallet": None, + } + + +def test_repr_unset_secrets_render_as_none_not_placeholder() -> None: + """Unset optional secrets show None; the placeholder is only for set values.""" + got = repr(CreateWalletParams(path="x", store_seed_phrase=False)) + assert "mnemonic=None" in got + assert "passphrase=None" in got + assert got.count("") == 0 diff --git a/tests/test_wallet_staking.py b/tests/test_wallet_staking.py new file mode 100644 index 0000000..089d111 --- /dev/null +++ b/tests/test_wallet_staking.py @@ -0,0 +1,274 @@ +"""Staking and delegation tests for the wallet client. + +Mirrors the "Staking" section of go-sdk/wallet/client_test.go, pinning the +pool params wire shape, the account-less pool-balance route, and the bare +string -> enum mapping of the staking status. +""" + +from __future__ import annotations + +import pytest + +from mintlayer.wallet import ( + Amount, + Client, + CreateDelegationParams, + CreateDelegationResult, + CreatePoolParams, + DecommissionParams, + DelegateParams, + DelegationInfo, + JSONRPCError, + OwnedPool, + StakingStatus, + WithdrawParams, +) + +_ZERO_OPTIONS = {"in_top_x_mb": None, "broadcast_to_mempool": None} + + +def _send_result(tx_id: str) -> dict: + return { + "tx_id": tx_id, + "fees": {"coins": {"atoms": "10000", "decimal": "0.0001"}, "tokens": {}}, + "broadcasted": True, + } + + +def test_create_stake_pool(rpc_server) -> None: + srv = rpc_server(result=_send_result("pool01")) + client = Client(srv.url) + got = client.create_stake_pool( + CreatePoolParams( + account=0, + amount=Amount(decimal="40000"), + cost_per_block=Amount(decimal="1"), + margin_ratio_per_thousand="5%", + decommission_address="tmltool1decom", + ) + ) + assert got.tx_id == "pool01" + assert got.broadcasted is True + assert srv.capture.method == "staking_create_pool" + # The margin ratio string passes through untouched; optional addresses + # stay explicit nulls. + assert srv.capture.params == { + "account": 0, + "amount": {"decimal": "40000"}, + "cost_per_block": {"decimal": "1"}, + "margin_ratio_per_thousand": "5%", + "decommission_address": "tmltool1decom", + "staker_address": None, + "vrf_public_key": None, + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_decommission_stake_pool(rpc_server) -> None: + srv = rpc_server(result=_send_result("decom01")) + client = Client(srv.url) + got = client.decommission_stake_pool( + DecommissionParams(account=0, pool_id="pool1abc", output_address="tmltool1dest") + ) + assert got.tx_id == "decom01" + assert srv.capture.method == "staking_decommission_pool" + assert srv.capture.params == { + "account": 0, + "pool_id": "pool1abc", + "output_address": "tmltool1dest", + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_list_owned_pools(rpc_server) -> None: + result = [ + { + "pool_id": "pool1abc", + "pledge": {"atoms": "40000000000000"}, + "balance": {"atoms": "50000000000000"}, + "margin_ratio_per_thousand": "5%", + "cost_per_block": {"atoms": "100000000"}, + } + ] + srv = rpc_server(result=result) + client = Client(srv.url) + got = client.list_owned_pools(0) + assert got == [ + OwnedPool( + pool_id="pool1abc", + pledge=Amount(atoms="40000000000000"), + balance=Amount(atoms="50000000000000"), + margin_ratio_per_thousand="5%", + cost_per_block=Amount(atoms="100000000"), + ) + ] + assert srv.capture.method == "staking_list_pools" + assert srv.capture.params == {"account": 0} + client.close() + + +def test_list_owned_pools_null_result_returns_empty(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + assert client.list_owned_pools(0) == [] + client.close() + + +@pytest.mark.parametrize( + "result", + [ + pytest.param({"pool_id": "pool1abc"}, id="dict"), + pytest.param("astring", id="bare-string"), + ], +) +def test_list_owned_pools_non_list_result_raises_jsonrpc_error(rpc_server, result: object) -> None: + """A non-list, non-null result is a protocol error, not a decode crash.""" + srv = rpc_server(result=result) + client = Client(srv.url) + with pytest.raises(JSONRPCError, match="staking_list_pools: expected list result"): + client.list_owned_pools(0) + client.close() + + +def test_get_pool_balance_account_not_sent(rpc_server) -> None: + srv = rpc_server(result={"balance": {"atoms": "50000000000000", "decimal": "500000.0"}}) + client = Client(srv.url) + got = client.get_pool_balance(0, "pool1abc") + assert got == Amount(atoms="50000000000000", decimal="500000.0") + assert srv.capture.method == "staking_pool_balance" + # The daemon route takes only the pool id: the account argument is + # accepted for API consistency but never serialised. + assert srv.capture.params == {"pool_id": "pool1abc"} + client.close() + + +def test_start_staking(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.start_staking(0) + assert srv.capture.method == "staking_start" + assert srv.capture.params == {"account": 0} + client.close() + + +def test_stop_staking(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.stop_staking(0) + assert srv.capture.method == "staking_stop" + assert srv.capture.params == {"account": 0} + client.close() + + +def test_get_staking_status_active(rpc_server) -> None: + srv = rpc_server(result="Staking") + client = Client(srv.url) + assert client.get_staking_status(0) is StakingStatus.ACTIVE + assert srv.capture.method == "staking_status" + assert srv.capture.params == {"account": 0} + client.close() + + +def test_get_staking_status_inactive(rpc_server) -> None: + srv = rpc_server(result="NotStaking") + client = Client(srv.url) + assert client.get_staking_status(0) is StakingStatus.INACTIVE + client.close() + + +def test_get_staking_status_unrecognized_raises_jsonrpc_error(rpc_server) -> None: + """An unrecognized status string raises the documented JSONRPCError + contract — including the offending value and every valid enum value — + not a bare ValueError from the enum constructor. + """ + srv = rpc_server(result="MAYBE") + client = Client(srv.url) + with pytest.raises(JSONRPCError, match="unexpected status") as excinfo: + client.get_staking_status(0) + message = str(excinfo.value) + assert "'MAYBE'" in message + for member in StakingStatus: + assert member.value in message + client.close() + + +def test_create_delegation(rpc_server) -> None: + srv = rpc_server(result={"delegation_id": "deleg1abc", "tx_id": "delegtx01"}) + client = Client(srv.url) + got = client.create_delegation( + CreateDelegationParams(account=0, address="tmltool1owner", pool_id="pool1abc") + ) + assert got == CreateDelegationResult(delegation_id="deleg1abc", tx_id="delegtx01") + assert srv.capture.method == "delegation_create" + assert srv.capture.params == { + "account": 0, + "address": "tmltool1owner", + "pool_id": "pool1abc", + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_delegate_staking(rpc_server) -> None: + srv = rpc_server(result=_send_result("stake01")) + client = Client(srv.url) + got = client.delegate_staking( + DelegateParams(account=0, amount=Amount(decimal="1000"), delegation_id="deleg1abc") + ) + assert got.tx_id == "stake01" + assert srv.capture.method == "delegation_stake" + assert srv.capture.params == { + "account": 0, + "amount": {"decimal": "1000"}, + "delegation_id": "deleg1abc", + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_withdraw_from_delegation(rpc_server) -> None: + srv = rpc_server(result=_send_result("withdraw01")) + client = Client(srv.url) + got = client.withdraw_from_delegation( + WithdrawParams( + account=0, + address="tmltool1dest", + amount=Amount(decimal="500"), + delegation_id="deleg1abc", + ) + ) + assert got.tx_id == "withdraw01" + assert srv.capture.method == "delegation_withdraw" + assert srv.capture.params == { + "account": 0, + "address": "tmltool1dest", + "amount": {"decimal": "500"}, + "delegation_id": "deleg1abc", + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_list_delegations(rpc_server) -> None: + result = [ + { + "delegation_id": "deleg1abc", + "pool_id": "pool1abc", + "balance": {"atoms": "1000000000000"}, + } + ] + srv = rpc_server(result=result) + client = Client(srv.url) + got = client.list_delegations(0) + assert got == [ + DelegationInfo( + delegation_id="deleg1abc", + pool_id="pool1abc", + balance=Amount(atoms="1000000000000"), + ) + ] + assert srv.capture.method == "delegation_list_ids" + assert srv.capture.params == {"account": 0} + client.close() diff --git a/tests/test_wallet_tokens.py b/tests/test_wallet_tokens.py new file mode 100644 index 0000000..cab8c6a --- /dev/null +++ b/tests/test_wallet_tokens.py @@ -0,0 +1,243 @@ +"""Token method tests for the wallet client. + +Mirrors the "Tokens" section of go-sdk/wallet/client_test.go, pinning the +nested metadata wire shapes, both TokenSupply encodings, and the deliberate +``account_index`` key of lock_token_supply. +""" + +from __future__ import annotations + +from mintlayer.wallet import ( + Amount, + ChangeAuthorityParams, + Client, + FreezeParams, + IssueNFTParams, + IssueTokenParams, + IssueTokenResult, + LockSupplyParams, + MintParams, + NFTMetadata, + TokenMetadata, + TokenSendParams, + TokenSupply, + UnfreezeParams, + UnmintParams, +) + +_ZERO_OPTIONS = {"in_top_x_mb": None, "broadcast_to_mempool": None} + + +def _send_result(tx_id: str) -> dict: + return { + "tx_id": tx_id, + "fees": {"coins": {"atoms": "10000", "decimal": "0.0001"}, "tokens": {}}, + "broadcasted": True, + } + + +def test_issue_token_lockable_supply(rpc_server) -> None: + srv = rpc_server(result={"token_id": "tok1abc", "tx_id": "issue01"}) + client = Client(srv.url) + got = client.issue_token( + IssueTokenParams( + account=0, + destination_address="tmltool1auth", + metadata=TokenMetadata( + token_ticker="MYTKN", + number_of_decimals=8, + metadata_uri="https://example.com/token", + token_supply=TokenSupply(type="Lockable"), + is_freezable=False, + ), + ) + ) + assert got == IssueTokenResult(token_id="tok1abc", tx_id="issue01") + assert srv.capture.method == "token_issue_new" + # Lockable supply serialises without a content key. + assert srv.capture.params["metadata"]["token_supply"] == {"type": "Lockable"} + client.close() + + +def test_issue_token_fixed_supply_wire_shape(rpc_server) -> None: + srv = rpc_server(result={"token_id": "tok1abc", "tx_id": "issue01"}) + client = Client(srv.url) + client.issue_token( + IssueTokenParams( + account=0, + destination_address="tmltool1auth", + metadata=TokenMetadata( + token_ticker="MYTKN", + number_of_decimals=8, + metadata_uri="https://example.com/token", + token_supply=TokenSupply(type="Fixed", content=Amount(atoms="1000000")), + is_freezable=True, + ), + ) + ) + assert srv.capture.method == "token_issue_new" + # Fixed supply carries its amount inside a content object. + assert srv.capture.params == { + "account": 0, + "destination_address": "tmltool1auth", + "metadata": { + "token_ticker": "MYTKN", + "number_of_decimals": 8, + "metadata_uri": "https://example.com/token", + "token_supply": {"type": "Fixed", "content": {"atoms": "1000000"}}, + "is_freezable": True, + }, + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_issue_nft_metadata_nulls(rpc_server) -> None: + srv = rpc_server(result={"token_id": "nft1abc", "tx_id": "nftissue01"}) + client = Client(srv.url) + got = client.issue_nft( + IssueNFTParams( + account=0, + destination_address="tmltool1dest", + metadata=NFTMetadata( + media_hash="a3f1e2d9c4", + name="Sunset #1", + description="A photograph of a sunset", + ticker="SUNST", + ), + ) + ) + assert got.token_id == "nft1abc" + assert srv.capture.method == "token_nft_issue_new" + # Optional NFT metadata keys are always present, null when unset. + assert srv.capture.params["metadata"] == { + "media_hash": "a3f1e2d9c4", + "name": "Sunset #1", + "description": "A photograph of a sunset", + "ticker": "SUNST", + "creator": None, + "icon_uri": None, + "media_uri": None, + "additional_metadata_uri": None, + } + client.close() + + +def test_mint_tokens(rpc_server) -> None: + srv = rpc_server(result=_send_result("mint01")) + client = Client(srv.url) + got = client.mint_tokens( + MintParams( + account=0, + token_id="tok1abc", + address="tmltool1dest", + amount=Amount(decimal="1000000"), + ) + ) + assert got.tx_id == "mint01" + assert srv.capture.method == "token_mint" + assert srv.capture.params == { + "account": 0, + "token_id": "tok1abc", + "address": "tmltool1dest", + "amount": {"decimal": "1000000"}, + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_unmint_tokens(rpc_server) -> None: + srv = rpc_server(result=_send_result("unmint01")) + client = Client(srv.url) + got = client.unmint_tokens( + UnmintParams(account=0, token_id="tok1abc", amount=Amount(decimal="5000")) + ) + assert got.tx_id == "unmint01" + assert srv.capture.method == "token_unmint" + assert srv.capture.params == { + "account": 0, + "token_id": "tok1abc", + "amount": {"decimal": "5000"}, + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_lock_token_supply_uses_account_index_key(rpc_server) -> None: + srv = rpc_server(result=_send_result("lock01")) + client = Client(srv.url) + got = client.lock_token_supply(LockSupplyParams(account_index=0, token_id="tok1abc")) + assert got.tx_id == "lock01" + assert srv.capture.method == "token_lock_supply" + # The daemon route expects "account_index", never "account". + assert srv.capture.params == { + "account_index": 0, + "token_id": "tok1abc", + "options": _ZERO_OPTIONS, + } + assert "account" not in srv.capture.params + client.close() + + +def test_freeze_token(rpc_server) -> None: + srv = rpc_server(result=_send_result("freeze01")) + client = Client(srv.url) + got = client.freeze_token(FreezeParams(account=0, token_id="tok1abc", is_unfreezable=True)) + assert got.tx_id == "freeze01" + assert srv.capture.method == "token_freeze" + assert srv.capture.params == { + "account": 0, + "token_id": "tok1abc", + "is_unfreezable": True, + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_unfreeze_token(rpc_server) -> None: + srv = rpc_server(result=_send_result("unfreeze01")) + client = Client(srv.url) + got = client.unfreeze_token(UnfreezeParams(account=0, token_id="tok1abc")) + assert got.tx_id == "unfreeze01" + assert srv.capture.method == "token_unfreeze" + assert srv.capture.params == { + "account": 0, + "token_id": "tok1abc", + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_change_token_authority(rpc_server) -> None: + srv = rpc_server(result=_send_result("chauth01")) + client = Client(srv.url) + got = client.change_token_authority( + ChangeAuthorityParams(account=0, token_id="tok1abc", address="tmltool1newauth") + ) + assert got.tx_id == "chauth01" + assert srv.capture.method == "token_change_authority" + assert srv.capture.params == { + "account": 0, + "token_id": "tok1abc", + "address": "tmltool1newauth", + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_send_token_alias_shares_token_send_method(rpc_server) -> None: + srv = rpc_server(result=_send_result("sendtok01")) + client = Client(srv.url) + got = client.send_token( + TokenSendParams( + account=0, + token_id="tok1abc", + address="tmltool1dest", + amount=Amount(decimal="100"), + ) + ) + assert got.tx_id == "sendtok01" + # Both spellings hit the same daemon route. + assert srv.capture.method == "token_send" + assert srv.capture.params["token_id"] == "tok1abc" + client.close() diff --git a/tests/test_wallet_transactions.py b/tests/test_wallet_transactions.py new file mode 100644 index 0000000..0dd37c4 --- /dev/null +++ b/tests/test_wallet_transactions.py @@ -0,0 +1,310 @@ +"""Transaction method tests for the wallet client. + +Mirrors the "Transactions" section of go-sdk/wallet/client_test.go, pinning +the exact RPC method names, the always-present TxOptions keys, and the +omitempty behaviour of ``selected_utxos``. +""" + +from __future__ import annotations + +import pytest + +from mintlayer.wallet import ( + Amount, + Client, + ComposeParams, + FeesBreakdown, + SendParams, + SendResult, + SignedTx, + SubmitResult, + SweepParams, + TokenSendParams, + UTXOSpendParams, + WalletTx, +) +from mintlayer.wallet.types import Outpoint, OutpointSourceID, Timestamp, TxStats + +_ZERO_OPTIONS = {"in_top_x_mb": None, "broadcast_to_mempool": None} + + +def test_outpoint_round_trip() -> None: + outpoint = Outpoint( + source_id=OutpointSourceID(type="Transaction", content={"tx_id": "beefcafe01"}), + index=3, + ) + assert Outpoint.from_json(outpoint.to_json()) == outpoint + + +def test_outpoint_source_id_block_reward_round_trip() -> None: + source = OutpointSourceID(type="BlockReward", content={"block_id": "aabb0102"}) + assert OutpointSourceID.from_json(source.to_json()) == source + + +def _send_result(tx_id: str = "cafebabe") -> dict: + return { + "tx_id": tx_id, + "fees": {"coins": {"atoms": "10000", "decimal": "0.0001"}, "tokens": {}}, + "broadcasted": True, + } + + +def test_address_send(rpc_server) -> None: + srv = rpc_server(result=_send_result()) + client = Client(srv.url) + got = client.address_send( + SendParams(account=0, address="tmltool1dest", amount=Amount(decimal="10.5")) + ) + assert got == SendResult( + tx_id="cafebabe", + fees=FeesBreakdown(coins=Amount(atoms="10000", decimal="0.0001"), tokens={}), + broadcasted=True, + ) + assert srv.capture.method == "address_send" + client.close() + + +@pytest.mark.parametrize("selected", [None, []], ids=["none", "empty_list"]) +def test_address_send_selected_utxos_key_absent(rpc_server, selected) -> None: + """None and [] both omit ``selected_utxos`` from the wire (omitempty).""" + srv = rpc_server(result=_send_result()) + client = Client(srv.url) + client.address_send( + SendParams( + account=0, + address="tmltool1dest", + amount=Amount(atoms="1000000000000"), + selected_utxos=selected, + ) + ) + assert srv.capture.method == "address_send" + assert srv.capture.params == { + "account": 0, + "address": "tmltool1dest", + "amount": {"atoms": "1000000000000"}, + "options": _ZERO_OPTIONS, + } + assert "selected_utxos" not in srv.capture.params + client.close() + + +def test_address_send_selected_utxos_outpoint_shape(rpc_server) -> None: + srv = rpc_server(result=_send_result()) + client = Client(srv.url) + tx_id = "beefcafe01" + client.address_send( + SendParams( + account=0, + address="tmltool1dest", + amount=Amount(atoms="1000000000000"), + selected_utxos=[ + Outpoint( + source_id=OutpointSourceID(type="Transaction", content={"tx_id": tx_id}), + index=0, + ) + ], + ) + ) + assert srv.capture.params["selected_utxos"] == [ + {"source_id": {"type": "Transaction", "content": {"tx_id": tx_id}}, "index": 0} + ] + client.close() + + +def test_token_send(rpc_server) -> None: + srv = rpc_server( + result={"tx_id": "aabbccdd", "fees": {"coins": {}, "tokens": {}}, "broadcasted": True} + ) + client = Client(srv.url) + got = client.token_send( + TokenSendParams( + account=0, + token_id="mytoken1abc", + address="tmltool1dest", + amount=Amount(decimal="100"), + ) + ) + assert got.tx_id == "aabbccdd" + assert got.broadcasted is True + assert srv.capture.method == "token_send" + assert srv.capture.params == { + "account": 0, + "token_id": "mytoken1abc", + "address": "tmltool1dest", + "amount": {"decimal": "100"}, + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_sweep_spendable(rpc_server) -> None: + srv = rpc_server(result=_send_result("sweep01")) + client = Client(srv.url) + got = client.sweep_spendable( + SweepParams(account=0, destination_address="tmltool1dest", all=True) + ) + assert got.tx_id == "sweep01" + assert got.broadcasted is True + assert srv.capture.method == "address_sweep_spendable" + # from_addresses stays an explicit empty list on the wire. + assert srv.capture.params == { + "account": 0, + "destination_address": "tmltool1dest", + "from_addresses": [], + "all": True, + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_spend_utxo(rpc_server) -> None: + srv = rpc_server(result=_send_result("utxo01")) + client = Client(srv.url) + got = client.spend_utxo( + UTXOSpendParams( + account=0, + utxo=Outpoint( + source_id=OutpointSourceID(type="Transaction", content={"tx_id": "beefcafe01"}), + index=1, + ), + output_address="tmltool1dest", + ) + ) + assert got.tx_id == "utxo01" + assert srv.capture.method == "utxo_spend" + assert srv.capture.params == { + "account": 0, + "utxo": { + "source_id": {"type": "Transaction", "content": {"tx_id": "beefcafe01"}}, + "index": 1, + }, + "output_address": "tmltool1dest", + "htlc_secret": None, + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_compose_transaction(rpc_server) -> None: + srv = rpc_server(result={"hex": "deadbeef", "fees": {"coins": {"atoms": "5000"}, "tokens": {}}}) + client = Client(srv.url) + got = client.compose_transaction( + ComposeParams(outputs=[{"output": "raw"}], only_transaction=True) + ) + assert got.hex == "deadbeef" + assert got.fees == FeesBreakdown(coins=Amount(atoms="5000"), tokens={}) + assert srv.capture.method == "transaction_compose" + assert srv.capture.params == { + "inputs": [], + "outputs": [{"output": "raw"}], + "htlc_secrets": None, + "only_transaction": True, + } + client.close() + + +def test_sign_raw_transaction(rpc_server) -> None: + srv = rpc_server(result={"hex": "signed01", "current_signatures": []}) + client = Client(srv.url) + got = client.sign_raw_transaction(0, "unsigned01") + assert got == SignedTx(hex="signed01", current_signatures=[]) + assert srv.capture.method == "account_sign_raw_transaction" + # The zero TxOptions shape is pinned on the wire. + assert srv.capture.params == { + "account": 0, + "raw_tx": "unsigned01", + "options": _ZERO_OPTIONS, + } + client.close() + + +def test_inspect_transaction_fees_null(rpc_server) -> None: + srv = rpc_server(result={"stats": {"num_inputs": 2, "total_signatures": 2}, "fees": None}) + client = Client(srv.url) + got = client.inspect_transaction("cafebabe") + assert got.stats == TxStats(num_inputs=2, total_signatures=2) + assert got.fees is None + assert srv.capture.method == "transaction_inspect" + assert srv.capture.params == {"transaction": "cafebabe"} + client.close() + + +def test_inspect_transaction_with_fees(rpc_server) -> None: + srv = rpc_server( + result={ + "stats": {"num_inputs": 2, "total_signatures": 2}, + "fees": {"coins": {"atoms": "10000", "decimal": "0.0001"}, "tokens": {}}, + } + ) + client = Client(srv.url) + got = client.inspect_transaction("cafebabe") + assert got.fees == FeesBreakdown(coins=Amount(atoms="10000", decimal="0.0001"), tokens={}) + client.close() + + +def test_submit_transaction(rpc_server) -> None: + srv = rpc_server(result={"tx_id": "submitted01"}) + client = Client(srv.url) + got = client.submit_transaction("cafebabe01020304") + assert got == SubmitResult(tx_id="submitted01") + assert srv.capture.method == "node_submit_transaction" + # Trust policy is hardcoded to "Trusted" by the daemon route. + assert srv.capture.params == { + "tx": "cafebabe01020304", + "do_not_store": False, + "options": {"trust_policy": "Trusted"}, + } + client.close() + + +def test_list_transactions_by_address(rpc_server) -> None: + result = [{"id": "tx1", "height": 100, "timestamp": {"timestamp": 1700000000}}] + srv = rpc_server(result=result) + client = Client(srv.url) + got = client.list_transactions_by_address(0, None, 20) + assert got == [WalletTx(id="tx1", height=100, timestamp=Timestamp(timestamp=1700000000))] + assert srv.capture.method == "transaction_list_by_address" + assert srv.capture.params == {"account": 0, "address": None, "limit": 20} + client.close() + + +def test_list_pending_transactions(rpc_server) -> None: + srv = rpc_server(result=["tx1", "tx2"]) + client = Client(srv.url) + assert client.list_pending_transactions(0) == ["tx1", "tx2"] + assert srv.capture.method == "transaction_list_pending" + assert srv.capture.params == {"account": 0} + client.close() + + +def test_get_transaction_raw_passthrough(rpc_server) -> None: + result = {"id": "tx1", "height": 100} + srv = rpc_server(result=result) + client = Client(srv.url) + assert client.get_transaction(0, "tx1") == result + assert srv.capture.method == "transaction_get" + assert srv.capture.params == {"account": 0, "transaction_id": "tx1"} + client.close() + + +def test_abandon_transaction(rpc_server) -> None: + srv = rpc_server(result=None) + client = Client(srv.url) + client.abandon_transaction(0, "tx1") + assert srv.capture.method == "transaction_abandon" + assert srv.capture.params == {"account": 0, "transaction_id": "tx1"} + client.close() + + +def test_deposit_data(rpc_server) -> None: + srv = rpc_server(result=_send_result("data01")) + client = Client(srv.url) + got = client.deposit_data(0, "68656c6c6f") + assert got.tx_id == "data01" + assert srv.capture.method == "address_deposit_data" + assert srv.capture.params == { + "account": 0, + "data": "68656c6c6f", + "options": _ZERO_OPTIONS, + } + client.close() diff --git a/tests/test_wallet_transport.py b/tests/test_wallet_transport.py new file mode 100644 index 0000000..6a684da --- /dev/null +++ b/tests/test_wallet_transport.py @@ -0,0 +1,102 @@ +"""Transport-level tests for the wallet client. + +Mirrors the "Transport" and "Error path" sections of +go-sdk/wallet/client_test.go: basic auth, JSON-RPC error objects, and +monotonically increasing request ids. +""" + +from __future__ import annotations + +import base64 +import json + +import pytest + +from mintlayer.wallet import BestBlock, Client, RPCError + +_BLOCK_RESULT = {"height": 100, "id": "aabbccdd"} + + +class TestClientConstruction: + def test_defaults(self) -> None: + client = Client("http://127.0.0.1:3034") + assert client._rpc.endpoint == "http://127.0.0.1:3034" + assert client._rpc.username == "" + assert client._rpc.password == "" + assert client._rpc.timeout == 30.0 + client.close() + + def test_timeout_parameter(self) -> None: + client = Client("http://127.0.0.1:3034", timeout=5.0) + assert client._rpc.timeout == 5.0 + client.close() + + +def test_basic_auth_header(rpc_server) -> None: + """The Authorization header is sent when a username is set.""" + srv = rpc_server(result=_BLOCK_RESULT) + client = Client(srv.url, username="alice", password="secret") + assert client.best_block() == BestBlock(height=100, id="aabbccdd") + expected = "Basic " + base64.b64encode(b"alice:secret").decode("ascii") + assert srv.capture.headers.get("authorization") == expected + client.close() + + +def test_no_auth_header_without_username(rpc_server) -> None: + """No Authorization header when the username is empty.""" + srv = rpc_server(result=_BLOCK_RESULT) + client = Client(srv.url) + client.best_block() + assert "authorization" not in srv.capture.headers + client.close() + + +def test_rpc_error(rpc_server) -> None: + """A JSON-RPC error object in the body raises RPCError.""" + srv = rpc_server(error=(-32601, "method not found")) + client = Client(srv.url) + with pytest.raises(RPCError) as excinfo: + client.best_block() + err = excinfo.value + assert err.code == -32601 + assert err.message == "method not found" + assert str(err) == "RPC error -32601: method not found" + client.close() + + +def test_request_wire_shape(rpc_server) -> None: + """Requests POST the full JSON-RPC 2.0 envelope straight to the endpoint.""" + srv = rpc_server(result=_BLOCK_RESULT) + client = Client(srv.url) + client.best_block() + assert srv.capture.method == "wallet_best_block" + assert srv.capture.params == {} + assert srv.capture.path == "/" + assert srv.capture.request_count == 1 + assert json.loads(srv.capture.raw_body) == { + "jsonrpc": "2.0", + "id": 1, + "method": "wallet_best_block", + "params": {}, + } + client.close() + + +def test_request_ids_monotonic(rpc_server) -> None: + srv = rpc_server(result=_BLOCK_RESULT) + client = Client(srv.url) + client.best_block() + client.best_block() + assert [payload["id"] for payload in srv.capture.payloads] == [1, 2] + client.close() + + +def test_context_manager_calls_close(rpc_server, monkeypatch) -> None: + srv = rpc_server(result=_BLOCK_RESULT) + client = Client(srv.url) + closed: list[bool] = [] + monkeypatch.setattr(client, "close", lambda: closed.append(True)) + with client as entered: + assert entered is client + entered.best_block() + assert closed == [True] diff --git a/tests/test_wasm_addresses.py b/tests/test_wasm_addresses.py new file mode 100644 index 0000000..7584602 --- /dev/null +++ b/tests/test_wasm_addresses.py @@ -0,0 +1,69 @@ +"""Tests for WASM address encoding (mirrors go-sdk/wasm/addresses.go).""" + +from __future__ import annotations + +import pytest +from wasm_helpers import derive + +from mintlayer.wasm import Client, Network, WasmError + + +def test_pubkey_to_pubkeyhash_address(wasm: Client) -> None: + _acct, _recv, pub, addr = derive(wasm) + assert addr + again = wasm.pubkey_to_pubkeyhash_address(pub, Network.MAINNET) + assert addr == again # deterministic + assert addr.startswith("mtc1"), f"unexpected mainnet address prefix: {addr}" + + +def test_address_depends_on_network(wasm: Client) -> None: + _acct, _recv, pub, mainnet = derive(wasm) + testnet = wasm.pubkey_to_pubkeyhash_address(pub, Network.TESTNET) + assert mainnet != testnet + + +def test_encode_destination(wasm: Client) -> None: + _acct, _recv, _pub, addr = derive(wasm) + dest = wasm.encode_destination(addr, Network.MAINNET) + assert isinstance(dest, bytes) and len(dest) > 0 + assert dest == wasm.encode_destination(addr, Network.MAINNET) + + +def test_multisig_challenge_roundtrip(wasm: Client) -> None: + pub1 = wasm.public_key_from_private_key(wasm.make_private_key()) + pub2 = wasm.public_key_from_private_key(wasm.make_private_key()) + challenge = wasm.encode_multisig_challenge(pub1 + pub2, 2, Network.MAINNET) + assert isinstance(challenge, bytes) and len(challenge) > 0 + address = wasm.multisig_challenge_to_address(challenge, Network.MAINNET) + assert address.startswith("mmtc1"), f"unexpected multisig address prefix: {address}" + assert address == wasm.multisig_challenge_to_address(challenge, Network.MAINNET) + + +def test_multisig_address_differs_from_single_key_address(wasm: Client) -> None: + pub1 = wasm.public_key_from_private_key(wasm.make_private_key()) + pub2 = wasm.public_key_from_private_key(wasm.make_private_key()) + multisig = wasm.multisig_challenge_to_address( + wasm.encode_multisig_challenge(pub1 + pub2, 2, Network.MAINNET), Network.MAINNET + ) + single = wasm.pubkey_to_pubkeyhash_address(pub1, Network.MAINNET) + assert multisig != single + + +def test_invalid_public_key_address_raises(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.pubkey_to_pubkeyhash_address(b"abc", Network.MAINNET) + + +def test_encode_destination_invalid_address_raises(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_destination("not-an-address", Network.MAINNET) + + +def test_multisig_challenge_invalid_pubkeys_raise(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_multisig_challenge(b"\x00" * 10, 1, Network.MAINNET) + + +def test_multisig_challenge_to_address_garbage_raises(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.multisig_challenge_to_address(b"\xff\xff", Network.MAINNET) diff --git a/tests/test_wasm_array_rollback.py b/tests/test_wasm_array_rollback.py new file mode 100644 index 0000000..33c592f --- /dev/null +++ b/tests/test_wasm_array_rollback.py @@ -0,0 +1,219 @@ +"""Tests for the pre-call rollback branches of the externref array writers. + +Fault injection (mirroring test_wasm_lifecycle.py) forces mid-loop failures +in ``_write_string_array`` / ``_write_uint8_array_array`` (``table.set`` +raising) and in ``estimate_transaction_size`` (``_write_bytes`` raising on +the outputs write) so the host-side rollback branches run: the +already-allocated externref table slots must be released via +``_dealloc_indices`` (reading back null and handed out again by the +allocator), backing buffers freed, the original exception re-raised, and the +client left fully usable. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from wasm_helpers import Wallet, fake_input +from wasmtime import Val + +from mintlayer.wasm import Amount, Client, Network + +# ── fault-injection helpers ─────────────────────────────────────────────────── + + +class TableSetFailsNth: + """Proxy for the externref table that fails the Nth ``set()`` call. + + Mirrors the ``MemorySpy`` pattern (instance attribute replacing the real + object): everything except ``set()`` is delegated to the real wasmtime + table. Slot indices are recorded for every ``set()`` attempt, including + the failing one, so tests know exactly which slots the rollback must + release. + """ + + def __init__(self, real: Any, fail_on: int) -> None: + self._real = real + self._fail_on = fail_on + self._calls = 0 + self.attempted_indices: list[int] = [] + self.error = RuntimeError("injected table.set failure") + + def set(self, store: Any, idx: int, value: Any) -> None: + self._calls += 1 + self.attempted_indices.append(idx) + if self._calls == self._fail_on: + raise self.error + self._real.set(store, idx, value) + + def __getattr__(self, name: str) -> Any: + return getattr(self._real, name) + + +def _is_null_anyref(value: object) -> bool: + """True when wasmtime reports a table slot as null/undefined (released).""" + if value is None: + return True + if isinstance(value, Val): + return value.__dict__.get("_val", object()) is None + return False + + +def _alloc_reusable_slot(c: Client) -> int: + """Allocate one externref-table slot from the reusable (recycled) region. + + Mirrors the helper in test_wasm_transactions.py: the module's lowest + table indices form a permanent slab that ``__externref_table_dealloc`` + deliberately does not recycle. Probing until a freed slot is handed out + again guarantees the free list is active, making later slot-reuse + assertions deterministic regardless of prior table state. + """ + for _ in range(512): + probe = c._invoke1("__externref_table_alloc") + c._dealloc_indices([probe]) + if c._invoke1("__externref_table_alloc") == probe: + return probe + raise AssertionError("no reusable externref table slot found") + + +def _assert_rolled_back_slots_released(c: Client, released: set[int]) -> None: + """Every rolled-back slot reads back null and is handed out again.""" + for idx in released: + assert _is_null_anyref(c.table.get(c.store, idx)), f"slot {idx} still holds a value" + for _ in range(512): + probe = c._invoke1("__externref_table_alloc") + c._dealloc_indices([probe]) + if probe in released: + return + raise AssertionError(f"none of the rolled-back slots {sorted(released)} was reused") + + +# ── _write_string_array rollback ────────────────────────────────────────────── + + +def test_write_string_array_rollback_releases_slots_on_mid_loop_failure( + wasm: Client, monkeypatch: pytest.MonkeyPatch +) -> None: + """``table.set`` raising on the 2nd element rolls back every slot so far.""" + _alloc_reusable_slot(wasm) # make slot-reuse assertions deterministic + table_spy = TableSetFailsNth(wasm.table, fail_on=2) + monkeypatch.setattr(wasm, "table", table_spy) + + with pytest.raises(RuntimeError) as excinfo: + wasm._write_string_array(["a", "b", "c"]) + + # The original injected exception propagated: the rollback neither + # swallowed nor replaced it. + assert excinfo.value is table_spy.error + # Two slots were allocated before the failure (2nd set() raised); both + # were appended to the indices list and must have been released. + assert len(table_spy.attempted_indices) == 2 + _assert_rolled_back_slots_released(wasm, set(table_spy.attempted_indices)) + + +# ── _write_uint8_array_array rollback ───────────────────────────────────────── + + +def test_write_uint8_array_array_rollback_releases_slots_and_buffers( + wasm: Client, monkeypatch: pytest.MonkeyPatch +) -> None: + """``table.set`` raising rolls back slots AND frees the backing buffers.""" + _alloc_reusable_slot(wasm) + table_spy = TableSetFailsNth(wasm.table, fail_on=2) + monkeypatch.setattr(wasm, "table", table_spy) + + writes: list[tuple[int, int]] = [] + frees: list[tuple[int, int]] = [] + original_write_bytes = wasm._write_bytes + original_free_wasm = wasm._free_wasm + + def recording_write_bytes(data: bytes) -> tuple[int, int]: + result = original_write_bytes(data) + writes.append(result) + return result + + def recording_free_wasm(ptr: int, size: int, align: int = 1) -> None: + frees.append((ptr, size)) + original_free_wasm(ptr, size, align) + + monkeypatch.setattr(wasm, "_write_bytes", recording_write_bytes) + monkeypatch.setattr(wasm, "_free_wasm", recording_free_wasm) + + with pytest.raises(RuntimeError) as excinfo: + wasm._write_uint8_array_array([b"slice-0", b"slice-1", b"slice-2"]) + + assert excinfo.value is table_spy.error + # One backing buffer per attempted slice: both were written before the + # failure and both must have been freed by the rollback (buffer free is + # best-effort host-side; the free call itself must have been made). + assert len(writes) == 2 + for buffer in writes: + assert buffer in frees + # Every externref slot touched before the failure is released too. + assert len(table_spy.attempted_indices) == 2 + _assert_rolled_back_slots_released(wasm, set(table_spy.attempted_indices)) + + +# ── estimate_transaction_size narrow rollback ───────────────────────────────── + + +def test_estimate_transaction_size_rollback_leaves_client_usable( + wasm: Client, monkeypatch: pytest.MonkeyPatch +) -> None: + """``_write_bytes`` failing on the outputs write rolls the dest slots back. + + The narrow rollback in ``estimate_transaction_size`` must release the + destination slots allocated by ``_write_string_array`` (the callee never + ran, so the host still owns them) and leave the client fully usable. + """ + wallet = Wallet(wasm) + inputs = fake_input(wasm) + outputs = wasm.encode_output_transfer( + Amount.from_atoms("100000000000"), wallet.addr, Network.MAINNET + ) + tx = wasm.encode_transaction(inputs, outputs, 0) + + _alloc_reusable_slot(wasm) # make the table-size invariant deterministic + warm = wasm.estimate_transaction_size(tx, [wallet.addr], outputs, Network.MAINNET) + assert warm > 0 + baseline_table = wasm.table.size(wasm.store) + + boom = RuntimeError("injected outputs write failure") + calls = {"count": 0} + original_write_bytes = wasm._write_bytes + original_string_array = wasm._write_string_array + array_indices: list[list[int]] = [] + + def fail_on_second_write(data: bytes) -> tuple[int, int]: + calls["count"] += 1 + if calls["count"] == 2: + raise boom + return original_write_bytes(data) + + def recording_string_array(strs: list[str]) -> tuple[int, list[int]]: + result = original_string_array(strs) + array_indices.append(result[1]) + return result + + monkeypatch.setattr(wasm, "_write_bytes", fail_on_second_write) + monkeypatch.setattr(wasm, "_write_string_array", recording_string_array) + + # inputs write (1st call) succeeds, the destinations array write + # succeeds, outputs write (2nd call) fails → rollback must run. + with pytest.raises(RuntimeError) as excinfo: + wasm.estimate_transaction_size(tx, [wallet.addr], outputs, Network.MAINNET) + assert excinfo.value is boom + assert calls["count"] == 2 + assert len(array_indices) == 1 and len(array_indices[0]) == 1 + # The narrow rollback must have released the destination slot already: + # the callee never ran, so the host still owns it. It reads back null + # and is handed out again by the allocator — not leaked. + _assert_rolled_back_slots_released(wasm, set(array_indices[0])) + + # The client is fully usable afterwards: the next real estimate succeeds + # with the very same result, and the externref table did not grow — the + # rolled-back slot was recycled, not leaked. + recovered = wasm.estimate_transaction_size(tx, [wallet.addr], outputs, Network.MAINNET) + assert recovered == warm + assert wasm.table.size(wasm.store) == baseline_table diff --git a/tests/test_wasm_ids.py b/tests/test_wasm_ids.py new file mode 100644 index 0000000..2e1a68c --- /dev/null +++ b/tests/test_wasm_ids.py @@ -0,0 +1,83 @@ +"""Tests for WASM object-ID derivation (mirrors go-sdk/wasm/ids.go). + +Pool/token/delegation/order IDs are derived from the *encoded inputs* of the +issuance transaction; the tests use a real encoded UTXO input. +""" + +from __future__ import annotations + +import pytest +from wasm_helpers import HEIGHT, fake_input + +from mintlayer.wasm import Client, Network, WasmError + + +def test_pool_id(wasm: Client) -> None: + pool_id = wasm.get_pool_id(fake_input(wasm), Network.MAINNET) + assert pool_id.startswith("mpool1") + assert pool_id == wasm.get_pool_id(fake_input(wasm), Network.MAINNET) # deterministic + + +def test_token_id(wasm: Client) -> None: + token_id = wasm.get_token_id(fake_input(wasm), HEIGHT, Network.MAINNET) + assert token_id.startswith("mmltk1") + assert token_id == wasm.get_token_id(fake_input(wasm), HEIGHT, Network.MAINNET) + + +def test_delegation_id(wasm: Client) -> None: + delegation_id = wasm.get_delegation_id(fake_input(wasm), Network.MAINNET) + assert delegation_id.startswith("mdelg1") + assert delegation_id == wasm.get_delegation_id(fake_input(wasm), Network.MAINNET) + + +def test_order_id(wasm: Client) -> None: + order_id = wasm.get_order_id(fake_input(wasm), Network.MAINNET) + assert order_id.startswith("mordr1") + assert order_id == wasm.get_order_id(fake_input(wasm), Network.MAINNET) + + +def test_ids_are_distinct_per_object_kind(wasm: Client) -> None: + inp = fake_input(wasm) + ids = { + wasm.get_pool_id(inp, Network.MAINNET), + wasm.get_token_id(inp, HEIGHT, Network.MAINNET), + wasm.get_delegation_id(inp, Network.MAINNET), + wasm.get_order_id(inp, Network.MAINNET), + } + assert len(ids) == 4, "different object kinds must derive different ids" + + +def test_ids_differ_for_different_inputs(wasm: Client) -> None: + a = wasm.get_pool_id(fake_input(wasm, txid=b"\x01" * 32), Network.MAINNET) + b = wasm.get_pool_id(fake_input(wasm, txid=b"\x02" * 32), Network.MAINNET) + assert a != b + + +def test_ids_depend_on_network(wasm: Client) -> None: + inp = fake_input(wasm) + assert wasm.get_pool_id(inp, Network.MAINNET) != wasm.get_pool_id(inp, Network.TESTNET) + + +def test_pool_id_garbage_inputs_raise(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.get_pool_id(b"\xff\xff", Network.MAINNET) + + +def test_pool_id_empty_inputs_raise(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.get_pool_id(b"", Network.MAINNET) + + +def test_delegation_id_garbage_inputs_raise(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.get_delegation_id(b"\xff\xff", Network.MAINNET) + + +def test_order_id_garbage_inputs_raise(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.get_order_id(b"\xff\xff", Network.MAINNET) + + +def test_token_id_garbage_inputs_raise(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.get_token_id(b"\xff\xff", HEIGHT, Network.MAINNET) diff --git a/tests/test_wasm_inputs_fees.py b/tests/test_wasm_inputs_fees.py new file mode 100644 index 0000000..323cba7 --- /dev/null +++ b/tests/test_wasm_inputs_fees.py @@ -0,0 +1,210 @@ +"""Tests for WASM inputs, timelocks, fees and staking helpers. + +The VRF public key used for pool data is the real mainnet vector from +mintlayer-core; every error-path assertion pins the ``mintlayer: `` message +prefix required by the SDK's error contract. +""" + +from __future__ import annotations + +import pytest +from wasm_helpers import HEIGHT, ORDERS_HEIGHT, VRF_MAINNET, Wallet, fake_input + +from mintlayer.wasm import Amount, Client, Network, SourceId, TokenUnfreezable, WasmError + +# ── inputs ──────────────────────────────────────────────────────────────────── + + +def test_encode_input_for_utxo(wasm: Client) -> None: + inp = wasm.encode_input_for_utxo( + wasm.encode_outpoint_source_id(b"\x01" * 32, SourceId.SOURCE_TRANSACTION), 0 + ) + assert isinstance(inp, bytes) and len(inp) > 0 + assert inp == fake_input(wasm) # deterministic + + +def test_utxo_input_depends_on_index(wasm: Client) -> None: + assert fake_input(wasm, index=0) != fake_input(wasm, index=1) + + +def test_encode_input_for_withdraw_from_delegation(wasm: Client) -> None: + delegation_id = wasm.get_delegation_id(fake_input(wasm), Network.MAINNET) + inp = wasm.encode_input_for_withdraw_from_delegation( + delegation_id, Amount.from_atoms("1"), 3, Network.MAINNET + ) + assert len(inp) > 0 + + +def test_encode_input_for_mint_unmint_lock(wasm: Client) -> None: + token_id = wasm.get_token_id(fake_input(wasm), HEIGHT, Network.MAINNET) + assert ( + len(wasm.encode_input_for_mint_tokens(token_id, Amount.from_atoms("1"), 3, Network.MAINNET)) + > 0 + ) + assert len(wasm.encode_input_for_unmint_tokens(token_id, 3, Network.MAINNET)) > 0 + assert len(wasm.encode_input_for_lock_token_supply(token_id, 3, Network.MAINNET)) > 0 + + +def test_encode_input_for_freeze_unfreeze_token(wasm: Client) -> None: + token_id = wasm.get_token_id(fake_input(wasm), HEIGHT, Network.MAINNET) + freeze = wasm.encode_input_for_freeze_token(token_id, TokenUnfreezable.YES, 3, Network.MAINNET) + assert len(freeze) > 0 + assert len(wasm.encode_input_for_unfreeze_token(token_id, 3, Network.MAINNET)) > 0 + + +def test_encode_input_for_change_token_authority_and_metadata(wasm: Client) -> None: + token_id = wasm.get_token_id(fake_input(wasm), HEIGHT, Network.MAINNET) + wallet = Wallet(wasm) + assert ( + len(wasm.encode_input_for_change_token_authority(token_id, wallet.addr, 3, Network.MAINNET)) + > 0 + ) + assert ( + len( + wasm.encode_input_for_change_token_metadata_uri( + token_id, "https://example.com/meta.json", 3, Network.MAINNET + ) + ) + > 0 + ) + + +def test_encode_input_for_conclude_fill_freeze_order(wasm: Client) -> None: + order_id = wasm.get_order_id(fake_input(wasm), Network.MAINNET) + wallet = Wallet(wasm) + assert len(wasm.encode_input_for_conclude_order(order_id, 3, HEIGHT, Network.MAINNET)) > 0 + assert ( + len( + wasm.encode_input_for_fill_order( + order_id, Amount.from_atoms("1"), wallet.addr, 3, HEIGHT, Network.MAINNET + ) + ) + > 0 + ) + # Order freezing only exists after the orders V1 fork. + assert len(wasm.encode_input_for_freeze_order(order_id, ORDERS_HEIGHT, Network.MAINNET)) > 0 + + +def test_freeze_order_before_fork_raises(wasm: Client) -> None: + order_id = wasm.get_order_id(fake_input(wasm), Network.MAINNET) + with pytest.raises(WasmError, match="Orders V1 not activated"): + wasm.encode_input_for_freeze_order(order_id, 100, Network.MAINNET) + + +def test_input_error_paths(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_input_for_withdraw_from_delegation( + "bad-id", Amount.from_atoms("1"), 0, Network.MAINNET + ) + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_input_for_mint_tokens("bad-token", Amount.from_atoms("1"), 0, Network.MAINNET) + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_input_for_mint_tokens( + "mmltk1jh783nqq5cnm73kq5jnwg8g6rnnf53h3d90c6mtv0y408jp4quqq696z9s", + Amount.from_atoms("not-a-number"), + 0, + Network.MAINNET, + ) + + +# ── timelocks ───────────────────────────────────────────────────────────────── + + +def test_timelocks_encode_non_empty(wasm: Client) -> None: + assert len(wasm.encode_lock_for_block_count(100)) > 0 + assert len(wasm.encode_lock_for_seconds(86400)) > 0 + assert len(wasm.encode_lock_until_height(HEIGHT)) > 0 + assert len(wasm.encode_lock_until_time(1_700_000_000)) > 0 + + +def test_timelocks_are_deterministic_and_argument_sensitive(wasm: Client) -> None: + assert wasm.encode_lock_for_block_count(100) == wasm.encode_lock_for_block_count(100) + assert wasm.encode_lock_for_block_count(100) != wasm.encode_lock_for_block_count(101) + assert wasm.encode_lock_until_height(HEIGHT) != wasm.encode_lock_until_time(HEIGHT) + + +# ── fees ────────────────────────────────────────────────────────────────────── + + +def test_fees_are_positive_at_fixed_height(wasm: Client) -> None: + fees = [ + wasm.fungible_token_issuance_fee(HEIGHT, Network.MAINNET), + wasm.nft_issuance_fee(HEIGHT, Network.MAINNET), + wasm.data_deposit_fee(HEIGHT, Network.MAINNET), + wasm.token_supply_change_fee(HEIGHT, Network.MAINNET), + wasm.token_freeze_fee(HEIGHT, Network.MAINNET), + wasm.token_change_authority_fee(HEIGHT, Network.MAINNET), + ] + for fee in fees: + assert isinstance(fee, Amount) + assert fee.atoms not in ("", "0"), f"expected a non-zero fee, got {fee.atoms!r}" + assert int(fee.atoms) > 0 + + +def test_fees_are_deterministic(wasm: Client) -> None: + a = wasm.fungible_token_issuance_fee(HEIGHT, Network.MAINNET) + b = wasm.fungible_token_issuance_fee(HEIGHT, Network.MAINNET) + assert a == b + + +# ── staking ─────────────────────────────────────────────────────────────────── + + +def test_encode_stake_pool_data(wasm: Client) -> None: + wallet = Wallet(wasm) + data = wasm.encode_stake_pool_data( + Amount.from_atoms("40000000000000"), + wallet.addr, + VRF_MAINNET, + wallet.addr, + 100, + Amount.from_atoms("100000000"), + Network.MAINNET, + ) + assert isinstance(data, bytes) and len(data) > 0 + assert data == wasm.encode_stake_pool_data( + Amount.from_atoms("40000000000000"), + wallet.addr, + VRF_MAINNET, + wallet.addr, + 100, + Amount.from_atoms("100000000"), + Network.MAINNET, + ) + + +def test_encode_stake_pool_data_invalid_vrf_key_raises(wasm: Client) -> None: + wallet = Wallet(wasm) + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_stake_pool_data( + Amount.from_atoms("40000000000000"), + wallet.addr, + "vrfpk1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0sq3rz3k", # wrong HRP + wallet.addr, + 100, + Amount.from_atoms("100000000"), + Network.MAINNET, + ) + + +def test_effective_pool_balance(wasm: Client) -> None: + balance = wasm.effective_pool_balance( + Network.MAINNET, + Amount.from_atoms("50000000000"), + Amount.from_atoms("1000000000000"), + ) + assert isinstance(balance, Amount) + assert int(balance.atoms) > 0 + + +def test_effective_pool_balance_invalid_amount_raises(wasm: Client) -> None: + with pytest.raises(WasmError, match="Invalid atoms amount"): + wasm.effective_pool_balance( + Network.MAINNET, Amount.from_atoms("zz"), Amount.from_atoms("1") + ) + + +def test_staking_pool_spend_maturity_block_count(wasm: Client) -> None: + count = wasm.staking_pool_spend_maturity_block_count(HEIGHT, Network.MAINNET) + assert count > 0 + assert count == wasm.staking_pool_spend_maturity_block_count(HEIGHT, Network.MAINNET) diff --git a/tests/test_wasm_intents.py b/tests/test_wasm_intents.py new file mode 100644 index 0000000..a3816cf --- /dev/null +++ b/tests/test_wasm_intents.py @@ -0,0 +1,75 @@ +"""Tests for WASM transaction intents (mirrors go-sdk/wasm/intent.go). + +Flow: derive the canonical message to sign, sign it with the input key, +encode the signed intent, then verify — including destination-mismatch +rejection. +""" + +from __future__ import annotations + +import pytest +from wasm_helpers import Wallet, fake_input + +from mintlayer.wasm import Amount, Client, Network, WasmError + + +@pytest.fixture +def wallet(wasm: Client) -> Wallet: + return Wallet(wasm) + + +@pytest.fixture +def tx_id(wasm: Client, wallet: Wallet) -> str: + inp = fake_input(wasm) + out = wasm.encode_output_transfer( + Amount.from_atoms("100000000000"), wallet.addr, Network.MAINNET + ) + tx = wasm.encode_transaction(inp, out, 0) + return wasm.get_transaction_id(tx, True) + + +def test_message_to_sign_is_deterministic(wasm: Client, tx_id: str) -> None: + message = wasm.make_transaction_intent_message_to_sign("test-intent", tx_id) + assert isinstance(message, bytes) and len(message) > 0 + assert message == wasm.make_transaction_intent_message_to_sign("test-intent", tx_id) + + +def test_message_to_sign_depends_on_inputs(wasm: Client, tx_id: str) -> None: + base = wasm.make_transaction_intent_message_to_sign("test-intent", tx_id) + other_intent = wasm.make_transaction_intent_message_to_sign("other-intent", tx_id) + other_txid = wasm.make_transaction_intent_message_to_sign("test-intent", "ff" * 32) + assert base != other_intent + assert base != other_txid + + +def test_intent_roundtrip(wasm: Client, wallet: Wallet, tx_id: str) -> None: + message = wasm.make_transaction_intent_message_to_sign("test-intent", tx_id) + signature = wasm.sign_challenge(wallet.recv, message) + encoded = wasm.encode_signed_transaction_intent(message, [signature]) + assert isinstance(encoded, bytes) and len(encoded) > 0 + wasm.verify_transaction_intent(message, encoded, [wallet.addr], Network.MAINNET) + + +def test_intent_rejects_mismatched_destinations(wasm: Client, wallet: Wallet, tx_id: str) -> None: + message = wasm.make_transaction_intent_message_to_sign("test-intent", tx_id) + signature = wasm.sign_challenge(wallet.recv, message) + encoded = wasm.encode_signed_transaction_intent(message, [signature]) + with pytest.raises(WasmError, match="^mintlayer: "): + # Two destinations for a one-signature intent: must not verify. + wasm.verify_transaction_intent( + message, encoded, [wallet.addr, wallet.addr], Network.MAINNET + ) + + +def test_intent_rejects_wrong_message(wasm: Client, wallet: Wallet, tx_id: str) -> None: + message = wasm.make_transaction_intent_message_to_sign("test-intent", tx_id) + signature = wasm.sign_challenge(wallet.recv, message) + encoded = wasm.encode_signed_transaction_intent(message, [signature]) + other = wasm.make_transaction_intent_message_to_sign("test-intent", "ff" * 32) + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.verify_transaction_intent(other, encoded, [wallet.addr], Network.MAINNET) + + +def test_intent_message_bad_txid_raises(wasm: Client) -> None: + with pytest.raises(WasmError, match="Error parsing transaction id"): + wasm.make_transaction_intent_message_to_sign("test-intent", "nothex") diff --git a/tests/test_wasm_keys.py b/tests/test_wasm_keys.py new file mode 100644 index 0000000..f38f3d8 --- /dev/null +++ b/tests/test_wasm_keys.py @@ -0,0 +1,90 @@ +"""Tests for WASM key derivation (mirrors go-sdk/wasm keys tests). + +The derivation vectors use the standard "abandon ... about" mnemonic; the +private-path vs extended-public-path address equality is the key invariant +from Go's TestKeyDerivationChain. +""" + +from __future__ import annotations + +import pytest +from wasm_helpers import MNEMONIC, derive + +from mintlayer.wasm import Client, Network, WasmError + + +def test_make_private_key(wasm: Client) -> None: + key = wasm.make_private_key() + assert len(key) > 0 + assert any(b != 0 for b in key), "private key is all zeros" + + +def test_make_private_key_is_random(wasm: Client) -> None: + assert wasm.make_private_key() != wasm.make_private_key() + + +def test_make_default_account_privkey_is_deterministic(wasm: Client) -> None: + key = wasm.make_default_account_privkey(MNEMONIC, Network.MAINNET) + again = wasm.make_default_account_privkey(MNEMONIC, Network.MAINNET) + assert key == again + assert len(key) > 0 + + +def test_public_key_from_private_key(wasm: Client) -> None: + pub = wasm.public_key_from_private_key(wasm.make_private_key()) + assert len(pub) > 0 + assert any(b != 0 for b in pub) + + +def test_extended_key_pair_is_deterministic(wasm: Client) -> None: + account = wasm.make_default_account_privkey(MNEMONIC, Network.MAINNET) + ext1 = wasm.extended_public_key_from_extended_private_key(account) + ext2 = wasm.extended_public_key_from_extended_private_key(account) + assert ext1 == ext2 + assert len(ext1) > 0 + + +def test_receiving_and_change_addresses_differ(wasm: Client) -> None: + account = wasm.make_default_account_privkey(MNEMONIC, Network.MAINNET) + recv0 = wasm.make_receiving_address(account, 0) + recv1 = wasm.make_receiving_address(account, 1) + change0 = wasm.make_change_address(account, 0) + assert recv0 != recv1, "different key indices must derive different keys" + assert recv0 != change0, "receiving and change chains must diverge" + + +def test_receiving_and_change_public_keys(wasm: Client) -> None: + account = wasm.make_default_account_privkey(MNEMONIC, Network.MAINNET) + ext_pub = wasm.extended_public_key_from_extended_private_key(account) + recv_pub = wasm.make_receiving_address_public_key(ext_pub, 0) + change_pub = wasm.make_change_address_public_key(ext_pub, 0) + assert recv_pub == wasm.public_key_from_private_key(wasm.make_receiving_address(account, 0)) + assert change_pub == wasm.public_key_from_private_key(wasm.make_change_address(account, 0)) + + +def test_full_derivation_chain_equality(wasm: Client) -> None: + """Address derived via private keys equals the one via extended public keys.""" + account, _recv, pub, addr = derive(wasm) + ext_pub = wasm.extended_public_key_from_extended_private_key(account) + pub_from_ext = wasm.make_receiving_address_public_key(ext_pub, 0) + addr_from_ext = wasm.pubkey_to_pubkeyhash_address(pub_from_ext, Network.MAINNET) + assert pub == pub_from_ext + assert addr == addr_from_ext + assert addr + + +@pytest.mark.parametrize( + ("label", "call"), + [ + ("pub_from_garbage", lambda c: c.public_key_from_private_key(b"abc")), + ("ext_from_garbage", lambda c: c.extended_public_key_from_extended_private_key(b"abc")), + ("receiving_from_garbage", lambda c: c.make_receiving_address(b"abc", 0)), + ("change_from_garbage", lambda c: c.make_change_address(b"abc", 0)), + ("receiving_pub_from_garbage", lambda c: c.make_receiving_address_public_key(b"abc", 0)), + ("change_pub_from_garbage", lambda c: c.make_change_address_public_key(b"abc", 0)), + ("empty_mnemonic", lambda c: c.make_default_account_privkey("", Network.MAINNET)), + ], +) +def test_key_error_paths(wasm: Client, label: str, call) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + call(wasm) diff --git a/tests/test_wasm_lifecycle.py b/tests/test_wasm_lifecycle.py new file mode 100644 index 0000000..d44fffe --- /dev/null +++ b/tests/test_wasm_lifecycle.py @@ -0,0 +1,297 @@ +"""Tests for WASM client lifecycle and the low-level _core machinery. + +Covers instantiation, close()/context-manager semantics, closed-client +errors, the sha256 integrity pin, call-convention edge cases reachable from +Python (unknown exports, unexpected return counts, memory-read failures), +and the documented result-buffer zeroing behaviour. +""" + +from __future__ import annotations + +import hashlib +import threading +from pathlib import Path + +import pytest +from wasm_helpers import MemorySpy + +from mintlayer.wasm import Client, Network, WasmError +from mintlayer.wasm import _core as wasm_core + +# ── integrity pin ───────────────────────────────────────────────────────────── + + +def test_integrity_pin_file_matches_binary() -> None: + """The vendored WASM binary matches its committed sha256 pin.""" + pin_path = wasm_core._WASM_PATH.with_suffix(".wasm.sha256") + assert pin_path.exists(), "sha256 pin file must be shipped next to the binary" + expected = pin_path.read_text().split()[0].strip() + actual = hashlib.sha256(wasm_core._WASM_BYTES).hexdigest() + assert actual == expected + # Reloading through the loader exercises the same verify path as import. + assert wasm_core._load_and_verify_wasm() == wasm_core._WASM_BYTES + + +def test_integrity_pin_rejects_tampered_binary( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A binary whose hash differs from the pin fails closed.""" + wasm_file = tmp_path / "fake.wasm" + wasm_file.write_bytes(b"tampered") + original = hashlib.sha256(b"original").hexdigest() + wasm_file.with_suffix(".wasm.sha256").write_text(f"{original}\n") + monkeypatch.setattr(wasm_core, "_WASM_PATH", wasm_file) + with pytest.raises(WasmError, match="integrity check failed"): + wasm_core._load_and_verify_wasm() + + +def test_missing_pin_file_fails_closed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A binary without its sha256 pin file is a packaging error.""" + wasm_file = tmp_path / "fake.wasm" + wasm_file.write_bytes(b"binary") + monkeypatch.setattr(wasm_core, "_WASM_PATH", wasm_file) # no .sha256 written + with pytest.raises(WasmError, match="pin file missing"): + wasm_core._load_and_verify_wasm() + + +def test_empty_pin_file_fails_closed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An empty pin file pins nothing and must not pass verification.""" + wasm_file = tmp_path / "fake.wasm" + wasm_file.write_bytes(b"binary") + wasm_file.with_suffix(".wasm.sha256").write_text("") + monkeypatch.setattr(wasm_core, "_WASM_PATH", wasm_file) + with pytest.raises(WasmError, match="pin file is empty"): + wasm_core._load_and_verify_wasm() + + +# ── lifecycle ───────────────────────────────────────────────────────────────── + + +def test_instantiation_and_basic_call() -> None: + c = Client() + try: + assert c.make_private_key() + finally: + c.close() + + +def test_context_manager_closes() -> None: + with Client() as c: + c.make_private_key() + with pytest.raises(WasmError, match="closed"): + c.make_private_key() + + +def test_close_is_idempotent() -> None: + c = Client() + c.make_private_key() + c.close() + c.close() + + +def test_closed_client_rejects_calls() -> None: + c = Client() + c.close() + with pytest.raises(WasmError, match="closed"): + c.make_private_key() + + +def test_get_export_after_close_is_none() -> None: + c = Client() + c.close() + assert c.get_export("make_private_key") is None + + +# ── export lookup / low-level call conventions ──────────────────────────────── + + +def test_call_unknown_export(wasm: Client) -> None: + with pytest.raises(WasmError, match='function "no_such_export" not found'): + wasm._call("no_such_export") + + +def test_fn_lookup_unknown_export_open_client(wasm: Client) -> None: + with pytest.raises(WasmError, match="not found"): + wasm._fn("no_such_export") + + +def test_call_return_bytes_unexpected_count(wasm: Client) -> None: + wasm._exports["fake_no_ret"] = lambda store: None + try: + with pytest.raises(WasmError, match="unexpected return count"): + wasm._call_return_bytes("fake_no_ret") + with pytest.raises(WasmError, match="unexpected return count"): + wasm._call_return_string("fake_no_ret") + with pytest.raises(WasmError, match="unexpected return count"): + wasm._call_return_bytes_no_err("fake_no_ret") + finally: + del wasm._exports["fake_no_ret"] + + +def test_call_return_json_without_json_result( + wasm: Client, monkeypatch: pytest.MonkeyPatch +) -> None: + """A successful call that captured no JSON payload is an error.""" + monkeypatch.setattr(wasm, "_call", lambda name, *params: [0, 0]) + with pytest.raises(WasmError, match="no JSON result"): + wasm._call_return_json("fake_json_fn") + + +def test_read_bytes_out_of_bounds_is_empty(wasm: Client) -> None: + """Out-of-bounds reads are clamped to an empty buffer by wasmtime.""" + data = wasm._read_bytes(0xFFFF_FFFF, 16) + assert data is not None and len(data) == 0 + + +def test_memory_read_failure(wasm: Client, monkeypatch: pytest.MonkeyPatch) -> None: + """If the result buffer cannot be read back, callers get a clear error.""" + pub = wasm.public_key_from_private_key(wasm.make_private_key()) + monkeypatch.setattr(wasm, "_read_bytes", lambda ptr, length: None) + with pytest.raises(WasmError, match="memory read failed"): + wasm.pubkey_to_pubkeyhash_address(pub, Network.MAINNET) + + +def test_no_return_value_from_amount_call(wasm: Client, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(wasm, "_call", lambda name, *params: []) + with pytest.raises(WasmError, match="no return value"): + wasm._call_return_amount("fake_amount_fn") + with pytest.raises(WasmError, match="no return value"): + wasm._call_return_amount_fallible("fake_amount_fn") + + +def test_memory_helpers_edge_cases(wasm: Client) -> None: + """Empty/None writes are the (0, 0) null pair; freeing null is a no-op.""" + assert wasm._write_bytes(b"") == (0, 0) + assert wasm._write_string("") == (0, 0) + assert wasm._write_optional_string(None) == (0, 0) + assert wasm._write_optional_bytes(None) == (0, 0) + wasm._free_wasm(0, 128) # null pointer: must not touch the allocator + wasm._dealloc_indices([]) # empty: no table slots to release + + +def test_write_bytes_roundtrip(wasm: Client) -> None: + ptr, length = wasm._write_bytes(b"payload") + try: + assert length == len(b"payload") + data = wasm.memory.read(wasm.store, ptr, ptr + length) + assert data is not None and bytes(data) == b"payload" + finally: + wasm._free_wasm(ptr, length) + + +# ── memory hygiene ──────────────────────────────────────────────────────────── + + +def test_result_buffers_are_zeroed_before_release(wasm: Client) -> None: + """Key-bearing result buffers are zeroed in WASM memory before being freed.""" + spy = MemorySpy(wasm.memory) + original = wasm.memory + wasm.memory = spy # type: ignore[assignment] + try: + key = wasm.make_private_key() + finally: + wasm.memory = original + assert len(key) > 0 + assert len(key) in spy.zero_writes, "result buffer carrying the key must be zeroed" + + +# ── concurrency ─────────────────────────────────────────────────────────────── + + +def test_concurrent_calls_are_serialised(wasm: Client) -> None: + """Concurrent public calls all succeed on the shared single instance.""" + errors: list[Exception] = [] + keys: list[bytes] = [] + lock = threading.Lock() + + def worker() -> None: + try: + for _ in range(5): + key = wasm.make_private_key() + addr = wasm.pubkey_to_pubkeyhash_address( + wasm.public_key_from_private_key(key), Network.MAINNET + ) + assert addr + with lock: + keys.append(key) + except Exception as exc: # pragma: no cover - only on failure + errors.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + assert not errors + assert len(keys) == 20 + assert len(set(keys)) == 20 + + +# ── host-created Uint8Array scratch buffers ─────────────────────────────────── + + +def test_cached_rng_scratch_buffer_survives_across_calls() -> None: + """The RNG scratch Uint8Array is cached by the module and never freed host-side. + + The wasm module creates its RNG scratch ``Uint8Array`` once (via the + ``__wbg_new_with_length`` host callback) and caches it in an externref + table slot, reusing it for every keygen/signing call — mirroring the JS + glue where ``new Uint8Array(n)`` is GC-managed, not call-scoped. + + Freeing that backing store at the end of a call would therefore be a + use-after-free: the next keygen writes through the cached reference into + freed memory and corrupts the allocator free-list (observed as + ``memory fault``/``unreachable`` traps on subsequent unrelated calls, + e.g. ``encode_multisig_challenge``). The host deliberately keeps + ``_new_with_length`` free of any cleanup — see the NOTE in + ``mintlayer/wasm/host.py``. + + Asserts two invariants: + + * no host-side free ever targets a (ptr, length) still referenced from + the externref table, and + * the SAME cached scratch buffer (ptr, length) is reused across two + consecutive keygen calls, proving the module caches it — and thus why + host-side freeing is forbidden. + """ + from mintlayer.wasm.host import Uint8ArrayRef + + c = Client() + try: + freed: list[tuple[int, int]] = [] + original_free = c._free_wasm + + def spy_free(ptr: int, size: int, align: int = 1) -> None: + freed.append((ptr, size)) + original_free(ptr, size, align) + + c._free_wasm = spy_free # type: ignore[method-assign] + + def cached_refs() -> set[tuple[int, int]]: + refs: set[tuple[int, int]] = set() + for idx in range(c.table.size(c.store)): + value = c.table.get(c.store, idx) + if isinstance(value, Uint8ArrayRef): + refs.add((value.ptr, value.length)) + return refs + + c.make_private_key() + cached_first = cached_refs() + assert cached_first, "expected the module to cache its RNG scratch Uint8Array" + + c.make_private_key() + cached_second = cached_refs() + + reused = cached_first & cached_second + assert reused, ( + "the cached RNG scratch Uint8Array (same ptr, length) must survive and " + "be reused across consecutive keygen calls — proving module-side " + "caching, which is why host-side freeing would be a use-after-free" + ) + still_referenced = cached_second & set(freed) + assert not still_referenced, ( + "use-after-free: host-side free touched buffer(s) still referenced " + f"by the externref table: {sorted(still_referenced)}" + ) + finally: + c.close() diff --git a/tests/test_wasm_outputs.py b/tests/test_wasm_outputs.py new file mode 100644 index 0000000..d6f67d8 --- /dev/null +++ b/tests/test_wasm_outputs.py @@ -0,0 +1,327 @@ +"""Tests for WASM output encoding (mirrors go-sdk/wasm/outputs.go). + +Every ``encode_output_*`` method must return non-empty deterministic bytes; +error paths pin the ``mintlayer: `` prefix contract. +""" + +from __future__ import annotations + +import pytest +from wasm_helpers import HEIGHT, VRF_MAINNET, Wallet, fake_input + +from mintlayer.wasm import ( + Amount, + Client, + FreezableToken, + Network, + TotalSupply, + WasmError, +) + +ONE_ML = Amount.from_atoms("100000000000") + + +@pytest.fixture +def wallet(wasm: Client) -> Wallet: + return Wallet(wasm) + + +@pytest.fixture +def pool_id(wasm: Client) -> str: + return wasm.get_pool_id(fake_input(wasm), Network.MAINNET) + + +@pytest.fixture +def token_id(wasm: Client) -> str: + return wasm.get_token_id(fake_input(wasm), HEIGHT, Network.MAINNET) + + +@pytest.fixture +def delegation_id(wasm: Client) -> str: + return wasm.get_delegation_id(fake_input(wasm), Network.MAINNET) + + +@pytest.fixture +def lock(wasm: Client) -> bytes: + return wasm.encode_lock_for_block_count(100) + + +# ── transfers ───────────────────────────────────────────────────────────────── + + +def test_encode_output_transfer(wallet: Wallet, wasm: Client) -> None: + out = wasm.encode_output_transfer(ONE_ML, wallet.addr, Network.MAINNET) + assert isinstance(out, bytes) and len(out) > 0 + assert out == wasm.encode_output_transfer(ONE_ML, wallet.addr, Network.MAINNET) + + +def test_encode_output_token_transfer(wallet: Wallet, token_id: str, wasm: Client) -> None: + out = wasm.encode_output_token_transfer( + Amount.from_atoms("5"), wallet.addr, token_id, Network.MAINNET + ) + assert len(out) > 0 + assert out != wasm.encode_output_transfer(Amount.from_atoms("5"), wallet.addr, Network.MAINNET) + + +def test_encode_output_lock_then_transfer(wallet: Wallet, lock: bytes, wasm: Client) -> None: + out = wasm.encode_output_lock_then_transfer(ONE_ML, wallet.addr, lock, Network.MAINNET) + assert len(out) > 0 + assert out != wasm.encode_output_transfer(ONE_ML, wallet.addr, Network.MAINNET) + + +def test_encode_output_token_lock_then_transfer( + wallet: Wallet, token_id: str, lock: bytes, wasm: Client +) -> None: + out = wasm.encode_output_token_lock_then_transfer( + Amount.from_atoms("5"), wallet.addr, token_id, lock, Network.MAINNET + ) + assert len(out) > 0 + + +# ── burns and data ──────────────────────────────────────────────────────────── + + +def test_encode_output_coin_burn(wasm: Client) -> None: + out = wasm.encode_output_coin_burn(Amount.from_atoms("1")) + assert len(out) > 0 + + +def test_encode_output_token_burn(token_id: str, wasm: Client) -> None: + out = wasm.encode_output_token_burn(Amount.from_atoms("1"), token_id, Network.MAINNET) + assert len(out) > 0 + + +def test_encode_output_data_deposit(wasm: Client) -> None: + assert len(wasm.encode_output_data_deposit(b"hello on-chain")) > 0 + assert len(wasm.encode_output_data_deposit(b"")) > 0 # empty payload allowed + + +# ── staking outputs ─────────────────────────────────────────────────────────── + + +def test_encode_output_create_delegation(wallet: Wallet, pool_id: str, wasm: Client) -> None: + out = wasm.encode_output_create_delegation(pool_id, wallet.addr, Network.MAINNET) + assert len(out) > 0 + + +def test_encode_output_delegate_staking(delegation_id: str, wasm: Client) -> None: + out = wasm.encode_output_delegate_staking( + Amount.from_atoms("1000"), delegation_id, Network.MAINNET + ) + assert len(out) > 0 + + +def test_encode_output_create_stake_pool(wallet: Wallet, pool_id: str, wasm: Client) -> None: + pool_data = wasm.encode_stake_pool_data( + Amount.from_atoms("40000000000000"), + wallet.addr, + VRF_MAINNET, + wallet.addr, + 100, + Amount.from_atoms("100000000"), + Network.MAINNET, + ) + out = wasm.encode_output_create_stake_pool(pool_id, pool_data, Network.MAINNET) + assert len(out) > 0 + + +def test_encode_output_produce_block_from_stake(wallet: Wallet, pool_id: str, wasm: Client) -> None: + out = wasm.encode_output_produce_block_from_stake(pool_id, wallet.addr, Network.MAINNET) + assert len(out) > 0 + + +# ── HTLC ────────────────────────────────────────────────────────────────────── + + +def test_encode_output_htlc_coin(wallet: Wallet, lock: bytes, wasm: Client) -> None: + secret_hash = "03" * 20 # RIPEMD160(SHA256(secret)) as hex + out = wasm.encode_output_htlc( + ONE_ML, None, secret_hash, wallet.addr, wallet.addr, lock, Network.MAINNET + ) + assert len(out) > 0 + + +def test_encode_output_htlc_token(wallet: Wallet, token_id: str, lock: bytes, wasm: Client) -> None: + out = wasm.encode_output_htlc( + Amount.from_atoms("5"), + token_id, + "ab" * 20, + wallet.addr, + wallet.addr, + lock, + Network.MAINNET, + ) + assert len(out) > 0 + + +def test_encode_output_htlc_bad_secret_hash(wallet: Wallet, lock: bytes, wasm: Client) -> None: + with pytest.raises(WasmError, match="htlc secret hash"): + wasm.encode_output_htlc( + ONE_ML, None, "03" * 32, wallet.addr, wallet.addr, lock, Network.MAINNET + ) + + +# ── issuance ────────────────────────────────────────────────────────────────── + + +def test_encode_output_issue_fungible_token_lockable(wallet: Wallet, wasm: Client) -> None: + out = wasm.encode_output_issue_fungible_token( + wallet.addr, + "GLD", + "https://example.com/gld.json", + 8, + TotalSupply.LOCKABLE, + None, + FreezableToken.YES, + HEIGHT, + Network.MAINNET, + ) + assert len(out) > 0 + + +def test_encode_output_issue_fungible_token_fixed(wallet: Wallet, wasm: Client) -> None: + out = wasm.encode_output_issue_fungible_token( + wallet.addr, + "GLD", + "", + 8, + TotalSupply.FIXED, + Amount.from_atoms("1000000"), + FreezableToken.NO, + HEIGHT, + Network.MAINNET, + ) + assert len(out) > 0 + + +def test_encode_output_issue_fungible_token_unlimited(wallet: Wallet, wasm: Client) -> None: + """UNLIMITED supply must not carry a supply_amount (valid combination).""" + out = wasm.encode_output_issue_fungible_token( + wallet.addr, + "GLD", + "", + 8, + TotalSupply.UNLIMITED, + None, + FreezableToken.NO, + HEIGHT, + Network.MAINNET, + ) + assert isinstance(out, bytes) and len(out) > 0 + + +def test_issue_fixed_supply_without_amount_raises(wallet: Wallet, wasm: Client) -> None: + """FIXED without supply_amount is rejected before any WASM call.""" + with pytest.raises(ValueError, match="supply_amount is required for TotalSupply.FIXED"): + wasm.encode_output_issue_fungible_token( + wallet.addr, + "GLD", + "", + 8, + TotalSupply.FIXED, + None, + FreezableToken.NO, + HEIGHT, + Network.MAINNET, + ) + + +def test_issue_non_fixed_supply_with_amount_raises(wallet: Wallet, wasm: Client) -> None: + """A supply_amount together with a non-FIXED supply policy is rejected.""" + with pytest.raises(ValueError, match="must be None otherwise"): + wasm.encode_output_issue_fungible_token( + wallet.addr, + "GLD", + "", + 8, + TotalSupply.UNLIMITED, + Amount.from_atoms("1000000"), + FreezableToken.NO, + HEIGHT, + Network.MAINNET, + ) + + +def test_issue_lockable_supply_with_amount_raises(wallet: Wallet, wasm: Client) -> None: + """LOCKABLE behaves like UNLIMITED: a supply_amount is rejected.""" + with pytest.raises(ValueError, match="must be None otherwise"): + wasm.encode_output_issue_fungible_token( + wallet.addr, + "GLD", + "", + 8, + TotalSupply.LOCKABLE, + Amount.from_atoms("1000000"), + FreezableToken.NO, + HEIGHT, + Network.MAINNET, + ) + + +def test_encode_output_issue_nft_minimal(wallet: Wallet, token_id: str, wasm: Client) -> None: + out = wasm.encode_output_issue_nft( + token_id, + wallet.addr, + "TestNFT", + "TNFT", + "TestNFTDescription", + b"\x02" * 32, + None, + None, + None, + None, + HEIGHT, + Network.MAINNET, + ) + assert len(out) > 0 + + +def test_encode_output_issue_nft_full(wallet: Wallet, token_id: str, wasm: Client) -> None: + out = wasm.encode_output_issue_nft( + token_id, + wallet.addr, + "TestNFT", + "TNFT", + "TestNFTDescription", + b"\x02" * 32, + wallet.pub, + "https://example.com/media", + "https://example.com/icon", + "https://example.com/meta", + HEIGHT, + Network.MAINNET, + ) + assert len(out) > 0 + + +# ── DEX orders ──────────────────────────────────────────────────────────────── + + +def test_encode_create_order_output_coin_for_token( + wallet: Wallet, token_id: str, wasm: Client +) -> None: + out = wasm.encode_create_order_output( + ONE_ML, None, Amount.from_atoms("20"), token_id, wallet.addr, Network.MAINNET + ) + assert len(out) > 0 + + +def test_encode_create_order_output_coin_for_coin(wallet: Wallet, wasm: Client) -> None: + out = wasm.encode_create_order_output( + ONE_ML, None, Amount.from_atoms("2"), None, wallet.addr, Network.MAINNET + ) + assert len(out) > 0 + + +# ── error contract ──────────────────────────────────────────────────────────── + + +def test_output_error_messages_carry_mintlayer_prefix(wallet: Wallet, wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: Invalid atoms amount: xyz$"): + wasm.encode_output_transfer(Amount.from_atoms("xyz"), wallet.addr, Network.MAINNET) + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_output_transfer(ONE_ML, "not-an-address", Network.MAINNET) + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_output_token_transfer( + Amount.from_atoms("1"), wallet.addr, "bad-token", Network.MAINNET + ) diff --git a/tests/test_wasm_signing.py b/tests/test_wasm_signing.py new file mode 100644 index 0000000..3cd0b2f --- /dev/null +++ b/tests/test_wasm_signing.py @@ -0,0 +1,205 @@ +"""Tests for WASM signing: witnesses, message signatures and verification. + +Mirrors go-sdk TestSignChallengeRoundtrip, TestSignMessageForSpendingRoundtrip +and TestEncodeWitnessNoSignature; the transaction-witness flow uses a real +signed UTXO input so the sighash path is genuinely exercised. +""" + +from __future__ import annotations + +import pytest +from wasm_helpers import Wallet, fake_input, witness_for + +from mintlayer.wasm import ( + Amount, + Client, + Network, + SignatureHashType, + TxAdditionalInfo, + WasmError, +) + +ONE_ML = Amount.from_atoms("100000000000") + + +@pytest.fixture +def wallet(wasm: Client) -> Wallet: + return Wallet(wasm) + + +@pytest.fixture +def self_transfer(wasm: Client, wallet: Wallet) -> tuple[bytes, bytes, bytes]: + """(input, output, unsigned tx) sending 1 ML back to the wallet.""" + inp = fake_input(wasm) + out = wasm.encode_output_transfer(ONE_ML, wallet.addr, Network.MAINNET) + return inp, out, wasm.encode_transaction(inp, out, 0) + + +# ── challenge signatures ────────────────────────────────────────────────────── + + +def test_sign_challenge_roundtrip(wasm: Client) -> None: + priv = wasm.make_private_key() + pub = wasm.public_key_from_private_key(priv) + addr = wasm.pubkey_to_pubkeyhash_address(pub, Network.MAINNET) + message = b"hello mintlayer" + sig = wasm.sign_challenge(priv, message) + assert len(sig) > 0 + assert wasm.verify_challenge(addr, Network.MAINNET, sig, message) is True + + +def test_verify_challenge_rejects_wrong_message(wasm: Client) -> None: + priv = wasm.make_private_key() + addr = wasm.pubkey_to_pubkeyhash_address( + wasm.public_key_from_private_key(priv), Network.MAINNET + ) + sig = wasm.sign_challenge(priv, b"hello mintlayer") + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.verify_challenge(addr, Network.MAINNET, sig, b"other message") + + +def test_verify_challenge_rejects_tampered_signature(wasm: Client) -> None: + priv = wasm.make_private_key() + addr = wasm.pubkey_to_pubkeyhash_address( + wasm.public_key_from_private_key(priv), Network.MAINNET + ) + sig = bytearray(wasm.sign_challenge(priv, b"hello mintlayer")) + sig[-1] ^= 0x01 + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.verify_challenge(addr, Network.MAINNET, bytes(sig), b"hello mintlayer") + + +def test_verify_challenge_invalid_address_raises(wasm: Client) -> None: + priv = wasm.make_private_key() + sig = wasm.sign_challenge(priv, b"m") + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.verify_challenge("not an address", Network.MAINNET, sig, b"m") + + +# ── spending signatures ─────────────────────────────────────────────────────── + + +def test_sign_message_for_spending_roundtrip(wasm: Client) -> None: + priv = wasm.make_private_key() + pub = wasm.public_key_from_private_key(priv) + message = b"spending message test" + sig = wasm.sign_message_for_spending(priv, message) + assert len(sig) > 0 + assert wasm.verify_signature_for_spending(pub, sig, message) is True + + +def test_verify_signature_for_spending_wrong_message_is_false(wasm: Client) -> None: + priv = wasm.make_private_key() + pub = wasm.public_key_from_private_key(priv) + sig = wasm.sign_message_for_spending(priv, b"m") + assert wasm.verify_signature_for_spending(pub, sig, b"other") is False + + +# ── transaction witnesses ───────────────────────────────────────────────────── + + +def test_encode_witness(wasm: Client, wallet: Wallet, self_transfer) -> None: + inp, out, tx = self_transfer + witness = witness_for(wasm, wallet, inp, out, tx) + assert isinstance(witness, bytes) and len(witness) > 0 + + +def test_encode_witness_no_signature(wasm: Client) -> None: + witness = wasm.encode_witness_no_signature() + assert isinstance(witness, bytes) and len(witness) > 0 + + +def test_encode_witness_key_destination_mismatch_raises( + wasm: Client, wallet: Wallet, self_transfer +) -> None: + _inp, out, tx = self_transfer + other = wasm.make_private_key() # not the key behind wallet.addr + with pytest.raises(WasmError, match="hash mismatch"): + wasm.encode_witness( + SignatureHashType.SIGHASH_ALL, + other, + wallet.addr, + tx, + b"\x01" + out, + 0, + TxAdditionalInfo(), + 100, + Network.MAINNET, + ) + + +def test_encode_witness_invalid_key_raises(wasm: Client, wallet: Wallet, self_transfer) -> None: + _inp, out, tx = self_transfer + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_witness( + SignatureHashType.SIGHASH_ALL, + b"abc", + wallet.addr, + tx, + b"\x01" + out, + 0, + TxAdditionalInfo(), + 100, + Network.MAINNET, + ) + + +# ── HTLC witness variants ───────────────────────────────────────────────────── + + +def test_encode_witness_htlc_spend_invalid_key_raises( + wasm: Client, wallet: Wallet, self_transfer +) -> None: + _inp, out, tx = self_transfer + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_witness_htlc_spend( + SignatureHashType.SIGHASH_ALL, + b"abc", + wallet.addr, + tx, + b"\x01" + out, + 0, + b"\x01" * 32, + TxAdditionalInfo(), + 100, + Network.MAINNET, + ) + + +def test_encode_witness_htlc_refund_single_sig_invalid_key_raises( + wasm: Client, wallet: Wallet, self_transfer +) -> None: + _inp, out, tx = self_transfer + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_witness_htlc_refund_single_sig( + SignatureHashType.SIGHASH_ALL, + b"abc", + wallet.addr, + tx, + b"\x01" + out, + 0, + TxAdditionalInfo(), + 100, + Network.MAINNET, + ) + + +def test_encode_witness_htlc_refund_multisig_invalid_key_raises( + wasm: Client, wallet: Wallet, self_transfer +) -> None: + _inp, out, tx = self_transfer + challenge = wasm.encode_multisig_challenge(wallet.pub + wallet.pub, 2, Network.MAINNET) + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_witness_htlc_refund_multisig( + SignatureHashType.SIGHASH_ALL, + b"abc", + 0, + b"", + challenge, + tx, + b"\x01" + out, + 0, + TxAdditionalInfo(), + 100, + Network.MAINNET, + ) diff --git a/tests/test_wasm_transactions.py b/tests/test_wasm_transactions.py new file mode 100644 index 0000000..5ebc212 --- /dev/null +++ b/tests/test_wasm_transactions.py @@ -0,0 +1,344 @@ +"""Tests for WASM transaction encoding, ids, sizes and decoding. + +Mirrors go-sdk TestEncodeTransaction plus the Python-specific flows +validated against the embedded module: estimate size stability (externref +table must not leak across repeated calls), signed/partially-signed +encoding and the decode-to-JSON paths. +""" + +from __future__ import annotations + +import pytest +from wasm_helpers import Wallet, fake_input, witness_for +from wasmtime import Val + +from mintlayer.wasm import ( + Amount, + Client, + Network, + SignatureHashType, + SourceId, + TxAdditionalInfo, + WasmError, +) + + +@pytest.fixture +def wallet(wasm: Client) -> Wallet: + return Wallet(wasm) + + +@pytest.fixture +def unsigned_tx(wasm: Client, wallet: Wallet) -> tuple[bytes, bytes, bytes]: + """(input, output, unsigned transaction) for a 1-ML self transfer.""" + inp = fake_input(wasm) + out = wasm.encode_output_transfer( + Amount.from_atoms("100000000000"), wallet.addr, Network.MAINNET + ) + tx = wasm.encode_transaction(inp, out, 0) + return inp, out, tx + + +def test_encode_outpoint_source_id_transaction(wasm: Client) -> None: + src = wasm.encode_outpoint_source_id(b"\x01" * 32, SourceId.SOURCE_TRANSACTION) + assert isinstance(src, bytes) and len(src) > 0 + assert src == wasm.encode_outpoint_source_id(b"\x01" * 32, SourceId.SOURCE_TRANSACTION) + + +def test_encode_outpoint_source_id_block_reward(wasm: Client) -> None: + from_tx = wasm.encode_outpoint_source_id(b"\x01" * 32, SourceId.SOURCE_TRANSACTION) + from_block = wasm.encode_outpoint_source_id(b"\x01" * 32, SourceId.SOURCE_BLOCK_REWARD) + assert len(from_block) > 0 + assert from_tx != from_block, "source kind must change the encoding" + + +def test_encode_outpoint_source_id_wrong_length_raises(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.encode_outpoint_source_id(b"\x00" * 31, SourceId.SOURCE_TRANSACTION) + + +def test_encode_transaction_and_get_id(wasm: Client, unsigned_tx) -> None: + _inp, _out, tx = unsigned_tx + assert len(tx) > 0 + tx_id = wasm.get_transaction_id(tx, True) + assert len(tx_id) == 64 + assert all(ch in "0123456789abcdef" for ch in tx_id) + assert tx_id == wasm.get_transaction_id(tx, True) # deterministic + + +def test_get_transaction_id_garbage_raises(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.get_transaction_id(b"garbage!", True) + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.get_transaction_id(b"\x00", False) + + +def test_encode_transaction_flags_change_the_id(wasm: Client, wallet: Wallet) -> None: + inp = fake_input(wasm) + out = wasm.encode_output_transfer( + Amount.from_atoms("100000000000"), wallet.addr, Network.MAINNET + ) + assert wasm.encode_transaction(inp, out, 0) != wasm.encode_transaction(inp, out, 1) + + +def test_estimate_transaction_size_stable(wasm: Client, wallet: Wallet, unsigned_tx) -> None: + _inp, out, tx = unsigned_tx + size = wasm.estimate_transaction_size(tx, [wallet.addr], out, Network.MAINNET) + assert 0 < size < 10000 + # Repeated calls must be stable: externref table slots must not leak. + for _ in range(100): + repeat = wasm.estimate_transaction_size(tx, [wallet.addr], out, Network.MAINNET) + assert repeat == size + + +def test_estimate_transaction_size_multiple_destinations(wallet: Wallet) -> None: + """One destination per input; a 2-input tx estimates with 2 destinations. + + Uses a fresh client: the first multi-element string-array call on a + client always works (later ones can hit the slot-reuse bug documented + in test_estimate_transaction_size_multi_dest_after_repeats). + """ + c = Client() + try: + inp0 = fake_input(c, txid=b"\x01" * 32) + inp1 = fake_input(c, txid=b"\x02" * 32) + out = c.encode_output_transfer( + Amount.from_atoms("100000000000"), wallet.addr, Network.MAINNET + ) + tx = c.encode_transaction(inp0 + inp1, out, 0) + size = c.estimate_transaction_size(tx, [wallet.addr, wallet.addr], out, Network.MAINNET) + assert size > 0 + finally: + c.close() + + +def test_estimate_transaction_size_multi_dest_after_repeats(wallet: Wallet) -> None: + """Regression: multi-destination estimates must survive interleaved use. + + Used to fail with 'array contains a value of the wrong type' after + repeated single-destination calls: the host deallocated the callee-owned + externref table slots after every call, double-freeing free-list entries. + """ + c = Client() + try: + inp = fake_input(c) + out = c.encode_output_transfer( + Amount.from_atoms("100000000000"), wallet.addr, Network.MAINNET + ) + tx = c.encode_transaction(inp, out, 0) + assert c.estimate_transaction_size(tx, [wallet.addr], out, Network.MAINNET) > 0 + assert c.estimate_transaction_size(tx, [wallet.addr, wallet.addr], out, Network.MAINNET) > 0 + assert c.estimate_transaction_size(tx, [wallet.addr], out, Network.MAINNET) > 0 + assert c.estimate_transaction_size(tx, [wallet.addr, wallet.addr], out, Network.MAINNET) > 0 + finally: + c.close() + + +def test_estimate_size_mixed_load_externref_table_stable() -> None: + """Stress regression: mixed single/multi-destination load on one client. + + Alternates 1-3 destination ``estimate_transaction_size`` calls with + occasional intent encode/verify rounds (multi-element externref arrays + of both the string and Uint8Array kinds). Every call must succeed, sizes + must stay consistent per destination count, and the wasm externref table + must not grow across the run — table growth would mean table slots are + leaking (the pre-fix double-dealloc corrupted the free list instead of + recycling it). + """ + c = Client() + try: + wallet = Wallet(c) + dests = [wallet.addr] * 3 + inputs = b"".join(fake_input(c, txid=bytes([i]) * 32, index=i) for i in range(3)) + out = c.encode_output_transfer( + Amount.from_atoms("100000000000"), wallet.addr, Network.MAINNET + ) + tx = c.encode_transaction(inputs, out, 0) + tx_id = c.get_transaction_id(tx, True) + message = c.make_transaction_intent_message_to_sign("stress-intent", tx_id) + + def intent_round() -> None: + signatures = [c.sign_challenge(wallet.recv, message) for _ in range(2)] + encoded = c.encode_signed_transaction_intent(message, signatures) + assert len(encoded) > 0 + c.verify_transaction_intent(message, encoded, dests[:2], Network.MAINNET) + + # Warmup with every call shape once: initial table growth (the module + # grows its 128-slot table on first demand) must not count as a leak. + expected: dict[int, int] = {} + for n in (1, 2, 3): + expected[n] = c.estimate_transaction_size(tx, dests[:n], out, Network.MAINNET) + assert expected[n] > 0 + intent_round() + baseline_table = c.table.size(c.store) + + for i in range(120): + n = 1 + (i % 3) + size = c.estimate_transaction_size(tx, dests[:n], out, Network.MAINNET) + assert size == expected[n], f"iteration {i}: size for {n} destinations changed" + if i % 10 == 0: + intent_round() + + assert c.table.size(c.store) == baseline_table, ( + "externref table grew across the mixed-load run (table slot leak)" + ) + finally: + c.close() + + +def _is_null_anyref(value: object) -> bool: + """True when wasmtime reports a null (undefined) externref table slot. + + Depending on version and context wasmtime surfaces null anyrefs either + as ``None`` or as a ``Val`` wrapping ``None``; both mean "the slot was + released". + """ + if value is None: + return True + if isinstance(value, Val): + return value.__dict__.get("_val", object()) is None + return False + + +def _alloc_reusable_slot(c: Client) -> int: + """Allocate one externref-table slot from the reusable region. + + The module reserves its lowest table indices as a permanent slab whose + ``__externref_table_dealloc`` deliberately does not recycle (bounded, + one-time JS globals); the managed region begins where a freed slot is + handed out again via the LIFO free list. Bounded loop: the slab is finite. + """ + for _ in range(512): + probe = c._invoke1("__externref_table_alloc") + c._dealloc_indices([probe]) + if c._invoke1("__externref_table_alloc") == probe: + return probe + raise AssertionError("no reusable externref table slot found") + + +def test_dealloc_indices_rollback_releases_slots_for_reuse() -> None: + """Unit test for the pre-call rollback path: ``_dealloc_indices``. + + A rolled-back array write (failure before the WASM callee ever runs) + must fully release the allocated externref table slots: they read back + as null/undefined and the allocator hands them out again. This is the + inverse contract of the post-call ownership rule (callee owns slots); + getting either side wrong corrupts the table free list. + """ + c = Client() + try: + slot_a = _alloc_reusable_slot(c) + slot_b = _alloc_reusable_slot(c) + assert slot_a != slot_b + c.table.set(c.store, slot_a, "rollback-a") + c.table.set(c.store, slot_b, "rollback-b") + assert c.table.get(c.store, slot_a) == "rollback-a" + assert c.table.get(c.store, slot_b) == "rollback-b" + + c._dealloc_indices([slot_a, slot_b]) + + assert _is_null_anyref(c.table.get(c.store, slot_a)), "slot_a still holds a value" + assert _is_null_anyref(c.table.get(c.store, slot_b)), "slot_b still holds a value" + # The free list must hand exactly these slots back (LIFO order). + assert c._invoke1("__externref_table_alloc") == slot_b + assert c._invoke1("__externref_table_alloc") == slot_a + finally: + c.close() + + +def test_encode_signed_transaction(wasm: Client, wallet: Wallet, unsigned_tx) -> None: + inp, out, tx = unsigned_tx + witness = witness_for(wasm, wallet, inp, out, tx) + signed = wasm.encode_signed_transaction(tx, witness) + assert len(signed) > len(tx) + + +def test_encode_partially_signed_transaction(wasm: Client, wallet: Wallet, unsigned_tx) -> None: + inp, out, tx = unsigned_tx + witness = witness_for(wasm, wallet, inp, out, tx) + pst = wasm.encode_partially_signed_transaction( + tx, + b"\x01" + witness, # Option::Some + b"\x01" + out, # Option::Some + b"\x01" + wasm.encode_destination(wallet.addr, Network.MAINNET), + b"\x00", # Option::None (one per input) + TxAdditionalInfo(), + Network.MAINNET, + ) + assert len(pst) > 0 + + +def test_decode_signed_transaction_to_js(wasm: Client, wallet: Wallet, unsigned_tx) -> None: + inp, out, tx = unsigned_tx + signed = wasm.encode_signed_transaction(tx, witness_for(wasm, wallet, inp, out, tx)) + decoded = wasm.decode_signed_transaction_to_js(signed, Network.MAINNET) + assert b"transaction" in decoded + assert decoded[:1] == b"{" + + +def test_decode_partially_signed_transaction_to_js( + wasm: Client, wallet: Wallet, unsigned_tx +) -> None: + inp, out, tx = unsigned_tx + witness = witness_for(wasm, wallet, inp, out, tx) + pst = wasm.encode_partially_signed_transaction( + tx, + b"\x01" + witness, + b"\x01" + out, + b"\x01" + wasm.encode_destination(wallet.addr, Network.MAINNET), + b"\x00", + TxAdditionalInfo(), + Network.MAINNET, + ) + decoded = wasm.decode_partially_signed_transaction_to_js(pst, Network.MAINNET) + assert b'"type":"V1"' in decoded + assert b'"tx"' in decoded + + +def test_decode_garbage_raises(wasm: Client) -> None: + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.decode_signed_transaction_to_js(b"\x00" * 8, Network.MAINNET) + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.decode_partially_signed_transaction_to_js(b"\x00" * 8, Network.MAINNET) + + +def test_extract_htlc_secret_no_htlc_raises(wasm: Client, wallet: Wallet, unsigned_tx) -> None: + inp, out, tx = unsigned_tx + signed = wasm.encode_signed_transaction(tx, witness_for(wasm, wallet, inp, out, tx)) + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.extract_htlc_secret(signed, True, b"\x02" * 32, 0) + + +def test_internal_verify_witness_ok(wasm: Client, wallet: Wallet, unsigned_tx) -> None: + inp, out, tx = unsigned_tx + witness = witness_for(wasm, wallet, inp, out, tx) + wasm.internal_verify_witness( + int(SignatureHashType.SIGHASH_ALL), + wallet.addr, + witness, + tx, + b"\x01" + out, + 0, + TxAdditionalInfo(), + 100, + Network.MAINNET, + ) + + +def test_internal_verify_witness_wrong_input_index( + wasm: Client, wallet: Wallet, unsigned_tx +) -> None: + inp, out, tx = unsigned_tx + witness = witness_for(wasm, wallet, inp, out, tx) + with pytest.raises(WasmError, match="^mintlayer: "): + wasm.internal_verify_witness( + int(SignatureHashType.SIGHASH_ALL), + wallet.addr, + witness, + tx, + b"\x01" + out, + 1, # transaction has a single input (index 0) + TxAdditionalInfo(), + 100, + Network.MAINNET, + ) diff --git a/tests/test_wasm_types.py b/tests/test_wasm_types.py new file mode 100644 index 0000000..e9de8c7 --- /dev/null +++ b/tests/test_wasm_types.py @@ -0,0 +1,171 @@ +"""Tests for the pure-Python WASM types and the host JSON serialisation. + +The JSON shapes are the serde wire shapes consumed by the Rust module and +must match go-sdk/wasm/types.go exactly (tagged enums, redundant balance +objects, nested additional-info maps). +""" + +from __future__ import annotations + +import json + +import pytest + +from mintlayer.wasm import ( + Amount, + CurrencyAmountKind, + FreezableToken, + Network, + OrderBalance, + OrderInfo, + PoolInfo, + SignatureHashType, + SimpleCurrencyAmount, + SourceId, + TokenUnfreezable, + TotalSupply, + TxAdditionalInfo, + WasmError, +) +from mintlayer.wasm.host import _json_dumps +from mintlayer.wasm.types import OrderBalance as OrderBalanceReimported + + +def test_wasm_error_prefix_contract() -> None: + err = WasmError("mintlayer: boom") + assert isinstance(err, Exception) + assert str(err).startswith("mintlayer: ") + + +def test_enum_discriminants() -> None: + assert [int(n) for n in Network] == [0, 1, 2, 3] + assert [int(s) for s in SignatureHashType] == [0, 1, 2, 3] + assert int(SourceId.SOURCE_TRANSACTION) == 0 + assert int(SourceId.SOURCE_BLOCK_REWARD) == 1 + assert [int(t) for t in TotalSupply] == [0, 1, 2] + assert int(FreezableToken.NO) == 0 and int(FreezableToken.YES) == 1 + assert int(TokenUnfreezable.NO) == 0 and int(TokenUnfreezable.YES) == 1 + assert int(CurrencyAmountKind.COINS) == 0 + assert int(CurrencyAmountKind.TOKENS) == 1 + + +def test_amount() -> None: + one_ml = Amount.from_atoms("100000000000") + assert one_ml.atoms == "100000000000" + assert str(one_ml) == "100000000000" + assert Amount.zero().atoms == "0" + assert one_ml.to_json_value() == {"atoms": "100000000000"} + assert one_ml == Amount.from_atoms("100000000000") + + +def test_simple_currency_amount_coins_shape() -> None: + coins = SimpleCurrencyAmount.coins("5") + assert coins.kind == CurrencyAmountKind.COINS + assert coins.token_id is None + assert coins.to_json_value() == {"coins": {"atoms": "5"}} + + +def test_simple_currency_amount_tokens_shape() -> None: + tokens = SimpleCurrencyAmount.tokens("7", "mmltk1abc") + assert tokens.kind == CurrencyAmountKind.TOKENS + assert tokens.to_json_value() == {"tokens": {"amount": {"atoms": "7"}, "token_id": "mmltk1abc"}} + + +def test_simple_currency_amount_tokens_without_token_id_rejected() -> None: + """A TOKENS amount without a token_id cannot be constructed.""" + with pytest.raises(ValueError, match="token_id is required for TOKENS amounts"): + SimpleCurrencyAmount(atoms="1", kind=CurrencyAmountKind.TOKENS) + + +def test_simple_currency_amount_coins_with_token_id_rejected() -> None: + """A COINS amount must not carry a token_id.""" + with pytest.raises(ValueError, match="token_id must be None for COINS amounts"): + SimpleCurrencyAmount(atoms="1", token_id="mmltk1abc") + + +def test_simple_currency_amount_valid_constructions_still_work() -> None: + """The invariant only rejects the two contradictory combinations.""" + coins = SimpleCurrencyAmount(atoms="1") + assert coins.kind == CurrencyAmountKind.COINS + assert coins.token_id is None + tokens = SimpleCurrencyAmount(atoms="1", kind=CurrencyAmountKind.TOKENS, token_id="mmltk1abc") + assert tokens.to_json_value() == {"tokens": {"amount": {"atoms": "1"}, "token_id": "mmltk1abc"}} + + +def test_order_balance_redundant_shape() -> None: + balance = OrderBalance("9", None) + assert balance.to_json_value() == { + "atoms": "9", + "amount": {"atoms": "9"}, + "token_id": None, + } + with_token = OrderBalance("9", "mmltk1abc") + assert with_token.to_json_value() == { + "atoms": "9", + "amount": {"atoms": "9"}, + "token_id": "mmltk1abc", + } + + +def test_pool_info_shape() -> None: + info = PoolInfo(staker_balance=Amount.from_atoms("42")) + assert info.to_json_value() == {"staker_balance": {"atoms": "42"}} + + +def test_order_info_shape() -> None: + info = OrderInfo( + initially_asked=SimpleCurrencyAmount.coins("1"), + initially_given=SimpleCurrencyAmount.tokens("2", "mmltk1abc"), + ask_balance=OrderBalance("3", None), + give_balance=OrderBalance("4", "mmltk1abc"), + ) + assert info.to_json_value() == { + "initially_asked": {"coins": {"atoms": "1"}}, + "initially_given": {"tokens": {"amount": {"atoms": "2"}, "token_id": "mmltk1abc"}}, + "ask_balance": {"atoms": "3", "amount": {"atoms": "3"}, "token_id": None}, + "give_balance": {"atoms": "4", "amount": {"atoms": "4"}, "token_id": "mmltk1abc"}, + } + + +def test_tx_additional_info_empty_defaults() -> None: + info = TxAdditionalInfo() + assert info.pool_info == {} + assert info.order_info == {} + assert info.to_json_value() == {"pool_info": {}, "order_info": {}} + + +def test_tx_additional_info_nested_shape() -> None: + info = TxAdditionalInfo( + pool_info={"mpool1x": PoolInfo(staker_balance=Amount.from_atoms("5"))}, + order_info={ + "mordr1y": OrderInfo( + initially_asked=SimpleCurrencyAmount.coins("1"), + initially_given=SimpleCurrencyAmount.coins("2"), + ask_balance=OrderBalance("3", None), + give_balance=OrderBalance("4", None), + ) + }, + ) + value = info.to_json_value() + assert set(value) == {"pool_info", "order_info"} + assert value["pool_info"]["mpool1x"] == {"staker_balance": {"atoms": "5"}} + assert value["order_info"]["mordr1y"]["ask_balance"]["atoms"] == "3" + + +def test_order_balance_reimport_is_same_class() -> None: + assert OrderBalanceReimported is OrderBalance + + +def test_host_json_dumps_honours_to_json_value() -> None: + assert _json_dumps(Amount.from_atoms("5")) == '{"atoms":"5"}' + assert _json_dumps(TxAdditionalInfo()) == '{"pool_info":{},"order_info":{}}' + # Nesting is handled inside to_json_value implementations, not by the helper. + info = TxAdditionalInfo(pool_info={"p": PoolInfo(staker_balance=Amount.from_atoms("5"))}) + expected = '{"pool_info":{"p":{"staker_balance":{"atoms":"5"}}},"order_info":{}}' + assert _json_dumps(info) == expected + + +def test_host_json_dumps_plain_values() -> None: + assert _json_dumps([1, 2, 3]) == "[1,2,3]" + assert _json_dumps("x") == '"x"' + assert json.loads(_json_dumps({"a": [True, None]})) == {"a": [True, None]} diff --git a/tests/test_wire_shapes.py b/tests/test_wire_shapes.py new file mode 100644 index 0000000..c4224a1 --- /dev/null +++ b/tests/test_wire_shapes.py @@ -0,0 +1,128 @@ +"""Wire-shape tests for node client methods lacking dedicated coverage. + +Pins the exact RPC method name and params dict sent on the wire for the +chainstate/p2p methods without their own request-shape assertion elsewhere, +mirroring the request-shape checks of go-sdk/node/client_test.go. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from mintlayer.node import Client + +_WIRE_CASES = [ + pytest.param(lambda c: c.get_block("aa"), "chainstate_get_block", {"id": "aa"}, id="get_block"), + pytest.param( + lambda c: c.get_block_json("aa"), + "chainstate_get_block_json", + {"id": "aa"}, + id="get_block_json", + ), + pytest.param( + lambda c: c.block_height_in_main_chain("bb"), + "chainstate_block_height_in_main_chain", + {"block_id": "bb"}, + id="block_height_in_main_chain", + ), + pytest.param( + lambda c: c.get_mainchain_blocks(1, 5), + "chainstate_get_mainchain_blocks", + {"from": 1, "max_count": 5}, + id="get_mainchain_blocks", + ), + pytest.param( + lambda c: c.staker_balance("pool1"), + "chainstate_staker_balance", + {"pool_address": "pool1"}, + id="staker_balance", + ), + pytest.param( + lambda c: c.pool_decommission_destination("pool1"), + "chainstate_pool_decommission_destination", + {"pool_address": "pool1"}, + id="pool_decommission_destination", + ), + pytest.param( + lambda c: c.delegation_share("pool1", "addr1"), + "chainstate_delegation_share", + {"pool_address": "pool1", "delegation_address": "addr1"}, + id="delegation_share", + ), + pytest.param( + lambda c: c.token_info("t1"), + "chainstate_token_info", + {"token_id": "t1"}, + id="token_info", + ), + pytest.param( + lambda c: c.tokens_info(["t1", "t2"]), + "chainstate_tokens_info", + {"token_ids": ["t1", "t2"]}, + id="tokens_info", + ), + pytest.param( + lambda c: c.submit_block("c0ffee"), + "chainstate_submit_block", + {"block_hex": "c0ffee"}, + id="submit_block", + ), + pytest.param( + lambda c: c.get_bind_addresses(), + "p2p_get_bind_addresses", + {}, + id="get_bind_addresses", + ), + pytest.param( + lambda c: c.add_reserved_node("h:1"), + "p2p_add_reserved_node", + {"addr": "h:1"}, + id="add_reserved_node", + ), + pytest.param( + lambda c: c.remove_reserved_node("h:1"), + "p2p_remove_reserved_node", + {"addr": "h:1"}, + id="remove_reserved_node", + ), + pytest.param( + lambda c: c.connect("h:1"), + "p2p_connect", + {"addr": "h:1"}, + id="connect", + ), + pytest.param( + lambda c: c.disconnect(7), + "p2p_disconnect", + {"peer_id": 7}, + id="disconnect", + ), + pytest.param( + lambda c: c.unban("1.2.3.4"), + "p2p_unban", + {"address": "1.2.3.4"}, + id="unban", + ), +] + + +@pytest.mark.parametrize(("invoke", "rpc_method", "expected_params"), _WIRE_CASES) +def test_method_wire_shapes( + rpc_server, + invoke: Callable[[Client], object], + rpc_method: str, + expected_params: dict, +) -> None: + """Each method posts its exact RPC method name and params object. + + The canned server answers JSON null, which every covered method tolerates + (optional -> None, void -> None, list -> [], raw decode -> None). + """ + srv = rpc_server(result=None) + client = Client(srv.url) + invoke(client) + assert srv.capture.method == rpc_method + assert srv.capture.params == expected_params + client.close() diff --git a/tests/wasm_helpers.py b/tests/wasm_helpers.py new file mode 100644 index 0000000..bde2a5d --- /dev/null +++ b/tests/wasm_helpers.py @@ -0,0 +1,96 @@ +"""Shared helpers for the WASM client tests. + +All WASM tests run fully offline: the embedded ``wasm_wrappers_bg.wasm`` +module is instantiated in-process via wasmtime, no node/daemon/network is +needed. Values pinned here are real test vectors taken from the Mintlayer +sources (mintlayer-core) so the WASM module accepts them. +""" + +from __future__ import annotations + +from mintlayer.wasm import ( + Amount, + Client, + Network, + SignatureHashType, + SourceId, + TxAdditionalInfo, +) + +# The canonical BIP-39 test mnemonic ("abandon ... about"). +MNEMONIC = "abandon " * 11 + "about" + +# A valid mainnet VRF public key: bech32m HRP "mvrfpk", payload +# 0x00 (Schnorrkel variant tag) + 32-byte key. Taken from the +# example_mainnet_vrf test in mintlayer-core common/src/address/hexified.rs. +VRF_MAINNET = "mvrfpk1qqyxcl4tc6y9amf2vmv6sgu8x5jwqlxawx73vhgemkduag9c8ku57m03mze" + +# Height used across fee/staking/ids calls (post all early forks). +HEIGHT = 500_000 +# Height at/after the orders V1 fork (encode_input_for_freeze_order). +ORDERS_HEIGHT = 1_000_000 + + +def derive(c: Client) -> tuple[bytes, bytes, bytes, str]: + """Derive the standard (account, receiving key, pubkey, address) chain.""" + account = c.make_default_account_privkey(MNEMONIC, Network.MAINNET) + recv = c.make_receiving_address(account, 0) + pub = c.public_key_from_private_key(recv) + addr = c.pubkey_to_pubkeyhash_address(pub, Network.MAINNET) + return account, recv, pub, addr + + +class Wallet: + """Derived identity bundle for one client (fixed by the test mnemonic).""" + + def __init__(self, c: Client) -> None: + self.account, self.recv, self.pub, self.addr = derive(c) + + +def fake_input(c: Client, txid: bytes = b"\x01" * 32, index: int = 0) -> bytes: + """A real encoded UTXO input spending ``txid`` output ``index``.""" + src = c.encode_outpoint_source_id(txid, SourceId.SOURCE_TRANSACTION) + return c.encode_input_for_utxo(src, index) + + +def simple_transfer_tx(c: Client, wallet: Wallet) -> tuple[bytes, bytes, bytes]: + """Build (input, output, unsigned tx) sending 1 ML back to ``wallet``.""" + inp = fake_input(c) + out = c.encode_output_transfer(Amount.from_atoms("100000000000"), wallet.addr, Network.MAINNET) + tx = c.encode_transaction(inp, out, 0) + return inp, out, tx + + +def witness_for(c: Client, wallet: Wallet, inp: bytes, out: bytes, tx: bytes) -> bytes: + """Sign input 0 of ``tx`` with the wallet's receiving key.""" + return c.encode_witness( + SignatureHashType.SIGHASH_ALL, + wallet.recv, + wallet.addr, + tx, + b"\x01" + out, + 0, + TxAdditionalInfo(), + 100, + Network.MAINNET, + ) + + +class MemorySpy: + """Proxy for ``client.memory`` recording zero-filled writes. + + ``_core._call_return_*`` zeroes result buffers that carried key material + before releasing them; the spy lets tests observe that contract. + """ + + def __init__(self, mem) -> None: + self._mem = mem + self.zero_writes: list[int] = [] + + def read(self, *args): # noqa: ANN002, ANN003 - passthrough + return self._mem.read(*args) + + def write(self, store, data, ptr): # noqa: ANN001 - passthrough + if len(data) > 0 and set(data) <= {0}: + self.zero_writes.append(len(data)) + return self._mem.write(store, data, ptr) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f86bb11 --- /dev/null +++ b/uv.lock @@ -0,0 +1,851 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" }, + { url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" }, + { url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" }, + { url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" }, + { url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" }, + { url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" }, + { url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" }, + { url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" }, + { url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" }, + { url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" }, + { url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" }, + { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/62/a8b4dd53308a4b1571e6bdd77ee562c9b1c1742db9886c0aef2f69463067/coverage-7.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f12a9e27ca7b65e40a8475d27899b2d45064d9020e6a89148939e01987b5853", size = 223178, upload-time = "2026-09-13T19:08:21.805Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/b9325b8d486a5c05fd4a9eb1cd9c9f697c361ddeda0e73567d5c2d8959f6/coverage-7.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f590d46c30d9e4c1fda3efefe5443f4c2f6a4192c5ca2ba403653e9ebadf097", size = 223701, upload-time = "2026-09-13T19:08:23.391Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ee/3ab3ac6e9e7165a623695b73a83d1bfbdf5069681a68a012cb5abb096060/coverage-7.16.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df82f0a3cef4e1bcfc799436056f0b978dda319d0bfd4460c6e479b2802d98a", size = 250434, upload-time = "2026-09-13T19:08:24.948Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/31ffc231e9879876f611cf1ac7622cf092d76762c07ca64dce47b29a200b/coverage-7.16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb462d59146656e278d1e8ed913ce374d0ba68a4e081acde1867f6d3377fc881", size = 252264, upload-time = "2026-09-13T19:08:26.734Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f4/4fb4f6b16de07dbae3d3a111ae28d32ad657ce6ff4d364c49c10962daad5/coverage-7.16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7db888dd0a1df1a653cae7f99d4047430d2187a3626eda51bc847b0fd6b9b43", size = 254127, upload-time = "2026-09-13T19:08:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/048e88eb610f44b7542dac10fa45bb2ffad5fbfcdab0f03289a843da934b/coverage-7.16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38a7e16f061504ac2b45370bf5bf97e8250d8d3f25e37385bae884978166554b", size = 256043, upload-time = "2026-09-13T19:08:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/97/59/d27b113c80b04c7fc64c69457132648e1d287b83a1de41189a504b50b52b/coverage-7.16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b26b55b18e1a53e1a159dd743728c4ddbbef28ba19000a91aaff5ce023197ec", size = 251092, upload-time = "2026-09-13T19:08:31.57Z" }, + { url = "https://files.pythonhosted.org/packages/45/42/b7eafaceff08dc1dcbfc924fa9233031fd00c8b8287cc4a1a0dcdedcb0f9/coverage-7.16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb2f528b50953e29d22033b3256c396a700193c6e45b2490222ef9c333cbbf9", size = 252170, upload-time = "2026-09-13T19:08:33.191Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8f/57dcc5360aa6d8d24370c12fe855f6ebf5fbf2718c154fb9fdec7161de07/coverage-7.16.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3284754371dc78592aa3ae4d661d30ee20e02b2b6b0de3590a181a936d0d3b38", size = 250173, upload-time = "2026-09-13T19:08:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/379a8c7ac0f3063769dc43fd2475e9fc9b2722cb88eabe71f4d862660c93/coverage-7.16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:802d1246c540e07486d4ee1adfa19a797e4b33529ffc371dc140644e8f27da0a", size = 253986, upload-time = "2026-09-13T19:08:36.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/25/70218d2a6077730843b75ee2525fa64a750283d71cc4c063dc0b75ba6979/coverage-7.16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6fc735d6fe6d57f803e7ba021be4dde48e43e6aff94e6954350555d5332b0594", size = 250444, upload-time = "2026-09-13T19:08:38.434Z" }, + { url = "https://files.pythonhosted.org/packages/58/ac/6c41c441baa6c8a1fed5615e4e1e7d0ca649b33b472ee28591a92d70a1ff/coverage-7.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed5ade1bb18f62edace1bd198c66f9d4c75a8385d5fd24e87d917ea1a5958773", size = 251046, upload-time = "2026-09-13T19:08:40.133Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/01166d6f8aa42acd148b4c2c88173d60eefdd0270ea2bcbba79be35bc193/coverage-7.16.1-cp310-cp310-win32.whl", hash = "sha256:0c309096926b119543dc16438a11ef4c80783d2f4e59ff94f7f462651a944cdc", size = 225242, upload-time = "2026-09-13T19:08:41.672Z" }, + { url = "https://files.pythonhosted.org/packages/d2/28/a76060478919becf0fbea2df2053a6e1a7195f11edf8dfd51c58c1636b43/coverage-7.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:7562f8067ed9360e8b9739e5703403a7686dd1b36bf0f89fc538047c54cdea90", size = 225868, upload-time = "2026-09-13T19:08:43.22Z" }, + { url = "https://files.pythonhosted.org/packages/af/3a/d09495dfd5191b5593852bbe2f037afae768157443378d066d9ecea69a49/coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da", size = 223307, upload-time = "2026-09-13T19:08:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/310196a80258e28ebf6e5aa814a5a6ffbb18c03cf3c0c0c04191ed893e88/coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07", size = 223817, upload-time = "2026-09-13T19:08:46.738Z" }, + { url = "https://files.pythonhosted.org/packages/73/29/ed0fda1fcd440cdfc07e72a07f3c9c3b43f65a6b3d53bff2cba9b9de50e8/coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5", size = 254222, upload-time = "2026-09-13T19:08:48.358Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4e/5e2507fbd71ec1047620978f39568b0d2ed6c4468d08495a0c24f5677f65/coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475", size = 256134, upload-time = "2026-09-13T19:08:49.926Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b1/a9f97846554bc240473656f9c3874591105f29c08eccf4282daadc6bc281/coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567", size = 258240, upload-time = "2026-09-13T19:08:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/2b1ab208d06719394851cdd7dc322157870a93b178410b1bda6cac7755f9/coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e", size = 260202, upload-time = "2026-09-13T19:08:53.204Z" }, + { url = "https://files.pythonhosted.org/packages/af/14/ff4db31d6e3126d5b72aab1e868d35ec94e1b71dee46f5c3b54cb1b2438e/coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d", size = 254305, upload-time = "2026-09-13T19:08:54.833Z" }, + { url = "https://files.pythonhosted.org/packages/ce/27/c28faa4818f61c49ca3bfb4a77e1c191e79a40447504b89af0bd859cd6fc/coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b", size = 255935, upload-time = "2026-09-13T19:08:56.453Z" }, + { url = "https://files.pythonhosted.org/packages/46/5a/42e64e5b716048cae33e2e15f8c6fe04a927467056c533b8e88900da895e/coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf", size = 253995, upload-time = "2026-09-13T19:08:58.159Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/860287acd5a1e29acd337f302b476b54148203590091b1a1555b2e914b50/coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01", size = 257767, upload-time = "2026-09-13T19:08:59.958Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c5/4b68006da567b4b413352f891776c35776b3557a0479a28187ab57c21659/coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08", size = 253715, upload-time = "2026-09-13T19:09:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0e/b2d0c47cfbf11770e197f1d0f11a92864eb29fd7eaca45bc9915573d9725/coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572", size = 254624, upload-time = "2026-09-13T19:09:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/3bab8821b2b942155578fc34e88e21b0670d922f039ab23651f6035ba7f7/coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2", size = 225404, upload-time = "2026-09-13T19:09:05.174Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ce/4f4ce667a97d1c538b46a79b89161395ad2beffc248b6c782afc9f521927/coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7", size = 225879, upload-time = "2026-09-13T19:09:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/26/17/f3dce5e47351ab34522dd037fd64b111742958e24861bdd2820971bc1a26/coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22", size = 225428, upload-time = "2026-09-13T19:09:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/7cd4c9f2a3b7574414222ec425d5eee21cc690d867a013315e3be8ba185c/coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af", size = 223475, upload-time = "2026-09-13T19:09:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f2/9ac65f9cedd43f91e2d3657c11f13aef82aebae89b2243dc9e44fe58a740/coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c", size = 223845, upload-time = "2026-09-13T19:09:11.988Z" }, + { url = "https://files.pythonhosted.org/packages/62/4d/f13db452d4367fe1122abfd98ae330596f65a3b40498f8e1a5811f68f123/coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809", size = 255341, upload-time = "2026-09-13T19:09:13.708Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6b/85ed86e82835a96ddfbe7705c387c28ce1cbbecd1b6d606f5ddfeeb712df/coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf", size = 258078, upload-time = "2026-09-13T19:09:15.368Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/f66945853d850b9c05f4e012e37396f1f31d0cd40d062394b229c22e7b56/coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f", size = 259191, upload-time = "2026-09-13T19:09:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/7b907abe62461f289035c7ac60ab3e6334efb8c425151aac4abfa0c97820/coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360", size = 261452, upload-time = "2026-09-13T19:09:18.756Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/e26f4d6b1069a2a2fee3238a132f85c5b4c1dc25aebd88e015451ff0bd68/coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08", size = 255698, upload-time = "2026-09-13T19:09:20.786Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ec/99db7450e813050ebce300e31bbd4f997c76a8c6e3a6d168c5dffc104109/coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941", size = 257111, upload-time = "2026-09-13T19:09:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9d/500df9d3cd8c541ac84b5e0bcab00646be75654e05aa34e8410ec851282d/coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489", size = 255258, upload-time = "2026-09-13T19:09:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/53318d96da332bb4946f8ac646fa19fd37fecbd0c49144735c716ac69bf0/coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776", size = 259326, upload-time = "2026-09-13T19:09:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ce/abc0462b2e6ae96ae22197d2bc30fe33b6b4e52937e782745436ceb2a761/coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6", size = 254827, upload-time = "2026-09-13T19:09:28.191Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e1/0a04eedaaf19196b51f0180968134228c7646e469ed29914e48690a7cf3e/coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0", size = 256698, upload-time = "2026-09-13T19:09:29.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/1d/d441a55cf22ce9d8e9e34814806c47441ab844bd534e0f4b64e1c6ae8bd1/coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d", size = 225541, upload-time = "2026-09-13T19:09:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/ec3d032375735dd331477caa051419678079ff90fcb53d0284a6c2bfb757/coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e", size = 226075, upload-time = "2026-09-13T19:09:33.556Z" }, + { url = "https://files.pythonhosted.org/packages/94/00/90e9f5c4434878494306b9c0ee8068ebbd829483737d8cefccc58884d728/coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8", size = 225461, upload-time = "2026-09-13T19:09:35.48Z" }, + { url = "https://files.pythonhosted.org/packages/aa/74/c08c0c4dc9fa6bcd1d90728a63660aa1b17b488a806948598456c48f75d1/coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", size = 223501, upload-time = "2026-09-13T19:09:37.276Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c8/784986d326663285258a8e39835c46fb730cc85284f0dbcd82078586dd22/coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", size = 223876, upload-time = "2026-09-13T19:09:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/15/76/73fb792928872bbb07e553f920ff55c65ee962c469265feb5d1d4ae5f97a/coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", size = 254863, upload-time = "2026-09-13T19:09:41.326Z" }, + { url = "https://files.pythonhosted.org/packages/ca/8d/1fd78899513244b065ccecdd1cfc6aa8b8f1bdee796d8c4f2aa8e8e5397d/coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", size = 257460, upload-time = "2026-09-13T19:09:43.086Z" }, + { url = "https://files.pythonhosted.org/packages/47/1c/b23ddfcb7ef9bd7b3fdb7be9a5dccf9925ae88e556df7aa5b655283c78f2/coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", size = 258697, upload-time = "2026-09-13T19:09:45.161Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c9/19d0c6ca35778a7e9415c30c46a0c64c9d4374219d004a35563132296896/coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", size = 260827, upload-time = "2026-09-13T19:09:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/97/fc/5862f7344382c62b85df43d483e579168cc062e007f54d895dfa51fe3e7d/coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", size = 255038, upload-time = "2026-09-13T19:09:48.945Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9f/5c26583199a68df8d15124b4590520903f8eb7cef17db293fea712bb0783/coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", size = 256827, upload-time = "2026-09-13T19:09:50.738Z" }, + { url = "https://files.pythonhosted.org/packages/95/13/605198bff079b107336710f28a797312ab132587168600d70a290e7ecd2e/coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", size = 254794, upload-time = "2026-09-13T19:09:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/43/b7/0bbb32dc5ccdac766a13763fdfbffd7d73fc80349a69230c46c6b71e7508/coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", size = 258947, upload-time = "2026-09-13T19:09:54.372Z" }, + { url = "https://files.pythonhosted.org/packages/13/bd/65c31ddd43ff4e61b63721b3dad17cca412dba6e2ce89f92abdf75734339/coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", size = 254613, upload-time = "2026-09-13T19:09:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/d278115f2ba522b3e3128c6852be265f4e0df7202b2effca4db96cf0217d/coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", size = 256388, upload-time = "2026-09-13T19:09:58.095Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ca/d43aa396fb3a2f71c9411b99d926475f5acaf5c124f605c592865c80c8d5/coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", size = 225550, upload-time = "2026-09-13T19:09:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/df2f6114f6a17cffe94721bd1d298f77f86aad493717fa5bc03cb291a1a6/coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", size = 226091, upload-time = "2026-09-13T19:10:02.331Z" }, + { url = "https://files.pythonhosted.org/packages/ee/31/6f90d8aab72a112492bd50e3b5485b53b772b1f5fa70da0a620e723caae6/coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", size = 225481, upload-time = "2026-09-13T19:10:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" }, + { url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" }, + { url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" }, + { url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" }, + { url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" }, + { url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" }, + { url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" }, + { url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/ae/16/e11addf5322d98e86307da9e00c94644b97cb72c534c74cda89705bebeef/coverage-7.16.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a337dc2d54c74430cd2febb8ee04f7c508ba8b3b412bf0463f077a66cfc73743", size = 223544, upload-time = "2026-09-13T19:11:09.108Z" }, + { url = "https://files.pythonhosted.org/packages/92/d9/7ccd6484f615961d94b09c75904f87d86375109596069ae3a495a205dbbe/coverage-7.16.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3acd1d78397dead78dd1b011b5fc19cc823c190349acd549e63856dff649c80e", size = 223882, upload-time = "2026-09-13T19:11:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/59/37/2fd78152a557df4a7e4ad78d6851ede90c211838d526e4916c6016f45144/coverage-7.16.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73a32694603a34ad01d7e51a481a4023410d8099e1d0757e067945695c10f0ae", size = 254987, upload-time = "2026-09-13T19:11:13.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/58e2f9b8b13cc422aa7f474a498945ec7f446ef473b5b6bae18d2981f2a9/coverage-7.16.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbbe8265736659a6be2e6042b6a35be13545d14b243cc1d7ecf65f90d788a370", size = 257902, upload-time = "2026-09-13T19:11:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/63f8f6a67c03b86455c726be273b2aa5b57fd1122f22350d141bbb142e91/coverage-7.16.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b0359eb4c62f9993e176bc8f50450fc736a6b90dbcc05bb8584e948812699ae", size = 259522, upload-time = "2026-09-13T19:11:18.206Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3b/1f2261da7483e345fe55de11e41ffc16a543511e37c0912134c0dccc4f6c/coverage-7.16.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da506e669a8a851b59e122b4b219ea70996a6296f44f3a9348a852526ff961de", size = 261723, upload-time = "2026-09-13T19:11:20.628Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8a/7eb1a361e044b7293f49c921a974b532627ed37f3bef5e7e9f58bcd0d5a2/coverage-7.16.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:64a2a5985d81810ed605ff0dc4ccd6555efcb5700353825532a9a0aea65826e1", size = 255462, upload-time = "2026-09-13T19:11:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/67/66/6d94f7ea87e99c519def5cfa5d4a1ab20247d6b403eb6e6b5b63d6aa5985/coverage-7.16.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:66d70132b69b861805dc1ca46cdd733e54c416890e8f1371d2fd103f70b59c9c", size = 257617, upload-time = "2026-09-13T19:11:24.933Z" }, + { url = "https://files.pythonhosted.org/packages/3d/52/c3de0a3868589a1463a4f1cb0222eca0e5fa9f91da865ccf20d2e19ec19c/coverage-7.16.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:db651a9cf325a542bc2b7b8cc8f1b2bdc6492739bae3103731b2f1c85b96cff6", size = 255495, upload-time = "2026-09-13T19:11:27.158Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a3/ea10e75f20fc1e7be10b166a494826a965d19b1dc9c8a81c511a01153311/coverage-7.16.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:4184e78a4465dcda359fb403172b8951dd220929cb0984c02fabca1742fff06f", size = 259728, upload-time = "2026-09-13T19:11:29.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/96/9d8020d97de46f0390739beda196322586cdd87e1fc357223e6a28bc4135/coverage-7.16.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bc53c3f3adaa939b7a063533ffe0ae1259e7073393c618043a99a6970a87e3df", size = 254904, upload-time = "2026-09-13T19:11:31.86Z" }, + { url = "https://files.pythonhosted.org/packages/5b/21/2ff8867d4560f4f639a82d8fcf13c27eea88a96193daba096027c48bb929/coverage-7.16.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b44308854ef210b9b78df9cdfd4e159513382a859f5ef8464306d14a54c2a040", size = 256828, upload-time = "2026-09-13T19:11:34.078Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4b/feacad51cb5163e3baa13281f8a3098f15d7934cc1f28cfe656557eb4e46/coverage-7.16.1-cp315-cp315-win32.whl", hash = "sha256:dccc142614d3419ed71857deb43f1d757829a4c7fce9994464b71e7e38309827", size = 225722, upload-time = "2026-09-13T19:11:36.314Z" }, + { url = "https://files.pythonhosted.org/packages/39/34/66bbc6ffd3c51fa67604c566920e772c5e3baa57f88dcf7b109c7f18b2cb/coverage-7.16.1-cp315-cp315-win_amd64.whl", hash = "sha256:961fc424e9d5229a99f8f1189942d8e7f4e1519147c3af64842f944aca03914d", size = 226195, upload-time = "2026-09-13T19:11:38.493Z" }, + { url = "https://files.pythonhosted.org/packages/f7/96/4bad920d4caed127c37c136a64b0730fa495517d2eb58809e0d81e6e7dff/coverage-7.16.1-cp315-cp315-win_arm64.whl", hash = "sha256:681a9488c5a234397c4f013da065aa9e53eb7af4c78f1f80c6f15e7208acb855", size = 225625, upload-time = "2026-09-13T19:11:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2b/a88c7906eeb7da4394e932060d7008e153b3a2cd8d11782d82faa03bb7a3/coverage-7.16.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8bb09a2d19b04db1fa0e087a7ca4f12458f7e0e7364cfcd838441d86fb1c61f6", size = 224271, upload-time = "2026-09-13T19:11:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1f/98db59c595680d16f553890deac6d72610e04f192c1964ec9b69cbe790ab/coverage-7.16.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2270a794600b635ca9452ce4c32e2fe81a35f9caa17ffac0eba99f14f275bd4d", size = 224564, upload-time = "2026-09-13T19:11:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/699e236d4cb97ec282b0655afc85acf12349e71a02fff0f1a7d0fb643079/coverage-7.16.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4eff405b545dfcf79cf0d9d3ff750e5c5a887aa066114175193a81d249c5ee6", size = 265423, upload-time = "2026-09-13T19:11:47.666Z" }, + { url = "https://files.pythonhosted.org/packages/96/1b/6eaa21912863b3e3bf23cfe605fb62929bb1a41dc33d2bfa8e92000232bd/coverage-7.16.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ee1d5fc9e3bd6a217906929cc97880239a91d20dae7746f538eb0eefee705ab1", size = 268503, upload-time = "2026-09-13T19:11:50.12Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/4eb46bffa98c28a80559a58c12f17e19308d52b31174af1bd49decf76606/coverage-7.16.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4ec944947de098ad5a1738413f9364689a57067ecbc328e9de37218aa1e5cc1", size = 271059, upload-time = "2026-09-13T19:11:52.363Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d4/17a51ef1f6a084c9abbead522cfbee0fe9d29a593128781e724b7a0795b3/coverage-7.16.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f73ee3956fde2d461c9e2955dd48166e4821fc8587d135e8780fb84da2a098b", size = 272039, upload-time = "2026-09-13T19:11:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/9fdeba8b8aa0848030f45f869ddedc306795a8c6e53e901b8553afbf5415/coverage-7.16.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ec9a4ee989c0d06ad95add0dbfdbb72b00ef53f43431ca0b612384e7878e5de", size = 265869, upload-time = "2026-09-13T19:11:57.423Z" }, + { url = "https://files.pythonhosted.org/packages/95/eb/ab3eb506b2e4dd278440fbbbc7ed18424a86d8a981db0b90fdba66f5d34d/coverage-7.16.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7e5727b2508f817f3126d6c33327dda32fe69d15514badfcd61db8bc4209ecef", size = 268883, upload-time = "2026-09-13T19:11:59.71Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0c/f4edefdce33f01954a74237df5ee83ccef5dcd1165e04abf0adf47543fca/coverage-7.16.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:19a3ea2f364012ef06678118fffdc92442a16bef4a5c8ad4f4019dd8f9ac8876", size = 265359, upload-time = "2026-09-13T19:12:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/df/86/ce5ed885fc7ce2adc9a5971134ace257d0f84f19c3d117da89c179b9662e/coverage-7.16.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:996c2b891b441ec2b39725ee3e8386e2f11b4894b92be225fdfd54a3eeada2c8", size = 270056, upload-time = "2026-09-13T19:12:04.46Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/5dbd5f95070cece25c6df9e6ea4ded2a87dc695ce82a49bcb2116452b342/coverage-7.16.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a125fac1f6b1e88488d208a86b578e1790e3c4937f2e1568d23356141d236220", size = 265498, upload-time = "2026-09-13T19:12:06.811Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ff/254b26f3300f87c7c53ac97da41ae75f0e2ac066aa964bc073c0f3750b38/coverage-7.16.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b10095528b866d322d33d6bf1709b7f8cbf959f12e8cb2ba22fc59c8717866b0", size = 267459, upload-time = "2026-09-13T19:12:09.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/20/bf9958f8ddbbf6a2d39da3d4009f33e0fcf19c2d2fd99715c6494cdc8e8c/coverage-7.16.1-cp315-cp315t-win32.whl", hash = "sha256:531d9be377fdcc05593b974656872eb82e808ebeb42a72515e3aaeb8bb7166f5", size = 226021, upload-time = "2026-09-13T19:12:11.933Z" }, + { url = "https://files.pythonhosted.org/packages/35/8f/12d46948704d9e437c8dca2718e5d54ecc2f85396b64b500c29d8dbe10db/coverage-7.16.1-cp315-cp315t-win_amd64.whl", hash = "sha256:46a88f51770df7c9bc376bd57d3f86cdc7624b8e16ac4b585a655c22b7a1b4db", size = 226853, upload-time = "2026-09-13T19:12:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7f/1ca5fd0601054fce5a3539375b997e67683646e2bf62f60c41e70b529fcc/coverage-7.16.1-cp315-cp315t-win_arm64.whl", hash = "sha256:7580432cbe1e8b762660ae5806f04f869e1c02e519836a43f8094437e561e9f0", size = 226163, upload-time = "2026-09-13T19:12:16.589Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "mintlayer" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "requests" }, + { name = "wasmtime" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "requests", specifier = ">=2.32.4" }, + { name = "wasmtime", specifier = ">=25.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.11" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/b9/de8f67e12d721cdcc8ba6cfc440b989a4ba4dfabe4402ae94dfdd8bb30a4/mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41", size = 14015541, upload-time = "2026-08-15T03:01:53.104Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8a/9e746ab012c67ed8ea3232a613716c306ee8c0b5682c80d8103b4f04568e/mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0", size = 14248142, upload-time = "2026-08-15T03:02:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/c99ff2d8d0e2c53393e32dfe22d9aa43a5d959d30db46c786dafd24527d3/mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167", size = 15193309, upload-time = "2026-08-15T03:01:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/124638f745243faae1ff4b37d5426fe41c0f0454535edc82fe8102b56a3c/mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13", size = 15498246, upload-time = "2026-08-15T03:02:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/31c0781e243836505c0fb5f4e865487d6df1023e4ad959f4ebd4b84a0226/mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53", size = 11155028, upload-time = "2026-08-15T03:01:39.08Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ab/bc2eb0129e72d7d7d93d5e981a78084a9abefda7efa732a7e02f97d6e27d/mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90", size = 10151438, upload-time = "2026-08-15T03:02:19.04Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "wasmtime" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/1f/03a286dc84d83cc3274d5599543558442ba9332b676e6408ee9e1c171199/wasmtime-48.0.0.tar.gz", hash = "sha256:dba27d59209fac703e7d5753af78c2af2c1cd1ed735f520ec76dc31c60a05815", size = 128804, upload-time = "2026-08-20T19:31:57.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/e6/39da2f4047a281bce7d4651e21785942d630033931eb397cf734e7ccc048/wasmtime-48.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:a55abf132fe238b843a963c68cd1a30d8f686c1bc75d8fbf042d8b7a1d51ee36", size = 8800816, upload-time = "2026-08-20T19:31:28.761Z" }, + { url = "https://files.pythonhosted.org/packages/be/d0/f4f107166a65ddf8a6d8cf74f0cea33c06a08c74fe686f8e83b194e57e8b/wasmtime-48.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:d8e94276ff6c0c5ce73ee16ccbacb00b3512a4b3a664749380705d81ee06a23c", size = 9731711, upload-time = "2026-08-20T19:31:31.905Z" }, + { url = "https://files.pythonhosted.org/packages/95/15/20fad0cb2b9cff130c225bf827e16365e02883f504297eccf172c6bb7228/wasmtime-48.0.0-py3-none-any.whl", hash = "sha256:49c9ee43e9cf59ad7453ac65dce0cc4b885837904dd3cfd45faafe930defe14a", size = 8157926, upload-time = "2026-08-20T19:31:34.74Z" }, + { url = "https://files.pythonhosted.org/packages/89/93/911434c6c4406e6979b6cb67ba889c85633ff8d92eb0cb569fec6e2a43f7/wasmtime-48.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:50e1ea81a3bec537d00e076722dfdc48978a56ea24619d8153aa1f75b11796b9", size = 9395773, upload-time = "2026-08-20T19:31:37.312Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/91c9c19ed7f8e164f4db6405d872c9397be9f53e4f325d0adcd5e67598f4/wasmtime-48.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ea69889a3c51702e9da5f5f441027ca934f7758f8926a4ed167b0d6877f092e8", size = 8343024, upload-time = "2026-08-20T19:31:39.922Z" }, + { url = "https://files.pythonhosted.org/packages/a2/92/e144fcf578fc394678c24b042efe45f3b0614acdb87ea95d8b839b208842/wasmtime-48.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:58544d539053dff7bd4cf30c40d7a540862d683013c0dfa6ba46a063f5b682f7", size = 9796354, upload-time = "2026-08-20T19:31:42.325Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c3/a957b226979daaeb09ec024562e9aac05e475a954537e6f150eb60bca84d/wasmtime-48.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:26fce3613fefbe29a28e9d659dca3326e800593858e5758cad086eb802b3b766", size = 8734885, upload-time = "2026-08-20T19:31:44.966Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bf/00e44d1971307620d6660760ed04796405a5fb1819c8b43ec03ad85efac6/wasmtime-48.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:77f6b75db20be065e205e7af814d4e4f06784c3a00eb346e8c76148ecb4afe5a", size = 8786289, upload-time = "2026-08-20T19:31:47.99Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/ce68af7734a5a9424dd66a301b11c810215ec7f70230b35bed10ed312e97/wasmtime-48.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62b241c8d5dfb59ff8af1ccaa5351f0ab7aba8cc872f7d80e0e3c95d54c13562", size = 9889697, upload-time = "2026-08-20T19:31:50.854Z" }, + { url = "https://files.pythonhosted.org/packages/9d/12/5266bebece874ebfa3196c973b917091dd4c55e9e9da55401e312c403044/wasmtime-48.0.0-py3-none-win_amd64.whl", hash = "sha256:21fa500e70f3819a8c0539c3f0be6b3b81ec3c630bb90c47dba4d8a2c1d4c698", size = 8157931, upload-time = "2026-08-20T19:31:53.312Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a4/bb6c90d99ad893bd42f33aa7fb386deecb55987f012c0c2f5fcaba83106d/wasmtime-48.0.0-py3-none-win_arm64.whl", hash = "sha256:09cd5e14df80a3a8d447428a548583181c568ea2e617419d23600deff21d4b82", size = 7044651, upload-time = "2026-08-20T19:31:55.63Z" }, +]