diff --git a/.gitignore b/.gitignore index 03f57a6..f5969a4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ docs/agents/ # Local Claude notes (gitignored per-repo configuration) CLAUDE.local.md +IMPLEMENTATION_PLAN.md 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..ad4a8a3 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", @@ -1509,15 +3245,27 @@ dependencies = [ [[package]] name = "quicknode-sdk" -version = "0.7.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d7fd1e431f62f6f82adca349756b31a07e082446c0664a81530e4a791c62ca" +checksum = "1d3e7b2ee767ea526c6508b09639ad4c35edeca3e7f642babc5fdff249b54861" 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 +3301,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1600,14 +3348,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 +3393,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 +3412,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 +3448,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 +3510,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 +3532,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -1738,6 +3574,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 +3608,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 +3632,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 +3683,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 +3728,7 @@ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1880,40 +3797,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 +3968,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 +4031,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2001,7 +4040,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 +4078,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 +4125,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 +4155,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 +4212,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 +4276,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 +4320,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 +4348,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 +4377,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 +4399,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 +4472,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2236,7 +4483,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 +4583,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2362,7 +4618,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -2402,7 +4658,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 +4666,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 +4699,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 +4793,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 +4865,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 +4971,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2709,7 +5001,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 +5014,8 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", - "semver", + "indexmap 2.14.0", + "semver 1.0.28", ] [[package]] @@ -2755,6 +5047,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 +5087,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 +5312,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 +5330,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2989,7 +5343,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -3008,9 +5362,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 +5372,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 +5428,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3075,7 +5449,7 @@ checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3095,7 +5469,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3104,6 +5478,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 +5523,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5738ac6..f0957f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,11 @@ name = "qn" path = "src/lib.rs" [dependencies] -quicknode-sdk = "0.7" +quicknode-sdk = { version = "0.8.1", 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 +46,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..3b6dc30 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,19 @@ +## Stage 1: SDK SIWS support +**Goal**: Authenticate Solana x402 drawdown sessions and settle Solana credit offers. +**Success criteria**: SDK tests cover SIWS construction, Base58 signatures, authentication, and Solana credit settlement. +**Status**: Complete + +## Stage 2: Local SDK integration +**Goal**: Run the CLI against the local SDK implementation. +**Success criteria**: Cargo resolves `quicknode-sdk` from `../sdk/crates/core` with all payment features enabled. +**Status**: Complete + +## Stage 3: CLI behavior and documentation +**Goal**: Expose Solana drawdown through CLI help, examples, and command handling. +**Success criteria**: Solana buy-credit and drawdown integration tests pass without changing EVM behavior. +**Status**: Complete + +## Stage 4: Verification +**Goal**: Validate both repositories with formatting, tests, clippy, and builds. +**Success criteria**: Required checks pass with no new warnings. +**Status**: Complete diff --git a/README.md b/README.md index dced139..3aff81c 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,240 @@ 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) + +Authenticate once, buy a block of prepaid credits when the gateway returns its +credit offer, then spend them with no per-call signing (one credit per +successful response). EVM and Solana payment wallets are supported: + +```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 +``` + +Solana Devnet USDC can fund the drawdown wallet directly. Solana has no x402 +faucet: + +```sh +qn wallet generate --vm svm --name sol-payer +qn rpc x402 buy-credits --network solana-devnet --payment-wallet sol-payer \ + --payment-network solana-devnet \ + --payment-asset 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \ + --max-amount 1000000 +qn rpc call getSlot --network solana-devnet --x402-drawdown \ + --payment-wallet sol-payer --payment-network solana-devnet +``` + +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`. + +> **Note:** `buy-credits` selects the gateway's largest regular x402 offer, +> which buys the credit block. `GatewayWalletBatched` is the separate Circle +> Gateway nanopayment option, not the credit offer. It requires USDC deposited +> in the Circle Gateway wallet contract and is not used by `buy-credits`. +> Path 1 (`--x402`, per request) pays for calls directly. + +##### 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 --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. Only the +# call names --network: the channel is not network-scoped, so query any +# supported network. Here a Tempo testnet deposit pays for an Ethereum mainnet +# call. +qn rpc call eth_blockNumber --network ethereum-mainnet --mpp-session \ + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset USDC --max-amount 1000000 + +# Inspect the channel from the local record (free, no network call). +qn rpc mpp status --payment-wallet payer \ + --payment-network tempo-testnet --payment-asset USDC --max-amount 1000000 + +# Ask the gateway instead and re-sync the accepted spend. This spends one +# request unit from the deposit: the gateway prices every session request. +qn rpc mpp status --verify --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 --deposit 1000000 \ + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset USDC --max-amount 1000000 +qn rpc mpp close --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 + payment +network + payment asset. Two payment assets on one payment network are separate +channels. 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..d99f8e4 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), @@ -210,8 +214,7 @@ impl Cli { config_file: self.config_file.clone(), format: self.format, wide: self.wide, - // format resolved-from-config in Ctx::from_global; auth.rs falls - // back to Table directly if it stays None there. + // Resolve format defaults in the context. no_color: self.no_color, quiet: self.quiet, verbose: self.verbose, @@ -253,9 +256,12 @@ impl Cli { Command::Webhook(args) => commands::webhook::run(args, Ctx::from_global(global)?).await, Command::Kv(args) => commands::kv::run(args, Ctx::from_global(global)?).await, Command::Sql(args) => commands::sql::run(args, Ctx::from_global(global)?).await, - // rpc builds its own Ctx (it seeds the SDK from the on-disk token - // cache before construction), so it takes `global` directly. + // RPC resolves its own context for token-cache seeding. Command::Rpc(args) => commands::rpc::run(args, global).await, + // Wallet management is local and keyless. + 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..b441875 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-2 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,76 @@ 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**: use a gateway micropayment instead of an API key, login, or + Tooling Access. Choose one mode before building the command: + + | Mode | Use it when | Call flag | What pays | + |------|-------------|-----------|-----------| + | x402 per request | You make a small number of calls. | `--x402` | A signed payment on each call. | + | MPP per request | You make a small number of calls through Tempo. | `--mpp` | A signed Tempo payment on each call. | + | x402 drawdown | You want prepaid credits and no per-call signing. | `--x402-drawdown` | One credit per successful response. | + | MPP session | You want repeated calls from a funded Tempo channel. | `--mpp-session` | A cumulative voucher from the channel. | + + Start with discovery. These commands need no API key: + + ```sh + qn rpc x402 supported-networks + qn rpc x402 supported-payments + qn rpc mpp supported-networks + qn rpc mpp supported-payments + ``` + + Use a supported gateway slug for `--network`. It selects the chain the + gateway call queries. Use `--payment-network` for the chain that settles the + payment. They can differ. For example, `--network ethereum-mainnet` can query + Ethereum while `--payment-network base-sepolia` settles an x402 payment. + + `--payment-asset` accepts a token symbol, EVM contract address, or Solana + mint. `--max-amount` sets the per-call or per-action ceiling in integer base + units. An offer above that ceiling is refused before signing. The CLI has no + default ceiling. + + The key source precedence is `--payment-key-file` > `--payment-wallet` > + `key_file` > `wallet` in `[rpc.payment]`. Keys come from a file or a stored + wallet. The CLI does not read payment keys from environment variables, + command-line values, or inline config. Config supplies values but never + activates a mode; the call still needs `--x402`, `--mpp`, + `--x402-drawdown`, or `--mpp-session`. + + `--receipt` changes paid-call stdout to + `{"result": ..., "payment_receipt": ...}`. MPP includes the settlement + reference. x402 returns `null`. Without `--receipt`, paid calls use the same + result shape as unpaid calls. Paid calls cannot use `--endpoint-url`. + + **x402 drawdown** uses a cached gateway session. `qn rpc x402 buy-credits` + signs the credit purchase and requires the full payment flags. `balance` and + `drip` authenticate the wallet but do not sign a payment, so they need only + the wallet and payment network. `drip` works on Base Sepolia only. Fund + Solana wallets out of band. `buy-credits` is gated and needs `--yes` in a + script. `qn rpc call --x402-drawdown` needs the query network and wallet; it + uses one credit after a successful response and does not need an asset or + spend ceiling. The pay network defaults to the query network. Credits can pay + for calls to any supported query network. + + **MPP session** uses a cached Tempo escrow channel. `open --deposit` funds + the channel, `top-up --deposit` adds funds, `status` reads the local record, + and `close` settles and refunds the unused balance. These lifecycle commands + identify the channel with `--payment-network` and `--payment-asset`; they do + not take a query `--network`. `status --verify` contacts the gateway and + spends one request unit. `--mpp-session` needs an open channel and adds the + query `--network`. The channel can fund calls to any supported query network. + `open`, `top-up`, and `close` are gated. A lost channel record under + `/qn/channels.toml` requires opening a new channel. +- `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 +284,79 @@ 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. +**Choose a paid RPC mode:** + +Discovery and wallet setup come first. Fund the address printed by +`qn wallet generate` before you run a paid command. + +```sh +qn rpc x402 supported-networks +qn rpc x402 supported-payments +qn rpc mpp supported-networks +qn rpc mpp supported-payments +qn wallet generate --vm evm --name payer +``` + +**x402 per request:** sign one payment for each call. Use this for occasional +calls. The query network and settlement network can differ. + +```sh +qn rpc call eth_blockNumber --network ethereum-mainnet --x402 \ + --payment-wallet payer --payment-network base-sepolia \ + --payment-asset USDC --max-amount 1000 +``` + +**MPP per request:** pay each call on Tempo. Use the same EVM wallet and add +`--receipt` when you need the settlement reference. + +```sh +qn rpc call eth_blockNumber --network ethereum-mainnet --mpp --receipt \ + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset USDC --max-amount 1000 +``` + +**x402 drawdown:** buy credits once, then spend one credit per successful call. +Use this when you want to avoid signing each call. + +```sh +qn rpc x402 buy-credits --network ethereum-mainnet --yes \ + --payment-wallet payer --payment-network base-sepolia \ + --payment-asset USDC --max-amount 10000000 +qn rpc x402 balance --payment-wallet payer --payment-network base-sepolia +qn rpc call eth_blockNumber --network ethereum-mainnet --x402-drawdown \ + --payment-wallet payer +``` + +**MPP session:** deposit into a Tempo channel, then pay with cumulative +vouchers. Use this for repeated calls. The channel's payment network and asset +identify the channel; the call's `--network` identifies the query chain. + +```sh +qn rpc mpp open --deposit 1000000 --yes \ + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset USDC --max-amount 1000000 +qn rpc call eth_blockNumber --network ethereum-mainnet --mpp-session \ + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset USDC --max-amount 1000000 +qn rpc mpp status --payment-wallet payer \ + --payment-network tempo-testnet --payment-asset USDC --max-amount 1000000 +``` + +Use `qn rpc mpp top-up` after the channel runs low. Use `qn rpc mpp close` to +settle the channel and refund its unused balance. Both commands need the same +channel flags and `--yes` in a script. + +For Solana x402, generate an SVM wallet and set both networks to +`solana-devnet`. Set `--svm-rpc-url` when the public Solana RPC rate-limits the +payment build. + ## 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 61% rename from src/commands/rpc.rs rename to src/commands/rpc/mod.rs index ec02a53..3a3f946 100644 --- a/src/commands/rpc.rs +++ b/src/commands/rpc/mod.rs @@ -1,23 +1,12 @@ -//! `qn rpc call [params]` — make a JSON-RPC call against the account's -//! Tooling Access endpoint (or a custom URL), and `qn rpc list-networks` — list -//! the multichain endpoint's available network keys. -//! -//! By default there's no endpoint URL or token to manage: the SDK mints and -//! refreshes a short-lived session JWT automatically. Because each CLI -//! invocation is a fresh process, we persist that JWT to `tokens.toml` and -//! re-seed it next time (see `crate::config` token cache), so a valid token -//! means no control-plane round trip. The cache is keyed by account id (all API -//! keys for one account share a token); the account is resolved offline on a hit -//! and learned via `account_info` only on a miss, where a mint already occurs. -//! -//! On a never-provisioned account the first call fails with "not enabled"; we -//! offer to enable (prompt on a TTY, `--yes` for scripts/agents, otherwise an -//! actionable error), then retry. -//! -//! A custom endpoint URL (`--endpoint-url` per call, or `[rpc] endpoint_url` in -//! 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. +//! RPC call and network-list commands. Default, custom-URL, and paid calls use +//! separate lanes so their auth, cache, and retry behavior cannot mix. + +mod mpp; +mod pay_asset; +mod pay_network; +mod payment; +mod supported_networks; +mod x402; use std::io::Read; use std::path::{Path, PathBuf}; @@ -44,21 +33,68 @@ pub enum RpcCmd { /// Make a JSON-RPC call. #[command(after_help = "Examples:\n \ qn rpc call eth_blockNumber\n \ - qn rpc call eth_getBalance '[\"0xabc...\", \"latest\"]'\n \ + qn rpc call eth_getBalance '[\"0xabc\", \"latest\"]'\n \ qn rpc call getSlot --network solana-mainnet\n \ 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). + echo '[{\"to\":\"0xabc\",\"data\":\"0x\"},\"latest\"]' | qn rpc call eth_call -\n \ + 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 \ + qn rpc call getSlot --network solana-devnet --x402 \\\n \ + --payment-wallet sol-payer --payment-network solana-devnet \\\n \ + --payment-asset USDC --max-amount 1000000\n \ + qn rpc call getSlot --network solana-devnet --x402 \\\n \ + --payment-wallet sol-payer --payment-network solana-devnet \\\n \ + --payment-asset USDC --max-amount 1000000 \\\n \ + --svm-rpc-url https://my-solana-endpoint.example\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\ + qn rpc x402 buy-credits --network solana-devnet --payment-wallet sol-payer \\\n \ + --payment-network solana-devnet --payment-asset 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \\\n \ + --max-amount 1000000\n\ + qn rpc call getSlot --network solana-devnet --x402-drawdown \\\n \ + --payment-wallet sol-payer --payment-network solana-devnet\n\n\ + MPP payment channel (open once, then pay per call with a voucher):\n \ + qn rpc mpp open --deposit 1000000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000\n \ + qn rpc call eth_blockNumber --network ethereum-mainnet --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 (x402/EVM and MPP)\n \ + qn wallet generate --vm svm --name sol-payer (x402/Solana)")] + 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,18 +126,118 @@ 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; + /// among those at or under it, the cheapest is paid. + #[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, } } -/// `qn rpc list-networks` — always targets the account's Tooling Access -/// multichain endpoint (a custom `endpoint_url` has no network map), so it -/// ignores any configured or flagged custom URL. +/// List network keys from the Tooling Access endpoint. async fn run_list_networks(global: GlobalArgs) -> Result<(), CliError> { let config_path = global.resolve_config_path(); let networks_path = config::networks_cache_path(config_path.as_deref()); @@ -114,11 +250,20 @@ async fn run_list_networks(global: GlobalArgs) -> Result<(), CliError> { } async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + // Paid lanes bypass Tooling Access caches and recovery. + 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) - // is a separate lane: no JWT, no token cache, no Tooling Access recovery. - // Validate the flag here; the config value is validated when `Ctx` builds it. + // Custom URLs bypass JWT and Tooling Access recovery. let flag_endpoint_url = match args.endpoint_url.as_deref() { Some(u) => Some(crate::context::validate_endpoint_url(u)?), None => None, @@ -128,26 +273,15 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { .clone() .or_else(|| config_endpoint_url.clone()); - // Load any cached token to seed the SDK and avoid a mint round trip. We need - // the resolved API key to scope the cache, so resolve the config path first. let config_path = global.resolve_config_path(); let token_path = config::token_cache_path(config_path.as_deref()); let networks_path = config::networks_cache_path(config_path.as_deref()); - // The cache is keyed by account id, resolved offline from the key's - // fingerprint. Resolve the key once here for the load; Ctx re-resolves and - // returns it for the write-back (cheap, and keeps Ctx the source of truth). - // A known key yields both a seed token and its account id, so a cache hit - // never needs a control-plane account lookup on write-back. let (seed, mut account_id) = load_cached_token(&token_path, resolve_key_quietly(&global).as_deref()); let (ctx, api_key) = Ctx::from_global_with_rpc_seed(global, seed, config_endpoint_url)?; - // Multichain selection (--network) needs the per-network URL map. Resolve it - // lazily — only when a network is involved — so the common default-network - // call path stays a single round trip. (A custom URL conflicts with - // --network at the clap layer, so this only runs on the Tooling Access lane.) if args.network.is_some() { let map = ensure_networks(&ctx, networks_path.as_deref()).await?; ctx.sdk.rpc.set_networks(map); @@ -155,17 +289,6 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { let method = args.method.as_str(); - // First attempt. When a custom URL is active, the call goes straight there - // (self-authenticating) and any error surfaces directly — the Tooling Access - // enable/probe recovery below only applies to the token-minting lane. - // - // On the Tooling Access lane, both ways of discovering "disabled" converge on - // the same flow: offer to enable (y/N on a TTY, --yes to auto-enable, - // actionable error otherwise), then retry. - // - mint returns the "not enabled" 400 (no usable token), or - // - the call connect/timeouts against a stale-but-unexpired cached token - // and a status probe confirms the endpoint is disabled (possibly - // out-of-band). That path also clears the stale token first. let result = match call_once( &ctx, method, @@ -183,12 +306,6 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { } Err(e) if is_transport_failure(&e) => { if disabled_per_status(&ctx).await { - // The stale token points at the disabled endpoint. Drop it both - // in memory (so this retry mints fresh) and on disk (so the next - // process does too) before enabling and retrying. Only this - // account's entry is removed; other accounts' tokens and every - // key mapping in the shared file are preserved. A stale token - // implies a cache hit, so `account_id` is known here. ctx.sdk.rpc.clear_cached_token(); if let (Some(p), Some(id)) = (&token_path, account_id) { let _ = config::delete_account_token(p, id); @@ -196,22 +313,13 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { maybe_enable(&ctx).await?; call_after_enable(&ctx, method, ¶ms, args.network.clone()).await? } else { - // Status enabled (real endpoint blip) or the probe itself failed - // (genuine network issue): the honest transport error. return Err(e); } } Err(e) => return Err(map_unknown_network(e)), }; - // Snapshot the (possibly refreshed) token and write it back. On the custom-URL - // lane no token is minted, so `current_token()` is `None` and nothing is - // written — the existing cache is left untouched. Otherwise we persist under - // the account id: an idempotent atomic replace preserving other accounts' - // entries. On a cache hit `account_id` is already known (no extra call); on a - // miss (a new key that just minted) we learn it from `account_info()` once, - // which is cheap relative to the mint that just happened. Best-effort — a - // cache write failure must not fail the call, which already succeeded. + // Persist a refreshed token, but never let cache failure fail a successful call. if let (Some(p), Some(current)) = (&token_path, ctx.sdk.rpc.current_token()) { if account_id.is_none() { account_id = ctx @@ -273,21 +381,13 @@ fn now_unix() -> i64 { .unwrap_or(0) } -/// Returns the endpoint's per-network URL map (key -> http_url), using the -/// `networks.toml` cache when fresh (24h TTL) and otherwise fetching it via -/// `get_endpoint_urls` and rewriting the cache. Requires Tooling Access to be -/// enabled (the endpoint id comes from status). +/// Load the endpoint's per-network URL map from cache or the API. async fn ensure_networks( ctx: &Ctx, networks_path: Option<&std::path::Path>, ) -> Result, CliError> { - // Need the endpoint id to scope the cache and fetch URLs. let mut status = retrying(ctx.global.retries, || ctx.sdk.admin.tooling_access_status()).await?; - // Not enabled yet: this is the same "offer to enable" decision the default - // lane makes on a mint 400 — prompt on a TTY, auto on --yes, actionable - // exit-5 error otherwise. `maybe_enable` returns Cancelled if the user - // declines, so we only re-fetch after a successful enable. if !status.enabled { maybe_enable(ctx).await?; status = retrying(ctx.global.retries, || ctx.sdk.admin.tooling_access_status()).await?; @@ -301,14 +401,12 @@ async fn ensure_networks( )); }; - // Fresh cache hit? if let Some(p) = networks_path { if let Some(map) = config::load_networks(p, &endpoint_id, now_unix()) { return Ok(map); } } - // Miss/stale: fetch and rewrite. let resp = retrying(ctx.global.retries, || { ctx.sdk.admin.get_endpoint_urls(&endpoint_id) }) @@ -331,8 +429,6 @@ fn emit_networks( ) -> Result<(), CliError> { let mut keys: Vec<&String> = map.keys().collect(); keys.sort(); - // Default to a plain one-key-per-line list (friendly for discovery, TTY or - // piped). Only an explicit structured format produces the JSON envelope. if matches!(ctx.global.format, Some(f) if f.is_structured()) { let v = serde_json::json!({ "networks": keys }); return emit_result(ctx, &v); @@ -343,11 +439,7 @@ fn emit_networks( Ok(()) } -/// Offline two-level cache load: resolve `key`'s account id from `[keys]`, then -/// load that account's cached JWT. Returns `(seed, account_id)`. The account id -/// is returned even when there's no token yet, so a subsequent write-back can -/// reuse it without a control-plane `account_info` call. A cache miss (unknown -/// key) yields `(None, None)`. +/// Load a cached token and account ID without network access. fn load_cached_token( token_path: &Option, key: Option<&str>, @@ -364,9 +456,7 @@ fn load_cached_token( ) } -/// Resolve the API key without prompting, swallowing errors (the real -/// resolution + error happens in `Ctx::build`). Used only to scope the cache -/// load before `Ctx` is constructed. +/// Resolve the API key quietly for cache lookup. fn resolve_key_quietly(global: &GlobalArgs) -> Option { let path = global.resolve_config_path(); config::resolve_api_key(global.api_key.as_deref(), path.as_deref(), false, || { @@ -383,9 +473,6 @@ async fn call_once( network: Option, endpoint_url: Option, ) -> Result { - // RPC reads are safe to retry on transient transport failures, same as - // other read-only commands, on both the Tooling Access and custom-URL lanes. - // The SDK handles its own one-shot 401 refresh (Tooling Access lane only). retrying(ctx.global.retries, || { ctx.sdk.rpc.call( method, @@ -398,18 +485,11 @@ async fn call_once( .map_err(Into::into) } -// Total wall-clock budget for retrying a call right after enabling Tooling -// Access, while the freshly-provisioned endpoint host becomes routable. +// Retry budget while a newly enabled endpoint becomes routable. const POST_ENABLE_BUDGET: std::time::Duration = std::time::Duration::from_secs(10); -// A just-enabled endpoint is essentially never reachable on the first instant; -// a short initial wait absorbs the common case before we even try. const POST_ENABLE_INITIAL_WAIT: std::time::Duration = std::time::Duration::from_secs(1); -/// Retry the call right after enabling Tooling Access, tolerating the -/// provisioning lag where the new endpoint host isn't routable yet. Waits 1s -/// first, then retries transient (connect/timeout/5xx/429) failures with -/// exponential backoff until `POST_ENABLE_BUDGET` (~10s) is exhausted. A -/// non-transient error (e.g. a JSON-RPC or 4xx) returns immediately. +/// Retry transient failures while a new endpoint provisions. async fn call_after_enable( ctx: &Ctx, method: &str, @@ -421,8 +501,6 @@ async fn call_after_enable( let deadline = tokio::time::Instant::now() + POST_ENABLE_BUDGET; let mut backoff = std::time::Duration::from_millis(500); loop { - // Post-enable retry only runs on the Tooling Access lane (a custom URL - // bypasses the enable flow entirely), so no custom endpoint_url here. match ctx .sdk .rpc @@ -436,7 +514,6 @@ async fn call_after_enable( if !is_transport_failure(&cli_err) || now >= deadline { return Err(cli_err); } - // Don't sleep past the deadline. let remaining = deadline - now; tokio::time::sleep(backoff.min(remaining)).await; backoff = (backoff * 2).min(std::time::Duration::from_secs(4)); @@ -445,7 +522,7 @@ async fn call_after_enable( } } -/// True when the error is the control plane's "Tooling Access not enabled" 400. +/// Check for the control plane's "not enabled" response. fn is_not_enabled(err: &CliError) -> bool { matches!( err, @@ -454,8 +531,7 @@ fn is_not_enabled(err: &CliError) -> bool { ) } -/// True for a connect/timeout transport failure (as opposed to an HTTP status -/// or JSON-RPC error). These are the ambiguous failures worth probing status for. +/// Check for a connect or timeout failure. fn is_transport_failure(err: &CliError) -> bool { use quicknode_sdk::errors::HttpKind; matches!( @@ -465,21 +541,13 @@ fn is_transport_failure(err: &CliError) -> bool { ) } -/// Probe `tooling_access_status` to disambiguate an RPC connect/timeout failure. -/// Returns true only if the probe succeeds and reports the endpoint disabled -/// (the case we can act on). A probe that fails (genuine network issue) or -/// reports enabled (real endpoint blip) returns false, so the caller surfaces -/// the original transport error. No retries: best-effort diagnosis on an -/// already-failed call. +/// Check whether a failed RPC targets a disabled endpoint. async fn disabled_per_status(ctx: &Ctx) -> bool { matches!(ctx.sdk.admin.tooling_access_status().await, Ok(s) if !s.enabled) } -/// Offer to enable Tooling Access: auto on `--yes`, prompt on a TTY, and an -/// actionable error in non-interactive contexts. Mirrors the confirmation -/// gating used for destructive ops (`crate::confirm`). +/// Enable Tooling Access with the standard confirmation behavior. async fn maybe_enable(ctx: &Ctx) -> Result<(), CliError> { - // The global -y/--yes (count) is consent to provision without prompting. let cfg = ConfirmCfg::new( ctx.global.yes_count, ctx.global.no_input, @@ -488,7 +556,6 @@ async fn maybe_enable(ctx: &Ctx) -> Result<(), CliError> { let proceed = match decide_without_prompt(Severity::Mild, cfg) { Ok(p) => p, - // Non-interactive without consent: point the user at the explicit command. Err(CliError::NeedsConfirmation) => { return Err(CliError::Arg( "Tooling Access is not enabled for this account. \ @@ -505,18 +572,16 @@ async fn maybe_enable(ctx: &Ctx) -> Result<(), CliError> { return Err(CliError::Cancelled); } - // enable mutates account state — never retry. enable() surfaces the typed - // reason (e.g. admin-role / eligibility 4xx) which renders actionably. ctx.sdk.admin.enable_tooling_access().await?; ctx.out.note("✓ Enabled Tooling Access"); Ok(()) } -/// Resolve params from the inline positional arg or `--params-file`, then parse -/// 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> { +/// Read and parse inline or file-based JSON-RPC params. +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 +604,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) @@ -547,12 +612,8 @@ fn read_stdin(what: &str) -> Result { Ok(buf) } -/// Emit the RPC result. RPC results are schemaless JSON, so JSON is the real -/// default: a bare `qn rpc` prints JSON whether on a TTY or piped (we read the -/// 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> { +/// Emit a schemaless RPC result in the requested format. +pub(super) fn emit_result(ctx: &Ctx, result: &Value) -> Result<(), CliError> { match ctx.global.format { None | Some(Format::Json) => { println!( @@ -574,8 +635,6 @@ fn emit_result(ctx: &Ctx, result: &Value) -> Result<(), CliError> { } Some(fmt @ (Format::Table | Format::Md)) => { let name = if fmt == Format::Md { "md" } else { "table" }; - // The user explicitly asked for a tabular format, which has no - // columns for schemaless RPC output; say so once, then print JSON. ctx.out.warn(&format!( "ℹ '-o {name}' has no columns for 'qn rpc'; printing JSON. Use -o json/yaml/toon for structured output." )); diff --git a/src/commands/rpc/mpp.rs b/src/commands/rpc/mpp.rs new file mode 100644 index 0000000..c08a579 --- /dev/null +++ b/src/commands/rpc/mpp.rs @@ -0,0 +1,495 @@ +//! MPP payment-channel lifecycle. Channel state is cached by payer, pay network, +//! and asset. Lifecycle operations are keyless and 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 --deposit 1000000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset pathUSD --max-amount 1000000\n \ + qn rpc call eth_blockNumber --network ethereum-mainnet --mpp-session \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset pathUSD --max-amount 1000000\n \ + qn rpc mpp status \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset pathUSD --max-amount 1000000\n \ + qn rpc mpp top-up --deposit 500000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset pathUSD --max-amount 500000\n \ + qn rpc mpp close \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset pathUSD --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 --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 --deposit 500000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset pathUSD --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 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset pathUSD --max-amount 1000000")] + Close(ChannelArgs), + + /// Show the channel's deposit and remaining balance from the local record. + /// Pass --verify to ask the gateway instead (spends one request unit). + #[command(after_help = "Examples:\n \ + qn rpc mpp status \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset pathUSD --max-amount 1000000\n \ + qn rpc mpp status --verify \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset pathUSD --max-amount 1000000")] + Status(StatusArgs), + + /// 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 shared by the MPP verbs. There is no query +/// network here: the channel is scoped by --payment-network and +/// --payment-asset, and one open channel funds paid calls to every supported +/// network, so the lifecycle verbs never name a chain to query. +#[derive(Debug, ClapArgs)] +pub struct PaymentArgs { + /// 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, +} + +/// `status` reads the local channel record by default. `--verify` asks the +/// gateway instead, which costs one request unit (see `run_status`). +#[derive(Debug, ClapArgs)] +pub struct StatusArgs { + #[command(flatten)] + pub payment: PaymentArgs, + + /// Ask the gateway for its view instead of reading the local record. Spends + /// one request unit from the channel deposit. + #[arg(long)] + pub verify: bool, +} + +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, 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 + } + } +} + +// Resolve payment config and the channel's pay scope before network I/O. +fn setup(args: &PaymentArgs, global: GlobalArgs) -> Result<(Ctx, PayScope), 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 scope = PayScope::from_config(&payment); + let ctx = Ctx::from_global_keyless_payment(global, payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + Ok((ctx, scope)) +} + +// Pay scope carried to channel-cache keying; the payer address is added later. +pub(super) struct PayScope { + pay_network: String, + pay_asset: String, +} + +impl PayScope { + pub(super) fn from_config(payment: &quicknode_sdk::PaymentConfig) -> Self { + PayScope { + pay_network: payment.pay_network.clone(), + pay_asset: payment.asset.clone(), + } + } + + pub(super) fn with_address(&self, address: String) -> config::ChannelScope { + config::ChannelScope { + address, + pay_network: self.pay_network.clone(), + pay_asset: self.pay_asset.clone(), + } + } + + pub(super) fn describe(&self) -> String { + format!("{} on {}", self.pay_asset, self.pay_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}'" + )) + }) +} + +// Print a ready-to-run next-command hint using explicit payment flags. +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, scope) = setup(&args.payment, global.clone())?; + + crate::confirm::confirm_mild( + &ctx, + &format!( + "Open an MPP channel with a {deposit} base-unit deposit of {}? \ + This moves real funds on-chain.", + scope.describe() + ), + )?; + + let channel = ctx + .sdk + .rpc + .mpp_open(deposit) + .await + .map_err(super::payment::map_paid_error)?; + persist_channel(&ctx, &global, &scope, &channel); + + ctx.out.note(&format!( + "✓ Opened channel {} (deposit: {})", + channel.channel_id, channel.deposit + )); + note_next( + &ctx, + "qn rpc call eth_blockNumber --network ethereum-mainnet --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, scope) = setup(&args.payment, global.clone())?; + let channel = require_channel(&ctx, &global, &scope)?; + + crate::confirm::confirm_mild( + &ctx, + &format!( + "Top up channel {} with {additional} more base units of {}? \ + This moves real funds on-chain.", + channel.channel_id, + scope.describe() + ), + )?; + + let updated = ctx + .sdk + .rpc + .mpp_top_up(&channel, additional) + .await + .map_err(super::payment::map_paid_error)?; + persist_channel(&ctx, &global, &scope, &updated); + + ctx.out.note(&format!( + "✓ Topped up channel {} (deposit: {})", + updated.channel_id, updated.deposit + )); + note_next(&ctx, "qn rpc mpp status", &args.payment); + emit_channel(&ctx, &updated) +} + +async fn run_close(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { + let (ctx, scope) = setup(&args, global.clone())?; + let channel = require_channel(&ctx, &global, &scope)?; + + crate::confirm::confirm_mild( + &ctx, + &format!( + "Close channel {} ({})? It settles on-chain and refunds the \ + unused deposit; further --mpp-session calls fail until you open a \ + new channel.", + channel.channel_id, + scope.describe() + ), + )?; + + ctx.sdk + .rpc + .mpp_close(&channel) + .await + .map_err(super::payment::map_paid_error)?; + // Do not reuse a channel after settlement. + 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, &scope.with_address(address)); + } + } + + ctx.out + .note(&format!("✓ Closed channel {}", channel.channel_id)); + note_next(&ctx, "qn rpc mpp open --deposit ", &args); + Ok(()) +} + +// Read local state by default; verification spends one request unit and syncs it. +async fn run_status(args: StatusArgs, global: GlobalArgs) -> Result<(), CliError> { + let (ctx, scope) = setup(&args.payment, global.clone())?; + let channel = require_channel(&ctx, &global, &scope)?; + + if !args.verify { + return emit_status(&ctx, &channel, channel.cumulative_spent, None); + } + + let status = ctx.sdk.rpc.mpp_status(&channel).await?; + + // Persist the gateway's accepted high-water mark before rendering. + let mut synced = channel.clone(); + synced.cumulative_spent = synced + .cumulative_spent + .saturating_add(synced.per_call) + .max(status.accepted_cumulative); + persist_channel(&ctx, &global, &scope, &synced); + + emit_status( + &ctx, + &synced, + status.accepted_cumulative, + Some(status.spent), + ) +} + +fn emit_status( + ctx: &Ctx, + channel: &ChannelState, + accepted: u128, + spent: Option, +) -> Result<(), CliError> { + let deposit = channel.deposit; + let remaining = deposit.saturating_sub(accepted); + if matches!(ctx.global.format, Some(f) if f.is_structured()) { + let mut v = serde_json::json!({ + "channel_id": channel.channel_id, + "deposit": deposit, + "accepted_cumulative": accepted, + "remaining": remaining, + "verified": spent.is_some(), + }); + if let Some(spent) = spent { + v["spent"] = serde_json::json!(spent); + } + return super::emit_result(ctx, &v); + } + ctx.out.note(&format!( + "channel {}: deposit {}, accepted {}, remaining {}{}", + channel.channel_id, + deposit, + accepted, + remaining, + if spent.is_some() { + "" + } else { + " (local record; --verify asks the gateway)" + }, + )); + Ok(()) +} + +fn wallet_address(ctx: &Ctx) -> Option { + ctx.sdk.rpc.payment_address().ok() +} + +fn persist_channel(ctx: &Ctx, global: &GlobalArgs, scope: &PayScope, 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, &scope.with_address(address), channel); + } + } +} + +fn require_channel( + ctx: &Ctx, + global: &GlobalArgs, + scope: &PayScope, +) -> 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, &scope.with_address(address))); + channel.ok_or_else(|| { + CliError::Arg(format!( + "no open MPP channel for this wallet paying {}. Open one with \ + 'qn rpc mpp open --deposit '.", + scope.describe() + )) + }) +} + +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..d38ceaf --- /dev/null +++ b/src/commands/rpc/pay_asset.rs @@ -0,0 +1,167 @@ +//! Resolve known payment-asset symbols against a CAIP-2 network. + +use crate::errors::CliError; + +/// Sorted `(network, symbol, address)` entries for binary search. +const PAY_ASSETS: &[(&str, &str, &str)] = &[ + ( + "eip155:196", + "usdc", + "0x4ae46a509F6b1D9056937BA4500cb143933D2dc8", + ), + ( + "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", + ), +]; + +/// Symbols accepted by the resolver, in lowercase. +const KNOWN_SYMBOLS: &[&str] = &["pathusd", "usdc"]; + +/// Resolve a symbol; pass explicit addresses 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() + ))), + } +} + +/// Return the display symbol for a known address on `network`. +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)) +} + +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" + ); + 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); + 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") + ); + assert_eq!( + symbol_for("eip155:84532", "0x036cbd53842c5426634e7929541ec2318f3dcf7e").as_deref(), + Some("USDC") + ); + 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..8df41f6 --- /dev/null +++ b/src/commands/rpc/pay_network.rs @@ -0,0 +1,174 @@ +//! Resolve Quicknode payment-network names to CAIP-2 IDs. + +use crate::errors::CliError; + +/// Sorted Quicknode-name to CAIP-2 mappings. +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"), + ("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"), +]; + +/// Resolve a network name or pass through a CAIP-2 ID. +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" + ))), + } +} + +/// Return the first Quicknode name mapped to a CAIP-2 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"); + 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..d9a7a01 --- /dev/null +++ b/src/commands/rpc/payment.rs @@ -0,0 +1,1036 @@ +//! Keyless x402/MPP payment lane. Configuration is resolved before network I/O; +//! paid calls are single-attempt because a lost response may mean settlement. + +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; + +// Refresh cached sessions before expiry to absorb clock skew. +const SESSION_MARGIN_SECS: i64 = 60; + +/// Reject using stdin for both params and the payment key. +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(()) +} + +pub(super) async fn run_paid_call(args: CallArgs, global: GlobalArgs) -> 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(), + )); + } + + let section = load_payment_section(&global)?; + + 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); + } + + let resp = ctx + .sdk + .rpc + .call_with_receipt(&args.method, params, Some(network), None) + .await + .map_err(map_paid_error)?; + + if args.receipt { + 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 { + super::emit_result(&ctx, &resp.result) + } +} + +// Drawdown calls use a cached Bearer session and one transparent auth refresh. +pub(super) async fn run_drawdown_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + check_single_stdin(&args)?; + 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?; + + 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) +} + +/// Load a fresh cached gateway session or authenticate one. +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 +} + +/// Authenticate and replace the cached session. +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) +} + +/// Check for an expired gateway session. +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")) + ) +} + +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") + ) +} + +// Add a balance hint for empty-credit failures, then map the 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) +} + +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() +} + +// Pay from a cached MPP channel with one cumulative voucher. +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 pay_scope = super::mpp::PayScope::from_config(&payment); + 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 scope = pay_scope.with_address(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, &scope)) + .ok_or_else(|| { + CliError::Arg(format!( + "no open MPP channel for this wallet paying {}. Open one with \ + 'qn rpc mpp open --deposit '.", + pay_scope.describe() + )) + })?; + + 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 --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)?; + + channel.cumulative_spent = new_cumulative; + if let Some(path) = &channels_path { + let _ = config::save_channel(path, &scope, &channel); + } + + super::emit_result(&ctx, &result) +} + +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) +} + +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()) +} + +/// Payment parameters shared by paid call and lifecycle commands. +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>, +} + +/// Parameters needed by keyless session commands. +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> { + let scheme = if args.x402 { "x402" } else { "mpp" }; + + 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)) +} + +// Resolve the wallet and pay network for a drawdown session. +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, + )?; + + 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: String::new(), + max_amount: "0".to_string(), + svm_rpc_url, + base_url_override, + }, + key_file_warning, + )) +} + +// Resolve the wallet and pay network for balance/drip. +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: String::new(), + max_amount: "0".to_string(), + svm_rpc_url, + base_url_override, + }, + key_file_warning, + )) +} + +/// Resolve payment parameters before any network I/O. +pub(super) fn resolve_payment_params( + scheme: &str, + params: &PaymentParams<'_>, + 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 (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, + )) +} + +/// Resolve a payment key from a file or stored wallet. +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(), + )) +} + +/// Read a key file and warn on loose Unix permissions. +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)) +} + +/// Trim and reject an empty key without exposing 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()) +} + +/// Map post-payment decode and 5xx failures to the unknown-outcome bucket. +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 + } + + 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"); + 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"); + } + + #[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}"); + 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); + 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() { + 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..d561022 --- /dev/null +++ b/src/commands/rpc/supported_networks.rs @@ -0,0 +1,468 @@ +//! Keyless discovery lists for the x402 and MPP gateways. Results are cached +//! per scheme for 24 hours. + +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 to query. +#[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, + } + } +} + +// Shared gateway and cache state. Test base URLs bypass the cache. +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() + } + + /// Get callable networks from cache or the gateway. + 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) + } + + /// Get payment options from cache or the gateway. + 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) + } +} + +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)) +} + +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)) +} + +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) +} + +// Parse x402's catalog and deduplicate by network and asset address. +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 catalog uses an x402 402 response. + 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))?; + + 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) +} + +// MPP exposes payment options in a keyless 402 challenge. +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}" + ))); + } + + 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()) +} + +struct Challenge { + method: String, + request: serde_json::Value, +} + +/// Parse the `Payment` challenges needed for discovery. +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 +} + +/// Split on a delimiter outside quoted values. +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) +} + +/// Decode one base64url-encoded challenge request. +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, + }) +} + +#[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) + } +} + +#[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"}}"#, + ); + 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}}"#); + 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..a495d4b --- /dev/null +++ b/src/commands/rpc/x402.rs @@ -0,0 +1,488 @@ +//! x402 prepaid-credit lifecycle. Sessions are cached by payer address and +//! paid operations are single-attempt. + +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, +}; + +// Keep examples explicit about query and payment networks being independent. +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 --payment-network base-sepolia\n \ + qn rpc x402 balance --payment-wallet payer --payment-network base-sepolia\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 buy-credits --network solana-devnet --payment-wallet sol-payer \\\n \ + --payment-network solana-devnet --payment-asset 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \\\n \ + --max-amount 1000000 --svm-rpc-url https://solana.example/rpc\n \ + qn rpc call getSlot --network solana-devnet --x402-drawdown \\\n \ + --payment-wallet sol-payer --payment-network solana-devnet\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 --payment-network base-sepolia\n \ + qn rpc x402 balance --payment-wallet payer --payment-network base-sepolia \\\n \ + --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 --payment-network base-sepolia")] + 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 or Solana base58); + /// `-` 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, Solana mint, 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 or Solana base58); + /// `-` 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 purchase config and build the keyless context before I/O. +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)) +} + +// Resolve the keyless session config; balance and drip do not sign payments. +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) +} + +// Resolve the gateway path slug for a credit purchase. +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(), + )) +} + +// Load the payment defaults, treating a missing file as empty. +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())?; + + // Confirm before the gateway probe because the purchase is single-attempt. + 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, &payment, ctx.out.color) + )); + if matches!(ctx.global.format, Some(f) if f.is_structured()) || !ctx.out.stdout_is_tty { + return emit_balance(&ctx, &balance); + } + Ok(()) +} + +// Build the next drawdown command. Credits are not network-scoped. +fn drawdown_call_hint(args: &PaymentArgs, payment: &PaymentConfig, color: bool) -> String { + let (method, query_network) = if payment.pay_network.starts_with("solana:") { + ("getSlot", "solana-mainnet") + } else { + ("eth_blockNumber", EXAMPLE_QUERY_NETWORK) + }; + 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("") + ); + cmd = cmd + .replace("eth_blockNumber", method) + .replace(EXAMPLE_QUERY_NETWORK, query_network); + 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> { + reject_non_base_drip(&args, &global)?; + let ctx = session_setup(&args, global.clone())?; + let session = ensure_gateway_session(&ctx, &global).await?; + let receipt = ctx.sdk.rpc.gateway_drip(&session).await?; + + ctx.out.note(&format!( + "✓ Faucet funded {} (tx: {})", + receipt.account_id, receipt.transaction_hash + )); + 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, + ), + 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); + } + if !ctx.out.stdout_is_tty { + println!("{}", receipt.transaction_hash); + } + Ok(()) +} + +fn reject_non_base_drip(args: &SessionArgs, global: &GlobalArgs) -> Result<(), CliError> { + let section = load_payment_section(global)?; + let Some(network) = args + .payment_network + .clone() + .or_else(|| section.payment_network.clone()) + else { + return Ok(()); + }; + let resolved = super::pay_network::resolve(&network)?; + if resolved != "eip155:84532" { + return Err(CliError::Arg( + "x402 drip is available only on Base Sepolia. Fund Solana wallets out of band." + .to_string(), + )); + } + Ok(()) +} + +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(()) +} + +fn fmt_credits(n: u64) -> String { + group_digits(&n.to_string()) +} + +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..4d91c4a --- /dev/null +++ b/src/commands/wallet.rs @@ -0,0 +1,476 @@ +//! Local payment-wallet storage. Raw keys are unencrypted files with 0600 +//! permissions; public metadata is stored in a separate sidecar. + +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::confirm_mild; +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)] +#[command(after_help = "Examples:\n \ + qn wallet generate --vm evm --name payer\n \ + qn wallet list\n \ + qn wallet show payer\n \ + qn wallet rm payer")] +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). + #[command(after_help = "Examples:\n \ + qn wallet show payer")] + Show(ShowArgs), + + /// Delete a stored wallet. The key is the only copy and cannot be recovered. + #[command(after_help = "Examples:\n \ + qn wallet rm payer\n \ + qn wallet rm payer --yes")] + 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), + } +} + +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(); + + // Write the key before its metadata so a partial write leaves no usable record. + 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)); + } + + confirm_mild( + &ctx, + &format!( + "Delete wallet '{name}'? The private key is destroyed locally; without a backup \ + of the key, funds at its address are unrecoverable." + ), + )?; + + // Remove the key first; a leftover sidecar must not preserve a usable key. + 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(()) +} + +// The custody disclaimer shown on generate/show. +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."; + +/// Print the address to stdout and interactive details to stderr. +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; + + let mut block = String::new(); + + if on_tty { + if let Some(qr) = render_qr(&meta.address) { + block.push('\n'); + block.push_str(&qr); + block.push('\n'); + } + } + + block.push_str(&format!( + "{} {}\n", + style("Private key file:", Style::Dim, c), + style(&key_path.display().to_string(), Style::Bold, c) + )); + + 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.\n\nTestnet 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))); + } + + block.push('\n'); + block.push_str(&style(&format!("⚠ {CUSTODY_NOTE}"), Style::Dim, c)); + + ctx.out.note(&block); +} + +// Build the matching `[rpc.payment]` example. +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" + ) +} + +// Build testnet funding hints for the wallet VM. +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" + ) + } + } +} + +// Build a runnable paid call for a freshly funded wallet. +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 1000000" + ), + _ => 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" + ), + } +} + +/// Render a Unicode QR code, if encoding succeeds. +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) + } +} + +// Public wallet metadata stored beside the key. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WalletMeta { + name: String, + #[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), + })?; + 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() +} + +/// Load readable wallet metadata sidecars. +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() +} + +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")) +} + +/// Validate a wallet name for use as a filename. +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'" + )) +} + +/// Resolve a stored wallet name to its key file. +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..8e1c7e2 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,51 @@ pub struct ApiSection { pub struct RpcSection { #[serde(default)] pub endpoint_url: Option, + #[serde(default)] + pub payment: PaymentSection, +} + +/// Defaults for the paid RPC lane. Values do not enable payment by themselves. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct PaymentSection { + /// Path to the raw payment key, never the key itself. + #[serde(default)] + pub key_file: Option, + /// Stored wallet name, as an alternative to `key_file`. + #[serde(default)] + pub wallet: Option, + /// Reject an inline raw key instead of silently ignoring it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + /// Default spend ceiling in asset base units. + #[serde(default, deserialize_with = "de_opt_string_or_int")] + pub max_amount: Option, + /// Network where payments settle, independent of the query network. + #[serde(default)] + pub payment_network: Option, + /// Payment token contract or mint. + #[serde(default)] + pub payment_asset: Option, + /// Solana RPC URL for payment builds. + #[serde(default)] + pub svm_rpc_url: Option, +} + +/// Deserialize an optional TOML string or integer as a string. +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)] @@ -224,36 +271,21 @@ fn write_config(path: &Path, cfg: &ConfigFile) -> Result<(), CliError> { Ok(()) } -// ── Tooling Access token cache ─────────────────────────────────────────────── -// -// Each `qn rpc` is a fresh process, so the SDK's in-memory JWT cache starts -// empty every time. We persist the short-lived (~10 min) session token next to -// the config (`tokens.toml`) and re-seed the SDK on the next invocation, -// avoiding a control-plane round trip while the token is still valid. -// -// Tooling Access endpoints are provisioned per account, not per API key, so the -// cache is keyed by account id via a two-level lookup: -// -// sha256(api_key) -> account_id -> { endpoint_url, token, exp_unix } -// -// The `[keys]` table maps an API-key fingerprint to its account id; the -// `[tokens.]` tables hold the JWTs. On a hit, both lookups are -// offline (no control-plane call). All API keys for one account share a single -// cached token. Only the short-lived JWT is written here — never the long-lived -// API key. The account id is a non-secret numeric identifier, stored raw. +// Tooling Access token cache. Tokens are keyed by account ID, with an API-key +// fingerprint mapping used to resolve the account offline. use std::collections::HashMap; use quicknode_sdk::CachedToken; +use quicknode_sdk::GatewaySession; -/// On-disk shape of `~/.config/qn/tokens.toml`. +/// On-disk token cache. #[derive(Debug, Default, Serialize, Deserialize)] pub struct TokenCacheFile { /// API-key fingerprint (SHA-256 hex) -> account id. #[serde(default)] pub keys: HashMap, - /// Account id (as a string, since TOML table keys must be strings) -> cached - /// JWT for that account's Tooling Access endpoint. + /// Account ID string to cached Tooling Access token. #[serde(default)] pub tokens: HashMap, } @@ -265,9 +297,7 @@ pub struct CachedTokenEntry { pub exp_unix: i64, } -/// The token cache path: `tokens.toml` alongside the resolved config file (so -/// `--config-file` keeps config and tokens together). Falls back to the default -/// config dir when no explicit config path is given. +/// Return the token-cache path beside the config file. pub fn token_cache_path(config_path: Option<&Path>) -> Option { match config_path { Some(p) => p.parent().map(|d| d.join("tokens.toml")), @@ -282,8 +312,7 @@ pub fn fingerprint_key(api_key: &str) -> String { digest.iter().map(|b| format!("{b:02x}")).collect() } -/// Reads `tokens.toml`, treating an absent or malformed file as an empty cache. -/// A malformed cache (e.g. an older on-disk schema) is a miss, never an error. +/// Read the token cache; malformed data is a cache miss. fn read_cache(path: &Path) -> TokenCacheFile { fs::read_to_string(path) .ok() @@ -291,8 +320,7 @@ fn read_cache(path: &Path) -> TokenCacheFile { .unwrap_or_default() } -/// Resolves the account id a known API key maps to, offline. Returns `None` when -/// the key hasn't been seen before (its fingerprint isn't in `[keys]` yet). +/// Resolve an API key to its cached account ID. pub fn account_for_key(path: &Path, api_key: &str) -> Option { read_cache(path) .keys @@ -300,8 +328,7 @@ pub fn account_for_key(path: &Path, api_key: &str) -> Option { .copied() } -/// Loads the cached token for `account_id`. Returns `None` if the file is -/// absent, unparseable, or has no entry for that account. +/// Load a cached token for an account. pub fn load_token_for_account(path: &Path, account_id: i64) -> Option { let entry = read_cache(path).tokens.remove(&account_id.to_string())?; Some(CachedToken { @@ -311,10 +338,7 @@ pub fn load_token_for_account(path: &Path, account_id: i64) -> Option account_id` mapping and stores `token` under that -/// account, preserving every other account's entry (read-modify-write). Written -/// atomically with 0600 perms. Last-write-wins under concurrency — two `qn rpc` -/// processes may both mint, but the atomic rename guarantees no partial file. +/// Store an account mapping and token atomically. pub fn save_token( path: &Path, api_key: &str, @@ -334,10 +358,7 @@ pub fn save_token( write_cache(path, &cache) } -/// Drops the cached JWT for `account_id` (e.g. a stale token against an endpoint -/// that was disabled out-of-band), leaving other accounts' tokens and every -/// `[keys]` mapping intact. No-op if there was no entry. Best-effort: a missing -/// or malformed file is not an error. +/// Remove one cached account token. pub fn delete_account_token(path: &Path, account_id: i64) -> Result<(), CliError> { let mut cache = read_cache(path); if cache.tokens.remove(&account_id.to_string()).is_none() { @@ -355,12 +376,7 @@ fn write_cache(path: &Path, cache: &TokenCacheFile) -> Result<(), CliError> { write_atomic_0600(path, text.as_bytes(), ".qn-tokens-") } -// ── Multichain network URL cache ───────────────────────────────────────────── -// -// The per-network URL map (network key -> http_url) is stable endpoint metadata, -// unlike the ~10-min JWT. We cache it separately in `networks.toml` with a -// 24-hour TTL so it isn't rewritten on every token refresh. Scoped to the -// endpoint id; re-fetched (via get_endpoint_urls) when absent or stale. +// Multichain network URL cache, scoped to the endpoint and refreshed daily. /// Seconds the cached network map is considered fresh (24h). pub const NETWORKS_TTL_SECS: i64 = 24 * 60 * 60; @@ -389,9 +405,218 @@ pub fn networks_cache_path(config_path: Option<&Path>) -> Option { } } -/// 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. +/// Return the wallet store directory. +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 cache, keyed by the payer address. + +/// On-disk gateway-session cache. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct SessionCacheFile { + /// Lowercase payer address to cached 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, +} + +/// Return the gateway-session cache path. +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")), + } +} + +fn session_key(address: &str) -> String { + address.to_lowercase() +} + +/// Load a cached gateway session for a payer. +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(), + }) +} + +/// Store a gateway session atomically. +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 cache, keyed by payer, pay network, and pay asset. + +/// On-disk channel cache. Amounts are decimal strings for TOML compatibility. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ChannelCacheFile { + /// "::" -> the open channel's state. + #[serde(default)] + pub channels: HashMap, +} + +/// Channel cache scope. It excludes the queried network. +#[derive(Debug, Clone)] +pub struct ChannelScope { + /// Payer address, as derived offline from the payment key. + pub address: String, + /// Resolved CAIP-2 pay network (e.g. `eip155:42431`). + pub pay_network: String, + /// Resolved pay asset — a token address, not a symbol. + pub pay_asset: String, +} + +impl ChannelScope { + /// Build the normalized cache key. + fn key(&self) -> String { + format!( + "{}:{}:{}", + self.address.to_lowercase(), + self.pay_network, + self.pay_asset.to_lowercase() + ) + } +} + +/// TOML-safe channel record. +#[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, + }) + } +} + +/// Return the channel-state cache path. +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")), + } +} + +/// Loads the open channel for `scope`, if any. +pub fn load_channel(path: &Path, scope: &ChannelScope) -> Option { + let text = fs::read_to_string(path).ok()?; + let cache: ChannelCacheFile = toml::from_str(&text).ok()?; + cache + .channels + .get(&scope.key()) + .and_then(ChannelEntry::to_state) +} + +/// Store a channel atomically. +pub fn save_channel( + path: &Path, + scope: &ChannelScope, + 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(scope.key(), 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-") +} + +/// Remove a channel entry. +pub fn delete_channel(path: &Path, scope: &ChannelScope) -> 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(&scope.key()).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-") +} + +/// Load a fresh network map for an endpoint. pub fn load_networks( path: &Path, endpoint_id: &str, @@ -409,9 +634,7 @@ pub fn load_networks( Some(entry.networks) } -/// Saves the network map for `endpoint_id` to `path` atomically with 0600 perms, -/// stamping `fetched_at_unix` for the TTL check. Same write discipline as -/// [`save_token`]. +/// Save a network map atomically. pub fn save_networks( path: &Path, endpoint_id: &str, @@ -432,10 +655,149 @@ pub fn save_networks( write_atomic_0600(path, text.as_bytes(), ".qn-networks-") } -/// 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> { +// Payment-gateway discovery cache, split by scheme and list. + +/// Seconds a cached discovery list is considered fresh (24h). +pub const PAY_NETWORKS_TTL_SECS: i64 = 24 * 60 * 60; + +/// One accepted payment option. +#[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, +} + +/// Cached discovery lists for one gateway. +#[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 { + /// Fetch time in Unix seconds. + pub fetched_at_unix: i64, + pub networks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentsCacheSection { + /// Fetch time in Unix seconds. + 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")), + } +} + +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-") +} + +/// Load a fresh cached network list. +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) +} + +/// Load a fresh cached payment list. +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) +} + +/// Save a network list while preserving other scheme sections. +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) +} + +/// Save a payment list while preserving other scheme sections. +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 write a 0600 file and tighten its parent directory. +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 +914,54 @@ 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(); + + 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"] + ); + + 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"); + 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()); + + 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(); @@ -632,7 +1042,6 @@ mod tests { #[test] fn config_dir_ignores_relative_xdg() { - // A non-absolute XDG_CONFIG_HOME is bogus per the spec; fall through to HOME. let d = resolve_config_dir( Some(std::ffi::OsString::from("relative/path")), Some(std::ffi::OsString::from("/home/u")), @@ -728,6 +1137,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/confirm.rs b/src/confirm.rs index 6c37700..f503959 100644 --- a/src/confirm.rs +++ b/src/confirm.rs @@ -84,6 +84,7 @@ pub fn prompt_yes_no(message: &str) -> Result { Confirm::new() .with_prompt(message) .default(false) + .report(false) .interact() .map_err(|e| CliError::Io(std::io::Error::other(e))) } diff --git a/src/context.rs b/src/context.rs index 4d460c7..a5ab953 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,59 @@ impl Ctx { Self::build(global, seed, config_endpoint_url) } + /// Build a keyless SDK context for paid RPC calls. + 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 }) + } + + /// Build a keyless SDK context for local-only commands. + 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, @@ -205,11 +265,7 @@ impl Ctx { let mut full = sdk_config(api_key.clone()); - // The `[rpc] endpoint_url` config default becomes the client-wide custom - // URL (a per-call `--endpoint-url` overrides it in the call itself). We - // validate it here so a malformed config value fails with a clear error - // rather than at call time. `seed` and `endpoint_url` coexist harmlessly: - // the SDK ignores the seed when a custom URL is set. + // Validate the config URL before building the SDK. let rpc_endpoint_url = match rpc_endpoint_url { Some(u) => Some(validate_endpoint_url(&u)?), None => None, @@ -222,20 +278,12 @@ impl Ctx { }); } - // --base-prefix only makes sense when overriding the host. Composing it - // against the default prod host isn't supported, so fail loudly rather - // than silently ignore it. if global.base_prefix.is_some() && global.base_url.is_none() { return Err(CliError::Arg( "--base-prefix requires --base-url".to_string(), )); } - // --base-url applies to every sub-client. Useful for wiremock tests and - // on-prem mirrors. Each sub-client has its own fixed suffix; an optional - // --base-prefix is inserted between the host and that suffix for - // reverse-proxy / gateway environments. Tooling Access / RPC minting - // lives on the admin `v0` base, so no separate RPC base is needed here. if let Some(base) = &global.base_url { let host = validate_base_url(base)?; let prefix = match &global.base_prefix { @@ -330,7 +378,6 @@ fn validate_base_prefix(prefix: &str) -> Result { } let inner = trimmed.trim_matches('/'); if inner.is_empty() { - // Bare "/" (or "///") carries no prefix. return Ok(String::new()); } let normalized = format!("/{inner}"); @@ -501,7 +548,6 @@ mod tests { http.headers.as_ref().and_then(|h| h.get("User-Agent")), Some(&user_agent()) ); - // SDK defaults (timeout, pooling) must stay untouched. assert_eq!(http.timeout_secs, None); assert_eq!(http.pool_max_idle_per_host, None); } diff --git a/src/errors.rs b/src/errors.rs index 66af44d..d4ec6fb 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -40,6 +40,17 @@ pub enum CliError { #[error(transparent)] Sdk(#[from] SdkError), + /// A paid call may have settled; maps to exit 3. + #[error( + "the paid request's outcome is unknown — the payment may have been settled; \ + check your wallet before retrying" + )] + PaymentMaybeCharged(#[source] SdkError), + + /// A paid request was refused without settlement; maps to exit 2. + #[error("{0}")] + PaymentRefused(String), + #[error(transparent)] Io(#[from] std::io::Error), @@ -50,28 +61,40 @@ pub enum CliError { Format(String), } -/// Maps a [`CliError`] to a process exit code per the plan. -/// -/// - 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) -/// - 4: NoApiKey / BadConfig -/// - 5: user cancelled or needs --yes +/// Map a CLI error to its process exit code. 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, } } +// Map known payment problem types to actionable headlines. +fn payment_problem_headline(body: &str) -> Option { + let problem: serde_json::Value = serde_json::from_str(body).ok()?; + let kind = problem.get("type").and_then(|v| v.as_str())?; + let slug = kind.rsplit('/').next().unwrap_or(kind); + match slug { + "insufficient-balance" => Some( + "the payment channel has too little deposit left for this request. \ + Add funds with 'qn rpc mpp top-up --deposit ', or close and \ + reopen it with 'qn rpc mpp close' then 'qn rpc mpp open'." + .to_string(), + ), + _ => None, + } +} + /// Renders the error to a human-friendly message using the real process argv /// for did-you-mean suggestions. Use [`render_with_argv`] from tests where the /// simulated argv differs from the process argv. @@ -85,6 +108,63 @@ 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 }) + if offered.contains("cannot sign") => + { + format!("Error: {offered} Nothing was charged.") + } + CliError::Sdk(SdkError::PaymentUnsupported { offered }) => { + if offered.contains("raise max_amount") { + let body = match offered.split_once(" Full menu: ") { + Some((lever, _)) if !verbose => lever, + _ => offered.as_str(), + }; + format!( + "Error: {}. Nothing was charged.", + body.trim_end().trim_end_matches('.') + ) + } else { + 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 }) => { + 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').", + ); + 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) } @@ -137,9 +217,28 @@ 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`. +/// Return a short gateway rejection reason, if present. +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 { + // Map actionable payment problem types before generic API errors. + if code == 402 { + if let Some(headline) = payment_problem_headline(body) { + let mut out = format!("Error: {headline}"); + if verbose && !body.is_empty() { + out.push('\n'); + out.push_str(body); + } + return out; + } + } + let headline = match code { 400 | 422 => "invalid request.".to_string(), 401 | 403 => "unauthorized. Check your API key with 'qn auth whoami'.".to_string(), @@ -152,8 +251,7 @@ fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> St _ => format!("API returned HTTP {code}."), }; - // For non-validation status codes, body is mostly noise (server stack traces, - // HTML error pages, etc). Only mine it for validation-class errors. + // Parse response bodies only for validation errors. let parsed = if matches!(code, 400 | 422) { parse_api_body(body, argv) } else { @@ -168,8 +266,7 @@ fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> St out.push_str(bullet); } } else if matches!(code, 400 | 422) && !body.is_empty() && !verbose { - // We tried to parse and got nothing useful; surface the raw body so - // the user isn't left with a bare "invalid request." line. + // Preserve an unstructured validation body. out.push('\n'); out.push_str(body.trim()); } @@ -182,8 +279,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 +566,153 @@ 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}"); + } + + // When the SDK identifies max_amount as the lever, that sentence leads and + // the (long, mostly irrelevant) menu dump is held back for --verbose. + #[test] + fn renders_over_ceiling_lever_first_and_hides_the_menu() { + let err = CliError::Sdk(SdkError::PaymentUnsupported { + offered: "every offer for solana:abc/mint1 is above max_amount 100; the cheapest \ + is 1000 base units — raise max_amount to at least that. Full menu: \ + [a, b, c]" + .to_string(), + }); + + let terse = render(&err, false); + assert!(terse.contains("raise max_amount"), "got: {terse}"); + assert!(terse.contains("Nothing was charged"), "got: {terse}"); + assert!( + !terse.contains("Full menu"), + "menu should be hidden: {terse}" + ); + assert!(!terse.contains(".."), "no doubled period: {terse}"); + + let verbose = render(&err, true); + assert!(verbose.contains("Full menu"), "got: {verbose}"); + } + + #[test] + fn renders_session_insufficient_balance_with_a_top_up_hint() { + // The gateway's RFC 9457 problem document for an exhausted channel. The + // raw JSON must not reach the user; the message names the fix. + let err = CliError::Sdk(SdkError::Api { + status: reqwest::StatusCode::PAYMENT_REQUIRED, + body: r#"{"type":"https://paymentauth.org/problems/session/insufficient-balance","title":"Insufficient Balance","status":402,"detail":"Insufficient balance: requested 10, available 0."}"# + .to_string(), + }); + let msg = render(&err, false); + assert!(msg.contains("qn rpc mpp top-up"), "got: {msg}"); + assert!(!msg.contains("API returned HTTP 402"), "got: {msg}"); + assert!(!msg.contains("paymentauth.org"), "got: {msg}"); + } + + #[test] + fn renders_unknown_402_problem_with_the_generic_headline() { + let err = CliError::Sdk(SdkError::Api { + status: reqwest::StatusCode::PAYMENT_REQUIRED, + body: r#"{"type":"https://paymentauth.org/problems/payment-required"}"#.to_string(), + }); + let msg = render(&err, false); + assert!(msg.contains("402"), "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 +743,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..18a9ca4 --- /dev/null +++ b/tests/rpc_payment.rs @@ -0,0 +1,2492 @@ +//! Integration tests for paid RPC calls and payment lifecycle commands. + +mod common; + +use common::{parse, run_qn, run_qn_no_key}; +use serde_json::json; +use std::sync::atomic::{AtomicUsize, Ordering}; +use wiremock::matchers::{body_partial_json, header, method, path}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +const EVM_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const USDC: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + +/// Write the test key to a temporary file. +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) +} + +/// Build one x402 offer. +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" } + }) +} + +/// Return a 402 offer, then a paid response. +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() + } + } +} + +/// Assert that the paid lane does not use control-plane routes. +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(); + + 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(); + + 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(); + + 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(); + + 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; + + 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; + 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; + 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; + 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; + assert_ne!(out.exit_code, 0); +} + +#[tokio::test] +async fn paid_lane_is_never_retried() { + let server = MockServer::start().await; + 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; + 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; + 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; + 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; + 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 ──────────────────────────── + +/// Run a paid invocation that must fail before network I/O. +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() { + 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) ── + +/// Run the real binary with an isolated home and key file. +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; + + 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") + ); + 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", + ]; + + 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()); + + 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) ────────────────────────────────── +// + +/// Mount a fixed SIWX session response. +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; +} + +/// Mount a fixed Solana SIWX session response. +async fn mount_solana_auth(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "token": "jwt-solana", + "expiresAt": "2099-01-01T00:00:00Z", + "accountId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:11111111111111111111111111111111" + }))) + .mount(server) + .await; +} + +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", + ] +} + +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_selects_the_regular_credit_tier() { + let server = MockServer::start().await; + mount_auth(&server).await; + let mut nanopayment = x402_accepts_entry("100"); + nanopayment["maxTimeoutSeconds"] = json!(604_900); + nanopayment["extra"] = json!({ + "name": "GatewayWalletBatched", + "version": "1", + "verifyingContract": "0x0077777d7EBA4688BDeF3E311b846F25870A19B9" + }); + struct BuySeq { + calls: AtomicUsize, + nanopayment: serde_json::Value, + } + impl Respond for BuySeq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 && !req.headers.contains_key("payment-signature") { + ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [x402_accepts_entry("1000000"), x402_accepts_entry("1000"), self.nanopayment.clone()], + })) + } else { + use base64::Engine; + let header = req + .headers + .get("payment-signature") + .expect("paid resend must include a payment signature") + .to_str() + .unwrap(); + let envelope: serde_json::Value = serde_json::from_slice( + &base64::engine::general_purpose::STANDARD + .decode(header) + .unwrap(), + ) + .unwrap(); + assert_eq!(envelope["accepted"]["amount"], "1000000"); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1" + })) + } + } + } + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(BuySeq { + calls: AtomicUsize::new(0), + nanopayment, + }) + .expect(2) // one offer probe and one paid resend + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", "credits": 100_000u64 + }))) + .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(); + + 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); +} + +#[tokio::test] +async fn x402_buy_credits_without_yes_is_needs_confirmation_and_settles_nothing() { + let server = MockServer::start().await; + 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(); + + 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; + 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; + 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_drip_rejects_solana_without_network_request() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth")) + .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", + "x402", + "drip", + "--payment-key-file", + &key_path, + "--payment-network", + "solana-devnet", + ], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!(out.stderr.contains("Base Sepolia"), "stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_balance_rejects_spend_flags() { + 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; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); +} + +// ── qn rpc call --x402-drawdown ────────────────────────────────────────────── +// + +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; + 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); + 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() { + 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(); + + 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); +} + +#[tokio::test] +async fn x402_solana_drawdown_authenticates_with_siwx_and_uses_bearer() { + let server = MockServer::start().await; + mount_solana_auth(&server).await; + Mock::given(method("POST")) + .and(path("/solana-devnet")) + .and(header("authorization", "Bearer jwt-solana")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": 1234 + }))) + .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 generated = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "svm", + "--name", + "sol-payer", + ], + ) + .await; + assert_eq!(generated.exit_code, 0, "stderr={}", generated.stderr); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "getSlot", + "--network", + "solana-devnet", + "--x402-drawdown", + "--payment-wallet", + "sol-payer", + "--payment-network", + "solana-devnet", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + let requests = server.received_requests().await.unwrap(); + let auth = requests + .iter() + .find(|request| request.url.path() == "/auth") + .expect("Solana drawdown must authenticate"); + let body: serde_json::Value = serde_json::from_slice(&auth.body).unwrap(); + assert_eq!(body["type"], "siwx"); + assert!(body["message"] + .as_str() + .unwrap() + .contains("sign in with your Solana account")); + assert!(!body["signature"].as_str().unwrap().is_empty()); +} + +#[tokio::test] +async fn x402_solana_drawdown_reuses_cached_session() { + let server = MockServer::start().await; + mount_solana_auth(&server).await; + Mock::given(method("POST")) + .and(path("/solana-devnet")) + .and(header("authorization", "Bearer jwt-solana")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": 1234 + }))) + .expect(2) + .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 generated = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "svm", + "--name", + "sol-payer", + ], + ) + .await; + assert_eq!(generated.exit_code, 0, "stderr={}", generated.stderr); + + let args = [ + "--config-file", + cfg.as_str(), + "rpc", + "call", + "getSlot", + "--network", + "solana-devnet", + "--x402-drawdown", + "--payment-wallet", + "sol-payer", + "--payment-network", + "solana-devnet", + ]; + let first = run_qn(&server.uri(), &args).await; + assert_eq!(first.exit_code, 0, "first stderr={}", first.stderr); + let second = run_qn(&server.uri(), &args).await; + assert_eq!(second.exit_code, 0, "second stderr={}", second.stderr); +} + +struct SolanaAuthThenRefresh { + calls: AtomicUsize, +} + +impl Respond for SolanaAuthThenRefresh { + fn respond(&self, _req: &Request) -> ResponseTemplate { + let token = if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { + "jwt-solana-old" + } else { + "jwt-solana-new" + }; + ResponseTemplate::new(200).set_body_json(json!({ + "token": token, + "expiresAt": "2099-01-01T00:00:00Z", + "accountId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:11111111111111111111111111111111" + })) + } +} + +struct SolanaExpiredThenOk { + calls: AtomicUsize, +} + +impl Respond for SolanaExpiredThenOk { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + let expected = if n == 0 { + "Bearer jwt-solana-old" + } else { + "Bearer jwt-solana-new" + }; + assert_eq!(req.headers.get("authorization").unwrap(), expected); + if n == 0 { + ResponseTemplate::new(401).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": 1234 + })) + } + } +} + +#[tokio::test] +async fn x402_solana_drawdown_reauthenticates_on_expired_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(SolanaAuthThenRefresh { + calls: AtomicUsize::new(0), + }) + .expect(2) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/solana-devnet")) + .respond_with(SolanaExpiredThenOk { + calls: AtomicUsize::new(0), + }) + .expect(2) + .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 generated = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "svm", + "--name", + "sol-payer", + ], + ) + .await; + assert_eq!(generated.exit_code, 0, "stderr={}", generated.stderr); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "getSlot", + "--network", + "solana-devnet", + "--x402-drawdown", + "--payment-wallet", + "sol-payer", + "--payment-network", + "solana-devnet", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +/// Return an expired-token response once, then success. +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; + 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() { + assert_drawdown_reauths_on(401).await; +} + +#[tokio::test] +async fn x402_drawdown_reauths_once_on_token_expired_403() { + assert_drawdown_reauths_on(403).await; +} + +#[tokio::test] +async fn x402_drawdown_rejects_key_and_params_both_from_stdin() { + 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; + 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; + 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) ───────────────────────────────────── +// + +use base64::Engine as _; + +const SESSION_ESCROW: &str = "0x33b901018174ddabe4841042ab76ba85d4e24f25"; + +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() + ) +} + +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()) +} + +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; +} + +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, + "--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" + ); + + 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(); + + 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); + + let before = server.received_requests().await.unwrap().len(); + let out = run_qn(&server.uri(), &mpp_args(&cfg, &key_path, "status")).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + let after = server.received_requests().await.unwrap().len(); + assert_eq!(after, before, "status without --verify must not call out"); + + let mut verify_args = mpp_args(&cfg, &key_path, "status"); + verify_args.push("--verify"); + let out = run_qn(&server.uri(), &verify_args).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + 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 --verify must POST a voucher credential"); + assert_eq!(voucher["cumulativeAmount"], "1000"); + 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; + 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_top_up_without_yes_is_needs_confirmation_and_settles_nothing() { + 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 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); + + let before = server.received_requests().await.unwrap().len(); + let mut args = mpp_args(&cfg, &key_path, "top-up"); + args.extend_from_slice(&["--deposit", "500000"]); + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); + let after = server.received_requests().await.unwrap().len(); + assert_eq!(after, before, "a refused top-up must not settle anything"); +} + +#[tokio::test] +async fn mpp_top_up_with_yes_adds_deposit_to_the_open_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 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); + + let mut args = mpp_args(&cfg, &key_path, "top-up"); + args.extend_from_slice(&["--deposit", "500000", "--yes"]); + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn mpp_close_without_yes_is_needs_confirmation_and_settles_nothing() { + 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 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); + + let before = server.received_requests().await.unwrap().len(); + let out = run_qn(&server.uri(), &mpp_args(&cfg, &key_path, "close")).await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); + let after = server.received_requests().await.unwrap().len(); + assert_eq!(after, before, "a refused close must not settle anything"); + assert!( + dir.path().join("channels.toml").exists(), + "a refused close must leave the channel record intact" + ); +} + +#[tokio::test] +async fn mpp_close_with_yes_settles_the_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 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); + + let mut args = mpp_args(&cfg, &key_path, "close"); + args.push("--yes"); + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn mpp_session_call_without_open_channel_points_at_open() { + let server = MockServer::start().await; + 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 + ); +} + +#[tokio::test] +async fn mpp_session_call_uses_the_channel_for_a_different_query_network() { + let server = MockServer::start().await; + mount_session(&server, "tempo-testnet").await; + mount_session(&server, "ethereum-mainnet").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 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); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "ethereum-mainnet", + "--mpp-session", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:42431", + "--payment-asset", + "0x20c0000000000000000000000000000000000000", + "--max-amount", + "100000000", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + let requests = server.received_requests().await.unwrap(); + let voucher = requests + .iter() + .filter(|r| r.url.path() == "/session/ethereum-mainnet") + .filter(|r| r.headers.contains_key("authorization")) + .map(credential_payload) + .find(|p| p["action"] == "voucher") + .expect("the mainnet call must POST a voucher credential"); + assert_eq!(voucher["cumulativeAmount"], "1000"); +} + +#[tokio::test] +async fn mpp_channels_do_not_collide_across_pay_assets() { + 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 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); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "mpp", + "status", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:42431", + "--payment-asset", + "0x20c0000000000000000000000000000000000001", + "--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 + ); +} + +#[tokio::test] +async fn mpp_lifecycle_verbs_reject_a_query_network() { + let server = MockServer::start().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(); + + for verb in ["open", "top-up", "close", "status"] { + let mut args = mpp_args(&cfg, &key_path, verb); + args.extend_from_slice(&["--network", "tempo-testnet"]); + if verb == "open" || verb == "top-up" { + args.extend_from_slice(&["--deposit", "1000000"]); + } + let out = run_qn(&server.uri(), &args).await; + assert_ne!(out.exit_code, 0, "{verb} should reject --network"); + assert!( + out.stderr.contains("unexpected argument '--network'"), + "{verb} should reject --network as unknown, got: {}", + out.stderr + ); + } +} + +// ── x402 / Solana ──────────────────────────────────────────────────────────── + +const SOL_MINT: &str = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; +const SOL_FEE_PAYER: &str = "CPZSjRmyfTS95UjQD8ZdeTEWbQvW9QvEXnn6aGP7yyMN"; +const SOL_PAY_TO: &str = "2LWbc9Mi6dRUrdEHBttoNS4udDtH1A4xwBdm1EKqcT57"; +const SOL_DEVNET: &str = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; + +struct SolanaCreditSeq { + calls: AtomicUsize, +} + +impl Respond for SolanaCreditSeq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 && !req.headers.contains_key("payment-signature") { + ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [x402_solana_entry("1000000"), x402_solana_entry("1000")] + })) + } else { + assert!(req.headers.contains_key("authorization")); + assert!(req.headers.contains_key("payment-signature")); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "credit-purchased" + })) + } + } +} + +/// Build a Solana x402 offer. +fn x402_solana_entry(amount: &str) -> serde_json::Value { + json!({ + "scheme": "exact", + "network": SOL_DEVNET, + "amount": amount, + "payTo": SOL_PAY_TO, + "maxTimeoutSeconds": 60, + "asset": SOL_MINT, + "extra": { "feePayer": SOL_FEE_PAYER } + }) +} + +#[tokio::test] +async fn x402_solana_buy_credits_authenticates_and_settles_credit_offer() { + let server = MockServer::start().await; + mount_solana_auth(&server).await; + Mock::given(method("POST")) + .and(path("/solana-devnet")) + .respond_with(SolanaCreditSeq { + calls: AtomicUsize::new(0), + }) + .expect(2) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/svm-rpc")) + .and(body_partial_json(json!({ "method": "getAccountInfo" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { "value": { + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": { "parsed": { "info": { "decimals": 6 } } } + }} + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/svm-rpc")) + .and(body_partial_json(json!({ "method": "getLatestBlockhash" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { "value": { "blockhash": "11111111111111111111111111111112" } } + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:11111111111111111111111111111111", + "credits": 100000 + }))) + .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 generated = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "svm", + "--name", + "sol-payer", + ], + ) + .await; + assert_eq!(generated.exit_code, 0, "stderr={}", generated.stderr); + let svm_rpc_url = format!("{}/svm-rpc", server.uri()); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "x402", + "buy-credits", + "--network", + "solana-devnet", + "--payment-wallet", + "sol-payer", + "--payment-network", + "solana-devnet", + "--payment-asset", + SOL_MINT, + "--max-amount", + "1000000", + "--svm-rpc-url", + &svm_rpc_url, + "--yes", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +struct SolanaSeq { + calls: AtomicUsize, + envelope: std::sync::Mutex>, +} + +impl SolanaSeq { + fn new() -> Self { + Self { + calls: AtomicUsize::new(0), + envelope: std::sync::Mutex::new(None), + } + } + + fn recorded_envelope(&self) -> Option { + self.envelope.lock().unwrap().clone() + } +} + +struct SharedSolanaSeq(std::sync::Arc); + +impl Respond for SharedSolanaSeq { + fn respond(&self, req: &Request) -> ResponseTemplate { + self.0.respond(req) + } +} + +impl SolanaSeq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let body: serde_json::Value = + serde_json::from_slice(&req.body).unwrap_or(serde_json::Value::Null); + match body.get("method").and_then(|m| m.as_str()) { + Some("getLatestBlockhash") => { + return ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": { "value": { "blockhash": "11111111111111111111111111111112" } } + })); + } + Some("getAccountInfo") => { + return ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": { "value": { + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": { "parsed": { "info": { "decimals": 6 } } } + } } + })); + } + _ => {} + } + + let n = self.calls.fetch_add(1, Ordering::SeqCst); + match req.headers.get("payment-signature") { + None if n == 0 => ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [x402_solana_entry("1000000"), x402_solana_entry("1000")] + })), + Some(sig) => { + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD + .decode(sig.to_str().unwrap()) + .expect("payment-signature must be base64"); + *self.envelope.lock().unwrap() = + Some(serde_json::from_slice(&decoded).expect("envelope must be JSON")); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": 1234 + })) + } + None => ResponseTemplate::new(500), + } + } +} + +#[tokio::test] +async fn x402_solana_pays_the_cheapest_offer_with_a_transaction_payload() { + let server = MockServer::start().await; + let seq = std::sync::Arc::new(SolanaSeq::new()); + Mock::given(method("POST")) + .respond_with(SharedSolanaSeq(seq.clone())) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + + let gen = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "wallet", + "generate", + "--vm", + "svm", + "--name", + "sol-payer", + ], + ) + .await; + assert_eq!(gen.exit_code, 0, "stderr={}", gen.stderr); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "getSlot", + "--network", + "solana-devnet", + "--x402", + "--payment-wallet", + "sol-payer", + "--payment-network", + "solana-devnet", + "--payment-asset", + SOL_MINT, + "--max-amount", + "1000000", + "--svm-rpc-url", + &server.uri(), + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + let envelope = seq + .recorded_envelope() + .expect("the paid resend must carry a payment-signature"); + + let tx = envelope + .pointer("/payload/transaction") + .and_then(|t| t.as_str()) + .expect("payload.transaction must be a base64 string"); + assert!(!tx.is_empty(), "transaction must not be empty"); + + assert_eq!( + envelope + .pointer("/accepted/amount") + .and_then(|a| a.as_str()), + Some("1000"), + "must pay the cheapest offer, not the first listed" + ); + assert_eq!( + envelope.get("x402Version").and_then(|v| v.as_u64()), + Some(2) + ); +} diff --git a/tests/rpc_supported_networks.rs b/tests/rpc_supported_networks.rs new file mode 100644 index 0000000..986a26c --- /dev/null +++ b/tests/rpc_supported_networks.rs @@ -0,0 +1,263 @@ +//! Integration tests for keyless payment-gateway discovery commands. + +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}; + +/// Mount the x402 network 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; +} + +/// Mount the x402 payment catalog. +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": [ + { + "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" + } + }, + { + "scheme": "exact", + "network": "eip155:999999", + "asset": "0x00000000000000000000000000000000000000aa", + "extra": {"name": "Fake Dollar", "version": "1"} + } + ] + }))) + .mount(server) + .await; +} + +/// Build an MPP discovery challenge header. +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\"" + ) +} + +/// Mount the MPP discovery endpoints. +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; +} + +/// Run the binary with a selected output format. +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 + ); +} + +#[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"]) + ); +} + +#[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" + } + ]) + ); +} + +#[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..d58d235 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -1,15 +1,9 @@ -//! Snapshot tests for human-readable table output, rendered by the real -//! binary against a wiremock server. -//! -//! Unlike `output_snapshots.rs` (which pins layout via re-declared renderers), -//! these run `qn` as a subprocess so the snapshot covers the actual -//! decode-and-render path for each command. +//! Snapshot tests for table output through the real binary. use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -/// Mount `body` at `GET url_path`, run `qn --format table ` against the -/// mock server, and return stdout. Panics (with stderr) on non-zero exit. +/// Mount a GET response and return table stdout. async fn table_stdout(url_path: &str, body: serde_json::Value, args: &[&str]) -> String { let server = MockServer::start().await; Mock::given(method("GET")) @@ -364,8 +358,7 @@ async fn endpoint_show_minimal_table_omits_security_and_rate_limit_rows() { insta::assert_snapshot!(out); } -/// Like [`table_stdout`] but mounts the body at `POST url_path`, for commands -/// that issue a POST (e.g. `sql query`). +/// Mount a POST response and return table stdout. async fn table_stdout_post(url_path: &str, body: serde_json::Value, args: &[&str]) -> String { let server = MockServer::start().await; Mock::given(method("POST")) @@ -463,3 +456,99 @@ async fn sql_schema_table_renders_nested_table_blocks() { .await; insta::assert_snapshot!(out); } + +/// Run an RPC discovery command and return table stdout. +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; + 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..401c62a --- /dev/null +++ b/tests/wallet.rs @@ -0,0 +1,289 @@ +//! Integration tests for the local payment-wallet store. + +mod common; + +use common::run_qn; + +// Wallet commands never make a request; the harness still requires a base URL. +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"); + + let raw = std::fs::read_to_string(&key).unwrap(); + assert!(raw.trim().starts_with("0x"), "key not 0x-prefixed: {raw}"); + + 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(); + assert!(!raw.trim().starts_with("0x")); + assert!( + raw.trim().len() > 64, + "svm key too short: {}", + raw.trim().len() + ); + + 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); + + 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 + ); + + 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 + ); +} + +// Use the real binary to verify stdout/stderr separation. +#[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(); + + assert!(stdout.trim().starts_with("0x"), "stdout={stdout}"); + + 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}" + ); + let raw = std::fs::read_to_string(&key_path).unwrap(); + assert!(!stdout.contains(raw.trim()) && !stderr.contains(raw.trim())); +}