Add Mintlayer Python SDK (port of go-sdk) - #1
Conversation
Full port of the Go SDK's wasm-bindgen host shim to wasmtime-py: - all 31 host imports from './wasm_wrappers_bg.js' (RNG via os.urandom, JSON serde round-trip for TxAdditionalInfo, error side-channel capture, externref table index arrays with callee-ownership-correct cleanup) - vendored wasm_wrappers_bg.wasm with load-time sha256 pinning - complete API surface: key derivation, addresses, ids, inputs/outputs encoding, timelocks, fees, transactions, witnesses/signing, staking, intents (78 public methods across 11 area mixins) - byte-for-byte parity with the Go SDK verified (deterministic mnemonic derivation, addresses, fees, timelocks, spend maturity) - result buffers zeroed before free; thread-safe via RLock + @synchronized - ruff + mypy clean
Shared JSON-RPC 2.0 transport (mintlayer/_jsonrpc.py) mirroring the Go semantics: params always objects, ids from 1, HTTP status never inspected, null result = not-found, basic auth gated on username (netrc fallback suppressed for owned sessions). Node client with all 38 methods across node/chainstate/mempool/p2p mixins: full wire parity with Go (durations as [secs, nanos], FeeRatePoint and BannedPeer tuple wire shapes, Currency omitempty vs OutpointSourceID null content, orders filters always sent). Deliberate fix over Go: OrderInfo.nonce is Optional (daemon sends null for active orders). 57 pytest tests mirroring and extending Go's client_test.go matrix, including wire-shape pinning and concurrency. ruff + mypy clean.
All 33 routes under /api/v2: chain, blocks, transactions (incl. the text/plain submit route), addresses, delegations, pools, tokens/NFTs, orders, statistics and fee rate. HTTPError for status >= 400 with trimmed body; base URL trailing-slash trimmed. Lenient numeric decoding per server behavior: uint64 fields arrive as numbers or strings, margin_ratio_per_thousand carries a trailing '%'. Strict uint64 parsing (no negatives/underscores), finite per-thousand values, path segments URL-encoded against traversal, and malformed payloads surface as IndexerError instead of KeyError. Zero-valued pagination params are omitted; block-stats from/to always sent. 59 new tests mirroring Go's indexer client_test.go (116 total passing). ruff + mypy clean.
All 56 daemon routes across management/transactions/tokens/staking/orders mixins with exact wire fidelity: TxOptions always-both-keys null pattern, selected_utxos omitempty for nil and empty, OutputValue/CurrencyFilter custom encodings with client-side validation before any HTTP request, account_index wire key for lock_token_supply, account dropped for get_pool_balance, hardcoded Trusted trust policy and Confirmed-only balances, empty-string-to-null coercion, CoinFilter without content key. Mnemonic/passphrase fields redacted from dataclass reprs. 101 new tests mirror Go's wallet client_test.go and orders_test.go (217 total passing); wallet package at 100% statement coverage. ruff + mypy clean.
Top-level mintlayer.Client(Config) constructs only the sub-clients whose URL is set, with lazy WASM init (init_wasm) and convenience re-exports mirroring the Go SDK root package (Amount, Network, sighash/source constants). Examples ported from go-sdk/examples: send_coins.py (full manual transaction flow: key derivation, UTXO fetch, tx build, witness signing, submit) and issue_token.py (wallet-daemon token issuance + mint). 15 new top-level client tests (232 total passing). ruff + mypy clean.
Port all 7 Go SDK guides to Python (node, indexer, wallet, wasm, transactions, staking, tokens) with verified-signature method references, wire-shape quirks and security guidance; full README with working quick-start and module references. Wallet params dataclasses redact mnemonic/passphrase in repr (6 regression tests). Misc: transport-security note in node docs, argv secrets warning in examples, dead code removal. 238 tests passing; ruff + mypy clean.
Node and wallet _core mixins repeated _call/_call_ignore/close over JSONRPCClient; hoist them into a shared base in _jsonrpc.py. Also replaces node/_core's function-local Amount import with a module-level import (no cycle: node/types.py has no intra-package imports).
IndexerCore only aliased IndexerHTTP.get/post_text as _get/_post_text. Mixins now subclass IndexerHTTP directly and call the transport methods; the _seg path-segment encoder moves next to the HTTP layer it serves.
block.py re-exported Transaction with noqa F401 but nothing imports it from there (public path is mintlayer.indexer.Transaction). Drop the noqa comments on JSONRPCError imports: both modules list it in __all__, so F401 never fires.
Baseline: 77.5% (WASM module undertested in-suite; node/indexer/wallet at 96-100%). Threshold target: >80%.
The wasm-bindgen callee takes ownership of the externref table slots
referenced by passArrayJsValueToWasm0 index arrays and deallocs them
itself; the host's post-call _dealloc_indices therefore double-freed
free-list entries, corrupting later allocations ('array contains a value
of the wrong type' on subsequent multi-element calls to
estimate_transaction_size / encode_signed_transaction_intent /
verify_transaction_intent).
Host-side slot release is now restricted to pre-call rollback (where the
callee never ran); post-call, only the host-malloc'd Uint8Array backing
buffers are freed (the callee to_vec-copies and never frees the
originals). Stale docstrings corrected; ownership contract documented,
including the known wasm-bindgen failing-call slot leak.
148 new offline tests covering the full wasm client: lifecycle (close, integrity pin fail-closed, unknown exports, memory errors, result-buffer zeroing via memory spy), the complete Go client_test.go matrix plus change-key/equality chains, all encode_input_for_* / encode_output_* / timelocks / fees with real mintlayer-core test vectors (VRF key), ids on real encoded inputs, transaction encode/sign/decode flows, witness and HTLC paths, intent roundtrips, types wire shapes, and fault-injected rollback branches (mutation-verified). Regression suite for the slot-ownership fix: multi-destination estimate calls, mixed-load table-stability stress, and dealloc rollback semantics. 386 tests passing; total coverage 95.5% (wasm/_core 92.5%, all other wasm modules 100%).
Mirrors go-sdk's ci.yml structure adapted to Python/uv: - lint job: ruff check + scoped format check + mypy - test matrix on Python 3.10-3.13 with pytest-cov and a hard --cov-fail-under=80 gate (current: 95.5%) - build job: sdist/wheel + smoke test that the vendored wasm binary and its sha256 pin ship in the wheel - least-privilege permissions (contents: read), SHA-pinned actions, persist-credentials: false, cache keyed on pyproject.toml (uv.lock is gitignored), concurrency cancellation, per-job timeouts
alibaba/open-code-review v1.12.0 (SHA-pinned) on pull_request events, reviewing with glm-5.3-flash via the z.ai API using the OCR_LLM_TOKEN secret. Fork PRs are skipped cleanly (no secret exposure); concurrency grouped per PR; npm CLI additionally pinned via ocr_version.
The pinned-version install flow leaves the ocr binary unresolvable in the run step (exit 127). Restore byte parity with the known-working rust-sdk code-review.yml (@latest install).
|
🔍 OpenCodeReview found 21 issue(s) in this PR.
📄
|
Library fixes: - Config.password redacted from repr (same treatment as wallet params) - Client.close() now also closes node/indexer/wallet sessions - indexer: get_token_statistics URL-encodes token id (_seg); submit_transaction and get_pool_block_stats validate response shape into IndexerError; from_json wrapper also catches AttributeError; parse_per_thousand rejects invalid types with IndexerError - wallet: _call_model_list validates shape (null -> [] Go nil-slice parity, non-list -> JSONRPCError); staking_status simplification - wasm host: signal_exception records the host-side message in the per-call side channel (first-wins; cast_2 still overwrites with the richer Rust message); _subarray clamps the range like JS TypedArray.subarray (closes OOB view arithmetic) - wasm _core: WasmError preserves the cause chain (from exc instead of from None); zero-length return buffers now uniformly read/zero/free Examples: - send_coins: mnemonic via env/hidden prompt (no argv requirement), Coin-only UTXO selection with warnings, fatal re-encode failure instead of silent 0x00 downgrade, change output + iterative fee estimation from the indexer fee rate - issue_token: poll the indexer for issuance confirmation (404-tolerant, bounded by --wait-timeout) before minting Meta: hatchling>=1.26 floor (PEP 639 fields), wheel-glob guard in CI, gitignore dedupe + .env.example negation. 20 new regression tests (406 total, 95.7% coverage); ruff + mypy clean. Rejected findings (documented): cast_2 last-wins error capture (Go/wasm- bindgen error convention), lazy Amount validation (Rust validates at encode time), strict response-id matching (sync client + JSON-RPC null-id error envelopes), naive-datetime handling (already documented).
- CI reproducibility: commit uv.lock, sync --locked in all jobs, cache keyed on uv.lock - _jsonrpc: non-dict JSON-RPC error object -> JSONRPCError (was raw AttributeError) - node: strict _call_opt_int (no bool/float/str coercion) and _call_str_list (list-of-strings validation, None -> []) - p2p: _duration_to_wire integer math (no float precision loss; old code could emit nanos == 1e9) - wasm: return-count guards in the bool/u32/u64 helpers (consistent with the string/bytes helpers) - examples: issue_token password via $WALLET_PASSWORD / hidden prompt; send_coins --mnemonic help text warns about ps/shell-history exposure Rejected (documented): code-review.yml pull-requests:write is required to post reviews; send_coins fee-loop 'oversized fee' claim is incorrect (the remainder always goes to the change output); naive-datetime behavior is documented Go parity; __wbindgen_free align=1 matches the JS glue and Go free convention (dlmalloc ignores the align hint).
- examples/issue_token: treat confirmations "" and "0" as unconfirmed (the field is a string; a bare truthiness check counted "0") - examples/send_coins: guard the fee-convergence loop (ValueError -> log.fatal), estimate size from the encoded INPUTS blob per the estimate_transaction_size contract, allow exact-sweep change == 0 - node: strict _call_opt_str (parity with _call_str; no silent str() coercion of numbers/objects)
OCR findings triage — all rounds addressedRound 1 (27 findings): 22 fixed, 4 rejected, 1 already documented Fixed: Config password repr redaction; Client.close() closes sub-client sessions; Rejected (with reasoning):
Round 2 (12 findings): 8 fixed, 4 rejected Fixed: Rejected: Round 3 (5 findings): 3 fixed, 2 rejected Fixed: Rejected: single-key assumption is incorrect — UTXOs at a pubkeyhash address are only spendable by that address's key; fee-loop guard was already present (stale diff context). Each round's changes were gated on security + code review agents and the full suite: 406 tests, 95.7% coverage, ruff + mypy clean. |
- host.py: populate the externref slot passed to __wbindgen_exn_store with the exception message (was passing an empty slot); fail loudly in fill_random when the memory write fails (stale bytes as key material) - _jsonrpc.py: refuse basic-auth credentials over cleartext http:// to non-loopback hosts (fail fast at client construction) - indexer/number.py: reject strings outside the uint64 range (incl. the 20-digit boundary) with IndexerError instead of a bare int() failure - wallet/management.py: raise a clear JSONRPCError on null results in new_address / reveal_public_key instead of TypeError - wasm/_core.py: document the verified memory-ownership protocol; the OCR-reported input-buffer 'leak' is a false positive - plain inputs are callee-owned (Rust String/Vec<u8> by value), confirmed against the wasm-bindgen glue of the byte-identical vendored binary - publish.yml: require the exact WASM ABI private-key length, gate the PyPI upload on sha256 equality with the TestPyPI dry-run, drop the unused id-token permission (attestations disabled under token auth), run lint/mypy/tests in the build job - tests: cleartext-guard, null-result, uint64-range coverage; resilient rpc_server fixture teardown
Round-4 OCR triage (70a3781)The
Fixed in this round:
Already fixed in round 3 (stale threads): Status: 418 tests pass, coverage 95.4%, ruff/mypy clean. Earlier |
- indexer/chain.py: get_block_id_at_height returns str | None instead of the ambiguous empty-string sentinel (docs updated) - node/types.py: Amount.from_json validates atoms is a decimal string, rejecting JSON numbers that would corrupt round-trips - wasm/_core.py: WASM integrity check raises WasmError (missing binary or pin file) instead of raw FileNotFoundError/IndexError at import time - examples/send_coins.py: verify the fee still covers the final tx size if the convergence loop exits without stabilising - tests: null block-id, Amount validation coverage; resilient rest_server fixture teardown
- wasm/_core.py: read the WASM binary inside _load_and_verify_wasm so a packaging mistake raises WasmError instead of a raw FileNotFoundError at import time - node/p2p.py: _duration_to_wire rejects negative timedeltas (Python's normalisation would silently ban for ~1 year instead of the intent) - _jsonrpc.py: fail loudly on JSON-RPC response id mismatch instead of misattributing another call's payload - examples/send_coins.py: start the fee loop from a zero fee so the first build only requires balance >= amount (a full-1KB start fee aborted affordable sends) - publish.yml: publish-pypi reuses the EXACT dist/ artifact from the successful tag-push run via gh run download (rebuilds are not byte-reproducible; sha256 comparison removed as it could spuriously fail); adds actions:read - code-review.yml llm_auth_token-as-input finding: considered and declined - it is the action's documented input and GitHub auto-masks secret values in logs - tests: updated for the new integrity API; negative-duration, id- mismatch and null-id coverage
CRITICAL fix verified against pinned wire fixtures:
- examples/send_coins.py: the indexer UTXO output is a tagged union
({"Transfer": {...}}), not {"type": ..., "value": {...}} - the example
would never find spendable UTXOs against a real indexer. Rewritten to
the actual shape (tests/test_indexer_address.py fixtures)
- examples/issue_token.py: only HTTP 404 counts as 'not indexed yet';
5xx/transport failures now propagate instead of masking a server error
- publish.yml: sha256 verification of the downloaded artifact against
TestPyPI's published digests (the dry-run uploads are the trust anchor)
- wasm/outputs.py: python-side supply_amount/total_supply invariant check
- node/types.py: _require_int validation for timestamps/block heights
(int(1.9) truncation, bool/str rejection)
- indexer/types.py: Amount.from_json requires atoms/decimal keys instead
of silently decoding truncated payloads to empty strings
- client.py: wasm property reads under the init lock (consistent with
init_wasm/close)
- indexer/__init__.py: thread-safety docstring clarified (shared session
cookie jar is not synchronised)
- wasm/host.py: _new_with_length must NOT free its backing store - the
module caches such Uint8Arrays in externref table slots (RNG scratch,
verified by test) so a call-end free is a use-after-free. OCR 'leak'
declined with evidence; a regression test pins the caching behaviour
- ci.yml per-leg mypy finding: declined (python_version=3.10 floor
already configured; matrix-wide mypy quadruples CI time for marginal
value)
- _jsonrpc.py: a JSON null (or missing) response id on a success payload is now rejected - JSON-RPC 2.0 reserves null ids for server-side error notifications, so such payloads cannot be attributed to this call - wasm/_core.py: externref index-array writes now check the memory.write return (consistent with _write_bytes); a failed write triggers the existing pre-call rollback instead of leaving a garbage index array - declined, with rationale documented in code: freeing result buffers on fallible-call error paths (reference glue zeroes rather than frees - ret[0] is not a valid allocation when the error flag is set); _read_amount bypassing _call (mirrors go-sdk; _call would reset the parent call's captured error state); speculative token-Transfer shape hardening in the send_coins example - tests: null-id expectation flipped to match the tightened contract
- wallet/__init__.py: export OwnOrder, Outpoint, OutpointSourceID, RevealPublicKeyResult, OrderState, TxStats and Timestamp (types needed to use the public API) - client.py: close already-created sub-clients if a later constructor fails (e.g. cleartext-credential guard) instead of leaking sessions - _jsonrpc.py: document the actual thread-safety contract of the shared session (synchronised id/flow, cookie-less daemons, urllib3 pool) - examples/send_coins.py: drop dust change outputs below a documented threshold (remainder goes to fees) with a warning - publish.yml: distinguish 'artifact missing on TestPyPI' from 'digest mismatch' in the integrity gate - declined: OCR bug-high claiming HTTPError is not exported - it is (mintlayer/indexer/__init__.py exports and documents it) - _free_wasm: document why cleanup failures are swallowed
- node/_core.py: _call_opt_amount wraps Amount decoding failures into JSONRPCError (was a bare ValueError/KeyError, breaking the documented error contract) - publish.yml: scope actions:read to the publish-pypi job only; build runs project code and must not carry cross-run artifact access - declined with rationale: init_wasm holding the lock through construction is intentional (no half-built client can escape); signal_exception returning normally matches the wasm-bindgen contract and go-sdk/JS hosts (the host cannot force an Err return); _cast_string error-message capture is inert on success paths
- indexer/number.py: parse_per_thousand converts OverflowError (float of a hostile oversized int) into IndexerError; the _safe_from_json wrapper also catches OverflowError - node: new _decode_model helper converts malformed-payload decode failures into the documented JSONRPCError contract at all typed call sites (chainstate_info, token/tokens/order info, orders info by currencies, mempool tx/fee rates); one test expectation updated to the converted exception type - wasm/_core.py: _load_and_verify_wasm docstring no longer contradicts the import-time fail-fast design - declined: wasm property TOCTOU vs concurrent close() - inherent to the property API; the failure is loud (client is closed) by design, and the lock already prevents the half-constructed escape
- node/_core.py: _call_opt_str rejects non-string results instead of silently coercing dicts/numbers to garbage strings (all three callers are documented string endpoints; get_block_json covers object payloads) - wallet/staking.py: get_staking_status raises JSONRPCError with the valid enum values instead of a bare ValueError on unknown statuses - wallet/types.py: MnemonicResult.from_json validates content (non-dict and empty dicts are malformed) and wraps kwargs TypeErrors into a descriptive ValueError - tests: coverage for all three guards (6 new tests)
- examples/issue_token.py: wallet sync failures catch RPC/JSON-RPC errors specifically instead of Exception (programming errors stay loud) - indexer/pool.py: document naive-datetime interpretation for block stats
Complete Python port of mintlayer/go-sdk: sync-first,
requests+wasmtime, MIT.What's included
mintlayer.node[secs, nanos], tuple wire shapes,OrderInfo.noncenullability fix over Go)mintlayer.indexer/api/v2— lenient numerics (string uint64s,"3.5%"margin ratios), path-traversal-encoded IDsmintlayer.walletaccount_index, droppedaccount, hardcodedTrusted,OutputValue/CurrencyFiltercustom encodings, null-vs-omitempty rules); mnemonic/passphrase redacted from reprsmintlayer.wasmClient(Config)selective wiring, lazyinit_wasm(), convenience re-exportsexamples/send_coins.py+examples/issue_token.pyQuality
pytest --cov); all wasm mixins at 100%ruff+mypyclean.netrcambient-credential fallback suppressed; Basic Auth gated on usernameNotable fixes over the Go original
OrderInfo.nonceisOptional(daemon sendsnullfor active orders; Go'suint64unmarshal silently skips it).Process
Every commit was gated on security-reviewer + code-reviewer agents (findings fixed pre-commit), tests were written/mutation-verified by the test agent, and wire parity was live-diffed against the Go SDK during review.