diff --git a/CLAUDE.md b/CLAUDE.md index 64b02db..f56a7df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,7 @@ Things to verify for each new endpoint: - **HTTP-noun → CLI-noun-verb**: `admin.get_endpoints` → `qn endpoint list`, `admin.show_endpoint` → `qn endpoint show `, `admin.update_endpoint_status(id, "paused")` → `qn endpoint pause ` (split the verb out, since the user thinks "pause", not "update status to paused"). - **Aliases**: every list-style command gets `#[command(visible_alias = "ls")]`. Plural top-level nouns get one too (`#[command(visible_alias = "endpoints")]`). - **Positional value names**: a field named `id` renders as an uninformative `` in help. Give every positional an explicit, resource-specific `#[arg(value_name = "ENDPOINT_ID")]` (uppercase, underscored: `STREAM_ID`, `WEBHOOK_ID`, `TEAM_ID`, …). Multi-word fields like `referrer_id` already render fine as ``. +- **Help examples**: every top-level noun's `Args` struct gets `#[command(after_help = "Examples:\n ...")]`; every verb that takes flags or positionals gets one too. Zero-arg verbs may skip it. Use `after_help` (not `after_long_help`) so examples show in both `-h` and `--help`. Style: `Examples:` header, 2-space indent, explicit `\` line continuations for wrapped commands (clap's `wrap_help` is on), canonical fake values (`payer` wallet, `ep-1`, `base-sepolia`), plain ASCII. **Every example is copy-pasteable on its own: show ALL the flags the command needs, every time.** Never show a shortened invocation that relies on config-supplied values, and never mention config fallbacks in example blocks (that includes README and context.md examples — the dedicated config sections document the fallback). No `...` ellipsis in place of flags. - **Hyphenation**: clap kebab-cases enum variants by default. `RateLimit` → `rate-limit`. Test invocations must use the kebab form (`qn endpoint rate-limit method-create`, not `ratelimit`). - **Negative numbers**: any `i64` flag that accepts `-1` (`--end`, etc.) needs `#[arg(long, allow_hyphen_values = true)]` or clap will read it as another flag. - **Multi-value flags**: prefer repeatable `--method foo --method bar` (clap `Vec` with `#[arg(long = "method")]`). Optionally also accept `--methods foo,bar` via a second field with `value_delimiter = ','`. The command body extends one into the other. diff --git a/Cargo.lock b/Cargo.lock index 2b8a411..e99a0ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,290 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloy-consensus" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ff0c4adba2abdcd9fb5829ae5f4394c06f8585ed283a9ba79aa33763c802e1" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-trie", + "alloy-tx-macros", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "k256 0.13.4", + "once_cell", + "secp256k1 0.30.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip2124" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "crc", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip2930" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eip7702" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "k256 0.13.4", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip7928" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b12337f74cbfa451cb04dac173974814a6ff463079e1793aa09600ba8813ab" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "once_cell", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eips" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea4c0453065b9206acc0f869a258dc8dcbbd595e144b4446f2c493a24a814d1f" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "serde", + "serde_with", + "sha2 0.10.9", +] + +[[package]] +name = "alloy-json-abi" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c36c9d7f9021601b04bfef14a4b64849f6d73116a4e91e071d7fbfe10247901" +dependencies = [ + "alloy-primitives", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-primitives" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itoa", + "k256 0.13.4", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.4", + "rapidhash", + "ruint", + "rustc-hash", + "secp256k1 0.31.1", + "serde", + "sha3 0.11.0", +] + +[[package]] +name = "alloy-rlp" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-rlp-derive" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "alloy-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e97b3e0b9f816b25083045dcfa69431bd059a078e828e4d82d296d1949b96c" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-sol-macro" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" +dependencies = [ + "alloy-sol-macro-expander", + "alloy-sol-macro-input", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "alloy-sol-macro-expander" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" +dependencies = [ + "alloy-json-abi", + "alloy-sol-macro-input", + "const-hex", + "heck", + "indexmap 2.14.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "sha3 0.11.0", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" +dependencies = [ + "alloy-json-abi", + "const-hex", + "dunce", + "heck", + "macro-string", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857b470ecdd2ed38beaf82ad1a38c516a8ff75266750f38b9eeed001d575241b" +dependencies = [ + "serde", + "winnow 1.0.3", +] + +[[package]] +name = "alloy-sol-types" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-macro", +] + +[[package]] +name = "alloy-trie" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "derive_more", + "nybbles", + "serde", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "alloy-tx-macros" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406bc1183f6843e0aba09f7b3365e828b597213d60793ba5cb41befc863e3a78" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -67,12 +351,281 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + [[package]] name = "arraydeque" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -106,7 +659,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -115,6 +668,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -143,12 +707,82 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" +dependencies = [ + "hex-conservative 0.3.2", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -158,6 +792,27 @@ dependencies = [ "serde_core", ] +[[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 = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -167,6 +822,60 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blst" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -184,11 +893,41 @@ 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.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "c-kzg" +version = "2.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d04308254695569fdb9bfe3bacc1c91837a670d0806605eb82d63748fbd3a6" +dependencies = [ + "blst", + "cc", + "glob", + "hex", + "libc", + "once_cell", + "serde", +] [[package]] name = "cc" @@ -214,6 +953,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -255,7 +1006,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -273,6 +1024,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -307,7 +1064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" dependencies = [ "async-trait", - "convert_case", + "convert_case 0.6.0", "json5", "pathdiff", "ron", @@ -344,6 +1101,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -364,6 +1145,27 @@ dependencies = [ "tiny-keccak", ] +[[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 = "convert_case" version = "0.6.0" @@ -373,6 +1175,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -389,6 +1200,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -398,6 +1215,30 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crossterm" version = "0.29.0" @@ -427,6 +1268,34 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -437,6 +1306,101 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + +[[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 0.10.7", + "fiat-crypto", + "rustc_version 0.4.1", + "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.117", +] + +[[package]] +name = "curve25519-dalek-ng" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c359b7249347e46fb28804470d071c921156ad62b3eef5d34e2ba867533dec8" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.6.4", + "subtle-ng", + "zeroize", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -455,6 +1419,26 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -462,6 +1446,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -483,14 +1502,37 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -501,7 +1543,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -529,18 +1571,167 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "encode_unicode" -version = "1.0.0" +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.1", + "digest 0.11.3", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-consensus" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8465edc8ee7436ffea81d21a019b16676ee3db267aa8d5a8d729581ecf998b" +dependencies = [ + "curve25519-dalek-ng", + "hex", + "rand_core 0.6.4", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array", + "group 0.13.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", + "subtle", + "zeroize", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "enum-ordinalize-derive" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" dependencies = [ - "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -576,12 +1767,72 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[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.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.7", + "rustc-hex", + "static_assertions", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -624,6 +1875,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.32" @@ -680,7 +1937,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -720,6 +1977,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -758,10 +2016,39 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.4.14" @@ -774,13 +2061,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -810,6 +2103,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] [[package]] name = "hashlink" @@ -832,6 +2130,48 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.4.1" @@ -883,6 +2223,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "1.10.0" @@ -918,6 +2269,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -943,6 +2295,30 @@ dependencies = [ "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.2.0" @@ -1031,6 +2407,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[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" @@ -1052,6 +2434,37 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[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.117", +] + +[[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.0" @@ -1089,6 +2502,24 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1120,9 +2551,9 @@ checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ "proc-macro2", "quote", - "rustc_version", + "rustc_version 0.4.1", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -1141,7 +2572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1177,6 +2608,78 @@ dependencies = [ "serde", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "once_cell", + "sha2 0.10.9", +] + +[[package]] +name = "k256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f50113171a713f4a4231ef82eb26703607139b35dcb56241f0ceab2ae1f7d8" +dependencies = [ + "cpubits", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "primeorder 0.14.0", + "sha2 0.11.0", + "signature 3.0.0", + "wnaf", +] + +[[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 = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "keccak-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + +[[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 = "lazy_static" version = "1.5.0" @@ -1195,6 +2698,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libyml" version = "0.0.5" @@ -1244,6 +2753,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "memchr" version = "2.8.1" @@ -1267,12 +2787,31 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[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-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1280,6 +2819,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1292,6 +2832,20 @@ dependencies = [ "libc", ] +[[package]] +name = "nybbles" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" +dependencies = [ + "alloy-rlp", + "cfg-if", + "proptest", + "ruint", + "serde", + "smallvec", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1304,6 +2858,12 @@ 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 = "openssl-probe" version = "0.2.1" @@ -1316,8 +2876,48 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" dependencies = [ - "dlv-list", - "hashbrown 0.14.5", + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +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 = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1343,6 +2943,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -1385,7 +2991,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1395,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1404,6 +3010,26 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1465,7 +3091,85 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve 0.14.1", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1477,6 +3181,37 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quicknode-cli" version = "0.5.0" @@ -1491,12 +3226,13 @@ dependencies = [ "humantime", "insta", "predicates", + "qrcode", "quicknode-sdk", "reqwest 0.12.28", "serde", "serde_json", "serde_yml", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "time", @@ -1510,14 +3246,24 @@ dependencies = [ [[package]] name = "quicknode-sdk" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d7fd1e431f62f6f82adca349756b31a07e082446c0664a81530e4a791c62ca" dependencies = [ + "alloy-consensus", + "alloy-primitives", + "alloy-rlp", + "base64", + "bs58", "config", + "ed25519-dalek", + "hex", + "k256 0.14.0", + "rand 0.8.7", "reqwest 0.13.4", "secrecy", "serde", "serde_json", + "sha2 0.10.9", + "sha3 0.10.9", + "tempo-primitives", "thiserror 1.0.69", "tokio", "url", @@ -1553,7 +3299,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1600,14 +3346,42 @@ 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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "serde", +] + +[[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]] @@ -1617,7 +3391,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[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]] @@ -1627,6 +3410,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", + "serde", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", ] [[package]] @@ -1638,6 +3446,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -1680,16 +3508,21 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "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", @@ -1697,6 +3530,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -1738,6 +3572,26 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.5", + "hmac 0.13.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -1752,6 +3606,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + [[package]] name = "ron" version = "0.12.1" @@ -1766,6 +3630,41 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ruint" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45caf26f647c19115bf9c453c70ffe4a4a3a6390dceebd942610584f99b8ddce" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "ark-ff 0.6.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.7", + "rand 0.9.4", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + [[package]] name = "rust-ini" version = "0.21.3" @@ -1782,13 +3681,28 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[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.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver", + "semver 1.0.28", ] [[package]] @@ -1812,6 +3726,7 @@ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1880,40 +3795,144 @@ dependencies = [ ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.1", + "hybrid-array", + "subtle", + "zeroize", +] [[package]] -name = "ryu" -version = "1.0.23" +name = "secp256k1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.7", + "secp256k1-sys 0.10.1", +] [[package]] -name = "same-file" -version = "1.0.6" +name = "secp256k1" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ - "winapi-util", + "bitcoin_hashes", + "rand 0.9.4", + "secp256k1-sys 0.11.0", ] [[package]] -name = "schannel" -version = "0.1.29" +name = "secp256k1-sys" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" dependencies = [ - "windows-sys 0.61.2", + "cc", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "secp256k1-sys" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] [[package]] name = "secrecy" @@ -1947,12 +3966,30 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + [[package]] name = "serde" version = "1.0.228" @@ -1992,7 +4029,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2001,7 +4038,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -2039,13 +4076,45 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_yml" version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "libyml", "memchr", @@ -2054,6 +4123,29 @@ dependencies = [ "version_check", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2061,8 +4153,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", +] + +[[package]] +name = "sha3-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" +dependencies = [ + "cc", + "cfg-if", ] [[package]] @@ -2077,13 +4210,33 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd_cesu8" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" dependencies = [ - "rustc_version", + "rustc_version 0.4.1", "simdutf8", ] @@ -2121,12 +4274,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.1", +] + [[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 = "strsim" version = "0.11.1" @@ -2139,6 +4318,23 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + +[[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.117" @@ -2150,6 +4346,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn-solidity" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2167,9 +4375,15 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.27.0" @@ -2183,6 +4397,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tempo-contracts" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a163690a85ff88c8cb98b42490fa95a33b114d6d90c653b7be2c9f794f7b2ab9" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", +] + +[[package]] +name = "tempo-primitives" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63f9919c82a4c68531609cd015994e1a81c48d4c84eeaa92a9d86651a0e6ec41" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "base64", + "derive_more", + "ed25519-consensus", + "once_cell", + "p256", + "serde", + "serde_json", + "sha2 0.10.9", + "tempo-contracts", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -2225,7 +4470,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2236,7 +4481,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", ] [[package]] @@ -2327,7 +4581,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2362,7 +4616,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -2402,7 +4656,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -2410,6 +4664,18 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -2431,7 +4697,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f89570c1a68d73941f728cca32a4345b2ffca36667ad921af336c60309a3e7e" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_json", "thiserror 2.0.18", @@ -2525,6 +4791,24 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2579,6 +4863,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -2679,7 +4969,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2709,7 +4999,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -2722,8 +5012,8 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", - "semver", + "indexmap 2.14.0", + "semver 1.0.28", ] [[package]] @@ -2755,6 +5045,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2786,12 +5085,65 @@ 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.117", +] + +[[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.117", +] + [[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.52.0" @@ -2958,9 +5310,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2976,7 +5328,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2989,7 +5341,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -3008,9 +5360,9 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", - "semver", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -3018,12 +5370,32 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wnaf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", +] + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yaml-rust2" version = "0.11.0" @@ -3054,7 +5426,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3075,7 +5447,7 @@ checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3095,7 +5467,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3104,6 +5476,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +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.117", +] [[package]] name = "zerotrie" @@ -3135,7 +5521,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5738ac6..ee7bb07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,14 @@ name = "qn" path = "src/lib.rs" [dependencies] -quicknode-sdk = "0.7" +# DEV ONLY: path dep on the SDK's unreleased crypto-micropayment branch. +# Swap back to a published crates.io version (with the same features) before +# any release tag — the release is gated on the SDK publishing this feature. +quicknode-sdk = { path = "../sdk/crates/core", features = [ + "payments", # x402/EVM + "payments-svm", # + x402/Solana + "payments-tempo", # + MPP/Tempo +] } clap = { version = "4", features = ["derive", "env", "wrap_help"] } clap_complete = "4" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } @@ -42,6 +49,14 @@ url = "2" # Stable fingerprint of the API key for scoping the JWT token cache to an # account. The key itself is never written to the cache — only this hash. sha2 = "0.10" +qrcode = { version = "0.14", default-features = false } +# Direct HTTP for the keyless payable-networks discovery fetch (the paid +# gateways' public /networks + /discovery/resources endpoints, which the SDK +# sub-clients don't cover). rustls matches the SDK's TLS backend. +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", +] } [dev-dependencies] reqwest = { version = "0.12", default-features = false } diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..369162a --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,48 @@ +# Implementation plan: x402 drawdown + MPP session + +Source of truth for the UX design: +`~/.claude/plans/research-quicknode-gateway-payment-vast-babbage.md`. +This file tracks execution status only; do not re-litigate the 14 decisions. + +Branch `x402_MPP`, one PR, both models, phased commits (x402 drawdown first, +then MPP session). Do NOT commit this file (public repo). + +Pre-stage (done): popped stash and committed the per-request `--payment-*` +flag rename + asset-name resolution. Plan naming is now real. + +## Stage 1: SDK x402 drawdown +**Goal**: SIWX auth (POST /auth), JWT seed/export, drawdown call (Bearer, no +signing), GET /credits, POST /drip, credit purchase via existing 402 signer. +New `PaymentScheme` variant(s) in `../sdk/crates/core/src/rpc/payment/mod.rs`. +**Success criteria**: SDK wiremock unit tests; per-request paths untouched. +**Status**: Complete (SDK commit e6416f4) + +## Stage 2: CLI `qn rpc x402` noun +**Goal**: buy-credits/balance/drip in `src/commands/rpc/x402.rs`; JWT cache +(0600, wallet-address keyed); Mild gating w/ ceiling-naming prompt; next hints. +**Success criteria**: happy + error + both gating tests in tests/rpc_payment.rs. +**Status**: Complete (CLI commit 21ef1b1) + +## Stage 3: CLI `--x402-drawdown` on call +**Goal**: ArgGroup member; auto re-auth; error mapping (empty credits, monthly +limit). context.md + README in same commits. +**Success criteria**: wiremock tests incl. expired-JWT re-auth; single-attempt. +**Status**: Complete (CLI commit 7b11106) + +## Stage 4: SDK MPP session +**Goal**: escrow deposit tx (Tempo first), open/top-up/close/status, cumulative +EIP-712 voucher signer, `/session/:network` prefix via `host_base`. +**Success criteria**: SDK tests; charge-intent path untouched. +**Status**: Complete (SDK commit 6da9adc; byte-exact voucher+channelId vectors) + +## Stage 5: CLI `qn rpc mpp` noun + `--mpp-session` on call +**Goal**: open/top-up/close/status in `src/commands/rpc/mpp.rs`; channel state +file + recovery-via-status; Mild gating incl. close; voucher call mode. +**Success criteria**: full test matrix; snapshot review for table output. +**Status**: Complete (SDK 06b5bc4; CLI ab0c188) + +## Stage 6: README Micropayments refactor + docs polish +**Goal**: one Micropayments section, four zero-to-call walkthroughs + shared +Get-a-wallet preamble; call --help examples; deferral notes. +**Success criteria**: all four walkthroughs copy-pasteable; full verify clean. +**Status**: In Progress diff --git a/README.md b/README.md index dced139..3bfca7a 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,211 @@ API key, so subsequent calls skip the mint round trip while it's valid. Results schemaless JSON; `-o json|yaml|toon` controls the format (`table`/`md` fall back to JSON). +#### Micropayments + +Pay for RPC with crypto instead of an account API key — no login, no Tooling +Access. There are four ways to pay, all through the same wallet and flags: + +| Path | Flag / verbs | When | +| --- | --- | --- | +| x402 per-request | `--x402` | One-off calls; sign a payment each call. | +| MPP per-request | `--mpp` | One-off calls on Tempo; settlement receipt per call. | +| x402 drawdown | `qn rpc x402` + `--x402-drawdown` | Buy prepaid credits once, then spend them. | +| MPP session | `qn rpc mpp` + `--mpp-session` | Open a channel once, then pay per call with a voucher. | + +**This moves real funds** (testnet tokens are still real transfers): use a +dedicated, minimally funded wallet. Every paid call is single-attempt — +`--retries` never applies (a retried payment could double-charge). + +Each walkthrough below is complete on its own, testnet-first. + +##### Get a wallet + +Every path needs a payment wallet. Two options: + +1. **Generate one locally** (stored at `0600` under `~/.config/qn/wallets/`, + referenced by name with `--payment-wallet`): + + ```sh + qn wallet generate --vm evm --name payer # evm also covers MPP/Tempo + qn wallet generate --vm svm --name sol-payer # svm for x402/Solana + ``` + + `generate` prints the address (and a QR on a terminal) to fund. The key is + stored unencrypted — treat each wallet as a dedicated, minimally funded hot + wallet. **Quicknode does not hold, back up, or recover it**; backing up the + key file is your responsibility. + +2. **Bring your own key** — point `--payment-key-file ` at a file holding + the raw key (EVM/Tempo hex, Solana base58), or set `key_file = ""` + under `[rpc.payment]`. The key comes only from a file or a stored wallet — + never an environment variable, never a flag value, never inline in config, + never printed. + +Each gateway exposes two discovery lists, no API key needed. `qn rpc +{x402,mpp} supported-networks` (alias `networks`) shows the networks you can +make paid calls to — each slug is a valid `--network`. `qn rpc {x402,mpp} +supported-payments` (alias `payments`) shows the payment options the gateway +accepts — each row's network/address pair is a ready +`--payment-network`/`--payment-asset`. Both are cached at +`~/.config/qn/pay-networks.toml` (24h). + +##### Path 1 — x402 per-request + +Sign an x402 payment on each call (EVM or Solana stablecoin): + +```sh +qn wallet generate --vm evm --name payer # fund the printed address with Base Sepolia USDC +qn rpc call eth_blockNumber \ + --network ethereum-mainnet --x402 \ + --payment-wallet payer \ + --payment-network base-sepolia \ + --payment-asset USDC \ + --max-amount 1000 +``` + +`--network` is the chain you *query*; `--payment-network` is the chain the +payment *settles* on (independent — above, testnet USDC pays for a mainnet +query). `--max-amount` is the per-call ceiling in +integer base units (e.g. `1000` = 0.001 USDC); an offer above it is refused +before anything is signed. For Solana, generate a `--vm svm` wallet and use +`--network solana-devnet --payment-network solana-devnet` (add `--svm-rpc-url` +at volume; the public default rate-limits). + +##### Path 2 — MPP per-request (charge) + +The same as path 1, paying on Tempo via the MPP gateway. The EVM wallet works +(MPP uses the same secp256k1 key format); `--receipt` wraps the result with +the settlement transaction hash: + +```sh +qn wallet generate --vm evm --name payer # fund on Tempo testnet +qn rpc call eth_blockNumber \ + --network ethereum-mainnet --mpp --receipt \ + --payment-wallet payer \ + --payment-network tempo-testnet \ + --payment-asset USDC \ + --max-amount 1000 +``` + +`--receipt` wraps stdout as `{"result": ..., "payment_receipt": ...}` (the +receipt carries `method`/`status`/`timestamp`/`reference`); without it, paid +output is shaped exactly like an unpaid call. On x402 the receipt is `null`. + +##### Path 3 — x402 drawdown (buy credits, then call) + +Buy a block of prepaid credits once, then spend them with no per-call signing +(one credit per successful response): + +```sh +qn wallet generate --vm evm --name payer # dedicated wallet + +# Testnet only: fund the wallet from the faucet (Base Sepolia, once per +# account). Prints the funding tx; mainnet wallets are funded normally. +qn rpc x402 drip --payment-wallet payer --payment-network base-sepolia + +# Buy prepaid credits with the funded wallet (moves real funds; gated — pass +# --yes to skip the prompt). +qn rpc x402 buy-credits --network ethereum-mainnet --payment-wallet payer \ + --payment-network base-sepolia --payment-asset USDC --max-amount 10000000 + +# Check the balance (prints the bare number). +qn rpc x402 balance --payment-wallet payer --payment-network base-sepolia + +# Spend credits on calls: 1 credit per call, no per-call payment, so only +# the wallet is needed — no asset or spend ceiling. Credits are not +# network-scoped: query any supported network, not just the one you paid on. +qn rpc call eth_blockNumber --network ethereum-mainnet --x402-drawdown --payment-wallet payer +``` + +The gateway session (a JWT) is authenticated once and cached (0600) under the +config dir, refreshed automatically. Out of credits points you back at +`qn rpc x402 buy-credits`. + +##### Path 4 — MPP session (open a channel, then call) + +Open an on-chain escrow payment channel once, then pay per call with a +cumulative EIP-712 voucher (no on-chain transaction per call): + +```sh +qn wallet generate --vm evm --name payer # evm covers Tempo; fund the address + +# Open a channel by depositing into the escrow (moves real funds; gated). +qn rpc mpp open --network tempo-testnet --deposit 1000000 --max-amount 1000000 \ + --payment-wallet payer --payment-network tempo-testnet --payment-asset USDC + +# Pay for calls from the channel — one cumulative voucher per call. +qn rpc call eth_blockNumber --network tempo-testnet --mpp-session \ + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset USDC --max-amount 1000000 + +# Inspect the channel (re-syncs the accepted spend from the gateway). +qn rpc mpp status --network tempo-testnet --payment-wallet payer \ + --payment-network tempo-testnet --payment-asset USDC --max-amount 1000000 + +# Add more deposit, or close to settle on-chain and refund the unused balance. +qn rpc mpp top-up --network tempo-testnet --deposit 1000000 \ + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset USDC --max-amount 1000000 +qn rpc mpp close --network tempo-testnet --payment-wallet payer \ + --payment-network tempo-testnet --payment-asset USDC --max-amount 1000000 +``` + +Channel state is cached (0600) under the config dir, keyed by wallet + network. +Exhausting the deposit points you at `qn rpc mpp top-up`; after `close`, open a +new channel to keep paying by session. + +##### Shared flags, config, and wallet management + +The flag stack is the same across all four paths: + +- `--payment-network` takes a Quicknode network name (`base-sepolia`, + `solana-devnet`, `tempo-testnet`, ...) or a raw CAIP-2 id (`eip155:84532`, + `solana:EtWTRA...`); anything with a `:` passes through verbatim. +- `--payment-asset` takes a token address (EVM), a mint (Solana), or a symbol + like `USDC` resolved to that network's address. +- `--max-amount` is the per-signature spend ceiling in integer base units; + offers/deposits above it are refused before anything is signed. +- Exit code 2 means the gateway refused and nothing settled; exit 3 means the + outcome is unknown (payment submitted, may have settled — check the wallet + before re-running). + +Store the parameters once in `~/.config/qn/config.toml` and the per-request +invocation shrinks to just the scheme flag (config supplies values but never +activates payment by itself): + +```toml +[rpc.payment] +wallet = "payer" # a stored wallet name (or key_file = "") +max_amount = "10000" +payment_network = "base-sepolia" # network name or CAIP-2 id +payment_asset = "USDC" # symbol (resolved per network), or a raw address/mint +``` + +```sh +qn rpc call eth_blockNumber --network ethereum-mainnet --x402 +``` + +Manage stored wallets with the top-level `qn wallet` noun (see +[Wallets](#wallets)). + +### Wallets + +`qn wallet` (alias `wallets`) manages the local store of payment wallets the +paid RPC lane uses (see [Micropayments](#micropayments)). It needs no API key +or login: + +```sh +qn wallet generate --vm evm --name payer # create + store; prints the address and a QR to fund +qn wallet list # names, vm, address (never the key) +qn wallet show payer # bare address to stdout; QR + key path to stderr +qn wallet rm payer # gated: --yes to confirm; destroys the local key +``` + +Keys are stored unencrypted at `0600` under `~/.config/qn/wallets/`. Treat each +wallet as a dedicated, minimally funded hot wallet: it lives only on this +machine, and Quicknode does not hold, back up, or recover it. + ### Other ```sh diff --git a/src/cli.rs b/src/cli.rs index 1cf73f3..f8f62eb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -155,9 +155,13 @@ pub enum Command { /// Run SQL queries and inspect cluster schemas. Sql(commands::sql::Args), - /// Make JSON-RPC calls against your Tooling Access endpoint. + /// Make RPC calls. Rpc(commands::rpc::Args), + /// Manage local payment wallets for the paid RPC lane (`--x402`/`--mpp`). + #[command(visible_alias = "wallets")] + Wallet(commands::wallet::Args), + /// Manage Tooling Access (the endpoint `qn rpc` uses). #[command(name = "tooling-access")] ToolingAccess(commands::tooling_access::Args), @@ -256,6 +260,11 @@ impl Cli { // rpc builds its own Ctx (it seeds the SDK from the on-disk token // cache before construction), so it takes `global` directly. Command::Rpc(args) => commands::rpc::run(args, global).await, + // wallet manages local key files only — no API key, no payment + // lane — so it uses the keyless Ctx constructor. + Command::Wallet(args) => { + commands::wallet::run(args, Ctx::from_global_keyless(global)?).await + } Command::ToolingAccess(args) => { commands::tooling_access::run(args, Ctx::from_global(global)?).await } diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index f6cf74a..211cd1b 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -61,6 +61,16 @@ Branch on these — especially **4** and **5**. | 5 | Cancelled, or confirmation required and not granted (see §4). | | 130 | Interrupted (SIGINT). | +On a **per-request paid** `rpc call` (`--x402`/`--mpp`, §6) the 2/3 split carries +payment semantics: **2** means the gateway refused and nothing settled (an +unmatched or unreadable offer, or a 4xx-refused payment), while **3** means the +outcome is unknown — the payment was submitted and **may have been charged** (a +gateway 5xx after the paid resend, a lost response, or an uninterpretable +post-payment response). On exit 3, check the wallet before re-running; never +blind-retry a paid call. A **drawdown** call (`--x402-drawdown`) spends prepaid +credits, not per-call funds: running out surfaces an actionable exit-1 error +pointing at `qn rpc x402 buy-credits`, and a credit is drawn only on success. + ## 4. Non-interactive & confirmation behavior Destructive commands are gated. On a TTY they prompt `y/N`. To proceed without a @@ -96,6 +106,13 @@ if you need it. nothing — it is read-only and safe to retry. - `qn sql query` is read-only but **does not auto-retry**: a query consumes credits, so a retried query re-bills. `qn sql schema` is a cheap read and retries normally. +- A **paid** `rpc call` (`--x402`/`--mpp`/`--x402-drawdown`/`--mpp-session`) + never auto-retries — `--retries` does not apply. A per-request attempt can + move funds, and after a lost response the previous attempt may already have + settled (§3, exit 3). A drawdown call draws 1 credit per success and is + single-attempt (the one exception is a transparent re-auth when the session + token expired, which draws nothing). A session call signs one cumulative + voucher and is single-attempt. ## 6. Command catalog @@ -128,6 +145,82 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase available keys. Custom endpoint: `--endpoint-url ` (or `[rpc] endpoint_url` in config) sends the call to a fully-formed HTTP URL that authenticates itself (no token minted); it's mutually exclusive with `--network`. + **Paid lane**: `--x402` (EVM/Solana stablecoin) or `--mpp` (Tempo) pays for + the call per request with a crypto micropayment instead of an API key — no + login, no Tooling Access. Requires `--network` as the payment gateway's path + slug (e.g. `base-sepolia`; NOT validated by `list-networks`). Parameters: + `--payment-network ` (the chain the payment settles on — independent of + `--network`; a network name like `base-sepolia`/`solana-devnet`/`tempo-testnet`, + or a raw CAIP-2 id like `eip155:84532`, which always passes through + verbatim), `--payment-asset
` (a token contract/mint, or the symbol + `USDC`, resolved to that network's address), `--max-amount ` (spend ceiling + per call, integer base units, no default), and the private key via + `--payment-key-file ` > `--payment-wallet ` > `key_file` > + `wallet` under `[rpc.payment]` in config. The key always comes from a file + or a stored wallet — never an env var and never a raw key on the command + line. All parameters fall back to `[rpc.payment]`, but config never + activates payment by itself: the scheme flag is always required. `--receipt` + wraps stdout as `{"result": ..., "payment_receipt": ...}` (on MPP an object + whose `reference` is the settlement tx hash; `null` on x402); without it the + paid output shape is identical to an unpaid call. + Mutually exclusive with `--endpoint-url`. + **Wallets**: stored payment wallets are managed by the top-level `qn wallet` + noun (see below); reference one on a paid call with `--payment-wallet `. + **Discovery** (per gateway, no API key): `qn rpc {x402,mpp} + supported-networks` (alias `networks`) lists the networks you can make paid + calls to — each slug is a valid `--network`. `qn rpc {x402,mpp} + supported-payments` (alias `payments`) lists the payment options the gateway + accepts — each row's network/address pair is a ready + `--payment-network`/`--payment-asset`. + **x402 drawdown**: `qn rpc x402 {buy-credits, balance, drip}` manages prepaid + gateway credits instead of paying per request, and `qn rpc call + --x402-drawdown` spends them (no per-call signing, 1 credit per successful + response). `buy-credits` signs a payment, so it takes the full paid-lane + stack (`--payment-wallet`/`-key-file`, `--payment-network`, `--payment-asset`, + `--max-amount`, `--svm-rpc-url`); `balance` and `drip` only present a Bearer + session JWT and sign nothing, so they take just the wallet + + `--payment-network` (+ `--svm-rpc-url` for Solana) and do NOT accept + `--payment-asset`/`--max-amount`. All fall back to `[rpc.payment]`. + `--x402-drawdown` requires `--network` (the query chain) and a wallet only — + it signs nothing per call, so `--payment-asset`/`--max-amount` aren't needed + and the pay network defaults to `--network`; mutually exclusive with + `--x402`/`--mpp`/`--endpoint-url`. `buy-credits` takes `--network` (the + gateway path chain the purchase settles on), SIWX-authenticates, then pays the + gateway's credit offer (gated Mild — `--yes`, or exit 5 in scripts — and names + the spend ceiling); `balance` (alias + `credits`) prints the current credit count (bare number, or the full envelope + with `--format json`); `drip` funds the wallet from the testnet faucet + (Base Sepolia, returns the funding tx, not credits; once per account). The + session JWT is authenticated once and cached under + `/qn/sessions.toml` (0600, keyed by wallet address); a + missing/expired session re-authenticates transparently (free, no + confirmation), including one automatic re-auth if a drawdown call's token + expired mid-use. Out of credits surfaces an actionable error pointing at + `qn rpc x402 buy-credits`; nothing auto-retries a credit-drawing call. + **MPP session**: `qn rpc mpp {open, top-up, close, status}` manages an + on-chain escrow payment channel (Tempo), and `qn rpc call --mpp-session` pays + from it with a cumulative EIP-712 voucher (no on-chain tx per call). All take + the same payment param stack (a Tempo wallet) plus `--network` (the query + chain the channel lives on). `open --deposit ` and + `top-up --deposit ` move real funds on-chain (gated Mild); `close` + cooperatively settles + refunds (gated Mild; its prompt warns further + `--mpp-session` calls fail until re-open); `status` shows the gateway's view + and re-syncs the accepted spend into the local record. Channel state is cached + under `/qn/channels.toml` (0600, keyed by wallet address + + network); every channel verb needs that record — a lost one means opening a + new channel. `--mpp-session` requires an open channel, is mutually exclusive with + `--x402`/`--mpp`/`--x402-drawdown`/`--endpoint-url`, and points at + `qn rpc mpp top-up` when the channel deposit is exhausted. +- `wallet` — the local store of payment wallets for the paid RPC lane; no API + key or login required. `qn wallet generate --vm --name ` + creates and stores a dedicated payment wallet (raw key at 0600 under + `/qn/wallets/`, `evm` also covers MPP/Tempo), printing its + address (and a QR to fund it on a terminal); `qn wallet list`/`show ` + display stored wallets (address only, never the key) and the key file path; + `qn wallet rm ` deletes one (gated: `--yes`, or exit 5 in scripts). + Reference a wallet on a paid call with `--payment-wallet `. These + wallets live only on this machine — Quicknode does not hold, back up, or + recover them; backing up the key file is the user's job. Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`, `qn endpoint rate-limit --help`. Shell completions: `qn completions `. @@ -197,9 +290,58 @@ qn rpc call eth_blockNumber --endpoint-url https://my-endpoint.example/rpc is enabling Tooling Access (or pass `--yes` to enable on first use). A custom `--endpoint-url` (or `[rpc] endpoint_url` in config) bypasses that entirely. +**Pay per call with a crypto micropayment (no API key, no login):** + +`--payment-asset`/`--max-amount` must match a real offer for the network (see +`qn rpc x402 supported-payments` / `qn rpc mpp supported-payments`); an +unfunded wallet or a mismatched asset/amount is refused with HTTP 400/402 +before anything settles. + +```sh +qn rpc x402 supported-networks # networks you can call (mpp: qn rpc mpp supported-networks) +qn rpc x402 supported-payments # payment options: network, token, address (mpp: qn rpc mpp supported-payments) +qn wallet generate --vm evm --name payer # dedicated wallet; prints its address + a QR to fund +# → fund THAT address, then pick a lane: + +# x402 on EVM (pays Base Sepolia USDC; --network, the chain you query, is +# independent of the chain you pay on): +qn rpc call eth_blockNumber --network ethereum-mainnet --x402 \ + --payment-wallet payer --payment-network base-sepolia \ + --payment-asset USDC --max-amount 1000 + +# MPP (pays Tempo testnet USDC); same EVM wallet, --receipt adds the settlement ref: +qn rpc call eth_blockNumber --network ethereum-mainnet --mpp --receipt \ + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset USDC --max-amount 1000 + +# x402 on Solana (devnet); needs an SVM wallet: +qn wallet generate --vm svm --name sol-payer +qn rpc call getSlot --network solana-devnet --x402 \ + --payment-wallet sol-payer --payment-network solana-devnet \ + --payment-asset USDC --max-amount 1000 + +# Store the parameters in config to keep calls short (never the raw key): +cat >> ~/.config/qn/config.toml <<'EOF' +[rpc.payment] +wallet = "payer" # a stored wallet, or key_file = "" (chmod 600) +max_amount = "1000" # spend ceiling per call, base units +payment_network = "base-sepolia" # settlement chain: network name or CAIP-2 id +payment_asset = "USDC" # symbol (resolved per network), or a raw address/mint +EOF +qn rpc call eth_blockNumber --network ethereum-mainnet --x402 # params now come from config +``` + +This moves real funds (even testnet tokens are real transfers) — use a +dedicated, minimally funded wallet. The spend ceiling bounds each call; there +is no built-in default. + ## 8. Gotchas & safety rails - Mutations are never retried; re-running a failed create can double-provision (§5). +- Paid `rpc call` moves real funds and never auto-retries; exit 3 means the + payment may have settled — check the wallet before re-running (§3, §5). The + CLI never prints the payment key; it comes only from a key file or a stored + wallet (never an env var, never argv, never inline in config). - No account-wide wipe command exists by design (§4). - Piped output defaults to `json`; pass `-o toon` for the compact LLM form (§2). - `--base-url` overrides the API host; it exists for testing. diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 50fd635..8b84645 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -16,4 +16,5 @@ pub mod stream; pub mod team; pub mod tooling_access; pub mod usage; +pub mod wallet; pub mod webhook; diff --git a/src/commands/rpc.rs b/src/commands/rpc/mod.rs similarity index 76% rename from src/commands/rpc.rs rename to src/commands/rpc/mod.rs index ec02a53..42d9a87 100644 --- a/src/commands/rpc.rs +++ b/src/commands/rpc/mod.rs @@ -18,6 +18,18 @@ //! config) is a separate lane: the SDK sends the call straight to that URL with //! no JWT minted or attached. That lane never touches the token cache or the //! Tooling Access enable/probe recovery. +//! +//! The crypto-micropayment lane (`--x402`/`--mpp`) is a third lane, in +//! `payment.rs`: keyless, paid per request, and structurally separate — it +//! branches off before any of this module's token-cache or Tooling Access +//! machinery runs. + +mod mpp; +mod pay_asset; +mod pay_network; +mod payment; +mod supported_networks; +mod x402; use std::io::Read; use std::path::{Path, PathBuf}; @@ -49,16 +61,50 @@ pub enum RpcCmd { qn rpc call eth_blockNumber --endpoint-url https://my-endpoint.example/rpc\n \ qn rpc call eth_call --params-file params.json\n \ echo '[...]' | qn rpc call eth_call -\n \ - cat params.json | qn rpc call eth_call -f -")] - Call(CallArgs), - - /// List the endpoint's available network keys (no RPC call). + cat params.json | qn rpc call eth_call -f -\n\n\ + Paid (crypto micropayment, no API key;\n \ + the payment chain is independent of the chain you query):\n \ + qn rpc call eth_blockNumber --network ethereum-mainnet --x402 \\\n \ + --payment-wallet payer --payment-network base-sepolia \\\n \ + --payment-asset USDC --max-amount 10000\n \ + qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 10000\n\n\ + Prepaid x402 credits (drawdown — buy once, then spend on any supported network):\n \ + qn rpc x402 buy-credits --network ethereum-mainnet --payment-wallet payer \\\n \ + --payment-network base-sepolia --payment-asset USDC --max-amount 10000000\n \ + qn rpc call eth_blockNumber --network ethereum-mainnet --x402-drawdown --payment-wallet payer\n\n\ + MPP payment channel (open once, then pay per call with a voucher):\n \ + qn rpc mpp open --network tempo-testnet --deposit 1000000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000\n \ + qn rpc call eth_blockNumber --network tempo-testnet --mpp-session \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000\n\n\ + Discover networks and payment options, and manage wallets:\n \ + qn rpc x402 supported-networks (mpp: qn rpc mpp supported-networks)\n \ + qn rpc x402 supported-payments (mpp: qn rpc mpp supported-payments)\n \ + qn wallet generate --vm evm --name payer")] + Call(Box), + + /// List the endpoint's available network keys. #[command(visible_alias = "ls")] ListNetworks, + + /// Manage x402 credit drawdown: buy prepaid credits, check the balance, or + /// drip testnet credits. Pair with `qn rpc call --x402-drawdown`. + X402(x402::Args), + + /// Manage an MPP payment channel: open, top-up, close, or check status. + /// Pair with `qn rpc call --mpp-session`. + Mpp(mpp::Args), } #[derive(Debug, ClapArgs)] -#[command(group(ArgGroup::new("params_source").args(["params", "params_file"])))] +#[command( + group(ArgGroup::new("params_source").args(["params", "params_file"])), + group(ArgGroup::new("payment").args(["x402", "mpp", "x402_drawdown", "mpp_session"])), +)] pub struct CallArgs { /// The JSON-RPC method, e.g. `eth_blockNumber`. #[arg(value_name = "METHOD")] @@ -90,12 +136,113 @@ pub struct CallArgs { /// exclusive with `--network` (a custom URL is not multichain-routed). #[arg(long, conflicts_with = "network", value_name = "URL")] pub endpoint_url: Option, + + /// Pay for this call per request with the x402 protocol (EVM or Solana + /// stablecoin) instead of the account's API key. Moves real funds; use a + /// dedicated, minimally funded wallet. Requires --network (the query + /// chain, as the payment gateway's path slug — e.g. `base-sepolia`). + #[arg(long, conflicts_with = "endpoint_url", help_heading = "Payment")] + pub x402: bool, + + /// Pay for this call per request with MPP (Tempo). Mutually exclusive + /// with --x402; same rules otherwise. + #[arg(long, conflicts_with = "endpoint_url", help_heading = "Payment")] + pub mpp: bool, + + /// Pay for this call from prepaid x402 credits (drawdown): no per-call + /// signing, 1 credit per successful response. Buy credits first with + /// `qn rpc x402 buy-credits`. Requires --network (the query chain). The + /// session JWT is authenticated and refreshed automatically. + #[arg(long, conflicts_with = "endpoint_url", help_heading = "Payment")] + pub x402_drawdown: bool, + + /// Pay for this call from an open MPP channel (session): a cumulative + /// EIP-712 voucher, no on-chain tx per call. Open a channel first with + /// `qn rpc mpp open`. Requires --network (the query chain). + #[arg(long, conflicts_with = "endpoint_url", help_heading = "Payment")] + pub mpp_session: bool, + + /// File containing the raw payment private key (EVM/Tempo hex, Solana + /// base58); pass `-` to read it from stdin. Never accepts the key itself. + /// Precedence: this flag > --payment-wallet > `key_file` > `wallet` under + /// [rpc.payment] in config. + #[arg( + long, + value_name = "PATH", + requires = "payment", + conflicts_with = "payment_wallet", + help_heading = "Payment" + )] + pub payment_key_file: Option, + + /// Name of a stored wallet (from `qn wallet generate`) to pay with. Its + /// key file under `/qn/wallets/` is used. Mutually exclusive + /// with --payment-key-file. + #[arg( + long, + value_name = "NAME", + requires = "payment", + help_heading = "Payment" + )] + pub payment_wallet: Option, + + /// Spend ceiling per call, in integer base units of the asset (e.g. + /// 10000 = 0.01 USDC). No built-in default: flag > `max_amount` under + /// [rpc.payment]. Offered payments above the ceiling are never signed. + #[arg( + long, + value_name = "BASE_UNITS", + requires = "payment", + help_heading = "Payment" + )] + pub max_amount: Option, + + /// Chain you PAY on — a network name (e.g. `base-sepolia`) or CAIP-2 id + /// (e.g. `eip155:84532`) — independent of --network (the chain you + /// query). Falls back to `payment_network` under [rpc.payment]. + #[arg( + long, + value_name = "NETWORK", + requires = "payment", + help_heading = "Payment" + )] + pub payment_network: Option, + + /// Token to pay with: EVM contract address, Solana mint, or a symbol like + /// USDC (resolved per network). Falls back to `payment_asset` under + /// [rpc.payment]. + #[arg( + long, + value_name = "ADDRESS", + requires = "payment", + help_heading = "Payment" + )] + pub payment_asset: Option, + + /// Explicit Solana RPC URL for building x402/Solana payments. Falls back + /// to `svm_rpc_url` under [rpc.payment], then a public Solana RPC (which + /// rate-limits aggressively — set this at any real volume). + #[arg( + long, + value_name = "URL", + requires = "payment", + help_heading = "Payment" + )] + pub svm_rpc_url: Option, + + /// Wrap stdout as {"result": ..., "payment_receipt": ...}. The receipt is + /// non-null only on MPP (the settlement transaction hash); null on x402. + /// Payment happens either way — this only changes the output. + #[arg(long, requires = "payment", help_heading = "Payment")] + pub receipt: bool, } pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { match args.cmd { - RpcCmd::Call(call) => run_call(call, global).await, + RpcCmd::Call(call) => run_call(*call, global).await, RpcCmd::ListNetworks => run_list_networks(global).await, + RpcCmd::X402(x402_args) => x402::run(x402_args, global).await, + RpcCmd::Mpp(mpp_args) => mpp::run(mpp_args, global).await, } } @@ -114,6 +261,19 @@ async fn run_list_networks(global: GlobalArgs) -> Result<(), CliError> { } async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + // The crypto-micropayment lanes branch off before any token-cache or + // Tooling Access work: they are keyless, never blind-retried, and never + // touch this function's caches or recovery paths. + if args.x402 || args.mpp { + return payment::run_paid_call(args, global).await; + } + if args.x402_drawdown { + return payment::run_drawdown_call(args, global).await; + } + if args.mpp_session { + return payment::run_session_call(args, global).await; + } + let params = parse_params(args.params.as_deref(), args.params_file.as_deref())?; // A custom URL (per-call flag, else the `[rpc] endpoint_url` config default) @@ -516,7 +676,10 @@ async fn maybe_enable(ctx: &Ctx) -> Result<(), CliError> { /// as JSON. `None`/`None` → no params (sends `[]`). Either source accepts `-` to /// read from stdin. The clap `ArgGroup` guarantees at most one is set. An empty /// value (after trimming) is treated as no params. -fn parse_params(arg: Option<&str>, file: Option<&Path>) -> Result, CliError> { +pub(super) fn parse_params( + arg: Option<&str>, + file: Option<&Path>, +) -> Result, CliError> { let raw = match (arg, file) { (None, None) => return Ok(None), (Some("-"), _) => read_stdin("params")?, @@ -539,7 +702,7 @@ fn parse_params(arg: Option<&str>, file: Option<&Path>) -> Result, } /// Read all of stdin as a UTF-8 string, labeling errors with `what`. -fn read_stdin(what: &str) -> Result { +pub(super) fn read_stdin(what: &str) -> Result { let mut buf = String::new(); std::io::stdin() .read_to_string(&mut buf) @@ -552,7 +715,7 @@ fn read_stdin(what: &str) -> Result { /// raw `--format` flag, not the TTY-aware resolved default). `json`/`yaml`/`toon` /// render as requested. `table`/`md` have no columns here, so they fall back to /// JSON — and only that explicit case prints a one-line note on stderr. -fn emit_result(ctx: &Ctx, result: &Value) -> Result<(), CliError> { +pub(super) fn emit_result(ctx: &Ctx, result: &Value) -> Result<(), CliError> { match ctx.global.format { None | Some(Format::Json) => { println!( diff --git a/src/commands/rpc/mpp.rs b/src/commands/rpc/mpp.rs new file mode 100644 index 0000000..e23fa8c --- /dev/null +++ b/src/commands/rpc/mpp.rs @@ -0,0 +1,451 @@ +//! `qn rpc mpp …` — the MPP payment-channel (session) lifecycle. +//! +//! Four verbs manage an on-chain escrow payment channel, all keyless (funded by +//! the configured Tempo wallet, not an account API key): +//! - `open` — deposit into the escrow, opening a channel. Gated Mild. +//! - `top-up` — add deposit to the open channel. Gated Mild. +//! - `close` — cooperatively close (settle on-chain + refund). Gated Mild; +//! the prompt warns that further `--mpp-session` calls fail until re-open. +//! - `status` — the gateway's view of the channel (re-syncs the accepted +//! spend high-water mark into the local record). +//! +//! Channel state (channelId, deposit, cumulative spend) is persisted under the +//! config dir (`channels.toml`, 0600, keyed by wallet address + network) and +//! re-seeded next run. Every lifecycle verb ends with a ready-to-run next +//! command. Paid verbs are single-attempt. + +use clap::{Args as ClapArgs, Subcommand}; +use quicknode_sdk::ChannelState; + +use crate::config::{self, PaymentSection}; +use crate::context::{Ctx, GlobalArgs}; +use crate::errors::CliError; +use crate::output::{style, Style}; + +use super::payment::{resolve_payment_params, PaymentParams}; + +#[derive(Debug, ClapArgs)] +#[command(subcommand_required = true, arg_required_else_help = true)] +#[command(after_help = "Examples:\n \ + qn rpc mpp open --network tempo-testnet --deposit 1000000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000\n \ + qn rpc call eth_blockNumber --network tempo-testnet --mpp-session \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000\n \ + qn rpc mpp status --network tempo-testnet \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000\n \ + qn rpc mpp top-up --network tempo-testnet --deposit 500000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 500000\n \ + qn rpc mpp close --network tempo-testnet \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000")] +pub struct Args { + #[command(subcommand)] + pub cmd: MppCmd, +} + +#[derive(Debug, Subcommand)] +pub enum MppCmd { + /// Open a payment channel by depositing into the escrow. Gated: names the + /// deposit before signing. + #[command(after_help = "Examples:\n \ + qn rpc mpp open --network tempo-testnet --deposit 1000000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet --payment-asset USDC \\\n \ + --max-amount 1000000")] + Open(OpenArgs), + + /// Add deposit to the open channel. Gated. + #[command(name = "top-up")] + #[command(after_help = "Examples:\n \ + qn rpc mpp top-up --network tempo-testnet --deposit 500000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 500000")] + TopUp(TopUpArgs), + + /// Cooperatively close the channel (settle on-chain + refund unused + /// deposit). Gated. + #[command(after_help = "Examples:\n \ + qn rpc mpp close --network tempo-testnet \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000")] + Close(ChannelArgs), + + /// Show the gateway's view of the channel (re-syncs the accepted spend). + #[command(after_help = "Examples:\n \ + qn rpc mpp status --network tempo-testnet \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000")] + Status(ChannelArgs), + + /// List the networks you can make MPP-paid RPC calls to. Each slug is a + /// valid --network for a paid call. No API key required. + #[command(visible_alias = "networks")] + #[command(after_help = "Examples:\n \ + qn rpc mpp networks\n \ + qn rpc mpp supported-networks --format json")] + SupportedNetworks, + + /// List the payment options the MPP gateway accepts: the network you pay + /// on, the token, and its contract address — ready --payment-network and + /// --payment-asset values. No API key required. + #[command(visible_alias = "payments")] + #[command(after_help = "Examples:\n \ + qn rpc mpp payments\n \ + qn rpc mpp supported-payments --format json")] + SupportedPayments, +} + +/// The payment parameter stack + query network shared by the MPP verbs. +#[derive(Debug, ClapArgs)] +pub struct PaymentArgs { + /// The query chain, as the payment gateway's path slug (e.g. + /// `tempo-testnet`). The channel lives on this network. + #[arg(long, value_name = "NETWORK")] + pub network: String, + + /// File containing the raw Tempo payment key (hex); `-` reads stdin. + /// Precedence: this > --payment-wallet > `key_file` > `wallet` in config. + #[arg(long, value_name = "PATH", conflicts_with = "payment_wallet")] + pub payment_key_file: Option, + + /// Name of a stored wallet (from `qn wallet generate`) to pay with. + #[arg(long, value_name = "NAME")] + pub payment_wallet: Option, + + /// Spend ceiling for a single signed action (deposit/top-up), in integer + /// base units. Flag > `max_amount` in [rpc.payment]. + #[arg(long, value_name = "BASE_UNITS")] + pub max_amount: Option, + + /// Chain the channel settles on: a network name or CAIP-2 id. Falls back to + /// `payment_network` in [rpc.payment]. + #[arg(long, value_name = "NETWORK")] + pub payment_network: Option, + + /// Token the channel is denominated in: an address or a symbol like USDC. + /// Falls back to `payment_asset` in [rpc.payment]. + #[arg(long, value_name = "ADDRESS")] + pub payment_asset: Option, + + /// Explicit Solana RPC URL (unused for Tempo channels; accepted for a + /// uniform flag stack). Falls back to `svm_rpc_url` in [rpc.payment]. + #[arg(long, value_name = "URL")] + pub svm_rpc_url: Option, +} + +impl PaymentArgs { + fn params(&self) -> PaymentParams<'_> { + PaymentParams { + key_file: self.payment_key_file.as_deref(), + wallet: self.payment_wallet.as_deref(), + max_amount: self.max_amount.as_deref(), + payment_network: self.payment_network.as_deref(), + payment_asset: self.payment_asset.as_deref(), + svm_rpc_url: self.svm_rpc_url.as_deref(), + } + } +} + +#[derive(Debug, ClapArgs)] +pub struct OpenArgs { + #[command(flatten)] + pub payment: PaymentArgs, + + /// Amount to deposit into the escrow, in integer base units of the asset. + #[arg(long, value_name = "BASE_UNITS")] + pub deposit: String, +} + +#[derive(Debug, ClapArgs)] +pub struct TopUpArgs { + #[command(flatten)] + pub payment: PaymentArgs, + + /// Additional amount to deposit into the open channel, in base units. + #[arg(long, value_name = "BASE_UNITS")] + pub deposit: String, +} + +/// Verbs that operate on the already-open channel (no new deposit amount). +#[derive(Debug, ClapArgs)] +pub struct ChannelArgs { + #[command(flatten)] + pub payment: PaymentArgs, +} + +pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { + match args.cmd { + MppCmd::Open(a) => run_open(a, global).await, + MppCmd::TopUp(a) => run_top_up(a, global).await, + MppCmd::Close(a) => run_close(a.payment, global).await, + MppCmd::Status(a) => run_status(a.payment, global).await, + MppCmd::SupportedNetworks => { + super::supported_networks::run_networks(super::supported_networks::Scheme::Mpp, global) + .await + } + MppCmd::SupportedPayments => { + super::supported_networks::run_payments(super::supported_networks::Scheme::Mpp, global) + .await + } + } +} + +// Shared setup: resolve the MPP payment config (scheme "mpp"), build the +// keyless-payment Ctx, and surface any key-file warning. No network I/O. +fn setup(args: &PaymentArgs, global: GlobalArgs) -> Result<(Ctx, String), CliError> { + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_payment_params( + "mpp", + &args.params(), + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + let network = args.network.clone(); + let ctx = Ctx::from_global_keyless_payment(global, payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + Ok((ctx, network)) +} + +fn load_payment_section(global: &GlobalArgs) -> Result { + let Some(path) = global.resolve_config_path() else { + return Ok(PaymentSection::default()); + }; + Ok(config::load_from(&path)? + .map(|cfg| cfg.rpc.payment) + .unwrap_or_default()) +} + +fn parse_base_units(s: &str, flag: &str) -> Result { + s.parse::().map_err(|_| { + CliError::Arg(format!( + "--{flag} must be a non-negative integer in base units, got '{s}'" + )) + }) +} + +// Prints a bold, ready-to-run next-command hint (stderr): the base command +// plus the payment flags this invocation passed, so the suggestion works +// verbatim. Values resolved from [rpc.payment] config need no flags and are +// not echoed. Wrapped two flags per continuation line. +fn note_next(ctx: &Ctx, base: &str, payment: &PaymentArgs) { + let mut flags: Vec = Vec::new(); + if let Some(f) = &payment.payment_key_file { + flags.push(format!("--payment-key-file {}", f.display())); + } + if let Some(w) = &payment.payment_wallet { + flags.push(format!("--payment-wallet {w}")); + } + if let Some(n) = &payment.payment_network { + flags.push(format!("--payment-network {n}")); + } + if let Some(a) = &payment.payment_asset { + flags.push(format!("--payment-asset {a}")); + } + if let Some(m) = &payment.max_amount { + flags.push(format!("--max-amount {m}")); + } + + let mut lines = vec![format!(" {base}")]; + for chunk in flags.chunks(2) { + lines.push(format!(" {}", chunk.join(" "))); + } + let cmd = lines.join(" \\\n"); + ctx.out.note(&format!( + "\n{}\n\n{}\n", + style("Example", Style::Bold, ctx.out.color), + style(&cmd, Style::Bold, ctx.out.color), + )); +} + +async fn run_open(args: OpenArgs, global: GlobalArgs) -> Result<(), CliError> { + let deposit = parse_base_units(&args.deposit, "deposit")?; + let (ctx, network) = setup(&args.payment, global.clone())?; + + crate::confirm::confirm_mild( + &ctx, + &format!( + "Open an MPP channel with a {deposit} base-unit deposit on {network}? \ + This moves real funds on-chain." + ), + )?; + + let channel = ctx + .sdk + .rpc + .mpp_open(&network, deposit) + .await + .map_err(super::payment::map_paid_error)?; + persist_channel(&ctx, &global, &network, &channel); + + ctx.out.note(&format!( + "✓ Opened channel {} (deposit: {})", + channel.channel_id, channel.deposit + )); + note_next( + &ctx, + &format!("qn rpc call eth_blockNumber --network {network} --mpp-session"), + &args.payment, + ); + emit_channel(&ctx, &channel) +} + +async fn run_top_up(args: TopUpArgs, global: GlobalArgs) -> Result<(), CliError> { + let additional = parse_base_units(&args.deposit, "deposit")?; + let (ctx, network) = setup(&args.payment, global.clone())?; + let channel = require_channel(&ctx, &global, &network)?; + + crate::confirm::confirm_mild( + &ctx, + &format!( + "Top up channel {} with {additional} more base units on {network}? \ + This moves real funds on-chain.", + channel.channel_id + ), + )?; + + let updated = ctx + .sdk + .rpc + .mpp_top_up(&network, &channel, additional) + .await + .map_err(super::payment::map_paid_error)?; + persist_channel(&ctx, &global, &network, &updated); + + ctx.out.note(&format!( + "✓ Topped up channel {} (deposit: {})", + updated.channel_id, updated.deposit + )); + note_next( + &ctx, + &format!("qn rpc mpp status --network {network}"), + &args.payment, + ); + emit_channel(&ctx, &updated) +} + +async fn run_close(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { + let (ctx, network) = setup(&args, global.clone())?; + let channel = require_channel(&ctx, &global, &network)?; + + crate::confirm::confirm_mild( + &ctx, + &format!( + "Close channel {} on {network}? It settles on-chain and refunds the \ + unused deposit; further --mpp-session calls fail until you open a \ + new channel.", + channel.channel_id + ), + )?; + + ctx.sdk + .rpc + .mpp_close(&network, &channel) + .await + .map_err(super::payment::map_paid_error)?; + // The channel is settled: drop the local record so a stale one can't be + // reused. Best-effort — a failed delete must not fail the completed close. + if let Some(address) = wallet_address(&ctx) { + if let Some(path) = config::channels_cache_path(global.resolve_config_path().as_deref()) { + let _ = config::delete_channel(&path, &address, &network); + } + } + + ctx.out + .note(&format!("✓ Closed channel {}", channel.channel_id)); + note_next( + &ctx, + &format!("qn rpc mpp open --network {network} --deposit "), + &args, + ); + Ok(()) +} + +async fn run_status(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { + let (ctx, network) = setup(&args, global.clone())?; + let channel = require_channel(&ctx, &global, &network)?; + + let status = ctx.sdk.rpc.mpp_status(&network, &channel).await?; + + // Re-seed the local spend high-water mark from the gateway (it is + // authoritative for what it has accepted). The deposit is tracked locally: + // the gateway exposes no read-only channel endpoint. + let mut synced = channel.clone(); + synced.cumulative_spent = synced.cumulative_spent.max(status.accepted_cumulative); + persist_channel(&ctx, &global, &network, &synced); + + let deposit = synced.deposit; + if matches!(ctx.global.format, Some(f) if f.is_structured()) { + let v = serde_json::json!({ + "channel_id": status.channel_id, + "deposit": deposit, + "accepted_cumulative": status.accepted_cumulative, + "spent": status.spent, + "remaining": deposit.saturating_sub(status.accepted_cumulative), + }); + return super::emit_result(&ctx, &v); + } + ctx.out.note(&format!( + "channel {}: deposit {}, accepted {}, remaining {}", + status.channel_id, + deposit, + status.accepted_cumulative, + deposit.saturating_sub(status.accepted_cumulative), + )); + Ok(()) +} + +// The wallet's on-chain address (derived offline), used to key channel state. +fn wallet_address(ctx: &Ctx) -> Option { + ctx.sdk.rpc.payment_address().ok() +} + +// Persist channel state, keyed by wallet address + network. Best-effort. +fn persist_channel(ctx: &Ctx, global: &GlobalArgs, network: &str, channel: &ChannelState) { + if let Some(address) = wallet_address(ctx) { + if let Some(path) = config::channels_cache_path(global.resolve_config_path().as_deref()) { + let _ = config::save_channel(&path, &address, network, channel); + } + } +} + +// Load the open channel for this wallet+network, or an actionable error +// pointing at `mpp open`. Every channel verb (including `status`) needs the +// local record; a lost one means opening a new channel. +fn require_channel( + ctx: &Ctx, + global: &GlobalArgs, + network: &str, +) -> Result { + let address = wallet_address(ctx) + .ok_or_else(|| CliError::Arg("could not derive the payment wallet address".to_string()))?; + let path = config::channels_cache_path(global.resolve_config_path().as_deref()); + let channel = path + .as_deref() + .and_then(|p| config::load_channel(p, &address, network)); + channel.ok_or_else(|| { + CliError::Arg(format!( + "no open MPP channel for this wallet on {network}. Open one with \ + 'qn rpc mpp open --network {network} --deposit '." + )) + }) +} + +fn emit_channel(ctx: &Ctx, channel: &ChannelState) -> Result<(), CliError> { + if matches!(ctx.global.format, Some(f) if f.is_structured()) { + let v = serde_json::json!({ + "channel_id": channel.channel_id, + "deposit": channel.deposit, + "cumulative_spent": channel.cumulative_spent, + }); + return super::emit_result(ctx, &v); + } + println!("{}", channel.channel_id); + Ok(()) +} diff --git a/src/commands/rpc/pay_asset.rs b/src/commands/rpc/pay_asset.rs new file mode 100644 index 0000000..d8de92e --- /dev/null +++ b/src/commands/rpc/pay_asset.rs @@ -0,0 +1,204 @@ +//! Human-readable symbols for `--payment-asset`. +//! +//! The SDK's `PaymentConfig.asset` is a token contract address (EVM) or mint +//! (Solana) — the exact value an x402/MPP offer is matched against. A symbol +//! like `USDC` only identifies a concrete address once the network is known: +//! USDC is a different address on every chain. So this resolver takes the +//! already-resolved CAIP-2 network alongside the input and maps a known symbol +//! to that network's address. +//! +//! Unlike `pay_network`, this is intentionally permissive: an asset is an +//! open-ended address across many chains, not a closed Quicknode vocabulary. +//! Anything that isn't a recognized symbol passes through verbatim, so any +//! token address reaches the gateway unchanged and the gateway stays the +//! authority on whether it is a valid, payable asset. Only symbols confirmed +//! against a public source (the gateway's own discovery catalog) are listed — +//! a wrong address could pay in the wrong token. + +use crate::errors::CliError; + +/// (CAIP-2 network, lowercase symbol) → token address. Sorted by the +/// `(network, symbol)` tuple (binary searched); a unit test enforces order and +/// uniqueness. Addresses are taken from a public source — the payment +/// gateways' discovery catalog (`qn rpc x402 supported-payments`) or, for +/// Tempo, the MPP spec / Tempo payment docs. +const PAY_ASSETS: &[(&str, &str, &str)] = &[ + ( + "eip155:196", + "usdc", + "0x4ae46a509F6b1D9056937BA4500cb143933D2dc8", + ), + // Tempo pathUSD and USDC.e (bridged USDC, mapped from the `usdc` symbol), + // from the MPP spec / Tempo payment docs and the MPP gateway's own 402 + // challenge. Testnet `usdc` resolves to pathUSD (the testnet stand-in). + ( + "eip155:4217", + "pathusd", + "0x20c0000000000000000000000000000000000000", + ), + ( + "eip155:4217", + "usdc", + "0x20c000000000000000000000b9537d11c60e8b50", + ), + ( + "eip155:42431", + "pathusd", + "0x20c0000000000000000000000000000000000000", + ), + ( + "eip155:42431", + "usdc", + "0x20c0000000000000000000000000000000000000", + ), + ( + "eip155:8453", + "usdc", + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + ), + ( + "eip155:84532", + "usdc", + "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + ), + ( + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "usdc", + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ), + ( + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "usdc", + "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + ), +]; + +/// The symbols this resolver recognizes, lowercased. Input matching one of +/// these (case-insensitively) is rewritten to an address for the given +/// network, or errors if that network has no mapping. Anything else passes +/// through verbatim as an address. +const KNOWN_SYMBOLS: &[&str] = &["pathusd", "usdc"]; + +/// Resolves a `--payment-asset` / config `payment_asset` value to a token +/// address, given the already-resolved CAIP-2 `network`. A recognized symbol +/// (e.g. `USDC`, case-insensitive) is mapped to that network's address; every +/// other value passes through unchanged. +pub(super) fn resolve(input: &str, network: &str) -> Result { + let lower = input.to_ascii_lowercase(); + if !KNOWN_SYMBOLS.contains(&lower.as_str()) { + return Ok(input.to_string()); + } + match PAY_ASSETS.binary_search_by(|(net, sym, _)| (*net, *sym).cmp(&(network, lower.as_str()))) + { + Ok(i) => Ok(PAY_ASSETS[i].2.to_string()), + Err(_) => Err(CliError::Arg(format!( + "no known {} address for network '{network}'. Pass the token \ + contract address (EVM) or mint (Solana) directly to \ + --payment-asset — run 'qn rpc x402 supported-payments' or \ + 'qn rpc mpp supported-payments' to find it", + input.to_ascii_uppercase() + ))), + } +} + +/// Reverse lookup: a resolved token address on `network` → its known symbol, +/// in display casing. Used to name the asset in funds-moving prompts and the +/// supported-payments table instead of echoing the raw contract address. EVM +/// hex compares case-insensitively (checksum casing varies); a miss just means +/// the caller shows the address. +pub(super) fn symbol_for(network: &str, address: &str) -> Option { + PAY_ASSETS + .iter() + .find(|(net, _, addr)| *net == network && addr.eq_ignore_ascii_case(address)) + .map(|(_, sym, _)| display_symbol(sym)) +} + +/// Display casing for a lowercase table symbol (`pathusd` → `pathUSD`). +fn display_symbol(sym: &str) -> String { + match sym { + "pathusd" => "pathUSD".to_string(), + other => other.to_ascii_uppercase(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_is_sorted_and_unique() { + for pair in PAY_ASSETS.windows(2) { + let a = (pair[0].0, pair[0].1); + let b = (pair[1].0, pair[1].1); + assert!(a < b, "PAY_ASSETS out of order or duplicated at {b:?}"); + } + } + + #[test] + fn resolves_symbol_per_network() { + assert_eq!( + resolve("usdc", "eip155:84532").unwrap(), + "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + ); + assert_eq!( + resolve("usdc", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp").unwrap(), + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + ); + // Tempo (MPP): mainnet USDC and testnet pathUSD. + assert_eq!( + resolve("usdc", "eip155:4217").unwrap(), + "0x20c000000000000000000000b9537d11c60e8b50" + ); + assert_eq!( + resolve("usdc", "eip155:42431").unwrap(), + "0x20c0000000000000000000000000000000000000" + ); + } + + #[test] + fn symbol_lookup_is_case_insensitive() { + assert_eq!( + resolve("USDC", "eip155:8453").unwrap(), + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + ); + } + + #[test] + fn address_passes_through_verbatim() { + let addr = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + assert_eq!(resolve(addr, "eip155:84532").unwrap(), addr); + // A base58 mint (unknown to the table) is untouched. + let mint = "So11111111111111111111111111111111111111112"; + assert_eq!( + resolve(mint, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp").unwrap(), + mint + ); + } + + #[test] + fn symbol_for_reverses_known_addresses() { + assert_eq!( + symbol_for("eip155:84532", "0x036CbD53842c5426634e7929541eC2318f3dCF7e").as_deref(), + Some("USDC") + ); + // Checksum casing differences still match. + assert_eq!( + symbol_for("eip155:84532", "0x036cbd53842c5426634e7929541ec2318f3dcf7e").as_deref(), + Some("USDC") + ); + // Unknown address, or a known address on the wrong network, is a miss. + assert_eq!(symbol_for("eip155:84532", "0xabc"), None); + assert_eq!( + symbol_for("eip155:1", "0x036CbD53842c5426634e7929541eC2318f3dCF7e"), + None + ); + } + + #[test] + fn known_symbol_on_unmapped_network_errors() { + let err = resolve("usdc", "eip155:1").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("USDC"), "got: {msg}"); + assert!(msg.contains("eip155:1"), "got: {msg}"); + } +} diff --git a/src/commands/rpc/pay_network.rs b/src/commands/rpc/pay_network.rs new file mode 100644 index 0000000..cef1f27 --- /dev/null +++ b/src/commands/rpc/pay_network.rs @@ -0,0 +1,198 @@ +//! Human-readable names for `--pay-network`. +//! +//! The SDK's `PaymentConfig.pay_network` is a canonical CAIP-2 id — that is +//! the form the x402/MPP offers are matched against. This module is the CLI's +//! presentation layer on top: it accepts the same Quicknode network-name +//! vocabulary as `--network` (e.g. `base-sepolia`) and resolves it to CAIP-2 +//! before the value reaches the SDK. Anything containing a `:` is treated as +//! an already-canonical CAIP-2 id and passed through verbatim, so every chain +//! is reachable even when it has no entry in the table. +//! +//! EVM chain ids are verified against the public EVM chain registry +//! (chainid.network, the dataset behind chainlist.org). Solana ids are the +//! standard CAIP-2 genesis-hash prefixes. Names whose chain id could not be +//! confirmed against a public source are deliberately absent — a wrong id +//! could match a payment offer on the wrong chain, while a missing name just +//! errors with the CAIP-2 escape hatch. + +use crate::errors::CliError; + +/// Quicknode network name → CAIP-2 pay-network id. Sorted by name (binary +/// searched); a unit test enforces order and uniqueness. +const PAY_NETWORKS: &[(&str, &str)] = &[ + ("0g-galileo", "eip155:16601"), + ("0g-mainnet", "eip155:16661"), + ("abstract-mainnet", "eip155:2741"), + ("abstract-testnet", "eip155:11124"), + ("arbitrum-mainnet", "eip155:42161"), + ("arbitrum-sepolia", "eip155:421614"), + ("ault-mainnet", "eip155:904"), + ("ault-testnet", "eip155:10904"), + ("avalanche-mainnet", "eip155:43114"), + ("avalanche-testnet", "eip155:43113"), + ("b3-mainnet", "eip155:8333"), + ("base-mainnet", "eip155:8453"), + ("base-sepolia", "eip155:84532"), + ("bera-bepolia", "eip155:80069"), + ("bera-mainnet", "eip155:80094"), + ("blast-mainnet", "eip155:81457"), + ("blast-sepolia", "eip155:168587773"), + ("bsc", "eip155:56"), + ("bsc-testnet", "eip155:97"), + ("celo-mainnet", "eip155:42220"), + ("cyber-mainnet", "eip155:7560"), + ("ethereum-hoodi", "eip155:560048"), + ("ethereum-mainnet", "eip155:1"), + ("ethereum-sepolia", "eip155:11155111"), + ("fantom", "eip155:250"), + ("flare-coston2", "eip155:114"), + ("flare-mainnet", "eip155:14"), + ("fluent-mainnet", "eip155:25363"), + ("fraxtal-mainnet", "eip155:252"), + ("gravity-alpham", "eip155:1625"), + ("hedera-mainnet", "eip155:295"), + ("hedera-testnet", "eip155:296"), + ("hemi-mainnet", "eip155:43111"), + ("hemi-testnet", "eip155:743111"), + // 999 is HyperEVM per Hyperliquid's docs; the chain registry still lists + // it under a stale earlier registration. + ("hype-mainnet", "eip155:999"), + ("hype-testnet", "eip155:998"), + ("injective-mainnet", "eip155:1776"), + ("injective-testnet", "eip155:1439"), + ("ink-mainnet", "eip155:57073"), + ("ink-sepolia", "eip155:763373"), + ("joc-mainnet", "eip155:81"), + ("kaia-kairos", "eip155:1001"), + ("kaia-mainnet", "eip155:8217"), + ("katana-mainnet", "eip155:747474"), + ("linea-mainnet", "eip155:59144"), + ("lisk-mainnet", "eip155:1135"), + ("mantle-mainnet", "eip155:5000"), + ("mantle-sepolia", "eip155:5003"), + ("megaeth-mainnet", "eip155:4326"), + ("moca-testnet", "eip155:5151"), + ("mode-mainnet", "eip155:34443"), + ("monad-mainnet", "eip155:143"), + ("monad-testnet", "eip155:10143"), + ("morph-mainnet", "eip155:2818"), + ("nova-mainnet", "eip155:42170"), + ("optimism", "eip155:10"), + ("optimism-sepolia", "eip155:11155420"), + ("peaq-mainnet", "eip155:3338"), + ("plasma-mainnet", "eip155:9745"), + ("plasma-testnet", "eip155:9746"), + ("polygon", "eip155:137"), + ("polygon-amoy", "eip155:80002"), + ("robinhood-mainnet", "eip155:4663"), + ("robinhood-testnet", "eip155:46630"), + ("sahara-testnet", "eip155:313313"), + ("scroll-mainnet", "eip155:534352"), + ("scroll-testnet", "eip155:534351"), + ("sei-atlantic", "eip155:1328"), + ("sei-pacific", "eip155:1329"), + ("solana-devnet", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"), + ("solana-mainnet", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"), + ("solana-testnet", "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z"), + ("soneium-mainnet", "eip155:1868"), + ("sonic-mainnet", "eip155:146"), + ("story-aeneid", "eip155:1315"), + ("story-mainnet", "eip155:1514"), + ("tempo-mainnet", "eip155:4217"), + ("tempo-testnet", "eip155:42431"), + ("unichain-mainnet", "eip155:130"), + ("unichain-sepolia", "eip155:1301"), + ("vana-mainnet", "eip155:1480"), + ("vana-moksha", "eip155:14800"), + ("worldchain-mainnet", "eip155:480"), + ("worldchain-sepolia", "eip155:4801"), + ("xdai", "eip155:100"), + ("xlayer-mainnet", "eip155:196"), + ("xlayer-testnet", "eip155:195"), + ("xrplevm-mainnet", "eip155:1440000"), + ("xrplevm-testnet", "eip155:1449000"), + ("zksync-mainnet", "eip155:324"), + ("zksync-sepolia", "eip155:300"), + ("zora-mainnet", "eip155:7777777"), +]; + +/// Resolves a `--pay-network` / config `pay_network` value to CAIP-2. Values +/// containing `:` pass through verbatim (Solana genesis-hash references are +/// case-sensitive, so no normalization is applied to them). +pub(super) fn resolve(input: &str) -> Result { + if input.contains(':') { + return Ok(input.to_string()); + } + let name = input.to_ascii_lowercase(); + match PAY_NETWORKS.binary_search_by_key(&name.as_str(), |(n, _)| n) { + Ok(i) => Ok(PAY_NETWORKS[i].1.to_string()), + Err(_) => Err(CliError::Arg(format!( + "unknown pay network '{input}'. Use a Quicknode network name \ + (e.g. base-sepolia, solana-devnet, tempo-testnet) or a raw \ + CAIP-2 id (e.g. eip155:84532) — any eip155: or \ + solana: is accepted as-is" + ))), + } +} + +/// Reverse lookup: a CAIP-2 id → the first Quicknode network name mapped to it, +/// if any. Used to name the discovery-catalog payment networks (keyed by +/// CAIP-2) in the `supported-networks` tables. Linear scan — the table is +/// small and sorted by name, not id. +pub(super) fn slug_for_caip2(caip2: &str) -> Option { + PAY_NETWORKS + .iter() + .find(|(_, id)| *id == caip2) + .map(|(name, _)| name.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_is_sorted_and_unique() { + for pair in PAY_NETWORKS.windows(2) { + assert!( + pair[0].0 < pair[1].0, + "PAY_NETWORKS out of order or duplicated at '{}'", + pair[1].0 + ); + } + } + + #[test] + fn resolves_network_names() { + assert_eq!(resolve("base-sepolia").unwrap(), "eip155:84532"); + assert_eq!(resolve("xdai").unwrap(), "eip155:100"); + assert_eq!(resolve("tempo-testnet").unwrap(), "eip155:42431"); + assert_eq!( + resolve("solana-devnet").unwrap(), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + ); + } + + #[test] + fn name_lookup_is_case_insensitive() { + assert_eq!(resolve("Base-Sepolia").unwrap(), "eip155:84532"); + } + + #[test] + fn caip2_passes_through_verbatim() { + assert_eq!(resolve("eip155:84532").unwrap(), "eip155:84532"); + // Unknown-to-the-table but well-formed ids still work, unchanged. + assert_eq!( + resolve("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1").unwrap(), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + ); + assert_eq!(resolve("eip155:424242").unwrap(), "eip155:424242"); + } + + #[test] + fn unknown_name_errors_with_escape_hatch() { + let err = resolve("morph-hoodi").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("morph-hoodi"), "got: {msg}"); + assert!(msg.contains("eip155:"), "got: {msg}"); + } +} diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs new file mode 100644 index 0000000..2f441a0 --- /dev/null +++ b/src/commands/rpc/payment.rs @@ -0,0 +1,1162 @@ +//! The crypto-micropayment lane of `qn rpc call` (`--x402`/`--mpp`): pay per +//! RPC request with a stablecoin instead of an account API key, via the SDK's +//! 402 → sign → resend handshake against Quicknode's payment gateways. +//! +//! This lane is deliberately structural, not conditional: `run_call` branches +//! here before any of the default lane's machinery, so the token cache, the +//! Tooling Access enable/probe recovery, the networks map, and `retrying()` +//! are unreachable. Paid calls are NEVER auto-retried — a lost response after +//! the payment was submitted (`PaymentIndeterminate`, or an uninterpretable +//! post-payment body) means the caller may already have been charged. +//! +//! Everything the lane needs is resolved before any network I/O: the private +//! key (flag file/stdin > `--payment-wallet` > `key_file` > `wallet` in config +//! — always from a file, never an env var or a raw key on the command line or +//! inline in config), the spend ceiling, the pay network, and the asset. The +//! key lives only inside the SDK's `PaymentConfig` (which redacts it in Debug) +//! and is never logged or echoed. + +use std::path::Path; + +use quicknode_sdk::errors::SdkError; +use quicknode_sdk::{GatewaySession, PaymentConfig}; + +use crate::config::{self, PaymentSection}; +use crate::context::{Ctx, GlobalArgs}; +use crate::errors::CliError; +use crate::output::{style, Style}; + +use super::CallArgs; + +// Re-auth margin for the cached gateway session: refresh a session expiring +// within this window, absorbing clock skew (mirrors the tooling token margin). +const SESSION_MARGIN_SECS: i64 = 60; + +/// Rejects reading both the params and the payment key from stdin — there is +/// only one stdin, and draining it into the key would silently leave the params +/// empty. Shared by every paid lane (per-request, drawdown, session). +fn check_single_stdin(args: &CallArgs) -> Result<(), CliError> { + let params_use_stdin = matches!(args.params.as_deref(), Some("-")) + || matches!(&args.params_file, Some(p) if p.as_os_str() == "-"); + let key_use_stdin = matches!(&args.payment_key_file, Some(p) if p.as_os_str() == "-"); + if params_use_stdin && key_use_stdin { + return Err(CliError::Arg( + "cannot read both the params and the payment key from stdin; \ + put one of them in a file" + .to_string(), + )); + } + Ok(()) +} + +/// Entry point from `run_call` once `--x402`/`--mpp` is present. +pub(super) async fn run_paid_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + // Both the key and the params may come from stdin; there is only one stdin. + let params_use_stdin = matches!(args.params.as_deref(), Some("-")) + || matches!(&args.params_file, Some(p) if p.as_os_str() == "-"); + let key_use_stdin = matches!(&args.payment_key_file, Some(p) if p.as_os_str() == "-"); + if params_use_stdin && key_use_stdin { + return Err(CliError::Arg( + "cannot read both the params and the payment key from stdin; \ + put one of them in a file" + .to_string(), + )); + } + + // Config parameter defaults. Unlike the default lane's endpoint_url load, + // a broken config file is a hard error here (exit 4): the user probably + // relies on [rpc.payment] values we could not read. + let section = load_payment_section(&global)?; + + // The wallet store directory backs `--payment-wallet` / config `wallet`. + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + + let (payment, network, key_file_warning) = resolve_payment_config( + &args, + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + + let params = super::parse_params(args.params.as_deref(), args.params_file.as_deref())?; + + let ctx = Ctx::from_global_keyless_payment(global, payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + + // ONE attempt, no retrying(): a retried paid call risks a double charge. + // call_with_receipt is the single code path; the receipt is dropped unless + // --receipt asked for it. + let resp = ctx + .sdk + .rpc + .call_with_receipt(&args.method, params, Some(network), None) + .await + .map_err(map_paid_error)?; + + if args.receipt { + // The receipt is data: it goes to stdout, opted into explicitly since + // it changes the output shape. `null` on x402 (no settlement + // reference exists in that protocol). + let receipt = resp.payment_receipt.map(|r| { + serde_json::json!({ + "method": r.method, + "status": r.status, + "timestamp": r.timestamp, + "reference": r.reference, + }) + }); + super::emit_result( + &ctx, + &serde_json::json!({ + "result": resp.result, + "payment_receipt": receipt, + }), + ) + } else { + // Identical output shape to an unpaid call. + super::emit_result(&ctx, &resp.result) + } +} + +/// Entry point from `run_call` once `--x402-drawdown` is present. Pays for the +/// call from prepaid x402 credits: no per-call signing, 1 credit per success. +/// +/// A missing/expired session JWT is re-authenticated transparently (free), and +/// a `token_expired` 401 on the call triggers exactly ONE re-auth + retry — +/// that path signs nothing and draws no credit, so it is not a paid retry. The +/// credit-drawing call itself is single-attempt. +pub(super) async fn run_drawdown_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + check_single_stdin(&args)?; + // The query chain, required here so the error can explain the distinction. + let Some(network) = args.network.clone() else { + return Err(CliError::Arg( + "--x402-drawdown requires --network: the chain the call queries, as \ + the payment gateway's path slug (e.g. --network base-sepolia)." + .to_string(), + )); + }; + + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_drawdown_config( + &args, + &network, + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + + let params = super::parse_params(args.params.as_deref(), args.params_file.as_deref())?; + + let ctx = Ctx::from_global_keyless_payment(global.clone(), payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + + let session = ensure_gateway_session(&ctx, &global).await?; + + // ONE credit-drawing attempt. A token_expired 401 is the sole exception: + // it drew no credit, so we re-auth once and retry (mirrors the tooling + // lane's reactive 401 refresh). + let result = match ctx + .sdk + .rpc + .gateway_drawdown_call(&args.method, params.clone(), &network, &session) + .await + { + Ok(v) => v, + Err(e) if is_token_expired(&e) => { + let fresh = reauthenticate(&ctx, &global).await?; + match ctx + .sdk + .rpc + .gateway_drawdown_call(&args.method, params, &network, &fresh) + .await + { + Ok(v) => v, + Err(e) => return Err(drawdown_failure(&ctx, &args, &network, e)), + } + } + Err(e) => return Err(drawdown_failure(&ctx, &args, &network, e)), + }; + + super::emit_result(&ctx, &result) +} + +/// Returns a valid gateway session for the configured wallet, authenticating +/// (and caching) when there's no fresh cached JWT. Free — no funds move — so no +/// confirmation. Keyed by the wallet's on-chain address (derived offline), so +/// the cache lookup is a single local read. Shared by `qn rpc x402 …` and the +/// `--x402-drawdown` call lane. +pub(super) async fn ensure_gateway_session( + ctx: &Ctx, + global: &GlobalArgs, +) -> Result { + let sessions_path = config::sessions_cache_path(global.resolve_config_path().as_deref()); + let address = ctx.sdk.rpc.payment_address()?; + + if let Some(path) = &sessions_path { + if let Some(existing) = config::load_gateway_session_by_address(path, &address) { + if existing.is_fresh(SESSION_MARGIN_SECS) { + return Ok(existing); + } + } + } + reauthenticate(ctx, global).await +} + +/// Authenticates a fresh session and caches it, unconditionally (bypassing the +/// cache). Used on first use and on a `token_expired` retry. +async fn reauthenticate(ctx: &Ctx, global: &GlobalArgs) -> Result { + let sessions_path = config::sessions_cache_path(global.resolve_config_path().as_deref()); + let address = ctx.sdk.rpc.payment_address()?; + let session = ctx.sdk.rpc.gateway_authenticate().await?; + if let Some(path) = &sessions_path { + let _ = config::save_gateway_session(path, &address, &session); + } + Ok(session) +} + +/// True when a gateway error is an expired/invalid session token — the one case +/// worth a transparent re-auth (it drew no credit). The gateway surfaces this +/// as a 401 OR a 403 (see the drawdown SDK docs), so match both. +fn is_token_expired(e: &SdkError) -> bool { + matches!( + e, + SdkError::Api { status, body } + if matches!(status.as_u16(), 401 | 403) + && (body.contains("token_expired") || body.contains("invalid_token")) + ) +} + +/// The gateway refused because the credit balance is empty (nothing settled). +fn is_out_of_credits(e: &SdkError) -> bool { + matches!( + e, + SdkError::Api { status, body } + if status.as_u16() == 402 + || body.contains("insufficient_credits") + || body.contains("no_credits") + ) +} + +/// Handles a drawdown-call failure: when the refusal is an empty credit +/// balance, first print the balance-check command (stderr) in the same bold +/// multi-line format as the other chained hints, then map to the CLI error. +fn drawdown_failure(ctx: &Ctx, args: &CallArgs, network: &str, e: SdkError) -> CliError { + if is_out_of_credits(&e) { + let wallet = args.payment_wallet.as_deref().unwrap_or(""); + let pay_net = args.payment_network.as_deref().unwrap_or(network); + ctx.out.note(&format!( + "{}\n\n{}\n", + style("Check balance:", Style::Bold, ctx.out.color), + style( + &format!( + " qn rpc x402 balance \\\n \ + --payment-wallet {wallet} \\\n \ + --payment-network {pay_net}" + ), + Style::Bold, + ctx.out.color, + ), + )); + } + map_drawdown_error(e) +} + +/// Maps a drawdown-call failure onto an actionable CLI error. An empty-credits +/// or monthly-limit gateway refusal points forward at the fixing verb; because +/// nothing settled, both map to exit 2 (`PaymentRefused`), not the generic +/// arg-error bucket. Every other SDK error keeps its normal exit-code mapping. +fn map_drawdown_error(e: SdkError) -> CliError { + if is_out_of_credits(&e) { + return CliError::PaymentRefused( + "out of x402 credits. Buy more with 'qn rpc x402 buy-credits', \ + then retry this call." + .to_string(), + ); + } + if let SdkError::Api { body, .. } = &e { + if body.contains("monthly_limit_reached") { + return CliError::PaymentRefused( + "the account's monthly x402 limit was reached; no credits were \ + drawn. Try again after the limit resets." + .to_string(), + ); + } + } + e.into() +} + +/// Entry point from `run_call` once `--mpp-session` is present. Pays for the +/// call from an open MPP channel with a cumulative EIP-712 voucher: no on-chain +/// tx per call. Single-attempt (a paid lane never blind-retries). +/// +/// Requires an already-open channel for this wallet + query network (from +/// `qn rpc mpp open`); a missing channel or one whose deposit is exhausted +/// surfaces an actionable error pointing at `mpp open` / `mpp top-up`. +pub(super) async fn run_session_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + check_single_stdin(&args)?; + let Some(network) = args.network.clone() else { + return Err(CliError::Arg( + "--mpp-session requires --network: the chain the call queries, as \ + the payment gateway's path slug (e.g. --network tempo-testnet)." + .to_string(), + )); + }; + + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_payment_params( + "mpp", + &args.payment_params(), + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + let params = super::parse_params(args.params.as_deref(), args.params_file.as_deref())?; + + let ctx = Ctx::from_global_keyless_payment(global.clone(), payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + + let address = ctx.sdk.rpc.payment_address()?; + let channels_path = config::channels_cache_path(global.resolve_config_path().as_deref()); + let mut channel = channels_path + .as_deref() + .and_then(|p| config::load_channel(p, &address, &network)) + .ok_or_else(|| { + CliError::Arg(format!( + "no open MPP channel for this wallet on {network}. Open one with \ + 'qn rpc mpp open --network {network} --deposit '." + )) + })?; + + // Advance the cumulative by one per-call unit. Refuse (before any signing) + // when the deposit can't cover it: point at top-up. + let new_cumulative = channel.cumulative_spent.saturating_add(channel.per_call); + if new_cumulative > channel.deposit { + return Err(CliError::Arg(format!( + "MPP channel deposit exhausted (deposit {}, would need {}). Top up \ + with 'qn rpc mpp top-up --network {network} --deposit '.", + channel.deposit, new_cumulative + ))); + } + + let result = ctx + .sdk + .rpc + .mpp_session_call(&args.method, params, &network, &channel, new_cumulative) + .await + .map_err(map_session_error)?; + + // The voucher was accepted: advance and persist the local high-water mark. + channel.cumulative_spent = new_cumulative; + if let Some(path) = &channels_path { + let _ = config::save_channel(path, &address, &network, &channel); + } + + super::emit_result(&ctx, &result) +} + +/// Maps a session-call failure onto an actionable CLI error. A refusal to +/// accept the voucher for want of deposit points at `mpp top-up`; everything +/// else keeps its normal exit-code mapping. +fn map_session_error(e: SdkError) -> CliError { + if let SdkError::Api { status, body } = &e { + if status.as_u16() == 402 + || body.contains("amount-exceeds-deposit") + || body.contains("AmountExceedsDeposit") + || body.contains("insufficient") + { + return CliError::PaymentRefused( + "the MPP channel can't cover this call. Top up with \ + 'qn rpc mpp top-up', or open a new channel with 'qn rpc mpp open'." + .to_string(), + ); + } + } + map_paid_error(e) +} + +/// Loads `[rpc.payment]` from the resolved config file. A missing file is an +/// empty section; an unreadable/invalid file is a hard error, since payment +/// parameters the user set there would otherwise be silently ignored. +fn load_payment_section(global: &GlobalArgs) -> Result { + let Some(path) = global.resolve_config_path() else { + return Ok(PaymentSection::default()); + }; + Ok(config::load_from(&path)? + .map(|cfg| cfg.rpc.payment) + .unwrap_or_default()) +} + +/// Resolves the full payment configuration from flags, the injected env value, +/// and the config section — entirely before any network I/O, so every missing +/// or malformed input fails fast with an actionable message and zero requests +/// sent. Returns the SDK config, the query network, and an optional key-file +/// permissions warning for the caller to print once output exists. +/// The payment parameter stack shared by `qn rpc call --x402/--mpp` and the +/// gateway lifecycle verbs (`qn rpc x402 …`, `qn rpc mpp …`). Plain data, +/// resolved from flags (or the verb's own args) with `[rpc.payment]` fallback +/// by [`resolve_payment_params`]. +pub(super) struct PaymentParams<'a> { + pub key_file: Option<&'a Path>, + pub wallet: Option<&'a str>, + pub max_amount: Option<&'a str>, + pub payment_network: Option<&'a str>, + pub payment_asset: Option<&'a str>, + pub svm_rpc_url: Option<&'a str>, +} + +/// The subset of the payment stack a keyless session needs: a wallet key and a +/// SIWX pay network, plus an optional SVM RPC URL for Solana auth. The gateway +/// `balance`/`drip` verbs present a Bearer JWT and sign nothing, so they read +/// this rather than [`PaymentParams`] — no asset, no spend ceiling. +pub(super) struct SessionParams<'a> { + pub key_file: Option<&'a Path>, + pub wallet: Option<&'a str>, + pub payment_network: Option<&'a str>, + pub svm_rpc_url: Option<&'a str>, +} + +impl CallArgs { + fn payment_params(&self) -> PaymentParams<'_> { + PaymentParams { + key_file: self.payment_key_file.as_deref(), + wallet: self.payment_wallet.as_deref(), + max_amount: self.max_amount.as_deref(), + payment_network: self.payment_network.as_deref(), + payment_asset: self.payment_asset.as_deref(), + svm_rpc_url: self.svm_rpc_url.as_deref(), + } + } +} + +fn resolve_payment_config( + args: &CallArgs, + section: &PaymentSection, + wallets_dir: Option<&Path>, + base_url_override: Option, +) -> Result<(PaymentConfig, String, Option), CliError> { + // Scheme comes from which flag is set; clap's ArgGroup guarantees at most + // one, and run_paid_call only runs when one is present. + let scheme = if args.x402 { "x402" } else { "mpp" }; + + // The query chain. Required, but enforced here rather than via clap so the + // error can explain the query-chain vs pay-chain distinction. + let Some(network) = args.network.clone() else { + return Err(CliError::Arg(format!( + "--{scheme} requires --network: the chain the call queries, as the \ + payment gateway's path slug (e.g. --network base-sepolia). This is \ + separate from --payment-network, the chain the payment settles on." + ))); + }; + + let payment = resolve_payment_params( + scheme, + &args.payment_params(), + section, + wallets_dir, + base_url_override, + )?; + let key_file_warning = payment.1; + Ok((payment.0, network, key_file_warning)) +} + +/// Resolves the minimal config a drawdown call needs. Unlike the per-request +/// lane, a drawdown call signs nothing per request (it presents a Bearer JWT), +/// so only the wallet key and the SIWX pay network matter — `--payment-asset` +/// and `--max-amount` are irrelevant and not required. The pay network defaults +/// to the query `--network` (the common case where you pay on the chain you +/// query), so a drawdown call needs only `--payment-wallet`. +fn resolve_drawdown_config( + args: &CallArgs, + query_network: &str, + section: &PaymentSection, + wallets_dir: Option<&Path>, + base_url_override: Option, +) -> Result<(PaymentConfig, Option), CliError> { + if section.key.is_some() { + return Err(CliError::Arg( + "[rpc.payment] does not accept an inline `key`; store the key in a \ + file and set `key_file = \"\"` instead" + .to_string(), + )); + } + let (key, key_file_warning) = resolve_key( + args.payment_key_file.as_deref(), + args.payment_wallet.as_deref(), + section.key_file.as_deref(), + section.wallet.as_deref(), + wallets_dir, + )?; + + // Pay network: explicit flag/config, else default to the query network. + let payment_network = args + .payment_network + .clone() + .or_else(|| section.payment_network.clone()) + .unwrap_or_else(|| query_network.to_string()); + let payment_network = super::pay_network::resolve(&payment_network)?; + + let svm_rpc_url = match args + .svm_rpc_url + .clone() + .or_else(|| section.svm_rpc_url.clone()) + { + Some(u) => Some(crate::context::validate_endpoint_url(&u)?), + None => None, + }; + + Ok(( + PaymentConfig { + scheme: "x402".to_string(), + key, + pay_network: payment_network, + // asset + max_amount are unused by the Bearer drawdown call (nothing + // is signed per request); placeholders keep the SDK config total. + asset: String::new(), + max_amount: "0".to_string(), + svm_rpc_url, + base_url_override, + }, + key_file_warning, + )) +} + +/// Resolves the minimal config a keyless gateway session needs (`x402 balance`, +/// `x402 drip`). These verbs authenticate a SIWX session and present a Bearer +/// JWT — they sign nothing per request — so only the wallet key and the SIWX +/// pay network matter. `asset`/`max_amount` are supplied as placeholders (the +/// same construction as [`resolve_drawdown_config`]); the CLI does not accept +/// `--payment-asset`/`--max-amount` for these verbs. +pub(super) fn resolve_session_params( + params: &SessionParams<'_>, + section: &PaymentSection, + wallets_dir: Option<&Path>, + base_url_override: Option, +) -> Result<(PaymentConfig, Option), CliError> { + if section.key.is_some() { + return Err(CliError::Arg( + "[rpc.payment] does not accept an inline `key`; store the key in a \ + file and set `key_file = \"\"` instead" + .to_string(), + )); + } + + let (key, key_file_warning) = resolve_key( + params.key_file, + params.wallet, + section.key_file.as_deref(), + section.wallet.as_deref(), + wallets_dir, + )?; + + let payment_network = params + .payment_network + .map(str::to_string) + .or_else(|| section.payment_network.clone()) + .ok_or_else(|| { + CliError::Arg( + "no payment network set. Pass --payment-network (a \ + network name like base-sepolia, or a CAIP-2 id like \ + eip155:84532) or set `payment_network` under [rpc.payment]" + .to_string(), + ) + })?; + let payment_network = super::pay_network::resolve(&payment_network)?; + + let svm_rpc_url = match params + .svm_rpc_url + .map(str::to_string) + .or_else(|| section.svm_rpc_url.clone()) + { + Some(u) => Some(crate::context::validate_endpoint_url(&u)?), + None => None, + }; + + Ok(( + PaymentConfig { + scheme: "x402".to_string(), + key, + pay_network: payment_network, + // asset + max_amount are unused by the Bearer session (nothing is + // signed per request); placeholders keep the SDK config total. + asset: String::new(), + max_amount: "0".to_string(), + svm_rpc_url, + base_url_override, + }, + key_file_warning, + )) +} + +/// Resolves the shared payment parameter stack (key, spend ceiling, pay +/// network, asset, SVM RPC URL) into an SDK [`PaymentConfig`] for `scheme`, +/// applying the flags-then-`[rpc.payment]` precedence. Returns the config plus +/// an optional key-file permissions warning. Does not touch the query network +/// (that is call-specific). +pub(super) fn resolve_payment_params( + scheme: &str, + params: &PaymentParams<'_>, + section: &PaymentSection, + wallets_dir: Option<&Path>, + base_url_override: Option, +) -> Result<(PaymentConfig, Option), CliError> { + // An inline raw key in config is never accepted — it belongs in a file. + if section.key.is_some() { + return Err(CliError::Arg( + "[rpc.payment] does not accept an inline `key`; store the key in a \ + file and set `key_file = \"\"` instead (the config file is \ + too easily shared to hold a raw wallet key)" + .to_string(), + )); + } + + let (key, key_file_warning) = resolve_key( + params.key_file, + params.wallet, + section.key_file.as_deref(), + section.wallet.as_deref(), + wallets_dir, + )?; + + let max_amount = params + .max_amount + .map(str::to_string) + .or_else(|| section.max_amount.clone()) + .ok_or_else(|| { + CliError::Arg( + "no spend ceiling set. Pass --max-amount or set \ + `max_amount` under [rpc.payment]. This is the most a single \ + call may pay, in integer base units of the asset (e.g. \ + 10000 = 0.01 USDC)" + .to_string(), + ) + })?; + if max_amount.parse::().is_err() { + return Err(CliError::Arg(format!( + "--max-amount must be a non-negative integer in the asset's base \ + units (e.g. 10000 = 0.01 USDC), got '{max_amount}'" + ))); + } + + let payment_network = params + .payment_network + .map(str::to_string) + .or_else(|| section.payment_network.clone()) + .ok_or_else(|| { + CliError::Arg( + "no payment network set. Pass --payment-network (a \ + network name like base-sepolia, or a CAIP-2 id like \ + eip155:84532) or set `payment_network` under [rpc.payment]" + .to_string(), + ) + })?; + let payment_network = super::pay_network::resolve(&payment_network)?; + + let payment_asset = params + .payment_asset + .map(str::to_string) + .or_else(|| section.payment_asset.clone()) + .ok_or_else(|| { + CliError::Arg( + "no payment asset set. Pass --payment-asset
(a token \ + contract or mint to pay with, or a symbol like USDC) or set \ + `payment_asset` under [rpc.payment]" + .to_string(), + ) + })?; + let payment_asset = super::pay_asset::resolve(&payment_asset, &payment_network)?; + + let svm_rpc_url = match params + .svm_rpc_url + .map(str::to_string) + .or_else(|| section.svm_rpc_url.clone()) + { + Some(u) => Some(crate::context::validate_endpoint_url(&u)?), + None => None, + }; + + Ok(( + PaymentConfig { + scheme: scheme.to_string(), + key, + pay_network: payment_network, + asset: payment_asset, + max_amount, + svm_rpc_url, + base_url_override, + }, + key_file_warning, + )) +} + +/// Resolves the raw private key from a file only — never an env var and never +/// a raw key on the command line. Precedence: `--payment-key-file` (a path, or +/// `-` for stdin) > `--payment-wallet` (a stored wallet name) > config +/// `key_file` > config `wallet`. Returns the key and an optional permissions +/// warning (group/world-readable key file). Error messages name the path or +/// wallet, never the file contents. +fn resolve_key( + flag_file: Option<&Path>, + flag_wallet: Option<&str>, + config_file: Option<&Path>, + config_wallet: Option<&str>, + wallets_dir: Option<&Path>, +) -> Result<(String, Option), CliError> { + if let Some(path) = flag_file { + if path.as_os_str() == "-" { + let key = super::read_stdin("the payment key")?; + return checked_key(key, "stdin").map(|k| (k, None)); + } + return read_key_file(path); + } + if let Some(name) = flag_wallet { + return read_key_file(&crate::commands::wallet::key_path(name, wallets_dir)?); + } + if let Some(path) = config_file { + return read_key_file(path); + } + if let Some(name) = config_wallet { + return read_key_file(&crate::commands::wallet::key_path(name, wallets_dir)?); + } + Err(CliError::Arg( + "no payment key found. Pass --payment-key-file (or '-' for \ + stdin), --payment-wallet (from 'qn wallet generate'), or \ + set `key_file`/`wallet` under [rpc.payment]" + .to_string(), + )) +} + +/// Reads and validates a key file, plus an ssh-style permissions warning when +/// the file is group- or world-readable. +fn read_key_file(path: &Path) -> Result<(String, Option), CliError> { + let raw = std::fs::read_to_string(path).map_err(|e| { + CliError::Arg(format!( + "could not read payment key file '{}': {e}", + path.display() + )) + })?; + let key = checked_key(raw, &format!("'{}'", path.display()))?; + + #[cfg(unix)] + let warning = { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .ok() + .map(|m| m.permissions().mode()) + .filter(|mode| mode & 0o077 != 0) + .map(|_| { + format!( + "⚠ payment key file '{}' is readable by other users; \ + consider `chmod 600`", + path.display() + ) + }) + }; + #[cfg(not(unix))] + let warning = None; + + Ok((key, warning)) +} + +/// Trims and rejects an empty key. `source` names where the key came from +/// (a path, `stdin`, or the env var) — never its contents. +fn checked_key(raw: String, source: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliError::Arg(format!( + "the payment key from {source} is empty" + ))); + } + Ok(trimmed.to_string()) +} + +/// Maps a paid-call failure onto the paid exit-code contract: 2 = the gateway +/// refused and nothing settled, 3 = outcome unknown, check the wallet. +/// +/// - `Decode` on the paid lane always means the gateway's post-payment 2xx +/// response could not be interpreted (the SDK classifies pre-payment parse +/// failures as `PaymentUnsupported`/`Config`) — the payment may already +/// have settled, so it gets the same never-blindly-retry treatment as +/// `PaymentIndeterminate` (exit 3). +/// - `PaymentRejected` with a 5xx status is a gateway/settlement failure +/// after the signed payment was submitted — also unknown, exit 3. A 4xx +/// rejection means the gateway refused the credential without settling it +/// and passes through to exit 2. +pub(super) fn map_paid_error(e: SdkError) -> CliError { + match &e { + SdkError::Decode { .. } => CliError::PaymentMaybeCharged(e), + SdkError::PaymentRejected { status, .. } if *status >= 500 => { + CliError::PaymentMaybeCharged(e) + } + _ => e.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn paid_args(x402: bool) -> CallArgs { + CallArgs { + method: "eth_blockNumber".to_string(), + params: None, + params_file: None, + network: Some("base-sepolia".to_string()), + endpoint_url: None, + x402, + mpp: !x402, + x402_drawdown: false, + mpp_session: false, + payment_key_file: None, + payment_wallet: None, + max_amount: Some("10000".to_string()), + payment_network: Some("eip155:84532".to_string()), + payment_asset: Some("0xabc".to_string()), + svm_rpc_url: None, + receipt: false, + } + } + + fn empty_section() -> PaymentSection { + PaymentSection::default() + } + + fn key_file_with(contents: &str) -> tempfile::NamedTempFile { + use std::io::Write; + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(contents.as_bytes()).unwrap(); + f.flush().unwrap(); + f + } + + /// `paid_args` with a valid key file attached, so a test can reach the + /// parameter-validation assertions without caring about the key source. + /// Returns the (args, key-file guard) pair — keep the guard alive. + fn paid_args_with_key(x402: bool) -> (CallArgs, tempfile::NamedTempFile) { + let f = key_file_with("0xkey\n"); + let mut args = paid_args(x402); + args.payment_key_file = Some(f.path().to_path_buf()); + (args, f) + } + + #[test] + fn resolves_full_config_from_flags_and_key_file() { + let (args, _f) = paid_args_with_key(true); + let (cfg, network, _) = + resolve_payment_config(&args, &empty_section(), None, None).unwrap(); + assert_eq!(cfg.scheme, "x402"); + assert_eq!(cfg.key, "0xkey"); // trimmed + assert_eq!(cfg.pay_network, "eip155:84532"); + assert_eq!(cfg.asset, "0xabc"); + assert_eq!(cfg.max_amount, "10000"); + assert_eq!(network, "base-sepolia"); + } + + #[test] + fn pay_network_name_resolves_to_caip2() { + let (mut args, _f) = paid_args_with_key(true); + args.payment_network = Some("base-sepolia".to_string()); + let (cfg, _, _) = resolve_payment_config(&args, &empty_section(), None, None).unwrap(); + assert_eq!(cfg.pay_network, "eip155:84532"); + } + + #[test] + fn config_pay_network_name_resolves_too() { + let mut section = empty_section(); + section.payment_network = Some("solana-devnet".to_string()); + let (mut args, _f) = paid_args_with_key(true); + args.payment_network = None; + let (cfg, _, _) = resolve_payment_config(&args, §ion, None, None).unwrap(); + assert_eq!(cfg.pay_network, "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"); + } + + #[test] + fn unknown_pay_network_name_is_an_arg_error() { + let (mut args, _f) = paid_args_with_key(true); + args.payment_network = Some("btc".to_string()); + let err = resolve_payment_config(&args, &empty_section(), None, None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("unknown pay network 'btc'"), "got: {msg}"); + } + + #[test] + fn mpp_flag_selects_mpp_scheme() { + let (args, _f) = paid_args_with_key(false); + let (cfg, _, _) = resolve_payment_config(&args, &empty_section(), None, None).unwrap(); + assert_eq!(cfg.scheme, "mpp"); + } + + #[test] + fn missing_network_names_both_flags() { + let (mut args, _f) = paid_args_with_key(true); + args.network = None; + let err = resolve_payment_config(&args, &empty_section(), None, None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("--network"), "got: {msg}"); + assert!(msg.contains("--payment-network"), "got: {msg}"); + } + + #[test] + fn flag_key_file_beats_config_key_file() { + let flag = key_file_with("0xfromflag\n"); + let cfg_f = key_file_with("0xfromconfig"); + let mut section = empty_section(); + section.key_file = Some(cfg_f.path().to_path_buf()); + let mut args = paid_args(true); + args.payment_key_file = Some(flag.path().to_path_buf()); + let (cfg, _, _) = resolve_payment_config(&args, §ion, None, None).unwrap(); + assert_eq!(cfg.key, "0xfromflag"); // and trimmed + } + + #[test] + fn config_key_file_used_when_nothing_else() { + let f = key_file_with("0xfromconfig"); + let mut section = empty_section(); + section.key_file = Some(f.path().to_path_buf()); + let args = paid_args(true); + let (cfg, _, _) = resolve_payment_config(&args, §ion, None, None).unwrap(); + assert_eq!(cfg.key, "0xfromconfig"); + } + + #[test] + fn payment_wallet_flag_resolves_to_store_key() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("payer"), "0xfromwallet\n").unwrap(); + let mut args = paid_args(true); + args.payment_wallet = Some("payer".to_string()); + let (cfg, _, _) = + resolve_payment_config(&args, &empty_section(), Some(dir.path()), None).unwrap(); + assert_eq!(cfg.key, "0xfromwallet"); + } + + #[test] + fn flag_key_file_beats_payment_wallet() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("payer"), "0xfromwallet").unwrap(); + let flag = key_file_with("0xfromflag"); + let mut args = paid_args(true); + args.payment_key_file = Some(flag.path().to_path_buf()); + args.payment_wallet = Some("payer".to_string()); + let (cfg, _, _) = + resolve_payment_config(&args, &empty_section(), Some(dir.path()), None).unwrap(); + assert_eq!(cfg.key, "0xfromflag"); + } + + #[test] + fn config_wallet_used_as_last_resort() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("saved"), "0xfromcfgwallet").unwrap(); + let mut section = empty_section(); + section.wallet = Some("saved".to_string()); + let args = paid_args(true); + let (cfg, _, _) = resolve_payment_config(&args, §ion, Some(dir.path()), None).unwrap(); + assert_eq!(cfg.key, "0xfromcfgwallet"); + } + + #[test] + fn unknown_payment_wallet_is_actionable() { + let dir = tempfile::tempdir().unwrap(); + let mut args = paid_args(true); + args.payment_wallet = Some("ghost".to_string()); + let err = + resolve_payment_config(&args, &empty_section(), Some(dir.path()), None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("no wallet named 'ghost'"), "got: {msg}"); + } + + #[test] + fn payment_wallet_rejects_unsafe_name() { + let dir = tempfile::tempdir().unwrap(); + let mut args = paid_args(true); + args.payment_wallet = Some("../escape".to_string()); + let err = + resolve_payment_config(&args, &empty_section(), Some(dir.path()), None).unwrap_err(); + assert!( + err.to_string().contains("invalid wallet name"), + "got: {err}" + ); + } + + #[test] + fn no_key_anywhere_is_actionable() { + let args = paid_args(true); + let err = resolve_payment_config(&args, &empty_section(), None, None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("--payment-key-file"), "got: {msg}"); + assert!(msg.contains("--payment-wallet"), "got: {msg}"); + assert!(msg.contains("key_file"), "got: {msg}"); + // The env var is gone; the message must not resurrect it. + assert!(!msg.contains("QN_PAYMENT_KEY"), "got: {msg}"); + } + + #[test] + fn unreadable_key_file_names_the_path() { + let mut args = paid_args(true); + args.payment_key_file = Some("/does/not/exist.key".into()); + let err = resolve_payment_config(&args, &empty_section(), None, None).unwrap_err(); + assert!(err.to_string().contains("/does/not/exist.key")); + } + + #[test] + fn empty_key_file_is_rejected_without_leaking_contents() { + let f = key_file_with(" \n"); + let mut args = paid_args(true); + args.payment_key_file = Some(f.path().to_path_buf()); + let err = resolve_payment_config(&args, &empty_section(), None, None).unwrap_err(); + assert!(err.to_string().contains("empty"), "got: {err}"); + } + + #[test] + fn inline_config_key_is_rejected_with_key_file_pointer() { + let mut section = empty_section(); + section.key = Some(toml::Value::String("0xraw".to_string())); + let (args, _f) = paid_args_with_key(true); + let err = resolve_payment_config(&args, §ion, None, None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("key_file"), "got: {msg}"); + assert!(!msg.contains("0xraw"), "must not echo the key: {msg}"); + } + + #[test] + fn missing_max_amount_is_actionable() { + let (mut args, _f) = paid_args_with_key(true); + args.max_amount = None; + let err = resolve_payment_config(&args, &empty_section(), None, None).unwrap_err(); + assert!(err.to_string().contains("--max-amount"), "got: {err}"); + } + + #[test] + fn non_integer_max_amount_is_rejected() { + for bad in ["1.5", "abc", "-1", "1_000"] { + let (mut args, _f) = paid_args_with_key(true); + args.max_amount = Some(bad.to_string()); + let err = resolve_payment_config(&args, &empty_section(), None, None).unwrap_err(); + assert!(err.to_string().contains("base units"), "for {bad}: {err}"); + } + } + + #[test] + fn flag_max_amount_beats_config() { + let mut section = empty_section(); + section.max_amount = Some("999999".to_string()); + let (args, _f) = paid_args_with_key(true); // flag says 10000 + let (cfg, _, _) = resolve_payment_config(&args, §ion, None, None).unwrap(); + assert_eq!(cfg.max_amount, "10000"); + } + + #[test] + fn config_fills_all_missing_params() { + let f = key_file_with("0xk"); + let section = PaymentSection { + key_file: Some(f.path().to_path_buf()), + wallet: None, + key: None, + max_amount: Some("5000".to_string()), + payment_network: Some("eip155:42431".to_string()), + payment_asset: Some("0xdef".to_string()), + svm_rpc_url: None, + }; + let mut args = paid_args(false); + args.max_amount = None; + args.payment_network = None; + args.payment_asset = None; + let (cfg, _, _) = resolve_payment_config(&args, §ion, None, None).unwrap(); + assert_eq!(cfg.scheme, "mpp"); + assert_eq!(cfg.max_amount, "5000"); + assert_eq!(cfg.pay_network, "eip155:42431"); + assert_eq!(cfg.asset, "0xdef"); + } + + #[test] + fn base_url_override_is_threaded_through() { + let (args, _f) = paid_args_with_key(true); + let (cfg, _, _) = resolve_payment_config( + &args, + &empty_section(), + None, + Some("http://127.0.0.1:9999".to_string()), + ) + .unwrap(); + assert_eq!( + cfg.base_url_override.as_deref(), + Some("http://127.0.0.1:9999") + ); + } + + #[test] + fn invalid_svm_rpc_url_is_rejected() { + let (mut args, _f) = paid_args_with_key(true); + args.svm_rpc_url = Some("ftp://nope".to_string()); + let err = resolve_payment_config(&args, &empty_section(), None, None).unwrap_err(); + assert!(err.to_string().contains("scheme"), "got: {err}"); + } + + #[cfg(unix)] + #[test] + fn world_readable_key_file_produces_warning() { + use std::os::unix::fs::PermissionsExt; + let f = key_file_with("0xk"); + std::fs::set_permissions(f.path(), std::fs::Permissions::from_mode(0o644)).unwrap(); + let (_, warning) = read_key_file(f.path()).unwrap(); + assert!(warning.is_some()); + let w = warning.unwrap(); + assert!(w.contains("chmod 600"), "got: {w}"); + assert!(!w.contains("0xk"), "must not leak contents: {w}"); + } + + #[cfg(unix)] + #[test] + fn private_key_file_produces_no_warning() { + use std::os::unix::fs::PermissionsExt; + let f = key_file_with("0xk"); + std::fs::set_permissions(f.path(), std::fs::Permissions::from_mode(0o600)).unwrap(); + let (_, warning) = read_key_file(f.path()).unwrap(); + assert!(warning.is_none(), "got: {warning:?}"); + } + + #[test] + fn decode_error_maps_to_payment_maybe_charged() { + let decode = SdkError::Decode { + source: serde_json::from_str::("x").unwrap_err(), + body: "gateway 5xx html".to_string(), + }; + let mapped = map_paid_error(decode); + assert!(matches!(mapped, CliError::PaymentMaybeCharged(_))); + assert_eq!(crate::errors::exit_code_for(&mapped), 3); + } + + #[test] + fn rejected_error_passes_through_to_exit_2() { + let rejected = SdkError::PaymentRejected { + status: 402, + body: "bad sig".to_string(), + }; + let mapped = map_paid_error(rejected); + assert_eq!(crate::errors::exit_code_for(&mapped), 2); + } + + #[test] + fn rejected_5xx_maps_to_payment_maybe_charged() { + // A settlement failure after the signed payment was submitted: the + // outcome is unknown, so it must NOT land in the "refused" bucket. + for status in [500, 502, 503] { + let rejected = SdkError::PaymentRejected { + status, + body: "settlement error".to_string(), + }; + let mapped = map_paid_error(rejected); + assert!( + matches!(mapped, CliError::PaymentMaybeCharged(_)), + "status {status} mapped to {mapped:?}" + ); + assert_eq!(crate::errors::exit_code_for(&mapped), 3); + } + } +} diff --git a/src/commands/rpc/supported_networks.rs b/src/commands/rpc/supported_networks.rs new file mode 100644 index 0000000..84e588a --- /dev/null +++ b/src/commands/rpc/supported_networks.rs @@ -0,0 +1,510 @@ +//! `qn rpc {x402,mpp} supported-networks` and `supported-payments` — one +//! payment gateway's discovery lists, one per verb: the networks you can make +//! paid RPC calls **to**, and the payment options the gateway accepts (the +//! network, token, and contract address you pay **with**). +//! +//! Keyless and public: this reads the gateway's own discovery surfaces, not +//! the account API. Callable networks come from `{gateway}/networks`. Payment +//! options come from `x402.quicknode.com/supported` for x402; the MPP gateway +//! publishes them in the `WWW-Authenticate: Payment` challenge of a keyless +//! 402 response, so `supported-payments` probes one callable network without +//! paying. Each list is cached per scheme in `pay-networks.toml` next to the +//! config with a 24h TTL, mirroring the multichain URL cache. A callable +//! network is a valid `--network` for a paid call; a payment option's +//! network/address pair is a ready `--payment-network`/`--payment-asset`. + +use std::collections::BTreeMap; +use std::path::Path; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use serde::Deserialize; + +use crate::config::{self, PayAssetEntry}; +use crate::context::{Ctx, GlobalArgs}; +use crate::errors::CliError; +use crate::output::{new_table, set_header_bold, write_table, OutputCtx, Render}; + +const X402_BASE: &str = "https://x402.quicknode.com"; +const MPP_BASE: &str = "https://mpp.quicknode.com"; + +/// Which payment gateway the command reads. +#[derive(Clone, Copy)] +pub(super) enum Scheme { + X402, + Mpp, +} + +impl Scheme { + fn as_str(self) -> &'static str { + match self { + Scheme::X402 => "x402", + Scheme::Mpp => "mpp", + } + } + + fn gateway_base(self) -> &'static str { + match self { + Scheme::X402 => X402_BASE, + Scheme::Mpp => MPP_BASE, + } + } +} + +/// Cache/override plumbing shared by both verbs. A `--base-url` override +/// points the gateway fetches at one host (used by tests to serve the +/// discovery endpoints from a mock) and bypasses the cache, since a test +/// host's data isn't the real catalog. +struct Discovery { + ctx: Ctx, + scheme: Scheme, + base: String, + cache_path: Option, + use_cache: bool, +} + +impl Discovery { + fn new(scheme: Scheme, global: GlobalArgs) -> Result { + let base_override = global.base_url.clone(); + let ctx = Ctx::from_global_keyless(global)?; + let cache_path = + config::pay_networks_cache_path(ctx.global.resolve_config_path().as_deref()); + let use_cache = base_override.is_none(); + let base = base_override.unwrap_or_else(|| scheme.gateway_base().to_string()); + Ok(Self { + ctx, + scheme, + base, + cache_path, + use_cache, + }) + } + + fn cache_path(&self) -> Option<&Path> { + self.use_cache + .then_some(self.cache_path.as_deref()) + .flatten() + } + + /// The callable-networks list: fresh cache hit, or fetch + cache. + async fn ensure_networks(&self, client: &reqwest::Client) -> Result, CliError> { + if let Some(cached) = self + .cache_path() + .and_then(|p| config::load_pay_networks(p, self.scheme.as_str(), now_unix())) + { + return Ok(cached); + } + let networks = fetch_networks(client, &self.base).await?; + if let Some(p) = self.cache_path() { + let _ = config::save_pay_networks(p, self.scheme.as_str(), now_unix(), &networks); + } + Ok(networks) + } + + /// The payment-options list: fresh cache hit, or fetch + cache. The MPP + /// probe needs a callable network, which reuses the networks cache. + async fn ensure_payments( + &self, + client: &reqwest::Client, + ) -> Result, CliError> { + if let Some(cached) = self + .cache_path() + .and_then(|p| config::load_pay_payments(p, self.scheme.as_str(), now_unix())) + { + return Ok(cached); + } + let payments = match self.scheme { + Scheme::X402 => fetch_x402_payments(client, &self.base).await?, + Scheme::Mpp => { + let networks = self.ensure_networks(client).await?; + fetch_mpp_payments(client, &self.base, networks.first()).await? + } + }; + if let Some(p) = self.cache_path() { + let _ = config::save_pay_payments(p, self.scheme.as_str(), now_unix(), &payments); + } + Ok(payments) + } +} + +/// `qn rpc {x402,mpp} supported-networks`. +pub(super) async fn run_networks(scheme: Scheme, global: GlobalArgs) -> Result<(), CliError> { + let d = Discovery::new(scheme, global)?; + let networks = d.ensure_networks(&reqwest::Client::new()).await?; + crate::output::emit(&d.ctx.out, &NetworksView(networks)) +} + +/// `qn rpc {x402,mpp} supported-payments`. +pub(super) async fn run_payments(scheme: Scheme, global: GlobalArgs) -> Result<(), CliError> { + let d = Discovery::new(scheme, global)?; + let payments = d.ensure_payments(&reqwest::Client::new()).await?; + crate::output::emit(&d.ctx.out, &PaymentsView(payments)) +} + +/// GET `{base}/networks` → the `networks` slug array. +async fn fetch_networks(client: &reqwest::Client, base: &str) -> Result, CliError> { + #[derive(Deserialize)] + struct NetworksResp { + networks: Vec, + } + let url = format!("{base}/networks"); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| fetch_err(&url, e))?; + if !resp.status().is_success() { + return Err(CliError::Arg(format!( + "discovery request to {url} failed with HTTP {}", + resp.status().as_u16() + ))); + } + let body: NetworksResp = resp.json().await.map_err(|e| fetch_err(&url, e))?; + Ok(body.networks) +} + +/// GET x402 `/supported` → the accepted payment options, deduplicated by +/// (network, token address). The display name prefers our known symbol table; +/// otherwise the offer's `extra.name`, but only from offers without a +/// `verifyingContract` (those are Circle Gateway variants whose `name` is an +/// EIP-712 domain, not a token). +async fn fetch_x402_payments( + client: &reqwest::Client, + base: &str, +) -> Result, CliError> { + #[derive(Deserialize)] + struct Supported { + #[serde(default)] + accepts: Vec, + } + #[derive(Deserialize)] + struct Accept { + network: String, + #[serde(default)] + asset: Option, + #[serde(default)] + extra: Option, + } + #[derive(Deserialize)] + struct Extra { + #[serde(default)] + name: Option, + #[serde(default, rename = "verifyingContract")] + verifying_contract: Option, + } + + let url = format!("{base}/supported"); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| fetch_err(&url, e))?; + // The gateway serves this catalog x402-style: the payment-requirements + // JSON arrives with HTTP 402, not 200. Both carry the same shape. + let status = resp.status(); + if !status.is_success() && status.as_u16() != 402 { + return Err(CliError::Arg(format!( + "discovery request to {url} failed with HTTP {}", + status.as_u16() + ))); + } + let body: Supported = resp.json().await.map_err(|e| fetch_err(&url, e))?; + + // (CAIP-2 network, address) → best-known display name. + let mut merged: BTreeMap<(String, String), Option> = BTreeMap::new(); + for accept in body.accepts { + let Some(address) = accept.asset else { + continue; + }; + let offer_name = match accept.extra { + Some(Extra { + verifying_contract: None, + name, + }) => name, + _ => None, + }; + let name = super::pay_asset::symbol_for(&accept.network, &address).or(offer_name); + let slot = merged.entry((accept.network.clone(), address)).or_default(); + if slot.is_none() { + *slot = name; + } + } + + let mut out: Vec = merged + .into_iter() + .map(|((caip2, address), asset)| PayAssetEntry { + network: super::pay_network::slug_for_caip2(&caip2).unwrap_or(caip2), + asset, + address, + }) + .collect(); + out.sort_by(|a, b| (&a.network, &a.address).cmp(&(&b.network, &b.address))); + Ok(out) +} + +/// The MPP gateway lists its accepted payment options only in the 402 +/// challenge, so probe one callable network with a keyless request (no payment +/// is taken) and parse the `WWW-Authenticate: Payment` header. +async fn fetch_mpp_payments( + client: &reqwest::Client, + base: &str, + probe_slug: Option<&String>, +) -> Result, CliError> { + let Some(slug) = probe_slug else { + return Err(CliError::Arg( + "the gateway listed no callable networks to probe".to_string(), + )); + }; + let url = format!("{base}/{slug}"); + let resp = client + .post(&url) + .json(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": [] + })) + .send() + .await + .map_err(|e| fetch_err(&url, e))?; + let status = resp.status().as_u16(); + let header = resp + .headers() + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + CliError::Arg(format!( + "the probe of {url} returned HTTP {status} without a payment challenge" + )) + })?; + let challenges = parse_payment_challenges(header); + if challenges.is_empty() { + return Err(CliError::Arg(format!( + "could not parse the payment challenge from {url}" + ))); + } + + // (network slug, address) → best-known display name. + let mut merged: BTreeMap<(String, String), Option> = BTreeMap::new(); + for ch in challenges { + let Some(address) = ch.request.get("currency").and_then(|v| v.as_str()) else { + continue; + }; + let details = ch.request.get("methodDetails"); + let slug = match ch.method.as_str() { + "tempo" => { + let Some(id) = details + .and_then(|d| d.get("chainId")) + .and_then(|v| v.as_i64()) + else { + continue; + }; + let caip2 = format!("eip155:{id}"); + super::pay_network::slug_for_caip2(&caip2).unwrap_or(caip2) + } + "solana" => { + match details + .and_then(|d| d.get("network")) + .and_then(|v| v.as_str()) + { + Some("mainnet-beta") => "solana-mainnet".to_string(), + Some("devnet") => "solana-devnet".to_string(), + Some("testnet") => "solana-testnet".to_string(), + Some(other) => other.to_string(), + None => continue, + } + } + _ => continue, + }; + let caip2 = super::pay_network::resolve(&slug).unwrap_or_else(|_| slug.clone()); + let name = super::pay_asset::symbol_for(&caip2, address); + let slot = merged.entry((slug, address.to_string())).or_default(); + if slot.is_none() { + *slot = name; + } + } + + Ok(merged + .into_iter() + .map(|((network, address), asset)| PayAssetEntry { + network, + asset, + address, + }) + .collect()) +} + +/// One parsed `Payment` challenge: its `method` and decoded `request` JSON. +struct Challenge { + method: String, + request: serde_json::Value, +} + +/// Parses a `WWW-Authenticate` header carrying one or more `Payment` +/// challenges (`Payment k="v", k="v", ..., Payment k="v", ...`). Pragmatic, +/// not a full RFC 7235 grammar: a `Payment ` prefix on a comma-separated part +/// starts a new challenge, and only `method` and `request` are read. Malformed +/// entries are skipped. +fn parse_payment_challenges(header: &str) -> Vec { + let mut out = Vec::new(); + let mut method: Option = None; + let mut request: Option = None; + for raw in split_outside_quotes(header, ',') { + let mut part = raw.trim(); + if let Some(rest) = part.strip_prefix("Payment ") { + if let Some(c) = decode_challenge(method.take(), request.take()) { + out.push(c); + } + part = rest.trim_start(); + } + if let Some(v) = part.strip_prefix("method=") { + method = unquote(v); + } else if let Some(v) = part.strip_prefix("request=") { + request = unquote(v); + } + } + if let Some(c) = decode_challenge(method, request) { + out.push(c); + } + out +} + +/// Splits on `delim` occurrences outside double-quoted spans — quoted header +/// values may themselves contain commas. +fn split_outside_quotes(s: &str, delim: char) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let mut in_quotes = false; + for (i, c) in s.char_indices() { + match c { + '"' => in_quotes = !in_quotes, + c if c == delim && !in_quotes => { + parts.push(&s[start..i]); + start = i + delim.len_utf8(); + } + _ => {} + } + } + parts.push(&s[start..]); + parts +} + +fn unquote(s: &str) -> Option { + s.strip_prefix('"')?.strip_suffix('"').map(str::to_string) +} + +/// Base64url-decodes a challenge's `request` value into JSON. `None` on any +/// decode failure, so one malformed challenge never sinks the others. +fn decode_challenge(method: Option, request: Option) -> Option { + let method = method?; + let request = request?; + let bytes = URL_SAFE_NO_PAD.decode(request.trim_end_matches('=')).ok()?; + let json = serde_json::from_slice(&bytes).ok()?; + Some(Challenge { + method, + request: json, + }) +} + +/// `supported-networks` output: a bare array in JSON, one NETWORK column as a +/// table. +#[derive(serde::Serialize)] +#[serde(transparent)] +struct NetworksView(Vec); + +impl Render for NetworksView { + fn render_table(&self, w: &mut dyn std::io::Write, ctx: &OutputCtx) -> std::io::Result<()> { + if self.0.is_empty() { + return writeln!(w, "(none listed)"); + } + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["NETWORK"]); + for n in &self.0 { + t.add_row(vec![comfy_table::Cell::new(n)]); + } + write_table(w, &t) + } +} + +/// `supported-payments` output: a bare array in JSON, NETWORK/ASSET/ADDRESS +/// columns as a table. +#[derive(serde::Serialize)] +#[serde(transparent)] +struct PaymentsView(Vec); + +impl Render for PaymentsView { + fn render_table(&self, w: &mut dyn std::io::Write, ctx: &OutputCtx) -> std::io::Result<()> { + if self.0.is_empty() { + return writeln!(w, "(none listed)"); + } + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["NETWORK", "ASSET", "ADDRESS"]); + for e in &self.0 { + t.add_row(vec![ + comfy_table::Cell::new(&e.network), + crate::output::opt_cell(&e.asset), + comfy_table::Cell::new(&e.address), + ]); + } + write_table(w, &t) + } +} + +fn fetch_err(url: &str, e: reqwest::Error) -> CliError { + CliError::Arg(format!( + "could not fetch the gateway catalog from {url}: {e}" + )) +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b64(json: &str) -> String { + URL_SAFE_NO_PAD.encode(json.as_bytes()) + } + + #[test] + fn parses_multi_challenge_header() { + let tempo = + b64(r#"{"amount":"1000","currency":"0xabc","methodDetails":{"chainId":42431}}"#); + let solana = b64( + r#"{"amount":"0.001","currency":"Mint111","methodDetails":{"network":"mainnet-beta"}}"#, + ); + // The description stresses the splitter: a quoted comma and the word + // "Payment" inside a value must not start a new challenge. + let header = format!( + "Payment id=\"a\", realm=\"r\", method=\"tempo\", intent=\"charge\", \ + request=\"{tempo}\", description=\"Payment, per request\", \ + Payment id=\"b\", method=\"solana\", request=\"{solana}\"" + ); + let out = parse_payment_challenges(&header); + assert_eq!(out.len(), 2); + assert_eq!(out[0].method, "tempo"); + assert_eq!(out[0].request["methodDetails"]["chainId"], 42431); + assert_eq!(out[1].method, "solana"); + assert_eq!(out[1].request["currency"], "Mint111"); + } + + #[test] + fn skips_malformed_entries() { + let good = b64(r#"{"currency":"0xabc","methodDetails":{"chainId":4217}}"#); + // First challenge has an undecodable request; second is fine. + let header = format!( + "Payment method=\"tempo\", request=\"!!!not-base64!!!\", \ + Payment method=\"tempo\", request=\"{good}\"" + ); + let out = parse_payment_challenges(&header); + assert_eq!(out.len(), 1); + assert_eq!(out[0].request["methodDetails"]["chainId"], 4217); + } + + #[test] + fn header_without_challenges_parses_empty() { + assert!(parse_payment_challenges("Bearer realm=\"api\"").is_empty()); + } +} diff --git a/src/commands/rpc/x402.rs b/src/commands/rpc/x402.rs new file mode 100644 index 0000000..158b901 --- /dev/null +++ b/src/commands/rpc/x402.rs @@ -0,0 +1,505 @@ +//! `qn rpc x402 …` — the x402 credit-drawdown gateway lifecycle. +//! +//! Three verbs manage prepaid gateway credits, all keyless (paid by the +//! configured wallet, not an account API key): +//! - `buy-credits` — SIWX-authenticate, then settle the gateway's 402 credit +//! offer with the payment wallet. Gated Mild (it moves funds). +//! - `balance` — GET the account's current credit balance. +//! - `drip` — testnet faucet (Base Sepolia, once per account). +//! +//! The session JWT is authenticated on first use, cached under the config dir +//! (`sessions.toml`, 0600, keyed by the payer wallet's address), and re-seeded +//! next run — the same seed/export pattern as the Tooling Access token. A +//! missing/expired session re-authenticates transparently: it moves no funds, +//! so it needs no confirmation. +//! +//! Every verb ends its success output with a ready-to-run next command +//! (stderr via `ctx.out.note`) so the drawdown flow chains without the user +//! hunting for the next step. Paid verbs are single-attempt: a paid lane never +//! blind-retries. + +use clap::{Args as ClapArgs, Subcommand}; +use quicknode_sdk::{CreditBalance, PaymentConfig}; + +use crate::config::{self, PaymentSection}; +use crate::context::{Ctx, GlobalArgs}; +use crate::errors::CliError; +use crate::output::{style, Style}; + +use super::payment::{ + ensure_gateway_session, resolve_payment_params, resolve_session_params, PaymentParams, + SessionParams, +}; + +/// Query network used in the printed paid-lane examples. Payments and credits +/// are independent of the chain a call queries, so the examples query a chain +/// the user did not pay on to make that clear. +const EXAMPLE_QUERY_NETWORK: &str = "ethereum-mainnet"; + +#[derive(Debug, ClapArgs)] +#[command(subcommand_required = true, arg_required_else_help = true)] +#[command(after_help = "Examples:\n \ + qn rpc x402 drip --payment-wallet payer\n \ + qn rpc x402 balance --payment-wallet payer\n \ + qn rpc x402 buy-credits --network ethereum-mainnet --payment-wallet payer \\\n \ + --payment-network base-sepolia --payment-asset USDC --max-amount 10000000\n \ + qn rpc call eth_blockNumber --network ethereum-mainnet --x402-drawdown --payment-wallet payer\n \ + qn rpc x402 supported-networks\n \ + qn rpc x402 supported-payments")] +pub struct Args { + #[command(subcommand)] + pub cmd: X402Cmd, +} + +#[derive(Debug, Subcommand)] +pub enum X402Cmd { + /// Buy a block of prepaid credits, paying the gateway's offer with the + /// configured wallet. Gated: names the spend ceiling before signing. + #[command(after_help = "Examples:\n \ + qn rpc x402 buy-credits --network ethereum-mainnet --payment-wallet payer \\\n \ + --payment-network base-sepolia --payment-asset USDC --max-amount 10000000")] + BuyCredits(PaymentArgs), + + /// Show the account's current credit balance (prints the bare number; + /// --format json for the full envelope). + #[command(visible_alias = "credits")] + #[command(after_help = "Examples:\n \ + qn rpc x402 balance --payment-wallet payer\n \ + qn rpc x402 balance --payment-wallet payer --format json")] + Balance(SessionArgs), + + /// Request testnet credits from the faucet (Base Sepolia, once per account). + #[command(after_help = "Examples:\n \ + qn rpc x402 drip --payment-wallet payer")] + Drip(SessionArgs), + + /// List the networks you can make x402-paid RPC calls to. Each slug is a + /// valid --network for a paid call. No API key required. + #[command(visible_alias = "networks")] + #[command(after_help = "Examples:\n \ + qn rpc x402 networks\n \ + qn rpc x402 supported-networks --format json")] + SupportedNetworks, + + /// List the payment options the x402 gateway accepts: the network you pay + /// on, the token, and its contract address — ready --payment-network and + /// --payment-asset values. No API key required. + #[command(visible_alias = "payments")] + #[command(after_help = "Examples:\n \ + qn rpc x402 payments\n \ + qn rpc x402 supported-payments --format json")] + SupportedPayments, +} + +/// The shared payment parameter stack every x402 verb accepts, with the same +/// flags-then-`[rpc.payment]` resolution as `qn rpc call --x402`. +#[derive(Debug, ClapArgs)] +pub struct PaymentArgs { + /// The gateway query chain to buy credits against, as its path slug (e.g. + /// `base-sepolia`). Defaults to the `--payment-network` name when it is a + /// slug. Credits are not network-scoped once bought. + #[arg(long, value_name = "NETWORK")] + pub network: Option, + + /// File containing the raw payment private key (EVM hex); `-` reads stdin. + /// Precedence: this > --payment-wallet > `key_file` > `wallet` in config. + #[arg(long, value_name = "PATH", conflicts_with = "payment_wallet")] + pub payment_key_file: Option, + + /// Name of a stored wallet (from `qn wallet generate`) to pay with. + #[arg(long, value_name = "NAME")] + pub payment_wallet: Option, + + /// Spend ceiling for a credit purchase, in integer base units of the asset + /// (e.g. 10000000 = 10 USDC). Flag > `max_amount` in [rpc.payment]. An + /// offered purchase above this is never signed. + #[arg(long, value_name = "BASE_UNITS")] + pub max_amount: Option, + + /// Chain you pay on: a network name (e.g. `base-sepolia`) or CAIP-2 id + /// (e.g. `eip155:84532`). Falls back to `payment_network` in [rpc.payment]. + #[arg(long, value_name = "NETWORK")] + pub payment_network: Option, + + /// Token to pay with: an EVM contract address or a symbol like USDC + /// (resolved per network). Falls back to `payment_asset` in [rpc.payment]. + #[arg(long, value_name = "ADDRESS")] + pub payment_asset: Option, + + /// Explicit Solana RPC URL for x402/Solana payment builds. Falls back to + /// `svm_rpc_url` in [rpc.payment], then a public Solana RPC. + #[arg(long, value_name = "URL")] + pub svm_rpc_url: Option, +} + +impl PaymentArgs { + fn params(&self) -> PaymentParams<'_> { + PaymentParams { + key_file: self.payment_key_file.as_deref(), + wallet: self.payment_wallet.as_deref(), + max_amount: self.max_amount.as_deref(), + payment_network: self.payment_network.as_deref(), + payment_asset: self.payment_asset.as_deref(), + svm_rpc_url: self.svm_rpc_url.as_deref(), + } + } +} + +/// The flags a keyless gateway session accepts (`balance`, `drip`). These verbs +/// present a Bearer JWT and sign nothing, so they take only the wallet key and +/// the SIWX pay network — no `--payment-asset`, no `--max-amount`. +#[derive(Debug, ClapArgs)] +pub struct SessionArgs { + /// The gateway query chain, as its path slug (e.g. `base-sepolia`). + /// Defaults to the `--payment-network` name when it is a slug. + #[arg(long, value_name = "NETWORK")] + pub network: Option, + + /// File containing the raw payment private key (EVM hex); `-` reads stdin. + /// Precedence: this > --payment-wallet > `key_file` > `wallet` in config. + #[arg(long, value_name = "PATH", conflicts_with = "payment_wallet")] + pub payment_key_file: Option, + + /// Name of a stored wallet (from `qn wallet generate`) to authenticate with. + #[arg(long, value_name = "NAME")] + pub payment_wallet: Option, + + /// Chain the SIWX session authenticates on: a network name (e.g. + /// `base-sepolia`) or CAIP-2 id (e.g. `eip155:84532`). Falls back to + /// `payment_network` in [rpc.payment]. + #[arg(long, value_name = "NETWORK")] + pub payment_network: Option, + + /// Explicit Solana RPC URL for x402/Solana session auth. Falls back to + /// `svm_rpc_url` in [rpc.payment], then a public Solana RPC. + #[arg(long, value_name = "URL")] + pub svm_rpc_url: Option, +} + +impl SessionArgs { + fn params(&self) -> SessionParams<'_> { + SessionParams { + key_file: self.payment_key_file.as_deref(), + wallet: self.payment_wallet.as_deref(), + payment_network: self.payment_network.as_deref(), + svm_rpc_url: self.svm_rpc_url.as_deref(), + } + } +} + +pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { + match args.cmd { + X402Cmd::BuyCredits(a) => run_buy_credits(a, global).await, + X402Cmd::Balance(a) => run_balance(a, global).await, + X402Cmd::Drip(a) => run_drip(a, global).await, + X402Cmd::SupportedNetworks => { + super::supported_networks::run_networks(super::supported_networks::Scheme::X402, global) + .await + } + X402Cmd::SupportedPayments => { + super::supported_networks::run_payments(super::supported_networks::Scheme::X402, global) + .await + } + } +} + +// Resolve the full payment config (buy-credits) from the verb's args + +// [rpc.payment], build the keyless-payment Ctx, and print any key-file +// permissions warning once output exists. No network I/O yet. +fn setup(args: &PaymentArgs, global: GlobalArgs) -> Result<(Ctx, PaymentConfig), CliError> { + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_payment_params( + "x402", + &args.params(), + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + + let ctx = Ctx::from_global_keyless_payment(global, payment.clone())?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + Ok((ctx, payment)) +} + +// Setup for the session-only verbs (balance, drip): resolve the minimal +// wallet + pay-network config, build the keyless Ctx, warn on a loose key file. +// Signs nothing, so it needs no asset or spend ceiling. +fn session_setup(args: &SessionArgs, global: GlobalArgs) -> Result { + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_session_params( + &args.params(), + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + + let ctx = Ctx::from_global_keyless_payment(global, payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + Ok(ctx) +} + +// The gateway query chain (path slug) for a credit purchase: the explicit +// --network, else the --payment-network flag when it's a name (not a CAIP-2 +// id). A resolved CAIP-2 payment network alone isn't a valid gateway slug, so +// require --network in that case. +fn resolve_query_network(args: &PaymentArgs, _payment: &PaymentConfig) -> Result { + if let Some(n) = &args.network { + return Ok(n.clone()); + } + if let Some(pn) = &args.payment_network { + if !pn.contains(':') { + return Ok(pn.clone()); + } + } + Err(CliError::Arg( + "buy-credits needs the gateway query chain. Pass --network \ + (e.g. --network base-sepolia)." + .to_string(), + )) +} + +/// Loads `[rpc.payment]`. A missing file is an empty section; an unreadable or +/// invalid file is a hard error (the user likely relies on values set there). +fn load_payment_section(global: &GlobalArgs) -> Result { + let Some(path) = global.resolve_config_path() else { + return Ok(PaymentSection::default()); + }; + Ok(config::load_from(&path)? + .map(|cfg| cfg.rpc.payment) + .unwrap_or_default()) +} + +async fn run_buy_credits(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { + let (ctx, payment) = setup(&args, global.clone())?; + + // Gate Mild BEFORE any network I/O: name the spend ceiling and blast radius. + // The exact charge is the gateway's offer, bounded by this ceiling; we name + // the ceiling since a single-attempt purchase can't safely probe first. + // Prompt in the user's vocabulary (symbol, network slug) where the tables + // know it; fall back to the raw resolved values otherwise. + let asset = super::pay_asset::symbol_for(&payment.pay_network, &payment.asset) + .unwrap_or_else(|| payment.asset.clone()); + let pay_network = super::pay_network::slug_for_caip2(&payment.pay_network) + .unwrap_or_else(|| payment.pay_network.clone()); + let msg = format!( + "Buy credits for up to {} base units of {asset} on {pay_network}? \ + This moves real funds.", + group_digits(&payment.max_amount) + ); + crate::confirm::confirm_mild(&ctx, &msg)?; + + let network = resolve_query_network(&args, &payment)?; + let session = ensure_gateway_session(&ctx, &global).await?; + let balance = ctx + .sdk + .rpc + .gateway_buy_credits(&session, &network) + .await + .map_err(super::payment::map_paid_error)?; + + ctx.out.note(&format!( + "✓ Bought credits (balance: {})", + fmt_credits(balance.credits) + )); + ctx.out.note(&format!( + "\n{}\n\n{}", + style( + "Spend the credits on calls (1 credit per call, no per-call payment):", + Style::Bold, + ctx.out.color, + ), + drawdown_call_hint(&args, ctx.out.color) + )); + // Bare balance to stdout for pipelines; on a TTY the ✓ line above already + // shows it, so skip the duplicate. + if matches!(ctx.global.format, Some(f) if f.is_structured()) || !ctx.out.stdout_is_tty { + return emit_balance(&ctx, &balance); + } + Ok(()) +} + +// A copy-pasteable, multi-line `--x402-drawdown` call with the wallet the user +// just paid with. Credits are not network-scoped, so the example deliberately +// queries a different chain than the one just bought against — it makes clear +// the credits spend on any supported network. +fn drawdown_call_hint(args: &PaymentArgs, color: bool) -> String { + let mut cmd = format!( + " qn rpc call eth_blockNumber \\\n \ + --network {EXAMPLE_QUERY_NETWORK} \\\n \ + --x402-drawdown \\\n \ + --payment-wallet {}", + args.payment_wallet.as_deref().unwrap_or("") + ); + // The drawdown call defaults its pay network to --network, so keep the + // SIWX auth chain on the network the user actually pays on when it differs. + if let Some(pn) = &args.payment_network { + if pn != EXAMPLE_QUERY_NETWORK { + cmd.push_str(&format!(" \\\n --payment-network {pn}")); + } + } + style(&cmd, Style::Bold, color) +} + +async fn run_balance(args: SessionArgs, global: GlobalArgs) -> Result<(), CliError> { + let ctx = session_setup(&args, global.clone())?; + let session = ensure_gateway_session(&ctx, &global).await?; + let balance = ctx.sdk.rpc.gateway_credits(&session).await?; + emit_balance(&ctx, &balance) +} + +async fn run_drip(args: SessionArgs, global: GlobalArgs) -> Result<(), CliError> { + let ctx = session_setup(&args, global.clone())?; + let session = ensure_gateway_session(&ctx, &global).await?; + let receipt = ctx.sdk.rpc.gateway_drip(&session).await?; + + // The faucet funds the wallet with testnet tokens (returns the funding tx), + // which you then spend on buy-credits — it does not grant credits directly. + ctx.out.note(&format!( + "✓ Faucet funded {} (tx: {})", + receipt.account_id, receipt.transaction_hash + )); + // Point at the two paid lanes with the flags the user already supplied; + // USDC is the asset the faucet just funded. The examples query a chain the + // faucet did not fund — payment is independent of the chain a call queries. + let wallet = args.payment_wallet.as_deref().unwrap_or(""); + let pay_net = args.payment_network.as_deref().unwrap_or(""); + let c = ctx.out.color; + + let mut block = String::new(); + block.push_str( + "\nThis wallet now has funds that can be used to pay for blockchain calls\n\ + using micropayments.\n\n", + ); + block.push_str(&style( + "Pay per-request (sign a payment on each call):", + Style::Bold, + c, + )); + block.push_str(&format!( + "\n\n{}\n\n", + style( + &format!( + " qn rpc call eth_blockNumber \\\n \ + --network {EXAMPLE_QUERY_NETWORK} \\\n \ + --x402 \\\n \ + --payment-wallet {wallet} \\\n \ + --payment-network {pay_net} \\\n \ + --payment-asset USDC \\\n \ + --max-amount 1000" + ), + Style::Bold, + c, + ) + )); + block.push_str(&style( + "Credit drawdown (buy prepaid credits once, then spend them):", + Style::Bold, + c, + )); + block.push_str(&format!( + "\n\n{}\n\n{}", + style( + &format!( + " qn rpc x402 buy-credits \\\n \ + --network {EXAMPLE_QUERY_NETWORK} \\\n \ + --payment-wallet {wallet} \\\n \ + --payment-network {pay_net} \\\n \ + --payment-asset USDC \\\n \ + --max-amount 1000000" + ), + Style::Bold, + c, + ), + // The explicit --payment-network keeps SIWX auth on the chain the + // faucet funded, since a drawdown call defaults it to --network. + style( + &format!( + " qn rpc call eth_blockNumber \\\n \ + --network {EXAMPLE_QUERY_NETWORK} \\\n \ + --x402-drawdown \\\n \ + --payment-wallet {wallet} \\\n \ + --payment-network {pay_net}" + ), + Style::Bold, + c, + ) + )); + ctx.out.note(&block); + if matches!(ctx.global.format, Some(f) if f.is_structured()) { + let v = serde_json::json!({ + "account_id": receipt.account_id, + "transaction_hash": receipt.transaction_hash, + }); + return super::emit_result(&ctx, &v); + } + // Bare tx hash to stdout for pipelines; on a TTY the ✓ line above already + // shows it, so skip the duplicate. + if !ctx.out.stdout_is_tty { + println!("{}", receipt.transaction_hash); + } + Ok(()) +} + +// Emit a credit balance: the bare number by default (friendly for scripts and +// pipelines, TTY or piped), the full envelope only for a structured --format. +fn emit_balance(ctx: &Ctx, balance: &CreditBalance) -> Result<(), CliError> { + if matches!(ctx.global.format, Some(f) if f.is_structured()) { + let v = serde_json::json!({ + "account_id": balance.account_id, + "credits": balance.credits, + }); + return super::emit_result(ctx, &v); + } + println!("{}", balance.credits); + Ok(()) +} + +// Group digits for the human-facing note (1000000 -> 1,000,000). The bare +// stdout number is never grouped, so pipelines get a clean integer. +fn fmt_credits(n: u64) -> String { + group_digits(&n.to_string()) +} + +// Digit-group a decimal string (1000000 -> 1,000,000). Anything that is not +// pure ASCII digits passes through unchanged rather than being mangled. +fn group_digits(s: &str) -> String { + let bytes = s.as_bytes(); + if s.is_empty() || !bytes.iter().all(u8::is_ascii_digit) { + return s.to_string(); + } + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, b) in bytes.iter().enumerate() { + if i > 0 && (bytes.len() - i) % 3 == 0 { + out.push(','); + } + out.push(*b as char); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fmt_credits_groups_thousands() { + assert_eq!(fmt_credits(0), "0"); + assert_eq!(fmt_credits(95), "95"); + assert_eq!(fmt_credits(1_000), "1,000"); + assert_eq!(fmt_credits(1_000_095), "1,000,095"); + } + + #[test] + fn group_digits_leaves_non_digit_strings_alone() { + assert_eq!(group_digits("1000000"), "1,000,000"); + assert_eq!(group_digits(""), ""); + assert_eq!(group_digits("+5000"), "+5000"); + assert_eq!(group_digits("0xabc"), "0xabc"); + } +} diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs new file mode 100644 index 0000000..831629e --- /dev/null +++ b/src/commands/wallet.rs @@ -0,0 +1,534 @@ +//! `qn wallet …` — a local store of dedicated payment wallets. +//! +//! Removes the raw-key-file juggling the paid RPC lane otherwise requires: +//! `generate` creates a fresh keypair, stores the raw key at 0600 under +//! `/qn/wallets/`, and prints the address (plus a QR on a +//! TTY) so the wallet can be funded. `qn rpc call --payment-wallet ` +//! then resolves the key by name — the raw key never appears on a command line. +//! +//! Storage layout, per wallet ``: +//! - `` — the raw private key (0600), the exact bytes the paid +//! lane's `read_key_file` expects. +//! - `.toml` — public metadata (vm, address, created-at). Never the +//! key, so `list`/`show` read this and never open the key file. +//! +//! The key is stored **unencrypted** at 0600 (the `solana-keygen` model): the +//! paid lane is keyless and non-interactive, so a passphrase prompt would break +//! it. Treat every managed wallet as a dedicated, minimally-funded hot wallet. + +use std::path::{Path, PathBuf}; + +use clap::{Args as ClapArgs, Subcommand, ValueEnum}; +use comfy_table::Cell; +use quicknode_sdk::{generate_payment_wallet, ChainKind}; +use serde::{Deserialize, Serialize}; + +use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity}; +use crate::context::Ctx; +use crate::errors::CliError; +use crate::output::{new_table, set_header_bold, style, write_table, Render, Style}; + +#[derive(Debug, ClapArgs)] +#[command(subcommand_required = true, arg_required_else_help = true)] +pub struct Args { + #[command(subcommand)] + pub cmd: WalletCmd, +} + +#[derive(Debug, Subcommand)] +pub enum WalletCmd { + /// Generate a new payment wallet and store it locally. + #[command(after_help = "Examples:\n \ + qn wallet generate --vm evm --name payer\n \ + qn wallet generate --vm svm --name sol-payer")] + Generate(GenerateArgs), + + /// List stored wallets (names, VM family, address — never keys). + #[command(visible_alias = "ls")] + List, + + /// Show a wallet's address, with a QR to fund it (on a terminal). + Show(ShowArgs), + + /// Delete a stored wallet. The key is the only copy and cannot be recovered. + Rm(RmArgs), +} + +/// VM family a generated wallet targets. `evm` also covers MPP/Tempo +/// (same secp256k1 key format). +#[derive(Debug, Clone, Copy, ValueEnum)] +#[value(rename_all = "lower")] +pub enum WalletVm { + /// secp256k1 wallet for x402/EVM and MPP/Tempo (`0x…` hex key). + Evm, + /// ed25519 wallet for x402/Solana (base58 key). + Svm, +} + +impl WalletVm { + fn kind(self) -> ChainKind { + match self { + WalletVm::Evm => ChainKind::Evm, + WalletVm::Svm => ChainKind::Svm, + } + } + + fn label(self) -> &'static str { + match self { + WalletVm::Evm => "evm", + WalletVm::Svm => "svm", + } + } +} + +#[derive(Debug, ClapArgs)] +pub struct GenerateArgs { + /// VM family: `evm` (x402/EVM, also MPP/Tempo) or `svm` (x402/Solana). + #[arg(long)] + pub vm: WalletVm, + + /// Wallet name (a-z, 0-9, `-`, `_`). Becomes the key file name and the + /// `--payment-wallet` handle. + #[arg(long, value_name = "NAME")] + pub name: String, + + /// Overwrite an existing wallet of the same name. + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, ClapArgs)] +pub struct ShowArgs { + /// Wallet name. + #[arg(value_name = "NAME")] + pub name: String, +} + +#[derive(Debug, ClapArgs)] +pub struct RmArgs { + /// Wallet name. + #[arg(value_name = "NAME")] + pub name: String, +} + +pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> { + match args.cmd { + WalletCmd::Generate(a) => generate(a, ctx), + WalletCmd::List => list(ctx), + WalletCmd::Show(a) => show(a, ctx), + WalletCmd::Rm(a) => rm(a, ctx), + } +} + +// ── verbs ──────────────────────────────────────────────────────────────────── + +fn generate(a: GenerateArgs, ctx: Ctx) -> Result<(), CliError> { + let name = validate_name(&a.name)?; + let dir = wallets_dir(&ctx)?; + let key_path = dir.join(&name); + let meta_path = meta_path(&dir, &name); + + if !a.force && (key_path.exists() || meta_path.exists()) { + return Err(CliError::Arg(format!( + "wallet '{name}' already exists. Pass --force to overwrite, or pick another name" + ))); + } + + let wallet = generate_payment_wallet(a.vm.kind())?; + let meta = WalletMeta { + name: name.clone(), + vm: a.vm.label().to_string(), + address: wallet.address.clone(), + created_at_unix: now_unix(), + }; + let raw = wallet.into_key(); + + // Key first (0600, tightens the dir to 0700), then the public sidecar. + crate::config::write_atomic_0600(&key_path, raw.as_bytes(), ".qn-wallet-")?; + write_meta(&meta_path, &meta)?; + + ctx.out + .note(&format!("✓ Generated {} wallet '{name}'", a.vm.label())); + emit_address(&ctx, &meta, &key_path, /* with_qr */ true); + Ok(()) +} + +fn list(ctx: Ctx) -> Result<(), CliError> { + let dir = wallets_dir(&ctx)?; + let mut wallets = load_all_meta(&dir); + wallets.sort_by(|a, b| a.name.cmp(&b.name)); + crate::output::emit(&ctx.out, &WalletsView(wallets)) +} + +fn show(a: ShowArgs, ctx: Ctx) -> Result<(), CliError> { + let name = validate_name(&a.name)?; + let dir = wallets_dir(&ctx)?; + let meta = load_meta(&meta_path(&dir, &name)).ok_or_else(|| not_found(&name))?; + + if ctx.out.format.is_structured() { + return crate::output::emit(&ctx.out, &meta); + } + emit_address(&ctx, &meta, &dir.join(&name), /* with_qr */ true); + Ok(()) +} + +fn rm(a: RmArgs, ctx: Ctx) -> Result<(), CliError> { + let name = validate_name(&a.name)?; + let dir = wallets_dir(&ctx)?; + let key_path = dir.join(&name); + let meta_path = meta_path(&dir, &name); + if !key_path.exists() && !meta_path.exists() { + return Err(not_found(&name)); + } + + let cfg = ConfirmCfg::new( + ctx.global.yes_count, + ctx.global.no_input, + ctx.out.stdout_is_tty, + ); + let proceed = match decide_without_prompt(Severity::Mild, cfg)? { + true => true, + false => prompt_yes_no(&format!( + "Delete wallet '{name}'? The private key is destroyed locally; without a backup \ + of the key, funds at its address are unrecoverable." + ))?, + }; + if !proceed { + return Err(CliError::Cancelled); + } + + // Remove the key first: if the sidecar removal somehow fails, we never leave + // an orphaned key on disk. + if key_path.exists() { + std::fs::remove_file(&key_path).map_err(|e| remove_err(&key_path, e))?; + } + if meta_path.exists() { + std::fs::remove_file(&meta_path).map_err(|e| remove_err(&meta_path, e))?; + } + ctx.out.note(&format!("✓ Deleted wallet '{name}'")); + Ok(()) +} + +// ── rendering ────────────────────────────────────────────────────────────── + +/// The custody disclaimer shown on generate/show. This wallet lives only on +/// this machine; backing it up is the user's responsibility. +const CUSTODY_NOTE: &str = "This wallet is stored only on this machine. \ + Quicknode does not hold, back up, or recover it — keep your own backup of \ + the key file; if you lose it, any funds in the wallet are gone."; + +/// Prints the address to stdout (the pipeable value), and to stderr a spaced, +/// lightly-styled block: the QR (TTY only), the private key file path, a +/// funding hint, and the custody note. Everything but the bare address goes to +/// stderr, so a piped `qn wallet show` yields just the address; the QR +/// must never go to stdout — it would corrupt the pipe. Styling is applied +/// only when `ctx.out.color` is set (a TTY with color enabled). +fn emit_address(ctx: &Ctx, meta: &WalletMeta, key_path: &Path, with_qr: bool) { + println!("{}", meta.address); + if ctx.out.quiet { + return; + } + let c = ctx.out.color; + let on_tty = with_qr && ctx.out.stdout_is_tty; + + // Build one spaced block so blank lines land predictably around the QR. + let mut block = String::new(); + + if on_tty { + if let Some(qr) = render_qr(&meta.address) { + // Blank line above so the QR isn't jammed against the address. + block.push('\n'); + block.push_str(&qr); + block.push('\n'); + } + } + + // Private key file path (the address is already on stdout, so it isn't + // echoed here). + block.push_str(&format!( + "{} {}\n", + style("Private key file:", Style::Dim, c), + style(&key_path.display().to_string(), Style::Bold, c) + )); + + // Funding hint (only meaningful interactively, where the QR is shown). + // Show the testnet funding routes for this wallet's VM family, then a + // complete, runnable paid-lane call so the address can go straight from + // funded to used. + if on_tty { + block.push('\n'); + block.push_str( + "This wallet can be used to pay for RPC calls via micropayments (x402/MPP).\n\ + Fund it on the network you'll pay from. Testnet funding:\n\n", + ); + block.push_str(&format!("{}\n\n", funding_hint(&meta.vm, &meta.name, c))); + block.push_str("Then use it:\n\n"); + block.push_str(&format!( + "{}\n", + style(&example_call(&meta.vm, &meta.name), Style::Bold, c), + )); + + let config_path = ctx + .global + .resolve_config_path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "~/.config/qn/config.toml".to_string()); + block.push('\n'); + block.push_str(&format!( + "To make it the default payment wallet (per-call flags still \ + override), add to {config_path}:\n\n", + )); + block.push_str(&format!("{}\n", config_example(&meta.vm, &meta.name))); + } + + // Custody note as dim fine-print, set off by a blank line. + block.push('\n'); + block.push_str(&style(&format!("⚠ {CUSTODY_NOTE}"), Style::Dim, c)); + + ctx.out.note(&block); +} + +/// A paste-ready `[rpc.payment]` config section that makes this wallet the +/// default payer, with network/asset/ceiling defaults matching the printed +/// example call. The section only supplies values — a scheme flag +/// (`--x402`/`--mpp`) still activates payment per call. +fn config_example(vm: &str, name: &str) -> String { + let network = match vm { + "svm" => "solana-devnet", + _ => "base-sepolia", + }; + format!( + " [rpc.payment]\n \ + wallet = \"{name}\"\n \ + payment_network = \"{network}\"\n \ + payment_asset = \"USDC\"\n \ + max_amount = 1000" + ) +} + +/// Testnet funding routes for a fresh wallet, per VM family. EVM gets the +/// gateway's built-in Base Sepolia faucet (`qn rpc x402 drip`, pre-filled with +/// this wallet's name), the Circle faucet for the USDC testnets, the Tempo +/// testnet faucet (pathUSD, for MPP), and the XLayer USDG note; SVM gets the +/// Circle faucet on Solana Devnet. +fn funding_hint(vm: &str, name: &str, color: bool) -> String { + match vm { + "svm" => " Circle faucet: https://faucet.circle.com (USDC on Solana Devnet)".to_string(), + _ => { + let drip = style( + &format!("qn rpc x402 drip --payment-wallet {name} --payment-network base-sepolia"), + Style::Bold, + color, + ); + format!( + " Base Sepolia: {drip}\n \ + Circle faucet: https://faucet.circle.com (USDC on Base Sepolia, Polygon Amoy, Arc)\n \ + Tempo Testnet: https://tempo.xyz/developers/docs/quickstart/faucet (pathUSD, for MPP)\n \ + XLayer Testnet: send USDG to the address above" + ) + } + } +} + +/// A complete, runnable paid-lane `qn rpc call` for a freshly funded wallet, +/// matching the documented examples in the README. SVM gets one x402/Solana +/// example; EVM gets both an x402 (pays Base Sepolia USDC) and an MPP (pays +/// Tempo testnet USDC) example, since the same secp256k1 key works for both. +/// The EVM examples query a chain the wallet is not funded on — the payment +/// chain is independent of the chain a call queries. +/// `--max-amount 1000` selects the per-request offer (0.001 USDC). +fn example_call(vm: &str, name: &str) -> String { + match vm { + "svm" => format!( + "qn rpc call getSlot \\\n \ + --network solana-devnet \\\n \ + --x402 \\\n \ + --payment-wallet {name} \\\n \ + --payment-network solana-devnet \\\n \ + --payment-asset USDC \\\n \ + --max-amount 1000" + ), + _ => format!( + "# x402 (pays Base Sepolia USDC):\n\ + qn rpc call eth_blockNumber \\\n \ + --network ethereum-mainnet \\\n \ + --x402 \\\n \ + --payment-wallet {name} \\\n \ + --payment-network base-sepolia \\\n \ + --payment-asset USDC \\\n \ + --max-amount 1000\n\n\ + # MPP (pays Tempo testnet USDC):\n\ + qn rpc call eth_blockNumber \\\n \ + --network ethereum-mainnet \\\n \ + --mpp \\\n \ + --payment-wallet {name} \\\n \ + --payment-network tempo-testnet \\\n \ + --payment-asset USDC \\\n \ + --max-amount 1000" + ), + } +} + +/// Unicode (half-block) QR of `data`, or `None` if it can't be encoded. +fn render_qr(data: &str) -> Option { + use qrcode::render::unicode; + use qrcode::QrCode; + let code = QrCode::new(data.as_bytes()).ok()?; + Some( + code.render::() + .dark_color(unicode::Dense1x2::Light) + .light_color(unicode::Dense1x2::Dark) + .quiet_zone(true) + .build(), + ) +} + +#[derive(Serialize)] +struct WalletsView(Vec); + +impl Render for WalletsView { + fn render_table( + &self, + w: &mut dyn std::io::Write, + ctx: &crate::output::OutputCtx, + ) -> std::io::Result<()> { + if self.0.is_empty() { + writeln!(w, "(no wallets)")?; + return Ok(()); + } + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["NAME", "VM", "ADDRESS"]); + for wl in &self.0 { + t.add_row(vec![ + Cell::new(&wl.name), + Cell::new(&wl.vm), + Cell::new(&wl.address), + ]); + } + write_table(w, &t) + } +} + +// ── metadata sidecar ───────────────────────────────────────────────────────── + +/// Public wallet metadata, stored as `.toml` beside the key. Never holds +/// the key itself, so `list`/`show` never open the 0600 key file. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WalletMeta { + name: String, + // `alias` keeps sidecars written before the `--vm` rename loadable. + #[serde(alias = "chain")] + vm: String, + address: String, + created_at_unix: i64, +} + +impl Render for WalletMeta { + fn render_table( + &self, + w: &mut dyn std::io::Write, + ctx: &crate::output::OutputCtx, + ) -> std::io::Result<()> { + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["NAME", "VM", "ADDRESS"]); + t.add_row(vec![ + Cell::new(&self.name), + Cell::new(&self.vm), + Cell::new(&self.address), + ]); + write_table(w, &t) + } +} + +fn write_meta(path: &Path, meta: &WalletMeta) -> Result<(), CliError> { + let text = toml::to_string_pretty(meta).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + // The sidecar is public metadata, but reuse the atomic writer for the same + // temp-file/rename durability (0600 is harmless for a public file). + crate::config::write_atomic_0600(path, text.as_bytes(), ".qn-wallet-meta-") +} + +fn load_meta(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + toml::from_str(&text).ok() +} + +/// Loads every `.toml` sidecar in the wallets directory. Missing dir or +/// unreadable sidecars yield an empty list / are skipped — `list` is best-effort. +fn load_all_meta(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "toml")) + .filter_map(|e| load_meta(&e.path())) + .collect() +} + +// ── paths & validation ───────────────────────────────────────────────────── + +fn wallets_dir(ctx: &Ctx) -> Result { + let config_path = ctx.global.resolve_config_path(); + crate::config::wallets_dir(config_path.as_deref()).ok_or_else(|| { + CliError::Arg("could not resolve a config directory for the wallet store".to_string()) + }) +} + +fn meta_path(dir: &Path, name: &str) -> PathBuf { + dir.join(format!("{name}.toml")) +} + +/// Restricts a wallet name to `[a-z0-9_-]` so it can never escape the wallets +/// directory (no separators, no `..`, no empties). Returns the validated name. +fn validate_name(name: &str) -> Result { + if name.is_empty() { + return Err(CliError::Arg("wallet name cannot be empty".to_string())); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + return Err(CliError::Arg(format!( + "invalid wallet name '{name}'. Use lowercase letters, digits, '-' and '_' only" + ))); + } + Ok(name.to_string()) +} + +fn not_found(name: &str) -> CliError { + CliError::Arg(format!( + "no wallet named '{name}'. Run 'qn wallet list' to see stored wallets, \ + or create one with 'qn wallet generate'" + )) +} + +/// Resolves a stored wallet name to its key file path, validating the name and +/// checking the file exists. Also used by the paid RPC lane's +/// `--payment-wallet`, so the name rules live in one place. +pub(crate) fn key_path(name: &str, wallets_dir: Option<&Path>) -> Result { + let name = validate_name(name)?; + let dir = wallets_dir + .ok_or_else(|| CliError::Arg("could not resolve the wallet store directory".to_string()))?; + let path = dir.join(&name); + if !path.exists() { + return Err(not_found(&name)); + } + Ok(path) +} + +fn remove_err(path: &Path, source: std::io::Error) -> CliError { + CliError::ConfigWrite { + path: path.to_path_buf(), + source, + } +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} diff --git a/src/config.rs b/src/config.rs index 2e2dc45..006a310 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,7 +7,9 @@ //! There is deliberately no environment-variable source: a key left exported //! in a shell is invisible state that outlives the session it was set for, //! and is the easiest way to run a destructive command against the wrong -//! account. +//! account. The paid RPC lane's payment key follows the same principle — it +//! comes only from a file or a stored wallet, never an env var (see +//! `commands::rpc::payment`). //! //! When both sources fail we return [`CliError::NoApiKey`] which exits 4 with //! a message directing the user to `qn auth login`. The `qn auth login` @@ -64,6 +66,68 @@ pub struct ApiSection { pub struct RpcSection { #[serde(default)] pub endpoint_url: Option, + #[serde(default)] + pub payment: PaymentSection, +} + +/// `[rpc.payment]` section: parameter defaults for the crypto-micropayment +/// lane of `qn rpc call` (`--x402`/`--mpp`). This section supplies values; +/// it never activates payment — only the per-invocation scheme flag does. +/// Per-call flags override every field here. +/// +/// There is deliberately no `scheme` key (the flag is the scheme) and no way +/// to store the raw private key: `key_file` points at a file holding the key, +/// keeping the key itself out of the most commonly shared/pasted file we own. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct PaymentSection { + /// Path to a file containing the raw payment private key (EVM/Tempo hex, + /// Solana base58). Never the key itself. + #[serde(default)] + pub key_file: Option, + /// Name of a stored wallet (from `qn wallet generate`) to pay with, as + /// an alternative to `key_file`. Resolved to its key file under the wallet + /// store. Never the key itself. + #[serde(default)] + pub wallet: Option, + /// Trap field: an inline raw key is rejected at payment-resolution time + /// with an error pointing at `key_file`. Without this field serde would + /// silently ignore `key = "..."` and the user would think it took effect. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + /// Default spend ceiling per paid call, in integer base units of + /// `payment_asset`. Accepts a TOML string or integer. + #[serde(default, deserialize_with = "de_opt_string_or_int")] + pub max_amount: Option, + /// Chain payments settle on: a Quicknode network name (e.g. + /// `base-sepolia`) or a CAIP-2 id (e.g. `eip155:84532`). Independent of + /// `--network` (the chain the RPC call queries). + #[serde(default)] + pub payment_network: Option, + /// Token to pay with: EVM contract address or Solana mint. + #[serde(default)] + pub payment_asset: Option, + /// Explicit Solana RPC URL for x402/Solana payment builds. + #[serde(default)] + pub svm_rpc_url: Option, +} + +/// Deserializes an optional TOML string-or-integer into `Option`, so +/// `max_amount = 10000` and `max_amount = "10000"` both work. (Base units can +/// exceed i64 for high-decimal assets, hence the canonical string form.) +fn de_opt_string_or_int<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let v = Option::::deserialize(deserializer)?; + match v { + None => Ok(None), + Some(toml::Value::String(s)) => Ok(Some(s)), + Some(toml::Value::Integer(i)) => Ok(Some(i.to_string())), + Some(other) => Err(serde::de::Error::custom(format!( + "expected a string or integer, got {}", + other.type_str() + ))), + } } #[derive(Debug, Default, Serialize, Deserialize)] @@ -245,6 +309,7 @@ fn write_config(path: &Path, cfg: &ConfigFile) -> Result<(), CliError> { use std::collections::HashMap; use quicknode_sdk::CachedToken; +use quicknode_sdk::GatewaySession; /// On-disk shape of `~/.config/qn/tokens.toml`. #[derive(Debug, Default, Serialize, Deserialize)] @@ -389,6 +454,235 @@ pub fn networks_cache_path(config_path: Option<&Path>) -> Option { } } +/// The wallet store directory: `wallets/` alongside the resolved config file. +/// Holds one raw-key file per wallet (0600) plus a `.toml` metadata +/// sidecar. The directory is tightened to 0700 on first write. +pub fn wallets_dir(config_path: Option<&Path>) -> Option { + match config_path { + Some(p) => p.parent().map(|d| d.join("wallets")), + None => config_dir().map(|d| d.join("qn").join("wallets")), + } +} + +// ── x402 gateway session (JWT) cache ───────────────────────────────────────── +// +// The drawdown lane authenticates once (SIWX → JWT, ~1h) and reuses the JWT +// across `qn rpc` processes. We persist it in `sessions.toml` alongside the +// config, keyed by the payer wallet's on-chain address (derived offline from +// the key), so distinct wallets don't collide and a cache lookup needs no +// network round trip. Only the short-lived JWT is written — never the wallet +// key. A missing/expired session simply re-authenticates (which is free). + +/// On-disk shape of `~/.config/qn/sessions.toml`. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct SessionCacheFile { + /// Payer wallet address (lowercase) -> cached gateway session. + #[serde(default)] + pub sessions: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewaySessionEntry { + pub token: String, + pub exp_unix: i64, + pub account_id: String, +} + +/// The gateway-session cache path: `sessions.toml` alongside the resolved +/// config file (falling back to the default config dir). +pub fn sessions_cache_path(config_path: Option<&Path>) -> Option { + match config_path { + Some(p) => p.parent().map(|d| d.join("sessions.toml")), + None => config_dir().map(|d| d.join("qn").join("sessions.toml")), + } +} + +// Normalize an address for use as a cache key (addresses are case-insensitive +// on EVM; Solana base58 is case-sensitive but never collides after lowercasing +// within a single wallet's own entries). +fn session_key(address: &str) -> String { + address.to_lowercase() +} + +/// Loads the cached gateway session for the payer `address`. Returns `None` if +/// the file is absent, unparseable, or has no entry for that wallet. +pub fn load_gateway_session_by_address(path: &Path, address: &str) -> Option { + let text = fs::read_to_string(path).ok()?; + let cache: SessionCacheFile = toml::from_str(&text).ok()?; + let entry = cache.sessions.get(&session_key(address))?; + Some(GatewaySession { + token: entry.token.clone(), + exp_unix: entry.exp_unix, + account_id: entry.account_id.clone(), + }) +} + +/// Stores `session` under the payer `address`, preserving every other wallet's +/// entry (read-modify-write). Written atomically at 0600. Best-effort under +/// concurrency: the atomic rename guarantees no partial file. +pub fn save_gateway_session( + path: &Path, + address: &str, + session: &GatewaySession, +) -> Result<(), CliError> { + let mut cache: SessionCacheFile = fs::read_to_string(path) + .ok() + .and_then(|t| toml::from_str(&t).ok()) + .unwrap_or_default(); + cache.sessions.insert( + session_key(address), + GatewaySessionEntry { + token: session.token.clone(), + exp_unix: session.exp_unix, + account_id: session.account_id.clone(), + }, + ); + let text = toml::to_string_pretty(&cache).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + write_atomic_0600(path, text.as_bytes(), ".qn-sessions-") +} + +// ── MPP channel state ──────────────────────────────────────────────────────── +// +// An open MPP payment channel is long-lived local state (channelId, deposit, +// cumulative spend) reused across `qn rpc` processes. We persist it in +// `channels.toml` alongside the config, keyed by payer address + query network +// so one wallet can hold a channel per network. The gateway is the source of +// truth for the accepted high-water mark; `qn rpc mpp status` re-syncs it into +// this record. A lost or unparseable record reads as "no channel". + +/// On-disk shape of `~/.config/qn/channels.toml`. Amounts are stored as +/// strings because TOML has no unsigned-128-bit integer type; they round-trip +/// through [`ChannelEntry`] to the SDK's `u128`-typed `ChannelState`. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ChannelCacheFile { + /// ":" -> the open channel's state. + #[serde(default)] + pub channels: HashMap, +} + +/// TOML-safe channel record (u128 amounts as decimal strings). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelEntry { + pub channel_id: String, + pub token: String, + pub payee: String, + pub salt: String, + pub authorized_signer: String, + pub escrow_contract: String, + pub deposit: String, + pub cumulative_spent: String, + pub per_call: String, + pub chain_id: u64, +} + +impl ChannelEntry { + fn from_state(s: &quicknode_sdk::ChannelState) -> Self { + ChannelEntry { + channel_id: s.channel_id.clone(), + token: s.token.clone(), + payee: s.payee.clone(), + salt: s.salt.clone(), + authorized_signer: s.authorized_signer.clone(), + escrow_contract: s.escrow_contract.clone(), + deposit: s.deposit.to_string(), + cumulative_spent: s.cumulative_spent.to_string(), + per_call: s.per_call.to_string(), + chain_id: s.chain_id, + } + } + + fn to_state(&self) -> Option { + Some(quicknode_sdk::ChannelState { + channel_id: self.channel_id.clone(), + token: self.token.clone(), + payee: self.payee.clone(), + salt: self.salt.clone(), + authorized_signer: self.authorized_signer.clone(), + escrow_contract: self.escrow_contract.clone(), + deposit: self.deposit.parse().ok()?, + cumulative_spent: self.cumulative_spent.parse().ok()?, + per_call: self.per_call.parse().ok()?, + chain_id: self.chain_id, + }) + } +} + +/// The channel-state cache path: `channels.toml` alongside the resolved config. +pub fn channels_cache_path(config_path: Option<&Path>) -> Option { + match config_path { + Some(p) => p.parent().map(|d| d.join("channels.toml")), + None => config_dir().map(|d| d.join("qn").join("channels.toml")), + } +} + +fn channel_key(address: &str, network: &str) -> String { + format!("{}:{}", address.to_lowercase(), network) +} + +/// Loads the open channel for `(address, network)`, if any. +pub fn load_channel( + path: &Path, + address: &str, + network: &str, +) -> Option { + let text = fs::read_to_string(path).ok()?; + let cache: ChannelCacheFile = toml::from_str(&text).ok()?; + cache + .channels + .get(&channel_key(address, network)) + .and_then(ChannelEntry::to_state) +} + +/// Stores `channel` under `(address, network)`, preserving other entries. +/// Written atomically at 0600. +pub fn save_channel( + path: &Path, + address: &str, + network: &str, + channel: &quicknode_sdk::ChannelState, +) -> Result<(), CliError> { + let mut cache: ChannelCacheFile = fs::read_to_string(path) + .ok() + .and_then(|t| toml::from_str(&t).ok()) + .unwrap_or_default(); + cache.channels.insert( + channel_key(address, network), + ChannelEntry::from_state(channel), + ); + let text = toml::to_string_pretty(&cache).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + write_atomic_0600(path, text.as_bytes(), ".qn-channels-") +} + +/// Drops the channel entry for `(address, network)` (e.g. after a close), +/// leaving other entries intact. No-op if absent. +pub fn delete_channel(path: &Path, address: &str, network: &str) -> Result<(), CliError> { + let mut cache: ChannelCacheFile = match fs::read_to_string(path) + .ok() + .and_then(|t| toml::from_str(&t).ok()) + { + Some(c) => c, + None => return Ok(()), + }; + if cache + .channels + .remove(&channel_key(address, network)) + .is_none() + { + return Ok(()); + } + let text = toml::to_string_pretty(&cache).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + write_atomic_0600(path, text.as_bytes(), ".qn-channels-") +} + /// Loads the cached network map for `endpoint_id` from `path`, if present, for /// the same endpoint, and fetched within the TTL (relative to `now_unix`). /// Returns `None` (a cache miss) on any mismatch or parse failure. @@ -432,10 +726,169 @@ pub fn save_networks( write_atomic_0600(path, text.as_bytes(), ".qn-networks-") } +// ── Payment-gateway discovery cache ────────────────────────────────────────── +// +// The networks callable via a payment gateway and the payment options it +// accepts are stable public metadata fetched from the gateways' discovery +// surfaces. Cached per scheme (x402 / mpp) in `pay-networks.toml` next to the +// config file, each section with its own 24-hour TTL (the two lists back +// separate commands and are fetched independently). Not account-scoped (the +// data is public and the same for everyone). + +/// Seconds a cached discovery list is considered fresh (24h). +pub const PAY_NETWORKS_TTL_SECS: i64 = 24 * 60 * 60; + +/// One accepted payment option: the network the payment moves on, a display +/// symbol when one is known, and the token contract address (EVM) / mint +/// (Solana). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PayAssetEntry { + pub network: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset: Option, + pub address: String, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct PayNetworksCacheFile { + #[serde(default)] + pub x402: SchemeCacheEntry, + #[serde(default)] + pub mpp: SchemeCacheEntry, +} + +/// One gateway's cached discovery lists, each independently timestamped. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SchemeCacheEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub networks: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payments: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworksCacheSection { + /// Unix seconds the list was fetched, for the TTL check. + pub fetched_at_unix: i64, + pub networks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentsCacheSection { + /// Unix seconds the list was fetched, for the TTL check. + pub fetched_at_unix: i64, + pub payments: Vec, +} + +/// The discovery cache path: `pay-networks.toml` alongside the config. +pub fn pay_networks_cache_path(config_path: Option<&Path>) -> Option { + match config_path { + Some(p) => p.parent().map(|d| d.join("pay-networks.toml")), + None => config_dir().map(|d| d.join("qn").join("pay-networks.toml")), + } +} + +/// Reads and parses the cache file, or an empty default when it is missing, +/// unreadable, or in an older format. +fn load_pay_cache_file(path: &Path) -> PayNetworksCacheFile { + fs::read_to_string(path) + .ok() + .and_then(|text| toml::from_str(&text).ok()) + .unwrap_or_default() +} + +fn scheme_entry<'a>( + cache: &'a mut PayNetworksCacheFile, + scheme: &str, +) -> Option<&'a mut SchemeCacheEntry> { + match scheme { + "x402" => Some(&mut cache.x402), + "mpp" => Some(&mut cache.mpp), + _ => None, + } +} + +fn write_pay_cache_file(path: &Path, cache: &PayNetworksCacheFile) -> Result<(), CliError> { + let text = toml::to_string_pretty(cache).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + write_atomic_0600(path, text.as_bytes(), ".qn-pay-networks-") +} + +/// Loads the cached callable-networks list for `scheme` ("x402" or "mpp") from +/// `path`, if present and fetched within the TTL (relative to `now_unix`). +/// Returns `None` on any miss, including a cache file in an older format. +pub fn load_pay_networks(path: &Path, scheme: &str, now_unix: i64) -> Option> { + let mut cache = load_pay_cache_file(path); + let section = scheme_entry(&mut cache, scheme)?.networks.take()?; + if now_unix.saturating_sub(section.fetched_at_unix) >= PAY_NETWORKS_TTL_SECS { + return None; + } + Some(section.networks) +} + +/// Loads the cached payment-options list for `scheme`, with the same TTL and +/// miss semantics as [`load_pay_networks`]. +pub fn load_pay_payments(path: &Path, scheme: &str, now_unix: i64) -> Option> { + let mut cache = load_pay_cache_file(path); + let section = scheme_entry(&mut cache, scheme)?.payments.take()?; + if now_unix.saturating_sub(section.fetched_at_unix) >= PAY_NETWORKS_TTL_SECS { + return None; + } + Some(section.payments) +} + +/// Saves the callable-networks list for `scheme` to `path` atomically, +/// stamping `fetched_at_unix` for the TTL check. Every other section is kept +/// when the existing file parses; an older-format file is replaced wholesale. +pub fn save_pay_networks( + path: &Path, + scheme: &str, + fetched_at_unix: i64, + networks: &[String], +) -> Result<(), CliError> { + let mut cache = load_pay_cache_file(path); + let entry = scheme_entry(&mut cache, scheme).ok_or_else(|| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(format!("unknown payment scheme '{scheme}'")), + })?; + entry.networks = Some(NetworksCacheSection { + fetched_at_unix, + networks: networks.to_vec(), + }); + write_pay_cache_file(path, &cache) +} + +/// Saves the payment-options list for `scheme`, with the same merge semantics +/// as [`save_pay_networks`]. +pub fn save_pay_payments( + path: &Path, + scheme: &str, + fetched_at_unix: i64, + payments: &[PayAssetEntry], +) -> Result<(), CliError> { + let mut cache = load_pay_cache_file(path); + let entry = scheme_entry(&mut cache, scheme).ok_or_else(|| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(format!("unknown payment scheme '{scheme}'")), + })?; + entry.payments = Some(PaymentsCacheSection { + fetched_at_unix, + payments: payments.to_vec(), + }); + write_pay_cache_file(path, &cache) +} + /// Atomically writes `bytes` to `path` with 0600 perms via a temp file in the -/// same directory (perms set before the bytes), then `rename`. Shared by the -/// token and networks caches. -fn write_atomic_0600(path: &Path, bytes: &[u8], tmp_prefix: &str) -> Result<(), CliError> { +/// same directory (perms set before the bytes), then `rename`. Also tightens +/// the parent directory to 0700. Shared by the token/networks caches and the +/// wallet store. +pub(crate) fn write_atomic_0600( + path: &Path, + bytes: &[u8], + tmp_prefix: &str, +) -> Result<(), CliError> { let parent = path.parent().ok_or_else(|| CliError::ConfigWrite { path: path.to_path_buf(), source: std::io::Error::other("cache path has no parent directory"), @@ -552,6 +1005,58 @@ mod tests { panic!("prompt should not be invoked") } + #[test] + fn pay_cache_sections_roundtrip_independently() { + let dir = tempdir().unwrap(); + let path = dir.path().join("pay-networks.toml"); + let payments = vec![PayAssetEntry { + network: "base-sepolia".to_string(), + asset: Some("USDC".to_string()), + address: "0xabc".to_string(), + }]; + save_pay_networks(&path, "x402", 100, &["base-sepolia".to_string()]).unwrap(); + save_pay_payments(&path, "x402", 200, &payments).unwrap(); + save_pay_networks(&path, "mpp", 100, &["tempo-testnet".to_string()]).unwrap(); + + // Each save kept the other sections. + assert_eq!( + load_pay_networks(&path, "x402", 100).unwrap(), + vec!["base-sepolia"] + ); + let got = load_pay_payments(&path, "x402", 200).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].asset.as_deref(), Some("USDC")); + assert_eq!( + load_pay_networks(&path, "mpp", 100).unwrap(), + vec!["tempo-testnet"] + ); + + // An unfetched section and a TTL-expired section are both misses. + assert!(load_pay_payments(&path, "mpp", 100).is_none()); + assert!(load_pay_networks(&path, "x402", 100 + PAY_NETWORKS_TTL_SECS).is_none()); + } + + #[test] + fn pay_cache_old_formats_are_a_miss() { + let dir = tempdir().unwrap(); + let path = dir.path().join("pay-networks.toml"); + // A pre-split cache shape: a per-scheme entry with merged lists. + fs::write( + &path, + "[x402]\nfetched_at_unix = 100\ncallable = [\"base-sepolia\"]\npayments = []\n", + ) + .unwrap(); + assert!(load_pay_networks(&path, "x402", 100).is_none()); + assert!(load_pay_payments(&path, "x402", 100).is_none()); + + // A save on top of the old format replaces it wholesale. + save_pay_networks(&path, "mpp", 100, &["tempo-testnet".to_string()]).unwrap(); + assert_eq!( + load_pay_networks(&path, "mpp", 100).unwrap(), + vec!["tempo-testnet"] + ); + } + #[test] fn flag_wins_over_config_and_prompt() { let dir = tempdir().unwrap(); @@ -728,6 +1233,89 @@ mod tests { assert_eq!(cfg.rpc.endpoint_url.as_deref(), Some("https://x/rpc")); } + #[test] + fn rpc_payment_section_round_trips_through_config_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + "[rpc.payment]\n\ + key_file = \"/keys/payer.key\"\n\ + max_amount = \"10000\"\n\ + payment_network = \"eip155:84532\"\n\ + payment_asset = \"0xabc\"\n\ + svm_rpc_url = \"https://solana.example/rpc\"\n", + ) + .unwrap(); + let cfg = load_from(&path).unwrap().unwrap(); + let p = &cfg.rpc.payment; + assert_eq!(p.key_file.as_deref(), Some(Path::new("/keys/payer.key"))); + assert_eq!(p.max_amount.as_deref(), Some("10000")); + assert_eq!(p.payment_network.as_deref(), Some("eip155:84532")); + assert_eq!(p.payment_asset.as_deref(), Some("0xabc")); + assert_eq!(p.svm_rpc_url.as_deref(), Some("https://solana.example/rpc")); + assert!(p.key.is_none()); + } + + #[test] + fn rpc_payment_section_is_optional() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[rpc]\nendpoint_url = \"https://x/rpc\"\n").unwrap(); + let cfg = load_from(&path).unwrap().unwrap(); + assert!(cfg.rpc.payment.key_file.is_none()); + assert!(cfg.rpc.payment.max_amount.is_none()); + } + + #[test] + fn rpc_payment_max_amount_accepts_toml_integer() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[rpc.payment]\nmax_amount = 10000\n").unwrap(); + let cfg = load_from(&path).unwrap().unwrap(); + assert_eq!(cfg.rpc.payment.max_amount.as_deref(), Some("10000")); + } + + #[test] + fn rpc_payment_max_amount_rejects_other_types() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[rpc.payment]\nmax_amount = 1.5\n").unwrap(); + let err = load_from(&path).unwrap_err(); + assert!(matches!(err, CliError::BadConfig { .. }), "got: {err:?}"); + } + + #[test] + fn rpc_payment_inline_key_parses_into_trap_field() { + // An inline raw key must not break config parsing (unrelated commands + // still run); the payment lane rejects it with an actionable error at + // resolution time instead. + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[rpc.payment]\nkey = \"0xdeadbeef\"\n").unwrap(); + let cfg = load_from(&path).unwrap().unwrap(); + assert!(cfg.rpc.payment.key.is_some()); + } + + #[test] + fn save_api_key_preserves_rpc_payment_section() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + "[rpc.payment]\nkey_file = \"/keys/payer.key\"\nmax_amount = \"10000\"\n", + ) + .unwrap(); + save_api_key(&path, "new-key").unwrap(); + let cfg = load_from(&path).unwrap().unwrap(); + assert_eq!(cfg.api.key.as_deref(), Some("new-key")); + assert_eq!( + cfg.rpc.payment.key_file.as_deref(), + Some(Path::new("/keys/payer.key")) + ); + assert_eq!(cfg.rpc.payment.max_amount.as_deref(), Some("10000")); + } + #[test] fn output_section_is_optional() { let dir = tempdir().unwrap(); diff --git a/src/context.rs b/src/context.rs index 4d460c7..b512a9f 100644 --- a/src/context.rs +++ b/src/context.rs @@ -114,13 +114,20 @@ pub fn user_agent() -> String { /// SDK's supported way to replace its auto-generated `User-Agent`. pub fn sdk_config(api_key: String) -> SdkFullConfig { let mut full = SdkFullConfig::from_api_key(api_key); + apply_user_agent(&mut full); + full +} + +/// Installs the CLI `User-Agent` header on `full`. Shared by the keyed +/// ([`sdk_config`]) and keyless ([`Ctx::from_global_keyless_payment`]) +/// construction paths. +fn apply_user_agent(full: &mut SdkFullConfig) { let mut headers = std::collections::HashMap::new(); headers.insert("User-Agent".to_string(), user_agent()); full.http = Some(HttpConfig { headers: Some(headers), ..Default::default() }); - full } /// Points every sub-client at a custom host, suffixing each with its own base @@ -187,6 +194,73 @@ impl Ctx { Self::build(global, seed, config_endpoint_url) } + /// Keyless construction for the crypto-micropayment lane of `qn rpc call` + /// (`--x402`/`--mpp`): no API key is resolved or required, so it works on + /// a machine that has never run `qn auth login`. Only the RPC payment lane + /// is usable — every keyed sub-client would 401. + /// + /// Deliberately NOT applied here: + /// - the token cache seed and `[rpc] endpoint_url` (either would conflict + /// with the payment lane — the SDK rejects a custom URL + payment); + /// - `--base-url` sub-client overrides (the paid lane's test hook rides in + /// `PaymentConfig.base_url_override`, set by the caller; no control-plane + /// sub-client is used). + pub fn from_global_keyless_payment( + global: GlobalArgs, + payment: quicknode_sdk::PaymentConfig, + ) -> Result { + let stdout_is_tty = std::io::stdout().is_terminal(); + let (format, wide) = global.resolve_output(stdout_is_tty); + + let mut full = SdkFullConfig::keyless(); + apply_user_agent(&mut full); + full.rpc = Some(RpcConfig { + payment: Some(payment), + ..Default::default() + }); + + let sdk = QuicknodeSdk::new(&full)?; + let out = OutputCtx::detect_with( + format, + global.no_color, + global.quiet, + global.verbose, + wide, + stdout_is_tty, + std::env::var_os("NO_COLOR"), + std::env::var("TERM").ok(), + ); + + Ok(Self { sdk, out, global }) + } + + /// Keyless construction for local-only commands that make no network calls + /// (e.g. `qn wallet` key management). Resolves no API key and builds no + /// payment lane, so it works on a machine that has never run `qn auth + /// login`. The SDK is present only to satisfy the [`Ctx`] shape; every + /// sub-client would 401 if called. + pub fn from_global_keyless(global: GlobalArgs) -> Result { + let stdout_is_tty = std::io::stdout().is_terminal(); + let (format, wide) = global.resolve_output(stdout_is_tty); + + let mut full = SdkFullConfig::keyless(); + apply_user_agent(&mut full); + + let sdk = QuicknodeSdk::new(&full)?; + let out = OutputCtx::detect_with( + format, + global.no_color, + global.quiet, + global.verbose, + wide, + stdout_is_tty, + std::env::var_os("NO_COLOR"), + std::env::var("TERM").ok(), + ); + + Ok(Self { sdk, out, global }) + } + fn build( global: GlobalArgs, rpc_seed: Option, diff --git a/src/errors.rs b/src/errors.rs index 66af44d..d7a564d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -40,6 +40,24 @@ pub enum CliError { #[error(transparent)] Sdk(#[from] SdkError), + /// A paid RPC call failed in a way where the payment may already have + /// settled (e.g. the gateway's post-payment response could not be + /// interpreted). Kept separate from `Sdk` so it maps to exit 3 and renders + /// the check-your-wallet guidance; the paid lane must never auto-retry it. + #[error( + "the paid request's outcome is unknown — the payment may have been settled; \ + check your wallet before retrying" + )] + PaymentMaybeCharged(#[source] SdkError), + + /// The gateway refused a paid request and nothing settled (out of credits, + /// monthly limit, an exhausted channel). Carries an actionable message and + /// maps to exit 2 — the "refused, nothing settled" bucket — so scripts can + /// distinguish it from a generic arg error (exit 1) or an unknown outcome + /// (exit 3). + #[error("{0}")] + PaymentRefused(String), + #[error(transparent)] Io(#[from] std::io::Error), @@ -55,17 +73,25 @@ pub enum CliError { /// - 0: success (never produced here) /// - 1: generic CLI failure (arg parse, IO, decode). clap usage errors are /// mapped to 1 in main.rs too, so 2 always and only means an API error. -/// - 2: SdkError::Api (server returned a non-2xx) -/// - 3: SdkError::Http (network failure) +/// - 2: SdkError::Api (server returned a non-2xx); also a payment the gateway +/// refused without settling (PaymentRejected 4xx / PaymentUnsupported — +/// the paid lane wraps 5xx rejections into PaymentMaybeCharged first) +/// - 3: SdkError::Http (network failure); also an unknown payment outcome +/// (PaymentIndeterminate / PaymentMaybeCharged — the payment was +/// submitted, the caller may have been charged) /// - 4: NoApiKey / BadConfig /// - 5: user cancelled or needs --yes pub fn exit_code_for(err: &CliError) -> i32 { match err { CliError::NoApiKey | CliError::BadConfig { .. } | CliError::ConfigWrite { .. } => 4, CliError::Cancelled | CliError::NeedsConfirmation => 5, + CliError::PaymentMaybeCharged(_) => 3, + CliError::PaymentRefused(_) => 2, CliError::Sdk(sdk) => match sdk { SdkError::Api { .. } => 2, SdkError::Http(_) => 3, + SdkError::PaymentUnsupported { .. } | SdkError::PaymentRejected { .. } => 2, + SdkError::PaymentIndeterminate => 3, _ => 1, }, _ => 1, @@ -85,6 +111,53 @@ pub fn render(err: &CliError, verbose: bool) -> String { /// Like [`render`] but uses the supplied argv values for did-you-mean lookup. pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> String { match err { + CliError::Sdk(SdkError::PaymentUnsupported { offered }) => { + format!( + "Error: no offered payment option matched your configuration \ + (check --payment-network, --payment-asset, and --max-amount). Nothing was charged.\n\ + Gateway offered: {offered}" + ) + } + CliError::Sdk(SdkError::PaymentRejected { status, body }) => { + // Only 4xx rejections reach this arm from the paid lane (5xx are + // wrapped into PaymentMaybeCharged): the gateway refused the + // credential without settling it. The SDK has already reduced the + // body to the gateway's own reason when it was the JSON error shape. + let reason = payment_rejection_reason(body); + let mut msg = format!("Error: the gateway refused the payment (HTTP {status})."); + if let Some(r) = &reason { + let r = r.trim_end_matches('.'); + msg.push_str(&format!(" Gateway: {r}.")); + } + msg.push_str( + " The signed payment was not accepted, so nothing should have settled. \ + Common causes: the wallet is unfunded, or --payment-network/--payment-asset/--max-amount \ + don't match an offer (see 'qn rpc x402 supported-payments' / 'qn rpc mpp supported-payments').", + ); + // When the reason wasn't a clean one-liner, append the raw body under + // --verbose for the full detail. + if verbose && reason.is_none() && !body.is_empty() { + format!("{msg}\n{body}") + } else { + msg + } + } + CliError::Sdk(SdkError::PaymentIndeterminate) => { + "Error: the paid request was sent but its response was lost — the request \ + may have been settled; check your wallet before retrying. Do not blindly \ + re-run this command." + .to_string() + } + CliError::PaymentMaybeCharged(source) => { + let msg = "Error: the paid request failed after the payment was submitted — \ + the payment may have been settled; check your wallet before \ + retrying. Do not blindly re-run this command."; + if verbose { + format!("{msg}\n{source}") + } else { + format!("{msg} Re-run with --verbose for the response detail.") + } + } CliError::Sdk(SdkError::Api { status, body }) => { render_api_error(status.as_u16(), body, verbose, argv) } @@ -139,6 +212,19 @@ pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> Strin /// Status codes have a small set of canonical user-facing messages. Validation /// (400/422) gets the structured body treatment from `parse_api_body`. +/// The gateway's own rejection reason, when the body is a clean short string +/// (the SDK reduces its JSON `{error, message}` shape to that before this +/// point). A long body — e.g. a full 402 payment menu — is not a reason, so it +/// returns `None` and the caller falls back to the generic guidance (plus the +/// raw body under `--verbose`). +fn payment_rejection_reason(body: &str) -> Option { + let trimmed = body.trim(); + if trimmed.is_empty() || trimmed.len() > 200 || trimmed.starts_with('{') { + return None; + } + Some(trimmed.to_string()) +} + fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> String { let headline = match code { 400 | 422 => "invalid request.".to_string(), @@ -182,8 +268,13 @@ fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> St if verbose && !body.is_empty() { out.push('\n'); out.push_str(body); - } else if matches!(code, 400 | 422) && !parsed.bullets.is_empty() && !body.is_empty() { - out.push_str("\nRe-run with --verbose for the full response body."); + } else if !body.is_empty() { + // 400/422 with nothing parseable already surfaced the raw body above; + // everywhere else, point at the flag that reveals it. + let raw_body_shown = matches!(code, 400 | 422) && parsed.bullets.is_empty(); + if !raw_body_shown { + out.push_str("\nRe-run with --verbose for the full response body."); + } } out @@ -464,6 +555,104 @@ mod tests { assert_eq!(exit_code_for(&CliError::Cancelled), 5); } + // ---- payment errors ---- + + fn decode_err() -> SdkError { + SdkError::Decode { + source: serde_json::from_str::("not json").unwrap_err(), + body: "gateway oops".to_string(), + } + } + + #[test] + fn exit_code_payment_refusals_are_2() { + // The gateway said no and nothing settled: an unmatched/unreadable + // offer (unsupported) or a 4xx-refused credential (rejected). The + // paid lane wraps 5xx rejections into PaymentMaybeCharged before + // this mapping ever sees them. + let unsupported = CliError::Sdk(SdkError::PaymentUnsupported { + offered: "eip155:84532/0xabc amount 999999".to_string(), + }); + let rejected = CliError::Sdk(SdkError::PaymentRejected { + status: 402, + body: "invalid signature".to_string(), + }); + assert_eq!(exit_code_for(&unsupported), 2); + assert_eq!(exit_code_for(&rejected), 2); + } + + #[test] + fn exit_code_unknown_payment_outcome_is_3() { + // Request sent, outcome unknown: the transport-ambiguity bucket, so + // scripts can distinguish "safe to retry" (2) from "check wallet" (3). + let indeterminate = CliError::Sdk(SdkError::PaymentIndeterminate); + let maybe_charged = CliError::PaymentMaybeCharged(decode_err()); + assert_eq!(exit_code_for(&indeterminate), 3); + assert_eq!(exit_code_for(&maybe_charged), 3); + } + + #[test] + fn renders_payment_unsupported_as_not_charged() { + let err = CliError::Sdk(SdkError::PaymentUnsupported { + offered: "eip155:84532/0xabc amount 999999".to_string(), + }); + let msg = render(&err, false); + assert!(msg.contains("Nothing was charged"), "got: {msg}"); + assert!(msg.contains("999999"), "got: {msg}"); + assert!(msg.contains("--max-amount"), "got: {msg}"); + } + + #[test] + fn renders_payment_rejected_as_refused_without_settling() { + // A short gateway reason surfaces in the default message (the SDK has + // already reduced its JSON error shape to this string). + let err = CliError::Sdk(SdkError::PaymentRejected { + status: 402, + body: "insufficient funds".to_string(), + }); + let msg = render(&err, false); + assert!(msg.contains("402"), "got: {msg}"); + assert!(msg.contains("refused"), "got: {msg}"); + assert!(msg.contains("nothing should have settled"), "got: {msg}"); + assert!(msg.contains("Gateway: insufficient funds"), "got: {msg}"); + } + + #[test] + fn payment_rejected_hides_long_body_unless_verbose() { + // A long/JSON body (e.g. a full 402 payment menu) is not a reason: the + // default message stays generic and the raw body appears under --verbose. + let body = format!("{{\"accepts\":[{}]}}", "\"x\",".repeat(80)); + let err = CliError::Sdk(SdkError::PaymentRejected { + status: 402, + body: body.clone(), + }); + let msg = render(&err, false); + assert!( + !msg.contains(&body), + "long body must not leak by default: {msg}" + ); + assert!(msg.contains("supported-payments"), "got: {msg}"); + let verbose = render(&err, true); + assert!(verbose.contains("accepts"), "got: {verbose}"); + } + + #[test] + fn renders_payment_indeterminate_as_possibly_settled() { + let msg = render(&CliError::Sdk(SdkError::PaymentIndeterminate), false); + assert!(msg.contains("may have been settled"), "got: {msg}"); + assert!(msg.contains("check your wallet"), "got: {msg}"); + } + + #[test] + fn renders_payment_maybe_charged_with_source_when_verbose() { + let err = CliError::PaymentMaybeCharged(decode_err()); + let msg = render(&err, false); + assert!(msg.contains("may have been settled"), "got: {msg}"); + assert!(!msg.contains("gateway oops"), "got: {msg}"); + let verbose = render(&err, true); + assert!(verbose.contains("gateway oops"), "got: {verbose}"); + } + #[test] fn renders_401_as_unauthorized() { let msg = render(&api_err(401), false); @@ -494,6 +683,19 @@ mod tests { assert!(!msg.contains("boom"), "got: {msg}"); } + #[test] + fn non_verbose_402_hints_at_verbose() { + // A gateway 402 carries an actionable problem+json body; without + // --verbose the message must say how to see it. + let body = r#"{"title":"Insufficient Balance","status":402,"detail":"Insufficient balance: requested 10, available 0."}"#; + let msg = render(&api_err_with(402, body), false); + assert!(msg.contains("402"), "got: {msg}"); + assert!(msg.contains("--verbose"), "got: {msg}"); + assert!(!msg.contains("Insufficient balance"), "got: {msg}"); + let verbose = render(&api_err_with(402, body), true); + assert!(verbose.contains("Insufficient balance"), "got: {verbose}"); + } + // ---- body parsing ---- #[test] diff --git a/src/output.rs b/src/output.rs index dc25071..b67cf28 100644 --- a/src/output.rs +++ b/src/output.rs @@ -295,6 +295,24 @@ where } } +/// A minimal ANSI style set for free-form stderr blocks (not tables), +/// applied only when color is enabled. +pub(crate) enum Style { + Bold, + Dim, +} + +pub(crate) fn style(s: &str, style: Style, color: bool) -> String { + if !color { + return s.to_string(); + } + let code = match style { + Style::Bold => "1", + Style::Dim => "2", + }; + format!("\x1b[{code}m{s}\x1b[0m") +} + /// Helper: a Cell whose text is `value.map_or("—", |v| &v.to_string())`. pub fn opt_cell(v: &Option) -> Cell { match v { diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs new file mode 100644 index 0000000..391f1f8 --- /dev/null +++ b/tests/rpc_payment.rs @@ -0,0 +1,1831 @@ +//! Integration tests for the crypto-micropayment lane of `qn rpc call` +//! (`--x402`/`--mpp`). +//! +//! The harness's hidden `--base-url` is threaded into +//! `PaymentConfig.base_url_override`, so the mock server doubles as the +//! payment gateway: the paid lane POSTs to `{base}/`. The 402 +//! handshake shapes mirror the SDK's own driver tests (an unpaid POST gets a +//! 402 with a payment menu; the resend carries a `payment-signature` / +//! `authorization` header and gets the JSON-RPC result). +//! +//! All key material is fake: anvil throwaway key #0 (public, never funded) +//! and the public Base Sepolia test-USDC address. + +mod common; + +use common::{parse, run_qn, run_qn_no_key}; +use serde_json::json; +use std::sync::atomic::{AtomicUsize, Ordering}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +// anvil key #0 (public throwaway, never funded). +const EVM_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +// Base Sepolia test USDC (public address). +const USDC: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + +/// Writes the throwaway key to a tempfile and returns the (guard, path) pair. +fn key_file() -> (tempfile::NamedTempFile, String) { + use std::io::Write; + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(EVM_KEY.as_bytes()).unwrap(); + f.flush().unwrap(); + let path = f.path().to_str().unwrap().to_string(); + (f, path) +} + +/// One entry of the x402 payment menu the mock gateway offers. +fn x402_accepts_entry(amount: &str) -> serde_json::Value { + json!({ + "scheme": "exact", + "network": "eip155:84532", + "amount": amount, + "payTo": "0x000000000000000000000000000000000000dEaD", + "maxTimeoutSeconds": 60, + "asset": USDC, + "extra": { "name": "USDC", "version": "2" } + }) +} + +/// Sequenced gateway responder: the first (unpaid) POST gets a 402 with a +/// one-entry menu at `amount`; any request carrying a payment signature gets +/// the `paid` response (the JSON-RPC result by default). +struct X402Seq { + amount: &'static str, + paid: ResponseTemplate, + calls: AtomicUsize, +} + +impl X402Seq { + fn new(amount: &'static str) -> Self { + Self::with_paid_response( + amount, + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + })), + ) + } + + fn with_paid_response(amount: &'static str, paid: ResponseTemplate) -> Self { + Self { + amount, + paid, + calls: AtomicUsize::new(0), + } + } +} + +impl Respond for X402Seq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + let has_sig = req.headers.contains_key("payment-signature"); + if n == 0 && !has_sig { + ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry(self.amount) ] + })) + } else { + self.paid.clone() + } + } +} + +/// Mounts `.expect(0)` mocks on the control-plane routes the default lane +/// uses, proving the paid lane never touches them. +async fn mount_control_plane_expect_zero(server: &MockServer) { + for p in ["/v0/tooling-access", "/v0/account/info"] { + Mock::given(path(p)) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(server) + .await; + } +} + +// ── happy paths ────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn x402_happy_path_pays_and_bypasses_control_plane() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(2) // one unpaid probe + one paid resend, nothing else + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + // The harness injects --api-key test: even with a key present, the paid + // lane must not mint, probe, or write the token cache. + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + assert!( + !dir.path().join("tokens.toml").exists(), + "paid lane must not write the token cache" + ); +} + +#[tokio::test] +async fn x402_resolves_usdc_symbol_to_network_asset() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(2) // one unpaid probe + one paid resend + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + // The symbol resolves to base-sepolia's USDC address (matching the offer's + // `asset`), so signing and the paid resend succeed exactly as passing the + // raw address would. + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + "USDC", + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn payment_wallet_resolves_stored_key_for_paid_call() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(2) // one unpaid probe + one paid resend + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + + // Generate a wallet, then pay for a call by referencing it by name. The + // wallet's key is random but a valid secp256k1 key, so the x402 EIP-712 + // signing succeeds and the resend carries a payment signature. + let gen = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "evm", + "--name", + "payer", + ], + ) + .await; + assert_eq!(gen.exit_code, 0, "stderr={}", gen.stderr); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-wallet", + "payer", + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn pay_network_name_matches_caip2_offer() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(2) + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let (_guard, key_path) = key_file(); + + // `base-sepolia` resolves to eip155:84532 before reaching the SDK, so it + // matches the mock gateway's CAIP-2 offer exactly. + let out = run_qn( + &server.uri(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "base-sepolia", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn paid_call_works_keyless_with_config_params() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(2) + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + // Config supplies every parameter and has NO [api] key: the minimal + // invocation is method + --network + the scheme flag, with no login ever. + let dir = tempfile::tempdir().unwrap(); + let (_guard, key_path) = key_file(); + let cfg = dir.path().join("config.toml"); + std::fs::write( + &cfg, + format!( + "[rpc.payment]\nkey_file = \"{key_path}\"\nmax_amount = \"10000\"\n\ + payment_network = \"eip155:84532\"\npayment_asset = \"{USDC}\"\n" + ), + ) + .unwrap(); + + let out = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + cfg.to_str().unwrap(), + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +// ── spend-cap enforcement ──────────────────────────────────────────────────── + +#[tokio::test] +async fn over_cap_offer_is_refused_before_signing() { + let server = MockServer::start().await; + // Menu offers 999999; the cap is 10000. Exactly ONE request: the unpaid + // probe. Nothing is signed, nothing is resent. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("999999")) + .expect(1) + .mount(&server) + .await; + + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!( + out.stderr.contains("Nothing was charged"), + "stderr={}", + out.stderr + ); +} + +#[tokio::test] +async fn flag_max_amount_overrides_config() { + let server = MockServer::start().await; + // Config would allow the 1000 offer (cap 999999); the flag caps at 1. + // A resulting "unsupported" refusal proves the flag won. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let (_guard, key_path) = key_file(); + let cfg = dir.path().join("config.toml"); + std::fs::write( + &cfg, + format!( + "[rpc.payment]\nkey_file = \"{key_path}\"\nmax_amount = \"999999\"\n\ + payment_network = \"eip155:84532\"\npayment_asset = \"{USDC}\"\n" + ), + ) + .unwrap(); + + let out = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + cfg.to_str().unwrap(), + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--max-amount", + "1", + ], + ) + .await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!( + out.stderr.contains("Nothing was charged"), + "stderr={}", + out.stderr + ); +} + +// ── activation and lane isolation ──────────────────────────────────────────── + +#[tokio::test] +async fn config_presence_does_not_auto_activate_payment() { + let server = MockServer::start().await; + // Fully populated [rpc.payment] but NO scheme flag: the call must take the + // normal Tooling Access lane (mint attempt against the control plane) and + // the gateway must see zero traffic. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + Mock::given(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let (_guard, key_path) = key_file(); + let cfg = dir.path().join("config.toml"); + std::fs::write( + &cfg, + format!( + "[api]\nkey = \"test\"\n\n\ + [rpc.payment]\nkey_file = \"{key_path}\"\nmax_amount = \"10000\"\n\ + payment_network = \"eip155:84532\"\npayment_asset = \"{USDC}\"\n" + ), + ) + .unwrap(); + + let out = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + cfg.to_str().unwrap(), + "rpc", + "call", + "eth_blockNumber", + ], + ) + .await; + // The default lane fails against the 500ing control plane — the exit code + // is not the point; the .expect(0) on the gateway is. + assert_ne!(out.exit_code, 0); +} + +#[tokio::test] +async fn paid_lane_is_never_retried() { + let server = MockServer::start().await; + // The gateway 500s every request. Even with --retries 5, exactly ONE + // request may arrive: a retried paid call risks a double charge. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .expect(1) + .mount(&server) + .await; + + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "--retries", + "5", + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_ne!(out.exit_code, 0); +} + +#[tokio::test] +async fn payment_rejected_on_paid_resend_exits_2_as_refused() { + let server = MockServer::start().await; + // 402 menu, then the paid resend is refused with another 402: the gateway + // refused the credential without settling it — exit 2, "refused", and no + // unknown-outcome language. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry("1000") ] + }))) + .expect(2) + .mount(&server) + .await; + + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!(out.stderr.contains("refused"), "stderr={}", out.stderr); + assert!( + out.stderr.contains("nothing should have settled"), + "stderr={}", + out.stderr + ); + assert!( + !out.stderr.contains("may have been settled"), + "a 4xx refusal must not carry unknown-outcome language: {}", + out.stderr + ); +} + +#[tokio::test] +async fn settlement_5xx_on_paid_resend_exits_3_check_wallet() { + let server = MockServer::start().await; + // 402 menu, then the paid resend dies with a 500: the signed payment was + // submitted and the outcome is unknown — exit 3, check-your-wallet. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::with_paid_response( + "1000", + ResponseTemplate::new(500).set_body_string("settlement error"), + )) + .expect(2) + .mount(&server) + .await; + + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 3, "stderr={}", out.stderr); + assert!( + out.stderr.contains("check your wallet"), + "stderr={}", + out.stderr + ); +} + +#[tokio::test] +async fn unparseable_paid_response_exits_3_check_wallet() { + let server = MockServer::start().await; + // The paid resend returns 200 with a body that isn't JSON: the payment + // was submitted (and likely settled) but the result is uninterpretable — + // exit 3, never a generic decode error. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::with_paid_response( + "1000", + ResponseTemplate::new(200).set_body_string("ok?"), + )) + .expect(2) + .mount(&server) + .await; + + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 3, "stderr={}", out.stderr); + assert!( + out.stderr.contains("may have been settled"), + "stderr={}", + out.stderr + ); +} + +#[tokio::test] +async fn malformed_challenge_menu_exits_2_nothing_charged() { + let server = MockServer::start().await; + // The 402 challenge body is not JSON. Nothing has been signed or paid, so + // this must exit 2 with "Nothing was charged" — never the exit-3 + // check-your-wallet path — after exactly one request. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(402).set_body_string("menu?")) + .expect(1) + .mount(&server) + .await; + + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!( + out.stderr.contains("Nothing was charged"), + "stderr={}", + out.stderr + ); +} + +// ── pre-flight failures: fail fast, zero requests ──────────────────────────── + +/// Runs a paid invocation expected to die in pre-flight: any request reaching +/// the mock is a failure. +async fn expect_preflight_error(extra: &[&str], needle: &str) { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let out = run_qn(&server.uri(), extra).await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains(needle), + "expected {needle:?} in stderr: {}", + out.stderr + ); +} + +#[tokio::test] +async fn missing_network_fails_before_any_request() { + let (_guard, key_path) = key_file(); + expect_preflight_error( + &[ + "rpc", + "call", + "eth_blockNumber", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + "--network", + ) + .await; +} + +#[tokio::test] +async fn unknown_pay_network_name_fails_before_any_request() { + let (_guard, key_path) = key_file(); + expect_preflight_error( + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "not-a-chain", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + "unknown pay network 'not-a-chain'", + ) + .await; +} + +#[tokio::test] +async fn missing_key_fails_before_any_request() { + // No flag and a config dir with no key_file/wallet: the key must come from + // a file or a stored wallet, so this fails fast with actionable guidance. + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + expect_preflight_error( + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + "--payment-key-file", + ) + .await; +} + +#[tokio::test] +async fn missing_max_amount_fails_before_any_request() { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + expect_preflight_error( + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + ], + "--max-amount", + ) + .await; +} + +#[tokio::test] +async fn non_integer_max_amount_fails_before_any_request() { + let (_guard, key_path) = key_file(); + expect_preflight_error( + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "1.5", + ], + "base units", + ) + .await; +} + +#[tokio::test] +async fn inline_config_key_fails_before_any_request() { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml"); + std::fs::write( + &cfg, + "[rpc.payment]\nkey = \"0xdeadbeef\"\nmax_amount = \"10000\"\n\ + payment_network = \"eip155:84532\"\npayment_asset = \"0xabc\"\n", + ) + .unwrap(); + expect_preflight_error( + &[ + "--config-file", + cfg.to_str().unwrap(), + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + ], + "key_file", + ) + .await; +} + +#[tokio::test] +async fn params_and_key_both_from_stdin_conflict() { + let (_guard, _) = key_file(); + expect_preflight_error( + &[ + "rpc", + "call", + "eth_call", + "-", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + "-", + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ], + "stdin", + ) + .await; +} + +// ── clap surface ───────────────────────────────────────────────────────────── + +#[test] +fn x402_and_mpp_are_mutually_exclusive() { + let err = parse(&[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "n", + "--x402", + "--mpp", + ]) + .unwrap_err(); + assert!(err.to_string().contains("--mpp"), "got: {err}"); +} + +#[test] +fn payment_flags_require_a_scheme_flag() { + for orphan in [ + vec!["--receipt"], + vec!["--payment-network", "eip155:84532"], + vec!["--payment-asset", "0xabc"], + vec!["--max-amount", "1"], + vec!["--payment-key-file", "/k"], + vec!["--svm-rpc-url", "https://x"], + ] { + let mut argv = vec!["rpc", "call", "eth_blockNumber"]; + argv.extend(orphan.iter().copied()); + assert!( + parse(&argv).is_err(), + "expected {orphan:?} to require --x402/--mpp" + ); + } +} + +#[test] +fn payment_conflicts_with_endpoint_url() { + for scheme in ["--x402", "--mpp"] { + let err = parse(&[ + "rpc", + "call", + "eth_blockNumber", + scheme, + "--endpoint-url", + "https://x/rpc", + ]) + .unwrap_err(); + assert!(err.to_string().contains("--endpoint-url"), "got: {err}"); + } +} + +// ── output shapes (subprocess: the in-process harness can't capture stdout) ── + +/// Runs the real binary against `server`, with HOME pointed at a tempdir so no +/// real config leaks in. The payment key is written to a file under `home` and +/// passed via `--payment-key-file` (the key never comes from the environment). +fn run_qn_subprocess( + server_uri: &str, + home: &std::path::Path, + args: &[&str], +) -> std::process::Output { + let key_path = home.join("payer.key"); + std::fs::write(&key_path, EVM_KEY).unwrap(); + assert_cmd::Command::cargo_bin("qn") + .unwrap() + .env_remove("HOME") + .env("HOME", home) + .args(["--base-url", server_uri, "--no-input"]) + .args(args) + .args(["--payment-key-file", key_path.to_str().unwrap()]) + .output() + .unwrap() +} + +#[tokio::test] +async fn receipt_flag_wraps_stdout_on_mpp() { + let server = MockServer::start().await; + + // MPP challenge (tempo, chain 42431) and a settlement receipt header, both + // base64url-encoded JSON — mirrors the SDK's own MPP driver test. + fn b64url(v: serde_json::Value) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&v).unwrap()) + } + let request = b64url(json!({ + "amount": "1000", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0x000000000000000000000000000000000000bEEF", + "methodDetails": { "chainId": 42431, "feePayer": true } + })); + let www = format!( + "Payment id=\"c1\", realm=\"mpp.example.com\", method=\"tempo\", \ + intent=\"charge\", description=\"d\", expires=\"2099-01-01T00:00:00Z\", \ + request=\"{request}\"" + ); + let receipt = b64url(json!({ + "method": "tempo", "status": "success", + "timestamp": "2026-01-01T00:00:00Z", + "reference": "0xdeadbeef" + })); + + struct MppSeq { + www: String, + receipt: String, + calls: AtomicUsize, + } + impl Respond for MppSeq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 && !req.headers.contains_key("authorization") { + ResponseTemplate::new(402) + .insert_header("WWW-Authenticate", self.www.as_str()) + .set_body_json(json!({ "type": "about:blank" })) + } else { + ResponseTemplate::new(200) + .insert_header("Payment-Receipt", self.receipt.as_str()) + .set_body_json(json!({ "jsonrpc": "2.0", "id": 1, "result": "0xok" })) + } + } + } + Mock::given(method("POST")) + .and(path("/tempo-testnet")) + .respond_with(MppSeq { + www, + receipt, + calls: AtomicUsize::new(0), + }) + .mount(&server) + .await; + + let home = tempfile::tempdir().unwrap(); + let output = run_qn_subprocess( + &server.uri(), + home.path(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "tempo-testnet", + "--mpp", + "--receipt", + "--payment-network", + "eip155:42431", + "--payment-asset", + "0x20c0000000000000000000000000000000000000", + "--max-amount", + "10000", + ], + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(output.status.success(), "stderr={stderr}"); + + let v: serde_json::Value = + serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("bad JSON: {e}\n{stdout}")); + assert_eq!(v["result"].as_str(), Some("0xok")); + assert_eq!( + v["payment_receipt"]["reference"].as_str(), + Some("0xdeadbeef") + ); + // The raw key must never appear on either stream. + assert!(!stdout.contains(EVM_KEY) && !stderr.contains(EVM_KEY)); +} + +#[tokio::test] +async fn receipt_is_null_on_x402_and_bare_without_flag() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .mount(&server) + .await; + + let home = tempfile::tempdir().unwrap(); + let paid_args = [ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000", + ]; + + // With --receipt: wrapped, and x402 has no settlement reference. + let mut with_receipt = paid_args.to_vec(); + with_receipt.push("--receipt"); + let output = run_qn_subprocess(&server.uri(), home.path(), &with_receipt); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(v["result"].as_str(), Some("0x1335f9a")); + assert!(v["payment_receipt"].is_null()); + + // Without --receipt: the bare result, byte-identical shape to unpaid. + let output = run_qn_subprocess(&server.uri(), home.path(), &paid_args); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(output.status.success()); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(v.as_str(), Some("0x1335f9a")); +} + +// ── qn rpc x402 (credit drawdown lifecycle) ────────────────────────────────── +// +// These exercise the x402 noun end-to-end against the mock gateway: SIWX auth +// (POST /auth) mints a session JWT, buy-credits settles the 402 credit offer +// (POST /credits), balance reads GET /credits, and drip hits POST /drip. The +// session is cached under the config dir so a second verb skips re-auth. + +/// Mounts a SIWX /auth responder that returns a fixed session JWT. +async fn mount_auth(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "token": "jwt-test", + "expiresAt": "2099-01-01T00:00:00Z", + "accountId": "eip155:84532:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + }))) + .mount(server) + .await; +} + +/// Sequenced network-scoped credit-purchase responder: the first (unpaid) POST +/// gets a 402 credit offer; the paid resend (with a payment signature) gets a +/// 200 RPC result. The funded balance is read separately via GET /credits. +struct CreditsSeq { + amount: &'static str, + calls: AtomicUsize, +} + +impl Respond for CreditsSeq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + let has_sig = req.headers.contains_key("payment-signature"); + if n == 0 && !has_sig { + ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry(self.amount) ] + })) + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1" + })) + } + } +} + +fn x402_args<'a>(cfg: &'a str, key_path: &'a str, verb: &'a str) -> Vec<&'a str> { + vec![ + "--config-file", + cfg, + "rpc", + "x402", + verb, + "--payment-key-file", + key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000000", + ] +} + +// The session verbs (balance, drip) sign nothing, so they take only the wallet +// key + pay network — no --payment-asset, no --max-amount. +fn x402_session_args<'a>(cfg: &'a str, key_path: &'a str, verb: &'a str) -> Vec<&'a str> { + vec![ + "--config-file", + cfg, + "rpc", + "x402", + verb, + "--payment-key-file", + key_path, + "--payment-network", + "eip155:84532", + ] +} + +#[tokio::test] +async fn x402_buy_credits_happy_path() { + let server = MockServer::start().await; + mount_auth(&server).await; + // Credits are bought by settling the offer on the network-scoped RPC path. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(CreditsSeq { + amount: "1000000", + calls: AtomicUsize::new(0), + }) + .expect(2) // one unpaid offer probe + one paid resend + .mount(&server) + .await; + // The funded balance is then read from GET /credits. + Mock::given(method("GET")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", "credits": 1_000_095u64 + }))) + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let mut args = x402_args(&cfg, &key_path, "buy-credits"); + args.extend_from_slice(&["--network", "base-sepolia", "--yes"]); // query chain + consent + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + // The session JWT is cached for reuse. + assert!(dir.path().join("sessions.toml").exists()); +} + +#[tokio::test] +async fn x402_buy_credits_without_yes_is_needs_confirmation_and_settles_nothing() { + let server = MockServer::start().await; + // The gate is checked before any network I/O: nothing must reach the gateway. + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + // No --yes, non-TTY (the harness sets --no-input): exit 5, zero requests. + let args = x402_args(&cfg, &key_path, "buy-credits"); + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_balance_prints_credits() { + let server = MockServer::start().await; + mount_auth(&server).await; + Mock::given(method("GET")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", "credits": 42u64 + }))) + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let out = run_qn( + &server.uri(), + &x402_session_args(&cfg, &key_path, "balance"), + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_balance_error_maps_to_exit_2() { + let server = MockServer::start().await; + mount_auth(&server).await; + Mock::given(method("GET")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": "invalid_token", "message": "session token invalid" + }))) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let out = run_qn( + &server.uri(), + &x402_session_args(&cfg, &key_path, "balance"), + ) + .await; + // A gateway 4xx that settled nothing maps to exit 2 (SDK Api error). + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_drip_reports_funding_tx() { + let server = MockServer::start().await; + mount_auth(&server).await; + // The faucet returns the on-chain funding transaction, not a credit balance. + Mock::given(method("POST")) + .and(path("/drip")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", + "walletAddress": "0xabc", + "transactionHash": "0xfeed" + }))) + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let out = run_qn(&server.uri(), &x402_session_args(&cfg, &key_path, "drip")).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_balance_rejects_spend_flags() { + // balance signs nothing, so --max-amount (and --payment-asset) are not part + // of its surface: clap rejects the unknown flag before any I/O. + let server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let mut args = x402_session_args(&cfg, &key_path, "balance"); + args.extend_from_slice(&["--max-amount", "10000000"]); + let out = run_qn(&server.uri(), &args).await; + // Unknown flag: clap usage error, exit 1. + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); +} + +// ── qn rpc call --x402-drawdown ────────────────────────────────────────────── +// +// The drawdown lane pays from prepaid credits: no per-call signing, Bearer JWT, +// 1 credit per success. The session is authenticated once (POST /auth), and a +// token_expired 401 triggers exactly one transparent re-auth + retry. + +fn drawdown_call_args<'a>(cfg: &'a str, key_path: &'a str) -> Vec<&'a str> { + vec![ + "--config-file", + cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402-drawdown", + "--payment-key-file", + key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000000", + ] +} + +#[tokio::test] +async fn x402_drawdown_happy_path_uses_bearer_no_signing() { + let server = MockServer::start().await; + mount_auth(&server).await; + // The drawdown POST carries a Bearer JWT and NO payment-signature. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + }))) + .expect(1) // single attempt, no per-call 402 handshake + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let out = run_qn(&server.uri(), &drawdown_call_args(&cfg, &key_path)).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + // No token cache written (keyless lane), but the gateway session is cached. + assert!(!dir.path().join("tokens.toml").exists()); + assert!(dir.path().join("sessions.toml").exists()); +} + +#[tokio::test] +async fn x402_drawdown_needs_only_wallet_and_network() { + // A drawdown call signs nothing per request, so it must NOT require + // --payment-asset or --max-amount; the pay network defaults to --network. + let server = MockServer::start().await; + mount_auth(&server).await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + }))) + .expect(1) + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + // Only --network + the key: no asset, no max-amount, no payment-network. + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402-drawdown", + "--payment-key-file", + &key_path, + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +/// Sequenced /base-sepolia responder: the FIRST drawdown call 401s with +/// token_expired; the SECOND (after a transparent re-auth) returns the result. +/// `status` is the HTTP code of the expired-token response (the gateway uses +/// 401 or 403). +struct ExpiredThenOk { + status: u16, + calls: AtomicUsize, +} + +impl Respond for ExpiredThenOk { + fn respond(&self, _req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 { + ResponseTemplate::new(self.status).set_body_json(json!({ + "error": "token_expired", "message": "session token expired" + })) + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xafterreauth" + })) + } + } +} + +async fn assert_drawdown_reauths_on(status: u16) { + let server = MockServer::start().await; + // Two /auth calls: the initial auth + the re-auth after the expired token. + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "token": "jwt-test", + "expiresAt": "2099-01-01T00:00:00Z", + "accountId": "eip155:84532:0xabc" + }))) + .expect(2) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ExpiredThenOk { + status, + calls: AtomicUsize::new(0), + }) + .expect(2) // one expired attempt + one retry after re-auth + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let out = run_qn(&server.uri(), &drawdown_call_args(&cfg, &key_path)).await; + assert_eq!(out.exit_code, 0, "status={status} stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_drawdown_reauths_once_on_token_expired_401() { + // The gateway can surface an expired token as a 401. + assert_drawdown_reauths_on(401).await; +} + +#[tokio::test] +async fn x402_drawdown_reauths_once_on_token_expired_403() { + // ...or as a 403 — both must trigger the transparent re-auth + retry. + assert_drawdown_reauths_on(403).await; +} + +#[tokio::test] +async fn x402_drawdown_rejects_key_and_params_both_from_stdin() { + // Only one stdin: reading the key from it would silently drain the params. + // The lane must refuse up front with zero gateway I/O. + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_call", + "-", // params from stdin + "--network", + "base-sepolia", + "--x402-drawdown", + "--payment-key-file", + "-", // key ALSO from stdin + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000000", + ], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("both") && out.stderr.contains("stdin"), + "expected the both-from-stdin guard, got: {}", + out.stderr + ); +} + +#[tokio::test] +async fn x402_drawdown_out_of_credits_points_at_buy_credits() { + let server = MockServer::start().await; + mount_auth(&server).await; + // 402 on the drawdown call = no credits; must NOT sign or resend (single + // attempt), and must surface an actionable "buy-credits" error at exit 2 + // (the gateway refused and nothing settled). + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "error": "insufficient_credits", "message": "no credits remaining" + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let out = run_qn(&server.uri(), &drawdown_call_args(&cfg, &key_path)).await; + // Refused, nothing settled → exit 2, with a message pointing at the fix. + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!( + out.stderr.contains("buy-credits"), + "stderr should point at buy-credits, got: {}", + out.stderr + ); +} + +// ── qn rpc mpp (payment channel session) ───────────────────────────────────── +// +// The MPP session lane opens an escrow channel (an on-chain Tempo tx, signed +// offline against the mock), then pays with cumulative vouchers. The mock +// gateway serves a tempo/session 402 challenge on probes and 2xx (with a +// Payment-Receipt header, like the real gateway) on credential POSTs +// (open/topUp/voucher/close). + +use base64::Engine as _; + +const SESSION_ESCROW: &str = "0x33b901018174ddabe4841042ab76ba85d4e24f25"; + +// A base64url tempo/session request body: currency, recipient, amount, and +// the escrow contract the gateway expects deposits in. +fn session_request_b64() -> String { + let json = json!({ + "amount": "500", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0xfd24114c3981aba78ae2441991b1bdb89329c556", + "methodDetails": { "chainId": 42431, "escrowContract": SESSION_ESCROW } + }); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap()) +} + +fn session_www_authenticate() -> String { + format!( + "Payment id=\"c1\", realm=\"mpp.quicknode.com\", method=\"tempo\", intent=\"session\", description=\"d\", expires=\"2099-01-01T00:00:00Z\", request=\"{}\"", + session_request_b64() + ) +} + +// A base64url Payment-Receipt header value, the shape the gateway attaches to +// every accepted session response. +fn session_receipt_b64(accepted: &str, spent: &str) -> String { + let json = json!({ + "method": "tempo", + "intent": "session", + "status": "success", + "timestamp": "2099-01-01T00:00:00Z", + "reference": format!("0x{}", "ab".repeat(32)), + "challengeId": "c1", + "channelId": format!("0x{}", "ab".repeat(32)), + "acceptedCumulative": accepted, + "spent": spent, + }); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap()) +} + +// Mount the session endpoint: a 402 session challenge on an unauthorized POST +// (the probe), and 200 + Payment-Receipt on any POST carrying an +// Authorization: Payment header (credential submissions). +async fn mount_session(server: &MockServer, network: &str) { + let path_str = format!("/session/{network}"); + Mock::given(method("POST")) + .and(path(path_str.clone())) + .and(wiremock::matchers::header_exists("authorization")) + .respond_with( + ResponseTemplate::new(200) + .insert_header( + "Payment-Receipt", + session_receipt_b64("500", "500").as_str(), + ) + .set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xok" + })), + ) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(path_str)) + .respond_with( + ResponseTemplate::new(402) + .insert_header("WWW-Authenticate", session_www_authenticate().as_str()) + .set_body_json(json!({ "type": "about:blank" })), + ) + .mount(server) + .await; +} + +// Decodes the `Authorization: Payment ` credential a request +// carried and returns its `payload` object. +fn credential_payload(req: &wiremock::Request) -> serde_json::Value { + let header = req + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .expect("credential POST must carry Authorization"); + let b64 = header + .strip_prefix("Payment ") + .expect("Authorization must be a Payment credential"); + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64) + .expect("credential must be base64url"); + let credential: serde_json::Value = + serde_json::from_slice(&bytes).expect("credential must be JSON"); + credential["payload"].clone() +} + +fn mpp_args<'a>(cfg: &'a str, key_path: &'a str, verb: &'a str) -> Vec<&'a str> { + vec![ + "--config-file", + cfg, + "rpc", + "mpp", + verb, + "--network", + "tempo-testnet", + "--payment-key-file", + key_path, + "--payment-network", + "eip155:42431", + "--payment-asset", + "0x20c0000000000000000000000000000000000000", + "--max-amount", + "100000000", + ] +} + +#[tokio::test] +async fn mpp_open_happy_path_and_caches_channel() { + let server = MockServer::start().await; + mount_session(&server, "tempo-testnet").await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let mut args = mpp_args(&cfg, &key_path, "open"); + args.extend_from_slice(&["--deposit", "1000000", "--yes"]); + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + assert!( + dir.path().join("channels.toml").exists(), + "open must cache the channel state" + ); + + // The credential POST must carry the legacy contract-backed open payload: + // a transaction plus the opening voucher, no descriptor. + let requests = server.received_requests().await.unwrap(); + let payload = requests + .iter() + .find(|r| r.headers.contains_key("authorization")) + .map(credential_payload) + .expect("an open credential must have been POSTed"); + assert_eq!(payload["action"], "open"); + assert_eq!(payload["type"], "transaction"); + assert_eq!(payload["cumulativeAmount"], "500"); + assert!(payload.get("descriptor").is_none(), "got: {payload}"); + for key in ["channelId", "transaction", "signature", "authorizedSigner"] { + assert!( + payload[key].as_str().is_some_and(|s| s.starts_with("0x")), + "payload must carry hex {key}: {payload}" + ); + } +} + +#[tokio::test] +async fn mpp_status_replays_voucher_and_reads_the_receipt() { + let server = MockServer::start().await; + mount_session(&server, "tempo-testnet").await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + // Open first so a channel is cached, then ask for its status. + let mut open_args = mpp_args(&cfg, &key_path, "open"); + open_args.extend_from_slice(&["--deposit", "1000000", "--yes"]); + let out = run_qn(&server.uri(), &open_args).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + // Exit 0 requires the Payment-Receipt header to have decoded: a missing + // or malformed receipt fails the command. (The harness can't capture the + // in-process stderr note; see run_qn.) + let out = run_qn(&server.uri(), &mpp_args(&cfg, &key_path, "status")).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + // The status POST is an idempotent replay of the current high-water + // voucher: cumulativeAmount stays at the opening amount. + let requests = server.received_requests().await.unwrap(); + let voucher = requests + .iter() + .filter(|r| r.headers.contains_key("authorization")) + .map(credential_payload) + .find(|p| p["action"] == "voucher") + .expect("status must POST a voucher credential"); + assert_eq!(voucher["cumulativeAmount"], "500"); + assert!(voucher.get("descriptor").is_none(), "got: {voucher}"); +} + +#[tokio::test] +async fn mpp_open_without_yes_is_needs_confirmation_and_settles_nothing() { + let server = MockServer::start().await; + // Gate is checked before any network I/O: nothing reaches the gateway. + Mock::given(method("POST")) + .and(path("/session/tempo-testnet")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let mut args = mpp_args(&cfg, &key_path, "open"); + args.extend_from_slice(&["--deposit", "1000000"]); + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn mpp_session_call_without_open_channel_points_at_open() { + let server = MockServer::start().await; + // No channel cached; the call must refuse before any gateway I/O. + Mock::given(method("POST")) + .and(path("/session/tempo-testnet")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "tempo-testnet", + "--mpp-session", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:42431", + "--payment-asset", + "0x20c0000000000000000000000000000000000000", + "--max-amount", + "100000000", + ], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("mpp open"), + "should point at 'mpp open', got: {}", + out.stderr + ); +} diff --git a/tests/rpc_supported_networks.rs b/tests/rpc_supported_networks.rs new file mode 100644 index 0000000..8051c93 --- /dev/null +++ b/tests/rpc_supported_networks.rs @@ -0,0 +1,282 @@ +//! Integration tests for `qn rpc {x402,mpp} supported-networks` and +//! `supported-payments` — the keyless per-gateway discovery lists. +//! +//! `--base-url` points the gateway fetches at one wiremock host and bypasses +//! the on-disk cache, so the mock server stands in for the gateway. The +//! in-process harness can't capture stdout, so rendered output (tables, JSON +//! shape) is asserted via a subprocess; the in-process tests cover exit codes. + +mod common; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use common::run_qn; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Mounts the x402 `/networks` list. +async fn mount_x402_networks(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/networks")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "networks": ["base-sepolia", "ethereum-mainnet", "solana-devnet"] + }))) + .mount(server) + .await; +} + +/// Mounts the x402 `/supported` payment catalog. Served with HTTP 402, like +/// the real gateway (the payment-requirements shape arrives on a 402 status). +async fn mount_x402_supported(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/supported")) + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ + // Two offers for the same (network, asset): a plain token + // offer and a Circle Gateway variant (verifyingContract) whose + // name is an EIP-712 domain — must dedupe to one USDC row. + { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "extra": {"name": "USDC", "version": "2"} + }, + { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "extra": { + "name": "GatewayWalletBatched", + "version": "1", + "verifyingContract": "0x0000000000000000000000000000000000000001" + } + }, + // A network outside the slug table renders as its raw CAIP-2 + // id, with the offer's token name. + { + "scheme": "exact", + "network": "eip155:999999", + "asset": "0x00000000000000000000000000000000000000aa", + "extra": {"name": "Fake Dollar", "version": "1"} + } + ] + }))) + .mount(server) + .await; +} + +/// Builds an MPP `WWW-Authenticate` header with one Tempo-testnet pathUSD +/// challenge and one Solana-mainnet USDC challenge. +fn mpp_challenge_header() -> String { + let tempo = URL_SAFE_NO_PAD.encode( + json!({ + "amount": "1000", + "currency": "0x20c0000000000000000000000000000000000000", + "methodDetails": {"chainId": 42431, "feePayer": true}, + "recipient": "0x0000000000000000000000000000000000000002" + }) + .to_string(), + ); + let solana = URL_SAFE_NO_PAD.encode( + json!({ + "amount": "0.001", + "currency": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "methodDetails": {"decimals": 6, "network": "mainnet-beta"}, + "recipient": "Recipient1111111111111111111111111111111111" + }) + .to_string(), + ); + format!( + "Payment id=\"a\", realm=\"mock\", method=\"tempo\", intent=\"charge\", \ + request=\"{tempo}\", description=\"Quicknode RPC request\", \ + Payment id=\"b\", realm=\"mock\", method=\"solana\", intent=\"charge\", \ + request=\"{solana}\", description=\"Quicknode RPC request\"" + ) +} + +/// Mounts the MPP discovery surface: `/networks` plus the keyless 402 probe +/// against the first listed network (the payments verb needs both). +async fn mount_mpp(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/networks")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "networks": ["base-sepolia", "tempo-testnet"] + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with( + ResponseTemplate::new(402) + .insert_header("www-authenticate", mpp_challenge_header().as_str()), + ) + .mount(server) + .await; +} + +/// Runs the binary with `--format ` and returns (stdout, stderr, ok). +async fn run_qn_bin(server: &MockServer, fmt: &str, args: &[&str]) -> (String, String, bool) { + let uri = server.uri(); + let mut argv = vec![ + "--base-url", + uri.as_str(), + "--no-input", + "--no-color", + "--format", + fmt, + ]; + argv.extend(args); + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .args(&argv) + .output() + .unwrap(); + ( + String::from_utf8(output.stdout).unwrap(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.success(), + ) +} + +#[tokio::test] +async fn x402_supported_networks_fetches_and_exits_zero() { + let server = MockServer::start().await; + mount_x402_networks(&server).await; + + let out = run_qn(&server.uri(), &["rpc", "x402", "supported-networks"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + let out = run_qn(&server.uri(), &["rpc", "x402", "networks"]).await; + assert_eq!(out.exit_code, 0, "alias failed: stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_supported_payments_fetches_and_exits_zero() { + let server = MockServer::start().await; + mount_x402_supported(&server).await; + + let out = run_qn(&server.uri(), &["rpc", "x402", "supported-payments"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + let out = run_qn(&server.uri(), &["rpc", "x402", "payments"]).await; + assert_eq!(out.exit_code, 0, "alias failed: stderr={}", out.stderr); +} + +#[tokio::test] +async fn mpp_supported_networks_fetches_and_exits_zero() { + let server = MockServer::start().await; + mount_mpp(&server).await; + + let out = run_qn(&server.uri(), &["rpc", "mpp", "supported-networks"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn mpp_supported_payments_fetches_and_exits_zero() { + let server = MockServer::start().await; + mount_mpp(&server).await; + + let out = run_qn(&server.uri(), &["rpc", "mpp", "supported-payments"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn supported_networks_surfaces_fetch_failure() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/networks")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let out = run_qn(&server.uri(), &["rpc", "x402", "supported-networks"]).await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("discovery") || out.stderr.contains("gateway catalog"), + "stderr={}", + out.stderr + ); +} + +#[tokio::test] +async fn supported_payments_surfaces_fetch_failure() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/supported")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let out = run_qn(&server.uri(), &["rpc", "x402", "supported-payments"]).await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("discovery") || out.stderr.contains("gateway catalog"), + "stderr={}", + out.stderr + ); +} + +// Subprocess: JSON is a bare array of slugs. +#[tokio::test] +async fn x402_supported_networks_json_is_a_slug_array() { + let server = MockServer::start().await; + mount_x402_networks(&server).await; + + let (stdout, stderr, ok) = + run_qn_bin(&server, "json", &["rpc", "x402", "supported-networks"]).await; + assert!(ok, "stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!( + v, + json!(["base-sepolia", "ethereum-mainnet", "solana-devnet"]) + ); +} + +// Subprocess: JSON is a bare array of payment options; offers dedupe, slugs +// resolve, and unknown networks stay raw CAIP-2. +#[tokio::test] +async fn x402_supported_payments_json_is_an_options_array() { + let server = MockServer::start().await; + mount_x402_supported(&server).await; + + let (stdout, stderr, ok) = + run_qn_bin(&server, "json", &["rpc", "x402", "supported-payments"]).await; + assert!(ok, "stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!( + v, + json!([ + { + "network": "base-sepolia", + "asset": "USDC", + "address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + }, + { + "network": "eip155:999999", + "asset": "Fake Dollar", + "address": "0x00000000000000000000000000000000000000aa" + } + ]) + ); +} + +// Subprocess: the MPP challenge-derived payment options render with resolved +// slugs and symbols. +#[tokio::test] +async fn mpp_supported_payments_renders_challenge_options() { + let server = MockServer::start().await; + mount_mpp(&server).await; + + let (stdout, stderr, ok) = + run_qn_bin(&server, "table", &["rpc", "mpp", "supported-payments"]).await; + assert!(ok, "stderr={stderr}"); + + assert!(stdout.contains("tempo-testnet"), "{stdout}"); + assert!(stdout.contains("pathUSD"), "{stdout}"); + assert!(stdout.contains("solana-mainnet"), "{stdout}"); + assert!(stdout.contains("USDC"), "{stdout}"); + assert!( + stdout.contains("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), + "{stdout}" + ); +} diff --git a/tests/snapshots/table_snapshots__mpp_supported_payments_table_lists_options.snap b/tests/snapshots/table_snapshots__mpp_supported_payments_table_lists_options.snap new file mode 100644 index 0000000..56c95ea --- /dev/null +++ b/tests/snapshots/table_snapshots__mpp_supported_payments_table_lists_options.snap @@ -0,0 +1,6 @@ +--- +source: tests/table_snapshots.rs +expression: "discovery_stdout(&server, \"mpp\", \"supported-payments\").await" +--- +NETWORK ASSET ADDRESS +tempo-testnet pathUSD 0x20c0000000000000000000000000000000000000 diff --git a/tests/snapshots/table_snapshots__x402_supported_networks_table_lists_slugs.snap b/tests/snapshots/table_snapshots__x402_supported_networks_table_lists_slugs.snap new file mode 100644 index 0000000..2764539 --- /dev/null +++ b/tests/snapshots/table_snapshots__x402_supported_networks_table_lists_slugs.snap @@ -0,0 +1,8 @@ +--- +source: tests/table_snapshots.rs +expression: "discovery_stdout(&server, \"x402\", \"supported-networks\").await" +--- +NETWORK +base-sepolia +ethereum-mainnet +solana-devnet diff --git a/tests/snapshots/table_snapshots__x402_supported_payments_table_lists_options.snap b/tests/snapshots/table_snapshots__x402_supported_payments_table_lists_options.snap new file mode 100644 index 0000000..fa9ff9e --- /dev/null +++ b/tests/snapshots/table_snapshots__x402_supported_payments_table_lists_options.snap @@ -0,0 +1,6 @@ +--- +source: tests/table_snapshots.rs +expression: "discovery_stdout(&server, \"x402\", \"supported-payments\").await" +--- +NETWORK ASSET ADDRESS +base-sepolia USDC 0x036CbD53842c5426634e7929541eC2318f3dCF7e diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs index 815feb8..4d9d54c 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -463,3 +463,101 @@ async fn sql_schema_table_renders_nested_table_blocks() { .await; insta::assert_snapshot!(out); } + +/// Runs `qn --format table rpc ` against `server` and returns +/// stdout. Panics (with stderr) on non-zero exit. +async fn discovery_stdout(server: &MockServer, scheme: &str, verb: &str) -> String { + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .env_remove("HOME") + .env("HOME", std::env::temp_dir()) + .args([ + "--base-url", + &server.uri(), + "--no-input", + "--no-color", + "--format", + "table", + "rpc", + scheme, + verb, + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[tokio::test] +async fn x402_supported_networks_table_lists_slugs() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/networks")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "networks": ["base-sepolia", "ethereum-mainnet", "solana-devnet"] + }))) + .mount(&server) + .await; + + insta::assert_snapshot!(discovery_stdout(&server, "x402", "supported-networks").await); +} + +#[tokio::test] +async fn x402_supported_payments_table_lists_options() { + let server = MockServer::start().await; + // Served with HTTP 402, like the real gateway. + Mock::given(method("GET")) + .and(path("/supported")) + .respond_with(ResponseTemplate::new(402).set_body_json(serde_json::json!({ + "x402Version": 2, + "accepts": [{ + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "extra": {"name": "USDC", "version": "2"} + }] + }))) + .mount(&server) + .await; + + insta::assert_snapshot!(discovery_stdout(&server, "x402", "supported-payments").await); +} + +#[tokio::test] +async fn mpp_supported_payments_table_lists_options() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/networks")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "networks": ["base-sepolia", "tempo-testnet"] + }))) + .mount(&server) + .await; + let tempo = URL_SAFE_NO_PAD.encode( + serde_json::json!({ + "amount": "1000", + "currency": "0x20c0000000000000000000000000000000000000", + "methodDetails": {"chainId": 42431}, + "recipient": "0x0000000000000000000000000000000000000002" + }) + .to_string(), + ); + let header = format!( + "Payment id=\"a\", realm=\"mock\", method=\"tempo\", intent=\"charge\", \ + request=\"{tempo}\", description=\"Quicknode RPC request\"" + ); + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(402).insert_header("www-authenticate", header.as_str())) + .mount(&server) + .await; + + insta::assert_snapshot!(discovery_stdout(&server, "mpp", "supported-payments").await); +} diff --git a/tests/wallet.rs b/tests/wallet.rs new file mode 100644 index 0000000..6fab0e1 --- /dev/null +++ b/tests/wallet.rs @@ -0,0 +1,309 @@ +//! Integration tests for `qn wallet …` — the local payment-wallet store. +//! +//! These commands are keyless and make no HTTP calls, so there's no wiremock +//! gateway here. The in-process harness doesn't capture stdout, so assertions +//! go through exit codes and on-disk effects (the key file, its 0600 perms, +//! and the metadata sidecar). `--config-file` points the wallet store at a +//! tempdir so nothing touches the real config. + +mod common; + +use common::run_qn; + +// Any URL works — wallet commands never make a request. The harness requires a +// --base-url value. +const BASE: &str = "http://127.0.0.1:1"; + +fn cfg_in(dir: &std::path::Path) -> String { + dir.join("config.toml").to_str().unwrap().to_string() +} + +fn wallets_dir(dir: &std::path::Path) -> std::path::PathBuf { + dir.join("wallets") +} + +#[tokio::test] +async fn generate_writes_key_and_sidecar_at_0600() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + let out = run_qn( + BASE, + &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "evm", + "--name", + "payer", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + let key = wallets_dir(dir.path()).join("payer"); + let meta = wallets_dir(dir.path()).join("payer.toml"); + assert!(key.exists(), "key file missing"); + assert!(meta.exists(), "metadata sidecar missing"); + + // The stored key is a valid 0x-prefixed hex secp256k1 key. + let raw = std::fs::read_to_string(&key).unwrap(); + assert!(raw.trim().starts_with("0x"), "key not 0x-prefixed: {raw}"); + + // The sidecar records the vm + a derived 0x address, never the key. + let meta_text = std::fs::read_to_string(&meta).unwrap(); + assert!(meta_text.contains("vm = \"evm\""), "meta: {meta_text}"); + assert!(meta_text.contains("0x"), "meta has no address: {meta_text}"); + assert!(!meta_text.contains(raw.trim()), "sidecar leaked the key!"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&key).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "key file not 0600: {mode:o}"); + let dir_mode = std::fs::metadata(wallets_dir(dir.path())) + .unwrap() + .permissions() + .mode(); + assert_eq!( + dir_mode & 0o777, + 0o700, + "wallets dir not 0700: {dir_mode:o}" + ); + } +} + +#[tokio::test] +async fn generate_svm_stores_base58_key() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + let out = run_qn( + BASE, + &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "svm", + "--name", + "sol", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + let raw = std::fs::read_to_string(wallets_dir(dir.path()).join("sol")).unwrap(); + // base58 key: not 0x-prefixed hex, and non-trivially long (64-byte base58 is + // ~88 chars). The SDK's round-trip unit tests cover exact decoding. + assert!(!raw.trim().starts_with("0x")); + assert!( + raw.trim().len() > 64, + "svm key too short: {}", + raw.trim().len() + ); + + // The address in the sidecar is the base58 pubkey, not a 0x address. + let meta = std::fs::read_to_string(wallets_dir(dir.path()).join("sol.toml")).unwrap(); + assert!(meta.contains("vm = \"svm\""), "meta: {meta}"); +} + +#[tokio::test] +async fn generate_refuses_overwrite_without_force() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + let args = &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "evm", + "--name", + "dup", + ]; + assert_eq!(run_qn(BASE, args).await.exit_code, 0); + + // Second generate without --force must fail and leave the key untouched. + let key = wallets_dir(dir.path()).join("dup"); + let before = std::fs::read_to_string(&key).unwrap(); + let out = run_qn(BASE, args).await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("already exists"), + "stderr={}", + out.stderr + ); + let after = std::fs::read_to_string(&key).unwrap(); + assert_eq!(before, after, "key was overwritten without --force"); +} + +#[tokio::test] +async fn generate_rejects_unsafe_name() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + let out = run_qn( + BASE, + &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "evm", + "--name", + "../escape", + ], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("invalid wallet name"), + "stderr={}", + out.stderr + ); +} + +#[tokio::test] +async fn rm_without_yes_non_tty_needs_confirmation_and_keeps_files() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + assert_eq!( + run_qn( + BASE, + &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "evm", + "--name", + "keep", + ], + ) + .await + .exit_code, + 0 + ); + + // Non-TTY without --yes: exit 5, and both files remain. + let out = run_qn(BASE, &["--config-file", &cfg, "wallet", "rm", "keep"]).await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); + assert!(wallets_dir(dir.path()).join("keep").exists()); + assert!(wallets_dir(dir.path()).join("keep.toml").exists()); +} + +#[tokio::test] +async fn rm_with_yes_deletes_key_and_sidecar() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + assert_eq!( + run_qn( + BASE, + &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "evm", + "--name", + "gone", + ], + ) + .await + .exit_code, + 0 + ); + + let out = run_qn( + BASE, + &["--config-file", &cfg, "wallet", "rm", "gone", "--yes"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + assert!(!wallets_dir(dir.path()).join("gone").exists()); + assert!(!wallets_dir(dir.path()).join("gone.toml").exists()); +} + +#[tokio::test] +async fn rm_unknown_wallet_errors() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + let out = run_qn( + BASE, + &["--config-file", &cfg, "wallet", "rm", "nope", "--yes"], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("no wallet named"), + "stderr={}", + out.stderr + ); +} + +#[tokio::test] +async fn show_unknown_wallet_errors() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + let out = run_qn(BASE, &["--config-file", &cfg, "wallet", "show", "ghost"]).await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("no wallet named"), + "stderr={}", + out.stderr + ); +} + +// Subprocess: the in-process harness can't capture stdout/stderr, so assert the +// generate output split (address on stdout; key path + custody note on stderr) +// via the real binary. +#[tokio::test] +async fn generate_prints_key_path_and_custody_note() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .args([ + "--config-file", + &cfg, + "--no-input", + "--no-color", + "wallet", + "generate", + "--vm", + "evm", + "--name", + "payer", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + + // Address is the only thing on stdout (the pipeable value). + assert!(stdout.trim().starts_with("0x"), "stdout={stdout}"); + + // The private key file path and the custody note go to stderr (the address + // is on stdout, so it isn't repeated on stderr). + let key_path = wallets_dir(dir.path()).join("payer"); + assert!( + stderr.contains(&format!("Private key file: {}", key_path.display())), + "stderr missing labeled key path: {stderr}" + ); + assert!( + stderr.contains("stored only on this machine") && stderr.contains("Quicknode does not"), + "stderr missing custody note: {stderr}" + ); + // The raw key must never appear on either stream. + let raw = std::fs::read_to_string(&key_path).unwrap(); + assert!(!stdout.contains(raw.trim()) && !stderr.contains(raw.trim())); +}