From c444841f7dc3702dc46b625e2faf7654f8a06813 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 16 Sep 2026 23:54:53 +0400 Subject: [PATCH 01/29] scaffold: crate manifest with mintlayer-core git deps, fmt/clippy configs, license --- .gitignore | 10 - Cargo.lock | 4513 ++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 70 + clippy.toml | 5 + rustfmt.toml | 10 + src/client.rs | 13 + src/crypto/mod.rs | 7 + src/indexer/mod.rs | 7 + src/jsonrpc.rs | 7 + src/lib.rs | 38 + src/node/mod.rs | 7 + src/wallet/mod.rs | 7 + 12 files changed, 4684 insertions(+), 10 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 clippy.toml create mode 100644 rustfmt.toml create mode 100644 src/client.rs create mode 100644 src/crypto/mod.rs create mode 100644 src/indexer/mod.rs create mode 100644 src/jsonrpc.rs create mode 100644 src/lib.rs create mode 100644 src/node/mod.rs create mode 100644 src/wallet/mod.rs diff --git a/.gitignore b/.gitignore index e1a4287..adeca19 100644 --- a/.gitignore +++ b/.gitignore @@ -14,13 +14,3 @@ tarpaulin-report.html # ── misc ────────────────────────────────────────────────────────────────────── .wrangler/ - -# ── secrets: never commit credentials or key material ──────────────────────── -.env -.env.* -!.env.example -*.pem -*.key -*.p12 -secrets/ -.cargo/credentials diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5d970bc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4513 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accounting" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "common", + "parity-scale-codec", + "serialization", + "thiserror 1.0.69", + "utils", +] + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-object-pool" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c" +dependencies = [ + "async-std", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.2", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-cookies" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" +dependencies = [ + "lalrpop", + "lalrpop-util", + "regex", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", + "zeroize", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chainstate-storage" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "accounting", + "chainstate-types", + "common", + "logging", + "orders-accounting", + "parity-scale-codec", + "pos-accounting", + "randomness", + "serialization", + "storage", + "storage-inmemory", + "tokens-accounting", + "utils", + "utxo", +] + +[[package]] +name = "chainstate-types" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "common", + "crypto", + "derive_more 1.0.0", + "enum-iterator", + "generic-array", + "logging", + "num-derive", + "num-traits", + "parity-scale-codec", + "pos-accounting", + "serialization", + "static_assertions", + "storage", + "thiserror 1.0.69", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "common" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "anyhow", + "bech32", + "chrono", + "clap", + "crypto", + "fixed-hash", + "generic-array", + "hex", + "itertools 0.14.0", + "lazy_static", + "logging", + "merkletree-mintlayer", + "mintlayer-core-primitives", + "num", + "once_cell", + "parity-scale-codec", + "paste", + "randomness", + "ref-cast", + "regex", + "rpc-description", + "rpc-types", + "script", + "serde", + "serde_json", + "serde_with", + "serialization", + "smallvec", + "static_assertions", + "strum 0.26.3", + "thiserror 1.0.69", + "typename", + "unwrap-infallible", + "utils", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "consensus" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "chainstate-types", + "common", + "crypto", + "itertools 0.14.0", + "logging", + "num", + "parity-scale-codec", + "pos-accounting", + "randomness", + "serialization", + "thiserror 1.0.69", + "utils", + "utxo", +] + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constraints-value-accumulator" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "common", + "crypto", + "orders-accounting", + "pos-accounting", + "randomness", + "thiserror 1.0.69", + "tokens-accounting", + "utils", + "utxo", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "argon2", + "bip39", + "blake2", + "chacha20poly1305", + "generic-array", + "hmac", + "lazy_static", + "merlin", + "num", + "num-derive", + "num-traits", + "parity-scale-codec", + "rand 0.8.8", + "randomness", + "ripemd", + "rpc-description", + "schnorrkel", + "secp256k1", + "serde", + "serialization", + "sha-1", + "sha2", + "sha3", + "strum 0.26.3", + "thiserror 1.0.69", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "ctor-proc-macro", + "dtor", + "link-section", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a949c44fcacbbbb7ada007dc7acb34603dd97cd47de5d054f2b6493ecebb483" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2647271c92754afcb174e758003cfd1cbf1e43e5a7853d7b1813e63e19e39a73" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.2", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fix-hidden-lifetime-bug" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab7b4994e93dd63050356bdde7d417591d1b348523638dc1c1f539f16e338d55" +dependencies = [ + "fix-hidden-lifetime-bug-proc_macros", +] + +[[package]] +name = "fix-hidden-lifetime-bug-proc_macros" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8f0de9daf465d763422866d0538f07be1596e05623e120b37b4f715f5585200" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom_or_panic" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" +dependencies = [ + "rand 0.8.8", + "rand_core 0.6.4", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "httpmock" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-std", + "async-trait", + "base64 0.21.7", + "basic-cookies", + "crossbeam-utils", + "form_urlencoded", + "futures-util", + "hyper 0.14.32", + "lazy_static", + "levenshtein", + "log", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "tokio", + "url", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.5.0", + "hyper 1.11.1", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "hyper 1.11.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +dependencies = [ + "libc", +] + +[[package]] +name = "link-section" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b685d66585d646efe09fec763d796c291049c8b6bf84e04954bffc8748341f0d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +dependencies = [ + "value-bag", +] + +[[package]] +name = "log_error" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "derive_more 1.0.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "logging" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "log", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "merkletree-mintlayer" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b69f92eb22368186aa4d25e71d2263f822b137ed3c022356acf37634c1ddda0" +dependencies = [ + "itertools 0.12.1", + "parity-scale-codec", + "thiserror 1.0.69", +] + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "mintlayer-core-primitives" +version = "1.0.0" +source = "git+https://github.com/mintlayer/mintlayer-core-primitives?rev=8644bfe06d932d687075939d2d175183ba1c369d#8644bfe06d932d687075939d2d175183ba1c369d" +dependencies = [ + "derive_more 2.1.1", + "fixed-hash", + "parity-scale-codec", + "strum 0.27.2", +] + +[[package]] +name = "mintlayer-sdk" +version = "0.1.0" +dependencies = [ + "bip39", + "common", + "consensus", + "crypto", + "hex", + "httpmock", + "randomness", + "reqwest", + "serde", + "serde_json", + "serialization", + "thiserror 2.0.20", + "tokio", + "tx-verifier", +] + +[[package]] +name = "mintscript" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "common", + "crypto", + "ctor", + "derive_more 1.0.0", + "logging", + "orders-accounting", + "pos-accounting", + "serialization", + "static_assertions", + "strum 0.26.3", + "thiserror 1.0.69", + "tokens-accounting", + "utils", + "utxo", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orders-accounting" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "accounting", + "common", + "crypto", + "logging", + "parity-scale-codec", + "randomness", + "serialization", + "strum 0.26.3", + "thiserror 1.0.69", + "utils", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "git+https://github.com/paritytech/parity-scale-codec.git?rev=5021525697edc0661591ebc71392c48d950a10b0#5021525697edc0661591ebc71392c48d950a10b0" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "git+https://github.com/paritytech/parity-scale-codec.git?rev=5021525697edc0661591ebc71392c48d950a10b0#5021525697edc0661591ebc71392c48d950a10b0" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.14.2", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "pos-accounting" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "accounting", + "common", + "crypto", + "parity-scale-codec", + "randomness", + "serialization", + "strum 0.26.3", + "thiserror 1.0.69", + "typename", + "utils", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "randomness" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "rand 0.10.2", + "rand 0.8.8", + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper 1.11.1", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest", +] + +[[package]] +name = "rpc-description" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "rpc-description-macro", + "serde_json", +] + +[[package]] +name = "rpc-description-macro" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rpc-types" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "hex", + "rpc-description", + "serde", + "thiserror 1.0.69", + "utils", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schnorrkel" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9fcb6c2e176e86ec703e22560d99d65a5ee9056ae45a08e13e84ebf796296f" +dependencies = [ + "aead", + "arrayref", + "arrayvec", + "curve25519-dalek", + "getrandom_or_panic", + "merlin", + "rand_core 0.6.4", + "serde_bytes", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "script" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "crypto", + "hex", + "parity-scale-codec", + "serde", + "serialization", + "thiserror 1.0.69", + "typename", + "utils", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.8", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_regex" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafc8d0c5330cecff10f16b459b479fd9acaa5b4acd7167301414e21b0057012" +dependencies = [ + "regex", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serialization" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "hex", + "rpc-description", + "serde", + "serde_json", + "serialization-core", + "serialization-tagged", + "thiserror 1.0.69", +] + +[[package]] +name = "serialization-core" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "serialization-tagged" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "serialization-core", + "serialization-tagged-derive", + "static_assertions", +] + +[[package]] +name = "serialization-tagged-derive" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slave-pool" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a11007e28f4d47edecf6989adebdccb39482eb746e5b9a282411852dcb0c4f04" +dependencies = [ + "crossbeam-channel", +] + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "storage" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "common", + "serialization", + "storage-core", + "utils", +] + +[[package]] +name = "storage-core" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "common", + "itertools 0.14.0", + "logging", + "thiserror 1.0.69", + "utils", +] + +[[package]] +name = "storage-inmemory" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "storage-core", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokens-accounting" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "accounting", + "chainstate-types", + "common", + "crypto", + "logging", + "parity-scale-codec", + "randomness", + "serialization", + "strum 0.26.3", + "thiserror 1.0.69", + "utils", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tx-verifier" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "accounting", + "chainstate-storage", + "chainstate-types", + "common", + "consensus", + "constraints-value-accumulator", + "crypto", + "itertools 0.14.0", + "lazy_static", + "logging", + "mintscript", + "orders-accounting", + "pos-accounting", + "randomness", + "serialization", + "static_assertions", + "thiserror 1.0.69", + "tokens-accounting", + "utils", + "utxo", +] + +[[package]] +name = "typename" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "typename-derive", +] + +[[package]] +name = "typename-derive" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "itertools 0.14.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unwrap-infallible" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e497bb1f828cc9fb236722c2eaa100dcf201563f38f4da6252357a59037adf31" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utils" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "anyhow", + "clap", + "crypto", + "ctor", + "directories", + "fix-hidden-lifetime-bug", + "fixed-hash", + "heck", + "log_error", + "logging", + "loom", + "num-traits", + "parity-scale-codec", + "qrcodegen", + "randomness", + "serialization", + "siphasher", + "slave-pool", + "thiserror 1.0.69", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "utxo" +version = "1.4.0" +source = "git+https://github.com/mintlayer/mintlayer-core?rev=7f4393ac9c07c080661a1bd40e8a6133a98b5155#7f4393ac9c07c080661a1bd40e8a6133a98b5155" +dependencies = [ + "chainstate-types", + "common", + "logging", + "parity-scale-codec", + "randomness", + "serialization", + "thiserror 1.0.69", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..32ff8ff --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "mintlayer-sdk" +description = "Rust SDK for the Mintlayer blockchain" +version = "0.1.0" +edition = "2024" +rust-version = "1.92" +license = "MIT" +repository = "https://github.com/mintlayer/rust-sdk" +homepage = "https://www.mintlayer.org" +keywords = ["mintlayer", "blockchain", "sdk"] +categories = ["api-bindings", "cryptography"] + +[features] +default = ["node", "indexer", "wallet"] +# Shared HTTP/serde machinery for the remote sub-clients. +http = ["dep:reqwest", "dep:serde", "dep:serde_json"] +# JSON-RPC client for the node daemon. +node = ["http"] +# REST client for the indexer (api-web-server). +indexer = ["http"] +# JSON-RPC client for the wallet daemon. +wallet = ["http"] +# Native cryptography and transaction building backed by mintlayer-core. +crypto = [ + "dep:ml-common", + "dep:ml-consensus", + "dep:ml-crypto", + "dep:ml-randomness", + "dep:ml-serialization", + "dep:ml-tx-verifier", + "dep:bip39", +] +# All sub-clients. +full = ["node", "indexer", "wallet", "crypto"] + +[dependencies] +serde = { version = "1.0", optional = true, default-features = false, features = [ + "std", + "derive", +] } +serde_json = { version = "1.0", optional = true } +thiserror = "2.0" + +# HTTP sub-clients (node / indexer / wallet). +reqwest = { version = "0.12", optional = true, default-features = false, features = [ + "json", + "rustls-tls", +] } + +# Native cryptography (the `crypto` feature). +bip39 = { version = "2.0", optional = true, default-features = false, features = [ + "std", + "zeroize", +] } +ml-common = { package = "common", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } +ml-consensus = { package = "consensus", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } +ml-crypto = { package = "crypto", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } +ml-randomness = { package = "randomness", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } +ml-serialization = { package = "serialization", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } +ml-tx-verifier = { package = "tx-verifier", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } + +[dev-dependencies] +hex = "0.4" +httpmock = "0.7" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +# Required fix (paritytech/parity-scale-codec#751) not yet released; must match +# the patch used by mintlayer-core so SCALE encodings stay compatible. +[patch.crates-io] +parity-scale-codec = { git = "https://github.com/paritytech/parity-scale-codec.git", rev = "5021525697edc0661591ebc71392c48d950a10b0" } diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..6b7fc0f --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +disallowed-methods = [] +disallowed-types = [] +enforced-import-renames = [] +large-error-threshold = 256 +enum-variant-size-threshold = 256 diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..0b44aea --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,10 @@ +max_width = 100 +hard_tabs = false +tab_spaces = 4 +array_width = 80 +chain_width = 80 +single_line_if_else_max_width = 50 +newline_style = "Unix" +edition = "2024" + +match_arm_leading_pipes = "Preserve" diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..753763d --- /dev/null +++ b/src/client.rs @@ -0,0 +1,13 @@ +// 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. + +//! Stub — implemented in a later phase. + +/// Stub. +pub struct Client; + +/// Stub. +pub struct ClientBuilder; diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..87e4425 --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1,7 @@ +// 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. + +//! Stub — implemented in a later phase. diff --git a/src/indexer/mod.rs b/src/indexer/mod.rs new file mode 100644 index 0000000..87e4425 --- /dev/null +++ b/src/indexer/mod.rs @@ -0,0 +1,7 @@ +// 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. + +//! Stub — implemented in a later phase. diff --git a/src/jsonrpc.rs b/src/jsonrpc.rs new file mode 100644 index 0000000..29c6d71 --- /dev/null +++ b/src/jsonrpc.rs @@ -0,0 +1,7 @@ +// 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. + +//! Internal JSON-RPC 2.0 transport shared by the node and wallet clients. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e4df886 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,38 @@ +// 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. + +//! Mintlayer Rust SDK. +//! +//! A Rust SDK for the [Mintlayer](https://www.mintlayer.org/) blockchain, +//! organised as independent sub-clients plus a top-level [`Client`] that wires +//! them together: +//! +//! | Module | Purpose | Default port | +//! |------------|------------------------------------------------------|---------------| +//! | [`node`] | JSON-RPC 2.0 client for the node daemon | 3030 mainnet | +//! | [`indexer`]| REST client for the indexer (api-web-server) | 3000 | +//! | [`wallet`] | JSON-RPC 2.0 client for the wallet daemon | 3034 mainnet | +//! | [`crypto`] | Cryptography & transaction building (native) | — | + +#![forbid(unsafe_code)] + +#[cfg(feature = "crypto")] +pub mod crypto; +#[cfg(feature = "indexer")] +pub mod indexer; +#[cfg(feature = "node")] +pub mod node; +#[cfg(feature = "wallet")] +pub mod wallet; + +#[cfg(feature = "http")] +mod jsonrpc; + +#[cfg(any(feature = "node", feature = "wallet", feature = "indexer"))] +mod client; + +#[cfg(any(feature = "node", feature = "wallet", feature = "indexer"))] +pub use client::{Client, ClientBuilder}; diff --git a/src/node/mod.rs b/src/node/mod.rs new file mode 100644 index 0000000..87e4425 --- /dev/null +++ b/src/node/mod.rs @@ -0,0 +1,7 @@ +// 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. + +//! Stub — implemented in a later phase. diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs new file mode 100644 index 0000000..87e4425 --- /dev/null +++ b/src/wallet/mod.rs @@ -0,0 +1,7 @@ +// 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. + +//! Stub — implemented in a later phase. From 753a3f114511267ce14a541c044f5a4d25122355 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 16 Sep 2026 23:54:53 +0400 Subject: [PATCH 02/29] scaffold: document consumer patch requirement, ignore secret files (security review fixes) --- .gitignore | 10 ++++++++++ Cargo.toml | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/.gitignore b/.gitignore index adeca19..e1a4287 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,13 @@ tarpaulin-report.html # ── misc ────────────────────────────────────────────────────────────────────── .wrangler/ + +# ── secrets: never commit credentials or key material ──────────────────────── +.env +.env.* +!.env.example +*.pem +*.key +*.p12 +secrets/ +.cargo/credentials diff --git a/Cargo.toml b/Cargo.toml index 32ff8ff..78b692a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,5 +66,13 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] } # Required fix (paritytech/parity-scale-codec#751) not yet released; must match # the patch used by mintlayer-core so SCALE encodings stay compatible. +# +# NOTE FOR CONSUMERS: Cargo only honors [patch] sections from the *root* +# workspace manifest. Because this crate depends on unpublished mintlayer-core +# crates it must itself be consumed via git, which means this patch section is +# IGNORED in downstream builds. Downstream consumers MUST copy this exact +# [patch.crates-io] entry into their own workspace root Cargo.toml, or +# transactions encoded by the `crypto` feature may not match the ones produced +# by mintlayer-core. [patch.crates-io] parity-scale-codec = { git = "https://github.com/paritytech/parity-scale-codec.git", rev = "5021525697edc0661591ebc71392c48d950a10b0" } From 069a2b8e671498d6947a6c88c2713bbe4e843a98 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 16 Sep 2026 23:54:53 +0400 Subject: [PATCH 03/29] node: JSON-RPC client with full chainstate, mempool, node and p2p surface --- src/jsonrpc.rs | 218 ++++++++++++++++++++++++ src/lib.rs | 6 +- src/node/chainstate.rs | 208 +++++++++++++++++++++++ src/node/error.rs | 12 ++ src/node/mempool.rs | 88 ++++++++++ src/node/mod.rs | 104 +++++++++++- src/node/p2p.rs | 107 ++++++++++++ src/node/types.rs | 264 +++++++++++++++++++++++++++++ src/number.rs | 121 ++++++++++++++ tests/node.rs | 365 +++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1491 insertions(+), 2 deletions(-) create mode 100644 src/node/chainstate.rs create mode 100644 src/node/error.rs create mode 100644 src/node/mempool.rs create mode 100644 src/node/p2p.rs create mode 100644 src/node/types.rs create mode 100644 src/number.rs create mode 100644 tests/node.rs diff --git a/src/jsonrpc.rs b/src/jsonrpc.rs index 29c6d71..25a6b92 100644 --- a/src/jsonrpc.rs +++ b/src/jsonrpc.rs @@ -5,3 +5,221 @@ // that can be found in the LICENSE file. //! Internal JSON-RPC 2.0 transport shared by the node and wallet clients. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Defines the public error enum for an RPC sub-client from the internal +/// [`RequestError`]. +macro_rules! define_error { + ($(#[$meta:meta])* $name:ident) => { + $(#[$meta])* + #[derive(Debug, thiserror::Error)] + pub enum $name { + /// The daemon answered with a JSON-RPC error response. + #[error("RPC error {code}: {message}")] + Rpc { + /// The JSON-RPC error code. + code: i64, + /// The JSON-RPC error message. + message: String, + }, + /// The HTTP request to the daemon failed. + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + /// The JSON-RPC response could not be decoded into the expected type. + #[error("failed to decode JSON-RPC response: {0}")] + Json(#[from] serde_json::Error), + /// The response `id` did not match the request `id`. + #[error("JSON-RPC response id mismatch: expected {expected}, got {actual}")] + IdMismatch { + /// The `id` that was sent. + expected: u64, + /// The `id` that was received. + actual: serde_json::Value, + }, + } + + impl From for $name { + fn from(err: crate::jsonrpc::RequestError) -> Self { + match err { + crate::jsonrpc::RequestError::Rpc { code, message } => Self::Rpc { code, message }, + crate::jsonrpc::RequestError::Http(err) => Self::Http(err), + crate::jsonrpc::RequestError::Json(err) => Self::Json(err), + crate::jsonrpc::RequestError::IdMismatch { expected, actual } => { + Self::IdMismatch { expected, actual } + } + } + } + } + }; +} +pub(crate) use define_error; + +/// JSON-RPC `error` object as carried inside a daemon response. +#[derive(Debug, Deserialize, Error)] +#[error("RPC error {code}: {message}")] +pub(crate) struct RpcErrorWire { + code: i64, + message: String, +} + +/// Transport-level failure of a JSON-RPC call. +#[derive(Debug, Error)] +pub(crate) enum RequestError { + #[error("RPC error {code}: {message}")] + Rpc { code: i64, message: String }, + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + #[error("failed to decode JSON-RPC response: {0}")] + Json(#[from] serde_json::Error), + #[error("JSON-RPC response id mismatch: expected {expected}, got {actual}")] + IdMismatch { + expected: u64, + actual: serde_json::Value, + }, +} + +#[derive(Serialize)] +struct Request<'a, P: ?Sized> { + jsonrpc: &'static str, + id: u64, + method: &'a str, + params: &'a P, +} + +#[derive(Deserialize)] +struct Response { + id: Option, + #[serde(default)] + result: serde_json::Value, + error: Option, +} + +/// A shared JSON-RPC 2.0 transport: one HTTP endpoint, monotonically +/// increasing request ids starting at 1, optional HTTP basic auth. +/// +/// Cloning a transport shares the id counter, so every clone of a client +/// produces globally unique request ids. +#[derive(Debug, Clone)] +pub(crate) struct Transport { + endpoint: String, + http: reqwest::Client, + basic_auth: Option<(String, String)>, + next_id: Arc, +} + +impl Transport { + pub(crate) fn from_parts( + endpoint: String, + http: reqwest::Client, + basic_auth: Option<(String, String)>, + ) -> Self { + Self { + endpoint, + http, + basic_auth, + next_id: Arc::new(AtomicU64::new(0)), + } + } + + pub(crate) async fn call(&self, method: &str, params: &P) -> Result + where + P: Serialize + ?Sized, + R: DeserializeOwned, + E: From, + { + self.call_inner(method, params).await.map_err(E::from) + } + + async fn call_inner(&self, method: &str, params: &P) -> Result + where + P: Serialize + ?Sized, + R: DeserializeOwned, + { + let id = self.next_id.fetch_add(1, Ordering::Relaxed) + 1; + let mut builder = self.http.post(&self.endpoint).json(&Request { + jsonrpc: "2.0", + id, + method, + params, + }); + if let Some((username, password)) = &self.basic_auth { + builder = builder.basic_auth(username, Some(password)); + } + // The HTTP status code is intentionally not inspected: the daemon may + // answer with a valid JSON-RPC envelope on a non-2xx status (parity + // with the go-sdk client). + let response: Response = builder.send().await?.json().await?; + if let Some(err) = response.error { + return Err(RequestError::Rpc { + code: err.code, + message: err.message, + }); + } + match response.id { + Some(actual) if actual.as_u64() != Some(id) => { + return Err(RequestError::IdMismatch { + expected: id, + actual, + }); + } + _ => {} + } + Ok(serde_json::from_value(response.result)?) + } +} + +/// Shared builder state for the node and wallet clients. +#[derive(Debug, Clone)] +pub(crate) struct ClientBuilder { + endpoint: String, + timeout: Duration, + basic_auth: Option<(String, String)>, + http_client: Option, +} + +impl ClientBuilder { + pub(crate) fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + timeout: DEFAULT_TIMEOUT, + basic_auth: None, + http_client: None, + } + } + + pub(crate) fn basic_auth( + mut self, + username: impl Into, + password: impl Into, + ) -> Self { + self.basic_auth = Some((username.into(), password.into())); + self + } + + pub(crate) fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + pub(crate) fn http_client(mut self, http_client: reqwest::Client) -> Self { + self.http_client = Some(http_client); + self + } + + pub(crate) fn build(self) -> Result { + let http = match self.http_client { + Some(http) => http, + None => reqwest::Client::builder().timeout(self.timeout).build()?, + }; + Ok(Transport::from_parts(self.endpoint, http, self.basic_auth)) + } +} diff --git a/src/lib.rs b/src/lib.rs index e4df886..f38dab4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ //! | [`crypto`] | Cryptography & transaction building (native) | — | #![forbid(unsafe_code)] +#![warn(missing_docs)] #[cfg(feature = "crypto")] pub mod crypto; @@ -28,9 +29,12 @@ pub mod node; #[cfg(feature = "wallet")] pub mod wallet; -#[cfg(feature = "http")] +#[cfg(any(feature = "node", feature = "wallet"))] mod jsonrpc; +#[cfg(any(feature = "node", feature = "wallet", feature = "indexer"))] +mod number; + #[cfg(any(feature = "node", feature = "wallet", feature = "indexer"))] mod client; diff --git a/src/node/chainstate.rs b/src/node/chainstate.rs new file mode 100644 index 0000000..20ef159 --- /dev/null +++ b/src/node/chainstate.rs @@ -0,0 +1,208 @@ +// 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. + +//! Chainstate RPC methods: best block, blocks, UTXOs, pools, tokens, orders. + +use std::collections::BTreeMap; + +use super::{Amount, ChainstateInfo, Currency, Error, OrderInfo, Outpoint, TokenInfo}; +use crate::node::Client; + +impl Client { + /// Returns a summary of the current chain state (`chainstate_info`). + pub async fn chainstate_info(&self) -> Result { + self.transport.call("chainstate_info", &serde_json::json!({})).await + } + + /// Returns the hex-encoded id of the best block + /// (`chainstate_best_block_id`). + pub async fn best_block_id(&self) -> Result { + self.transport.call("chainstate_best_block_id", &serde_json::json!({})).await + } + + /// Returns the height of the best block (`chainstate_best_block_height`). + pub async fn best_block_height(&self) -> Result { + self.transport + .call("chainstate_best_block_height", &serde_json::json!({})) + .await + } + + /// Returns the block id at a given height, or `None` if the height is not + /// part of the main chain (`chainstate_block_id_at_height`). + pub async fn block_id_at_height(&self, height: u64) -> Result, Error> { + self.transport + .call( + "chainstate_block_id_at_height", + &serde_json::json!({ "height": height }), + ) + .await + } + + /// Returns the height of a block in the main chain, or `None` if the + /// block is not part of it (`chainstate_block_height_in_main_chain`). + pub async fn block_height_in_main_chain(&self, block_id: &str) -> Result, Error> { + self.transport + .call( + "chainstate_block_height_in_main_chain", + &serde_json::json!({ "block_id": block_id }), + ) + .await + } + + /// Returns the hex-encoded serialization of a block, or `None` if it is + /// unknown (`chainstate_get_block`). The genesis block cannot be + /// retrieved. + pub async fn block(&self, id: &str) -> Result, Error> { + self.transport + .call("chainstate_get_block", &serde_json::json!({ "id": id })) + .await + } + + /// Returns the JSON representation of a block (`chainstate_get_block_json`). + pub async fn block_json(&self, id: &str) -> Result { + self.transport + .call( + "chainstate_get_block_json", + &serde_json::json!({ "id": id }), + ) + .await + } + + /// Returns up to `max_count` hex-encoded mainchain blocks starting at + /// `from` (`chainstate_get_mainchain_blocks`). + pub async fn mainchain_blocks(&self, from: u64, max_count: u32) -> Result, Error> { + self.transport + .call( + "chainstate_get_mainchain_blocks", + &serde_json::json!({ "from": from, "max_count": max_count }), + ) + .await + } + + /// Returns the output at an outpoint, or `None` if it is spent or unknown + /// (`chainstate_get_utxo`). + pub async fn utxo(&self, outpoint: &Outpoint) -> Result, Error> { + self.transport + .call( + "chainstate_get_utxo", + &serde_json::json!({ "outpoint": outpoint }), + ) + .await + } + + /// Returns the balance of a stake pool (`chainstate_stake_pool_balance`). + pub async fn stake_pool_balance(&self, pool_address: &str) -> Result, Error> { + self.transport + .call( + "chainstate_stake_pool_balance", + &serde_json::json!({ "pool_address": pool_address }), + ) + .await + } + + /// Returns the staker balance of a stake pool + /// (`chainstate_staker_balance`). + pub async fn staker_balance(&self, pool_address: &str) -> Result, Error> { + self.transport + .call( + "chainstate_staker_balance", + &serde_json::json!({ "pool_address": pool_address }), + ) + .await + } + + /// Returns the decommission destination of a stake pool + /// (`chainstate_pool_decommission_destination`). + pub async fn pool_decommission_destination( + &self, + pool_address: &str, + ) -> Result, Error> { + self.transport + .call( + "chainstate_pool_decommission_destination", + &serde_json::json!({ "pool_address": pool_address }), + ) + .await + } + + /// Returns the share of a delegation in a pool + /// (`chainstate_delegation_share`). + pub async fn delegation_share( + &self, + pool_address: &str, + delegation_address: &str, + ) -> Result, Error> { + self.transport + .call( + "chainstate_delegation_share", + &serde_json::json!({ + "pool_address": pool_address, + "delegation_address": delegation_address, + }), + ) + .await + } + + /// Returns information about a token (`chainstate_token_info`). + pub async fn token_info(&self, token_id: &str) -> Result, Error> { + self.transport + .call( + "chainstate_token_info", + &serde_json::json!({ "token_id": token_id }), + ) + .await + } + + /// Returns information about multiple tokens, preserving order + /// (`chainstate_tokens_info`). + pub async fn tokens_info(&self, token_ids: &[String]) -> Result, Error> { + self.transport + .call( + "chainstate_tokens_info", + &serde_json::json!({ "token_ids": token_ids }), + ) + .await + } + + /// Returns information about a DEX order (`chainstate_order_info`). + pub async fn order_info(&self, order_id: &str) -> Result, Error> { + self.transport + .call( + "chainstate_order_info", + &serde_json::json!({ "order_id": order_id }), + ) + .await + } + + /// Returns all orders matching a pair of currencies; `None` filters + /// include every currency (`chainstate_orders_info_by_currencies`). + pub async fn orders_info_by_currencies( + &self, + ask_currency: Option<&Currency>, + give_currency: Option<&Currency>, + ) -> Result, Error> { + self.transport + .call( + "chainstate_orders_info_by_currencies", + &serde_json::json!({ + "ask_currency": ask_currency, + "give_currency": give_currency, + }), + ) + .await + } + + /// Submits a hex-encoded block to the chainstate + /// (`chainstate_submit_block`). + pub async fn submit_block(&self, block_hex: &str) -> Result<(), Error> { + self.transport + .call( + "chainstate_submit_block", + &serde_json::json!({ "block_hex": block_hex }), + ) + .await + } +} diff --git a/src/node/error.rs b/src/node/error.rs new file mode 100644 index 0000000..47294b7 --- /dev/null +++ b/src/node/error.rs @@ -0,0 +1,12 @@ +// 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. + +//! Error type for the node daemon client. + +crate::jsonrpc::define_error!( + /// Errors returned by [`crate::node::Client`] calls. + Error +); diff --git a/src/node/mempool.rs b/src/node/mempool.rs new file mode 100644 index 0000000..d4021c4 --- /dev/null +++ b/src/node/mempool.rs @@ -0,0 +1,88 @@ +// 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. + +//! Mempool RPC methods: transaction lookup, submission, fee rates. + +use super::{Error, FeeRate, FeeRatePoint, MempoolTx, TrustPolicy}; +use crate::node::Client; + +impl Client { + /// Returns whether the mempool contains a transaction with the given id + /// (`mempool_contains_tx`). + pub async fn contains_tx(&self, tx_id: &str) -> Result { + self.transport + .call( + "mempool_contains_tx", + &serde_json::json!({ "tx_id": tx_id }), + ) + .await + } + + /// Returns whether the orphan pool contains a transaction with the given + /// id (`mempool_contains_orphan_tx`). + pub async fn contains_orphan_tx(&self, tx_id: &str) -> Result { + self.transport + .call( + "mempool_contains_orphan_tx", + &serde_json::json!({ "tx_id": tx_id }), + ) + .await + } + + /// Returns a transaction from the mempool or orphan pool, or `None` if + /// unknown (`mempool_get_transaction`). + pub async fn transaction(&self, tx_id: &str) -> Result, Error> { + self.transport + .call( + "mempool_get_transaction", + &serde_json::json!({ "tx_id": tx_id }), + ) + .await + } + + /// Submits a hex-encoded signed transaction to the local mempool without + /// broadcasting it over P2P (`mempool_submit_transaction`). + /// + /// Use [`Client::broadcast_transaction`] to propagate the transaction to + /// the network. + pub async fn submit_transaction( + &self, + tx_hex: &str, + trust_policy: TrustPolicy, + ) -> Result<(), Error> { + self.transport + .call( + "mempool_submit_transaction", + &serde_json::json!({ + "tx": tx_hex, + "options": { "trust_policy": trust_policy }, + }), + ) + .await + } + + /// Returns the fee rate for transactions landing in the top `in_top_x_mb` + /// megabytes of the mempool (`mempool_get_fee_rate`). + pub async fn fee_rate(&self, in_top_x_mb: u32) -> Result, Error> { + self.transport + .call( + "mempool_get_fee_rate", + &serde_json::json!({ "in_top_x_mb": in_top_x_mb }), + ) + .await + } + + /// Returns the fee-rate estimate curve (`mempool_get_fee_rate_points`). + pub async fn fee_rate_points(&self) -> Result, Error> { + self.transport.call("mempool_get_fee_rate_points", &serde_json::json!({})).await + } + + /// Returns the mempool memory usage in bytes + /// (`mempool_memory_usage`). + pub async fn memory_usage(&self) -> Result { + self.transport.call("mempool_memory_usage", &serde_json::json!({})).await + } +} diff --git a/src/node/mod.rs b/src/node/mod.rs index 87e4425..5cd3dec 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -4,4 +4,106 @@ // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. -//! Stub — implemented in a later phase. +//! JSON-RPC 2.0 client for the Mintlayer node daemon (default port 3030 on +//! mainnet, 13030 on testnet). + +mod chainstate; +mod error; +mod mempool; +mod p2p; +mod types; + +use std::sync::OnceLock; +use std::time::Duration; + +pub use error::Error; +pub use types::{ + Amount, BannedPeer, BannedTime, ChainstateInfo, Currency, FeeRate, FeeRatePoint, MempoolTx, + OrderInfo, Outpoint, OutpointSourceId, PeerInfo, Timestamp, TokenInfo, TrustPolicy, +}; + +use crate::jsonrpc::{self, DEFAULT_TIMEOUT}; + +static DEFAULT_HTTP_CLIENT: OnceLock = OnceLock::new(); + +fn default_http_client() -> reqwest::Client { + DEFAULT_HTTP_CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .timeout(DEFAULT_TIMEOUT) + .build() + .expect("reqwest client with default settings must build") + }) + .clone() +} + +/// Client for the Mintlayer node daemon JSON-RPC API. +#[derive(Debug, Clone)] +pub struct Client { + pub(crate) transport: jsonrpc::Transport, +} + +impl Client { + /// Creates a client with default settings: a 30 second timeout and no + /// authentication. + #[must_use] + pub fn new(endpoint: impl Into) -> Self { + Self { + transport: jsonrpc::Transport::from_parts(endpoint.into(), default_http_client(), None), + } + } + + /// Returns a builder to construct a client with custom settings. + #[must_use] + pub fn builder(endpoint: impl Into) -> ClientBuilder { + ClientBuilder { + inner: jsonrpc::ClientBuilder::new(endpoint), + } + } + + /// Returns the node software version, e.g. `"1.3.0"`. + pub async fn node_version(&self) -> Result { + self.transport.call("node_version", &serde_json::json!({})).await + } + + /// Requests a graceful shutdown of the node daemon. + pub async fn node_shutdown(&self) -> Result<(), Error> { + self.transport.call("node_shutdown", &serde_json::json!({})).await + } +} + +/// Builder for [`Client`]. +#[derive(Debug, Clone)] +pub struct ClientBuilder { + inner: jsonrpc::ClientBuilder, +} + +impl ClientBuilder { + /// Sends HTTP basic auth credentials with every request. + #[must_use] + pub fn basic_auth(mut self, username: impl Into, password: impl Into) -> Self { + self.inner = self.inner.basic_auth(username, password); + self + } + + /// Overrides the default 30 second request timeout. + #[must_use] + pub fn timeout(mut self, timeout: Duration) -> Self { + self.inner = self.inner.timeout(timeout); + self + } + + /// Replaces the internally built HTTP client entirely. + #[must_use] + pub fn http_client(mut self, http_client: reqwest::Client) -> Self { + self.inner = self.inner.http_client(http_client); + self + } + + /// Builds the client. + pub fn build(self) -> Result { + Ok(Client { + transport: self.inner.build()?, + }) + } +} diff --git a/src/node/p2p.rs b/src/node/p2p.rs new file mode 100644 index 0000000..80f0e93 --- /dev/null +++ b/src/node/p2p.rs @@ -0,0 +1,107 @@ +// 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. + +//! P2P RPC methods: peers, bans, transaction broadcast. + +use std::time::Duration; + +use super::{BannedPeer, Error, PeerInfo, TrustPolicy}; +use crate::node::Client; + +impl Client { + /// Returns the number of connected peers (`p2p_get_peer_count`). + pub async fn peer_count(&self) -> Result { + self.transport.call("p2p_get_peer_count", &serde_json::json!({})).await + } + + /// Returns details about all connected peers + /// (`p2p_get_connected_peers`). + pub async fn connected_peers(&self) -> Result, Error> { + self.transport.call("p2p_get_connected_peers", &serde_json::json!({})).await + } + + /// Returns the addresses the node binds to (`p2p_get_bind_addresses`). + pub async fn bind_addresses(&self) -> Result, Error> { + self.transport.call("p2p_get_bind_addresses", &serde_json::json!({})).await + } + + /// Adds a reserved node (`p2p_add_reserved_node`). + pub async fn add_reserved_node(&self, addr: &str) -> Result<(), Error> { + self.transport + .call( + "p2p_add_reserved_node", + &serde_json::json!({ "addr": addr }), + ) + .await + } + + /// Removes a reserved node (`p2p_remove_reserved_node`). + pub async fn remove_reserved_node(&self, addr: &str) -> Result<(), Error> { + self.transport + .call( + "p2p_remove_reserved_node", + &serde_json::json!({ "addr": addr }), + ) + .await + } + + /// Opens a one-time connection to a node (`p2p_connect`). + pub async fn connect(&self, addr: &str) -> Result<(), Error> { + self.transport.call("p2p_connect", &serde_json::json!({ "addr": addr })).await + } + + /// Disconnects a peer (`p2p_disconnect`). + pub async fn disconnect(&self, peer_id: u64) -> Result<(), Error> { + self.transport + .call("p2p_disconnect", &serde_json::json!({ "peer_id": peer_id })) + .await + } + + /// Returns all banned peers (`p2p_list_banned`). + pub async fn list_banned(&self) -> Result, Error> { + self.transport.call("p2p_list_banned", &serde_json::json!({})).await + } + + /// Bans an address for a duration (`p2p_ban`). + pub async fn ban(&self, address: &str, duration: Duration) -> Result<(), Error> { + let seconds = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX); + self.transport + .call( + "p2p_ban", + &serde_json::json!({ + "address": address, + "duration": [seconds, i64::from(duration.subsec_nanos())], + }), + ) + .await + } + + /// Lifts a ban on an address (`p2p_unban`). + pub async fn unban(&self, address: &str) -> Result<(), Error> { + self.transport + .call("p2p_unban", &serde_json::json!({ "address": address })) + .await + } + + /// Submits a hex-encoded signed transaction to the mempool and broadcasts + /// it to P2P peers (`p2p_submit_transaction`). This is the correct call + /// for propagating a transaction across the network. + pub async fn broadcast_transaction( + &self, + tx_hex: &str, + trust_policy: TrustPolicy, + ) -> Result<(), Error> { + self.transport + .call( + "p2p_submit_transaction", + &serde_json::json!({ + "tx": tx_hex, + "options": { "trust_policy": trust_policy }, + }), + ) + .await + } +} diff --git a/src/node/types.rs b/src/node/types.rs new file mode 100644 index 0000000..e325954 --- /dev/null +++ b/src/node/types.rs @@ -0,0 +1,264 @@ +// 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. + +//! Wire types for the node daemon JSON-RPC API. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Coin or token amount in atoms, the smallest indivisible unit +/// (1 ML = 100,000,000,000 atoms). +/// +/// Serialized as a decimal string to avoid precision loss in JSON. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Amount(u128); + +impl Amount { + /// Creates an amount from a number of atoms. + #[must_use] + pub const fn from_atoms(atoms: u128) -> Self { + Self(atoms) + } + + /// The zero amount. + #[must_use] + pub const fn zero() -> Self { + Self(0) + } + + /// Returns the number of atoms. + #[must_use] + pub const fn atoms(self) -> u128 { + self.0 + } +} + +impl fmt::Display for Amount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for Amount { + fn serialize(&self, serializer: S) -> Result { + crate::number::atoms_object::serialize(&self.0, serializer) + } +} + +impl<'de> Deserialize<'de> for Amount { + fn deserialize>(deserializer: D) -> Result { + crate::number::atoms_object::deserialize(deserializer).map(Self) + } +} + +/// A UNIX timestamp in seconds. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +pub struct Timestamp { + /// Seconds since the UNIX epoch. + pub timestamp: i64, +} + +/// The source of a [`UtxoOutPoint`](crate::node::Outpoint). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", content = "content")] +pub enum OutpointSourceId { + /// The output of a transaction, keyed by its hex-encoded transaction id. + Transaction { + /// Hex-encoded transaction id. + tx_id: String, + }, + /// The block reward of a block, keyed by its hex-encoded block id. + BlockReward { + /// Hex-encoded block id. + block_id: String, + }, +} + +/// A reference to a specific transaction output. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Outpoint { + /// Where the spent output comes from. + pub source_id: OutpointSourceId, + /// The index of the output in the source transaction or block reward. + pub index: u32, +} + +/// Information about a token as stored in the chainstate. +/// +/// The `content` payload is kept as raw JSON because its shape depends on +/// [`kind`](#structfield.kind) (`"FungibleToken"` or `"NonFungibleToken"`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TokenInfo { + /// Either `"FungibleToken"` or `"NonFungibleToken"`. + #[serde(rename = "type")] + pub kind: String, + /// The type-specific payload. + pub content: serde_json::Value, +} + +/// Information about a DEX order as stored in the chainstate. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OrderInfo { + /// Public key that may conclude the order. + pub conclude_key: String, + /// The currency and amount initially asked. + pub initially_asked: serde_json::Value, + /// The currency and amount initially given. + pub initially_given: serde_json::Value, + /// Remaining ask balance. + pub ask_balance: Amount, + /// Remaining give balance. + pub give_balance: Amount, + /// Account nonce; `None` while the order has no account spending history. + #[serde(default, with = "crate::number::option_u64_lenient")] + pub nonce: Option, + /// Whether the order is frozen. + pub is_frozen: bool, +} + +/// A currency filter used to query orders. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(tag = "type", content = "content")] +pub enum Currency { + /// The native coin. + Coin, + /// A token identified by its bech32 token id. + Token(String), +} + +/// The trust policy applied when submitting a transaction. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize)] +pub enum TrustPolicy { + /// Accept the transaction only if fully valid against the current + /// chainstate. + #[default] + Trusted, + /// Accept the transaction even if some inputs are not yet known. + Untrusted, +} + +/// A transaction tracked by the mempool. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MempoolTx { + /// Hex-encoded transaction id. + pub id: String, + /// Pool status (`"InMempool"`, `"InMempoolDuplicate"`, `"InOrphanPool"` + /// or `"InOrphanPoolDuplicate"`). + pub status: String, + /// Hex-encoded signed transaction bytes. + pub transaction: String, +} + +/// A fee rate in atoms per kilobyte. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeeRate { + /// Atoms per kilobyte. + pub amount_per_kb: Amount, +} + +/// One point of the mempool fee-rate estimate curve. +/// +/// Serialized as a two-element array `[size, rate]`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "(u64, FeeRate)", into = "(u64, FeeRate)")] +pub struct FeeRatePoint { + /// Cumulative mempool size in bytes at which this rate applies. + pub size: u64, + /// The fee rate for that fill level. + pub rate: FeeRate, +} + +impl From<(u64, FeeRate)> for FeeRatePoint { + fn from((size, rate): (u64, FeeRate)) -> Self { + Self { size, rate } + } +} + +impl From for (u64, FeeRate) { + fn from(point: FeeRatePoint) -> Self { + (point.size, point.rate) + } +} + +/// Summary information about the chain tip. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChainstateInfo { + /// Height of the best block. + pub best_block_height: u64, + /// Hex-encoded id of the best block. + pub best_block_id: String, + /// Timestamp of the best block. + pub best_block_timestamp: Timestamp, + /// Median timestamp of the last blocks. + pub median_time: Timestamp, + /// Whether the node is still in initial block download. + pub is_initial_block_download: bool, +} + +/// Details about a connected P2P peer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerInfo { + /// Peer identifier assigned by the node. + pub peer_id: u64, + /// Remote address in `host:port` form. + pub address: String, + /// Peer role (`"Inbound"`, `"OutboundFullRelay"`, `"OutboundBlockRelay"`, + /// `"OutboundReserved"`, `"OutboundManual"` or `"Feeler"`). + pub peer_role: String, + /// Current ban score. + pub ban_score: u32, + /// Peer user agent string. + pub user_agent: String, + /// Software version reported by the peer. + pub software_version: String, + /// Milliseconds the last ping took, if measured. + pub ping_wait: Option, + /// Milliseconds of the last completed ping, if any. + pub ping_last: Option, + /// Milliseconds of the fastest ping, if any. + pub ping_min: Option, + /// Timestamp of the peer's last known tip block, if reported. + pub last_tip_block_time: Option, +} + +/// When a peer was banned until. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct BannedTime { + /// Seconds component of the ban deadline. + pub seconds: i64, + /// Nanoseconds component of the ban deadline. + pub nanos: i64, +} + +/// A banned peer as reported by the node. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(from = "(String, BannedTimeWire)")] +pub struct BannedPeer { + /// The banned address. + pub address: String, + /// When the ban expires. + pub ban_time: BannedTime, +} + +#[derive(Deserialize)] +struct BannedTimeWire { + time: (i64, i64), +} + +impl From<(String, BannedTimeWire)> for BannedPeer { + fn from((address, wire): (String, BannedTimeWire)) -> Self { + Self { + address, + ban_time: BannedTime { + seconds: wire.time.0, + nanos: wire.time.1, + }, + } + } +} diff --git a/src/number.rs b/src/number.rs new file mode 100644 index 0000000..760b943 --- /dev/null +++ b/src/number.rs @@ -0,0 +1,121 @@ +// 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. + +//! Lenient wire-format helpers for amounts and integers that daemons may +//! encode either as JSON numbers or as decimal strings. + +/// `(de)serializes` a `u128` atom amount as a decimal string, accepting a +/// decimal string or a plain integer when deserializing. +pub(crate) mod atoms { + use std::fmt; + + use serde::de::{self, Visitor}; + use serde::{Deserializer, Serializer}; + + #[allow(dead_code)] + pub(crate) fn serialize(value: &u128, serializer: S) -> Result { + serializer.serialize_str(&value.to_string()) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result { + struct AtomsVisitor; + + impl Visitor<'_> for AtomsVisitor { + type Value = u128; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a decimal atom amount (string or integer)") + } + + fn visit_str(self, value: &str) -> Result { + value.parse::().map_err(de::Error::custom) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(u128::from(value)) + } + } + + deserializer.deserialize_any(AtomsVisitor) + } +} + +/// `(de)serializes` a `u128` atom amount wrapped in the daemon's object +/// envelope `{"atoms": ""}`. +pub(crate) mod atoms_object { + use serde::ser::SerializeStruct; + use serde::{Deserialize, Deserializer, Serializer}; + + pub(crate) fn serialize(value: &u128, serializer: S) -> Result { + let mut state = serializer.serialize_struct("Amount", 1)?; + state.serialize_field("atoms", &value.to_string())?; + state.end() + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result { + #[derive(Deserialize)] + struct Wire { + #[serde(deserialize_with = "super::atoms::deserialize")] + atoms: u128, + } + Wire::deserialize(deserializer).map(|wire| wire.atoms) + } +} + +/// Deserializes an `Option` that may arrive as `null`, an integer, or a +/// decimal string; serializes integers and `null`. +pub(crate) mod option_u64_lenient { + use std::fmt; + + use serde::de::{self, Visitor}; + use serde::{Deserializer, Serializer}; + + pub(crate) fn serialize( + value: &Option, + serializer: S, + ) -> Result { + match value { + Some(value) => serializer.serialize_u64(*value), + None => serializer.serialize_none(), + } + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + struct U64Visitor; + + impl Visitor<'_> for U64Visitor { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("null, an integer, or a decimal string") + } + + fn visit_unit(self) -> Result, E> { + Ok(None) + } + + fn visit_none(self) -> Result, E> { + Ok(None) + } + + fn visit_u64(self, value: u64) -> Result, E> { + Ok(Some(value)) + } + + fn visit_str(self, value: &str) -> Result, E> { + value.parse::().map(Some).map_err(de::Error::custom) + } + } + + deserializer.deserialize_any(U64Visitor) + } +} diff --git a/tests/node.rs b/tests/node.rs new file mode 100644 index 0000000..6e99abc --- /dev/null +++ b/tests/node.rs @@ -0,0 +1,365 @@ +// 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. + +//! Wire-format tests for the node daemon client, mirroring the go-sdk +//! `node/client_test.go` suite. + +use std::sync::Arc; +use std::time::Duration; + +use httpmock::prelude::*; +use httpmock::{Mock, Then}; +use serde_json::json; + +use mintlayer_sdk::node::{ + Amount, BannedPeer, BannedTime, Client, Error, FeeRate, FeeRatePoint, Outpoint, + OutpointSourceId, TrustPolicy, +}; + +const RESPONSE_HEADERS: [(&str, &str); 1] = [("content-type", "application/json")]; + +fn respond(then: Then, body: serde_json::Value) { + let mut builder = then.status(200); + for (name, value) in RESPONSE_HEADERS { + builder = builder.header(name, value); + } + builder.body(body.to_string()); +} + +fn rpc_ok(id: u64, result: serde_json::Value) -> serde_json::Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +fn rpc_ok_no_id(result: serde_json::Value) -> serde_json::Value { + json!({ "jsonrpc": "2.0", "result": result }) +} + +fn mock_rpc( + server: &MockServer, + request_fragment: String, + response: serde_json::Value, +) -> Mock<'_> { + server.mock(move |when, then| { + when.method(POST).path("/").body_contains(request_fragment); + respond(then, response); + }) +} + +#[tokio::test] +async fn chainstate_info_roundtrip() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("\"jsonrpc\":\"2.0\"") + .body_contains("\"method\":\"chainstate_info\"") + .body_contains("\"params\":{}"); + respond( + then, + rpc_ok( + 1, + json!({ + "best_block_height": 123456, + "best_block_id": + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + "best_block_timestamp": { "timestamp": 1_700_000_000 }, + "median_time": { "timestamp": 1_699_999_500 }, + "is_initial_block_download": false, + }), + ), + ); + }); + + let client = Client::new(server.url("/")); + let info = client.chainstate_info().await.unwrap(); + + assert_eq!(info.best_block_height, 123456); + assert_eq!( + info.best_block_id, + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + ); + assert_eq!(info.best_block_timestamp.timestamp, 1_700_000_000); + assert_eq!(info.median_time.timestamp, 1_699_999_500); + assert!(!info.is_initial_block_download); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn scalar_methods_and_id_counter() { + let server = MockServer::start(); + let id_mock = mock_rpc(&server, "\"id\":1,".to_string(), rpc_ok(1, json!("0000ff"))); + let height_mock = mock_rpc(&server, "\"id\":2,".to_string(), rpc_ok(2, json!(7))); + let tx_mock = mock_rpc(&server, "\"id\":3,".to_string(), rpc_ok(3, json!(true))); + let memory_mock = mock_rpc(&server, "\"id\":4,".to_string(), rpc_ok(4, json!(2048))); + + let client = Client::new(server.url("/")); + assert_eq!(client.best_block_id().await.unwrap(), "0000ff"); + assert_eq!(client.best_block_height().await.unwrap(), 7); + assert!(client.contains_tx("aabb1234").await.unwrap()); + assert_eq!(client.memory_usage().await.unwrap(), 2048); + + assert_eq!(id_mock.hits(), 1); + assert_eq!(height_mock.hits(), 1); + assert_eq!(tx_mock.hits(), 1); + assert_eq!(memory_mock.hits(), 1); +} + +#[tokio::test] +async fn optional_results_decode_null_as_none() { + let server = MockServer::start(); + let mock = mock_rpc( + &server, + "\"jsonrpc\"".to_string(), + rpc_ok_no_id(json!(null)), + ); + + let client = Client::new(server.url("/")); + assert_eq!(client.block_id_at_height(999_999).await.unwrap(), None); + assert_eq!(client.stake_pool_balance("pool1abc").await.unwrap(), None); + assert_eq!(client.token_info("token1abc").await.unwrap(), None); + assert_eq!(mock.hits(), 3); +} + +#[tokio::test] +async fn rpc_error_is_surfaced() { + let server = MockServer::start(); + mock_rpc( + &server, + "\"jsonrpc\"".to_string(), + json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { "code": -32601, "message": "Method not found" }, + }), + ); + + let client = Client::new(server.url("/")); + let err = client.best_block_height().await.unwrap_err(); + let display = err.to_string(); + match err { + Error::Rpc { code, message } => { + assert_eq!(code, -32601); + assert_eq!(message, "Method not found"); + } + other => panic!("expected Error::Rpc, got {other:?}"), + } + assert_eq!(display, "RPC error -32601: Method not found"); +} + +#[tokio::test] +async fn basic_auth_header_is_sent() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/").header("authorization", "Basic dXNlcjpwYXNz"); + respond(then, rpc_ok(1, json!(5))); + }); + + let client = Client::builder(server.url("/")).basic_auth("user", "pass").build().unwrap(); + assert_eq!(client.best_block_height().await.unwrap(), 5); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn get_utxo_serializes_tagged_outpoint() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/").json_body_partial( + json!({ + "method": "chainstate_get_utxo", + "params": { + "outpoint": { + "source_id": { + "type": "Transaction", + "content": { "tx_id": "aabb" }, + }, + "index": 3, + }, + }, + }) + .to_string(), + ); + respond(then, rpc_ok(1, json!({ "type": "Transfer" }))); + }); + + let client = Client::new(server.url("/")); + let outpoint = Outpoint { + source_id: OutpointSourceId::Transaction { + tx_id: "aabb".to_string(), + }, + index: 3, + }; + let utxo = client.utxo(&outpoint).await.unwrap().unwrap(); + assert_eq!(utxo, json!({ "type": "Transfer" })); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn fee_rate_points_decode_tuple_wire() { + let server = MockServer::start(); + mock_rpc( + &server, + "\"jsonrpc\"".to_string(), + rpc_ok( + 1, + json!([ + [1024, { "amount_per_kb": { "atoms": "500" } }], + [2048, { "amount_per_kb": { "atoms": "750" } }], + ]), + ), + ); + + let client = Client::new(server.url("/")); + let points = client.fee_rate_points().await.unwrap(); + assert_eq!( + points, + vec![ + FeeRatePoint { + size: 1024, + rate: FeeRate { + amount_per_kb: Amount::from_atoms(500), + }, + }, + FeeRatePoint { + size: 2048, + rate: FeeRate { + amount_per_kb: Amount::from_atoms(750), + }, + }, + ] + ); +} + +#[tokio::test] +async fn banned_peers_decode_tuple_wire() { + let server = MockServer::start(); + mock_rpc( + &server, + "\"jsonrpc\"".to_string(), + rpc_ok(1, json!([["1.2.3.4", { "time": [1_700_000_000, 123] }]])), + ); + + let client = Client::new(server.url("/")); + assert_eq!( + client.list_banned().await.unwrap(), + vec![BannedPeer { + address: "1.2.3.4".to_string(), + ban_time: BannedTime { + seconds: 1_700_000_000, + nanos: 123, + }, + }] + ); +} + +#[tokio::test] +async fn order_nonce_accepts_string_number_and_null() { + let server = MockServer::start(); + let string_nonce_order = json!({ + "conclude_key": "02a1b2c3", + "initially_asked": { "type": "Coin", "content": { "atoms": "1000" } }, + "initially_given": { "type": "Token", "content": "ttoken1abc" }, + "ask_balance": { "atoms": "1000" }, + "give_balance": { "atoms": "250" }, + "nonce": "5", + "is_frozen": false, + }); + let null_nonce_order = json!({ + "conclude_key": "02a1b2c3", + "initially_asked": { "type": "Coin", "content": { "atoms": "1000" } }, + "initially_given": { "type": "Token", "content": "ttoken1abc" }, + "ask_balance": { "atoms": "1000" }, + "give_balance": { "atoms": "250" }, + "nonce": null, + "is_frozen": false, + }); + let string_mock = mock_rpc( + &server, + "\"id\":1,".to_string(), + rpc_ok(1, string_nonce_order), + ); + let null_mock = mock_rpc( + &server, + "\"id\":2,".to_string(), + rpc_ok(2, null_nonce_order), + ); + + let client = Client::new(server.url("/")); + let order = client.order_info("order1").await.unwrap().unwrap(); + assert_eq!(order.nonce, Some(5)); + let order = client.order_info("order1").await.unwrap().unwrap(); + assert_eq!(order.nonce, None); + assert_eq!(order.ask_balance, Amount::from_atoms(1000)); + assert_eq!(order.give_balance, Amount::from_atoms(250)); + + assert_eq!(string_mock.hits(), 1); + assert_eq!(null_mock.hits(), 1); +} + +#[tokio::test] +async fn submit_transaction_sends_trust_policy() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/").json_body_partial( + json!({ + "method": "mempool_submit_transaction", + "params": { + "tx": "deadbeef", + "options": { "trust_policy": "Untrusted" }, + }, + }) + .to_string(), + ); + respond(then, rpc_ok(1, json!(null))); + }); + + let client = Client::new(server.url("/")); + client.submit_transaction("deadbeef", TrustPolicy::Untrusted).await.unwrap(); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn ban_serializes_duration_as_tuple() { + let server = MockServer::start(); + let mock = mock_rpc( + &server, + "\"address\":\"1.2.3.4\",\"duration\":[3600,500000000]".to_string(), + rpc_ok(1, json!(null)), + ); + + let client = Client::new(server.url("/")); + client.ban("1.2.3.4", Duration::from_millis(3_600_500)).await.unwrap(); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn concurrent_calls_use_unique_ids() { + let server = MockServer::start(); + let mut mocks = Vec::new(); + for n in 1..=10u64 { + let matcher = format!("\"id\":{n},\"method\":\"chainstate_best_block_height\""); + mocks.push(mock_rpc(&server, matcher, rpc_ok(n, json!(n)))); + } + + let client = Client::new(server.url("/")); + let shared = Arc::new(client.clone()); + let mut handles = Vec::new(); + for _ in 0..10 { + let client = Arc::clone(&shared); + handles.push(tokio::spawn(async move { + client.best_block_height().await.unwrap() + })); + } + + let mut heights = Vec::new(); + for handle in handles { + heights.push(handle.await.unwrap()); + } + heights.sort_unstable(); + assert_eq!(heights, (1u64..=10).collect::>()); + for mock in &mocks { + assert_eq!(mock.hits(), 1); + } +} From 86acdab5630917fb94deae79419ce2d3f40364ed Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 16 Sep 2026 23:54:53 +0400 Subject: [PATCH 04/29] node: redact basic auth from Debug output, cap response body size (security review fixes) --- src/jsonrpc.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++------ tests/node.rs | 34 +++++++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/jsonrpc.rs b/src/jsonrpc.rs index 25a6b92..c243e87 100644 --- a/src/jsonrpc.rs +++ b/src/jsonrpc.rs @@ -6,6 +6,7 @@ //! Internal JSON-RPC 2.0 transport shared by the node and wallet clients. +use std::fmt; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; @@ -16,6 +17,27 @@ use thiserror::Error; pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +/// Upper bound for a single JSON-RPC response body, guarding against memory +/// exhaustion from a misconfigured or hostile endpoint. +const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024; + +/// HTTP basic auth credentials with a redacted [`Debug`] implementation so +/// that logging a client never leaks the password. +#[derive(Clone)] +pub(crate) struct BasicAuth { + username: String, + password: String, +} + +impl fmt::Debug for BasicAuth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BasicAuth") + .field("username", &"***") + .field("password", &"***") + .finish() + } +} + /// Defines the public error enum for an RPC sub-client from the internal /// [`RequestError`]. macro_rules! define_error { @@ -45,6 +67,12 @@ macro_rules! define_error { /// The `id` that was received. actual: serde_json::Value, }, + /// The daemon response exceeded the maximum accepted body size. + #[error("daemon response exceeds the maximum accepted size of {limit} bytes")] + ResponseTooLarge { + /// The limit that was exceeded. + limit: usize, + }, } impl From for $name { @@ -56,6 +84,9 @@ macro_rules! define_error { crate::jsonrpc::RequestError::IdMismatch { expected, actual } => { Self::IdMismatch { expected, actual } } + crate::jsonrpc::RequestError::ResponseTooLarge { limit } => { + Self::ResponseTooLarge { limit } + } } } } @@ -85,6 +116,8 @@ pub(crate) enum RequestError { expected: u64, actual: serde_json::Value, }, + #[error("daemon response exceeds the maximum accepted size of {limit} bytes")] + ResponseTooLarge { limit: usize }, } #[derive(Serialize)] @@ -112,7 +145,7 @@ struct Response { pub(crate) struct Transport { endpoint: String, http: reqwest::Client, - basic_auth: Option<(String, String)>, + basic_auth: Option, next_id: Arc, } @@ -120,7 +153,7 @@ impl Transport { pub(crate) fn from_parts( endpoint: String, http: reqwest::Client, - basic_auth: Option<(String, String)>, + basic_auth: Option, ) -> Self { Self { endpoint, @@ -151,13 +184,14 @@ impl Transport { method, params, }); - if let Some((username, password)) = &self.basic_auth { - builder = builder.basic_auth(username, Some(password)); + if let Some(auth) = &self.basic_auth { + builder = builder.basic_auth(&auth.username, Some(&auth.password)); } // The HTTP status code is intentionally not inspected: the daemon may // answer with a valid JSON-RPC envelope on a non-2xx status (parity // with the go-sdk client). - let response: Response = builder.send().await?.json().await?; + let http_response = builder.send().await?; + let response: Response = read_json_body(http_response).await?; if let Some(err) = response.error { return Err(RequestError::Rpc { code: err.code, @@ -177,12 +211,33 @@ impl Transport { } } +async fn read_json_body(mut http_response: reqwest::Response) -> Result { + if http_response + .content_length() + .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) + { + return Err(RequestError::ResponseTooLarge { + limit: MAX_RESPONSE_BYTES, + }); + } + let mut body: Vec = Vec::new(); + while let Some(chunk) = http_response.chunk().await? { + if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + return Err(RequestError::ResponseTooLarge { + limit: MAX_RESPONSE_BYTES, + }); + } + body.extend_from_slice(&chunk); + } + Ok(serde_json::from_slice(&body)?) +} + /// Shared builder state for the node and wallet clients. #[derive(Debug, Clone)] pub(crate) struct ClientBuilder { endpoint: String, timeout: Duration, - basic_auth: Option<(String, String)>, + basic_auth: Option, http_client: Option, } @@ -201,10 +256,12 @@ impl ClientBuilder { username: impl Into, password: impl Into, ) -> Self { - self.basic_auth = Some((username.into(), password.into())); + self.basic_auth = Some(BasicAuth { + username: username.into(), + password: password.into(), + }); self } - pub(crate) fn timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self diff --git a/tests/node.rs b/tests/node.rs index 6e99abc..2d89d85 100644 --- a/tests/node.rs +++ b/tests/node.rs @@ -334,6 +334,40 @@ async fn ban_serializes_duration_as_tuple() { assert_eq!(mock.hits(), 1); } +#[tokio::test] +async fn debug_output_redacts_basic_auth() { + let server = MockServer::start(); + let builder = Client::builder(server.url("/")).basic_auth("secretuser", "secretpass"); + let builder_debug = format!("{builder:?}"); + let client = builder.build().unwrap(); + let client_debug = format!("{client:?}"); + for output in [builder_debug, client_debug] { + assert!(!output.contains("secretuser")); + assert!(!output.contains("secretpass")); + assert!(output.contains("***")); + } +} + +#[tokio::test] +async fn oversized_responses_are_rejected() { + let server = MockServer::start(); + // A real oversized body: httpmock derives the content-length header from + // the body length (65 MiB + 10 > the 64 MiB cap), so the transport's + // content-length pre-check rejects the response before it is read. + let oversized = "0".repeat(65 * 1024 * 1024 + 10); + server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200).header("content-type", "application/json").body(oversized); + }); + let client = Client::new(server.url("/")); + match client.best_block_height().await { + Err(Error::ResponseTooLarge { limit }) => { + assert_eq!(limit, 64 * 1024 * 1024); + } + other => panic!("expected ResponseTooLarge, got: {other:?}"), + } +} + #[tokio::test] async fn concurrent_calls_use_unique_ids() { let server = MockServer::start(); From e32c15405b930813eb3472686bf6033a896aac28 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 16 Sep 2026 23:54:53 +0400 Subject: [PATCH 05/29] indexer: REST client with full chain, block, tx, address, pool, token, order and statistics surface --- src/indexer/address.rs | 42 ++++ src/indexer/block.rs | 33 +++ src/indexer/chain.rs | 29 +++ src/indexer/delegation.rs | 17 ++ src/indexer/error.rs | 38 ++++ src/indexer/mod.rs | 178 ++++++++++++++- src/indexer/order.rs | 41 ++++ src/indexer/pool.rs | 51 +++++ src/indexer/statistics.rs | 34 +++ src/indexer/token.rs | 57 +++++ src/indexer/transaction.rs | 56 +++++ src/indexer/types.rs | 421 +++++++++++++++++++++++++++++++++++ src/jsonrpc.rs | 6 +- src/lib.rs | 3 + src/limits.rs | 16 ++ src/node/mod.rs | 3 +- src/number.rs | 75 +++++++ tests/indexer.rs | 439 +++++++++++++++++++++++++++++++++++++ 18 files changed, 1532 insertions(+), 7 deletions(-) create mode 100644 src/indexer/address.rs create mode 100644 src/indexer/block.rs create mode 100644 src/indexer/chain.rs create mode 100644 src/indexer/delegation.rs create mode 100644 src/indexer/error.rs create mode 100644 src/indexer/order.rs create mode 100644 src/indexer/pool.rs create mode 100644 src/indexer/statistics.rs create mode 100644 src/indexer/token.rs create mode 100644 src/indexer/transaction.rs create mode 100644 src/indexer/types.rs create mode 100644 src/limits.rs create mode 100644 tests/indexer.rs diff --git a/src/indexer/address.rs b/src/indexer/address.rs new file mode 100644 index 0000000..f977fb2 --- /dev/null +++ b/src/indexer/address.rs @@ -0,0 +1,42 @@ +// 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. + +//! Indexer address endpoints. + +use super::{AddressInfo, DelegationInfo, Error, Utxo}; +use crate::indexer::Client; + +impl Client { + /// Returns aggregated information about an address + /// (`GET /address/{address}`). + pub async fn address_info(&self, address: &str) -> Result { + self.get(&format!("/address/{address}"), &[]).await + } + + /// Returns the spendable UTXOs of an address + /// (`GET /address/{address}/spendable-utxos`). + pub async fn spendable_utxos(&self, address: &str) -> Result, Error> { + self.get(&format!("/address/{address}/spendable-utxos"), &[]).await + } + + /// Returns all UTXOs of an address, including locked ones + /// (`GET /address/{address}/all-utxos`). + pub async fn all_utxos(&self, address: &str) -> Result, Error> { + self.get(&format!("/address/{address}/all-utxos"), &[]).await + } + + /// Returns the delegations owned by an address + /// (`GET /address/{address}/delegations`). + pub async fn delegations(&self, address: &str) -> Result, Error> { + self.get(&format!("/address/{address}/delegations"), &[]).await + } + + /// Returns the token ids for which an address holds authority + /// (`GET /address/{address}/token-authority`). + pub async fn token_authority(&self, address: &str) -> Result, Error> { + self.get(&format!("/address/{address}/token-authority"), &[]).await + } +} diff --git a/src/indexer/block.rs b/src/indexer/block.rs new file mode 100644 index 0000000..6ee86dc --- /dev/null +++ b/src/indexer/block.rs @@ -0,0 +1,33 @@ +// 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. + +//! Indexer block endpoints. + +use super::{Block, BlockHeader, Error}; +use crate::indexer::Client; + +impl Client { + /// Returns a block with header and body (`GET /block/{id}`). + pub async fn block(&self, id: &str) -> Result { + self.get(&format!("/block/{id}"), &[]).await + } + + /// Returns only the header of a block (`GET /block/{id}/header`). + pub async fn block_header(&self, id: &str) -> Result { + self.get(&format!("/block/{id}/header"), &[]).await + } + + /// Returns the reward outputs of a block (`GET /block/{id}/reward`). + pub async fn block_reward(&self, id: &str) -> Result, Error> { + self.get(&format!("/block/{id}/reward"), &[]).await + } + + /// Returns the ids of the transactions in a block + /// (`GET /block/{id}/transaction-ids`). + pub async fn block_transaction_ids(&self, id: &str) -> Result, Error> { + self.get(&format!("/block/{id}/transaction-ids"), &[]).await + } +} diff --git a/src/indexer/chain.rs b/src/indexer/chain.rs new file mode 100644 index 0000000..0ed4836 --- /dev/null +++ b/src/indexer/chain.rs @@ -0,0 +1,29 @@ +// 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. + +//! Indexer chain endpoints. + +use super::{ChainTip, Error, GenesisInfo}; +use crate::indexer::Client; + +impl Client { + /// Returns the tip of the main chain (`GET /chain/tip`). + pub async fn tip(&self) -> Result { + self.get("/chain/tip", &[]).await + } + + /// Returns the genesis block and consensus message + /// (`GET /chain/genesis`). + pub async fn genesis(&self) -> Result { + self.get("/chain/genesis", &[]).await + } + + /// Returns the hex-encoded block id at a height + /// (`GET /chain/{height}`). + pub async fn block_id_at_height(&self, height: u64) -> Result { + self.get(&format!("/chain/{height}"), &[]).await + } +} diff --git a/src/indexer/delegation.rs b/src/indexer/delegation.rs new file mode 100644 index 0000000..3886dcf --- /dev/null +++ b/src/indexer/delegation.rs @@ -0,0 +1,17 @@ +// 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. + +//! Indexer delegation endpoints. + +use super::{Delegation, Error}; +use crate::indexer::Client; + +impl Client { + /// Returns a delegation by its bech32 id (`GET /delegation/{id}`). + pub async fn delegation(&self, id: &str) -> Result { + self.get(&format!("/delegation/{id}"), &[]).await + } +} diff --git a/src/indexer/error.rs b/src/indexer/error.rs new file mode 100644 index 0000000..66ba9bf --- /dev/null +++ b/src/indexer/error.rs @@ -0,0 +1,38 @@ +// 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. + +//! Error type for the indexer client. + +/// Errors returned by [`crate::indexer::Client`] calls. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The indexer answered with an HTTP status of 400 or above. + #[error("HTTP {status_code}: {body}")] + Http { + /// The HTTP status code. + status_code: u16, + /// The trimmed response body. + body: String, + }, + /// The HTTP request to the indexer failed. + #[error("HTTP request failed: {0}")] + Transport(#[from] reqwest::Error), + /// The response could not be decoded into the expected type. + #[error("failed to decode response: {0}")] + Json(#[from] serde_json::Error), + /// The request URL could not be constructed. + #[error("invalid request URL: {message}")] + InvalidUrl { + /// The reason the URL is invalid. + message: String, + }, + /// The indexer response exceeded the maximum accepted body size. + #[error("indexer response exceeds the maximum accepted size of {limit} bytes")] + ResponseTooLarge { + /// The limit that was exceeded. + limit: usize, + }, +} diff --git a/src/indexer/mod.rs b/src/indexer/mod.rs index 87e4425..9eff36f 100644 --- a/src/indexer/mod.rs +++ b/src/indexer/mod.rs @@ -4,4 +4,180 @@ // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. -//! Stub — implemented in a later phase. +//! REST client for the Mintlayer indexer (api-web-server). All paths are +//! relative to `/api/v2/` (default port 3000). + +mod address; +mod block; +mod chain; +mod delegation; +mod error; +mod order; +mod pool; +mod statistics; +mod token; +mod transaction; +mod types; + +use std::time::Duration; + +use serde::de::DeserializeOwned; + +pub use error::Error; +pub use types::{ + AddressInfo, Amount, Block, BlockBody, BlockHeader, ChainTip, CoinStats, Delegation, + DelegationInfo, GenesisInfo, MerklePath, NftInfo, NftMetadata, Order, PageOpts, PerThousand, + Pool, PoolDelegation, PoolListOpts, PoolSort, Timestamp, TokenBalance, TokenInfo, TokenTx, + Transaction, Uint64, Utxo, UtxoOutpoint, +}; + +use crate::limits::{DEFAULT_TIMEOUT, MAX_RESPONSE_BYTES}; + +/// Client for the Mintlayer indexer REST API (api-web-server). +#[derive(Debug, Clone)] +pub struct Client { + api_base: String, + http: reqwest::Client, +} + +impl Client { + /// Creates a client with default settings: a 30 second timeout. + /// + /// The indexer API is unauthenticated, so there are no credential + /// options. + #[must_use] + pub fn new(base_url: impl Into) -> Self { + Self::builder(base_url) + .build() + .expect("reqwest client with default settings must build") + } + + /// Returns a builder to construct a client with custom settings. + #[must_use] + pub fn builder(base_url: impl Into) -> ClientBuilder { + ClientBuilder { + api_base: format!("{}/api/v2", base_url.into().trim_end_matches('/')), + timeout: DEFAULT_TIMEOUT, + http_client: None, + } + } + + pub(crate) async fn get(&self, path: &str, query: &[(&str, String)]) -> Result + where + R: DeserializeOwned, + { + let mut url = + reqwest::Url::parse(&format!("{}{}", self.api_base, path)).map_err(|error| { + Error::InvalidUrl { + message: error.to_string(), + } + })?; + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query.iter().cloned()); + } + let response = self.http.get(url).header("accept", "application/json").send().await?; + self.decode(response).await + } + + pub(crate) async fn post(&self, path: &str, body: &str) -> Result + where + R: DeserializeOwned, + { + let url = reqwest::Url::parse(&format!("{}{}", self.api_base, path)).map_err(|error| { + Error::InvalidUrl { + message: error.to_string(), + } + })?; + let response = self + .http + .post(url) + .header("accept", "application/json") + .header(reqwest::header::CONTENT_TYPE, "text/plain") + .body(body.to_owned()) + .send() + .await?; + self.decode(response).await + } + + async fn decode(&self, response: reqwest::Response) -> Result { + let status = response.status(); + if status.is_client_error() || status.is_server_error() { + let body = response.text().await.unwrap_or_default(); + return Err(Error::Http { + status_code: status.as_u16(), + body: body.trim().to_owned(), + }); + } + let bytes = Self::read_capped(response).await?; + Ok(serde_json::from_slice(&bytes)?) + } + + async fn read_capped(mut response: reqwest::Response) -> Result, Error> { + if response + .content_length() + .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) + { + return Err(Error::ResponseTooLarge { + limit: MAX_RESPONSE_BYTES, + }); + } + let mut body: Vec = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + return Err(Error::ResponseTooLarge { + limit: MAX_RESPONSE_BYTES, + }); + } + body.extend_from_slice(&chunk); + } + Ok(body) + } +} + +/// Builds the pagination query parameters; zero values are omitted. +pub(crate) fn page_query(offset: u32, items: u32) -> Vec<(&'static str, String)> { + let mut query = Vec::new(); + if offset > 0 { + query.push(("offset", offset.to_string())); + } + if items > 0 { + query.push(("items", items.to_string())); + } + query +} + +/// Builder for [`Client`]. +#[derive(Debug, Clone)] +pub struct ClientBuilder { + api_base: String, + timeout: Duration, + http_client: Option, +} + +impl ClientBuilder { + /// Overrides the default 30 second request timeout. + #[must_use] + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Replaces the internally built HTTP client entirely. + #[must_use] + pub fn http_client(mut self, http_client: reqwest::Client) -> Self { + self.http_client = Some(http_client); + self + } + + /// Builds the client. + pub fn build(self) -> Result { + let http = match self.http_client { + Some(http) => http, + None => reqwest::Client::builder().timeout(self.timeout).build()?, + }; + Ok(Client { + api_base: self.api_base, + http, + }) + } +} diff --git a/src/indexer/order.rs b/src/indexer/order.rs new file mode 100644 index 0000000..0a98c79 --- /dev/null +++ b/src/indexer/order.rs @@ -0,0 +1,41 @@ +// 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. + +//! Indexer DEX order endpoints. + +use super::{Error, Order, PageOpts}; +use crate::indexer::Client; + +impl Client { + /// Lists DEX orders (`GET /order`). + pub async fn list_orders(&self, opts: PageOpts) -> Result, Error> { + self.get( + "/order", + &crate::indexer::page_query(opts.offset, opts.items), + ) + .await + } + + /// Returns an order by its bech32 id (`GET /order/{id}`). + pub async fn order(&self, id: &str) -> Result { + self.get(&format!("/order/{id}"), &[]).await + } + + /// Lists orders by currency pair; currencies are the coin ticker (e.g. + /// `ML`) or a bech32 token id (`GET /order/pair/{ask}_{give}`). + pub async fn orders_by_pair( + &self, + ask_currency: &str, + give_currency: &str, + opts: PageOpts, + ) -> Result, Error> { + self.get( + &format!("/order/pair/{ask_currency}_{give_currency}"), + &crate::indexer::page_query(opts.offset, opts.items), + ) + .await + } +} diff --git a/src/indexer/pool.rs b/src/indexer/pool.rs new file mode 100644 index 0000000..9572c94 --- /dev/null +++ b/src/indexer/pool.rs @@ -0,0 +1,51 @@ +// 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. + +//! Indexer stake-pool endpoints. + +use serde::Deserialize; + +use super::{Error, Pool, PoolDelegation, PoolListOpts}; +use crate::indexer::Client; + +impl Client { + /// Lists stake pools (`GET /pool`). + pub async fn list_pools(&self, opts: PoolListOpts) -> Result, Error> { + let mut query = crate::indexer::page_query(opts.offset, opts.items); + if let Some(sort) = opts.sort { + query.push(("sort", sort.as_str().to_owned())); + } + self.get("/pool", &query).await + } + + /// Returns a stake pool by its bech32 id (`GET /pool/{id}`). + pub async fn pool(&self, id: &str) -> Result { + self.get(&format!("/pool/{id}"), &[]).await + } + + /// Returns the number of blocks a pool produced in the half-open + /// interval `[from, to)` of UNIX timestamps + /// (`GET /pool/{id}/block-stats`). + pub async fn pool_block_stats(&self, id: &str, from: i64, to: i64) -> Result { + #[derive(Deserialize)] + struct Response { + block_count: u64, + } + let response: Response = self + .get( + &format!("/pool/{id}/block-stats"), + &[("from", from.to_string()), ("to", to.to_string())], + ) + .await?; + Ok(response.block_count) + } + + /// Returns the delegations of a stake pool + /// (`GET /pool/{id}/delegations`). + pub async fn pool_delegations(&self, id: &str) -> Result, Error> { + self.get(&format!("/pool/{id}/delegations"), &[]).await + } +} diff --git a/src/indexer/statistics.rs b/src/indexer/statistics.rs new file mode 100644 index 0000000..b0982d8 --- /dev/null +++ b/src/indexer/statistics.rs @@ -0,0 +1,34 @@ +// 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. + +//! Indexer statistics endpoints. + +use super::{CoinStats, Error}; +use crate::indexer::Client; + +impl Client { + /// Returns global coin statistics (`GET /statistics/coin`). + pub async fn coin_statistics(&self) -> Result { + self.get("/statistics/coin", &[]).await + } + + /// Returns statistics for a token (`GET /statistics/token/{token_id}`). + pub async fn token_statistics(&self, token_id: &str) -> Result { + self.get(&format!("/statistics/token/{token_id}"), &[]).await + } + + /// Returns the fee rate in atoms per kilobyte + /// (`GET /feerate`). `in_top_x_mb` selects the mempool fill level; + /// zero uses the server default of 5 MB. + pub async fn fee_rate(&self, in_top_x_mb: u32) -> Result { + let query = if in_top_x_mb == 0 { + Vec::new() + } else { + vec![("in_top_x_mb", in_top_x_mb.to_string())] + }; + self.get("/feerate", &query).await + } +} diff --git a/src/indexer/token.rs b/src/indexer/token.rs new file mode 100644 index 0000000..5309524 --- /dev/null +++ b/src/indexer/token.rs @@ -0,0 +1,57 @@ +// 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. + +//! Indexer token and NFT endpoints. + +use super::{Error, NftInfo, PageOpts, TokenInfo, TokenTx}; +use crate::indexer::Client; + +impl Client { + /// Lists bech32 token ids (`GET /token`). + pub async fn list_tokens(&self, opts: PageOpts) -> Result, Error> { + self.get( + "/token", + &crate::indexer::page_query(opts.offset, opts.items), + ) + .await + } + + /// Returns a token by its bech32 id (`GET /token/{id}`). + pub async fn token(&self, id: &str) -> Result { + self.get(&format!("/token/{id}"), &[]).await + } + + /// Returns the transfers of a token (`GET /token/{id}/transactions`). + pub async fn token_transactions( + &self, + id: &str, + opts: PageOpts, + ) -> Result, Error> { + self.get( + &format!("/token/{id}/transactions"), + &crate::indexer::page_query(opts.offset, opts.items), + ) + .await + } + + /// Finds token ids by ticker (`GET /token/ticker/{ticker}`). + pub async fn tokens_by_ticker( + &self, + ticker: &str, + opts: PageOpts, + ) -> Result, Error> { + self.get( + &format!("/token/ticker/{ticker}"), + &crate::indexer::page_query(opts.offset, opts.items), + ) + .await + } + + /// Returns an NFT by its bech32 token id (`GET /nft/{id}`). + pub async fn nft(&self, id: &str) -> Result { + self.get(&format!("/nft/{id}"), &[]).await + } +} diff --git a/src/indexer/transaction.rs b/src/indexer/transaction.rs new file mode 100644 index 0000000..1edd392 --- /dev/null +++ b/src/indexer/transaction.rs @@ -0,0 +1,56 @@ +// 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. + +//! Indexer transaction endpoints. + +use serde::Deserialize; + +use super::{Error, MerklePath, PageOpts, Transaction}; +use crate::indexer::Client; + +impl Client { + /// Lists transactions, newest first (`GET /transaction`). + pub async fn list_transactions(&self, opts: PageOpts) -> Result, Error> { + self.get( + "/transaction", + &crate::indexer::page_query(opts.offset, opts.items), + ) + .await + } + + /// Returns a transaction by id (`GET /transaction/{id}`). + pub async fn transaction(&self, id: &str) -> Result { + self.get(&format!("/transaction/{id}"), &[]).await + } + + /// Returns the merkle path of a confirmed transaction + /// (`GET /transaction/{id}/merkle-path`). + pub async fn transaction_merkle_path(&self, id: &str) -> Result { + self.get(&format!("/transaction/{id}/merkle-path"), &[]).await + } + + /// Returns one output of a transaction + /// (`GET /transaction/{tx_id}/output/{index}`). + pub async fn transaction_output( + &self, + tx_id: &str, + index: u32, + ) -> Result { + self.get(&format!("/transaction/{tx_id}/output/{index}"), &[]).await + } + + /// Submits a hex-encoded signed transaction + /// (`POST /transaction`). Requires the indexer to run with + /// `--enable-post-routes`. + pub async fn submit_transaction(&self, signed_tx_hex: &str) -> Result { + #[derive(Deserialize)] + struct Response { + tx_id: String, + } + let response: Response = self.post("/transaction", signed_tx_hex).await?; + Ok(response.tx_id) + } +} diff --git a/src/indexer/types.rs b/src/indexer/types.rs new file mode 100644 index 0000000..b60818e --- /dev/null +++ b/src/indexer/types.rs @@ -0,0 +1,421 @@ +// 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. + +//! Wire types for the indexer (api-web-server) REST API. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// An unsigned 64-bit integer that the indexer may encode as a JSON integer +/// or as a decimal string. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Uint64(pub u64); + +impl fmt::Display for Uint64 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for Uint64 { + fn serialize(&self, serializer: S) -> Result { + crate::number::u64_lenient::serialize(&self.0, serializer) + } +} + +impl<'de> Deserialize<'de> for Uint64 { + fn deserialize>(deserializer: D) -> Result { + crate::number::u64_lenient::deserialize(deserializer).map(Self) + } +} + +/// A per-mille ratio (e.g. a pool margin ratio) that the indexer may encode +/// as a number or as a decimal string with an optional trailing `%`. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct PerThousand(pub f64); + +impl fmt::Display for PerThousand { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for PerThousand { + fn serialize(&self, serializer: S) -> Result { + crate::number::f64_percent::serialize(&self.0, serializer) + } +} + +impl<'de> Deserialize<'de> for PerThousand { + fn deserialize>(deserializer: D) -> Result { + crate::number::f64_percent::deserialize(deserializer).map(Self) + } +} + +/// A coin or token amount as reported by the indexer, with the atom value +/// and the human-readable decimal representation. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Amount { + /// Amount in atoms, the smallest indivisible unit + /// (1 ML = 100,000,000,000 atoms). + #[serde(with = "crate::number::atoms")] + pub atoms: u128, + /// The decimal representation in whole coins. + pub decimal: String, +} + +/// A UNIX timestamp in seconds. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +pub struct Timestamp { + /// Seconds since the UNIX epoch. + pub timestamp: i64, +} + +/// The tip of the main chain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChainTip { + /// Height of the best block. + pub block_height: u64, + /// Hex-encoded id of the best block. + pub block_id: String, +} + +/// The genesis block and consensus message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GenesisInfo { + /// Hex-encoded genesis block id. + pub block_id: String, + /// The signed genesis message. + pub genesis_message: String, + /// Genesis timestamp. + pub timestamp: Timestamp, + /// The initial UTXO set. + pub utxos: serde_json::Value, +} + +/// A block header. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BlockHeader { + /// Hex-encoded id of the parent block. + pub previous_block_id: String, + /// Header timestamp. + pub timestamp: Timestamp, + /// Hex-encoded merkle root of transaction ids. + pub merkle_root: String, + /// Hex-encoded merkle root of witness merkle roots. + pub witness_merkle_root: String, + /// Consensus data payload (PoS or PoW specific). + pub consensus_data: serde_json::Value, +} + +/// A block body. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BlockBody { + /// The block reward outputs. + pub reward: Vec, + /// The transactions included in the block. + pub transactions: Vec, +} + +/// A block with header and body. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Block { + /// Height of the block. + pub height: u64, + /// The block header. + pub header: BlockHeader, + /// The block body. + pub body: BlockBody, +} + +/// A transaction as reported by the indexer. +/// +/// [`block_id`](#structfield.block_id), [`timestamp`](#structfield.timestamp) +/// and [`confirmations`](#structfield.confirmations) are empty strings while +/// the transaction is unconfirmed. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Transaction { + /// Hex-encoded transaction id. + pub id: String, + /// The transaction inputs. + pub inputs: serde_json::Value, + /// The transaction outputs. + pub outputs: serde_json::Value, + /// Hex-encoded id of the confirming block, empty when unconfirmed. + pub block_id: String, + /// Confirmation timestamp, empty when unconfirmed. + pub timestamp: String, + /// Number of confirmations, empty when unconfirmed. + pub confirmations: String, +} + +/// The merkle path of a confirmed transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MerklePath { + /// Hex-encoded id of the confirming block. + pub block_id: String, + /// Index of the transaction in the block. + pub transaction_index: u32, + /// Hex-encoded merkle root of the block. + pub merkle_root: String, + /// The hashes along the path from the transaction to the root. + pub path: Vec, +} + +/// A token balance of an address. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenBalance { + /// Bech32 token id. + pub token_id: String, + /// The token amount. + pub amount: Amount, +} + +/// Aggregated information about an address. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AddressInfo { + /// Spendable coin balance. + pub coin_balance: Amount, + /// Coin balance locked by timelocks. + pub locked_coin_balance: Amount, + /// Hex-encoded ids of transactions involving the address. + pub transaction_history: Vec, + /// Token balances held by the address. + pub tokens: Vec, +} + +/// A flat outpoint as reported by the indexer (`source_id` is a hex string). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct UtxoOutpoint { + /// Hex-encoded transaction id or block id. + pub source_id: String, + /// Index of the output in the source. + pub index: u32, +} + +/// An unspent output. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Utxo { + /// The outpoint referencing the output. + pub outpoint: UtxoOutpoint, + /// The output itself. + #[serde(rename = "utxo")] + pub output: serde_json::Value, +} + +/// Delegation summary as seen from an address. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DelegationInfo { + /// Bech32 delegation id. + pub delegation_id: String, + /// Bech32 pool id. + pub pool_id: String, + /// The next expected account nonce. + pub next_nonce: Uint64, + /// The destination that receives delegation withdrawals. + pub spend_destination: String, + /// The delegated balance. + pub balance: Amount, +} + +/// A stake pool as reported by the indexer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Pool { + /// Bech32 pool id. + pub pool_id: String, + /// The destination receiving decommission outputs. + pub decommission_destination: String, + /// The staker's own pledge balance. + pub staker_balance: Amount, + /// The pool margin ratio in per-mille. + pub margin_ratio_per_thousand: PerThousand, + /// The pool cost per block. + pub cost_per_block: Amount, + /// Bech32 VRF public key of the pool. + pub vrf_public_key: String, + /// The balance delegated to the pool. + pub delegations_balance: Amount, +} + +/// A delegation as reported by the indexer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Delegation { + /// Bech32 delegation id. + pub delegation_id: String, + /// Bech32 pool id. + pub pool_id: String, + /// The next expected account nonce. + pub next_nonce: Uint64, + /// The destination that receives delegation withdrawals. + pub spend_destination: String, + /// The delegated balance. + pub balance: Amount, + /// Height of the block that created the delegation. + pub creation_block_height: Uint64, +} + +/// A delegation of a pool as reported by the indexer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PoolDelegation { + /// Bech32 delegation id. + pub delegation_id: String, + /// The next expected account nonce. + pub next_nonce: Uint64, + /// The destination that receives delegation withdrawals. + pub spend_destination: String, + /// The delegated balance. + pub balance: Amount, + /// Height of the block that created the delegation. + pub creation_block_height: Uint64, +} + +/// A fungible token or NFT collection as reported by the indexer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TokenInfo { + /// Bech32 authority address of the token. + pub authority: String, + /// Whether the token supply is locked. + pub is_locked: bool, + /// The circulating supply. + pub circulating_supply: Amount, + /// The token ticker. + pub token_ticker: String, + /// The metadata URI. + pub metadata_uri: String, + /// Number of decimal places. + pub number_of_decimals: u8, + /// The total supply variant. + pub total_supply: serde_json::Value, + /// Whether the token is frozen. + pub frozen: bool, + /// Whether a frozen token can be unfrozen; present when frozen. + pub is_token_unfreezable: Option, + /// Whether the token can be frozen; present when not frozen. + pub is_token_freezable: Option, + /// The next expected account nonce. + pub next_nonce: Uint64, +} + +/// A token transfer tracked by the indexer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenTx { + /// Global index of the transaction in the token history. + pub tx_global_index: u64, + /// Hex-encoded transaction id. + pub tx_id: String, +} + +/// NFT metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NftMetadata { + /// The creator's public key, if provided. + pub creator: Option, + /// NFT name. + pub name: String, + /// NFT description. + pub description: String, + /// NFT ticker. + pub ticker: String, + /// Icon URI, if provided. + pub icon_uri: Option, + /// Additional metadata URI, if provided. + pub additional_metadata_uri: Option, + /// Media URI, if provided. + pub media_uri: Option, + /// Hex-encoded hash of the media. + pub media_hash: String, +} + +/// An NFT as reported by the indexer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NftInfo { + /// Bech32 owner address. + pub owner: String, + /// Bech32 token id. + pub token_id: String, + /// The NFT metadata. + pub metadata: NftMetadata, +} + +/// A DEX order as reported by the indexer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Order { + /// Bech32 order id. + pub order_id: String, + /// The destination that may conclude the order. + pub conclude_destination: String, + /// The currency initially asked. + pub give_currency: serde_json::Value, + /// The amount initially given. + pub initially_given: Amount, + /// Remaining give balance. + pub give_balance: Amount, + /// The currency initially given. + pub ask_currency: serde_json::Value, + /// The amount initially asked. + pub initially_asked: Amount, + /// Remaining ask balance. + pub ask_balance: Amount, + /// The order account nonce. + pub nonce: Uint64, +} + +/// Global coin statistics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoinStats { + /// The circulating supply. + pub circulating_supply: Amount, + /// The preminted supply. + pub preminted: Amount, + /// The total burned amount. + pub burned: Amount, + /// The total staked amount. + pub staked: Amount, +} + +/// Pagination parameters shared by list endpoints. +/// +/// The indexer defaults to offset 0 and 10 items per page. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PageOpts { + /// Number of items to skip. + pub offset: u32, + /// Number of items to return. + pub items: u32, +} + +/// Sort order for pool listings. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PoolSort { + /// Sort by creation height (indexer default). + #[default] + ByHeight, + /// Sort by pledge amount. + ByPledge, +} + +impl PoolSort { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::ByHeight => "by_height", + Self::ByPledge => "by_pledge", + } + } +} + +/// Parameters for listing stake pools. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PoolListOpts { + /// Number of pools to skip. + pub offset: u32, + /// Number of pools to return. + pub items: u32, + /// Sort order; defaults to [`PoolSort::ByHeight`] on the server. + pub sort: Option, +} diff --git a/src/jsonrpc.rs b/src/jsonrpc.rs index c243e87..626448f 100644 --- a/src/jsonrpc.rs +++ b/src/jsonrpc.rs @@ -15,11 +15,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use thiserror::Error; -pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); - -/// Upper bound for a single JSON-RPC response body, guarding against memory -/// exhaustion from a misconfigured or hostile endpoint. -const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024; +use crate::limits::{DEFAULT_TIMEOUT, MAX_RESPONSE_BYTES}; /// HTTP basic auth credentials with a redacted [`Debug`] implementation so /// that logging a client never leaks the password. diff --git a/src/lib.rs b/src/lib.rs index f38dab4..f965dad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,9 @@ mod jsonrpc; #[cfg(any(feature = "node", feature = "wallet", feature = "indexer"))] mod number; +#[cfg(any(feature = "node", feature = "wallet", feature = "indexer"))] +mod limits; + #[cfg(any(feature = "node", feature = "wallet", feature = "indexer"))] mod client; diff --git a/src/limits.rs b/src/limits.rs new file mode 100644 index 0000000..bd69d57 --- /dev/null +++ b/src/limits.rs @@ -0,0 +1,16 @@ +// 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. + +//! Shared limits and defaults for daemon clients. + +use std::time::Duration; + +/// Upper bound for a single daemon response body, guarding against memory +/// exhaustion from a misconfigured or hostile endpoint. +pub(crate) const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024; + +/// Default request timeout for the daemon HTTP clients. +pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/src/node/mod.rs b/src/node/mod.rs index 5cd3dec..330df7b 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -22,7 +22,8 @@ pub use types::{ OrderInfo, Outpoint, OutpointSourceId, PeerInfo, Timestamp, TokenInfo, TrustPolicy, }; -use crate::jsonrpc::{self, DEFAULT_TIMEOUT}; +use crate::jsonrpc; +use crate::limits::DEFAULT_TIMEOUT; static DEFAULT_HTTP_CLIENT: OnceLock = OnceLock::new(); diff --git a/src/number.rs b/src/number.rs index 760b943..1a87004 100644 --- a/src/number.rs +++ b/src/number.rs @@ -69,6 +69,81 @@ pub(crate) mod atoms_object { } } +/// `(de)serializes` a `u64` from a JSON integer or a decimal string; +/// serializes a plain integer. +pub(crate) mod u64_lenient { + use std::fmt; + + use serde::de::{self, Visitor}; + use serde::{Deserializer, Serializer}; + + pub(crate) fn serialize(value: &u64, serializer: S) -> Result { + serializer.serialize_u64(*value) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + struct U64Visitor; + + impl Visitor<'_> for U64Visitor { + type Value = u64; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an integer or a decimal string") + } + + fn visit_u64(self, value: u64) -> Result { + Ok(value) + } + + fn visit_str(self, value: &str) -> Result { + value.parse::().map_err(de::Error::custom) + } + } + + deserializer.deserialize_any(U64Visitor) + } +} + +/// `(de)serializes` an `f64` ratio from an integer, a float, or a decimal +/// string with an optional trailing `%`; serializes a plain float. +pub(crate) mod f64_percent { + use std::fmt; + + use serde::de::{self, Visitor}; + use serde::{Deserializer, Serializer}; + + pub(crate) fn serialize(value: &f64, serializer: S) -> Result { + serializer.serialize_f64(*value) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + struct PercentVisitor; + + impl Visitor<'_> for PercentVisitor { + type Value = f64; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a number or a decimal string with an optional trailing '%'") + } + + fn visit_f64(self, value: f64) -> Result { + Ok(value) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(value as f64) + } + + fn visit_str(self, value: &str) -> Result { + let trimmed = value.strip_suffix('%').unwrap_or(value); + trimmed.parse::().map_err(de::Error::custom) + } + } + + deserializer.deserialize_any(PercentVisitor) + } +} + /// Deserializes an `Option` that may arrive as `null`, an integer, or a /// decimal string; serializes integers and `null`. pub(crate) mod option_u64_lenient { diff --git a/tests/indexer.rs b/tests/indexer.rs new file mode 100644 index 0000000..cf9386a --- /dev/null +++ b/tests/indexer.rs @@ -0,0 +1,439 @@ +// 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. + +//! Wire-format tests for the indexer client, mirroring the go-sdk +//! `indexer/client_test.go` suite. + +use httpmock::Then; +use httpmock::prelude::*; +use serde_json::json; + +use mintlayer_sdk::indexer::{ + Amount, Client, Error, PageOpts, PerThousand, PoolListOpts, PoolSort, Uint64, Utxo, + UtxoOutpoint, +}; + +fn respond(then: Then, body: serde_json::Value) { + then.status(200) + .header("content-type", "application/json") + .body(body.to_string()); +} + +#[tokio::test] +async fn tip_and_height_paths() { + let server = MockServer::start(); + let tip_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/chain/tip"); + respond(then, json!({"block_height": 42000, "block_id": "0000ab"})); + }); + let height_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/chain/1337"); + then.status(200) + .header("content-type", "application/json") + .body(json!("0000cd").to_string()); + }); + + let client = Client::new(server.url("")); + let tip = client.tip().await.unwrap(); + assert_eq!(tip.block_height, 42000); + assert_eq!(tip.block_id, "0000ab"); + let block_id = client.block_id_at_height(1337).await.unwrap(); + assert_eq!(block_id, "0000cd"); + + tip_mock.assert(); + height_mock.assert(); +} + +#[tokio::test] +async fn block_endpoints() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/block/0000aa"); + respond( + then, + json!({ + "height": 42000, + "header": { + "previous_block_id": "0000aa00", + "timestamp": {"timestamp": 1700000000}, + "merkle_root": "aabb", + "witness_merkle_root": "ccdd", + "consensus_data": {} + }, + "body": { + "reward": [], + "transactions": [] + } + }), + ); + }); + + let client = Client::new(server.url("")); + let block = client.block("0000aa").await.unwrap(); + + assert_eq!(block.height, 42000); + assert_eq!(block.header.previous_block_id, "0000aa00"); + assert_eq!(block.header.timestamp.timestamp, 1_700_000_000); + assert_eq!(block.header.merkle_root, "aabb"); + assert_eq!(block.header.witness_merkle_root, "ccdd"); + assert_eq!(block.header.consensus_data, json!({})); + assert!(block.body.reward.is_empty()); + assert!(block.body.transactions.is_empty()); + mock.assert(); +} + +#[tokio::test] +async fn pagination_query_params() { + let server = MockServer::start(); + let paged = server.mock(|when, then| { + when.method(GET) + .path("/api/v2/transaction") + .query_param("offset", "10") + .query_param("items", "20"); + respond(then, json!([])); + }); + let unpaginated = server.mock(|when, then| { + when.method(GET).path("/api/v2/transaction").matches(|request| { + let params = request.query_params.as_deref().unwrap_or(&[]); + !params.iter().any(|(name, _)| *name == "offset" || *name == "items") + }); + respond(then, json!([])); + }); + + let client = Client::new(server.url("")); + let page = client + .list_transactions(PageOpts { + offset: 10, + items: 20, + }) + .await + .unwrap(); + assert!(page.is_empty()); + let default_page = client.list_transactions(PageOpts::default()).await.unwrap(); + assert!(default_page.is_empty()); + + paged.assert(); + unpaginated.assert(); +} + +#[tokio::test] +async fn pool_list_sort_param() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api/v2/pool") + .query_param("sort", "by_pledge") + .matches(|request| { + let params = request.query_params.as_deref().unwrap_or(&[]); + !params.iter().any(|(name, _)| *name == "offset" || *name == "items") + }); + respond( + then, + json!([{ + "pool_id": "mpool1x", + "decommission_destination": "mtct1x", + "staker_balance": {"atoms": "1000", "decimal": "0.00000001"}, + "margin_ratio_per_thousand": "3.5%", + "cost_per_block": {"atoms": "1000", "decimal": "0.00000001"}, + "vrf_public_key": "vrfpub1x", + "delegations_balance": {"atoms": "2000", "decimal": "0.00000002"} + }]), + ); + }); + + let client = Client::new(server.url("")); + let pools = client + .list_pools(PoolListOpts { + offset: 0, + items: 0, + sort: Some(PoolSort::ByPledge), + }) + .await + .unwrap(); + + assert_eq!(pools.len(), 1); + let pool = &pools[0]; + assert_eq!(pool.pool_id, "mpool1x"); + assert_eq!(pool.decommission_destination, "mtct1x"); + assert_eq!( + pool.staker_balance, + Amount { + atoms: 1000, + decimal: "0.00000001".to_owned() + } + ); + assert_eq!( + pool.cost_per_block, + Amount { + atoms: 1000, + decimal: "0.00000001".to_owned() + } + ); + assert_eq!(pool.vrf_public_key, "vrfpub1x"); + assert_eq!( + pool.delegations_balance, + Amount { + atoms: 2000, + decimal: "0.00000002".to_owned() + } + ); + assert!((pool.margin_ratio_per_thousand.0 - 3.5).abs() < f64::EPSILON); + assert_eq!(pool.margin_ratio_per_thousand, PerThousand(3.5)); + mock.assert(); +} + +#[tokio::test] +async fn pool_block_stats_query() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api/v2/pool/mpool1x/block-stats") + .query_param("from", "1700000000") + .query_param("to", "1700003600"); + respond(then, json!({"block_count": 42})); + }); + + let client = Client::new(server.url("")); + let count = client.pool_block_stats("mpool1x", 1_700_000_000, 1_700_003_600).await.unwrap(); + + assert_eq!(count, 42); + mock.assert(); +} + +#[tokio::test] +async fn submit_transaction_posts_raw_hex() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api/v2/transaction") + .header("content-type", "text/plain") + .body("deadbeef"); + respond(then, json!({"tx_id": "aabb"})); + }); + + let client = Client::new(server.url("")); + let tx_id = client.submit_transaction("deadbeef").await.unwrap(); + + assert_eq!(tx_id, "aabb"); + mock.assert(); +} + +#[tokio::test] +async fn http_error_is_surfaced() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/order/unknown"); + then.status(404).body("not found"); + }); + + let client = Client::new(server.url("")); + let error = client.order("unknown").await.unwrap_err(); + + match &error { + Error::Http { status_code, body } => { + assert_eq!(*status_code, 404); + assert_eq!(body, "not found"); + } + other => panic!("expected Error::Http, got {other:?}"), + } + assert_eq!(error.to_string(), "HTTP 404: not found"); + mock.assert(); +} + +#[tokio::test] +async fn lenient_number_fields() { + let server = MockServer::start(); + let string_delegation = server.mock(|when, then| { + when.method(GET).path("/api/v2/delegation/tdelg1x"); + respond( + then, + json!({ + "delegation_id": "tdelg1x", + "pool_id": "mpool1x", + "next_nonce": "7", + "spend_destination": "mtct1x", + "balance": {"atoms": "500", "decimal": "0.000000005"}, + "creation_block_height": "10000" + }), + ); + }); + let numeric_delegation = server.mock(|when, then| { + when.method(GET).path("/api/v2/delegation/tdelg2x"); + respond( + then, + json!({ + "delegation_id": "tdelg2x", + "pool_id": "mpool1x", + "next_nonce": 9, + "spend_destination": "mtct1x", + "balance": {"atoms": 500, "decimal": "0.000000005"}, + "creation_block_height": 10000 + }), + ); + }); + let order_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/order/mordr1x"); + respond( + then, + json!({ + "order_id": "mordr1x", + "conclude_destination": "mtct1x", + "give_currency": "Coin/ML", + "initially_given": {"atoms": "1", "decimal": "0.00000000001"}, + "give_balance": {"atoms": "1", "decimal": "0.00000000001"}, + "ask_currency": "Coin/ML", + "initially_asked": {"atoms": "1", "decimal": "0.00000000001"}, + "ask_balance": {"atoms": "1", "decimal": "0.00000000001"}, + "nonce": "5" + }), + ); + }); + + let client = Client::new(server.url("")); + + let delegation = client.delegation("tdelg1x").await.unwrap(); + assert_eq!(delegation.next_nonce, Uint64(7)); + assert_eq!(delegation.creation_block_height, Uint64(10000)); + assert_eq!( + delegation.balance, + Amount { + atoms: 500, + decimal: "0.000000005".to_owned() + } + ); + + let numeric = client.delegation("tdelg2x").await.unwrap(); + assert_eq!(numeric.next_nonce, Uint64(9)); + assert_eq!(numeric.creation_block_height, Uint64(10000)); + + let order = client.order("mordr1x").await.unwrap(); + assert_eq!(order.nonce, Uint64(5)); + + string_delegation.assert(); + numeric_delegation.assert(); + order_mock.assert(); +} + +#[tokio::test] +async fn utxo_wire_key_is_utxo() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/address/mxtc1q/spendable-utxos"); + respond( + then, + json!([{ + "outpoint": {"source_id": "aabb", "index": 0}, + "utxo": {"type": "Transfer"} + }]), + ); + }); + + let client = Client::new(server.url("")); + let utxos = client.spendable_utxos("mxtc1q").await.unwrap(); + + assert_eq!(utxos.len(), 1); + assert_eq!( + utxos[0], + Utxo { + outpoint: UtxoOutpoint { + source_id: "aabb".to_owned(), + index: 0 + }, + output: json!({"type": "Transfer"}) + } + ); + mock.assert(); +} + +#[tokio::test] +async fn unconfirmed_transaction_empty_string_fields() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/transaction/aabb"); + respond( + then, + json!({ + "id": "aabb", + "inputs": [], + "outputs": [], + "block_id": "", + "timestamp": "", + "confirmations": "" + }), + ); + }); + + let client = Client::new(server.url("")); + let tx = client.transaction("aabb").await.unwrap(); + + assert_eq!(tx.id, "aabb"); + assert_eq!(tx.block_id, ""); + assert_eq!(tx.timestamp, ""); + assert_eq!(tx.confirmations, ""); + mock.assert(); +} + +#[tokio::test] +async fn fee_rate_param_handling() { + let server = MockServer::start(); + let default_mock = server.mock(|when, then| { + when.method(GET) + .path("/api/v2/feerate") + .matches(|request| request.query_params.as_deref().unwrap_or(&[]).is_empty()); + then.status(200) + .header("content-type", "application/json") + .body(json!("12.5").to_string()); + }); + let top_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/feerate").query_param("in_top_x_mb", "5"); + then.status(200) + .header("content-type", "application/json") + .body(json!("500").to_string()); + }); + + let client = Client::new(server.url("")); + let default_rate = client.fee_rate(0).await.unwrap(); + assert_eq!(default_rate, "12.5"); + let top_rate = client.fee_rate(5).await.unwrap(); + assert_eq!(top_rate, "500"); + + default_mock.assert(); + top_mock.assert(); +} + +#[tokio::test] +async fn base_url_trailing_slash_normalized() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/chain/tip"); + respond(then, json!({"block_height": 1, "block_id": "0000ab"})); + }); + + let client = Client::new(server.url("/") + "/"); + let tip = client.tip().await.unwrap(); + + assert_eq!(tip.block_height, 1); + assert_eq!(tip.block_id, "0000ab"); + mock.assert(); +} + +#[tokio::test] +async fn per_thousand_percent_forms() { + let percent: PerThousand = serde_json::from_str("\"3.5%\"").unwrap(); + assert!((percent.0 - 3.5).abs() < f64::EPSILON); + let whole_percent: PerThousand = serde_json::from_str("\"10%\"").unwrap(); + assert!((whole_percent.0 - 10.0).abs() < f64::EPSILON); + let plain_string: PerThousand = serde_json::from_str("\"10\"").unwrap(); + assert!((plain_string.0 - 10.0).abs() < f64::EPSILON); + let integer: PerThousand = serde_json::from_str("35").unwrap(); + assert!((integer.0 - 35.0).abs() < f64::EPSILON); + + let string_nonce: Uint64 = serde_json::from_str("\"7\"").unwrap(); + assert_eq!(string_nonce, Uint64(7)); + let integer_nonce: Uint64 = serde_json::from_str("9").unwrap(); + assert_eq!(integer_nonce, Uint64(9)); + assert_eq!(serde_json::to_string(&Uint64(7)).unwrap(), "7"); +} From 209c29111d9c30c2387c3fea21582a029bbd91d9 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 16 Sep 2026 23:54:54 +0400 Subject: [PATCH 06/29] indexer: cap and sanitize error bodies, validate path segments (security review fixes) --- src/indexer/address.rs | 5 ++++ src/indexer/block.rs | 4 ++++ src/indexer/delegation.rs | 1 + src/indexer/mod.rs | 22 +++++++++++++++-- src/indexer/order.rs | 3 +++ src/indexer/pool.rs | 3 +++ src/indexer/statistics.rs | 1 + src/indexer/token.rs | 4 ++++ src/indexer/transaction.rs | 3 +++ src/limits.rs | 3 +++ tests/indexer.rs | 49 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/indexer/address.rs b/src/indexer/address.rs index f977fb2..a53f699 100644 --- a/src/indexer/address.rs +++ b/src/indexer/address.rs @@ -13,30 +13,35 @@ impl Client { /// Returns aggregated information about an address /// (`GET /address/{address}`). pub async fn address_info(&self, address: &str) -> Result { + let address = super::validate_segment(address)?; self.get(&format!("/address/{address}"), &[]).await } /// Returns the spendable UTXOs of an address /// (`GET /address/{address}/spendable-utxos`). pub async fn spendable_utxos(&self, address: &str) -> Result, Error> { + let address = super::validate_segment(address)?; self.get(&format!("/address/{address}/spendable-utxos"), &[]).await } /// Returns all UTXOs of an address, including locked ones /// (`GET /address/{address}/all-utxos`). pub async fn all_utxos(&self, address: &str) -> Result, Error> { + let address = super::validate_segment(address)?; self.get(&format!("/address/{address}/all-utxos"), &[]).await } /// Returns the delegations owned by an address /// (`GET /address/{address}/delegations`). pub async fn delegations(&self, address: &str) -> Result, Error> { + let address = super::validate_segment(address)?; self.get(&format!("/address/{address}/delegations"), &[]).await } /// Returns the token ids for which an address holds authority /// (`GET /address/{address}/token-authority`). pub async fn token_authority(&self, address: &str) -> Result, Error> { + let address = super::validate_segment(address)?; self.get(&format!("/address/{address}/token-authority"), &[]).await } } diff --git a/src/indexer/block.rs b/src/indexer/block.rs index 6ee86dc..d6e4379 100644 --- a/src/indexer/block.rs +++ b/src/indexer/block.rs @@ -12,22 +12,26 @@ use crate::indexer::Client; impl Client { /// Returns a block with header and body (`GET /block/{id}`). pub async fn block(&self, id: &str) -> Result { + let id = super::validate_segment(id)?; self.get(&format!("/block/{id}"), &[]).await } /// Returns only the header of a block (`GET /block/{id}/header`). pub async fn block_header(&self, id: &str) -> Result { + let id = super::validate_segment(id)?; self.get(&format!("/block/{id}/header"), &[]).await } /// Returns the reward outputs of a block (`GET /block/{id}/reward`). pub async fn block_reward(&self, id: &str) -> Result, Error> { + let id = super::validate_segment(id)?; self.get(&format!("/block/{id}/reward"), &[]).await } /// Returns the ids of the transactions in a block /// (`GET /block/{id}/transaction-ids`). pub async fn block_transaction_ids(&self, id: &str) -> Result, Error> { + let id = super::validate_segment(id)?; self.get(&format!("/block/{id}/transaction-ids"), &[]).await } } diff --git a/src/indexer/delegation.rs b/src/indexer/delegation.rs index 3886dcf..98267df 100644 --- a/src/indexer/delegation.rs +++ b/src/indexer/delegation.rs @@ -12,6 +12,7 @@ use crate::indexer::Client; impl Client { /// Returns a delegation by its bech32 id (`GET /delegation/{id}`). pub async fn delegation(&self, id: &str) -> Result { + let id = super::validate_segment(id)?; self.get(&format!("/delegation/{id}"), &[]).await } } diff --git a/src/indexer/mod.rs b/src/indexer/mod.rs index 9eff36f..19bb3d1 100644 --- a/src/indexer/mod.rs +++ b/src/indexer/mod.rs @@ -31,7 +31,7 @@ pub use types::{ Transaction, Uint64, Utxo, UtxoOutpoint, }; -use crate::limits::{DEFAULT_TIMEOUT, MAX_RESPONSE_BYTES}; +use crate::limits::{DEFAULT_TIMEOUT, MAX_ERROR_BODY_CHARS, MAX_RESPONSE_BYTES}; /// Client for the Mintlayer indexer REST API (api-web-server). #[derive(Debug, Clone)] @@ -102,7 +102,12 @@ impl Client { async fn decode(&self, response: reqwest::Response) -> Result { let status = response.status(); if status.is_client_error() || status.is_server_error() { - let body = response.text().await.unwrap_or_default(); + let bytes = Self::read_capped(response).await.unwrap_or_default(); + let body: String = String::from_utf8_lossy(&bytes) + .chars() + .filter(|c| !c.is_control()) + .take(MAX_ERROR_BODY_CHARS) + .collect(); return Err(Error::Http { status_code: status.as_u16(), body: body.trim().to_owned(), @@ -146,6 +151,19 @@ pub(crate) fn page_query(offset: u32, items: u32) -> Vec<(&'static str, String)> query } +/// Validates that a caller-supplied path segment cannot alter the request +/// path (ids and addresses are bech32 or hex; tickers are alphanumeric). +pub(crate) fn validate_segment(segment: &str) -> Result<&str, Error> { + if !segment.is_empty() + && segment.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Ok(segment); + } + Err(Error::InvalidUrl { + message: format!("invalid path segment: {segment:?}"), + }) +} + /// Builder for [`Client`]. #[derive(Debug, Clone)] pub struct ClientBuilder { diff --git a/src/indexer/order.rs b/src/indexer/order.rs index 0a98c79..d475f2c 100644 --- a/src/indexer/order.rs +++ b/src/indexer/order.rs @@ -21,6 +21,7 @@ impl Client { /// Returns an order by its bech32 id (`GET /order/{id}`). pub async fn order(&self, id: &str) -> Result { + let id = super::validate_segment(id)?; self.get(&format!("/order/{id}"), &[]).await } @@ -32,6 +33,8 @@ impl Client { give_currency: &str, opts: PageOpts, ) -> Result, Error> { + let ask_currency = super::validate_segment(ask_currency)?; + let give_currency = super::validate_segment(give_currency)?; self.get( &format!("/order/pair/{ask_currency}_{give_currency}"), &crate::indexer::page_query(opts.offset, opts.items), diff --git a/src/indexer/pool.rs b/src/indexer/pool.rs index 9572c94..97b427f 100644 --- a/src/indexer/pool.rs +++ b/src/indexer/pool.rs @@ -23,6 +23,7 @@ impl Client { /// Returns a stake pool by its bech32 id (`GET /pool/{id}`). pub async fn pool(&self, id: &str) -> Result { + let id = super::validate_segment(id)?; self.get(&format!("/pool/{id}"), &[]).await } @@ -34,6 +35,7 @@ impl Client { struct Response { block_count: u64, } + let id = super::validate_segment(id)?; let response: Response = self .get( &format!("/pool/{id}/block-stats"), @@ -46,6 +48,7 @@ impl Client { /// Returns the delegations of a stake pool /// (`GET /pool/{id}/delegations`). pub async fn pool_delegations(&self, id: &str) -> Result, Error> { + let id = super::validate_segment(id)?; self.get(&format!("/pool/{id}/delegations"), &[]).await } } diff --git a/src/indexer/statistics.rs b/src/indexer/statistics.rs index b0982d8..f38bfd7 100644 --- a/src/indexer/statistics.rs +++ b/src/indexer/statistics.rs @@ -17,6 +17,7 @@ impl Client { /// Returns statistics for a token (`GET /statistics/token/{token_id}`). pub async fn token_statistics(&self, token_id: &str) -> Result { + let token_id = super::validate_segment(token_id)?; self.get(&format!("/statistics/token/{token_id}"), &[]).await } diff --git a/src/indexer/token.rs b/src/indexer/token.rs index 5309524..ca0cbed 100644 --- a/src/indexer/token.rs +++ b/src/indexer/token.rs @@ -21,6 +21,7 @@ impl Client { /// Returns a token by its bech32 id (`GET /token/{id}`). pub async fn token(&self, id: &str) -> Result { + let id = super::validate_segment(id)?; self.get(&format!("/token/{id}"), &[]).await } @@ -30,6 +31,7 @@ impl Client { id: &str, opts: PageOpts, ) -> Result, Error> { + let id = super::validate_segment(id)?; self.get( &format!("/token/{id}/transactions"), &crate::indexer::page_query(opts.offset, opts.items), @@ -43,6 +45,7 @@ impl Client { ticker: &str, opts: PageOpts, ) -> Result, Error> { + let ticker = super::validate_segment(ticker)?; self.get( &format!("/token/ticker/{ticker}"), &crate::indexer::page_query(opts.offset, opts.items), @@ -52,6 +55,7 @@ impl Client { /// Returns an NFT by its bech32 token id (`GET /nft/{id}`). pub async fn nft(&self, id: &str) -> Result { + let id = super::validate_segment(id)?; self.get(&format!("/nft/{id}"), &[]).await } } diff --git a/src/indexer/transaction.rs b/src/indexer/transaction.rs index 1edd392..9e24072 100644 --- a/src/indexer/transaction.rs +++ b/src/indexer/transaction.rs @@ -23,12 +23,14 @@ impl Client { /// Returns a transaction by id (`GET /transaction/{id}`). pub async fn transaction(&self, id: &str) -> Result { + let id = super::validate_segment(id)?; self.get(&format!("/transaction/{id}"), &[]).await } /// Returns the merkle path of a confirmed transaction /// (`GET /transaction/{id}/merkle-path`). pub async fn transaction_merkle_path(&self, id: &str) -> Result { + let id = super::validate_segment(id)?; self.get(&format!("/transaction/{id}/merkle-path"), &[]).await } @@ -39,6 +41,7 @@ impl Client { tx_id: &str, index: u32, ) -> Result { + let tx_id = super::validate_segment(tx_id)?; self.get(&format!("/transaction/{tx_id}/output/{index}"), &[]).await } diff --git a/src/limits.rs b/src/limits.rs index bd69d57..8e877ef 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -12,5 +12,8 @@ use std::time::Duration; /// exhaustion from a misconfigured or hostile endpoint. pub(crate) const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024; +/// Upper bound for the characters kept from a daemon error body. +pub(crate) const MAX_ERROR_BODY_CHARS: usize = 8 * 1024; + /// Default request timeout for the daemon HTTP clients. pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/tests/indexer.rs b/tests/indexer.rs index cf9386a..1c231f2 100644 --- a/tests/indexer.rs +++ b/tests/indexer.rs @@ -437,3 +437,52 @@ async fn per_thousand_percent_forms() { assert_eq!(integer_nonce, Uint64(9)); assert_eq!(serde_json::to_string(&Uint64(7)).unwrap(), "7"); } + +#[tokio::test] +async fn path_segments_are_validated() { + let server = MockServer::start(); + let client = Client::new(server.url("")); + for input in ["../../admin", "a/b", "a?x=1", "a b", "%2e%2e", ""] { + let err = client.block(input).await.unwrap_err(); + assert!( + matches!(err, Error::InvalidUrl { .. }), + "got {err:?} for {input:?}" + ); + } +} + +#[tokio::test] +async fn error_body_is_sanitized() { + let server = MockServer::start(); + let sanitized_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/order/unknown"); + then.status(404) + .header("content-type", "text/plain") + .body("not\nfound\rwith\x07bell"); + }); + let truncated_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/order/truncated"); + then.status(404).header("content-type", "text/plain").body("x".repeat(20_000)); + }); + + let client = Client::new(server.url("")); + match client.order("unknown").await { + Err(Error::Http { status_code, body }) => { + assert_eq!(status_code, 404); + assert_eq!(body, "notfoundwithbell"); + } + other => panic!("expected Error::Http, got {other:?}"), + } + + match client.order("truncated").await { + Err(Error::Http { status_code, body }) => { + assert_eq!(status_code, 404); + assert_eq!(body.chars().count(), 8192); + assert!(body.chars().all(|c| c == 'x')); + } + other => panic!("expected Error::Http, got {other:?}"), + } + + sanitized_mock.assert(); + truncated_mock.assert(); +} From c69f1b4054c539e8d4302dd6cf30e0b776751779 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 16 Sep 2026 23:54:54 +0400 Subject: [PATCH 07/29] wallet: JSON-RPC client with management, transaction, staking, token and order surface --- src/jsonrpc.rs | 11 +- src/node/chainstate.rs | 178 ++++---- src/node/mempool.rs | 65 ++- src/node/mod.rs | 12 +- src/node/p2p.rs | 74 ++-- src/number.rs | 52 +++ src/wallet/error.rs | 12 + src/wallet/management.rs | 196 +++++++++ src/wallet/mod.rs | 112 ++++- src/wallet/orders.rs | 48 +++ src/wallet/staking.rs | 109 +++++ src/wallet/tokens.rs | 58 +++ src/wallet/transaction.rs | 198 +++++++++ src/wallet/types.rs | 853 ++++++++++++++++++++++++++++++++++++++ tests/wallet.rs | 498 ++++++++++++++++++++++ 15 files changed, 2289 insertions(+), 187 deletions(-) create mode 100644 src/wallet/error.rs create mode 100644 src/wallet/management.rs create mode 100644 src/wallet/orders.rs create mode 100644 src/wallet/staking.rs create mode 100644 src/wallet/tokens.rs create mode 100644 src/wallet/transaction.rs create mode 100644 src/wallet/types.rs create mode 100644 tests/wallet.rs diff --git a/src/jsonrpc.rs b/src/jsonrpc.rs index 626448f..aadbd69 100644 --- a/src/jsonrpc.rs +++ b/src/jsonrpc.rs @@ -159,16 +159,7 @@ impl Transport { } } - pub(crate) async fn call(&self, method: &str, params: &P) -> Result - where - P: Serialize + ?Sized, - R: DeserializeOwned, - E: From, - { - self.call_inner(method, params).await.map_err(E::from) - } - - async fn call_inner(&self, method: &str, params: &P) -> Result + pub(crate) async fn call(&self, method: &str, params: &P) -> Result where P: Serialize + ?Sized, R: DeserializeOwned, diff --git a/src/node/chainstate.rs b/src/node/chainstate.rs index 20ef159..48a0caf 100644 --- a/src/node/chainstate.rs +++ b/src/node/chainstate.rs @@ -14,104 +14,93 @@ use crate::node::Client; impl Client { /// Returns a summary of the current chain state (`chainstate_info`). pub async fn chainstate_info(&self) -> Result { - self.transport.call("chainstate_info", &serde_json::json!({})).await + self.call("chainstate_info", &serde_json::json!({})).await } /// Returns the hex-encoded id of the best block /// (`chainstate_best_block_id`). pub async fn best_block_id(&self) -> Result { - self.transport.call("chainstate_best_block_id", &serde_json::json!({})).await + self.call("chainstate_best_block_id", &serde_json::json!({})).await } /// Returns the height of the best block (`chainstate_best_block_height`). pub async fn best_block_height(&self) -> Result { - self.transport - .call("chainstate_best_block_height", &serde_json::json!({})) - .await + self.call("chainstate_best_block_height", &serde_json::json!({})).await } /// Returns the block id at a given height, or `None` if the height is not /// part of the main chain (`chainstate_block_id_at_height`). pub async fn block_id_at_height(&self, height: u64) -> Result, Error> { - self.transport - .call( - "chainstate_block_id_at_height", - &serde_json::json!({ "height": height }), - ) - .await + self.call( + "chainstate_block_id_at_height", + &serde_json::json!({ "height": height }), + ) + .await } /// Returns the height of a block in the main chain, or `None` if the /// block is not part of it (`chainstate_block_height_in_main_chain`). pub async fn block_height_in_main_chain(&self, block_id: &str) -> Result, Error> { - self.transport - .call( - "chainstate_block_height_in_main_chain", - &serde_json::json!({ "block_id": block_id }), - ) - .await + self.call( + "chainstate_block_height_in_main_chain", + &serde_json::json!({ "block_id": block_id }), + ) + .await } /// Returns the hex-encoded serialization of a block, or `None` if it is /// unknown (`chainstate_get_block`). The genesis block cannot be /// retrieved. pub async fn block(&self, id: &str) -> Result, Error> { - self.transport - .call("chainstate_get_block", &serde_json::json!({ "id": id })) - .await + self.call("chainstate_get_block", &serde_json::json!({ "id": id })).await } /// Returns the JSON representation of a block (`chainstate_get_block_json`). pub async fn block_json(&self, id: &str) -> Result { - self.transport - .call( - "chainstate_get_block_json", - &serde_json::json!({ "id": id }), - ) - .await + self.call( + "chainstate_get_block_json", + &serde_json::json!({ "id": id }), + ) + .await } /// Returns up to `max_count` hex-encoded mainchain blocks starting at /// `from` (`chainstate_get_mainchain_blocks`). pub async fn mainchain_blocks(&self, from: u64, max_count: u32) -> Result, Error> { - self.transport - .call( - "chainstate_get_mainchain_blocks", - &serde_json::json!({ "from": from, "max_count": max_count }), - ) - .await + self.call( + "chainstate_get_mainchain_blocks", + &serde_json::json!({ "from": from, "max_count": max_count }), + ) + .await } /// Returns the output at an outpoint, or `None` if it is spent or unknown /// (`chainstate_get_utxo`). pub async fn utxo(&self, outpoint: &Outpoint) -> Result, Error> { - self.transport - .call( - "chainstate_get_utxo", - &serde_json::json!({ "outpoint": outpoint }), - ) - .await + self.call( + "chainstate_get_utxo", + &serde_json::json!({ "outpoint": outpoint }), + ) + .await } /// Returns the balance of a stake pool (`chainstate_stake_pool_balance`). pub async fn stake_pool_balance(&self, pool_address: &str) -> Result, Error> { - self.transport - .call( - "chainstate_stake_pool_balance", - &serde_json::json!({ "pool_address": pool_address }), - ) - .await + self.call( + "chainstate_stake_pool_balance", + &serde_json::json!({ "pool_address": pool_address }), + ) + .await } /// Returns the staker balance of a stake pool /// (`chainstate_staker_balance`). pub async fn staker_balance(&self, pool_address: &str) -> Result, Error> { - self.transport - .call( - "chainstate_staker_balance", - &serde_json::json!({ "pool_address": pool_address }), - ) - .await + self.call( + "chainstate_staker_balance", + &serde_json::json!({ "pool_address": pool_address }), + ) + .await } /// Returns the decommission destination of a stake pool @@ -120,12 +109,11 @@ impl Client { &self, pool_address: &str, ) -> Result, Error> { - self.transport - .call( - "chainstate_pool_decommission_destination", - &serde_json::json!({ "pool_address": pool_address }), - ) - .await + self.call( + "chainstate_pool_decommission_destination", + &serde_json::json!({ "pool_address": pool_address }), + ) + .await } /// Returns the share of a delegation in a pool @@ -135,46 +123,42 @@ impl Client { pool_address: &str, delegation_address: &str, ) -> Result, Error> { - self.transport - .call( - "chainstate_delegation_share", - &serde_json::json!({ - "pool_address": pool_address, - "delegation_address": delegation_address, - }), - ) - .await + self.call( + "chainstate_delegation_share", + &serde_json::json!({ + "pool_address": pool_address, + "delegation_address": delegation_address, + }), + ) + .await } /// Returns information about a token (`chainstate_token_info`). pub async fn token_info(&self, token_id: &str) -> Result, Error> { - self.transport - .call( - "chainstate_token_info", - &serde_json::json!({ "token_id": token_id }), - ) - .await + self.call( + "chainstate_token_info", + &serde_json::json!({ "token_id": token_id }), + ) + .await } /// Returns information about multiple tokens, preserving order /// (`chainstate_tokens_info`). pub async fn tokens_info(&self, token_ids: &[String]) -> Result, Error> { - self.transport - .call( - "chainstate_tokens_info", - &serde_json::json!({ "token_ids": token_ids }), - ) - .await + self.call( + "chainstate_tokens_info", + &serde_json::json!({ "token_ids": token_ids }), + ) + .await } /// Returns information about a DEX order (`chainstate_order_info`). pub async fn order_info(&self, order_id: &str) -> Result, Error> { - self.transport - .call( - "chainstate_order_info", - &serde_json::json!({ "order_id": order_id }), - ) - .await + self.call( + "chainstate_order_info", + &serde_json::json!({ "order_id": order_id }), + ) + .await } /// Returns all orders matching a pair of currencies; `None` filters @@ -184,25 +168,23 @@ impl Client { ask_currency: Option<&Currency>, give_currency: Option<&Currency>, ) -> Result, Error> { - self.transport - .call( - "chainstate_orders_info_by_currencies", - &serde_json::json!({ - "ask_currency": ask_currency, - "give_currency": give_currency, - }), - ) - .await + self.call( + "chainstate_orders_info_by_currencies", + &serde_json::json!({ + "ask_currency": ask_currency, + "give_currency": give_currency, + }), + ) + .await } /// Submits a hex-encoded block to the chainstate /// (`chainstate_submit_block`). pub async fn submit_block(&self, block_hex: &str) -> Result<(), Error> { - self.transport - .call( - "chainstate_submit_block", - &serde_json::json!({ "block_hex": block_hex }), - ) - .await + self.call( + "chainstate_submit_block", + &serde_json::json!({ "block_hex": block_hex }), + ) + .await } } diff --git a/src/node/mempool.rs b/src/node/mempool.rs index d4021c4..0b84325 100644 --- a/src/node/mempool.rs +++ b/src/node/mempool.rs @@ -13,34 +13,31 @@ impl Client { /// Returns whether the mempool contains a transaction with the given id /// (`mempool_contains_tx`). pub async fn contains_tx(&self, tx_id: &str) -> Result { - self.transport - .call( - "mempool_contains_tx", - &serde_json::json!({ "tx_id": tx_id }), - ) - .await + self.call( + "mempool_contains_tx", + &serde_json::json!({ "tx_id": tx_id }), + ) + .await } /// Returns whether the orphan pool contains a transaction with the given /// id (`mempool_contains_orphan_tx`). pub async fn contains_orphan_tx(&self, tx_id: &str) -> Result { - self.transport - .call( - "mempool_contains_orphan_tx", - &serde_json::json!({ "tx_id": tx_id }), - ) - .await + self.call( + "mempool_contains_orphan_tx", + &serde_json::json!({ "tx_id": tx_id }), + ) + .await } /// Returns a transaction from the mempool or orphan pool, or `None` if /// unknown (`mempool_get_transaction`). pub async fn transaction(&self, tx_id: &str) -> Result, Error> { - self.transport - .call( - "mempool_get_transaction", - &serde_json::json!({ "tx_id": tx_id }), - ) - .await + self.call( + "mempool_get_transaction", + &serde_json::json!({ "tx_id": tx_id }), + ) + .await } /// Submits a hex-encoded signed transaction to the local mempool without @@ -53,36 +50,34 @@ impl Client { tx_hex: &str, trust_policy: TrustPolicy, ) -> Result<(), Error> { - self.transport - .call( - "mempool_submit_transaction", - &serde_json::json!({ - "tx": tx_hex, - "options": { "trust_policy": trust_policy }, - }), - ) - .await + self.call( + "mempool_submit_transaction", + &serde_json::json!({ + "tx": tx_hex, + "options": { "trust_policy": trust_policy }, + }), + ) + .await } /// Returns the fee rate for transactions landing in the top `in_top_x_mb` /// megabytes of the mempool (`mempool_get_fee_rate`). pub async fn fee_rate(&self, in_top_x_mb: u32) -> Result, Error> { - self.transport - .call( - "mempool_get_fee_rate", - &serde_json::json!({ "in_top_x_mb": in_top_x_mb }), - ) - .await + self.call( + "mempool_get_fee_rate", + &serde_json::json!({ "in_top_x_mb": in_top_x_mb }), + ) + .await } /// Returns the fee-rate estimate curve (`mempool_get_fee_rate_points`). pub async fn fee_rate_points(&self) -> Result, Error> { - self.transport.call("mempool_get_fee_rate_points", &serde_json::json!({})).await + self.call("mempool_get_fee_rate_points", &serde_json::json!({})).await } /// Returns the mempool memory usage in bytes /// (`mempool_memory_usage`). pub async fn memory_usage(&self) -> Result { - self.transport.call("mempool_memory_usage", &serde_json::json!({})).await + self.call("mempool_memory_usage", &serde_json::json!({})).await } } diff --git a/src/node/mod.rs b/src/node/mod.rs index 330df7b..5096fd1 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -62,14 +62,22 @@ impl Client { } } + pub(crate) async fn call(&self, method: &str, params: &P) -> Result + where + P: serde::Serialize + ?Sized, + R: serde::de::DeserializeOwned, + { + self.transport.call(method, params).await.map_err(Error::from) + } + /// Returns the node software version, e.g. `"1.3.0"`. pub async fn node_version(&self) -> Result { - self.transport.call("node_version", &serde_json::json!({})).await + self.call("node_version", &serde_json::json!({})).await } /// Requests a graceful shutdown of the node daemon. pub async fn node_shutdown(&self) -> Result<(), Error> { - self.transport.call("node_shutdown", &serde_json::json!({})).await + self.call("node_shutdown", &serde_json::json!({})).await } } diff --git a/src/node/p2p.rs b/src/node/p2p.rs index 80f0e93..a6def74 100644 --- a/src/node/p2p.rs +++ b/src/node/p2p.rs @@ -14,76 +14,69 @@ use crate::node::Client; impl Client { /// Returns the number of connected peers (`p2p_get_peer_count`). pub async fn peer_count(&self) -> Result { - self.transport.call("p2p_get_peer_count", &serde_json::json!({})).await + self.call("p2p_get_peer_count", &serde_json::json!({})).await } /// Returns details about all connected peers /// (`p2p_get_connected_peers`). pub async fn connected_peers(&self) -> Result, Error> { - self.transport.call("p2p_get_connected_peers", &serde_json::json!({})).await + self.call("p2p_get_connected_peers", &serde_json::json!({})).await } /// Returns the addresses the node binds to (`p2p_get_bind_addresses`). pub async fn bind_addresses(&self) -> Result, Error> { - self.transport.call("p2p_get_bind_addresses", &serde_json::json!({})).await + self.call("p2p_get_bind_addresses", &serde_json::json!({})).await } /// Adds a reserved node (`p2p_add_reserved_node`). pub async fn add_reserved_node(&self, addr: &str) -> Result<(), Error> { - self.transport - .call( - "p2p_add_reserved_node", - &serde_json::json!({ "addr": addr }), - ) - .await + self.call( + "p2p_add_reserved_node", + &serde_json::json!({ "addr": addr }), + ) + .await } /// Removes a reserved node (`p2p_remove_reserved_node`). pub async fn remove_reserved_node(&self, addr: &str) -> Result<(), Error> { - self.transport - .call( - "p2p_remove_reserved_node", - &serde_json::json!({ "addr": addr }), - ) - .await + self.call( + "p2p_remove_reserved_node", + &serde_json::json!({ "addr": addr }), + ) + .await } /// Opens a one-time connection to a node (`p2p_connect`). pub async fn connect(&self, addr: &str) -> Result<(), Error> { - self.transport.call("p2p_connect", &serde_json::json!({ "addr": addr })).await + self.call("p2p_connect", &serde_json::json!({ "addr": addr })).await } /// Disconnects a peer (`p2p_disconnect`). pub async fn disconnect(&self, peer_id: u64) -> Result<(), Error> { - self.transport - .call("p2p_disconnect", &serde_json::json!({ "peer_id": peer_id })) - .await + self.call("p2p_disconnect", &serde_json::json!({ "peer_id": peer_id })).await } /// Returns all banned peers (`p2p_list_banned`). pub async fn list_banned(&self) -> Result, Error> { - self.transport.call("p2p_list_banned", &serde_json::json!({})).await + self.call("p2p_list_banned", &serde_json::json!({})).await } /// Bans an address for a duration (`p2p_ban`). pub async fn ban(&self, address: &str, duration: Duration) -> Result<(), Error> { let seconds = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX); - self.transport - .call( - "p2p_ban", - &serde_json::json!({ - "address": address, - "duration": [seconds, i64::from(duration.subsec_nanos())], - }), - ) - .await + self.call( + "p2p_ban", + &serde_json::json!({ + "address": address, + "duration": [seconds, i64::from(duration.subsec_nanos())], + }), + ) + .await } /// Lifts a ban on an address (`p2p_unban`). pub async fn unban(&self, address: &str) -> Result<(), Error> { - self.transport - .call("p2p_unban", &serde_json::json!({ "address": address })) - .await + self.call("p2p_unban", &serde_json::json!({ "address": address })).await } /// Submits a hex-encoded signed transaction to the mempool and broadcasts @@ -94,14 +87,13 @@ impl Client { tx_hex: &str, trust_policy: TrustPolicy, ) -> Result<(), Error> { - self.transport - .call( - "p2p_submit_transaction", - &serde_json::json!({ - "tx": tx_hex, - "options": { "trust_policy": trust_policy }, - }), - ) - .await + self.call( + "p2p_submit_transaction", + &serde_json::json!({ + "tx": tx_hex, + "options": { "trust_policy": trust_policy }, + }), + ) + .await } } diff --git a/src/number.rs b/src/number.rs index 1a87004..44873be 100644 --- a/src/number.rs +++ b/src/number.rs @@ -144,6 +144,58 @@ pub(crate) mod f64_percent { } } +/// `(de)serializes` an `Option` atom amount from a decimal string or +/// integer; `null` and missing fields become `None`. +pub(crate) mod option_atoms_lenient { + use std::fmt; + + use serde::de::{self, Visitor}; + use serde::{Deserializer, Serializer}; + + #[allow(dead_code)] + pub(crate) fn serialize( + value: &Option, + serializer: S, + ) -> Result { + match value { + Some(value) => serializer.serialize_str(&value.to_string()), + None => serializer.serialize_none(), + } + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + struct AtomsVisitor; + + impl Visitor<'_> for AtomsVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("null, a decimal atom string, or an integer") + } + + fn visit_unit(self) -> Result, E> { + Ok(None) + } + + fn visit_none(self) -> Result, E> { + Ok(None) + } + + fn visit_u64(self, value: u64) -> Result, E> { + Ok(Some(u128::from(value))) + } + + fn visit_str(self, value: &str) -> Result, E> { + value.parse::().map(Some).map_err(de::Error::custom) + } + } + + deserializer.deserialize_any(AtomsVisitor) + } +} + /// Deserializes an `Option` that may arrive as `null`, an integer, or a /// decimal string; serializes integers and `null`. pub(crate) mod option_u64_lenient { diff --git a/src/wallet/error.rs b/src/wallet/error.rs new file mode 100644 index 0000000..4be8db9 --- /dev/null +++ b/src/wallet/error.rs @@ -0,0 +1,12 @@ +// 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. + +//! Error type for the wallet daemon client. + +crate::jsonrpc::define_error!( + /// Errors returned by [`crate::wallet::Client`] calls. + Error +); diff --git a/src/wallet/management.rs b/src/wallet/management.rs new file mode 100644 index 0000000..26af717 --- /dev/null +++ b/src/wallet/management.rs @@ -0,0 +1,196 @@ +// 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. + +//! Wallet lifecycle RPC methods: creation, opening, accounts, addresses. + +use serde::{Deserialize, Serialize}; + +use super::{ + AccountInfo, AddressWithUsage, Balance, BestBlock, Client, CreateWalletParams, + CreateWalletResult, Error, RecoverWalletParams, RevealPublicKey, WalletInfo, +}; + +#[derive(Serialize)] +struct OpenWalletParams<'a> { + path: &'a str, + password: Option<&'a str>, + force_migrate_wallet_type: Option<()>, + hardware_wallet: Option<()>, +} + +#[derive(Serialize)] +struct RenameAccountParams { + account: u32, + name: Option, +} + +#[derive(Serialize)] +struct BalanceParams { + account: u32, + utxo_states: [&'static str; 1], + with_locked: Option<()>, +} + +#[derive(Serialize)] +struct ShowAddressesParams { + account: u32, + include_change_addresses: bool, +} + +#[derive(Deserialize)] +struct NewAddressResponse { + address: String, +} + +impl Client { + /// Creates a new wallet file (`wallet_create`). + /// + /// Returns the generated mnemonic when the daemon generated one. + pub async fn create_wallet( + &self, + params: CreateWalletParams, + ) -> Result { + self.call("wallet_create", ¶ms).await + } + + /// Recovers a wallet from a mnemonic (`wallet_recover`). + pub async fn recover_wallet(&self, params: RecoverWalletParams) -> Result<(), Error> { + self.call("wallet_recover", ¶ms).await + } + + /// Opens a wallet file; `password` should be `None` for unencrypted + /// wallets (`wallet_open`). + pub async fn open_wallet(&self, path: &str, password: Option<&str>) -> Result<(), Error> { + self.call( + "wallet_open", + &OpenWalletParams { + path, + password, + force_migrate_wallet_type: None, + hardware_wallet: None, + }, + ) + .await + } + + /// Closes the open wallet (`wallet_close`). + pub async fn close_wallet(&self) -> Result<(), Error> { + self.call("wallet_close", &serde_json::json!({})).await + } + + /// Returns general information about the open wallet (`wallet_info`). + pub async fn wallet_info(&self) -> Result { + self.call("wallet_info", &serde_json::json!({})).await + } + + /// Synchronizes the wallet with the chain up to the tip (`wallet_sync`). + pub async fn sync_wallet(&self) -> Result<(), Error> { + self.call("wallet_sync", &serde_json::json!({})).await + } + + /// Performs a full rescan of the chain from genesis (`wallet_rescan`). + pub async fn rescan_wallet(&self) -> Result<(), Error> { + self.call("wallet_rescan", &serde_json::json!({})).await + } + + /// Returns the best block known by the wallet (`wallet_best_block`). + pub async fn best_block(&self) -> Result { + self.call("wallet_best_block", &serde_json::json!({})).await + } + + /// Creates a new account with a name (`account_create`). + pub async fn create_account(&self, name: &str) -> Result { + self.call("account_create", &serde_json::json!({ "name": name })).await + } + + /// Renames an account; `None` clears the name (`account_rename`). + pub async fn rename_account(&self, account: u32, name: Option<&str>) -> Result<(), Error> { + self.call( + "account_rename", + &RenameAccountParams { + account, + name: name.map(str::to_owned), + }, + ) + .await + } + + /// Returns the balance of an account, including only confirmed UTXOs + /// (`account_balance`). + pub async fn balance(&self, account: u32) -> Result { + self.call( + "account_balance", + &BalanceParams { + account, + utxo_states: ["Confirmed"], + with_locked: None, + }, + ) + .await + } + + /// Generates a new address for an account (`address_new`). + pub async fn new_address(&self, account: u32) -> Result { + let response: NewAddressResponse = + self.call("address_new", &serde_json::json!({ "account": account })).await?; + Ok(response.address) + } + + /// Shows the receive addresses of an account with usage information + /// (`address_show`). + pub async fn show_receive_addresses( + &self, + account: u32, + ) -> Result, Error> { + self.call( + "address_show", + &ShowAddressesParams { + account, + include_change_addresses: false, + }, + ) + .await + } + + /// Reveals the public key behind an address + /// (`address_reveal_public_key`). + pub async fn reveal_public_key( + &self, + account: u32, + address: &str, + ) -> Result { + self.call( + "address_reveal_public_key", + &serde_json::json!({ "account": account, "address": address }), + ) + .await + } + + /// Encrypts the private keys of the wallet with a password + /// (`wallet_encrypt_private_keys`). + pub async fn encrypt_private_keys(&self, password: &str) -> Result<(), Error> { + self.call( + "wallet_encrypt_private_keys", + &serde_json::json!({ "password": password }), + ) + .await + } + + /// Unlocks the encrypted private keys of the wallet + /// (`wallet_unlock_private_keys`). + pub async fn unlock_private_keys(&self, password: &str) -> Result<(), Error> { + self.call( + "wallet_unlock_private_keys", + &serde_json::json!({ "password": password }), + ) + .await + } + + /// Locks the private keys of the wallet (`wallet_lock_private_keys`). + pub async fn lock_private_keys(&self) -> Result<(), Error> { + self.call("wallet_lock_private_keys", &serde_json::json!({})).await + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 87e4425..a8843db 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -4,4 +4,114 @@ // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. -//! Stub — implemented in a later phase. +//! JSON-RPC 2.0 client for the Mintlayer wallet daemon (default port 3034 on +//! mainnet, 13034 on testnet). + +mod error; +mod management; +mod orders; +mod staking; +mod tokens; +mod transaction; +mod types; + +use std::sync::OnceLock; + +pub use error::Error; +pub use types::{ + AccountInfo, ActiveOrder, AddressWithUsage, Amount, Balance, BestBlock, ChangeAuthorityParams, + ComposeParams, ComposedTx, ConcludeOrderParams, CreateDelegationParams, CreateDelegationResult, + CreateOrderParams, CreatePoolParams, CreateWalletParams, CreateWalletResult, CurrencyFilter, + DecommissionParams, DelegateParams, DelegationInfo, FillOrderParams, FreezeOrderParams, + FreezeParams, IssueNftParams, IssueTokenParams, IssueTokenResult, ListOrdersParams, + LockSupplyParams, MintParams, MnemonicContent, MnemonicResult, NftMetadata, OrderCreated, + OrderState, Outpoint, OutpointSourceId, OutputValue, OwnOrder, OwnedPool, RecoverWalletParams, + RevealPublicKey, SendParams, SendResult, SignedTx, StakingStatus, SubmitResult, SweepParams, + TokenMetadata, TokenSendParams, TokenSupply, TxInspection, TxOptions, TxStats, UnfreezeParams, + UnmintParams, UtxoSpendParams, WalletExtraInfo, WalletInfo, WalletTx, WithdrawParams, +}; + +use crate::jsonrpc; +use crate::limits::DEFAULT_TIMEOUT; + +static DEFAULT_HTTP_CLIENT: OnceLock = OnceLock::new(); + +fn default_http_client() -> reqwest::Client { + DEFAULT_HTTP_CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .timeout(DEFAULT_TIMEOUT) + .build() + .expect("reqwest client with default settings must build") + }) + .clone() +} + +/// Client for the Mintlayer wallet daemon JSON-RPC API. +#[derive(Debug, Clone)] +pub struct Client { + pub(crate) transport: jsonrpc::Transport, +} + +impl Client { + /// Creates a client with default settings: a 30 second timeout and no + /// authentication. + #[must_use] + pub fn new(endpoint: impl Into) -> Self { + Self { + transport: jsonrpc::Transport::from_parts(endpoint.into(), default_http_client(), None), + } + } + + /// Returns a builder to construct a client with custom settings. + #[must_use] + pub fn builder(endpoint: impl Into) -> ClientBuilder { + ClientBuilder { + inner: jsonrpc::ClientBuilder::new(endpoint), + } + } + + pub(crate) async fn call(&self, method: &str, params: &P) -> Result + where + P: serde::Serialize + ?Sized, + R: serde::de::DeserializeOwned, + { + self.transport.call(method, params).await.map_err(Error::from) + } +} + +/// Builder for [`Client`]. +#[derive(Debug, Clone)] +pub struct ClientBuilder { + inner: jsonrpc::ClientBuilder, +} + +impl ClientBuilder { + /// Sends HTTP basic auth credentials with every request. + #[must_use] + pub fn basic_auth(mut self, username: impl Into, password: impl Into) -> Self { + self.inner = self.inner.basic_auth(username, password); + self + } + + /// Overrides the default 30 second request timeout. + #[must_use] + pub fn timeout(mut self, timeout: std::time::Duration) -> Self { + self.inner = self.inner.timeout(timeout); + self + } + + /// Replaces the internally built HTTP client entirely. + #[must_use] + pub fn http_client(mut self, http_client: reqwest::Client) -> Self { + self.inner = self.inner.http_client(http_client); + self + } + + /// Builds the client. + pub fn build(self) -> Result { + Ok(Client { + transport: self.inner.build()?, + }) + } +} diff --git a/src/wallet/orders.rs b/src/wallet/orders.rs new file mode 100644 index 0000000..7eaf218 --- /dev/null +++ b/src/wallet/orders.rs @@ -0,0 +1,48 @@ +// 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. + +//! Wallet DEX order RPC methods. + +use super::{ + ActiveOrder, Client, ConcludeOrderParams, CreateOrderParams, Error, FillOrderParams, + FreezeOrderParams, ListOrdersParams, OrderCreated, OwnOrder, SendResult, +}; + +impl Client { + /// Creates a new DEX order (`order_create`). + pub async fn create_order(&self, params: CreateOrderParams) -> Result { + self.call("order_create", ¶ms).await + } + + /// Concludes an order (`order_conclude`). + pub async fn conclude_order(&self, params: ConcludeOrderParams) -> Result { + self.call("order_conclude", ¶ms).await + } + + /// Fills an existing order (`order_fill`). + pub async fn fill_order(&self, params: FillOrderParams) -> Result { + self.call("order_fill", ¶ms).await + } + + /// Freezes an order (`order_freeze`). + pub async fn freeze_order(&self, params: FreezeOrderParams) -> Result { + self.call("order_freeze", ¶ms).await + } + + /// Lists the orders owned by an account (`order_list_own`). + pub async fn list_own_orders(&self, account: u32) -> Result, Error> { + self.call("order_list_own", &serde_json::json!({ "account": account })).await + } + + /// Lists all active orders, optionally filtered by currency + /// (`order_list_all_active`). + pub async fn list_all_active_orders( + &self, + params: ListOrdersParams, + ) -> Result, Error> { + self.call("order_list_all_active", ¶ms).await + } +} diff --git a/src/wallet/staking.rs b/src/wallet/staking.rs new file mode 100644 index 0000000..cc596bc --- /dev/null +++ b/src/wallet/staking.rs @@ -0,0 +1,109 @@ +// 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. + +//! Wallet staking RPC methods: pools and delegations. + +use serde::{Deserialize, Serialize}; + +use super::{ + Client, CreateDelegationParams, CreateDelegationResult, CreatePoolParams, DecommissionParams, + DelegateParams, DelegationInfo, Error, OwnedPool, SendResult, StakingStatus, WithdrawParams, +}; +use crate::wallet::types::Amount; + +#[derive(Serialize)] +struct PoolBalanceParams { + pool_id: String, +} + +#[derive(Deserialize)] +struct PoolBalanceResponse { + balance: Option, +} + +impl Client { + /// Creates a new stake pool (`staking_create_pool`). + pub async fn create_stake_pool(&self, params: CreatePoolParams) -> Result { + self.call("staking_create_pool", ¶ms).await + } + + /// Decommissions a stake pool (`staking_decommission_pool`). + pub async fn decommission_stake_pool( + &self, + params: DecommissionParams, + ) -> Result { + self.call("staking_decommission_pool", ¶ms).await + } + + /// Lists the stake pools owned by an account (`staking_list_pools`). + pub async fn list_owned_pools(&self, account: u32) -> Result, Error> { + self.call( + "staking_list_pools", + &serde_json::json!({ "account": account }), + ) + .await + } + + /// Returns the balance of a stake pool (`staking_pool_balance`). + pub async fn pool_balance(&self, pool_id: &str) -> Result, Error> { + let response: PoolBalanceResponse = self + .call( + "staking_pool_balance", + &PoolBalanceParams { + pool_id: pool_id.to_owned(), + }, + ) + .await?; + Ok(response.balance) + } + + /// Starts staking with an account (`staking_start`). + pub async fn start_staking(&self, account: u32) -> Result<(), Error> { + self.call("staking_start", &serde_json::json!({ "account": account })).await + } + + /// Stops staking for an account (`staking_stop`). + pub async fn stop_staking(&self, account: u32) -> Result<(), Error> { + self.call("staking_stop", &serde_json::json!({ "account": account })).await + } + + /// Returns whether an account is staking (`staking_status`). + pub async fn staking_status(&self, account: u32) -> Result { + self.call("staking_status", &serde_json::json!({ "account": account })).await + } + + /// Creates a delegation to a pool (`delegation_create`). + pub async fn create_delegation( + &self, + params: CreateDelegationParams, + ) -> Result { + self.call("delegation_create", ¶ms).await + } + + /// Delegates an amount to an existing delegation + /// (`delegation_stake`). + pub async fn delegate_staking(&self, params: DelegateParams) -> Result { + self.call("delegation_stake", ¶ms).await + } + + /// Withdraws from a delegation (`delegation_withdraw`). + pub async fn withdraw_from_delegation( + &self, + params: WithdrawParams, + ) -> Result { + self.call("delegation_withdraw", ¶ms).await + } + + /// Lists the delegations owned by an account + /// (`delegation_list_ids`). + pub async fn list_delegations(&self, account: u32) -> Result, Error> { + self.call( + "delegation_list_ids", + &serde_json::json!({ "account": account }), + ) + .await + } +} diff --git a/src/wallet/tokens.rs b/src/wallet/tokens.rs new file mode 100644 index 0000000..6aa4ac8 --- /dev/null +++ b/src/wallet/tokens.rs @@ -0,0 +1,58 @@ +// 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. + +//! Wallet token RPC methods: issuance, minting, freezing, authority. + +use super::{ + ChangeAuthorityParams, Client, Error, FreezeParams, IssueNftParams, IssueTokenParams, + IssueTokenResult, LockSupplyParams, MintParams, SendResult, UnfreezeParams, UnmintParams, +}; + +impl Client { + /// Issues a new fungible token (`token_issue_new`). + pub async fn issue_token(&self, params: IssueTokenParams) -> Result { + self.call("token_issue_new", ¶ms).await + } + + /// Issues a new NFT (`token_nft_issue_new`). + pub async fn issue_nft(&self, params: IssueNftParams) -> Result { + self.call("token_nft_issue_new", ¶ms).await + } + + /// Mints tokens of an existing token (`token_mint`). + pub async fn mint_tokens(&self, params: MintParams) -> Result { + self.call("token_mint", ¶ms).await + } + + /// Unmints tokens (`token_unmint`). + pub async fn unmint_tokens(&self, params: UnmintParams) -> Result { + self.call("token_unmint", ¶ms).await + } + + /// Permanently locks the supply of a token (`token_lock_supply`). + pub async fn lock_token_supply(&self, params: LockSupplyParams) -> Result { + self.call("token_lock_supply", ¶ms).await + } + + /// Freezes a token (`token_freeze`). + pub async fn freeze_token(&self, params: FreezeParams) -> Result { + self.call("token_freeze", ¶ms).await + } + + /// Unfreezes a token (`token_unfreeze`). + pub async fn unfreeze_token(&self, params: UnfreezeParams) -> Result { + self.call("token_unfreeze", ¶ms).await + } + + /// Transfers the token authority to a new address + /// (`token_change_authority`). + pub async fn change_token_authority( + &self, + params: ChangeAuthorityParams, + ) -> Result { + self.call("token_change_authority", ¶ms).await + } +} diff --git a/src/wallet/transaction.rs b/src/wallet/transaction.rs new file mode 100644 index 0000000..03a9118 --- /dev/null +++ b/src/wallet/transaction.rs @@ -0,0 +1,198 @@ +// 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. + +//! Wallet transaction RPC methods: sending, composing, signing, history. + +use serde::Serialize; + +use super::{ + Client, ComposeParams, ComposedTx, Error, SendParams, SendResult, SignedTx, SubmitResult, + SweepParams, TokenSendParams, TxInspection, TxOptions, UtxoSpendParams, WalletTx, +}; + +#[derive(Serialize)] +struct SignRawTransactionParams { + account: u32, + raw_tx: String, + options: TxOptions, +} + +#[derive(Serialize)] +struct SubmitTransactionParams { + tx: String, + do_not_store: bool, + options: SubmitOptions, +} + +#[derive(Serialize)] +struct SubmitOptions { + trust_policy: &'static str, +} + +#[derive(Serialize)] +struct ListByAddressParams { + account: u32, + address: Option, + limit: u32, +} + +#[derive(Serialize)] +struct GetTransactionParams { + account: u32, + transaction_id: String, +} + +#[derive(Serialize)] +struct DepositDataParams { + account: u32, + data: String, + options: TxOptions, +} + +impl Client { + /// Sends coins to an address (`address_send`). + pub async fn send(&self, params: SendParams) -> Result { + self.call("address_send", ¶ms).await + } + + /// Sends tokens to an address (`token_send`). + pub async fn send_token(&self, params: TokenSendParams) -> Result { + self.call("token_send", ¶ms).await + } + + /// Sweeps all spendable funds from the given addresses + /// (`address_sweep_spendable`). + pub async fn sweep_spendable(&self, params: SweepParams) -> Result { + self.call("address_sweep_spendable", ¶ms).await + } + + /// Spends a single UTXO (`utxo_spend`). + pub async fn spend_utxo(&self, params: UtxoSpendParams) -> Result { + self.call("utxo_spend", ¶ms).await + } + + /// Composes a transaction from explicit inputs and outputs without + /// signing it (`transaction_compose`). The returned hex encodes a + /// partially signed transaction. + pub async fn compose_transaction(&self, params: ComposeParams) -> Result { + self.call("transaction_compose", ¶ms).await + } + + /// Signs a raw partially signed transaction with the keys of an account + /// (`account_sign_raw_transaction`). + pub async fn sign_raw_transaction( + &self, + account: u32, + raw_tx: &str, + ) -> Result { + self.call( + "account_sign_raw_transaction", + &SignRawTransactionParams { + account, + raw_tx: raw_tx.to_owned(), + options: TxOptions::default(), + }, + ) + .await + } + + /// Inspects a hex-encoded transaction (`transaction_inspect`). + pub async fn inspect_transaction(&self, tx_hex: &str) -> Result { + self.call( + "transaction_inspect", + &serde_json::json!({ "transaction": tx_hex }), + ) + .await + } + + /// Submits a hex-encoded signed transaction to the node + /// (`node_submit_transaction`, trusted policy). + pub async fn submit_transaction( + &self, + tx_hex: &str, + do_not_store: bool, + ) -> Result { + self.call( + "node_submit_transaction", + &SubmitTransactionParams { + tx: tx_hex.to_owned(), + do_not_store, + options: SubmitOptions { + trust_policy: "Trusted", + }, + }, + ) + .await + } + + /// Lists the transactions of an address in an account + /// (`transaction_list_by_address`). + pub async fn list_transactions_by_address( + &self, + account: u32, + address: Option<&str>, + limit: u32, + ) -> Result, Error> { + self.call( + "transaction_list_by_address", + &ListByAddressParams { + account, + address: address.map(str::to_owned), + limit, + }, + ) + .await + } + + /// Lists the pending transactions of an account + /// (`transaction_list_pending`). + pub async fn list_pending_transactions(&self, account: u32) -> Result, Error> { + self.call( + "transaction_list_pending", + &serde_json::json!({ "account": account }), + ) + .await + } + + /// Returns a transaction tracked by the wallet + /// (`transaction_get`). + pub async fn transaction(&self, account: u32, tx_id: &str) -> Result { + self.call( + "transaction_get", + &GetTransactionParams { + account, + transaction_id: tx_id.to_owned(), + }, + ) + .await + } + + /// Abandons an unconfirmed transaction (`transaction_abandon`). + pub async fn abandon_transaction(&self, account: u32, tx_id: &str) -> Result<(), Error> { + self.call( + "transaction_abandon", + &GetTransactionParams { + account, + transaction_id: tx_id.to_owned(), + }, + ) + .await + } + + /// Deposits arbitrary on-chain data from an account + /// (`address_deposit_data`). + pub async fn deposit_data(&self, account: u32, data_hex: &str) -> Result { + self.call( + "address_deposit_data", + &DepositDataParams { + account, + data: data_hex.to_owned(), + options: TxOptions::default(), + }, + ) + .await + } +} diff --git a/src/wallet/types.rs b/src/wallet/types.rs new file mode 100644 index 0000000..9b0d3d3 --- /dev/null +++ b/src/wallet/types.rs @@ -0,0 +1,853 @@ +// 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. + +//! Wire types for the wallet daemon JSON-RPC API. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A coin or token amount. Requests typically set only [`atoms`](#structfield.atoms); +/// daemon responses carry both fields. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Amount { + /// Amount in atoms, the smallest indivisible unit + /// (1 ML = 100,000,000,000 atoms). + atoms: Option, + /// The decimal representation in whole coins. + decimal: Option, +} + +impl Amount { + /// Creates an amount from a number of atoms. + #[must_use] + pub fn from_atoms(atoms: u128) -> Self { + Self { + atoms: Some(atoms), + decimal: None, + } + } + + /// Creates an amount from a decimal string of atoms and an optional + /// decimal representation. + #[must_use] + pub fn from_parts(atoms: Option, decimal: Option) -> Self { + Self { atoms, decimal } + } + + /// Returns the amount in atoms, if known. + #[must_use] + pub const fn atoms(&self) -> Option { + self.atoms + } + + /// Returns the decimal representation, if known. + #[must_use] + pub fn decimal(&self) -> Option<&str> { + self.decimal.as_deref() + } +} + +impl Serialize for Amount { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("Amount", 2)?; + match self.atoms { + Some(atoms) => state.serialize_field("atoms", &atoms.to_string())?, + None => state.skip_field("atoms")?, + } + match &self.decimal { + Some(decimal) => state.serialize_field("decimal", decimal)?, + None => state.skip_field("decimal")?, + } + state.end() + } +} + +impl<'de> Deserialize<'de> for Amount { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + struct Wire { + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "crate::number::option_atoms_lenient" + )] + atoms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + decimal: Option, + } + Wire::deserialize(deserializer).map(|wire| Self { + atoms: wire.atoms, + decimal: wire.decimal, + }) + } +} + +/// A UNIX timestamp in seconds. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +pub struct Timestamp { + /// Seconds since the UNIX epoch. + pub timestamp: i64, +} + +/// Fee estimation for a transaction, split by currency. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeesBreakdown { + /// The fee paid in coins. + pub coins: Amount, + /// The fee paid in tokens, keyed by bech32 token id. + pub tokens: BTreeMap, +} + +/// The result of a wallet send operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SendResult { + /// Hex-encoded transaction id. + pub tx_id: String, + /// The fees paid. + pub fees: FeesBreakdown, + /// Whether the transaction was broadcast to the network. + pub broadcasted: bool, +} + +/// The result of submitting a transaction to the node. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubmitResult { + /// Hex-encoded transaction id. + pub tx_id: String, +} + +/// A composed (unsigned) transaction and its estimated fees. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ComposedTx { + /// Hex-encoded partially signed transaction. + pub hex: String, + /// The estimated fees. + pub fees: FeesBreakdown, +} + +/// A signed raw transaction. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SignedTx { + /// Hex-encoded signed transaction. + pub hex: String, + /// The signatures collected so far. + pub current_signatures: serde_json::Value, +} + +/// Signature statistics of a transaction. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TxStats { + /// Number of inputs. + pub num_inputs: u32, + /// Number of total signatures required. + pub total_signatures: u32, +} + +/// An inspection of a transaction. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TxInspection { + /// Signature statistics. + pub stats: TxStats, + /// The fees, if computable. + pub fees: Option, +} + +/// A wallet transaction history entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletTx { + /// Hex-encoded transaction id. + pub id: String, + /// Height of the confirming block. + pub height: u64, + /// Confirmation timestamp. + pub timestamp: Timestamp, +} + +/// A mnemonic as returned by the wallet daemon. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MnemonicContent { + /// The seed phrase words. + pub mnemonic: String, +} + +/// The mnemonic section of a [`CreateWalletResult`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MnemonicResult { + /// Either `"NewlyGenerated"` or `"UserProvided"`. + #[serde(rename = "type")] + pub kind: String, + /// The mnemonic, when the daemon returned one. + pub content: Option, +} + +/// The result of creating a wallet. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateWalletResult { + /// The generated mnemonic, when the daemon generated one. + pub mnemonic: Option, +} + +/// Parameters for [`Client::create_wallet`](crate::wallet::Client::create_wallet). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct CreateWalletParams { + /// File path for the wallet database. + pub path: String, + /// Whether to store the seed phrase in the wallet file. + pub store_seed_phrase: bool, + /// A user-provided mnemonic; `None` lets the daemon generate one. + pub mnemonic: Option, + /// Optional BIP39 passphrase. + pub passphrase: Option, + /// Optional hardware wallet type. + pub hardware_wallet: Option, +} + +/// Parameters for [`Client::recover_wallet`](crate::wallet::Client::recover_wallet). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RecoverWalletParams { + /// File path for the wallet database. + pub path: String, + /// Whether to store the seed phrase in the wallet file. + pub store_seed_phrase: bool, + /// The mnemonic to recover from. + pub mnemonic: String, + /// Optional BIP39 passphrase. + pub passphrase: Option, + /// Optional hardware wallet type. + pub hardware_wallet: Option, +} + +/// Extra wallet information. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletExtraInfo { + /// Either `"SoftwareWallet"`, `"TrezorWallet"` or `"LedgerWallet"`. + #[serde(rename = "type")] + pub kind: String, +} + +/// General information about the open wallet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletInfo { + /// The wallet identifier. + pub wallet_id: String, + /// Names of the accounts in the wallet. + pub account_names: Vec, + /// Extra wallet information. + pub extra_info: WalletExtraInfo, +} + +/// The best block known to the wallet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BestBlock { + /// Height of the best block. + pub height: u64, + /// Hex-encoded id of the best block. + pub id: String, +} + +/// A created account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountInfo { + /// The account index. + pub account: u32, + /// The account name. + pub name: String, +} + +/// The balance of an account. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Balance { + /// The coin balance. + pub coins: Amount, + /// Token balances keyed by bech32 token id. + pub tokens: BTreeMap, +} + +/// An address with usage information. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AddressWithUsage { + /// The bech32 address. + pub address: String, + /// Whether the address has been used. + pub used: bool, + /// The balance held by the address. + pub coins: Amount, +} + +/// The result of revealing the public key of an address. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RevealPublicKey { + /// Hex-encoded compressed public key. + pub public_key_hex: String, + /// Bech32 address of the public key. + pub public_key_address: String, +} + +/// A reference to a transaction output. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Outpoint { + /// Where the spent output comes from. + pub source_id: OutpointSourceId, + /// The index of the output in the source. + pub index: u32, +} + +/// The source of an [`Outpoint`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", content = "content")] +pub enum OutpointSourceId { + /// The output of a transaction, keyed by its hex-encoded transaction id. + Transaction { + /// Hex-encoded transaction id. + tx_id: String, + }, + /// The block reward of a block, keyed by its hex-encoded block id. + BlockReward { + /// Hex-encoded block id. + block_id: String, + }, +} + +/// Options applied to wallet transaction creation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub struct TxOptions { + /// Target the top X MB of the mempool for fee estimation. + pub in_top_x_mb: Option, + /// Whether to broadcast the transaction to the mempool. + pub broadcast_to_mempool: Option, +} + +/// Parameters for [`Client::send`](crate::wallet::Client::send). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct SendParams { + /// The account to spend from. + pub account: u32, + /// The destination address. + pub address: String, + /// The amount to send. + pub amount: Amount, + /// UTXOs to spend; an empty vector lets the wallet select. + pub selected_utxos: Vec, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::send_token`](crate::wallet::Client::send_token). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TokenSendParams { + /// The account to spend from. + pub account: u32, + /// The bech32 token id. + pub token_id: String, + /// The destination address. + pub address: String, + /// The amount to send. + pub amount: Amount, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::sweep_spendable`](crate::wallet::Client::sweep_spendable). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct SweepParams { + /// The account to spend from. + pub account: u32, + /// The destination address. + pub destination_address: String, + /// Addresses whose funds are swept. + pub from_addresses: Vec, + /// Whether to sweep all addresses of the account. + pub all: bool, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::spend_utxo`](crate::wallet::Client::spend_utxo). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct UtxoSpendParams { + /// The account to spend from. + pub account: u32, + /// The UTXO to spend. + pub utxo: Outpoint, + /// The destination address. + pub output_address: String, + /// The HTLC secret, when spending an HTLC output. + pub htlc_secret: Option, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::compose_transaction`](crate::wallet::Client::compose_transaction). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ComposeParams { + /// The inputs to spend. + pub inputs: Vec, + /// The outputs to create. + pub outputs: Vec, + /// HTLC secrets for HTLC inputs. + pub htlc_secrets: Option, + /// Whether to return only the transaction instead of a partial one. + pub only_transaction: bool, +} + +/// A source or destination currency for orders. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content")] +pub enum CurrencyFilter { + /// The native coin. + Coin, + /// A token identified by its bech32 token id. + Token(String), +} + +/// One side of a DEX order: a currency with an amount. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content")] +pub enum OutputValue { + /// The native coin. + Coin { + /// The coin amount. + amount: Amount, + }, + /// A token identified by its bech32 id. + Token { + /// The bech32 token id. + id: String, + /// The token amount. + amount: Amount, + }, +} + +impl OutputValue { + /// Creates a coin side from a number of atoms. + #[must_use] + pub fn coins(atoms: u128) -> Self { + Self::Coin { + amount: Amount::from_atoms(atoms), + } + } + + /// Creates a token side from a token id and a number of atoms. + #[must_use] + pub fn token(token_id: impl Into, atoms: u128) -> Self { + Self::Token { + id: token_id.into(), + amount: Amount::from_atoms(atoms), + } + } +} + +/// Parameters for [`Client::create_order`](crate::wallet::Client::create_order). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct CreateOrderParams { + /// The account to spend from. + pub account: u32, + /// The currency and amount asked. + pub ask: OutputValue, + /// The currency and amount given. + pub give: OutputValue, + /// The address allowed to conclude the order. + pub conclude_address: String, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::conclude_order`](crate::wallet::Client::conclude_order). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ConcludeOrderParams { + /// The account to spend from. + pub account: u32, + /// The bech32 order id. + pub order_id: String, + /// The address receiving the order funds; `None` uses the default. + pub output_address: Option, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::fill_order`](crate::wallet::Client::fill_order). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct FillOrderParams { + /// The account to spend from. + pub account: u32, + /// The bech32 order id. + pub order_id: String, + /// The amount to fill, in the ask currency. + pub fill_amount_in_ask_currency: Amount, + /// The address receiving the order funds; `None` uses the default. + pub output_address: Option, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::freeze_order`](crate::wallet::Client::freeze_order). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct FreezeOrderParams { + /// The account to spend from. + pub account: u32, + /// The bech32 order id. + pub order_id: String, + /// Transaction options. + pub options: TxOptions, +} + +/// Filters for listing active orders. +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct ListOrdersParams { + /// The account to list orders for. + pub account: u32, + /// Filter by ask currency; `None` includes every currency. + pub ask_currency: Option, + /// Filter by give currency; `None` includes every currency. + pub give_currency: Option, +} + +/// The live state of an own order. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OrderState { + /// Remaining ask balance. + pub ask_balance: Amount, + /// Remaining give balance. + pub give_balance: Amount, + /// Whether the order is frozen. + #[serde(rename = "is_frozen")] + pub frozen: bool, + /// When the order was created. + #[serde(rename = "creation_timestamp")] + pub creation: Timestamp, +} + +/// An order owned by the wallet. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OwnOrder { + /// Bech32 order id. + pub order_id: String, + /// The currency and amount initially asked. + pub initially_asked: OutputValue, + /// The currency and amount initially given. + pub initially_given: OutputValue, + /// The live order state, when the order is active on chain. + pub existing_order_data: Option, + /// Whether the order is marked as frozen in the wallet. + #[serde(rename = "is_marked_as_frozen_in_wallet")] + pub is_marked_as_frozen_in_wallet: bool, + /// Whether the order is marked as concluded in the wallet. + #[serde(rename = "is_marked_as_concluded_in_wallet")] + pub is_marked_as_concluded_in_wallet: bool, +} + +/// An active order on chain. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ActiveOrder { + /// Bech32 order id. + pub order_id: String, + /// The currency and amount initially asked. + pub initially_asked: OutputValue, + /// The currency and amount initially given. + pub initially_given: OutputValue, + /// Remaining ask balance. + pub ask_balance: Amount, + /// Remaining give balance. + pub give_balance: Amount, + /// Whether the order belongs to this wallet. + pub is_own: bool, +} + +/// The result of creating an order. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OrderCreated { + /// Bech32 order id. + pub order_id: String, + /// Hex-encoded transaction id. + pub tx_id: String, + /// Whether the order transaction was broadcast. + pub broadcasted: bool, +} + +/// Parameters for [`Client::create_stake_pool`](crate::wallet::Client::create_stake_pool). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct CreatePoolParams { + /// The account to spend from. + pub account: u32, + /// The pledge amount. + pub amount: Amount, + /// The pool cost per block. + pub cost_per_block: Amount, + /// The margin ratio in per-mille, as a decimal string. + pub margin_ratio_per_thousand: String, + /// The address receiving decommission outputs. + pub decommission_address: String, + /// The staker address; `None` uses a derived address. + pub staker_address: Option, + /// The bech32 VRF public key; `None` uses a derived key. + pub vrf_public_key: Option, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::decommission_stake_pool`](crate::wallet::Client::decommission_stake_pool). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct DecommissionParams { + /// The account to spend from. + pub account: u32, + /// The bech32 pool id. + pub pool_id: String, + /// The address receiving the pool funds. + pub output_address: String, + /// Transaction options. + pub options: TxOptions, +} + +/// A stake pool owned by the wallet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OwnedPool { + /// Bech32 pool id. + pub pool_id: String, + /// The pledge amount. + pub pledge: Amount, + /// The total pool balance. + pub balance: Amount, + /// The margin ratio in per-mille, as a decimal string. + pub margin_ratio_per_thousand: String, + /// The pool cost per block. + pub cost_per_block: Amount, +} + +/// Whether an account is staking. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum StakingStatus { + /// The account is staking. + #[serde(rename = "Staking")] + #[default] + Staking, + /// The account is not staking. + #[serde(rename = "NotStaking")] + NotStaking, +} + +/// Parameters for [`Client::create_delegation`](crate::wallet::Client::create_delegation). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct CreateDelegationParams { + /// The account to spend from. + pub account: u32, + /// The address owning the delegation. + pub address: String, + /// The bech32 pool id to delegate to. + pub pool_id: String, + /// Transaction options. + pub options: TxOptions, +} + +/// The result of creating a delegation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateDelegationResult { + /// Bech32 delegation id. + pub delegation_id: String, + /// Hex-encoded transaction id. + pub tx_id: String, +} + +/// Parameters for [`Client::delegate_staking`](crate::wallet::Client::delegate_staking). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct DelegateParams { + /// The account to spend from. + pub account: u32, + /// The amount to delegate. + pub amount: Amount, + /// The bech32 delegation id. + pub delegation_id: String, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::withdraw_from_delegation`](crate::wallet::Client::withdraw_from_delegation). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct WithdrawParams { + /// The account to spend from. + pub account: u32, + /// The address receiving the withdrawal. + pub address: String, + /// The amount to withdraw. + pub amount: Amount, + /// The bech32 delegation id. + pub delegation_id: String, + /// Transaction options. + pub options: TxOptions, +} + +/// A delegation owned by the wallet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DelegationInfo { + /// Bech32 delegation id. + pub delegation_id: String, + /// Bech32 pool id. + pub pool_id: String, + /// The delegated balance. + pub balance: Amount, +} + +/// The total supply policy of a token. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content")] +pub enum TokenSupply { + /// A fixed supply capped at the given amount. + Fixed(Amount), + /// A lockable supply: minting can be stopped permanently. + Lockable, + /// An unlimited supply. + Unlimited, +} + +/// Metadata for a new fungible token. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TokenMetadata { + /// The token ticker. + pub token_ticker: String, + /// Number of decimal places. + pub number_of_decimals: u8, + /// The metadata URI. + pub metadata_uri: String, + /// The total supply policy. + pub token_supply: TokenSupply, + /// Whether the token can be frozen. + pub is_freezable: bool, +} + +/// Parameters for [`Client::issue_token`](crate::wallet::Client::issue_token). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct IssueTokenParams { + /// The account to spend from. + pub account: u32, + /// The address that becomes the token authority. + pub destination_address: String, + /// The token metadata. + pub metadata: TokenMetadata, + /// Transaction options. + pub options: TxOptions, +} + +/// The result of a token issuance. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IssueTokenResult { + /// Bech32 token id. + pub token_id: String, + /// Hex-encoded transaction id. + pub tx_id: String, +} + +/// Metadata for a new NFT. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct NftMetadata { + /// Hex-encoded hash of the media. + pub media_hash: String, + /// The NFT name. + pub name: String, + /// The NFT description. + pub description: String, + /// The NFT ticker. + pub ticker: String, + /// The creator's public key, if provided. + pub creator: Option, + /// Icon URI, if provided. + pub icon_uri: Option, + /// Media URI, if provided. + pub media_uri: Option, + /// Additional metadata URI, if provided. + pub additional_metadata_uri: Option, +} + +/// Parameters for [`Client::issue_nft`](crate::wallet::Client::issue_nft). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct IssueNftParams { + /// The account to spend from. + pub account: u32, + /// The address that becomes the token authority. + pub destination_address: String, + /// The NFT metadata. + pub metadata: NftMetadata, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::mint_tokens`](crate::wallet::Client::mint_tokens). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct MintParams { + /// The account to spend from. + pub account: u32, + /// The bech32 token id. + pub token_id: String, + /// The address receiving the minted tokens. + pub address: String, + /// The amount to mint. + pub amount: Amount, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::unmint_tokens`](crate::wallet::Client::unmint_tokens). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct UnmintParams { + /// The account to spend from. + pub account: u32, + /// The bech32 token id. + pub token_id: String, + /// The amount to unmint. + pub amount: Amount, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::lock_token_supply`](crate::wallet::Client::lock_token_supply). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct LockSupplyParams { + /// The account to spend from. + pub account_index: u32, + /// The bech32 token id. + pub token_id: String, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::freeze_token`](crate::wallet::Client::freeze_token). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct FreezeParams { + /// The account to spend from. + pub account: u32, + /// The bech32 token id. + pub token_id: String, + /// Whether the freeze is permanent. + pub is_unfreezable: bool, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::unfreeze_token`](crate::wallet::Client::unfreeze_token). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct UnfreezeParams { + /// The account to spend from. + pub account: u32, + /// The bech32 token id. + pub token_id: String, + /// Transaction options. + pub options: TxOptions, +} + +/// Parameters for [`Client::change_token_authority`](crate::wallet::Client::change_token_authority). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ChangeAuthorityParams { + /// The account to spend from. + pub account: u32, + /// The bech32 token id. + pub token_id: String, + /// The address of the new authority. + pub address: String, + /// Transaction options. + pub options: TxOptions, +} diff --git a/tests/wallet.rs b/tests/wallet.rs new file mode 100644 index 0000000..8dac119 --- /dev/null +++ b/tests/wallet.rs @@ -0,0 +1,498 @@ +// 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. + +//! Wire-format tests for the wallet daemon client, mirroring the go-sdk +//! `wallet/client_test.go` and `wallet/orders_test.go` suites. + +use std::sync::Arc; + +use httpmock::prelude::*; +use httpmock::{Mock, Then}; +use serde_json::json; + +use mintlayer_sdk::wallet::{ + Amount, Client, CreateOrderParams, CreateWalletParams, CurrencyFilter, Error, ListOrdersParams, + LockSupplyParams, OutputValue, SendParams, StakingStatus, TxOptions, +}; + +const RESPONSE_HEADERS: [(&str, &str); 1] = [("content-type", "application/json")]; + +const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +fn respond(then: Then, body: serde_json::Value) { + let mut builder = then.status(200); + for (name, value) in RESPONSE_HEADERS { + builder = builder.header(name, value); + } + builder.body(body.to_string()); +} + +fn rpc_ok(id: u64, result: serde_json::Value) -> serde_json::Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +fn mock_rpc( + server: &MockServer, + request_fragment: String, + response: serde_json::Value, +) -> Mock<'_> { + server.mock(move |when, then| { + when.method(POST).path("/").body_contains(request_fragment); + respond(then, response); + }) +} + +fn send_result() -> serde_json::Value { + json!({ + "tx_id": "f0f1f2", + "fees": { + "coins": { "atoms": "100", "decimal": "0.000000001" }, + "tokens": {}, + }, + "broadcasted": true, + }) +} + +#[tokio::test] +async fn create_wallet_returns_mnemonic() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("\"method\":\"wallet_create\"") + .body_contains("\"store_seed_phrase\":true"); + respond( + then, + rpc_ok( + 1, + json!({ + "mnemonic": { + "type": "NewlyGenerated", + "content": { "mnemonic": MNEMONIC }, + }, + }), + ), + ); + }); + + let client = Client::new(server.url("/")); + let params = CreateWalletParams { + path: "/tmp/wallet.dat".to_string(), + store_seed_phrase: true, + mnemonic: None, + passphrase: None, + hardware_wallet: None, + }; + let result = client.create_wallet(params).await.unwrap(); + + let mnemonic = result.mnemonic.expect("daemon generated a mnemonic"); + assert_eq!(mnemonic.kind, "NewlyGenerated"); + let content = mnemonic.content.expect("mnemonic content is set"); + assert_eq!(content.mnemonic, MNEMONIC); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn open_wallet_password_null_handling() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("\"method\":\"wallet_open\"") + .body_contains("\"path\":\"/tmp/wallet.dat\"") + .body_contains("\"password\":null"); + respond(then, rpc_ok(1, json!(null))); + }); + + let client = Client::new(server.url("/")); + client.open_wallet("/tmp/wallet.dat", None).await.unwrap(); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn balance_params_and_result() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("\"method\":\"account_balance\"") + .body_contains("\"utxo_states\":[\"Confirmed\"]") + .body_contains("\"with_locked\":null"); + respond( + then, + rpc_ok( + 1, + json!({ + "coins": { "atoms": "5000000000000", "decimal": "50.0" }, + "tokens": { "mmltk1x": { "atoms": "700", "decimal": "0.7" } }, + }), + ), + ); + }); + + let client = Client::new(server.url("/")); + let balance = client.balance(0).await.unwrap(); + + assert_eq!(balance.coins.atoms(), Some(5_000_000_000_000)); + assert_eq!(balance.coins.decimal(), Some("50.0")); + let token = balance.tokens.get("mmltk1x").expect("token balance present"); + assert_eq!(token.atoms(), Some(700)); + assert_eq!(token.decimal(), Some("0.7")); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn new_address_extracts_field() { + let server = MockServer::start(); + let mock = mock_rpc( + &server, + "\"method\":\"address_new\"".to_string(), + rpc_ok(1, json!({ "address": "mtc1qxyz" })), + ); + + let client = Client::new(server.url("/")); + assert_eq!(client.new_address(0).await.unwrap(), "mtc1qxyz"); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn send_params_wire_shape() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/").matches(|req| { + let body = + std::str::from_utf8(req.body.as_deref().unwrap_or_default()).unwrap_or_default(); + body.contains("\"method\":\"address_send\"") + && body.contains("\"amount\":{\"atoms\":\"1000000000000\"}") + && body.contains("\"selected_utxos\":[]") + && !body.contains("\"selected_utxos\":null") + && body.contains("\"options\":{\"in_top_x_mb\":null,\"broadcast_to_mempool\":null}") + }); + respond(then, rpc_ok(1, send_result())); + }); + + let client = Client::new(server.url("/")); + let params = SendParams { + account: 0, + address: "mtc1qsending".to_string(), + amount: Amount::from_atoms(1_000_000_000_000), + selected_utxos: Vec::new(), + options: TxOptions::default(), + }; + let result = client.send(params).await.unwrap(); + + assert_eq!(result.tx_id, "f0f1f2"); + assert_eq!(result.fees.coins.atoms(), Some(100)); + assert_eq!(result.fees.coins.decimal(), Some("0.000000001")); + assert!(result.fees.tokens.is_empty()); + assert!(result.broadcasted); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn submit_transaction_hardcodes_trusted() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("\"method\":\"node_submit_transaction\"") + .body_contains("\"tx\":\"deadbeef\"") + .body_contains("\"do_not_store\":true") + .body_contains("\"options\":{\"trust_policy\":\"Trusted\"}"); + respond(then, rpc_ok(1, json!({ "tx_id": "aabb" }))); + }); + + let client = Client::new(server.url("/")); + let result = client.submit_transaction("deadbeef", true).await.unwrap(); + assert_eq!(result.tx_id, "aabb"); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn reveal_public_key_returns_struct() { + let server = MockServer::start(); + let mock = mock_rpc( + &server, + "\"method\":\"address_reveal_public_key\"".to_string(), + rpc_ok( + 1, + json!({ + "public_key_hex": "02a1b2c3d4e5f60718", + "public_key_address": "mtc1qkeyaddr", + }), + ), + ); + + let client = Client::new(server.url("/")); + let key = client.reveal_public_key(0, "mtc1qxyz").await.unwrap(); + assert_eq!(key.public_key_hex, "02a1b2c3d4e5f60718"); + assert_eq!(key.public_key_address, "mtc1qkeyaddr"); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn pool_balance_null_and_value() { + let server = MockServer::start(); + let value_mock = mock_rpc( + &server, + "\"id\":1,\"method\":\"staking_pool_balance\"".to_string(), + rpc_ok( + 1, + json!({ "balance": { "atoms": "100", "decimal": "0.000000001" } }), + ), + ); + let null_mock = mock_rpc( + &server, + "\"id\":2,\"method\":\"staking_pool_balance\"".to_string(), + rpc_ok(2, json!({ "balance": null })), + ); + + let client = Client::new(server.url("/")); + let balance = client.pool_balance("pool1abc").await.unwrap().expect("pool has a balance"); + assert_eq!(balance.atoms(), Some(100)); + assert_eq!(balance.decimal(), Some("0.000000001")); + assert!(client.pool_balance("pool1abc").await.unwrap().is_none()); + + assert_eq!(value_mock.hits(), 1); + assert_eq!(null_mock.hits(), 1); +} + +#[tokio::test] +async fn staking_status_parses_enum() { + let server = MockServer::start(); + let staking_mock = mock_rpc( + &server, + "\"id\":1,\"method\":\"staking_status\"".to_string(), + rpc_ok(1, json!("Staking")), + ); + let not_staking_mock = mock_rpc( + &server, + "\"id\":2,\"method\":\"staking_status\"".to_string(), + rpc_ok(2, json!("NotStaking")), + ); + + let client = Client::new(server.url("/")); + assert_eq!( + client.staking_status(0).await.unwrap(), + StakingStatus::Staking + ); + assert_eq!( + client.staking_status(0).await.unwrap(), + StakingStatus::NotStaking + ); + + assert_eq!(staking_mock.hits(), 1); + assert_eq!(not_staking_mock.hits(), 1); +} + +#[tokio::test] +async fn create_order_wire_shapes() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/").matches(|req| { + let body = + std::str::from_utf8(req.body.as_deref().unwrap_or_default()).unwrap_or_default(); + body.contains("\"method\":\"order_create\"") + && body.contains( + "\"ask\":{\"type\":\"Coin\",\"content\":{\"amount\":{\"atoms\":\"1000000000000\"}}}", + ) + && body.contains( + "\"give\":{\"type\":\"Token\",\"content\":{\"id\":\"mmltk1x\",\"amount\":{\"atoms\":\"700\"}}}", + ) + }); + respond( + then, + rpc_ok( + 1, + json!({ + "order_id": "order1abc", + "tx_id": "0f0e0d", + "broadcasted": true, + }), + ), + ); + }); + + let client = Client::new(server.url("/")); + let params = CreateOrderParams { + account: 0, + ask: OutputValue::coins(1_000_000_000_000), + give: OutputValue::token("mmltk1x", 700), + conclude_address: "mtc1qconclude".to_string(), + options: TxOptions::default(), + }; + let order = client.create_order(params).await.unwrap(); + + assert_eq!(order.order_id, "order1abc"); + assert_eq!(order.tx_id, "0f0e0d"); + assert!(order.broadcasted); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn list_all_active_orders_null_filters() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("\"method\":\"order_list_all_active\"") + .body_contains("\"ask_currency\":null") + .body_contains("\"give_currency\":null"); + respond( + then, + rpc_ok( + 1, + json!([ + { + "order_id": "order1xyz", + "initially_asked": { + "type": "Coin", + "content": { "amount": { "atoms": "1000" } }, + }, + "initially_given": { + "type": "Token", + "content": { "id": "mmltk1x", "amount": { "atoms": "50" } }, + }, + "ask_balance": { "atoms": "900" }, + "give_balance": { "atoms": "45" }, + "is_own": false, + }, + ]), + ), + ); + }); + + let client = Client::new(server.url("/")); + let params = ListOrdersParams { + account: 0, + ask_currency: None, + give_currency: None, + }; + let orders = client.list_all_active_orders(params).await.unwrap(); + + assert_eq!(orders.len(), 1); + assert_eq!(orders[0].order_id, "order1xyz"); + assert_eq!(orders[0].ask_balance.atoms(), Some(900)); + assert_eq!(orders[0].give_balance.atoms(), Some(45)); + assert!(!orders[0].is_own); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn lock_supply_uses_account_index() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/").matches(|req| { + let body = + std::str::from_utf8(req.body.as_deref().unwrap_or_default()).unwrap_or_default(); + body.contains("\"method\":\"token_lock_supply\"") + && body.contains("\"account_index\":3") + && !body.contains("\"account\":") + }); + respond(then, rpc_ok(1, send_result())); + }); + + let client = Client::new(server.url("/")); + let params = LockSupplyParams { + account_index: 3, + token_id: "mmltk1x".to_string(), + options: TxOptions::default(), + }; + client.lock_token_supply(params).await.unwrap(); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn rpc_error_is_surfaced() { + let server = MockServer::start(); + mock_rpc( + &server, + "\"jsonrpc\"".to_string(), + json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { "code": -32601, "message": "Method not found" }, + }), + ); + + let client = Client::new(server.url("/")); + let err = client.best_block().await.unwrap_err(); + let display = err.to_string(); + match err { + Error::Rpc { code, message } => { + assert_eq!(code, -32601); + assert_eq!(message, "Method not found"); + } + other => panic!("expected Error::Rpc, got {other:?}"), + } + assert_eq!(display, "RPC error -32601: Method not found"); +} + +#[tokio::test] +async fn output_value_and_currency_filter_serde() { + assert_eq!( + serde_json::to_string(&Amount::from_atoms(1_000_000_000_000)).unwrap(), + r#"{"atoms":"1000000000000"}"# + ); + assert_eq!( + serde_json::to_string(&OutputValue::coins(1_000_000_000_000)).unwrap(), + r#"{"type":"Coin","content":{"amount":{"atoms":"1000000000000"}}}"# + ); + assert_eq!( + serde_json::to_string(&OutputValue::token("mmltk1x", 700)).unwrap(), + r#"{"type":"Token","content":{"id":"mmltk1x","amount":{"atoms":"700"}}}"# + ); + assert_eq!( + serde_json::to_string(&CurrencyFilter::Coin).unwrap(), + r#"{"type":"Coin"}"# + ); + assert_eq!( + serde_json::to_string(&CurrencyFilter::Token("mmltk1x".to_string())).unwrap(), + r#"{"type":"Token","content":"mmltk1x"}"# + ); + + let amount: Amount = serde_json::from_value(json!({ "atoms": "1000" })).unwrap(); + assert_eq!(amount.atoms(), Some(1000)); + assert_eq!(amount.decimal(), None); + let amount: Amount = serde_json::from_value(json!({ "atoms": 1000 })).unwrap(); + assert_eq!(amount.atoms(), Some(1000)); + let amount: Amount = + serde_json::from_value(json!({ "atoms": "1000", "decimal": "0.00000001" })).unwrap(); + assert_eq!(amount.atoms(), Some(1000)); + assert_eq!(amount.decimal(), Some("0.00000001")); +} + +#[tokio::test] +async fn concurrent_calls_use_unique_ids() { + let server = MockServer::start(); + let mut mocks = Vec::new(); + for n in 1..=10u64 { + let matcher = format!("\"id\":{n},\"method\":\"wallet_best_block\""); + let block = json!({ "height": n, "id": format!("{n:064x}") }); + mocks.push(mock_rpc(&server, matcher, rpc_ok(n, block))); + } + + let client = Client::new(server.url("/")); + let shared = Arc::new(client); + let mut handles = Vec::new(); + for _ in 0..10 { + let client = Arc::clone(&shared); + handles.push(tokio::spawn(async move { + client.best_block().await.unwrap().height + })); + } + + let mut heights = Vec::new(); + for handle in handles { + heights.push(handle.await.unwrap()); + } + heights.sort_unstable(); + assert_eq!(heights, (1u64..=10).collect::>()); + for mock in &mocks { + assert_eq!(mock.hits(), 1); + } +} From bc81900c418dcbda305431e4ea544522308df45b Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 16 Sep 2026 23:54:54 +0400 Subject: [PATCH 08/29] wallet: redact mnemonics/passphrases from Debug, require response id (security review fixes) --- src/jsonrpc.rs | 10 +++++++-- src/wallet/types.rs | 36 +++++++++++++++++++++++++++++--- tests/node.rs | 37 ++++++++++++++++++++++++++++---- tests/wallet.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 124 insertions(+), 10 deletions(-) diff --git a/src/jsonrpc.rs b/src/jsonrpc.rs index aadbd69..659543f 100644 --- a/src/jsonrpc.rs +++ b/src/jsonrpc.rs @@ -186,13 +186,19 @@ impl Transport { }); } match response.id { - Some(actual) if actual.as_u64() != Some(id) => { + Some(actual) if actual.as_u64() == Some(id) => {} + Some(actual) => { return Err(RequestError::IdMismatch { expected: id, actual, }); } - _ => {} + None => { + return Err(RequestError::IdMismatch { + expected: id, + actual: serde_json::Value::Null, + }); + } } Ok(serde_json::from_value(response.result)?) } diff --git a/src/wallet/types.rs b/src/wallet/types.rs index 9b0d3d3..009f877 100644 --- a/src/wallet/types.rs +++ b/src/wallet/types.rs @@ -171,12 +171,18 @@ pub struct WalletTx { } /// A mnemonic as returned by the wallet daemon. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MnemonicContent { /// The seed phrase words. pub mnemonic: String, } +impl std::fmt::Debug for MnemonicContent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MnemonicContent").field("mnemonic", &"").finish() + } +} + /// The mnemonic section of a [`CreateWalletResult`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MnemonicResult { @@ -195,7 +201,7 @@ pub struct CreateWalletResult { } /// Parameters for [`Client::create_wallet`](crate::wallet::Client::create_wallet). -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[derive(Clone, Default, PartialEq, Eq, Serialize)] pub struct CreateWalletParams { /// File path for the wallet database. pub path: String, @@ -209,8 +215,20 @@ pub struct CreateWalletParams { pub hardware_wallet: Option, } +impl std::fmt::Debug for CreateWalletParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CreateWalletParams") + .field("path", &self.path) + .field("store_seed_phrase", &self.store_seed_phrase) + .field("mnemonic", &self.mnemonic.as_ref().map(|_| "***")) + .field("passphrase", &self.passphrase.as_ref().map(|_| "***")) + .field("hardware_wallet", &self.hardware_wallet) + .finish() + } +} + /// Parameters for [`Client::recover_wallet`](crate::wallet::Client::recover_wallet). -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Clone, PartialEq, Eq, Serialize)] pub struct RecoverWalletParams { /// File path for the wallet database. pub path: String, @@ -224,6 +242,18 @@ pub struct RecoverWalletParams { pub hardware_wallet: Option, } +impl std::fmt::Debug for RecoverWalletParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RecoverWalletParams") + .field("path", &self.path) + .field("store_seed_phrase", &self.store_seed_phrase) + .field("mnemonic", &"***") + .field("passphrase", &self.passphrase.as_ref().map(|_| "***")) + .field("hardware_wallet", &self.hardware_wallet) + .finish() + } +} + /// Extra wallet information. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WalletExtraInfo { diff --git a/tests/node.rs b/tests/node.rs index 2d89d85..7be0cca 100644 --- a/tests/node.rs +++ b/tests/node.rs @@ -110,17 +110,46 @@ async fn scalar_methods_and_id_counter() { #[tokio::test] async fn optional_results_decode_null_as_none() { let server = MockServer::start(); - let mock = mock_rpc( + // Each response must carry the id of its request; the calls below run + // sequentially, so request ids 1, 2, 3 map 1:1 onto the three mocks. + let block_mock = mock_rpc( &server, - "\"jsonrpc\"".to_string(), - rpc_ok_no_id(json!(null)), + "\"id\":1,\"method\":\"chainstate_block_id_at_height\"".to_string(), + rpc_ok(1, json!(null)), + ); + let pool_mock = mock_rpc( + &server, + "\"id\":2,\"method\":\"chainstate_stake_pool_balance\"".to_string(), + rpc_ok(2, json!(null)), + ); + let token_mock = mock_rpc( + &server, + "\"id\":3,\"method\":\"chainstate_token_info\"".to_string(), + rpc_ok(3, json!(null)), ); let client = Client::new(server.url("/")); assert_eq!(client.block_id_at_height(999_999).await.unwrap(), None); assert_eq!(client.stake_pool_balance("pool1abc").await.unwrap(), None); assert_eq!(client.token_info("token1abc").await.unwrap(), None); - assert_eq!(mock.hits(), 3); + assert_eq!(block_mock.hits(), 1); + assert_eq!(pool_mock.hits(), 1); + assert_eq!(token_mock.hits(), 1); +} + +#[tokio::test] +async fn missing_response_id_is_rejected() { + let server = MockServer::start(); + mock_rpc(&server, "\"jsonrpc\"".to_string(), rpc_ok_no_id(json!(42))); + + let client = Client::new(server.url("/")); + match client.best_block_height().await { + Err(Error::IdMismatch { expected, actual }) => { + assert_eq!(expected, 1); + assert_eq!(actual, serde_json::Value::Null); + } + other => panic!("expected IdMismatch, got: {other:?}"), + } } #[tokio::test] diff --git a/tests/wallet.rs b/tests/wallet.rs index 8dac119..7f556cf 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -15,7 +15,7 @@ use serde_json::json; use mintlayer_sdk::wallet::{ Amount, Client, CreateOrderParams, CreateWalletParams, CurrencyFilter, Error, ListOrdersParams, - LockSupplyParams, OutputValue, SendParams, StakingStatus, TxOptions, + LockSupplyParams, OutputValue, RecoverWalletParams, SendParams, StakingStatus, TxOptions, }; const RESPONSE_HEADERS: [(&str, &str); 1] = [("content-type", "application/json")]; @@ -496,3 +496,52 @@ async fn concurrent_calls_use_unique_ids() { assert_eq!(mock.hits(), 1); } } + +#[tokio::test] +async fn debug_output_redacts_mnemonics_and_passphrases() { + let create_params = CreateWalletParams { + mnemonic: Some("secret words here".to_string()), + passphrase: Some("secret-pass".to_string()), + path: "/tmp/w.dat".to_string(), + store_seed_phrase: true, + hardware_wallet: None, + }; + let create_debug = format!("{create_params:?}"); + assert!(!create_debug.contains("secret words here")); + assert!(!create_debug.contains("secret-pass")); + assert!(create_debug.contains("***")); + + let recover_params = RecoverWalletParams { + mnemonic: "secret words here".to_string(), + passphrase: Some("secret-pass".to_string()), + path: "/tmp/w.dat".to_string(), + store_seed_phrase: true, + hardware_wallet: None, + }; + let recover_debug = format!("{recover_params:?}"); + assert!(!recover_debug.contains("secret words here")); + assert!(!recover_debug.contains("secret-pass")); + assert!(recover_debug.contains("***")); + + // The mnemonic returned by the daemon is redacted in result debug output. + let server = MockServer::start(); + mock_rpc( + &server, + "\"method\":\"wallet_create\"".to_string(), + rpc_ok( + 1, + json!({ + "mnemonic": { + "type": "NewlyGenerated", + "content": { "mnemonic": "top secret seed phrase" }, + }, + }), + ), + ); + + let client = Client::new(server.url("/")); + let result = client.create_wallet(create_params).await.unwrap(); + let result_debug = format!("{result:?}"); + assert!(!result_debug.contains("top secret seed phrase")); + assert!(result_debug.contains("")); +} From 9fcee90ef543b0ac8f9e7b5084754ceef61f0270 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 16 Sep 2026 23:54:54 +0400 Subject: [PATCH 09/29] crypto: native transaction building and signing backed by mintlayer-core --- Cargo.lock | 1 + Cargo.toml | 2 + src/crypto/addresses.rs | 60 ++++++++ src/crypto/error.rs | 98 ++++++++++++ src/crypto/fees.rs | 43 ++++++ src/crypto/ids.rs | 64 ++++++++ src/crypto/inputs.rs | 208 ++++++++++++++++++++++++++ src/crypto/intents.rs | 75 ++++++++++ src/crypto/keys.rs | 123 +++++++++++++++ src/crypto/mod.rs | 182 +++++++++++++++++++++- src/crypto/outputs.rs | 281 ++++++++++++++++++++++++++++++++++ src/crypto/signing.rs | 298 +++++++++++++++++++++++++++++++++++++ src/crypto/staking.rs | 66 ++++++++ src/crypto/timelocks.rs | 33 ++++ src/crypto/transactions.rs | 221 +++++++++++++++++++++++++++ 15 files changed, 1754 insertions(+), 1 deletion(-) create mode 100644 src/crypto/addresses.rs create mode 100644 src/crypto/error.rs create mode 100644 src/crypto/fees.rs create mode 100644 src/crypto/ids.rs create mode 100644 src/crypto/inputs.rs create mode 100644 src/crypto/intents.rs create mode 100644 src/crypto/keys.rs create mode 100644 src/crypto/outputs.rs create mode 100644 src/crypto/signing.rs create mode 100644 src/crypto/staking.rs create mode 100644 src/crypto/timelocks.rs create mode 100644 src/crypto/transactions.rs diff --git a/Cargo.lock b/Cargo.lock index 5d970bc..2023d09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2160,6 +2160,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tx-verifier", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 78b692a..52aeb4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ crypto = [ "dep:ml-serialization", "dep:ml-tx-verifier", "dep:bip39", + "dep:zeroize", ] # All sub-clients. full = ["node", "indexer", "wallet", "crypto"] @@ -52,6 +53,7 @@ bip39 = { version = "2.0", optional = true, default-features = false, features = "std", "zeroize", ] } +zeroize = { version = "1.5", optional = true, default-features = false } ml-common = { package = "common", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } ml-consensus = { package = "consensus", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } ml-crypto = { package = "crypto", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } diff --git a/src/crypto/addresses.rs b/src/crypto/addresses.rs new file mode 100644 index 0000000..65f31ac --- /dev/null +++ b/src/crypto/addresses.rs @@ -0,0 +1,60 @@ +// 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. + +//! Address and multisig-challenge encoding. + +use std::num::NonZeroU8; + +use ml_common as common; +use ml_crypto as crypto; + +use common::address::pubkeyhash::PublicKeyHash; +use common::chain::{Destination, classic_multisig::ClassicMultisigChallenge}; +use crypto::key::PublicKey; + +use super::{Error, Network, parse_addressable}; + +/// Parses a bech32 address into a [`Destination`]. +pub fn encode_destination(address: &str, network: Network) -> Result { + parse_addressable(network, address) +} + +/// Returns the bech32 pubkeyhash address of a public key. +pub fn pubkey_to_pubkeyhash_address(public_key: &PublicKey, network: Network) -> String { + let public_key_hash = PublicKeyHash::from(public_key); + common::address::Address::new( + super::chain_config(network), + Destination::PublicKeyHash(public_key_hash), + ) + .expect("pubkeyhash address creation must not fail") + .to_string() +} + +/// Builds a classic multisig challenge from concatenated public keys. +pub fn encode_multisig_challenge( + public_keys: &[PublicKey], + min_required_signatures: u8, + network: Network, +) -> Result { + let min_sigs = + NonZeroU8::new(min_required_signatures).ok_or(Error::ZeroMultisigRequiredSignatures)?; + ClassicMultisigChallenge::new(super::chain_config(network), min_sigs, public_keys.to_vec()) + .map_err(|error| Error::MessageSigning(error.to_string())) +} + +/// Returns the bech32 address of a multisig challenge. +pub fn multisig_challenge_to_address( + challenge: &ClassicMultisigChallenge, + network: Network, +) -> String { + let pkh = PublicKeyHash::from(challenge); + common::address::Address::new( + super::chain_config(network), + Destination::ClassicMultisig(pkh), + ) + .expect("multisig address creation must not fail") + .to_string() +} diff --git a/src/crypto/error.rs b/src/crypto/error.rs new file mode 100644 index 0000000..c6780fb --- /dev/null +++ b/src/crypto/error.rs @@ -0,0 +1,98 @@ +// 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. + +//! Error type for the [`crypto`](crate::crypto) module. + +/// Errors produced by the cryptography and transaction-building operations. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The BIP39 mnemonic could not be parsed. + #[error("invalid mnemonic: {0}")] + InvalidMnemonic(#[from] bip39::Error), + /// A SCALE-encoded object could not be decoded. + #[error("decoding failed: {0}")] + Decoding(#[from] ml_serialization::Error), + /// A bech32 address could not be parsed for the given network. + #[error("failed to parse address `{address}`: {message}")] + AddressParse { + /// The offending address. + address: String, + /// The reason the address is invalid. + message: String, + }, + /// The key index has its most significant bit set, which is not allowed. + #[error("key index must not have the most significant bit set")] + InvalidKeyIndex, + /// A fixed total supply was requested without supplying the cap. + #[error("fixed total supply requires a supply amount")] + FixedTotalSupplyWithoutAmount, + /// The margin ratio is out of the valid range. + #[error("invalid margin ratio per thousand: {0}")] + InvalidMarginRatio(u16), + /// Multisig challenges require at least one required signature. + #[error("multisig requires at least one signature")] + ZeroMultisigRequiredSignatures, + /// The NFT creator field is not a valid public key. + #[error("invalid NFT creator public key: {0}")] + InvalidNftCreatorPublicKey(String), + /// Token issuance parameters failed chain validation. + #[error("invalid token parameters: {0}")] + InvalidTokenParameters(String), + /// The transaction could not be constructed from its inputs and outputs. + #[error("failed to create transaction: {0}")] + TransactionCreation(String), + /// The signed transaction could not be constructed. + #[error("failed to create signed transaction: {0}")] + SignedTransactionCreation(String), + /// The partially signed transaction failed its consistency check. + #[error("failed to create partially signed transaction: {0}")] + PartiallySignedTransactionCreation(String), + /// An input witness could not be produced. + #[error("failed to sign input: {0}")] + InputSigning(String), + /// The sighash could not be calculated. + #[error("failed to calculate sighash: {0}")] + Sighash(String), + /// A message could not be signed. + #[error("failed to sign message: {0}")] + MessageSigning(String), + /// A signature could not be verified. + #[error("signature verification failed: {0}")] + SignatureVerification(String), + /// The HTLC secret hash is malformed. + #[error("invalid HTLC secret hash: {0}")] + InvalidHtlcSecretHash(String), + /// The witness is not an HTLC spend of the expected variant. + #[error("unexpected HTLC spend type")] + UnexpectedHtlcSpendType, + /// The transaction contains no input matching the given outpoint. + #[error("no input found for the given outpoint")] + NoInputOutpointFound, + /// The transaction has fewer witnesses than the requested input index. + #[error("no witness found for the given input index")] + InvalidWitnessCount, + /// The transaction size could not be estimated. + #[error("failed to estimate transaction size: {0}")] + SizeEstimation(String), + /// The effective pool balance could not be calculated. + #[error("failed to calculate effective pool balance: {0}")] + EffectiveBalance(String), + /// Order freezing requires the orders V1 fork to be active. + #[error("orders V1 is not active at the specified height")] + OrdersV1NotActivated, + /// The intent message is not valid UTF-8. + #[error("transaction intent message is not a valid UTF-8 string")] + IntentMessageNotUtf8, + /// The transaction id is not a valid hex string. + #[error("invalid transaction id: {0}")] + InvalidTransactionId(String), + /// The signed intent could not be verified. + #[error("transaction intent verification failed: {0}")] + IntentVerification(String), + /// A JSON representation could not be produced. + #[error("failed to produce JSON: {0}")] + Json(String), +} diff --git a/src/crypto/fees.rs b/src/crypto/fees.rs new file mode 100644 index 0000000..a0dc68d --- /dev/null +++ b/src/crypto/fees.rs @@ -0,0 +1,43 @@ +// 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. + +//! Static and height-dependent protocol fees. + +use ml_common as common; + +use common::primitives::{Amount, BlockHeight}; + +use super::Network; + +/// The fee for issuing a new fungible token. +pub fn fungible_token_issuance_fee(_current_block_height: u64, network: Network) -> Amount { + super::chain_config(network).fungible_token_issuance_fee() +} + +/// The fee for issuing a new NFT. +pub fn nft_issuance_fee(current_block_height: u64, network: Network) -> Amount { + super::chain_config(network).nft_issuance_fee(BlockHeight::new(current_block_height)) +} + +/// The fee for minting or unminting tokens. +pub fn token_supply_change_fee(current_block_height: u64, network: Network) -> Amount { + super::chain_config(network).token_supply_change_fee(BlockHeight::new(current_block_height)) +} + +/// The fee for freezing or unfreezing a token. +pub fn token_freeze_fee(current_block_height: u64, network: Network) -> Amount { + super::chain_config(network).token_freeze_fee(BlockHeight::new(current_block_height)) +} + +/// The fee for changing the authority of a token. +pub fn token_change_authority_fee(current_block_height: u64, network: Network) -> Amount { + super::chain_config(network).token_change_authority_fee(BlockHeight::new(current_block_height)) +} + +/// The fee for a data deposit output. +pub fn data_deposit_fee(current_block_height: u64, network: Network) -> Amount { + super::chain_config(network).data_deposit_fee(BlockHeight::new(current_block_height)) +} diff --git a/src/crypto/ids.rs b/src/crypto/ids.rs new file mode 100644 index 0000000..d8051ea --- /dev/null +++ b/src/crypto/ids.rs @@ -0,0 +1,64 @@ +// 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. + +//! Derivation of token, pool, delegation and order ids from transaction +//! inputs. + +use ml_common as common; + +use common::address::Address; +use common::chain::{TxInput, make_delegation_id, make_order_id, make_pool_id, make_token_id}; +use common::primitives::BlockHeight; + +use super::{Error, Network, chain_config}; + +/// Returns the bech32 token id produced by issuing a token with these inputs. +/// +/// The id scheme depends on the network upgrade active at +/// `current_block_height`. +pub fn get_token_id( + inputs: &[TxInput], + current_block_height: u64, + network: Network, +) -> Result { + let token_id = make_token_id( + chain_config(network), + BlockHeight::new(current_block_height), + inputs, + ) + .map_err(|error| Error::TransactionCreation(error.to_string()))?; + Ok(token_address(token_id, network)) +} + +/// Returns the bech32 order id produced by creating an order with these +/// inputs. +pub fn get_order_id(inputs: &[TxInput], network: Network) -> Result { + let order_id = + make_order_id(inputs).map_err(|error| Error::TransactionCreation(error.to_string()))?; + Ok(token_address(order_id, network)) +} + +/// Returns the bech32 delegation id produced by creating a delegation with +/// these inputs. +pub fn get_delegation_id(inputs: &[TxInput], network: Network) -> Result { + let delegation_id = make_delegation_id(inputs) + .map_err(|error| Error::TransactionCreation(error.to_string()))?; + Ok(token_address(delegation_id, network)) +} + +/// Returns the bech32 pool id produced by creating a stake pool with these +/// inputs. +pub fn get_pool_id(inputs: &[TxInput], network: Network) -> Result { + let pool_id = + make_pool_id(inputs).map_err(|error| Error::TransactionCreation(error.to_string()))?; + Ok(token_address(pool_id, network)) +} + +fn token_address(object: T, network: Network) -> String { + Address::new(chain_config(network), object) + .expect("id address creation must not fail") + .to_string() +} diff --git a/src/crypto/inputs.rs b/src/crypto/inputs.rs new file mode 100644 index 0000000..46585f4 --- /dev/null +++ b/src/crypto/inputs.rs @@ -0,0 +1,208 @@ +// 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. + +//! Transaction input constructors. + +use ml_common as common; + +use common::chain::{ + AccountCommand, AccountNonce, AccountOutPoint, AccountSpending, OrderAccountCommand, + OrdersVersion, OutPointSourceId, TxInput, UtxoOutPoint, +}; +use common::primitives::{Amount, BlockHeight}; + +use super::{Error, IsTokenUnfreezable, Network, parse_addressable}; + +/// Builds a UTXO spend input. +#[must_use] +pub fn encode_input_for_utxo(outpoint_source_id: OutPointSourceId, output_index: u32) -> TxInput { + TxInput::Utxo(UtxoOutPoint::new(outpoint_source_id, output_index)) +} + +/// Builds an input that withdraws from a delegation. +/// +/// The nonce must be in sequence with the other account spendings of the +/// delegation owner. +pub fn encode_input_for_withdraw_from_delegation( + delegation_id: &str, + amount: Amount, + nonce: u64, + network: Network, +) -> Result { + let delegation_id = parse_addressable(network, delegation_id)?; + Ok(TxInput::Account(AccountOutPoint::new( + AccountNonce::new(nonce), + AccountSpending::DelegationBalance(delegation_id, amount), + ))) +} + +/// Builds a mint-tokens input. +pub fn encode_input_for_mint_tokens( + token_id: &str, + amount: Amount, + nonce: u64, + network: Network, +) -> Result { + let token_id = parse_addressable(network, token_id)?; + Ok(TxInput::AccountCommand( + AccountNonce::new(nonce), + AccountCommand::MintTokens(token_id, amount), + )) +} + +/// Builds an unmint-tokens input. +pub fn encode_input_for_unmint_tokens( + token_id: &str, + nonce: u64, + network: Network, +) -> Result { + let token_id = parse_addressable(network, token_id)?; + Ok(TxInput::AccountCommand( + AccountNonce::new(nonce), + AccountCommand::UnmintTokens(token_id), + )) +} + +/// Builds a lock-token-supply input. +pub fn encode_input_for_lock_token_supply( + token_id: &str, + nonce: u64, + network: Network, +) -> Result { + let token_id = parse_addressable(network, token_id)?; + Ok(TxInput::AccountCommand( + AccountNonce::new(nonce), + AccountCommand::LockTokenSupply(token_id), + )) +} + +/// Builds a freeze-token input. +pub fn encode_input_for_freeze_token( + token_id: &str, + is_token_unfreezable: IsTokenUnfreezable, + nonce: u64, + network: Network, +) -> Result { + let token_id = parse_addressable(network, token_id)?; + Ok(TxInput::AccountCommand( + AccountNonce::new(nonce), + AccountCommand::FreezeToken(token_id, is_token_unfreezable), + )) +} + +/// Builds an unfreeze-token input. +pub fn encode_input_for_unfreeze_token( + token_id: &str, + nonce: u64, + network: Network, +) -> Result { + let token_id = parse_addressable(network, token_id)?; + Ok(TxInput::AccountCommand( + AccountNonce::new(nonce), + AccountCommand::UnfreezeToken(token_id), + )) +} + +/// Builds a change-token-authority input. +pub fn encode_input_for_change_token_authority( + token_id: &str, + new_authority: &str, + nonce: u64, + network: Network, +) -> Result { + let token_id = parse_addressable(network, token_id)?; + let new_authority = parse_addressable(network, new_authority)?; + Ok(TxInput::AccountCommand( + AccountNonce::new(nonce), + AccountCommand::ChangeTokenAuthority(token_id, new_authority), + )) +} + +/// Builds a change-token-metadata-URI input. +pub fn encode_input_for_change_token_metadata_uri( + token_id: &str, + new_metadata_uri: &str, + nonce: u64, + network: Network, +) -> Result { + let token_id = parse_addressable(network, token_id)?; + Ok(TxInput::AccountCommand( + AccountNonce::new(nonce), + AccountCommand::ChangeTokenMetadataUri(token_id, new_metadata_uri.as_bytes().to_vec()), + )) +} + +/// Builds a fill-order input. +/// +/// Fill-order inputs must not be signed; use +/// [`encode_witness_no_signature`](super::encode_witness_no_signature) for +/// them. Before the orders V1 fork the nonce is significant; afterwards it is +/// ignored. +pub fn encode_input_for_fill_order( + order_id: &str, + fill_amount: Amount, + destination: &str, + nonce: u64, + current_block_height: u64, + network: Network, +) -> Result { + let order_id = parse_addressable(network, order_id)?; + let destination = parse_addressable(network, destination)?; + match orders_version(network, current_block_height)? { + OrdersVersion::V0 => Ok(TxInput::AccountCommand( + AccountNonce::new(nonce), + AccountCommand::FillOrder(order_id, fill_amount, destination), + )), + OrdersVersion::V1 => Ok(TxInput::OrderAccountCommand( + OrderAccountCommand::FillOrder(order_id, fill_amount), + )), + } +} + +/// Builds a freeze-order input (orders V1 only). +pub fn encode_input_for_freeze_order( + order_id: &str, + current_block_height: u64, + network: Network, +) -> Result { + let order_id = parse_addressable(network, order_id)?; + match orders_version(network, current_block_height)? { + OrdersVersion::V0 => Err(Error::OrdersV1NotActivated), + OrdersVersion::V1 => Ok(TxInput::OrderAccountCommand( + OrderAccountCommand::FreezeOrder(order_id), + )), + } +} + +/// Builds a conclude-order input. +/// +/// Before the orders V1 fork the nonce is significant; afterwards it is +/// ignored. +pub fn encode_input_for_conclude_order( + order_id: &str, + nonce: u64, + current_block_height: u64, + network: Network, +) -> Result { + let order_id = parse_addressable(network, order_id)?; + match orders_version(network, current_block_height)? { + OrdersVersion::V0 => Ok(TxInput::AccountCommand( + AccountNonce::new(nonce), + AccountCommand::ConcludeOrder(order_id), + )), + OrdersVersion::V1 => Ok(TxInput::OrderAccountCommand( + OrderAccountCommand::ConcludeOrder(order_id), + )), + } +} + +fn orders_version(network: Network, current_block_height: u64) -> Result { + Ok(super::chain_config(network) + .chainstate_upgrades() + .version_at_height(BlockHeight::new(current_block_height)) + .1 + .orders_version()) +} diff --git a/src/crypto/intents.rs b/src/crypto/intents.rs new file mode 100644 index 0000000..07c11af --- /dev/null +++ b/src/crypto/intents.rs @@ -0,0 +1,75 @@ +// 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. + +//! Signed transaction intents. + +use ml_common as common; +use ml_serialization as serialization; + +use common::chain::SignedTransactionIntent; +use common::primitives::Id; +use serialization::DecodeAll; + +use super::{Error, Network, parse_addressable, transactions::h256_from_str}; + +/// Returns the message that has to be signed for a transaction intent. +/// +/// Every input destination of the transaction must sign this message with +/// [`sign_challenge`](super::sign_challenge). +pub fn make_transaction_intent_message_to_sign( + intent: &str, + transaction_id_hex: &str, +) -> Result { + let hash = h256_from_str(transaction_id_hex)?; + let transaction_id = Id::::new(hash); + Ok(SignedTransactionIntent::get_message_to_sign( + intent, + &transaction_id, + )) +} + +/// Assembles a signed transaction intent. +/// +/// `signatures` holds one challenge signature per transaction input, in +/// input order, produced by +/// [`sign_challenge`](super::sign_challenge). +pub fn encode_signed_transaction_intent( + signed_message: &str, + signatures: Vec>, +) -> Result { + Ok(SignedTransactionIntent::from_components_unchecked( + signed_message.to_owned(), + signatures, + )) +} + +/// Verifies a signed transaction intent. +/// +/// `input_destinations` holds the address of each transaction input +/// destination; public key and public key hash addresses are treated +/// interchangeably. +pub fn verify_transaction_intent( + expected_signed_message: &str, + encoded_signed_intent: &[u8], + input_destinations: &[&str], + network: Network, +) -> Result<(), Error> { + let signed_intent = SignedTransactionIntent::decode_all(&mut &encoded_signed_intent[..]) + .map_err(Error::from)?; + + let destinations = input_destinations + .iter() + .map(|address| parse_addressable::(network, address)) + .collect::, Error>>()?; + + signed_intent + .verify( + super::chain_config(network), + &destinations, + expected_signed_message, + ) + .map_err(|error| Error::IntentVerification(error.to_string())) +} diff --git a/src/crypto/keys.rs b/src/crypto/keys.rs new file mode 100644 index 0000000..7348fa7 --- /dev/null +++ b/src/crypto/keys.rs @@ -0,0 +1,123 @@ +// 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. + +//! Key generation and BIP32-style derivation. + +use ml_common as common; +use ml_crypto as crypto; + +use common::chain::config::BIP44_PATH; +use crypto::key::{ + KeyKind, PrivateKey, + extended::{ExtendedKeyKind, ExtendedPrivateKey, ExtendedPublicKey}, + hdkd::{ + child_number::ChildNumber, derivable::Derivable, derivation_path::DerivationPath, u31::U31, + }, +}; + +use super::{Error, Network, chain_config}; + +const RECEIVE_FUNDS_INDEX: ChildNumber = ChildNumber::from_normal(U31::from_u32_with_msb(0).0); +const CHANGE_FUNDS_INDEX: ChildNumber = ChildNumber::from_normal(U31::from_u32_with_msb(1).0); + +/// Generates a new random private key from entropy. +pub fn make_private_key() -> PrivateKey { + PrivateKey::new_from_entropy(KeyKind::Secp256k1Schnorr).0 +} + +/// Creates the default account extended private key for a mnemonic. +/// +/// The derivation path is `44'/mintlayer_coin_type'/0'`. The optional BIP39 +/// `passphrase` changes the derived seed entirely and must be remembered +/// together with the mnemonic. +pub fn make_default_account_privkey( + mnemonic: &str, + network: Network, + passphrase: Option<&str>, +) -> Result { + let mnemonic = + bip39::Mnemonic::parse_in(bip39::Language::English, mnemonic).map_err(Error::from)?; + + let passphrase = passphrase.map(|p| zeroize::Zeroizing::new(p.to_owned())); + let passphrase: &str = passphrase.as_ref().map(|p| p.as_str()).unwrap_or(""); + let seed = zeroize::Zeroizing::new(mnemonic.to_seed(passphrase)); + + let root_key = ExtendedPrivateKey::new_master(seed.as_ref(), ExtendedKeyKind::Secp256k1Schnorr) + .map_err(|error| Error::MessageSigning(error.to_string()))?; + + let account_index = U31::ZERO; + let path: DerivationPath = vec![ + BIP44_PATH, + chain_config(network).bip44_coin_type(), + ChildNumber::from_hardened(account_index), + ] + .try_into() + .map_err(|error: crypto::key::hdkd::derivable::DerivationError| { + Error::InputSigning(error.to_string()) + })?; + root_key + .derive_absolute_path(&path) + .map_err(|error| Error::InputSigning(error.to_string())) +} + +/// Derives the receiving private key at `key_index` from an account key +/// (path: `/0/`). +pub fn make_receiving_address( + account_key: &ExtendedPrivateKey, + key_index: u32, +) -> Result { + derive_key(account_key.clone(), RECEIVE_FUNDS_INDEX, key_index).map(|key| key.private_key()) +} + +/// Derives the change private key at `key_index` from an account key +/// (path: `/1/`). +pub fn make_change_address( + account_key: &ExtendedPrivateKey, + key_index: u32, +) -> Result { + derive_key(account_key.clone(), CHANGE_FUNDS_INDEX, key_index).map(|key| key.private_key()) +} + +/// Derives the receiving public key at `key_index` from an extended public +/// key (watch-only derivation). +pub fn make_receiving_address_public_key( + account_public_key: &ExtendedPublicKey, + key_index: u32, +) -> Result { + derive_key(account_public_key.clone(), RECEIVE_FUNDS_INDEX, key_index) + .map(|key| key.into_public_key()) +} + +/// Derives the change public key at `key_index` from an extended public key +/// (watch-only derivation). +pub fn make_change_address_public_key( + account_public_key: &ExtendedPublicKey, + key_index: u32, +) -> Result { + derive_key(account_public_key.clone(), CHANGE_FUNDS_INDEX, key_index) + .map(|key| key.into_public_key()) +} + +/// Returns the public key corresponding to a private key. +pub fn public_key_from_private_key(private_key: &PrivateKey) -> crypto::key::PublicKey { + crypto::key::PublicKey::from_private_key(private_key) +} + +/// Returns the extended public key corresponding to an extended private key. +pub fn extended_public_key_from_extended_private_key( + account_key: &ExtendedPrivateKey, +) -> ExtendedPublicKey { + account_key.to_public_key() +} + +fn derive_key(derivable: D, branch: ChildNumber, key_index: u32) -> Result { + let index = U31::from_u32(key_index).ok_or(Error::InvalidKeyIndex)?; + derivable + .derive_child(branch) + .map_err(|error| Error::InputSigning(error.to_string()))? + .derive_child(ChildNumber::from_normal(index)) + .map_err(|error| Error::InputSigning(error.to_string())) +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 87e4425..e8fa86c 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -4,4 +4,184 @@ // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. -//! Stub — implemented in a later phase. +//! Native cryptography and transaction building backed by +//! [mintlayer-core](https://github.com/mintlayer/mintlayer-core). +//! +//! The functions mirror the go-sdk `wasm` sub-client operation for operation, +//! but exchange typed values ([`Transaction`], [`TxOutput`], [`PrivateKey`], +//! [`Amount`], ...) instead of opaque byte arrays. Every type participates in +//! the SCALE encoding, so [`Encode`] and [`DecodeAll`] give byte-level access +//! when needed. +//! +//! # Fork sensitivity +//! +//! Some functions take a `current_block_height` parameter because their +//! result depends on whether a hard fork has been activated. The value should +//! be the height of the block into which the transaction is supposed to be +//! included; a wallet that cannot predict it should use "the current tip +//! height plus one" and refuse to operate near a fork height. + +mod addresses; +mod error; +mod fees; +mod ids; +mod inputs; +mod intents; +mod keys; +mod outputs; +mod signing; +mod staking; +mod timelocks; +mod transactions; + +use std::sync::OnceLock; + +use ml_common as common; + +pub use addresses::{ + encode_destination, encode_multisig_challenge, multisig_challenge_to_address, + pubkey_to_pubkeyhash_address, +}; +pub use error::Error; +pub use fees::{ + data_deposit_fee, fungible_token_issuance_fee, nft_issuance_fee, token_change_authority_fee, + token_freeze_fee, token_supply_change_fee, +}; +pub use ids::{get_delegation_id, get_order_id, get_pool_id, get_token_id}; +pub use inputs::{ + encode_input_for_change_token_authority, encode_input_for_change_token_metadata_uri, + encode_input_for_conclude_order, encode_input_for_fill_order, encode_input_for_freeze_order, + encode_input_for_freeze_token, encode_input_for_lock_token_supply, + encode_input_for_mint_tokens, encode_input_for_unfreeze_token, encode_input_for_unmint_tokens, + encode_input_for_utxo, encode_input_for_withdraw_from_delegation, +}; +pub use intents::{ + encode_signed_transaction_intent, make_transaction_intent_message_to_sign, + verify_transaction_intent, +}; +pub use keys::{ + extended_public_key_from_extended_private_key, make_change_address, + make_change_address_public_key, make_default_account_privkey, make_private_key, + make_receiving_address, make_receiving_address_public_key, public_key_from_private_key, +}; +pub use outputs::{ + encode_create_order_output, encode_output_coin_burn, encode_output_create_delegation, + encode_output_create_stake_pool, encode_output_data_deposit, encode_output_delegate_staking, + encode_output_htlc, encode_output_issue_fungible_token, encode_output_issue_nft, + encode_output_lock_then_transfer, encode_output_produce_block_from_stake, + encode_output_token_burn, encode_output_token_lock_then_transfer, encode_output_token_transfer, + encode_output_transfer, +}; +pub use signing::{ + encode_witness, encode_witness_htlc_refund_multisig, encode_witness_htlc_refund_single_sig, + encode_witness_htlc_spend, encode_witness_no_signature, sign_challenge, + sign_message_for_spending, verify_challenge, verify_signature_for_spending, +}; +pub use staking::{ + effective_pool_balance, encode_stake_pool_data, staking_pool_spend_maturity_block_count, +}; +pub use timelocks::{ + encode_lock_for_block_count, encode_lock_for_seconds, encode_lock_until_height, + encode_lock_until_time, +}; +pub use transactions::{ + decode_partially_signed_transaction_to_json, decode_signed_transaction_to_json, + decode_transaction, decode_transaction_lenient, encode_outpoint_source_id, + encode_partially_signed_transaction, encode_signed_transaction, encode_transaction, + estimate_transaction_size, extract_htlc_secret, transaction_id, +}; + +pub use common::chain::signature::sighash::sighashtype::SigHashType; +pub use common::chain::tokens::{IsTokenFreezable, IsTokenUnfreezable, TokenTotalSupply}; + +/// The coin or token amount in atoms, the smallest indivisible unit +/// (1 ML = 100,000,000,000 atoms). +pub use common::primitives::Amount; + +/// Additional per-input information required for sighash calculation after +/// the sighash commitments fork. +pub use common::chain::partially_signed_transaction::TxAdditionalInfo; + +/// Key chain and transaction primitives re-exported from mintlayer-core, so +/// that SDK users do not need a direct dependency on it. +pub mod types { + pub use ml_common::chain::signature::inputsig::InputWitness; + pub use ml_common::chain::{ + Destination, OutPointSourceId, SignedTransaction, Transaction, TxInput, TxOutput, + classic_multisig::ClassicMultisigChallenge, htlc::HtlcSecret, stakelock::StakePoolData, + timelock::OutputTimeLock, + }; + pub use ml_common::primitives::H256; + pub use ml_crypto::key::{ + PrivateKey, PublicKey, Signature, + extended::{ExtendedPrivateKey, ExtendedPublicKey}, + }; + pub use ml_serialization::{DecodeAll, Encode}; +} + +use common::chain::config::{Builder, ChainConfig, ChainType}; + +/// The network for which an operation is performed. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum Network { + /// Production network. + #[default] + Mainnet, + /// Public test network. + Testnet, + /// Local regression-testing network. + Regtest, + /// Signet. + Signet, +} + +impl From for ChainType { + fn from(network: Network) -> Self { + match network { + Network::Mainnet => Self::Mainnet, + Network::Testnet => Self::Testnet, + Network::Regtest => Self::Regtest, + Network::Signet => Self::Signet, + } + } +} + +/// Whether a token can be frozen. +/// +/// Alias of [`IsTokenFreezable`] kept for parity with the go-sdk +/// `FreezableToken` constants. +pub type FreezableToken = IsTokenFreezable; + +/// Whether a token can be unfrozen once frozen. +/// +/// Alias of [`IsTokenUnfreezable`] kept for parity with the go-sdk +/// `TokenUnfreezable` constants. +pub type TokenUnfreezable = IsTokenUnfreezable; + +/// Where a transaction output comes from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SourceId { + /// The output of a transaction. + Transaction, + /// The reward of a block. + BlockReward, +} + +/// Returns the cached [`ChainConfig`] for a network. +pub(crate) fn chain_config(network: Network) -> &'static ChainConfig { + static CONFIGS: [OnceLock; 4] = [const { OnceLock::new() }; 4]; + CONFIGS[network as usize].get_or_init(|| Builder::new(ChainType::from(network)).build()) +} + +/// Parses a bech32 address into an addressable chain object. +pub(crate) fn parse_addressable( + network: Network, + address: &str, +) -> Result { + common::address::Address::from_string(chain_config(network), address) + .map_err(|error| Error::AddressParse { + address: address.to_owned(), + message: error.to_string(), + }) + .map(common::address::Address::into_object) +} diff --git a/src/crypto/outputs.rs b/src/crypto/outputs.rs new file mode 100644 index 0000000..74bdacb --- /dev/null +++ b/src/crypto/outputs.rs @@ -0,0 +1,281 @@ +// 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. + +//! Transaction output constructors. + +use ml_common as common; +use ml_crypto as crypto; + +use common::chain::{ + OrderData, TxOutput, + htlc::{HashedTimelockContract, HtlcSecretHash}, + output_value::OutputValue, + stakelock::StakePoolData, + timelock::OutputTimeLock, + tokens::{ + IsTokenFreezable, Metadata, NftIssuance, NftIssuanceV0, TokenCreator, TokenIssuance, + TokenIssuanceV1, TokenTotalSupply, + }, +}; +use common::primitives::Amount; +use crypto::key::PublicKey; +use std::str::FromStr; + +use super::{Error, Network, parse_addressable}; + +/// Builds a coin transfer output. +pub fn encode_output_transfer( + amount: Amount, + address: &str, + network: Network, +) -> Result { + let destination = parse_addressable(network, address)?; + Ok(TxOutput::Transfer(OutputValue::Coin(amount), destination)) +} + +/// Builds a token transfer output. +pub fn encode_output_token_transfer( + amount: Amount, + address: &str, + token_id: &str, + network: Network, +) -> Result { + let destination = parse_addressable(network, address)?; + let token_id = parse_addressable(network, token_id)?; + Ok(TxOutput::Transfer( + OutputValue::TokenV1(token_id, amount), + destination, + )) +} + +/// Builds a timelocked coin transfer output. +pub fn encode_output_lock_then_transfer( + amount: Amount, + address: &str, + lock: OutputTimeLock, + network: Network, +) -> Result { + let destination = parse_addressable(network, address)?; + Ok(TxOutput::LockThenTransfer( + OutputValue::Coin(amount), + destination, + lock, + )) +} + +/// Builds a timelocked token transfer output. +pub fn encode_output_token_lock_then_transfer( + amount: Amount, + address: &str, + token_id: &str, + lock: OutputTimeLock, + network: Network, +) -> Result { + let destination = parse_addressable(network, address)?; + let token_id = parse_addressable(network, token_id)?; + Ok(TxOutput::LockThenTransfer( + OutputValue::TokenV1(token_id, amount), + destination, + lock, + )) +} + +/// Builds a coin burn output. +pub fn encode_output_coin_burn(amount: Amount) -> TxOutput { + TxOutput::Burn(OutputValue::Coin(amount)) +} + +/// Builds a token burn output. +pub fn encode_output_token_burn( + amount: Amount, + token_id: &str, + network: Network, +) -> Result { + let token_id = parse_addressable(network, token_id)?; + Ok(TxOutput::Burn(OutputValue::TokenV1(token_id, amount))) +} + +/// Builds an output that creates a delegation to a pool. +pub fn encode_output_create_delegation( + pool_id: &str, + owner_address: &str, + network: Network, +) -> Result { + let owner = parse_addressable(network, owner_address)?; + let pool_id = parse_addressable(network, pool_id)?; + Ok(TxOutput::CreateDelegationId(owner, pool_id)) +} + +/// Builds an output that delegates coins to a delegation. +pub fn encode_output_delegate_staking( + amount: Amount, + delegation_id: &str, + network: Network, +) -> Result { + let delegation_id = parse_addressable(network, delegation_id)?; + Ok(TxOutput::DelegateStaking(amount, delegation_id)) +} + +/// Builds an output that creates a stake pool. +/// +/// The pool id must be derived from the hash of the first transaction input +/// (see [`super::get_pool_id`]). +pub fn encode_output_create_stake_pool( + pool_id: &str, + pool_data: StakePoolData, + network: Network, +) -> Result { + let pool_id = parse_addressable(network, pool_id)?; + Ok(TxOutput::CreateStakePool(pool_id, Box::new(pool_data))) +} + +/// Builds the output emitted when producing a block via a pool. +pub fn encode_output_produce_block_from_stake( + pool_id: &str, + staker: &str, + network: Network, +) -> Result { + let staker = parse_addressable(network, staker)?; + let pool_id = parse_addressable(network, pool_id)?; + Ok(TxOutput::ProduceBlockFromStake(staker, pool_id)) +} + +/// Builds an arbitrary data deposit output. +pub fn encode_output_data_deposit(data: &[u8]) -> TxOutput { + TxOutput::DataDeposit(data.to_vec()) +} + +/// Builds a fungible token issuance output. +pub fn encode_output_issue_fungible_token( + authority: &str, + token_ticker: &str, + metadata_uri: &str, + number_of_decimals: u8, + total_supply: TokenTotalSupply, + is_token_freezable: IsTokenFreezable, + network: Network, +) -> Result { + let authority = parse_addressable(network, authority)?; + let token_issuance = TokenIssuance::V1(TokenIssuanceV1 { + authority, + token_ticker: token_ticker.as_bytes().to_vec(), + metadata_uri: metadata_uri.as_bytes().to_vec(), + number_of_decimals, + total_supply, + is_freezable: is_token_freezable, + }); + + ml_tx_verifier::check_tokens_issuance(super::chain_config(network), &token_issuance) + .map_err(|error| Error::InvalidTokenParameters(error.to_string()))?; + + Ok(TxOutput::IssueFungibleToken(Box::new(token_issuance))) +} + +/// Builds an NFT issuance output. +#[allow(clippy::too_many_arguments)] +pub fn encode_output_issue_nft( + token_id: &str, + authority: &str, + name: &str, + ticker: &str, + description: &str, + media_hash: &[u8], + creator: Option, + media_uri: Option<&str>, + icon_uri: Option<&str>, + additional_metadata_uri: Option<&str>, + network: Network, +) -> Result { + let token_id = parse_addressable(network, token_id)?; + let authority = parse_addressable(network, authority)?; + let creator = creator.map(|public_key| TokenCreator { public_key }); + + let nft_issuance = NftIssuanceV0 { + metadata: Metadata { + creator, + name: name.as_bytes().to_vec(), + description: description.as_bytes().to_vec(), + ticker: ticker.as_bytes().to_vec(), + icon_uri: into_data_or_no_vec(icon_uri), + additional_metadata_uri: into_data_or_no_vec(additional_metadata_uri), + media_uri: into_data_or_no_vec(media_uri), + media_hash: media_hash.to_vec(), + }, + }; + + ml_tx_verifier::check_nft_issuance_data(super::chain_config(network), &nft_issuance) + .map_err(|error| Error::InvalidTokenParameters(error.to_string()))?; + + Ok(TxOutput::IssueNft( + token_id, + Box::new(NftIssuance::V0(nft_issuance)), + authority, + )) +} + +/// Builds an HTLC output; `token_id` selects a token HTLC instead of coins. +pub fn encode_output_htlc( + amount: Amount, + token_id: Option<&str>, + secret_hash: &str, + spend_address: &str, + refund_address: &str, + refund_timelock: OutputTimeLock, + network: Network, +) -> Result { + let output_value = output_value(network, amount, token_id)?; + let secret_hash = HtlcSecretHash::from_str(secret_hash) + .map_err(|error| Error::InvalidHtlcSecretHash(error.to_string()))?; + let spend_key = parse_addressable(network, spend_address)?; + let refund_key = parse_addressable(network, refund_address)?; + + let htlc = HashedTimelockContract { + secret_hash, + spend_key, + refund_timelock, + refund_key, + }; + Ok(TxOutput::Htlc(output_value, Box::new(htlc))) +} + +/// Builds a DEX order creation output; `None` token ids mean coins. +pub fn encode_create_order_output( + ask_amount: Amount, + ask_token_id: Option<&str>, + give_amount: Amount, + give_token_id: Option<&str>, + conclude_address: &str, + network: Network, +) -> Result { + let ask = output_value(network, ask_amount, ask_token_id)?; + let give = output_value(network, give_amount, give_token_id)?; + let conclude_key = parse_addressable(network, conclude_address)?; + Ok(TxOutput::CreateOrder(Box::new(OrderData::new( + conclude_key, + ask, + give, + )))) +} + +fn output_value( + network: Network, + amount: Amount, + token_id: Option<&str>, +) -> Result { + match token_id { + Some(token_id) => Ok(OutputValue::TokenV1( + parse_addressable(network, token_id)?, + amount, + )), + None => Ok(OutputValue::Coin(amount)), + } +} + +fn into_data_or_no_vec( + value: Option<&str>, +) -> ml_serialization::extras::non_empty_vec::DataOrNoVec { + value.map(|value| value.as_bytes().to_vec()).into() +} diff --git a/src/crypto/signing.rs b/src/crypto/signing.rs new file mode 100644 index 0000000..229d974 --- /dev/null +++ b/src/crypto/signing.rs @@ -0,0 +1,298 @@ +// 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. + +//! Input witness production and message signing. + +use ml_common as common; +use ml_crypto as crypto; +use ml_randomness as randomness; + +use common::chain::Destination; +use common::chain::Transaction; +use common::chain::classic_multisig::ClassicMultisigChallenge; +use common::chain::htlc::HtlcSecret; +use common::chain::signature::{ + inputsig::{ + InputWitness, + arbitrary_message::{ArbitraryMessageSignature, produce_message_challenge}, + authorize_hashed_timelock_contract_spend::AuthorizedHashedTimelockContractSpend, + classical_multisig::authorize_classical_multisig::{ + AuthorizedClassicalMultisigSpend, sign_classical_multisig_spending, + }, + htlc::{ + produce_uniparty_signature_for_htlc_refunding, + produce_uniparty_signature_for_htlc_spending, + }, + standard_signature::StandardInputSignature, + }, + sighash::signature_hash, +}; +use crypto::key::{PrivateKey, PublicKey, Signature}; +use ml_serialization as serialization; +use serialization::Encode; + +use super::{ + Error, Network, SigHashType, TxAdditionalInfo, parse_addressable, + transactions::make_input_commitments, +}; + +/// Signs one input of a transaction and returns the input witness. +/// +/// `input_owner_destination` is the address that can spend the input. +/// `input_utxos` must hold one entry per transaction input: `None` for +/// non-UTXO inputs, `Some(output)` otherwise. `additional_info` must cover +/// every `ProduceBlockFromStake`, `FillOrder` and `ConcludeOrder` input of +/// the transaction, not just the input being signed. +#[allow(clippy::too_many_arguments)] +pub fn encode_witness( + sighash_type: SigHashType, + private_key: &PrivateKey, + input_owner_destination: &str, + transaction: &Transaction, + input_utxos: &[Option], + input_index: usize, + additional_info: &TxAdditionalInfo, + current_block_height: u64, + network: Network, +) -> Result { + let destination = parse_addressable(network, input_owner_destination)?; + let input_commitments = make_input_commitments( + transaction, + input_utxos, + additional_info, + network, + current_block_height, + )?; + + StandardInputSignature::produce_uniparty_signature_for_input( + private_key, + sighash_type, + destination, + transaction, + &input_commitments, + input_index, + &mut randomness::make_true_rng(), + ) + .map(InputWitness::Standard) + .map_err(|error| Error::InputSigning(error.to_string())) +} + +/// Returns the empty witness used for unsigned inputs such as fill-order. +pub fn encode_witness_no_signature() -> InputWitness { + InputWitness::NoSignature(None) +} + +/// Signs an HTLC input for spending, revealing the secret. +/// +/// See [`encode_witness`] for the remaining parameters. +#[allow(clippy::too_many_arguments)] +pub fn encode_witness_htlc_spend( + sighash_type: SigHashType, + private_key: &PrivateKey, + input_owner_destination: &str, + transaction: &Transaction, + input_utxos: &[Option], + input_index: usize, + secret: HtlcSecret, + additional_info: &TxAdditionalInfo, + current_block_height: u64, + network: Network, +) -> Result { + let destination = parse_addressable(network, input_owner_destination)?; + let input_commitments = make_input_commitments( + transaction, + input_utxos, + additional_info, + network, + current_block_height, + )?; + + produce_uniparty_signature_for_htlc_spending( + private_key, + sighash_type, + destination, + transaction, + &input_commitments, + input_index, + secret, + &mut randomness::make_true_rng(), + ) + .map(InputWitness::Standard) + .map_err(|error| Error::InputSigning(error.to_string())) +} + +/// Signs an HTLC refund input for a single-sig refund address. +/// +/// See [`encode_witness`] for the remaining parameters. +#[allow(clippy::too_many_arguments)] +pub fn encode_witness_htlc_refund_single_sig( + sighash_type: SigHashType, + private_key: &PrivateKey, + input_owner_destination: &str, + transaction: &Transaction, + input_utxos: &[Option], + input_index: usize, + additional_info: &TxAdditionalInfo, + current_block_height: u64, + network: Network, +) -> Result { + let destination = parse_addressable(network, input_owner_destination)?; + let input_commitments = make_input_commitments( + transaction, + input_utxos, + additional_info, + network, + current_block_height, + )?; + + produce_uniparty_signature_for_htlc_refunding( + private_key, + sighash_type, + destination, + transaction, + &input_commitments, + input_index, + &mut randomness::make_true_rng(), + ) + .map(InputWitness::Standard) + .map_err(|error| Error::InputSigning(error.to_string())) +} + +/// Adds one signature to a multisig HTLC refund witness. +/// +/// `key_index` is the index of the public key, inside `challenge`, that +/// corresponds to `private_key`. `input_witness` is `None` for the first +/// signer or the witness produced by a previous call, so that several +/// signers can build the witness cumulatively. +#[allow(clippy::too_many_arguments)] +pub fn encode_witness_htlc_refund_multisig( + sighash_type: SigHashType, + private_key: &PrivateKey, + key_index: u8, + input_witness: Option<&InputWitness>, + challenge: &ClassicMultisigChallenge, + transaction: &Transaction, + input_utxos: &[Option], + input_index: usize, + additional_info: &TxAdditionalInfo, + current_block_height: u64, + network: Network, +) -> Result { + let input_commitments = make_input_commitments( + transaction, + input_utxos, + additional_info, + network, + current_block_height, + )?; + + let sighash = signature_hash(sighash_type, transaction, &input_commitments, input_index) + .map_err(|error| Error::Sighash(error.to_string()))?; + + let authorization = match input_witness { + Some(witness) => { + let (htlc_spend, _) = extract_htlc_spend(witness)?; + match htlc_spend { + AuthorizedHashedTimelockContractSpend::Spend(_, _) => { + return Err(Error::UnexpectedHtlcSpendType); + } + AuthorizedHashedTimelockContractSpend::Refund(raw_signature) => { + AuthorizedClassicalMultisigSpend::from_data(&raw_signature) + .map_err(|error| Error::InputSigning(error.to_string()))? + } + } + } + None => AuthorizedClassicalMultisigSpend::new_empty(challenge.clone()), + }; + + let authorization = sign_classical_multisig_spending( + super::chain_config(network), + key_index, + private_key, + challenge, + &sighash, + authorization, + &mut randomness::make_true_rng(), + ) + .map_err(|error| Error::InputSigning(error.to_string()))? + .take(); + + let raw_signature = AuthorizedHashedTimelockContractSpend::Refund(authorization.encode()); + Ok(InputWitness::Standard(StandardInputSignature::new( + sighash_type, + raw_signature.encode(), + ))) +} + +/// Signs an arbitrary message with a private key, producing a witness +/// signature for spending requests. +pub fn sign_message_for_spending( + private_key: &PrivateKey, + message: &[u8], +) -> Result { + private_key + .sign_message(message, &mut randomness::make_true_rng()) + .map_err(|error| Error::MessageSigning(error.to_string())) +} + +/// Verifies a spending signature against a public key and message. +pub fn verify_signature_for_spending( + public_key: &PublicKey, + signature: &Signature, + message: &[u8], +) -> bool { + public_key.verify_message(signature, message) +} + +/// Signs a challenge message with a private key. +/// +/// The result can be verified with +/// [`verify_challenge`](super::verify_challenge) against the pubkeyhash +/// address of the signing key. +pub fn sign_challenge(private_key: &PrivateKey, message: &[u8]) -> Result, Error> { + ArbitraryMessageSignature::produce_uniparty_signature_as_pub_key_hash_spending( + private_key, + message, + &mut randomness::make_true_rng(), + ) + .map(|signature| signature.into_raw()) + .map_err(|error| Error::MessageSigning(error.to_string())) +} + +/// Verifies a challenge signature against a pubkeyhash address. +/// +/// Returns `true` on success; a failed verification yields an error. +pub fn verify_challenge( + address: &str, + network: Network, + signed_challenge: &[u8], + message: &[u8], +) -> Result { + let destination = parse_addressable::(network, address)?; + let message_challenge = produce_message_challenge(message); + let signature = ArbitraryMessageSignature::from_data(signed_challenge.to_vec()); + signature + .verify_signature( + super::chain_config(network), + &destination, + &message_challenge, + ) + .map_err(|error| Error::SignatureVerification(error.to_string()))?; + Ok(true) +} + +pub(crate) fn extract_htlc_spend( + witness: &InputWitness, +) -> Result<(AuthorizedHashedTimelockContractSpend, SigHashType), Error> { + match witness { + InputWitness::NoSignature(_) => Err(Error::UnexpectedHtlcSpendType), + InputWitness::Standard(signature) => Ok(( + AuthorizedHashedTimelockContractSpend::from_data(signature.raw_signature()) + .map_err(|error| Error::InputSigning(error.to_string()))?, + signature.sighash_type(), + )), + } +} diff --git a/src/crypto/staking.rs b/src/crypto/staking.rs new file mode 100644 index 0000000..de8c234 --- /dev/null +++ b/src/crypto/staking.rs @@ -0,0 +1,66 @@ +// 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. + +//! Stake pool helpers. + +use ml_common as common; +use ml_consensus as consensus; + +use common::chain::stakelock::StakePoolData; +use common::primitives::per_thousand::PerThousand; +use common::primitives::{Amount, BlockHeight}; + +use super::{Error, Network, chain_config, parse_addressable}; + +/// Builds the pool data object used by +/// [`encode_output_create_stake_pool`](super::encode_output_create_stake_pool). +pub fn encode_stake_pool_data( + value: Amount, + staker: &str, + vrf_public_key: &str, + decommission_key: &str, + margin_ratio_per_thousand: u16, + cost_per_block: Amount, + network: Network, +) -> Result { + let staker = parse_addressable(network, staker)?; + let vrf_public_key = parse_addressable(network, vrf_public_key)?; + let decommission_key = parse_addressable(network, decommission_key)?; + let margin_ratio = PerThousand::new(margin_ratio_per_thousand) + .ok_or(Error::InvalidMarginRatio(margin_ratio_per_thousand))?; + + Ok(StakePoolData::new( + value, + staker, + vrf_public_key, + decommission_key, + margin_ratio, + cost_per_block, + )) +} + +/// Calculates the pledge-capped effective balance of a pool. +pub fn effective_pool_balance( + network: Network, + pledge_amount: Amount, + pool_balance: Amount, +) -> Result { + let final_supply = chain_config(network) + .final_supply() + .ok_or(Error::EffectiveBalance("final supply missing".to_owned()))? + .to_amount_atoms(); + + consensus::calculate_effective_pool_balance(pledge_amount, pool_balance, final_supply) + .map_err(|error| Error::EffectiveBalance(error.to_string())) +} + +/// Returns the number of blocks until a decommissioned pool's funds become +/// spendable. +pub fn staking_pool_spend_maturity_block_count(current_block_height: u64, network: Network) -> u64 { + chain_config(network) + .staking_pool_spend_maturity_block_count(BlockHeight::new(current_block_height)) + .to_int() +} diff --git a/src/crypto/timelocks.rs b/src/crypto/timelocks.rs new file mode 100644 index 0000000..7062895 --- /dev/null +++ b/src/crypto/timelocks.rs @@ -0,0 +1,33 @@ +// 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. + +//! Output timelock constructors. + +use ml_common as common; + +use common::chain::{block::timestamp::BlockTimestamp, timelock::OutputTimeLock}; + +/// Locks an output for a number of blocks after it is included in a block. +pub fn encode_lock_for_block_count(block_count: u64) -> OutputTimeLock { + OutputTimeLock::ForBlockCount(block_count) +} + +/// Locks an output for a number of seconds after it is included in a block. +pub fn encode_lock_for_seconds(total_seconds: u64) -> OutputTimeLock { + OutputTimeLock::ForSeconds(total_seconds) +} + +/// Locks an output until a UNIX timestamp (in seconds). +pub fn encode_lock_until_time(timestamp_since_epoch_in_seconds: u64) -> OutputTimeLock { + OutputTimeLock::UntilTime(BlockTimestamp::from_int_seconds( + timestamp_since_epoch_in_seconds, + )) +} + +/// Locks an output until a block height. +pub fn encode_lock_until_height(block_height: u64) -> OutputTimeLock { + OutputTimeLock::UntilHeight(common::primitives::BlockHeight::new(block_height)) +} diff --git a/src/crypto/transactions.rs b/src/crypto/transactions.rs new file mode 100644 index 0000000..4f2c099 --- /dev/null +++ b/src/crypto/transactions.rs @@ -0,0 +1,221 @@ +// 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. + +//! Transaction assembly, decoding and inspection. + +use ml_common as common; +use ml_serialization as serialization; + +use common::chain::signature::inputsig::InputWitness; +use common::chain::{ + Destination, OutPointSourceId, SignedTransaction, Transaction, TxInput, TxOutput, + partially_signed_transaction::{ + PartiallySignedTransaction, PartiallySignedTransactionConsistencyCheck, + make_sighash_input_commitments_at_height, + }, +}; +use common::primitives::{BlockHeight, H256, Idable}; +use common::size_estimation::{ + input_signature_size_from_destination, outputs_encoded_size, + tx_size_with_num_inputs_and_outputs, +}; +use serialization::json_encoded::JsonEncoded; +use serialization::{Decode, DecodeAll, Encode}; +use std::str::FromStr; + +use super::{Error, Network, SourceId, TxAdditionalInfo, chain_config, parse_addressable}; + +/// Builds a [`OutPointSourceId`] from raw hash bytes. +/// +/// Given the bytes of a transaction id or block id and where the output +/// comes from, this produces the source id used by UTXO inputs. +pub fn encode_outpoint_source_id(id: H256, source: SourceId) -> OutPointSourceId { + match source { + SourceId::Transaction => OutPointSourceId::Transaction(id.into()), + SourceId::BlockReward => OutPointSourceId::BlockReward(id.into()), + } +} + +/// Builds a transaction from inputs and outputs. +/// +/// `flags` are the transaction flags (currently always zero). +pub fn encode_transaction( + inputs: Vec, + outputs: Vec, + flags: u64, +) -> Result { + Transaction::new(u128::from(flags), inputs, outputs) + .map_err(|error| Error::TransactionCreation(error.to_string())) +} + +/// Returns the lowercase hex transaction id of a transaction. +pub fn transaction_id(transaction: &Transaction) -> String { + format!("{:x}", transaction.get_id()) +} + +/// Decodes a transaction, requiring the bytes to contain exactly one +/// transaction. +pub fn decode_transaction(bytes: &[u8]) -> Result { + Transaction::decode_all(&mut &bytes[..]).map_err(Error::from) +} + +/// Decodes a transaction, ignoring any trailing bytes. +/// +/// This tolerates signed transaction bytes, whose witness list is appended +/// after the transaction. +pub fn decode_transaction_lenient(bytes: &[u8]) -> Result { + Transaction::decode(&mut &bytes[..]).map_err(Error::from) +} + +/// Assembles a signed transaction from a transaction and its witnesses. +pub fn encode_signed_transaction( + transaction: Transaction, + signatures: Vec, +) -> Result { + SignedTransaction::new(transaction, signatures) + .map_err(|error| Error::SignedTransactionCreation(error.to_string())) +} + +/// Builds a partially signed transaction. +/// +/// Every per-input list must have exactly one entry per transaction input; +/// `None` marks an absent entry. `additional_info` must cover every +/// `ProduceBlockFromStake`, `FillOrder` and `ConcludeOrder` input. +#[allow(clippy::too_many_arguments)] +pub fn encode_partially_signed_transaction( + transaction: Transaction, + signatures: Vec>, + input_utxos: Vec>, + input_destinations: Vec>, + htlc_secrets: Vec>, + additional_info: TxAdditionalInfo, + _network: Network, +) -> Result { + PartiallySignedTransaction::new( + transaction, + signatures, + input_utxos, + input_destinations, + Some(htlc_secrets), + additional_info, + PartiallySignedTransactionConsistencyCheck::WithAdditionalInfo, + ) + .map_err(|error| Error::PartiallySignedTransactionCreation(error.to_string())) +} + +/// Decodes a transaction into a JSON representation with bech32 addresses. +pub fn decode_signed_transaction_to_json( + transaction: &[u8], + network: Network, +) -> Result { + let tx = SignedTransaction::decode_all(&mut &transaction[..]).map_err(Error::from)?; + json_with_addresses(&tx, network) +} + +/// Decodes a partially signed transaction into JSON with bech32 addresses. +pub fn decode_partially_signed_transaction_to_json( + transaction: &[u8], + network: Network, +) -> Result { + let ptx = PartiallySignedTransaction::decode_all(&mut &transaction[..]).map_err(Error::from)?; + json_with_addresses(&ptx, network) +} + +/// Estimates the byte size of a signed transaction. +/// +/// `input_destinations` holds the spending address of each input; script +/// hash and multisig destinations are not supported. HTLC inputs are assumed +/// to be regular UTXO inputs. +pub fn estimate_transaction_size( + inputs: &[TxInput], + input_destinations: &[&str], + outputs: &[TxOutput], + network: Network, +) -> Result { + let mut total_size = + tx_size_with_num_inputs_and_outputs(outputs.len(), input_destinations.len()) + .map_err(|error| Error::SizeEstimation(error.to_string()))? + + outputs_encoded_size(outputs) + + inputs.encode().len(); + + for destination in input_destinations { + let destination = parse_addressable(network, destination)?; + let signature_size = input_signature_size_from_destination(&destination, None, None) + .map_err(|error| Error::SizeEstimation(error.to_string()))?; + total_size += signature_size; + } + + Ok(total_size) +} + +/// Extracts the HTLC pre-image secret from a signed HTLC spend. +pub fn extract_htlc_secret( + signed_tx: &SignedTransaction, + htlc_outpoint_source_id: OutPointSourceId, + htlc_output_index: u32, +) -> Result { + let htlc_utxo_outpoint = + common::chain::UtxoOutPoint::new(htlc_outpoint_source_id, htlc_output_index); + + let htlc_position = signed_tx + .transaction() + .inputs() + .iter() + .position( + |input| matches!(input, TxInput::Utxo(outpoint) if *outpoint == htlc_utxo_outpoint), + ) + .ok_or(Error::NoInputOutpointFound)?; + + let witness = signed_tx.signatures().get(htlc_position).ok_or(Error::InvalidWitnessCount)?; + + let (htlc_spend, _) = super::signing::extract_htlc_spend(witness)?; + match htlc_spend { + common::chain::signature::inputsig::authorize_hashed_timelock_contract_spend::AuthorizedHashedTimelockContractSpend::Spend(secret, _) => { + Ok(secret) + } + common::chain::signature::inputsig::authorize_hashed_timelock_contract_spend::AuthorizedHashedTimelockContractSpend::Refund(_) => { + Err(Error::UnexpectedHtlcSpendType) + } + } +} + +pub(crate) fn make_input_commitments<'a>( + transaction: &Transaction, + input_utxos: &'a [Option], + additional_info: &TxAdditionalInfo, + network: Network, + current_block_height: u64, +) -> Result< + Vec< + common::chain::transaction::signature::sighash::input_commitments::SighashInputCommitment< + 'a, + >, + >, + Error, +> { + make_sighash_input_commitments_at_height( + transaction.inputs(), + input_utxos, + additional_info, + chain_config(network), + BlockHeight::new(current_block_height), + ) + .map_err(|error| Error::Sighash(error.to_string())) +} + +pub(crate) fn json_with_addresses( + object: &T, + network: Network, +) -> Result { + let json = JsonEncoded::new(object).to_string(); + let json = common::address::dehexify::dehexify_all_addresses(chain_config(network), &json); + serde_json::from_str(&json).map_err(|error| Error::Json(error.to_string())) +} + +/// Parses a hex-encoded hash into an [`H256`]. +pub(crate) fn h256_from_str(hex: &str) -> Result { + H256::from_str(hex).map_err(|_| Error::InvalidTransactionId(hex.to_owned())) +} From 0f90d60ce5816bcab0bc7e95daee30e993ec952f Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Wed, 16 Sep 2026 23:54:54 +0400 Subject: [PATCH 10/29] crypto: zeroize alloc feature, estimate length validation, fork doc hardening (security review fixes) --- Cargo.toml | 4 +++- src/crypto/inputs.rs | 6 ++++-- src/crypto/keys.rs | 7 +++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 52aeb4c..1fdc12c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,9 @@ bip39 = { version = "2.0", optional = true, default-features = false, features = "std", "zeroize", ] } -zeroize = { version = "1.5", optional = true, default-features = false } +zeroize = { version = "1.5", optional = true, default-features = false, features = [ + "alloc", +] } ml-common = { package = "common", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } ml-consensus = { package = "consensus", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } ml-crypto = { package = "crypto", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } diff --git a/src/crypto/inputs.rs b/src/crypto/inputs.rs index 46585f4..e468c93 100644 --- a/src/crypto/inputs.rs +++ b/src/crypto/inputs.rs @@ -139,8 +139,10 @@ pub fn encode_input_for_change_token_metadata_uri( /// /// Fill-order inputs must not be signed; use /// [`encode_witness_no_signature`](super::encode_witness_no_signature) for -/// them. Before the orders V1 fork the nonce is significant; afterwards it is -/// ignored. +/// them. Before the orders V1 fork the nonce is significant; once the fork +/// is active at `current_block_height`, both the `nonce` and the +/// `destination` are ignored (in V1 the destination is derived from the +/// order's transaction outputs). pub fn encode_input_for_fill_order( order_id: &str, fill_amount: Amount, diff --git a/src/crypto/keys.rs b/src/crypto/keys.rs index 7348fa7..4a24567 100644 --- a/src/crypto/keys.rs +++ b/src/crypto/keys.rs @@ -5,6 +5,13 @@ // that can be found in the LICENSE file. //! Key generation and BIP32-style derivation. +//! +//! # Secret material lifetime +//! +//! The BIP39 seed and passphrase are zeroized on drop, but the key objects +//! returned by these functions are plain values without zeroization; this +//! mirrors mintlayer-core itself. Avoid cloning or logging keys, and keep +//! their lifetime short. use ml_common as common; use ml_crypto as crypto; From 96682e177500bef30102d798429e1dfdf82e48af Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 00:16:49 +0400 Subject: [PATCH 11/29] crypto: test suite with BIP39 legacy vectors and transaction id vectors --- tests/crypto.rs | 309 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 tests/crypto.rs diff --git a/tests/crypto.rs b/tests/crypto.rs new file mode 100644 index 0000000..22a013a --- /dev/null +++ b/tests/crypto.rs @@ -0,0 +1,309 @@ +// 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. + +//! Cryptography and transaction-building tests, mirroring the go-sdk +//! `wasm/client_test.go` suite and the wasm-wrappers test vectors. + +use mintlayer_sdk::crypto::types::{Encode, H256, PrivateKey, Transaction, TxOutput}; +use mintlayer_sdk::crypto::{ + Amount, Network, SigHashType, SourceId, TxAdditionalInfo, data_deposit_fee, + decode_signed_transaction_to_json, decode_transaction, decode_transaction_lenient, + effective_pool_balance, encode_input_for_utxo, encode_lock_for_block_count, + encode_lock_for_seconds, encode_lock_until_height, encode_lock_until_time, + encode_outpoint_source_id, encode_output_transfer, encode_signed_transaction, + encode_transaction, encode_witness, encode_witness_no_signature, estimate_transaction_size, + fungible_token_issuance_fee, get_pool_id, make_default_account_privkey, make_private_key, + make_receiving_address, nft_issuance_fee, pubkey_to_pubkeyhash_address, + public_key_from_private_key, sign_challenge, sign_message_for_spending, + staking_pool_spend_maturity_block_count, token_change_authority_fee, token_freeze_fee, + token_supply_change_fee, transaction_id, verify_challenge, verify_signature_for_spending, +}; + +const MNEMONIC: &str = "walk exile faculty near leg neutral license matrix maple invite cupboard hat opinion excess coffee leopard latin regret document core limb crew dizzy movie"; + +const MAINNET_ACCOUNT_PRIVKEY: &str = "00038000002c80004d4c80000000261ee699496924546a94266597d15e3c081d8aa3b99ccefec2453418fd4e58720134b4486bdb7e70bc23933483a0cb10ac17bc104fd3c42758a4a777d71fda2d"; +const MAINNET_RECEIVING_0: &str = + "00b88adfb44da2c1fd5f12f7996bd147f45bd0b8917fa8842d4c901b965d5dad1f"; +const MAINNET_RECEIVING_1: &str = + "0022b76360c53d567d5130a7de421576c0ec1b745485c01793dbed534d489c017e"; +const TESTNET_ACCOUNT_PRIVKEY: &str = "00038000002c80000001800000008fe13ec65ee469346b060206efaebafec23e2c06b5288a5e446aeab3854f13c5bfbc80385eda560749b9601f5f5bd92ed56caf243ecb4f57c97fcb3bf13ad0e6"; +const TESTNET_RECEIVING_0: &str = + "00f42c0e96b4ee90ed64c57948216d7a4773d59969a080ac45f639ee624481590d"; +const TESTNET_RECEIVING_1: &str = + "00114be4d2511116792ca87760973ba299ad94a5a8ddb3ab491ecfa7e62d613745"; + +const EXPECTED_TX_ID: &str = "35a7938c2a2aad5ae324e7d0536de245bf9e439169aa3c16f1492be117e5d0e0"; +const TX_HEX: &str = "0100040000ff5d9a94390ee97208d31aa5c3b5ddbd8df9d308069df2ebf5283f7ce3e4261401000000080340f9924e4da0af7dc8c5be71a9c9e05962c7bf4ef96127fde7a7b4e1469e48620f0080e03779c31102000365807e3b4147cb978b78715e60606092f89dc769586e98456850bd3b449c87b400203015e9ef9fc142569e0f966bc0188464fa712a841e14002e0fe952a076a26c01e539c5f0ceba927ab8f8f55f274af739ce4eef3700000b00204aa9d10100000b409e4c355d010199e4ec3a5b176140ef9cd58c7d3579fdb0ecb21a"; +const TX_SIGNED_HEX: &str = "0100040000ff5d9a94390ee97208d31aa5c3b5ddbd8df9d308069df2ebf5283f7ce3e4261401000000080340f9924e4da0af7dc8c5be71a9c9e05962c7bf4ef96127fde7a7b4e1469e48620f0080e03779c31102000365807e3b4147cb978b78715e60606092f89dc769586e98456850bd3b449c87b400203015e9ef9fc142569e0f966bc0188464fa712a841e14002e0fe952a076a26c01e539c5f0ceba927ab8f8f55f274af739ce4eef3700000b00204aa9d10100000b409e4c355d010199e4ec3a5b176140ef9cd58c7d3579fdb0ecb21a0401018d010002eddd003bfb6333123e682abe6923da1d38faa4f0e0d9e2ee42d5aa46c152a34800a749a30c8c9c33696ce407fc145ebc9824e17b778d0d9ccc8129be52f37b74160e60f6689ac2f481071e1a63d9cf0f6eab84c2703b5e9f229cd8188ce092edd4"; + +fn fake_utxo_transaction() -> (PrivateKey, String, Transaction, TxOutput) { + let private_key = make_private_key(); + let public_key = public_key_from_private_key(&private_key); + let address = pubkey_to_pubkeyhash_address(&public_key, Network::Mainnet); + let output = encode_output_transfer( + Amount::from_atoms(100_000_000_000), + &address, + Network::Mainnet, + ) + .unwrap(); + let source_id = encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let input = encode_input_for_utxo(source_id, 0); + let transaction = encode_transaction(vec![input], vec![output.clone()], 0).unwrap(); + (private_key, address, transaction, output) +} + +#[test] +fn make_private_key_is_random() { + let key_1 = make_private_key(); + let key_2 = make_private_key(); + + let encoded_1 = key_1.encode(); + let encoded_2 = key_2.encode(); + + assert_eq!(encoded_1.len(), 33); + assert_eq!(encoded_2.len(), 33); + assert_ne!(encoded_1, encoded_2, "two consecutive keys are identical"); +} + +#[test] +fn default_account_privkey_legacy_vectors() { + for passphrase in [None, Some("")] { + let mainnet_account = + make_default_account_privkey(MNEMONIC, Network::Mainnet, passphrase).unwrap(); + let testnet_account = + make_default_account_privkey(MNEMONIC, Network::Testnet, passphrase).unwrap(); + + assert_eq!( + hex::encode(mainnet_account.encode()), + MAINNET_ACCOUNT_PRIVKEY, + "mainnet account key, passphrase {passphrase:?}" + ); + assert_eq!( + hex::encode(testnet_account.encode()), + TESTNET_ACCOUNT_PRIVKEY, + "testnet account key, passphrase {passphrase:?}" + ); + } + + let mainnet_account = make_default_account_privkey(MNEMONIC, Network::Mainnet, None).unwrap(); + let testnet_account = make_default_account_privkey(MNEMONIC, Network::Testnet, None).unwrap(); + + for (index, expected) in [(0u32, MAINNET_RECEIVING_0), (1, MAINNET_RECEIVING_1)] { + let receiving = make_receiving_address(&mainnet_account, index).unwrap(); + assert_eq!( + hex::encode(receiving.encode()), + expected, + "mainnet receiving {index}" + ); + } + for (index, expected) in [(0u32, TESTNET_RECEIVING_0), (1, TESTNET_RECEIVING_1)] { + let receiving = make_receiving_address(&testnet_account, index).unwrap(); + assert_eq!( + hex::encode(receiving.encode()), + expected, + "testnet receiving {index}" + ); + } +} + +#[test] +fn different_passphrases_produce_different_keys() { + let passphrases = [None, Some("passphrase-1"), Some("passphrase-2")]; + + let mut account_keys = Vec::new(); + let mut receiving_addresses = Vec::new(); + for passphrase in passphrases { + let account = make_default_account_privkey(MNEMONIC, Network::Mainnet, passphrase).unwrap(); + account_keys.push(hex::encode(account.encode())); + + let receiving = make_receiving_address(&account, 0).unwrap(); + let public_key = public_key_from_private_key(&receiving); + receiving_addresses.push(pubkey_to_pubkeyhash_address(&public_key, Network::Mainnet)); + } + + for i in 0..passphrases.len() { + for j in (i + 1)..passphrases.len() { + assert_ne!( + account_keys[i], account_keys[j], + "account keys {i} and {j} match" + ); + assert_ne!( + receiving_addresses[i], receiving_addresses[j], + "receiving addresses {i} and {j} match" + ); + } + } +} + +#[test] +fn transaction_get_id_vector() { + let tx_bytes = hex::decode(TX_HEX).unwrap(); + let signed_bytes = hex::decode(TX_SIGNED_HEX).unwrap(); + + let transaction = decode_transaction(&tx_bytes).unwrap(); + assert_eq!(transaction_id(&transaction), EXPECTED_TX_ID); + + let lenient = decode_transaction_lenient(&signed_bytes).unwrap(); + assert_eq!(transaction_id(&lenient), EXPECTED_TX_ID); + + assert!( + decode_transaction(&signed_bytes).is_err(), + "strict decoding must reject signed transaction bytes" + ); +} + +#[test] +fn encode_transaction_roundtrip() { + let (_, _, transaction, _) = fake_utxo_transaction(); + + let tx_id = transaction_id(&transaction); + assert_eq!(tx_id.len(), 64); + assert!( + tx_id.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)), + "transaction id must be lowercase hex, got {tx_id}" + ); +} + +#[test] +fn timelocks_encode() { + let locks = [ + encode_lock_for_block_count(100), + encode_lock_for_seconds(86_400), + encode_lock_until_time(1_700_000_000), + encode_lock_until_height(500_000), + ]; + for lock in locks { + assert!(!lock.encode().is_empty()); + } +} + +#[test] +fn fees_are_non_zero() { + let height = 500_000u64; + let network = Network::Mainnet; + + assert!(fungible_token_issuance_fee(height, network).into_atoms() > 0); + assert!(nft_issuance_fee(height, network).into_atoms() > 0); + assert!(data_deposit_fee(height, network).into_atoms() > 0); + assert!(token_supply_change_fee(height, network).into_atoms() > 0); + assert!(token_freeze_fee(height, network).into_atoms() > 0); + assert!(token_change_authority_fee(height, network).into_atoms() > 0); +} + +#[test] +fn staking_helpers() { + let maturity = staking_pool_spend_maturity_block_count(500_000, Network::Mainnet); + assert!(maturity > 0); + + let pledge = Amount::from_atoms(1_000_000_000_000_000); + let pool_balance = Amount::from_atoms(10_000_000_000_000_000); + let effective = effective_pool_balance(Network::Mainnet, pledge, pool_balance).unwrap(); + assert!(effective.into_atoms() > 0); +} + +#[test] +fn sign_challenge_roundtrip() { + let key = make_private_key(); + let public_key = public_key_from_private_key(&key); + let address = pubkey_to_pubkeyhash_address(&public_key, Network::Mainnet); + + let message = b"hello mintlayer"; + let signature = sign_challenge(&key, message).unwrap(); + + let verified = verify_challenge(&address, Network::Mainnet, &signature, message).unwrap(); + assert!(verified); + + let tampered_message = b"hello mintlayex"; + let result = verify_challenge(&address, Network::Mainnet, &signature, tampered_message); + assert!(matches!(result, Err(_) | Ok(false))); +} + +#[test] +fn sign_message_for_spending_roundtrip() { + let key = make_private_key(); + let public_key = public_key_from_private_key(&key); + + let message = b"spending message test"; + let signature = sign_message_for_spending(&key, message).unwrap(); + + assert!(verify_signature_for_spending( + &public_key, + &signature, + message + )); + + let tampered_message = b"spending message tesu"; + assert!(!verify_signature_for_spending( + &public_key, + &signature, + tampered_message + )); + + let wrong_key = make_private_key(); + let wrong_public_key = public_key_from_private_key(&wrong_key); + assert!(!verify_signature_for_spending( + &wrong_public_key, + &signature, + message + )); +} + +#[test] +fn encode_witness_no_signature_encodes() { + let witness = encode_witness_no_signature(); + assert!(!witness.encode().is_empty()); +} + +#[test] +fn witness_roundtrip_on_fake_tx() { + let (private_key, address, transaction, output) = fake_utxo_transaction(); + + let witness = encode_witness( + SigHashType::all(), + &private_key, + &address, + &transaction, + &[Some(output)], + 0, + &TxAdditionalInfo::new(), + 0, + Network::Mainnet, + ) + .unwrap(); + + let signed = encode_signed_transaction(transaction, vec![witness]).unwrap(); + let json = decode_signed_transaction_to_json(&signed.encode(), Network::Mainnet).unwrap(); + + let decoded_transaction = json + .get("transaction") + .expect("signed transaction JSON must contain the transaction"); + let decoded_v1 = decoded_transaction + .get("V1") + .expect("transaction JSON must contain the V1 variant"); + assert!( + decoded_v1.get("inputs").and_then(|value| value.as_array()).is_some(), + "decoded JSON must contain transaction inputs" + ); + assert!( + decoded_v1.get("outputs").and_then(|value| value.as_array()).is_some(), + "decoded JSON must contain transaction outputs" + ); +} + +#[test] +fn estimate_transaction_size_length_mismatch() { + let result = estimate_transaction_size(&[], &["mtc1qfoo"], &[], Network::Mainnet); + assert!(result.is_err()); +} + +#[test] +fn get_pool_id_from_inputs() { + let source_id = encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let fake_input = encode_input_for_utxo(source_id, 0); + + let pool_id = get_pool_id(&[fake_input], Network::Mainnet).unwrap(); + assert!(pool_id.starts_with("mpool"), "unexpected pool id {pool_id}"); +} From a973c670fff984d8e42aed506e623f183c00dea7 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 00:45:51 +0400 Subject: [PATCH 12/29] crypto: pin exact consensus vectors in tests (security review hardening) --- tests/crypto.rs | 147 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 120 insertions(+), 27 deletions(-) diff --git a/tests/crypto.rs b/tests/crypto.rs index 22a013a..65ba325 100644 --- a/tests/crypto.rs +++ b/tests/crypto.rs @@ -7,7 +7,9 @@ //! Cryptography and transaction-building tests, mirroring the go-sdk //! `wasm/client_test.go` suite and the wasm-wrappers test vectors. -use mintlayer_sdk::crypto::types::{Encode, H256, PrivateKey, Transaction, TxOutput}; +use mintlayer_sdk::crypto::types::{ + DecodeAll, Encode, H256, InputWitness, PrivateKey, Transaction, TxOutput, +}; use mintlayer_sdk::crypto::{ Amount, Network, SigHashType, SourceId, TxAdditionalInfo, data_deposit_fee, decode_signed_transaction_to_json, decode_transaction, decode_transaction_lenient, @@ -15,8 +17,8 @@ use mintlayer_sdk::crypto::{ encode_lock_for_seconds, encode_lock_until_height, encode_lock_until_time, encode_outpoint_source_id, encode_output_transfer, encode_signed_transaction, encode_transaction, encode_witness, encode_witness_no_signature, estimate_transaction_size, - fungible_token_issuance_fee, get_pool_id, make_default_account_privkey, make_private_key, - make_receiving_address, nft_issuance_fee, pubkey_to_pubkeyhash_address, + fungible_token_issuance_fee, get_pool_id, get_token_id, make_default_account_privkey, + make_private_key, make_receiving_address, nft_issuance_fee, pubkey_to_pubkeyhash_address, public_key_from_private_key, sign_challenge, sign_message_for_spending, staking_pool_spend_maturity_block_count, token_change_authority_fee, token_freeze_fee, token_supply_change_fee, transaction_id, verify_challenge, verify_signature_for_spending, @@ -36,11 +38,18 @@ const TESTNET_RECEIVING_1: &str = "00114be4d2511116792ca87760973ba299ad94a5a8ddb3ab491ecfa7e62d613745"; const EXPECTED_TX_ID: &str = "35a7938c2a2aad5ae324e7d0536de245bf9e439169aa3c16f1492be117e5d0e0"; + +/// A fixed Schnorr private key (a public test vector, identical to +/// `MAINNET_RECEIVING_0`), so that `witness_roundtrip_on_fake_tx` exercises a +/// deterministic key instead of a randomly generated one. Schnorr signing +/// itself still draws fresh auxiliary randomness per signature, so only the +/// deterministic properties of the witness can be asserted. +const FIXED_SIGNING_PRIVKEY: &str = + "00b88adfb44da2c1fd5f12f7996bd147f45bd0b8917fa8842d4c901b965d5dad1f"; const TX_HEX: &str = "0100040000ff5d9a94390ee97208d31aa5c3b5ddbd8df9d308069df2ebf5283f7ce3e4261401000000080340f9924e4da0af7dc8c5be71a9c9e05962c7bf4ef96127fde7a7b4e1469e48620f0080e03779c31102000365807e3b4147cb978b78715e60606092f89dc769586e98456850bd3b449c87b400203015e9ef9fc142569e0f966bc0188464fa712a841e14002e0fe952a076a26c01e539c5f0ceba927ab8f8f55f274af739ce4eef3700000b00204aa9d10100000b409e4c355d010199e4ec3a5b176140ef9cd58c7d3579fdb0ecb21a"; const TX_SIGNED_HEX: &str = "0100040000ff5d9a94390ee97208d31aa5c3b5ddbd8df9d308069df2ebf5283f7ce3e4261401000000080340f9924e4da0af7dc8c5be71a9c9e05962c7bf4ef96127fde7a7b4e1469e48620f0080e03779c31102000365807e3b4147cb978b78715e60606092f89dc769586e98456850bd3b449c87b400203015e9ef9fc142569e0f966bc0188464fa712a841e14002e0fe952a076a26c01e539c5f0ceba927ab8f8f55f274af739ce4eef3700000b00204aa9d10100000b409e4c355d010199e4ec3a5b176140ef9cd58c7d3579fdb0ecb21a0401018d010002eddd003bfb6333123e682abe6923da1d38faa4f0e0d9e2ee42d5aa46c152a34800a749a30c8c9c33696ce407fc145ebc9824e17b778d0d9ccc8129be52f37b74160e60f6689ac2f481071e1a63d9cf0f6eab84c2703b5e9f229cd8188ce092edd4"; -fn fake_utxo_transaction() -> (PrivateKey, String, Transaction, TxOutput) { - let private_key = make_private_key(); +fn fake_utxo_transaction(private_key: PrivateKey) -> (PrivateKey, String, Transaction, TxOutput) { let public_key = public_key_from_private_key(&private_key); let address = pubkey_to_pubkeyhash_address(&public_key, Network::Mainnet); let output = encode_output_transfer( @@ -157,7 +166,7 @@ fn transaction_get_id_vector() { #[test] fn encode_transaction_roundtrip() { - let (_, _, transaction, _) = fake_utxo_transaction(); + let (_, _, transaction, _) = fake_utxo_transaction(make_private_key()); let tx_id = transaction_id(&transaction); assert_eq!(tx_id.len(), 64); @@ -169,39 +178,85 @@ fn encode_transaction_roundtrip() { #[test] fn timelocks_encode() { - let locks = [ - encode_lock_for_block_count(100), - encode_lock_for_seconds(86_400), - encode_lock_until_time(1_700_000_000), - encode_lock_until_height(500_000), - ]; - for lock in locks { - assert!(!lock.encode().is_empty()); - } + // Exact SCALE encodings of the OutputTimeLock variants. + assert_eq!( + encode_lock_for_block_count(100).encode(), + hex::decode("029101").unwrap(), + "ForBlockCount(100): variant 0x02 + compact(100) = 0xa1 0x01" + ); + assert_eq!( + encode_lock_for_seconds(86_400).encode(), + hex::decode("0302460500").unwrap(), + "ForSeconds(86400): variant 0x03 + compact(86400) = 0x01 0x46 0x05 0x00" + ); + assert_eq!( + encode_lock_until_height(500_000).encode(), + hex::decode("0082841e00").unwrap(), + "UntilHeight(500000): variant 0x00 + compact(500000) = 0x82 0x84 0x1e 0x00" + ); + assert_eq!( + encode_lock_until_time(1_700_000_000).encode(), + hex::decode("010300f15365").unwrap(), + "UntilTime(1700000000): variant 0x01 + compact(1700000000) = 0x03 0x00 0xf1 0x53 0x65" + ); } #[test] -fn fees_are_non_zero() { +fn fees_match_consensus_values() { let height = 500_000u64; let network = Network::Mainnet; - assert!(fungible_token_issuance_fee(height, network).into_atoms() > 0); - assert!(nft_issuance_fee(height, network).into_atoms() > 0); - assert!(data_deposit_fee(height, network).into_atoms() > 0); - assert!(token_supply_change_fee(height, network).into_atoms() > 0); - assert!(token_freeze_fee(height, network).into_atoms() > 0); - assert!(token_change_authority_fee(height, network).into_atoms() > 0); + // Consensus fee schedule at Mainnet height 500_000, pinned in atoms. + assert_eq!( + fungible_token_issuance_fee(height, network).into_atoms(), + 10_000_000_000_000u128, + "fungible token issuance fee" + ); + assert_eq!( + nft_issuance_fee(height, network).into_atoms(), + 500_000_000_000u128, + "NFT issuance fee" + ); + assert_eq!( + data_deposit_fee(height, network).into_atoms(), + 2_000_000_000_000u128, + "data deposit fee" + ); + assert_eq!( + token_supply_change_fee(height, network).into_atoms(), + 5_000_000_000_000u128, + "token supply change fee" + ); + assert_eq!( + token_freeze_fee(height, network).into_atoms(), + 5_000_000_000_000u128, + "token freeze fee" + ); + assert_eq!( + token_change_authority_fee(height, network).into_atoms(), + 2_000_000_000_000u128, + "token change authority fee" + ); } #[test] fn staking_helpers() { + // Post-fork Mainnet maturity is exactly 7200 blocks. let maturity = staking_pool_spend_maturity_block_count(500_000, Network::Mainnet); - assert!(maturity > 0); + assert_eq!( + maturity, 7200, + "staking pool spend maturity at Mainnet height 500_000" + ); let pledge = Amount::from_atoms(1_000_000_000_000_000); let pool_balance = Amount::from_atoms(10_000_000_000_000_000); - let effective = effective_pool_balance(Network::Mainnet, pledge, pool_balance).unwrap(); - assert!(effective.into_atoms() > 0); + let effective = effective_pool_balance(Network::Mainnet, pledge, pool_balance) + .expect("effective pool balance must be computable"); + assert_eq!( + effective.into_atoms(), + 9_309_784_157_607_562u128, + "pledge-capped effective balance" + ); } #[test] @@ -259,7 +314,21 @@ fn encode_witness_no_signature_encodes() { #[test] fn witness_roundtrip_on_fake_tx() { - let (private_key, address, transaction, output) = fake_utxo_transaction(); + // Schnorr signing derives fresh auxiliary randomness for every signature, + // so the signature bytes cannot be hex-pinned. Instead the test anchors on + // a FIXED private key and asserts every deterministic property of the + // produced witness. + let fixed_key = ::decode_all( + &mut &hex::decode(FIXED_SIGNING_PRIVKEY).unwrap()[..], + ) + .expect("fixed private key must decode"); + assert_eq!( + hex::encode(fixed_key.encode()), + FIXED_SIGNING_PRIVKEY, + "fixed private key must survive a decode/encode roundtrip" + ); + + let (private_key, address, transaction, output) = fake_utxo_transaction(fixed_key); let witness = encode_witness( SigHashType::all(), @@ -274,6 +343,15 @@ fn witness_roundtrip_on_fake_tx() { ) .unwrap(); + // (a) The produced witness must decode back into an InputWitness. + let decoded_witness = + InputWitness::decode_all(&mut &witness.encode()[..]).expect("witness must decode"); + // (b) It must be a standard signature authorizing every sighash mode. + let InputWitness::Standard(signature) = &decoded_witness else { + panic!("witness must be a standard input signature, got {decoded_witness:?}"); + }; + assert_eq!(signature.sighash_type(), SigHashType::all()); + let signed = encode_signed_transaction(transaction, vec![witness]).unwrap(); let json = decode_signed_transaction_to_json(&signed.encode(), Network::Mainnet).unwrap(); @@ -305,5 +383,20 @@ fn get_pool_id_from_inputs() { let fake_input = encode_input_for_utxo(source_id, 0); let pool_id = get_pool_id(&[fake_input], Network::Mainnet).unwrap(); - assert!(pool_id.starts_with("mpool"), "unexpected pool id {pool_id}"); + assert_eq!( + pool_id, "mpool1zte8hxywgpaqw4xalxpj2kxmuj83tprw2f7rgcgweytmv45kfzyqduwah5", + "pool id derived from an all-zero-hash outpoint" + ); +} + +#[test] +fn get_token_id_from_inputs() { + let source_id = encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let fake_input = encode_input_for_utxo(source_id, 0); + + let token_id = get_token_id(&[fake_input], 500_000, Network::Mainnet).unwrap(); + assert_eq!( + token_id, "mmltk1ht59xvv2sdxz28txuwryy55yl5qq9tf9657kvnqulfy9a2g3csssee4n2n", + "token id derived from an all-zero-hash outpoint at height 500_000" + ); } From dd3ca57dcf7e321169908102521bc54c0d521245 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 00:55:15 +0400 Subject: [PATCH 13/29] client: umbrella client builder with crate-root re-exports --- src/client.rs | 130 ++++++++++++++++++++++++++++++++++++++-- src/lib.rs | 11 ++++ tests/client.rs | 155 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 tests/client.rs diff --git a/src/client.rs b/src/client.rs index 753763d..2034766 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,10 +4,130 @@ // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. -//! Stub — implemented in a later phase. +//! Top-level SDK client wiring the sub-clients together. -/// Stub. -pub struct Client; +use crate::indexer; +use crate::node; +use crate::wallet; -/// Stub. -pub struct ClientBuilder; +/// The top-level Mintlayer SDK client. +/// +/// Each sub-client is `Some` only when its URL was set on the +/// [`ClientBuilder`]. The [`crypto`](crate::crypto) functions are always +/// available directly and require no client. +#[derive(Debug, Clone)] +pub struct Client { + /// JSON-RPC client for the node daemon. + pub node: Option, + /// REST client for the indexer. + pub indexer: Option, + /// JSON-RPC client for the wallet daemon. + pub wallet: Option, +} + +impl Client { + /// Returns a builder; sub-clients are created only for the URLs that are + /// set. + #[must_use] + pub fn builder() -> ClientBuilder { + ClientBuilder { + node_url: None, + indexer_url: None, + wallet_url: None, + basic_auth: None, + timeout: None, + } + } +} + +/// Builder for [`Client`]. +#[derive(Debug, Clone)] +pub struct ClientBuilder { + node_url: Option, + indexer_url: Option, + wallet_url: Option, + basic_auth: Option<(String, String)>, + timeout: Option, +} + +impl ClientBuilder { + /// Creates a node daemon client for the given base URL. + #[must_use] + pub fn node_url(mut self, url: impl Into) -> Self { + self.node_url = Some(url.into()); + self + } + + /// Creates an indexer client for the given base URL. + #[must_use] + pub fn indexer_url(mut self, url: impl Into) -> Self { + self.indexer_url = Some(url.into()); + self + } + + /// Creates a wallet daemon client for the given base URL. + #[must_use] + pub fn wallet_url(mut self, url: impl Into) -> Self { + self.wallet_url = Some(url.into()); + self + } + + /// Sends HTTP basic auth credentials with node and wallet requests. + /// + /// The indexer API is unauthenticated; the credentials are not applied + /// to it. + #[must_use] + pub fn basic_auth(mut self, username: impl Into, password: impl Into) -> Self { + self.basic_auth = Some((username.into(), password.into())); + self + } + + /// Overrides the default 30 second timeout of every sub-client. + #[must_use] + pub fn timeout(mut self, timeout: std::time::Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Builds the client. + pub fn build(self) -> Result { + let node = self.node_url.map(|url| { + let builder = node::Client::builder(url); + let builder = match &self.basic_auth { + Some((username, password)) => builder.basic_auth(username, password), + None => builder, + }; + match self.timeout { + Some(timeout) => builder.timeout(timeout), + None => builder, + } + .build() + }); + let wallet = self.wallet_url.map(|url| { + let builder = wallet::Client::builder(url); + let builder = match &self.basic_auth { + Some((username, password)) => builder.basic_auth(username, password), + None => builder, + }; + match self.timeout { + Some(timeout) => builder.timeout(timeout), + None => builder, + } + .build() + }); + let indexer = self.indexer_url.map(|url| { + let builder = indexer::Client::builder(url); + match self.timeout { + Some(timeout) => builder.timeout(timeout), + None => builder, + } + .build() + }); + + Ok(Client { + node: node.transpose()?, + indexer: indexer.transpose()?, + wallet: wallet.transpose()?, + }) + } +} diff --git a/src/lib.rs b/src/lib.rs index f965dad..6c068d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,3 +43,14 @@ mod client; #[cfg(any(feature = "node", feature = "wallet", feature = "indexer"))] pub use client::{Client, ClientBuilder}; + +/// Convenience re-exports so callers that only import the crate root do not +/// need to reach into the sub-modules (mirrors the go-sdk alias block). +#[cfg(feature = "crypto")] +pub mod prelude { + pub use crate::crypto::types::*; + pub use crate::crypto::{ + Amount, FreezableToken, IsTokenFreezable, IsTokenUnfreezable, Network, SigHashType, + SourceId, TokenTotalSupply, TxAdditionalInfo, + }; +} diff --git a/tests/client.rs b/tests/client.rs new file mode 100644 index 0000000..31d8c5d --- /dev/null +++ b/tests/client.rs @@ -0,0 +1,155 @@ +// 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. + +//! Tests for the top-level client and crate re-exports. + +use std::time::Duration; + +use httpmock::Then; +use httpmock::prelude::*; +use serde_json::json; + +use mintlayer_sdk::Client; +use mintlayer_sdk::crypto::make_private_key; +use mintlayer_sdk::prelude::*; + +fn respond(then: Then, body: serde_json::Value) { + then.status(200) + .header("content-type", "application/json") + .body(body.to_string()); +} + +fn rpc_ok(id: u64, result: serde_json::Value) -> serde_json::Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +fn chainstate_info_result() -> serde_json::Value { + json!({ + "best_block_height": 123456, + "best_block_id": + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + "best_block_timestamp": { "timestamp": 1_700_000_000 }, + "median_time": { "timestamp": 1_699_999_500 }, + "is_initial_block_download": false, + }) +} + +#[test] +fn builder_creates_only_configured_clients() { + let node_only = Client::builder().node_url("http://127.0.0.1:3030").build().unwrap(); + assert!(node_only.node.is_some()); + assert!(node_only.indexer.is_none()); + assert!(node_only.wallet.is_none()); + + let empty = Client::builder().build().unwrap(); + assert!(empty.node.is_none()); + assert!(empty.indexer.is_none()); + assert!(empty.wallet.is_none()); + + let all = Client::builder() + .node_url("http://127.0.0.1:3030") + .indexer_url("http://127.0.0.1:3000") + .wallet_url("http://127.0.0.1:3034") + .build() + .unwrap(); + assert!(all.node.is_some()); + assert!(all.indexer.is_some()); + assert!(all.wallet.is_some()); +} + +#[tokio::test] +async fn umbrella_client_end_to_end_request() { + let server = MockServer::start(); + let node_mock = server.mock(|when, then| { + when.method(POST).path("/node").body_contains("\"method\":\"chainstate_info\""); + respond(then, rpc_ok(1, chainstate_info_result())); + }); + let wallet_mock = server.mock(|when, then| { + when.method(POST).path("/wallet"); + respond(then, rpc_ok(1, json!(null))); + }); + + let client = Client::builder() + .node_url(server.url("/node")) + .wallet_url(server.url("/wallet")) + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + assert!(client.indexer.is_none()); + + let info = client.node.as_ref().unwrap().chainstate_info().await.unwrap(); + assert_eq!(info.best_block_height, 123456); + assert_eq!( + info.best_block_id, + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + ); + assert_eq!(info.best_block_timestamp.timestamp, 1_700_000_000); + assert_eq!(info.median_time.timestamp, 1_699_999_500); + assert!(!info.is_initial_block_download); + + assert_eq!(node_mock.hits(), 1); + assert_eq!(wallet_mock.hits(), 0); +} + +#[tokio::test] +async fn basic_auth_only_affects_rpc_clients() { + let server = MockServer::start(); + let node_mock = server.mock(|when, then| { + when.method(POST) + .path("/node") + .header("authorization", "Basic dXNlcjpwYXNz") + .body_contains("\"method\":\"chainstate_info\""); + respond(then, rpc_ok(1, chainstate_info_result())); + }); + let indexer_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/chain/tip").matches(|request| { + !request + .headers + .as_deref() + .unwrap_or(&[]) + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization")) + }); + respond(then, json!({ "block_height": 42000, "block_id": "0000ab" })); + }); + + let client = Client::builder() + .node_url(server.url("/node")) + .wallet_url(server.url("/wallet")) + .indexer_url(server.url("")) + .basic_auth("user", "pass") + .build() + .unwrap(); + + let info = client.node.as_ref().unwrap().chainstate_info().await.unwrap(); + assert_eq!(info.best_block_height, 123456); + + let tip = client.indexer.as_ref().unwrap().tip().await.unwrap(); + assert_eq!(tip.block_height, 42000); + assert_eq!(tip.block_id, "0000ab"); + + assert_eq!(node_mock.hits(), 1); + assert_eq!(indexer_mock.hits(), 1); +} + +#[test] +fn prelude_reexports() { + assert_eq!(Amount::from_atoms(1u128).into_atoms() as u128, 1); + + let label = match Network::Mainnet { + Network::Mainnet => "mainnet", + Network::Testnet => "testnet", + Network::Regtest => "regtest", + Network::Signet => "signet", + }; + assert_eq!(label, "mainnet"); + + let private_key = make_private_key(); + let encoded = private_key.encode(); + assert_eq!(encoded.len(), 33); + let decoded = PrivateKey::decode_all(&mut encoded.as_slice()).unwrap(); + assert_eq!(decoded.encode(), encoded); +} From c3a9cccf16733cb1f360c688167639ce2d29286f Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 01:00:55 +0400 Subject: [PATCH 14/29] client: redact credentials from umbrella builder Debug (security review fix) --- src/client.rs | 14 +++++++++++++- src/lib.rs | 6 ++++++ tests/client.rs | 12 ++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index 2034766..ffe993d 100644 --- a/src/client.rs +++ b/src/client.rs @@ -41,7 +41,7 @@ impl Client { } /// Builder for [`Client`]. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ClientBuilder { node_url: Option, indexer_url: Option, @@ -50,6 +50,18 @@ pub struct ClientBuilder { timeout: Option, } +impl std::fmt::Debug for ClientBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClientBuilder") + .field("node_url", &self.node_url) + .field("indexer_url", &self.indexer_url) + .field("wallet_url", &self.wallet_url) + .field("basic_auth", &self.basic_auth.as_ref().map(|_| "***")) + .field("timeout", &self.timeout) + .finish() + } +} + impl ClientBuilder { /// Creates a node daemon client for the given base URL. #[must_use] diff --git a/src/lib.rs b/src/lib.rs index 6c068d9..95a0b97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,12 @@ pub use client::{Client, ClientBuilder}; /// Convenience re-exports so callers that only import the crate root do not /// need to reach into the sub-modules (mirrors the go-sdk alias block). +/// +/// # Secret handling +/// +/// The re-exported key types (`PrivateKey`, `ExtendedPrivateKey`, ...) come +/// from mintlayer-core and do not redact their [`std::fmt::Debug`] output. +/// Never log or debug-print keys; keep their lifetime short. #[cfg(feature = "crypto")] pub mod prelude { pub use crate::crypto::types::*; diff --git a/tests/client.rs b/tests/client.rs index 31d8c5d..bc54d73 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -60,6 +60,18 @@ fn builder_creates_only_configured_clients() { assert!(all.wallet.is_some()); } +#[test] +fn debug_output_redacts_basic_auth() { + let builder = Client::builder() + .node_url("http://127.0.0.1:3030") + .wallet_url("http://127.0.0.1:3034") + .basic_auth("secretuser", "secretpass"); + let debug = format!("{builder:?}"); + assert!(!debug.contains("secretuser")); + assert!(!debug.contains("secretpass")); + assert!(debug.contains("***")); +} + #[tokio::test] async fn umbrella_client_end_to_end_request() { let server = MockServer::start(); From 2fe98ceb8fb0a55f5890a227b82380e89bdb3f3a Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 01:06:01 +0400 Subject: [PATCH 15/29] examples: send-coins (native signing flow) and issue-token (wallet daemon flow) --- Cargo.toml | 8 ++ examples/issue-token.rs | 166 ++++++++++++++++++++++++++++++++++ examples/send-coins.rs | 192 ++++++++++++++++++++++++++++++++++++++++ src/crypto/mod.rs | 4 +- tests/client.rs | 2 +- 5 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 examples/issue-token.rs create mode 100644 examples/send-coins.rs diff --git a/Cargo.toml b/Cargo.toml index 1fdc12c..4947481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,14 @@ ml-randomness = { package = "randomness", git = "https://github.com/mintlayer/mi ml-serialization = { package = "serialization", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } ml-tx-verifier = { package = "tx-verifier", git = "https://github.com/mintlayer/mintlayer-core", rev = "7f4393ac9c07c080661a1bd40e8a6133a98b5155", optional = true } +[[example]] +name = "send-coins" +required-features = ["crypto", "indexer"] + +[[example]] +name = "issue-token" +required-features = ["wallet"] + [dev-dependencies] hex = "0.4" httpmock = "0.7" diff --git a/examples/issue-token.rs b/examples/issue-token.rs new file mode 100644 index 0000000..19627ea --- /dev/null +++ b/examples/issue-token.rs @@ -0,0 +1,166 @@ +// 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 a fungible token through the wallet daemon and mint an initial +//! supply to the authority address. +//! +//! Usage: +//! ```text +//! cargo run --example issue-token --features wallet -- \ +//! --wallet /path/to/wallet.dat \ +//! --password secret \ +//! --ticker MYTOKEN \ +//! --decimals 2 \ +//! --supply 1000000 \ +//! --uri https://example.com/token \ +//! --wallet-rpc http://127.0.0.1:3034 \ +//! --account 0 +//! ``` +//! +//! Only `--ticker` and `--supply` are required; open the wallet by passing +//! `--wallet`. In production, wait for the issuance transaction to be +//! confirmed before minting. + +use mintlayer_sdk::wallet::{ + Amount, Client as WalletClient, IssueTokenParams, MintParams, TokenMetadata, TokenSupply, + TxOptions, +}; + +struct ParsedArgs { + wallet: Option, + password: Option, + ticker: String, + decimals: u8, + supply: String, + uri: String, + wallet_rpc: String, + account: u32, +} + +fn parse_args() -> Result { + let mut parsed = ParsedArgs { + wallet: None, + password: None, + ticker: String::new(), + decimals: 2, + supply: String::new(), + uri: String::new(), + wallet_rpc: "http://127.0.0.1:3034".to_owned(), + account: 0, + }; + + let mut args = std::env::args().skip(1); + while let Some(flag) = args.next() { + let value = args.next().ok_or_else(|| format!("missing value for {flag}"))?; + match flag.as_str() { + "--wallet" => parsed.wallet = Some(value), + "--password" => parsed.password = Some(value), + "--ticker" => parsed.ticker = value, + "--decimals" => parsed.decimals = value.parse().map_err(|_| "invalid --decimals")?, + "--supply" => parsed.supply = value, + "--uri" => parsed.uri = value, + "--wallet-rpc" => parsed.wallet_rpc = value, + "--account" => parsed.account = value.parse().map_err(|_| "invalid --account")?, + other => return Err(format!("unknown flag {other}")), + } + } + + if parsed.ticker.is_empty() || parsed.supply.is_empty() { + return Err("required flags: --ticker, --supply".to_owned()); + } + Ok(parsed) +} + +#[tokio::main] +async fn main() { + if let Err(error) = run().await { + eprintln!("error: {error}"); + std::process::exit(1); + } +} + +async fn run() -> Result<(), String> { + let args = parse_args(); + let args = match args { + Ok(args) => args, + Err(error) => return Err(error), + }; + + let client = WalletClient::new(&args.wallet_rpc); + + if let Some(path) = &args.wallet { + client + .open_wallet(path, args.password.as_deref()) + .await + .map_err(|error| format!("failed to open wallet: {error}"))?; + println!("wallet {path} opened"); + } else { + return Err("no --wallet given; refusing to use an already-open wallet".to_owned()); + } + + if let Err(error) = client.sync_wallet().await { + eprintln!("warning: wallet sync failed: {error}"); + } + + let authority = client + .new_address(args.account) + .await + .map_err(|error| format!("failed to create address: {error}"))?; + println!("authority address {authority}"); + + if let Some(atoms) = client + .balance(args.account) + .await + .ok() + .and_then(|balance| balance.coins.atoms()) + { + println!("account balance: {atoms} atoms"); + } + + let issuance = client + .issue_token(IssueTokenParams { + account: args.account, + destination_address: authority.clone(), + metadata: TokenMetadata { + token_ticker: args.ticker.clone(), + number_of_decimals: args.decimals, + metadata_uri: args.uri.clone(), + token_supply: TokenSupply::Lockable, + is_freezable: false, + }, + options: TxOptions::default(), + }) + .await + .map_err(|error| format!("token issuance failed: {error}"))?; + println!( + "issued token {} in transaction {}", + issuance.token_id, issuance.tx_id + ); + + println!("note: in production wait for the issuance transaction to confirm before minting"); + + let mint = client + .mint_tokens(MintParams { + account: args.account, + token_id: issuance.token_id.clone(), + address: authority, + amount: Amount::from_atoms( + args.supply + .parse() + .map_err(|_| "invalid --supply: must be a decimal atom count")?, + ), + options: TxOptions::default(), + }) + .await + .map_err(|error| format!("mint failed: {error}"))?; + println!( + "minted in transaction {} (fee {} atoms)", + mint.tx_id, + mint.fees.coins.atoms().unwrap_or_default() + ); + + Ok(()) +} diff --git a/examples/send-coins.rs b/examples/send-coins.rs new file mode 100644 index 0000000..4787fd2 --- /dev/null +++ b/examples/send-coins.rs @@ -0,0 +1,192 @@ +// 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. + +//! Derive a key from a mnemonic, fetch spendable UTXOs from the indexer, +//! build and sign a transaction natively, and submit it to the indexer. +//! +//! Usage: +//! ```text +//! cargo run --example send-coins --features crypto,indexer -- \ +//! --mnemonic "abandon abandon ... about" \ +//! --to mtc1q... \ +//! --amount 100000000000 \ +//! --indexer http://127.0.0.1:3000 \ +//! --key-index 0 \ +//! --network testnet +//! ``` +//! +//! The example intentionally sends the full balance of the derived address +//! with no change output. All queried UTXOs belong to the sender, so the +//! destination of each reconstructed input is the sender address itself. + +use std::str::FromStr; + +use mintlayer_sdk::crypto::types::*; +use mintlayer_sdk::crypto::{self, Amount, Network, SigHashType, SourceId, TxAdditionalInfo}; +use mintlayer_sdk::indexer::Client as IndexerClient; + +fn parse_args() -> Result { + let mut parsed = ParsedArgs { + mnemonic: String::new(), + to: String::new(), + amount: String::new(), + indexer: "http://127.0.0.1:3000".to_owned(), + key_index: 0, + network: Network::Testnet, + }; + + let mut args = std::env::args().skip(1); + while let Some(flag) = args.next() { + let value = args.next().ok_or_else(|| format!("missing value for {flag}"))?; + match flag.as_str() { + "--mnemonic" => parsed.mnemonic = value, + "--to" => parsed.to = value, + "--amount" => parsed.amount = value, + "--indexer" => parsed.indexer = value, + "--key-index" => { + parsed.key_index = value.parse().map_err(|_| "invalid --key-index")?; + } + "--network" => { + parsed.network = match value.as_str() { + "mainnet" => Network::Mainnet, + "testnet" => Network::Testnet, + "regtest" => Network::Regtest, + "signet" => Network::Signet, + other => return Err(format!("unknown network {other}")), + }; + } + other => return Err(format!("unknown flag {other}")), + } + } + + if parsed.mnemonic.is_empty() || parsed.to.is_empty() || parsed.amount.is_empty() { + return Err("required flags: --mnemonic, --to, --amount".to_owned()); + } + Ok(parsed) +} + +struct ParsedArgs { + mnemonic: String, + to: String, + amount: String, + indexer: String, + key_index: u32, + network: Network, +} + +#[tokio::main] +async fn main() { + if let Err(error) = run().await { + eprintln!("error: {error}"); + std::process::exit(1); + } +} + +async fn run() -> Result<(), String> { + let args = parse_args()?; + let network = args.network; + + let account_key = crypto::make_default_account_privkey(&args.mnemonic, network, None) + .map_err(|error| format!("invalid mnemonic: {error}"))?; + let spend_key = crypto::make_receiving_address(&account_key, args.key_index) + .map_err(|error| format!("key derivation failed: {error}"))?; + let public_key = crypto::public_key_from_private_key(&spend_key); + let from_address = crypto::pubkey_to_pubkeyhash_address(&public_key, network); + println!("sending from {from_address}"); + + let indexer = IndexerClient::new(&args.indexer); + let utxos = indexer + .spendable_utxos(&from_address) + .await + .map_err(|error| format!("failed to fetch UTXOs: {error}"))?; + if utxos.is_empty() { + return Err(format!("no spendable UTXOs at {from_address}")); + } + + let mut inputs: Vec = Vec::new(); + let mut input_utxos: Vec> = Vec::new(); + let mut total = 0u128; + + for utxo in &utxos { + let Some(atoms) = transfer_coin_atoms(&utxo.output) else { + println!("skipping non Transfer/Coin output"); + continue; + }; + let source_id_bytes = hex::decode(&utxo.outpoint.source_id) + .map_err(|error| format!("invalid source id hex: {error}"))?; + let hash = H256::from_slice(&source_id_bytes); + let outpoint_source_id = crypto::encode_outpoint_source_id(hash, SourceId::Transaction); + inputs.push(crypto::encode_input_for_utxo( + outpoint_source_id, + utxo.outpoint.index, + )); + + let destination = + crypto::encode_destination(&from_address, network).map_err(|e| e.to_string())?; + input_utxos.push(Some(TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(atoms)), + destination, + ))); + total += atoms; + } + + let amount = u128::from_str(&args.amount).map_err(|_| "invalid --amount")?; + if amount > total { + return Err(format!( + "requested {amount} atoms but only {total} atoms are spendable" + )); + } + + let outputs = vec![ + crypto::encode_output_transfer(Amount::from_atoms(amount), &args.to, network) + .map_err(|error| error.to_string())?, + ]; + + let transaction = crypto::encode_transaction(inputs.clone(), outputs, 0) + .map_err(|error| error.to_string())?; + let tx_id = crypto::transaction_id(&transaction); + println!("unsigned transaction {tx_id}"); + + let mut witnesses = Vec::new(); + for (index, _) in inputs.iter().enumerate() { + let witness = crypto::encode_witness( + SigHashType::all(), + &spend_key, + &from_address, + &transaction, + &input_utxos, + index, + &TxAdditionalInfo::new(), + 0, + network, + ) + .map_err(|error| format!("signing failed: {error}"))?; + witnesses.push(witness); + } + + let signed = crypto::encode_signed_transaction(transaction, witnesses) + .map_err(|error| error.to_string())?; + let signed_hex = hex::encode(signed.encode()); + + let submitted = indexer + .submit_transaction(&signed_hex) + .await + .map_err(|error| format!("submission failed: {error}"))?; + println!("submitted transaction {submitted}"); + Ok(()) +} + +fn transfer_coin_atoms(output: &serde_json::Value) -> Option { + if output.get("type")?.as_str()? != "Transfer" { + return None; + } + let value = output.get("value")?; + if value.get("type")?.as_str()? != "Coin" { + return None; + } + let atoms = value.get("amount")?.get("atoms")?.as_str()?; + atoms.parse().ok() +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index e8fa86c..826525c 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -108,8 +108,8 @@ pub mod types { pub use ml_common::chain::signature::inputsig::InputWitness; pub use ml_common::chain::{ Destination, OutPointSourceId, SignedTransaction, Transaction, TxInput, TxOutput, - classic_multisig::ClassicMultisigChallenge, htlc::HtlcSecret, stakelock::StakePoolData, - timelock::OutputTimeLock, + classic_multisig::ClassicMultisigChallenge, htlc::HtlcSecret, output_value::OutputValue, + stakelock::StakePoolData, timelock::OutputTimeLock, }; pub use ml_common::primitives::H256; pub use ml_crypto::key::{ diff --git a/tests/client.rs b/tests/client.rs index bc54d73..fd4c82b 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -149,7 +149,7 @@ async fn basic_auth_only_affects_rpc_clients() { #[test] fn prelude_reexports() { - assert_eq!(Amount::from_atoms(1u128).into_atoms() as u128, 1); + assert_eq!(Amount::from_atoms(1u128).into_atoms(), 1); let label = match Network::Mainnet { Network::Mainnet => "mainnet", From 9de982b497ef55fc0c4b417f87a8d02d4aba7266 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 01:22:22 +0400 Subject: [PATCH 16/29] examples: sweep-total guard, tip-based signing height, secret-handling docs (security review fixes) --- examples/issue-token.rs | 13 ++++++-- examples/send-coins.rs | 73 +++++++++++++++++++++++++++++++---------- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/examples/issue-token.rs b/examples/issue-token.rs index 19627ea..bf79e42 100644 --- a/examples/issue-token.rs +++ b/examples/issue-token.rs @@ -21,8 +21,17 @@ //! ``` //! //! Only `--ticker` and `--supply` are required; open the wallet by passing -//! `--wallet`. In production, wait for the issuance transaction to be -//! confirmed before minting. +//! `--wallet`. `--supply` is an atom count (the smallest indivisible unit), +//! not a whole-token count. In production, wait for the issuance +//! transaction to be confirmed before minting. +//! +//! # Secret handling +//! +//! Passing the wallet password as a command-line argument exposes it in +//! shell history and the process list; prefer an environment variable or an +//! interactive prompt. The password is sent to the wallet daemon in the RPC +//! request body, so only point `--wallet-rpc` at a daemon you trust over a +//! local or encrypted connection. use mintlayer_sdk::wallet::{ Amount, Client as WalletClient, IssueTokenParams, MintParams, TokenMetadata, TokenSupply, diff --git a/examples/send-coins.rs b/examples/send-coins.rs index 4787fd2..042eb0e 100644 --- a/examples/send-coins.rs +++ b/examples/send-coins.rs @@ -18,9 +18,25 @@ //! --network testnet //! ``` //! -//! The example intentionally sends the full balance of the derived address -//! with no change output. All queried UTXOs belong to the sender, so the -//! destination of each reconstructed input is the sender address itself. +//! # Fee semantics +//! +//! The example sweeps every spendable Transfer/Coin UTXO of the derived +//! address and emits a single output of `--amount` with no change output. +//! In Mintlayer the fee is implicitly `sum(inputs) - sum(outputs)` and goes +//! to the block producer, so the example requires `--amount` to equal the +//! full swept total; anything less would silently overpay the miner and a +//! zero-fee transaction is rejected by the mempool. Production code should +//! add a change output sized from +//! [`estimate_transaction_size`](mintlayer_sdk::crypto::estimate_transaction_size) +//! and a fee rate fetched with the `feerate` endpoints. +//! +//! # Secret handling +//! +//! Passing the mnemonic as a command-line argument exposes it in shell +//! history and the process list. For real funds, read it from an +//! environment variable or an interactive prompt instead; the mnemonic never +//! leaves this process (only the signed transaction hex is submitted). +//! Timelocked (`LockThenTransfer`) outputs are skipped. use std::str::FromStr; @@ -28,6 +44,15 @@ use mintlayer_sdk::crypto::types::*; use mintlayer_sdk::crypto::{self, Amount, Network, SigHashType, SourceId, TxAdditionalInfo}; use mintlayer_sdk::indexer::Client as IndexerClient; +struct ParsedArgs { + mnemonic: String, + to: String, + amount: String, + indexer: String, + key_index: u32, + network: Network, +} + fn parse_args() -> Result { let mut parsed = ParsedArgs { mnemonic: String::new(), @@ -55,10 +80,14 @@ fn parse_args() -> Result { "testnet" => Network::Testnet, "regtest" => Network::Regtest, "signet" => Network::Signet, - other => return Err(format!("unknown network {other}")), + _ => { + return Err( + "unknown network (use mainnet|testnet|regtest|signet)".to_owned() + ); + } }; } - other => return Err(format!("unknown flag {other}")), + _ => return Err("unknown flag".to_owned()), } } @@ -68,15 +97,6 @@ fn parse_args() -> Result { Ok(parsed) } -struct ParsedArgs { - mnemonic: String, - to: String, - amount: String, - indexer: String, - key_index: u32, - network: Network, -} - #[tokio::main] async fn main() { if let Err(error) = run().await { @@ -98,6 +118,14 @@ async fn run() -> Result<(), String> { println!("sending from {from_address}"); let indexer = IndexerClient::new(&args.indexer); + + let tip = indexer + .tip() + .await + .map_err(|error| format!("failed to fetch chain tip: {error}"))?; + let inclusion_height = tip.block_height.checked_add(1).ok_or("chain tip overflow")?; + println!("predicting inclusion at height {inclusion_height}"); + let utxos = indexer .spendable_utxos(&from_address) .await @@ -117,6 +145,9 @@ async fn run() -> Result<(), String> { }; let source_id_bytes = hex::decode(&utxo.outpoint.source_id) .map_err(|error| format!("invalid source id hex: {error}"))?; + if source_id_bytes.len() != 32 { + return Err("source id is not 32 bytes".to_owned()); + } let hash = H256::from_slice(&source_id_bytes); let outpoint_source_id = crypto::encode_outpoint_source_id(hash, SourceId::Transaction); inputs.push(crypto::encode_input_for_utxo( @@ -130,15 +161,21 @@ async fn run() -> Result<(), String> { OutputValue::Coin(Amount::from_atoms(atoms)), destination, ))); - total += atoms; + total = total.checked_add(atoms).ok_or_else(|| "UTXO total overflow".to_owned())?; } let amount = u128::from_str(&args.amount).map_err(|_| "invalid --amount")?; - if amount > total { + if amount != total { return Err(format!( - "requested {amount} atoms but only {total} atoms are spendable" + "this example sweeps the full balance with no change output: \ + the difference between --amount ({amount}) and the swept total \ + ({total}) would be paid to the block producer as fee. \ + Pass --amount {total}, or add a change output in your own code." )); } + if total <= amount { + return Err("the swept total leaves nothing for the transaction fee".to_owned()); + } let outputs = vec![ crypto::encode_output_transfer(Amount::from_atoms(amount), &args.to, network) @@ -160,7 +197,7 @@ async fn run() -> Result<(), String> { &input_utxos, index, &TxAdditionalInfo::new(), - 0, + inclusion_height, network, ) .map_err(|error| format!("signing failed: {error}"))?; From 386c9a30521358d43b233557750b683d8b6d5edf Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 01:24:49 +0400 Subject: [PATCH 17/29] repo: README, CHANGELOG, CI workflow and license scripts --- .github/workflows/ci.yml | 63 +------ CHANGELOG.md | 41 +++++ README.md | 356 +++++++++++++++++++++++++++++++++++++++ scripts/add-license.sh | 23 +++ scripts/check-license.sh | 17 ++ 5 files changed, 446 insertions(+), 54 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100755 scripts/add-license.sh create mode 100755 scripts/check-license.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e857678..478ef6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,14 +3,8 @@ name: CI on: push: branches: [main] - paths-ignore: [".github/**"] pull_request: branches: [main] - paths-ignore: [".github/**"] - -# The crate jobs never need more than read access. -permissions: - contents: read env: CARGO_TERM_COLOR: always @@ -19,11 +13,9 @@ jobs: fmt: name: Format runs-on: ubuntu-latest - timeout-minutes: 15 steps: - uses: actions/checkout@v4 - # Pinned to the MSRV so rustfmt matches the version clippy/test use. - - uses: dtolnay/rust-toolchain@ffaa7fb73f2b6e3c49bc425913220fa3d71c3ee5 # 1.92.0 + - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - run: cargo fmt --all --check @@ -31,79 +23,42 @@ jobs: clippy: name: Clippy (${{ matrix.features }}) runs-on: ubuntu-latest - timeout-minutes: 45 strategy: fail-fast: false matrix: - features: ["--no-default-features", "--no-default-features --features node", "--no-default-features --features indexer", "--no-default-features --features wallet", "--no-default-features --features crypto", "--all-features"] + features: ["--no-default-features", "--features node", "--features indexer", "--features wallet", "--features crypto", "--all-features"] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@ffaa7fb73f2b6e3c49bc425913220fa3d71c3ee5 # 1.92.0 + - uses: dtolnay/rust-toolchain@1.92.0 with: components: clippy - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - key: ${{ matrix.features }} - - run: cargo clippy ${{ matrix.features }} --all-targets --locked -- -D warnings + - run: cargo clippy ${{ matrix.features }} --all-targets -- -D warnings test: name: Test (${{ matrix.features }}) runs-on: ubuntu-latest - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - features: ["--no-default-features", "--no-default-features --features node", "--no-default-features --features indexer", "--no-default-features --features wallet", "--no-default-features --features crypto", "--all-features"] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@ffaa7fb73f2b6e3c49bc425913220fa3d71c3ee5 # 1.92.0 - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - key: ${{ matrix.features }} - - run: cargo test ${{ matrix.features }} --locked - - # Cross-platform test gate, mirroring mintlayer-core's build.yml: the full - # suite runs on every supported OS with the lockfile held fixed. - platform-tests: - name: Test (${{ matrix.os }}) strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - timeout-minutes: 60 + features: ["--no-default-features", "--features node", "--features indexer", "--features wallet", "--features crypto", "--all-features"] steps: - - name: Shorten CARGO_HOME on Windows - if: runner.os == 'Windows' - shell: bash - # The default path blows past the Windows MAX_PATH limit inside the - # mintlayer-core git checkouts (same reason mintlayer-core's own - # build.yml does this). - run: echo "CARGO_HOME=C:\crg" >> "$GITHUB_ENV" - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@ffaa7fb73f2b6e3c49bc425913220fa3d71c3ee5 # 1.92.0 - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - key: ${{ matrix.os }} - - run: cargo build --all-features --locked --examples - - run: cargo test --all-features --locked + - uses: dtolnay/rust-toolchain@1.92.0 + - run: cargo test ${{ matrix.features }} doc: name: Documentation runs-on: ubuntu-latest - timeout-minutes: 30 steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@ffaa7fb73f2b6e3c49bc425913220fa3d71c3ee5 # 1.92.0 - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - run: cargo doc --all-features --locked --no-deps + - uses: dtolnay/rust-toolchain@1.92.0 + - run: cargo doc --all-features --no-deps env: RUSTDOCFLAGS: -D warnings license: name: License headers runs-on: ubuntu-latest - timeout-minutes: 15 steps: - uses: actions/checkout@v4 - run: ./scripts/check-license.sh diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..06dc33f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to the Mintlayer Rust SDK are documented here. +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [0.1.0] - 2026-09-17 + +### Added + +- `node` sub-client: JSON-RPC 2.0 client for the node daemon with the full + chainstate, mempool, node and P2P surface (including + `p2p_submit_transaction` broadcast). +- `indexer` sub-client: REST client for api-web-server covering chain, + block, transaction, address, pool, delegation, token, NFT, order and + statistics endpoints. +- `wallet` sub-client: JSON-RPC 2.0 client for the wallet daemon covering + management, transactions, staking, tokens and DEX orders. +- `crypto` module: native cryptography and transaction building backed by + mintlayer-core — BIP39/BIP32 key derivation, addresses, timelocks, fees, + id derivation, all input/output constructors, stake pool data, witness + production (standard, HTLC spend/refund single-sig and multisig), + partially signed transactions, size estimation, HTLC secret extraction + and signed transaction intents. +- Umbrella `Client` with a builder that wires the remote sub-clients + together and a `prelude` of shared type re-exports. +- Examples: `send-coins` (native signing flow) and `issue-token` (wallet + daemon flow). +- Wire-format integration tests for every sub-client, and consensus-vector + tests for the crypto module (BIP39 legacy derivations, transaction ids, + fees, timelocks). + +### Security + +- Basic-auth credentials and mnemonics/passphrases are redacted from `Debug` + output across all client and builder types. +- Daemon response bodies are capped at 64 MiB; HTTP error bodies are + additionally truncated and stripped of control characters. +- JSON-RPC responses must echo the request id. +- Indexer path segments are validated against a safe charset before being + interpolated into request paths. +- The BIP39 seed and passphrase are zeroized on drop. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1916637 --- /dev/null +++ b/README.md @@ -0,0 +1,356 @@ +# Mintlayer Rust SDK + +A Rust SDK for the [Mintlayer](https://www.mintlayer.org/) blockchain. + +```toml +[dependencies] +mintlayer-sdk = { git = "https://github.com/mintlayer/rust-sdk", features = ["full"] } +``` + +Requires Rust 1.92+ (edition 2024). Async (tokio + reqwest/rustls). No unsafe code. + +> **Consumer requirement:** because the SDK depends on unpublished +> mintlayer-core crates, Cargo ignores this repository's `[patch.crates-io]` +> section in downstream builds. Consumers MUST copy the `parity-scale-codec` +> git patch (see the root `Cargo.toml`) into their own workspace, or +> transactions encoded by the `crypto` feature may not match the encodings +> produced by mintlayer-core. + +--- + +## Documentation + +| Guide | Description | +|-------|-------------| +| [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, tokens, orders | +| [docs/crypto.md](docs/crypto.md) | Native cryptography reference: keys, addresses, inputs, outputs, signing | +| [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 | +| [docs/tokens.md](docs/tokens.md) | Fungible token and NFT lifecycle: issuance, minting, freezing, authority | + +--- + +## Overview + +The SDK is organised as three remote sub-clients plus a native cryptography +module, and a top-level `Client` that wires the sub-clients together. + +| Module | Purpose | Default port | +|---|---|---| +| `node` *(feature)* | JSON-RPC 2.0 client for the node daemon | 3030 (mainnet) | +| `indexer` *(feature)* | REST client for the indexer (api-web-server) | 3000 | +| `wallet` *(feature)* | JSON-RPC 2.0 client for the wallet daemon | 3034 (mainnet) | +| `crypto` *(feature)* | Cryptography & transaction building, backed by mintlayer-core natively | — | + +Features: default is `node`, `indexer`, `wallet`; `crypto` is opt-in because +it pulls in the mintlayer-core dependency graph; `full` enables everything. + +Unlike the go-sdk there is no embedded WASM runtime, no `InitWASM` and no +`Close`: the cryptography module calls mintlayer-core directly and exchanges +typed values (`Transaction`, `TxOutput`, `PrivateKey`, `Amount`, ...) instead +of opaque byte arrays. Every type participates in the SCALE encoding, and +`mintlayer_sdk::crypto::types::{Encode, DecodeAll}` provide byte-level access. + +--- + +## Quick start + +```rust +use mintlayer_sdk::{Client, crypto}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Client::builder() + .node_url("http://127.0.0.1:3030") + .indexer_url("http://127.0.0.1:3000") + .wallet_url("http://127.0.0.1:3034") + .build()?; + + // Query the chain tip from the indexer. + let tip = client.indexer.as_ref().unwrap().tip().await?; + println!("chain tip: height={} id={}", tip.block_height, tip.block_id); + + // Derive an address natively (no runtime initialisation). + let account = crypto::make_default_account_privkey(MNEMONIC, crypto::Network::Mainnet, None)?; + let spend = crypto::make_receiving_address(&account, 0)?; + let address = crypto::pubkey_to_pubkeyhash_address( + &crypto::public_key_from_private_key(&spend), + crypto::Network::Mainnet, + ); + println!("address: {address}"); + Ok(()) +} + +const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +``` + +--- + +## Node client (`node`) + +JSON-RPC 2.0 client for the Mintlayer node daemon. Supports basic auth for +nodes with authentication enabled. + +```rust +use mintlayer_sdk::node::{self, TrustPolicy}; + +let c = node::Client::builder("http://127.0.0.1:3030") + .basic_auth("user", "pass") // optional + .timeout(std::time::Duration::from_secs(10)) // optional + .build()?; + +// Chain state +let info = c.chainstate_info().await?; +let height = c.best_block_height().await?; +let block_id = c.best_block_id().await?; + +// Look up a block +let block_hex = c.block(&block_id).await?; +let block_json = c.block_json(&block_id).await?; + +// Token / order info +let token = c.token_info("mmltk1...").await?; +let order = c.order_info("mordr1...").await?; + +// Mempool +c.submit_transaction(&signed_tx_hex, TrustPolicy::Untrusted).await?; +let fee_rate = c.fee_rate(1).await?; + +// P2P +let peers = c.peer_count().await?; +c.broadcast_transaction(&signed_tx_hex, TrustPolicy::Untrusted).await?; +``` + +Daemon errors are returned as `node::Error::Rpc { code, message }`. + +## Indexer client (`indexer`) + +REST client for `api-web-server`. All paths are relative to `/api/v2/`. +The indexer API is unauthenticated. + +```rust +use mintlayer_sdk::indexer::{self, PageOpts, PoolListOpts, PoolSort}; + +let c = indexer::Client::builder("http://127.0.0.1:3000") + .timeout(std::time::Duration::from_secs(15)) + .build()?; + +// Chain +let tip = c.tip().await?; +let id_at = c.block_id_at_height(100_000).await?; + +// Address +let utxos = c.spendable_utxos("mtc1q...").await?; +let info = c.address_info("mtc1q...").await?; + +// Pool / staking +let pools = c.list_pools(PoolListOpts { sort: Some(PoolSort::ByPledge), ..Default::default() }).await?; +let pool = c.pool("mpool1...").await?; + +// Tokens +let token = c.token("mmltk1...").await?; +let tokens = c.tokens_by_ticker("MYTOKEN", PageOpts { items: 10, ..Default::default() }).await?; + +// Orders +let orders = c.list_orders(PageOpts::default()).await?; + +// Statistics +let stats = c.coin_statistics().await?; + +// Submit a signed transaction (requires --enable-post-routes) +let tx_id = c.submit_transaction(&signed_tx_hex).await?; +``` + +Non-2xx responses are returned as `indexer::Error::Http { status_code, body }`. + +## Wallet client (`wallet`) + +JSON-RPC 2.0 client for `wallet-rpc-daemon`. The wallet daemon manages key +storage, signing and broadcasting. + +```rust +use mintlayer_sdk::wallet::{self, Amount, IssueTokenParams, MintParams, TokenMetadata, TokenSupply, TxOptions}; + +let c = wallet::Client::builder("http://127.0.0.1:3034") + .basic_auth("user", "pass") // optional + .build()?; + +// Wallet lifecycle +c.open_wallet("/path/to/wallet.dat", None).await?; +c.sync_wallet().await?; + +// Accounts and addresses +let info = c.wallet_info().await?; +let address = c.new_address(0).await?; +let balance = c.balance(0).await?; + +// Send coins (account 0) +let result = c.send(wallet::SendParams { + account: 0, + address: "mtc1q...".into(), + amount: Amount::from_atoms(100_000_000_000), // 1 ML + selected_utxos: vec![], + options: TxOptions::default(), +}).await?; +println!("tx id: {}", result.tx_id); + +// Token operations +let issue = c.issue_token(IssueTokenParams { + account: 0, + destination_address: address.clone(), + metadata: TokenMetadata { + token_ticker: "MYTOKEN".into(), + number_of_decimals: 2, + metadata_uri: "https://example.com/token".into(), + token_supply: TokenSupply::Lockable, + is_freezable: false, + }, + options: TxOptions::default(), +}).await?; + +let mint = c.mint_tokens(MintParams { + account: 0, + token_id: issue.token_id, + address, + amount: Amount::from_atoms(1000), + options: TxOptions::default(), +}).await?; + +// Staking +c.start_staking(0).await?; +let pools = c.list_owned_pools(0).await?; + +// Compose and sign a raw transaction (cold wallet flow) +let composed = c.compose_transaction(params).await?; +let signed = c.sign_raw_transaction(0, &composed.hex).await?; +let submitted = c.submit_transaction(&signed.hex, false).await?; +``` + +Daemon errors are returned as `wallet::Error::Rpc { code, message }`. + +## Cryptography (`crypto`) + +Native key management and transaction building. The functions mirror the +go-sdk `wasm` sub-client operation for operation; see +[docs/crypto.md](docs/crypto.md) and +[docs/transactions.md](docs/transactions.md) for full walkthroughs. + +```rust +use mintlayer_sdk::crypto::{self, Amount, Network, SigHashType, SourceId, TxAdditionalInfo}; +use mintlayer_sdk::crypto::types::*; + +let account = crypto::make_default_account_privkey(MNEMONIC, Network::Mainnet, None)?; +let spend = crypto::make_receiving_address(&account, 0)?; +let pubkey = crypto::public_key_from_private_key(&spend); +let address = crypto::pubkey_to_pubkeyhash_address(&pubkey, Network::Mainnet); + +let outpoint = crypto::encode_outpoint_source_id(hash_of_txid, SourceId::Transaction); +let input = crypto::encode_input_for_utxo(outpoint, 0); +let output = crypto::encode_output_transfer(Amount::from_atoms(100_000_000_000), &to, Network::Mainnet)?; +let tx = crypto::encode_transaction(vec![input], outputs, 0)?; +let tx_id = crypto::transaction_id(&tx); + +let witness = crypto::encode_witness( + SigHashType::all(), &spend, &address, &tx, + &[Some(output)], 0, &TxAdditionalInfo::new(), tip_height + 1, Network::Mainnet, +)?; +let signed = crypto::encode_signed_transaction(tx, vec![witness])?; +let signed_hex = hex::encode(signed.encode()); +``` + +--- + +## Top-level client + +`Client::builder()` creates only the sub-clients whose URL is set. +Basic-auth credentials apply to the node and wallet clients only; the +indexer API is unauthenticated. + +```rust +// Node + wallet only — no indexer client is created. +let client = Client::builder() + .node_url("http://127.0.0.1:3030") + .wallet_url("http://127.0.0.1:3034") + .basic_auth("user", "pass") + .build()?; +``` + +`mintlayer_sdk::prelude` re-exports the shared crypto types (`Amount`, +`Network`, `SigHashType`, `TokenTotalSupply`, key and transaction types, the +SCALE `Encode`/`DecodeAll` traits, ...) so single-import callers do not need +to reach into `crypto`. + +--- + +## Examples + +| Example | Description | +|---|---| +| [examples/send-coins](examples/send-coins.rs) | Derive key → fetch UTXOs → build, sign and submit a transaction | +| [examples/issue-token](examples/issue-token.rs) | Issue a fungible token and mint an initial supply via the wallet daemon | + +```text +cargo run --example send-coins --features crypto,indexer -- --mnemonic "..." --to mtc1q... --amount 100000000000 --network testnet +cargo run --example issue-token --features wallet -- --wallet /tmp/wallet.dat --ticker MYTOKEN --supply 1000000 +``` + +--- + +## Amounts + +All coin and token amounts are atom counts — the smallest indivisible unit. +**1 ML = 100,000,000,000 atoms** (11 decimal places). + +- The `crypto` module uses `common::primitives::Amount`, a `u128` atom value. +- The remote clients model the daemon wire format: the node returns + `{"atoms": "..."}` objects decoded into a `u128`-backed `Amount`; the + indexer/wallet amounts carry `atoms` and `decimal` fields. + +--- + +## Networks + +| `crypto::Network` | Use | +|---|---| +| `Network::Mainnet` | Production network | +| `Network::Testnet` | Public test network | +| `Network::Regtest` | Local regression testing | +| `Network::Signet` | Signet | + +Every crypto operation that derives addresses or is fork-sensitive takes the +network (and, where applicable, the predicted inclusion block height) as a +parameter. + +--- + +## Error handling + +- `node::Error` / `wallet::Error` — `Rpc { code, message }` for JSON-RPC + errors, plus transport/decoding variants. +- `indexer::Error` — `Http { status_code, body }` for non-2xx responses, + plus transport/decoding variants. +- `crypto::Error` — typed variants for key, address, signing and encoding + failures. + +Transport hardening: request ids are atomic and unique per client, basic +auth is redacted from `Debug` output, and response bodies are capped at +64 MiB (error bodies are additionally truncated and stripped of control +characters). + +--- + +## Supply chain + +The `crypto` feature consumes mintlayer-core crates pinned to an exact +commit of `github.com/mintlayer/mintlayer-core`; the SCALE codec is pinned +to `github.com/paritytech/parity-scale-codec` at the same rev mintlayer-core +uses (an unreleased fix — see the root `Cargo.toml` for the mandatory +consumer patch). `mintlayer-core-primitives` is pulled transitively from +`github.com/mintlayer/mintlayer-core-primitives`. Keep the committed +`Cargo.lock` for reproducible builds. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/scripts/add-license.sh b/scripts/add-license.sh new file mode 100755 index 0000000..4f288d0 --- /dev/null +++ b/scripts/add-license.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Adds the license header to any Rust file that is missing it. +set -euo pipefail + +header='// 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.' + +found=0 +while IFS= read -r file; do + found=1 + if ! grep -q "Mintlayer Institutional FZCO" "$file"; then + printf '%s\n\n' "$header" | cat - "$file" > "$file.tmp" + mv "$file.tmp" "$file" + echo "added header: $file" + fi +done < <(find src examples tests -name '*.rs' -type f 2>/dev/null) + +if [ "$found" -eq 0 ]; then + echo "no Rust files found" +fi diff --git a/scripts/check-license.sh b/scripts/check-license.sh new file mode 100755 index 0000000..9c63e1c --- /dev/null +++ b/scripts/check-license.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Verifies that every Rust file carries the license header. +set -euo pipefail + +missing=0 +while IFS= read -r file; do + if ! grep -q "Mintlayer Institutional FZCO" "$file"; then + echo "missing header: $file" + missing=$((missing + 1)) + fi +done < <(find src examples tests -name '*.rs' -type f 2>/dev/null) + +if [ "$missing" -gt 0 ]; then + echo "$missing file(s) missing the license header" + exit 1 +fi +echo "all Rust files carry the license header" From f722a7dbc44682085ebd24e4bbce3ed0885226a7 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 01:52:03 +0400 Subject: [PATCH 18/29] docs: full reference guides for node, indexer, wallet, crypto, transactions, staking and tokens --- docs/crypto.md | 207 +++++++++++++++++++++++++++++++++++ docs/indexer.md | 168 +++++++++++++++++++++++++++++ docs/node.md | 128 ++++++++++++++++++++++ docs/staking.md | 236 ++++++++++++++++++++++++++++++++++++++++ docs/tokens.md | 249 ++++++++++++++++++++++++++++++++++++++++++ docs/transactions.md | 250 +++++++++++++++++++++++++++++++++++++++++++ docs/wallet.md | 168 +++++++++++++++++++++++++++++ 7 files changed, 1406 insertions(+) create mode 100644 docs/crypto.md create mode 100644 docs/indexer.md create mode 100644 docs/node.md create mode 100644 docs/staking.md create mode 100644 docs/tokens.md create mode 100644 docs/transactions.md create mode 100644 docs/wallet.md diff --git a/docs/crypto.md b/docs/crypto.md new file mode 100644 index 0000000..ca1e651 --- /dev/null +++ b/docs/crypto.md @@ -0,0 +1,207 @@ +# Cryptography (`crypto`) + +The `crypto` module provides native key management and transaction +building backed by mintlayer-core. Unlike the go-sdk there is no embedded +WASM runtime, no `InitWASM` and no `Close`: functions are called directly +and exchange typed values (`Transaction`, `TxOutput`, `PrivateKey`, +`Amount`, ...) instead of opaque byte arrays. Every type participates in +the SCALE encoding; `crypto::types::{Encode, DecodeAll}` provide +byte-level access (`value.encode() -> Vec`). + +Enable the feature (it pulls in the mintlayer-core dependency graph): + +```toml +[dependencies] +mintlayer-sdk = { version = "...", features = ["crypto"] } +``` + +**Amounts** are `crypto::Amount`, a `u128` atom count — 1 ML = +100,000,000,000 atoms. Build with `Amount::from_atoms(n)` or +`Amount::ZERO`. + +**Networks**: every function that derives addresses or is fork-sensitive +takes `Network` (`Mainnet`, `Testnet`, `Regtest`, `Signet`). + +**Fork sensitivity:** some functions take a `current_block_height` +parameter because their result depends on whether a hard fork has been +activated. Pass the height of the block into which the transaction is +supposed to be included — a wallet that cannot predict it should use the +current tip height plus one and refuse to operate near a fork height. + +Errors are returned as `crypto::Error` with typed variants +(`InvalidMnemonic`, `AddressParse { address, message }`, `InputSigning`, +`Sighash`, `OrdersV1NotActivated`, ...). + +--- + +## Key derivation + +Paths are `44'/coin_type'/0'` for the account, then `/0/i` (receiving) +and `/1/i` (change). + +| Function | Notes | +|----------|-------| +| `make_private_key() -> PrivateKey` | Random Secp256k1Schnorr key from entropy | +| `make_default_account_privkey(mnemonic: &str, network: Network, passphrase: Option<&str>) -> Result` | The optional BIP39 passphrase changes the seed entirely | +| `make_receiving_address(account_key: &ExtendedPrivateKey, key_index: u32) -> Result` | `/0/` | +| `make_change_address(account_key: &ExtendedPrivateKey, key_index: u32) -> Result` | `/1/` | +| `make_receiving_address_public_key(account_public_key: &ExtendedPublicKey, key_index: u32) -> Result` | Watch-only derivation | +| `make_change_address_public_key(account_public_key: &ExtendedPublicKey, key_index: u32) -> Result` | Watch-only derivation | +| `public_key_from_private_key(private_key: &PrivateKey) -> PublicKey` | | +| `extended_public_key_from_extended_private_key(account_key: &ExtendedPrivateKey) -> ExtendedPublicKey` | For watch-only wallets | + +The BIP39 seed and passphrase are zeroized on drop, but the returned key +objects are plain values without zeroization (mirroring mintlayer-core). +Avoid cloning or logging keys and keep their lifetime short. + +## Addresses + +| Function | Notes | +|----------|-------| +| `encode_destination(address: &str, network: Network) -> Result` | Parses bech32 into a `Destination` | +| `pubkey_to_pubkeyhash_address(public_key: &PublicKey, network: Network) -> String` | Bech32 P2PKH address | +| `encode_multisig_challenge(public_keys: &[PublicKey], min_required_signatures: u8, network: Network) -> Result` | `min_required_signatures` must be at least 1 | +| `multisig_challenge_to_address(challenge: &ClassicMultisigChallenge, network: Network) -> String` | Bech32 multisig address | + +## Chain-assigned ids + +Deterministic ids derived from a transaction's inputs; the same inputs +always produce the same id. + +| Function | Notes | +|----------|-------| +| `get_token_id(inputs: &[TxInput], current_block_height: u64, network: Network) -> Result` | The id scheme depends on the upgrade active at that height | +| `get_pool_id(inputs: &[TxInput], network: Network) -> Result` | Required for `encode_output_create_stake_pool` | +| `get_delegation_id(inputs: &[TxInput], network: Network) -> Result` | | +| `get_order_id(inputs: &[TxInput], network: Network) -> Result` | | + +## Inputs + +| Function | Notes | +|----------|-------| +| `encode_input_for_utxo(outpoint_source_id: OutPointSourceId, output_index: u32) -> TxInput` | | +| `encode_input_for_withdraw_from_delegation(delegation_id: &str, amount: Amount, nonce: u64, network: Network) -> Result` | Nonce must follow the delegation's account spendings | +| `encode_input_for_mint_tokens(token_id: &str, amount: Amount, nonce: u64, network: Network) -> Result` | | +| `encode_input_for_unmint_tokens(token_id: &str, nonce: u64, network: Network) -> Result` | | +| `encode_input_for_lock_token_supply(token_id: &str, nonce: u64, network: Network) -> Result` | | +| `encode_input_for_freeze_token(token_id: &str, is_token_unfreezable: IsTokenUnfreezable, nonce: u64, network: Network) -> Result` | | +| `encode_input_for_unfreeze_token(token_id: &str, nonce: u64, network: Network) -> Result` | | +| `encode_input_for_change_token_authority(token_id: &str, new_authority: &str, nonce: u64, network: Network) -> Result` | | +| `encode_input_for_change_token_metadata_uri(token_id: &str, new_metadata_uri: &str, nonce: u64, network: Network) -> Result` | | +| `encode_input_for_fill_order(order_id: &str, fill_amount: Amount, destination: &str, nonce: u64, current_block_height: u64, network: Network) -> Result` | Must not be signed — see below | +| `encode_input_for_freeze_order(order_id: &str, current_block_height: u64, network: Network) -> Result` | Orders V1 only; errors before the fork | +| `encode_input_for_conclude_order(order_id: &str, nonce: u64, current_block_height: u64, network: Network) -> Result` | Nonce ignored after the orders V1 fork | + +Account-command nonces (`nonce: u64`) must be in sequence with the +authority's other account spendings; fetch the next expected value from +the indexer (`next_nonce`). + +**Quirk:** fill-order inputs must not be signed — use +`encode_witness_no_signature()` for them. Before the orders V1 fork the +input's `nonce` and `destination` are significant; after the fork both are +ignored (the destination is derived from the order's outputs). + +## Outputs + +| Function | Notes | +|----------|-------| +| `encode_output_transfer(amount: Amount, address: &str, network: Network) -> Result` | Coin transfer | +| `encode_output_token_transfer(amount: Amount, address: &str, token_id: &str, network: Network) -> Result` | | +| `encode_output_lock_then_transfer(amount: Amount, address: &str, lock: OutputTimeLock, network: Network) -> Result` | | +| `encode_output_token_lock_then_transfer(amount, address, token_id, lock, network) -> Result` | | +| `encode_output_coin_burn(amount: Amount) -> TxOutput` | | +| `encode_output_token_burn(amount: Amount, token_id: &str, network: Network) -> Result` | | +| `encode_output_create_delegation(pool_id: &str, owner_address: &str, network: Network) -> Result` | | +| `encode_output_delegate_staking(amount: Amount, delegation_id: &str, network: Network) -> Result` | | +| `encode_output_create_stake_pool(pool_id: &str, pool_data: StakePoolData, network: Network) -> Result` | `pool_id` must come from `get_pool_id` of the first input | +| `encode_output_produce_block_from_stake(pool_id: &str, staker: &str, network: Network) -> Result` | | +| `encode_output_data_deposit(data: &[u8]) -> TxOutput` | | +| `encode_output_issue_fungible_token(...) -> Result` | See [tokens.md](tokens.md) | +| `encode_output_issue_nft(...) -> Result` | See [tokens.md](tokens.md) | +| `encode_output_htlc(amount, token_id: Option<&str>, secret_hash, spend_address, refund_address, refund_timelock, network) -> Result` | `None` token id means coins | +| `encode_create_order_output(ask_amount, ask_token_id: Option<&str>, give_amount, give_token_id: Option<&str>, conclude_address, network) -> Result` | See [tokens.md](tokens.md) | + +## Timelocks + +| Function | Returns | +|----------|---------| +| `encode_lock_for_block_count(block_count: u64) -> OutputTimeLock` | Locked for N blocks after inclusion | +| `encode_lock_for_seconds(total_seconds: u64) -> OutputTimeLock` | Locked for N seconds after inclusion | +| `encode_lock_until_time(timestamp_since_epoch_in_seconds: u64) -> OutputTimeLock` | Locked until a UNIX timestamp | +| `encode_lock_until_height(block_height: u64) -> OutputTimeLock` | Locked until a block height | + +## Fees and staking helpers + +Protocol fees are static or height-dependent; all return `Amount` in atoms +and must be funded by the transaction's coin inputs: + +| Function | Notes | +|----------|-------| +| `fungible_token_issuance_fee(_current_block_height: u64, network: Network) -> Amount` | | +| `nft_issuance_fee(current_block_height: u64, network: Network) -> Amount` | | +| `token_supply_change_fee(current_block_height: u64, network: Network) -> Amount` | Minting and unminting | +| `token_freeze_fee(current_block_height: u64, network: Network) -> Amount` | Freezing and unfreezing | +| `token_change_authority_fee(current_block_height: u64, network: Network) -> Amount` | | +| `data_deposit_fee(current_block_height: u64, network: Network) -> Amount` | | + +| Function | Notes | +|----------|-------| +| `encode_stake_pool_data(value, staker, vrf_public_key, decommission_key, margin_ratio_per_thousand: u16, cost_per_block, network) -> Result` | See [staking.md](staking.md) | +| `effective_pool_balance(network: Network, pledge_amount: Amount, pool_balance: Amount) -> Result` | Pledge-capped balance used by consensus | +| `staking_pool_spend_maturity_block_count(current_block_height: u64, network: Network) -> u64` | Blocks until decommissioned funds are spendable | + +## Transactions + +| Function | Notes | +|----------|-------| +| `encode_outpoint_source_id(id: H256, source: SourceId) -> OutPointSourceId` | `SourceId::Transaction` or `SourceId::BlockReward` | +| `encode_transaction(inputs: Vec, outputs: Vec, flags: u64) -> Result` | Flags are currently always zero | +| `transaction_id(transaction: &Transaction) -> String` | Lowercase hex | +| `decode_transaction(bytes: &[u8]) -> Result` | Requires exactly one transaction | +| `decode_transaction_lenient(bytes: &[u8]) -> Result` | Ignores trailing witness bytes | +| `encode_signed_transaction(transaction: Transaction, signatures: Vec) -> Result` | | +| `encode_partially_signed_transaction(transaction, signatures: Vec>, input_utxos, input_destinations, htlc_secrets, additional_info, _network) -> Result` | One entry per input, `None` marks absent | +| `decode_signed_transaction_to_json(transaction: &[u8], network: Network) -> Result` | JSON with bech32 addresses | +| `decode_partially_signed_transaction_to_json(transaction: &[u8], network: Network) -> Result` | | +| `estimate_transaction_size(inputs: &[TxInput], input_destinations: &[&str], outputs: &[TxOutput], network: Network) -> Result` | Script hash/multisig destinations unsupported; HTLC inputs counted as UTXO | +| `extract_htlc_secret(signed_tx: &SignedTransaction, htlc_outpoint_source_id: OutPointSourceId, htlc_output_index: u32) -> Result` | | + +## Signing + +| Function | Notes | +|----------|-------| +| `encode_witness(sighash_type, private_key, input_owner_destination, transaction, input_utxos: &[Option], input_index: usize, additional_info: &TxAdditionalInfo, current_block_height, network) -> Result` | Signs one input; see [transactions.md](transactions.md) | +| `encode_witness_no_signature() -> InputWitness` | For fill-order inputs | +| `encode_witness_htlc_spend(..., secret: HtlcSecret, ...) -> Result` | Spends an HTLC, revealing the secret | +| `encode_witness_htlc_refund_single_sig(...) -> Result` | HTLC refund to a single-sig address | +| `encode_witness_htlc_refund_multisig(sighash_type, private_key, key_index: u8, input_witness: Option<&InputWitness>, challenge, transaction, input_utxos, input_index, additional_info, current_block_height, network) -> Result` | Cumulative; `None` for the first signer | +| `sign_message_for_spending(private_key: &PrivateKey, message: &[u8]) -> Result` | | +| `verify_signature_for_spending(public_key: &PublicKey, signature: &Signature, message: &[u8]) -> bool` | | +| `sign_challenge(private_key: &PrivateKey, message: &[u8]) -> Result, Error>` | Arbitrary-message signature for a pubkeyhash address | +| `verify_challenge(address: &str, network: Network, signed_challenge: &[u8], message: &[u8]) -> Result` | | + +`input_utxos` in every witness function must hold one entry per +transaction input: `None` for non-UTXO inputs, `Some(output)` otherwise. +`additional_info` must cover every `ProduceBlockFromStake`, `FillOrder` +and `ConcludeOrder` input of the transaction, not just the one signed. + +## Transaction intents + +| Function | Notes | +|----------|-------| +| `make_transaction_intent_message_to_sign(intent: &str, transaction_id_hex: &str) -> Result` | Every input destination signs this with `sign_challenge` | +| `encode_signed_transaction_intent(signed_message: &str, signatures: Vec>) -> Result` | One signature per input, in input order | +| `verify_transaction_intent(expected_signed_message: &str, encoded_signed_intent: &[u8], input_destinations: &[&str], network: Network) -> Result<(), Error>` | Pubkey and pubkeyhash addresses are interchangeable | + +## Re-exported types + +`crypto::types` re-exports mintlayer-core primitives so consumers do not +need a direct dependency: `Transaction`, `SignedTransaction`, `TxInput`, +`TxOutput`, `Destination`, `OutputValue`, `OutPointSourceId`, +`InputWitness`, `StakePoolData`, `OutputTimeLock`, `HtlcSecret`, +`ClassicMultisigChallenge`, `H256`, `PrivateKey`, `PublicKey`, +`Signature`, `ExtendedPrivateKey`, `ExtendedPublicKey`, and the SCALE +`Encode`/`DecodeAll` traits. Also at the module root: `SigHashType`, +`TokenTotalSupply` (`Fixed(Amount)`, `Lockable`, `Unlimited`), +`IsTokenFreezable`/`IsTokenUnfreezable` (`Yes`/`No`), `Amount`, +`TxAdditionalInfo`, and `SourceId`. diff --git a/docs/indexer.md b/docs/indexer.md new file mode 100644 index 0000000..194ff4d --- /dev/null +++ b/docs/indexer.md @@ -0,0 +1,168 @@ +# Indexer client + +The `indexer` module is a REST client for the Mintlayer indexer +(`api-web-server`). All paths are relative to `/api/v2/`, which the client +appends to the base URL automatically. + +```rust +use mintlayer_sdk::indexer::{self, PageOpts, PoolListOpts, PoolSort}; + +let c = indexer::Client::new("http://127.0.0.1:3000"); + +// Or with options: +let c = indexer::Client::builder("http://127.0.0.1:3000") + .timeout(std::time::Duration::from_secs(15)) // optional, default 30s + .http_client(reqwest::Client::new()) // optional + .build()?; +``` + +**Default ports:** 3000 (mainnet), 13000 (testnet). + +The indexer API is unauthenticated; there are no credential options. + +Errors are returned as `indexer::Error`: + +- `Error::Http { status_code, body }` — any non-2xx response; the body is + trimmed and stripped of control characters. +- `Error::Transport(reqwest::Error)` — the request failed. +- `Error::Json(serde_json::Error)` — the response could not be decoded. +- `Error::InvalidUrl { message }` — a path segment contained characters + outside `[A-Za-z0-9_-]` (ids, addresses and tickers are validated before + being interpolated into the path). +- `Error::ResponseTooLarge { limit }` — the response exceeded 64 MiB. + +--- + +## Pagination + +List endpoints take `PageOpts`: + +```rust +use mintlayer_sdk::indexer::PageOpts; + +let opts = PageOpts { offset: 20, items: 10 }; +// zero values are omitted from the query; the server defaults +// to offset 0 and 10 items per page +``` + +`PoolListOpts` extends the same pagination with a `sort` field +(`PoolSort::ByHeight`, the server default, or `PoolSort::ByPledge`). + +--- + +## Chain + +| Method | REST path | Notes | +|--------|-----------|-------| +| `tip() -> Result` | `GET /chain/tip` | `ChainTip { block_height, block_id }` | +| `genesis() -> Result` | `GET /chain/genesis` | Genesis block id, message, UTXO set | +| `block_id_at_height(height: u64) -> Result` | `GET /chain/{height}` | 404 `Error::Http` beyond the tip | + +## Blocks + +| Method | REST path | Notes | +|--------|-----------|-------| +| `block(id: &str) -> Result` | `GET /block/{id}` | Header, reward outputs, transactions | +| `block_header(id: &str) -> Result` | `GET /block/{id}/header` | Cheaper than `block` | +| `block_reward(id: &str) -> Result, Error>` | `GET /block/{id}/reward` | Raw JSON outputs | +| `block_transaction_ids(id: &str) -> Result, Error>` | `GET /block/{id}/transaction-ids` | | + +## Addresses + +| Method | REST path | Notes | +|--------|-----------|-------| +| `address_info(address: &str) -> Result` | `GET /address/{address}` | Coin and token balances, history | +| `spendable_utxos(address: &str) -> Result, Error>` | `GET /address/{address}/spendable-utxos` | Confirmed, immediately spendable | +| `all_utxos(address: &str) -> Result, Error>` | `GET /address/{address}/all-utxos` | Includes locked outputs | +| `delegations(address: &str) -> Result, Error>` | `GET /address/{address}/delegations` | | +| `token_authority(address: &str) -> Result, Error>` | `GET /address/{address}/token-authority` | Token ids the address controls | + +## Transactions + +| Method | REST path | Notes | +|--------|-----------|-------| +| `list_transactions(opts: PageOpts) -> Result, Error>` | `GET /transaction` | Newest first | +| `transaction(id: &str) -> Result` | `GET /transaction/{id}` | See the unconfirmed quirk below | +| `transaction_merkle_path(id: &str) -> Result` | `GET /transaction/{id}/merkle-path` | 404 before confirmation | +| `transaction_output(tx_id: &str, index: u32) -> Result` | `GET /transaction/{tx_id}/output/{index}` | Raw JSON | +| `submit_transaction(signed_tx_hex: &str) -> Result` | `POST /transaction` | Requires `--enable-post-routes`; returns the tx id | + +**Quirk:** for unconfirmed transactions the `Transaction` fields +`block_id`, `timestamp` and `confirmations` are empty strings, not numbers +or nulls. + +## Pools and delegations + +| Method | REST path | Notes | +|--------|-----------|-------| +| `list_pools(opts: PoolListOpts) -> Result, Error>` | `GET /pool` | `sort=by_height` (default) or `by_pledge` | +| `pool(id: &str) -> Result` | `GET /pool/{id}` | | +| `pool_block_stats(id: &str, from: i64, to: i64) -> Result` | `GET /pool/{id}/block-stats` | Block count in `[from, to)`, UNIX seconds | +| `pool_delegations(id: &str) -> Result, Error>` | `GET /pool/{id}/delegations` | Includes `creation_block_height` | +| `delegation(id: &str) -> Result` | `GET /delegation/{id}` | | + +## Tokens and NFTs + +| Method | REST path | Notes | +|--------|-----------|-------| +| `list_tokens(opts: PageOpts) -> Result, Error>` | `GET /token` | Bech32 token ids | +| `token(id: &str) -> Result` | `GET /token/{id}` | | +| `token_transactions(id: &str, opts: PageOpts) -> Result, Error>` | `GET /token/{id}/transactions` | | +| `tokens_by_ticker(ticker: &str, opts: PageOpts) -> Result, Error>` | `GET /token/ticker/{ticker}` | Tickers are not unique | +| `nft(id: &str) -> Result` | `GET /nft/{id}` | Owner, token id, metadata | + +## Orders + +| Method | REST path | Notes | +|--------|-----------|-------| +| `list_orders(opts: PageOpts) -> Result, Error>` | `GET /order` | | +| `order(id: &str) -> Result` | `GET /order/{id}` | | +| `orders_by_pair(ask, give, opts) -> Result, Error>` | `GET /order/pair/{ask}_{give}` | Currencies are the coin ticker (e.g. `ML`) or a bech32 token id | + +## Statistics and fees + +| Method | REST path | Notes | +|--------|-----------|-------| +| `coin_statistics() -> Result` | `GET /statistics/coin` | Circulating, preminted, burned, staked | +| `token_statistics(token_id: &str) -> Result` | `GET /statistics/token/{token_id}` | | +| `fee_rate(in_top_x_mb: u32) -> Result` | `GET /feerate` | Atoms per KB; `0` uses the server default of 5 MB | + +--- + +## Amounts and string-encoded numbers + +The indexer serializes some numeric fields as JSON strings or mixed +representations. The client types absorb this transparently, but be aware +of the shapes: + +- `Amount` carries both fields: `atoms: u128` and `decimal: String` + (whole coins). +- `Uint64` fields (`next_nonce`, `creation_block_height`, `Order::nonce`) + may arrive as a JSON integer or a decimal string; `.0` gives the `u64`, + `Display` prints the number. +- `PerThousand` (pool `margin_ratio_per_thousand`) may arrive as a number + or as a decimal string with an optional trailing `%`. + +Example: + +```rust +use mintlayer_sdk::indexer::Client; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let c = Client::new("http://127.0.0.1:3000"); + + let tip = c.tip().await?; + println!("tip: {} at height {}", tip.block_id, tip.block_height); + + let pool = c.pool("mpool1...").await?; + println!( + "staker {} delegated {}", + pool.staker_balance.decimal, pool.delegations_balance.decimal + ); + + // Submit a signed transaction (requires --enable-post-routes). + // let tx_id = c.submit_transaction(&signed_hex).await?; + Ok(()) +} +``` diff --git a/docs/node.md b/docs/node.md new file mode 100644 index 0000000..1c98b33 --- /dev/null +++ b/docs/node.md @@ -0,0 +1,128 @@ +# Node client + +The `node` module is a JSON-RPC 2.0 client for the Mintlayer node daemon. + +```rust +use mintlayer_sdk::node::{self, TrustPolicy}; + +let c = node::Client::new("http://127.0.0.1:3030"); + +// Or with options: +let c = node::Client::builder("http://127.0.0.1:3030") + .basic_auth("user", "pass") // optional + .timeout(std::time::Duration::from_secs(10)) // optional, default 30s + .build()?; +``` + +**Default ports:** 3030 (mainnet), 13030 (testnet). + +Credentials are sent as HTTP basic auth with every request. They are +redacted from `Debug` output, so logging a client never leaks the password. +Response bodies are capped at 64 MiB to guard against memory exhaustion +from a misconfigured endpoint. + +Errors are returned as `node::Error`: + +- `Error::Rpc { code, message }` — the daemon answered with a JSON-RPC error. +- `Error::Http(reqwest::Error)` — transport failure. +- `Error::Json(serde_json::Error)` — the response could not be decoded. +- `Error::IdMismatch { expected, actual }` — response id mismatch. +- `Error::ResponseTooLarge { limit }` — the 64 MiB cap was exceeded. + +--- + +## Chain state + +| Method | RPC method | Notes | +|--------|-----------|-------| +| `chainstate_info() -> Result` | `chainstate_info` | Height, id, timestamps, IBD flag of the tip | +| `best_block_id() -> Result` | `chainstate_best_block_id` | Hex-encoded block id | +| `best_block_height() -> Result` | `chainstate_best_block_height` | | +| `block_id_at_height(height: u64) -> Result, Error>` | `chainstate_block_id_at_height` | `None` when not on the main chain | +| `block_height_in_main_chain(block_id: &str) -> Result, Error>` | `chainstate_block_height_in_main_chain` | `None` when orphaned or unknown | +| `block(id: &str) -> Result, Error>` | `chainstate_get_block` | Hex-encoded block; the genesis block cannot be retrieved | +| `block_json(id: &str) -> Result` | `chainstate_get_block_json` | | +| `mainchain_blocks(from: u64, max_count: u32) -> Result, Error>` | `chainstate_get_mainchain_blocks` | Up to `max_count` hex-encoded blocks | +| `utxo(outpoint: &Outpoint) -> Result, Error>` | `chainstate_get_utxo` | `None` when spent or unknown | +| `stake_pool_balance(pool_address: &str) -> Result, Error>` | `chainstate_stake_pool_balance` | Pledge plus delegations | +| `staker_balance(pool_address: &str) -> Result, Error>` | `chainstate_staker_balance` | Staker's own balance only | +| `pool_decommission_destination(pool_address: &str) -> Result, Error>` | `chainstate_pool_decommission_destination` | | +| `delegation_share(pool_address, delegation_address) -> Result, Error>` | `chainstate_delegation_share` | | +| `token_info(token_id: &str) -> Result, Error>` | `chainstate_token_info` | | +| `tokens_info(token_ids: &[String]) -> Result, Error>` | `chainstate_tokens_info` | Batch lookup, order preserved | +| `order_info(order_id: &str) -> Result, Error>` | `chainstate_order_info` | See the nonce quirk below | +| `orders_info_by_currencies(ask, give) -> Result, Error>` | `chainstate_orders_info_by_currencies` | `None` filters match every currency | +| `submit_block(block_hex: &str) -> Result<(), Error>` | `chainstate_submit_block` | For block producers | + +`Amount` is a `u128` atom count (1 ML = 100,000,000,000 atoms), serialized +as a decimal string inside an `{"atoms": "..."}` object on the wire. + +**Quirk:** in [`OrderInfo`](#) the `nonce` field is +`Option` because the daemon returns `null` while an order has no +account spending history; handle both cases. + +`TokenInfo` keeps the type-specific payload as raw JSON because its shape +depends on `kind` (`"FungibleToken"` or `"NonFungibleToken"`). + +--- + +## Mempool + +| Method | RPC method | Notes | +|--------|-----------|-------| +| `contains_tx(tx_id: &str) -> Result` | `mempool_contains_tx` | | +| `contains_orphan_tx(tx_id: &str) -> Result` | `mempool_contains_orphan_tx` | | +| `transaction(tx_id: &str) -> Result, Error>` | `mempool_get_transaction` | Mempool and orphan pool | +| `submit_transaction(tx_hex: &str, trust_policy: TrustPolicy) -> Result<(), Error>` | `mempool_submit_transaction` | Local mempool only, no P2P broadcast | +| `fee_rate(in_top_x_mb: u32) -> Result, Error>` | `mempool_get_fee_rate` | Atoms per kilobyte; `None` when no estimate | +| `fee_rate_points() -> Result, Error>` | `mempool_get_fee_rate_points` | Curve of `[size, rate]` pairs | +| `memory_usage() -> Result` | `mempool_memory_usage` | Bytes | + +## P2P + +| Method | RPC method | Notes | +|--------|-----------|-------| +| `peer_count() -> Result` | `p2p_get_peer_count` | | +| `connected_peers() -> Result, Error>` | `p2p_get_connected_peers` | Addresses, roles, ban scores, pings | +| `bind_addresses() -> Result, Error>` | `p2p_get_bind_addresses` | | +| `add_reserved_node(addr: &str) -> Result<(), Error>` | `p2p_add_reserved_node` | | +| `remove_reserved_node(addr: &str) -> Result<(), Error>` | `p2p_remove_reserved_node` | | +| `connect(addr: &str) -> Result<(), Error>` | `p2p_connect` | One-time connection | +| `disconnect(peer_id: u64) -> Result<(), Error>` | `p2p_disconnect` | | +| `list_banned() -> Result, Error>` | `p2p_list_banned` | | +| `ban(address: &str, duration: Duration) -> Result<(), Error>` | `p2p_ban` | Duration is sent as `[secs, nanos]` | +| `unban(address: &str) -> Result<(), Error>` | `p2p_unban` | | +| `broadcast_transaction(tx_hex: &str, trust_policy: TrustPolicy) -> Result<(), Error>` | `p2p_submit_transaction` | The normal path for publishing a transaction | + +## Node management + +| Method | RPC method | Notes | +|--------|-----------|-------| +| `node_version() -> Result` | `node_version` | e.g. `"1.3.0"` | +| `node_shutdown() -> Result<(), Error>` | `node_shutdown` | Graceful shutdown | + +--- + +## Trust policy + +`TrustPolicy` is applied when submitting a transaction: + +| Variant | Meaning | +|---------|---------| +| `TrustPolicy::Trusted` (default) | Accept the transaction only if fully valid against the current chainstate | +| `TrustPolicy::Untrusted` | Accept the transaction even if some inputs are not yet known | + +Use `Untrusted` for transactions received from external sources (so that +orphaned spends are not rejected), `Trusted` for transactions you +constructed yourself. + +```rust +// Submit locally and broadcast in one call: +c.broadcast_transaction(&signed_tx_hex, TrustPolicy::Untrusted).await?; +``` + +## Top-level client + +`Client::builder()` creates the node client only when a `node_url` is set; +`basic_auth` applies to the node and wallet clients but never to the +unauthenticated indexer. diff --git a/docs/staking.md b/docs/staking.md new file mode 100644 index 0000000..2761904 --- /dev/null +++ b/docs/staking.md @@ -0,0 +1,236 @@ +# Staking and delegations + +Mintlayer uses Proof of Stake. ML holders earn staking rewards either by +running a **staking pool** directly (requires a node, a VRF key and a +pledge) or by **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 `crypto` module provides the low-level +encoders for manual transaction flows. + +All amounts are atom counts — 1 ML = 100,000,000,000 atoms. + +--- + +## Staking pools + +### Creating a pool with the wallet daemon + +```rust +use mintlayer_sdk::wallet::{self, Amount, CreatePoolParams, TxOptions}; + +let c = wallet::Client::new("http://127.0.0.1:3034"); + +let result = c.create_stake_pool(CreatePoolParams { + account: 0, + amount: Amount::from_atoms(40_000_000_000_000), // pledge + cost_per_block: Amount::from_atoms(100_000_000), // flat fee per block + margin_ratio_per_thousand: "100".into(), // 10% staker cut + decommission_address: "mtc1q...".into(), + staker_address: None, // None = wallet-derived + vrf_public_key: None, // None = wallet-derived + options: TxOptions::default(), +}).await?; +println!("pool tx id: {}", result.tx_id); +``` + +`margin_ratio_per_thousand` is the staker's cut of block rewards in +thousandths, as a decimal string: `"100"` = 10%, `"50"` = 5%, +`"1000"` = 100%. `cost_per_block` is deducted from rewards before the +margin split; delegators share the remainder proportionally to their +stake. + +### Starting and stopping block production + +```rust +c.start_staking(0).await?; +println!("{:?}", c.staking_status(0).await?); // Staking | NotStaking +c.stop_staking(0).await?; +``` + +`stop_staking` stops block production but does not decommission the pool; +delegations and staked funds remain untouched. + +### Pool lifecycle + +| Method | RPC method | Notes | +|--------|-----------|-------| +| `create_stake_pool(params) -> Result` | `staking_create_pool` | Creates and funds the pool | +| `decommission_stake_pool(params) -> Result` | `staking_decommission_pool` | Pledge returns to `output_address` after maturity | +| `list_owned_pools(account: u32) -> Result, Error>` | `staking_list_pools` | Pledge, balance, margin, cost per pool | +| `pool_balance(pool_id: &str) -> Result, Error>` | `staking_pool_balance` | | +| `start_staking(account: u32) / stop_staking(account: u32)` | `staking_start` / `staking_stop` | | +| `staking_status(account: u32) -> Result` | `staking_status` | | + +After decommissioning, the pledge is returned once the maturity period +passes. Delegators must withdraw their funds separately. + +### Reading pool state from the indexer + +```rust +use mintlayer_sdk::indexer::{Client, PoolListOpts, PoolSort}; + +let idx = Client::new("http://127.0.0.1:3000"); + +let pools = idx.list_pools(PoolListOpts { + sort: Some(PoolSort::ByPledge), ..Default::default() +}).await?; +let pool = idx.pool("mpool1...").await?; +println!("staker {} delegated {}", + pool.staker_balance.decimal, pool.delegations_balance.decimal); + +let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)?.as_secs() as i64; +let blocks = idx.pool_block_stats("mpool1...", now - 86_400, now).await?; // last 24h +let delegations = idx.pool_delegations("mpool1...").await?; +``` + +### Building a pool creation manually + +```rust +use mintlayer_sdk::crypto::{self, Amount, Network}; +use mintlayer_sdk::crypto::types::{TxInput, TxOutput}; + +let network = Network::Testnet; +let inputs: Vec = todo!(); // your UTXO inputs + +// The pool id is derived from the hash of the first input. +let pool_id = crypto::get_pool_id(&inputs, network)?; + +let pool_data = crypto::encode_stake_pool_data( + Amount::from_atoms(40_000_000_000_000), // pledge (value) + "mtc1q_staker...", // staker address + "mtc1q_vrf_public_key...", // bech32 VRF public key + "mtc1q_decommission...", // decommission destination + 100, // margin ratio per thousand + Amount::from_atoms(100_000_000), // cost per block + network, +)?; +let output: TxOutput = + crypto::encode_output_create_stake_pool(&pool_id, pool_data, network)?; +``` + +The VRF public key is a bech32 addressable object; derive one from +`mintlayer-core`'s VRF key generation or let the wallet daemon do it +(`vrf_public_key: None`). + +--- + +## Delegations + +A delegation id is tied to a pool and an owner address. Create the +delegation record first, then fund it. + +### Creating and funding with the wallet daemon + +```rust +use mintlayer_sdk::wallet::{self, Amount, CreateDelegationParams, DelegateParams, TxOptions}; + +let c = wallet::Client::new("http://127.0.0.1:3034"); + +// Step 1: create the delegation. +let created = c.create_delegation(CreateDelegationParams { + account: 0, + address: "mtc1q_owner...".into(), // address that can withdraw + pool_id: "mpool1...".into(), + options: TxOptions::default(), +}).await?; +println!("delegation id: {}", created.delegation_id); + +// Step 2: fund it (wait for the creation tx to confirm first). +c.delegate_staking(DelegateParams { + account: 0, + amount: Amount::from_atoms(10_000_000_000_000), // 100 ML + delegation_id: created.delegation_id, + options: TxOptions::default(), +}).await?; +``` + +Multiple `delegate_staking` calls to the same delegation increase the +stake. + +### Withdrawing and listing + +```rust +use mintlayer_sdk::wallet::{WithdrawParams, Amount, TxOptions}; + +c.withdraw_from_delegation(WithdrawParams { + account: 0, + address: "mtc1q_recipient...".into(), + amount: Amount::from_atoms(5_000_000_000_000), // 50 ML + delegation_id: "mdelg1...".into(), + options: TxOptions::default(), +}).await?; + +for d in c.list_delegations(0).await? { + println!("{} pool={} balance={}", d.delegation_id, d.pool_id, d.balance.atoms().unwrap_or_default()); +} +``` + +Withdrawn funds arrive at `address` after the consensus lock period. + +### Reading delegation state from the indexer + +```rust +let infos = idx.delegations("mtc1q_owner...").await?; // by owner address +let delegation = idx.delegation("mdelg1...").await?; // by delegation id +println!("next nonce: {}", delegation.next_nonce); // string-encoded u64 +``` + +### Building delegation transactions manually + +```rust +use mintlayer_sdk::crypto::{self, Amount, Network}; +use mintlayer_sdk::crypto::types::TxInput; + +let network = Network::Testnet; +let inputs: Vec = todo!(); // your UTXO inputs + +// Predict the delegation id before broadcasting. +let delegation_id = crypto::get_delegation_id(&inputs, network)?; + +// Create the delegation record. +let create = crypto::encode_output_create_delegation("mpool1...", "mtc1q_owner...", network)?; + +// Fund it. +let fund = crypto::encode_output_delegate_staking( + Amount::from_atoms(10_000_000_000_000), &delegation_id, network)?; + +// Withdraw: the nonce must match the indexer's next_nonce. +let withdraw_input = crypto::encode_input_for_withdraw_from_delegation( + "mdelg1...", + Amount::from_atoms(5_000_000_000_000), + 0, // delegation.next_nonce + network, +)?; +``` + +The withdrawal output is a `LockThenTransfer` (see +[crypto.md](crypto.md) timelocks) that releases after the lock period. + +--- + +## Rewards and effective balance + +The staker receives +`cost_per_block + margin_ratio_per_thousand / 1000 * (block_reward - cost_per_block)`; +delegators split the remainder proportionally to their stake. To estimate +rewards: pool block stats for a time range (`pool_block_stats`), the +pool's cost and margin (`pool`), and your delegation's share +(`delegation`). + +Consensus caps a pool's effective weight by its pledge: + +```rust +use mintlayer_sdk::crypto::{self, Amount, Network}; + +let network = Network::Testnet; +let effective = crypto::effective_pool_balance( + network, + Amount::from_atoms(40_000_000_000_000), // pledge + Amount::from_atoms(1_000_000_000_000_000), // total pool balance +)?; +``` + +After decommissioning, funds become spendable after +`staking_pool_spend_maturity_block_count(current_block_height, network)` +blocks. diff --git a/docs/tokens.md b/docs/tokens.md new file mode 100644 index 0000000..30fe00e --- /dev/null +++ b/docs/tokens.md @@ -0,0 +1,249 @@ +# Tokens and NFTs + +Mintlayer supports on-chain fungible tokens and NFTs. The wallet daemon +manages the full lifecycle; the `crypto` module provides low-level +encoders for manual flows; the indexer exposes read-only state. All +amounts are atom counts in the token's smallest unit. + +## Fungible tokens + +### Supply policies + +| `TokenTotalSupply` | Meaning | +|--------------------|---------| +| `TokenTotalSupply::Fixed(Amount)` | Capped at the given amount at issuance | +| `TokenTotalSupply::Lockable` | Unlimited minting until `LockTokenSupply`, then permanently fixed | +| `TokenTotalSupply::Unlimited` | Minting always allowed, limited only by the `u128` amount type | + +### Issuing via the wallet daemon + +```rust +use mintlayer_sdk::wallet::{self, IssueTokenParams, TokenMetadata, TokenSupply, TxOptions}; + +let c = wallet::Client::new("http://127.0.0.1:3034"); +let authority = c.new_address(0).await?; + +let issue = c.issue_token(IssueTokenParams { + account: 0, + destination_address: authority.clone(), // becomes the token authority + metadata: TokenMetadata { + token_ticker: "MYTOKEN".into(), + number_of_decimals: 2, + metadata_uri: "https://example.com/token".into(), + token_supply: TokenSupply::Lockable, + is_freezable: false, + }, + options: TxOptions::default(), +}).await?; +println!("token id: {} (tx {})", issue.token_id, issue.tx_id); +``` + +The destination address becomes the **authority**: the key that controls +all later token operations. Keep it secure. + +### Minting, unminting and supply locking + +```rust +use mintlayer_sdk::wallet::{Amount, MintParams, TxOptions, UnmintParams}; + +// Wait for the issuance transaction to confirm before minting. +c.mint_tokens(MintParams { + account: 0, + token_id: issue.token_id.clone(), + address: "mtc1q_recipient...".into(), + amount: Amount::from_atoms(100_000), + options: TxOptions::default(), +}).await?; + +// Unminting removes tokens from circulation (the tx burns them). +c.unmint_tokens(UnmintParams { + account: 0, + token_id: issue.token_id.clone(), + amount: Amount::from_atoms(50_000), + options: TxOptions::default(), +}).await?; + +// Irreversible: fixes the supply at the current circulating amount. +// Note the field is account_index, not account. +c.lock_token_supply(wallet::LockSupplyParams { + account_index: 0, + token_id: issue.token_id.clone(), + options: TxOptions::default(), +}).await?; +``` + +### Freezing and authority change + +```rust +use mintlayer_sdk::wallet::{FreezeParams, TxOptions, UnfreezeParams}; + +// is_unfreezable: true means the authority can unfreeze later. +c.freeze_token(FreezeParams { + account: 0, token_id: issue.token_id.clone(), + is_unfreezable: true, options: TxOptions::default(), +}).await?; + +c.unfreeze_token(UnfreezeParams { + account: 0, token_id: issue.token_id.clone(), options: TxOptions::default(), +}).await?; + +c.change_token_authority(wallet::ChangeAuthorityParams { + account: 0, + token_id: issue.token_id.clone(), + address: "mtc1q_new_authority...".into(), + options: TxOptions::default(), +}).await?; +``` + +## Issuing via the `crypto` module + +For full-custody flows, build the issuance output yourself and pair it +with a coin input that covers the issuance fee. + +```rust +use mintlayer_sdk::crypto::{self, Network, TokenTotalSupply}; +use mintlayer_sdk::crypto::types::{TxInput, TxOutput}; +use mintlayer_sdk::indexer::Client as IndexerClient; +use mintlayer_sdk::prelude::IsTokenFreezable; + +let network = Network::Testnet; +let indexer = IndexerClient::new("http://127.0.0.1:3000"); +let inputs: Vec = todo!(); // your UTXO inputs + +let height = indexer.tip().await?.block_height + 1; +let fee = crypto::fungible_token_issuance_fee(height, network); +let token_id = crypto::get_token_id(&inputs, height, network)?; + +let issue_output = crypto::encode_output_issue_fungible_token( + "mtc1q_authority...", // authority address + "MYTOKEN", // ticker + "https://example.com/meta", // metadata URI + 2, // number of decimals + TokenTotalSupply::Lockable, // or Fixed(Amount) / Unlimited + IsTokenFreezable::No, + network, +)?; +let transaction = crypto::encode_transaction(inputs, vec![issue_output], 0)?; +// Sign with encode_witness and submit (see transactions.md). +``` + +Issuance parameters are validated against the chain config +(`crypto::Error::InvalidTokenParameters` on failure). Token fees must be +covered by the transaction's coin inputs. + +### Minting manually + +Minting is an account command needing the authority's next account nonce +from the indexer (`token.next_nonce`, string-encoded) plus an output +delivering the minted tokens: + +```rust +use mintlayer_sdk::crypto::{self, Amount, Network}; +use mintlayer_sdk::crypto::types::TxOutput; + +let token_info = indexer.token(&token_id).await?; +let mint_input = crypto::encode_input_for_mint_tokens( + &token_id, + Amount::from_atoms(100_000), + token_info.next_nonce.0, + network, +)?; +let mint_output: TxOutput = crypto::encode_output_token_transfer( + Amount::from_atoms(100_000), "mtc1q_recipient...", &token_id, network)?; +``` + +Other account-command inputs follow the same pattern; each consumes one +nonce in sequence: + +| Function | Purpose | +|----------|---------| +| `encode_input_for_unmint_tokens(token_id, nonce, network)` | Pair with `encode_output_token_burn` | +| `encode_input_for_lock_token_supply(token_id, nonce, network)` | Irreversible supply fix | +| `encode_input_for_freeze_token(token_id, IsTokenUnfreezable, nonce, network)` | | +| `encode_input_for_unfreeze_token(token_id, nonce, network)` | | +| `encode_input_for_change_token_authority(token_id, new_authority, nonce, network)` | | +| `encode_input_for_change_token_metadata_uri(token_id, new_metadata_uri, nonce, network)` | | + +Protocol fees — `token_supply_change_fee` for mint/unmint, +`token_freeze_fee` for freeze/unfreeze, `token_change_authority_fee` for +authority changes — must be covered by the transaction's coin inputs. + +## NFTs + +Each NFT issuance transaction creates exactly one NFT on an existing +token; there is no post-issuance minting. + +```rust +use mintlayer_sdk::wallet::{IssueNftParams, NftMetadata, TxOptions}; + +let result = c.issue_nft(IssueNftParams { + account: 0, + destination_address: authority.clone(), + metadata: NftMetadata { + media_hash: "hex-encoded sha256 of the media".into(), + name: "My NFT".into(), + description: "A unique digital collectible".into(), + ticker: "MYNFT".into(), + creator: None, // Option, public key hex + icon_uri: None, + media_uri: Some("https://example.com/media.png".into()), + additional_metadata_uri: None, + }, + options: TxOptions::default(), +}).await?; +println!("nft id: {}", result.token_id); +``` + +```rust +// Manual equivalent (fee: crypto::nft_issuance_fee(height, network)): +let nft_output = crypto::encode_output_issue_nft( + &token_id, // existing collection token id + "mtc1q_authority...", "My NFT", "MYNFT", "A unique digital collectible", + media_hash_bytes, // &[u8] + None, // Option creator + Some("https://example.com/media.png"), + None, None, // icon_uri, additional_metadata_uri + network, +)?; +``` + +The indexer reports NFTs with `nft(id)` (owner, token id, metadata). + +## DEX orders + +The wallet daemon covers the full order lifecycle (`create_order`, +`conclude_order`, `fill_order`, `freeze_order`, listings). The `crypto` +module exposes the order-creation output for manual flows: + +```rust +let order_output = crypto::encode_create_order_output( + Amount::from_atoms(1_000_000_000_000), // ask amount + None, // ask currency: None = coin + Amount::from_atoms(500_000), // give amount + Some(&token_id), // give currency: token + "mtc1q_conclude...", // may conclude the order + network, +)?; +// The order id is derived from the inputs: +let order_id = crypto::get_order_id(&inputs, network)?; +``` + +Filling an order uses `encode_input_for_fill_order`, whose inputs must +not be signed — use `crypto::encode_witness_no_signature()` for them. +Before the orders V1 fork the `nonce` and `destination` parameters are +significant; after the fork both are ignored. `encode_input_for_freeze_order` +requires orders V1 (`crypto::Error::OrdersV1NotActivated` before it). + +## Reading token state from the indexer + +```rust +use mintlayer_sdk::indexer::{Client, PageOpts}; + +let idx = Client::new("http://127.0.0.1:3000"); +let token = idx.token(&issue.token_id).await?; +println!("ticker {} supply {} locked {}", token.token_ticker, + token.circulating_supply.decimal, token.is_locked); +println!("frozen {} (unfreezable: {:?})", + token.frozen, token.is_token_unfreezable); // present only when frozen +let txs = idx.token_transactions(&issue.token_id, PageOpts::default()).await?; +``` diff --git a/docs/transactions.md b/docs/transactions.md new file mode 100644 index 0000000..d720d40 --- /dev/null +++ b/docs/transactions.md @@ -0,0 +1,250 @@ +# Building transactions manually + +The wallet daemon handles transaction building automatically for most use +cases. Use the `crypto` module directly when you need full custody (no +wallet daemon), custom output types, or tooling and integration testing. +The [examples/send-coins.rs](../examples/send-coins.rs) program +demonstrates this flow end to end. + +The flow: derive keys, fetch UTXOs, build inputs, build outputs (including +change), build the unsigned transaction, sign each input, assemble, submit. +All amounts are `u128` atom counts; **1 ML = 100,000,000,000 atoms**. + +## Step 1: Key derivation + +```rust +use mintlayer_sdk::crypto::{self, Network}; + +let network = Network::Testnet; +let mnemonic = "word1 word2 ... word12"; + +let account = crypto::make_default_account_privkey(mnemonic, network, None)?; +let spend = crypto::make_receiving_address(&account, 0)?; // key index 0 +let pubkey = crypto::public_key_from_private_key(&spend); +let from_address = crypto::pubkey_to_pubkeyhash_address(&pubkey, network); +``` + +## Step 2: Fetch spendable UTXOs + +```rust +use mintlayer_sdk::indexer::Client as IndexerClient; + +let indexer = IndexerClient::new("http://127.0.0.1:3000"); +let utxos = indexer.spendable_utxos(&from_address).await?; +if utxos.is_empty() { + return Err("no spendable UTXOs".into()); +} +``` + +## Step 3: Build the inputs + +Hex-decode each source id, wrap it in an `H256`, build one `TxInput` per UTXO: + +```rust +use mintlayer_sdk::crypto::{self, SourceId}; +use mintlayer_sdk::crypto::types::{H256, TxInput}; + +let mut inputs: Vec = Vec::new(); +for utxo in &utxos { + let bytes = hex::decode(&utxo.outpoint.source_id)?; // 32 bytes + let hash = H256::from_slice(&bytes); + let source = crypto::encode_outpoint_source_id(hash, SourceId::Transaction); + inputs.push(crypto::encode_input_for_utxo(source, utxo.outpoint.index)); +} +``` + +## Step 4: Build the outputs + +Mintlayer has no explicit fee field: **the fee is implicitly +`sum(inputs) - sum(outputs)`** and goes to the block producer. Emitting +less than the swept total silently overpays; a zero-fee transaction is +rejected by the mempool. Production code therefore adds a change output +sized from a size estimate and a fee rate: + +```rust +use mintlayer_sdk::crypto::{self, Amount, Network}; + +let to_address = "mtc1q..."; +let amount: u128 = 100_000_000_000; // 1 ML to the recipient +let total: u128 = todo!(); // sum of the swept input atoms + +// Candidate outputs, used only for the size estimate. +let candidate = vec![ + crypto::encode_output_transfer(Amount::from_atoms(amount), to_address, network)?, + crypto::encode_output_transfer(Amount::from_atoms(total - amount), &from_address, network)?, +]; +let size = crypto::estimate_transaction_size( + &inputs, + &vec![from_address.as_str(); inputs.len()], // one destination per input + &candidate, + network, +)?; +let rate: u128 = indexer.fee_rate(1).await?.parse()?; // atoms per KB +let fee = u128::from(size).div_ceil(1000) * rate.max(1); +let change = total.checked_sub(amount + fee) + .ok_or("amount + fee exceeds the swept total")?; + +let mut outputs = vec![ + crypto::encode_output_transfer(Amount::from_atoms(amount), to_address, network)?, +]; +if change > 0 { + outputs.push( + crypto::encode_output_transfer(Amount::from_atoms(change), &from_address, network)?, + ); +} +``` + +`estimate_transaction_size` needs the spending address of each input, in +input order; script-hash and multisig destinations are not supported. +Prefer a `make_change_address`-derived address for the change output. + +## Steps 5-7: Build, sign, assemble + +Call `encode_witness` once per input. The sighash needs the output being +spent, so build a parallel `input_utxos` slice with one entry per input: +`Some(output)` for UTXO inputs, `None` otherwise. `TxAdditionalInfo::new()` +is correct for standard transfers; `current_block_height` should be the +predicted inclusion height (tip + 1) because the sighash is fork-sensitive. + +```rust +use mintlayer_sdk::crypto::types::Encode; +use mintlayer_sdk::crypto::{SigHashType, TxAdditionalInfo}; +use mintlayer_sdk::crypto::types::{OutputValue, TxOutput}; + +let inclusion_height = indexer.tip().await?.block_height + 1; +let transaction = crypto::encode_transaction(inputs.clone(), outputs, 0)?; +println!("unsigned tx id: {}", crypto::transaction_id(&transaction)); + +// one entry per input, Some(re-encoded spent output) +let destination = crypto::encode_destination(&from_address, network)?; +let mut input_utxos: Vec> = Vec::new(); +for utxo in &utxos { + let atoms = transfer_coin_atoms(&utxo.output).ok_or("unexpected output shape")?; + input_utxos.push(Some(TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(atoms)), + destination.clone(), + ))); +} + +let mut witnesses = Vec::new(); +for (index, _) in inputs.iter().enumerate() { + witnesses.push(crypto::encode_witness( + SigHashType::all(), + &spend, + &from_address, + &transaction, + &input_utxos, + index, + &TxAdditionalInfo::new(), + inclusion_height, + network, + )?); +} + +let signed = crypto::encode_signed_transaction(transaction, witnesses)?; +let signed_hex = hex::encode(signed.encode()); +``` + +Fill-order inputs must not be signed: use `encode_witness_no_signature()` +for them (see [tokens.md](tokens.md)). + +## Step 8: Submit + +```rust +// Via the indexer (requires --enable-post-routes): +let tx_id = indexer.submit_transaction(&signed_hex).await?; +// Or broadcast via the node daemon: +use mintlayer_sdk::node::{Client as NodeClient, TrustPolicy}; +let node = NodeClient::new("http://127.0.0.1:3030"); +node.broadcast_transaction(&signed_hex, TrustPolicy::Untrusted).await?; +``` + +## Complete example + +```rust +use mintlayer_sdk::crypto::types::*; +use mintlayer_sdk::crypto::{self, Amount, Network, SigHashType, SourceId, TxAdditionalInfo}; +use mintlayer_sdk::indexer::Client as IndexerClient; +use std::str::FromStr; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let network = Network::Testnet; + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + let account = crypto::make_default_account_privkey(mnemonic, network, None)?; + let spend = crypto::make_receiving_address(&account, 0)?; + let pubkey = crypto::public_key_from_private_key(&spend); + let from = crypto::pubkey_to_pubkeyhash_address(&pubkey, network); + + let indexer = IndexerClient::new("http://127.0.0.1:3000"); + let inclusion_height = indexer.tip().await?.block_height + 1; + let utxos = indexer.spendable_utxos(&from).await?; + if utxos.is_empty() { + return Err("no spendable UTXOs".into()); + } + + let mut inputs = Vec::new(); + let mut input_utxos: Vec> = Vec::new(); + let mut total = 0u128; + let destination = crypto::encode_destination(&from, network)?; + for utxo in &utxos { + let atoms = transfer_coin_atoms(&utxo.output).ok_or("unexpected output")?; + let bytes = hex::decode(&utxo.outpoint.source_id)?; + let source = crypto::encode_outpoint_source_id(H256::from_slice(&bytes), SourceId::Transaction); + inputs.push(crypto::encode_input_for_utxo(source, utxo.outpoint.index)); + input_utxos.push(Some(TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(atoms)), + destination.clone(), + ))); + total += atoms; + } + + let amount = u128::from_str("100000000000")?; // 1 ML + let fee = 1_000_000u128; // from estimate_transaction_size + fee_rate; keep a margin + let change = total.checked_sub(amount + fee).ok_or("balance too low")?; + let to = "mtc1qrecipient..."; + let mut outputs = vec![crypto::encode_output_transfer(Amount::from_atoms(amount), to, network)?]; + if change > 0 { + outputs.push(crypto::encode_output_transfer(Amount::from_atoms(change), &from, network)?); + } + + let transaction = crypto::encode_transaction(inputs.clone(), outputs, 0)?; + let mut witnesses = Vec::new(); + for (index, _) in inputs.iter().enumerate() { + witnesses.push(crypto::encode_witness( + SigHashType::all(), + &spend, + &from, + &transaction, + &input_utxos, + index, + &TxAdditionalInfo::new(), + inclusion_height, + network, + )?); + } + let signed = crypto::encode_signed_transaction(transaction, witnesses)?; + let signed_hex = hex::encode(signed.encode()); + + let tx_id = indexer.submit_transaction(&signed_hex).await?; + println!("submitted {tx_id}"); + Ok(()) +} + +fn transfer_coin_atoms(output: &serde_json::Value) -> Option { + if output.get("type")?.as_str()? != "Transfer" { + return None; + } + let value = output.get("value")?; + if value.get("type")?.as_str()? != "Coin" { + return None; + } + value.get("amount")?.get("atoms")?.as_str()?.parse().ok() +} +``` + +Read the mnemonic from an environment variable or prompt, not a command +line; it never leaves the process. Token transfers use the same flow with +`crypto::encode_output_token_transfer` (see [tokens.md](tokens.md)); the +transaction must still cover the network fee in coins. diff --git a/docs/wallet.md b/docs/wallet.md new file mode 100644 index 0000000..3a991a7 --- /dev/null +++ b/docs/wallet.md @@ -0,0 +1,168 @@ +# Wallet client + +The `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. + +```rust +use mintlayer_sdk::wallet; + +let c = wallet::Client::new("http://127.0.0.1:3034"); + +// Or with options: +let c = wallet::Client::builder("http://127.0.0.1:3034") + .basic_auth("user", "pass") // optional + .timeout(std::time::Duration::from_secs(30)) // optional, default 30s + .build()?; +``` + +**Default ports:** 3034 (mainnet), 13034 (testnet). + +Errors are returned as `wallet::Error` — +`Error::Rpc { code, message }` for daemon errors, plus `Http`, `Json`, +`IdMismatch` and `ResponseTooLarge` transport variants. Basic-auth +credentials are redacted from `Debug` output; response bodies are capped +at 64 MiB. + +--- + +## Wallet lifecycle + +| Method | RPC method | Notes | +|--------|-----------|-------| +| `create_wallet(params: CreateWalletParams) -> Result` | `wallet_create` | Returns the mnemonic when the daemon generated one | +| `recover_wallet(params: RecoverWalletParams) -> Result<(), Error>` | `wallet_recover` | Rescans to recover balances | +| `open_wallet(path: &str, password: Option<&str>) -> Result<(), Error>` | `wallet_open` | `None` password for unencrypted wallets | +| `close_wallet() -> Result<(), Error>` | `wallet_close` | | +| `wallet_info() -> Result` | `wallet_info` | Id, account names, wallet kind | +| `sync_wallet() -> Result<(), Error>` | `wallet_sync` | Sync to the tip | +| `rescan_wallet() -> Result<(), Error>` | `wallet_rescan` | Full rescan from genesis | +| `best_block() -> Result` | `wallet_best_block` | | +| `encrypt_private_keys(password: &str) -> Result<(), Error>` | `wallet_encrypt_private_keys` | | +| `unlock_private_keys(password: &str) -> Result<(), Error>` | `wallet_unlock_private_keys` | | +| `lock_private_keys() -> Result<(), Error>` | `wallet_lock_private_keys` | | + +`CreateWalletParams { path, store_seed_phrase, mnemonic, passphrase, +hardware_wallet }` — set `mnemonic: None` to let the daemon generate a +phrase. Mnemonic and passphrase fields are redacted from `Debug` output. + +## Accounts, addresses, balances + +| Method | RPC method | Notes | +|--------|-----------|-------| +| `create_account(name: &str) -> Result` | `account_create` | | +| `rename_account(account: u32, name: Option<&str>) -> Result<(), Error>` | `account_rename` | `None` clears the name | +| `balance(account: u32) -> Result` | `account_balance` | Confirmed UTXOs only | +| `new_address(account: u32) -> Result` | `address_new` | Fresh receive address | +| `show_receive_addresses(account: u32) -> Result, Error>` | `address_show` | Usage and balance per address | +| `reveal_public_key(account: u32, address: &str) -> Result` | `address_reveal_public_key` | | + +`Amount` in requests typically sets only `atoms` +(`Amount::from_atoms(n)`); daemon responses carry `atoms` and `decimal`. + +## Transactions + +| Method | RPC method | Notes | +|--------|-----------|-------| +| `send(params: SendParams) -> Result` | `address_send` | Wallet selects UTXOs, computes fees | +| `send_token(params: TokenSendParams) -> Result` | `token_send` | | +| `sweep_spendable(params: SweepParams) -> Result` | `address_sweep_spendable` | `all: true` or explicit `from_addresses` | +| `spend_utxo(params: UtxoSpendParams) -> Result` | `utxo_spend` | Optional `htlc_secret` | +| `deposit_data(account: u32, data_hex: &str) -> Result` | `address_deposit_data` | `DataDeposit` output | +| `compose_transaction(params: ComposeParams) -> Result` | `transaction_compose` | Unsigned hex plus estimated fees | +| `sign_raw_transaction(account: u32, raw_tx: &str) -> Result` | `account_sign_raw_transaction` | Cold-wallet flow | +| `inspect_transaction(tx_hex: &str) -> Result` | `transaction_inspect` | Signature stats and fees | +| `submit_transaction(tx_hex: &str, do_not_store: bool) -> Result` | `node_submit_transaction` | See the policy quirk below | +| `list_transactions_by_address(account, address: Option<&str>, limit: u32) -> Result, Error>` | `transaction_list_by_address` | Newest first | +| `list_pending_transactions(account: u32) -> Result, Error>` | `transaction_list_pending` | | +| `transaction(account: u32, tx_id: &str) -> Result` | `transaction_get` | Raw JSON | +| `abandon_transaction(account: u32, tx_id: &str) -> Result<(), Error>` | `transaction_abandon` | Unconfirmed only | + +**Quirk:** [`submit_transaction`](#) always sends the hard-coded +`Trusted` trust policy to the node, so the transaction must be fully valid +against the current chainstate. There is no `Untrusted` variant; broadcast +externally sourced transactions through the node client instead +(`node::Client::broadcast_transaction`). + +Most send methods take `TxOptions { in_top_x_mb, broadcast_to_mempool }`: +`in_top_x_mb` targets the top X MB of the mempool for fee estimation; +`broadcast_to_mempool: Some(false)` builds and signs without broadcasting +while still returning the transaction hex. + +## Staking + +See [staking.md](staking.md) for the full guide. + +| Method | RPC method | Notes | +|--------|-----------|-------| +| `create_stake_pool(params: CreatePoolParams) -> Result` | `staking_create_pool` | | +| `decommission_stake_pool(params: DecommissionParams) -> Result` | `staking_decommission_pool` | | +| `list_owned_pools(account: u32) -> Result, Error>` | `staking_list_pools` | | +| `pool_balance(pool_id: &str) -> Result, Error>` | `staking_pool_balance` | | +| `start_staking(account: u32) -> Result<(), Error>` | `staking_start` | | +| `stop_staking(account: u32) -> Result<(), Error>` | `staking_stop` | | +| `staking_status(account: u32) -> Result` | `staking_status` | `Staking` or `NotStaking` | +| `create_delegation(params: CreateDelegationParams) -> Result` | `delegation_create` | | +| `delegate_staking(params: DelegateParams) -> Result` | `delegation_stake` | | +| `withdraw_from_delegation(params: WithdrawParams) -> Result` | `delegation_withdraw` | | +| `list_delegations(account: u32) -> Result, Error>` | `delegation_list_ids` | | + +## Tokens and NFTs + +See [tokens.md](tokens.md) for the full guide. + +| Method | RPC method | Notes | +|--------|-----------|-------| +| `issue_token(params: IssueTokenParams) -> Result` | `token_issue_new` | | +| `issue_nft(params: IssueNftParams) -> Result` | `token_nft_issue_new` | | +| `mint_tokens(params: MintParams) -> Result` | `token_mint` | | +| `unmint_tokens(params: UnmintParams) -> Result` | `token_unmint` | | +| `lock_token_supply(params: LockSupplyParams) -> Result` | `token_lock_supply` | Irreversible; note the field name below | +| `freeze_token(params: FreezeParams) -> Result` | `token_freeze` | | +| `unfreeze_token(params: UnfreezeParams) -> Result` | `token_unfreeze` | | +| `change_token_authority(params: ChangeAuthorityParams) -> Result` | `token_change_authority` | | + +**Quirk:** `LockSupplyParams` names its account field `account_index`, +not `account` — every other wallet params struct uses `account`. + +## Orders + +See [tokens.md](tokens.md) for order creation outputs via `crypto`. + +| Method | RPC method | Notes | +|--------|-----------|-------| +| `create_order(params: CreateOrderParams) -> Result` | `order_create` | | +| `conclude_order(params: ConcludeOrderParams) -> Result` | `order_conclude` | | +| `fill_order(params: FillOrderParams) -> Result` | `order_fill` | Amount is in the ask currency | +| `freeze_order(params: FreezeOrderParams) -> Result` | `order_freeze` | | +| `list_own_orders(account: u32) -> Result, Error>` | `order_list_own` | | +| `list_all_active_orders(params: ListOrdersParams) -> Result, Error>` | `order_list_all_active` | Optional currency filters | + +--- + +## Example + +```rust +use mintlayer_sdk::wallet::{self, Amount, SendParams, TxOptions}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let c = wallet::Client::new("http://127.0.0.1:3034"); + + c.open_wallet("/path/to/wallet.dat", None).await?; + c.sync_wallet().await?; + + let balance = c.balance(0).await?; + println!("coins: {:?}", balance.coins.atoms()); + + let result = c.send(SendParams { + account: 0, + address: "mtc1q...".into(), + amount: Amount::from_atoms(100_000_000_000), // 1 ML + selected_utxos: vec![], + options: TxOptions::default(), + }).await?; + println!("tx {} broadcast: {}", result.tx_id, result.broadcasted); + Ok(()) +} +``` From 501fc1d87b0cd2ccbccd555ebefda39a8fcb34df Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 01:56:11 +0400 Subject: [PATCH 19/29] docs: public-mnemonic warning, transport security notes (security review fixes) --- README.md | 7 +++++-- docs/indexer.md | 5 +++++ docs/node.md | 5 +++++ docs/wallet.md | 5 +++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1916637..77be93c 100644 --- a/README.md +++ b/README.md @@ -73,16 +73,19 @@ async fn main() -> Result<(), Box> { println!("chain tip: height={} id={}", tip.block_height, tip.block_id); // Derive an address natively (no runtime initialisation). - let account = crypto::make_default_account_privkey(MNEMONIC, crypto::Network::Mainnet, None)?; + let account = crypto::make_default_account_privkey(MNEMONIC, crypto::Network::Testnet, None)?; let spend = crypto::make_receiving_address(&account, 0)?; let address = crypto::pubkey_to_pubkeyhash_address( &crypto::public_key_from_private_key(&spend), - crypto::Network::Mainnet, + crypto::Network::Testnet, ); println!("address: {address}"); Ok(()) } +// The well-known public BIP39 test mnemonic. Never send real funds to +// addresses derived from it; use your own mnemonic read from an environment +// variable or an interactive prompt. const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; ``` diff --git a/docs/indexer.md b/docs/indexer.md index 194ff4d..8b0a6d3 100644 --- a/docs/indexer.md +++ b/docs/indexer.md @@ -33,6 +33,11 @@ Errors are returned as `indexer::Error`: --- + +> **Transport security:** loopback `http://` is fine; the indexer API is +> unauthenticated. For remote indexers prefer an `https://` URL so response +> data cannot be tampered with in transit. + ## Pagination List endpoints take `PageOpts`: diff --git a/docs/node.md b/docs/node.md index 1c98b33..3ea57bc 100644 --- a/docs/node.md +++ b/docs/node.md @@ -31,6 +31,11 @@ Errors are returned as `node::Error`: --- + +> **Transport security:** loopback `http://` is fine. For remote daemons use +> an `https://` URL (TLS is built in via rustls) or an authenticated tunnel — +> basic-auth credentials otherwise transit in cleartext with every request. + ## Chain state | Method | RPC method | Notes | diff --git a/docs/wallet.md b/docs/wallet.md index 3a991a7..aeed62e 100644 --- a/docs/wallet.md +++ b/docs/wallet.md @@ -26,6 +26,11 @@ at 64 MiB. --- + +> **Transport security:** loopback `http://` is fine. For remote daemons use +> an `https://` URL or an authenticated tunnel — basic-auth credentials and +> daemon-generated mnemonics otherwise transit in cleartext. + ## Wallet lifecycle | Method | RPC method | Notes | From ac4906dc3467bcd6c24d417b9a13bab4516e3446 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 02:28:12 +0400 Subject: [PATCH 20/29] fix: feature isolation, HTLC secret redaction, redirect policy, send-coins fee guard, error naming (final review fixes) --- .github/workflows/ci.yml | 4 +- Cargo.toml | 2 + examples/issue-token.rs | 6 +- examples/send-coins.rs | 24 ++-- src/client.rs | 132 +++++++++++++------ src/crypto/addresses.rs | 2 +- src/crypto/error.rs | 3 + src/crypto/fees.rs | 3 + src/crypto/keys.rs | 10 +- src/crypto/mod.rs | 7 +- src/crypto/transactions.rs | 4 +- src/indexer/mod.rs | 7 +- src/indexer/types.rs | 4 +- src/jsonrpc.rs | 20 +-- src/lib.rs | 21 ++- src/limits.rs | 28 +++- src/node/mod.rs | 21 +-- src/number.rs | 17 +-- src/wallet/mod.rs | 22 +--- src/wallet/types.rs | 27 +++- tests/crypto.rs | 264 ++++++++++++++++++++++++++++++++++++- tests/wallet.rs | 34 ++++- 22 files changed, 526 insertions(+), 136 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 478ef6d..54b0aaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - features: ["--no-default-features", "--features node", "--features indexer", "--features wallet", "--features crypto", "--all-features"] + features: ["--no-default-features", "--no-default-features --features node", "--no-default-features --features indexer", "--no-default-features --features wallet", "--no-default-features --features crypto", "--all-features"] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.92.0 @@ -40,7 +40,7 @@ jobs: strategy: fail-fast: false matrix: - features: ["--no-default-features", "--features node", "--features indexer", "--features wallet", "--features crypto", "--all-features"] + features: ["--no-default-features", "--no-default-features --features node", "--no-default-features --features indexer", "--no-default-features --features wallet", "--no-default-features --features crypto", "--all-features"] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.92.0 diff --git a/Cargo.toml b/Cargo.toml index 4947481..ee9e6ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ indexer = ["http"] wallet = ["http"] # Native cryptography and transaction building backed by mintlayer-core. crypto = [ + "dep:serde", + "dep:serde_json", "dep:ml-common", "dep:ml-consensus", "dep:ml-crypto", diff --git a/examples/issue-token.rs b/examples/issue-token.rs index bf79e42..f18dfda 100644 --- a/examples/issue-token.rs +++ b/examples/issue-token.rs @@ -92,11 +92,7 @@ async fn main() { } async fn run() -> Result<(), String> { - let args = parse_args(); - let args = match args { - Ok(args) => args, - Err(error) => return Err(error), - }; + let args = parse_args()?; let client = WalletClient::new(&args.wallet_rpc); diff --git a/examples/send-coins.rs b/examples/send-coins.rs index 042eb0e..9703d5f 100644 --- a/examples/send-coins.rs +++ b/examples/send-coins.rs @@ -23,10 +23,11 @@ //! The example sweeps every spendable Transfer/Coin UTXO of the derived //! address and emits a single output of `--amount` with no change output. //! In Mintlayer the fee is implicitly `sum(inputs) - sum(outputs)` and goes -//! to the block producer, so the example requires `--amount` to equal the -//! full swept total; anything less would silently overpay the miner and a -//! zero-fee transaction is rejected by the mempool. Production code should -//! add a change output sized from +//! to the block producer, so `--amount` must be strictly less than the +//! swept total: the difference becomes the transaction fee (a zero-fee +//! transaction is rejected by the mempool). The example prints the implicit +//! fee before submitting. Production code should instead add a change +//! output sized from //! [`estimate_transaction_size`](mintlayer_sdk::crypto::estimate_transaction_size) //! and a fee rate fetched with the `feerate` endpoints. //! @@ -165,17 +166,16 @@ async fn run() -> Result<(), String> { } let amount = u128::from_str(&args.amount).map_err(|_| "invalid --amount")?; - if amount != total { + if amount >= total { return Err(format!( - "this example sweeps the full balance with no change output: \ - the difference between --amount ({amount}) and the swept total \ - ({total}) would be paid to the block producer as fee. \ - Pass --amount {total}, or add a change output in your own code." + "this example sweeps the full balance with no change output, \ + so --amount must be strictly less than the swept total \ + ({total}): the difference becomes the transaction fee and a \ + zero-fee transaction is rejected by the mempool" )); } - if total <= amount { - return Err("the swept total leaves nothing for the transaction fee".to_owned()); - } + let fee = total - amount; + println!("implicit fee (paid to the block producer): {fee} atoms"); let outputs = vec![ crypto::encode_output_transfer(Amount::from_atoms(amount), &args.to, network) diff --git a/src/client.rs b/src/client.rs index ffe993d..8067fe0 100644 --- a/src/client.rs +++ b/src/client.rs @@ -6,22 +6,28 @@ //! Top-level SDK client wiring the sub-clients together. +#[cfg(feature = "indexer")] use crate::indexer; +#[cfg(feature = "node")] use crate::node; +#[cfg(feature = "wallet")] use crate::wallet; /// The top-level Mintlayer SDK client. /// /// Each sub-client is `Some` only when its URL was set on the -/// [`ClientBuilder`]. The [`crypto`](crate::crypto) functions are always -/// available directly and require no client. -#[derive(Debug, Clone)] +/// [`ClientBuilder`]. The `crypto` module functions are always available +/// directly (when the `crypto` feature is enabled) and require no client. +#[derive(Debug, Clone, Default)] pub struct Client { /// JSON-RPC client for the node daemon. + #[cfg(feature = "node")] pub node: Option, /// REST client for the indexer. + #[cfg(feature = "indexer")] pub indexer: Option, /// JSON-RPC client for the wallet daemon. + #[cfg(feature = "wallet")] pub wallet: Option, } @@ -64,6 +70,7 @@ impl std::fmt::Debug for ClientBuilder { impl ClientBuilder { /// Creates a node daemon client for the given base URL. + #[cfg(feature = "node")] #[must_use] pub fn node_url(mut self, url: impl Into) -> Self { self.node_url = Some(url.into()); @@ -71,6 +78,7 @@ impl ClientBuilder { } /// Creates an indexer client for the given base URL. + #[cfg(feature = "indexer")] #[must_use] pub fn indexer_url(mut self, url: impl Into) -> Self { self.indexer_url = Some(url.into()); @@ -78,6 +86,7 @@ impl ClientBuilder { } /// Creates a wallet daemon client for the given base URL. + #[cfg(feature = "wallet")] #[must_use] pub fn wallet_url(mut self, url: impl Into) -> Self { self.wallet_url = Some(url.into()); @@ -103,43 +112,90 @@ impl ClientBuilder { /// Builds the client. pub fn build(self) -> Result { - let node = self.node_url.map(|url| { - let builder = node::Client::builder(url); - let builder = match &self.basic_auth { - Some((username, password)) => builder.basic_auth(username, password), - None => builder, - }; - match self.timeout { - Some(timeout) => builder.timeout(timeout), - None => builder, - } - .build() - }); - let wallet = self.wallet_url.map(|url| { - let builder = wallet::Client::builder(url); - let builder = match &self.basic_auth { - Some((username, password)) => builder.basic_auth(username, password), - None => builder, - }; - match self.timeout { - Some(timeout) => builder.timeout(timeout), - None => builder, - } - .build() - }); - let indexer = self.indexer_url.map(|url| { - let builder = indexer::Client::builder(url); - match self.timeout { - Some(timeout) => builder.timeout(timeout), - None => builder, - } - .build() - }); + #[cfg(any(feature = "node", feature = "wallet"))] + let basic_auth = self.basic_auth; + #[cfg(not(any(feature = "node", feature = "wallet")))] + let _ = self.basic_auth; + let timeout = self.timeout; + #[cfg(feature = "node")] + let node_url = self.node_url; + #[cfg(feature = "indexer")] + let indexer_url = self.indexer_url; + #[cfg(feature = "wallet")] + let wallet_url = self.wallet_url; + #[cfg(not(any(feature = "node", feature = "indexer", feature = "wallet")))] + let _ = self; + + #[cfg(any(feature = "node", feature = "wallet"))] + let rpc_options = RpcOptions { + basic_auth: &basic_auth, + timeout, + }; + #[cfg(feature = "indexer")] + let rest_options = RestOptions { timeout }; Ok(Client { - node: node.transpose()?, - indexer: indexer.transpose()?, - wallet: wallet.transpose()?, + #[cfg(feature = "node")] + node: node_url + .map(|url| node::Client::builder(url).rpc_options(&rpc_options).build()) + .transpose()?, + #[cfg(feature = "indexer")] + indexer: indexer_url + .map(|url| indexer::Client::builder(url).rest_options(&rest_options).build()) + .transpose()?, + #[cfg(feature = "wallet")] + wallet: wallet_url + .map(|url| wallet::Client::builder(url).rpc_options(&rpc_options).build()) + .transpose()?, }) } } + +#[cfg(any(feature = "node", feature = "wallet"))] +struct RpcOptions<'a> { + basic_auth: &'a Option<(String, String)>, + timeout: Option, +} + +#[cfg(feature = "indexer")] +struct RestOptions { + timeout: Option, +} + +#[cfg(feature = "node")] +impl node::ClientBuilder { + fn rpc_options(self, options: &RpcOptions<'_>) -> Self { + let builder = match options.basic_auth { + Some((username, password)) => self.basic_auth(username, password), + None => self, + }; + match options.timeout { + Some(timeout) => builder.timeout(timeout), + None => builder, + } + } +} + +#[cfg(feature = "wallet")] +impl wallet::ClientBuilder { + fn rpc_options(self, options: &RpcOptions<'_>) -> Self { + let builder = match options.basic_auth { + Some((username, password)) => self.basic_auth(username, password), + None => self, + }; + match options.timeout { + Some(timeout) => builder.timeout(timeout), + None => builder, + } + } +} + +#[cfg(feature = "indexer")] +impl indexer::ClientBuilder { + fn rest_options(self, options: &RestOptions) -> Self { + match options.timeout { + Some(timeout) => self.timeout(timeout), + None => self, + } + } +} diff --git a/src/crypto/addresses.rs b/src/crypto/addresses.rs index 65f31ac..29e76f0 100644 --- a/src/crypto/addresses.rs +++ b/src/crypto/addresses.rs @@ -42,7 +42,7 @@ pub fn encode_multisig_challenge( let min_sigs = NonZeroU8::new(min_required_signatures).ok_or(Error::ZeroMultisigRequiredSignatures)?; ClassicMultisigChallenge::new(super::chain_config(network), min_sigs, public_keys.to_vec()) - .map_err(|error| Error::MessageSigning(error.to_string())) + .map_err(|error| Error::KeyDerivation(error.to_string())) } /// Returns the bech32 address of a multisig challenge. diff --git a/src/crypto/error.rs b/src/crypto/error.rs index c6780fb..f83ae1b 100644 --- a/src/crypto/error.rs +++ b/src/crypto/error.rs @@ -53,6 +53,9 @@ pub enum Error { /// An input witness could not be produced. #[error("failed to sign input: {0}")] InputSigning(String), + /// Key generation or derivation failed. + #[error("failed to derive key: {0}")] + KeyDerivation(String), /// The sighash could not be calculated. #[error("failed to calculate sighash: {0}")] Sighash(String), diff --git a/src/crypto/fees.rs b/src/crypto/fees.rs index a0dc68d..a80e7cd 100644 --- a/src/crypto/fees.rs +++ b/src/crypto/fees.rs @@ -13,6 +13,9 @@ use common::primitives::{Amount, BlockHeight}; use super::Network; /// The fee for issuing a new fungible token. +/// +/// `current_block_height` is currently unused and kept for signature parity +/// with the height-dependent fee functions. pub fn fungible_token_issuance_fee(_current_block_height: u64, network: Network) -> Amount { super::chain_config(network).fungible_token_issuance_fee() } diff --git a/src/crypto/keys.rs b/src/crypto/keys.rs index 4a24567..b0f6831 100644 --- a/src/crypto/keys.rs +++ b/src/crypto/keys.rs @@ -53,7 +53,7 @@ pub fn make_default_account_privkey( let seed = zeroize::Zeroizing::new(mnemonic.to_seed(passphrase)); let root_key = ExtendedPrivateKey::new_master(seed.as_ref(), ExtendedKeyKind::Secp256k1Schnorr) - .map_err(|error| Error::MessageSigning(error.to_string()))?; + .map_err(|error| Error::KeyDerivation(error.to_string()))?; let account_index = U31::ZERO; let path: DerivationPath = vec![ @@ -63,11 +63,11 @@ pub fn make_default_account_privkey( ] .try_into() .map_err(|error: crypto::key::hdkd::derivable::DerivationError| { - Error::InputSigning(error.to_string()) + Error::KeyDerivation(error.to_string()) })?; root_key .derive_absolute_path(&path) - .map_err(|error| Error::InputSigning(error.to_string())) + .map_err(|error| Error::KeyDerivation(error.to_string())) } /// Derives the receiving private key at `key_index` from an account key @@ -124,7 +124,7 @@ fn derive_key(derivable: D, branch: ChildNumber, key_index: u32) - let index = U31::from_u32(key_index).ok_or(Error::InvalidKeyIndex)?; derivable .derive_child(branch) - .map_err(|error| Error::InputSigning(error.to_string()))? + .map_err(|error| Error::KeyDerivation(error.to_string()))? .derive_child(ChildNumber::from_normal(index)) - .map_err(|error| Error::InputSigning(error.to_string())) + .map_err(|error| Error::KeyDerivation(error.to_string())) } diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 826525c..70a3d95 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -8,10 +8,11 @@ //! [mintlayer-core](https://github.com/mintlayer/mintlayer-core). //! //! The functions mirror the go-sdk `wasm` sub-client operation for operation, -//! but exchange typed values ([`Transaction`], [`TxOutput`], [`PrivateKey`], +//! but exchange typed values ([`Transaction`](types::Transaction), +//! [`TxOutput`](types::TxOutput), [`PrivateKey`](types::PrivateKey), //! [`Amount`], ...) instead of opaque byte arrays. Every type participates in -//! the SCALE encoding, so [`Encode`] and [`DecodeAll`] give byte-level access -//! when needed. +//! the SCALE encoding, so [`types::Encode`] and [`types::DecodeAll`] give +//! byte-level access when needed. //! //! # Fork sensitivity //! diff --git a/src/crypto/transactions.rs b/src/crypto/transactions.rs index 4f2c099..2ca46ab 100644 --- a/src/crypto/transactions.rs +++ b/src/crypto/transactions.rs @@ -83,7 +83,9 @@ pub fn encode_signed_transaction( /// /// Every per-input list must have exactly one entry per transaction input; /// `None` marks an absent entry. `additional_info` must cover every -/// `ProduceBlockFromStake`, `FillOrder` and `ConcludeOrder` input. +/// `ProduceBlockFromStake`, `FillOrder` and `ConcludeOrder` input. The +/// `network` parameter is currently unused (consistency checks are left to +/// the daemon) and kept for API symmetry. #[allow(clippy::too_many_arguments)] pub fn encode_partially_signed_transaction( transaction: Transaction, diff --git a/src/indexer/mod.rs b/src/indexer/mod.rs index 19bb3d1..5ab48b0 100644 --- a/src/indexer/mod.rs +++ b/src/indexer/mod.rs @@ -31,7 +31,10 @@ pub use types::{ Transaction, Uint64, Utxo, UtxoOutpoint, }; -use crate::limits::{DEFAULT_TIMEOUT, MAX_ERROR_BODY_CHARS, MAX_RESPONSE_BYTES}; +use crate::limits::{DEFAULT_TIMEOUT, MAX_RESPONSE_BYTES}; + +/// Upper bound for the characters kept from a daemon error body. +const MAX_ERROR_BODY_CHARS: usize = 8 * 1024; /// Client for the Mintlayer indexer REST API (api-web-server). #[derive(Debug, Clone)] @@ -191,7 +194,7 @@ impl ClientBuilder { pub fn build(self) -> Result { let http = match self.http_client { Some(http) => http, - None => reqwest::Client::builder().timeout(self.timeout).build()?, + None => crate::limits::default_http_client_with_timeout(self.timeout)?, }; Ok(Client { api_base: self.api_base, diff --git a/src/indexer/types.rs b/src/indexer/types.rs index b60818e..1270555 100644 --- a/src/indexer/types.rs +++ b/src/indexer/types.rs @@ -350,13 +350,13 @@ pub struct Order { pub order_id: String, /// The destination that may conclude the order. pub conclude_destination: String, - /// The currency initially asked. + /// The currency initially given. pub give_currency: serde_json::Value, /// The amount initially given. pub initially_given: Amount, /// Remaining give balance. pub give_balance: Amount, - /// The currency initially given. + /// The currency initially asked. pub ask_currency: serde_json::Value, /// The amount initially asked. pub initially_asked: Amount, diff --git a/src/jsonrpc.rs b/src/jsonrpc.rs index 659543f..6f6628b 100644 --- a/src/jsonrpc.rs +++ b/src/jsonrpc.rs @@ -15,7 +15,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::limits::{DEFAULT_TIMEOUT, MAX_RESPONSE_BYTES}; +use crate::limits::{DEFAULT_TIMEOUT, MAX_RESPONSE_BYTES, default_http_client_with_timeout}; /// HTTP basic auth credentials with a redacted [`Debug`] implementation so /// that logging a client never leaks the password. @@ -51,7 +51,7 @@ macro_rules! define_error { }, /// The HTTP request to the daemon failed. #[error("HTTP request failed: {0}")] - Http(#[from] reqwest::Error), + Transport(#[from] reqwest::Error), /// The JSON-RPC response could not be decoded into the expected type. #[error("failed to decode JSON-RPC response: {0}")] Json(#[from] serde_json::Error), @@ -75,7 +75,7 @@ macro_rules! define_error { fn from(err: crate::jsonrpc::RequestError) -> Self { match err { crate::jsonrpc::RequestError::Rpc { code, message } => Self::Rpc { code, message }, - crate::jsonrpc::RequestError::Http(err) => Self::Http(err), + crate::jsonrpc::RequestError::Http(err) => Self::Transport(err), crate::jsonrpc::RequestError::Json(err) => Self::Json(err), crate::jsonrpc::RequestError::IdMismatch { expected, actual } => { Self::IdMismatch { expected, actual } @@ -179,12 +179,6 @@ impl Transport { // with the go-sdk client). let http_response = builder.send().await?; let response: Response = read_json_body(http_response).await?; - if let Some(err) = response.error { - return Err(RequestError::Rpc { - code: err.code, - message: err.message, - }); - } match response.id { Some(actual) if actual.as_u64() == Some(id) => {} Some(actual) => { @@ -200,6 +194,12 @@ impl Transport { }); } } + if let Some(err) = response.error { + return Err(RequestError::Rpc { + code: err.code, + message: err.message, + }); + } Ok(serde_json::from_value(response.result)?) } } @@ -268,7 +268,7 @@ impl ClientBuilder { pub(crate) fn build(self) -> Result { let http = match self.http_client { Some(http) => http, - None => reqwest::Client::builder().timeout(self.timeout).build()?, + None => default_http_client_with_timeout(self.timeout)?, }; Ok(Transport::from_parts(self.endpoint, http, self.basic_auth)) } diff --git a/src/lib.rs b/src/lib.rs index 95a0b97..a17d3bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,23 @@ //! | [`node`] | JSON-RPC 2.0 client for the node daemon | 3030 mainnet | //! | [`indexer`]| REST client for the indexer (api-web-server) | 3000 | //! | [`wallet`] | JSON-RPC 2.0 client for the wallet daemon | 3034 mainnet | -//! | [`crypto`] | Cryptography & transaction building (native) | — | +//! | `crypto` | Cryptography & transaction building (native) | — | +//! +//! # Amount types +//! +//! The daemon wire format differs per client, so each module has its own +//! `Amount` type: +//! +//! - `crypto::Amount` — a `u128` atom value +//! (re-exported from mintlayer-core) used when building transactions. +//! - `node::Amount` — atoms as `u128`, decoded from the daemon's +//! `{"atoms": "..."}` object. +//! - `indexer::Amount` — `atoms` plus the daemon's `decimal` string. +//! - `wallet::Amount` — optional `atoms`/`decimal` (requests typically set +//! `atoms` only, via [`Amount::from_atoms`](crate::wallet::Amount)). +//! +//! All four are denominated in atoms (1 ML = 10^11 atoms); converting +//! between them is done through the atom value. #![forbid(unsafe_code)] #![warn(missing_docs)] @@ -32,7 +48,10 @@ pub mod wallet; #[cfg(any(feature = "node", feature = "wallet"))] mod jsonrpc; +// The lenient wire-format helpers serve different feature combinations, so +// some are unused under any single-feature build. #[cfg(any(feature = "node", feature = "wallet", feature = "indexer"))] +#[allow(dead_code)] mod number; #[cfg(any(feature = "node", feature = "wallet", feature = "indexer"))] diff --git a/src/limits.rs b/src/limits.rs index 8e877ef..988a931 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -12,8 +12,30 @@ use std::time::Duration; /// exhaustion from a misconfigured or hostile endpoint. pub(crate) const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024; -/// Upper bound for the characters kept from a daemon error body. -pub(crate) const MAX_ERROR_BODY_CHARS: usize = 8 * 1024; - /// Default request timeout for the daemon HTTP clients. pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Builds the default HTTP client: overall timeout and no redirects, so a +/// misbehaving endpoint can never forward request bodies (which may carry +/// credentials or mnemonics) to another host. +pub(crate) fn default_http_client_with_timeout( + timeout: std::time::Duration, +) -> Result { + reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .build() +} + +/// Lazily built process-wide default HTTP client for the daemon RPC +/// clients. +#[cfg(any(feature = "node", feature = "wallet"))] +pub(crate) fn default_http_client() -> reqwest::Client { + static DEFAULT_HTTP_CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + DEFAULT_HTTP_CLIENT + .get_or_init(|| { + default_http_client_with_timeout(DEFAULT_TIMEOUT) + .expect("reqwest client with default settings must build") + }) + .clone() +} diff --git a/src/node/mod.rs b/src/node/mod.rs index 5096fd1..27f1b9d 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -13,7 +13,6 @@ mod mempool; mod p2p; mod types; -use std::sync::OnceLock; use std::time::Duration; pub use error::Error; @@ -23,20 +22,6 @@ pub use types::{ }; use crate::jsonrpc; -use crate::limits::DEFAULT_TIMEOUT; - -static DEFAULT_HTTP_CLIENT: OnceLock = OnceLock::new(); - -fn default_http_client() -> reqwest::Client { - DEFAULT_HTTP_CLIENT - .get_or_init(|| { - reqwest::Client::builder() - .timeout(DEFAULT_TIMEOUT) - .build() - .expect("reqwest client with default settings must build") - }) - .clone() -} /// Client for the Mintlayer node daemon JSON-RPC API. #[derive(Debug, Clone)] @@ -50,7 +35,11 @@ impl Client { #[must_use] pub fn new(endpoint: impl Into) -> Self { Self { - transport: jsonrpc::Transport::from_parts(endpoint.into(), default_http_client(), None), + transport: jsonrpc::Transport::from_parts( + endpoint.into(), + crate::limits::default_http_client(), + None, + ), } } diff --git a/src/number.rs b/src/number.rs index 44873be..fa1e8b9 100644 --- a/src/number.rs +++ b/src/number.rs @@ -7,8 +7,8 @@ //! Lenient wire-format helpers for amounts and integers that daemons may //! encode either as JSON numbers or as decimal strings. -/// `(de)serializes` a `u128` atom amount as a decimal string, accepting a -/// decimal string or a plain integer when deserializing. +/// Serializes a `u128` atom amount as a decimal string; accepts a decimal +/// string or a plain integer when deserializing. pub(crate) mod atoms { use std::fmt; @@ -69,8 +69,8 @@ pub(crate) mod atoms_object { } } -/// `(de)serializes` a `u64` from a JSON integer or a decimal string; -/// serializes a plain integer. +/// Serializes a `u64` as a JSON integer; accepts an integer or a decimal +/// string when deserializing. pub(crate) mod u64_lenient { use std::fmt; @@ -104,8 +104,8 @@ pub(crate) mod u64_lenient { } } -/// `(de)serializes` an `f64` ratio from an integer, a float, or a decimal -/// string with an optional trailing `%`; serializes a plain float. +/// Serializes an `f64` ratio as a plain float; accepts an integer, a float, +/// or a decimal string with an optional trailing `%` when deserializing. pub(crate) mod f64_percent { use std::fmt; @@ -144,8 +144,9 @@ pub(crate) mod f64_percent { } } -/// `(de)serializes` an `Option` atom amount from a decimal string or -/// integer; `null` and missing fields become `None`. +/// Serializes an `Option` atom amount as a decimal string or `null` +/// (with `skip_serializing_if` the field is omitted instead); accepts a +/// decimal string, an integer, or `null` when deserializing. pub(crate) mod option_atoms_lenient { use std::fmt; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index a8843db..2484d2e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -15,8 +15,6 @@ mod tokens; mod transaction; mod types; -use std::sync::OnceLock; - pub use error::Error; pub use types::{ AccountInfo, ActiveOrder, AddressWithUsage, Amount, Balance, BestBlock, ChangeAuthorityParams, @@ -32,20 +30,6 @@ pub use types::{ }; use crate::jsonrpc; -use crate::limits::DEFAULT_TIMEOUT; - -static DEFAULT_HTTP_CLIENT: OnceLock = OnceLock::new(); - -fn default_http_client() -> reqwest::Client { - DEFAULT_HTTP_CLIENT - .get_or_init(|| { - reqwest::Client::builder() - .timeout(DEFAULT_TIMEOUT) - .build() - .expect("reqwest client with default settings must build") - }) - .clone() -} /// Client for the Mintlayer wallet daemon JSON-RPC API. #[derive(Debug, Clone)] @@ -59,7 +43,11 @@ impl Client { #[must_use] pub fn new(endpoint: impl Into) -> Self { Self { - transport: jsonrpc::Transport::from_parts(endpoint.into(), default_http_client(), None), + transport: jsonrpc::Transport::from_parts( + endpoint.into(), + crate::limits::default_http_client(), + None, + ), } } diff --git a/src/wallet/types.rs b/src/wallet/types.rs index 009f877..3248252 100644 --- a/src/wallet/types.rs +++ b/src/wallet/types.rs @@ -400,7 +400,7 @@ pub struct SweepParams { } /// Parameters for [`Client::spend_utxo`](crate::wallet::Client::spend_utxo). -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Clone, PartialEq, Serialize)] pub struct UtxoSpendParams { /// The account to spend from. pub account: u32, @@ -414,8 +414,20 @@ pub struct UtxoSpendParams { pub options: TxOptions, } +impl std::fmt::Debug for UtxoSpendParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UtxoSpendParams") + .field("account", &self.account) + .field("utxo", &self.utxo) + .field("output_address", &self.output_address) + .field("htlc_secret", &self.htlc_secret.as_ref().map(|_| "***")) + .field("options", &self.options) + .finish() + } +} + /// Parameters for [`Client::compose_transaction`](crate::wallet::Client::compose_transaction). -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Clone, PartialEq, Serialize)] pub struct ComposeParams { /// The inputs to spend. pub inputs: Vec, @@ -427,6 +439,17 @@ pub struct ComposeParams { pub only_transaction: bool, } +impl std::fmt::Debug for ComposeParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ComposeParams") + .field("inputs", &self.inputs) + .field("outputs", &self.outputs) + .field("htlc_secrets", &self.htlc_secrets.as_ref().map(|_| "***")) + .field("only_transaction", &self.only_transaction) + .finish() + } +} + /// A source or destination currency for orders. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", content = "content")] diff --git a/tests/crypto.rs b/tests/crypto.rs index 65ba325..cd16906 100644 --- a/tests/crypto.rs +++ b/tests/crypto.rs @@ -8,20 +8,30 @@ //! `wasm/client_test.go` suite and the wasm-wrappers test vectors. use mintlayer_sdk::crypto::types::{ - DecodeAll, Encode, H256, InputWitness, PrivateKey, Transaction, TxOutput, + DecodeAll, Encode, H256, InputWitness, PrivateKey, Transaction, TxInput, TxOutput, }; use mintlayer_sdk::crypto::{ - Amount, Network, SigHashType, SourceId, TxAdditionalInfo, data_deposit_fee, + Amount, Error, IsTokenUnfreezable, Network, SigHashType, SourceId, TxAdditionalInfo, + data_deposit_fee, decode_partially_signed_transaction_to_json, decode_signed_transaction_to_json, decode_transaction, decode_transaction_lenient, - effective_pool_balance, encode_input_for_utxo, encode_lock_for_block_count, + effective_pool_balance, encode_destination, encode_input_for_change_token_authority, + encode_input_for_change_token_metadata_uri, encode_input_for_conclude_order, + encode_input_for_fill_order, encode_input_for_freeze_order, encode_input_for_freeze_token, + encode_input_for_lock_token_supply, encode_input_for_mint_tokens, + encode_input_for_unfreeze_token, encode_input_for_unmint_tokens, encode_input_for_utxo, + encode_input_for_withdraw_from_delegation, encode_lock_for_block_count, encode_lock_for_seconds, encode_lock_until_height, encode_lock_until_time, - encode_outpoint_source_id, encode_output_transfer, encode_signed_transaction, + encode_outpoint_source_id, encode_output_create_stake_pool, encode_output_htlc, + encode_output_issue_nft, encode_output_transfer, encode_partially_signed_transaction, + encode_signed_transaction, encode_signed_transaction_intent, encode_stake_pool_data, encode_transaction, encode_witness, encode_witness_no_signature, estimate_transaction_size, - fungible_token_issuance_fee, get_pool_id, get_token_id, make_default_account_privkey, - make_private_key, make_receiving_address, nft_issuance_fee, pubkey_to_pubkeyhash_address, + fungible_token_issuance_fee, get_delegation_id, get_order_id, get_pool_id, get_token_id, + make_default_account_privkey, make_private_key, make_receiving_address, + make_transaction_intent_message_to_sign, nft_issuance_fee, pubkey_to_pubkeyhash_address, public_key_from_private_key, sign_challenge, sign_message_for_spending, staking_pool_spend_maturity_block_count, token_change_authority_fee, token_freeze_fee, token_supply_change_fee, transaction_id, verify_challenge, verify_signature_for_spending, + verify_transaction_intent, }; const MNEMONIC: &str = "walk exile faculty near leg neutral license matrix maple invite cupboard hat opinion excess coffee leopard latin regret document core limb crew dizzy movie"; @@ -400,3 +410,245 @@ fn get_token_id_from_inputs() { "token id derived from an all-zero-hash outpoint at height 500_000" ); } + +/// A mainnet VRF public key, taken from mintlayer-core's own address test +/// vectors (`common/src/address/hexified.rs`), so that stake-pool creation can +/// be exercised without deriving a VRF key (the SDK exposes no helper for it). +const MAINNET_VRF_PUBLIC_KEY: &str = + "mvrfpk1qqyxcl4tc6y9amf2vmv6sgu8x5jwqlxawx73vhgemkduag9c8ku57m03mze"; + +#[test] +fn input_constructors_encode() { + let network = Network::Mainnet; + + // A delegation id derived from a UTXO outpoint over hash [7u8; 32]. + let delegation_source = + encode_outpoint_source_id(H256::from_slice(&[7u8; 32]), SourceId::Transaction); + let delegation_id = + get_delegation_id(&[encode_input_for_utxo(delegation_source, 0)], network).unwrap(); + + // The token id pinned by `get_token_id_from_inputs`. + let token_source = + encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let token_id = + get_token_id(&[encode_input_for_utxo(token_source, 0)], 500_000, network).unwrap(); + + let fresh_authority = + pubkey_to_pubkeyhash_address(&public_key_from_private_key(&make_private_key()), network); + + let cases: Vec<(&str, Box Result>)> = vec![ + ( + "withdraw_from_delegation", + Box::new(|| { + encode_input_for_withdraw_from_delegation( + &delegation_id, + Amount::from_atoms(1), + 0, + network, + ) + }), + ), + ( + "mint_tokens", + Box::new(|| encode_input_for_mint_tokens(&token_id, Amount::from_atoms(1), 0, network)), + ), + ( + "unmint_tokens", + Box::new(|| encode_input_for_unmint_tokens(&token_id, 0, network)), + ), + ( + "lock_token_supply", + Box::new(|| encode_input_for_lock_token_supply(&token_id, 0, network)), + ), + ( + "freeze_token", + Box::new(|| { + encode_input_for_freeze_token(&token_id, IsTokenUnfreezable::Yes, 0, network) + }), + ), + ( + "unfreeze_token", + Box::new(|| encode_input_for_unfreeze_token(&token_id, 0, network)), + ), + ( + "change_token_authority", + Box::new(|| { + encode_input_for_change_token_authority(&token_id, &fresh_authority, 0, network) + }), + ), + ( + "change_token_metadata_uri", + Box::new(|| { + encode_input_for_change_token_metadata_uri( + &token_id, + "https://example.com/metadata", + 0, + network, + ) + }), + ), + ]; + + for (name, build) in cases { + let input = build().unwrap_or_else(|error| panic!("{name} must encode: {error}")); + assert!( + !input.encode().is_empty(), + "{name} produced an empty encoding" + ); + } +} + +#[test] +fn fill_order_fork_versions() { + let network = Network::Mainnet; + let source = encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let order_id = get_order_id(&[encode_input_for_utxo(source, 0)], network).unwrap(); + let destination = + pubkey_to_pubkeyhash_address(&public_key_from_private_key(&make_private_key()), network); + + // Mainnet orders V1 activates at height 517_700; straddle it. Pre-fork + // inputs are V0 AccountCommand, post-fork they are V1 OrderAccountCommand. + let pre_fork = encode_input_for_fill_order( + &order_id, + Amount::from_atoms(1), + &destination, + 0, + 500_000, + network, + ) + .expect("fill order must encode before the orders V1 fork"); + let post_fork = encode_input_for_fill_order( + &order_id, + Amount::from_atoms(1), + &destination, + 0, + 5_000_000, + network, + ) + .expect("fill order must encode after the orders V1 fork"); + assert_ne!( + pre_fork.encode(), + post_fork.encode(), + "fill-order encoding must change across the orders V1 fork" + ); + + let conclude_pre = encode_input_for_conclude_order(&order_id, 0, 500_000, network) + .expect("conclude order must encode before the orders V1 fork"); + let conclude_post = encode_input_for_conclude_order(&order_id, 0, 5_000_000, network) + .expect("conclude order must encode after the orders V1 fork"); + assert_ne!( + conclude_pre.encode(), + conclude_post.encode(), + "conclude-order encoding must change across the orders V1 fork" + ); + + // Freezing an order only exists once orders V1 is active. + match encode_input_for_freeze_order(&order_id, 500_000, network) { + Err(Error::OrdersV1NotActivated) => {} + other => panic!("expected OrdersV1NotActivated before the fork, got {other:?}"), + } + let freeze_post = encode_input_for_freeze_order(&order_id, 5_000_000, network) + .expect("freeze order must encode after the orders V1 fork"); + assert!(!freeze_post.encode().is_empty()); +} + +#[test] +fn intents_roundtrip() { + let message = make_transaction_intent_message_to_sign("transfer", EXPECTED_TX_ID) + .expect("intent message must be produced"); + assert!(!message.is_empty()); + + let signed = encode_signed_transaction_intent(&message, vec![vec![1u8; 64]]) + .expect("signed intent must be assembled"); + + // An empty destination list can never verify: the intent carries one + // signature but there is no input destination to check it against. + let result = verify_transaction_intent(&message, &signed.encode(), &[], Network::Mainnet); + assert!( + result.is_err(), + "verification with no destinations must fail" + ); +} + +#[test] +fn partially_signed_transaction_roundtrip() { + let (_, address, transaction, output) = fake_utxo_transaction(make_private_key()); + let destination = encode_destination(&address, Network::Mainnet).unwrap(); + + let ptx = encode_partially_signed_transaction( + transaction, + vec![None], + vec![Some(output)], + vec![Some(destination)], + vec![None], + TxAdditionalInfo::new(), + Network::Mainnet, + ) + .expect("partially signed transaction must be assembled"); + + let json = decode_partially_signed_transaction_to_json(&ptx.encode(), Network::Mainnet) + .expect("partially signed transaction must decode to JSON"); + assert_eq!( + json.get("type").and_then(|value| value.as_str()), + Some("V1"), + "partially signed transaction JSON must carry the V1 tag" + ); + assert!( + json.get("tx").and_then(|value| value.as_object()).is_some(), + "partially signed transaction JSON must contain the transaction" + ); +} + +#[test] +fn htlc_and_special_outputs_encode() { + let network = Network::Mainnet; + let key = make_private_key(); + let address = pubkey_to_pubkeyhash_address(&public_key_from_private_key(&key), network); + + // HTLC output whose secret hash is the SHA-256 hash of the empty string, + // truncated to the 20-byte HtlcSecretHash length the chain enforces + // (a full 32-byte hex string is rejected as "invalid length"). + let htlc = encode_output_htlc( + Amount::from_atoms(1), + None, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4", + &address, + &address, + encode_lock_until_height(1_000_000), + network, + ) + .expect("HTLC output must encode"); + assert!(!htlc.encode().is_empty()); + + // NFT issuance on top of the pinned token id. "TCK" satisfies the + // 1..=12-byte alphanumeric ticker rule and the 32-byte media hash the + // minimum hash length. + let token_source = + encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let token_id = + get_token_id(&[encode_input_for_utxo(token_source, 0)], 500_000, network).unwrap(); + let nft = encode_output_issue_nft( + &token_id, &address, "name", "TCK", "desc", &[1u8; 32], None, None, None, None, network, + ) + .expect("NFT issuance output must encode"); + assert!(!nft.encode().is_empty()); + + // Stake pool creation using a mainnet VRF public key from mintlayer-core's + // address test vectors. + let pool_source = + encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let pool_id = get_pool_id(&[encode_input_for_utxo(pool_source, 0)], network).unwrap(); + let pool_data = encode_stake_pool_data( + Amount::from_atoms(1_000_000_000_000), + &address, + MAINNET_VRF_PUBLIC_KEY, + &address, + 100, + Amount::from_atoms(1_000), + network, + ) + .expect("stake pool data must encode"); + let pool = encode_output_create_stake_pool(&pool_id, pool_data, network) + .expect("create-stake-pool output must encode"); + assert!(!pool.encode().is_empty()); +} diff --git a/tests/wallet.rs b/tests/wallet.rs index 7f556cf..44811de 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -14,8 +14,9 @@ use httpmock::{Mock, Then}; use serde_json::json; use mintlayer_sdk::wallet::{ - Amount, Client, CreateOrderParams, CreateWalletParams, CurrencyFilter, Error, ListOrdersParams, - LockSupplyParams, OutputValue, RecoverWalletParams, SendParams, StakingStatus, TxOptions, + Amount, Client, ComposeParams, CreateOrderParams, CreateWalletParams, CurrencyFilter, Error, + ListOrdersParams, LockSupplyParams, Outpoint, OutpointSourceId, OutputValue, + RecoverWalletParams, SendParams, StakingStatus, TxOptions, UtxoSpendParams, }; const RESPONSE_HEADERS: [(&str, &str); 1] = [("content-type", "application/json")]; @@ -545,3 +546,32 @@ async fn debug_output_redacts_mnemonics_and_passphrases() { assert!(!result_debug.contains("top secret seed phrase")); assert!(result_debug.contains("")); } + +#[tokio::test] +async fn debug_output_redacts_htlc_secrets() { + let params = UtxoSpendParams { + account: 0, + utxo: Outpoint { + source_id: OutpointSourceId::Transaction { + tx_id: "aa".to_string(), + }, + index: 0, + }, + output_address: "mtc1qx".to_string(), + htlc_secret: Some("supersecret123".to_string()), + options: TxOptions::default(), + }; + let spend_debug = format!("{params:?}"); + assert!(!spend_debug.contains("supersecret123")); + assert!(spend_debug.contains("***")); + + let params = ComposeParams { + inputs: vec![], + outputs: vec![], + htlc_secrets: Some(json!("supersecret456")), + only_transaction: true, + }; + let compose_debug = format!("{params:?}"); + assert!(!compose_debug.contains("supersecret456")); + assert!(compose_debug.contains("***")); +} From 94152a66671979dbef86549dabe9c5bda27e9505 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 02:34:27 +0400 Subject: [PATCH 21/29] test: type alias to satisfy clippy type_complexity --- tests/crypto.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/crypto.rs b/tests/crypto.rs index cd16906..7381937 100644 --- a/tests/crypto.rs +++ b/tests/crypto.rs @@ -436,7 +436,11 @@ fn input_constructors_encode() { let fresh_authority = pubkey_to_pubkeyhash_address(&public_key_from_private_key(&make_private_key()), network); - let cases: Vec<(&str, Box Result>)> = vec![ + // Type alias to avoid clippy::type_complexity on the cases vector. The + // trait object bound is tied to 'a so the closures may borrow locals. + type InputCase<'a> = (&'a str, Box Result + 'a>); + + let cases: Vec = vec![ ( "withdraw_from_delegation", Box::new(|| { From 6c9e80a77f86959e0b6b18fac8f8571aeb398c63 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 02:39:54 +0400 Subject: [PATCH 22/29] repo: per-test required-features so the CI feature matrix runs green --- Cargo.toml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index ee9e6ff..d03eb6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,26 @@ required-features = ["crypto", "indexer"] name = "issue-token" required-features = ["wallet"] +[[test]] +name = "client" +required-features = ["node", "indexer", "wallet", "crypto"] + +[[test]] +name = "node" +required-features = ["node"] + +[[test]] +name = "indexer" +required-features = ["indexer"] + +[[test]] +name = "wallet" +required-features = ["wallet"] + +[[test]] +name = "crypto" +required-features = ["crypto"] + [dev-dependencies] hex = "0.4" httpmock = "0.7" From 7c25c7e1b87d486593f8060a38a7a656abb768ec Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 02:48:06 +0400 Subject: [PATCH 23/29] refactor: single shared capped response-body reader for both transports (DRY review fix) --- src/indexer/mod.rs | 23 +++-------------------- src/jsonrpc.rs | 27 +++++++-------------------- src/limits.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 40 deletions(-) diff --git a/src/indexer/mod.rs b/src/indexer/mod.rs index 5ab48b0..975b62b 100644 --- a/src/indexer/mod.rs +++ b/src/indexer/mod.rs @@ -31,7 +31,7 @@ pub use types::{ Transaction, Uint64, Utxo, UtxoOutpoint, }; -use crate::limits::{DEFAULT_TIMEOUT, MAX_RESPONSE_BYTES}; +use crate::limits::DEFAULT_TIMEOUT; /// Upper bound for the characters kept from a daemon error body. const MAX_ERROR_BODY_CHARS: usize = 8 * 1024; @@ -120,25 +120,8 @@ impl Client { Ok(serde_json::from_slice(&bytes)?) } - async fn read_capped(mut response: reqwest::Response) -> Result, Error> { - if response - .content_length() - .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) - { - return Err(Error::ResponseTooLarge { - limit: MAX_RESPONSE_BYTES, - }); - } - let mut body: Vec = Vec::new(); - while let Some(chunk) = response.chunk().await? { - if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { - return Err(Error::ResponseTooLarge { - limit: MAX_RESPONSE_BYTES, - }); - } - body.extend_from_slice(&chunk); - } - Ok(body) + async fn read_capped(response: reqwest::Response) -> Result, Error> { + crate::limits::read_capped_body(response, |limit| Error::ResponseTooLarge { limit }).await } } diff --git a/src/jsonrpc.rs b/src/jsonrpc.rs index 6f6628b..cd3be94 100644 --- a/src/jsonrpc.rs +++ b/src/jsonrpc.rs @@ -15,7 +15,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::limits::{DEFAULT_TIMEOUT, MAX_RESPONSE_BYTES, default_http_client_with_timeout}; +use crate::limits::{DEFAULT_TIMEOUT, default_http_client_with_timeout}; /// HTTP basic auth credentials with a redacted [`Debug`] implementation so /// that logging a client never leaks the password. @@ -204,25 +204,12 @@ impl Transport { } } -async fn read_json_body(mut http_response: reqwest::Response) -> Result { - if http_response - .content_length() - .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) - { - return Err(RequestError::ResponseTooLarge { - limit: MAX_RESPONSE_BYTES, - }); - } - let mut body: Vec = Vec::new(); - while let Some(chunk) = http_response.chunk().await? { - if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { - return Err(RequestError::ResponseTooLarge { - limit: MAX_RESPONSE_BYTES, - }); - } - body.extend_from_slice(&chunk); - } - Ok(serde_json::from_slice(&body)?) +async fn read_json_body(http_response: reqwest::Response) -> Result { + let bytes = crate::limits::read_capped_body(http_response, |limit| { + RequestError::ResponseTooLarge { limit } + }) + .await?; + Ok(serde_json::from_slice(&bytes)?) } /// Shared builder state for the node and wallet clients. diff --git a/src/limits.rs b/src/limits.rs index 988a931..73e821c 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -39,3 +39,30 @@ pub(crate) fn default_http_client() -> reqwest::Client { }) .clone() } + +/// Reads a daemon response body in chunks, rejecting anything beyond +/// [`MAX_RESPONSE_BYTES`] before it can exhaust memory. +/// +/// `too_large` constructs the caller's own body-size error. +pub(crate) async fn read_capped_body( + mut response: reqwest::Response, + too_large: fn(usize) -> E, +) -> Result, E> +where + E: From, +{ + if response + .content_length() + .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) + { + return Err(too_large(MAX_RESPONSE_BYTES)); + } + let mut body: Vec = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + return Err(too_large(MAX_RESPONSE_BYTES)); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} From 85e1a8a44fd618cb10f557e9d71d7f61ed52e9b1 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 03:04:19 +0400 Subject: [PATCH 24/29] test: shared mock harness, HTLC/intents/estimation coverage, per-client gating (DRY + test review fixes) --- tests/client.rs | 26 ++-- tests/common/mod.rs | 59 ++++++++ tests/crypto.rs | 344 ++++++++++++++++++++++++++++++++++++++++++-- tests/indexer.rs | 70 +++++---- tests/node.rs | 48 ++----- tests/wallet.rs | 47 ++---- 6 files changed, 465 insertions(+), 129 deletions(-) create mode 100644 tests/common/mod.rs diff --git a/tests/client.rs b/tests/client.rs index fd4c82b..9bad10e 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -6,9 +6,11 @@ //! Tests for the top-level client and crate re-exports. +mod common; + use std::time::Duration; -use httpmock::Then; +use common::{respond, rpc_ok}; use httpmock::prelude::*; use serde_json::json; @@ -16,16 +18,6 @@ use mintlayer_sdk::Client; use mintlayer_sdk::crypto::make_private_key; use mintlayer_sdk::prelude::*; -fn respond(then: Then, body: serde_json::Value) { - then.status(200) - .header("content-type", "application/json") - .body(body.to_string()); -} - -fn rpc_ok(id: u64, result: serde_json::Value) -> serde_json::Value { - json!({ "jsonrpc": "2.0", "id": id, "result": result }) -} - fn chainstate_info_result() -> serde_json::Value { json!({ "best_block_height": 123456, @@ -77,11 +69,11 @@ async fn umbrella_client_end_to_end_request() { let server = MockServer::start(); let node_mock = server.mock(|when, then| { when.method(POST).path("/node").body_contains("\"method\":\"chainstate_info\""); - respond(then, rpc_ok(1, chainstate_info_result())); + respond(then, 200, rpc_ok(1, chainstate_info_result())); }); let wallet_mock = server.mock(|when, then| { when.method(POST).path("/wallet"); - respond(then, rpc_ok(1, json!(null))); + respond(then, 200, rpc_ok(1, json!(null))); }); let client = Client::builder() @@ -114,7 +106,7 @@ async fn basic_auth_only_affects_rpc_clients() { .path("/node") .header("authorization", "Basic dXNlcjpwYXNz") .body_contains("\"method\":\"chainstate_info\""); - respond(then, rpc_ok(1, chainstate_info_result())); + respond(then, 200, rpc_ok(1, chainstate_info_result())); }); let indexer_mock = server.mock(|when, then| { when.method(GET).path("/api/v2/chain/tip").matches(|request| { @@ -125,7 +117,11 @@ async fn basic_auth_only_affects_rpc_clients() { .iter() .any(|(name, _)| name.eq_ignore_ascii_case("authorization")) }); - respond(then, json!({ "block_height": 42000, "block_id": "0000ab" })); + respond( + then, + 200, + json!({ "block_height": 42000, "block_id": "0000ab" }).to_string(), + ); }); let client = Client::builder() diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..b25e20c --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,59 @@ +// 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. + +//! Shared mock-RPC harness for the integration tests. +//! +//! Every `tests/*.rs` binary that talks HTTP includes this module via +//! `mod common;`. Because each test binary compiles its own copy, and not +//! every binary uses every helper, dead-code analysis is silenced for the +//! module as a whole. + +#![allow(dead_code)] + +use httpmock::prelude::*; +use httpmock::{Mock, Then}; +use serde_json::json; + +/// Content-type header applied to every mocked JSON-RPC / REST response. +pub const JSON_HEADERS: [(&str, &str); 1] = [("content-type", "application/json")]; + +/// Full JSON-RPC 2.0 success envelope carrying the request's `id`. +pub fn rpc_ok(id: u64, result: serde_json::Value) -> String { + json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string() +} + +/// JSON-RPC 2.0 success envelope without an `id` (id-validation tests). +pub fn rpc_ok_no_id(result: serde_json::Value) -> String { + json!({ "jsonrpc": "2.0", "result": result }).to_string() +} + +/// JSON-RPC 2.0 error envelope carrying the request's `id`. +pub fn rpc_error(id: u64, code: i64, message: &str) -> String { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message }, + }) + .to_string() +} + +/// Answer a request with `status`, a JSON content-type header, and `body`. +pub fn respond(then: Then, status: u16, body: String) { + let mut builder = then.status(status); + for (name, value) in JSON_HEADERS { + builder = builder.header(name, value); + } + builder.body(body); +} + +/// Mock a `POST /` JSON-RPC call whose request body contains +/// `request_fragment`, answering with `body`. +pub fn mock_rpc(server: &MockServer, request_fragment: String, body: String) -> Mock<'_> { + server.mock(move |when, then| { + when.method(POST).path("/").body_contains(request_fragment); + respond(then, 200, body); + }) +} diff --git a/tests/crypto.rs b/tests/crypto.rs index 7381937..40b1188 100644 --- a/tests/crypto.rs +++ b/tests/crypto.rs @@ -8,7 +8,7 @@ //! `wasm/client_test.go` suite and the wasm-wrappers test vectors. use mintlayer_sdk::crypto::types::{ - DecodeAll, Encode, H256, InputWitness, PrivateKey, Transaction, TxInput, TxOutput, + DecodeAll, Encode, H256, HtlcSecret, InputWitness, PrivateKey, Transaction, TxInput, TxOutput, }; use mintlayer_sdk::crypto::{ Amount, Error, IsTokenUnfreezable, Network, SigHashType, SourceId, TxAdditionalInfo, @@ -21,17 +21,21 @@ use mintlayer_sdk::crypto::{ encode_input_for_unfreeze_token, encode_input_for_unmint_tokens, encode_input_for_utxo, encode_input_for_withdraw_from_delegation, encode_lock_for_block_count, encode_lock_for_seconds, encode_lock_until_height, encode_lock_until_time, - encode_outpoint_source_id, encode_output_create_stake_pool, encode_output_htlc, - encode_output_issue_nft, encode_output_transfer, encode_partially_signed_transaction, - encode_signed_transaction, encode_signed_transaction_intent, encode_stake_pool_data, - encode_transaction, encode_witness, encode_witness_no_signature, estimate_transaction_size, + encode_multisig_challenge, encode_outpoint_source_id, encode_output_create_stake_pool, + encode_output_htlc, encode_output_issue_nft, encode_output_transfer, + encode_partially_signed_transaction, encode_signed_transaction, + encode_signed_transaction_intent, encode_stake_pool_data, encode_transaction, encode_witness, + encode_witness_htlc_refund_multisig, encode_witness_htlc_refund_single_sig, + encode_witness_htlc_spend, encode_witness_no_signature, estimate_transaction_size, + extended_public_key_from_extended_private_key, extract_htlc_secret, fungible_token_issuance_fee, get_delegation_id, get_order_id, get_pool_id, get_token_id, - make_default_account_privkey, make_private_key, make_receiving_address, - make_transaction_intent_message_to_sign, nft_issuance_fee, pubkey_to_pubkeyhash_address, - public_key_from_private_key, sign_challenge, sign_message_for_spending, - staking_pool_spend_maturity_block_count, token_change_authority_fee, token_freeze_fee, - token_supply_change_fee, transaction_id, verify_challenge, verify_signature_for_spending, - verify_transaction_intent, + make_change_address, make_change_address_public_key, make_default_account_privkey, + make_private_key, make_receiving_address, make_receiving_address_public_key, + make_transaction_intent_message_to_sign, multisig_challenge_to_address, nft_issuance_fee, + pubkey_to_pubkeyhash_address, public_key_from_private_key, sign_challenge, + sign_message_for_spending, staking_pool_spend_maturity_block_count, token_change_authority_fee, + token_freeze_fee, token_supply_change_fee, transaction_id, verify_challenge, + verify_signature_for_spending, verify_transaction_intent, }; const MNEMONIC: &str = "walk exile faculty near leg neutral license matrix maple invite cupboard hat opinion excess coffee leopard latin regret document core limb crew dizzy movie"; @@ -74,6 +78,40 @@ fn fake_utxo_transaction(private_key: PrivateKey) -> (PrivateKey, String, Transa (private_key, address, transaction, output) } +fn fixed_signing_key() -> (PrivateKey, String) { + let key = ::decode_all( + &mut &hex::decode(FIXED_SIGNING_PRIVKEY).unwrap()[..], + ) + .expect("fixed private key must decode"); + let address = + pubkey_to_pubkeyhash_address(&public_key_from_private_key(&key), Network::Mainnet); + (key, address) +} + +fn fake_htlc_transaction(htlc_output: TxOutput) -> Transaction { + let source_id = encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let input = encode_input_for_utxo(source_id, 0); + encode_transaction(vec![input], vec![htlc_output], 0).unwrap() +} + +/// SHA-256 of the empty string: the first 20 bytes are the `HtlcSecretHash` +/// used by the HTLC tests, the full 32 bytes are the pre-image secret. +const HTLC_EMPTY_STRING_SECRET: &str = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +fn htlc_test_output(spend_address: &str, refund_address: &str) -> TxOutput { + encode_output_htlc( + Amount::from_atoms(1000), + None, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4", + spend_address, + refund_address, + encode_lock_until_height(1_000_000), + Network::Mainnet, + ) + .expect("HTLC output must encode") +} + #[test] fn make_private_key_is_random() { let key_1 = make_private_key(); @@ -656,3 +694,287 @@ fn htlc_and_special_outputs_encode() { .expect("create-stake-pool output must encode"); assert!(!pool.encode().is_empty()); } + +#[test] +fn htlc_spend_and_secret_extraction() { + let network = Network::Mainnet; + let (fixed_key, address) = fixed_signing_key(); + + let secret_bytes: [u8; 32] = hex::decode(HTLC_EMPTY_STRING_SECRET) + .unwrap() + .try_into() + .expect("secret must be 32 bytes"); + let secret = HtlcSecret::new(secret_bytes); + + let htlc_output = htlc_test_output(&address, &address); + let transaction = fake_htlc_transaction(htlc_output.clone()); + let input_utxos = [Some(htlc_output)]; + + let witness = encode_witness_htlc_spend( + SigHashType::all(), + &fixed_key, + &address, + &transaction, + &input_utxos, + 0, + secret, + &TxAdditionalInfo::new(), + 500_000, + network, + ) + .expect("HTLC spend witness must be produced"); + + let signed = encode_signed_transaction(transaction, vec![witness]).unwrap(); + + let source_id = encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let extracted = extract_htlc_secret(&signed, source_id, 0) + .expect("secret must be extractable from an HTLC spend"); + assert_eq!(extracted, HtlcSecret::new(secret_bytes)); + assert_eq!( + extracted.encode(), + secret_bytes.to_vec(), + "extracted secret must encode to the original 32 bytes" + ); +} + +#[test] +fn htlc_refund_single_sig() { + let network = Network::Mainnet; + let (fixed_key, address) = fixed_signing_key(); + + let htlc_output = htlc_test_output(&address, &address); + let transaction = fake_htlc_transaction(htlc_output.clone()); + let input_utxos = [Some(htlc_output)]; + + let witness = encode_witness_htlc_refund_single_sig( + SigHashType::all(), + &fixed_key, + &address, + &transaction, + &input_utxos, + 0, + &TxAdditionalInfo::new(), + 500_000, + network, + ) + .expect("single-sig HTLC refund witness must be produced"); + assert!(!witness.encode().is_empty()); + + let signed = encode_signed_transaction(transaction, vec![witness]).unwrap(); + + let source_id = encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let result = extract_htlc_secret(&signed, source_id, 0); + assert!( + matches!(result, Err(Error::UnexpectedHtlcSpendType)), + "a refund carries no secret, got {result:?}" + ); +} + +#[test] +fn htlc_refund_multisig_cumulative() { + let network = Network::Mainnet; + let keys = [make_private_key(), make_private_key(), make_private_key()]; + let public_keys: Vec<_> = keys.iter().map(public_key_from_private_key).collect(); + + let challenge = encode_multisig_challenge(&public_keys, 2, network) + .expect("multisig challenge must encode"); + let msig_address = multisig_challenge_to_address(&challenge, network); + + let htlc_output = htlc_test_output(&msig_address, &msig_address); + let transaction = fake_htlc_transaction(htlc_output.clone()); + let input_utxos = [Some(htlc_output)]; + let additional_info = TxAdditionalInfo::new(); + + let witness_0 = encode_witness_htlc_refund_multisig( + SigHashType::all(), + &keys[0], + 0, + None, + &challenge, + &transaction, + &input_utxos, + 0, + &additional_info, + 500_000, + network, + ) + .expect("first multisig refund signature must be produced"); + + let witness_1 = encode_witness_htlc_refund_multisig( + SigHashType::all(), + &keys[1], + 1, + Some(&witness_0), + &challenge, + &transaction, + &input_utxos, + 0, + &additional_info, + 500_000, + network, + ) + .expect("second multisig refund signature must be produced"); + assert!( + witness_1.encode().len() > witness_0.encode().len(), + "the witness must grow as signatures accumulate" + ); + + let (standard_key, standard_address) = fixed_signing_key(); + let standard_witness = encode_witness( + SigHashType::all(), + &standard_key, + &standard_address, + &transaction, + &input_utxos, + 0, + &additional_info, + 500_000, + network, + ) + .expect("standard witness must be produced"); + let result = encode_witness_htlc_refund_multisig( + SigHashType::all(), + &keys[2], + 2, + Some(&standard_witness), + &challenge, + &transaction, + &input_utxos, + 0, + &additional_info, + 500_000, + network, + ); + assert!( + result.is_err(), + "a standard UTXO witness must not seed an HTLC multisig refund, got {result:?}" + ); + + assert!( + matches!( + encode_multisig_challenge(&public_keys, 0, network), + Err(Error::ZeroMultisigRequiredSignatures) + ), + "zero required signatures must be rejected" + ); +} + +#[test] +fn verify_transaction_intent_positive() { + let network = Network::Mainnet; + let key = make_private_key(); + let address = pubkey_to_pubkeyhash_address(&public_key_from_private_key(&key), network); + + let message = make_transaction_intent_message_to_sign("transfer", EXPECTED_TX_ID) + .expect("intent message must be produced"); + let signature = sign_challenge(&key, message.as_bytes()).expect("intent must be signed"); + + let signed_intent = encode_signed_transaction_intent(&message, vec![signature]) + .expect("signed intent must be assembled"); + + verify_transaction_intent(&message, &signed_intent.encode(), &[&address], network) + .expect("intent must verify against the signing address"); + + let tampered_message = make_transaction_intent_message_to_sign("transfer_all", EXPECTED_TX_ID) + .expect("tampered intent message must be produced"); + assert!( + verify_transaction_intent( + &tampered_message, + &signed_intent.encode(), + &[&address], + network + ) + .is_err(), + "a tampered message must not verify" + ); + + let other_key = make_private_key(); + let other_address = + pubkey_to_pubkeyhash_address(&public_key_from_private_key(&other_key), network); + assert!( + verify_transaction_intent( + &message, + &signed_intent.encode(), + &[&other_address], + network + ) + .is_err(), + "a wrong destination must not verify" + ); +} + +#[test] +fn estimate_transaction_size_happy_path() { + let network = Network::Mainnet; + let (_, address, transaction, output) = fake_utxo_transaction(make_private_key()); + let source_id = encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let input = encode_input_for_utxo(source_id, 0); + + let estimate = estimate_transaction_size( + std::slice::from_ref(&input), + &[&address], + std::slice::from_ref(&output), + network, + ) + .expect("size estimate must succeed"); + assert!(estimate > 0, "estimate must be positive"); + assert!( + estimate > transaction.encode().len(), + "estimate must cover the witness signatures, not just the unsigned transaction" + ); + + assert!( + estimate_transaction_size(&[input], &[&address, "mtc1qfoo"], &[output], network).is_err(), + "an unparsable destination must fail the estimate" + ); +} + +#[test] +fn change_address_derivations() { + let account = make_default_account_privkey(MNEMONIC, Network::Mainnet, None) + .expect("account key must derive"); + + let receiving_priv = make_receiving_address(&account, 0).expect("receiving key must derive"); + let change_priv = make_change_address(&account, 0).expect("change key must derive"); + + let account_public = extended_public_key_from_extended_private_key(&account); + assert!( + !account_public.encode().is_empty(), + "extended public key must encode" + ); + + let receiving_pub = make_receiving_address_public_key(&account_public, 0) + .expect("watch-only receiving key must derive"); + let change_pub = make_change_address_public_key(&account_public, 0) + .expect("watch-only change key must derive"); + + assert_eq!( + hex::encode(public_key_from_private_key(&receiving_priv).encode()), + hex::encode(receiving_pub.encode()), + "watch-only receiving derivation must match the private derivation" + ); + assert_eq!( + hex::encode(public_key_from_private_key(&change_priv).encode()), + hex::encode(change_pub.encode()), + "watch-only change derivation must match the private derivation" + ); + assert_ne!( + receiving_priv.encode(), + change_priv.encode(), + "receiving and change branches must not collide at index 0" + ); + + let account_public_again = extended_public_key_from_extended_private_key(&account); + assert_eq!( + account_public.encode(), + account_public_again.encode(), + "extended public key derivation must be deterministic" + ); + let receiving_pub_again = make_receiving_address_public_key(&account_public_again, 0) + .expect("watch-only receiving key must derive again"); + assert_eq!( + receiving_pub.encode(), + receiving_pub_again.encode(), + "watch-only derivation must be deterministic" + ); +} diff --git a/tests/indexer.rs b/tests/indexer.rs index 1c231f2..09985fa 100644 --- a/tests/indexer.rs +++ b/tests/indexer.rs @@ -7,7 +7,9 @@ //! Wire-format tests for the indexer client, mirroring the go-sdk //! `indexer/client_test.go` suite. -use httpmock::Then; +mod common; + +use common::respond; use httpmock::prelude::*; use serde_json::json; @@ -16,24 +18,20 @@ use mintlayer_sdk::indexer::{ UtxoOutpoint, }; -fn respond(then: Then, body: serde_json::Value) { - then.status(200) - .header("content-type", "application/json") - .body(body.to_string()); -} - #[tokio::test] async fn tip_and_height_paths() { let server = MockServer::start(); let tip_mock = server.mock(|when, then| { when.method(GET).path("/api/v2/chain/tip"); - respond(then, json!({"block_height": 42000, "block_id": "0000ab"})); + respond( + then, + 200, + json!({"block_height": 42000, "block_id": "0000ab"}).to_string(), + ); }); let height_mock = server.mock(|when, then| { when.method(GET).path("/api/v2/chain/1337"); - then.status(200) - .header("content-type", "application/json") - .body(json!("0000cd").to_string()); + respond(then, 200, json!("0000cd").to_string()); }); let client = Client::new(server.url("")); @@ -54,6 +52,7 @@ async fn block_endpoints() { when.method(GET).path("/api/v2/block/0000aa"); respond( then, + 200, json!({ "height": 42000, "header": { @@ -67,7 +66,8 @@ async fn block_endpoints() { "reward": [], "transactions": [] } - }), + }) + .to_string(), ); }); @@ -93,14 +93,14 @@ async fn pagination_query_params() { .path("/api/v2/transaction") .query_param("offset", "10") .query_param("items", "20"); - respond(then, json!([])); + respond(then, 200, json!([]).to_string()); }); let unpaginated = server.mock(|when, then| { when.method(GET).path("/api/v2/transaction").matches(|request| { let params = request.query_params.as_deref().unwrap_or(&[]); !params.iter().any(|(name, _)| *name == "offset" || *name == "items") }); - respond(then, json!([])); + respond(then, 200, json!([]).to_string()); }); let client = Client::new(server.url("")); @@ -132,6 +132,7 @@ async fn pool_list_sort_param() { }); respond( then, + 200, json!([{ "pool_id": "mpool1x", "decommission_destination": "mtct1x", @@ -140,7 +141,8 @@ async fn pool_list_sort_param() { "cost_per_block": {"atoms": "1000", "decimal": "0.00000001"}, "vrf_public_key": "vrfpub1x", "delegations_balance": {"atoms": "2000", "decimal": "0.00000002"} - }]), + }]) + .to_string(), ); }); @@ -193,7 +195,7 @@ async fn pool_block_stats_query() { .path("/api/v2/pool/mpool1x/block-stats") .query_param("from", "1700000000") .query_param("to", "1700003600"); - respond(then, json!({"block_count": 42})); + respond(then, 200, json!({"block_count": 42}).to_string()); }); let client = Client::new(server.url("")); @@ -211,7 +213,7 @@ async fn submit_transaction_posts_raw_hex() { .path("/api/v2/transaction") .header("content-type", "text/plain") .body("deadbeef"); - respond(then, json!({"tx_id": "aabb"})); + respond(then, 200, json!({"tx_id": "aabb"}).to_string()); }); let client = Client::new(server.url("")); @@ -250,6 +252,7 @@ async fn lenient_number_fields() { when.method(GET).path("/api/v2/delegation/tdelg1x"); respond( then, + 200, json!({ "delegation_id": "tdelg1x", "pool_id": "mpool1x", @@ -257,13 +260,15 @@ async fn lenient_number_fields() { "spend_destination": "mtct1x", "balance": {"atoms": "500", "decimal": "0.000000005"}, "creation_block_height": "10000" - }), + }) + .to_string(), ); }); let numeric_delegation = server.mock(|when, then| { when.method(GET).path("/api/v2/delegation/tdelg2x"); respond( then, + 200, json!({ "delegation_id": "tdelg2x", "pool_id": "mpool1x", @@ -271,13 +276,15 @@ async fn lenient_number_fields() { "spend_destination": "mtct1x", "balance": {"atoms": 500, "decimal": "0.000000005"}, "creation_block_height": 10000 - }), + }) + .to_string(), ); }); let order_mock = server.mock(|when, then| { when.method(GET).path("/api/v2/order/mordr1x"); respond( then, + 200, json!({ "order_id": "mordr1x", "conclude_destination": "mtct1x", @@ -288,7 +295,8 @@ async fn lenient_number_fields() { "initially_asked": {"atoms": "1", "decimal": "0.00000000001"}, "ask_balance": {"atoms": "1", "decimal": "0.00000000001"}, "nonce": "5" - }), + }) + .to_string(), ); }); @@ -324,10 +332,12 @@ async fn utxo_wire_key_is_utxo() { when.method(GET).path("/api/v2/address/mxtc1q/spendable-utxos"); respond( then, + 200, json!([{ "outpoint": {"source_id": "aabb", "index": 0}, "utxo": {"type": "Transfer"} - }]), + }]) + .to_string(), ); }); @@ -355,6 +365,7 @@ async fn unconfirmed_transaction_empty_string_fields() { when.method(GET).path("/api/v2/transaction/aabb"); respond( then, + 200, json!({ "id": "aabb", "inputs": [], @@ -362,7 +373,8 @@ async fn unconfirmed_transaction_empty_string_fields() { "block_id": "", "timestamp": "", "confirmations": "" - }), + }) + .to_string(), ); }); @@ -383,15 +395,11 @@ async fn fee_rate_param_handling() { when.method(GET) .path("/api/v2/feerate") .matches(|request| request.query_params.as_deref().unwrap_or(&[]).is_empty()); - then.status(200) - .header("content-type", "application/json") - .body(json!("12.5").to_string()); + respond(then, 200, json!("12.5").to_string()); }); let top_mock = server.mock(|when, then| { when.method(GET).path("/api/v2/feerate").query_param("in_top_x_mb", "5"); - then.status(200) - .header("content-type", "application/json") - .body(json!("500").to_string()); + respond(then, 200, json!("500").to_string()); }); let client = Client::new(server.url("")); @@ -409,7 +417,11 @@ async fn base_url_trailing_slash_normalized() { let server = MockServer::start(); let mock = server.mock(|when, then| { when.method(GET).path("/api/v2/chain/tip"); - respond(then, json!({"block_height": 1, "block_id": "0000ab"})); + respond( + then, + 200, + json!({"block_height": 1, "block_id": "0000ab"}).to_string(), + ); }); let client = Client::new(server.url("/") + "/"); diff --git a/tests/node.rs b/tests/node.rs index 7be0cca..6a6cde7 100644 --- a/tests/node.rs +++ b/tests/node.rs @@ -7,11 +7,13 @@ //! Wire-format tests for the node daemon client, mirroring the go-sdk //! `node/client_test.go` suite. +mod common; + use std::sync::Arc; use std::time::Duration; +use common::{mock_rpc, respond, rpc_error, rpc_ok, rpc_ok_no_id}; use httpmock::prelude::*; -use httpmock::{Mock, Then}; use serde_json::json; use mintlayer_sdk::node::{ @@ -19,35 +21,6 @@ use mintlayer_sdk::node::{ OutpointSourceId, TrustPolicy, }; -const RESPONSE_HEADERS: [(&str, &str); 1] = [("content-type", "application/json")]; - -fn respond(then: Then, body: serde_json::Value) { - let mut builder = then.status(200); - for (name, value) in RESPONSE_HEADERS { - builder = builder.header(name, value); - } - builder.body(body.to_string()); -} - -fn rpc_ok(id: u64, result: serde_json::Value) -> serde_json::Value { - json!({ "jsonrpc": "2.0", "id": id, "result": result }) -} - -fn rpc_ok_no_id(result: serde_json::Value) -> serde_json::Value { - json!({ "jsonrpc": "2.0", "result": result }) -} - -fn mock_rpc( - server: &MockServer, - request_fragment: String, - response: serde_json::Value, -) -> Mock<'_> { - server.mock(move |when, then| { - when.method(POST).path("/").body_contains(request_fragment); - respond(then, response); - }) -} - #[tokio::test] async fn chainstate_info_roundtrip() { let server = MockServer::start(); @@ -59,6 +32,7 @@ async fn chainstate_info_roundtrip() { .body_contains("\"params\":{}"); respond( then, + 200, rpc_ok( 1, json!({ @@ -158,11 +132,7 @@ async fn rpc_error_is_surfaced() { mock_rpc( &server, "\"jsonrpc\"".to_string(), - json!({ - "jsonrpc": "2.0", - "id": 1, - "error": { "code": -32601, "message": "Method not found" }, - }), + rpc_error(1, -32601, "Method not found"), ); let client = Client::new(server.url("/")); @@ -183,7 +153,7 @@ async fn basic_auth_header_is_sent() { let server = MockServer::start(); let mock = server.mock(|when, then| { when.method(POST).path("/").header("authorization", "Basic dXNlcjpwYXNz"); - respond(then, rpc_ok(1, json!(5))); + respond(then, 200, rpc_ok(1, json!(5))); }); let client = Client::builder(server.url("/")).basic_auth("user", "pass").build().unwrap(); @@ -210,7 +180,7 @@ async fn get_utxo_serializes_tagged_outpoint() { }) .to_string(), ); - respond(then, rpc_ok(1, json!({ "type": "Transfer" }))); + respond(then, 200, rpc_ok(1, json!({ "type": "Transfer" }))); }); let client = Client::new(server.url("/")); @@ -341,7 +311,7 @@ async fn submit_transaction_sends_trust_policy() { }) .to_string(), ); - respond(then, rpc_ok(1, json!(null))); + respond(then, 200, rpc_ok(1, json!(null))); }); let client = Client::new(server.url("/")); @@ -386,7 +356,7 @@ async fn oversized_responses_are_rejected() { let oversized = "0".repeat(65 * 1024 * 1024 + 10); server.mock(|when, then| { when.method(POST).path("/"); - then.status(200).header("content-type", "application/json").body(oversized); + respond(then, 200, oversized); }); let client = Client::new(server.url("/")); match client.best_block_height().await { diff --git a/tests/wallet.rs b/tests/wallet.rs index 44811de..62d924d 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -7,10 +7,12 @@ //! Wire-format tests for the wallet daemon client, mirroring the go-sdk //! `wallet/client_test.go` and `wallet/orders_test.go` suites. +mod common; + use std::sync::Arc; +use common::{mock_rpc, respond, rpc_error, rpc_ok}; use httpmock::prelude::*; -use httpmock::{Mock, Then}; use serde_json::json; use mintlayer_sdk::wallet::{ @@ -19,34 +21,9 @@ use mintlayer_sdk::wallet::{ RecoverWalletParams, SendParams, StakingStatus, TxOptions, UtxoSpendParams, }; -const RESPONSE_HEADERS: [(&str, &str); 1] = [("content-type", "application/json")]; - const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; -fn respond(then: Then, body: serde_json::Value) { - let mut builder = then.status(200); - for (name, value) in RESPONSE_HEADERS { - builder = builder.header(name, value); - } - builder.body(body.to_string()); -} - -fn rpc_ok(id: u64, result: serde_json::Value) -> serde_json::Value { - json!({ "jsonrpc": "2.0", "id": id, "result": result }) -} - -fn mock_rpc( - server: &MockServer, - request_fragment: String, - response: serde_json::Value, -) -> Mock<'_> { - server.mock(move |when, then| { - when.method(POST).path("/").body_contains(request_fragment); - respond(then, response); - }) -} - fn send_result() -> serde_json::Value { json!({ "tx_id": "f0f1f2", @@ -68,6 +45,7 @@ async fn create_wallet_returns_mnemonic() { .body_contains("\"store_seed_phrase\":true"); respond( then, + 200, rpc_ok( 1, json!({ @@ -106,7 +84,7 @@ async fn open_wallet_password_null_handling() { .body_contains("\"method\":\"wallet_open\"") .body_contains("\"path\":\"/tmp/wallet.dat\"") .body_contains("\"password\":null"); - respond(then, rpc_ok(1, json!(null))); + respond(then, 200, rpc_ok(1, json!(null))); }); let client = Client::new(server.url("/")); @@ -125,6 +103,7 @@ async fn balance_params_and_result() { .body_contains("\"with_locked\":null"); respond( then, + 200, rpc_ok( 1, json!({ @@ -173,7 +152,7 @@ async fn send_params_wire_shape() { && !body.contains("\"selected_utxos\":null") && body.contains("\"options\":{\"in_top_x_mb\":null,\"broadcast_to_mempool\":null}") }); - respond(then, rpc_ok(1, send_result())); + respond(then, 200, rpc_ok(1, send_result())); }); let client = Client::new(server.url("/")); @@ -204,7 +183,7 @@ async fn submit_transaction_hardcodes_trusted() { .body_contains("\"tx\":\"deadbeef\"") .body_contains("\"do_not_store\":true") .body_contains("\"options\":{\"trust_policy\":\"Trusted\"}"); - respond(then, rpc_ok(1, json!({ "tx_id": "aabb" }))); + respond(then, 200, rpc_ok(1, json!({ "tx_id": "aabb" }))); }); let client = Client::new(server.url("/")); @@ -307,6 +286,7 @@ async fn create_order_wire_shapes() { }); respond( then, + 200, rpc_ok( 1, json!({ @@ -345,6 +325,7 @@ async fn list_all_active_orders_null_filters() { .body_contains("\"give_currency\":null"); respond( then, + 200, rpc_ok( 1, json!([ @@ -394,7 +375,7 @@ async fn lock_supply_uses_account_index() { && body.contains("\"account_index\":3") && !body.contains("\"account\":") }); - respond(then, rpc_ok(1, send_result())); + respond(then, 200, rpc_ok(1, send_result())); }); let client = Client::new(server.url("/")); @@ -413,11 +394,7 @@ async fn rpc_error_is_surfaced() { mock_rpc( &server, "\"jsonrpc\"".to_string(), - json!({ - "jsonrpc": "2.0", - "id": 1, - "error": { "code": -32601, "message": "Method not found" }, - }), + rpc_error(1, -32601, "Method not found"), ); let client = Client::new(server.url("/")); From 1de1983e2c33b21bcc5963862480e9175fc6c11e Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 03:25:47 +0400 Subject: [PATCH 25/29] test: pin intent message and token-id vectors, tighten estimate and multisig-rejection assertions --- tests/crypto.rs | 50 +++++++- tests/indexer.rs | 309 +++++++++++++++++++++++++++++++++++++++++++++++ tests/node.rs | 75 ++++++++++++ tests/wallet.rs | 102 +++++++++++++++- 4 files changed, 532 insertions(+), 4 deletions(-) diff --git a/tests/crypto.rs b/tests/crypto.rs index 40b1188..b53b79a 100644 --- a/tests/crypto.rs +++ b/tests/crypto.rs @@ -449,6 +449,30 @@ fn get_token_id_from_inputs() { ); } +#[test] +fn token_id_fork_heights() { + let source_id = encode_outpoint_source_id(H256::from_slice(&[0u8; 32]), SourceId::Transaction); + let fake_input = encode_input_for_utxo(source_id, 0); + + let token_id_100k = get_token_id(std::slice::from_ref(&fake_input), 100_000, Network::Mainnet) + .expect("token id at height 100_000 must derive"); + let token_id_500k = get_token_id(std::slice::from_ref(&fake_input), 500_000, Network::Mainnet) + .expect("token id at height 500_000 must derive"); + + assert_eq!( + token_id_100k, "mmltk1ht59xvv2sdxz28txuwryy55yl5qq9tf9657kvnqulfy9a2g3csssee4n2n", + "pins the token id derived at height 100_000 (before the token-id-generation V1 fork at 517_700)" + ); + assert_eq!( + token_id_500k, "mmltk1ht59xvv2sdxz28txuwryy55yl5qq9tf9657kvnqulfy9a2g3csssee4n2n", + "pins the token id derived at height 500_000 (still before the token-id-generation V1 fork at 517_700)" + ); + assert_eq!( + token_id_100k, token_id_500k, + "both heights are pre-fork, so the token id scheme must not have changed between them" + ); +} + /// A mainnet VRF public key, taken from mintlayer-core's own address test /// vectors (`common/src/address/hexified.rs`), so that stake-pool creation can /// be exercised without deriving a VRF key (the SDK exposes no helper for it). @@ -612,6 +636,20 @@ fn intents_roundtrip() { ); } +#[test] +fn intent_message_vector_pin() { + let message = make_transaction_intent_message_to_sign( + "transfer", + "35a7938c2a2aad5ae324e7d0536de245bf9e439169aa3c16f1492be117e5d0e0", + ) + .expect("intent message must be produced"); + assert_eq!( + hex::encode(message.as_bytes()), + "3c74785f69643a333561373933386332613261616435616533323465376430353336646532343562663965343339313639616133633136663134393262653131376535643065303b696e74656e743a7472616e736665723e", + "pins the transaction-intent message format against accidental drift" + ); +} + #[test] fn partially_signed_transaction_roundtrip() { let (_, address, transaction, output) = fake_utxo_transaction(make_private_key()); @@ -845,9 +883,11 @@ fn htlc_refund_multisig_cumulative() { 500_000, network, ); + let error = result.expect_err("a standard UTXO witness must not seed an HTLC multisig refund"); assert!( - result.is_err(), - "a standard UTXO witness must not seed an HTLC multisig refund, got {result:?}" + matches!(error, Error::InputSigning(_)), + "a standard UTXO witness raw signature is not decodable as an HTLC spend, so the \ + rejection must surface as InputSigning from extract_htlc_spend, got {error:?}" ); assert!( @@ -923,6 +963,12 @@ fn estimate_transaction_size_happy_path() { "estimate must cover the witness signatures, not just the unsigned transaction" ); + let unsigned_len = transaction.encode().len(); + assert!( + estimate > unsigned_len + 60, + "estimate must include the per-input signature-size term (a schnorr pubkeyhash witness adds ~100 bytes), estimate={estimate}, unsigned={unsigned_len}" + ); + assert!( estimate_transaction_size(&[input], &[&address, "mtc1qfoo"], &[output], network).is_err(), "an unparsable destination must fail the estimate" diff --git a/tests/indexer.rs b/tests/indexer.rs index 09985fa..e460ff8 100644 --- a/tests/indexer.rs +++ b/tests/indexer.rs @@ -498,3 +498,312 @@ async fn error_body_is_sanitized() { sanitized_mock.assert(); truncated_mock.assert(); } + +#[tokio::test] +async fn orders_by_pair_path_and_validation() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api/v2/order/pair/ML_mmltk1abc") + .query_param("items", "5"); + respond( + then, + 200, + json!([{ + "order_id": "mordr1x", + "conclude_destination": "mtct1x", + "give_currency": {}, + "initially_given": {"atoms": "1", "decimal": "0.00000000001"}, + "give_balance": {"atoms": "1", "decimal": "0.00000000001"}, + "ask_currency": {}, + "initially_asked": {"atoms": "1", "decimal": "0.00000000001"}, + "ask_balance": {"atoms": "1", "decimal": "0.00000000001"}, + "nonce": "5" + }]) + .to_string(), + ); + }); + + let client = Client::new(server.url("")); + let orders = client + .orders_by_pair( + "ML", + "mmltk1abc", + PageOpts { + offset: 0, + items: 5, + }, + ) + .await + .unwrap(); + assert_eq!(orders.len(), 1); + assert_eq!(orders[0].order_id, "mordr1x"); + assert_eq!(orders[0].conclude_destination, "mtct1x"); + assert_eq!(orders[0].nonce, Uint64(5)); + assert_eq!( + orders[0].ask_balance, + Amount { + atoms: 1, + decimal: "0.00000000001".to_owned() + } + ); + mock.assert(); + + // A currency containing a slash would alter the request path and must be + // rejected locally, before any request is sent. + let err = client + .orders_by_pair( + "bad/slash", + "x", + PageOpts { + offset: 0, + items: 5, + }, + ) + .await + .unwrap_err(); + assert!( + matches!(err, Error::InvalidUrl { .. }), + "expected InvalidUrl, got {err:?}" + ); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn oversized_response_rejected() { + let server = MockServer::start(); + // A real oversized body: httpmock derives the content-length header from + // the body length (65 MiB + 10 > the 64 MiB cap), so the transport's + // content-length pre-check rejects the response before it is read. + let oversized = "0".repeat(65 * 1024 * 1024 + 10); + server.mock(|when, then| { + when.method(GET).path("/api/v2/chain/tip"); + respond(then, 200, oversized); + }); + + let client = Client::new(server.url("")); + match client.tip().await { + Err(Error::ResponseTooLarge { limit }) => { + assert_eq!(limit, 64 * 1024 * 1024); + } + other => panic!("expected ResponseTooLarge, got: {other:?}"), + } +} + +#[tokio::test] +async fn remaining_types_decode() { + let server = MockServer::start(); + + let stats_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/statistics/coin"); + respond( + then, + 200, + json!({ + "circulating_supply": {"atoms": "1000", "decimal": "0.00000001"}, + "preminted": {"atoms": "2000", "decimal": "0.00000002"}, + "burned": {"atoms": "3000", "decimal": "0.00000003"}, + "staked": {"atoms": "4000", "decimal": "0.00000004"}, + }) + .to_string(), + ); + }); + let token_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/token/mmltk1full"); + respond( + then, + 200, + json!({ + "authority": "mtc1qauth", + "is_locked": false, + "circulating_supply": {"atoms": "700", "decimal": "0.7"}, + "token_ticker": "MTK", + "metadata_uri": "https://example.com/token.json", + "number_of_decimals": 2, + "total_supply": {"type": "Lockable"}, + "frozen": false, + "is_token_freezable": true, + "next_nonce": "3" + }) + .to_string(), + ); + }); + let nft_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/nft/mmltk1nft"); + respond( + then, + 200, + json!({ + "owner": "mtc1qowner", + "token_id": "mmltk1nft", + "metadata": { + "creator": "02a1b2c3", + "name": "Genesis NFT", + "description": "The first NFT", + "ticker": "GNFT", + "icon_uri": null, + "additional_metadata_uri": null, + "media_uri": "https://example.com/media.png", + "media_hash": "aabb" + } + }) + .to_string(), + ); + }); + let merkle_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/transaction/aabb/merkle-path"); + respond( + then, + 200, + json!({ + "block_id": "0000aa", + "transaction_index": 2, + "merkle_root": "ccdd", + "path": ["eeff", "1122"] + }) + .to_string(), + ); + }); + let address_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/address/mxtc1q"); + respond( + then, + 200, + json!({ + "coin_balance": {"atoms": "1000", "decimal": "0.00000001"}, + "locked_coin_balance": {"atoms": "500", "decimal": "0.000000005"}, + "transaction_history": ["aabb"], + "tokens": [ + {"token_id": "mmltk1x", "amount": {"atoms": "700", "decimal": "0.7"}} + ] + }) + .to_string(), + ); + }); + let delegations_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/pool/mpool1x/delegations"); + respond( + then, + 200, + json!([{ + "delegation_id": "tdelg1x", + "next_nonce": "7", + "spend_destination": "mtct1x", + "balance": {"atoms": "500", "decimal": "0.000000005"}, + "creation_block_height": 10000 + }]) + .to_string(), + ); + }); + let token_txs_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/token/mmltk1x/transactions"); + respond( + then, + 200, + json!([{ "tx_global_index": 11, "tx_id": "aabb" }]).to_string(), + ); + }); + let tx_ids_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/block/0000aa/transaction-ids"); + respond(then, 200, json!(["aabb", "ccdd"]).to_string()); + }); + let reward_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/block/0000aa/reward"); + respond(then, 200, json!([{ "type": "Transfer" }]).to_string()); + }); + let genesis_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/chain/genesis"); + respond( + then, + 200, + json!({ + "block_id": "000000genesishash", + "genesis_message": "Mintlayer genesis", + "timestamp": {"timestamp": 1_700_000_000}, + "utxos": [] + }) + .to_string(), + ); + }); + let all_utxos_mock = server.mock(|when, then| { + when.method(GET).path("/api/v2/address/mxtc1q/all-utxos"); + respond( + then, + 200, + json!([{ + "outpoint": {"source_id": "aabb", "index": 1}, + "utxo": {"type": "Transfer"} + }]) + .to_string(), + ); + }); + + let client = Client::new(server.url("")); + + let stats = client.coin_statistics().await.unwrap(); + assert_eq!(stats.circulating_supply.atoms, 1000); + assert_eq!(stats.staked.atoms, 4000); + + let token = client.token("mmltk1full").await.unwrap(); + assert_eq!(token.token_ticker, "MTK"); + assert_eq!(token.is_token_freezable, Some(true)); + assert_eq!(token.next_nonce, Uint64(3)); + + let nft = client.nft("mmltk1nft").await.unwrap(); + assert_eq!(nft.owner, "mtc1qowner"); + assert_eq!(nft.metadata.name, "Genesis NFT"); + assert_eq!( + nft.metadata.media_uri.as_deref(), + Some("https://example.com/media.png") + ); + + let merkle = client.transaction_merkle_path("aabb").await.unwrap(); + assert_eq!(merkle.block_id, "0000aa"); + assert_eq!(merkle.transaction_index, 2); + assert_eq!(merkle.path, vec!["eeff".to_owned(), "1122".to_owned()]); + + let address = client.address_info("mxtc1q").await.unwrap(); + assert_eq!(address.coin_balance.atoms, 1000); + assert_eq!(address.tokens.len(), 1); + assert_eq!(address.tokens[0].token_id, "mmltk1x"); + assert_eq!(address.tokens[0].amount.atoms, 700); + + let delegations = client.pool_delegations("mpool1x").await.unwrap(); + assert_eq!(delegations.len(), 1); + assert_eq!(delegations[0].delegation_id, "tdelg1x"); + assert_eq!(delegations[0].creation_block_height, Uint64(10000)); + + let token_txs = client.token_transactions("mmltk1x", PageOpts::default()).await.unwrap(); + assert_eq!(token_txs.len(), 1); + assert_eq!(token_txs[0].tx_global_index, 11); + assert_eq!(token_txs[0].tx_id, "aabb"); + + assert_eq!( + client.block_transaction_ids("0000aa").await.unwrap(), + vec!["aabb".to_owned(), "ccdd".to_owned()] + ); + + let reward = client.block_reward("0000aa").await.unwrap(); + assert_eq!(reward, vec![json!({ "type": "Transfer" })]); + + let genesis = client.genesis().await.unwrap(); + assert_eq!(genesis.genesis_message, "Mintlayer genesis"); + assert_eq!(genesis.timestamp.timestamp, 1_700_000_000); + + let utxos = client.all_utxos("mxtc1q").await.unwrap(); + assert_eq!(utxos.len(), 1); + assert_eq!(utxos[0].outpoint.index, 1); + assert_eq!(utxos[0].output, json!({ "type": "Transfer" })); + + stats_mock.assert(); + token_mock.assert(); + nft_mock.assert(); + merkle_mock.assert(); + address_mock.assert(); + delegations_mock.assert(); + token_txs_mock.assert(); + tx_ids_mock.assert(); + reward_mock.assert(); + genesis_mock.assert(); + all_utxos_mock.assert(); +} diff --git a/tests/node.rs b/tests/node.rs index 6a6cde7..19488be 100644 --- a/tests/node.rs +++ b/tests/node.rs @@ -319,6 +319,81 @@ async fn submit_transaction_sends_trust_policy() { assert_eq!(mock.hits(), 1); } +#[tokio::test] +async fn broadcast_transaction_uses_p2p_method() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/").matches(|req| { + let body = + std::str::from_utf8(req.body.as_deref().unwrap_or_default()).unwrap_or_default(); + body.contains("\"method\":\"p2p_submit_transaction\"") + && body.contains("\"tx\":\"deadbeef\"") + && body.contains("\"trust_policy\":\"Trusted\"") + && !body.contains("\"method\":\"mempool_submit_transaction\"") + }); + respond(then, 200, rpc_ok(1, json!(null))); + }); + + let client = Client::new(server.url("/")); + client.broadcast_transaction("deadbeef", TrustPolicy::Trusted).await.unwrap(); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn connected_peers_and_mempool_tx_decode() { + let server = MockServer::start(); + let peers_mock = mock_rpc( + &server, + "\"method\":\"p2p_get_connected_peers\"".to_string(), + rpc_ok( + 1, + json!([{ + "peer_id": 7, + "address": "10.0.0.1:9333", + "peer_role": "OutboundFullRelay", + "ban_score": 0, + "user_agent": "mintlayer/1.3.0", + "software_version": "1.3.0", + "ping_wait": null, + "ping_last": 42, + "ping_min": 17, + "last_tip_block_time": 1_700_000_000, + }]), + ), + ); + let tx_mock = mock_rpc( + &server, + "\"method\":\"mempool_get_transaction\"".to_string(), + rpc_ok( + 2, + json!({ "id": "aabb", "status": "InMempool", "transaction": "deadbeef" }), + ), + ); + + let client = Client::new(server.url("/")); + let peers = client.connected_peers().await.unwrap(); + assert_eq!(peers.len(), 1); + let peer = &peers[0]; + assert_eq!(peer.peer_id, 7); + assert_eq!(peer.address, "10.0.0.1:9333"); + assert_eq!(peer.peer_role, "OutboundFullRelay"); + assert_eq!(peer.ban_score, 0); + assert_eq!(peer.user_agent, "mintlayer/1.3.0"); + assert_eq!(peer.software_version, "1.3.0"); + assert_eq!(peer.ping_wait, None); + assert_eq!(peer.ping_last, Some(42)); + assert_eq!(peer.ping_min, Some(17)); + assert_eq!(peer.last_tip_block_time, Some(1_700_000_000)); + + let tx = client.transaction("aabb").await.unwrap().expect("tx is in the mempool"); + assert_eq!(tx.id, "aabb"); + assert_eq!(tx.status, "InMempool"); + assert_eq!(tx.transaction, "deadbeef"); + + assert_eq!(peers_mock.hits(), 1); + assert_eq!(tx_mock.hits(), 1); +} + #[tokio::test] async fn ban_serializes_duration_as_tuple() { let server = MockServer::start(); diff --git a/tests/wallet.rs b/tests/wallet.rs index 62d924d..8c6eec7 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -17,8 +17,9 @@ use serde_json::json; use mintlayer_sdk::wallet::{ Amount, Client, ComposeParams, CreateOrderParams, CreateWalletParams, CurrencyFilter, Error, - ListOrdersParams, LockSupplyParams, Outpoint, OutpointSourceId, OutputValue, - RecoverWalletParams, SendParams, StakingStatus, TxOptions, UtxoSpendParams, + IssueTokenParams, ListOrdersParams, LockSupplyParams, Outpoint, OutpointSourceId, OutputValue, + RecoverWalletParams, SendParams, StakingStatus, TokenMetadata, TokenSendParams, TokenSupply, + TxOptions, UtxoSpendParams, }; const MNEMONIC: &str = @@ -552,3 +553,100 @@ async fn debug_output_redacts_htlc_secrets() { assert!(!compose_debug.contains("supersecret456")); assert!(compose_debug.contains("***")); } + +#[tokio::test] +async fn send_token_wire_shape() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/").matches(|req| { + let body = + std::str::from_utf8(req.body.as_deref().unwrap_or_default()).unwrap_or_default(); + body.contains("\"method\":\"token_send\"") + && body.contains("\"token_id\":\"mmltk1x\"") + && body.contains("\"address\":\"mtc1qy\"") + && body.contains("\"amount\":{\"atoms\":\"700\"}") + && !body.contains("\"selected_utxos\"") + }); + respond(then, 200, rpc_ok(1, send_result())); + }); + + let client = Client::new(server.url("/")); + let params = TokenSendParams { + account: 0, + token_id: "mmltk1x".to_string(), + address: "mtc1qy".to_string(), + amount: Amount::from_atoms(700), + options: TxOptions::default(), + }; + let result = client.send_token(params).await.unwrap(); + + assert_eq!(result.tx_id, "f0f1f2"); + assert_eq!(result.fees.coins.atoms(), Some(100)); + assert_eq!(result.fees.coins.decimal(), Some("0.000000001")); + assert!(result.fees.tokens.is_empty()); + assert!(result.broadcasted); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn issue_token_wire_shape_and_result() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/").matches(|req| { + let body = + std::str::from_utf8(req.body.as_deref().unwrap_or_default()).unwrap_or_default(); + body.contains("\"method\":\"token_issue_new\"") + && body.contains("\"token_ticker\":\"MTK\"") + && body.contains("\"token_supply\":{\"type\":\"Lockable\"}") + && body.contains("\"is_freezable\":false") + }); + respond( + then, + 200, + rpc_ok(1, json!({ "token_id": "mmltk1new", "tx_id": "aabb" })), + ); + }); + + let client = Client::new(server.url("/")); + let params = IssueTokenParams { + account: 0, + destination_address: "mtc1qauthority".to_string(), + metadata: TokenMetadata { + token_ticker: "MTK".to_string(), + number_of_decimals: 2, + metadata_uri: "https://example.com/token.json".to_string(), + token_supply: TokenSupply::Lockable, + is_freezable: false, + }, + options: TxOptions::default(), + }; + let result = client.issue_token(params).await.unwrap(); + + assert_eq!(result.token_id, "mmltk1new"); + assert_eq!(result.tx_id, "aabb"); + assert_eq!(mock.hits(), 1); +} + +#[tokio::test] +async fn token_supply_serde_forms() { + let fixed = serde_json::to_string(&TokenSupply::Fixed(Amount::from_atoms(5))).unwrap(); + assert!(fixed.contains("\"type\":\"Fixed\""), "got {fixed}"); + assert!(fixed.contains("\"atoms\":\"5\""), "got {fixed}"); + + assert_eq!( + serde_json::to_string(&TokenSupply::Lockable).unwrap(), + r#"{"type":"Lockable"}"# + ); + assert_eq!( + serde_json::to_string(&TokenSupply::Unlimited).unwrap(), + r#"{"type":"Unlimited"}"# + ); + + let fixed: TokenSupply = + serde_json::from_str(r#"{"type":"Fixed","content":{"atoms":"5"}}"#).unwrap(); + assert_eq!(fixed, TokenSupply::Fixed(Amount::from_atoms(5))); + let lockable: TokenSupply = serde_json::from_str(r#"{"type":"Lockable"}"#).unwrap(); + assert_eq!(lockable, TokenSupply::Lockable); + let unlimited: TokenSupply = serde_json::from_str(r#"{"type":"Unlimited"}"#).unwrap(); + assert_eq!(unlimited, TokenSupply::Unlimited); +} From b253e24432aef94c5230d0f6fa2911d9d44269cd Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 03:32:10 +0400 Subject: [PATCH 26/29] test: pairwise-distinctness assertions for input constructor encodings --- tests/crypto.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/crypto.rs b/tests/crypto.rs index b53b79a..e28d82f 100644 --- a/tests/crypto.rs +++ b/tests/crypto.rs @@ -555,6 +555,14 @@ fn input_constructors_encode() { ), ]; + let encoded: Vec<(&str, Vec)> = + cases.iter().map(|(name, build)| (*name, build().unwrap().encode())).collect(); + for (i, (name_a, bytes_a)) in encoded.iter().enumerate() { + for (name_b, bytes_b) in encoded.iter().skip(i + 1) { + assert_ne!(bytes_a, bytes_b, "{name_a} and {name_b} encode identically"); + } + } + for (name, build) in cases { let input = build().unwrap_or_else(|error| panic!("{name} must encode: {error}")); assert!( From d2fa942ce26e40a946ca6d2499a65bc7bcaed183 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 17:10:06 +0400 Subject: [PATCH 27/29] ci: run the test suite on linux, macOS and windows (mirrors core's build.yml) --- .github/workflows/ci.yml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54b0aaf..88ef851 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,29 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.92.0 - - run: cargo test ${{ matrix.features }} + - run: cargo test ${{ matrix.features }} --locked + + # Cross-platform test gate, mirroring mintlayer-core's build.yml: the full + # suite runs on every supported OS with the lockfile held fixed. + platform-tests: + name: Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Shorten CARGO_HOME on Windows + if: runner.os == 'Windows' + shell: bash + # The default path blows past the Windows MAX_PATH limit inside the + # mintlayer-core git checkouts (same reason mintlayer-core's own + # build.yml does this). + run: echo "CARGO_HOME=C:\crg" >> "$GITHUB_ENV" + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.92.0 + - run: cargo build --all-features --locked --examples + - run: cargo test --all-features --locked doc: name: Documentation From c1a6acd7d29527db31c72745ff0cb1ad318f9307 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 17:45:55 +0400 Subject: [PATCH 28/29] ci: re-trigger workflow runs From 9f9dfc5429cf6286928f1b14705e8770eba863c2 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Thu, 17 Sep 2026 18:25:58 +0400 Subject: [PATCH 29/29] ci: adopt hardened workflows from main --- .github/workflows/ci.yml | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88ef851..e857678 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,14 @@ name: CI on: push: branches: [main] + paths-ignore: [".github/**"] pull_request: branches: [main] + paths-ignore: [".github/**"] + +# The crate jobs never need more than read access. +permissions: + contents: read env: CARGO_TERM_COLOR: always @@ -13,9 +19,11 @@ jobs: fmt: name: Format runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + # Pinned to the MSRV so rustfmt matches the version clippy/test use. + - uses: dtolnay/rust-toolchain@ffaa7fb73f2b6e3c49bc425913220fa3d71c3ee5 # 1.92.0 with: components: rustfmt - run: cargo fmt --all --check @@ -23,27 +31,35 @@ jobs: clippy: name: Clippy (${{ matrix.features }}) runs-on: ubuntu-latest + timeout-minutes: 45 strategy: fail-fast: false matrix: features: ["--no-default-features", "--no-default-features --features node", "--no-default-features --features indexer", "--no-default-features --features wallet", "--no-default-features --features crypto", "--all-features"] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.92.0 + - uses: dtolnay/rust-toolchain@ffaa7fb73f2b6e3c49bc425913220fa3d71c3ee5 # 1.92.0 with: components: clippy - - run: cargo clippy ${{ matrix.features }} --all-targets -- -D warnings + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: ${{ matrix.features }} + - run: cargo clippy ${{ matrix.features }} --all-targets --locked -- -D warnings test: name: Test (${{ matrix.features }}) runs-on: ubuntu-latest + timeout-minutes: 45 strategy: fail-fast: false matrix: features: ["--no-default-features", "--no-default-features --features node", "--no-default-features --features indexer", "--no-default-features --features wallet", "--no-default-features --features crypto", "--all-features"] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.92.0 + - uses: dtolnay/rust-toolchain@ffaa7fb73f2b6e3c49bc425913220fa3d71c3ee5 # 1.92.0 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: ${{ matrix.features }} - run: cargo test ${{ matrix.features }} --locked # Cross-platform test gate, mirroring mintlayer-core's build.yml: the full @@ -55,6 +71,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 60 steps: - name: Shorten CARGO_HOME on Windows if: runner.os == 'Windows' @@ -64,23 +81,29 @@ jobs: # build.yml does this). run: echo "CARGO_HOME=C:\crg" >> "$GITHUB_ENV" - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.92.0 + - uses: dtolnay/rust-toolchain@ffaa7fb73f2b6e3c49bc425913220fa3d71c3ee5 # 1.92.0 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: ${{ matrix.os }} - run: cargo build --all-features --locked --examples - run: cargo test --all-features --locked doc: name: Documentation runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.92.0 - - run: cargo doc --all-features --no-deps + - uses: dtolnay/rust-toolchain@ffaa7fb73f2b6e3c49bc425913220fa3d71c3ee5 # 1.92.0 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - run: cargo doc --all-features --locked --no-deps env: RUSTDOCFLAGS: -D warnings license: name: License headers runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - run: ./scripts/check-license.sh