From 6ce43ee0a664b2dd15bd7fe4573d367220a6f2ca Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 13 Jul 2026 03:10:34 -0400 Subject: [PATCH 01/32] build: use local quicknode-sdk with payment features for development Points the SDK dependency at the local checkout's crypto-micropayment branch and enables the payments, payments-svm, and payments-tempo features. Dev-only: the release swaps back to a published crates.io version once the SDK ships this feature. --- Cargo.lock | 2528 ++++++++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 9 +- 2 files changed, 2453 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b8a411..98fbe70 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" @@ -943,6 +2294,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 +2406,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 +2433,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 +2501,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 +2550,9 @@ checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ "proc-macro2", "quote", - "rustc_version", + "rustc_version 0.4.1", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -1141,7 +2571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1177,6 +2607,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 +2697,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 +2752,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 +2786,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 +2818,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1292,6 +2831,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,20 +2857,66 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "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 = "ordered-multimap" -version = "0.7.3" +name = "parity-scale-codec-derive" +version = "3.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" dependencies = [ - "dlv-list", - "hashbrown 0.14.5", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1343,6 +2942,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 +2990,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1395,7 +3000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1404,6 +3009,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 +3090,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 +3180,31 @@ 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 = "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" @@ -1496,7 +3224,7 @@ dependencies = [ "serde", "serde_json", "serde_yml", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "time", @@ -1510,14 +3238,24 @@ dependencies = [ [[package]] name = "quicknode-sdk" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d7fd1e431f62f6f82adca349756b31a07e082446c0664a81530e4a791c62ca" dependencies = [ + "alloy-consensus", + "alloy-primitives", + "alloy-rlp", + "base64", + "bs58", "config", + "ed25519-dalek", + "hex", + "k256 0.14.0", + "rand 0.8.7", "reqwest 0.13.4", "secrecy", "serde", "serde_json", + "sha2 0.10.9", + "sha3 0.10.9", + "tempo-primitives", "thiserror 1.0.69", "tokio", "url", @@ -1553,7 +3291,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1600,14 +3338,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 +3383,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 +3402,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 +3438,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" @@ -1738,6 +3558,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 +3592,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 +3616,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 +3667,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]] @@ -1873,47 +3773,151 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +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 = "rustversion" -version = "1.0.22" +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 +3951,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 +4014,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2001,7 +4023,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 +4061,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 +4108,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 +4138,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 +4195,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 +4259,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 +4303,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 +4331,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 +4360,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 +4382,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 +4455,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2236,7 +4466,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 +4566,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2362,7 +4601,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -2402,7 +4641,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 +4649,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 +4682,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 +4776,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 +4848,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 +4954,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2709,7 +4984,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 +4997,8 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", - "semver", + "indexmap 2.14.0", + "semver 1.0.28", ] [[package]] @@ -2786,12 +5061,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 +5286,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 +5304,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2989,7 +5317,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -3008,9 +5336,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 +5346,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 +5402,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3075,7 +5423,7 @@ checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3095,7 +5443,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3104,6 +5452,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 +5497,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5738ac6..25a9589 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,14 @@ name = "qn" path = "src/lib.rs" [dependencies] -quicknode-sdk = "0.7" +# DEV ONLY: path dep on the SDK's unreleased crypto-micropayment branch. +# Swap back to a published crates.io version (with the same features) before +# any release tag — the release is gated on the SDK publishing this feature. +quicknode-sdk = { path = "../sdk/crates/core", features = [ + "payments", # x402/EVM + "payments-svm", # + x402/Solana + "payments-tempo", # + MPP/Tempo +] } clap = { version = "4", features = ["derive", "env", "wrap_help"] } clap_complete = "4" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } From 688022b594b07ea3c72db3192b3f4ec7058c87af Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 13 Jul 2026 03:12:11 -0400 Subject: [PATCH 02/32] feat(config): add [rpc.payment] section for the paid RPC lane Parameter defaults (key_file, max_amount, pay_network, asset, svm_rpc_url) for qn rpc call --x402/--mpp. The section only supplies values; activation stays with the per-invocation scheme flag. The raw private key never lives in config.toml: key_file points at a file, and an inline key = ... is captured by a trap field so payment resolution can reject it with an actionable error instead of serde silently ignoring it. max_amount accepts a TOML string or integer. 7 new tests. --- src/config.rs | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/config.rs b/src/config.rs index 2e2dc45..d9de3b2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -64,6 +64,62 @@ pub struct ApiSection { pub struct RpcSection { #[serde(default)] pub endpoint_url: Option, + #[serde(default)] + pub payment: PaymentSection, +} + +/// `[rpc.payment]` section: parameter defaults for the crypto-micropayment +/// lane of `qn rpc call` (`--x402`/`--mpp`). This section supplies values; +/// it never activates payment — only the per-invocation scheme flag does. +/// Per-call flags override every field here. +/// +/// There is deliberately no `scheme` key (the flag is the scheme) and no way +/// to store the raw private key: `key_file` points at a file holding the key, +/// keeping the key itself out of the most commonly shared/pasted file we own. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct PaymentSection { + /// Path to a file containing the raw payment private key (EVM/Tempo hex, + /// Solana base58). Never the key itself. + #[serde(default)] + pub key_file: Option, + /// Trap field: an inline raw key is rejected at payment-resolution time + /// with an error pointing at `key_file`. Without this field serde would + /// silently ignore `key = "..."` and the user would think it took effect. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + /// Default spend ceiling per paid call, in integer base units of `asset`. + /// Accepts a TOML string or integer. + #[serde(default, deserialize_with = "de_opt_string_or_int")] + pub max_amount: Option, + /// CAIP-2 id of the chain payments settle on, e.g. `eip155:84532`. + /// Independent of `--network` (the chain the RPC call queries). + #[serde(default)] + pub pay_network: Option, + /// Token to pay with: EVM contract address or Solana mint. + #[serde(default)] + pub asset: Option, + /// Explicit Solana RPC URL for x402/Solana payment builds. + #[serde(default)] + pub svm_rpc_url: Option, +} + +/// Deserializes an optional TOML string-or-integer into `Option`, so +/// `max_amount = 10000` and `max_amount = "10000"` both work. (Base units can +/// exceed i64 for high-decimal assets, hence the canonical string form.) +fn de_opt_string_or_int<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let v = Option::::deserialize(deserializer)?; + match v { + None => Ok(None), + Some(toml::Value::String(s)) => Ok(Some(s)), + Some(toml::Value::Integer(i)) => Ok(Some(i.to_string())), + Some(other) => Err(serde::de::Error::custom(format!( + "expected a string or integer, got {}", + other.type_str() + ))), + } } #[derive(Debug, Default, Serialize, Deserialize)] @@ -728,6 +784,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\ + pay_network = \"eip155:84532\"\n\ + 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.pay_network.as_deref(), Some("eip155:84532")); + assert_eq!(p.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(); From bc01c72f1745f2f92c51922cb5d28e5f36316c34 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 13 Jul 2026 03:14:23 -0400 Subject: [PATCH 03/32] feat(errors): map payment errors to exit codes with wallet-check guidance PaymentUnsupported and PaymentRejected map to exit 2 (the gateway refused); PaymentIndeterminate and the new PaymentMaybeCharged wrapper map to exit 3 (request sent, outcome unknown), so scripts can distinguish safe-to-retry from check-your-wallet-first. Render arms state whether anything was charged and warn against blind re-runs; gateway bodies appear only under --verbose. 6 new tests. --- src/errors.rs | 130 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index 66af44d..b8bd29a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -40,6 +40,16 @@ pub enum CliError { #[error(transparent)] Sdk(#[from] SdkError), + /// A paid RPC call failed in a way where the payment may already have + /// settled (e.g. the gateway's post-payment response could not be + /// interpreted). Kept separate from `Sdk` so it maps to exit 3 and renders + /// the check-your-wallet guidance; the paid lane must never auto-retry it. + #[error( + "the paid request's outcome is unknown — the payment may have been settled; \ + check your wallet before retrying" + )] + PaymentMaybeCharged(#[source] SdkError), + #[error(transparent)] Io(#[from] std::io::Error), @@ -55,17 +65,23 @@ pub enum CliError { /// - 0: success (never produced here) /// - 1: generic CLI failure (arg parse, IO, decode). clap usage errors are /// mapped to 1 in main.rs too, so 2 always and only means an API error. -/// - 2: SdkError::Api (server returned a non-2xx) -/// - 3: SdkError::Http (network failure) +/// - 2: SdkError::Api (server returned a non-2xx); also a payment the gateway +/// refused or never matched (PaymentRejected / PaymentUnsupported) +/// - 3: SdkError::Http (network failure); also an unknown payment outcome +/// (PaymentIndeterminate / PaymentMaybeCharged — request sent, response +/// lost, the caller may have been charged) /// - 4: NoApiKey / BadConfig /// - 5: user cancelled or needs --yes pub fn exit_code_for(err: &CliError) -> i32 { match err { CliError::NoApiKey | CliError::BadConfig { .. } | CliError::ConfigWrite { .. } => 4, CliError::Cancelled | CliError::NeedsConfirmation => 5, + CliError::PaymentMaybeCharged(_) => 3, CliError::Sdk(sdk) => match sdk { SdkError::Api { .. } => 2, SdkError::Http(_) => 3, + SdkError::PaymentUnsupported { .. } | SdkError::PaymentRejected { .. } => 2, + SdkError::PaymentIndeterminate => 3, _ => 1, }, _ => 1, @@ -85,6 +101,40 @@ pub fn render(err: &CliError, verbose: bool) -> String { /// Like [`render`] but uses the supplied argv values for did-you-mean lookup. pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> String { match err { + CliError::Sdk(SdkError::PaymentUnsupported { offered }) => { + format!( + "Error: no offered payment option matched your configuration \ + (check --pay-network, --asset, and --max-amount). Nothing was charged.\n\ + Gateway offered: {offered}" + ) + } + CliError::Sdk(SdkError::PaymentRejected { status, body }) => { + let msg = format!( + "Error: the gateway rejected the submitted payment (HTTP {status}). \ + A signed payment was sent — check your wallet before retrying." + ); + if verbose && !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 returned an unexpected response — the \ + request 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) } @@ -464,6 +514,82 @@ 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 (unsupported) or the refusal + // is terminal (rejected): the API-error bucket. + let unsupported = CliError::Sdk(SdkError::PaymentUnsupported { + offered: "eip155:84532/0xabc amount 999999".to_string(), + }); + let rejected = CliError::Sdk(SdkError::PaymentRejected { + status: 402, + body: "invalid signature".to_string(), + }); + assert_eq!(exit_code_for(&unsupported), 2); + assert_eq!(exit_code_for(&rejected), 2); + } + + #[test] + fn exit_code_unknown_payment_outcome_is_3() { + // Request sent, outcome unknown: the transport-ambiguity bucket, so + // scripts can distinguish "safe to retry" (2) from "check wallet" (3). + let indeterminate = CliError::Sdk(SdkError::PaymentIndeterminate); + let maybe_charged = CliError::PaymentMaybeCharged(decode_err()); + assert_eq!(exit_code_for(&indeterminate), 3); + assert_eq!(exit_code_for(&maybe_charged), 3); + } + + #[test] + fn renders_payment_unsupported_as_not_charged() { + let err = CliError::Sdk(SdkError::PaymentUnsupported { + offered: "eip155:84532/0xabc amount 999999".to_string(), + }); + let msg = render(&err, false); + assert!(msg.contains("Nothing was charged"), "got: {msg}"); + assert!(msg.contains("999999"), "got: {msg}"); + assert!(msg.contains("--max-amount"), "got: {msg}"); + } + + #[test] + fn renders_payment_rejected_with_wallet_warning() { + let err = CliError::Sdk(SdkError::PaymentRejected { + status: 402, + body: "invalid signature".to_string(), + }); + let msg = render(&err, false); + assert!(msg.contains("402"), "got: {msg}"); + assert!(msg.contains("check your wallet"), "got: {msg}"); + assert!(!msg.contains("invalid signature"), "got: {msg}"); + let verbose = render(&err, true); + assert!(verbose.contains("invalid signature"), "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); From d1627efb4683c77cc38353a9296589ed7554473c Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 13 Jul 2026 03:15:47 -0400 Subject: [PATCH 04/32] feat(context): add keyless Ctx constructor for the paid RPC lane Ctx::from_global_keyless_payment builds a keyless SDK carrying only the payment config, so paid calls work with no API key configured. It skips API-key resolution, the token seed, [rpc] endpoint_url, and --base-url sub-client overrides (the paid lane's test hook is PaymentConfig.base_url_override). The User-Agent install is factored into a helper shared with the keyed path. --- src/context.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/context.rs b/src/context.rs index 4d460c7..cdf8ea2 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,46 @@ impl Ctx { Self::build(global, seed, config_endpoint_url) } + /// Keyless construction for the crypto-micropayment lane of `qn rpc call` + /// (`--x402`/`--mpp`): no API key is resolved or required, so it works on + /// a machine that has never run `qn auth login`. Only the RPC payment lane + /// is usable — every keyed sub-client would 401. + /// + /// Deliberately NOT applied here: + /// - the token cache seed and `[rpc] endpoint_url` (either would conflict + /// with the payment lane — the SDK rejects a custom URL + payment); + /// - `--base-url` sub-client overrides (the paid lane's test hook rides in + /// `PaymentConfig.base_url_override`, set by the caller; no control-plane + /// sub-client is used). + pub fn from_global_keyless_payment( + global: GlobalArgs, + payment: quicknode_sdk::PaymentConfig, + ) -> Result { + let stdout_is_tty = std::io::stdout().is_terminal(); + let (format, wide) = global.resolve_output(stdout_is_tty); + + let mut full = SdkFullConfig::keyless(); + apply_user_agent(&mut full); + full.rpc = Some(RpcConfig { + payment: Some(payment), + ..Default::default() + }); + + let sdk = QuicknodeSdk::new(&full)?; + let out = OutputCtx::detect_with( + format, + global.no_color, + global.quiet, + global.verbose, + wide, + stdout_is_tty, + std::env::var_os("NO_COLOR"), + std::env::var("TERM").ok(), + ); + + Ok(Self { sdk, out, global }) + } + fn build( global: GlobalArgs, rpc_seed: Option, From c2aeb02b39bba0e762136006b8d2c9a1f1fe38a0 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 13 Jul 2026 03:20:38 -0400 Subject: [PATCH 05/32] feat(rpc): crypto-micropayment lane for qn rpc call (--x402/--mpp) Adds per-request stablecoin payment to qn rpc call via the SDK's 402 -> sign -> resend handshake. Activation is explicit and per invocation: --x402 or --mpp both turns the lane on and selects the scheme; [rpc.payment] config only supplies parameter defaults. The lane lives in src/commands/rpc/payment.rs (rpc.rs became a directory module) and branches off before the default lane's machinery, so the token cache, Tooling Access enable/recovery, networks map, and retrying() are structurally unreachable: paid calls are never auto-retried, and a lost or uninterpretable post-payment response maps to PaymentMaybeCharged (exit 3, check-your-wallet guidance). Key resolution: --payment-key-file > QN_PAYMENT_KEY > key_file in config; the raw key is never a flag value, never inline in config, and never logged. The spend ceiling (--max-amount, base units) has no built-in default and is integer-validated before any request. --receipt opts stdout into {result, payment_receipt} (settlement tx hash on MPP, null on x402); the default paid output shape is identical to an unpaid call. 20 new unit tests. --- src/commands/{rpc.rs => rpc/mod.rs} | 85 ++++- src/commands/rpc/payment.rs | 559 ++++++++++++++++++++++++++++ 2 files changed, 637 insertions(+), 7 deletions(-) rename src/commands/{rpc.rs => rpc/mod.rs} (85%) create mode 100644 src/commands/rpc/payment.rs diff --git a/src/commands/rpc.rs b/src/commands/rpc/mod.rs similarity index 85% rename from src/commands/rpc.rs rename to src/commands/rpc/mod.rs index ec02a53..126672a 100644 --- a/src/commands/rpc.rs +++ b/src/commands/rpc/mod.rs @@ -18,6 +18,13 @@ //! config) is a separate lane: the SDK sends the call straight to that URL with //! no JWT minted or attached. That lane never touches the token cache or the //! Tooling Access enable/probe recovery. +//! +//! The crypto-micropayment lane (`--x402`/`--mpp`) is a third lane, in +//! `payment.rs`: keyless, paid per request, and structurally separate — it +//! branches off before any of this module's token-cache or Tooling Access +//! machinery runs. + +mod payment; use std::io::Read; use std::path::{Path, PathBuf}; @@ -49,8 +56,14 @@ pub enum RpcCmd { qn rpc call eth_blockNumber --endpoint-url https://my-endpoint.example/rpc\n \ qn rpc call eth_call --params-file params.json\n \ echo '[...]' | qn rpc call eth_call -\n \ - cat params.json | qn rpc call eth_call -f -")] - Call(CallArgs), + cat params.json | qn rpc call eth_call -f -\n\n\ + Paid (crypto micropayment, no API key; params from [rpc.payment] in config):\n \ + qn rpc call eth_blockNumber --network base-sepolia --x402\n \ + qn rpc call eth_blockNumber --network base-sepolia --x402 \\\n \ + --payment-key-file ~/.keys/payer --pay-network eip155:84532 \\\n \ + --asset 0x036C... --max-amount 10000\n \ + qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt")] + Call(Box), /// List the endpoint's available network keys (no RPC call). #[command(visible_alias = "ls")] @@ -58,7 +71,10 @@ pub enum RpcCmd { } #[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"])), +)] pub struct CallArgs { /// The JSON-RPC method, e.g. `eth_blockNumber`. #[arg(value_name = "METHOD")] @@ -90,11 +106,59 @@ 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, + + /// 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 > QN_PAYMENT_KEY env var (raw key) > `key_file` + /// under [rpc.payment] in config. + #[arg(long, value_name = "PATH", requires = "payment", help_heading = "Payment")] + pub payment_key_file: Option, + + /// Spend ceiling per call, in integer base units of the asset (e.g. + /// 10000 = 0.01 USDC). No built-in default: flag > `max_amount` under + /// [rpc.payment]. Offered payments above the ceiling are never signed. + #[arg(long, value_name = "BASE_UNITS", requires = "payment", help_heading = "Payment")] + pub max_amount: Option, + + /// CAIP-2 id of the chain you PAY on (e.g. `eip155:84532`), independent + /// of --network (the chain you query). Falls back to `pay_network` under + /// [rpc.payment]. + #[arg(long, value_name = "CAIP2", requires = "payment", help_heading = "Payment")] + pub pay_network: Option, + + /// Token to pay with: EVM contract address or Solana mint. Falls back to + /// `asset` under [rpc.payment]. + #[arg(long, value_name = "ADDRESS", requires = "payment", help_heading = "Payment")] + pub 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, } } @@ -114,6 +178,13 @@ async fn run_list_networks(global: GlobalArgs) -> Result<(), CliError> { } async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + // The crypto-micropayment lane branches off before any token-cache or + // Tooling Access work: it is keyless, never retried, and never touches + // this function's caches or recovery paths. + if args.x402 || args.mpp { + return payment::run_paid_call(args, global).await; + } + let params = parse_params(args.params.as_deref(), args.params_file.as_deref())?; // A custom URL (per-call flag, else the `[rpc] endpoint_url` config default) @@ -516,7 +587,7 @@ async fn maybe_enable(ctx: &Ctx) -> Result<(), CliError> { /// as JSON. `None`/`None` → no params (sends `[]`). Either source accepts `-` to /// read from stdin. The clap `ArgGroup` guarantees at most one is set. An empty /// value (after trimming) is treated as no params. -fn parse_params(arg: Option<&str>, file: Option<&Path>) -> Result, CliError> { +pub(super) fn parse_params(arg: Option<&str>, file: Option<&Path>) -> Result, CliError> { let raw = match (arg, file) { (None, None) => return Ok(None), (Some("-"), _) => read_stdin("params")?, @@ -539,7 +610,7 @@ fn parse_params(arg: Option<&str>, file: Option<&Path>) -> Result, } /// Read all of stdin as a UTF-8 string, labeling errors with `what`. -fn read_stdin(what: &str) -> Result { +pub(super) fn read_stdin(what: &str) -> Result { let mut buf = String::new(); std::io::stdin() .read_to_string(&mut buf) @@ -552,7 +623,7 @@ fn read_stdin(what: &str) -> Result { /// raw `--format` flag, not the TTY-aware resolved default). `json`/`yaml`/`toon` /// render as requested. `table`/`md` have no columns here, so they fall back to /// JSON — and only that explicit case prints a one-line note on stderr. -fn emit_result(ctx: &Ctx, result: &Value) -> Result<(), CliError> { +pub(super) fn emit_result(ctx: &Ctx, result: &Value) -> Result<(), CliError> { match ctx.global.format { None | Some(Format::Json) => { println!( diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs new file mode 100644 index 0000000..dd17877 --- /dev/null +++ b/src/commands/rpc/payment.rs @@ -0,0 +1,559 @@ +//! The crypto-micropayment lane of `qn rpc call` (`--x402`/`--mpp`): pay per +//! RPC request with a stablecoin instead of an account API key, via the SDK's +//! 402 → sign → resend handshake against Quicknode's payment gateways. +//! +//! This lane is deliberately structural, not conditional: `run_call` branches +//! here before any of the default lane's machinery, so the token cache, the +//! Tooling Access enable/probe recovery, the networks map, and `retrying()` +//! are unreachable. Paid calls are NEVER auto-retried — a lost response after +//! the payment was submitted (`PaymentIndeterminate`, or an uninterpretable +//! post-payment body) means the caller may already have been charged. +//! +//! Everything the lane needs is resolved before any network I/O: the private +//! key (flag file/stdin > `QN_PAYMENT_KEY` env > `key_file` in config — never +//! a raw key on the command line or inline in config), the spend ceiling, the +//! pay network, and the asset. The key lives only inside the SDK's +//! `PaymentConfig` (which redacts it in Debug) and is never logged or echoed. + +use std::path::Path; + +use quicknode_sdk::errors::SdkError; +use quicknode_sdk::PaymentConfig; + +use crate::config::{self, PaymentSection}; +use crate::context::{Ctx, GlobalArgs}; +use crate::errors::CliError; + +use super::CallArgs; + +/// Entry point from `run_call` once `--x402`/`--mpp` is present. +pub(super) async fn run_paid_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + // Both the key and the params may come from stdin; there is only one stdin. + let params_use_stdin = matches!(args.params.as_deref(), Some("-")) + || matches!(&args.params_file, Some(p) if p.as_os_str() == "-"); + let key_use_stdin = matches!(&args.payment_key_file, Some(p) if p.as_os_str() == "-"); + if params_use_stdin && key_use_stdin { + return Err(CliError::Arg( + "cannot read both the params and the payment key from stdin; \ + put one of them in a file" + .to_string(), + )); + } + + // Config parameter defaults. Unlike the default lane's endpoint_url load, + // a broken config file is a hard error here (exit 4): the user probably + // relies on [rpc.payment] values we could not read. + let section = load_payment_section(&global)?; + + // The single documented env read, injected into the resolver so it stays + // deterministic under test (no process-env mutation). + let env_key = std::env::var("QN_PAYMENT_KEY").ok(); + + let (payment, network, key_file_warning) = + resolve_payment_config(&args, §ion, env_key, global.base_url.clone())?; + + let params = super::parse_params(args.params.as_deref(), args.params_file.as_deref())?; + + let ctx = Ctx::from_global_keyless_payment(global, payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + + // ONE attempt, no retrying(): a retried paid call risks a double charge. + // call_with_receipt is the single code path; the receipt is dropped unless + // --receipt asked for it. + let resp = ctx + .sdk + .rpc + .call_with_receipt(&args.method, params, Some(network), None) + .await + .map_err(map_paid_error)?; + + if args.receipt { + // The receipt is data: it goes to stdout, opted into explicitly since + // it changes the output shape. `null` on x402 (no settlement + // reference exists in that protocol). + let receipt = resp.payment_receipt.map(|r| { + serde_json::json!({ + "method": r.method, + "status": r.status, + "timestamp": r.timestamp, + "reference": r.reference, + }) + }); + super::emit_result( + &ctx, + &serde_json::json!({ + "result": resp.result, + "payment_receipt": receipt, + }), + ) + } else { + // Identical output shape to an unpaid call. + super::emit_result(&ctx, &resp.result) + } +} + +/// Loads `[rpc.payment]` from the resolved config file. A missing file is an +/// empty section; an unreadable/invalid file is a hard error, since payment +/// parameters the user set there would otherwise be silently ignored. +fn load_payment_section(global: &GlobalArgs) -> Result { + let Some(path) = global.resolve_config_path() else { + return Ok(PaymentSection::default()); + }; + Ok(config::load_from(&path)? + .map(|cfg| cfg.rpc.payment) + .unwrap_or_default()) +} + +/// Resolves the full payment configuration from flags, the injected env value, +/// and the config section — entirely before any network I/O, so every missing +/// or malformed input fails fast with an actionable message and zero requests +/// sent. Returns the SDK config, the query network, and an optional key-file +/// permissions warning for the caller to print once output exists. +fn resolve_payment_config( + args: &CallArgs, + section: &PaymentSection, + env_key: Option, + base_url_override: Option, +) -> Result<(PaymentConfig, String, Option), CliError> { + // Scheme comes from which flag is set; clap's ArgGroup guarantees at most + // one, and run_paid_call only runs when one is present. + let scheme = if args.x402 { "x402" } else { "mpp" }; + + // The query chain. Required, but enforced here rather than via clap so the + // error can explain the query-chain vs pay-chain distinction. + let Some(network) = args.network.clone() else { + return Err(CliError::Arg(format!( + "--{scheme} requires --network: the chain the call queries, as the \ + payment gateway's path slug (e.g. --network base-sepolia). This is \ + separate from --pay-network, the chain the payment settles on." + ))); + }; + + // An inline raw key in config is never accepted — it belongs in a file. + if section.key.is_some() { + return Err(CliError::Arg( + "[rpc.payment] does not accept an inline `key`; store the key in a \ + file and set `key_file = \"\"` instead (the config file is \ + too easily shared to hold a raw wallet key)" + .to_string(), + )); + } + + let (key, key_file_warning) = + resolve_key(args.payment_key_file.as_deref(), env_key, section.key_file.as_deref())?; + + let max_amount = args + .max_amount + .clone() + .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 pay_network = args + .pay_network + .clone() + .or_else(|| section.pay_network.clone()) + .ok_or_else(|| { + CliError::Arg( + "no pay network set. Pass --pay-network (e.g. \ + eip155:84532) or set `pay_network` under [rpc.payment]" + .to_string(), + ) + })?; + + let asset = args + .asset + .clone() + .or_else(|| section.asset.clone()) + .ok_or_else(|| { + CliError::Arg( + "no payment asset set. Pass --asset
(the token \ + contract or mint to pay with) or set `asset` under \ + [rpc.payment]" + .to_string(), + ) + })?; + + 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: scheme.to_string(), + key, + pay_network, + asset, + max_amount, + svm_rpc_url, + base_url_override, + }, + network, + key_file_warning, + )) +} + +/// Resolves the raw private key: `--payment-key-file` (a path, or `-` for +/// stdin) > env `QN_PAYMENT_KEY` > config `key_file` path. The raw key is +/// never a flag value. Returns the key and an optional permissions warning +/// (group/world-readable key file). Error messages name the path, never the +/// file contents. +fn resolve_key( + flag_file: Option<&Path>, + env_key: Option, + config_file: 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(key) = env_key { + return checked_key(key, "QN_PAYMENT_KEY").map(|k| (k, None)); + } + if let Some(path) = config_file { + return read_key_file(path); + } + Err(CliError::Arg( + "no payment key found. Pass --payment-key-file (or '-' for \ + stdin), set QN_PAYMENT_KEY, or set `key_file` under [rpc.payment]" + .to_string(), + )) +} + +/// Reads and validates a key file, plus an ssh-style permissions warning when +/// the file is group- or world-readable. +fn read_key_file(path: &Path) -> Result<(String, Option), CliError> { + let raw = std::fs::read_to_string(path).map_err(|e| { + CliError::Arg(format!( + "could not read payment key file '{}': {e}", + path.display() + )) + })?; + let key = checked_key(raw, &format!("'{}'", path.display()))?; + + #[cfg(unix)] + let warning = { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .ok() + .map(|m| m.permissions().mode()) + .filter(|mode| mode & 0o077 != 0) + .map(|_| { + format!( + "⚠ payment key file '{}' is readable by other users; \ + consider `chmod 600`", + path.display() + ) + }) + }; + #[cfg(not(unix))] + let warning = None; + + Ok((key, warning)) +} + +/// Trims and rejects an empty key. `source` names where the key came from +/// (a path, `stdin`, or the env var) — never its contents. +fn checked_key(raw: String, source: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliError::Arg(format!( + "the payment key from {source} is empty" + ))); + } + Ok(trimmed.to_string()) +} + +/// Maps a paid-call failure. A `Decode` error on the paid lane means the +/// gateway's post-payment response could not be interpreted — the payment may +/// already have settled, so it gets the same never-blindly-retry treatment as +/// `PaymentIndeterminate` (exit 3) instead of rendering as a generic decode +/// failure. Everything else passes through to the normal SDK mapping. +fn map_paid_error(e: SdkError) -> CliError { + match e { + SdkError::Decode { .. } => CliError::PaymentMaybeCharged(e), + other => other.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, + payment_key_file: None, + max_amount: Some("10000".to_string()), + pay_network: Some("eip155:84532".to_string()), + 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 + } + + #[test] + fn resolves_full_config_from_flags_and_env_key() { + let args = paid_args(true); + let (cfg, network, _) = + resolve_payment_config(&args, &empty_section(), Some("0xkey".to_string()), 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 mpp_flag_selects_mpp_scheme() { + let args = paid_args(false); + let (cfg, _, _) = + resolve_payment_config(&args, &empty_section(), Some("k".to_string()), None).unwrap(); + assert_eq!(cfg.scheme, "mpp"); + } + + #[test] + fn missing_network_names_both_flags() { + let mut args = paid_args(true); + args.network = None; + let err = resolve_payment_config(&args, &empty_section(), Some("k".to_string()), None) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("--network"), "got: {msg}"); + assert!(msg.contains("--pay-network"), "got: {msg}"); + } + + #[test] + fn flag_key_file_beats_env_key() { + let f = key_file_with("0xfromfile\n"); + let mut args = paid_args(true); + args.payment_key_file = Some(f.path().to_path_buf()); + let (cfg, _, _) = + resolve_payment_config(&args, &empty_section(), Some("0xfromenv".to_string()), None) + .unwrap(); + assert_eq!(cfg.key, "0xfromfile"); // and trimmed + } + + #[test] + fn env_key_beats_config_key_file() { + 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, Some("0xfromenv".to_string()), None).unwrap(); + assert_eq!(cfg.key, "0xfromenv"); + } + + #[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 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("QN_PAYMENT_KEY"), "got: {msg}"); + assert!(msg.contains("key_file"), "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 = paid_args(true); + let err = + resolve_payment_config(&args, §ion, Some("k".to_string()), 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 = paid_args(true); + args.max_amount = None; + let err = resolve_payment_config(&args, &empty_section(), Some("k".to_string()), 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 = paid_args(true); + args.max_amount = Some(bad.to_string()); + let err = resolve_payment_config(&args, &empty_section(), Some("k".to_string()), 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 = paid_args(true); // flag says 10000 + let (cfg, _, _) = + resolve_payment_config(&args, §ion, Some("k".to_string()), 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()), + key: None, + max_amount: Some("5000".to_string()), + pay_network: Some("eip155:42431".to_string()), + asset: Some("0xdef".to_string()), + svm_rpc_url: None, + }; + let mut args = paid_args(false); + args.max_amount = None; + args.pay_network = None; + args.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 = paid_args(true); + let (cfg, _, _) = resolve_payment_config( + &args, + &empty_section(), + Some("k".to_string()), + 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 = paid_args(true); + args.svm_rpc_url = Some("ftp://nope".to_string()); + let err = resolve_payment_config(&args, &empty_section(), Some("k".to_string()), 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); + } +} From b46e57d6ca5f55ef1972820bc4f2b718d72c02f7 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 13 Jul 2026 03:25:43 -0400 Subject: [PATCH 06/32] test(rpc): integration coverage for the crypto-micropayment lane 18 tests against a wiremock gateway using the SDK's 402-handshake shapes (x402 menu + signed resend; MPP WWW-Authenticate challenge + Payment-Receipt header) and public fixtures (anvil key #0, Base Sepolia test USDC). Safety-critical assertions: - lane isolation: a paid call sends exactly the two gateway POSTs, never touches /v0 control-plane routes, and writes no tokens.toml - config presence never auto-activates payment (gateway .expect(0)) - the paid lane ignores --retries (one request, ever) - an over-cap offer is refused after one request, before signing - every pre-flight failure (missing network/key/cap, non-integer cap, inline config key, double-stdin) exits before any request - --receipt wraps stdout with the MPP settlement reference and null on x402; without it the paid output is the bare result; the raw key never appears on either stream (subprocess assertions) Also applies rustfmt to the new payment modules. --- src/commands/rpc/mod.rs | 40 +- src/commands/rpc/payment.rs | 21 +- tests/rpc_payment.rs | 791 ++++++++++++++++++++++++++++++++++++ 3 files changed, 837 insertions(+), 15 deletions(-) create mode 100644 tests/rpc_payment.rs diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index 126672a..e43053f 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -123,30 +123,55 @@ pub struct CallArgs { /// base58); pass `-` to read it from stdin. Never accepts the key itself. /// Precedence: this flag > QN_PAYMENT_KEY env var (raw key) > `key_file` /// under [rpc.payment] in config. - #[arg(long, value_name = "PATH", requires = "payment", help_heading = "Payment")] + #[arg( + long, + value_name = "PATH", + requires = "payment", + help_heading = "Payment" + )] pub payment_key_file: Option, /// Spend ceiling per call, in integer base units of the asset (e.g. /// 10000 = 0.01 USDC). No built-in default: flag > `max_amount` under /// [rpc.payment]. Offered payments above the ceiling are never signed. - #[arg(long, value_name = "BASE_UNITS", requires = "payment", help_heading = "Payment")] + #[arg( + long, + value_name = "BASE_UNITS", + requires = "payment", + help_heading = "Payment" + )] pub max_amount: Option, /// CAIP-2 id of the chain you PAY on (e.g. `eip155:84532`), independent /// of --network (the chain you query). Falls back to `pay_network` under /// [rpc.payment]. - #[arg(long, value_name = "CAIP2", requires = "payment", help_heading = "Payment")] + #[arg( + long, + value_name = "CAIP2", + requires = "payment", + help_heading = "Payment" + )] pub pay_network: Option, /// Token to pay with: EVM contract address or Solana mint. Falls back to /// `asset` under [rpc.payment]. - #[arg(long, value_name = "ADDRESS", requires = "payment", help_heading = "Payment")] + #[arg( + long, + value_name = "ADDRESS", + requires = "payment", + help_heading = "Payment" + )] pub 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")] + #[arg( + long, + value_name = "URL", + requires = "payment", + help_heading = "Payment" + )] pub svm_rpc_url: Option, /// Wrap stdout as {"result": ..., "payment_receipt": ...}. The receipt is @@ -587,7 +612,10 @@ async fn maybe_enable(ctx: &Ctx) -> Result<(), CliError> { /// as JSON. `None`/`None` → no params (sends `[]`). Either source accepts `-` to /// read from stdin. The clap `ArgGroup` guarantees at most one is set. An empty /// value (after trimming) is treated as no params. -pub(super) fn parse_params(arg: Option<&str>, file: Option<&Path>) -> Result, CliError> { +pub(super) fn parse_params( + arg: Option<&str>, + file: Option<&Path>, +) -> Result, CliError> { let raw = match (arg, file) { (None, None) => return Ok(None), (Some("-"), _) => read_stdin("params")?, diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index dd17877..aa43fb7 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -141,8 +141,11 @@ fn resolve_payment_config( )); } - let (key, key_file_warning) = - resolve_key(args.payment_key_file.as_deref(), env_key, section.key_file.as_deref())?; + let (key, key_file_warning) = resolve_key( + args.payment_key_file.as_deref(), + env_key, + section.key_file.as_deref(), + )?; let max_amount = args .max_amount @@ -189,7 +192,11 @@ fn resolve_payment_config( ) })?; - let svm_rpc_url = match args.svm_rpc_url.clone().or_else(|| section.svm_rpc_url.clone()) { + 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, }; @@ -426,8 +433,7 @@ mod tests { let mut section = empty_section(); section.key = Some(toml::Value::String("0xraw".to_string())); let args = paid_args(true); - let err = - resolve_payment_config(&args, §ion, Some("k".to_string()), None).unwrap_err(); + let err = resolve_payment_config(&args, §ion, Some("k".to_string()), 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}"); @@ -449,10 +455,7 @@ mod tests { args.max_amount = Some(bad.to_string()); let err = resolve_payment_config(&args, &empty_section(), Some("k".to_string()), None) .unwrap_err(); - assert!( - err.to_string().contains("base units"), - "for {bad}: {err}" - ); + assert!(err.to_string().contains("base units"), "for {bad}: {err}"); } } diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs new file mode 100644 index 0000000..fd7bcf3 --- /dev/null +++ b/tests/rpc_payment.rs @@ -0,0 +1,791 @@ +//! Integration tests for the crypto-micropayment lane of `qn rpc call` +//! (`--x402`/`--mpp`). +//! +//! The harness's hidden `--base-url` is threaded into +//! `PaymentConfig.base_url_override`, so the mock server doubles as the +//! payment gateway: the paid lane POSTs to `{base}/`. The 402 +//! handshake shapes mirror the SDK's own driver tests (an unpaid POST gets a +//! 402 with a payment menu; the resend carries a `payment-signature` / +//! `authorization` header and gets the JSON-RPC result). +//! +//! All key material is fake: anvil throwaway key #0 (public, never funded) +//! and the public Base Sepolia test-USDC address. + +mod common; + +use common::{parse, run_qn, run_qn_no_key}; +use serde_json::json; +use std::sync::atomic::{AtomicUsize, Ordering}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +// anvil key #0 (public throwaway, never funded). +const EVM_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +// Base Sepolia test USDC (public address). +const USDC: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + +/// Writes the throwaway key to a tempfile and returns the (guard, path) pair. +fn key_file() -> (tempfile::NamedTempFile, String) { + use std::io::Write; + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(EVM_KEY.as_bytes()).unwrap(); + f.flush().unwrap(); + let path = f.path().to_str().unwrap().to_string(); + (f, path) +} + +/// One entry of the x402 payment menu the mock gateway offers. +fn x402_accepts_entry(amount: &str) -> serde_json::Value { + json!({ + "scheme": "exact", + "network": "eip155:84532", + "amount": amount, + "payTo": "0x000000000000000000000000000000000000dEaD", + "maxTimeoutSeconds": 60, + "asset": USDC, + "extra": { "name": "USDC", "version": "2" } + }) +} + +/// Sequenced gateway responder: the first (unpaid) POST gets a 402 with a +/// one-entry menu at `amount`; any request carrying a payment signature gets +/// the JSON-RPC result. +struct X402Seq { + amount: &'static str, + calls: AtomicUsize, +} + +impl X402Seq { + fn new(amount: &'static str) -> Self { + Self { + amount, + 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 { + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + })) + } + } +} + +/// Mounts `.expect(0)` mocks on the control-plane routes the default lane +/// uses, proving the paid lane never touches them. +async fn mount_control_plane_expect_zero(server: &MockServer) { + for p in ["/v0/tooling-access", "/v0/account/info"] { + Mock::given(path(p)) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(server) + .await; + } +} + +// ── happy paths ────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn x402_happy_path_pays_and_bypasses_control_plane() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(2) // one unpaid probe + one paid resend, nothing else + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + // The harness injects --api-key test: even with a key present, the paid + // lane must not mint, probe, or write the token cache. + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--pay-network", + "eip155:84532", + "--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 paid_call_works_keyless_with_config_params() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(2) + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + // Config supplies every parameter and has NO [api] key: the minimal + // invocation is method + --network + the scheme flag, with no login ever. + let dir = tempfile::tempdir().unwrap(); + let (_guard, key_path) = key_file(); + let cfg = dir.path().join("config.toml"); + std::fs::write( + &cfg, + format!( + "[rpc.payment]\nkey_file = \"{key_path}\"\nmax_amount = \"10000\"\n\ + pay_network = \"eip155:84532\"\nasset = \"{USDC}\"\n" + ), + ) + .unwrap(); + + let out = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + cfg.to_str().unwrap(), + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +// ── spend-cap enforcement ──────────────────────────────────────────────────── + +#[tokio::test] +async fn over_cap_offer_is_refused_before_signing() { + let server = MockServer::start().await; + // Menu offers 999999; the cap is 10000. Exactly ONE request: the unpaid + // probe. Nothing is signed, nothing is resent. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("999999")) + .expect(1) + .mount(&server) + .await; + + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--pay-network", + "eip155:84532", + "--asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!( + out.stderr.contains("Nothing was charged"), + "stderr={}", + out.stderr + ); +} + +#[tokio::test] +async fn flag_max_amount_overrides_config() { + let server = MockServer::start().await; + // Config would allow the 1000 offer (cap 999999); the flag caps at 1. + // A resulting "unsupported" refusal proves the flag won. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let (_guard, key_path) = key_file(); + let cfg = dir.path().join("config.toml"); + std::fs::write( + &cfg, + format!( + "[rpc.payment]\nkey_file = \"{key_path}\"\nmax_amount = \"999999\"\n\ + pay_network = \"eip155:84532\"\nasset = \"{USDC}\"\n" + ), + ) + .unwrap(); + + let out = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + cfg.to_str().unwrap(), + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--max-amount", + "1", + ], + ) + .await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!( + out.stderr.contains("Nothing was charged"), + "stderr={}", + out.stderr + ); +} + +// ── activation and lane isolation ──────────────────────────────────────────── + +#[tokio::test] +async fn config_presence_does_not_auto_activate_payment() { + let server = MockServer::start().await; + // Fully populated [rpc.payment] but NO scheme flag: the call must take the + // normal Tooling Access lane (mint attempt against the control plane) and + // the gateway must see zero traffic. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + Mock::given(path("/v0/tooling-access")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let (_guard, key_path) = key_file(); + let cfg = dir.path().join("config.toml"); + std::fs::write( + &cfg, + format!( + "[api]\nkey = \"test\"\n\n\ + [rpc.payment]\nkey_file = \"{key_path}\"\nmax_amount = \"10000\"\n\ + pay_network = \"eip155:84532\"\nasset = \"{USDC}\"\n" + ), + ) + .unwrap(); + + let out = run_qn_no_key( + &server.uri(), + &[ + "--config-file", + cfg.to_str().unwrap(), + "rpc", + "call", + "eth_blockNumber", + ], + ) + .await; + // The default lane fails against the 500ing control plane — the exit code + // is not the point; the .expect(0) on the gateway is. + assert_ne!(out.exit_code, 0); +} + +#[tokio::test] +async fn paid_lane_is_never_retried() { + let server = MockServer::start().await; + // The gateway 500s every request. Even with --retries 5, exactly ONE + // request may arrive: a retried paid call risks a double charge. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .expect(1) + .mount(&server) + .await; + + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "--retries", + "5", + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--pay-network", + "eip155:84532", + "--asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_ne!(out.exit_code, 0); +} + +#[tokio::test] +async fn payment_rejected_on_paid_resend_exits_2_with_wallet_warning() { + let server = MockServer::start().await; + // 402 menu, then the paid resend is refused with another 402: a signed + // payment went out, so the message must say "check your wallet". + 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, + "--pay-network", + "eip155:84532", + "--asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!( + out.stderr.contains("check your wallet"), + "stderr={}", + out.stderr + ); +} + +// ── pre-flight failures: fail fast, zero requests ──────────────────────────── + +/// Runs a paid invocation expected to die in pre-flight: any request reaching +/// the mock is a failure. +async fn expect_preflight_error(extra: &[&str], needle: &str) { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let out = run_qn(&server.uri(), extra).await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains(needle), + "expected {needle:?} in stderr: {}", + out.stderr + ); +} + +#[tokio::test] +async fn missing_network_fails_before_any_request() { + let (_guard, key_path) = key_file(); + expect_preflight_error( + &[ + "rpc", + "call", + "eth_blockNumber", + "--x402", + "--payment-key-file", + &key_path, + "--pay-network", + "eip155:84532", + "--asset", + USDC, + "--max-amount", + "10000", + ], + "--network", + ) + .await; +} + +#[tokio::test] +async fn missing_key_fails_before_any_request() { + // No flag, no env in-process, and a config dir with no key_file. + 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", + "--pay-network", + "eip155:84532", + "--asset", + USDC, + "--max-amount", + "10000", + ], + "QN_PAYMENT_KEY", + ) + .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, + "--pay-network", + "eip155:84532", + "--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, + "--pay-network", + "eip155:84532", + "--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\ + pay_network = \"eip155:84532\"\nasset = \"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", + "-", + "--pay-network", + "eip155:84532", + "--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!["--pay-network", "eip155:84532"], + vec!["--asset", "0xabc"], + vec!["--max-amount", "1"], + vec!["--payment-key-file", "/k"], + vec!["--svm-rpc-url", "https://x"], + ] { + let mut argv = vec!["rpc", "call", "eth_blockNumber"]; + argv.extend(orphan.iter().copied()); + assert!( + parse(&argv).is_err(), + "expected {orphan:?} to require --x402/--mpp" + ); + } +} + +#[test] +fn payment_conflicts_with_endpoint_url() { + for scheme in ["--x402", "--mpp"] { + let err = parse(&[ + "rpc", + "call", + "eth_blockNumber", + scheme, + "--endpoint-url", + "https://x/rpc", + ]) + .unwrap_err(); + assert!(err.to_string().contains("--endpoint-url"), "got: {err}"); + } +} + +// ── output shapes (subprocess: the in-process harness can't capture stdout) ── + +/// Runs the real binary against `server`, with HOME pointed at a tempdir so no +/// real config leaks in. The key is passed via QN_PAYMENT_KEY to also cover +/// env resolution (a subprocess env can't race parallel tests). +fn run_qn_subprocess( + server_uri: &str, + home: &std::path::Path, + args: &[&str], +) -> std::process::Output { + assert_cmd::Command::cargo_bin("qn") + .unwrap() + .env_remove("HOME") + .env("HOME", home) + .env("QN_PAYMENT_KEY", EVM_KEY) + .args(["--base-url", server_uri, "--no-input"]) + .args(args) + .output() + .unwrap() +} + +#[tokio::test] +async fn receipt_flag_wraps_stdout_on_mpp() { + let server = MockServer::start().await; + + // MPP challenge (tempo, chain 42431) and a settlement receipt header, both + // base64url-encoded JSON — mirrors the SDK's own MPP driver test. + fn b64url(v: serde_json::Value) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&v).unwrap()) + } + let request = b64url(json!({ + "amount": "1000", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0xfd24114c3981aba78ae2441991b1bdb89329c556", + "methodDetails": { "chainId": 42431, "feePayer": true } + })); + let www = format!( + "Payment id=\"c1\", realm=\"mpp.quicknode.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-07-13T02:05:10.119Z", + "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", + "--pay-network", + "eip155:42431", + "--asset", + "0x20c0000000000000000000000000000000000000", + "--max-amount", + "10000", + ], + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(output.status.success(), "stderr={stderr}"); + + let v: serde_json::Value = + serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("bad JSON: {e}\n{stdout}")); + assert_eq!(v["result"].as_str(), Some("0xok")); + assert_eq!( + v["payment_receipt"]["reference"].as_str(), + Some("0xdeadbeef") + ); + // The raw key must never appear on either stream. + assert!(!stdout.contains(EVM_KEY) && !stderr.contains(EVM_KEY)); +} + +#[tokio::test] +async fn receipt_is_null_on_x402_and_bare_without_flag() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .mount(&server) + .await; + + let home = tempfile::tempdir().unwrap(); + let paid_args = [ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--pay-network", + "eip155:84532", + "--asset", + USDC, + "--max-amount", + "10000", + ]; + + // With --receipt: wrapped, and x402 has no settlement reference. + let mut with_receipt = paid_args.to_vec(); + with_receipt.push("--receipt"); + let output = run_qn_subprocess(&server.uri(), home.path(), &with_receipt); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(v["result"].as_str(), Some("0x1335f9a")); + assert!(v["payment_receipt"].is_null()); + + // Without --receipt: the bare result, byte-identical shape to unpaid. + let output = run_qn_subprocess(&server.uri(), home.path(), &paid_args); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(output.status.success()); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(v.as_str(), Some("0x1335f9a")); +} From 9c878069879fe9622ac1c63cd4f68469721aeaf2 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 13 Jul 2026 03:27:34 -0400 Subject: [PATCH 07/32] docs: document the paid RPC lane in the agent guide and README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the --x402/--mpp surface, the key-resolution ladder (file/stdin flag > QN_PAYMENT_KEY > key_file in config; never a raw key on argv or in config), the per-call spend ceiling, --receipt output shape, the query-chain vs pay-chain distinction, and the payment exit-code semantics: 2 = the gateway refused, 3 = outcome unknown, the payment may have settled — check the wallet before re-running. Notes that paid calls never auto-retry and that config supplies parameters but never activates payment. --- README.md | 52 +++++++++++++++++++++++++++++++++++ src/commands/agent/context.md | 47 +++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/README.md b/README.md index dced139..6011748 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,58 @@ 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). +#### Pay per call with a crypto micropayment + +`--x402` (EVM or Solana stablecoin) or `--mpp` (Tempo) pays for the call per +request instead of using an account API key — no login and no Tooling Access +needed. **This moves real funds** (testnet tokens are still real transfers): +use a dedicated, minimally funded wallet. + +```sh +qn rpc call eth_blockNumber --network base-sepolia --x402 \ + --payment-key-file ~/.keys/payer \ + --pay-network eip155:84532 \ + --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ + --max-amount 10000 + +# MPP settles on Tempo and returns a settlement receipt with --receipt: +qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt ... +``` + +- `--network` is required and names the chain you *query*, as the payment + gateway's path slug (independent of `--pay-network`, the CAIP-2 chain the + payment *settles* on). +- The private key resolves from `--payment-key-file ` (`-` for stdin), + then the `QN_PAYMENT_KEY` env var, then `key_file` in config. It is never + accepted as a flag value, never stored in config, and never printed. +- `--max-amount` is the per-call spend ceiling in integer base units of the + asset (e.g. `10000` = 0.01 USDC). There is no built-in default; offers above + the ceiling are refused before anything is signed. +- `--receipt` wraps stdout as `{"result": ..., "payment_receipt": ...}` — the + settlement transaction hash on MPP, `null` on x402. Without it, paid output + is shaped exactly like unpaid output. +- Paid calls **never auto-retry** (`--retries` does not apply). Exit code 2 + means the gateway refused; exit 3 means the outcome is unknown and the + payment may have settled — check the wallet before re-running. +- x402/Solana at volume: pass `--svm-rpc-url `; the default public Solana + RPC rate-limits aggressively. + +Store the parameters once in `~/.config/qn/config.toml` and the invocation +shrinks to the scheme flag — config supplies values but never activates +payment by itself: + +```toml +[rpc.payment] +key_file = "/home/me/.config/qn/payment.key" # a path; never the raw key +max_amount = "10000" +pay_network = "eip155:84532" +asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" +``` + +```sh +qn rpc call eth_blockNumber --network base-sepolia --x402 +``` + ### Other ```sh diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index f6cf74a..e4500ce 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -61,6 +61,12 @@ Branch on these — especially **4** and **5**. | 5 | Cancelled, or confirmation required and not granted (see §4). | | 130 | Interrupted (SIGINT). | +On a **paid** `rpc call` (`--x402`/`--mpp`, §6) the 2/3 split carries payment +semantics: **2** means the gateway refused (an unmatched offer settles nothing; +a rejected payment is terminal), while **3** means the outcome is unknown — the +request was sent and **may have been charged**. On exit 3, check the wallet +before re-running; never blind-retry a paid call. + ## 4. Non-interactive & confirmation behavior Destructive commands are gated. On a TTY they prompt `y/N`. To proceed without a @@ -96,6 +102,9 @@ 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`) never auto-retries — `--retries` does + not apply. Each attempt can move funds, and after a lost response the previous + attempt may already have settled (§3, exit 3). ## 6. Command catalog @@ -128,6 +137,20 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase available keys. Custom endpoint: `--endpoint-url ` (or `[rpc] endpoint_url` in config) sends the call to a fully-formed HTTP URL that authenticates itself (no token minted); it's mutually exclusive with `--network`. + **Paid lane**: `--x402` (EVM/Solana stablecoin) or `--mpp` (Tempo) pays for + the call per request with a crypto micropayment instead of an API key — no + login, no Tooling Access. Requires `--network` as the payment gateway's path + slug (e.g. `base-sepolia`; NOT validated by `list-networks`). Parameters: + `--pay-network ` (the chain the payment settles on — independent of + `--network`), `--asset
`, `--max-amount ` (spend ceiling + per call, integer base units, no default), and the private key via + `--payment-key-file ` > `QN_PAYMENT_KEY` env > `key_file` under + `[rpc.payment]` in config (a path — never the raw key). All of these fall + back to `[rpc.payment]`, but config never activates payment by itself: the + scheme flag is always required. `--receipt` wraps stdout as + `{"result": ..., "payment_receipt": ...}` (settlement tx hash on MPP; `null` + on x402); without it the paid output shape is identical to an unpaid call. + Mutually exclusive with `--endpoint-url`. Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`, `qn endpoint rate-limit --help`. Shell completions: `qn completions `. @@ -197,9 +220,33 @@ qn rpc call eth_blockNumber --endpoint-url https://my-endpoint.example/rpc is enabling Tooling Access (or pass `--yes` to enable on first use). A custom `--endpoint-url` (or `[rpc] endpoint_url` in config) bypasses that entirely. +**Pay per call with a crypto micropayment (no API key, no login):** + +```sh +# One-time: store the wallet parameters (never the raw key) in config +cat >> ~/.config/qn/config.toml <<'EOF' +[rpc.payment] +key_file = "/home/me/.config/qn/payment.key" # file holding the raw key; chmod 600 +max_amount = "10000" # spend ceiling per call, base units +pay_network = "eip155:84532" # chain the payment settles on (CAIP-2) +asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" +EOF + +qn rpc call eth_blockNumber --network base-sepolia --x402 # pays, prints the result +qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt # + settlement tx hash +``` + +This moves real funds (even testnet tokens are real transfers) — use a +dedicated, minimally funded wallet. The spend ceiling bounds each call; there +is no built-in default. + ## 8. Gotchas & safety rails - Mutations are never retried; re-running a failed create can double-provision (§5). +- Paid `rpc call` moves real funds and never auto-retries; exit 3 means the + payment may have settled — check the wallet before re-running (§3, §5). The + CLI never prints the payment key; keep it in a file or `QN_PAYMENT_KEY`, not + in config or argv. - 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. From 8ae231c644e67d54009a3ae5ef5625da9e82063e Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 13 Jul 2026 20:36:57 -0400 Subject: [PATCH 08/32] feat(rpc): accept network names for --pay-network and refine paid-lane errors Adds human-readable names to the paid RPC lane: --pay-network (and pay_network in [rpc.payment]) now takes a Quicknode network name like base-sepolia, solana-devnet, or tempo-testnet, resolved to CAIP-2 before reaching the SDK. Raw CAIP-2 ids still pass through verbatim, so every chain stays reachable without a table entry. EVM chain ids are verified against the public registry at chainid.network; names that could not be verified are deliberately absent, since a wrong id is worse than an error pointing at the CAIP-2 escape hatch. Also sharpens paid-lane error semantics: a gateway 5xx on the paid resend is wrapped as PaymentMaybeCharged (exit 3, payment submitted, check the wallet), so exit 2 (PaymentRejected 4xx / PaymentUnsupported) always and only means the gateway refused without settling. Messages reworded to match. 9 new tests: 5 unit on the resolver, 3 unit on payment config resolution, 2 integration (name matches a CAIP-2 offer end-to-end; unknown name fails preflight with zero requests sent); MPP flow with --pay-network tempo-testnet verified against the live gateway. --- README.md | 24 ++-- src/commands/agent/context.md | 25 ++-- src/commands/rpc/mod.rs | 11 +- src/commands/rpc/pay_network.rs | 187 ++++++++++++++++++++++++++ src/commands/rpc/payment.rs | 78 +++++++++-- src/config.rs | 10 +- src/errors.rs | 32 +++-- tests/rpc_payment.rs | 230 ++++++++++++++++++++++++++++++-- 8 files changed, 539 insertions(+), 58 deletions(-) create mode 100644 src/commands/rpc/pay_network.rs diff --git a/README.md b/README.md index 6011748..2d4d2be 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,7 @@ use a dedicated, minimally funded wallet. ```sh qn rpc call eth_blockNumber --network base-sepolia --x402 \ --payment-key-file ~/.keys/payer \ - --pay-network eip155:84532 \ + --pay-network base-sepolia \ --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ --max-amount 10000 @@ -346,20 +346,26 @@ qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt ... ``` - `--network` is required and names the chain you *query*, as the payment - gateway's path slug (independent of `--pay-network`, the CAIP-2 chain the - payment *settles* on). + gateway's path slug (independent of `--pay-network`, the chain the payment + *settles* on). +- `--pay-network` takes a Quicknode network name (`base-sepolia`, + `solana-devnet`, `tempo-testnet`, ...) or a raw CAIP-2 id (`eip155:84532`, + `solana:EtWTRA...`). Anything containing a `:` is passed through verbatim, + so any `eip155:` works even without a named entry. - The private key resolves from `--payment-key-file ` (`-` for stdin), then the `QN_PAYMENT_KEY` env var, then `key_file` in config. It is never accepted as a flag value, never stored in config, and never printed. - `--max-amount` is the per-call spend ceiling in integer base units of the asset (e.g. `10000` = 0.01 USDC). There is no built-in default; offers above the ceiling are refused before anything is signed. -- `--receipt` wraps stdout as `{"result": ..., "payment_receipt": ...}` — the - settlement transaction hash on MPP, `null` on x402. Without it, paid output - is shaped exactly like unpaid output. +- `--receipt` wraps stdout as `{"result": ..., "payment_receipt": ...}`. On + MPP the receipt is an object (`method`, `status`, `timestamp`, and + `reference` — the settlement transaction hash); on x402 it is `null`. + Without it, paid output is shaped exactly like unpaid output. - Paid calls **never auto-retry** (`--retries` does not apply). Exit code 2 - means the gateway refused; exit 3 means the outcome is unknown and the - payment may have settled — check the wallet before re-running. + means the gateway refused and nothing settled; exit 3 means the outcome is + unknown — the payment was submitted and may have settled; check the wallet + before re-running. - x402/Solana at volume: pass `--svm-rpc-url `; the default public Solana RPC rate-limits aggressively. @@ -371,7 +377,7 @@ payment by itself: [rpc.payment] key_file = "/home/me/.config/qn/payment.key" # a path; never the raw key max_amount = "10000" -pay_network = "eip155:84532" +pay_network = "base-sepolia" # network name or CAIP-2 id asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" ``` diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index e4500ce..c2480cd 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -62,10 +62,12 @@ Branch on these — especially **4** and **5**. | 130 | Interrupted (SIGINT). | On a **paid** `rpc call` (`--x402`/`--mpp`, §6) the 2/3 split carries payment -semantics: **2** means the gateway refused (an unmatched offer settles nothing; -a rejected payment is terminal), while **3** means the outcome is unknown — the -request was sent and **may have been charged**. On exit 3, check the wallet -before re-running; never blind-retry a paid call. +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. ## 4. Non-interactive & confirmation behavior @@ -141,15 +143,18 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase the call per request with a crypto micropayment instead of an API key — no login, no Tooling Access. Requires `--network` as the payment gateway's path slug (e.g. `base-sepolia`; NOT validated by `list-networks`). Parameters: - `--pay-network ` (the chain the payment settles on — independent of - `--network`), `--asset
`, `--max-amount ` (spend ceiling + `--pay-network ` (the chain the payment settles on — independent of + `--network`; a network name like `base-sepolia`/`solana-devnet`/`tempo-testnet`, + or a raw CAIP-2 id like `eip155:84532`, which always passes through + verbatim), `--asset
`, `--max-amount ` (spend ceiling per call, integer base units, no default), and the private key via `--payment-key-file ` > `QN_PAYMENT_KEY` env > `key_file` under `[rpc.payment]` in config (a path — never the raw key). All of these fall back to `[rpc.payment]`, but config never activates payment by itself: the scheme flag is always required. `--receipt` wraps stdout as - `{"result": ..., "payment_receipt": ...}` (settlement tx hash on MPP; `null` - on x402); without it the paid output shape is identical to an unpaid call. + `{"result": ..., "payment_receipt": ...}` (on MPP an object whose + `reference` is the settlement tx hash; `null` on x402); without it the paid + output shape is identical to an unpaid call. Mutually exclusive with `--endpoint-url`. Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`, @@ -228,12 +233,12 @@ cat >> ~/.config/qn/config.toml <<'EOF' [rpc.payment] key_file = "/home/me/.config/qn/payment.key" # file holding the raw key; chmod 600 max_amount = "10000" # spend ceiling per call, base units -pay_network = "eip155:84532" # chain the payment settles on (CAIP-2) +pay_network = "base-sepolia" # settlement chain: network name or CAIP-2 id asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" EOF qn rpc call eth_blockNumber --network base-sepolia --x402 # pays, prints the result -qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt # + settlement tx hash +qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt # + settlement receipt ``` This moves real funds (even testnet tokens are real transfers) — use a diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index e43053f..a6af2f9 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -24,6 +24,7 @@ //! branches off before any of this module's token-cache or Tooling Access //! machinery runs. +mod pay_network; mod payment; use std::io::Read; @@ -60,7 +61,7 @@ pub enum RpcCmd { Paid (crypto micropayment, no API key; params from [rpc.payment] in config):\n \ qn rpc call eth_blockNumber --network base-sepolia --x402\n \ qn rpc call eth_blockNumber --network base-sepolia --x402 \\\n \ - --payment-key-file ~/.keys/payer --pay-network eip155:84532 \\\n \ + --payment-key-file ~/.keys/payer --pay-network base-sepolia \\\n \ --asset 0x036C... --max-amount 10000\n \ qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt")] Call(Box), @@ -142,12 +143,12 @@ pub struct CallArgs { )] pub max_amount: Option, - /// CAIP-2 id of the chain you PAY on (e.g. `eip155:84532`), independent - /// of --network (the chain you query). Falls back to `pay_network` under - /// [rpc.payment]. + /// 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 `pay_network` under [rpc.payment]. #[arg( long, - value_name = "CAIP2", + value_name = "NETWORK", requires = "payment", help_heading = "Payment" )] diff --git a/src/commands/rpc/pay_network.rs b/src/commands/rpc/pay_network.rs new file mode 100644 index 0000000..48067c7 --- /dev/null +++ b/src/commands/rpc/pay_network.rs @@ -0,0 +1,187 @@ +//! Human-readable names for `--pay-network`. +//! +//! The SDK's `PaymentConfig.pay_network` is a canonical CAIP-2 id — that is +//! the form the x402/MPP offers are matched against. This module is the CLI's +//! presentation layer on top: it accepts the same Quicknode network-name +//! vocabulary as `--network` (e.g. `base-sepolia`) and resolves it to CAIP-2 +//! before the value reaches the SDK. Anything containing a `:` is treated as +//! an already-canonical CAIP-2 id and passed through verbatim, so every chain +//! is reachable even when it has no entry in the table. +//! +//! EVM chain ids are verified against the public EVM chain registry +//! (chainid.network, the dataset behind chainlist.org). Solana ids are the +//! standard CAIP-2 genesis-hash prefixes. Names whose chain id could not be +//! confirmed against a public source are deliberately absent — a wrong id +//! could match a payment offer on the wrong chain, while a missing name just +//! errors with the CAIP-2 escape hatch. + +use crate::errors::CliError; + +/// Quicknode network name → CAIP-2 pay-network id. Sorted by name (binary +/// searched); a unit test enforces order and uniqueness. +const PAY_NETWORKS: &[(&str, &str)] = &[ + ("0g-galileo", "eip155:16601"), + ("0g-mainnet", "eip155:16661"), + ("abstract-mainnet", "eip155:2741"), + ("abstract-testnet", "eip155:11124"), + ("arbitrum-mainnet", "eip155:42161"), + ("arbitrum-sepolia", "eip155:421614"), + ("ault-mainnet", "eip155:904"), + ("ault-testnet", "eip155:10904"), + ("avalanche-mainnet", "eip155:43114"), + ("avalanche-testnet", "eip155:43113"), + ("b3-mainnet", "eip155:8333"), + ("base-mainnet", "eip155:8453"), + ("base-sepolia", "eip155:84532"), + ("bera-bepolia", "eip155:80069"), + ("bera-mainnet", "eip155:80094"), + ("blast-mainnet", "eip155:81457"), + ("blast-sepolia", "eip155:168587773"), + ("bsc", "eip155:56"), + ("bsc-testnet", "eip155:97"), + ("celo-mainnet", "eip155:42220"), + ("cyber-mainnet", "eip155:7560"), + ("ethereum-hoodi", "eip155:560048"), + ("ethereum-mainnet", "eip155:1"), + ("ethereum-sepolia", "eip155:11155111"), + ("fantom", "eip155:250"), + ("flare-coston2", "eip155:114"), + ("flare-mainnet", "eip155:14"), + ("fluent-mainnet", "eip155:25363"), + ("fraxtal-mainnet", "eip155:252"), + ("gravity-alpham", "eip155:1625"), + ("hedera-mainnet", "eip155:295"), + ("hedera-testnet", "eip155:296"), + ("hemi-mainnet", "eip155:43111"), + ("hemi-testnet", "eip155:743111"), + // 999 is HyperEVM per Hyperliquid's docs; the chain registry still lists + // it under a stale earlier registration. + ("hype-mainnet", "eip155:999"), + ("hype-testnet", "eip155:998"), + ("injective-mainnet", "eip155:1776"), + ("injective-testnet", "eip155:1439"), + ("ink-mainnet", "eip155:57073"), + ("ink-sepolia", "eip155:763373"), + ("joc-mainnet", "eip155:81"), + ("kaia-kairos", "eip155:1001"), + ("kaia-mainnet", "eip155:8217"), + ("katana-mainnet", "eip155:747474"), + ("linea-mainnet", "eip155:59144"), + ("lisk-mainnet", "eip155:1135"), + ("mantle-mainnet", "eip155:5000"), + ("mantle-sepolia", "eip155:5003"), + ("megaeth-mainnet", "eip155:4326"), + ("moca-testnet", "eip155:5151"), + ("mode-mainnet", "eip155:34443"), + ("monad-mainnet", "eip155:143"), + ("monad-testnet", "eip155:10143"), + ("morph-mainnet", "eip155:2818"), + ("nova-mainnet", "eip155:42170"), + ("optimism", "eip155:10"), + ("optimism-sepolia", "eip155:11155420"), + ("peaq-mainnet", "eip155:3338"), + ("plasma-mainnet", "eip155:9745"), + ("plasma-testnet", "eip155:9746"), + ("polygon", "eip155:137"), + ("polygon-amoy", "eip155:80002"), + ("robinhood-mainnet", "eip155:4663"), + ("robinhood-testnet", "eip155:46630"), + ("sahara-testnet", "eip155:313313"), + ("scroll-mainnet", "eip155:534352"), + ("scroll-testnet", "eip155:534351"), + ("sei-atlantic", "eip155:1328"), + ("sei-pacific", "eip155:1329"), + ("solana-devnet", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"), + ("solana-mainnet", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"), + ("solana-testnet", "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z"), + ("soneium-mainnet", "eip155:1868"), + ("sonic-mainnet", "eip155:146"), + ("story-aeneid", "eip155:1315"), + ("story-mainnet", "eip155:1514"), + ("tempo-mainnet", "eip155:4217"), + ("tempo-testnet", "eip155:42431"), + ("unichain-mainnet", "eip155:130"), + ("unichain-sepolia", "eip155:1301"), + ("vana-mainnet", "eip155:1480"), + ("vana-moksha", "eip155:14800"), + ("worldchain-mainnet", "eip155:480"), + ("worldchain-sepolia", "eip155:4801"), + ("xdai", "eip155:100"), + ("xlayer-mainnet", "eip155:196"), + ("xlayer-testnet", "eip155:195"), + ("xrplevm-mainnet", "eip155:1440000"), + ("xrplevm-testnet", "eip155:1449000"), + ("zksync-mainnet", "eip155:324"), + ("zksync-sepolia", "eip155:300"), + ("zora-mainnet", "eip155:7777777"), +]; + +/// Resolves a `--pay-network` / config `pay_network` value to CAIP-2. Values +/// containing `:` pass through verbatim (Solana genesis-hash references are +/// case-sensitive, so no normalization is applied to them). +pub(super) fn resolve(input: &str) -> Result { + if input.contains(':') { + return Ok(input.to_string()); + } + let name = input.to_ascii_lowercase(); + match PAY_NETWORKS.binary_search_by_key(&name.as_str(), |(n, _)| n) { + Ok(i) => Ok(PAY_NETWORKS[i].1.to_string()), + Err(_) => Err(CliError::Arg(format!( + "unknown pay network '{input}'. Use a Quicknode network name \ + (e.g. base-sepolia, solana-devnet, tempo-testnet) or a raw \ + CAIP-2 id (e.g. eip155:84532) — any eip155: or \ + solana: is accepted as-is" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_is_sorted_and_unique() { + for pair in PAY_NETWORKS.windows(2) { + assert!( + pair[0].0 < pair[1].0, + "PAY_NETWORKS out of order or duplicated at '{}'", + pair[1].0 + ); + } + } + + #[test] + fn resolves_network_names() { + assert_eq!(resolve("base-sepolia").unwrap(), "eip155:84532"); + assert_eq!(resolve("xdai").unwrap(), "eip155:100"); + assert_eq!(resolve("tempo-testnet").unwrap(), "eip155:42431"); + assert_eq!( + resolve("solana-devnet").unwrap(), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + ); + } + + #[test] + fn name_lookup_is_case_insensitive() { + assert_eq!(resolve("Base-Sepolia").unwrap(), "eip155:84532"); + } + + #[test] + fn caip2_passes_through_verbatim() { + assert_eq!(resolve("eip155:84532").unwrap(), "eip155:84532"); + // Unknown-to-the-table but well-formed ids still work, unchanged. + assert_eq!( + resolve("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1").unwrap(), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + ); + assert_eq!(resolve("eip155:424242").unwrap(), "eip155:424242"); + } + + #[test] + fn unknown_name_errors_with_escape_hatch() { + let err = resolve("morph-hoodi").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("morph-hoodi"), "got: {msg}"); + assert!(msg.contains("eip155:"), "got: {msg}"); + } +} diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index aa43fb7..ae4d214 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -173,11 +173,13 @@ fn resolve_payment_config( .or_else(|| section.pay_network.clone()) .ok_or_else(|| { CliError::Arg( - "no pay network set. Pass --pay-network (e.g. \ - eip155:84532) or set `pay_network` under [rpc.payment]" + "no pay network set. Pass --pay-network (a network \ + name like base-sepolia, or a CAIP-2 id like eip155:84532) \ + or set `pay_network` under [rpc.payment]" .to_string(), ) })?; + let pay_network = super::pay_network::resolve(&pay_network)?; let asset = args .asset @@ -290,15 +292,25 @@ fn checked_key(raw: String, source: &str) -> Result { Ok(trimmed.to_string()) } -/// Maps a paid-call failure. A `Decode` error on the paid lane means the -/// gateway's post-payment response could not be interpreted — the payment may -/// already have settled, so it gets the same never-blindly-retry treatment as -/// `PaymentIndeterminate` (exit 3) instead of rendering as a generic decode -/// failure. Everything else passes through to the normal SDK mapping. +/// Maps a paid-call failure onto the paid exit-code contract: 2 = the gateway +/// refused and nothing settled, 3 = outcome unknown, check the wallet. +/// +/// - `Decode` on the paid lane always means the gateway's post-payment 2xx +/// response could not be interpreted (the SDK classifies pre-payment parse +/// failures as `PaymentUnsupported`/`Config`) — the payment may already +/// have settled, so it gets the same never-blindly-retry treatment as +/// `PaymentIndeterminate` (exit 3). +/// - `PaymentRejected` with a 5xx status is a gateway/settlement failure +/// after the signed payment was submitted — also unknown, exit 3. A 4xx +/// rejection means the gateway refused the credential without settling it +/// and passes through to exit 2. fn map_paid_error(e: SdkError) -> CliError { - match e { + match &e { SdkError::Decode { .. } => CliError::PaymentMaybeCharged(e), - other => other.into(), + SdkError::PaymentRejected { status, .. } if *status >= 500 => { + CliError::PaymentMaybeCharged(e) + } + _ => e.into(), } } @@ -350,6 +362,36 @@ mod tests { assert_eq!(network, "base-sepolia"); } + #[test] + fn pay_network_name_resolves_to_caip2() { + let mut args = paid_args(true); + args.pay_network = Some("base-sepolia".to_string()); + let (cfg, _, _) = + resolve_payment_config(&args, &empty_section(), Some("k".to_string()), None).unwrap(); + assert_eq!(cfg.pay_network, "eip155:84532"); + } + + #[test] + fn config_pay_network_name_resolves_too() { + let mut section = empty_section(); + section.pay_network = Some("solana-devnet".to_string()); + let mut args = paid_args(true); + args.pay_network = None; + let (cfg, _, _) = + resolve_payment_config(&args, §ion, Some("k".to_string()), None).unwrap(); + assert_eq!(cfg.pay_network, "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"); + } + + #[test] + fn unknown_pay_network_name_is_an_arg_error() { + let mut args = paid_args(true); + args.pay_network = Some("btc".to_string()); + let err = resolve_payment_config(&args, &empty_section(), Some("k".to_string()), 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 = paid_args(false); @@ -559,4 +601,22 @@ mod tests { let mapped = map_paid_error(rejected); assert_eq!(crate::errors::exit_code_for(&mapped), 2); } + + #[test] + fn rejected_5xx_maps_to_payment_maybe_charged() { + // A settlement failure after the signed payment was submitted: the + // outcome is unknown, so it must NOT land in the "refused" bucket. + for status in [500, 502, 503] { + let rejected = SdkError::PaymentRejected { + status, + body: "settlement error".to_string(), + }; + let mapped = map_paid_error(rejected); + assert!( + matches!(mapped, CliError::PaymentMaybeCharged(_)), + "status {status} mapped to {mapped:?}" + ); + assert_eq!(crate::errors::exit_code_for(&mapped), 3); + } + } } diff --git a/src/config.rs b/src/config.rs index d9de3b2..414aec5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,7 +7,10 @@ //! 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. (`QN_PAYMENT_KEY`, read by the paid RPC lane, is the one +//! deliberate exception: it never selects an account, so a stale value +//! cannot aim a mutating command at the wrong one — 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` @@ -91,8 +94,9 @@ pub struct PaymentSection { /// Accepts a TOML string or integer. #[serde(default, deserialize_with = "de_opt_string_or_int")] pub max_amount: Option, - /// CAIP-2 id of the chain payments settle on, e.g. `eip155:84532`. - /// Independent of `--network` (the chain the RPC call queries). + /// Chain payments settle on: a Quicknode network name (e.g. + /// `base-sepolia`) or a CAIP-2 id (e.g. `eip155:84532`). Independent of + /// `--network` (the chain the RPC call queries). #[serde(default)] pub pay_network: Option, /// Token to pay with: EVM contract address or Solana mint. diff --git a/src/errors.rs b/src/errors.rs index b8bd29a..d732920 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -66,10 +66,11 @@ pub enum CliError { /// - 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); also a payment the gateway -/// refused or never matched (PaymentRejected / PaymentUnsupported) +/// refused without settling (PaymentRejected 4xx / PaymentUnsupported — +/// the paid lane wraps 5xx rejections into PaymentMaybeCharged first) /// - 3: SdkError::Http (network failure); also an unknown payment outcome -/// (PaymentIndeterminate / PaymentMaybeCharged — request sent, response -/// lost, the caller may have been charged) +/// (PaymentIndeterminate / PaymentMaybeCharged — the payment was +/// submitted, the caller may have been charged) /// - 4: NoApiKey / BadConfig /// - 5: user cancelled or needs --yes pub fn exit_code_for(err: &CliError) -> i32 { @@ -109,9 +110,13 @@ pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> Strin ) } CliError::Sdk(SdkError::PaymentRejected { status, body }) => { + // Only 4xx rejections reach this arm from the paid lane (5xx are + // wrapped into PaymentMaybeCharged): the gateway refused the + // credential without settling it. let msg = format!( - "Error: the gateway rejected the submitted payment (HTTP {status}). \ - A signed payment was sent — check your wallet before retrying." + "Error: the gateway refused the payment (HTTP {status}). The signed \ + payment was not accepted, so nothing should have settled; check the \ + wallet balance and --pay-network/--asset/--max-amount, then retry." ); if verbose && !body.is_empty() { format!("{msg}\n{body}") @@ -126,9 +131,9 @@ pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> Strin .to_string() } CliError::PaymentMaybeCharged(source) => { - let msg = "Error: the paid request returned an unexpected response — the \ - request may have been settled; check your wallet before retrying. \ - Do not blindly re-run this command."; + 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 { @@ -525,8 +530,10 @@ mod tests { #[test] fn exit_code_payment_refusals_are_2() { - // The gateway said no and nothing settled (unsupported) or the refusal - // is terminal (rejected): the API-error bucket. + // 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(), }); @@ -560,14 +567,15 @@ mod tests { } #[test] - fn renders_payment_rejected_with_wallet_warning() { + fn renders_payment_rejected_as_refused_without_settling() { let err = CliError::Sdk(SdkError::PaymentRejected { status: 402, body: "invalid signature".to_string(), }); let msg = render(&err, false); assert!(msg.contains("402"), "got: {msg}"); - assert!(msg.contains("check your wallet"), "got: {msg}"); + assert!(msg.contains("refused"), "got: {msg}"); + assert!(msg.contains("nothing should have settled"), "got: {msg}"); assert!(!msg.contains("invalid signature"), "got: {msg}"); let verbose = render(&err, true); assert!(verbose.contains("invalid signature"), "got: {verbose}"); diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index fd7bcf3..fa96126 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -49,16 +49,27 @@ fn x402_accepts_entry(amount: &str) -> serde_json::Value { /// Sequenced gateway responder: the first (unpaid) POST gets a 402 with a /// one-entry menu at `amount`; any request carrying a payment signature gets -/// the JSON-RPC result. +/// the `paid` response (the JSON-RPC result by default). struct X402Seq { amount: &'static str, + paid: ResponseTemplate, calls: AtomicUsize, } impl X402Seq { fn new(amount: &'static str) -> Self { + Self::with_paid_response( + amount, + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + })), + ) + } + + fn with_paid_response(amount: &'static str, paid: ResponseTemplate) -> Self { Self { amount, + paid, calls: AtomicUsize::new(0), } } @@ -74,9 +85,7 @@ impl Respond for X402Seq { "accepts": [ x402_accepts_entry(self.amount) ] })) } else { - ResponseTemplate::new(200).set_body_json(json!({ - "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" - })) + self.paid.clone() } } } @@ -141,6 +150,44 @@ async fn x402_happy_path_pays_and_bypasses_control_plane() { ); } +#[tokio::test] +async fn pay_network_name_matches_caip2_offer() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(2) + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let (_guard, key_path) = key_file(); + + // `base-sepolia` resolves to eip155:84532 before reaching the SDK, so it + // matches the mock gateway's CAIP-2 offer exactly. + let out = run_qn( + &server.uri(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--pay-network", + "base-sepolia", + "--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; @@ -361,10 +408,11 @@ async fn paid_lane_is_never_retried() { } #[tokio::test] -async fn payment_rejected_on_paid_resend_exits_2_with_wallet_warning() { +async fn payment_rejected_on_paid_resend_exits_2_as_refused() { let server = MockServer::start().await; - // 402 menu, then the paid resend is refused with another 402: a signed - // payment went out, so the message must say "check your wallet". + // 402 menu, then the paid resend is refused with another 402: the gateway + // refused the credential without settling it — exit 2, "refused", and no + // unknown-outcome language. Mock::given(method("POST")) .and(path("/base-sepolia")) .respond_with(ResponseTemplate::new(402).set_body_json(json!({ @@ -397,6 +445,56 @@ async fn payment_rejected_on_paid_resend_exits_2_with_wallet_warning() { ) .await; assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!(out.stderr.contains("refused"), "stderr={}", out.stderr); + assert!( + out.stderr.contains("nothing should have settled"), + "stderr={}", + out.stderr + ); + assert!( + !out.stderr.contains("may have been settled"), + "a 4xx refusal must not carry unknown-outcome language: {}", + out.stderr + ); +} + +#[tokio::test] +async fn settlement_5xx_on_paid_resend_exits_3_check_wallet() { + let server = MockServer::start().await; + // 402 menu, then the paid resend dies with a 500: the signed payment was + // submitted and the outcome is unknown — exit 3, check-your-wallet. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::with_paid_response( + "1000", + ResponseTemplate::new(500).set_body_string("settlement error"), + )) + .expect(2) + .mount(&server) + .await; + + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--pay-network", + "eip155:84532", + "--asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 3, "stderr={}", out.stderr); assert!( out.stderr.contains("check your wallet"), "stderr={}", @@ -404,6 +502,93 @@ async fn payment_rejected_on_paid_resend_exits_2_with_wallet_warning() { ); } +#[tokio::test] +async fn unparseable_paid_response_exits_3_check_wallet() { + let server = MockServer::start().await; + // The paid resend returns 200 with a body that isn't JSON: the payment + // was submitted (and likely settled) but the result is uninterpretable — + // exit 3, never a generic decode error. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::with_paid_response( + "1000", + ResponseTemplate::new(200).set_body_string("ok?"), + )) + .expect(2) + .mount(&server) + .await; + + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--pay-network", + "eip155:84532", + "--asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 3, "stderr={}", out.stderr); + assert!( + out.stderr.contains("may have been settled"), + "stderr={}", + out.stderr + ); +} + +#[tokio::test] +async fn malformed_challenge_menu_exits_2_nothing_charged() { + let server = MockServer::start().await; + // The 402 challenge body is not JSON. Nothing has been signed or paid, so + // this must exit 2 with "Nothing was charged" — never the exit-3 + // check-your-wallet path — after exactly one request. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(402).set_body_string("menu?")) + .expect(1) + .mount(&server) + .await; + + let (_guard, key_path) = key_file(); + let out = run_qn( + &server.uri(), + &[ + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--pay-network", + "eip155:84532", + "--asset", + USDC, + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!( + out.stderr.contains("Nothing was charged"), + "stderr={}", + out.stderr + ); +} + // ── pre-flight failures: fail fast, zero requests ──────────────────────────── /// Runs a paid invocation expected to die in pre-flight: any request reaching @@ -448,6 +633,31 @@ async fn missing_network_fails_before_any_request() { .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, + "--pay-network", + "not-a-chain", + "--asset", + USDC, + "--max-amount", + "10000", + ], + "unknown pay network 'not-a-chain'", + ) + .await; +} + #[tokio::test] async fn missing_key_fails_before_any_request() { // No flag, no env in-process, and a config dir with no key_file. @@ -665,17 +875,17 @@ async fn receipt_flag_wraps_stdout_on_mpp() { let request = b64url(json!({ "amount": "1000", "currency": "0x20c0000000000000000000000000000000000000", - "recipient": "0xfd24114c3981aba78ae2441991b1bdb89329c556", + "recipient": "0x000000000000000000000000000000000000bEEF", "methodDetails": { "chainId": 42431, "feePayer": true } })); let www = format!( - "Payment id=\"c1\", realm=\"mpp.quicknode.com\", method=\"tempo\", \ + "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-07-13T02:05:10.119Z", + "timestamp": "2026-01-01T00:00:00Z", "reference": "0xdeadbeef" })); From b8323b62b86ee8449543e326deafd872617e7666 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 16 Jul 2026 13:19:41 -0400 Subject: [PATCH 09/32] feat(rpc): qn rpc wallet store for the paid lane Adds `qn rpc wallet generate|list|show|rm` to create and manage dedicated payment wallets locally, so the crypto-micropayment lane no longer requires hand-managing a raw key file. - `generate --chain evm|svm --name ` creates a fresh keypair via the SDK, stores the raw key at 0600 under `/qn/wallets/` (evm also covers MPP/Tempo), and prints the address (plus a QR to fund it on a terminal). - `list` / `show` read a per-wallet `.toml` sidecar (chain, address, created-at) and never open the key file; `show` prints the bare address to stdout and the QR to stderr, so a pipe yields just the address. - `rm` is gated (single --yes; non-TTY without it exits 5) and warns the key is unrecoverable. Keys are stored unencrypted at 0600 (the solana-keygen model) so the keyless, non-interactive paid lane keeps working without a passphrase prompt; treat each managed wallet as a dedicated, minimally-funded hot wallet. Wallet names are restricted to [a-z0-9_-] so they cannot escape the store directory. Reuses config's atomic 0600 writer (now pub(crate)) and adds a keyless Ctx constructor for local-only commands. New dep: qrcode (unicode renderer only, default features off) for the funding QR. 8 integration tests cover the key file, its perms, the sidecar, overwrite refusal, name validation, and rm gating. --- Cargo.lock | 7 + Cargo.toml | 1 + src/commands/rpc/mod.rs | 8 + src/commands/rpc/wallet.rs | 376 +++++++++++++++++++++++++++++++++++++ src/config.rs | 21 ++- src/context.rs | 27 +++ tests/rpc_wallet.rs | 289 ++++++++++++++++++++++++++++ 7 files changed, 726 insertions(+), 3 deletions(-) create mode 100644 src/commands/rpc/wallet.rs create mode 100644 tests/rpc_wallet.rs diff --git a/Cargo.lock b/Cargo.lock index 98fbe70..4753e3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3199,6 +3199,12 @@ dependencies = [ "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" @@ -3219,6 +3225,7 @@ dependencies = [ "humantime", "insta", "predicates", + "qrcode", "quicknode-sdk", "reqwest 0.12.28", "serde", diff --git a/Cargo.toml b/Cargo.toml index 25a9589..426e8ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ 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 } [dev-dependencies] reqwest = { version = "0.12", default-features = false } diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index a6af2f9..ca6506b 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -26,6 +26,7 @@ mod pay_network; mod payment; +mod wallet; use std::io::Read; use std::path::{Path, PathBuf}; @@ -69,6 +70,9 @@ pub enum RpcCmd { /// List the endpoint's available network keys (no RPC call). #[command(visible_alias = "ls")] ListNetworks, + + /// Manage local payment wallets for the paid lane (`--x402`/`--mpp`). + Wallet(wallet::Args), } #[derive(Debug, ClapArgs)] @@ -186,6 +190,10 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { match args.cmd { RpcCmd::Call(call) => run_call(*call, global).await, RpcCmd::ListNetworks => run_list_networks(global).await, + RpcCmd::Wallet(wallet_args) => { + let ctx = Ctx::from_global_keyless(global)?; + wallet::run(wallet_args, ctx).await + } } } diff --git a/src/commands/rpc/wallet.rs b/src/commands/rpc/wallet.rs new file mode 100644 index 0000000..0f9adff --- /dev/null +++ b/src/commands/rpc/wallet.rs @@ -0,0 +1,376 @@ +//! `qn rpc wallet …` — a local store of dedicated payment wallets. +//! +//! Removes the raw-key-file juggling the paid RPC lane otherwise requires: +//! `generate` creates a fresh keypair, stores the raw key at 0600 under +//! `/qn/wallets/`, and prints the address (plus a QR on a +//! TTY) so the wallet can be funded. `qn rpc call --payment-wallet ` +//! then resolves the key by name — the raw key never appears on a command line. +//! +//! Storage layout, per wallet ``: +//! - `` — the raw private key (0600), the exact bytes the paid +//! lane's `read_key_file` expects. +//! - `.toml` — public metadata (chain, address, created-at). Never the +//! key, so `list`/`show` read this and never open the key file. +//! +//! The key is stored **unencrypted** at 0600 (the `solana-keygen` model): the +//! paid lane is keyless and non-interactive, so a passphrase prompt would break +//! it. Treat every managed wallet as a dedicated, minimally-funded hot wallet. + +use std::path::{Path, PathBuf}; + +use clap::{Args as ClapArgs, Subcommand, ValueEnum}; +use comfy_table::Cell; +use quicknode_sdk::{generate_payment_wallet, ChainKind}; +use serde::{Deserialize, Serialize}; + +use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity}; +use crate::context::Ctx; +use crate::errors::CliError; +use crate::output::{new_table, set_header_bold, write_table, Render}; + +#[derive(Debug, ClapArgs)] +#[command(subcommand_required = true, arg_required_else_help = true)] +pub struct Args { + #[command(subcommand)] + pub cmd: WalletCmd, +} + +#[derive(Debug, Subcommand)] +pub enum WalletCmd { + /// Generate a new payment wallet and store it locally. + #[command(after_help = "Examples:\n \ + qn rpc wallet generate --chain evm --name payer\n \ + qn rpc wallet generate --chain svm --name sol-payer")] + Generate(GenerateArgs), + + /// List stored wallets (names, chain, address — never keys). + #[command(visible_alias = "ls")] + List, + + /// Show a wallet's address, with a QR to fund it (on a terminal). + Show(ShowArgs), + + /// Delete a stored wallet. The key is the only copy and cannot be recovered. + Rm(RmArgs), +} + +/// Pay-chain 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 WalletChain { + /// secp256k1 wallet for x402/EVM and MPP/Tempo (`0x…` hex key). + Evm, + /// ed25519 wallet for x402/Solana (base58 key). + Svm, +} + +impl WalletChain { + fn kind(self) -> ChainKind { + match self { + WalletChain::Evm => ChainKind::Evm, + WalletChain::Svm => ChainKind::Svm, + } + } + + fn label(self) -> &'static str { + match self { + WalletChain::Evm => "evm", + WalletChain::Svm => "svm", + } + } +} + +#[derive(Debug, ClapArgs)] +pub struct GenerateArgs { + /// Pay-chain family: `evm` (x402/EVM, also MPP/Tempo) or `svm` (x402/Solana). + #[arg(long)] + pub chain: WalletChain, + + /// Wallet name (a-z, 0-9, `-`, `_`). Becomes the key file name and the + /// `--payment-wallet` handle. + #[arg(long, value_name = "NAME")] + pub name: String, + + /// Overwrite an existing wallet of the same name. + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, ClapArgs)] +pub struct ShowArgs { + /// Wallet name. + #[arg(value_name = "NAME")] + pub name: String, +} + +#[derive(Debug, ClapArgs)] +pub struct RmArgs { + /// Wallet name. + #[arg(value_name = "NAME")] + pub name: String, +} + +pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> { + match args.cmd { + WalletCmd::Generate(a) => generate(a, ctx), + WalletCmd::List => list(ctx), + WalletCmd::Show(a) => show(a, ctx), + WalletCmd::Rm(a) => rm(a, ctx), + } +} + +// ── verbs ──────────────────────────────────────────────────────────────────── + +fn generate(a: GenerateArgs, ctx: Ctx) -> Result<(), CliError> { + let name = validate_name(&a.name)?; + let dir = wallets_dir(&ctx)?; + let key_path = dir.join(&name); + let meta_path = meta_path(&dir, &name); + + if !a.force && (key_path.exists() || meta_path.exists()) { + return Err(CliError::Arg(format!( + "wallet '{name}' already exists. Pass --force to overwrite, or pick another name" + ))); + } + + let wallet = generate_payment_wallet(a.chain.kind())?; + let meta = WalletMeta { + name: name.clone(), + chain: a.chain.label().to_string(), + address: wallet.address.clone(), + created_at_unix: now_unix(), + }; + let raw = wallet.into_key(); + + // Key first (0600, tightens the dir to 0700), then the public sidecar. + crate::config::write_atomic_0600(&key_path, raw.as_bytes(), ".qn-wallet-")?; + write_meta(&meta_path, &meta)?; + + ctx.out + .note(&format!("✓ Generated {} wallet '{name}'", a.chain.label())); + emit_address(&ctx, &meta, /* 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, /* with_qr */ true); + Ok(()) +} + +fn rm(a: RmArgs, ctx: Ctx) -> Result<(), CliError> { + let name = validate_name(&a.name)?; + let dir = wallets_dir(&ctx)?; + let key_path = dir.join(&name); + let meta_path = meta_path(&dir, &name); + if !key_path.exists() && !meta_path.exists() { + return Err(not_found(&name)); + } + + let cfg = ConfirmCfg::new( + ctx.global.yes_count, + ctx.global.no_input, + ctx.out.stdout_is_tty, + ); + let proceed = match decide_without_prompt(Severity::Mild, cfg)? { + true => true, + false => prompt_yes_no(&format!( + "Delete wallet '{name}'? The private key is destroyed and any funds sent to it \ + become unrecoverable." + ))?, + }; + if !proceed { + return Err(CliError::Cancelled); + } + + // Remove the key first: if the sidecar removal somehow fails, we never leave + // an orphaned key on disk. + if key_path.exists() { + std::fs::remove_file(&key_path).map_err(|e| remove_err(&key_path, e))?; + } + if meta_path.exists() { + std::fs::remove_file(&meta_path).map_err(|e| remove_err(&meta_path, e))?; + } + ctx.out.note(&format!("✓ Deleted wallet '{name}'")); + Ok(()) +} + +// ── rendering ────────────────────────────────────────────────────────────── + +/// Prints the address to stdout (the pipeable value) and, on a TTY, a QR code +/// plus a funding hint to stderr (suppressed by `--quiet`/non-TTY, so a piped +/// `qn rpc wallet show` yields just the bare address). A QR must never go to +/// stdout — it would corrupt the pipe. +fn emit_address(ctx: &Ctx, meta: &WalletMeta, with_qr: bool) { + println!("{}", meta.address); + if with_qr && ctx.out.stdout_is_tty && !ctx.out.quiet { + if let Some(qr) = render_qr(&meta.address) { + ctx.out.note(&qr); + } + ctx.out.note(&format!( + "Fund this {} address to use it with 'qn rpc call --payment-wallet {}'.", + meta.chain, meta.name + )); + } +} + +/// Unicode (half-block) QR of `data`, or `None` if it can't be encoded. +fn render_qr(data: &str) -> Option { + use qrcode::render::unicode; + use qrcode::QrCode; + let code = QrCode::new(data.as_bytes()).ok()?; + Some( + code.render::() + .dark_color(unicode::Dense1x2::Light) + .light_color(unicode::Dense1x2::Dark) + .quiet_zone(true) + .build(), + ) +} + +#[derive(Serialize)] +struct WalletsView(Vec); + +impl Render for WalletsView { + fn render_table( + &self, + w: &mut dyn std::io::Write, + ctx: &crate::output::OutputCtx, + ) -> std::io::Result<()> { + if self.0.is_empty() { + writeln!(w, "(no wallets)")?; + return Ok(()); + } + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["NAME", "CHAIN", "ADDRESS"]); + for wl in &self.0 { + t.add_row(vec![ + Cell::new(&wl.name), + Cell::new(&wl.chain), + Cell::new(&wl.address), + ]); + } + write_table(w, &t) + } +} + +// ── metadata sidecar ───────────────────────────────────────────────────────── + +/// Public wallet metadata, stored as `.toml` beside the key. Never holds +/// the key itself, so `list`/`show` never open the 0600 key file. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WalletMeta { + name: String, + chain: 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", "CHAIN", "ADDRESS"]); + t.add_row(vec![ + Cell::new(&self.name), + Cell::new(&self.chain), + Cell::new(&self.address), + ]); + write_table(w, &t) + } +} + +fn write_meta(path: &Path, meta: &WalletMeta) -> Result<(), CliError> { + let text = toml::to_string_pretty(meta).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + // The sidecar is public metadata, but reuse the atomic writer for the same + // temp-file/rename durability (0600 is harmless for a public file). + crate::config::write_atomic_0600(path, text.as_bytes(), ".qn-wallet-meta-") +} + +fn load_meta(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + toml::from_str(&text).ok() +} + +/// Loads every `.toml` sidecar in the wallets directory. Missing dir or +/// unreadable sidecars yield an empty list / are skipped — `list` is best-effort. +fn load_all_meta(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "toml")) + .filter_map(|e| load_meta(&e.path())) + .collect() +} + +// ── paths & validation ───────────────────────────────────────────────────── + +fn wallets_dir(ctx: &Ctx) -> Result { + let config_path = ctx.global.resolve_config_path(); + crate::config::wallets_dir(config_path.as_deref()).ok_or_else(|| { + CliError::Arg("could not resolve a config directory for the wallet store".to_string()) + }) +} + +fn meta_path(dir: &Path, name: &str) -> PathBuf { + dir.join(format!("{name}.toml")) +} + +/// Restricts a wallet name to `[a-z0-9_-]` so it can never escape the wallets +/// directory (no separators, no `..`, no empties). Returns the validated name. +fn validate_name(name: &str) -> Result { + if name.is_empty() { + return Err(CliError::Arg("wallet name cannot be empty".to_string())); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + return Err(CliError::Arg(format!( + "invalid wallet name '{name}'. Use lowercase letters, digits, '-' and '_' only" + ))); + } + Ok(name.to_string()) +} + +fn not_found(name: &str) -> CliError { + CliError::Arg(format!( + "no wallet named '{name}'. Run 'qn rpc wallet list' to see stored wallets" + )) +} + +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 414aec5..f0a46d8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -449,6 +449,16 @@ pub fn networks_cache_path(config_path: Option<&Path>) -> Option { } } +/// The wallet store directory: `wallets/` alongside the resolved config file. +/// Holds one raw-key file per wallet (0600) plus a `.toml` metadata +/// sidecar. The directory is tightened to 0700 on first write. +pub fn wallets_dir(config_path: Option<&Path>) -> Option { + match config_path { + Some(p) => p.parent().map(|d| d.join("wallets")), + None => config_dir().map(|d| d.join("qn").join("wallets")), + } +} + /// 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. @@ -493,9 +503,14 @@ pub fn save_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> { +/// same directory (perms set before the bytes), then `rename`. Also tightens +/// the parent directory to 0700. Shared by the token/networks caches and the +/// wallet store. +pub(crate) fn write_atomic_0600( + path: &Path, + bytes: &[u8], + tmp_prefix: &str, +) -> Result<(), CliError> { let parent = path.parent().ok_or_else(|| CliError::ConfigWrite { path: path.to_path_buf(), source: std::io::Error::other("cache path has no parent directory"), diff --git a/src/context.rs b/src/context.rs index cdf8ea2..94de951 100644 --- a/src/context.rs +++ b/src/context.rs @@ -234,6 +234,33 @@ impl Ctx { Ok(Self { sdk, out, global }) } + /// Keyless construction for local-only commands that make no network calls + /// (e.g. `qn rpc wallet` key management). Resolves no API key and builds no + /// payment lane, so it works on a machine that has never run `qn auth + /// login`. The SDK is present only to satisfy the [`Ctx`] shape; every + /// sub-client would 401 if called. + pub fn from_global_keyless(global: GlobalArgs) -> Result { + let stdout_is_tty = std::io::stdout().is_terminal(); + let (format, wide) = global.resolve_output(stdout_is_tty); + + let mut full = SdkFullConfig::keyless(); + apply_user_agent(&mut full); + + let sdk = QuicknodeSdk::new(&full)?; + let out = OutputCtx::detect_with( + format, + global.no_color, + global.quiet, + global.verbose, + wide, + stdout_is_tty, + std::env::var_os("NO_COLOR"), + std::env::var("TERM").ok(), + ); + + Ok(Self { sdk, out, global }) + } + fn build( global: GlobalArgs, rpc_seed: Option, diff --git a/tests/rpc_wallet.rs b/tests/rpc_wallet.rs new file mode 100644 index 0000000..95270f1 --- /dev/null +++ b/tests/rpc_wallet.rs @@ -0,0 +1,289 @@ +//! Integration tests for `qn rpc wallet …` — the local payment-wallet store. +//! +//! These commands are keyless and make no HTTP calls, so there's no wiremock +//! gateway here. The in-process harness doesn't capture stdout, so assertions +//! go through exit codes and on-disk effects (the key file, its 0600 perms, +//! and the metadata sidecar). `--config-file` points the wallet store at a +//! tempdir so nothing touches the real config. + +mod common; + +use common::run_qn; + +// Any URL works — wallet commands never make a request. The harness requires a +// --base-url value. +const BASE: &str = "http://127.0.0.1:1"; + +fn cfg_in(dir: &std::path::Path) -> String { + dir.join("config.toml").to_str().unwrap().to_string() +} + +fn wallets_dir(dir: &std::path::Path) -> std::path::PathBuf { + dir.join("wallets") +} + +#[tokio::test] +async fn generate_writes_key_and_sidecar_at_0600() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + let out = run_qn( + BASE, + &[ + "--config-file", + &cfg, + "rpc", + "wallet", + "generate", + "--chain", + "evm", + "--name", + "payer", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + let key = wallets_dir(dir.path()).join("payer"); + let meta = wallets_dir(dir.path()).join("payer.toml"); + assert!(key.exists(), "key file missing"); + assert!(meta.exists(), "metadata sidecar missing"); + + // The stored key is a valid 0x-prefixed hex secp256k1 key. + let raw = std::fs::read_to_string(&key).unwrap(); + assert!(raw.trim().starts_with("0x"), "key not 0x-prefixed: {raw}"); + + // The sidecar records the chain + a derived 0x address, never the key. + let meta_text = std::fs::read_to_string(&meta).unwrap(); + assert!(meta_text.contains("chain = \"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, + "rpc", + "wallet", + "generate", + "--chain", + "svm", + "--name", + "sol", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + let raw = std::fs::read_to_string(wallets_dir(dir.path()).join("sol")).unwrap(); + // base58 key: not 0x-prefixed hex, and non-trivially long (64-byte base58 is + // ~88 chars). The SDK's round-trip unit tests cover exact decoding. + assert!(!raw.trim().starts_with("0x")); + assert!( + raw.trim().len() > 64, + "svm key too short: {}", + raw.trim().len() + ); + + // The address in the sidecar is the base58 pubkey, not a 0x address. + let meta = std::fs::read_to_string(wallets_dir(dir.path()).join("sol.toml")).unwrap(); + assert!(meta.contains("chain = \"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, + "rpc", + "wallet", + "generate", + "--chain", + "evm", + "--name", + "dup", + ]; + assert_eq!(run_qn(BASE, args).await.exit_code, 0); + + // Second generate without --force must fail and leave the key untouched. + let key = wallets_dir(dir.path()).join("dup"); + let before = std::fs::read_to_string(&key).unwrap(); + let out = run_qn(BASE, args).await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("already exists"), + "stderr={}", + out.stderr + ); + let after = std::fs::read_to_string(&key).unwrap(); + assert_eq!(before, after, "key was overwritten without --force"); +} + +#[tokio::test] +async fn generate_rejects_unsafe_name() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + let out = run_qn( + BASE, + &[ + "--config-file", + &cfg, + "rpc", + "wallet", + "generate", + "--chain", + "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, + "rpc", + "wallet", + "generate", + "--chain", + "evm", + "--name", + "keep", + ], + ) + .await + .exit_code, + 0 + ); + + // Non-TTY without --yes: exit 5, and both files remain. + let out = run_qn( + BASE, + &["--config-file", &cfg, "rpc", "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, + "rpc", + "wallet", + "generate", + "--chain", + "evm", + "--name", + "gone", + ], + ) + .await + .exit_code, + 0 + ); + + let out = run_qn( + BASE, + &[ + "--config-file", + &cfg, + "rpc", + "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, + "rpc", + "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, "rpc", "wallet", "show", "ghost"], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("no wallet named"), + "stderr={}", + out.stderr + ); +} From 71f699c30a6fe5bd04b3cc7f3474183b4db1b6ce Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 16 Jul 2026 13:25:57 -0400 Subject: [PATCH 10/32] feat(rpc): --payment-wallet source; drop env-var payment key Adds `--payment-wallet ` to `qn rpc call`, resolving a stored wallet (from `qn rpc wallet generate`) to its key file, and a matching `wallet` key under [rpc.payment] in config. New key-source precedence: --payment-key-file > --payment-wallet > config key_file > config wallet. Removes the QN_PAYMENT_KEY environment variable entirely: the payment key now comes only from a file or a stored wallet, never an env var. An exported key is invisible state that outlives its session and leaks into process listings, shell history, and CI logs; a file (0600) or managed wallet is the safer source. The resolver validates the wallet name so it cannot escape the store directory. Updates the payment-key doc/comments and the resolver unit tests, rewrites the subprocess integration helper to pass the key via a file, and adds an end-to-end test that generates a wallet then pays a call with --payment-wallet. (Agent guide + README still mention the old env var; they are updated in the docs-sync commit.) --- src/commands/rpc/mod.rs | 16 ++- src/commands/rpc/payment.rs | 235 +++++++++++++++++++++++++----------- src/config.rs | 12 +- tests/rpc_payment.rs | 72 ++++++++++- 4 files changed, 252 insertions(+), 83 deletions(-) diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index ca6506b..3fc39dc 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -126,16 +126,28 @@ pub struct CallArgs { /// 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 > QN_PAYMENT_KEY env var (raw key) > `key_file` - /// under [rpc.payment] in config. + /// 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 rpc 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. diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index ae4d214..d258d06 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -10,10 +10,11 @@ //! post-payment body) means the caller may already have been charged. //! //! Everything the lane needs is resolved before any network I/O: the private -//! key (flag file/stdin > `QN_PAYMENT_KEY` env > `key_file` in config — never -//! a raw key on the command line or inline in config), the spend ceiling, the -//! pay network, and the asset. The key lives only inside the SDK's -//! `PaymentConfig` (which redacts it in Debug) and is never logged or echoed. +//! key (flag file/stdin > `--payment-wallet` > `key_file` > `wallet` in config +//! — always from a file, never an env var or a raw key on the command line or +//! inline in config), the spend ceiling, the pay network, and the asset. The +//! key lives only inside the SDK's `PaymentConfig` (which redacts it in Debug) +//! and is never logged or echoed. use std::path::Path; @@ -45,12 +46,15 @@ pub(super) async fn run_paid_call(args: CallArgs, global: GlobalArgs) -> Result< // relies on [rpc.payment] values we could not read. let section = load_payment_section(&global)?; - // The single documented env read, injected into the resolver so it stays - // deterministic under test (no process-env mutation). - let env_key = std::env::var("QN_PAYMENT_KEY").ok(); + // The wallet store directory backs `--payment-wallet` / config `wallet`. + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); - let (payment, network, key_file_warning) = - resolve_payment_config(&args, §ion, env_key, global.base_url.clone())?; + 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())?; @@ -114,7 +118,7 @@ fn load_payment_section(global: &GlobalArgs) -> Result fn resolve_payment_config( args: &CallArgs, section: &PaymentSection, - env_key: Option, + wallets_dir: Option<&Path>, base_url_override: Option, ) -> Result<(PaymentConfig, String, Option), CliError> { // Scheme comes from which flag is set; clap's ArgGroup guarantees at most @@ -143,8 +147,10 @@ fn resolve_payment_config( let (key, key_file_warning) = resolve_key( args.payment_key_file.as_deref(), - env_key, + args.payment_wallet.as_deref(), section.key_file.as_deref(), + section.wallet.as_deref(), + wallets_dir, )?; let max_amount = args @@ -218,15 +224,18 @@ fn resolve_payment_config( )) } -/// Resolves the raw private key: `--payment-key-file` (a path, or `-` for -/// stdin) > env `QN_PAYMENT_KEY` > config `key_file` path. The raw key is -/// never a flag value. Returns the key and an optional permissions warning -/// (group/world-readable key file). Error messages name the path, never the -/// file contents. +/// Resolves the raw private key from a file only — never an env var and never +/// a raw key on the command line. Precedence: `--payment-key-file` (a path, or +/// `-` for stdin) > `--payment-wallet` (a stored wallet name) > config +/// `key_file` > config `wallet`. Returns the key and an optional permissions +/// warning (group/world-readable key file). Error messages name the path or +/// wallet, never the file contents. fn resolve_key( flag_file: Option<&Path>, - env_key: Option, + 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() == "-" { @@ -235,19 +244,48 @@ fn resolve_key( } return read_key_file(path); } - if let Some(key) = env_key { - return checked_key(key, "QN_PAYMENT_KEY").map(|k| (k, None)); + if let Some(name) = flag_wallet { + return read_key_file(&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(&wallet_key_path(name, wallets_dir)?); + } Err(CliError::Arg( "no payment key found. Pass --payment-key-file (or '-' for \ - stdin), set QN_PAYMENT_KEY, or set `key_file` under [rpc.payment]" + stdin), --payment-wallet (from 'qn rpc wallet generate'), or \ + set `key_file`/`wallet` under [rpc.payment]" .to_string(), )) } +/// Resolves a stored wallet name to its key file path, validating the name and +/// checking the file exists. Mirrors the `wallet` module's name rules so the +/// name can never escape the store directory. +fn wallet_key_path(name: &str, wallets_dir: Option<&Path>) -> Result { + if name.is_empty() + || !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" + ))); + } + 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(CliError::Arg(format!( + "no wallet named '{name}'. Run 'qn rpc wallet list' to see stored wallets, \ + or create one with 'qn rpc wallet generate'" + ))); + } + Ok(path) +} + /// Reads and validates a key file, plus an ssh-style permissions warning when /// the file is group- or world-readable. fn read_key_file(path: &Path) -> Result<(String, Option), CliError> { @@ -328,6 +366,7 @@ mod tests { x402, mpp: !x402, payment_key_file: None, + payment_wallet: None, max_amount: Some("10000".to_string()), pay_network: Some("eip155:84532".to_string()), asset: Some("0xabc".to_string()), @@ -348,14 +387,23 @@ mod tests { f } + /// `paid_args` with a valid key file attached, so a test can reach the + /// parameter-validation assertions without caring about the key source. + /// Returns the (args, key-file guard) pair — keep the guard alive. + fn paid_args_with_key(x402: bool) -> (CallArgs, tempfile::NamedTempFile) { + let f = key_file_with("0xkey\n"); + let mut args = paid_args(x402); + args.payment_key_file = Some(f.path().to_path_buf()); + (args, f) + } + #[test] - fn resolves_full_config_from_flags_and_env_key() { - let args = paid_args(true); + 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(), Some("0xkey".to_string()), None) - .unwrap(); + resolve_payment_config(&args, &empty_section(), None, None).unwrap(); assert_eq!(cfg.scheme, "x402"); - assert_eq!(cfg.key, "0xkey"); + assert_eq!(cfg.key, "0xkey"); // trimmed assert_eq!(cfg.pay_network, "eip155:84532"); assert_eq!(cfg.asset, "0xabc"); assert_eq!(cfg.max_amount, "10000"); @@ -364,10 +412,9 @@ mod tests { #[test] fn pay_network_name_resolves_to_caip2() { - let mut args = paid_args(true); + let (mut args, _f) = paid_args_with_key(true); args.pay_network = Some("base-sepolia".to_string()); - let (cfg, _, _) = - resolve_payment_config(&args, &empty_section(), Some("k".to_string()), None).unwrap(); + let (cfg, _, _) = resolve_payment_config(&args, &empty_section(), None, None).unwrap(); assert_eq!(cfg.pay_network, "eip155:84532"); } @@ -375,72 +422,117 @@ mod tests { fn config_pay_network_name_resolves_too() { let mut section = empty_section(); section.pay_network = Some("solana-devnet".to_string()); - let mut args = paid_args(true); + let (mut args, _f) = paid_args_with_key(true); args.pay_network = None; - let (cfg, _, _) = - resolve_payment_config(&args, §ion, Some("k".to_string()), None).unwrap(); + 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 = paid_args(true); + let (mut args, _f) = paid_args_with_key(true); args.pay_network = Some("btc".to_string()); - let err = resolve_payment_config(&args, &empty_section(), Some("k".to_string()), None) - .unwrap_err(); + 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 = paid_args(false); - let (cfg, _, _) = - resolve_payment_config(&args, &empty_section(), Some("k".to_string()), None).unwrap(); + 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 = paid_args(true); + let (mut args, _f) = paid_args_with_key(true); args.network = None; - let err = resolve_payment_config(&args, &empty_section(), Some("k".to_string()), None) - .unwrap_err(); + 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("--pay-network"), "got: {msg}"); } #[test] - fn flag_key_file_beats_env_key() { - let f = key_file_with("0xfromfile\n"); + 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(f.path().to_path_buf()); - let (cfg, _, _) = - resolve_payment_config(&args, &empty_section(), Some("0xfromenv".to_string()), None) - .unwrap(); - assert_eq!(cfg.key, "0xfromfile"); // and trimmed + args.payment_key_file = Some(flag.path().to_path_buf()); + let (cfg, _, _) = resolve_payment_config(&args, §ion, None, None).unwrap(); + assert_eq!(cfg.key, "0xfromflag"); // and trimmed } #[test] - fn env_key_beats_config_key_file() { + 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, §ion, Some("0xfromenv".to_string()), None).unwrap(); - assert_eq!(cfg.key, "0xfromenv"); + resolve_payment_config(&args, &empty_section(), Some(dir.path()), None).unwrap(); + assert_eq!(cfg.key, "0xfromwallet"); } #[test] - fn config_key_file_used_when_nothing_else() { - let f = key_file_with("0xfromconfig"); + 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.key_file = Some(f.path().to_path_buf()); + section.wallet = Some("saved".to_string()); let args = paid_args(true); - let (cfg, _, _) = resolve_payment_config(&args, §ion, None, None).unwrap(); - assert_eq!(cfg.key, "0xfromconfig"); + 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] @@ -449,8 +541,10 @@ mod tests { 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("QN_PAYMENT_KEY"), "got: {msg}"); + assert!(msg.contains("--payment-wallet"), "got: {msg}"); assert!(msg.contains("key_file"), "got: {msg}"); + // The env var is gone; the message must not resurrect it. + assert!(!msg.contains("QN_PAYMENT_KEY"), "got: {msg}"); } #[test] @@ -474,8 +568,8 @@ mod tests { 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 = paid_args(true); - let err = resolve_payment_config(&args, §ion, Some("k".to_string()), None).unwrap_err(); + 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}"); @@ -483,20 +577,18 @@ mod tests { #[test] fn missing_max_amount_is_actionable() { - let mut args = paid_args(true); + let (mut args, _f) = paid_args_with_key(true); args.max_amount = None; - let err = resolve_payment_config(&args, &empty_section(), Some("k".to_string()), None) - .unwrap_err(); + 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 = paid_args(true); + let (mut args, _f) = paid_args_with_key(true); args.max_amount = Some(bad.to_string()); - let err = resolve_payment_config(&args, &empty_section(), Some("k".to_string()), None) - .unwrap_err(); + let err = resolve_payment_config(&args, &empty_section(), None, None).unwrap_err(); assert!(err.to_string().contains("base units"), "for {bad}: {err}"); } } @@ -505,9 +597,8 @@ mod tests { fn flag_max_amount_beats_config() { let mut section = empty_section(); section.max_amount = Some("999999".to_string()); - let args = paid_args(true); // flag says 10000 - let (cfg, _, _) = - resolve_payment_config(&args, §ion, Some("k".to_string()), None).unwrap(); + let (args, _f) = paid_args_with_key(true); // flag says 10000 + let (cfg, _, _) = resolve_payment_config(&args, §ion, None, None).unwrap(); assert_eq!(cfg.max_amount, "10000"); } @@ -516,6 +607,7 @@ mod tests { 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()), pay_network: Some("eip155:42431".to_string()), @@ -535,11 +627,11 @@ mod tests { #[test] fn base_url_override_is_threaded_through() { - let args = paid_args(true); + let (args, _f) = paid_args_with_key(true); let (cfg, _, _) = resolve_payment_config( &args, &empty_section(), - Some("k".to_string()), + None, Some("http://127.0.0.1:9999".to_string()), ) .unwrap(); @@ -551,10 +643,9 @@ mod tests { #[test] fn invalid_svm_rpc_url_is_rejected() { - let mut args = paid_args(true); + 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(), Some("k".to_string()), None) - .unwrap_err(); + let err = resolve_payment_config(&args, &empty_section(), None, None).unwrap_err(); assert!(err.to_string().contains("scheme"), "got: {err}"); } diff --git a/src/config.rs b/src/config.rs index f0a46d8..279035c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,10 +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. (`QN_PAYMENT_KEY`, read by the paid RPC lane, is the one -//! deliberate exception: it never selects an account, so a stale value -//! cannot aim a mutating command at the wrong one — see -//! `commands::rpc::payment`.) +//! 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` @@ -85,6 +84,11 @@ pub struct PaymentSection { /// Solana base58). Never the key itself. #[serde(default)] pub key_file: Option, + /// Name of a stored wallet (from `qn rpc wallet generate`) to pay with, as + /// an alternative to `key_file`. Resolved to its key file under the wallet + /// store. Never the key itself. + #[serde(default)] + pub wallet: Option, /// Trap field: an inline raw key is rejected at payment-resolution time /// with an error pointing at `key_file`. Without this field serde would /// silently ignore `key = "..."` and the user would think it took effect. diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index fa96126..56425c9 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -150,6 +150,65 @@ async fn x402_happy_path_pays_and_bypasses_control_plane() { ); } +#[tokio::test] +async fn payment_wallet_resolves_stored_key_for_paid_call() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(2) // one unpaid probe + one paid resend + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + + // Generate a wallet, then pay for a call by referencing it by name. The + // wallet's key is random but a valid secp256k1 key, so the x402 EIP-712 + // signing succeeds and the resend carries a payment signature. + let gen = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "wallet", + "generate", + "--chain", + "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", + "--pay-network", + "eip155:84532", + "--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; @@ -660,7 +719,8 @@ async fn unknown_pay_network_name_fails_before_any_request() { #[tokio::test] async fn missing_key_fails_before_any_request() { - // No flag, no env in-process, and a config dir with no key_file. + // No flag and a config dir with no key_file/wallet: the key must come from + // a file or a stored wallet, so this fails fast with actionable guidance. let dir = tempfile::tempdir().unwrap(); let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); expect_preflight_error( @@ -680,7 +740,7 @@ async fn missing_key_fails_before_any_request() { "--max-amount", "10000", ], - "QN_PAYMENT_KEY", + "--payment-key-file", ) .await; } @@ -844,20 +904,22 @@ fn payment_conflicts_with_endpoint_url() { // ── output shapes (subprocess: the in-process harness can't capture stdout) ── /// Runs the real binary against `server`, with HOME pointed at a tempdir so no -/// real config leaks in. The key is passed via QN_PAYMENT_KEY to also cover -/// env resolution (a subprocess env can't race parallel tests). +/// real config leaks in. The payment key is written to a file under `home` and +/// passed via `--payment-key-file` (the key never comes from the environment). fn run_qn_subprocess( server_uri: &str, home: &std::path::Path, args: &[&str], ) -> std::process::Output { + let key_path = home.join("payer.key"); + std::fs::write(&key_path, EVM_KEY).unwrap(); assert_cmd::Command::cargo_bin("qn") .unwrap() .env_remove("HOME") .env("HOME", home) - .env("QN_PAYMENT_KEY", EVM_KEY) .args(["--base-url", server_uri, "--no-input"]) .args(args) + .args(["--payment-key-file", key_path.to_str().unwrap()]) .output() .unwrap() } From 0b024fca758b19ce39717b09a360d4708ed0a798 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 16 Jul 2026 13:33:42 -0400 Subject: [PATCH 11/32] feat(rpc): qn rpc pay-networks discovery list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `qn rpc pay-networks` (alias `pay-nets`): a keyless list of the networks payable via the crypto-micropayment lane, fetched from the gateways' public discovery endpoints (x402 + MPP `/networks`, enriched with the x402 asset from `/discovery/resources`). A slug in the list is a valid `--network` for a paid call; the asset column is a ready `--asset` value. Fetched directly with reqwest, not through the SDK — those hosts are the payment gateways, not the account API. Results are cached in `pay-networks.toml` next to the config with a 24h TTL, mirroring the multichain URL cache; a `--base-url` override targets one host and bypasses the cache (used by tests). Asset enrichment is best-effort: a discovery fetch/parse failure leaves the column blank rather than failing the command. Adds a reverse CAIP-2 -> slug lookup to the pay-network table and promotes reqwest to a direct dependency (json + rustls-tls) for the fetch. 4 integration tests cover the merged/enriched render, the alias, and a fetch failure. --- Cargo.lock | 17 +++ Cargo.toml | 7 + src/commands/rpc/mod.rs | 7 + src/commands/rpc/pay_network.rs | 11 ++ src/commands/rpc/pay_networks.rs | 211 +++++++++++++++++++++++++++++++ src/config.rs | 76 +++++++++++ tests/rpc_pay_networks.rs | 120 ++++++++++++++++++ 7 files changed, 449 insertions(+) create mode 100644 src/commands/rpc/pay_networks.rs create mode 100644 tests/rpc_pay_networks.rs diff --git a/Cargo.lock b/Cargo.lock index 4753e3d..e99a0ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2269,6 +2269,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -3507,16 +3508,21 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -3524,6 +3530,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -3719,6 +3726,7 @@ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -5037,6 +5045,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 426e8ae..ee7bb07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,13 @@ url = "2" # 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/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index 3fc39dc..7378c73 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -25,6 +25,7 @@ //! machinery runs. mod pay_network; +mod pay_networks; mod payment; mod wallet; @@ -73,6 +74,11 @@ pub enum RpcCmd { /// Manage local payment wallets for the paid lane (`--x402`/`--mpp`). Wallet(wallet::Args), + + /// List the networks payable via the paid lane (`--x402`/`--mpp`), from the + /// gateways' public discovery endpoints. No API key required. + #[command(visible_alias = "pay-nets")] + PayNetworks, } #[derive(Debug, ClapArgs)] @@ -206,6 +212,7 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { let ctx = Ctx::from_global_keyless(global)?; wallet::run(wallet_args, ctx).await } + RpcCmd::PayNetworks => pay_networks::run(global).await, } } diff --git a/src/commands/rpc/pay_network.rs b/src/commands/rpc/pay_network.rs index 48067c7..8291e96 100644 --- a/src/commands/rpc/pay_network.rs +++ b/src/commands/rpc/pay_network.rs @@ -135,6 +135,17 @@ pub(super) fn resolve(input: &str) -> Result { } } +/// Reverse lookup: a CAIP-2 id → the first Quicknode network name mapped to it, +/// if any. Used to attach discovery-catalog assets (keyed by CAIP-2) to the +/// slug rows in `qn rpc pay-networks`. Linear scan — the table is small and +/// sorted by name, not id. +pub(super) fn slug_for_caip2(caip2: &str) -> Option { + PAY_NETWORKS + .iter() + .find(|(_, id)| *id == caip2) + .map(|(name, _)| name.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/commands/rpc/pay_networks.rs b/src/commands/rpc/pay_networks.rs new file mode 100644 index 0000000..2c3caf3 --- /dev/null +++ b/src/commands/rpc/pay_networks.rs @@ -0,0 +1,211 @@ +//! `qn rpc pay-networks` — list the networks payable via the crypto +//! micropayment lane (`--x402`/`--mpp`). +//! +//! Keyless and public: this fetches the gateways' own discovery endpoints +//! (`x402.quicknode.com/networks` + `/discovery/resources`, and +//! `mpp.quicknode.com/networks`) directly, not through the SDK — those hosts +//! are the payment gateways, not the account API. Results are cached in +//! `pay-networks.toml` next to the config with a 24h TTL, mirroring the +//! multichain URL cache. A `--network` value from this list is a valid +//! `--network` slug for a paid call; the x402 `asset` column (when shown) is a +//! ready value for `--asset`. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use crate::config::{self, PayNetworkEntry}; +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"; + +pub(super) async fn run(global: GlobalArgs) -> Result<(), CliError> { + // A `--base-url` override points both gateway fetches at one host (used by + // tests to serve the discovery endpoints from a mock). It also bypasses the + // cache, since a test host's data isn't the real catalog. + 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()); + + // Fresh cache hit? (Skipped when a --base-url override is active.) + let cached = if base_override.is_none() { + cache_path + .as_deref() + .and_then(|p| config::load_pay_networks(p, now_unix())) + } else { + None + }; + + let networks = match cached { + Some(n) => n, + None => { + let (x402_base, mpp_base) = match &base_override { + Some(b) => (b.as_str(), b.as_str()), + None => (X402_BASE, MPP_BASE), + }; + let fetched = fetch_pay_networks(x402_base, mpp_base).await?; + if base_override.is_none() { + if let Some(p) = cache_path.as_deref() { + let _ = config::save_pay_networks(p, now_unix(), &fetched); + } + } + fetched + } + }; + + crate::output::emit(&ctx.out, &PayNetworksView(networks)) +} + +/// Fetches and merges the payable networks from both gateways. x402 networks +/// are enriched with the asset from the Bazaar discovery catalog when present. +async fn fetch_pay_networks( + x402_base: &str, + mpp_base: &str, +) -> Result, CliError> { + let client = reqwest::Client::new(); + + // BTreeMap keeps the merged list sorted by slug for a stable render. + let mut merged: BTreeMap = BTreeMap::new(); + + let x402 = fetch_networks(&client, x402_base).await?; + for net in x402 { + merged.insert( + net.clone(), + PayNetworkEntry { + network: net, + schemes: vec!["x402".to_string()], + asset: None, + }, + ); + } + + let mpp = fetch_networks(&client, mpp_base).await?; + for net in mpp { + merged + .entry(net.clone()) + .and_modify(|e| e.schemes.push("mpp".to_string())) + .or_insert(PayNetworkEntry { + network: net, + schemes: vec!["mpp".to_string()], + asset: None, + }); + } + + // Enrich x402 networks with the asset from the discovery catalog. This is + // best-effort: a fetch/parse failure leaves the asset column blank rather + // than failing the whole command. + if let Ok(assets) = fetch_x402_assets(&client, x402_base).await { + for (caip2_asset, slug) in assets { + if let Some(entry) = merged.get_mut(&slug) { + entry.asset = Some(caip2_asset); + } + } + } + + Ok(merged.into_values().collect()) +} + +/// GET `{base}/networks` → the `networks` slug array. +async fn fetch_networks(client: &reqwest::Client, base: &str) -> Result, CliError> { + #[derive(Deserialize)] + struct NetworksResp { + networks: Vec, + } + let url = format!("{base}/networks"); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| fetch_err(&url, e))?; + if !resp.status().is_success() { + return Err(CliError::Arg(format!( + "discovery request to {url} failed with HTTP {}", + resp.status().as_u16() + ))); + } + let body: NetworksResp = resp.json().await.map_err(|e| fetch_err(&url, e))?; + Ok(body.networks) +} + +/// GET x402 `/discovery/resources` → a map of Quicknode-slug → asset address, +/// derived from the accepted payment offers. Only EVM (`eip155:`) assets carry +/// a stable slug mapping here; the caller treats this as best-effort. +async fn fetch_x402_assets( + client: &reqwest::Client, + x402_base: &str, +) -> Result, CliError> { + #[derive(Deserialize)] + struct Resources { + items: Vec, + } + #[derive(Deserialize)] + struct ResourceItem { + #[serde(default)] + accepts: Vec, + } + #[derive(Deserialize)] + struct Accept { + network: String, + #[serde(default)] + asset: Option, + } + + let url = format!("{x402_base}/discovery/resources"); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| fetch_err(&url, e))?; + let body: Resources = resp.json().await.map_err(|e| fetch_err(&url, e))?; + + // Map the CAIP-2 network in each offer back to a Quicknode slug via the + // static pay_network table, so the asset lands on the right row. + let mut out = Vec::new(); + for item in body.items { + for accept in item.accepts { + if let (Some(asset), Some(slug)) = ( + accept.asset, + super::pay_network::slug_for_caip2(&accept.network), + ) { + out.push((asset, slug)); + } + } + } + Ok(out) +} + +#[derive(serde::Serialize)] +struct PayNetworksView(Vec); + +impl Render for PayNetworksView { + fn render_table(&self, w: &mut dyn std::io::Write, ctx: &OutputCtx) -> std::io::Result<()> { + if self.0.is_empty() { + writeln!(w, "(no payable networks)")?; + return Ok(()); + } + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["NETWORK", "SCHEMES", "X402 ASSET"]); + for n in &self.0 { + t.add_row(vec![ + comfy_table::Cell::new(&n.network), + comfy_table::Cell::new(n.schemes.join(", ")), + crate::output::opt_cell(&n.asset), + ]); + } + write_table(w, &t) + } +} + +fn fetch_err(url: &str, e: reqwest::Error) -> CliError { + CliError::Arg(format!("could not fetch payable networks 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) +} diff --git a/src/config.rs b/src/config.rs index 279035c..23e16ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -506,6 +506,82 @@ pub fn save_networks( write_atomic_0600(path, text.as_bytes(), ".qn-networks-") } +// ── Payable-networks discovery cache ───────────────────────────────────────── +// +// The set of networks payable via the x402/MPP gateways (with the x402 asset, +// when discoverable) is stable public metadata fetched from the gateways' +// discovery endpoints. Cached in `pay-networks.toml` next to the config file +// with a 24-hour TTL, mirroring the multichain URL cache. Not account-scoped +// (the data is public and the same for everyone). + +/// Seconds the cached payable-networks list is considered fresh (24h). +pub const PAY_NETWORKS_TTL_SECS: i64 = 24 * 60 * 60; + +/// One payable network: its Quicknode slug, the payment schemes that accept it, +/// and the x402 asset/amount when the discovery catalog lists them. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PayNetworkEntry { + pub network: String, + /// Schemes offering this network, e.g. `["x402", "mpp"]`. + pub schemes: Vec, + /// x402 asset (token contract/mint), when the discovery catalog lists one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct PayNetworksCacheFile { + #[serde(default)] + pub entry: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PayNetworksCacheEntry { + /// Unix seconds the list was fetched, for the TTL check. + pub fetched_at_unix: i64, + pub networks: Vec, +} + +/// The payable-networks 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")), + } +} + +/// Loads the cached payable-networks list from `path`, if present and fetched +/// within the TTL (relative to `now_unix`). Returns `None` on any miss. +pub fn load_pay_networks(path: &Path, now_unix: i64) -> Option> { + let text = fs::read_to_string(path).ok()?; + let cache: PayNetworksCacheFile = toml::from_str(&text).ok()?; + let entry = cache.entry?; + if now_unix.saturating_sub(entry.fetched_at_unix) >= PAY_NETWORKS_TTL_SECS { + return None; + } + Some(entry.networks) +} + +/// Saves the payable-networks list to `path` atomically, stamping +/// `fetched_at_unix` for the TTL check. +pub fn save_pay_networks( + path: &Path, + fetched_at_unix: i64, + networks: &[PayNetworkEntry], +) -> Result<(), CliError> { + let cache = PayNetworksCacheFile { + entry: Some(PayNetworksCacheEntry { + fetched_at_unix, + networks: networks.to_vec(), + }), + }; + 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-") +} + /// Atomically writes `bytes` to `path` with 0600 perms via a temp file in the /// same directory (perms set before the bytes), then `rename`. Also tightens /// the parent directory to 0700. Shared by the token/networks caches and the diff --git a/tests/rpc_pay_networks.rs b/tests/rpc_pay_networks.rs new file mode 100644 index 0000000..35779fc --- /dev/null +++ b/tests/rpc_pay_networks.rs @@ -0,0 +1,120 @@ +//! Integration tests for `qn rpc pay-networks` — the keyless payable-networks +//! discovery list. +//! +//! `--base-url` points both gateway fetches (`/networks` and +//! `/discovery/resources`) at one wiremock host and bypasses the on-disk +//! cache, so the mock server stands in for the x402 + MPP gateways. The +//! in-process harness can't capture stdout, so the merge/asset-mapping render +//! is asserted via a subprocess; the in-process test covers exit codes and the +//! request shape. + +mod common; + +use common::run_qn; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Mounts the two discovery endpoints the command reads. +async fn mount_discovery(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; + Mock::given(method("GET")) + .and(path("/discovery/resources")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "x402Version": 2, + "items": [ + { + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + } + ] + } + ] + }))) + .mount(server) + .await; +} + +#[tokio::test] +async fn pay_networks_fetches_and_exits_zero() { + let server = MockServer::start().await; + mount_discovery(&server).await; + + let out = run_qn(&server.uri(), &["rpc", "pay-networks"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn pay_networks_alias_works() { + let server = MockServer::start().await; + mount_discovery(&server).await; + + let out = run_qn(&server.uri(), &["rpc", "pay-nets"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn pay_networks_surfaces_fetch_failure() { + let server = MockServer::start().await; + // /networks returns 500 — the command should fail with an actionable error. + Mock::given(method("GET")) + .and(path("/networks")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let out = run_qn(&server.uri(), &["rpc", "pay-networks"]).await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("payable networks") || out.stderr.contains("discovery"), + "stderr={}", + out.stderr + ); +} + +// Subprocess: assert the merged/enriched table content (stdout). +#[tokio::test] +async fn pay_networks_renders_merged_table() { + let server = MockServer::start().await; + mount_discovery(&server).await; + + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .args([ + "--base-url", + &server.uri(), + "--no-input", + "--no-color", + "--format", + "table", + "rpc", + "pay-networks", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + + // All three slugs present, sorted; base-sepolia carries the x402 asset from + // the discovery catalog. + assert!(stdout.contains("base-sepolia"), "{stdout}"); + assert!(stdout.contains("ethereum-mainnet"), "{stdout}"); + assert!(stdout.contains("solana-devnet"), "{stdout}"); + assert!( + stdout.contains("0x036CbD53842c5426634e7929541eC2318f3dCF7e"), + "asset not mapped onto base-sepolia row: {stdout}" + ); +} From 12c272431fca067ebd6a56de1e4c66a1af599b0e Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 16 Jul 2026 13:40:58 -0400 Subject: [PATCH 12/32] docs(rpc): sync agent guide + README for wallets and pay-networks Documents `qn rpc wallet` (generate/list/show/rm), `qn rpc pay-networks`, and the `--payment-wallet` key source across the embedded agent guide (context.md) and the README, and removes the old QN_PAYMENT_KEY references now that the payment key comes only from a file or a stored wallet. Updates the `rpc call` after-help example to lead with --payment-wallet, and adds a pay-networks table snapshot (merged schemes + the x402 asset mapped onto its network row). --- README.md | 43 ++++++++++++++-- src/commands/agent/context.md | 41 +++++++++++---- src/commands/rpc/mod.rs | 7 ++- ...tworks_table_merges_schemes_and_asset.snap | 8 +++ tests/table_snapshots.rs | 51 +++++++++++++++++++ 5 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 tests/snapshots/table_snapshots__pay_networks_table_merges_schemes_and_asset.snap diff --git a/README.md b/README.md index 2d4d2be..e14b2c7 100644 --- a/README.md +++ b/README.md @@ -334,9 +334,15 @@ request instead of using an account API key — no login and no Tooling Access needed. **This moves real funds** (testnet tokens are still real transfers): use a dedicated, minimally funded wallet. +The quickest path: see what's payable, generate a wallet, fund it, then pay by +name. + ```sh +qn rpc pay-networks # payable networks + the x402 asset per network +qn rpc wallet generate --chain evm --name payer # new wallet; prints its address + a QR to fund it +# → send funds to that address, then: qn rpc call eth_blockNumber --network base-sepolia --x402 \ - --payment-key-file ~/.keys/payer \ + --payment-wallet payer \ --pay-network base-sepolia \ --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ --max-amount 10000 @@ -345,6 +351,9 @@ qn rpc call eth_blockNumber --network base-sepolia --x402 \ qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt ... ``` +You can also point `--payment-key-file ` at a key file you manage +yourself instead of a stored wallet. + - `--network` is required and names the chain you *query*, as the payment gateway's path slug (independent of `--pay-network`, the chain the payment *settles* on). @@ -353,8 +362,10 @@ qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt ... `solana:EtWTRA...`). Anything containing a `:` is passed through verbatim, so any `eip155:` works even without a named entry. - The private key resolves from `--payment-key-file ` (`-` for stdin), - then the `QN_PAYMENT_KEY` env var, then `key_file` in config. It is never - accepted as a flag value, never stored in config, and never printed. + then `--payment-wallet ` (a stored wallet), then `key_file`, then + `wallet` in config. It always comes from a file or a stored wallet — never + an environment variable, never a flag value, never inline in config, and + never printed. - `--max-amount` is the per-call spend ceiling in integer base units of the asset (e.g. `10000` = 0.01 USDC). There is no built-in default; offers above the ceiling are refused before anything is signed. @@ -375,7 +386,7 @@ payment by itself: ```toml [rpc.payment] -key_file = "/home/me/.config/qn/payment.key" # a path; never the raw key +wallet = "payer" # a stored wallet name (or key_file = "") max_amount = "10000" pay_network = "base-sepolia" # network name or CAIP-2 id asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" @@ -385,6 +396,30 @@ asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" qn rpc call eth_blockNumber --network base-sepolia --x402 ``` +##### Managing payment wallets + +`qn rpc wallet` keeps dedicated payment wallets under +`~/.config/qn/wallets/` (raw key at `0600`; the key is stored unencrypted, so +treat each as a dedicated, minimally funded hot wallet): + +```sh +qn rpc wallet generate --chain evm --name payer # evm also covers MPP/Tempo; svm for x402/Solana +qn rpc wallet list # names, chain, address (never the key) +qn rpc wallet show payer # address to stdout, plus a QR to fund it on a terminal +qn rpc wallet rm payer # gated: --yes to confirm; the key is unrecoverable +``` + +`qn rpc wallet show payer` prints only the bare address to stdout (the QR and +hint go to stderr), so `qn rpc wallet show payer` in a pipe yields just the +address. + +##### Discovering payable networks + +`qn rpc pay-networks` (alias `pay-nets`) lists the networks the paid lane can +use, read from the gateways' public discovery endpoints (no API key). A listed +network is a valid `--network`; the x402 asset column is a ready `--asset` +value. The list is cached in `~/.config/qn/pay-networks.toml` (24h TTL). + ### Other ```sh diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index c2480cd..b861f0b 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -148,14 +148,26 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase or a raw CAIP-2 id like `eip155:84532`, which always passes through verbatim), `--asset
`, `--max-amount ` (spend ceiling per call, integer base units, no default), and the private key via - `--payment-key-file ` > `QN_PAYMENT_KEY` env > `key_file` under - `[rpc.payment]` in config (a path — never the raw key). All of these fall - back to `[rpc.payment]`, but config never activates payment by itself: the - scheme flag is always required. `--receipt` wraps stdout as - `{"result": ..., "payment_receipt": ...}` (on MPP an object whose - `reference` is the settlement tx hash; `null` on x402); without it the paid - output shape is identical to an unpaid call. + `--payment-key-file ` > `--payment-wallet ` > `key_file` > + `wallet` under `[rpc.payment]` in config. The key always comes from a file + or a stored wallet — never an env var and never a raw key on the command + line. All parameters fall back to `[rpc.payment]`, but config never + activates payment by itself: the scheme flag is always required. `--receipt` + wraps stdout as `{"result": ..., "payment_receipt": ...}` (on MPP an object + whose `reference` is the settlement tx hash; `null` on x402); without it the + paid output shape is identical to an unpaid call. Mutually exclusive with `--endpoint-url`. + **Wallets**: `qn rpc wallet generate --chain --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 rpc wallet list`/`show + ` display stored wallets (address only, never the key); `qn rpc wallet + rm ` deletes one (gated: `--yes`, or exit 5 in scripts). Reference a + wallet on a paid call with `--payment-wallet `. + **Discovery**: `qn rpc pay-networks` (alias `pay-nets`) lists the networks + payable via the paid lane, from the gateways' public discovery endpoints + (no API key). A listed slug is a valid `--network`; the x402 asset column is + a ready `--asset` value. Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`, `qn endpoint rate-limit --help`. Shell completions: `qn completions `. @@ -228,10 +240,17 @@ is enabling Tooling Access (or pass `--yes` to enable on first use). A custom **Pay per call with a crypto micropayment (no API key, no login):** ```sh -# One-time: store the wallet parameters (never the raw key) in config +qn rpc pay-networks # which networks are payable, and the x402 asset +qn rpc wallet generate --chain evm --name payer # create a dedicated wallet; prints its address + a QR to fund +# → fund that address, then: +qn rpc call eth_blockNumber --network base-sepolia --x402 \ + --payment-wallet payer --pay-network base-sepolia \ + --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e --max-amount 10000 + +# Or store the parameters (never the raw key) in config to keep calls short: cat >> ~/.config/qn/config.toml <<'EOF' [rpc.payment] -key_file = "/home/me/.config/qn/payment.key" # file holding the raw key; chmod 600 +wallet = "payer" # a stored wallet, or use key_file = "" (chmod 600) max_amount = "10000" # spend ceiling per call, base units pay_network = "base-sepolia" # settlement chain: network name or CAIP-2 id asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" @@ -250,8 +269,8 @@ is no built-in default. - 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; keep it in a file or `QN_PAYMENT_KEY`, not - in config or argv. + 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/rpc/mod.rs b/src/commands/rpc/mod.rs index 7378c73..be61179 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -63,9 +63,12 @@ pub enum RpcCmd { Paid (crypto micropayment, no API key; params from [rpc.payment] in config):\n \ qn rpc call eth_blockNumber --network base-sepolia --x402\n \ qn rpc call eth_blockNumber --network base-sepolia --x402 \\\n \ - --payment-key-file ~/.keys/payer --pay-network base-sepolia \\\n \ + --payment-wallet payer --pay-network base-sepolia \\\n \ --asset 0x036C... --max-amount 10000\n \ - qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt")] + qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt\n\n\ + See payable networks and manage wallets:\n \ + qn rpc pay-networks\n \ + qn rpc wallet generate --chain evm --name payer")] Call(Box), /// List the endpoint's available network keys (no RPC call). diff --git a/tests/snapshots/table_snapshots__pay_networks_table_merges_schemes_and_asset.snap b/tests/snapshots/table_snapshots__pay_networks_table_merges_schemes_and_asset.snap new file mode 100644 index 0000000..8d68f0f --- /dev/null +++ b/tests/snapshots/table_snapshots__pay_networks_table_merges_schemes_and_asset.snap @@ -0,0 +1,8 @@ +--- +source: tests/table_snapshots.rs +expression: "String::from_utf8(output.stdout).unwrap()" +--- +NETWORK SCHEMES X402 ASSET +base-sepolia x402, mpp 0x036CbD53842c5426634e7929541eC2318f3dCF7e +ethereum-mainnet x402, mpp — +solana-devnet x402, mpp — diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs index 815feb8..bb665c3 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -463,3 +463,54 @@ async fn sql_schema_table_renders_nested_table_blocks() { .await; insta::assert_snapshot!(out); } + +#[tokio::test] +async fn pay_networks_table_merges_schemes_and_asset() { + // Both gateways share the mock host (--base-url points both at it). The + // /discovery/resources asset lands on the base-sepolia row. + 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; + Mock::given(method("GET")) + .and(path("/discovery/resources")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "x402Version": 2, + "items": [{ + "accepts": [{ + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + }] + }] + }))) + .mount(&server) + .await; + + 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", + "pay-networks", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + insta::assert_snapshot!(String::from_utf8(output.stdout).unwrap()); +} From a55a1a83a5008e657961313535d74c630ec4f253 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 16 Jul 2026 23:00:39 -0400 Subject: [PATCH 13/32] feat(rpc): show key file path + local-custody note on wallet generate/show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `qn rpc wallet generate`/`show` now print the key file path and a note that the wallet lives only on this machine — Quicknode does not hold, back up, or recover it, so backing up the key file is the user's responsibility. Both go to stderr (address stays the sole stdout value for pipes); the note respects --quiet. Documents the same in the agent guide and README, and adds a subprocess test asserting the stdout/stderr split. --- README.md | 11 +++++--- src/commands/agent/context.md | 8 +++--- src/commands/rpc/wallet.rs | 23 +++++++++++----- tests/rpc_wallet.rs | 50 +++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e14b2c7..efe91f6 100644 --- a/README.md +++ b/README.md @@ -409,9 +409,14 @@ qn rpc wallet show payer # address to stdout, plus a QR qn rpc wallet rm payer # gated: --yes to confirm; the key is unrecoverable ``` -`qn rpc wallet show payer` prints only the bare address to stdout (the QR and -hint go to stderr), so `qn rpc wallet show payer` in a pipe yields just the -address. +`qn rpc wallet show payer` prints only the bare address to stdout (the key +file path, QR, and hint go to stderr), so `qn rpc wallet show payer` in a pipe +yields just the address. + +These wallets live only on your machine. **Quicknode does not hold, back up, +or recover them** — managing and backing up the key file (shown on +`generate`/`show`) is your responsibility; if you lose it, any funds in the +wallet are gone. ##### Discovering payable networks diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index b861f0b..d042ec7 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -161,9 +161,11 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase 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 rpc wallet list`/`show - ` display stored wallets (address only, never the key); `qn rpc wallet - rm ` deletes one (gated: `--yes`, or exit 5 in scripts). Reference a - wallet on a paid call with `--payment-wallet `. + ` display stored wallets (address only, never the key) and the key + file path; `qn rpc 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. **Discovery**: `qn rpc pay-networks` (alias `pay-nets`) lists the networks payable via the paid lane, from the gateways' public discovery endpoints (no API key). A listed slug is a valid `--network`; the x402 asset column is diff --git a/src/commands/rpc/wallet.rs b/src/commands/rpc/wallet.rs index 0f9adff..62f5381 100644 --- a/src/commands/rpc/wallet.rs +++ b/src/commands/rpc/wallet.rs @@ -149,7 +149,7 @@ fn generate(a: GenerateArgs, ctx: Ctx) -> Result<(), CliError> { ctx.out .note(&format!("✓ Generated {} wallet '{name}'", a.chain.label())); - emit_address(&ctx, &meta, /* with_qr */ true); + emit_address(&ctx, &meta, &key_path, /* with_qr */ true); Ok(()) } @@ -168,7 +168,7 @@ fn show(a: ShowArgs, ctx: Ctx) -> Result<(), CliError> { if ctx.out.format.is_structured() { return crate::output::emit(&ctx.out, &meta); } - emit_address(&ctx, &meta, /* with_qr */ true); + emit_address(&ctx, &meta, &dir.join(&name), /* with_qr */ true); Ok(()) } @@ -211,12 +211,20 @@ fn rm(a: RmArgs, ctx: Ctx) -> Result<(), CliError> { // ── rendering ────────────────────────────────────────────────────────────── -/// Prints the address to stdout (the pipeable value) and, on a TTY, a QR code -/// plus a funding hint to stderr (suppressed by `--quiet`/non-TTY, so a piped -/// `qn rpc wallet show` yields just the bare address). A QR must never go to -/// stdout — it would corrupt the pipe. -fn emit_address(ctx: &Ctx, meta: &WalletMeta, with_qr: bool) { +/// The custody disclaimer shown on generate/show. This wallet lives only on +/// this machine; backing it up is the user's responsibility. +const CUSTODY_NOTE: &str = "This wallet is stored only on this machine. \ + Quicknode does not hold, back up, or recover it — keep your own backup of \ + the key file; if you lose it, any funds in the wallet are gone."; + +/// Prints the address to stdout (the pipeable value), and to stderr: the key +/// file path and the custody note always (both plain text, `--quiet`-gated), +/// plus a QR code and funding hint only on a TTY. Everything but the address +/// goes to stderr, so a piped `qn rpc wallet show` yields just the bare +/// address. A QR must never go to stdout — it would corrupt the pipe. +fn emit_address(ctx: &Ctx, meta: &WalletMeta, key_path: &Path, with_qr: bool) { println!("{}", meta.address); + ctx.out.note(&format!("Key file: {}", key_path.display())); if with_qr && ctx.out.stdout_is_tty && !ctx.out.quiet { if let Some(qr) = render_qr(&meta.address) { ctx.out.note(&qr); @@ -226,6 +234,7 @@ fn emit_address(ctx: &Ctx, meta: &WalletMeta, with_qr: bool) { meta.chain, meta.name )); } + ctx.out.note(CUSTODY_NOTE); } /// Unicode (half-block) QR of `data`, or `None` if it can't be encoded. diff --git a/tests/rpc_wallet.rs b/tests/rpc_wallet.rs index 95270f1..fbbf01f 100644 --- a/tests/rpc_wallet.rs +++ b/tests/rpc_wallet.rs @@ -287,3 +287,53 @@ async fn show_unknown_wallet_errors() { out.stderr ); } + +// Subprocess: the in-process harness can't capture stdout/stderr, so assert the +// generate output split (address on stdout; key path + custody note on stderr) +// via the real binary. +#[tokio::test] +async fn generate_prints_key_path_and_custody_note() { + let dir = tempfile::tempdir().unwrap(); + let cfg = cfg_in(dir.path()); + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .args([ + "--config-file", + &cfg, + "--no-input", + "--no-color", + "rpc", + "wallet", + "generate", + "--chain", + "evm", + "--name", + "payer", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + + // Address is the only thing on stdout (the pipeable value). + assert!(stdout.trim().starts_with("0x"), "stdout={stdout}"); + + // Key path and custody note go to stderr. + let key_path = wallets_dir(dir.path()).join("payer"); + assert!( + stderr.contains(&key_path.display().to_string()), + "stderr missing key path: {stderr}" + ); + assert!( + stderr.contains("stored only on this machine") && stderr.contains("Quicknode does not"), + "stderr missing custody note: {stderr}" + ); + // The raw key must never appear on either stream. + let raw = std::fs::read_to_string(&key_path).unwrap(); + assert!(!stdout.contains(raw.trim()) && !stderr.contains(raw.trim())); +} From 3bfcd5aeeb7a36430ec94aeaa2cd0c3ab01751d9 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 16 Jul 2026 23:03:59 -0400 Subject: [PATCH 14/32] docs(rpc): working paid-lane examples for x402/MPP EVM and x402 SVM Replaces the single illustrative snippet with three copy-pasteable examples whose --pay-network/--asset/--max-amount match real gateway offers: x402 on Base Sepolia (USDC), MPP on Tempo testnet, and x402 on Solana devnet. Each was run against the live gateway and reaches the signing/settlement stage (only wallet funding is left to the user), rather than failing parameter validation. Notes that asset/amount must match an offer from `qn rpc pay-networks`, since a mismatch and an unfunded wallet both surface as an HTTP 400/402 refusal. --- README.md | 50 +++++++++++++++++++++++++++-------- src/commands/agent/context.md | 33 ++++++++++++++++------- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index efe91f6..a3612ee 100644 --- a/README.md +++ b/README.md @@ -334,25 +334,53 @@ request instead of using an account API key — no login and no Tooling Access needed. **This moves real funds** (testnet tokens are still real transfers): use a dedicated, minimally funded wallet. -The quickest path: see what's payable, generate a wallet, fund it, then pay by -name. +The quickest path: see what's payable, generate a wallet, fund that exact +address, then pay by name. `--asset` and `--max-amount` must match a real +payment offer for the network — `qn rpc pay-networks` shows the x402 asset per +network, and a paid call that names an unfunded wallet or a mismatched +asset/amount is refused with HTTP 402 before anything settles. + +**x402 on EVM (Base Sepolia testnet, USDC):** ```sh -qn rpc pay-networks # payable networks + the x402 asset per network -qn rpc wallet generate --chain evm --name payer # new wallet; prints its address + a QR to fund it -# → send funds to that address, then: -qn rpc call eth_blockNumber --network base-sepolia --x402 \ +qn rpc wallet generate --chain evm --name payer # prints the address + a QR; fund THAT address +qn rpc call eth_blockNumber \ + --network base-sepolia --x402 \ --payment-wallet payer \ --pay-network base-sepolia \ --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ - --max-amount 10000 + --max-amount 1000000 +``` + +**MPP on Tempo (testnet):** same EVM wallet works (MPP uses the secp256k1 key +format); `--receipt` wraps the result with the settlement reference. -# MPP settles on Tempo and returns a settlement receipt with --receipt: -qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt ... +```sh +qn rpc call eth_blockNumber \ + --network tempo-testnet --mpp --receipt \ + --payment-wallet payer \ + --pay-network tempo-testnet \ + --asset 0x20c0000000000000000000000000000000000000 \ + --max-amount 1000000 +``` + +**x402 on Solana (devnet):** needs an SVM wallet. + +```sh +qn rpc wallet generate --chain svm --name sol-payer # fund this base58 address +qn rpc call getSlot \ + --network solana-devnet --x402 \ + --payment-wallet sol-payer \ + --pay-network solana-devnet \ + --asset 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \ + --max-amount 1000000 ``` -You can also point `--payment-key-file ` at a key file you manage -yourself instead of a stored wallet. +The query chain (`--network`) and the settlement chain (`--pay-network`) are +independent, but the `--pay-network`/`--asset`/`--max-amount` trio must be a +combination the gateway actually offers (again, `qn rpc pay-networks`). You can +also point `--payment-key-file ` at a key file you manage yourself +instead of a stored wallet. - `--network` is required and names the chain you *query*, as the payment gateway's path slug (independent of `--pay-network`, the chain the payment diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index d042ec7..71cb7e5 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -241,25 +241,40 @@ is enabling Tooling Access (or pass `--yes` to enable on first use). A custom **Pay per call with a crypto micropayment (no API key, no login):** +`--asset`/`--max-amount` must match a real offer for the network (see +`qn rpc pay-networks`); an unfunded wallet or a mismatched asset/amount is +refused with HTTP 400/402 before anything settles. + ```sh qn rpc pay-networks # which networks are payable, and the x402 asset -qn rpc wallet generate --chain evm --name payer # create a dedicated wallet; prints its address + a QR to fund -# → fund that address, then: +qn rpc wallet generate --chain evm --name payer # dedicated wallet; prints its address + a QR to fund +# → fund THAT address, then pick a lane: + +# x402 on EVM (Base Sepolia testnet, USDC): qn rpc call eth_blockNumber --network base-sepolia --x402 \ --payment-wallet payer --pay-network base-sepolia \ - --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e --max-amount 10000 + --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e --max-amount 1000000 + +# MPP on Tempo (testnet); same EVM wallet, --receipt adds the settlement ref: +qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt \ + --payment-wallet payer --pay-network tempo-testnet \ + --asset 0x20c0000000000000000000000000000000000000 --max-amount 1000000 -# Or store the parameters (never the raw key) in config to keep calls short: +# x402 on Solana (devnet); needs an SVM wallet: +qn rpc wallet generate --chain svm --name sol-payer +qn rpc call getSlot --network solana-devnet --x402 \ + --payment-wallet sol-payer --pay-network solana-devnet \ + --asset 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU --max-amount 1000000 + +# Store the parameters in config to keep calls short (never the raw key): cat >> ~/.config/qn/config.toml <<'EOF' [rpc.payment] -wallet = "payer" # a stored wallet, or use key_file = "" (chmod 600) -max_amount = "10000" # spend ceiling per call, base units +wallet = "payer" # a stored wallet, or key_file = "" (chmod 600) +max_amount = "1000000" # spend ceiling per call, base units pay_network = "base-sepolia" # settlement chain: network name or CAIP-2 id asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" EOF - -qn rpc call eth_blockNumber --network base-sepolia --x402 # pays, prints the result -qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt # + settlement receipt +qn rpc call eth_blockNumber --network base-sepolia --x402 # params now come from config ``` This moves real funds (even testnet tokens are real transfers) — use a From dae28ad0e7f4dece9efa9da56de237c7c227e618 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 16 Jul 2026 23:29:08 -0400 Subject: [PATCH 15/32] fix(rpc): working paid examples + clearer refusal error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paid-lane examples used --max-amount 1000000, which routes to the SIWX credit-drawdown offer the CLI can't authenticate (HTTP 400 auth_required). Corrects every example to --max-amount 1000 — the per-request USDC offer that only needs a funded wallet — so each command is copy-paste-runnable: generate a wallet, fund the printed address, run the command. Also surfaces the gateway's own reason in the refusal error (leading with "Gateway: " when the body reduces to one) and reframes the guidance to name the two real causes — an unfunded wallet, or asset/amount/pay-network not matching an offer (pointing at 'qn rpc pay-networks'). A long body (e.g. a 402 menu) still only appears under --verbose. --- README.md | 10 ++++-- src/commands/agent/context.md | 8 ++--- src/errors.rs | 59 +++++++++++++++++++++++++++++------ 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a3612ee..f54cb6e 100644 --- a/README.md +++ b/README.md @@ -349,9 +349,13 @@ qn rpc call eth_blockNumber \ --payment-wallet payer \ --pay-network base-sepolia \ --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ - --max-amount 1000000 + --max-amount 1000 ``` +`--max-amount 1000` selects the per-request USDC offer (0.001 USDC). Fund the +wallet's address with a little Base Sepolia USDC and the same command returns +the block number. + **MPP on Tempo (testnet):** same EVM wallet works (MPP uses the secp256k1 key format); `--receipt` wraps the result with the settlement reference. @@ -361,7 +365,7 @@ qn rpc call eth_blockNumber \ --payment-wallet payer \ --pay-network tempo-testnet \ --asset 0x20c0000000000000000000000000000000000000 \ - --max-amount 1000000 + --max-amount 1000 ``` **x402 on Solana (devnet):** needs an SVM wallet. @@ -373,7 +377,7 @@ qn rpc call getSlot \ --payment-wallet sol-payer \ --pay-network solana-devnet \ --asset 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \ - --max-amount 1000000 + --max-amount 1000 ``` The query chain (`--network`) and the settlement chain (`--pay-network`) are diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index 71cb7e5..ea37772 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -253,24 +253,24 @@ qn rpc wallet generate --chain evm --name payer # dedicated wallet; prints # x402 on EVM (Base Sepolia testnet, USDC): qn rpc call eth_blockNumber --network base-sepolia --x402 \ --payment-wallet payer --pay-network base-sepolia \ - --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e --max-amount 1000000 + --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e --max-amount 1000 # MPP on Tempo (testnet); same EVM wallet, --receipt adds the settlement ref: qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt \ --payment-wallet payer --pay-network tempo-testnet \ - --asset 0x20c0000000000000000000000000000000000000 --max-amount 1000000 + --asset 0x20c0000000000000000000000000000000000000 --max-amount 1000 # x402 on Solana (devnet); needs an SVM wallet: qn rpc wallet generate --chain svm --name sol-payer qn rpc call getSlot --network solana-devnet --x402 \ --payment-wallet sol-payer --pay-network solana-devnet \ - --asset 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU --max-amount 1000000 + --asset 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU --max-amount 1000 # Store the parameters in config to keep calls short (never the raw key): cat >> ~/.config/qn/config.toml <<'EOF' [rpc.payment] wallet = "payer" # a stored wallet, or key_file = "" (chmod 600) -max_amount = "1000000" # spend ceiling per call, base units +max_amount = "1000" # spend ceiling per call, base units pay_network = "base-sepolia" # settlement chain: network name or CAIP-2 id asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" EOF diff --git a/src/errors.rs b/src/errors.rs index d732920..304b2d9 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -112,13 +112,22 @@ pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> Strin CliError::Sdk(SdkError::PaymentRejected { status, body }) => { // Only 4xx rejections reach this arm from the paid lane (5xx are // wrapped into PaymentMaybeCharged): the gateway refused the - // credential without settling it. - let msg = format!( - "Error: the gateway refused the payment (HTTP {status}). The signed \ - payment was not accepted, so nothing should have settled; check the \ - wallet balance and --pay-network/--asset/--max-amount, then retry." + // credential without settling it. The SDK has already reduced the + // body to the gateway's own reason when it was the JSON error shape. + let reason = payment_rejection_reason(body); + let mut msg = format!("Error: the gateway refused the payment (HTTP {status})."); + if let Some(r) = &reason { + let r = r.trim_end_matches('.'); + msg.push_str(&format!(" Gateway: {r}.")); + } + msg.push_str( + " The signed payment was not accepted, so nothing should have settled. \ + Common causes: the wallet is unfunded, or --pay-network/--asset/--max-amount \ + don't match an offer (see 'qn rpc pay-networks').", ); - if verbose && !body.is_empty() { + // When the reason wasn't a clean one-liner, append the raw body under + // --verbose for the full detail. + if verbose && reason.is_none() && !body.is_empty() { format!("{msg}\n{body}") } else { msg @@ -194,6 +203,19 @@ pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> Strin /// Status codes have a small set of canonical user-facing messages. Validation /// (400/422) gets the structured body treatment from `parse_api_body`. +/// The gateway's own rejection reason, when the body is a clean short string +/// (the SDK reduces its JSON `{error, message}` shape to that before this +/// point). A long body — e.g. a full 402 payment menu — is not a reason, so it +/// returns `None` and the caller falls back to the generic guidance (plus the +/// raw body under `--verbose`). +fn payment_rejection_reason(body: &str) -> Option { + let trimmed = body.trim(); + if trimmed.is_empty() || trimmed.len() > 200 || trimmed.starts_with('{') { + return None; + } + Some(trimmed.to_string()) +} + fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> String { let headline = match code { 400 | 422 => "invalid request.".to_string(), @@ -568,17 +590,36 @@ mod tests { #[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: "invalid signature".to_string(), + 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("invalid signature"), "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("qn rpc pay-networks"), "got: {msg}"); let verbose = render(&err, true); - assert!(verbose.contains("invalid signature"), "got: {verbose}"); + assert!(verbose.contains("accepts"), "got: {verbose}"); } #[test] From 949f64444edf5ccbdbed5cddf600c8d3807c9a9d Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 16 Jul 2026 23:33:58 -0400 Subject: [PATCH 16/32] feat(rpc): label wallet show/generate output as Public Key / Private Key File The stderr lines are now "Public Key:
" and "Private Key File: " (was an unlabeled address plus "Key file: "), so it's obvious which is which. The bare address still goes to stdout so a piped `qn rpc wallet show` yields just the address; the labeled pair is stderr. Updates the subprocess test to assert the labels. --- src/commands/rpc/wallet.rs | 15 +++++++++------ tests/rpc_wallet.rs | 11 ++++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/commands/rpc/wallet.rs b/src/commands/rpc/wallet.rs index 62f5381..bd3eb80 100644 --- a/src/commands/rpc/wallet.rs +++ b/src/commands/rpc/wallet.rs @@ -217,14 +217,17 @@ const CUSTODY_NOTE: &str = "This wallet is stored only on this machine. \ Quicknode does not hold, back up, or recover it — keep your own backup of \ the key file; if you lose it, any funds in the wallet are gone."; -/// Prints the address to stdout (the pipeable value), and to stderr: the key -/// file path and the custody note always (both plain text, `--quiet`-gated), -/// plus a QR code and funding hint only on a TTY. Everything but the address -/// goes to stderr, so a piped `qn rpc wallet show` yields just the bare -/// address. A QR must never go to stdout — it would corrupt the pipe. +/// Prints the address to stdout (the pipeable value), and to stderr: the +/// labeled public key + private key file path and the custody note always +/// (plain text, `--quiet`-gated), plus a QR code and funding hint only on a +/// TTY. Everything but the bare address goes to stderr, so a piped `qn rpc +/// wallet show` yields just the address. A QR must never go to stdout — it +/// would corrupt the pipe. fn emit_address(ctx: &Ctx, meta: &WalletMeta, key_path: &Path, with_qr: bool) { println!("{}", meta.address); - ctx.out.note(&format!("Key file: {}", key_path.display())); + ctx.out.note(&format!("Public Key: {}", meta.address)); + ctx.out + .note(&format!("Private Key File: {}", key_path.display())); if with_qr && ctx.out.stdout_is_tty && !ctx.out.quiet { if let Some(qr) = render_qr(&meta.address) { ctx.out.note(&qr); diff --git a/tests/rpc_wallet.rs b/tests/rpc_wallet.rs index fbbf01f..afaa90e 100644 --- a/tests/rpc_wallet.rs +++ b/tests/rpc_wallet.rs @@ -323,11 +323,16 @@ async fn generate_prints_key_path_and_custody_note() { // Address is the only thing on stdout (the pipeable value). assert!(stdout.trim().starts_with("0x"), "stdout={stdout}"); - // Key path and custody note go to stderr. + // Labeled public key + private key file path and the custody note go to + // stderr. let key_path = wallets_dir(dir.path()).join("payer"); assert!( - stderr.contains(&key_path.display().to_string()), - "stderr missing key path: {stderr}" + stderr.contains(&format!("Private Key File: {}", key_path.display())), + "stderr missing labeled key path: {stderr}" + ); + assert!( + stderr.contains("Public Key: 0x"), + "stderr missing labeled public key: {stderr}" ); assert!( stderr.contains("stored only on this machine") && stderr.contains("Quicknode does not"), From 2240871dda403b1cd5a6b1ee9f0506caf3884ee7 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 17 Jul 2026 08:33:46 -0400 Subject: [PATCH 17/32] feat(rpc): space and style the wallet show/generate output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the generate/show stderr block for readability: a blank line above and below the QR so it isn't jammed against the surrounding text, a blank line before the funding hint and before the custody note, and light ANSI styling (dim labels/fine-print, bold path and command) applied only when color is enabled. Drops the redundant address echo on stderr — the bare address is already on stdout for pipes. Removes the "Public Key" label per the same reasoning. --- src/commands/rpc/wallet.rs | 79 ++++++++++++++++++++++++++++++-------- tests/rpc_wallet.rs | 10 ++--- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/commands/rpc/wallet.rs b/src/commands/rpc/wallet.rs index bd3eb80..971f745 100644 --- a/src/commands/rpc/wallet.rs +++ b/src/commands/rpc/wallet.rs @@ -217,27 +217,76 @@ const CUSTODY_NOTE: &str = "This wallet is stored only on this machine. \ Quicknode does not hold, back up, or recover it — keep your own backup of \ the key file; if you lose it, any funds in the wallet are gone."; -/// Prints the address to stdout (the pipeable value), and to stderr: the -/// labeled public key + private key file path and the custody note always -/// (plain text, `--quiet`-gated), plus a QR code and funding hint only on a -/// TTY. Everything but the bare address goes to stderr, so a piped `qn rpc -/// wallet show` yields just the address. A QR must never go to stdout — it -/// would corrupt the pipe. +/// Prints the address to stdout (the pipeable value), and to stderr a spaced, +/// lightly-styled block: the QR (TTY only), the private key file path, a +/// funding hint, and the custody note. Everything but the bare address goes to +/// stderr, so a piped `qn rpc wallet show` yields just the address; the QR +/// must never go to stdout — it would corrupt the pipe. Styling is applied +/// only when `ctx.out.color` is set (a TTY with color enabled). fn emit_address(ctx: &Ctx, meta: &WalletMeta, key_path: &Path, with_qr: bool) { println!("{}", meta.address); - ctx.out.note(&format!("Public Key: {}", meta.address)); - ctx.out - .note(&format!("Private Key File: {}", key_path.display())); - if with_qr && ctx.out.stdout_is_tty && !ctx.out.quiet { + if ctx.out.quiet { + return; + } + let c = ctx.out.color; + let on_tty = with_qr && ctx.out.stdout_is_tty; + + // Build one spaced block so blank lines land predictably around the QR. + let mut block = String::new(); + + if on_tty { if let Some(qr) = render_qr(&meta.address) { - ctx.out.note(&qr); + // Blank line above so the QR isn't jammed against the address. + block.push('\n'); + block.push_str(&qr); + block.push('\n'); } - ctx.out.note(&format!( - "Fund this {} address to use it with 'qn rpc call --payment-wallet {}'.", - meta.chain, meta.name + } + + // Private key file path (the address is already on stdout, so it isn't + // echoed here). + block.push_str(&format!( + "{} {}\n", + style("Private key file:", Style::Dim, c), + style(&key_path.display().to_string(), Style::Bold, c) + )); + + // Funding hint (only meaningful interactively, where the QR is shown). + if on_tty { + block.push('\n'); + block.push_str(&format!( + "Fund this {} address, then use it with {}.\n", + meta.chain, + style( + &format!("qn rpc call --payment-wallet {}", meta.name), + Style::Bold, + c + ), )); } - ctx.out.note(CUSTODY_NOTE); + + // Custody note as dim fine-print, set off by a blank line. + block.push('\n'); + block.push_str(&style(&format!("⚠ {CUSTODY_NOTE}"), Style::Dim, c)); + + ctx.out.note(&block); +} + +/// A minimal ANSI style set, applied only when color is enabled. +enum Style { + Bold, + Dim, +} + +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") } /// Unicode (half-block) QR of `data`, or `None` if it can't be encoded. diff --git a/tests/rpc_wallet.rs b/tests/rpc_wallet.rs index afaa90e..b8c0109 100644 --- a/tests/rpc_wallet.rs +++ b/tests/rpc_wallet.rs @@ -323,17 +323,13 @@ async fn generate_prints_key_path_and_custody_note() { // Address is the only thing on stdout (the pipeable value). assert!(stdout.trim().starts_with("0x"), "stdout={stdout}"); - // Labeled public key + private key file path and the custody note go to - // stderr. + // The private key file path and the custody note go to stderr (the address + // is on stdout, so it isn't repeated on stderr). let key_path = wallets_dir(dir.path()).join("payer"); assert!( - stderr.contains(&format!("Private Key File: {}", key_path.display())), + stderr.contains(&format!("Private key file: {}", key_path.display())), "stderr missing labeled key path: {stderr}" ); - assert!( - stderr.contains("Public Key: 0x"), - "stderr missing labeled public key: {stderr}" - ); assert!( stderr.contains("stored only on this machine") && stderr.contains("Quicknode does not"), "stderr missing custody note: {stderr}" From 00c84e3c7d6480b2e52159faee034fab9fe7ce0b Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 17 Jul 2026 10:18:28 -0400 Subject: [PATCH 18/32] feat(rpc): rename paid-lane flags to --payment-* and accept asset names Rename the call-side payment flags for a consistent --payment-* stack: --pay-network becomes --payment-network, --asset becomes --payment-asset, with matching [rpc.payment] config keys (payment_network, payment_asset). --payment-asset now accepts friendly names (USDC) resolved per network via pay_asset::resolve, not only raw token addresses. Wallet show/generate output and the paid-lane README/agent-guide examples move to the new flag names. --- README.md | 50 ++++++----- src/commands/agent/context.md | 29 +++---- src/commands/rpc/mod.rs | 16 ++-- src/commands/rpc/pay_asset.rs | 153 ++++++++++++++++++++++++++++++++++ src/commands/rpc/payment.rs | 51 ++++++------ src/commands/rpc/wallet.rs | 49 +++++++++-- src/config.rs | 16 ++-- src/errors.rs | 4 +- tests/rpc_payment.rs | 123 ++++++++++++++++++--------- 9 files changed, 365 insertions(+), 126 deletions(-) create mode 100644 src/commands/rpc/pay_asset.rs diff --git a/README.md b/README.md index f54cb6e..da24fc7 100644 --- a/README.md +++ b/README.md @@ -335,9 +335,9 @@ needed. **This moves real funds** (testnet tokens are still real transfers): use a dedicated, minimally funded wallet. The quickest path: see what's payable, generate a wallet, fund that exact -address, then pay by name. `--asset` and `--max-amount` must match a real -payment offer for the network — `qn rpc pay-networks` shows the x402 asset per -network, and a paid call that names an unfunded wallet or a mismatched +address, then pay by name. `--payment-asset` and `--max-amount` must match a +real payment offer for the network — `qn rpc pay-networks` shows the x402 asset +per network, and a paid call that names an unfunded wallet or a mismatched asset/amount is refused with HTTP 402 before anything settles. **x402 on EVM (Base Sepolia testnet, USDC):** @@ -347,8 +347,8 @@ qn rpc wallet generate --chain evm --name payer # prints the address + a Q qn rpc call eth_blockNumber \ --network base-sepolia --x402 \ --payment-wallet payer \ - --pay-network base-sepolia \ - --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ + --payment-network base-sepolia \ + --payment-asset USDC \ --max-amount 1000 ``` @@ -363,8 +363,8 @@ format); `--receipt` wraps the result with the settlement reference. qn rpc call eth_blockNumber \ --network tempo-testnet --mpp --receipt \ --payment-wallet payer \ - --pay-network tempo-testnet \ - --asset 0x20c0000000000000000000000000000000000000 \ + --payment-network tempo-testnet \ + --payment-asset USDC \ --max-amount 1000 ``` @@ -375,24 +375,27 @@ qn rpc wallet generate --chain svm --name sol-payer # fund this base58 address qn rpc call getSlot \ --network solana-devnet --x402 \ --payment-wallet sol-payer \ - --pay-network solana-devnet \ - --asset 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \ + --payment-network solana-devnet \ + --payment-asset USDC \ --max-amount 1000 ``` -The query chain (`--network`) and the settlement chain (`--pay-network`) are -independent, but the `--pay-network`/`--asset`/`--max-amount` trio must be a -combination the gateway actually offers (again, `qn rpc pay-networks`). You can -also point `--payment-key-file ` at a key file you manage yourself -instead of a stored wallet. +The query chain (`--network`) and the settlement chain (`--payment-network`) +are independent, but the `--payment-network`/`--payment-asset`/`--max-amount` +trio must be a combination the gateway actually offers (again, `qn rpc +pay-networks`). You can also point `--payment-key-file ` at a key file +you manage yourself instead of a stored wallet. - `--network` is required and names the chain you *query*, as the payment - gateway's path slug (independent of `--pay-network`, the chain the payment - *settles* on). -- `--pay-network` takes a Quicknode network name (`base-sepolia`, + gateway's path slug (independent of `--payment-network`, the chain the + payment *settles* on). +- `--payment-network` takes a Quicknode network name (`base-sepolia`, `solana-devnet`, `tempo-testnet`, ...) or a raw CAIP-2 id (`eip155:84532`, `solana:EtWTRA...`). Anything containing a `:` is passed through verbatim, so any `eip155:` works even without a named entry. +- `--payment-asset` takes a token contract address (EVM), a mint (Solana), or + a symbol like `USDC` resolved to that network's address; run `qn rpc + pay-networks` for the raw addresses. - The private key resolves from `--payment-key-file ` (`-` for stdin), then `--payment-wallet ` (a stored wallet), then `key_file`, then `wallet` in config. It always comes from a file or a stored wallet — never @@ -418,10 +421,10 @@ payment by itself: ```toml [rpc.payment] -wallet = "payer" # a stored wallet name (or key_file = "") -max_amount = "10000" -pay_network = "base-sepolia" # network name or CAIP-2 id -asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" +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 @@ -454,8 +457,9 @@ wallet are gone. `qn rpc pay-networks` (alias `pay-nets`) lists the networks the paid lane can use, read from the gateways' public discovery endpoints (no API key). A listed -network is a valid `--network`; the x402 asset column is a ready `--asset` -value. The list is cached in `~/.config/qn/pay-networks.toml` (24h TTL). +network is a valid `--network`; the x402 asset column is a ready +`--payment-asset` value. The list is cached in +`~/.config/qn/pay-networks.toml` (24h TTL). ### Other diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index ea37772..ed67e12 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -143,10 +143,11 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase the call per request with a crypto micropayment instead of an API key — no login, no Tooling Access. Requires `--network` as the payment gateway's path slug (e.g. `base-sepolia`; NOT validated by `list-networks`). Parameters: - `--pay-network ` (the chain the payment settles on — independent of + `--payment-network ` (the chain the payment settles on — independent of `--network`; a network name like `base-sepolia`/`solana-devnet`/`tempo-testnet`, or a raw CAIP-2 id like `eip155:84532`, which always passes through - verbatim), `--asset
`, `--max-amount ` (spend ceiling + verbatim), `--payment-asset
` (a token contract/mint, or the symbol + `USDC`, resolved to that network's address), `--max-amount ` (spend ceiling per call, integer base units, no default), and the private key via `--payment-key-file ` > `--payment-wallet ` > `key_file` > `wallet` under `[rpc.payment]` in config. The key always comes from a file @@ -169,7 +170,7 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase **Discovery**: `qn rpc pay-networks` (alias `pay-nets`) lists the networks payable via the paid lane, from the gateways' public discovery endpoints (no API key). A listed slug is a valid `--network`; the x402 asset column is - a ready `--asset` value. + a ready `--payment-asset` value. Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`, `qn endpoint rate-limit --help`. Shell completions: `qn completions `. @@ -241,7 +242,7 @@ is enabling Tooling Access (or pass `--yes` to enable on first use). A custom **Pay per call with a crypto micropayment (no API key, no login):** -`--asset`/`--max-amount` must match a real offer for the network (see +`--payment-asset`/`--max-amount` must match a real offer for the network (see `qn rpc pay-networks`); an unfunded wallet or a mismatched asset/amount is refused with HTTP 400/402 before anything settles. @@ -252,27 +253,27 @@ qn rpc wallet generate --chain evm --name payer # dedicated wallet; prints # x402 on EVM (Base Sepolia testnet, USDC): qn rpc call eth_blockNumber --network base-sepolia --x402 \ - --payment-wallet payer --pay-network base-sepolia \ - --asset 0x036CbD53842c5426634e7929541eC2318f3dCF7e --max-amount 1000 + --payment-wallet payer --payment-network base-sepolia \ + --payment-asset USDC --max-amount 1000 # MPP on Tempo (testnet); same EVM wallet, --receipt adds the settlement ref: qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt \ - --payment-wallet payer --pay-network tempo-testnet \ - --asset 0x20c0000000000000000000000000000000000000 --max-amount 1000 + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset USDC --max-amount 1000 # x402 on Solana (devnet); needs an SVM wallet: qn rpc wallet generate --chain svm --name sol-payer qn rpc call getSlot --network solana-devnet --x402 \ - --payment-wallet sol-payer --pay-network solana-devnet \ - --asset 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU --max-amount 1000 + --payment-wallet sol-payer --payment-network solana-devnet \ + --payment-asset USDC --max-amount 1000 # Store the parameters in config to keep calls short (never the raw key): cat >> ~/.config/qn/config.toml <<'EOF' [rpc.payment] -wallet = "payer" # a stored wallet, or key_file = "" (chmod 600) -max_amount = "1000" # spend ceiling per call, base units -pay_network = "base-sepolia" # settlement chain: network name or CAIP-2 id -asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" +wallet = "payer" # a stored wallet, or key_file = "" (chmod 600) +max_amount = "1000" # spend ceiling per call, base units +payment_network = "base-sepolia" # settlement chain: network name or CAIP-2 id +payment_asset = "USDC" # symbol (resolved per network), or a raw address/mint EOF qn rpc call eth_blockNumber --network base-sepolia --x402 # params now come from config ``` diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index be61179..621c51b 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -24,6 +24,7 @@ //! branches off before any of this module's token-cache or Tooling Access //! machinery runs. +mod pay_asset; mod pay_network; mod pay_networks; mod payment; @@ -63,8 +64,8 @@ pub enum RpcCmd { Paid (crypto micropayment, no API key; params from [rpc.payment] in config):\n \ qn rpc call eth_blockNumber --network base-sepolia --x402\n \ qn rpc call eth_blockNumber --network base-sepolia --x402 \\\n \ - --payment-wallet payer --pay-network base-sepolia \\\n \ - --asset 0x036C... --max-amount 10000\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\n\ See payable networks and manage wallets:\n \ qn rpc pay-networks\n \ @@ -170,24 +171,25 @@ pub struct CallArgs { /// 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 `pay_network` under [rpc.payment]. + /// query). Falls back to `payment_network` under [rpc.payment]. #[arg( long, value_name = "NETWORK", requires = "payment", help_heading = "Payment" )] - pub pay_network: Option, + pub payment_network: Option, - /// Token to pay with: EVM contract address or Solana mint. Falls back to - /// `asset` under [rpc.payment]. + /// 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 asset: Option, + 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 diff --git a/src/commands/rpc/pay_asset.rs b/src/commands/rpc/pay_asset.rs new file mode 100644 index 0000000..bd17566 --- /dev/null +++ b/src/commands/rpc/pay_asset.rs @@ -0,0 +1,153 @@ +//! Human-readable symbols for `--payment-asset`. +//! +//! The SDK's `PaymentConfig.asset` is a token contract address (EVM) or mint +//! (Solana) — the exact value an x402/MPP offer is matched against. A symbol +//! like `USDC` only identifies a concrete address once the network is known: +//! USDC is a different address on every chain. So this resolver takes the +//! already-resolved CAIP-2 network alongside the input and maps a known symbol +//! to that network's address. +//! +//! Unlike `pay_network`, this is intentionally permissive: an asset is an +//! open-ended address across many chains, not a closed Quicknode vocabulary. +//! Anything that isn't a recognized symbol passes through verbatim, so any +//! token address reaches the gateway unchanged and the gateway stays the +//! authority on whether it is a valid, payable asset. Only symbols confirmed +//! against a public source (the gateway's own discovery catalog) are listed — +//! a wrong address could pay in the wrong token. + +use crate::errors::CliError; + +/// (CAIP-2 network, lowercase symbol) → token address. Sorted by the +/// `(network, symbol)` tuple (binary searched); a unit test enforces order and +/// uniqueness. Addresses are taken from a public source — the payment +/// gateways' discovery catalog (`qn rpc pay-networks`) or, for Tempo, the MPP +/// spec / Tempo payment docs. +const PAY_ASSETS: &[(&str, &str, &str)] = &[ + ( + "eip155:196", + "usdc", + "0x4ae46a509F6b1D9056937BA4500cb143933D2dc8", + ), + // Tempo USDC (mainnet) and pathUSD (testnet USDC), from the MPP spec / + // Tempo payment docs rather than the x402 discovery catalog. + ( + "eip155:4217", + "usdc", + "0x20c000000000000000000000b9537d11c60e8b50", + ), + ( + "eip155:42431", + "usdc", + "0x20c0000000000000000000000000000000000000", + ), + ( + "eip155:8453", + "usdc", + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + ), + ( + "eip155:84532", + "usdc", + "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + ), + ( + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "usdc", + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ), + ( + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "usdc", + "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + ), +]; + +/// The symbols this resolver recognizes, lowercased. Input matching one of +/// these (case-insensitively) is rewritten to an address for the given +/// network, or errors if that network has no mapping. Anything else passes +/// through verbatim as an address. +const KNOWN_SYMBOLS: &[&str] = &["usdc"]; + +/// Resolves a `--payment-asset` / config `payment_asset` value to a token +/// address, given the already-resolved CAIP-2 `network`. A recognized symbol +/// (e.g. `USDC`, case-insensitive) is mapped to that network's address; every +/// other value passes through unchanged. +pub(super) fn resolve(input: &str, network: &str) -> Result { + let lower = input.to_ascii_lowercase(); + if !KNOWN_SYMBOLS.contains(&lower.as_str()) { + return Ok(input.to_string()); + } + match PAY_ASSETS.binary_search_by(|(net, sym, _)| (*net, *sym).cmp(&(network, lower.as_str()))) + { + Ok(i) => Ok(PAY_ASSETS[i].2.to_string()), + Err(_) => Err(CliError::Arg(format!( + "no known {} address for network '{network}'. Pass the token \ + contract address (EVM) or mint (Solana) directly to \ + --payment-asset — run 'qn rpc pay-networks' to find it", + input.to_ascii_uppercase() + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_is_sorted_and_unique() { + for pair in PAY_ASSETS.windows(2) { + let a = (pair[0].0, pair[0].1); + let b = (pair[1].0, pair[1].1); + assert!(a < b, "PAY_ASSETS out of order or duplicated at {b:?}"); + } + } + + #[test] + fn resolves_symbol_per_network() { + assert_eq!( + resolve("usdc", "eip155:84532").unwrap(), + "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + ); + assert_eq!( + resolve("usdc", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp").unwrap(), + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + ); + // Tempo (MPP): mainnet USDC and testnet pathUSD. + assert_eq!( + resolve("usdc", "eip155:4217").unwrap(), + "0x20c000000000000000000000b9537d11c60e8b50" + ); + assert_eq!( + resolve("usdc", "eip155:42431").unwrap(), + "0x20c0000000000000000000000000000000000000" + ); + } + + #[test] + fn symbol_lookup_is_case_insensitive() { + assert_eq!( + resolve("USDC", "eip155:8453").unwrap(), + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + ); + } + + #[test] + fn address_passes_through_verbatim() { + let addr = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + assert_eq!(resolve(addr, "eip155:84532").unwrap(), addr); + // A base58 mint (unknown to the table) is untouched. + let mint = "So11111111111111111111111111111111111111112"; + assert_eq!( + resolve(mint, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp").unwrap(), + mint + ); + } + + #[test] + fn 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/payment.rs b/src/commands/rpc/payment.rs index d258d06..3839995 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -173,32 +173,33 @@ fn resolve_payment_config( ))); } - let pay_network = args - .pay_network + let payment_network = args + .payment_network .clone() - .or_else(|| section.pay_network.clone()) + .or_else(|| section.payment_network.clone()) .ok_or_else(|| { CliError::Arg( - "no pay network set. Pass --pay-network (a network \ - name like base-sepolia, or a CAIP-2 id like eip155:84532) \ - or set `pay_network` under [rpc.payment]" + "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 pay_network = super::pay_network::resolve(&pay_network)?; + let payment_network = super::pay_network::resolve(&payment_network)?; - let asset = args - .asset + let payment_asset = args + .payment_asset .clone() - .or_else(|| section.asset.clone()) + .or_else(|| section.payment_asset.clone()) .ok_or_else(|| { CliError::Arg( - "no payment asset set. Pass --asset
(the token \ - contract or mint to pay with) or set `asset` under \ - [rpc.payment]" + "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 args .svm_rpc_url @@ -213,8 +214,8 @@ fn resolve_payment_config( PaymentConfig { scheme: scheme.to_string(), key, - pay_network, - asset, + pay_network: payment_network, + asset: payment_asset, max_amount, svm_rpc_url, base_url_override, @@ -368,8 +369,8 @@ mod tests { payment_key_file: None, payment_wallet: None, max_amount: Some("10000".to_string()), - pay_network: Some("eip155:84532".to_string()), - asset: Some("0xabc".to_string()), + payment_network: Some("eip155:84532".to_string()), + payment_asset: Some("0xabc".to_string()), svm_rpc_url: None, receipt: false, } @@ -413,7 +414,7 @@ mod tests { #[test] fn pay_network_name_resolves_to_caip2() { let (mut args, _f) = paid_args_with_key(true); - args.pay_network = Some("base-sepolia".to_string()); + 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"); } @@ -421,9 +422,9 @@ mod tests { #[test] fn config_pay_network_name_resolves_too() { let mut section = empty_section(); - section.pay_network = Some("solana-devnet".to_string()); + section.payment_network = Some("solana-devnet".to_string()); let (mut args, _f) = paid_args_with_key(true); - args.pay_network = None; + args.payment_network = None; let (cfg, _, _) = resolve_payment_config(&args, §ion, None, None).unwrap(); assert_eq!(cfg.pay_network, "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"); } @@ -431,7 +432,7 @@ mod tests { #[test] fn unknown_pay_network_name_is_an_arg_error() { let (mut args, _f) = paid_args_with_key(true); - args.pay_network = Some("btc".to_string()); + 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}"); @@ -610,14 +611,14 @@ mod tests { wallet: None, key: None, max_amount: Some("5000".to_string()), - pay_network: Some("eip155:42431".to_string()), - asset: Some("0xdef".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.pay_network = None; - args.asset = 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"); diff --git a/src/commands/rpc/wallet.rs b/src/commands/rpc/wallet.rs index 971f745..f1f3d73 100644 --- a/src/commands/rpc/wallet.rs +++ b/src/commands/rpc/wallet.rs @@ -252,16 +252,17 @@ fn emit_address(ctx: &Ctx, meta: &WalletMeta, key_path: &Path, with_qr: bool) { )); // Funding hint (only meaningful interactively, where the QR is shown). + // Show a complete, runnable paid-lane call for this wallet's chain family + // so the address can go straight from funded to used. if on_tty { block.push('\n'); + block.push_str( + "This wallet pays for RPC calls via micropayments (x402/MPP).\n\ + Fund it on the chain you'll pay from, then use it:\n\n", + ); block.push_str(&format!( - "Fund this {} address, then use it with {}.\n", - meta.chain, - style( - &format!("qn rpc call --payment-wallet {}", meta.name), - Style::Bold, - c - ), + "{}\n", + style(&example_call(&meta.chain, &meta.name), Style::Bold, c), )); } @@ -272,6 +273,40 @@ fn emit_address(ctx: &Ctx, meta: &WalletMeta, key_path: &Path, with_qr: bool) { ctx.out.note(&block); } +/// A complete, runnable paid-lane `qn rpc call` for a freshly funded wallet, +/// matching the documented examples in the README. SVM gets one x402/Solana +/// example; EVM gets both an x402 (Base Sepolia USDC) and an MPP (Tempo +/// testnet) example, since the same secp256k1 key works for both. +/// `--max-amount 1000` selects the per-request offer (0.001 USDC). +fn example_call(chain: &str, name: &str) -> String { + match chain { + "svm" => format!( + "qn rpc call getSlot \\\n \ + --network solana-devnet --x402 \\\n \ + --payment-wallet {name} \\\n \ + --payment-network solana-devnet \\\n \ + --payment-asset USDC \\\n \ + --max-amount 1000" + ), + _ => format!( + "# x402 (Base Sepolia USDC):\n\ + qn rpc call eth_blockNumber \\\n \ + --network base-sepolia --x402 \\\n \ + --payment-wallet {name} \\\n \ + --payment-network base-sepolia \\\n \ + --payment-asset USDC \\\n \ + --max-amount 1000\n\n\ + # MPP (Tempo testnet):\n\ + qn rpc call eth_blockNumber \\\n \ + --network tempo-testnet --mpp \\\n \ + --payment-wallet {name} \\\n \ + --payment-network tempo-testnet \\\n \ + --payment-asset USDC \\\n \ + --max-amount 1000" + ), + } +} + /// A minimal ANSI style set, applied only when color is enabled. enum Style { Bold, diff --git a/src/config.rs b/src/config.rs index 23e16ca..0f1aec8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -94,18 +94,18 @@ pub struct PaymentSection { /// silently ignore `key = "..."` and the user would think it took effect. #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option, - /// Default spend ceiling per paid call, in integer base units of `asset`. - /// Accepts a TOML string or integer. + /// Default spend ceiling per paid call, in integer base units of + /// `payment_asset`. Accepts a TOML string or integer. #[serde(default, deserialize_with = "de_opt_string_or_int")] pub max_amount: Option, /// Chain payments settle on: a Quicknode network name (e.g. /// `base-sepolia`) or a CAIP-2 id (e.g. `eip155:84532`). Independent of /// `--network` (the chain the RPC call queries). #[serde(default)] - pub pay_network: Option, + pub payment_network: Option, /// Token to pay with: EVM contract address or Solana mint. #[serde(default)] - pub asset: Option, + pub payment_asset: Option, /// Explicit Solana RPC URL for x402/Solana payment builds. #[serde(default)] pub svm_rpc_url: Option, @@ -892,8 +892,8 @@ mod tests { "[rpc.payment]\n\ key_file = \"/keys/payer.key\"\n\ max_amount = \"10000\"\n\ - pay_network = \"eip155:84532\"\n\ - asset = \"0xabc\"\n\ + payment_network = \"eip155:84532\"\n\ + payment_asset = \"0xabc\"\n\ svm_rpc_url = \"https://solana.example/rpc\"\n", ) .unwrap(); @@ -901,8 +901,8 @@ mod tests { 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.pay_network.as_deref(), Some("eip155:84532")); - assert_eq!(p.asset.as_deref(), Some("0xabc")); + 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()); } diff --git a/src/errors.rs b/src/errors.rs index 304b2d9..69d6faa 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -105,7 +105,7 @@ pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> Strin CliError::Sdk(SdkError::PaymentUnsupported { offered }) => { format!( "Error: no offered payment option matched your configuration \ - (check --pay-network, --asset, and --max-amount). Nothing was charged.\n\ + (check --payment-network, --payment-asset, and --max-amount). Nothing was charged.\n\ Gateway offered: {offered}" ) } @@ -122,7 +122,7 @@ pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> Strin } msg.push_str( " The signed payment was not accepted, so nothing should have settled. \ - Common causes: the wallet is unfunded, or --pay-network/--asset/--max-amount \ + Common causes: the wallet is unfunded, or --payment-network/--payment-asset/--max-amount \ don't match an offer (see 'qn rpc pay-networks').", ); // When the reason wasn't a clean one-liner, append the raw body under diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index 56425c9..e5ab557 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -134,9 +134,9 @@ async fn x402_happy_path_pays_and_bypasses_control_plane() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -150,6 +150,49 @@ async fn x402_happy_path_pays_and_bypasses_control_plane() { ); } +#[tokio::test] +async fn x402_resolves_usdc_symbol_to_network_asset() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(X402Seq::new("1000")) + .expect(2) // one unpaid probe + one paid resend + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + // The symbol resolves to base-sepolia's USDC address (matching the offer's + // `asset`), so signing and the paid resend succeed exactly as passing the + // raw address would. + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + "USDC", + "--max-amount", + "10000", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + #[tokio::test] async fn payment_wallet_resolves_stored_key_for_paid_call() { let server = MockServer::start().await; @@ -197,9 +240,9 @@ async fn payment_wallet_resolves_stored_key_for_paid_call() { "--x402", "--payment-wallet", "payer", - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -235,9 +278,9 @@ async fn pay_network_name_matches_caip2_offer() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "base-sepolia", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -267,7 +310,7 @@ async fn paid_call_works_keyless_with_config_params() { &cfg, format!( "[rpc.payment]\nkey_file = \"{key_path}\"\nmax_amount = \"10000\"\n\ - pay_network = \"eip155:84532\"\nasset = \"{USDC}\"\n" + payment_network = \"eip155:84532\"\npayment_asset = \"{USDC}\"\n" ), ) .unwrap(); @@ -315,9 +358,9 @@ async fn over_cap_offer_is_refused_before_signing() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -351,7 +394,7 @@ async fn flag_max_amount_overrides_config() { &cfg, format!( "[rpc.payment]\nkey_file = \"{key_path}\"\nmax_amount = \"999999\"\n\ - pay_network = \"eip155:84532\"\nasset = \"{USDC}\"\n" + payment_network = \"eip155:84532\"\npayment_asset = \"{USDC}\"\n" ), ) .unwrap(); @@ -407,7 +450,7 @@ async fn config_presence_does_not_auto_activate_payment() { format!( "[api]\nkey = \"test\"\n\n\ [rpc.payment]\nkey_file = \"{key_path}\"\nmax_amount = \"10000\"\n\ - pay_network = \"eip155:84532\"\nasset = \"{USDC}\"\n" + payment_network = \"eip155:84532\"\npayment_asset = \"{USDC}\"\n" ), ) .unwrap(); @@ -454,9 +497,9 @@ async fn paid_lane_is_never_retried() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -494,9 +537,9 @@ async fn payment_rejected_on_paid_resend_exits_2_as_refused() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -544,9 +587,9 @@ async fn settlement_5xx_on_paid_resend_exits_3_check_wallet() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -589,9 +632,9 @@ async fn unparseable_paid_response_exits_3_check_wallet() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -631,9 +674,9 @@ async fn malformed_challenge_menu_exits_2_nothing_charged() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -680,9 +723,9 @@ async fn missing_network_fails_before_any_request() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -705,9 +748,9 @@ async fn unknown_pay_network_name_fails_before_any_request() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "not-a-chain", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -733,9 +776,9 @@ async fn missing_key_fails_before_any_request() { "--network", "base-sepolia", "--x402", - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -762,9 +805,9 @@ async fn missing_max_amount_fails_before_any_request() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, ], "--max-amount", @@ -785,9 +828,9 @@ async fn non_integer_max_amount_fails_before_any_request() { "--x402", "--payment-key-file", &key_path, - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "1.5", @@ -804,7 +847,7 @@ async fn inline_config_key_fails_before_any_request() { std::fs::write( &cfg, "[rpc.payment]\nkey = \"0xdeadbeef\"\nmax_amount = \"10000\"\n\ - pay_network = \"eip155:84532\"\nasset = \"0xabc\"\n", + payment_network = \"eip155:84532\"\npayment_asset = \"0xabc\"\n", ) .unwrap(); expect_preflight_error( @@ -837,9 +880,9 @@ async fn params_and_key_both_from_stdin_conflict() { "--x402", "--payment-key-file", "-", - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", @@ -870,8 +913,8 @@ fn x402_and_mpp_are_mutually_exclusive() { fn payment_flags_require_a_scheme_flag() { for orphan in [ vec!["--receipt"], - vec!["--pay-network", "eip155:84532"], - vec!["--asset", "0xabc"], + vec!["--payment-network", "eip155:84532"], + vec!["--payment-asset", "0xabc"], vec!["--max-amount", "1"], vec!["--payment-key-file", "/k"], vec!["--svm-rpc-url", "https://x"], @@ -992,9 +1035,9 @@ async fn receipt_flag_wraps_stdout_on_mpp() { "tempo-testnet", "--mpp", "--receipt", - "--pay-network", + "--payment-network", "eip155:42431", - "--asset", + "--payment-asset", "0x20c0000000000000000000000000000000000000", "--max-amount", "10000", @@ -1032,9 +1075,9 @@ async fn receipt_is_null_on_x402_and_bare_without_flag() { "--network", "base-sepolia", "--x402", - "--pay-network", + "--payment-network", "eip155:84532", - "--asset", + "--payment-asset", USDC, "--max-amount", "10000", From 21ef1b128b21e9d79fcbe1cf17338981a19bfa38 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 17 Jul 2026 10:42:05 -0400 Subject: [PATCH 19/32] feat(rpc): qn rpc x402 credit-drawdown lifecycle Adds `qn rpc x402 {buy-credits, balance, drip}` for prepaid gateway credits, an alternative to per-request payment. All three take the same payment parameter stack as the paid lane (--payment-wallet/-key-file, --payment-network, --payment-asset, --max-amount, --svm-rpc-url) with the same [rpc.payment] fallback. - buy-credits: SIWX-authenticates, then settles the gateway's credit offer with the configured wallet. Gated Mild (names the spend ceiling; --yes skips, non-TTY without --yes returns exit 5 with zero requests sent). - balance (alias credits): prints the bare credit count, or the full envelope with --format json. - drip: testnet faucet (Base Sepolia, once per account). The session JWT is authenticated once and cached at /qn/sessions.toml (0600, keyed by wallet address), re-seeded next run like the tooling token; a missing/expired session re-auths transparently. Factors the shared payment-param resolution out of resolve_payment_config into resolve_payment_params, reused by both the call lane and the x402 verbs. Syncs the agent guide. 5 new integration tests (happy/error/both gating paths). --- src/commands/agent/context.md | 12 ++ src/commands/rpc/mod.rs | 6 + src/commands/rpc/payment.rs | 76 ++++++++-- src/commands/rpc/x402.rs | 261 ++++++++++++++++++++++++++++++++++ src/config.rs | 81 +++++++++++ tests/rpc_payment.rs | 181 +++++++++++++++++++++++ 6 files changed, 603 insertions(+), 14 deletions(-) create mode 100644 src/commands/rpc/x402.rs diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index ed67e12..cdf3a6e 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -171,6 +171,18 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase payable via the paid lane, from the gateways' public discovery endpoints (no API key). A listed slug is a valid `--network`; the x402 asset column is a ready `--payment-asset` value. + **x402 drawdown**: `qn rpc x402 {buy-credits, balance, drip}` manages prepaid + gateway credits instead of paying per request. All three take the same + payment parameter stack as the paid lane (`--payment-wallet`/`-key-file`, + `--payment-network`, `--payment-asset`, `--max-amount`, `--svm-rpc-url`) with + the same `[rpc.payment]` fallback. `buy-credits` SIWX-authenticates then pays + the gateway's credit offer (gated Mild — `--yes`, or exit 5 in scripts — and + names the spend ceiling); `balance` (alias `credits`) prints the current + credit count (bare number, or the full envelope with `--format json`); `drip` + requests testnet credits (Base Sepolia faucet, once per account). The session + JWT is authenticated once and cached under `/qn/sessions.toml` + (0600, keyed by wallet address); a missing/expired session re-authenticates + transparently (free, no confirmation). Paid verbs never auto-retry. Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`, `qn endpoint rate-limit --help`. Shell completions: `qn completions `. diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index 621c51b..856beb1 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -29,6 +29,7 @@ mod pay_network; mod pay_networks; mod payment; mod wallet; +mod x402; use std::io::Read; use std::path::{Path, PathBuf}; @@ -83,6 +84,10 @@ pub enum RpcCmd { /// gateways' public discovery endpoints. No API key required. #[command(visible_alias = "pay-nets")] PayNetworks, + + /// Manage x402 credit drawdown: buy prepaid credits, check the balance, or + /// drip testnet credits. Pair with `qn rpc call --x402-drawdown`. + X402(x402::Args), } #[derive(Debug, ClapArgs)] @@ -218,6 +223,7 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { wallet::run(wallet_args, ctx).await } RpcCmd::PayNetworks => pay_networks::run(global).await, + RpcCmd::X402(x402_args) => x402::run(x402_args, global).await, } } diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index 3839995..8613873 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -115,6 +115,32 @@ fn load_payment_section(global: &GlobalArgs) -> Result /// or malformed input fails fast with an actionable message and zero requests /// sent. Returns the SDK config, the query network, and an optional key-file /// permissions warning for the caller to print once output exists. +/// The payment parameter stack shared by `qn rpc call --x402/--mpp` and the +/// gateway lifecycle verbs (`qn rpc x402 …`, `qn rpc mpp …`). Plain data, +/// resolved from flags (or the verb's own args) with `[rpc.payment]` fallback +/// by [`resolve_payment_params`]. +pub(super) struct PaymentParams<'a> { + pub key_file: Option<&'a Path>, + pub wallet: Option<&'a str>, + pub max_amount: Option<&'a str>, + pub payment_network: Option<&'a str>, + pub payment_asset: Option<&'a str>, + pub svm_rpc_url: Option<&'a str>, +} + +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, @@ -131,10 +157,33 @@ fn resolve_payment_config( 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 --pay-network, the chain the payment settles on." + separate from --payment-network, the chain the payment settles on." ))); }; + let payment = resolve_payment_params( + scheme, + &args.payment_params(), + section, + wallets_dir, + base_url_override, + )?; + let key_file_warning = payment.1; + Ok((payment.0, network, key_file_warning)) +} + +/// Resolves the shared payment parameter stack (key, spend ceiling, pay +/// network, asset, SVM RPC URL) into an SDK [`PaymentConfig`] for `scheme`, +/// applying the flags-then-`[rpc.payment]` precedence. Returns the config plus +/// an optional key-file permissions warning. Does not touch the query network +/// (that is call-specific). +pub(super) fn resolve_payment_params( + scheme: &str, + params: &PaymentParams<'_>, + section: &PaymentSection, + wallets_dir: Option<&Path>, + base_url_override: Option, +) -> Result<(PaymentConfig, Option), CliError> { // An inline raw key in config is never accepted — it belongs in a file. if section.key.is_some() { return Err(CliError::Arg( @@ -146,16 +195,16 @@ fn resolve_payment_config( } let (key, key_file_warning) = resolve_key( - args.payment_key_file.as_deref(), - args.payment_wallet.as_deref(), + params.key_file, + params.wallet, section.key_file.as_deref(), section.wallet.as_deref(), wallets_dir, )?; - let max_amount = args + let max_amount = params .max_amount - .clone() + .map(str::to_string) .or_else(|| section.max_amount.clone()) .ok_or_else(|| { CliError::Arg( @@ -173,9 +222,9 @@ fn resolve_payment_config( ))); } - let payment_network = args + let payment_network = params .payment_network - .clone() + .map(str::to_string) .or_else(|| section.payment_network.clone()) .ok_or_else(|| { CliError::Arg( @@ -187,9 +236,9 @@ fn resolve_payment_config( })?; let payment_network = super::pay_network::resolve(&payment_network)?; - let payment_asset = args + let payment_asset = params .payment_asset - .clone() + .map(str::to_string) .or_else(|| section.payment_asset.clone()) .ok_or_else(|| { CliError::Arg( @@ -201,9 +250,9 @@ fn resolve_payment_config( })?; let payment_asset = super::pay_asset::resolve(&payment_asset, &payment_network)?; - let svm_rpc_url = match args + let svm_rpc_url = match params .svm_rpc_url - .clone() + .map(str::to_string) .or_else(|| section.svm_rpc_url.clone()) { Some(u) => Some(crate::context::validate_endpoint_url(&u)?), @@ -220,7 +269,6 @@ fn resolve_payment_config( svm_rpc_url, base_url_override, }, - network, key_file_warning, )) } @@ -343,7 +391,7 @@ fn checked_key(raw: String, source: &str) -> Result { /// after the signed payment was submitted — also unknown, exit 3. A 4xx /// rejection means the gateway refused the credential without settling it /// and passes through to exit 2. -fn map_paid_error(e: SdkError) -> CliError { +pub(super) fn map_paid_error(e: SdkError) -> CliError { match &e { SdkError::Decode { .. } => CliError::PaymentMaybeCharged(e), SdkError::PaymentRejected { status, .. } if *status >= 500 => { @@ -452,7 +500,7 @@ mod tests { 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("--pay-network"), "got: {msg}"); + assert!(msg.contains("--payment-network"), "got: {msg}"); } #[test] diff --git a/src/commands/rpc/x402.rs b/src/commands/rpc/x402.rs new file mode 100644 index 0000000..6a4999a --- /dev/null +++ b/src/commands/rpc/x402.rs @@ -0,0 +1,261 @@ +//! `qn rpc x402 …` — the x402 credit-drawdown gateway lifecycle. +//! +//! Three verbs manage prepaid gateway credits, all keyless (paid by the +//! configured wallet, not an account API key): +//! - `buy-credits` — SIWX-authenticate, then settle the gateway's 402 credit +//! offer with the payment wallet. Gated Mild (it moves funds). +//! - `balance` — GET the account's current credit balance. +//! - `drip` — testnet faucet (Base Sepolia, once per account). +//! +//! The session JWT is authenticated on first use, cached under the config dir +//! (`sessions.toml`, 0600, keyed by the payer wallet's address), and re-seeded +//! next run — the same seed/export pattern as the Tooling Access token. A +//! missing/expired session re-authenticates transparently: it moves no funds, +//! so it needs no confirmation. +//! +//! Every verb ends its success output with a ready-to-run next command +//! (stderr via `ctx.out.note`) so the drawdown flow chains without the user +//! hunting for the next step. Paid verbs are single-attempt: a paid lane never +//! blind-retries. + +use clap::{Args as ClapArgs, Subcommand}; +use quicknode_sdk::{CreditBalance, GatewaySession, PaymentConfig}; + +use crate::config::{self, PaymentSection}; +use crate::context::{Ctx, GlobalArgs}; +use crate::errors::CliError; + +use super::payment::{resolve_payment_params, PaymentParams}; + +// Re-auth margin: treat a session expiring within this window as stale and mint +// a fresh one, absorbing clock skew. Mirrors the tooling token's 60s margin. +const SESSION_MARGIN_SECS: i64 = 60; + +#[derive(Debug, ClapArgs)] +#[command(subcommand_required = true, arg_required_else_help = true)] +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 --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")] + Balance(PaymentArgs), + + /// Request testnet credits from the faucet (Base Sepolia, once per account). + Drip(PaymentArgs), +} + +/// 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 { + /// File containing the raw payment private key (EVM hex); `-` reads stdin. + /// Precedence: this > --payment-wallet > `key_file` > `wallet` in config. + #[arg(long, value_name = "PATH", conflicts_with = "payment_wallet")] + pub payment_key_file: Option, + + /// Name of a stored wallet (from `qn rpc wallet generate`) to pay with. + #[arg(long, value_name = "NAME")] + pub payment_wallet: Option, + + /// Spend ceiling for a credit purchase, in integer base units of the asset + /// (e.g. 10000000 = 10 USDC). Flag > `max_amount` in [rpc.payment]. An + /// offered purchase above this is never signed. + #[arg(long, value_name = "BASE_UNITS")] + pub max_amount: Option, + + /// Chain you pay on: a network name (e.g. `base-sepolia`) or CAIP-2 id + /// (e.g. `eip155:84532`). Falls back to `payment_network` in [rpc.payment]. + #[arg(long, value_name = "NETWORK")] + pub payment_network: Option, + + /// Token to pay with: an EVM contract address or a symbol like USDC + /// (resolved per network). Falls back to `payment_asset` in [rpc.payment]. + #[arg(long, value_name = "ADDRESS")] + pub payment_asset: Option, + + /// Explicit Solana RPC URL for x402/Solana payment builds. Falls back to + /// `svm_rpc_url` in [rpc.payment], then a public Solana RPC. + #[arg(long, value_name = "URL")] + pub svm_rpc_url: Option, +} + +impl PaymentArgs { + fn params(&self) -> PaymentParams<'_> { + PaymentParams { + key_file: self.payment_key_file.as_deref(), + wallet: self.payment_wallet.as_deref(), + max_amount: self.max_amount.as_deref(), + payment_network: self.payment_network.as_deref(), + payment_asset: self.payment_asset.as_deref(), + svm_rpc_url: self.svm_rpc_url.as_deref(), + } + } +} + +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, + } +} + +// Resolve the payment config from the verb's args + [rpc.payment], build the +// keyless-payment Ctx, and print any key-file permissions warning once output +// exists. Shared setup for all three verbs — no network I/O yet. +fn setup(args: &PaymentArgs, global: GlobalArgs) -> Result<(Ctx, PaymentConfig), CliError> { + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_payment_params( + "x402", + &args.params(), + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + + let ctx = Ctx::from_global_keyless_payment(global, payment.clone())?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + Ok((ctx, payment)) +} + +/// Loads `[rpc.payment]`. A missing file is an empty section; an unreadable or +/// invalid file is a hard error (the user likely relies on values set there). +fn load_payment_section(global: &GlobalArgs) -> Result { + let Some(path) = global.resolve_config_path() else { + return Ok(PaymentSection::default()); + }; + Ok(config::load_from(&path)? + .map(|cfg| cfg.rpc.payment) + .unwrap_or_default()) +} + +// Return a valid gateway session for the configured wallet, authenticating +// (and caching) when there's no fresh cached JWT. Free — no funds move — so no +// confirmation. The cache is keyed by the wallet's on-chain address, derived +// offline, so the lookup is a single local read; a hit skips the SIWX round +// trip, a miss (or an expired session) authenticates once and re-caches. +async fn ensure_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); + } + } + } + + let session = ctx.sdk.rpc.gateway_authenticate().await?; + if let Some(path) = &sessions_path { + let _ = config::save_gateway_session(path, &address, &session); + } + Ok(session) +} + +async fn run_buy_credits(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { + let (ctx, payment) = setup(&args, global.clone())?; + + // Gate Mild BEFORE any network I/O: name the spend ceiling and blast radius. + // The exact charge is the gateway's offer, bounded by this ceiling; we name + // the ceiling since a single-attempt purchase can't safely probe first. + let msg = format!( + "Buy credits for up to {} base units of {} on {}? This moves real funds.", + payment.max_amount, payment.asset, payment.pay_network + ); + crate::confirm::confirm_mild(&ctx, &msg)?; + + let session = ensure_session(&ctx, &global).await?; + let balance = ctx + .sdk + .rpc + .gateway_buy_credits(&session) + .await + .map_err(super::payment::map_paid_error)?; + + ctx.out.note(&format!( + "✓ Bought credits (balance: {})", + fmt_credits(balance.credits) + )); + ctx.out + .note(" Next: qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown"); + emit_balance(&ctx, &balance) +} + +async fn run_balance(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { + let (ctx, _payment) = setup(&args, global.clone())?; + let session = ensure_session(&ctx, &global).await?; + let balance = ctx.sdk.rpc.gateway_credits(&session).await?; + emit_balance(&ctx, &balance) +} + +async fn run_drip(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { + let (ctx, _payment) = setup(&args, global.clone())?; + let session = ensure_session(&ctx, &global).await?; + let balance = ctx.sdk.rpc.gateway_drip(&session).await?; + + ctx.out.note(&format!( + "✓ Dripped testnet credits (balance: {})", + fmt_credits(balance.credits) + )); + ctx.out + .note(" Next: qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown"); + emit_balance(&ctx, &balance) +} + +// Emit a credit balance: the bare number by default (friendly for scripts and +// pipelines, TTY or piped), the full envelope only for a structured --format. +fn emit_balance(ctx: &Ctx, balance: &CreditBalance) -> Result<(), CliError> { + if matches!(ctx.global.format, Some(f) if f.is_structured()) { + let v = serde_json::json!({ + "account_id": balance.account_id, + "credits": balance.credits, + }); + return super::emit_result(ctx, &v); + } + println!("{}", balance.credits); + Ok(()) +} + +// Group digits for the human-facing note (1000000 -> 1,000,000). The bare +// stdout number is never grouped, so pipelines get a clean integer. +fn fmt_credits(n: u64) -> String { + let s = n.to_string(); + let bytes = s.as_bytes(); + 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"); + } +} diff --git a/src/config.rs b/src/config.rs index 0f1aec8..f4a5f90 100644 --- a/src/config.rs +++ b/src/config.rs @@ -309,6 +309,7 @@ fn write_config(path: &Path, cfg: &ConfigFile) -> Result<(), CliError> { use std::collections::HashMap; use quicknode_sdk::CachedToken; +use quicknode_sdk::GatewaySession; /// On-disk shape of `~/.config/qn/tokens.toml`. #[derive(Debug, Default, Serialize, Deserialize)] @@ -463,6 +464,86 @@ pub fn wallets_dir(config_path: Option<&Path>) -> Option { } } +// ── x402 gateway session (JWT) cache ───────────────────────────────────────── +// +// The drawdown lane authenticates once (SIWX → JWT, ~1h) and reuses the JWT +// across `qn rpc` processes. We persist it in `sessions.toml` alongside the +// config, keyed by the payer wallet's on-chain address (derived offline from +// the key), so distinct wallets don't collide and a cache lookup needs no +// network round trip. Only the short-lived JWT is written — never the wallet +// key. A missing/expired session simply re-authenticates (which is free). + +/// On-disk shape of `~/.config/qn/sessions.toml`. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct SessionCacheFile { + /// Payer wallet address (lowercase) -> cached gateway session. + #[serde(default)] + pub sessions: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewaySessionEntry { + pub token: String, + pub exp_unix: i64, + pub account_id: String, +} + +/// The gateway-session cache path: `sessions.toml` alongside the resolved +/// config file (falling back to the default config dir). +pub fn sessions_cache_path(config_path: Option<&Path>) -> Option { + match config_path { + Some(p) => p.parent().map(|d| d.join("sessions.toml")), + None => config_dir().map(|d| d.join("qn").join("sessions.toml")), + } +} + +// Normalize an address for use as a cache key (addresses are case-insensitive +// on EVM; Solana base58 is case-sensitive but never collides after lowercasing +// within a single wallet's own entries). +fn session_key(address: &str) -> String { + address.to_lowercase() +} + +/// Loads the cached gateway session for the payer `address`. Returns `None` if +/// the file is absent, unparseable, or has no entry for that wallet. +pub fn load_gateway_session_by_address(path: &Path, address: &str) -> Option { + let text = fs::read_to_string(path).ok()?; + let cache: SessionCacheFile = toml::from_str(&text).ok()?; + let entry = cache.sessions.get(&session_key(address))?; + Some(GatewaySession { + token: entry.token.clone(), + exp_unix: entry.exp_unix, + account_id: entry.account_id.clone(), + }) +} + +/// Stores `session` under the payer `address`, preserving every other wallet's +/// entry (read-modify-write). Written atomically at 0600. Best-effort under +/// concurrency: the atomic rename guarantees no partial file. +pub fn save_gateway_session( + path: &Path, + address: &str, + session: &GatewaySession, +) -> Result<(), CliError> { + let mut cache: SessionCacheFile = fs::read_to_string(path) + .ok() + .and_then(|t| toml::from_str(&t).ok()) + .unwrap_or_default(); + cache.sessions.insert( + session_key(address), + GatewaySessionEntry { + token: session.token.clone(), + exp_unix: session.exp_unix, + account_id: session.account_id.clone(), + }, + ); + let text = toml::to_string_pretty(&cache).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + write_atomic_0600(path, text.as_bytes(), ".qn-sessions-") +} + /// 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. diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index e5ab557..8deeed9 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -1104,3 +1104,184 @@ async fn receipt_is_null_on_x402_and_bare_without_flag() { let v: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(v.as_str(), Some("0x1335f9a")); } + +// ── qn rpc x402 (credit drawdown lifecycle) ────────────────────────────────── +// +// These exercise the x402 noun end-to-end against the mock gateway: SIWX auth +// (POST /auth) mints a session JWT, buy-credits settles the 402 credit offer +// (POST /credits), balance reads GET /credits, and drip hits POST /drip. The +// session is cached under the config dir so a second verb skips re-auth. + +/// Mounts a SIWX /auth responder that returns a fixed session JWT. +async fn mount_auth(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "token": "jwt-test", + "expiresAt": "2099-01-01T00:00:00Z", + "accountId": "eip155:84532:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" + }))) + .mount(server) + .await; +} + +/// Sequenced /credits responder: the first (unpaid) POST gets a 402 credit +/// offer; a POST carrying a payment signature gets the post-purchase balance. +struct CreditsSeq { + amount: &'static str, + credits: u64, + calls: AtomicUsize, +} + +impl Respond for CreditsSeq { + fn respond(&self, req: &Request) -> ResponseTemplate { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + let has_sig = req.headers.contains_key("payment-signature"); + if n == 0 && !has_sig { + ResponseTemplate::new(402).set_body_json(json!({ + "x402Version": 2, + "accepts": [ x402_accepts_entry(self.amount) ] + })) + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", "credits": self.credits + })) + } + } +} + +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", + ] +} + +#[tokio::test] +async fn x402_buy_credits_happy_path() { + let server = MockServer::start().await; + mount_auth(&server).await; + Mock::given(method("POST")) + .and(path("/credits")) + .respond_with(CreditsSeq { + amount: "1000000", + credits: 1_000_095, + calls: AtomicUsize::new(0), + }) + .expect(2) // one unpaid offer 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 mut args = x402_args(&cfg, &key_path, "buy-credits"); + args.push("--yes"); // Mild gate: consent to spend + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + // The session JWT is cached for reuse. + assert!(dir.path().join("sessions.toml").exists()); +} + +#[tokio::test] +async fn x402_buy_credits_without_yes_is_needs_confirmation_and_settles_nothing() { + let server = MockServer::start().await; + // The gate is checked before any network I/O: nothing must reach the gateway. + Mock::given(method("POST")) + .and(path("/auth")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + // No --yes, non-TTY (the harness sets --no-input): exit 5, zero requests. + let args = x402_args(&cfg, &key_path, "buy-credits"); + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_balance_prints_credits() { + let server = MockServer::start().await; + mount_auth(&server).await; + Mock::given(method("GET")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", "credits": 42u64 + }))) + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let out = run_qn(&server.uri(), &x402_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_args(&cfg, &key_path, "balance")).await; + // A gateway 4xx that settled nothing maps to exit 2 (SDK Api error). + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_drip_reports_balance() { + 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", "credits": 100u64 + }))) + .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_args(&cfg, &key_path, "drip")).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} From 7b11106dffbe98602a62a23ce203a5996e466876 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 17 Jul 2026 10:49:26 -0400 Subject: [PATCH 20/32] feat(rpc): qn rpc call --x402-drawdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth payment mode to `qn rpc call`: spend prepaid x402 credits with no per-call signing (1 credit per successful response), the counterpart to the `qn rpc x402` credit lifecycle. Joins the mutually-exclusive payment ArgGroup alongside --x402/--mpp and conflicts with --endpoint-url; requires --network (the query chain). The gateway session is authenticated on first use and cached (0600, keyed by wallet address); the SDK's ensure_gateway_session is now shared by both the x402 noun and this call lane. A token_expired 401 triggers exactly one transparent re-auth + retry — that path draws no credit, so it isn't a paid retry; the credit-drawing call itself is single-attempt. Running out of credits surfaces an actionable error pointing at `qn rpc x402 buy-credits`. Syncs the agent guide (exit-code semantics, retry note, catalog) and adds a README drawdown walkthrough plus a call --help example. 3 new integration tests (happy path, expired-JWT auto re-auth sequence, out-of-credits single-attempt). --- README.md | 29 ++++++++ src/commands/agent/context.md | 49 +++++++----- src/commands/rpc/mod.rs | 23 +++++- src/commands/rpc/payment.rs | 136 +++++++++++++++++++++++++++++++++- src/commands/rpc/x402.rs | 38 ++-------- tests/rpc_payment.rs | 135 +++++++++++++++++++++++++++++++++ 6 files changed, 353 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index da24fc7..a0e7872 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,35 @@ network is a valid `--network`; the x402 asset column is a ready `--payment-asset` value. The list is cached in `~/.config/qn/pay-networks.toml` (24h TTL). +#### Prepaid x402 credits (drawdown) + +Instead of signing a payment on every call, buy a block of credits once and +draw them down: no per-call signing, one credit per successful response. Buy +credits, then call with `--x402-drawdown`: + +```sh +qn rpc wallet generate --chain evm --name payer # dedicated wallet; fund its address + +# Testnet: get free credits from the faucet (Base Sepolia, once per account). +qn rpc x402 drip --payment-wallet payer --payment-network base-sepolia --payment-asset USDC + +# Or buy credits (moves real funds; gated — pass --yes to skip the prompt). +qn rpc x402 buy-credits --payment-wallet payer \ + --payment-network base-sepolia --payment-asset USDC --max-amount 10000000 + +# Check the balance any time (prints the bare number). +qn rpc x402 balance --payment-wallet payer --payment-network base-sepolia --payment-asset USDC + +# Spend credits on calls. +qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown \ + --payment-wallet payer --payment-network base-sepolia --payment-asset USDC +``` + +The gateway session is authenticated once and cached (0600) under the config +dir, refreshed automatically. When you run out, the call points you back at +`qn rpc x402 buy-credits`. Like every paid call, a drawdown call never +auto-retries. + ### Other ```sh diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index cdf3a6e..671ef78 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -61,13 +61,15 @@ Branch on these — especially **4** and **5**. | 5 | Cancelled, or confirmation required and not granted (see §4). | | 130 | Interrupted (SIGINT). | -On a **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 +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. +blind-retry a paid call. A **drawdown** call (`--x402-drawdown`) spends prepaid +credits, not per-call funds: running out surfaces an actionable exit-1 error +pointing at `qn rpc x402 buy-credits`, and a credit is drawn only on success. ## 4. Non-interactive & confirmation behavior @@ -104,9 +106,12 @@ 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`) never auto-retries — `--retries` does - not apply. Each attempt can move funds, and after a lost response the previous - attempt may already have settled (§3, exit 3). +- A **paid** `rpc call` (`--x402`/`--mpp`/`--x402-drawdown`) 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). ## 6. Command catalog @@ -172,17 +177,23 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase (no API key). A listed slug is a valid `--network`; the x402 asset column is a ready `--payment-asset` value. **x402 drawdown**: `qn rpc x402 {buy-credits, balance, drip}` manages prepaid - gateway credits instead of paying per request. All three take the same - payment parameter stack as the paid lane (`--payment-wallet`/`-key-file`, - `--payment-network`, `--payment-asset`, `--max-amount`, `--svm-rpc-url`) with - the same `[rpc.payment]` fallback. `buy-credits` SIWX-authenticates then pays - the gateway's credit offer (gated Mild — `--yes`, or exit 5 in scripts — and - names the spend ceiling); `balance` (alias `credits`) prints the current - credit count (bare number, or the full envelope with `--format json`); `drip` - requests testnet credits (Base Sepolia faucet, once per account). The session - JWT is authenticated once and cached under `/qn/sessions.toml` - (0600, keyed by wallet address); a missing/expired session re-authenticates - transparently (free, no confirmation). Paid verbs never auto-retry. + gateway credits instead of paying per request, and `qn rpc call + --x402-drawdown` spends them (no per-call signing, 1 credit per successful + response). All take the same payment parameter stack as the paid lane + (`--payment-wallet`/`-key-file`, `--payment-network`, `--payment-asset`, + `--max-amount`, `--svm-rpc-url`) with the same `[rpc.payment]` fallback. + `--x402-drawdown` requires `--network` (the query chain) and is mutually + exclusive with `--x402`/`--mpp`/`--endpoint-url`. `buy-credits` + SIWX-authenticates then pays the gateway's credit offer (gated Mild — `--yes`, + or exit 5 in scripts — and names the spend ceiling); `balance` (alias + `credits`) prints the current credit count (bare number, or the full envelope + with `--format json`); `drip` requests testnet credits (Base Sepolia faucet, + once per account). The session JWT is authenticated once and cached under + `/qn/sessions.toml` (0600, keyed by wallet address); a + missing/expired session re-authenticates transparently (free, no + confirmation), including one automatic re-auth if a drawdown call's token + expired mid-use. Out of credits surfaces an actionable error pointing at + `qn rpc x402 buy-credits`; nothing auto-retries a credit-drawing call. Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`, `qn endpoint rate-limit --help`. Shell completions: `qn completions `. diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index 856beb1..7692bfd 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -68,6 +68,11 @@ pub enum RpcCmd { --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\n\ + Prepaid x402 credits (drawdown — buy once, then spend, no per-call signing):\n \ + qn rpc x402 buy-credits --payment-wallet payer \\\n \ + --payment-network base-sepolia --payment-asset USDC --max-amount 10000000\n \ + qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown \\\n \ + --payment-wallet payer --payment-network base-sepolia --payment-asset USDC\n\n\ See payable networks and manage wallets:\n \ qn rpc pay-networks\n \ qn rpc wallet generate --chain evm --name payer")] @@ -93,7 +98,7 @@ pub enum RpcCmd { #[derive(Debug, ClapArgs)] #[command( group(ArgGroup::new("params_source").args(["params", "params_file"])), - group(ArgGroup::new("payment").args(["x402", "mpp"])), + group(ArgGroup::new("payment").args(["x402", "mpp", "x402_drawdown"])), )] pub struct CallArgs { /// The JSON-RPC method, e.g. `eth_blockNumber`. @@ -139,6 +144,13 @@ pub struct CallArgs { #[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, + /// 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 @@ -242,12 +254,15 @@ async fn run_list_networks(global: GlobalArgs) -> Result<(), CliError> { } async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { - // The crypto-micropayment lane branches off before any token-cache or - // Tooling Access work: it is keyless, never retried, and never touches - // this function's caches or recovery paths. + // The crypto-micropayment lanes branch off before any token-cache or + // Tooling Access work: they are keyless, never blind-retried, and never + // touch this function's caches or recovery paths. if args.x402 || args.mpp { return payment::run_paid_call(args, global).await; } + if args.x402_drawdown { + return payment::run_drawdown_call(args, global).await; + } let params = parse_params(args.params.as_deref(), args.params_file.as_deref())?; diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index 8613873..2334287 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -19,7 +19,7 @@ use std::path::Path; use quicknode_sdk::errors::SdkError; -use quicknode_sdk::PaymentConfig; +use quicknode_sdk::{GatewaySession, PaymentConfig}; use crate::config::{self, PaymentSection}; use crate::context::{Ctx, GlobalArgs}; @@ -27,6 +27,10 @@ use crate::errors::CliError; use super::CallArgs; +// Re-auth margin for the cached gateway session: refresh a session expiring +// within this window, absorbing clock skew (mirrors the tooling token margin). +const SESSION_MARGIN_SECS: i64 = 60; + /// Entry point from `run_call` once `--x402`/`--mpp` is present. pub(super) async fn run_paid_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { // Both the key and the params may come from stdin; there is only one stdin. @@ -98,6 +102,135 @@ pub(super) async fn run_paid_call(args: CallArgs, global: GlobalArgs) -> Result< } } +/// Entry point from `run_call` once `--x402-drawdown` is present. Pays for the +/// call from prepaid x402 credits: no per-call signing, 1 credit per success. +/// +/// A missing/expired session JWT is re-authenticated transparently (free), and +/// a `token_expired` 401 on the call triggers exactly ONE re-auth + retry — +/// that path signs nothing and draws no credit, so it is not a paid retry. The +/// credit-drawing call itself is single-attempt. +pub(super) async fn run_drawdown_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + // The query chain, required here so the error can explain the distinction. + let Some(network) = args.network.clone() else { + return Err(CliError::Arg( + "--x402-drawdown requires --network: the chain the call queries, as \ + the payment gateway's path slug (e.g. --network base-sepolia)." + .to_string(), + )); + }; + + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_payment_params( + "x402", + &args.payment_params(), + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + + let params = super::parse_params(args.params.as_deref(), args.params_file.as_deref())?; + + let ctx = Ctx::from_global_keyless_payment(global.clone(), payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + + let session = ensure_gateway_session(&ctx, &global).await?; + + // ONE credit-drawing attempt. A token_expired 401 is the sole exception: + // it drew no credit, so we re-auth once and retry (mirrors the tooling + // lane's reactive 401 refresh). + let result = match ctx + .sdk + .rpc + .gateway_drawdown_call(&args.method, params.clone(), &network, &session) + .await + { + Ok(v) => v, + Err(e) if is_token_expired(&e) => { + let fresh = reauthenticate(&ctx, &global).await?; + ctx.sdk + .rpc + .gateway_drawdown_call(&args.method, params, &network, &fresh) + .await + .map_err(map_drawdown_error)? + } + Err(e) => return Err(map_drawdown_error(e)), + }; + + super::emit_result(&ctx, &result) +} + +/// Returns a valid gateway session for the configured wallet, authenticating +/// (and caching) when there's no fresh cached JWT. Free — no funds move — so no +/// confirmation. Keyed by the wallet's on-chain address (derived offline), so +/// the cache lookup is a single local read. Shared by `qn rpc x402 …` and the +/// `--x402-drawdown` call lane. +pub(super) async fn ensure_gateway_session( + ctx: &Ctx, + global: &GlobalArgs, +) -> Result { + let sessions_path = config::sessions_cache_path(global.resolve_config_path().as_deref()); + let address = ctx.sdk.rpc.payment_address()?; + + if let Some(path) = &sessions_path { + if let Some(existing) = config::load_gateway_session_by_address(path, &address) { + if existing.is_fresh(SESSION_MARGIN_SECS) { + return Ok(existing); + } + } + } + reauthenticate(ctx, global).await +} + +/// Authenticates a fresh session and caches it, unconditionally (bypassing the +/// cache). Used on first use and on a `token_expired` retry. +async fn reauthenticate(ctx: &Ctx, global: &GlobalArgs) -> Result { + let sessions_path = config::sessions_cache_path(global.resolve_config_path().as_deref()); + let address = ctx.sdk.rpc.payment_address()?; + let session = ctx.sdk.rpc.gateway_authenticate().await?; + if let Some(path) = &sessions_path { + let _ = config::save_gateway_session(path, &address, &session); + } + Ok(session) +} + +/// True when a gateway error is a `token_expired`/`invalid_token` 401 — the one +/// case worth a transparent re-auth (it drew no credit). +fn is_token_expired(e: &SdkError) -> bool { + matches!( + e, + SdkError::Api { status, body } + if status.as_u16() == 401 + && (body.contains("token_expired") || body.contains("invalid_token")) + ) +} + +/// Maps a drawdown-call failure onto an actionable CLI error. An empty-credits +/// or monthly-limit gateway refusal points forward at the fixing verb; every +/// other SDK error keeps its normal exit-code mapping. +fn map_drawdown_error(e: SdkError) -> CliError { + if let SdkError::Api { status, body } = &e { + let s = status.as_u16(); + if s == 402 || body.contains("insufficient_credits") || body.contains("no_credits") { + return CliError::Arg( + "out of x402 credits. Buy more with 'qn rpc x402 buy-credits', \ + then retry this call." + .to_string(), + ); + } + if body.contains("monthly_limit_reached") { + return CliError::Arg( + "the account's monthly x402 limit was reached; no credits were \ + drawn. Try again after the limit resets." + .to_string(), + ); + } + } + e.into() +} + /// Loads `[rpc.payment]` from the resolved config file. A missing file is an /// empty section; an unreadable/invalid file is a hard error, since payment /// parameters the user set there would otherwise be silently ignored. @@ -414,6 +547,7 @@ mod tests { endpoint_url: None, x402, mpp: !x402, + x402_drawdown: false, payment_key_file: None, payment_wallet: None, max_amount: Some("10000".to_string()), diff --git a/src/commands/rpc/x402.rs b/src/commands/rpc/x402.rs index 6a4999a..8b99e13 100644 --- a/src/commands/rpc/x402.rs +++ b/src/commands/rpc/x402.rs @@ -19,17 +19,13 @@ //! blind-retries. use clap::{Args as ClapArgs, Subcommand}; -use quicknode_sdk::{CreditBalance, GatewaySession, PaymentConfig}; +use quicknode_sdk::{CreditBalance, PaymentConfig}; use crate::config::{self, PaymentSection}; use crate::context::{Ctx, GlobalArgs}; use crate::errors::CliError; -use super::payment::{resolve_payment_params, PaymentParams}; - -// Re-auth margin: treat a session expiring within this window as stale and mint -// a fresh one, absorbing clock skew. Mirrors the tooling token's 60s margin. -const SESSION_MARGIN_SECS: i64 = 60; +use super::payment::{ensure_gateway_session, resolve_payment_params, PaymentParams}; #[derive(Debug, ClapArgs)] #[command(subcommand_required = true, arg_required_else_help = true)] @@ -144,30 +140,6 @@ fn load_payment_section(global: &GlobalArgs) -> Result .unwrap_or_default()) } -// Return a valid gateway session for the configured wallet, authenticating -// (and caching) when there's no fresh cached JWT. Free — no funds move — so no -// confirmation. The cache is keyed by the wallet's on-chain address, derived -// offline, so the lookup is a single local read; a hit skips the SIWX round -// trip, a miss (or an expired session) authenticates once and re-caches. -async fn ensure_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); - } - } - } - - let session = ctx.sdk.rpc.gateway_authenticate().await?; - if let Some(path) = &sessions_path { - let _ = config::save_gateway_session(path, &address, &session); - } - Ok(session) -} - async fn run_buy_credits(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { let (ctx, payment) = setup(&args, global.clone())?; @@ -180,7 +152,7 @@ async fn run_buy_credits(args: PaymentArgs, global: GlobalArgs) -> Result<(), Cl ); crate::confirm::confirm_mild(&ctx, &msg)?; - let session = ensure_session(&ctx, &global).await?; + let session = ensure_gateway_session(&ctx, &global).await?; let balance = ctx .sdk .rpc @@ -199,14 +171,14 @@ async fn run_buy_credits(args: PaymentArgs, global: GlobalArgs) -> Result<(), Cl async fn run_balance(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { let (ctx, _payment) = setup(&args, global.clone())?; - let session = ensure_session(&ctx, &global).await?; + 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: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { let (ctx, _payment) = setup(&args, global.clone())?; - let session = ensure_session(&ctx, &global).await?; + let session = ensure_gateway_session(&ctx, &global).await?; let balance = ctx.sdk.rpc.gateway_drip(&session).await?; ctx.out.note(&format!( diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index 8deeed9..1015510 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -1285,3 +1285,138 @@ async fn x402_drip_reports_balance() { let out = run_qn(&server.uri(), &x402_args(&cfg, &key_path, "drip")).await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); } + +// ── qn rpc call --x402-drawdown ────────────────────────────────────────────── +// +// The drawdown lane pays from prepaid credits: no per-call signing, Bearer JWT, +// 1 credit per success. The session is authenticated once (POST /auth), and a +// token_expired 401 triggers exactly one transparent re-auth + retry. + +fn drawdown_call_args<'a>(cfg: &'a str, key_path: &'a str) -> Vec<&'a str> { + vec![ + "--config-file", + cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402-drawdown", + "--payment-key-file", + key_path, + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000000", + ] +} + +#[tokio::test] +async fn x402_drawdown_happy_path_uses_bearer_no_signing() { + let server = MockServer::start().await; + mount_auth(&server).await; + // The drawdown POST carries a Bearer JWT and NO payment-signature. + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + }))) + .expect(1) // single attempt, no per-call 402 handshake + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let out = run_qn(&server.uri(), &drawdown_call_args(&cfg, &key_path)).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + // No token cache written (keyless lane), but the gateway session is cached. + assert!(!dir.path().join("tokens.toml").exists()); + assert!(dir.path().join("sessions.toml").exists()); +} + +/// Sequenced /base-sepolia responder: the FIRST drawdown call 401s with +/// token_expired; the SECOND (after a transparent re-auth) returns the result. +struct ExpiredThenOk { + 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(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": "0xafterreauth" + })) + } + } +} + +#[tokio::test] +async fn x402_drawdown_reauths_once_on_token_expired() { + let server = MockServer::start().await; + // Two /auth calls: the initial auth + the re-auth after the 401. + 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 { + 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, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_drawdown_out_of_credits_points_at_buy_credits() { + let server = MockServer::start().await; + mount_auth(&server).await; + // 402 on the drawdown call = no credits; must NOT sign or resend (single + // attempt), and must surface an actionable "buy-credits" error at exit 1. + 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; + // Actionable arg error (exit 1) pointing forward at the fixing verb. + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("buy-credits"), + "stderr should point at buy-credits, got: {}", + out.stderr + ); +} From ab0c18876d48b7866e9aeb5e91ec646180c0bfbf Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 17 Jul 2026 11:22:12 -0400 Subject: [PATCH 21/32] feat(rpc): qn rpc mpp channel lifecycle + --mpp-session on call Adds `qn rpc mpp {open, top-up, close, status}` for MPP payment channels and `qn rpc call --mpp-session` to pay from an open channel with a cumulative EIP-712 voucher (no on-chain tx per call), the counterpart to per-request --mpp. - open/top-up: deposit into the escrow (moves real funds on-chain, gated Mild). - close: cooperative settle + refund (gated Mild; the prompt warns further --mpp-session calls fail until re-open), then drops the local channel record. - status: the gateway's view, and the recovery path that re-seeds lost local channel state. - --mpp-session: joins the mutually-exclusive payment ArgGroup; requires an open channel and --network; advances the cumulative by one per-call unit, single-attempt; an exhausted deposit points at `qn rpc mpp top-up`. Channel state persists at /qn/channels.toml (0600, keyed by wallet address + network; amounts stored as strings since TOML has no u128). Syncs the agent guide and adds a README MPP-session walkthrough + call --help example. 4 new integration tests (open happy/gating, no-channel session call, plus the channel-state cache assertion). --- README.md | 30 +++ src/commands/agent/context.md | 26 ++- src/commands/rpc/mod.rs | 20 +- src/commands/rpc/mpp.rs | 357 ++++++++++++++++++++++++++++++++++ src/commands/rpc/payment.rs | 92 +++++++++ src/config.rs | 141 ++++++++++++++ tests/rpc_payment.rs | 157 +++++++++++++++ 7 files changed, 816 insertions(+), 7 deletions(-) create mode 100644 src/commands/rpc/mpp.rs diff --git a/README.md b/README.md index a0e7872..7a88b3f 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,36 @@ dir, refreshed automatically. When you run out, the call points you back at `qn rpc x402 buy-credits`. Like every paid call, a drawdown call never auto-retries. +#### MPP payment channel (session) + +For high-frequency access, open an on-chain escrow payment channel once, then +pay per call with a cumulative EIP-712 voucher (no on-chain transaction per +call). Open, call, then top-up or close: + +```sh +qn rpc wallet generate --chain evm --name payer # evm covers Tempo; fund its address + +# Open a channel by depositing into the escrow (moves real funds; gated). +qn rpc mpp open --network tempo-testnet --deposit 1000000 \ + --payment-wallet payer --payment-network tempo-testnet --payment-asset USDC + +# Pay for calls from the channel — one cumulative voucher per call. +qn rpc call eth_blockNumber --network tempo-testnet --mpp-session \ + --payment-wallet payer --payment-network tempo-testnet --payment-asset USDC + +# Inspect the channel (also recovers local state from the gateway). +qn rpc mpp status --network tempo-testnet --payment-wallet payer \ + --payment-network tempo-testnet --payment-asset USDC + +# Add more deposit, or close to settle on-chain and refund the unused balance. +qn rpc mpp top-up --network tempo-testnet --deposit 1000000 --payment-wallet payer ... +qn rpc mpp close --network tempo-testnet --payment-wallet payer ... +``` + +Channel state is cached (0600) under the config dir, keyed by wallet and +network. When the deposit is exhausted the call points you at `qn rpc mpp +top-up`; after `close`, open a new channel to keep paying by session. + ### Other ```sh diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index 671ef78..787bed3 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -106,12 +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`) 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 **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 @@ -194,6 +195,19 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase confirmation), including one automatic re-auth if a drawdown call's token expired mid-use. Out of credits surfaces an actionable error pointing at `qn rpc x402 buy-credits`; nothing auto-retries a credit-drawing call. + **MPP session**: `qn rpc mpp {open, top-up, close, status}` manages an + on-chain escrow payment channel (Tempo), and `qn rpc call --mpp-session` pays + from it with a cumulative EIP-712 voucher (no on-chain tx per call). All take + the same payment param stack (a Tempo wallet) plus `--network` (the query + chain the channel lives on). `open --deposit ` and + `top-up --deposit ` move real funds on-chain (gated Mild); `close` + cooperatively settles + refunds (gated Mild; its prompt warns further + `--mpp-session` calls fail until re-open); `status` shows the gateway's view + and is the recovery path for lost local channel state. Channel state is cached + under `/qn/channels.toml` (0600, keyed by wallet address + + network). `--mpp-session` requires an open channel, is mutually exclusive with + `--x402`/`--mpp`/`--x402-drawdown`/`--endpoint-url`, and points at + `qn rpc mpp top-up` when the channel deposit is exhausted. Drill into any level with `--help`: `qn endpoint --help`, `qn endpoint security --help`, `qn endpoint rate-limit --help`. Shell completions: `qn completions `. diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index 7692bfd..43e096a 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -24,6 +24,7 @@ //! branches off before any of this module's token-cache or Tooling Access //! machinery runs. +mod mpp; mod pay_asset; mod pay_network; mod pay_networks; @@ -73,6 +74,9 @@ pub enum RpcCmd { --payment-network base-sepolia --payment-asset USDC --max-amount 10000000\n \ qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown \\\n \ --payment-wallet payer --payment-network base-sepolia --payment-asset USDC\n\n\ + MPP payment channel (open once, then pay per call with a voucher):\n \ + qn rpc mpp open --network tempo-testnet --deposit 1000000 --payment-wallet payer\n \ + qn rpc call eth_blockNumber --network tempo-testnet --mpp-session --payment-wallet payer\n\n\ See payable networks and manage wallets:\n \ qn rpc pay-networks\n \ qn rpc wallet generate --chain evm --name payer")] @@ -93,12 +97,16 @@ pub enum RpcCmd { /// 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"])), - group(ArgGroup::new("payment").args(["x402", "mpp", "x402_drawdown"])), + group(ArgGroup::new("payment").args(["x402", "mpp", "x402_drawdown", "mpp_session"])), )] pub struct CallArgs { /// The JSON-RPC method, e.g. `eth_blockNumber`. @@ -151,6 +159,12 @@ pub struct CallArgs { #[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 @@ -236,6 +250,7 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { } RpcCmd::PayNetworks => pay_networks::run(global).await, RpcCmd::X402(x402_args) => x402::run(x402_args, global).await, + RpcCmd::Mpp(mpp_args) => mpp::run(mpp_args, global).await, } } @@ -263,6 +278,9 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { 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())?; diff --git a/src/commands/rpc/mpp.rs b/src/commands/rpc/mpp.rs new file mode 100644 index 0000000..a6797cc --- /dev/null +++ b/src/commands/rpc/mpp.rs @@ -0,0 +1,357 @@ +//! `qn rpc mpp …` — the MPP payment-channel (session) lifecycle. +//! +//! Four verbs manage an on-chain escrow payment channel, all keyless (funded by +//! the configured Tempo wallet, not an account API key): +//! - `open` — deposit into the escrow, opening a channel. Gated Mild. +//! - `top-up` — add deposit to the open channel. Gated Mild. +//! - `close` — cooperatively close (settle on-chain + refund). Gated Mild; +//! the prompt warns that further `--mpp-session` calls fail until re-open. +//! - `status` — the gateway's view of the channel (also the recovery path +//! when local channel state is lost). +//! +//! Channel state (channelId, deposit, cumulative spend) is persisted under the +//! config dir (`channels.toml`, 0600, keyed by wallet address + network) and +//! re-seeded next run. Every lifecycle verb ends with a ready-to-run next +//! command. Paid verbs are single-attempt. + +use clap::{Args as ClapArgs, Subcommand}; +use quicknode_sdk::ChannelState; + +use crate::config::{self, PaymentSection}; +use crate::context::{Ctx, GlobalArgs}; +use crate::errors::CliError; + +use super::payment::{resolve_payment_params, PaymentParams}; + +#[derive(Debug, ClapArgs)] +#[command(subcommand_required = true, arg_required_else_help = true)] +pub struct Args { + #[command(subcommand)] + pub cmd: MppCmd, +} + +#[derive(Debug, Subcommand)] +pub enum MppCmd { + /// Open a payment channel by depositing into the escrow. Gated: names the + /// deposit before signing. + #[command(after_help = "Examples:\n \ + qn rpc mpp open --network tempo-testnet --deposit 1000000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet --payment-asset USDC")] + Open(OpenArgs), + + /// Add deposit to the open channel. Gated. + #[command(name = "top-up")] + TopUp(TopUpArgs), + + /// Cooperatively close the channel (settle on-chain + refund unused + /// deposit). Gated. + Close(ChannelArgs), + + /// Show the gateway's view of the channel (also the state-recovery path). + Status(ChannelArgs), +} + +/// The payment parameter stack + query network shared by the MPP verbs. +#[derive(Debug, ClapArgs)] +pub struct PaymentArgs { + /// The query chain, as the payment gateway's path slug (e.g. + /// `tempo-testnet`). The channel lives on this network. + #[arg(long, value_name = "NETWORK")] + pub network: String, + + /// File containing the raw Tempo payment key (hex); `-` reads stdin. + /// Precedence: this > --payment-wallet > `key_file` > `wallet` in config. + #[arg(long, value_name = "PATH", conflicts_with = "payment_wallet")] + pub payment_key_file: Option, + + /// Name of a stored wallet (from `qn rpc wallet generate`) to pay with. + #[arg(long, value_name = "NAME")] + pub payment_wallet: Option, + + /// Spend ceiling for a single signed action (deposit/top-up), in integer + /// base units. Flag > `max_amount` in [rpc.payment]. + #[arg(long, value_name = "BASE_UNITS")] + pub max_amount: Option, + + /// Chain the channel settles on: a network name or CAIP-2 id. Falls back to + /// `payment_network` in [rpc.payment]. + #[arg(long, value_name = "NETWORK")] + pub payment_network: Option, + + /// Token the channel is denominated in: an address or a symbol like USDC. + /// Falls back to `payment_asset` in [rpc.payment]. + #[arg(long, value_name = "ADDRESS")] + pub payment_asset: Option, + + /// Explicit Solana RPC URL (unused for Tempo channels; accepted for a + /// uniform flag stack). Falls back to `svm_rpc_url` in [rpc.payment]. + #[arg(long, value_name = "URL")] + pub svm_rpc_url: Option, +} + +impl PaymentArgs { + fn params(&self) -> PaymentParams<'_> { + PaymentParams { + key_file: self.payment_key_file.as_deref(), + wallet: self.payment_wallet.as_deref(), + max_amount: self.max_amount.as_deref(), + payment_network: self.payment_network.as_deref(), + payment_asset: self.payment_asset.as_deref(), + svm_rpc_url: self.svm_rpc_url.as_deref(), + } + } +} + +#[derive(Debug, ClapArgs)] +pub struct OpenArgs { + #[command(flatten)] + pub payment: PaymentArgs, + + /// Amount to deposit into the escrow, in integer base units of the asset. + #[arg(long, value_name = "BASE_UNITS")] + pub deposit: String, +} + +#[derive(Debug, ClapArgs)] +pub struct TopUpArgs { + #[command(flatten)] + pub payment: PaymentArgs, + + /// Additional amount to deposit into the open channel, in base units. + #[arg(long, value_name = "BASE_UNITS")] + pub deposit: String, +} + +/// Verbs that operate on the already-open channel (no new deposit amount). +#[derive(Debug, ClapArgs)] +pub struct ChannelArgs { + #[command(flatten)] + pub payment: PaymentArgs, +} + +pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { + match args.cmd { + MppCmd::Open(a) => run_open(a, global).await, + MppCmd::TopUp(a) => run_top_up(a, global).await, + MppCmd::Close(a) => run_close(a.payment, global).await, + MppCmd::Status(a) => run_status(a.payment, global).await, + } +} + +// Shared setup: resolve the MPP payment config (scheme "mpp"), build the +// keyless-payment Ctx, and surface any key-file warning. No network I/O. +fn setup(args: &PaymentArgs, global: GlobalArgs) -> Result<(Ctx, String), CliError> { + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_payment_params( + "mpp", + &args.params(), + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + let network = args.network.clone(); + let ctx = Ctx::from_global_keyless_payment(global, payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + Ok((ctx, network)) +} + +fn load_payment_section(global: &GlobalArgs) -> Result { + let Some(path) = global.resolve_config_path() else { + return Ok(PaymentSection::default()); + }; + Ok(config::load_from(&path)? + .map(|cfg| cfg.rpc.payment) + .unwrap_or_default()) +} + +fn parse_base_units(s: &str, flag: &str) -> Result { + s.parse::().map_err(|_| { + CliError::Arg(format!( + "--{flag} must be a non-negative integer in base units, got '{s}'" + )) + }) +} + +async fn run_open(args: OpenArgs, global: GlobalArgs) -> Result<(), CliError> { + let deposit = parse_base_units(&args.deposit, "deposit")?; + let (ctx, network) = setup(&args.payment, global.clone())?; + + crate::confirm::confirm_mild( + &ctx, + &format!( + "Open an MPP channel with a {deposit} base-unit deposit on {network}? \ + This moves real funds on-chain." + ), + )?; + + let channel = ctx + .sdk + .rpc + .mpp_open(&network, deposit) + .await + .map_err(super::payment::map_paid_error)?; + persist_channel(&ctx, &global, &network, &channel); + + ctx.out.note(&format!( + "✓ Opened channel {} (deposit: {})", + channel.channel_id, channel.deposit + )); + ctx.out.note(&format!( + " Next: qn rpc call eth_blockNumber --network {network} --mpp-session" + )); + emit_channel(&ctx, &channel) +} + +async fn run_top_up(args: TopUpArgs, global: GlobalArgs) -> Result<(), CliError> { + let additional = parse_base_units(&args.deposit, "deposit")?; + let (ctx, network) = setup(&args.payment, global.clone())?; + let channel = require_channel(&ctx, &global, &network)?; + + crate::confirm::confirm_mild( + &ctx, + &format!( + "Top up channel {} with {additional} more base units on {network}? \ + This moves real funds on-chain.", + channel.channel_id + ), + )?; + + let updated = ctx + .sdk + .rpc + .mpp_top_up(&network, &channel, additional) + .await + .map_err(super::payment::map_paid_error)?; + persist_channel(&ctx, &global, &network, &updated); + + ctx.out.note(&format!( + "✓ Topped up channel {} (deposit: {})", + updated.channel_id, updated.deposit + )); + ctx.out + .note(&format!(" Next: qn rpc mpp status --network {network}")); + emit_channel(&ctx, &updated) +} + +async fn run_close(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { + let (ctx, network) = setup(&args, global.clone())?; + let channel = require_channel(&ctx, &global, &network)?; + + crate::confirm::confirm_mild( + &ctx, + &format!( + "Close channel {} on {network}? It settles on-chain and refunds the \ + unused deposit; further --mpp-session calls fail until you open a \ + new channel.", + channel.channel_id + ), + )?; + + ctx.sdk + .rpc + .mpp_close(&network, &channel) + .await + .map_err(super::payment::map_paid_error)?; + // The channel is settled: drop the local record so a stale one can't be + // reused. Best-effort — a failed delete must not fail the completed close. + if let Some(address) = wallet_address(&ctx) { + if let Some(path) = config::channels_cache_path(global.resolve_config_path().as_deref()) { + let _ = config::delete_channel(&path, &address, &network); + } + } + + ctx.out + .note(&format!("✓ Closed channel {}", channel.channel_id)); + ctx.out.note(&format!( + " Next: qn rpc mpp open --network {network} --deposit " + )); + Ok(()) +} + +async fn run_status(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { + let (ctx, network) = setup(&args, global.clone())?; + let channel = require_channel(&ctx, &global, &network)?; + + let status = ctx + .sdk + .rpc + .mpp_status(&network, &channel.channel_id) + .await?; + + // Re-seed local state from the gateway's high-water mark (the recovery + // path): the gateway is authoritative for deposit + accepted cumulative. + let mut synced = channel.clone(); + synced.deposit = status.deposit; + synced.cumulative_spent = status.accepted_cumulative; + persist_channel(&ctx, &global, &network, &synced); + + if matches!(ctx.global.format, Some(f) if f.is_structured()) { + let v = serde_json::json!({ + "channel_id": status.channel_id, + "deposit": status.deposit, + "accepted_cumulative": status.accepted_cumulative, + "remaining": status.deposit.saturating_sub(status.accepted_cumulative), + }); + return super::emit_result(&ctx, &v); + } + ctx.out.note(&format!( + "channel {}: deposit {}, spent {}, remaining {}", + status.channel_id, + status.deposit, + status.accepted_cumulative, + status.deposit.saturating_sub(status.accepted_cumulative), + )); + Ok(()) +} + +// The wallet's on-chain address (derived offline), used to key channel state. +fn wallet_address(ctx: &Ctx) -> Option { + ctx.sdk.rpc.payment_address().ok() +} + +// Persist channel state, keyed by wallet address + network. Best-effort. +fn persist_channel(ctx: &Ctx, global: &GlobalArgs, network: &str, channel: &ChannelState) { + if let Some(address) = wallet_address(ctx) { + if let Some(path) = config::channels_cache_path(global.resolve_config_path().as_deref()) { + let _ = config::save_channel(&path, &address, network, channel); + } + } +} + +// Load the open channel for this wallet+network, or an actionable error +// pointing at `mpp open`. A lost local record is recovered by re-opening or, +// if the channel is known, by `mpp status` (which re-seeds it). +fn require_channel( + ctx: &Ctx, + global: &GlobalArgs, + network: &str, +) -> Result { + let address = wallet_address(ctx) + .ok_or_else(|| CliError::Arg("could not derive the payment wallet address".to_string()))?; + let path = config::channels_cache_path(global.resolve_config_path().as_deref()); + let channel = path + .as_deref() + .and_then(|p| config::load_channel(p, &address, network)); + channel.ok_or_else(|| { + CliError::Arg(format!( + "no open MPP channel for this wallet on {network}. Open one with \ + 'qn rpc mpp open --network {network} --deposit '." + )) + }) +} + +fn emit_channel(ctx: &Ctx, channel: &ChannelState) -> Result<(), CliError> { + if matches!(ctx.global.format, Some(f) if f.is_structured()) { + let v = serde_json::json!({ + "channel_id": channel.channel_id, + "deposit": channel.deposit, + "cumulative_spent": channel.cumulative_spent, + }); + return super::emit_result(ctx, &v); + } + println!("{}", channel.channel_id); + Ok(()) +} diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index 2334287..ba30bb9 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -231,6 +231,97 @@ fn map_drawdown_error(e: SdkError) -> CliError { e.into() } +/// Entry point from `run_call` once `--mpp-session` is present. Pays for the +/// call from an open MPP channel with a cumulative EIP-712 voucher: no on-chain +/// tx per call. Single-attempt (a paid lane never blind-retries). +/// +/// Requires an already-open channel for this wallet + query network (from +/// `qn rpc mpp open`); a missing channel or one whose deposit is exhausted +/// surfaces an actionable error pointing at `mpp open` / `mpp top-up`. +pub(super) async fn run_session_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + let Some(network) = args.network.clone() else { + return Err(CliError::Arg( + "--mpp-session requires --network: the chain the call queries, as \ + the payment gateway's path slug (e.g. --network tempo-testnet)." + .to_string(), + )); + }; + + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_payment_params( + "mpp", + &args.payment_params(), + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + let params = super::parse_params(args.params.as_deref(), args.params_file.as_deref())?; + + let ctx = Ctx::from_global_keyless_payment(global.clone(), payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + + let address = ctx.sdk.rpc.payment_address()?; + let channels_path = config::channels_cache_path(global.resolve_config_path().as_deref()); + let mut channel = channels_path + .as_deref() + .and_then(|p| config::load_channel(p, &address, &network)) + .ok_or_else(|| { + CliError::Arg(format!( + "no open MPP channel for this wallet on {network}. Open one with \ + 'qn rpc mpp open --network {network} --deposit '." + )) + })?; + + // Advance the cumulative by one per-call unit. Refuse (before any signing) + // when the deposit can't cover it: point at top-up. + let new_cumulative = channel.cumulative_spent.saturating_add(channel.per_call); + if new_cumulative > channel.deposit { + return Err(CliError::Arg(format!( + "MPP channel deposit exhausted (deposit {}, would need {}). Top up \ + with 'qn rpc mpp top-up --network {network} --deposit '.", + channel.deposit, new_cumulative + ))); + } + + let result = ctx + .sdk + .rpc + .mpp_session_call(&args.method, params, &network, &channel, new_cumulative) + .await + .map_err(map_session_error)?; + + // The voucher was accepted: advance and persist the local high-water mark. + channel.cumulative_spent = new_cumulative; + if let Some(path) = &channels_path { + let _ = config::save_channel(path, &address, &network, &channel); + } + + super::emit_result(&ctx, &result) +} + +/// Maps a session-call failure onto an actionable CLI error. A refusal to +/// accept the voucher for want of deposit points at `mpp top-up`; everything +/// else keeps its normal exit-code mapping. +fn map_session_error(e: SdkError) -> CliError { + if let SdkError::Api { status, body } = &e { + if status.as_u16() == 402 + || body.contains("amount-exceeds-deposit") + || body.contains("AmountExceedsDeposit") + || body.contains("insufficient") + { + return CliError::Arg( + "the MPP channel can't cover this call. Top up with \ + 'qn rpc mpp top-up', or open a new channel with 'qn rpc mpp open'." + .to_string(), + ); + } + } + map_paid_error(e) +} + /// Loads `[rpc.payment]` from the resolved config file. A missing file is an /// empty section; an unreadable/invalid file is a hard error, since payment /// parameters the user set there would otherwise be silently ignored. @@ -548,6 +639,7 @@ mod tests { x402, mpp: !x402, x402_drawdown: false, + mpp_session: false, payment_key_file: None, payment_wallet: None, max_amount: Some("10000".to_string()), diff --git a/src/config.rs b/src/config.rs index f4a5f90..c4c44b4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -544,6 +544,147 @@ pub fn save_gateway_session( write_atomic_0600(path, text.as_bytes(), ".qn-sessions-") } +// ── MPP channel state ──────────────────────────────────────────────────────── +// +// An open MPP payment channel is long-lived local state (channelId, deposit, +// cumulative spend) reused across `qn rpc` processes. We persist it in +// `channels.toml` alongside the config, keyed by payer address + query network +// so one wallet can hold a channel per network. The gateway is the source of +// truth for the high-water mark; `qn rpc mpp status` re-derives a lost entry. + +/// On-disk shape of `~/.config/qn/channels.toml`. Amounts are stored as +/// strings because TOML has no unsigned-128-bit integer type; they round-trip +/// through [`ChannelEntry`] to the SDK's `u128`-typed `ChannelState`. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ChannelCacheFile { + /// ":" -> the open channel's state. + #[serde(default)] + pub channels: HashMap, +} + +/// TOML-safe channel record (u128 amounts as decimal strings). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelEntry { + pub channel_id: String, + pub token: String, + pub payee: String, + pub operator: String, + pub salt: String, + pub authorized_signer: String, + pub expiring_nonce_hash: 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(), + operator: s.operator.clone(), + salt: s.salt.clone(), + authorized_signer: s.authorized_signer.clone(), + expiring_nonce_hash: s.expiring_nonce_hash.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(), + operator: self.operator.clone(), + salt: self.salt.clone(), + authorized_signer: self.authorized_signer.clone(), + expiring_nonce_hash: self.expiring_nonce_hash.clone(), + deposit: self.deposit.parse().ok()?, + cumulative_spent: self.cumulative_spent.parse().ok()?, + per_call: self.per_call.parse().ok()?, + chain_id: self.chain_id, + }) + } +} + +/// The channel-state cache path: `channels.toml` alongside the resolved config. +pub fn channels_cache_path(config_path: Option<&Path>) -> Option { + match config_path { + Some(p) => p.parent().map(|d| d.join("channels.toml")), + None => config_dir().map(|d| d.join("qn").join("channels.toml")), + } +} + +fn channel_key(address: &str, network: &str) -> String { + format!("{}:{}", address.to_lowercase(), network) +} + +/// Loads the open channel for `(address, network)`, if any. +pub fn load_channel( + path: &Path, + address: &str, + network: &str, +) -> Option { + let text = fs::read_to_string(path).ok()?; + let cache: ChannelCacheFile = toml::from_str(&text).ok()?; + cache + .channels + .get(&channel_key(address, network)) + .and_then(ChannelEntry::to_state) +} + +/// Stores `channel` under `(address, network)`, preserving other entries. +/// Written atomically at 0600. +pub fn save_channel( + path: &Path, + address: &str, + network: &str, + channel: &quicknode_sdk::ChannelState, +) -> Result<(), CliError> { + let mut cache: ChannelCacheFile = fs::read_to_string(path) + .ok() + .and_then(|t| toml::from_str(&t).ok()) + .unwrap_or_default(); + cache.channels.insert( + channel_key(address, network), + ChannelEntry::from_state(channel), + ); + let text = toml::to_string_pretty(&cache).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + write_atomic_0600(path, text.as_bytes(), ".qn-channels-") +} + +/// Drops the channel entry for `(address, network)` (e.g. after a close), +/// leaving other entries intact. No-op if absent. +pub fn delete_channel(path: &Path, address: &str, network: &str) -> Result<(), CliError> { + let mut cache: ChannelCacheFile = match fs::read_to_string(path) + .ok() + .and_then(|t| toml::from_str(&t).ok()) + { + Some(c) => c, + None => return Ok(()), + }; + if cache + .channels + .remove(&channel_key(address, network)) + .is_none() + { + return Ok(()); + } + let text = toml::to_string_pretty(&cache).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + write_atomic_0600(path, text.as_bytes(), ".qn-channels-") +} + /// Loads the cached network map for `endpoint_id` from `path`, if present, for /// the same endpoint, and fetched within the TTL (relative to `now_unix`). /// Returns `None` (a cache miss) on any mismatch or parse failure. diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index 1015510..bfacbe4 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -1420,3 +1420,160 @@ async fn x402_drawdown_out_of_credits_points_at_buy_credits() { out.stderr ); } + +// ── qn rpc mpp (payment channel session) ───────────────────────────────────── +// +// The MPP session lane opens an escrow channel (an on-chain Tempo tx, signed +// offline against the mock), then pays with cumulative vouchers. The mock +// gateway serves a tempo/session 402 challenge on probes and 2xx on credential +// POSTs; channel status is a GET under /session/:network/channels/:id. + +use base64::Engine as _; + +// A base64url tempo/session request body (currency, recipient, amount, chainId). +fn session_request_b64() -> String { + let json = json!({ + "amount": "500", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0xfd24114c3981aba78ae2441991b1bdb89329c556", + "methodDetails": { "chainId": 42431 } + }); + 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() + ) +} + +// Mount the session endpoint: a 402 session challenge on an unauthorized POST +// (the probe), and 200 on any POST carrying an Authorization: Payment header +// (credential submissions: open/topUp/voucher/close). +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).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 mpp_args<'a>(cfg: &'a str, key_path: &'a str, verb: &'a str) -> Vec<&'a str> { + vec![ + "--config-file", + cfg, + "rpc", + "mpp", + verb, + "--network", + "tempo-testnet", + "--payment-key-file", + key_path, + "--payment-network", + "eip155:42431", + "--payment-asset", + "0x20c0000000000000000000000000000000000000", + "--max-amount", + "100000000", + ] +} + +#[tokio::test] +async fn mpp_open_happy_path_and_caches_channel() { + let server = MockServer::start().await; + mount_session(&server, "tempo-testnet").await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let mut args = mpp_args(&cfg, &key_path, "open"); + args.extend_from_slice(&["--deposit", "1000000", "--yes"]); + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + assert!( + dir.path().join("channels.toml").exists(), + "open must cache the channel state" + ); +} + +#[tokio::test] +async fn mpp_open_without_yes_is_needs_confirmation_and_settles_nothing() { + let server = MockServer::start().await; + // Gate is checked before any network I/O: nothing reaches the gateway. + Mock::given(method("POST")) + .and(path("/session/tempo-testnet")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let mut args = mpp_args(&cfg, &key_path, "open"); + args.extend_from_slice(&["--deposit", "1000000"]); + let out = run_qn(&server.uri(), &args).await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn mpp_session_call_without_open_channel_points_at_open() { + let server = MockServer::start().await; + // No channel cached; the call must refuse before any gateway I/O. + Mock::given(method("POST")) + .and(path("/session/tempo-testnet")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "tempo-testnet", + "--mpp-session", + "--payment-key-file", + &key_path, + "--payment-network", + "eip155:42431", + "--payment-asset", + "0x20c0000000000000000000000000000000000000", + "--max-amount", + "100000000", + ], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("mpp open"), + "should point at 'mpp open', got: {}", + out.stderr + ); +} From 3f0736e375d3651d7ccc41a758371a361ef0d070 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 17 Jul 2026 11:24:52 -0400 Subject: [PATCH 22/32] docs(rpc): one Micropayments section with four zero-to-call paths Restructures the README paid-lane docs into a single Micropayments section: a comparison table of the four ways to pay, a shared "Get a wallet" preamble (local generation or bring-your-own-key), and four self-contained testnet-first walkthroughs (x402 per-request, MPP per-request, x402 drawdown, MPP session), each copy-pasteable end to end. Shared flags, config, and wallet management fold into one closing subsection. --- README.md | 244 ++++++++++++++++++++++++++---------------------------- 1 file changed, 116 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 7a88b3f..b473a4e 100644 --- a/README.md +++ b/README.md @@ -327,23 +327,57 @@ 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). -#### Pay per call with a crypto micropayment +#### Micropayments -`--x402` (EVM or Solana stablecoin) or `--mpp` (Tempo) pays for the call per -request instead of using an account API key — no login and no Tooling Access -needed. **This moves real funds** (testnet tokens are still real transfers): -use a dedicated, minimally funded wallet. +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: -The quickest path: see what's payable, generate a wallet, fund that exact -address, then pay by name. `--payment-asset` and `--max-amount` must match a -real payment offer for the network — `qn rpc pay-networks` shows the x402 asset -per network, and a paid call that names an unfunded wallet or a mismatched -asset/amount is refused with HTTP 402 before anything settles. +| 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. | -**x402 on EVM (Base Sepolia testnet, USDC):** +**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 rpc wallet generate --chain evm --name payer # evm also covers MPP/Tempo + qn rpc wallet generate --chain 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. + +`qn rpc pay-networks` (alias `pay-nets`) lists what each gateway accepts (no +API key needed): a listed network is a valid `--network`, and its asset column +is a ready `--payment-asset`. 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 rpc wallet generate --chain evm --name payer # prints the address + a QR; fund THAT address +qn rpc wallet generate --chain evm --name payer # fund the printed address with Base Sepolia USDC qn rpc call eth_blockNumber \ --network base-sepolia --x402 \ --payment-wallet payer \ @@ -352,14 +386,21 @@ qn rpc call eth_blockNumber \ --max-amount 1000 ``` -`--max-amount 1000` selects the per-request USDC offer (0.001 USDC). Fund the -wallet's address with a little Base Sepolia USDC and the same command returns -the block number. +`--network` is the chain you *query*; `--payment-network` is the chain the +payment *settles* on (independent). `--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 an `--chain svm` wallet and use +`--network solana-devnet --payment-network solana-devnet` (add `--svm-rpc-url` +at volume; the public default rate-limits). -**MPP on Tempo (testnet):** same EVM wallet works (MPP uses the secp256k1 key -format); `--receipt` wraps the result with the settlement reference. +##### Path 2 — MPP per-request (charge) + +The same as path 1 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 rpc wallet generate --chain evm --name payer # fund on Tempo testnet qn rpc call eth_blockNumber \ --network tempo-testnet --mpp --receipt \ --payment-wallet payer \ @@ -368,116 +409,26 @@ qn rpc call eth_blockNumber \ --max-amount 1000 ``` -**x402 on Solana (devnet):** needs an SVM wallet. - -```sh -qn rpc wallet generate --chain svm --name sol-payer # fund this base58 address -qn rpc call getSlot \ - --network solana-devnet --x402 \ - --payment-wallet sol-payer \ - --payment-network solana-devnet \ - --payment-asset USDC \ - --max-amount 1000 -``` - -The query chain (`--network`) and the settlement chain (`--payment-network`) -are independent, but the `--payment-network`/`--payment-asset`/`--max-amount` -trio must be a combination the gateway actually offers (again, `qn rpc -pay-networks`). You can also point `--payment-key-file ` at a key file -you manage yourself instead of a stored wallet. - -- `--network` is required and names the chain you *query*, as the payment - gateway's path slug (independent of `--payment-network`, the chain the - payment *settles* on). -- `--payment-network` takes a Quicknode network name (`base-sepolia`, - `solana-devnet`, `tempo-testnet`, ...) or a raw CAIP-2 id (`eip155:84532`, - `solana:EtWTRA...`). Anything containing a `:` is passed through verbatim, - so any `eip155:` works even without a named entry. -- `--payment-asset` takes a token contract address (EVM), a mint (Solana), or - a symbol like `USDC` resolved to that network's address; run `qn rpc - pay-networks` for the raw addresses. -- The private key resolves from `--payment-key-file ` (`-` for stdin), - then `--payment-wallet ` (a stored wallet), then `key_file`, then - `wallet` in config. It always comes from a file or a stored wallet — never - an environment variable, never a flag value, never inline in config, and - never printed. -- `--max-amount` is the per-call spend ceiling in integer base units of the - asset (e.g. `10000` = 0.01 USDC). There is no built-in default; offers above - the ceiling are refused before anything is signed. -- `--receipt` wraps stdout as `{"result": ..., "payment_receipt": ...}`. On - MPP the receipt is an object (`method`, `status`, `timestamp`, and - `reference` — the settlement transaction hash); on x402 it is `null`. - Without it, paid output is shaped exactly like unpaid output. -- Paid calls **never auto-retry** (`--retries` does not apply). Exit code 2 - means the gateway refused and nothing settled; exit 3 means the outcome is - unknown — the payment was submitted and may have settled; check the wallet - before re-running. -- x402/Solana at volume: pass `--svm-rpc-url `; the default public Solana - RPC rate-limits aggressively. - -Store the parameters once in `~/.config/qn/config.toml` and the invocation -shrinks to 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 base-sepolia --x402 -``` - -##### Managing payment wallets +`--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`. -`qn rpc wallet` keeps dedicated payment wallets under -`~/.config/qn/wallets/` (raw key at `0600`; the key is stored unencrypted, so -treat each as a dedicated, minimally funded hot wallet): +##### Path 3 — x402 drawdown (buy credits, then call) -```sh -qn rpc wallet generate --chain evm --name payer # evm also covers MPP/Tempo; svm for x402/Solana -qn rpc wallet list # names, chain, address (never the key) -qn rpc wallet show payer # address to stdout, plus a QR to fund it on a terminal -qn rpc wallet rm payer # gated: --yes to confirm; the key is unrecoverable -``` - -`qn rpc wallet show payer` prints only the bare address to stdout (the key -file path, QR, and hint go to stderr), so `qn rpc wallet show payer` in a pipe -yields just the address. - -These wallets live only on your machine. **Quicknode does not hold, back up, -or recover them** — managing and backing up the key file (shown on -`generate`/`show`) is your responsibility; if you lose it, any funds in the -wallet are gone. - -##### Discovering payable networks - -`qn rpc pay-networks` (alias `pay-nets`) lists the networks the paid lane can -use, read from the gateways' public discovery endpoints (no API key). A listed -network is a valid `--network`; the x402 asset column is a ready -`--payment-asset` value. The list is cached in -`~/.config/qn/pay-networks.toml` (24h TTL). - -#### Prepaid x402 credits (drawdown) - -Instead of signing a payment on every call, buy a block of credits once and -draw them down: no per-call signing, one credit per successful response. Buy -credits, then call with `--x402-drawdown`: +Buy a block of prepaid credits once, then spend them with no per-call signing +(one credit per successful response): ```sh -qn rpc wallet generate --chain evm --name payer # dedicated wallet; fund its address +qn rpc wallet generate --chain evm --name payer # fund the printed address -# Testnet: get free credits from the faucet (Base Sepolia, once per account). +# Testnet: free credits from the faucet (Base Sepolia, once per account). qn rpc x402 drip --payment-wallet payer --payment-network base-sepolia --payment-asset USDC # Or buy credits (moves real funds; gated — pass --yes to skip the prompt). qn rpc x402 buy-credits --payment-wallet payer \ --payment-network base-sepolia --payment-asset USDC --max-amount 10000000 -# Check the balance any time (prints the bare number). +# Check the balance (prints the bare number). qn rpc x402 balance --payment-wallet payer --payment-network base-sepolia --payment-asset USDC # Spend credits on calls. @@ -485,19 +436,17 @@ qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown \ --payment-wallet payer --payment-network base-sepolia --payment-asset USDC ``` -The gateway session is authenticated once and cached (0600) under the config -dir, refreshed automatically. When you run out, the call points you back at -`qn rpc x402 buy-credits`. Like every paid call, a drawdown call never -auto-retries. +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`. -#### MPP payment channel (session) +##### Path 4 — MPP session (open a channel, then call) -For high-frequency access, open an on-chain escrow payment channel once, then -pay per call with a cumulative EIP-712 voucher (no on-chain transaction per -call). Open, call, then top-up or close: +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 rpc wallet generate --chain evm --name payer # evm covers Tempo; fund its address +qn rpc wallet generate --chain evm --name payer # evm covers Tempo; fund the address # Open a channel by depositing into the escrow (moves real funds; gated). qn rpc mpp open --network tempo-testnet --deposit 1000000 \ @@ -516,9 +465,48 @@ qn rpc mpp top-up --network tempo-testnet --deposit 1000000 --payment-wallet pay qn rpc mpp close --network tempo-testnet --payment-wallet payer ... ``` -Channel state is cached (0600) under the config dir, keyed by wallet and -network. When the deposit is exhausted the call points you at `qn rpc mpp -top-up`; after `close`, open a new channel to keep paying by session. +Channel state is cached (0600) under the config dir, keyed by wallet + network. +Exhausting the deposit points you at `qn rpc mpp top-up`; after `close`, open a +new channel to keep paying by session. + +##### Shared flags, config, and wallet management + +The flag stack is the same across all four paths: + +- `--payment-network` takes a Quicknode network name (`base-sepolia`, + `solana-devnet`, `tempo-testnet`, ...) or a raw CAIP-2 id (`eip155:84532`, + `solana:EtWTRA...`); anything with a `:` passes through verbatim. +- `--payment-asset` takes a token address (EVM), a mint (Solana), or a symbol + like `USDC` resolved to that network's address. +- `--max-amount` is the per-signature spend ceiling in integer base units; + offers/deposits above it are refused before anything is signed. +- Exit code 2 means the gateway refused and nothing settled; exit 3 means the + outcome is unknown (payment submitted, may have settled — check the wallet + before re-running). + +Store the parameters once in `~/.config/qn/config.toml` and the per-request +invocation shrinks to just the scheme flag (config supplies values but never +activates payment by itself): + +```toml +[rpc.payment] +wallet = "payer" # a stored wallet name (or key_file = "") +max_amount = "10000" +payment_network = "base-sepolia" # network name or CAIP-2 id +payment_asset = "USDC" # symbol (resolved per network), or a raw address/mint +``` + +```sh +qn rpc call eth_blockNumber --network base-sepolia --x402 +``` + +Manage stored wallets with `qn rpc wallet`: + +```sh +qn rpc wallet list # names, chain, address (never the key) +qn rpc wallet show payer # bare address to stdout; QR + key path to stderr +qn rpc wallet rm payer # gated: --yes to confirm; the key is unrecoverable +``` ### Other From edfdbee1c01991ede582857e4a8319fd6aa871f9 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 17 Jul 2026 11:28:35 -0400 Subject: [PATCH 23/32] docs(rpc): simplify the top-level rpc command description Drop the Tooling Access mention from 'qn rpc' one-liner; it also serves the paid micropayment lanes, so 'Make RPC calls' is the accurate summary. --- src/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.rs b/src/cli.rs index 1cf73f3..15d83d1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -155,7 +155,7 @@ 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 Tooling Access (the endpoint `qn rpc` uses). From 494496f9b68dc6be76ac7e9f3b5f179b9d721d55 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 17 Jul 2026 11:43:08 -0400 Subject: [PATCH 24/32] fix(rpc): correct paid-lane exit codes + stdin guard on drawdown/session Three fixes from the branch code review: - A gateway refusal that settled nothing (out of credits, monthly limit, exhausted channel) now maps to exit 2 via a new CliError::PaymentRefused, matching the documented payment exit-code contract, instead of the generic exit 1 (was CliError::Arg). The actionable message is preserved. - An expired session token surfaces as HTTP 401 OR 403; is_token_expired now matches both, so a 403 triggers the transparent re-auth + retry instead of failing the call. - The drawdown and session call lanes now share the per-request lane's guard rejecting params-and-key both from stdin (draining stdin into the key would silently drop the params). Adds regression tests: 401 and 403 re-auth, the both-from-stdin rejection, and updates the out-of-credits test to assert exit 2. --- src/commands/rpc/payment.rs | 37 ++++++++++++++---- src/errors.rs | 9 +++++ tests/rpc_payment.rs | 77 +++++++++++++++++++++++++++++++++---- 3 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index ba30bb9..f929304 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -31,6 +31,23 @@ use super::CallArgs; // within this window, absorbing clock skew (mirrors the tooling token margin). const SESSION_MARGIN_SECS: i64 = 60; +/// Rejects reading both the params and the payment key from stdin — there is +/// only one stdin, and draining it into the key would silently leave the params +/// empty. Shared by every paid lane (per-request, drawdown, session). +fn check_single_stdin(args: &CallArgs) -> Result<(), CliError> { + let params_use_stdin = matches!(args.params.as_deref(), Some("-")) + || matches!(&args.params_file, Some(p) if p.as_os_str() == "-"); + let key_use_stdin = matches!(&args.payment_key_file, Some(p) if p.as_os_str() == "-"); + if params_use_stdin && key_use_stdin { + return Err(CliError::Arg( + "cannot read both the params and the payment key from stdin; \ + put one of them in a file" + .to_string(), + )); + } + Ok(()) +} + /// Entry point from `run_call` once `--x402`/`--mpp` is present. pub(super) async fn run_paid_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { // Both the key and the params may come from stdin; there is only one stdin. @@ -110,6 +127,7 @@ pub(super) async fn run_paid_call(args: CallArgs, global: GlobalArgs) -> Result< /// that path signs nothing and draws no credit, so it is not a paid retry. The /// credit-drawing call itself is single-attempt. pub(super) async fn run_drawdown_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + check_single_stdin(&args)?; // The query chain, required here so the error can explain the distinction. let Some(network) = args.network.clone() else { return Err(CliError::Arg( @@ -196,32 +214,34 @@ async fn reauthenticate(ctx: &Ctx, global: &GlobalArgs) -> Result bool { matches!( e, SdkError::Api { status, body } - if status.as_u16() == 401 + if matches!(status.as_u16(), 401 | 403) && (body.contains("token_expired") || body.contains("invalid_token")) ) } /// Maps a drawdown-call failure onto an actionable CLI error. An empty-credits -/// or monthly-limit gateway refusal points forward at the fixing verb; every -/// other SDK error keeps its normal exit-code mapping. +/// or monthly-limit gateway refusal points forward at the fixing verb; because +/// nothing settled, both map to exit 2 (`PaymentRefused`), not the generic +/// arg-error bucket. Every other SDK error keeps its normal exit-code mapping. fn map_drawdown_error(e: SdkError) -> CliError { if let SdkError::Api { status, body } = &e { let s = status.as_u16(); if s == 402 || body.contains("insufficient_credits") || body.contains("no_credits") { - return CliError::Arg( + return CliError::PaymentRefused( "out of x402 credits. Buy more with 'qn rpc x402 buy-credits', \ then retry this call." .to_string(), ); } if body.contains("monthly_limit_reached") { - return CliError::Arg( + return CliError::PaymentRefused( "the account's monthly x402 limit was reached; no credits were \ drawn. Try again after the limit resets." .to_string(), @@ -239,6 +259,7 @@ fn map_drawdown_error(e: SdkError) -> CliError { /// `qn rpc mpp open`); a missing channel or one whose deposit is exhausted /// surfaces an actionable error pointing at `mpp open` / `mpp top-up`. pub(super) async fn run_session_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { + check_single_stdin(&args)?; let Some(network) = args.network.clone() else { return Err(CliError::Arg( "--mpp-session requires --network: the chain the call queries, as \ @@ -312,7 +333,7 @@ fn map_session_error(e: SdkError) -> CliError { || body.contains("AmountExceedsDeposit") || body.contains("insufficient") { - return CliError::Arg( + 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(), diff --git a/src/errors.rs b/src/errors.rs index 69d6faa..f685665 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -50,6 +50,14 @@ pub enum CliError { )] PaymentMaybeCharged(#[source] SdkError), + /// The gateway refused a paid request and nothing settled (out of credits, + /// monthly limit, an exhausted channel). Carries an actionable message and + /// maps to exit 2 — the "refused, nothing settled" bucket — so scripts can + /// distinguish it from a generic arg error (exit 1) or an unknown outcome + /// (exit 3). + #[error("{0}")] + PaymentRefused(String), + #[error(transparent)] Io(#[from] std::io::Error), @@ -78,6 +86,7 @@ pub fn exit_code_for(err: &CliError) -> i32 { 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, diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index bfacbe4..c8e3547 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -1341,7 +1341,10 @@ async fn x402_drawdown_happy_path_uses_bearer_no_signing() { /// Sequenced /base-sepolia responder: the FIRST drawdown call 401s with /// token_expired; the SECOND (after a transparent re-auth) returns the result. +/// `status` is the HTTP code of the expired-token response (the gateway uses +/// 401 or 403). struct ExpiredThenOk { + status: u16, calls: AtomicUsize, } @@ -1349,7 +1352,7 @@ impl Respond for ExpiredThenOk { fn respond(&self, _req: &Request) -> ResponseTemplate { let n = self.calls.fetch_add(1, Ordering::SeqCst); if n == 0 { - ResponseTemplate::new(401).set_body_json(json!({ + ResponseTemplate::new(self.status).set_body_json(json!({ "error": "token_expired", "message": "session token expired" })) } else { @@ -1360,10 +1363,9 @@ impl Respond for ExpiredThenOk { } } -#[tokio::test] -async fn x402_drawdown_reauths_once_on_token_expired() { +async fn assert_drawdown_reauths_on(status: u16) { let server = MockServer::start().await; - // Two /auth calls: the initial auth + the re-auth after the 401. + // Two /auth calls: the initial auth + the re-auth after the expired token. Mock::given(method("POST")) .and(path("/auth")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -1377,6 +1379,7 @@ async fn x402_drawdown_reauths_once_on_token_expired() { 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 @@ -1389,7 +1392,64 @@ async fn x402_drawdown_reauths_once_on_token_expired() { 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_eq!(out.exit_code, 0, "status={status} stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_drawdown_reauths_once_on_token_expired_401() { + // The gateway can surface an expired token as a 401. + assert_drawdown_reauths_on(401).await; +} + +#[tokio::test] +async fn x402_drawdown_reauths_once_on_token_expired_403() { + // ...or as a 403 — both must trigger the transparent re-auth + retry. + assert_drawdown_reauths_on(403).await; +} + +#[tokio::test] +async fn x402_drawdown_rejects_key_and_params_both_from_stdin() { + // Only one stdin: reading the key from it would silently drain the params. + // The lane must refuse up front with zero gateway I/O. + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_call", + "-", // params from stdin + "--network", + "base-sepolia", + "--x402-drawdown", + "--payment-key-file", + "-", // key ALSO from stdin + "--payment-network", + "eip155:84532", + "--payment-asset", + USDC, + "--max-amount", + "10000000", + ], + ) + .await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + assert!( + out.stderr.contains("both") && out.stderr.contains("stdin"), + "expected the both-from-stdin guard, got: {}", + out.stderr + ); } #[tokio::test] @@ -1397,7 +1457,8 @@ async fn x402_drawdown_out_of_credits_points_at_buy_credits() { let server = MockServer::start().await; mount_auth(&server).await; // 402 on the drawdown call = no credits; must NOT sign or resend (single - // attempt), and must surface an actionable "buy-credits" error at exit 1. + // attempt), and must surface an actionable "buy-credits" error at exit 2 + // (the gateway refused and nothing settled). Mock::given(method("POST")) .and(path("/base-sepolia")) .respond_with(ResponseTemplate::new(402).set_body_json(json!({ @@ -1412,8 +1473,8 @@ async fn x402_drawdown_out_of_credits_points_at_buy_credits() { let (_guard, key_path) = key_file(); let out = run_qn(&server.uri(), &drawdown_call_args(&cfg, &key_path)).await; - // Actionable arg error (exit 1) pointing forward at the fixing verb. - assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); + // Refused, nothing settled → exit 2, with a message pointing at the fix. + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); assert!( out.stderr.contains("buy-credits"), "stderr should point at buy-credits, got: {}", From 076ef099f98549e664919fe0fa5e9f4f332f376d Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 17 Jul 2026 11:59:37 -0400 Subject: [PATCH 25/32] fix(rpc): x402 drip reports the funding tx; buy-credits takes --network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the corrected SDK drawdown shapes: - drip prints the faucet funding transaction (not a credit balance) and points Next at buy-credits. - buy-credits gains --network (the gateway query chain / path slug) since the purchase settles on a network-scoped RPC request; resolved from --network, else the --payment-network name when it isn't a CAIP-2 id. Updates the agent guide, README drawdown walkthrough (drip funds the wallet → buy-credits spends it), and the call/x402 --help examples. Tests updated for the network-scoped purchase + GET /credits balance read and the funding-tx drip. --- README.md | 10 +++--- src/commands/agent/context.md | 5 +-- src/commands/rpc/mod.rs | 2 +- src/commands/rpc/x402.rs | 58 ++++++++++++++++++++++++++++++----- tests/rpc_payment.rs | 29 ++++++++++++------ 5 files changed, 80 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index b473a4e..2079dc5 100644 --- a/README.md +++ b/README.md @@ -419,13 +419,15 @@ Buy a block of prepaid credits once, then spend them with no per-call signing (one credit per successful response): ```sh -qn rpc wallet generate --chain evm --name payer # fund the printed address +qn rpc wallet generate --chain evm --name payer # dedicated wallet -# Testnet: free credits from the faucet (Base Sepolia, once per account). +# 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 --payment-asset USDC -# Or buy credits (moves real funds; gated — pass --yes to skip the prompt). -qn rpc x402 buy-credits --payment-wallet payer \ +# Buy prepaid credits with the funded wallet (moves real funds; gated — pass +# --yes to skip the prompt). +qn rpc x402 buy-credits --network base-sepolia --payment-wallet payer \ --payment-network base-sepolia --payment-asset USDC --max-amount 10000000 # Check the balance (prints the bare number). diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index 787bed3..a637c38 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -188,8 +188,9 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase SIWX-authenticates then pays the gateway's credit offer (gated Mild — `--yes`, or exit 5 in scripts — and names the spend ceiling); `balance` (alias `credits`) prints the current credit count (bare number, or the full envelope - with `--format json`); `drip` requests testnet credits (Base Sepolia faucet, - once per account). The session JWT is authenticated once and cached under + with `--format json`); `drip` funds the wallet from the testnet faucet + (Base Sepolia, returns the funding tx, not credits; once per account). The + session JWT is authenticated once and cached under `/qn/sessions.toml` (0600, keyed by wallet address); a missing/expired session re-authenticates transparently (free, no confirmation), including one automatic re-auth if a drawdown call's token diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index 43e096a..4555941 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -70,7 +70,7 @@ pub enum RpcCmd { --payment-asset USDC --max-amount 10000\n \ qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt\n\n\ Prepaid x402 credits (drawdown — buy once, then spend, no per-call signing):\n \ - qn rpc x402 buy-credits --payment-wallet payer \\\n \ + qn rpc x402 buy-credits --network base-sepolia --payment-wallet payer \\\n \ --payment-network base-sepolia --payment-asset USDC --max-amount 10000000\n \ qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown \\\n \ --payment-wallet payer --payment-network base-sepolia --payment-asset USDC\n\n\ diff --git a/src/commands/rpc/x402.rs b/src/commands/rpc/x402.rs index 8b99e13..061db73 100644 --- a/src/commands/rpc/x402.rs +++ b/src/commands/rpc/x402.rs @@ -39,7 +39,7 @@ 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 --payment-wallet payer \\\n \ + qn rpc x402 buy-credits --network base-sepolia --payment-wallet payer \\\n \ --payment-network base-sepolia --payment-asset USDC --max-amount 10000000")] BuyCredits(PaymentArgs), @@ -56,6 +56,12 @@ pub enum X402Cmd { /// flags-then-`[rpc.payment]` resolution as `qn rpc call --x402`. #[derive(Debug, ClapArgs)] pub struct PaymentArgs { + /// The gateway query chain to buy credits against, as its path slug (e.g. + /// `base-sepolia`). Defaults to the `--payment-network` name when it is a + /// slug. Credits are not network-scoped once bought. + #[arg(long, value_name = "NETWORK")] + pub network: Option, + /// File containing the raw payment private key (EVM hex); `-` reads stdin. /// Precedence: this > --payment-wallet > `key_file` > `wallet` in config. #[arg(long, value_name = "PATH", conflicts_with = "payment_wallet")] @@ -131,6 +137,29 @@ fn setup(args: &PaymentArgs, global: GlobalArgs) -> Result<(Ctx, PaymentConfig), /// Loads `[rpc.payment]`. A missing file is an empty section; an unreadable or /// invalid file is a hard error (the user likely relies on values set there). +// The gateway query chain (path slug) for a credit purchase: the explicit +// --network, else the --payment-network flag when it's a name (not a CAIP-2 +// id). A resolved CAIP-2 payment network alone isn't a valid gateway slug, so +// require --network in that case. +fn resolve_query_network( + args: &PaymentArgs, + _payment: &PaymentConfig, +) -> Result { + if let Some(n) = &args.network { + return Ok(n.clone()); + } + if let Some(pn) = &args.payment_network { + if !pn.contains(':') { + return Ok(pn.clone()); + } + } + Err(CliError::Arg( + "buy-credits needs the gateway query chain. Pass --network \ + (e.g. --network base-sepolia)." + .to_string(), + )) +} + fn load_payment_section(global: &GlobalArgs) -> Result { let Some(path) = global.resolve_config_path() else { return Ok(PaymentSection::default()); @@ -152,11 +181,12 @@ async fn run_buy_credits(args: PaymentArgs, global: GlobalArgs) -> Result<(), Cl ); 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) + .gateway_buy_credits(&session, &network) .await .map_err(super::payment::map_paid_error)?; @@ -179,15 +209,27 @@ async fn run_balance(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliErr async fn run_drip(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { let (ctx, _payment) = setup(&args, global.clone())?; let session = ensure_gateway_session(&ctx, &global).await?; - let balance = ctx.sdk.rpc.gateway_drip(&session).await?; + let receipt = ctx.sdk.rpc.gateway_drip(&session).await?; + // The faucet funds the wallet with testnet tokens (returns the funding tx), + // which you then spend on buy-credits — it does not grant credits directly. ctx.out.note(&format!( - "✓ Dripped testnet credits (balance: {})", - fmt_credits(balance.credits) + "✓ Faucet funded {} (tx: {})", + receipt.account_id, receipt.transaction_hash )); - ctx.out - .note(" Next: qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown"); - emit_balance(&ctx, &balance) + ctx.out.note( + " Next: qn rpc x402 buy-credits (spend the funded balance on prepaid credits)", + ); + if matches!(ctx.global.format, Some(f) if f.is_structured()) { + let v = serde_json::json!({ + "account_id": receipt.account_id, + "transaction_hash": receipt.transaction_hash, + }); + return super::emit_result(&ctx, &v); + } + // Bare tx hash to stdout for pipelines. + println!("{}", receipt.transaction_hash); + Ok(()) } // Emit a credit balance: the bare number by default (friendly for scripts and diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index c8e3547..7fd18a6 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -1125,11 +1125,11 @@ async fn mount_auth(server: &MockServer) { .await; } -/// Sequenced /credits responder: the first (unpaid) POST gets a 402 credit -/// offer; a POST carrying a payment signature gets the post-purchase balance. +/// Sequenced network-scoped credit-purchase responder: the first (unpaid) POST +/// gets a 402 credit offer; the paid resend (with a payment signature) gets a +/// 200 RPC result. The funded balance is read separately via GET /credits. struct CreditsSeq { amount: &'static str, - credits: u64, calls: AtomicUsize, } @@ -1144,7 +1144,7 @@ impl Respond for CreditsSeq { })) } else { ResponseTemplate::new(200).set_body_json(json!({ - "accountId": "eip155:84532:0xabc", "credits": self.credits + "jsonrpc": "2.0", "id": 1, "result": "0x1" })) } } @@ -1172,16 +1172,24 @@ fn x402_args<'a>(cfg: &'a str, key_path: &'a str, verb: &'a str) -> Vec<&'a str> async fn x402_buy_credits_happy_path() { let server = MockServer::start().await; mount_auth(&server).await; + // Credits are bought by settling the offer on the network-scoped RPC path. Mock::given(method("POST")) - .and(path("/credits")) + .and(path("/base-sepolia")) .respond_with(CreditsSeq { amount: "1000000", - credits: 1_000_095, calls: AtomicUsize::new(0), }) .expect(2) // one unpaid offer probe + one paid resend .mount(&server) .await; + // The funded balance is then read from GET /credits. + Mock::given(method("GET")) + .and(path("/credits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "accountId": "eip155:84532:0xabc", "credits": 1_000_095u64 + }))) + .mount(&server) + .await; mount_control_plane_expect_zero(&server).await; let dir = tempfile::tempdir().unwrap(); @@ -1189,7 +1197,7 @@ async fn x402_buy_credits_happy_path() { let (_guard, key_path) = key_file(); let mut args = x402_args(&cfg, &key_path, "buy-credits"); - args.push("--yes"); // Mild gate: consent to spend + args.extend_from_slice(&["--network", "base-sepolia", "--yes"]); // query chain + consent let out = run_qn(&server.uri(), &args).await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); // The session JWT is cached for reuse. @@ -1266,13 +1274,16 @@ async fn x402_balance_error_maps_to_exit_2() { } #[tokio::test] -async fn x402_drip_reports_balance() { +async fn x402_drip_reports_funding_tx() { let server = MockServer::start().await; mount_auth(&server).await; + // The faucet returns the on-chain funding transaction, not a credit balance. Mock::given(method("POST")) .and(path("/drip")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "accountId": "eip155:84532:0xabc", "credits": 100u64 + "accountId": "eip155:84532:0xabc", + "walletAddress": "0xabc", + "transactionHash": "0xfeed" }))) .mount(&server) .await; From 24ad58163408f8d07e3fdcb326a80ffa5a7f4d10 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 17 Jul 2026 12:09:23 -0400 Subject: [PATCH 26/32] feat(rpc): drawdown call needs only a wallet; chained next-command hints A drawdown call presents a Bearer JWT and signs nothing per request, so it no longer requires --payment-asset or --max-amount, and the pay network defaults to --network. `qn rpc call --x402-drawdown --payment-wallet ` now works with just the wallet (new resolve_drawdown_config, separate from the per-request resolver which still requires the full stack). The buy-credits, drip, and drawdown-call success outputs now print a copy-pasteable next command built from the flags the user supplied, so the flow chains without the wall of missing-flag errors. Docs/examples updated to the minimal drawdown call. Adds a test asserting a drawdown call succeeds with only --network + the wallet. --- README.md | 6 +-- src/commands/agent/context.md | 11 ++++-- src/commands/rpc/mod.rs | 3 +- src/commands/rpc/payment.rs | 71 +++++++++++++++++++++++++++++++++-- src/commands/rpc/x402.rs | 41 +++++++++++++++----- tests/rpc_payment.rs | 40 ++++++++++++++++++++ 6 files changed, 150 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 2079dc5..2a67ba1 100644 --- a/README.md +++ b/README.md @@ -433,9 +433,9 @@ qn rpc x402 buy-credits --network base-sepolia --payment-wallet payer \ # Check the balance (prints the bare number). qn rpc x402 balance --payment-wallet payer --payment-network base-sepolia --payment-asset USDC -# Spend credits on calls. -qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown \ - --payment-wallet payer --payment-network base-sepolia --payment-asset USDC +# Spend credits on calls. A drawdown call signs nothing per request, so it +# needs only the wallet — no asset or spend ceiling. +qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown --payment-wallet payer ``` The gateway session (a JWT) is authenticated once and cached (0600) under the diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index a637c38..12022ad 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -183,10 +183,13 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase response). All take the same payment parameter stack as the paid lane (`--payment-wallet`/`-key-file`, `--payment-network`, `--payment-asset`, `--max-amount`, `--svm-rpc-url`) with the same `[rpc.payment]` fallback. - `--x402-drawdown` requires `--network` (the query chain) and is mutually - exclusive with `--x402`/`--mpp`/`--endpoint-url`. `buy-credits` - SIWX-authenticates then pays the gateway's credit offer (gated Mild — `--yes`, - or exit 5 in scripts — and names the spend ceiling); `balance` (alias + `--x402-drawdown` requires `--network` (the query chain) and a wallet only — + it signs nothing per call, so `--payment-asset`/`--max-amount` aren't needed + and the pay network defaults to `--network`; mutually exclusive with + `--x402`/`--mpp`/`--endpoint-url`. `buy-credits` takes `--network` (the + gateway path chain the purchase settles on), SIWX-authenticates, then pays the + gateway's credit offer (gated Mild — `--yes`, or exit 5 in scripts — and names + the spend ceiling); `balance` (alias `credits`) prints the current credit count (bare number, or the full envelope with `--format json`); `drip` funds the wallet from the testnet faucet (Base Sepolia, returns the funding tx, not credits; once per account). The diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index 4555941..0ba8f5f 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -72,8 +72,7 @@ pub enum RpcCmd { Prepaid x402 credits (drawdown — buy once, then spend, no per-call signing):\n \ qn rpc x402 buy-credits --network base-sepolia --payment-wallet payer \\\n \ --payment-network base-sepolia --payment-asset USDC --max-amount 10000000\n \ - qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown \\\n \ - --payment-wallet payer --payment-network base-sepolia --payment-asset USDC\n\n\ + qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown --payment-wallet payer\n\n\ MPP payment channel (open once, then pay per call with a voucher):\n \ qn rpc mpp open --network tempo-testnet --deposit 1000000 --payment-wallet payer\n \ qn rpc call eth_blockNumber --network tempo-testnet --mpp-session --payment-wallet payer\n\n\ diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index f929304..fdbcd39 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -139,9 +139,9 @@ pub(super) async fn run_drawdown_call(args: CallArgs, global: GlobalArgs) -> Res 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.payment_params(), + let (payment, key_file_warning) = resolve_drawdown_config( + &args, + &network, §ion, wallets_dir.as_deref(), global.base_url.clone(), @@ -177,6 +177,10 @@ pub(super) async fn run_drawdown_call(args: CallArgs, global: GlobalArgs) -> Res Err(e) => return Err(map_drawdown_error(e)), }; + // A drew-a-credit confirmation with the balance-check next step (stderr, so + // stdout stays exactly the RPC result). + ctx.out + .note(" Next: qn rpc x402 balance --payment-wallet --payment-network --payment-asset "); super::emit_result(&ctx, &result) } @@ -417,6 +421,67 @@ fn resolve_payment_config( Ok((payment.0, network, key_file_warning)) } +/// Resolves the minimal config a drawdown call needs. Unlike the per-request +/// lane, a drawdown call signs nothing per request (it presents a Bearer JWT), +/// so only the wallet key and the SIWX pay network matter — `--payment-asset` +/// and `--max-amount` are irrelevant and not required. The pay network defaults +/// to the query `--network` (the common case where you pay on the chain you +/// query), so a drawdown call needs only `--payment-wallet`. +fn resolve_drawdown_config( + args: &CallArgs, + query_network: &str, + section: &PaymentSection, + wallets_dir: Option<&Path>, + base_url_override: Option, +) -> Result<(PaymentConfig, Option), CliError> { + if section.key.is_some() { + return Err(CliError::Arg( + "[rpc.payment] does not accept an inline `key`; store the key in a \ + file and set `key_file = \"\"` instead" + .to_string(), + )); + } + let (key, key_file_warning) = resolve_key( + args.payment_key_file.as_deref(), + args.payment_wallet.as_deref(), + section.key_file.as_deref(), + section.wallet.as_deref(), + wallets_dir, + )?; + + // Pay network: explicit flag/config, else default to the query network. + let payment_network = args + .payment_network + .clone() + .or_else(|| section.payment_network.clone()) + .unwrap_or_else(|| query_network.to_string()); + let payment_network = super::pay_network::resolve(&payment_network)?; + + let svm_rpc_url = match args + .svm_rpc_url + .clone() + .or_else(|| section.svm_rpc_url.clone()) + { + Some(u) => Some(crate::context::validate_endpoint_url(&u)?), + None => None, + }; + + Ok(( + PaymentConfig { + scheme: "x402".to_string(), + key, + pay_network: payment_network, + // asset + max_amount are unused by the Bearer drawdown call (nothing + // is signed per request); placeholders keep the SDK config total. + asset: String::new(), + max_amount: "0".to_string(), + svm_rpc_url, + base_url_override, + }, + key_file_warning, + )) +} + /// Resolves the shared payment parameter stack (key, spend ceiling, pay /// network, asset, SVM RPC URL) into an SDK [`PaymentConfig`] for `scheme`, /// applying the flags-then-`[rpc.payment]` precedence. Returns the config plus diff --git a/src/commands/rpc/x402.rs b/src/commands/rpc/x402.rs index 061db73..d5498da 100644 --- a/src/commands/rpc/x402.rs +++ b/src/commands/rpc/x402.rs @@ -135,16 +135,11 @@ fn setup(args: &PaymentArgs, global: GlobalArgs) -> Result<(Ctx, PaymentConfig), Ok((ctx, payment)) } -/// Loads `[rpc.payment]`. A missing file is an empty section; an unreadable or -/// invalid file is a hard error (the user likely relies on values set there). // The gateway query chain (path slug) for a credit purchase: the explicit // --network, else the --payment-network flag when it's a name (not a CAIP-2 // id). A resolved CAIP-2 payment network alone isn't a valid gateway slug, so // require --network in that case. -fn resolve_query_network( - args: &PaymentArgs, - _payment: &PaymentConfig, -) -> Result { +fn resolve_query_network(args: &PaymentArgs, _payment: &PaymentConfig) -> Result { if let Some(n) = &args.network { return Ok(n.clone()); } @@ -160,6 +155,8 @@ fn resolve_query_network( )) } +/// Loads `[rpc.payment]`. A missing file is an empty section; an unreadable or +/// invalid file is a hard error (the user likely relies on values set there). fn load_payment_section(global: &GlobalArgs) -> Result { let Some(path) = global.resolve_config_path() else { return Ok(PaymentSection::default()); @@ -195,10 +192,27 @@ async fn run_buy_credits(args: PaymentArgs, global: GlobalArgs) -> Result<(), Cl fmt_credits(balance.credits) )); ctx.out - .note(" Next: qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown"); + .note(&format!(" Next: {}", drawdown_call_hint(&args, &network))); emit_balance(&ctx, &balance) } +// A copy-pasteable `--x402-drawdown` call reflecting the flags the user just +// used, so the chained next step runs as-is. +fn drawdown_call_hint(args: &PaymentArgs, network: &str) -> String { + let mut cmd = format!( + "qn rpc call eth_blockNumber --network {network} --x402-drawdown --payment-wallet {}", + args.payment_wallet.as_deref().unwrap_or("") + ); + // The drawdown call defaults its pay network to --network, so only append + // --payment-network when the user set an explicit one that differs. + if let Some(pn) = &args.payment_network { + if pn != network { + cmd.push_str(&format!(" --payment-network {pn}")); + } + } + cmd +} + async fn run_balance(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { let (ctx, _payment) = setup(&args, global.clone())?; let session = ensure_gateway_session(&ctx, &global).await?; @@ -217,9 +231,16 @@ async fn run_drip(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> "✓ Faucet funded {} (tx: {})", receipt.account_id, receipt.transaction_hash )); - ctx.out.note( - " Next: qn rpc x402 buy-credits (spend the funded balance on prepaid credits)", - ); + // Point at buy-credits with the flags the user already supplied. + let net = args.network.as_deref().or(args.payment_network.as_deref()); + ctx.out.note(&format!( + " Next: qn rpc x402 buy-credits --network {} --payment-wallet {} \ + --payment-network {} --payment-asset {} --max-amount 1000000", + net.unwrap_or(""), + args.payment_wallet.as_deref().unwrap_or(""), + args.payment_network.as_deref().unwrap_or(""), + args.payment_asset.as_deref().unwrap_or(""), + )); if matches!(ctx.global.format, Some(f) if f.is_structured()) { let v = serde_json::json!({ "account_id": receipt.account_id, diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index 7fd18a6..8afccc2 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -1350,6 +1350,46 @@ async fn x402_drawdown_happy_path_uses_bearer_no_signing() { assert!(dir.path().join("sessions.toml").exists()); } +#[tokio::test] +async fn x402_drawdown_needs_only_wallet_and_network() { + // A drawdown call signs nothing per request, so it must NOT require + // --payment-asset or --max-amount; the pay network defaults to --network. + let server = MockServer::start().await; + mount_auth(&server).await; + Mock::given(method("POST")) + .and(path("/base-sepolia")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x1335f9a" + }))) + .expect(1) + .mount(&server) + .await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + // Only --network + the key: no asset, no max-amount, no payment-network. + let out = run_qn( + &server.uri(), + &[ + "--config-file", + &cfg, + "rpc", + "call", + "eth_blockNumber", + "--network", + "base-sepolia", + "--x402-drawdown", + "--payment-key-file", + &key_path, + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + /// Sequenced /base-sepolia responder: the FIRST drawdown call 401s with /// token_expired; the SECOND (after a transparent re-auth) returns the result. /// `status` is the HTTP code of the expired-token response (the gateway uses From 11ab91f497aa47fb6230dc2d90166e14f2c6ff06 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 29 Jul 2026 11:21:34 -0300 Subject: [PATCH 27/32] feat(rpc): split x402 balance/drip onto a narrower SessionArgs x402 balance and drip present a Bearer JWT and sign nothing. They now take SessionArgs (key/wallet/network only) instead of the full PaymentArgs, so --payment-asset and --max-amount are no longer on their surface. Spend flags on these verbs are a clap error. Adds resolve_session_params in payment.rs and a SessionArgs struct in x402.rs. Updates the drawdown next-command hint and context.md. Adds a test that rejects spend flags on balance. --- src/commands/agent/context.md | 9 ++-- src/commands/rpc/payment.rs | 80 ++++++++++++++++++++++++++++++- src/commands/rpc/x402.rs | 90 ++++++++++++++++++++++++++++++----- tests/rpc_payment.rs | 46 ++++++++++++++++-- 4 files changed, 206 insertions(+), 19 deletions(-) diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index 12022ad..1bb7b8f 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -180,9 +180,12 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase **x402 drawdown**: `qn rpc x402 {buy-credits, balance, drip}` manages prepaid gateway credits instead of paying per request, and `qn rpc call --x402-drawdown` spends them (no per-call signing, 1 credit per successful - response). All take the same payment parameter stack as the paid lane - (`--payment-wallet`/`-key-file`, `--payment-network`, `--payment-asset`, - `--max-amount`, `--svm-rpc-url`) with the same `[rpc.payment]` fallback. + response). `buy-credits` signs a payment, so it takes the full paid-lane + stack (`--payment-wallet`/`-key-file`, `--payment-network`, `--payment-asset`, + `--max-amount`, `--svm-rpc-url`); `balance` and `drip` only present a Bearer + session JWT and sign nothing, so they take just the wallet + + `--payment-network` (+ `--svm-rpc-url` for Solana) and do NOT accept + `--payment-asset`/`--max-amount`. All fall back to `[rpc.payment]`. `--x402-drawdown` requires `--network` (the query chain) and a wallet only — it signs nothing per call, so `--payment-asset`/`--max-amount` aren't needed and the pay network defaults to `--network`; mutually exclusive with diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index fdbcd39..4c27787 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -180,7 +180,7 @@ pub(super) async fn run_drawdown_call(args: CallArgs, global: GlobalArgs) -> Res // A drew-a-credit confirmation with the balance-check next step (stderr, so // stdout stays exactly the RPC result). ctx.out - .note(" Next: qn rpc x402 balance --payment-wallet --payment-network --payment-asset "); + .note(" Next: qn rpc x402 balance --payment-wallet --payment-network "); super::emit_result(&ctx, &result) } @@ -377,6 +377,17 @@ pub(super) struct PaymentParams<'a> { pub svm_rpc_url: Option<&'a str>, } +/// The subset of the payment stack a keyless session needs: a wallet key and a +/// SIWX pay network, plus an optional SVM RPC URL for Solana auth. The gateway +/// `balance`/`drip` verbs present a Bearer JWT and sign nothing, so they read +/// this rather than [`PaymentParams`] — no asset, no spend ceiling. +pub(super) struct SessionParams<'a> { + pub key_file: Option<&'a Path>, + pub wallet: Option<&'a str>, + pub payment_network: Option<&'a str>, + pub svm_rpc_url: Option<&'a str>, +} + impl CallArgs { fn payment_params(&self) -> PaymentParams<'_> { PaymentParams { @@ -482,6 +493,73 @@ fn resolve_drawdown_config( )) } +/// Resolves the minimal config a keyless gateway session needs (`x402 balance`, +/// `x402 drip`). These verbs authenticate a SIWX session and present a Bearer +/// JWT — they sign nothing per request — so only the wallet key and the SIWX +/// pay network matter. `asset`/`max_amount` are supplied as placeholders (the +/// same construction as [`resolve_drawdown_config`]); the CLI does not accept +/// `--payment-asset`/`--max-amount` for these verbs. +pub(super) fn resolve_session_params( + params: &SessionParams<'_>, + section: &PaymentSection, + wallets_dir: Option<&Path>, + base_url_override: Option, +) -> Result<(PaymentConfig, Option), CliError> { + if section.key.is_some() { + return Err(CliError::Arg( + "[rpc.payment] does not accept an inline `key`; store the key in a \ + file and set `key_file = \"\"` instead" + .to_string(), + )); + } + + let (key, key_file_warning) = resolve_key( + params.key_file, + params.wallet, + section.key_file.as_deref(), + section.wallet.as_deref(), + wallets_dir, + )?; + + let payment_network = params + .payment_network + .map(str::to_string) + .or_else(|| section.payment_network.clone()) + .ok_or_else(|| { + CliError::Arg( + "no payment network set. Pass --payment-network (a \ + network name like base-sepolia, or a CAIP-2 id like \ + eip155:84532) or set `payment_network` under [rpc.payment]" + .to_string(), + ) + })?; + let payment_network = super::pay_network::resolve(&payment_network)?; + + let svm_rpc_url = match params + .svm_rpc_url + .map(str::to_string) + .or_else(|| section.svm_rpc_url.clone()) + { + Some(u) => Some(crate::context::validate_endpoint_url(&u)?), + None => None, + }; + + Ok(( + PaymentConfig { + scheme: "x402".to_string(), + key, + pay_network: payment_network, + // asset + max_amount are unused by the Bearer session (nothing is + // signed per request); placeholders keep the SDK config total. + asset: String::new(), + max_amount: "0".to_string(), + svm_rpc_url, + base_url_override, + }, + key_file_warning, + )) +} + /// Resolves the shared payment parameter stack (key, spend ceiling, pay /// network, asset, SVM RPC URL) into an SDK [`PaymentConfig`] for `scheme`, /// applying the flags-then-`[rpc.payment]` precedence. Returns the config plus diff --git a/src/commands/rpc/x402.rs b/src/commands/rpc/x402.rs index d5498da..9f42030 100644 --- a/src/commands/rpc/x402.rs +++ b/src/commands/rpc/x402.rs @@ -25,7 +25,10 @@ use crate::config::{self, PaymentSection}; use crate::context::{Ctx, GlobalArgs}; use crate::errors::CliError; -use super::payment::{ensure_gateway_session, resolve_payment_params, PaymentParams}; +use super::payment::{ + ensure_gateway_session, resolve_payment_params, resolve_session_params, PaymentParams, + SessionParams, +}; #[derive(Debug, ClapArgs)] #[command(subcommand_required = true, arg_required_else_help = true)] @@ -46,10 +49,10 @@ pub enum X402Cmd { /// Show the account's current credit balance (prints the bare number; /// --format json for the full envelope). #[command(visible_alias = "credits")] - Balance(PaymentArgs), + Balance(SessionArgs), /// Request testnet credits from the faucet (Base Sepolia, once per account). - Drip(PaymentArgs), + Drip(SessionArgs), } /// The shared payment parameter stack every x402 verb accepts, with the same @@ -106,6 +109,48 @@ impl PaymentArgs { } } +/// The flags a keyless gateway session accepts (`balance`, `drip`). These verbs +/// present a Bearer JWT and sign nothing, so they take only the wallet key and +/// the SIWX pay network — no `--payment-asset`, no `--max-amount`. +#[derive(Debug, ClapArgs)] +pub struct SessionArgs { + /// The gateway query chain, as its path slug (e.g. `base-sepolia`). + /// Defaults to the `--payment-network` name when it is a slug. + #[arg(long, value_name = "NETWORK")] + pub network: Option, + + /// File containing the raw payment private key (EVM hex); `-` reads stdin. + /// Precedence: this > --payment-wallet > `key_file` > `wallet` in config. + #[arg(long, value_name = "PATH", conflicts_with = "payment_wallet")] + pub payment_key_file: Option, + + /// Name of a stored wallet (from `qn rpc 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, @@ -114,9 +159,9 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { } } -// Resolve the payment config from the verb's args + [rpc.payment], build the -// keyless-payment Ctx, and print any key-file permissions warning once output -// exists. Shared setup for all three verbs — no network I/O yet. +// Resolve the full payment config (buy-credits) from the verb's args + +// [rpc.payment], build the keyless-payment Ctx, and print any key-file +// permissions warning once output exists. No network I/O yet. fn setup(args: &PaymentArgs, global: GlobalArgs) -> Result<(Ctx, PaymentConfig), CliError> { let section = load_payment_section(&global)?; let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); @@ -135,6 +180,26 @@ fn setup(args: &PaymentArgs, global: GlobalArgs) -> Result<(Ctx, PaymentConfig), Ok((ctx, payment)) } +// Setup for the session-only verbs (balance, drip): resolve the minimal +// wallet + pay-network config, build the keyless Ctx, warn on a loose key file. +// Signs nothing, so it needs no asset or spend ceiling. +fn session_setup(args: &SessionArgs, global: GlobalArgs) -> Result { + let section = load_payment_section(&global)?; + let wallets_dir = config::wallets_dir(global.resolve_config_path().as_deref()); + let (payment, key_file_warning) = resolve_session_params( + &args.params(), + §ion, + wallets_dir.as_deref(), + global.base_url.clone(), + )?; + + let ctx = Ctx::from_global_keyless_payment(global, payment)?; + if let Some(w) = key_file_warning { + ctx.out.warn(&w); + } + Ok(ctx) +} + // The gateway query chain (path slug) for a credit purchase: the explicit // --network, else the --payment-network flag when it's a name (not a CAIP-2 // id). A resolved CAIP-2 payment network alone isn't a valid gateway slug, so @@ -213,15 +278,15 @@ fn drawdown_call_hint(args: &PaymentArgs, network: &str) -> String { cmd } -async fn run_balance(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { - let (ctx, _payment) = setup(&args, global.clone())?; +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: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> { - let (ctx, _payment) = setup(&args, global.clone())?; +async fn run_drip(args: SessionArgs, global: GlobalArgs) -> Result<(), CliError> { + let ctx = session_setup(&args, global.clone())?; let session = ensure_gateway_session(&ctx, &global).await?; let receipt = ctx.sdk.rpc.gateway_drip(&session).await?; @@ -233,13 +298,14 @@ async fn run_drip(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError> )); // Point at buy-credits with the flags the user already supplied. let net = args.network.as_deref().or(args.payment_network.as_deref()); + // buy-credits signs, so the hint carries the asset + ceiling placeholders + // the user fills in for the purchase (drip itself collects neither). ctx.out.note(&format!( " Next: qn rpc x402 buy-credits --network {} --payment-wallet {} \ - --payment-network {} --payment-asset {} --max-amount 1000000", + --payment-network {} --payment-asset --max-amount 1000000", net.unwrap_or(""), args.payment_wallet.as_deref().unwrap_or(""), args.payment_network.as_deref().unwrap_or(""), - args.payment_asset.as_deref().unwrap_or(""), )); if matches!(ctx.global.format, Some(f) if f.is_structured()) { let v = serde_json::json!({ diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index 8afccc2..7287491 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -1168,6 +1168,22 @@ fn x402_args<'a>(cfg: &'a str, key_path: &'a str, verb: &'a str) -> Vec<&'a str> ] } +// The session verbs (balance, drip) sign nothing, so they take only the wallet +// key + pay network — no --payment-asset, no --max-amount. +fn x402_session_args<'a>(cfg: &'a str, key_path: &'a str, verb: &'a str) -> Vec<&'a str> { + vec![ + "--config-file", + cfg, + "rpc", + "x402", + verb, + "--payment-key-file", + key_path, + "--payment-network", + "eip155:84532", + ] +} + #[tokio::test] async fn x402_buy_credits_happy_path() { let server = MockServer::start().await; @@ -1248,7 +1264,11 @@ async fn x402_balance_prints_credits() { 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_args(&cfg, &key_path, "balance")).await; + let out = run_qn( + &server.uri(), + &x402_session_args(&cfg, &key_path, "balance"), + ) + .await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); } @@ -1268,7 +1288,11 @@ async fn x402_balance_error_maps_to_exit_2() { 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_args(&cfg, &key_path, "balance")).await; + let out = run_qn( + &server.uri(), + &x402_session_args(&cfg, &key_path, "balance"), + ) + .await; // A gateway 4xx that settled nothing maps to exit 2 (SDK Api error). assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); } @@ -1293,10 +1317,26 @@ async fn x402_drip_reports_funding_tx() { 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_args(&cfg, &key_path, "drip")).await; + let out = run_qn(&server.uri(), &x402_session_args(&cfg, &key_path, "drip")).await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); } +#[tokio::test] +async fn x402_balance_rejects_spend_flags() { + // balance signs nothing, so --max-amount (and --payment-asset) are not part + // of its surface: clap rejects the unknown flag before any I/O. + let server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + let mut args = x402_session_args(&cfg, &key_path, "balance"); + args.extend_from_slice(&["--max-amount", "10000000"]); + let out = run_qn(&server.uri(), &args).await; + // Unknown flag: clap usage error, exit 1. + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); +} + // ── qn rpc call --x402-drawdown ────────────────────────────────────────────── // // The drawdown lane pays from prepaid credits: no per-call signing, Bearer JWT, From e3a27161d97ce13fb0812b739f9b354bea2717a8 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 29 Jul 2026 11:29:23 -0300 Subject: [PATCH 28/32] refactor(wallet): promote wallet to a top-level noun Moves wallet management from 'qn rpc wallet' to 'qn wallet' (alias 'wallets'). Wallets are a standalone local key store the paid RPC lane uses, not an RPC operation, so they get their own noun. The old 'qn rpc wallet' path is removed. The module moves unchanged to src/commands/wallet.rs and keeps the keyless Ctx (no API key needed). payment.rs now resolves --payment-wallet through a shared commands::wallet::key_path helper instead of its own copy of the name rules, and the not-found message is unified. All help text, error hints, context.md, and the README now say 'qn wallet'; the README gets its own Wallets section. Tests move to tests/wallet.rs with retargeted argv; 417 tests pass. --- README.md | 32 +++++++++++++++-------- src/cli.rs | 9 +++++++ src/commands/agent/context.md | 25 ++++++++++-------- src/commands/mod.rs | 1 + src/commands/rpc/mod.rs | 12 ++------- src/commands/rpc/mpp.rs | 2 +- src/commands/rpc/payment.rs | 31 +++------------------- src/commands/rpc/x402.rs | 4 +-- src/commands/{rpc => }/wallet.rs | 25 ++++++++++++++---- src/config.rs | 2 +- src/context.rs | 2 +- tests/rpc_payment.rs | 1 - tests/{rpc_wallet.rs => wallet.rs} | 41 ++++-------------------------- 13 files changed, 81 insertions(+), 106 deletions(-) rename src/commands/{rpc => }/wallet.rs (94%) rename tests/{rpc_wallet.rs => wallet.rs} (91%) diff --git a/README.md b/README.md index 2a67ba1..5f2ec31 100644 --- a/README.md +++ b/README.md @@ -353,8 +353,8 @@ Every path needs a payment wallet. Two options: referenced by name with `--payment-wallet`): ```sh - qn rpc wallet generate --chain evm --name payer # evm also covers MPP/Tempo - qn rpc wallet generate --chain svm --name sol-payer # svm for x402/Solana + qn wallet generate --chain evm --name payer # evm also covers MPP/Tempo + qn wallet generate --chain svm --name sol-payer # svm for x402/Solana ``` `generate` prints the address (and a QR on a terminal) to fund. The key is @@ -377,7 +377,7 @@ is a ready `--payment-asset`. Cached at `~/.config/qn/pay-networks.toml` (24h). Sign an x402 payment on each call (EVM or Solana stablecoin): ```sh -qn rpc wallet generate --chain evm --name payer # fund the printed address with Base Sepolia USDC +qn wallet generate --chain evm --name payer # fund the printed address with Base Sepolia USDC qn rpc call eth_blockNumber \ --network base-sepolia --x402 \ --payment-wallet payer \ @@ -400,7 +400,7 @@ uses the same secp256k1 key format); `--receipt` wraps the result with the settlement transaction hash: ```sh -qn rpc wallet generate --chain evm --name payer # fund on Tempo testnet +qn wallet generate --chain evm --name payer # fund on Tempo testnet qn rpc call eth_blockNumber \ --network tempo-testnet --mpp --receipt \ --payment-wallet payer \ @@ -419,7 +419,7 @@ Buy a block of prepaid credits once, then spend them with no per-call signing (one credit per successful response): ```sh -qn rpc wallet generate --chain evm --name payer # dedicated wallet +qn wallet generate --chain 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. @@ -448,7 +448,7 @@ 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 rpc wallet generate --chain evm --name payer # evm covers Tempo; fund the address +qn wallet generate --chain evm --name payer # evm covers Tempo; fund the address # Open a channel by depositing into the escrow (moves real funds; gated). qn rpc mpp open --network tempo-testnet --deposit 1000000 \ @@ -502,14 +502,26 @@ payment_asset = "USDC" # symbol (resolved per network), or a raw add qn rpc call eth_blockNumber --network base-sepolia --x402 ``` -Manage stored wallets with `qn rpc wallet`: +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 rpc wallet list # names, chain, address (never the key) -qn rpc wallet show payer # bare address to stdout; QR + key path to stderr -qn rpc wallet rm payer # gated: --yes to confirm; the key is unrecoverable +qn wallet generate --chain evm --name payer # create + store; prints the address and a QR to fund +qn wallet list # names, chain, 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; the key is unrecoverable ``` +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 15d83d1..f8f62eb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -158,6 +158,10 @@ pub enum Command { /// Make RPC calls. Rpc(commands::rpc::Args), + /// Manage local payment wallets for the paid RPC lane (`--x402`/`--mpp`). + #[command(visible_alias = "wallets")] + Wallet(commands::wallet::Args), + /// Manage Tooling Access (the endpoint `qn rpc` uses). #[command(name = "tooling-access")] ToolingAccess(commands::tooling_access::Args), @@ -256,6 +260,11 @@ impl Cli { // rpc builds its own Ctx (it seeds the SDK from the on-disk token // cache before construction), so it takes `global` directly. Command::Rpc(args) => commands::rpc::run(args, global).await, + // wallet manages local key files only — no API key, no payment + // lane — so it uses the keyless Ctx constructor. + Command::Wallet(args) => { + commands::wallet::run(args, Ctx::from_global_keyless(global)?).await + } Command::ToolingAccess(args) => { commands::tooling_access::run(args, Ctx::from_global(global)?).await } diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index 1bb7b8f..ef4a87e 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -164,15 +164,8 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase whose `reference` is the settlement tx hash; `null` on x402); without it the paid output shape is identical to an unpaid call. Mutually exclusive with `--endpoint-url`. - **Wallets**: `qn rpc wallet generate --chain --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 rpc wallet list`/`show - ` display stored wallets (address only, never the key) and the key - file path; `qn rpc 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. + **Wallets**: stored payment wallets are managed by the top-level `qn wallet` + noun (see below); reference one on a paid call with `--payment-wallet `. **Discovery**: `qn rpc pay-networks` (alias `pay-nets`) lists the networks payable via the paid lane, from the gateways' public discovery endpoints (no API key). A listed slug is a valid `--network`; the x402 asset column is @@ -215,6 +208,16 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase network). `--mpp-session` requires an open channel, is mutually exclusive with `--x402`/`--mpp`/`--x402-drawdown`/`--endpoint-url`, and points at `qn rpc mpp top-up` when the channel deposit is exhausted. +- `wallet` — the local store of payment wallets for the paid RPC lane; no API + key or login required. `qn wallet generate --chain --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 `. @@ -292,7 +295,7 @@ refused with HTTP 400/402 before anything settles. ```sh qn rpc pay-networks # which networks are payable, and the x402 asset -qn rpc wallet generate --chain evm --name payer # dedicated wallet; prints its address + a QR to fund +qn wallet generate --chain evm --name payer # dedicated wallet; prints its address + a QR to fund # → fund THAT address, then pick a lane: # x402 on EVM (Base Sepolia testnet, USDC): @@ -306,7 +309,7 @@ qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt \ --payment-asset USDC --max-amount 1000 # x402 on Solana (devnet); needs an SVM wallet: -qn rpc wallet generate --chain svm --name sol-payer +qn wallet generate --chain svm --name sol-payer qn rpc call getSlot --network solana-devnet --x402 \ --payment-wallet sol-payer --payment-network solana-devnet \ --payment-asset USDC --max-amount 1000 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/mod.rs b/src/commands/rpc/mod.rs index 0ba8f5f..bd9ae30 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -29,7 +29,6 @@ mod pay_asset; mod pay_network; mod pay_networks; mod payment; -mod wallet; mod x402; use std::io::Read; @@ -78,16 +77,13 @@ pub enum RpcCmd { qn rpc call eth_blockNumber --network tempo-testnet --mpp-session --payment-wallet payer\n\n\ See payable networks and manage wallets:\n \ qn rpc pay-networks\n \ - qn rpc wallet generate --chain evm --name payer")] + qn wallet generate --chain evm --name payer")] Call(Box), /// List the endpoint's available network keys (no RPC call). #[command(visible_alias = "ls")] ListNetworks, - /// Manage local payment wallets for the paid lane (`--x402`/`--mpp`). - Wallet(wallet::Args), - /// List the networks payable via the paid lane (`--x402`/`--mpp`), from the /// gateways' public discovery endpoints. No API key required. #[command(visible_alias = "pay-nets")] @@ -177,7 +173,7 @@ pub struct CallArgs { )] pub payment_key_file: Option, - /// Name of a stored wallet (from `qn rpc wallet generate`) to pay with. Its + /// 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( @@ -243,10 +239,6 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { match args.cmd { RpcCmd::Call(call) => run_call(*call, global).await, RpcCmd::ListNetworks => run_list_networks(global).await, - RpcCmd::Wallet(wallet_args) => { - let ctx = Ctx::from_global_keyless(global)?; - wallet::run(wallet_args, ctx).await - } RpcCmd::PayNetworks => pay_networks::run(global).await, RpcCmd::X402(x402_args) => x402::run(x402_args, global).await, RpcCmd::Mpp(mpp_args) => mpp::run(mpp_args, global).await, diff --git a/src/commands/rpc/mpp.rs b/src/commands/rpc/mpp.rs index a6797cc..908f951 100644 --- a/src/commands/rpc/mpp.rs +++ b/src/commands/rpc/mpp.rs @@ -64,7 +64,7 @@ pub struct PaymentArgs { #[arg(long, value_name = "PATH", conflicts_with = "payment_wallet")] pub payment_key_file: Option, - /// Name of a stored wallet (from `qn rpc wallet generate`) to pay with. + /// Name of a stored wallet (from `qn wallet generate`) to pay with. #[arg(long, value_name = "NAME")] pub payment_wallet: Option, diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index 4c27787..73a36d8 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -682,47 +682,22 @@ fn resolve_key( return read_key_file(path); } if let Some(name) = flag_wallet { - return read_key_file(&wallet_key_path(name, wallets_dir)?); + 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(&wallet_key_path(name, wallets_dir)?); + 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 rpc wallet generate'), or \ + stdin), --payment-wallet (from 'qn wallet generate'), or \ set `key_file`/`wallet` under [rpc.payment]" .to_string(), )) } -/// Resolves a stored wallet name to its key file path, validating the name and -/// checking the file exists. Mirrors the `wallet` module's name rules so the -/// name can never escape the store directory. -fn wallet_key_path(name: &str, wallets_dir: Option<&Path>) -> Result { - if name.is_empty() - || !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" - ))); - } - 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(CliError::Arg(format!( - "no wallet named '{name}'. Run 'qn rpc wallet list' to see stored wallets, \ - or create one with 'qn rpc wallet generate'" - ))); - } - Ok(path) -} - /// Reads and validates a key file, plus an ssh-style permissions warning when /// the file is group- or world-readable. fn read_key_file(path: &Path) -> Result<(String, Option), CliError> { diff --git a/src/commands/rpc/x402.rs b/src/commands/rpc/x402.rs index 9f42030..c0f92ce 100644 --- a/src/commands/rpc/x402.rs +++ b/src/commands/rpc/x402.rs @@ -70,7 +70,7 @@ pub struct PaymentArgs { #[arg(long, value_name = "PATH", conflicts_with = "payment_wallet")] pub payment_key_file: Option, - /// Name of a stored wallet (from `qn rpc wallet generate`) to pay with. + /// Name of a stored wallet (from `qn wallet generate`) to pay with. #[arg(long, value_name = "NAME")] pub payment_wallet: Option, @@ -124,7 +124,7 @@ pub struct SessionArgs { #[arg(long, value_name = "PATH", conflicts_with = "payment_wallet")] pub payment_key_file: Option, - /// Name of a stored wallet (from `qn rpc wallet generate`) to authenticate with. + /// Name of a stored wallet (from `qn wallet generate`) to authenticate with. #[arg(long, value_name = "NAME")] pub payment_wallet: Option, diff --git a/src/commands/rpc/wallet.rs b/src/commands/wallet.rs similarity index 94% rename from src/commands/rpc/wallet.rs rename to src/commands/wallet.rs index f1f3d73..717a30a 100644 --- a/src/commands/rpc/wallet.rs +++ b/src/commands/wallet.rs @@ -1,4 +1,4 @@ -//! `qn rpc wallet …` — a local store of dedicated payment wallets. +//! `qn wallet …` — a local store of dedicated payment wallets. //! //! Removes the raw-key-file juggling the paid RPC lane otherwise requires: //! `generate` creates a fresh keypair, stores the raw key at 0600 under @@ -39,8 +39,8 @@ pub struct Args { pub enum WalletCmd { /// Generate a new payment wallet and store it locally. #[command(after_help = "Examples:\n \ - qn rpc wallet generate --chain evm --name payer\n \ - qn rpc wallet generate --chain svm --name sol-payer")] + qn wallet generate --chain evm --name payer\n \ + qn wallet generate --chain svm --name sol-payer")] Generate(GenerateArgs), /// List stored wallets (names, chain, address — never keys). @@ -220,7 +220,7 @@ const CUSTODY_NOTE: &str = "This wallet is stored only on this machine. \ /// Prints the address to stdout (the pipeable value), and to stderr a spaced, /// lightly-styled block: the QR (TTY only), the private key file path, a /// funding hint, and the custody note. Everything but the bare address goes to -/// stderr, so a piped `qn rpc wallet show` yields just the address; the QR +/// stderr, so a piped `qn wallet show` yields just the address; the QR /// must never go to stdout — it would corrupt the pipe. Styling is applied /// only when `ctx.out.color` is set (a TTY with color enabled). fn emit_address(ctx: &Ctx, meta: &WalletMeta, key_path: &Path, with_qr: bool) { @@ -453,10 +453,25 @@ fn validate_name(name: &str) -> Result { fn not_found(name: &str) -> CliError { CliError::Arg(format!( - "no wallet named '{name}'. Run 'qn rpc wallet list' to see stored wallets" + "no wallet named '{name}'. Run 'qn wallet list' to see stored wallets, \ + or create one with 'qn wallet generate'" )) } +/// Resolves a stored wallet name to its key file path, validating the name and +/// checking the file exists. Also used by the paid RPC lane's +/// `--payment-wallet`, so the name rules live in one place. +pub(crate) fn key_path(name: &str, wallets_dir: Option<&Path>) -> Result { + let name = validate_name(name)?; + let dir = wallets_dir + .ok_or_else(|| CliError::Arg("could not resolve the wallet store directory".to_string()))?; + let path = dir.join(&name); + if !path.exists() { + return Err(not_found(&name)); + } + Ok(path) +} + fn remove_err(path: &Path, source: std::io::Error) -> CliError { CliError::ConfigWrite { path: path.to_path_buf(), diff --git a/src/config.rs b/src/config.rs index c4c44b4..c85830e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -84,7 +84,7 @@ pub struct PaymentSection { /// Solana base58). Never the key itself. #[serde(default)] pub key_file: Option, - /// Name of a stored wallet (from `qn rpc wallet generate`) to pay with, as + /// Name of a stored wallet (from `qn wallet generate`) to pay with, as /// an alternative to `key_file`. Resolved to its key file under the wallet /// store. Never the key itself. #[serde(default)] diff --git a/src/context.rs b/src/context.rs index 94de951..b512a9f 100644 --- a/src/context.rs +++ b/src/context.rs @@ -235,7 +235,7 @@ impl Ctx { } /// Keyless construction for local-only commands that make no network calls - /// (e.g. `qn rpc wallet` key management). Resolves no API key and builds no + /// (e.g. `qn wallet` key management). Resolves no API key and builds no /// payment lane, so it works on a machine that has never run `qn auth /// login`. The SDK is present only to satisfy the [`Ctx`] shape; every /// sub-client would 401 if called. diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index 7287491..be4cb26 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -215,7 +215,6 @@ async fn payment_wallet_resolves_stored_key_for_paid_call() { &[ "--config-file", &cfg, - "rpc", "wallet", "generate", "--chain", diff --git a/tests/rpc_wallet.rs b/tests/wallet.rs similarity index 91% rename from tests/rpc_wallet.rs rename to tests/wallet.rs index b8c0109..3b78e42 100644 --- a/tests/rpc_wallet.rs +++ b/tests/wallet.rs @@ -1,4 +1,4 @@ -//! Integration tests for `qn rpc wallet …` — the local payment-wallet store. +//! Integration tests for `qn wallet …` — the local payment-wallet store. //! //! These commands are keyless and make no HTTP calls, so there's no wiremock //! gateway here. The in-process harness doesn't capture stdout, so assertions @@ -31,7 +31,6 @@ async fn generate_writes_key_and_sidecar_at_0600() { &[ "--config-file", &cfg, - "rpc", "wallet", "generate", "--chain", @@ -84,7 +83,6 @@ async fn generate_svm_stores_base58_key() { &[ "--config-file", &cfg, - "rpc", "wallet", "generate", "--chain", @@ -118,7 +116,6 @@ async fn generate_refuses_overwrite_without_force() { let args = &[ "--config-file", &cfg, - "rpc", "wallet", "generate", "--chain", @@ -151,7 +148,6 @@ async fn generate_rejects_unsafe_name() { &[ "--config-file", &cfg, - "rpc", "wallet", "generate", "--chain", @@ -179,7 +175,6 @@ async fn rm_without_yes_non_tty_needs_confirmation_and_keeps_files() { &[ "--config-file", &cfg, - "rpc", "wallet", "generate", "--chain", @@ -194,11 +189,7 @@ async fn rm_without_yes_non_tty_needs_confirmation_and_keeps_files() { ); // Non-TTY without --yes: exit 5, and both files remain. - let out = run_qn( - BASE, - &["--config-file", &cfg, "rpc", "wallet", "rm", "keep"], - ) - .await; + 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()); @@ -214,7 +205,6 @@ async fn rm_with_yes_deletes_key_and_sidecar() { &[ "--config-file", &cfg, - "rpc", "wallet", "generate", "--chain", @@ -230,15 +220,7 @@ async fn rm_with_yes_deletes_key_and_sidecar() { let out = run_qn( BASE, - &[ - "--config-file", - &cfg, - "rpc", - "wallet", - "rm", - "gone", - "--yes", - ], + &["--config-file", &cfg, "wallet", "rm", "gone", "--yes"], ) .await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); @@ -252,15 +234,7 @@ async fn rm_unknown_wallet_errors() { let cfg = cfg_in(dir.path()); let out = run_qn( BASE, - &[ - "--config-file", - &cfg, - "rpc", - "wallet", - "rm", - "nope", - "--yes", - ], + &["--config-file", &cfg, "wallet", "rm", "nope", "--yes"], ) .await; assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); @@ -275,11 +249,7 @@ async fn rm_unknown_wallet_errors() { 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, "rpc", "wallet", "show", "ghost"], - ) - .await; + 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"), @@ -302,7 +272,6 @@ async fn generate_prints_key_path_and_custody_note() { &cfg, "--no-input", "--no-color", - "rpc", "wallet", "generate", "--chain", From b89808ea9616294d00985fb3310419b176da07ed Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 29 Jul 2026 11:29:57 -0300 Subject: [PATCH 29/32] docs(readme): drop spend flags from x402 drip/balance examples drip and balance no longer accept --payment-asset (they present a Bearer JWT and sign nothing), so the walkthrough examples must not pass it. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5f2ec31..44cacc9 100644 --- a/README.md +++ b/README.md @@ -423,7 +423,7 @@ qn wallet generate --chain 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 --payment-asset USDC +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). @@ -431,7 +431,7 @@ qn rpc x402 buy-credits --network base-sepolia --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 --payment-asset USDC +qn rpc x402 balance --payment-wallet payer --payment-network base-sepolia # Spend credits on calls. A drawdown call signs nothing per request, so it # needs only the wallet — no asset or spend ceiling. From 3664609351b3d8bca919ebf606233e325340cdbd Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 29 Jul 2026 13:53:34 -0300 Subject: [PATCH 30/32] Formatting updates --- IMPLEMENTATION_PLAN.md | 48 +++++++++++++ README.md | 18 ++--- src/commands/agent/context.md | 6 +- src/commands/rpc/mod.rs | 2 +- src/commands/rpc/x402.rs | 79 ++++++++++++++++----- src/commands/wallet.rs | 125 +++++++++++++++++++++------------- src/output.rs | 18 +++++ tests/rpc_payment.rs | 2 +- tests/wallet.rs | 20 +++--- 9 files changed, 232 insertions(+), 86 deletions(-) create mode 100644 IMPLEMENTATION_PLAN.md diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..369162a --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,48 @@ +# Implementation plan: x402 drawdown + MPP session + +Source of truth for the UX design: +`~/.claude/plans/research-quicknode-gateway-payment-vast-babbage.md`. +This file tracks execution status only; do not re-litigate the 14 decisions. + +Branch `x402_MPP`, one PR, both models, phased commits (x402 drawdown first, +then MPP session). Do NOT commit this file (public repo). + +Pre-stage (done): popped stash and committed the per-request `--payment-*` +flag rename + asset-name resolution. Plan naming is now real. + +## Stage 1: SDK x402 drawdown +**Goal**: SIWX auth (POST /auth), JWT seed/export, drawdown call (Bearer, no +signing), GET /credits, POST /drip, credit purchase via existing 402 signer. +New `PaymentScheme` variant(s) in `../sdk/crates/core/src/rpc/payment/mod.rs`. +**Success criteria**: SDK wiremock unit tests; per-request paths untouched. +**Status**: Complete (SDK commit e6416f4) + +## Stage 2: CLI `qn rpc x402` noun +**Goal**: buy-credits/balance/drip in `src/commands/rpc/x402.rs`; JWT cache +(0600, wallet-address keyed); Mild gating w/ ceiling-naming prompt; next hints. +**Success criteria**: happy + error + both gating tests in tests/rpc_payment.rs. +**Status**: Complete (CLI commit 21ef1b1) + +## Stage 3: CLI `--x402-drawdown` on call +**Goal**: ArgGroup member; auto re-auth; error mapping (empty credits, monthly +limit). context.md + README in same commits. +**Success criteria**: wiremock tests incl. expired-JWT re-auth; single-attempt. +**Status**: Complete (CLI commit 7b11106) + +## Stage 4: SDK MPP session +**Goal**: escrow deposit tx (Tempo first), open/top-up/close/status, cumulative +EIP-712 voucher signer, `/session/:network` prefix via `host_base`. +**Success criteria**: SDK tests; charge-intent path untouched. +**Status**: Complete (SDK commit 6da9adc; byte-exact voucher+channelId vectors) + +## Stage 5: CLI `qn rpc mpp` noun + `--mpp-session` on call +**Goal**: open/top-up/close/status in `src/commands/rpc/mpp.rs`; channel state +file + recovery-via-status; Mild gating incl. close; voucher call mode. +**Success criteria**: full test matrix; snapshot review for table output. +**Status**: Complete (SDK 06b5bc4; CLI ab0c188) + +## Stage 6: README Micropayments refactor + docs polish +**Goal**: one Micropayments section, four zero-to-call walkthroughs + shared +Get-a-wallet preamble; call --help examples; deferral notes. +**Success criteria**: all four walkthroughs copy-pasteable; full verify clean. +**Status**: In Progress diff --git a/README.md b/README.md index 44cacc9..58808a9 100644 --- a/README.md +++ b/README.md @@ -353,8 +353,8 @@ Every path needs a payment wallet. Two options: referenced by name with `--payment-wallet`): ```sh - qn wallet generate --chain evm --name payer # evm also covers MPP/Tempo - qn wallet generate --chain svm --name sol-payer # svm for x402/Solana + 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 @@ -377,7 +377,7 @@ is a ready `--payment-asset`. Cached at `~/.config/qn/pay-networks.toml` (24h). Sign an x402 payment on each call (EVM or Solana stablecoin): ```sh -qn wallet generate --chain evm --name payer # fund the printed address with Base Sepolia USDC +qn wallet generate --vm evm --name payer # fund the printed address with Base Sepolia USDC qn rpc call eth_blockNumber \ --network base-sepolia --x402 \ --payment-wallet payer \ @@ -389,7 +389,7 @@ qn rpc call eth_blockNumber \ `--network` is the chain you *query*; `--payment-network` is the chain the payment *settles* on (independent). `--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 an `--chain svm` wallet and use +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). @@ -400,7 +400,7 @@ uses the same secp256k1 key format); `--receipt` wraps the result with the settlement transaction hash: ```sh -qn wallet generate --chain evm --name payer # fund on Tempo testnet +qn wallet generate --vm evm --name payer # fund on Tempo testnet qn rpc call eth_blockNumber \ --network tempo-testnet --mpp --receipt \ --payment-wallet payer \ @@ -419,7 +419,7 @@ Buy a block of prepaid credits once, then spend them with no per-call signing (one credit per successful response): ```sh -qn wallet generate --chain evm --name payer # dedicated wallet +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. @@ -448,7 +448,7 @@ 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 --chain evm --name payer # evm covers Tempo; fund the address +qn wallet generate --vm evm --name payer # evm covers Tempo; fund the address # Open a channel by depositing into the escrow (moves real funds; gated). qn rpc mpp open --network tempo-testnet --deposit 1000000 \ @@ -512,8 +512,8 @@ paid RPC lane uses (see [Micropayments](#micropayments)). It needs no API key or login: ```sh -qn wallet generate --chain evm --name payer # create + store; prints the address and a QR to fund -qn wallet list # names, chain, address (never the key) +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; the key is unrecoverable ``` diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index ef4a87e..30a1841 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -209,7 +209,7 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase `--x402`/`--mpp`/`--x402-drawdown`/`--endpoint-url`, and points at `qn rpc mpp top-up` when the channel deposit is exhausted. - `wallet` — the local store of payment wallets for the paid RPC lane; no API - key or login required. `qn wallet generate --chain --name ` + 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 ` @@ -295,7 +295,7 @@ refused with HTTP 400/402 before anything settles. ```sh qn rpc pay-networks # which networks are payable, and the x402 asset -qn wallet generate --chain evm --name payer # dedicated wallet; prints its address + a QR to fund +qn wallet generate --vm evm --name payer # dedicated wallet; prints its address + a QR to fund # → fund THAT address, then pick a lane: # x402 on EVM (Base Sepolia testnet, USDC): @@ -309,7 +309,7 @@ qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt \ --payment-asset USDC --max-amount 1000 # x402 on Solana (devnet); needs an SVM wallet: -qn wallet generate --chain svm --name sol-payer +qn wallet generate --vm svm --name sol-payer qn rpc call getSlot --network solana-devnet --x402 \ --payment-wallet sol-payer --payment-network solana-devnet \ --payment-asset USDC --max-amount 1000 diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index bd9ae30..5945863 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -77,7 +77,7 @@ pub enum RpcCmd { qn rpc call eth_blockNumber --network tempo-testnet --mpp-session --payment-wallet payer\n\n\ See payable networks and manage wallets:\n \ qn rpc pay-networks\n \ - qn wallet generate --chain evm --name payer")] + qn wallet generate --vm evm --name payer")] Call(Box), /// List the endpoint's available network keys (no RPC call). diff --git a/src/commands/rpc/x402.rs b/src/commands/rpc/x402.rs index c0f92ce..39da0f4 100644 --- a/src/commands/rpc/x402.rs +++ b/src/commands/rpc/x402.rs @@ -24,6 +24,7 @@ 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, @@ -256,23 +257,32 @@ async fn run_buy_credits(args: PaymentArgs, global: GlobalArgs) -> Result<(), Cl "✓ Bought credits (balance: {})", fmt_credits(balance.credits) )); - ctx.out - .note(&format!(" Next: {}", drawdown_call_hint(&args, &network))); + ctx.out.note(&format!( + "\n{}\n\n{}", + style( + "Spend the credits on calls (signs nothing per request):", + Style::Bold, + ctx.out.color, + ), + drawdown_call_hint(&args, &network) + )); emit_balance(&ctx, &balance) } -// A copy-pasteable `--x402-drawdown` call reflecting the flags the user just -// used, so the chained next step runs as-is. +// A copy-pasteable, multi-line `--x402-drawdown` call reflecting the flags the +// user just used, so the chained next step runs as-is. fn drawdown_call_hint(args: &PaymentArgs, network: &str) -> String { let mut cmd = format!( - "qn rpc call eth_blockNumber --network {network} --x402-drawdown --payment-wallet {}", + " qn rpc call eth_blockNumber \\\n \ + --network {network} --x402-drawdown \\\n \ + --payment-wallet {}", args.payment_wallet.as_deref().unwrap_or("") ); // The drawdown call defaults its pay network to --network, so only append // --payment-network when the user set an explicit one that differs. if let Some(pn) = &args.payment_network { if pn != network { - cmd.push_str(&format!(" --payment-network {pn}")); + cmd.push_str(&format!(" \\\n --payment-network {pn}")); } } cmd @@ -296,17 +306,54 @@ async fn run_drip(args: SessionArgs, global: GlobalArgs) -> Result<(), CliError> "✓ Faucet funded {} (tx: {})", receipt.account_id, receipt.transaction_hash )); - // Point at buy-credits with the flags the user already supplied. - let net = args.network.as_deref().or(args.payment_network.as_deref()); - // buy-credits signs, so the hint carries the asset + ceiling placeholders - // the user fills in for the purchase (drip itself collects neither). - ctx.out.note(&format!( - " Next: qn rpc x402 buy-credits --network {} --payment-wallet {} \ - --payment-network {} --payment-asset --max-amount 1000000", - net.unwrap_or(""), - args.payment_wallet.as_deref().unwrap_or(""), - args.payment_network.as_deref().unwrap_or(""), + // Point at the two paid lanes with the flags the user already supplied; + // USDC is the asset the faucet just funded. + let query_net = args + .network + .as_deref() + .or(args.payment_network.as_deref()) + .unwrap_or(""); + 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 \ + qn rpc call eth_blockNumber \\\n \ + --network {query_net} --x402 \\\n \ + --payment-wallet {wallet} \\\n \ + --payment-network {pay_net} \\\n \ + --payment-asset USDC \\\n \ + --max-amount 1000\n\n" + )); + block.push_str(&style( + "Credit drawdown (buy prepaid credits once, then spend them):", + Style::Bold, + c, + )); + block.push_str(&format!( + "\n\n \ + qn rpc x402 buy-credits \\\n \ + --network {query_net} \\\n \ + --payment-wallet {wallet} \\\n \ + --payment-network {pay_net} \\\n \ + --payment-asset USDC \\\n \ + --max-amount 1000000\n\n \ + qn rpc call eth_blockNumber \\\n \ + --network {query_net} --x402-drawdown \\\n \ + --payment-wallet {wallet}" )); + 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, diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index 717a30a..1af1344 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -9,7 +9,7 @@ //! Storage layout, per wallet ``: //! - `` — the raw private key (0600), the exact bytes the paid //! lane's `read_key_file` expects. -//! - `.toml` — public metadata (chain, address, created-at). Never the +//! - `.toml` — public metadata (vm, address, created-at). Never the //! key, so `list`/`show` read this and never open the key file. //! //! The key is stored **unencrypted** at 0600 (the `solana-keygen` model): the @@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize}; use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity}; use crate::context::Ctx; use crate::errors::CliError; -use crate::output::{new_table, set_header_bold, write_table, Render}; +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)] @@ -39,11 +39,11 @@ pub struct Args { pub enum WalletCmd { /// Generate a new payment wallet and store it locally. #[command(after_help = "Examples:\n \ - qn wallet generate --chain evm --name payer\n \ - qn wallet generate --chain svm --name sol-payer")] + qn wallet generate --vm evm --name payer\n \ + qn wallet generate --vm svm --name sol-payer")] Generate(GenerateArgs), - /// List stored wallets (names, chain, address — never keys). + /// List stored wallets (names, VM family, address — never keys). #[command(visible_alias = "ls")] List, @@ -54,38 +54,38 @@ pub enum WalletCmd { Rm(RmArgs), } -/// Pay-chain family a generated wallet targets. `evm` also covers MPP/Tempo +/// 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 WalletChain { +pub enum WalletVm { /// secp256k1 wallet for x402/EVM and MPP/Tempo (`0x…` hex key). Evm, /// ed25519 wallet for x402/Solana (base58 key). Svm, } -impl WalletChain { +impl WalletVm { fn kind(self) -> ChainKind { match self { - WalletChain::Evm => ChainKind::Evm, - WalletChain::Svm => ChainKind::Svm, + WalletVm::Evm => ChainKind::Evm, + WalletVm::Svm => ChainKind::Svm, } } fn label(self) -> &'static str { match self { - WalletChain::Evm => "evm", - WalletChain::Svm => "svm", + WalletVm::Evm => "evm", + WalletVm::Svm => "svm", } } } #[derive(Debug, ClapArgs)] pub struct GenerateArgs { - /// Pay-chain family: `evm` (x402/EVM, also MPP/Tempo) or `svm` (x402/Solana). + /// VM family: `evm` (x402/EVM, also MPP/Tempo) or `svm` (x402/Solana). #[arg(long)] - pub chain: WalletChain, + pub vm: WalletVm, /// Wallet name (a-z, 0-9, `-`, `_`). Becomes the key file name and the /// `--payment-wallet` handle. @@ -134,10 +134,10 @@ fn generate(a: GenerateArgs, ctx: Ctx) -> Result<(), CliError> { ))); } - let wallet = generate_payment_wallet(a.chain.kind())?; + let wallet = generate_payment_wallet(a.vm.kind())?; let meta = WalletMeta { name: name.clone(), - chain: a.chain.label().to_string(), + vm: a.vm.label().to_string(), address: wallet.address.clone(), created_at_unix: now_unix(), }; @@ -148,7 +148,7 @@ fn generate(a: GenerateArgs, ctx: Ctx) -> Result<(), CliError> { write_meta(&meta_path, &meta)?; ctx.out - .note(&format!("✓ Generated {} wallet '{name}'", a.chain.label())); + .note(&format!("✓ Generated {} wallet '{name}'", a.vm.label())); emit_address(&ctx, &meta, &key_path, /* with_qr */ true); Ok(()) } @@ -252,18 +252,33 @@ fn emit_address(ctx: &Ctx, meta: &WalletMeta, key_path: &Path, with_qr: bool) { )); // Funding hint (only meaningful interactively, where the QR is shown). - // Show a complete, runnable paid-lane call for this wallet's chain family - // so the address can go straight from funded to used. + // Show the testnet funding routes for this wallet's VM family, then a + // complete, runnable paid-lane call so the address can go straight from + // funded to used. if on_tty { block.push('\n'); block.push_str( - "This wallet pays for RPC calls via micropayments (x402/MPP).\n\ - Fund it on the chain you'll pay from, then use it:\n\n", + "This wallet can be used to pay for RPC calls via micropayments (x402/MPP).\n\ + Fund it on the network you'll pay from. Testnet funding:\n\n", ); + block.push_str(&format!("{}\n\n", funding_hint(&meta.vm, &meta.name))); + block.push_str("Then use it:\n\n"); block.push_str(&format!( "{}\n", - style(&example_call(&meta.chain, &meta.name), Style::Bold, c), + style(&example_call(&meta.vm, &meta.name), Style::Bold, c), )); + + let config_path = ctx + .global + .resolve_config_path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "~/.config/qn/config.toml".to_string()); + block.push('\n'); + block.push_str(&format!( + "To make it the default payment wallet (per-call flags still \ + override), add to {config_path}:\n\n", + )); + block.push_str(&format!("{}\n", config_example(&meta.vm, &meta.name))); } // Custody note as dim fine-print, set off by a blank line. @@ -273,13 +288,46 @@ fn emit_address(ctx: &Ctx, meta: &WalletMeta, key_path: &Path, with_qr: bool) { ctx.out.note(&block); } +/// A paste-ready `[rpc.payment]` config section that makes this wallet the +/// default payer, with network/asset/ceiling defaults matching the printed +/// example call. The section only supplies values — a scheme flag +/// (`--x402`/`--mpp`) still activates payment per call. +fn config_example(vm: &str, name: &str) -> String { + let network = match vm { + "svm" => "solana-devnet", + _ => "base-sepolia", + }; + format!( + " [rpc.payment]\n \ + wallet = \"{name}\"\n \ + payment_network = \"{network}\"\n \ + payment_asset = \"USDC\"\n \ + max_amount = 1000" + ) +} + +/// Testnet funding routes for a fresh wallet, per VM family. EVM gets the +/// gateway's built-in Base Sepolia faucet (`qn rpc x402 drip`, pre-filled with +/// this wallet's name), the Circle faucet for the USDC testnets, and the +/// XLayer USDG note; SVM gets the Circle faucet on Solana Devnet. +fn funding_hint(vm: &str, name: &str) -> String { + match vm { + "svm" => " Circle faucet: https://faucet.circle.com (USDC on Solana Devnet)".to_string(), + _ => format!( + " Base Sepolia: qn rpc x402 drip --payment-wallet {name} --payment-network base-sepolia\n \ + Circle faucet: https://faucet.circle.com (USDC on Base Sepolia, Polygon Amoy, Arc)\n \ + XLayer Testnet: send USDG to the address above" + ), + } +} + /// A complete, runnable paid-lane `qn rpc call` for a freshly funded wallet, /// matching the documented examples in the README. SVM gets one x402/Solana /// example; EVM gets both an x402 (Base Sepolia USDC) and an MPP (Tempo /// testnet) example, since the same secp256k1 key works for both. /// `--max-amount 1000` selects the per-request offer (0.001 USDC). -fn example_call(chain: &str, name: &str) -> String { - match chain { +fn example_call(vm: &str, name: &str) -> String { + match vm { "svm" => format!( "qn rpc call getSlot \\\n \ --network solana-devnet --x402 \\\n \ @@ -307,23 +355,6 @@ fn example_call(chain: &str, name: &str) -> String { } } -/// A minimal ANSI style set, applied only when color is enabled. -enum Style { - Bold, - Dim, -} - -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") -} - /// Unicode (half-block) QR of `data`, or `None` if it can't be encoded. fn render_qr(data: &str) -> Option { use qrcode::render::unicode; @@ -352,11 +383,11 @@ impl Render for WalletsView { return Ok(()); } let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["NAME", "CHAIN", "ADDRESS"]); + 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.chain), + Cell::new(&wl.vm), Cell::new(&wl.address), ]); } @@ -371,7 +402,9 @@ impl Render for WalletsView { #[derive(Debug, Clone, Serialize, Deserialize)] struct WalletMeta { name: String, - chain: String, + // `alias` keeps sidecars written before the `--vm` rename loadable. + #[serde(alias = "chain")] + vm: String, address: String, created_at_unix: i64, } @@ -383,10 +416,10 @@ impl Render for WalletMeta { ctx: &crate::output::OutputCtx, ) -> std::io::Result<()> { let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["NAME", "CHAIN", "ADDRESS"]); + set_header_bold(&mut t, ctx, vec!["NAME", "VM", "ADDRESS"]); t.add_row(vec![ Cell::new(&self.name), - Cell::new(&self.chain), + Cell::new(&self.vm), Cell::new(&self.address), ]); write_table(w, &t) 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 index be4cb26..dba6213 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -217,7 +217,7 @@ async fn payment_wallet_resolves_stored_key_for_paid_call() { &cfg, "wallet", "generate", - "--chain", + "--vm", "evm", "--name", "payer", diff --git a/tests/wallet.rs b/tests/wallet.rs index 3b78e42..6fab0e1 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -33,7 +33,7 @@ async fn generate_writes_key_and_sidecar_at_0600() { &cfg, "wallet", "generate", - "--chain", + "--vm", "evm", "--name", "payer", @@ -51,9 +51,9 @@ async fn generate_writes_key_and_sidecar_at_0600() { let raw = std::fs::read_to_string(&key).unwrap(); assert!(raw.trim().starts_with("0x"), "key not 0x-prefixed: {raw}"); - // The sidecar records the chain + a derived 0x address, never the key. + // The sidecar records the vm + a derived 0x address, never the key. let meta_text = std::fs::read_to_string(&meta).unwrap(); - assert!(meta_text.contains("chain = \"evm\""), "meta: {meta_text}"); + 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!"); @@ -85,7 +85,7 @@ async fn generate_svm_stores_base58_key() { &cfg, "wallet", "generate", - "--chain", + "--vm", "svm", "--name", "sol", @@ -106,7 +106,7 @@ async fn generate_svm_stores_base58_key() { // The address in the sidecar is the base58 pubkey, not a 0x address. let meta = std::fs::read_to_string(wallets_dir(dir.path()).join("sol.toml")).unwrap(); - assert!(meta.contains("chain = \"svm\""), "meta: {meta}"); + assert!(meta.contains("vm = \"svm\""), "meta: {meta}"); } #[tokio::test] @@ -118,7 +118,7 @@ async fn generate_refuses_overwrite_without_force() { &cfg, "wallet", "generate", - "--chain", + "--vm", "evm", "--name", "dup", @@ -150,7 +150,7 @@ async fn generate_rejects_unsafe_name() { &cfg, "wallet", "generate", - "--chain", + "--vm", "evm", "--name", "../escape", @@ -177,7 +177,7 @@ async fn rm_without_yes_non_tty_needs_confirmation_and_keeps_files() { &cfg, "wallet", "generate", - "--chain", + "--vm", "evm", "--name", "keep", @@ -207,7 +207,7 @@ async fn rm_with_yes_deletes_key_and_sidecar() { &cfg, "wallet", "generate", - "--chain", + "--vm", "evm", "--name", "gone", @@ -274,7 +274,7 @@ async fn generate_prints_key_path_and_custody_note() { "--no-color", "wallet", "generate", - "--chain", + "--vm", "evm", "--name", "payer", From 7c8a5078e0de18f768fbde43532bd254def1ae48 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 30 Jul 2026 11:14:59 -0300 Subject: [PATCH 31/32] Review updates --- README.md | 37 +- src/commands/agent/context.md | 28 +- src/commands/rpc/mod.rs | 31 +- src/commands/rpc/mpp.rs | 11 +- src/commands/rpc/pay_asset.rs | 63 ++- src/commands/rpc/pay_network.rs | 6 +- src/commands/rpc/pay_networks.rs | 211 -------- src/commands/rpc/payment.rs | 67 ++- src/commands/rpc/supported_networks.rs | 483 ++++++++++++++++++ src/commands/rpc/x402.rs | 146 ++++-- src/commands/wallet.rs | 50 +- src/config.rs | 142 +++-- src/errors.rs | 4 +- tests/rpc_pay_networks.rs | 120 ----- tests/rpc_supported_networks.rs | 336 ++++++++++++ ...ted_networks_table_shows_two_sections.snap | 12 + ...tworks_table_merges_schemes_and_asset.snap | 8 - ...ted_networks_table_shows_two_sections.snap | 13 + tests/table_snapshots.rs | 97 ++-- 19 files changed, 1335 insertions(+), 530 deletions(-) delete mode 100644 src/commands/rpc/pay_networks.rs create mode 100644 src/commands/rpc/supported_networks.rs delete mode 100644 tests/rpc_pay_networks.rs create mode 100644 tests/rpc_supported_networks.rs create mode 100644 tests/snapshots/table_snapshots__mpp_supported_networks_table_shows_two_sections.snap delete mode 100644 tests/snapshots/table_snapshots__pay_networks_table_merges_schemes_and_asset.snap create mode 100644 tests/snapshots/table_snapshots__x402_supported_networks_table_shows_two_sections.snap diff --git a/README.md b/README.md index 58808a9..c238640 100644 --- a/README.md +++ b/README.md @@ -368,9 +368,12 @@ Every path needs a payment wallet. Two options: never an environment variable, never a flag value, never inline in config, never printed. -`qn rpc pay-networks` (alias `pay-nets`) lists what each gateway accepts (no -API key needed): a listed network is a valid `--network`, and its asset column -is a ready `--payment-asset`. Cached at `~/.config/qn/pay-networks.toml` (24h). +`qn rpc x402 supported-networks` and `qn rpc mpp supported-networks` (alias +`networks`) each show one gateway's catalog in two sections (no API key +needed): the callable networks (each slug is a valid `--network` for a paid +call) and the accepted currencies (each row's network/address pair is a ready +`--payment-network`/`--payment-asset`). Cached at +`~/.config/qn/pay-networks.toml` (24h). ##### Path 1 — x402 per-request @@ -379,7 +382,7 @@ 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 base-sepolia --x402 \ + --network ethereum-mainnet --x402 \ --payment-wallet payer \ --payment-network base-sepolia \ --payment-asset USDC \ @@ -387,7 +390,8 @@ qn rpc call eth_blockNumber \ ``` `--network` is the chain you *query*; `--payment-network` is the chain the -payment *settles* on (independent). `--max-amount` is the per-call ceiling in +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` @@ -395,14 +399,14 @@ at volume; the public default rate-limits). ##### Path 2 — MPP per-request (charge) -The same as path 1 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: +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 tempo-testnet --mpp --receipt \ + --network ethereum-mainnet --mpp --receipt \ --payment-wallet payer \ --payment-network tempo-testnet \ --payment-asset USDC \ @@ -427,15 +431,16 @@ 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 base-sepolia --payment-wallet payer \ +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. A drawdown call signs nothing per request, so it -# needs only the wallet — no asset or spend ceiling. -qn rpc call eth_blockNumber --network base-sepolia --x402-drawdown --payment-wallet payer +# Spend credits on calls: 1 credit per call, no per-call payment, so only +# the wallet is needed — no asset or spend ceiling. Credits are not +# network-scoped: query any supported network, not just the one you paid on. +qn rpc call eth_blockNumber --network ethereum-mainnet --x402-drawdown --payment-wallet payer ``` The gateway session (a JWT) is authenticated once and cached (0600) under the @@ -451,7 +456,7 @@ cumulative EIP-712 voucher (no on-chain transaction per call): qn wallet generate --vm evm --name payer # evm covers Tempo; fund the address # Open a channel by depositing into the escrow (moves real funds; gated). -qn rpc mpp open --network tempo-testnet --deposit 1000000 \ +qn rpc mpp open --network tempo-testnet --deposit 1000000 --max-amount 1000000 \ --payment-wallet payer --payment-network tempo-testnet --payment-asset USDC # Pay for calls from the channel — one cumulative voucher per call. @@ -499,7 +504,7 @@ payment_asset = "USDC" # symbol (resolved per network), or a raw add ``` ```sh -qn rpc call eth_blockNumber --network base-sepolia --x402 +qn rpc call eth_blockNumber --network ethereum-mainnet --x402 ``` Manage stored wallets with the top-level `qn wallet` noun (see @@ -515,7 +520,7 @@ or login: 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; the key is unrecoverable +qn wallet rm payer # gated: --yes to confirm; destroys the local key ``` Keys are stored unencrypted at `0600` under `~/.config/qn/wallets/`. Treat each diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index 30a1841..fb39f9a 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -166,10 +166,12 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase Mutually exclusive with `--endpoint-url`. **Wallets**: stored payment wallets are managed by the top-level `qn wallet` noun (see below); reference one on a paid call with `--payment-wallet `. - **Discovery**: `qn rpc pay-networks` (alias `pay-nets`) lists the networks - payable via the paid lane, from the gateways' public discovery endpoints - (no API key). A listed slug is a valid `--network`; the x402 asset column is - a ready `--payment-asset` value. + **Discovery**: `qn rpc x402 supported-networks` and `qn rpc mpp + supported-networks` (alias `networks`) each show one gateway's catalog in + two sections, from its public discovery surfaces (no API key): the callable + networks (each slug is a valid `--network` for a paid call) and the accepted + currencies (each row's network/address pair is a ready + `--payment-network`/`--payment-asset`). **x402 drawdown**: `qn rpc x402 {buy-credits, balance, drip}` manages prepaid gateway credits instead of paying per request, and `qn rpc call --x402-drawdown` spends them (no per-call signing, 1 credit per successful @@ -290,21 +292,23 @@ is enabling Tooling Access (or pass `--yes` to enable on first use). A custom **Pay per call with a crypto micropayment (no API key, no login):** `--payment-asset`/`--max-amount` must match a real offer for the network (see -`qn rpc pay-networks`); an unfunded wallet or a mismatched asset/amount is -refused with HTTP 400/402 before anything settles. +`qn rpc x402 supported-networks` / `qn rpc mpp supported-networks`); an +unfunded wallet or a mismatched asset/amount is refused with HTTP 400/402 +before anything settles. ```sh -qn rpc pay-networks # which networks are payable, and the x402 asset +qn rpc x402 supported-networks # callable networks + accepted currencies (mpp: qn rpc mpp supported-networks) qn wallet generate --vm evm --name payer # dedicated wallet; prints its address + a QR to fund # → fund THAT address, then pick a lane: -# x402 on EVM (Base Sepolia testnet, USDC): -qn rpc call eth_blockNumber --network base-sepolia --x402 \ +# x402 on EVM (pays Base Sepolia USDC; --network, the chain you query, is +# independent of the chain you pay on): +qn rpc call eth_blockNumber --network ethereum-mainnet --x402 \ --payment-wallet payer --payment-network base-sepolia \ --payment-asset USDC --max-amount 1000 -# MPP on Tempo (testnet); same EVM wallet, --receipt adds the settlement ref: -qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt \ +# MPP (pays Tempo testnet USDC); same EVM wallet, --receipt adds the settlement ref: +qn rpc call eth_blockNumber --network ethereum-mainnet --mpp --receipt \ --payment-wallet payer --payment-network tempo-testnet \ --payment-asset USDC --max-amount 1000 @@ -322,7 +326,7 @@ max_amount = "1000" # spend ceiling per call, base payment_network = "base-sepolia" # settlement chain: network name or CAIP-2 id payment_asset = "USDC" # symbol (resolved per network), or a raw address/mint EOF -qn rpc call eth_blockNumber --network base-sepolia --x402 # params now come from config +qn rpc call eth_blockNumber --network ethereum-mainnet --x402 # params now come from config ``` This moves real funds (even testnet tokens are real transfers) — use a diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index 5945863..606a6db 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -27,8 +27,8 @@ mod mpp; mod pay_asset; mod pay_network; -mod pay_networks; mod payment; +mod supported_networks; mod x402; use std::io::Read; @@ -62,33 +62,31 @@ pub enum RpcCmd { 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 -\n\n\ - Paid (crypto micropayment, no API key; params from [rpc.payment] in config):\n \ - qn rpc call eth_blockNumber --network base-sepolia --x402\n \ - qn rpc call eth_blockNumber --network base-sepolia --x402 \\\n \ + Paid (crypto micropayment, no API key; params from [rpc.payment] in config;\n \ + the payment chain is independent of the chain you query):\n \ + qn rpc call eth_blockNumber --network ethereum-mainnet --x402\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\n\ - Prepaid x402 credits (drawdown — buy once, then spend, no per-call signing):\n \ - qn rpc x402 buy-credits --network base-sepolia --payment-wallet payer \\\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 base-sepolia --x402-drawdown --payment-wallet payer\n\n\ + qn rpc call eth_blockNumber --network ethereum-mainnet --x402-drawdown --payment-wallet payer\n\n\ MPP payment channel (open once, then pay per call with a voucher):\n \ - qn rpc mpp open --network tempo-testnet --deposit 1000000 --payment-wallet payer\n \ + qn rpc mpp open --network tempo-testnet --deposit 1000000 \\\n \ + --payment-wallet payer --max-amount 1000000\n \ qn rpc call eth_blockNumber --network tempo-testnet --mpp-session --payment-wallet payer\n\n\ - See payable networks and manage wallets:\n \ - qn rpc pay-networks\n \ + See callable networks, accepted currencies, and manage wallets:\n \ + qn rpc x402 supported-networks\n \ + qn rpc mpp supported-networks\n \ qn wallet generate --vm evm --name payer")] Call(Box), - /// List the endpoint's available network keys (no RPC call). + /// List the endpoint's available network keys. #[command(visible_alias = "ls")] ListNetworks, - /// List the networks payable via the paid lane (`--x402`/`--mpp`), from the - /// gateways' public discovery endpoints. No API key required. - #[command(visible_alias = "pay-nets")] - PayNetworks, - /// Manage x402 credit drawdown: buy prepaid credits, check the balance, or /// drip testnet credits. Pair with `qn rpc call --x402-drawdown`. X402(x402::Args), @@ -239,7 +237,6 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { match args.cmd { RpcCmd::Call(call) => run_call(*call, global).await, RpcCmd::ListNetworks => run_list_networks(global).await, - RpcCmd::PayNetworks => pay_networks::run(global).await, RpcCmd::X402(x402_args) => x402::run(x402_args, global).await, RpcCmd::Mpp(mpp_args) => mpp::run(mpp_args, global).await, } diff --git a/src/commands/rpc/mpp.rs b/src/commands/rpc/mpp.rs index 908f951..ebc1367 100644 --- a/src/commands/rpc/mpp.rs +++ b/src/commands/rpc/mpp.rs @@ -36,7 +36,8 @@ pub enum MppCmd { /// deposit before signing. #[command(after_help = "Examples:\n \ qn rpc mpp open --network tempo-testnet --deposit 1000000 \\\n \ - --payment-wallet payer --payment-network tempo-testnet --payment-asset USDC")] + --payment-wallet payer --payment-network tempo-testnet --payment-asset USDC \\\n \ + --max-amount 1000000")] Open(OpenArgs), /// Add deposit to the open channel. Gated. @@ -49,6 +50,11 @@ pub enum MppCmd { /// Show the gateway's view of the channel (also the state-recovery path). Status(ChannelArgs), + + /// List the networks callable via MPP and the currencies the gateway + /// accepts as payment. No API key required. + #[command(visible_alias = "networks")] + SupportedNetworks, } /// The payment parameter stack + query network shared by the MPP verbs. @@ -135,6 +141,9 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { MppCmd::TopUp(a) => run_top_up(a, global).await, MppCmd::Close(a) => run_close(a.payment, global).await, MppCmd::Status(a) => run_status(a.payment, global).await, + MppCmd::SupportedNetworks => { + super::supported_networks::run(super::supported_networks::Scheme::Mpp, global).await + } } } diff --git a/src/commands/rpc/pay_asset.rs b/src/commands/rpc/pay_asset.rs index bd17566..1c791a7 100644 --- a/src/commands/rpc/pay_asset.rs +++ b/src/commands/rpc/pay_asset.rs @@ -20,21 +20,32 @@ use crate::errors::CliError; /// (CAIP-2 network, lowercase symbol) → token address. Sorted by the /// `(network, symbol)` tuple (binary searched); a unit test enforces order and /// uniqueness. Addresses are taken from a public source — the payment -/// gateways' discovery catalog (`qn rpc pay-networks`) or, for Tempo, the MPP -/// spec / Tempo payment docs. +/// gateways' discovery catalog (`qn rpc x402 supported-networks`) or, for +/// Tempo, the MPP spec / Tempo payment docs. const PAY_ASSETS: &[(&str, &str, &str)] = &[ ( "eip155:196", "usdc", "0x4ae46a509F6b1D9056937BA4500cb143933D2dc8", ), - // Tempo USDC (mainnet) and pathUSD (testnet USDC), from the MPP spec / - // Tempo payment docs rather than the x402 discovery catalog. + // Tempo pathUSD and USDC.e (bridged USDC, mapped from the `usdc` symbol), + // from the MPP spec / Tempo payment docs and the MPP gateway's own 402 + // challenge. Testnet `usdc` resolves to pathUSD (the testnet stand-in). + ( + "eip155:4217", + "pathusd", + "0x20c0000000000000000000000000000000000000", + ), ( "eip155:4217", "usdc", "0x20c000000000000000000000b9537d11c60e8b50", ), + ( + "eip155:42431", + "pathusd", + "0x20c0000000000000000000000000000000000000", + ), ( "eip155:42431", "usdc", @@ -66,7 +77,7 @@ const PAY_ASSETS: &[(&str, &str, &str)] = &[ /// these (case-insensitively) is rewritten to an address for the given /// network, or errors if that network has no mapping. Anything else passes /// through verbatim as an address. -const KNOWN_SYMBOLS: &[&str] = &["usdc"]; +const KNOWN_SYMBOLS: &[&str] = &["pathusd", "usdc"]; /// Resolves a `--payment-asset` / config `payment_asset` value to a token /// address, given the already-resolved CAIP-2 `network`. A recognized symbol @@ -83,12 +94,33 @@ pub(super) fn resolve(input: &str, network: &str) -> Result { 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 pay-networks' to find it", + --payment-asset — run 'qn rpc x402 supported-networks' or \ + 'qn rpc mpp supported-networks' to find it", input.to_ascii_uppercase() ))), } } +/// Reverse lookup: a resolved token address on `network` → its known symbol, +/// in display casing. Used to name the asset in funds-moving prompts and the +/// supported-networks tables instead of echoing the raw contract address. EVM +/// hex compares case-insensitively (checksum casing varies); a miss just means +/// the caller shows the address. +pub(super) fn symbol_for(network: &str, address: &str) -> Option { + PAY_ASSETS + .iter() + .find(|(net, _, addr)| *net == network && addr.eq_ignore_ascii_case(address)) + .map(|(_, sym, _)| display_symbol(sym)) +} + +/// Display casing for a lowercase table symbol (`pathusd` → `pathUSD`). +fn display_symbol(sym: &str) -> String { + match sym { + "pathusd" => "pathUSD".to_string(), + other => other.to_ascii_uppercase(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -143,6 +175,25 @@ mod tests { ); } + #[test] + fn symbol_for_reverses_known_addresses() { + assert_eq!( + symbol_for("eip155:84532", "0x036CbD53842c5426634e7929541eC2318f3dCF7e").as_deref(), + Some("USDC") + ); + // Checksum casing differences still match. + assert_eq!( + symbol_for("eip155:84532", "0x036cbd53842c5426634e7929541ec2318f3dcf7e").as_deref(), + Some("USDC") + ); + // Unknown address, or a known address on the wrong network, is a miss. + assert_eq!(symbol_for("eip155:84532", "0xabc"), None); + assert_eq!( + symbol_for("eip155:1", "0x036CbD53842c5426634e7929541eC2318f3dCF7e"), + None + ); + } + #[test] fn known_symbol_on_unmapped_network_errors() { let err = resolve("usdc", "eip155:1").unwrap_err(); diff --git a/src/commands/rpc/pay_network.rs b/src/commands/rpc/pay_network.rs index 8291e96..cef1f27 100644 --- a/src/commands/rpc/pay_network.rs +++ b/src/commands/rpc/pay_network.rs @@ -136,9 +136,9 @@ pub(super) fn resolve(input: &str) -> Result { } /// Reverse lookup: a CAIP-2 id → the first Quicknode network name mapped to it, -/// if any. Used to attach discovery-catalog assets (keyed by CAIP-2) to the -/// slug rows in `qn rpc pay-networks`. Linear scan — the table is small and -/// sorted by name, not id. +/// if any. Used to name the discovery-catalog payment networks (keyed by +/// CAIP-2) in the `supported-networks` tables. Linear scan — the table is +/// small and sorted by name, not id. pub(super) fn slug_for_caip2(caip2: &str) -> Option { PAY_NETWORKS .iter() diff --git a/src/commands/rpc/pay_networks.rs b/src/commands/rpc/pay_networks.rs deleted file mode 100644 index 2c3caf3..0000000 --- a/src/commands/rpc/pay_networks.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! `qn rpc pay-networks` — list the networks payable via the crypto -//! micropayment lane (`--x402`/`--mpp`). -//! -//! Keyless and public: this fetches the gateways' own discovery endpoints -//! (`x402.quicknode.com/networks` + `/discovery/resources`, and -//! `mpp.quicknode.com/networks`) directly, not through the SDK — those hosts -//! are the payment gateways, not the account API. Results are cached in -//! `pay-networks.toml` next to the config with a 24h TTL, mirroring the -//! multichain URL cache. A `--network` value from this list is a valid -//! `--network` slug for a paid call; the x402 `asset` column (when shown) is a -//! ready value for `--asset`. - -use std::collections::BTreeMap; - -use serde::Deserialize; - -use crate::config::{self, PayNetworkEntry}; -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"; - -pub(super) async fn run(global: GlobalArgs) -> Result<(), CliError> { - // A `--base-url` override points both gateway fetches at one host (used by - // tests to serve the discovery endpoints from a mock). It also bypasses the - // cache, since a test host's data isn't the real catalog. - 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()); - - // Fresh cache hit? (Skipped when a --base-url override is active.) - let cached = if base_override.is_none() { - cache_path - .as_deref() - .and_then(|p| config::load_pay_networks(p, now_unix())) - } else { - None - }; - - let networks = match cached { - Some(n) => n, - None => { - let (x402_base, mpp_base) = match &base_override { - Some(b) => (b.as_str(), b.as_str()), - None => (X402_BASE, MPP_BASE), - }; - let fetched = fetch_pay_networks(x402_base, mpp_base).await?; - if base_override.is_none() { - if let Some(p) = cache_path.as_deref() { - let _ = config::save_pay_networks(p, now_unix(), &fetched); - } - } - fetched - } - }; - - crate::output::emit(&ctx.out, &PayNetworksView(networks)) -} - -/// Fetches and merges the payable networks from both gateways. x402 networks -/// are enriched with the asset from the Bazaar discovery catalog when present. -async fn fetch_pay_networks( - x402_base: &str, - mpp_base: &str, -) -> Result, CliError> { - let client = reqwest::Client::new(); - - // BTreeMap keeps the merged list sorted by slug for a stable render. - let mut merged: BTreeMap = BTreeMap::new(); - - let x402 = fetch_networks(&client, x402_base).await?; - for net in x402 { - merged.insert( - net.clone(), - PayNetworkEntry { - network: net, - schemes: vec!["x402".to_string()], - asset: None, - }, - ); - } - - let mpp = fetch_networks(&client, mpp_base).await?; - for net in mpp { - merged - .entry(net.clone()) - .and_modify(|e| e.schemes.push("mpp".to_string())) - .or_insert(PayNetworkEntry { - network: net, - schemes: vec!["mpp".to_string()], - asset: None, - }); - } - - // Enrich x402 networks with the asset from the discovery catalog. This is - // best-effort: a fetch/parse failure leaves the asset column blank rather - // than failing the whole command. - if let Ok(assets) = fetch_x402_assets(&client, x402_base).await { - for (caip2_asset, slug) in assets { - if let Some(entry) = merged.get_mut(&slug) { - entry.asset = Some(caip2_asset); - } - } - } - - Ok(merged.into_values().collect()) -} - -/// GET `{base}/networks` → the `networks` slug array. -async fn fetch_networks(client: &reqwest::Client, base: &str) -> Result, CliError> { - #[derive(Deserialize)] - struct NetworksResp { - networks: Vec, - } - let url = format!("{base}/networks"); - let resp = client - .get(&url) - .send() - .await - .map_err(|e| fetch_err(&url, e))?; - if !resp.status().is_success() { - return Err(CliError::Arg(format!( - "discovery request to {url} failed with HTTP {}", - resp.status().as_u16() - ))); - } - let body: NetworksResp = resp.json().await.map_err(|e| fetch_err(&url, e))?; - Ok(body.networks) -} - -/// GET x402 `/discovery/resources` → a map of Quicknode-slug → asset address, -/// derived from the accepted payment offers. Only EVM (`eip155:`) assets carry -/// a stable slug mapping here; the caller treats this as best-effort. -async fn fetch_x402_assets( - client: &reqwest::Client, - x402_base: &str, -) -> Result, CliError> { - #[derive(Deserialize)] - struct Resources { - items: Vec, - } - #[derive(Deserialize)] - struct ResourceItem { - #[serde(default)] - accepts: Vec, - } - #[derive(Deserialize)] - struct Accept { - network: String, - #[serde(default)] - asset: Option, - } - - let url = format!("{x402_base}/discovery/resources"); - let resp = client - .get(&url) - .send() - .await - .map_err(|e| fetch_err(&url, e))?; - let body: Resources = resp.json().await.map_err(|e| fetch_err(&url, e))?; - - // Map the CAIP-2 network in each offer back to a Quicknode slug via the - // static pay_network table, so the asset lands on the right row. - let mut out = Vec::new(); - for item in body.items { - for accept in item.accepts { - if let (Some(asset), Some(slug)) = ( - accept.asset, - super::pay_network::slug_for_caip2(&accept.network), - ) { - out.push((asset, slug)); - } - } - } - Ok(out) -} - -#[derive(serde::Serialize)] -struct PayNetworksView(Vec); - -impl Render for PayNetworksView { - fn render_table(&self, w: &mut dyn std::io::Write, ctx: &OutputCtx) -> std::io::Result<()> { - if self.0.is_empty() { - writeln!(w, "(no payable networks)")?; - return Ok(()); - } - let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["NETWORK", "SCHEMES", "X402 ASSET"]); - for n in &self.0 { - t.add_row(vec![ - comfy_table::Cell::new(&n.network), - comfy_table::Cell::new(n.schemes.join(", ")), - crate::output::opt_cell(&n.asset), - ]); - } - write_table(w, &t) - } -} - -fn fetch_err(url: &str, e: reqwest::Error) -> CliError { - CliError::Arg(format!("could not fetch payable networks 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) -} diff --git a/src/commands/rpc/payment.rs b/src/commands/rpc/payment.rs index 73a36d8..2f441a0 100644 --- a/src/commands/rpc/payment.rs +++ b/src/commands/rpc/payment.rs @@ -24,6 +24,7 @@ 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; @@ -168,19 +169,19 @@ pub(super) async fn run_drawdown_call(args: CallArgs, global: GlobalArgs) -> Res Ok(v) => v, Err(e) if is_token_expired(&e) => { let fresh = reauthenticate(&ctx, &global).await?; - ctx.sdk + match ctx + .sdk .rpc .gateway_drawdown_call(&args.method, params, &network, &fresh) .await - .map_err(map_drawdown_error)? + { + Ok(v) => v, + Err(e) => return Err(drawdown_failure(&ctx, &args, &network, e)), + } } - Err(e) => return Err(map_drawdown_error(e)), + Err(e) => return Err(drawdown_failure(&ctx, &args, &network, e)), }; - // A drew-a-credit confirmation with the balance-check next step (stderr, so - // stdout stays exactly the RPC result). - ctx.out - .note(" Next: qn rpc x402 balance --payment-wallet --payment-network "); super::emit_result(&ctx, &result) } @@ -230,20 +231,54 @@ fn is_token_expired(e: &SdkError) -> bool { ) } +/// The gateway refused because the credit balance is empty (nothing settled). +fn is_out_of_credits(e: &SdkError) -> bool { + matches!( + e, + SdkError::Api { status, body } + if status.as_u16() == 402 + || body.contains("insufficient_credits") + || body.contains("no_credits") + ) +} + +/// Handles a drawdown-call failure: when the refusal is an empty credit +/// balance, first print the balance-check command (stderr) in the same bold +/// multi-line format as the other chained hints, then map to the CLI error. +fn drawdown_failure(ctx: &Ctx, args: &CallArgs, network: &str, e: SdkError) -> CliError { + if is_out_of_credits(&e) { + let wallet = args.payment_wallet.as_deref().unwrap_or(""); + let pay_net = args.payment_network.as_deref().unwrap_or(network); + ctx.out.note(&format!( + "{}\n\n{}\n", + style("Check balance:", Style::Bold, ctx.out.color), + style( + &format!( + " qn rpc x402 balance \\\n \ + --payment-wallet {wallet} \\\n \ + --payment-network {pay_net}" + ), + Style::Bold, + ctx.out.color, + ), + )); + } + map_drawdown_error(e) +} + /// Maps a drawdown-call failure onto an actionable CLI error. An empty-credits /// or monthly-limit gateway refusal points forward at the fixing verb; because /// nothing settled, both map to exit 2 (`PaymentRefused`), not the generic /// arg-error bucket. Every other SDK error keeps its normal exit-code mapping. fn map_drawdown_error(e: SdkError) -> CliError { - if let SdkError::Api { status, body } = &e { - let s = status.as_u16(); - if s == 402 || body.contains("insufficient_credits") || body.contains("no_credits") { - return CliError::PaymentRefused( - "out of x402 credits. Buy more with 'qn rpc x402 buy-credits', \ - then retry this call." - .to_string(), - ); - } + 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 \ diff --git a/src/commands/rpc/supported_networks.rs b/src/commands/rpc/supported_networks.rs new file mode 100644 index 0000000..976edb4 --- /dev/null +++ b/src/commands/rpc/supported_networks.rs @@ -0,0 +1,483 @@ +//! `qn rpc x402 supported-networks` / `qn rpc mpp supported-networks` — one +//! payment gateway's discovery catalog, in two sections: the networks you can +//! make paid RPC calls **to** (callable), and the currencies the gateway +//! accepts as payment. +//! +//! Keyless and public: this reads the gateway's own discovery surfaces, not +//! the account API. Callable networks come from `{gateway}/networks`. Accepted +//! currencies come from `x402.quicknode.com/supported` for x402; the MPP +//! gateway publishes them in the `WWW-Authenticate: Payment` challenge of a +//! keyless 402 response, so the command probes one callable network without +//! paying. Results are cached per scheme in `pay-networks.toml` next to the +//! config with a 24h TTL, mirroring the multichain URL cache. A callable +//! network is a valid `--network` for a paid call; an accepted currency's +//! network/address pair is a ready `--payment-network`/`--payment-asset`. + +use std::collections::BTreeMap; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use serde::Deserialize; + +use crate::config::{self, PayAssetEntry, SupportedNetworks}; +use crate::context::{Ctx, GlobalArgs}; +use crate::errors::CliError; +use crate::output::{new_table, set_header_bold, write_table, OutputCtx, Render}; + +const X402_BASE: &str = "https://x402.quicknode.com"; +const MPP_BASE: &str = "https://mpp.quicknode.com"; + +/// Which payment gateway the command reads. +#[derive(Clone, Copy)] +pub(super) enum Scheme { + X402, + Mpp, +} + +impl Scheme { + fn as_str(self) -> &'static str { + match self { + Scheme::X402 => "x402", + Scheme::Mpp => "mpp", + } + } + + fn gateway_base(self) -> &'static str { + match self { + Scheme::X402 => X402_BASE, + Scheme::Mpp => MPP_BASE, + } + } +} + +pub(super) async fn run(scheme: Scheme, global: GlobalArgs) -> Result<(), CliError> { + // A `--base-url` override points the gateway fetches at one host (used by + // tests to serve the discovery endpoints from a mock). It also bypasses + // the cache, since a test host's data isn't the real catalog. + 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()); + + // Fresh cache hit? (Skipped when a --base-url override is active.) + let cached = if base_override.is_none() { + cache_path + .as_deref() + .and_then(|p| config::load_pay_networks(p, scheme.as_str(), now_unix())) + } else { + None + }; + + let (callable, payments) = match cached { + Some(c) => (c.callable, Some(c.payments)), + None => { + let base = base_override.as_deref().unwrap_or(scheme.gateway_base()); + let client = reqwest::Client::new(); + let callable = fetch_networks(&client, base).await?; + let fetched = match scheme { + Scheme::X402 => fetch_x402_payments(&client, base).await, + Scheme::Mpp => fetch_mpp_payments(&client, base, callable.first()).await, + }; + match fetched { + Ok(payments) => { + let catalog = SupportedNetworks { callable, payments }; + if base_override.is_none() { + if let Some(p) = cache_path.as_deref() { + let _ = + config::save_pay_networks(p, scheme.as_str(), now_unix(), &catalog); + } + } + (catalog.callable, Some(catalog.payments)) + } + Err(e) => { + // Best-effort: the callable list still renders; the + // currencies section reports the miss and nothing is + // cached, so the next run retries. + ctx.out + .warn(&format!("could not fetch accepted currencies: {e}")); + (callable, None) + } + } + } + }; + + crate::output::emit( + &ctx.out, + &SupportedNetworksView { + scheme: scheme.as_str(), + callable, + payments, + }, + ) +} + +/// GET `{base}/networks` → the `networks` slug array. +async fn fetch_networks(client: &reqwest::Client, base: &str) -> Result, CliError> { + #[derive(Deserialize)] + struct NetworksResp { + networks: Vec, + } + let url = format!("{base}/networks"); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| fetch_err(&url, e))?; + if !resp.status().is_success() { + return Err(CliError::Arg(format!( + "discovery request to {url} failed with HTTP {}", + resp.status().as_u16() + ))); + } + let body: NetworksResp = resp.json().await.map_err(|e| fetch_err(&url, e))?; + Ok(body.networks) +} + +/// GET x402 `/supported` → the accepted payment currencies, deduplicated by +/// (network, token address). The display name prefers our known symbol table; +/// otherwise the offer's `extra.name`, but only from offers without a +/// `verifyingContract` (those are Circle Gateway variants whose `name` is an +/// EIP-712 domain, not a token). +async fn fetch_x402_payments( + client: &reqwest::Client, + base: &str, +) -> Result, CliError> { + #[derive(Deserialize)] + struct Supported { + #[serde(default)] + accepts: Vec, + } + #[derive(Deserialize)] + struct Accept { + network: String, + #[serde(default)] + asset: Option, + #[serde(default)] + extra: Option, + } + #[derive(Deserialize)] + struct Extra { + #[serde(default)] + name: Option, + #[serde(default, rename = "verifyingContract")] + verifying_contract: Option, + } + + let url = format!("{base}/supported"); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| fetch_err(&url, e))?; + // The gateway serves this catalog x402-style: the payment-requirements + // JSON arrives with HTTP 402, not 200. Both carry the same shape. + let status = resp.status(); + if !status.is_success() && status.as_u16() != 402 { + return Err(CliError::Arg(format!( + "discovery request to {url} failed with HTTP {}", + status.as_u16() + ))); + } + let body: Supported = resp.json().await.map_err(|e| fetch_err(&url, e))?; + + // (CAIP-2 network, address) → best-known display name. + let mut merged: BTreeMap<(String, String), Option> = BTreeMap::new(); + for accept in body.accepts { + let Some(address) = accept.asset else { + continue; + }; + let offer_name = match accept.extra { + Some(Extra { + verifying_contract: None, + name, + }) => name, + _ => None, + }; + let name = super::pay_asset::symbol_for(&accept.network, &address).or(offer_name); + let slot = merged.entry((accept.network.clone(), address)).or_default(); + if slot.is_none() { + *slot = name; + } + } + + let mut out: Vec = merged + .into_iter() + .map(|((caip2, address), asset)| PayAssetEntry { + network: super::pay_network::slug_for_caip2(&caip2).unwrap_or(caip2), + asset, + address, + }) + .collect(); + out.sort_by(|a, b| (&a.network, &a.address).cmp(&(&b.network, &b.address))); + Ok(out) +} + +/// The MPP gateway lists its accepted currencies only in the 402 challenge, so +/// probe one callable network with a keyless request (no payment is taken) and +/// parse the `WWW-Authenticate: Payment` header. +async fn fetch_mpp_payments( + client: &reqwest::Client, + base: &str, + probe_slug: Option<&String>, +) -> Result, CliError> { + let Some(slug) = probe_slug else { + return Err(CliError::Arg( + "the gateway listed no callable networks to probe".to_string(), + )); + }; + let url = format!("{base}/{slug}"); + let resp = client + .post(&url) + .json(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": [] + })) + .send() + .await + .map_err(|e| fetch_err(&url, e))?; + let status = resp.status().as_u16(); + let header = resp + .headers() + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + CliError::Arg(format!( + "the probe of {url} returned HTTP {status} without a payment challenge" + )) + })?; + let challenges = parse_payment_challenges(header); + if challenges.is_empty() { + return Err(CliError::Arg(format!( + "could not parse the payment challenge from {url}" + ))); + } + + // (network slug, address) → best-known display name. + let mut merged: BTreeMap<(String, String), Option> = BTreeMap::new(); + for ch in challenges { + let Some(address) = ch.request.get("currency").and_then(|v| v.as_str()) else { + continue; + }; + let details = ch.request.get("methodDetails"); + let slug = match ch.method.as_str() { + "tempo" => { + let Some(id) = details + .and_then(|d| d.get("chainId")) + .and_then(|v| v.as_i64()) + else { + continue; + }; + let caip2 = format!("eip155:{id}"); + super::pay_network::slug_for_caip2(&caip2).unwrap_or(caip2) + } + "solana" => { + match details + .and_then(|d| d.get("network")) + .and_then(|v| v.as_str()) + { + Some("mainnet-beta") => "solana-mainnet".to_string(), + Some("devnet") => "solana-devnet".to_string(), + Some("testnet") => "solana-testnet".to_string(), + Some(other) => other.to_string(), + None => continue, + } + } + _ => continue, + }; + let caip2 = super::pay_network::resolve(&slug).unwrap_or_else(|_| slug.clone()); + let name = super::pay_asset::symbol_for(&caip2, address); + let slot = merged.entry((slug, address.to_string())).or_default(); + if slot.is_none() { + *slot = name; + } + } + + Ok(merged + .into_iter() + .map(|((network, address), asset)| PayAssetEntry { + network, + asset, + address, + }) + .collect()) +} + +/// One parsed `Payment` challenge: its `method` and decoded `request` JSON. +struct Challenge { + method: String, + request: serde_json::Value, +} + +/// Parses a `WWW-Authenticate` header carrying one or more `Payment` +/// challenges (`Payment k="v", k="v", ..., Payment k="v", ...`). Pragmatic, +/// not a full RFC 7235 grammar: a `Payment ` prefix on a comma-separated part +/// starts a new challenge, and only `method` and `request` are read. Malformed +/// entries are skipped. +fn parse_payment_challenges(header: &str) -> Vec { + let mut out = Vec::new(); + let mut method: Option = None; + let mut request: Option = None; + for raw in split_outside_quotes(header, ',') { + let mut part = raw.trim(); + if let Some(rest) = part.strip_prefix("Payment ") { + if let Some(c) = decode_challenge(method.take(), request.take()) { + out.push(c); + } + part = rest.trim_start(); + } + if let Some(v) = part.strip_prefix("method=") { + method = unquote(v); + } else if let Some(v) = part.strip_prefix("request=") { + request = unquote(v); + } + } + if let Some(c) = decode_challenge(method, request) { + out.push(c); + } + out +} + +/// Splits on `delim` occurrences outside double-quoted spans — quoted header +/// values may themselves contain commas. +fn split_outside_quotes(s: &str, delim: char) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let mut in_quotes = false; + for (i, c) in s.char_indices() { + match c { + '"' => in_quotes = !in_quotes, + c if c == delim && !in_quotes => { + parts.push(&s[start..i]); + start = i + delim.len_utf8(); + } + _ => {} + } + } + parts.push(&s[start..]); + parts +} + +fn unquote(s: &str) -> Option { + s.strip_prefix('"')?.strip_suffix('"').map(str::to_string) +} + +/// Base64url-decodes a challenge's `request` value into JSON. `None` on any +/// decode failure, so one malformed challenge never sinks the others. +fn decode_challenge(method: Option, request: Option) -> Option { + let method = method?; + let request = request?; + let bytes = URL_SAFE_NO_PAD.decode(request.trim_end_matches('=')).ok()?; + let json = serde_json::from_slice(&bytes).ok()?; + Some(Challenge { + method, + request: json, + }) +} + +#[derive(serde::Serialize)] +struct SupportedNetworksView { + #[serde(skip)] + scheme: &'static str, + callable: Vec, + /// `None` when the currencies fetch failed: rendered as unavailable in the + /// table, serialized as `null` in JSON. + payments: Option>, +} + +impl Render for SupportedNetworksView { + fn render_table(&self, w: &mut dyn std::io::Write, ctx: &OutputCtx) -> std::io::Result<()> { + writeln!( + w, + "Callable networks (make {}-paid RPC calls to these):", + self.scheme + )?; + if self.callable.is_empty() { + writeln!(w, "(no callable networks)")?; + } else { + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["NETWORK"]); + for n in &self.callable { + t.add_row(vec![comfy_table::Cell::new(n)]); + } + write_table(w, &t)?; + } + writeln!(w)?; + writeln!(w, "Accepted currencies (pay for calls with one of these):")?; + match &self.payments { + None => writeln!(w, "(could not fetch accepted currencies)"), + Some(p) if p.is_empty() => writeln!(w, "(none listed)"), + Some(p) => { + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["NETWORK", "ASSET", "ADDRESS"]); + for e in p { + t.add_row(vec![ + comfy_table::Cell::new(&e.network), + crate::output::opt_cell(&e.asset), + comfy_table::Cell::new(&e.address), + ]); + } + write_table(w, &t) + } + } + } +} + +fn fetch_err(url: &str, e: reqwest::Error) -> CliError { + CliError::Arg(format!( + "could not fetch the gateway catalog from {url}: {e}" + )) +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b64(json: &str) -> String { + URL_SAFE_NO_PAD.encode(json.as_bytes()) + } + + #[test] + fn parses_multi_challenge_header() { + let tempo = + b64(r#"{"amount":"1000","currency":"0xabc","methodDetails":{"chainId":42431}}"#); + let solana = b64( + r#"{"amount":"0.001","currency":"Mint111","methodDetails":{"network":"mainnet-beta"}}"#, + ); + // The description stresses the splitter: a quoted comma and the word + // "Payment" inside a value must not start a new challenge. + let header = format!( + "Payment id=\"a\", realm=\"r\", method=\"tempo\", intent=\"charge\", \ + request=\"{tempo}\", description=\"Payment, per request\", \ + Payment id=\"b\", method=\"solana\", request=\"{solana}\"" + ); + let out = parse_payment_challenges(&header); + assert_eq!(out.len(), 2); + assert_eq!(out[0].method, "tempo"); + assert_eq!(out[0].request["methodDetails"]["chainId"], 42431); + assert_eq!(out[1].method, "solana"); + assert_eq!(out[1].request["currency"], "Mint111"); + } + + #[test] + fn skips_malformed_entries() { + let good = b64(r#"{"currency":"0xabc","methodDetails":{"chainId":4217}}"#); + // First challenge has an undecodable request; second is fine. + let header = format!( + "Payment method=\"tempo\", request=\"!!!not-base64!!!\", \ + Payment method=\"tempo\", request=\"{good}\"" + ); + let out = parse_payment_challenges(&header); + assert_eq!(out.len(), 1); + assert_eq!(out[0].request["methodDetails"]["chainId"], 4217); + } + + #[test] + fn header_without_challenges_parses_empty() { + assert!(parse_payment_challenges("Bearer realm=\"api\"").is_empty()); + } +} diff --git a/src/commands/rpc/x402.rs b/src/commands/rpc/x402.rs index 39da0f4..d9b6ab1 100644 --- a/src/commands/rpc/x402.rs +++ b/src/commands/rpc/x402.rs @@ -31,6 +31,11 @@ use super::payment::{ SessionParams, }; +/// Query network used in the printed paid-lane examples. Payments and credits +/// are independent of the chain a call queries, so the examples query a chain +/// the user did not pay on to make that clear. +const EXAMPLE_QUERY_NETWORK: &str = "ethereum-mainnet"; + #[derive(Debug, ClapArgs)] #[command(subcommand_required = true, arg_required_else_help = true)] pub struct Args { @@ -43,7 +48,7 @@ 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 base-sepolia --payment-wallet payer \\\n \ + qn rpc x402 buy-credits --network ethereum-mainnet --payment-wallet payer \\\n \ --payment-network base-sepolia --payment-asset USDC --max-amount 10000000")] BuyCredits(PaymentArgs), @@ -54,6 +59,11 @@ pub enum X402Cmd { /// Request testnet credits from the faucet (Base Sepolia, once per account). Drip(SessionArgs), + + /// List the networks callable via x402 and the currencies the gateway + /// accepts as payment. No API key required. + #[command(visible_alias = "networks")] + SupportedNetworks, } /// The shared payment parameter stack every x402 verb accepts, with the same @@ -157,6 +167,9 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { 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(super::supported_networks::Scheme::X402, global).await + } } } @@ -238,9 +251,16 @@ async fn run_buy_credits(args: PaymentArgs, global: GlobalArgs) -> Result<(), Cl // Gate Mild BEFORE any network I/O: name the spend ceiling and blast radius. // The exact charge is the gateway's offer, bounded by this ceiling; we name // the ceiling since a single-attempt purchase can't safely probe first. + // Prompt in the user's vocabulary (symbol, network slug) where the tables + // know it; fall back to the raw resolved values otherwise. + let asset = super::pay_asset::symbol_for(&payment.pay_network, &payment.asset) + .unwrap_or_else(|| payment.asset.clone()); + let pay_network = super::pay_network::slug_for_caip2(&payment.pay_network) + .unwrap_or_else(|| payment.pay_network.clone()); let msg = format!( - "Buy credits for up to {} base units of {} on {}? This moves real funds.", - payment.max_amount, payment.asset, payment.pay_network + "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)?; @@ -260,32 +280,40 @@ async fn run_buy_credits(args: PaymentArgs, global: GlobalArgs) -> Result<(), Cl ctx.out.note(&format!( "\n{}\n\n{}", style( - "Spend the credits on calls (signs nothing per request):", + "Spend the credits on calls (1 credit per call, no per-call payment):", Style::Bold, ctx.out.color, ), - drawdown_call_hint(&args, &network) + drawdown_call_hint(&args, ctx.out.color) )); - emit_balance(&ctx, &balance) + // Bare balance to stdout for pipelines; on a TTY the ✓ line above already + // shows it, so skip the duplicate. + if matches!(ctx.global.format, Some(f) if f.is_structured()) || !ctx.out.stdout_is_tty { + return emit_balance(&ctx, &balance); + } + Ok(()) } -// A copy-pasteable, multi-line `--x402-drawdown` call reflecting the flags the -// user just used, so the chained next step runs as-is. -fn drawdown_call_hint(args: &PaymentArgs, network: &str) -> String { +// A copy-pasteable, multi-line `--x402-drawdown` call with the wallet the user +// just paid with. Credits are not network-scoped, so the example deliberately +// queries a different chain than the one just bought against — it makes clear +// the credits spend on any supported network. +fn drawdown_call_hint(args: &PaymentArgs, color: bool) -> String { let mut cmd = format!( " qn rpc call eth_blockNumber \\\n \ - --network {network} --x402-drawdown \\\n \ + --network {EXAMPLE_QUERY_NETWORK} \\\n \ + --x402-drawdown \\\n \ --payment-wallet {}", args.payment_wallet.as_deref().unwrap_or("") ); - // The drawdown call defaults its pay network to --network, so only append - // --payment-network when the user set an explicit one that differs. + // The drawdown call defaults its pay network to --network, so keep the + // SIWX auth chain on the network the user actually pays on when it differs. if let Some(pn) = &args.payment_network { - if pn != network { + if pn != EXAMPLE_QUERY_NETWORK { cmd.push_str(&format!(" \\\n --payment-network {pn}")); } } - cmd + style(&cmd, Style::Bold, color) } async fn run_balance(args: SessionArgs, global: GlobalArgs) -> Result<(), CliError> { @@ -307,12 +335,8 @@ async fn run_drip(args: SessionArgs, global: GlobalArgs) -> Result<(), CliError> receipt.account_id, receipt.transaction_hash )); // Point at the two paid lanes with the flags the user already supplied; - // USDC is the asset the faucet just funded. - let query_net = args - .network - .as_deref() - .or(args.payment_network.as_deref()) - .unwrap_or(""); + // USDC is the asset the faucet just funded. The examples query a chain the + // faucet did not fund — payment is independent of the chain a call queries. let wallet = args.payment_wallet.as_deref().unwrap_or(""); let pay_net = args.payment_network.as_deref().unwrap_or(""); let c = ctx.out.color; @@ -328,13 +352,20 @@ async fn run_drip(args: SessionArgs, global: GlobalArgs) -> Result<(), CliError> c, )); block.push_str(&format!( - "\n\n \ - qn rpc call eth_blockNumber \\\n \ - --network {query_net} --x402 \\\n \ - --payment-wallet {wallet} \\\n \ - --payment-network {pay_net} \\\n \ - --payment-asset USDC \\\n \ - --max-amount 1000\n\n" + "\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):", @@ -342,16 +373,32 @@ async fn run_drip(args: SessionArgs, global: GlobalArgs) -> Result<(), CliError> c, )); block.push_str(&format!( - "\n\n \ - qn rpc x402 buy-credits \\\n \ - --network {query_net} \\\n \ - --payment-wallet {wallet} \\\n \ - --payment-network {pay_net} \\\n \ - --payment-asset USDC \\\n \ - --max-amount 1000000\n\n \ - qn rpc call eth_blockNumber \\\n \ - --network {query_net} --x402-drawdown \\\n \ - --payment-wallet {wallet}" + "\n\n{}\n\n{}", + style( + &format!( + " qn rpc x402 buy-credits \\\n \ + --network {EXAMPLE_QUERY_NETWORK} \\\n \ + --payment-wallet {wallet} \\\n \ + --payment-network {pay_net} \\\n \ + --payment-asset USDC \\\n \ + --max-amount 1000000" + ), + Style::Bold, + c, + ), + // The explicit --payment-network keeps SIWX auth on the chain the + // faucet funded, since a drawdown call defaults it to --network. + style( + &format!( + " qn rpc call eth_blockNumber \\\n \ + --network {EXAMPLE_QUERY_NETWORK} \\\n \ + --x402-drawdown \\\n \ + --payment-wallet {wallet} \\\n \ + --payment-network {pay_net}" + ), + Style::Bold, + c, + ) )); ctx.out.note(&block); if matches!(ctx.global.format, Some(f) if f.is_structured()) { @@ -361,8 +408,11 @@ async fn run_drip(args: SessionArgs, global: GlobalArgs) -> Result<(), CliError> }); return super::emit_result(&ctx, &v); } - // Bare tx hash to stdout for pipelines. - println!("{}", receipt.transaction_hash); + // Bare tx hash to stdout for pipelines; on a TTY the ✓ line above already + // shows it, so skip the duplicate. + if !ctx.out.stdout_is_tty { + println!("{}", receipt.transaction_hash); + } Ok(()) } @@ -383,8 +433,16 @@ fn emit_balance(ctx: &Ctx, balance: &CreditBalance) -> Result<(), CliError> { // Group digits for the human-facing note (1000000 -> 1,000,000). The bare // stdout number is never grouped, so pipelines get a clean integer. fn fmt_credits(n: u64) -> String { - let s = n.to_string(); + group_digits(&n.to_string()) +} + +// Digit-group a decimal string (1000000 -> 1,000,000). Anything that is not +// pure ASCII digits passes through unchanged rather than being mangled. +fn group_digits(s: &str) -> String { let bytes = s.as_bytes(); + if s.is_empty() || !bytes.iter().all(u8::is_ascii_digit) { + return s.to_string(); + } let mut out = String::with_capacity(s.len() + s.len() / 3); for (i, b) in bytes.iter().enumerate() { if i > 0 && (bytes.len() - i) % 3 == 0 { @@ -406,4 +464,12 @@ mod tests { 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 index 1af1344..831629e 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -189,8 +189,8 @@ fn rm(a: RmArgs, ctx: Ctx) -> Result<(), CliError> { let proceed = match decide_without_prompt(Severity::Mild, cfg)? { true => true, false => prompt_yes_no(&format!( - "Delete wallet '{name}'? The private key is destroyed and any funds sent to it \ - become unrecoverable." + "Delete wallet '{name}'? The private key is destroyed locally; without a backup \ + of the key, funds at its address are unrecoverable." ))?, }; if !proceed { @@ -261,7 +261,7 @@ fn emit_address(ctx: &Ctx, meta: &WalletMeta, key_path: &Path, with_qr: bool) { "This wallet can be used to pay for RPC calls via micropayments (x402/MPP).\n\ Fund it on the network you'll pay from. Testnet funding:\n\n", ); - block.push_str(&format!("{}\n\n", funding_hint(&meta.vm, &meta.name))); + 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", @@ -308,45 +308,59 @@ fn config_example(vm: &str, name: &str) -> String { /// Testnet funding routes for a fresh wallet, per VM family. EVM gets the /// gateway's built-in Base Sepolia faucet (`qn rpc x402 drip`, pre-filled with -/// this wallet's name), the Circle faucet for the USDC testnets, and the -/// XLayer USDG note; SVM gets the Circle faucet on Solana Devnet. -fn funding_hint(vm: &str, name: &str) -> String { +/// this wallet's name), the Circle faucet for the USDC testnets, the Tempo +/// testnet faucet (pathUSD, for MPP), and the XLayer USDG note; SVM gets the +/// Circle faucet on Solana Devnet. +fn funding_hint(vm: &str, name: &str, color: bool) -> String { match vm { "svm" => " Circle faucet: https://faucet.circle.com (USDC on Solana Devnet)".to_string(), - _ => format!( - " Base Sepolia: qn rpc x402 drip --payment-wallet {name} --payment-network base-sepolia\n \ - Circle faucet: https://faucet.circle.com (USDC on Base Sepolia, Polygon Amoy, Arc)\n \ - XLayer Testnet: send USDG to the address above" - ), + _ => { + let drip = style( + &format!("qn rpc x402 drip --payment-wallet {name} --payment-network base-sepolia"), + Style::Bold, + color, + ); + format!( + " Base Sepolia: {drip}\n \ + Circle faucet: https://faucet.circle.com (USDC on Base Sepolia, Polygon Amoy, Arc)\n \ + Tempo Testnet: https://tempo.xyz/developers/docs/quickstart/faucet (pathUSD, for MPP)\n \ + XLayer Testnet: send USDG to the address above" + ) + } } } /// A complete, runnable paid-lane `qn rpc call` for a freshly funded wallet, /// matching the documented examples in the README. SVM gets one x402/Solana -/// example; EVM gets both an x402 (Base Sepolia USDC) and an MPP (Tempo -/// testnet) example, since the same secp256k1 key works for both. +/// example; EVM gets both an x402 (pays Base Sepolia USDC) and an MPP (pays +/// Tempo testnet USDC) example, since the same secp256k1 key works for both. +/// The EVM examples query a chain the wallet is not funded on — the payment +/// chain is independent of the chain a call queries. /// `--max-amount 1000` selects the per-request offer (0.001 USDC). fn example_call(vm: &str, name: &str) -> String { match vm { "svm" => format!( "qn rpc call getSlot \\\n \ - --network solana-devnet --x402 \\\n \ + --network solana-devnet \\\n \ + --x402 \\\n \ --payment-wallet {name} \\\n \ --payment-network solana-devnet \\\n \ --payment-asset USDC \\\n \ --max-amount 1000" ), _ => format!( - "# x402 (Base Sepolia USDC):\n\ + "# x402 (pays Base Sepolia USDC):\n\ qn rpc call eth_blockNumber \\\n \ - --network base-sepolia --x402 \\\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 (Tempo testnet):\n\ + # MPP (pays Tempo testnet USDC):\n\ qn rpc call eth_blockNumber \\\n \ - --network tempo-testnet --mpp \\\n \ + --network ethereum-mainnet \\\n \ + --mpp \\\n \ --payment-wallet {name} \\\n \ --payment-network tempo-testnet \\\n \ --payment-asset USDC \\\n \ diff --git a/src/config.rs b/src/config.rs index c85830e..dbfbe9e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -728,43 +728,53 @@ pub fn save_networks( write_atomic_0600(path, text.as_bytes(), ".qn-networks-") } -// ── Payable-networks discovery cache ───────────────────────────────────────── +// ── Supported-networks discovery cache ─────────────────────────────────────── // -// The set of networks payable via the x402/MPP gateways (with the x402 asset, -// when discoverable) is stable public metadata fetched from the gateways' -// discovery endpoints. Cached in `pay-networks.toml` next to the config file -// with a 24-hour TTL, mirroring the multichain URL cache. Not account-scoped -// (the data is public and the same for everyone). +// The networks callable via a payment gateway and the currencies it accepts as +// payment are stable public metadata fetched from the gateways' discovery +// surfaces. Cached per scheme (x402 / mpp) in `pay-networks.toml` next to the +// config file with a 24-hour TTL, mirroring the multichain URL cache. Not +// account-scoped (the data is public and the same for everyone). -/// Seconds the cached payable-networks list is considered fresh (24h). +/// Seconds a cached supported-networks catalog is considered fresh (24h). pub const PAY_NETWORKS_TTL_SECS: i64 = 24 * 60 * 60; -/// One payable network: its Quicknode slug, the payment schemes that accept it, -/// and the x402 asset/amount when the discovery catalog lists them. +/// One accepted payment currency: the network the payment moves on, a display +/// symbol when one is known, and the token contract address (EVM) / mint +/// (Solana). #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PayNetworkEntry { +pub struct PayAssetEntry { pub network: String, - /// Schemes offering this network, e.g. `["x402", "mpp"]`. - pub schemes: Vec, - /// x402 asset (token contract/mint), when the discovery catalog lists one. #[serde(default, skip_serializing_if = "Option::is_none")] pub asset: Option, + pub address: String, +} + +/// One gateway's discovery catalog: the networks it can proxy paid calls to, +/// and the currencies it accepts as payment. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SupportedNetworks { + pub callable: Vec, + pub payments: Vec, } #[derive(Debug, Default, Serialize, Deserialize)] pub struct PayNetworksCacheFile { #[serde(default)] - pub entry: Option, + pub x402: Option, + #[serde(default)] + pub mpp: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PayNetworksCacheEntry { - /// Unix seconds the list was fetched, for the TTL check. + /// Unix seconds the catalog was fetched, for the TTL check. pub fetched_at_unix: i64, - pub networks: Vec, + pub callable: Vec, + pub payments: Vec, } -/// The payable-networks cache path: `pay-networks.toml` alongside the config. +/// The supported-networks 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")), @@ -772,31 +782,54 @@ pub fn pay_networks_cache_path(config_path: Option<&Path>) -> Option { } } -/// Loads the cached payable-networks list from `path`, if present and fetched -/// within the TTL (relative to `now_unix`). Returns `None` on any miss. -pub fn load_pay_networks(path: &Path, now_unix: i64) -> Option> { +/// Loads the cached catalog for `scheme` ("x402" or "mpp") from `path`, if +/// present and fetched within the TTL (relative to `now_unix`). Returns `None` +/// on any miss, including a cache file in an older format. +pub fn load_pay_networks(path: &Path, scheme: &str, now_unix: i64) -> Option { let text = fs::read_to_string(path).ok()?; let cache: PayNetworksCacheFile = toml::from_str(&text).ok()?; - let entry = cache.entry?; + let entry = match scheme { + "x402" => cache.x402?, + "mpp" => cache.mpp?, + _ => return None, + }; if now_unix.saturating_sub(entry.fetched_at_unix) >= PAY_NETWORKS_TTL_SECS { return None; } - Some(entry.networks) + Some(SupportedNetworks { + callable: entry.callable, + payments: entry.payments, + }) } -/// Saves the payable-networks list to `path` atomically, stamping -/// `fetched_at_unix` for the TTL check. +/// Saves the catalog for `scheme` to `path` atomically, stamping +/// `fetched_at_unix` for the TTL check. The other scheme's entry is kept when +/// the existing file parses; an older-format file is replaced wholesale. pub fn save_pay_networks( path: &Path, + scheme: &str, fetched_at_unix: i64, - networks: &[PayNetworkEntry], + catalog: &SupportedNetworks, ) -> Result<(), CliError> { - let cache = PayNetworksCacheFile { - entry: Some(PayNetworksCacheEntry { - fetched_at_unix, - networks: networks.to_vec(), - }), - }; + let mut cache: PayNetworksCacheFile = fs::read_to_string(path) + .ok() + .and_then(|text| toml::from_str(&text).ok()) + .unwrap_or_default(); + let entry = Some(PayNetworksCacheEntry { + fetched_at_unix, + callable: catalog.callable.clone(), + payments: catalog.payments.clone(), + }); + match scheme { + "x402" => cache.x402 = entry, + "mpp" => cache.mpp = entry, + _ => { + return Err(CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(format!("unknown payment scheme '{scheme}'")), + }) + } + } let text = toml::to_string_pretty(&cache).map_err(|e| CliError::ConfigWrite { path: path.to_path_buf(), source: std::io::Error::other(e), @@ -929,6 +962,53 @@ mod tests { panic!("prompt should not be invoked") } + #[test] + fn pay_networks_cache_roundtrips_per_scheme() { + let dir = tempdir().unwrap(); + let path = dir.path().join("pay-networks.toml"); + let x402 = SupportedNetworks { + callable: vec!["base-sepolia".to_string()], + payments: vec![PayAssetEntry { + network: "base-sepolia".to_string(), + asset: Some("USDC".to_string()), + address: "0xabc".to_string(), + }], + }; + let mpp = SupportedNetworks { + callable: vec!["tempo-testnet".to_string()], + payments: vec![], + }; + save_pay_networks(&path, "x402", 100, &x402).unwrap(); + save_pay_networks(&path, "mpp", 100, &mpp).unwrap(); + + // Each scheme loads its own entry; saving one kept the other. + let got = load_pay_networks(&path, "x402", 100).unwrap(); + assert_eq!(got.callable, vec!["base-sepolia"]); + assert_eq!(got.payments.len(), 1); + assert_eq!(got.payments[0].asset.as_deref(), Some("USDC")); + let got = load_pay_networks(&path, "mpp", 100).unwrap(); + assert_eq!(got.callable, vec!["tempo-testnet"]); + assert!(got.payments.is_empty()); + + // TTL expiry is a miss. + assert!(load_pay_networks(&path, "x402", 100 + PAY_NETWORKS_TTL_SECS).is_none()); + } + + #[test] + fn pay_networks_cache_old_format_is_a_miss() { + let dir = tempdir().unwrap(); + let path = dir.path().join("pay-networks.toml"); + // The pre-split cache shape: one `entry` with merged network rows. + fs::write( + &path, + "[entry]\nfetched_at_unix = 100\n\n\ + [[entry.networks]]\nnetwork = \"base-sepolia\"\nschemes = [\"x402\"]\n", + ) + .unwrap(); + assert!(load_pay_networks(&path, "x402", 100).is_none()); + assert!(load_pay_networks(&path, "mpp", 100).is_none()); + } + #[test] fn flag_wins_over_config_and_prompt() { let dir = tempdir().unwrap(); diff --git a/src/errors.rs b/src/errors.rs index f685665..aeba556 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -132,7 +132,7 @@ pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> Strin 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 pay-networks').", + don't match an offer (see 'qn rpc x402 supported-networks' / 'qn rpc mpp supported-networks').", ); // When the reason wasn't a clean one-liner, append the raw body under // --verbose for the full detail. @@ -626,7 +626,7 @@ mod tests { !msg.contains(&body), "long body must not leak by default: {msg}" ); - assert!(msg.contains("qn rpc pay-networks"), "got: {msg}"); + assert!(msg.contains("supported-networks"), "got: {msg}"); let verbose = render(&err, true); assert!(verbose.contains("accepts"), "got: {verbose}"); } diff --git a/tests/rpc_pay_networks.rs b/tests/rpc_pay_networks.rs deleted file mode 100644 index 35779fc..0000000 --- a/tests/rpc_pay_networks.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Integration tests for `qn rpc pay-networks` — the keyless payable-networks -//! discovery list. -//! -//! `--base-url` points both gateway fetches (`/networks` and -//! `/discovery/resources`) at one wiremock host and bypasses the on-disk -//! cache, so the mock server stands in for the x402 + MPP gateways. The -//! in-process harness can't capture stdout, so the merge/asset-mapping render -//! is asserted via a subprocess; the in-process test covers exit codes and the -//! request shape. - -mod common; - -use common::run_qn; -use serde_json::json; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -/// Mounts the two discovery endpoints the command reads. -async fn mount_discovery(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; - Mock::given(method("GET")) - .and(path("/discovery/resources")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "x402Version": 2, - "items": [ - { - "accepts": [ - { - "scheme": "exact", - "network": "eip155:84532", - "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" - } - ] - } - ] - }))) - .mount(server) - .await; -} - -#[tokio::test] -async fn pay_networks_fetches_and_exits_zero() { - let server = MockServer::start().await; - mount_discovery(&server).await; - - let out = run_qn(&server.uri(), &["rpc", "pay-networks"]).await; - assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); -} - -#[tokio::test] -async fn pay_networks_alias_works() { - let server = MockServer::start().await; - mount_discovery(&server).await; - - let out = run_qn(&server.uri(), &["rpc", "pay-nets"]).await; - assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); -} - -#[tokio::test] -async fn pay_networks_surfaces_fetch_failure() { - let server = MockServer::start().await; - // /networks returns 500 — the command should fail with an actionable error. - Mock::given(method("GET")) - .and(path("/networks")) - .respond_with(ResponseTemplate::new(500)) - .mount(&server) - .await; - - let out = run_qn(&server.uri(), &["rpc", "pay-networks"]).await; - assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); - assert!( - out.stderr.contains("payable networks") || out.stderr.contains("discovery"), - "stderr={}", - out.stderr - ); -} - -// Subprocess: assert the merged/enriched table content (stdout). -#[tokio::test] -async fn pay_networks_renders_merged_table() { - let server = MockServer::start().await; - mount_discovery(&server).await; - - let output = assert_cmd::Command::cargo_bin("qn") - .unwrap() - .args([ - "--base-url", - &server.uri(), - "--no-input", - "--no-color", - "--format", - "table", - "rpc", - "pay-networks", - ]) - .output() - .unwrap(); - let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - output.status.success(), - "stderr={}", - String::from_utf8_lossy(&output.stderr) - ); - - // All three slugs present, sorted; base-sepolia carries the x402 asset from - // the discovery catalog. - assert!(stdout.contains("base-sepolia"), "{stdout}"); - assert!(stdout.contains("ethereum-mainnet"), "{stdout}"); - assert!(stdout.contains("solana-devnet"), "{stdout}"); - assert!( - stdout.contains("0x036CbD53842c5426634e7929541eC2318f3dCF7e"), - "asset not mapped onto base-sepolia row: {stdout}" - ); -} diff --git a/tests/rpc_supported_networks.rs b/tests/rpc_supported_networks.rs new file mode 100644 index 0000000..d728ed6 --- /dev/null +++ b/tests/rpc_supported_networks.rs @@ -0,0 +1,336 @@ +//! Integration tests for `qn rpc x402 supported-networks` and +//! `qn rpc mpp supported-networks` — the keyless per-gateway discovery +//! catalogs (callable networks + accepted currencies). +//! +//! `--base-url` points the gateway fetches at one wiremock host and bypasses +//! the on-disk cache, so the mock server stands in for the gateway. The +//! in-process harness can't capture stdout, so rendered output (tables, JSON +//! shape, the best-effort warning) is asserted via a subprocess; the +//! in-process tests cover exit codes. + +mod common; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use common::run_qn; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Mounts the two x402 discovery endpoints (`/networks` + `/supported`). +async fn mount_x402(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; + Mock::given(method("GET")) + .and(path("/supported")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "x402Version": 2, + "accepts": [ + // Two offers for the same (network, asset): a plain token + // offer and a Circle Gateway variant (verifyingContract) whose + // name is an EIP-712 domain — must dedupe to one USDC row. + { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "extra": {"name": "USDC", "version": "2"} + }, + { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "extra": { + "name": "GatewayWalletBatched", + "version": "1", + "verifyingContract": "0x0000000000000000000000000000000000000001" + } + }, + // A network outside the slug table renders as its raw CAIP-2 + // id, with the offer's token name. + { + "scheme": "exact", + "network": "eip155:999999", + "asset": "0x00000000000000000000000000000000000000aa", + "extra": {"name": "Fake Dollar", "version": "1"} + } + ] + }))) + .mount(server) + .await; +} + +/// Builds an MPP `WWW-Authenticate` header with one Tempo-testnet pathUSD +/// challenge and one Solana-mainnet USDC challenge. +fn mpp_challenge_header() -> String { + let tempo = URL_SAFE_NO_PAD.encode( + json!({ + "amount": "1000", + "currency": "0x20c0000000000000000000000000000000000000", + "methodDetails": {"chainId": 42431, "feePayer": true}, + "recipient": "0x0000000000000000000000000000000000000002" + }) + .to_string(), + ); + let solana = URL_SAFE_NO_PAD.encode( + json!({ + "amount": "0.001", + "currency": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "methodDetails": {"decimals": 6, "network": "mainnet-beta"}, + "recipient": "Recipient1111111111111111111111111111111111" + }) + .to_string(), + ); + format!( + "Payment id=\"a\", realm=\"mock\", method=\"tempo\", intent=\"charge\", \ + request=\"{tempo}\", description=\"Quicknode RPC request\", \ + Payment id=\"b\", realm=\"mock\", method=\"solana\", intent=\"charge\", \ + request=\"{solana}\", description=\"Quicknode RPC request\"" + ) +} + +/// Mounts the MPP discovery surface: `/networks` plus the keyless 402 probe +/// against the first listed network. +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; +} + +#[tokio::test] +async fn x402_supported_networks_fetches_and_exits_zero() { + let server = MockServer::start().await; + mount_x402(&server).await; + + let out = run_qn(&server.uri(), &["rpc", "x402", "supported-networks"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn x402_supported_networks_alias_works() { + let server = MockServer::start().await; + mount_x402(&server).await; + + let out = run_qn(&server.uri(), &["rpc", "x402", "networks"]).await; + assert_eq!(out.exit_code, 0, "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 supported_networks_surfaces_callable_fetch_failure() { + let server = MockServer::start().await; + // /networks returns 500 — the command should fail with an actionable error. + 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 + ); +} + +/// A currencies fetch failure is best-effort: exit 0, callable table renders, +/// a warning lands on stderr, and the JSON payments field is null. +#[tokio::test] +async fn x402_currencies_failure_is_best_effort() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/networks")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "networks": ["base-sepolia"] + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/supported")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .args([ + "--base-url", + &server.uri(), + "--no-input", + "--no-color", + "--format", + "json", + "rpc", + "x402", + "supported-networks", + ]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "stderr={stderr}"); + assert!( + stderr.contains("could not fetch accepted currencies"), + "stderr={stderr}" + ); + let v: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON"); + assert_eq!(v["callable"], json!(["base-sepolia"])); + assert!(v["payments"].is_null(), "{v}"); +} + +// Subprocess: assert the two-section x402 render and the JSON shape. +#[tokio::test] +async fn x402_supported_networks_renders_two_sections() { + let server = MockServer::start().await; + mount_x402(&server).await; + + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .args([ + "--base-url", + &server.uri(), + "--no-input", + "--no-color", + "--format", + "table", + "rpc", + "x402", + "supported-networks", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + + assert!(stdout.contains("Callable networks"), "{stdout}"); + assert!(stdout.contains("Accepted currencies"), "{stdout}"); + assert!(stdout.contains("ethereum-mainnet"), "{stdout}"); + // The payment network maps to its slug, the two offers dedupe to one row, + // and the known address resolves to its symbol. + assert_eq!(stdout.matches("base-sepolia").count(), 2, "{stdout}"); + assert!( + stdout.contains("USDC") && !stdout.contains("GatewayWalletBatched"), + "{stdout}" + ); + // The unknown payment network falls back to its raw CAIP-2 id with the + // offer's token name. + assert!(stdout.contains("eip155:999999"), "{stdout}"); + assert!(stdout.contains("Fake Dollar"), "{stdout}"); +} + +#[tokio::test] +async fn x402_supported_networks_json_shape() { + let server = MockServer::start().await; + mount_x402(&server).await; + + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .args([ + "--base-url", + &server.uri(), + "--no-input", + "--format", + "json", + "rpc", + "x402", + "supported-networks", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let v: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON"); + assert_eq!( + v["callable"], + json!(["base-sepolia", "ethereum-mainnet", "solana-devnet"]) + ); + assert_eq!( + v["payments"], + json!([ + { + "network": "base-sepolia", + "asset": "USDC", + "address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + }, + { + "network": "eip155:999999", + "asset": "Fake Dollar", + "address": "0x00000000000000000000000000000000000000aa" + } + ]) + ); +} + +// Subprocess: assert the MPP challenge-derived currencies render. +#[tokio::test] +async fn mpp_supported_networks_renders_challenge_currencies() { + let server = MockServer::start().await; + mount_mpp(&server).await; + + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .args([ + "--base-url", + &server.uri(), + "--no-input", + "--no-color", + "--format", + "table", + "rpc", + "mpp", + "supported-networks", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + + // Tempo testnet resolves to its slug and the enshrined token to pathUSD; + // the Solana challenge maps to solana-mainnet USDC. + 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_networks_table_shows_two_sections.snap b/tests/snapshots/table_snapshots__mpp_supported_networks_table_shows_two_sections.snap new file mode 100644 index 0000000..8d5b54c --- /dev/null +++ b/tests/snapshots/table_snapshots__mpp_supported_networks_table_shows_two_sections.snap @@ -0,0 +1,12 @@ +--- +source: tests/table_snapshots.rs +expression: "supported_networks_stdout(&server, \"mpp\").await" +--- +Callable networks (make mpp-paid RPC calls to these): +NETWORK +base-sepolia +tempo-testnet + +Accepted currencies (pay for calls with one of these): +NETWORK ASSET ADDRESS +tempo-testnet pathUSD 0x20c0000000000000000000000000000000000000 diff --git a/tests/snapshots/table_snapshots__pay_networks_table_merges_schemes_and_asset.snap b/tests/snapshots/table_snapshots__pay_networks_table_merges_schemes_and_asset.snap deleted file mode 100644 index 8d68f0f..0000000 --- a/tests/snapshots/table_snapshots__pay_networks_table_merges_schemes_and_asset.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: tests/table_snapshots.rs -expression: "String::from_utf8(output.stdout).unwrap()" ---- -NETWORK SCHEMES X402 ASSET -base-sepolia x402, mpp 0x036CbD53842c5426634e7929541eC2318f3dCF7e -ethereum-mainnet x402, mpp — -solana-devnet x402, mpp — diff --git a/tests/snapshots/table_snapshots__x402_supported_networks_table_shows_two_sections.snap b/tests/snapshots/table_snapshots__x402_supported_networks_table_shows_two_sections.snap new file mode 100644 index 0000000..c8feefa --- /dev/null +++ b/tests/snapshots/table_snapshots__x402_supported_networks_table_shows_two_sections.snap @@ -0,0 +1,13 @@ +--- +source: tests/table_snapshots.rs +expression: "supported_networks_stdout(&server, \"x402\").await" +--- +Callable networks (make x402-paid RPC calls to these): +NETWORK +base-sepolia +ethereum-mainnet +solana-devnet + +Accepted currencies (pay for calls with one of these): +NETWORK ASSET ADDRESS +base-sepolia USDC 0x036CbD53842c5426634e7929541eC2318f3dCF7e diff --git a/tests/table_snapshots.rs b/tests/table_snapshots.rs index bb665c3..2d1eee8 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -464,33 +464,9 @@ async fn sql_schema_table_renders_nested_table_blocks() { insta::assert_snapshot!(out); } -#[tokio::test] -async fn pay_networks_table_merges_schemes_and_asset() { - // Both gateways share the mock host (--base-url points both at it). The - // /discovery/resources asset lands on the base-sepolia row. - 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; - Mock::given(method("GET")) - .and(path("/discovery/resources")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "x402Version": 2, - "items": [{ - "accepts": [{ - "scheme": "exact", - "network": "eip155:84532", - "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" - }] - }] - }))) - .mount(&server) - .await; - +/// Runs `qn --format table rpc supported-networks` against `server` +/// and returns stdout. Panics (with stderr) on non-zero exit. +async fn supported_networks_stdout(server: &MockServer, scheme: &str) -> String { let output = assert_cmd::Command::cargo_bin("qn") .unwrap() .env_remove("HOME") @@ -503,7 +479,8 @@ async fn pay_networks_table_merges_schemes_and_asset() { "--format", "table", "rpc", - "pay-networks", + scheme, + "supported-networks", ]) .output() .unwrap(); @@ -512,5 +489,67 @@ async fn pay_networks_table_merges_schemes_and_asset() { "stderr={}", String::from_utf8_lossy(&output.stderr) ); - insta::assert_snapshot!(String::from_utf8(output.stdout).unwrap()); + String::from_utf8(output.stdout).unwrap() +} + +#[tokio::test] +async fn x402_supported_networks_table_shows_two_sections() { + 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; + Mock::given(method("GET")) + .and(path("/supported")) + .respond_with(ResponseTemplate::new(200).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!(supported_networks_stdout(&server, "x402").await); +} + +#[tokio::test] +async fn mpp_supported_networks_table_shows_two_sections() { + 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!(supported_networks_stdout(&server, "mpp").await); } From 6f90d57f03607ab9e0bb8c9f6a4231ed0295d3a9 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 30 Jul 2026 13:00:04 -0300 Subject: [PATCH 32/32] Review updates --- CLAUDE.md | 1 + README.md | 25 +- src/commands/agent/context.md | 22 +- src/commands/rpc/mod.rs | 20 +- src/commands/rpc/mpp.rs | 145 ++++++++--- src/commands/rpc/pay_asset.rs | 8 +- src/commands/rpc/supported_networks.rs | 243 +++++++++-------- src/commands/rpc/x402.rs | 36 ++- src/config.rs | 246 +++++++++++------- src/errors.rs | 26 +- tests/rpc_payment.rs | 119 ++++++++- tests/rpc_supported_networks.rs | 224 ++++++---------- ...ted_networks_table_shows_two_sections.snap | 12 - ...upported_payments_table_lists_options.snap | 6 + ..._supported_networks_table_lists_slugs.snap | 8 + ...ted_networks_table_shows_two_sections.snap | 13 - ...upported_payments_table_lists_options.snap | 6 + tests/table_snapshots.rs | 26 +- 18 files changed, 728 insertions(+), 458 deletions(-) delete mode 100644 tests/snapshots/table_snapshots__mpp_supported_networks_table_shows_two_sections.snap create mode 100644 tests/snapshots/table_snapshots__mpp_supported_payments_table_lists_options.snap create mode 100644 tests/snapshots/table_snapshots__x402_supported_networks_table_lists_slugs.snap delete mode 100644 tests/snapshots/table_snapshots__x402_supported_networks_table_shows_two_sections.snap create mode 100644 tests/snapshots/table_snapshots__x402_supported_payments_table_lists_options.snap 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/README.md b/README.md index c238640..3bfca7a 100644 --- a/README.md +++ b/README.md @@ -368,11 +368,12 @@ Every path needs a payment wallet. Two options: never an environment variable, never a flag value, never inline in config, never printed. -`qn rpc x402 supported-networks` and `qn rpc mpp supported-networks` (alias -`networks`) each show one gateway's catalog in two sections (no API key -needed): the callable networks (each slug is a valid `--network` for a paid -call) and the accepted currencies (each row's network/address pair is a ready -`--payment-network`/`--payment-asset`). Cached at +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 @@ -461,15 +462,19 @@ qn rpc mpp open --network tempo-testnet --deposit 1000000 --max-amount 1000000 \ # Pay for calls from the channel — one cumulative voucher per call. qn rpc call eth_blockNumber --network tempo-testnet --mpp-session \ - --payment-wallet payer --payment-network tempo-testnet --payment-asset USDC + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset USDC --max-amount 1000000 -# Inspect the channel (also recovers local state from the gateway). +# Inspect the channel (re-syncs the accepted spend from the gateway). qn rpc mpp status --network tempo-testnet --payment-wallet payer \ - --payment-network tempo-testnet --payment-asset USDC + --payment-network tempo-testnet --payment-asset USDC --max-amount 1000000 # Add more deposit, or close to settle on-chain and refund the unused balance. -qn rpc mpp top-up --network tempo-testnet --deposit 1000000 --payment-wallet payer ... -qn rpc mpp close --network tempo-testnet --payment-wallet payer ... +qn rpc mpp top-up --network tempo-testnet --deposit 1000000 \ + --payment-wallet payer --payment-network tempo-testnet \ + --payment-asset USDC --max-amount 1000000 +qn rpc mpp close --network tempo-testnet --payment-wallet payer \ + --payment-network tempo-testnet --payment-asset USDC --max-amount 1000000 ``` Channel state is cached (0600) under the config dir, keyed by wallet + network. diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index fb39f9a..211cd1b 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -166,12 +166,12 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase Mutually exclusive with `--endpoint-url`. **Wallets**: stored payment wallets are managed by the top-level `qn wallet` noun (see below); reference one on a paid call with `--payment-wallet `. - **Discovery**: `qn rpc x402 supported-networks` and `qn rpc mpp - supported-networks` (alias `networks`) each show one gateway's catalog in - two sections, from its public discovery surfaces (no API key): the callable - networks (each slug is a valid `--network` for a paid call) and the accepted - currencies (each row's network/address pair is a ready - `--payment-network`/`--payment-asset`). + **Discovery** (per gateway, no API key): `qn rpc {x402,mpp} + supported-networks` (alias `networks`) lists the networks you can make paid + calls to — each slug is a valid `--network`. `qn rpc {x402,mpp} + supported-payments` (alias `payments`) lists the payment options the gateway + accepts — each row's network/address pair is a ready + `--payment-network`/`--payment-asset`. **x402 drawdown**: `qn rpc x402 {buy-credits, balance, drip}` manages prepaid gateway credits instead of paying per request, and `qn rpc call --x402-drawdown` spends them (no per-call signing, 1 credit per successful @@ -205,9 +205,10 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase `top-up --deposit ` move real funds on-chain (gated Mild); `close` cooperatively settles + refunds (gated Mild; its prompt warns further `--mpp-session` calls fail until re-open); `status` shows the gateway's view - and is the recovery path for lost local channel state. Channel state is cached + and re-syncs the accepted spend into the local record. Channel state is cached under `/qn/channels.toml` (0600, keyed by wallet address + - network). `--mpp-session` requires an open channel, is mutually exclusive with + network); every channel verb needs that record — a lost one means opening a + new channel. `--mpp-session` requires an open channel, is mutually exclusive with `--x402`/`--mpp`/`--x402-drawdown`/`--endpoint-url`, and points at `qn rpc mpp top-up` when the channel deposit is exhausted. - `wallet` — the local store of payment wallets for the paid RPC lane; no API @@ -292,12 +293,13 @@ is enabling Tooling Access (or pass `--yes` to enable on first use). A custom **Pay per call with a crypto micropayment (no API key, no login):** `--payment-asset`/`--max-amount` must match a real offer for the network (see -`qn rpc x402 supported-networks` / `qn rpc mpp supported-networks`); an +`qn rpc x402 supported-payments` / `qn rpc mpp supported-payments`); an unfunded wallet or a mismatched asset/amount is refused with HTTP 400/402 before anything settles. ```sh -qn rpc x402 supported-networks # callable networks + accepted currencies (mpp: qn rpc mpp supported-networks) +qn rpc x402 supported-networks # networks you can call (mpp: qn rpc mpp supported-networks) +qn rpc x402 supported-payments # payment options: network, token, address (mpp: qn rpc mpp supported-payments) qn wallet generate --vm evm --name payer # dedicated wallet; prints its address + a QR to fund # → fund THAT address, then pick a lane: diff --git a/src/commands/rpc/mod.rs b/src/commands/rpc/mod.rs index 606a6db..42d9a87 100644 --- a/src/commands/rpc/mod.rs +++ b/src/commands/rpc/mod.rs @@ -62,24 +62,28 @@ pub enum RpcCmd { 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 -\n\n\ - Paid (crypto micropayment, no API key; params from [rpc.payment] in config;\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 \ 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\n\ + qn rpc call eth_blockNumber --network tempo-testnet --mpp --receipt \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 10000\n\n\ Prepaid x402 credits (drawdown — buy once, then spend on any supported network):\n \ qn rpc x402 buy-credits --network ethereum-mainnet --payment-wallet payer \\\n \ --payment-network base-sepolia --payment-asset USDC --max-amount 10000000\n \ qn rpc call eth_blockNumber --network ethereum-mainnet --x402-drawdown --payment-wallet payer\n\n\ MPP payment channel (open once, then pay per call with a voucher):\n \ qn rpc mpp open --network tempo-testnet --deposit 1000000 \\\n \ - --payment-wallet payer --max-amount 1000000\n \ - qn rpc call eth_blockNumber --network tempo-testnet --mpp-session --payment-wallet payer\n\n\ - See callable networks, accepted currencies, and manage wallets:\n \ - qn rpc x402 supported-networks\n \ - qn rpc mpp supported-networks\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000\n \ + qn rpc call eth_blockNumber --network tempo-testnet --mpp-session \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000\n\n\ + Discover networks and payment options, and manage wallets:\n \ + qn rpc x402 supported-networks (mpp: qn rpc mpp supported-networks)\n \ + qn rpc x402 supported-payments (mpp: qn rpc mpp supported-payments)\n \ qn wallet generate --vm evm --name payer")] Call(Box), diff --git a/src/commands/rpc/mpp.rs b/src/commands/rpc/mpp.rs index ebc1367..e23fa8c 100644 --- a/src/commands/rpc/mpp.rs +++ b/src/commands/rpc/mpp.rs @@ -6,8 +6,8 @@ //! - `top-up` — add deposit to the open channel. Gated Mild. //! - `close` — cooperatively close (settle on-chain + refund). Gated Mild; //! the prompt warns that further `--mpp-session` calls fail until re-open. -//! - `status` — the gateway's view of the channel (also the recovery path -//! when local channel state is lost). +//! - `status` — the gateway's view of the channel (re-syncs the accepted +//! spend high-water mark into the local record). //! //! Channel state (channelId, deposit, cumulative spend) is persisted under the //! config dir (`channels.toml`, 0600, keyed by wallet address + network) and @@ -20,11 +20,28 @@ use quicknode_sdk::ChannelState; use crate::config::{self, PaymentSection}; use crate::context::{Ctx, GlobalArgs}; use crate::errors::CliError; +use crate::output::{style, Style}; use super::payment::{resolve_payment_params, PaymentParams}; #[derive(Debug, ClapArgs)] #[command(subcommand_required = true, arg_required_else_help = true)] +#[command(after_help = "Examples:\n \ + qn rpc mpp open --network tempo-testnet --deposit 1000000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000\n \ + qn rpc call eth_blockNumber --network tempo-testnet --mpp-session \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000\n \ + qn rpc mpp status --network tempo-testnet \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000\n \ + qn rpc mpp top-up --network tempo-testnet --deposit 500000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 500000\n \ + qn rpc mpp close --network tempo-testnet \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000")] pub struct Args { #[command(subcommand)] pub cmd: MppCmd, @@ -42,19 +59,43 @@ pub enum MppCmd { /// Add deposit to the open channel. Gated. #[command(name = "top-up")] + #[command(after_help = "Examples:\n \ + qn rpc mpp top-up --network tempo-testnet --deposit 500000 \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 500000")] TopUp(TopUpArgs), /// Cooperatively close the channel (settle on-chain + refund unused /// deposit). Gated. + #[command(after_help = "Examples:\n \ + qn rpc mpp close --network tempo-testnet \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000")] Close(ChannelArgs), - /// Show the gateway's view of the channel (also the state-recovery path). + /// Show the gateway's view of the channel (re-syncs the accepted spend). + #[command(after_help = "Examples:\n \ + qn rpc mpp status --network tempo-testnet \\\n \ + --payment-wallet payer --payment-network tempo-testnet \\\n \ + --payment-asset USDC --max-amount 1000000")] Status(ChannelArgs), - /// List the networks callable via MPP and the currencies the gateway - /// accepts as payment. No API key required. + /// List the networks you can make MPP-paid RPC calls to. Each slug is a + /// valid --network for a paid call. No API key required. #[command(visible_alias = "networks")] + #[command(after_help = "Examples:\n \ + qn rpc mpp networks\n \ + qn rpc mpp supported-networks --format json")] SupportedNetworks, + + /// List the payment options the MPP gateway accepts: the network you pay + /// on, the token, and its contract address — ready --payment-network and + /// --payment-asset values. No API key required. + #[command(visible_alias = "payments")] + #[command(after_help = "Examples:\n \ + qn rpc mpp payments\n \ + qn rpc mpp supported-payments --format json")] + SupportedPayments, } /// The payment parameter stack + query network shared by the MPP verbs. @@ -142,7 +183,12 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { MppCmd::Close(a) => run_close(a.payment, global).await, MppCmd::Status(a) => run_status(a.payment, global).await, MppCmd::SupportedNetworks => { - super::supported_networks::run(super::supported_networks::Scheme::Mpp, global).await + 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 } } } @@ -184,6 +230,40 @@ fn parse_base_units(s: &str, flag: &str) -> Result { }) } +// Prints a bold, ready-to-run next-command hint (stderr): the base command +// plus the payment flags this invocation passed, so the suggestion works +// verbatim. Values resolved from [rpc.payment] config need no flags and are +// not echoed. Wrapped two flags per continuation line. +fn note_next(ctx: &Ctx, base: &str, payment: &PaymentArgs) { + let mut flags: Vec = Vec::new(); + if let Some(f) = &payment.payment_key_file { + flags.push(format!("--payment-key-file {}", f.display())); + } + if let Some(w) = &payment.payment_wallet { + flags.push(format!("--payment-wallet {w}")); + } + if let Some(n) = &payment.payment_network { + flags.push(format!("--payment-network {n}")); + } + if let Some(a) = &payment.payment_asset { + flags.push(format!("--payment-asset {a}")); + } + if let Some(m) = &payment.max_amount { + flags.push(format!("--max-amount {m}")); + } + + let mut lines = vec![format!(" {base}")]; + for chunk in flags.chunks(2) { + lines.push(format!(" {}", chunk.join(" "))); + } + let cmd = lines.join(" \\\n"); + ctx.out.note(&format!( + "\n{}\n\n{}\n", + style("Example", Style::Bold, ctx.out.color), + style(&cmd, Style::Bold, ctx.out.color), + )); +} + async fn run_open(args: OpenArgs, global: GlobalArgs) -> Result<(), CliError> { let deposit = parse_base_units(&args.deposit, "deposit")?; let (ctx, network) = setup(&args.payment, global.clone())?; @@ -208,9 +288,11 @@ async fn run_open(args: OpenArgs, global: GlobalArgs) -> Result<(), CliError> { "✓ Opened channel {} (deposit: {})", channel.channel_id, channel.deposit )); - ctx.out.note(&format!( - " Next: qn rpc call eth_blockNumber --network {network} --mpp-session" - )); + note_next( + &ctx, + &format!("qn rpc call eth_blockNumber --network {network} --mpp-session"), + &args.payment, + ); emit_channel(&ctx, &channel) } @@ -240,8 +322,11 @@ async fn run_top_up(args: TopUpArgs, global: GlobalArgs) -> Result<(), CliError> "✓ Topped up channel {} (deposit: {})", updated.channel_id, updated.deposit )); - ctx.out - .note(&format!(" Next: qn rpc mpp status --network {network}")); + note_next( + &ctx, + &format!("qn rpc mpp status --network {network}"), + &args.payment, + ); emit_channel(&ctx, &updated) } @@ -274,9 +359,11 @@ async fn run_close(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliError ctx.out .note(&format!("✓ Closed channel {}", channel.channel_id)); - ctx.out.note(&format!( - " Next: qn rpc mpp open --network {network} --deposit " - )); + note_next( + &ctx, + &format!("qn rpc mpp open --network {network} --deposit "), + &args, + ); Ok(()) } @@ -284,34 +371,32 @@ async fn run_status(args: PaymentArgs, global: GlobalArgs) -> Result<(), CliErro let (ctx, network) = setup(&args, global.clone())?; let channel = require_channel(&ctx, &global, &network)?; - let status = ctx - .sdk - .rpc - .mpp_status(&network, &channel.channel_id) - .await?; + let status = ctx.sdk.rpc.mpp_status(&network, &channel).await?; - // Re-seed local state from the gateway's high-water mark (the recovery - // path): the gateway is authoritative for deposit + accepted cumulative. + // Re-seed the local spend high-water mark from the gateway (it is + // authoritative for what it has accepted). The deposit is tracked locally: + // the gateway exposes no read-only channel endpoint. let mut synced = channel.clone(); - synced.deposit = status.deposit; - synced.cumulative_spent = status.accepted_cumulative; + synced.cumulative_spent = synced.cumulative_spent.max(status.accepted_cumulative); persist_channel(&ctx, &global, &network, &synced); + let deposit = synced.deposit; if matches!(ctx.global.format, Some(f) if f.is_structured()) { let v = serde_json::json!({ "channel_id": status.channel_id, - "deposit": status.deposit, + "deposit": deposit, "accepted_cumulative": status.accepted_cumulative, - "remaining": status.deposit.saturating_sub(status.accepted_cumulative), + "spent": status.spent, + "remaining": deposit.saturating_sub(status.accepted_cumulative), }); return super::emit_result(&ctx, &v); } ctx.out.note(&format!( - "channel {}: deposit {}, spent {}, remaining {}", + "channel {}: deposit {}, accepted {}, remaining {}", status.channel_id, - status.deposit, + deposit, status.accepted_cumulative, - status.deposit.saturating_sub(status.accepted_cumulative), + deposit.saturating_sub(status.accepted_cumulative), )); Ok(()) } @@ -331,8 +416,8 @@ fn persist_channel(ctx: &Ctx, global: &GlobalArgs, network: &str, channel: &Chan } // Load the open channel for this wallet+network, or an actionable error -// pointing at `mpp open`. A lost local record is recovered by re-opening or, -// if the channel is known, by `mpp status` (which re-seeds it). +// pointing at `mpp open`. Every channel verb (including `status`) needs the +// local record; a lost one means opening a new channel. fn require_channel( ctx: &Ctx, global: &GlobalArgs, diff --git a/src/commands/rpc/pay_asset.rs b/src/commands/rpc/pay_asset.rs index 1c791a7..d8de92e 100644 --- a/src/commands/rpc/pay_asset.rs +++ b/src/commands/rpc/pay_asset.rs @@ -20,7 +20,7 @@ use crate::errors::CliError; /// (CAIP-2 network, lowercase symbol) → token address. Sorted by the /// `(network, symbol)` tuple (binary searched); a unit test enforces order and /// uniqueness. Addresses are taken from a public source — the payment -/// gateways' discovery catalog (`qn rpc x402 supported-networks`) or, for +/// gateways' discovery catalog (`qn rpc x402 supported-payments`) or, for /// Tempo, the MPP spec / Tempo payment docs. const PAY_ASSETS: &[(&str, &str, &str)] = &[ ( @@ -94,8 +94,8 @@ pub(super) fn resolve(input: &str, network: &str) -> Result { 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-networks' or \ - 'qn rpc mpp supported-networks' to find it", + --payment-asset — run 'qn rpc x402 supported-payments' or \ + 'qn rpc mpp supported-payments' to find it", input.to_ascii_uppercase() ))), } @@ -103,7 +103,7 @@ pub(super) fn resolve(input: &str, network: &str) -> Result { /// Reverse lookup: a resolved token address on `network` → its known symbol, /// in display casing. Used to name the asset in funds-moving prompts and the -/// supported-networks tables instead of echoing the raw contract address. EVM +/// supported-payments table instead of echoing the raw contract address. EVM /// hex compares case-insensitively (checksum casing varies); a miss just means /// the caller shows the address. pub(super) fn symbol_for(network: &str, address: &str) -> Option { diff --git a/src/commands/rpc/supported_networks.rs b/src/commands/rpc/supported_networks.rs index 976edb4..84e588a 100644 --- a/src/commands/rpc/supported_networks.rs +++ b/src/commands/rpc/supported_networks.rs @@ -1,25 +1,26 @@ -//! `qn rpc x402 supported-networks` / `qn rpc mpp supported-networks` — one -//! payment gateway's discovery catalog, in two sections: the networks you can -//! make paid RPC calls **to** (callable), and the currencies the gateway -//! accepts as payment. +//! `qn rpc {x402,mpp} supported-networks` and `supported-payments` — one +//! payment gateway's discovery lists, one per verb: the networks you can make +//! paid RPC calls **to**, and the payment options the gateway accepts (the +//! network, token, and contract address you pay **with**). //! //! Keyless and public: this reads the gateway's own discovery surfaces, not -//! the account API. Callable networks come from `{gateway}/networks`. Accepted -//! currencies come from `x402.quicknode.com/supported` for x402; the MPP -//! gateway publishes them in the `WWW-Authenticate: Payment` challenge of a -//! keyless 402 response, so the command probes one callable network without -//! paying. Results are cached per scheme in `pay-networks.toml` next to the +//! the account API. Callable networks come from `{gateway}/networks`. Payment +//! options come from `x402.quicknode.com/supported` for x402; the MPP gateway +//! publishes them in the `WWW-Authenticate: Payment` challenge of a keyless +//! 402 response, so `supported-payments` probes one callable network without +//! paying. Each list is cached per scheme in `pay-networks.toml` next to the //! config with a 24h TTL, mirroring the multichain URL cache. A callable -//! network is a valid `--network` for a paid call; an accepted currency's +//! network is a valid `--network` for a paid call; a payment option's //! network/address pair is a ready `--payment-network`/`--payment-asset`. use std::collections::BTreeMap; +use std::path::Path; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use serde::Deserialize; -use crate::config::{self, PayAssetEntry, SupportedNetworks}; +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}; @@ -50,64 +51,94 @@ impl Scheme { } } -pub(super) async fn run(scheme: Scheme, global: GlobalArgs) -> Result<(), CliError> { - // A `--base-url` override points the gateway fetches at one host (used by - // tests to serve the discovery endpoints from a mock). It also bypasses - // the cache, since a test host's data isn't the real catalog. - 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()); - - // Fresh cache hit? (Skipped when a --base-url override is active.) - let cached = if base_override.is_none() { - cache_path - .as_deref() - .and_then(|p| config::load_pay_networks(p, scheme.as_str(), now_unix())) - } else { - None - }; +/// Cache/override plumbing shared by both verbs. A `--base-url` override +/// points the gateway fetches at one host (used by tests to serve the +/// discovery endpoints from a mock) and bypasses the cache, since a test +/// host's data isn't the real catalog. +struct Discovery { + ctx: Ctx, + scheme: Scheme, + base: String, + cache_path: Option, + use_cache: bool, +} - let (callable, payments) = match cached { - Some(c) => (c.callable, Some(c.payments)), - None => { - let base = base_override.as_deref().unwrap_or(scheme.gateway_base()); - let client = reqwest::Client::new(); - let callable = fetch_networks(&client, base).await?; - let fetched = match scheme { - Scheme::X402 => fetch_x402_payments(&client, base).await, - Scheme::Mpp => fetch_mpp_payments(&client, base, callable.first()).await, - }; - match fetched { - Ok(payments) => { - let catalog = SupportedNetworks { callable, payments }; - if base_override.is_none() { - if let Some(p) = cache_path.as_deref() { - let _ = - config::save_pay_networks(p, scheme.as_str(), now_unix(), &catalog); - } - } - (catalog.callable, Some(catalog.payments)) - } - Err(e) => { - // Best-effort: the callable list still renders; the - // currencies section reports the miss and nothing is - // cached, so the next run retries. - ctx.out - .warn(&format!("could not fetch accepted currencies: {e}")); - (callable, None) - } +impl Discovery { + fn new(scheme: Scheme, global: GlobalArgs) -> Result { + let base_override = global.base_url.clone(); + let ctx = Ctx::from_global_keyless(global)?; + let cache_path = + config::pay_networks_cache_path(ctx.global.resolve_config_path().as_deref()); + let use_cache = base_override.is_none(); + let base = base_override.unwrap_or_else(|| scheme.gateway_base().to_string()); + Ok(Self { + ctx, + scheme, + base, + cache_path, + use_cache, + }) + } + + fn cache_path(&self) -> Option<&Path> { + self.use_cache + .then_some(self.cache_path.as_deref()) + .flatten() + } + + /// The callable-networks list: fresh cache hit, or fetch + cache. + async fn ensure_networks(&self, client: &reqwest::Client) -> Result, CliError> { + if let Some(cached) = self + .cache_path() + .and_then(|p| config::load_pay_networks(p, self.scheme.as_str(), now_unix())) + { + return Ok(cached); + } + let networks = fetch_networks(client, &self.base).await?; + if let Some(p) = self.cache_path() { + let _ = config::save_pay_networks(p, self.scheme.as_str(), now_unix(), &networks); + } + Ok(networks) + } + + /// The payment-options list: fresh cache hit, or fetch + cache. The MPP + /// probe needs a callable network, which reuses the networks cache. + async fn ensure_payments( + &self, + client: &reqwest::Client, + ) -> Result, CliError> { + if let Some(cached) = self + .cache_path() + .and_then(|p| config::load_pay_payments(p, self.scheme.as_str(), now_unix())) + { + return Ok(cached); + } + let payments = match self.scheme { + Scheme::X402 => fetch_x402_payments(client, &self.base).await?, + Scheme::Mpp => { + let networks = self.ensure_networks(client).await?; + fetch_mpp_payments(client, &self.base, networks.first()).await? } + }; + if let Some(p) = self.cache_path() { + let _ = config::save_pay_payments(p, self.scheme.as_str(), now_unix(), &payments); } - }; + Ok(payments) + } +} + +/// `qn rpc {x402,mpp} supported-networks`. +pub(super) async fn run_networks(scheme: Scheme, global: GlobalArgs) -> Result<(), CliError> { + let d = Discovery::new(scheme, global)?; + let networks = d.ensure_networks(&reqwest::Client::new()).await?; + crate::output::emit(&d.ctx.out, &NetworksView(networks)) +} - crate::output::emit( - &ctx.out, - &SupportedNetworksView { - scheme: scheme.as_str(), - callable, - payments, - }, - ) +/// `qn rpc {x402,mpp} supported-payments`. +pub(super) async fn run_payments(scheme: Scheme, global: GlobalArgs) -> Result<(), CliError> { + let d = Discovery::new(scheme, global)?; + let payments = d.ensure_payments(&reqwest::Client::new()).await?; + crate::output::emit(&d.ctx.out, &PaymentsView(payments)) } /// GET `{base}/networks` → the `networks` slug array. @@ -132,7 +163,7 @@ async fn fetch_networks(client: &reqwest::Client, base: &str) -> Result, request: Option) -> Option, - /// `None` when the currencies fetch failed: rendered as unavailable in the - /// table, serialized as `null` in JSON. - payments: Option>, +#[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) + } } -impl Render for SupportedNetworksView { +/// `supported-payments` output: a bare array in JSON, NETWORK/ASSET/ADDRESS +/// columns as a table. +#[derive(serde::Serialize)] +#[serde(transparent)] +struct PaymentsView(Vec); + +impl Render for PaymentsView { fn render_table(&self, w: &mut dyn std::io::Write, ctx: &OutputCtx) -> std::io::Result<()> { - writeln!( - w, - "Callable networks (make {}-paid RPC calls to these):", - self.scheme - )?; - if self.callable.is_empty() { - writeln!(w, "(no callable networks)")?; - } else { - let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["NETWORK"]); - for n in &self.callable { - t.add_row(vec![comfy_table::Cell::new(n)]); - } - write_table(w, &t)?; + if self.0.is_empty() { + return writeln!(w, "(none listed)"); } - writeln!(w)?; - writeln!(w, "Accepted currencies (pay for calls with one of these):")?; - match &self.payments { - None => writeln!(w, "(could not fetch accepted currencies)"), - Some(p) if p.is_empty() => writeln!(w, "(none listed)"), - Some(p) => { - let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["NETWORK", "ASSET", "ADDRESS"]); - for e in p { - 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) - } + 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) } } diff --git a/src/commands/rpc/x402.rs b/src/commands/rpc/x402.rs index d9b6ab1..158b901 100644 --- a/src/commands/rpc/x402.rs +++ b/src/commands/rpc/x402.rs @@ -38,6 +38,14 @@ const EXAMPLE_QUERY_NETWORK: &str = "ethereum-mainnet"; #[derive(Debug, ClapArgs)] #[command(subcommand_required = true, arg_required_else_help = true)] +#[command(after_help = "Examples:\n \ + qn rpc x402 drip --payment-wallet payer\n \ + qn rpc x402 balance --payment-wallet payer\n \ + qn rpc x402 buy-credits --network ethereum-mainnet --payment-wallet payer \\\n \ + --payment-network base-sepolia --payment-asset USDC --max-amount 10000000\n \ + qn rpc call eth_blockNumber --network ethereum-mainnet --x402-drawdown --payment-wallet payer\n \ + qn rpc x402 supported-networks\n \ + qn rpc x402 supported-payments")] pub struct Args { #[command(subcommand)] pub cmd: X402Cmd, @@ -55,15 +63,32 @@ pub enum X402Cmd { /// Show the account's current credit balance (prints the bare number; /// --format json for the full envelope). #[command(visible_alias = "credits")] + #[command(after_help = "Examples:\n \ + qn rpc x402 balance --payment-wallet payer\n \ + qn rpc x402 balance --payment-wallet payer --format json")] Balance(SessionArgs), /// Request testnet credits from the faucet (Base Sepolia, once per account). + #[command(after_help = "Examples:\n \ + qn rpc x402 drip --payment-wallet payer")] Drip(SessionArgs), - /// List the networks callable via x402 and the currencies the gateway - /// accepts as payment. No API key required. + /// 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 @@ -168,7 +193,12 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { X402Cmd::Balance(a) => run_balance(a, global).await, X402Cmd::Drip(a) => run_drip(a, global).await, X402Cmd::SupportedNetworks => { - super::supported_networks::run(super::supported_networks::Scheme::X402, global).await + 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 } } } diff --git a/src/config.rs b/src/config.rs index dbfbe9e..006a310 100644 --- a/src/config.rs +++ b/src/config.rs @@ -550,7 +550,8 @@ pub fn save_gateway_session( // cumulative spend) reused across `qn rpc` processes. We persist it in // `channels.toml` alongside the config, keyed by payer address + query network // so one wallet can hold a channel per network. The gateway is the source of -// truth for the high-water mark; `qn rpc mpp status` re-derives a lost entry. +// truth for the accepted high-water mark; `qn rpc mpp status` re-syncs it into +// this record. A lost or unparseable record reads as "no channel". /// On-disk shape of `~/.config/qn/channels.toml`. Amounts are stored as /// strings because TOML has no unsigned-128-bit integer type; they round-trip @@ -568,10 +569,9 @@ pub struct ChannelEntry { pub channel_id: String, pub token: String, pub payee: String, - pub operator: String, pub salt: String, pub authorized_signer: String, - pub expiring_nonce_hash: String, + pub escrow_contract: String, pub deposit: String, pub cumulative_spent: String, pub per_call: String, @@ -584,10 +584,9 @@ impl ChannelEntry { channel_id: s.channel_id.clone(), token: s.token.clone(), payee: s.payee.clone(), - operator: s.operator.clone(), salt: s.salt.clone(), authorized_signer: s.authorized_signer.clone(), - expiring_nonce_hash: s.expiring_nonce_hash.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(), @@ -600,10 +599,9 @@ impl ChannelEntry { channel_id: self.channel_id.clone(), token: self.token.clone(), payee: self.payee.clone(), - operator: self.operator.clone(), salt: self.salt.clone(), authorized_signer: self.authorized_signer.clone(), - expiring_nonce_hash: self.expiring_nonce_hash.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()?, @@ -728,18 +726,19 @@ pub fn save_networks( write_atomic_0600(path, text.as_bytes(), ".qn-networks-") } -// ── Supported-networks discovery cache ─────────────────────────────────────── +// ── Payment-gateway discovery cache ────────────────────────────────────────── // -// The networks callable via a payment gateway and the currencies it accepts as -// payment are stable public metadata fetched from the gateways' discovery +// The networks callable via a payment gateway and the payment options it +// accepts are stable public metadata fetched from the gateways' discovery // surfaces. Cached per scheme (x402 / mpp) in `pay-networks.toml` next to the -// config file with a 24-hour TTL, mirroring the multichain URL cache. Not -// account-scoped (the data is public and the same for everyone). +// config file, each section with its own 24-hour TTL (the two lists back +// separate commands and are fetched independently). Not account-scoped (the +// data is public and the same for everyone). -/// Seconds a cached supported-networks catalog is considered fresh (24h). +/// Seconds a cached discovery list is considered fresh (24h). pub const PAY_NETWORKS_TTL_SECS: i64 = 24 * 60 * 60; -/// One accepted payment currency: the network the payment moves on, a display +/// One accepted payment option: the network the payment moves on, a display /// symbol when one is known, and the token contract address (EVM) / mint /// (Solana). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -750,31 +749,38 @@ pub struct PayAssetEntry { pub address: String, } -/// One gateway's discovery catalog: the networks it can proxy paid calls to, -/// and the currencies it accepts as payment. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SupportedNetworks { - pub callable: Vec, - pub payments: Vec, -} - #[derive(Debug, Default, Serialize, Deserialize)] pub struct PayNetworksCacheFile { #[serde(default)] - pub x402: Option, + pub x402: SchemeCacheEntry, #[serde(default)] - pub mpp: Option, + pub mpp: SchemeCacheEntry, +} + +/// One gateway's cached discovery lists, each independently timestamped. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SchemeCacheEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub networks: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payments: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PayNetworksCacheEntry { - /// Unix seconds the catalog was fetched, for the TTL check. +pub struct NetworksCacheSection { + /// Unix seconds the list was fetched, for the TTL check. + pub fetched_at_unix: i64, + pub networks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentsCacheSection { + /// Unix seconds the list was fetched, for the TTL check. pub fetched_at_unix: i64, - pub callable: Vec, pub payments: Vec, } -/// The supported-networks cache path: `pay-networks.toml` alongside the config. +/// 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")), @@ -782,59 +788,96 @@ pub fn pay_networks_cache_path(config_path: Option<&Path>) -> Option { } } -/// Loads the cached catalog for `scheme` ("x402" or "mpp") from `path`, if -/// present and fetched within the TTL (relative to `now_unix`). Returns `None` -/// on any miss, including a cache file in an older format. -pub fn load_pay_networks(path: &Path, scheme: &str, now_unix: i64) -> Option { - let text = fs::read_to_string(path).ok()?; - let cache: PayNetworksCacheFile = toml::from_str(&text).ok()?; - let entry = match scheme { - "x402" => cache.x402?, - "mpp" => cache.mpp?, - _ => return None, - }; - if now_unix.saturating_sub(entry.fetched_at_unix) >= PAY_NETWORKS_TTL_SECS { +/// Reads and parses the cache file, or an empty default when it is missing, +/// unreadable, or in an older format. +fn load_pay_cache_file(path: &Path) -> PayNetworksCacheFile { + fs::read_to_string(path) + .ok() + .and_then(|text| toml::from_str(&text).ok()) + .unwrap_or_default() +} + +fn scheme_entry<'a>( + cache: &'a mut PayNetworksCacheFile, + scheme: &str, +) -> Option<&'a mut SchemeCacheEntry> { + match scheme { + "x402" => Some(&mut cache.x402), + "mpp" => Some(&mut cache.mpp), + _ => None, + } +} + +fn write_pay_cache_file(path: &Path, cache: &PayNetworksCacheFile) -> Result<(), CliError> { + let text = toml::to_string_pretty(cache).map_err(|e| CliError::ConfigWrite { + path: path.to_path_buf(), + source: std::io::Error::other(e), + })?; + write_atomic_0600(path, text.as_bytes(), ".qn-pay-networks-") +} + +/// Loads the cached callable-networks list for `scheme` ("x402" or "mpp") from +/// `path`, if present and fetched within the TTL (relative to `now_unix`). +/// Returns `None` on any miss, including a cache file in an older format. +pub fn load_pay_networks(path: &Path, scheme: &str, now_unix: i64) -> Option> { + let mut cache = load_pay_cache_file(path); + let section = scheme_entry(&mut cache, scheme)?.networks.take()?; + if now_unix.saturating_sub(section.fetched_at_unix) >= PAY_NETWORKS_TTL_SECS { return None; } - Some(SupportedNetworks { - callable: entry.callable, - payments: entry.payments, - }) + Some(section.networks) } -/// Saves the catalog for `scheme` to `path` atomically, stamping -/// `fetched_at_unix` for the TTL check. The other scheme's entry is kept when -/// the existing file parses; an older-format file is replaced wholesale. +/// Loads the cached payment-options list for `scheme`, with the same TTL and +/// miss semantics as [`load_pay_networks`]. +pub fn load_pay_payments(path: &Path, scheme: &str, now_unix: i64) -> Option> { + let mut cache = load_pay_cache_file(path); + let section = scheme_entry(&mut cache, scheme)?.payments.take()?; + if now_unix.saturating_sub(section.fetched_at_unix) >= PAY_NETWORKS_TTL_SECS { + return None; + } + Some(section.payments) +} + +/// Saves the callable-networks list for `scheme` to `path` atomically, +/// stamping `fetched_at_unix` for the TTL check. Every other section is kept +/// when the existing file parses; an older-format file is replaced wholesale. pub fn save_pay_networks( path: &Path, scheme: &str, fetched_at_unix: i64, - catalog: &SupportedNetworks, + networks: &[String], ) -> Result<(), CliError> { - let mut cache: PayNetworksCacheFile = fs::read_to_string(path) - .ok() - .and_then(|text| toml::from_str(&text).ok()) - .unwrap_or_default(); - let entry = Some(PayNetworksCacheEntry { + 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, - callable: catalog.callable.clone(), - payments: catalog.payments.clone(), + networks: networks.to_vec(), }); - match scheme { - "x402" => cache.x402 = entry, - "mpp" => cache.mpp = entry, - _ => { - return Err(CliError::ConfigWrite { - path: path.to_path_buf(), - source: std::io::Error::other(format!("unknown payment scheme '{scheme}'")), - }) - } - } - let text = toml::to_string_pretty(&cache).map_err(|e| CliError::ConfigWrite { + write_pay_cache_file(path, &cache) +} + +/// Saves the payment-options list for `scheme`, with the same merge semantics +/// as [`save_pay_networks`]. +pub fn save_pay_payments( + path: &Path, + scheme: &str, + fetched_at_unix: i64, + payments: &[PayAssetEntry], +) -> Result<(), CliError> { + let mut cache = load_pay_cache_file(path); + let entry = scheme_entry(&mut cache, scheme).ok_or_else(|| CliError::ConfigWrite { path: path.to_path_buf(), - source: std::io::Error::other(e), + source: std::io::Error::other(format!("unknown payment scheme '{scheme}'")), })?; - write_atomic_0600(path, text.as_bytes(), ".qn-pay-networks-") + entry.payments = Some(PaymentsCacheSection { + fetched_at_unix, + payments: payments.to_vec(), + }); + write_pay_cache_file(path, &cache) } /// Atomically writes `bytes` to `path` with 0600 perms via a temp file in the @@ -963,50 +1006,55 @@ mod tests { } #[test] - fn pay_networks_cache_roundtrips_per_scheme() { + fn pay_cache_sections_roundtrip_independently() { let dir = tempdir().unwrap(); let path = dir.path().join("pay-networks.toml"); - let x402 = SupportedNetworks { - callable: vec!["base-sepolia".to_string()], - payments: vec![PayAssetEntry { - network: "base-sepolia".to_string(), - asset: Some("USDC".to_string()), - address: "0xabc".to_string(), - }], - }; - let mpp = SupportedNetworks { - callable: vec!["tempo-testnet".to_string()], - payments: vec![], - }; - save_pay_networks(&path, "x402", 100, &x402).unwrap(); - save_pay_networks(&path, "mpp", 100, &mpp).unwrap(); - - // Each scheme loads its own entry; saving one kept the other. - let got = load_pay_networks(&path, "x402", 100).unwrap(); - assert_eq!(got.callable, vec!["base-sepolia"]); - assert_eq!(got.payments.len(), 1); - assert_eq!(got.payments[0].asset.as_deref(), Some("USDC")); - let got = load_pay_networks(&path, "mpp", 100).unwrap(); - assert_eq!(got.callable, vec!["tempo-testnet"]); - assert!(got.payments.is_empty()); - - // TTL expiry is a miss. + let payments = vec![PayAssetEntry { + network: "base-sepolia".to_string(), + asset: Some("USDC".to_string()), + address: "0xabc".to_string(), + }]; + save_pay_networks(&path, "x402", 100, &["base-sepolia".to_string()]).unwrap(); + save_pay_payments(&path, "x402", 200, &payments).unwrap(); + save_pay_networks(&path, "mpp", 100, &["tempo-testnet".to_string()]).unwrap(); + + // Each save kept the other sections. + assert_eq!( + load_pay_networks(&path, "x402", 100).unwrap(), + vec!["base-sepolia"] + ); + let got = load_pay_payments(&path, "x402", 200).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].asset.as_deref(), Some("USDC")); + assert_eq!( + load_pay_networks(&path, "mpp", 100).unwrap(), + vec!["tempo-testnet"] + ); + + // An unfetched section and a TTL-expired section are both misses. + assert!(load_pay_payments(&path, "mpp", 100).is_none()); assert!(load_pay_networks(&path, "x402", 100 + PAY_NETWORKS_TTL_SECS).is_none()); } #[test] - fn pay_networks_cache_old_format_is_a_miss() { + fn pay_cache_old_formats_are_a_miss() { let dir = tempdir().unwrap(); let path = dir.path().join("pay-networks.toml"); - // The pre-split cache shape: one `entry` with merged network rows. + // A pre-split cache shape: a per-scheme entry with merged lists. fs::write( &path, - "[entry]\nfetched_at_unix = 100\n\n\ - [[entry.networks]]\nnetwork = \"base-sepolia\"\nschemes = [\"x402\"]\n", + "[x402]\nfetched_at_unix = 100\ncallable = [\"base-sepolia\"]\npayments = []\n", ) .unwrap(); assert!(load_pay_networks(&path, "x402", 100).is_none()); - assert!(load_pay_networks(&path, "mpp", 100).is_none()); + assert!(load_pay_payments(&path, "x402", 100).is_none()); + + // A save on top of the old format replaces it wholesale. + save_pay_networks(&path, "mpp", 100, &["tempo-testnet".to_string()]).unwrap(); + assert_eq!( + load_pay_networks(&path, "mpp", 100).unwrap(), + vec!["tempo-testnet"] + ); } #[test] diff --git a/src/errors.rs b/src/errors.rs index aeba556..d7a564d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -132,7 +132,7 @@ pub fn render_with_argv(err: &CliError, verbose: bool, argv: &[String]) -> Strin 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-networks' / 'qn rpc mpp supported-networks').", + don't match an offer (see 'qn rpc x402 supported-payments' / 'qn rpc mpp supported-payments').", ); // When the reason wasn't a clean one-liner, append the raw body under // --verbose for the full detail. @@ -268,8 +268,13 @@ fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> St if verbose && !body.is_empty() { out.push('\n'); out.push_str(body); - } else if matches!(code, 400 | 422) && !parsed.bullets.is_empty() && !body.is_empty() { - out.push_str("\nRe-run with --verbose for the full response body."); + } else if !body.is_empty() { + // 400/422 with nothing parseable already surfaced the raw body above; + // everywhere else, point at the flag that reveals it. + let raw_body_shown = matches!(code, 400 | 422) && parsed.bullets.is_empty(); + if !raw_body_shown { + out.push_str("\nRe-run with --verbose for the full response body."); + } } out @@ -626,7 +631,7 @@ mod tests { !msg.contains(&body), "long body must not leak by default: {msg}" ); - assert!(msg.contains("supported-networks"), "got: {msg}"); + assert!(msg.contains("supported-payments"), "got: {msg}"); let verbose = render(&err, true); assert!(verbose.contains("accepts"), "got: {verbose}"); } @@ -678,6 +683,19 @@ mod tests { assert!(!msg.contains("boom"), "got: {msg}"); } + #[test] + fn non_verbose_402_hints_at_verbose() { + // A gateway 402 carries an actionable problem+json body; without + // --verbose the message must say how to see it. + let body = r#"{"title":"Insufficient Balance","status":402,"detail":"Insufficient balance: requested 10, available 0."}"#; + let msg = render(&api_err_with(402, body), false); + assert!(msg.contains("402"), "got: {msg}"); + assert!(msg.contains("--verbose"), "got: {msg}"); + assert!(!msg.contains("Insufficient balance"), "got: {msg}"); + let verbose = render(&api_err_with(402, body), true); + assert!(verbose.contains("Insufficient balance"), "got: {verbose}"); + } + // ---- body parsing ---- #[test] diff --git a/tests/rpc_payment.rs b/tests/rpc_payment.rs index dba6213..391f1f8 100644 --- a/tests/rpc_payment.rs +++ b/tests/rpc_payment.rs @@ -1576,18 +1576,22 @@ async fn x402_drawdown_out_of_credits_points_at_buy_credits() { // // The MPP session lane opens an escrow channel (an on-chain Tempo tx, signed // offline against the mock), then pays with cumulative vouchers. The mock -// gateway serves a tempo/session 402 challenge on probes and 2xx on credential -// POSTs; channel status is a GET under /session/:network/channels/:id. +// gateway serves a tempo/session 402 challenge on probes and 2xx (with a +// Payment-Receipt header, like the real gateway) on credential POSTs +// (open/topUp/voucher/close). use base64::Engine as _; -// A base64url tempo/session request body (currency, recipient, amount, chainId). +const SESSION_ESCROW: &str = "0x33b901018174ddabe4841042ab76ba85d4e24f25"; + +// A base64url tempo/session request body: currency, recipient, amount, and +// the escrow contract the gateway expects deposits in. fn session_request_b64() -> String { let json = json!({ "amount": "500", "currency": "0x20c0000000000000000000000000000000000000", "recipient": "0xfd24114c3981aba78ae2441991b1bdb89329c556", - "methodDetails": { "chainId": 42431 } + "methodDetails": { "chainId": 42431, "escrowContract": SESSION_ESCROW } }); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap()) } @@ -1599,17 +1603,41 @@ fn session_www_authenticate() -> String { ) } +// A base64url Payment-Receipt header value, the shape the gateway attaches to +// every accepted session response. +fn session_receipt_b64(accepted: &str, spent: &str) -> String { + let json = json!({ + "method": "tempo", + "intent": "session", + "status": "success", + "timestamp": "2099-01-01T00:00:00Z", + "reference": format!("0x{}", "ab".repeat(32)), + "challengeId": "c1", + "channelId": format!("0x{}", "ab".repeat(32)), + "acceptedCumulative": accepted, + "spent": spent, + }); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap()) +} + // Mount the session endpoint: a 402 session challenge on an unauthorized POST -// (the probe), and 200 on any POST carrying an Authorization: Payment header -// (credential submissions: open/topUp/voucher/close). +// (the probe), and 200 + Payment-Receipt on any POST carrying an +// Authorization: Payment header (credential submissions). async fn mount_session(server: &MockServer, network: &str) { let path_str = format!("/session/{network}"); Mock::given(method("POST")) .and(path(path_str.clone())) .and(wiremock::matchers::header_exists("authorization")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "jsonrpc": "2.0", "id": 1, "result": "0xok" - }))) + .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")) @@ -1623,6 +1651,25 @@ async fn mount_session(server: &MockServer, network: &str) { .await; } +// Decodes the `Authorization: Payment ` credential a request +// carried and returns its `payload` object. +fn credential_payload(req: &wiremock::Request) -> serde_json::Value { + let header = req + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .expect("credential POST must carry Authorization"); + let b64 = header + .strip_prefix("Payment ") + .expect("Authorization must be a Payment credential"); + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64) + .expect("credential must be base64url"); + let credential: serde_json::Value = + serde_json::from_slice(&bytes).expect("credential must be JSON"); + credential["payload"].clone() +} + fn mpp_args<'a>(cfg: &'a str, key_path: &'a str, verb: &'a str) -> Vec<&'a str> { vec![ "--config-file", @@ -1661,6 +1708,60 @@ async fn mpp_open_happy_path_and_caches_channel() { dir.path().join("channels.toml").exists(), "open must cache the channel state" ); + + // The credential POST must carry the legacy contract-backed open payload: + // a transaction plus the opening voucher, no descriptor. + let requests = server.received_requests().await.unwrap(); + let payload = requests + .iter() + .find(|r| r.headers.contains_key("authorization")) + .map(credential_payload) + .expect("an open credential must have been POSTed"); + assert_eq!(payload["action"], "open"); + assert_eq!(payload["type"], "transaction"); + assert_eq!(payload["cumulativeAmount"], "500"); + assert!(payload.get("descriptor").is_none(), "got: {payload}"); + for key in ["channelId", "transaction", "signature", "authorizedSigner"] { + assert!( + payload[key].as_str().is_some_and(|s| s.starts_with("0x")), + "payload must carry hex {key}: {payload}" + ); + } +} + +#[tokio::test] +async fn mpp_status_replays_voucher_and_reads_the_receipt() { + let server = MockServer::start().await; + mount_session(&server, "tempo-testnet").await; + mount_control_plane_expect_zero(&server).await; + + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml").to_str().unwrap().to_string(); + let (_guard, key_path) = key_file(); + + // Open first so a channel is cached, then ask for its status. + let mut open_args = mpp_args(&cfg, &key_path, "open"); + open_args.extend_from_slice(&["--deposit", "1000000", "--yes"]); + let out = run_qn(&server.uri(), &open_args).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + // Exit 0 requires the Payment-Receipt header to have decoded: a missing + // or malformed receipt fails the command. (The harness can't capture the + // in-process stderr note; see run_qn.) + let out = run_qn(&server.uri(), &mpp_args(&cfg, &key_path, "status")).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + // The status POST is an idempotent replay of the current high-water + // voucher: cumulativeAmount stays at the opening amount. + let requests = server.received_requests().await.unwrap(); + let voucher = requests + .iter() + .filter(|r| r.headers.contains_key("authorization")) + .map(credential_payload) + .find(|p| p["action"] == "voucher") + .expect("status must POST a voucher credential"); + assert_eq!(voucher["cumulativeAmount"], "500"); + assert!(voucher.get("descriptor").is_none(), "got: {voucher}"); } #[tokio::test] diff --git a/tests/rpc_supported_networks.rs b/tests/rpc_supported_networks.rs index d728ed6..8051c93 100644 --- a/tests/rpc_supported_networks.rs +++ b/tests/rpc_supported_networks.rs @@ -1,12 +1,10 @@ -//! Integration tests for `qn rpc x402 supported-networks` and -//! `qn rpc mpp supported-networks` — the keyless per-gateway discovery -//! catalogs (callable networks + accepted currencies). +//! Integration tests for `qn rpc {x402,mpp} supported-networks` and +//! `supported-payments` — the keyless per-gateway discovery lists. //! //! `--base-url` points the gateway fetches at one wiremock host and bypasses //! the on-disk cache, so the mock server stands in for the gateway. The //! in-process harness can't capture stdout, so rendered output (tables, JSON -//! shape, the best-effort warning) is asserted via a subprocess; the -//! in-process tests cover exit codes. +//! shape) is asserted via a subprocess; the in-process tests cover exit codes. mod common; @@ -17,8 +15,8 @@ use serde_json::json; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -/// Mounts the two x402 discovery endpoints (`/networks` + `/supported`). -async fn mount_x402(server: &MockServer) { +/// Mounts the x402 `/networks` list. +async fn mount_x402_networks(server: &MockServer) { Mock::given(method("GET")) .and(path("/networks")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -26,9 +24,14 @@ async fn mount_x402(server: &MockServer) { }))) .mount(server) .await; +} + +/// Mounts the x402 `/supported` payment catalog. Served with HTTP 402, like +/// the real gateway (the payment-requirements shape arrives on a 402 status). +async fn mount_x402_supported(server: &MockServer) { Mock::given(method("GET")) .and(path("/supported")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + .respond_with(ResponseTemplate::new(402).set_body_json(json!({ "x402Version": 2, "accepts": [ // Two offers for the same (network, asset): a plain token @@ -94,7 +97,7 @@ fn mpp_challenge_header() -> String { } /// Mounts the MPP discovery surface: `/networks` plus the keyless 402 probe -/// against the first listed network. +/// against the first listed network (the payments verb needs both). async fn mount_mpp(server: &MockServer) { Mock::given(method("GET")) .and(path("/networks")) @@ -113,22 +116,50 @@ async fn mount_mpp(server: &MockServer) { .await; } +/// Runs the binary with `--format ` and returns (stdout, stderr, ok). +async fn run_qn_bin(server: &MockServer, fmt: &str, args: &[&str]) -> (String, String, bool) { + let uri = server.uri(); + let mut argv = vec![ + "--base-url", + uri.as_str(), + "--no-input", + "--no-color", + "--format", + fmt, + ]; + argv.extend(args); + let output = assert_cmd::Command::cargo_bin("qn") + .unwrap() + .args(&argv) + .output() + .unwrap(); + ( + String::from_utf8(output.stdout).unwrap(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.success(), + ) +} + #[tokio::test] async fn x402_supported_networks_fetches_and_exits_zero() { let server = MockServer::start().await; - mount_x402(&server).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_networks_alias_works() { +async fn x402_supported_payments_fetches_and_exits_zero() { let server = MockServer::start().await; - mount_x402(&server).await; + mount_x402_supported(&server).await; - let out = run_qn(&server.uri(), &["rpc", "x402", "networks"]).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] @@ -141,9 +172,17 @@ async fn mpp_supported_networks_fetches_and_exits_zero() { } #[tokio::test] -async fn supported_networks_surfaces_callable_fetch_failure() { +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; - // /networks returns 500 — the command should fail with an actionable error. Mock::given(method("GET")) .and(path("/networks")) .respond_with(ResponseTemplate::new(500)) @@ -159,127 +198,53 @@ async fn supported_networks_surfaces_callable_fetch_failure() { ); } -/// A currencies fetch failure is best-effort: exit 0, callable table renders, -/// a warning lands on stderr, and the JSON payments field is null. #[tokio::test] -async fn x402_currencies_failure_is_best_effort() { +async fn supported_payments_surfaces_fetch_failure() { let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/networks")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "networks": ["base-sepolia"] - }))) - .mount(&server) - .await; Mock::given(method("GET")) .and(path("/supported")) .respond_with(ResponseTemplate::new(500)) .mount(&server) .await; - let output = assert_cmd::Command::cargo_bin("qn") - .unwrap() - .args([ - "--base-url", - &server.uri(), - "--no-input", - "--no-color", - "--format", - "json", - "rpc", - "x402", - "supported-networks", - ]) - .output() - .unwrap(); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(output.status.success(), "stderr={stderr}"); + let out = run_qn(&server.uri(), &["rpc", "x402", "supported-payments"]).await; + assert_eq!(out.exit_code, 1, "stderr={}", out.stderr); assert!( - stderr.contains("could not fetch accepted currencies"), - "stderr={stderr}" + out.stderr.contains("discovery") || out.stderr.contains("gateway catalog"), + "stderr={}", + out.stderr ); - let v: serde_json::Value = - serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON"); - assert_eq!(v["callable"], json!(["base-sepolia"])); - assert!(v["payments"].is_null(), "{v}"); } -// Subprocess: assert the two-section x402 render and the JSON shape. +// Subprocess: JSON is a bare array of slugs. #[tokio::test] -async fn x402_supported_networks_renders_two_sections() { +async fn x402_supported_networks_json_is_a_slug_array() { let server = MockServer::start().await; - mount_x402(&server).await; + mount_x402_networks(&server).await; - let output = assert_cmd::Command::cargo_bin("qn") - .unwrap() - .args([ - "--base-url", - &server.uri(), - "--no-input", - "--no-color", - "--format", - "table", - "rpc", - "x402", - "supported-networks", - ]) - .output() - .unwrap(); - let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - output.status.success(), - "stderr={}", - String::from_utf8_lossy(&output.stderr) - ); - - assert!(stdout.contains("Callable networks"), "{stdout}"); - assert!(stdout.contains("Accepted currencies"), "{stdout}"); - assert!(stdout.contains("ethereum-mainnet"), "{stdout}"); - // The payment network maps to its slug, the two offers dedupe to one row, - // and the known address resolves to its symbol. - assert_eq!(stdout.matches("base-sepolia").count(), 2, "{stdout}"); - assert!( - stdout.contains("USDC") && !stdout.contains("GatewayWalletBatched"), - "{stdout}" + 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"]) ); - // The unknown payment network falls back to its raw CAIP-2 id with the - // offer's token name. - assert!(stdout.contains("eip155:999999"), "{stdout}"); - assert!(stdout.contains("Fake Dollar"), "{stdout}"); } +// Subprocess: JSON is a bare array of payment options; offers dedupe, slugs +// resolve, and unknown networks stay raw CAIP-2. #[tokio::test] -async fn x402_supported_networks_json_shape() { +async fn x402_supported_payments_json_is_an_options_array() { let server = MockServer::start().await; - mount_x402(&server).await; + mount_x402_supported(&server).await; - let output = assert_cmd::Command::cargo_bin("qn") - .unwrap() - .args([ - "--base-url", - &server.uri(), - "--no-input", - "--format", - "json", - "rpc", - "x402", - "supported-networks", - ]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr={}", - String::from_utf8_lossy(&output.stderr) - ); - let v: serde_json::Value = - serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON"); + 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["callable"], - json!(["base-sepolia", "ethereum-mainnet", "solana-devnet"]) - ); - assert_eq!( - v["payments"], + v, json!([ { "network": "base-sepolia", @@ -295,36 +260,17 @@ async fn x402_supported_networks_json_shape() { ); } -// Subprocess: assert the MPP challenge-derived currencies render. +// Subprocess: the MPP challenge-derived payment options render with resolved +// slugs and symbols. #[tokio::test] -async fn mpp_supported_networks_renders_challenge_currencies() { +async fn mpp_supported_payments_renders_challenge_options() { let server = MockServer::start().await; mount_mpp(&server).await; - let output = assert_cmd::Command::cargo_bin("qn") - .unwrap() - .args([ - "--base-url", - &server.uri(), - "--no-input", - "--no-color", - "--format", - "table", - "rpc", - "mpp", - "supported-networks", - ]) - .output() - .unwrap(); - let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - output.status.success(), - "stderr={}", - String::from_utf8_lossy(&output.stderr) - ); + let (stdout, stderr, ok) = + run_qn_bin(&server, "table", &["rpc", "mpp", "supported-payments"]).await; + assert!(ok, "stderr={stderr}"); - // Tempo testnet resolves to its slug and the enshrined token to pathUSD; - // the Solana challenge maps to solana-mainnet USDC. assert!(stdout.contains("tempo-testnet"), "{stdout}"); assert!(stdout.contains("pathUSD"), "{stdout}"); assert!(stdout.contains("solana-mainnet"), "{stdout}"); diff --git a/tests/snapshots/table_snapshots__mpp_supported_networks_table_shows_two_sections.snap b/tests/snapshots/table_snapshots__mpp_supported_networks_table_shows_two_sections.snap deleted file mode 100644 index 8d5b54c..0000000 --- a/tests/snapshots/table_snapshots__mpp_supported_networks_table_shows_two_sections.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tests/table_snapshots.rs -expression: "supported_networks_stdout(&server, \"mpp\").await" ---- -Callable networks (make mpp-paid RPC calls to these): -NETWORK -base-sepolia -tempo-testnet - -Accepted currencies (pay for calls with one of these): -NETWORK ASSET ADDRESS -tempo-testnet pathUSD 0x20c0000000000000000000000000000000000000 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_networks_table_shows_two_sections.snap b/tests/snapshots/table_snapshots__x402_supported_networks_table_shows_two_sections.snap deleted file mode 100644 index c8feefa..0000000 --- a/tests/snapshots/table_snapshots__x402_supported_networks_table_shows_two_sections.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tests/table_snapshots.rs -expression: "supported_networks_stdout(&server, \"x402\").await" ---- -Callable networks (make x402-paid RPC calls to these): -NETWORK -base-sepolia -ethereum-mainnet -solana-devnet - -Accepted currencies (pay for calls with one of these): -NETWORK ASSET ADDRESS -base-sepolia USDC 0x036CbD53842c5426634e7929541eC2318f3dCF7e 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 2d1eee8..4d9d54c 100644 --- a/tests/table_snapshots.rs +++ b/tests/table_snapshots.rs @@ -464,9 +464,9 @@ async fn sql_schema_table_renders_nested_table_blocks() { insta::assert_snapshot!(out); } -/// Runs `qn --format table rpc supported-networks` against `server` -/// and returns stdout. Panics (with stderr) on non-zero exit. -async fn supported_networks_stdout(server: &MockServer, scheme: &str) -> String { +/// Runs `qn --format table rpc ` against `server` and returns +/// stdout. Panics (with stderr) on non-zero exit. +async fn discovery_stdout(server: &MockServer, scheme: &str, verb: &str) -> String { let output = assert_cmd::Command::cargo_bin("qn") .unwrap() .env_remove("HOME") @@ -480,7 +480,7 @@ async fn supported_networks_stdout(server: &MockServer, scheme: &str) -> String "table", "rpc", scheme, - "supported-networks", + verb, ]) .output() .unwrap(); @@ -493,7 +493,7 @@ async fn supported_networks_stdout(server: &MockServer, scheme: &str) -> String } #[tokio::test] -async fn x402_supported_networks_table_shows_two_sections() { +async fn x402_supported_networks_table_lists_slugs() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/networks")) @@ -502,9 +502,17 @@ async fn x402_supported_networks_table_shows_two_sections() { }))) .mount(&server) .await; + + insta::assert_snapshot!(discovery_stdout(&server, "x402", "supported-networks").await); +} + +#[tokio::test] +async fn x402_supported_payments_table_lists_options() { + let server = MockServer::start().await; + // Served with HTTP 402, like the real gateway. Mock::given(method("GET")) .and(path("/supported")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + .respond_with(ResponseTemplate::new(402).set_body_json(serde_json::json!({ "x402Version": 2, "accepts": [{ "scheme": "exact", @@ -516,11 +524,11 @@ async fn x402_supported_networks_table_shows_two_sections() { .mount(&server) .await; - insta::assert_snapshot!(supported_networks_stdout(&server, "x402").await); + insta::assert_snapshot!(discovery_stdout(&server, "x402", "supported-payments").await); } #[tokio::test] -async fn mpp_supported_networks_table_shows_two_sections() { +async fn mpp_supported_payments_table_lists_options() { use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; @@ -551,5 +559,5 @@ async fn mpp_supported_networks_table_shows_two_sections() { .mount(&server) .await; - insta::assert_snapshot!(supported_networks_stdout(&server, "mpp").await); + insta::assert_snapshot!(discovery_stdout(&server, "mpp", "supported-payments").await); }