diff --git a/Cargo.lock b/Cargo.lock index 9df7ab3..296f0db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -593,6 +593,12 @@ 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" + [[package]] name = "cc" version = "1.2.56" @@ -743,6 +749,35 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -889,6 +924,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derivative" version = "2.2.0" @@ -967,6 +1011,26 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "eager" version = "0.1.0" @@ -1220,6 +1284,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1371,6 +1444,22 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1395,6 +1484,87 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1407,6 +1577,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1637,6 +1828,18 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1817,6 +2020,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "num-derive" version = "0.4.2" @@ -1976,6 +2185,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "percentage" version = "0.1.0" @@ -2051,6 +2266,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2104,6 +2334,8 @@ name = "quasar-cli" version = "0.0.0" dependencies = [ "anyhow", + "base64 0.22.1", + "bincode", "bs58", "clap", "clap_complete", @@ -2117,8 +2349,19 @@ dependencies = [ "rand 0.8.5", "serde", "serde_json", + "sha2 0.10.9", + "solana-address 2.3.0", + "solana-hash 3.1.0", + "solana-instruction", + "solana-message", + "solana-signature", + "solana-signer", + "solana-transaction", + "syn 2.0.117", + "tempfile", "thiserror 2.0.18", "toml 0.8.23", + "ureq", ] [[package]] @@ -2445,6 +2688,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -2473,6 +2730,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2544,6 +2836,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -3147,12 +3448,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0448b1fd891c5f46491e5dc7d9986385ba3c852c340db2911dd29faa01d2b08d" dependencies = [ + "bincode", "lazy_static", + "serde", + "serde_derive", "solana-address 2.3.0", "solana-hash 4.2.0", "solana-instruction", "solana-sanitize", "solana-sdk-ids", + "solana-short-vec", "solana-transaction-error", ] @@ -3439,6 +3744,15 @@ dependencies = [ "solana-hash 4.2.0", ] +[[package]] +name = "solana-short-vec" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3bd991c2cc415291c86bb0b6b4d53e93d13bb40344e4c5a2884e0e4f5fa93f" +dependencies = [ + "serde_core", +] + [[package]] name = "solana-signature" version = "3.3.0" @@ -3446,7 +3760,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "132a93134f1262aa832f1849b83bec6c9945669b866da18661a427943b9e801e" dependencies = [ "five8", + "serde", + "serde-big-array", + "serde_derive", "solana-sanitize", + "wincode", +] + +[[package]] +name = "solana-signer" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bfea97951fee8bae0d6038f39a5efcb6230ecdfe33425ac75196d1a1e3e3235" +dependencies = [ + "solana-pubkey 3.0.0", + "solana-signature", + "solana-transaction-error", ] [[package]] @@ -3660,6 +3989,9 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96697cff5075a028265324255efed226099f6d761ca67342b230d09f72cc48d2" dependencies = [ + "bincode", + "serde", + "serde_derive", "solana-address 2.3.0", "solana-hash 4.2.0", "solana-instruction", @@ -3667,7 +3999,9 @@ dependencies = [ "solana-message", "solana-sanitize", "solana-sdk-ids", + "solana-short-vec", "solana-signature", + "solana-signer", "solana-transaction-error", ] @@ -3695,6 +4029,8 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8396904805b0b385b9de115a652fe80fd01e5b98ce0513f4fcd8184ada9bb792" dependencies = [ + "serde", + "serde_derive", "solana-instruction-error", "solana-sanitize", ] @@ -3785,6 +4121,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "target-triple" version = "1.0.0" @@ -3853,6 +4200,47 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -4030,6 +4418,12 @@ dependencies = [ "void", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "upstream-vault" version = "0.1.0" @@ -4041,6 +4435,62 @@ dependencies = [ "solana-instruction", ] +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -4178,6 +4628,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4293,6 +4752,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4472,6 +4940,35 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.42" @@ -4492,6 +4989,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" @@ -4512,6 +5030,39 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index ae9f501..1f88e8c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -28,3 +28,18 @@ dirs = "6" indicatif = "0.18.4" clap_complete = "4.6.0" ctrlc = "3.4" +syn = { version = "2", features = ["full", "parsing"] } +solana-transaction = { version = "3", features = ["bincode"] } +solana-signer = "3" +solana-hash = "3" +solana-signature = "3" +solana-message = { version = "3", features = ["bincode"] } +solana-instruction = "3" +solana-address = { version = "2", features = ["curve25519"] } +sha2 = "0.10" +base64 = "0.22" +ureq = { version = "3", features = ["json"] } +bincode = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/cli/src/bpf_loader.rs b/cli/src/bpf_loader.rs new file mode 100644 index 0000000..50254e8 --- /dev/null +++ b/cli/src/bpf_loader.rs @@ -0,0 +1,722 @@ +use crate::rpc::{ + self, confirm_transaction, get_latest_blockhash, get_minimum_balance_for_rent_exemption, + send_transaction, Keypair, +}; +use solana_address::Address; +use solana_instruction::{AccountMeta, Instruction}; + +// --------------------------------------------------------------------------- +// Well-known program & sysvar addresses +// --------------------------------------------------------------------------- + +/// BPF Loader Upgradeable — BPFLoaderUpgradeab1e11111111111111111111111. +pub const BPF_LOADER_UPGRADEABLE_ID: Address = Address::new_from_array([ + 0x02, 0xa8, 0xf6, 0x91, 0x4e, 0x88, 0xa1, 0xb0, 0xe2, 0x10, 0x15, 0x3e, 0xf7, 0x63, 0xae, + 0x2b, 0x00, 0xc2, 0xb9, 0x3d, 0x16, 0xc1, 0x24, 0xd2, 0xc0, 0x53, 0x7a, 0x10, 0x04, 0x80, + 0x00, 0x00, +]); + +/// System program ID — 11111111111111111111111111111111. +pub const SYSTEM_PROGRAM_ID: Address = Address::new_from_array([0; 32]); + +/// Sysvar Rent — SysvarRent111111111111111111111111111111111. +pub const SYSVAR_RENT_ID: Address = Address::new_from_array([ + 6, 167, 213, 23, 25, 44, 92, 81, 33, 140, 201, 76, 61, 74, 241, 127, 88, 218, 238, 8, 155, + 161, 253, 68, 227, 219, 217, 138, 0, 0, 0, 0, +]); + +/// Sysvar Clock — SysvarC1ock11111111111111111111111111111111. +pub const SYSVAR_CLOCK_ID: Address = Address::new_from_array([ + 6, 167, 213, 23, 24, 199, 116, 201, 40, 86, 99, 152, 105, 29, 94, 182, 139, 94, 184, 163, + 155, 75, 109, 92, 115, 85, 91, 33, 0, 0, 0, 0, +]); + +/// Compute Budget program — ComputeBudget111111111111111111111111111111. +pub const COMPUTE_BUDGET_PROGRAM_ID: Address = Address::new_from_array([ + 3, 6, 70, 111, 229, 33, 23, 50, 255, 236, 173, 186, 114, 195, 155, 231, 188, 140, 229, 187, + 197, 247, 18, 107, 44, 67, 155, 58, 64, 0, 0, 0, +]); + +// --------------------------------------------------------------------------- +// BPF Loader constants +// --------------------------------------------------------------------------- + +/// Maximum payload per `Write` instruction (keeps transactions under the +/// 1232-byte packet limit with room for signatures and accounts). +pub const CHUNK_SIZE: usize = 950; + +/// Size of the `Buffer` account header: 4-byte enum tag + 1-byte Option +/// discriminant + 32-byte authority pubkey. +pub const BUFFER_HEADER_SIZE: usize = 37; + +/// Maximum number of retry attempts for buffer chunk writes. +const WRITE_RETRIES: u32 = 3; + +// --------------------------------------------------------------------------- +// PDA helpers +// --------------------------------------------------------------------------- + +/// Derive the program-data account address for a given program. +pub fn programdata_pda(program_id: &Address) -> (Address, u8) { + Address::find_program_address(&[program_id.as_ref()], &BPF_LOADER_UPGRADEABLE_ID) +} + +// --------------------------------------------------------------------------- +// Instruction builders +// --------------------------------------------------------------------------- + +/// Build an `InitializeBuffer` instruction for the BPF Loader Upgradeable. +pub fn initialize_buffer_ix(buffer: &Address, authority: &Address) -> Instruction { + let data = 0u32.to_le_bytes().to_vec(); + Instruction { + program_id: BPF_LOADER_UPGRADEABLE_ID, + accounts: vec![ + AccountMeta::new(*buffer, false), + AccountMeta::new_readonly(*authority, false), + ], + data, + } +} + +/// Build a `Write` instruction for the BPF Loader Upgradeable. +pub fn write_ix(buffer: &Address, authority: &Address, offset: u32, bytes: &[u8]) -> Instruction { + let mut data = Vec::with_capacity(4 + 4 + 4 + bytes.len()); + data.extend_from_slice(&1u32.to_le_bytes()); + data.extend_from_slice(&offset.to_le_bytes()); + data.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + data.extend_from_slice(bytes); + Instruction { + program_id: BPF_LOADER_UPGRADEABLE_ID, + accounts: vec![ + AccountMeta::new(*buffer, false), + AccountMeta::new_readonly(*authority, true), + ], + data, + } +} + +/// Build a `DeployWithMaxDataLen` instruction for the BPF Loader Upgradeable. +pub fn deploy_with_max_data_len_ix( + payer: &Address, + programdata: &Address, + program: &Address, + buffer: &Address, + authority: &Address, + max_data_len: u64, +) -> Instruction { + let mut data = Vec::with_capacity(12); + data.extend_from_slice(&2u32.to_le_bytes()); + data.extend_from_slice(&max_data_len.to_le_bytes()); + Instruction { + program_id: BPF_LOADER_UPGRADEABLE_ID, + accounts: vec![ + AccountMeta::new(*payer, true), + AccountMeta::new(*programdata, false), + AccountMeta::new(*program, false), + AccountMeta::new(*buffer, false), + AccountMeta::new_readonly(SYSVAR_RENT_ID, false), + AccountMeta::new_readonly(SYSVAR_CLOCK_ID, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + AccountMeta::new_readonly(*authority, true), + ], + data, + } +} + +/// Build an `Upgrade` instruction for the BPF Loader Upgradeable. +pub fn upgrade_ix( + programdata: &Address, + program: &Address, + buffer: &Address, + spill: &Address, + authority: &Address, +) -> Instruction { + let data = 3u32.to_le_bytes().to_vec(); + Instruction { + program_id: BPF_LOADER_UPGRADEABLE_ID, + accounts: vec![ + AccountMeta::new(*programdata, false), + AccountMeta::new(*program, false), + AccountMeta::new(*buffer, false), + AccountMeta::new(*spill, false), + AccountMeta::new_readonly(SYSVAR_RENT_ID, false), + AccountMeta::new_readonly(SYSVAR_CLOCK_ID, false), + AccountMeta::new_readonly(*authority, true), + ], + data, + } +} + +/// Build a `SetAuthority` instruction for the BPF Loader Upgradeable. +/// +/// When `new_authority` is `None` the program is made immutable. +pub fn set_authority_ix( + account: &Address, + current_authority: &Address, + new_authority: Option<&Address>, +) -> Instruction { + let data = 4u32.to_le_bytes().to_vec(); + let mut accounts = vec![ + AccountMeta::new(*account, false), + AccountMeta::new_readonly(*current_authority, true), + ]; + if let Some(new_auth) = new_authority { + accounts.push(AccountMeta::new_readonly(*new_auth, false)); + } + Instruction { + program_id: BPF_LOADER_UPGRADEABLE_ID, + accounts, + data, + } +} + +/// Build a `SetComputeUnitPrice` instruction for the Compute Budget program. +pub fn set_compute_unit_price_ix(micro_lamports: u64) -> Instruction { + let mut data = Vec::with_capacity(9); + data.push(3u8); + data.extend_from_slice(µ_lamports.to_le_bytes()); + Instruction { + program_id: COMPUTE_BUDGET_PROGRAM_ID, + accounts: vec![], + data, + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Return the number of `CHUNK_SIZE` chunks needed to upload `file_size` bytes. +pub fn num_chunks(file_size: usize) -> usize { + if file_size == 0 { + 0 + } else { + file_size.div_ceil(CHUNK_SIZE) + } +} + +// --------------------------------------------------------------------------- +// Orchestrators +// --------------------------------------------------------------------------- + +/// Read the upgrade authority from a programdata account's raw bytes. +/// +/// Returns `None` if the program is immutable (authority option tag is 0). +pub fn parse_programdata_authority(data: &[u8]) -> Result, crate::error::CliError> { + if data.len() < 45 { + return Err(anyhow::anyhow!( + "programdata account too short ({} bytes, need at least 45)", + data.len() + ) + .into()); + } + match data[12] { + 0 => Ok(None), + 1 => { + let pubkey: [u8; 32] = data[13..45] + .try_into() + .map_err(|_| anyhow::anyhow!("invalid authority pubkey slice"))?; + Ok(Some(Address::from(pubkey))) + } + other => Err(anyhow::anyhow!("invalid authority option tag: {other}").into()), + } +} + +/// Build a `SystemProgram::CreateAccount` instruction. +pub fn create_account_ix( + payer: &Address, + new_account: &Address, + lamports: u64, + space: u64, + owner: &Address, +) -> Instruction { + let mut data = Vec::with_capacity(52); + data.extend_from_slice(&0u32.to_le_bytes()); // discriminant + data.extend_from_slice(&lamports.to_le_bytes()); + data.extend_from_slice(&space.to_le_bytes()); + data.extend_from_slice(owner.as_ref()); + Instruction { + program_id: SYSTEM_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(*payer, true), + AccountMeta::new(*new_account, true), + ], + data, + } +} + +/// Verify that the on-chain upgrade authority matches `expected_authority`. +/// +/// Errors if the program is immutable or if the authority doesn't match. +pub fn verify_upgrade_authority( + rpc_url: &str, + program_id: &Address, + expected_authority: &Address, +) -> Result<(), crate::error::CliError> { + let (programdata, _) = programdata_pda(program_id); + let data = rpc::get_account_data(rpc_url, &programdata)? + .ok_or_else(|| anyhow::anyhow!("programdata account not found"))?; + let authority = parse_programdata_authority(&data)?; + match authority { + None => Err(anyhow::anyhow!("program is immutable (no upgrade authority)").into()), + Some(on_chain) if on_chain != *expected_authority => Err(anyhow::anyhow!( + "upgrade authority mismatch: on-chain is {}, your keypair is {}", + bs58::encode(on_chain).into_string(), + bs58::encode(expected_authority).into_string() + ) + .into()), + Some(_) => Ok(()), + } +} + +/// Upload a .so binary to a new buffer account. +/// +/// Returns the buffer account address on success. +pub fn write_buffer( + so_path: &std::path::Path, + payer: &Keypair, + rpc_url: &str, + priority_fee: u64, +) -> Result { + let program_data = std::fs::read(so_path) + .map_err(|e| anyhow::anyhow!("failed to read {}: {e}", so_path.display()))?; + let buffer_keypair = Keypair::generate(); + let buffer_addr = buffer_keypair.address(); + + let total_size = program_data.len() + BUFFER_HEADER_SIZE; + let lamports = get_minimum_balance_for_rent_exemption(rpc_url, total_size)?; + + // 1. Create buffer account + initialize in one transaction + let mut ixs = Vec::new(); + if priority_fee > 0 { + ixs.push(set_compute_unit_price_ix(priority_fee)); + } + ixs.push(create_account_ix( + &payer.address(), + &buffer_addr, + lamports, + total_size as u64, + &BPF_LOADER_UPGRADEABLE_ID, + )); + ixs.push(initialize_buffer_ix(&buffer_addr, &payer.address())); + + let blockhash = get_latest_blockhash(rpc_url)?; + let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(&payer.address()), + &[payer, &buffer_keypair], + blockhash, + ); + let tx_bytes = + bincode::serialize(&tx).map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + let sig = send_transaction(rpc_url, &tx_bytes)?; + let confirmed = confirm_transaction(rpc_url, &sig, 30)?; + if !confirmed { + return Err(anyhow::anyhow!( + "buffer creation timed out (buffer: {})", + bs58::encode(buffer_addr).into_string() + ) + .into()); + } + + // 2. Write chunks sequentially with a progress bar + let chunks = num_chunks(program_data.len()); + let bar = indicatif::ProgressBar::new(program_data.len() as u64); + bar.set_style( + indicatif::ProgressStyle::with_template(" {bar:40.cyan/dim} {bytes}/{total_bytes} ({eta})") + .unwrap(), + ); + + for i in 0..chunks { + let offset = i * CHUNK_SIZE; + let end = (offset + CHUNK_SIZE).min(program_data.len()); + let chunk = &program_data[offset..end]; + + let mut write_ixs = Vec::new(); + if priority_fee > 0 { + write_ixs.push(set_compute_unit_price_ix(priority_fee)); + } + write_ixs.push(write_ix( + &buffer_addr, + &payer.address(), + offset as u32, + chunk, + )); + + let mut last_err = None; + for attempt in 0..WRITE_RETRIES { + let bh = get_latest_blockhash(rpc_url)?; + let write_tx = solana_transaction::Transaction::new_signed_with_payer( + &write_ixs, + Some(&payer.address()), + &[payer], + bh, + ); + let write_tx_bytes = bincode::serialize(&write_tx) + .map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + match send_transaction(rpc_url, &write_tx_bytes) { + Ok(write_sig) => match confirm_transaction(rpc_url, &write_sig, 30) { + Ok(true) => { + last_err = None; + break; + } + Ok(false) => { + last_err = Some(anyhow::anyhow!( + "write chunk {i} timed out (buffer: {}, attempt {}/{})", + bs58::encode(buffer_addr).into_string(), + attempt + 1, + WRITE_RETRIES, + )); + } + Err(e) => { + last_err = Some(anyhow::anyhow!("{e}")); + } + }, + Err(e) => { + last_err = Some(anyhow::anyhow!("{e}")); + } + } + if attempt + 1 < WRITE_RETRIES { + std::thread::sleep(std::time::Duration::from_secs(1)); + } + } + if let Some(e) = last_err { + return Err(e.into()); + } + bar.set_position(end as u64); + } + + bar.finish_and_clear(); + Ok(buffer_addr) +} + +/// Deploy a new program. +/// +/// Uploads the .so to a buffer, creates the program account, and deploys. +/// Returns the program address. +pub fn deploy_program( + so_path: &std::path::Path, + program_keypair: &Keypair, + payer: &Keypair, + rpc_url: &str, + priority_fee: u64, +) -> Result { + let so_len = std::fs::metadata(so_path) + .map_err(|e| anyhow::anyhow!("failed to read {}: {e}", so_path.display()))? + .len() as usize; + + let buffer_addr = write_buffer(so_path, payer, rpc_url, priority_fee)?; + + let program_addr = program_keypair.address(); + let (programdata, _) = programdata_pda(&program_addr); + let max_data_len = (so_len * 2) as u64; + + // Program account is 36 bytes + let program_lamports = get_minimum_balance_for_rent_exemption(rpc_url, 36)?; + + let mut ixs = Vec::new(); + if priority_fee > 0 { + ixs.push(set_compute_unit_price_ix(priority_fee)); + } + ixs.push(create_account_ix( + &payer.address(), + &program_addr, + program_lamports, + 36, + &BPF_LOADER_UPGRADEABLE_ID, + )); + ixs.push(deploy_with_max_data_len_ix( + &payer.address(), + &programdata, + &program_addr, + &buffer_addr, + &payer.address(), + max_data_len, + )); + + let blockhash = get_latest_blockhash(rpc_url)?; + let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(&payer.address()), + &[payer, program_keypair], + blockhash, + ); + let tx_bytes = + bincode::serialize(&tx).map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + let sig = send_transaction(rpc_url, &tx_bytes)?; + let confirmed = confirm_transaction(rpc_url, &sig, 30)?; + if !confirmed { + return Err(anyhow::anyhow!("deploy transaction timed out").into()); + } + + Ok(program_addr) +} + +/// Upgrade an existing program with a new .so binary. +pub fn upgrade_program( + so_path: &std::path::Path, + program_id: &Address, + authority: &Keypair, + rpc_url: &str, + priority_fee: u64, +) -> Result<(), crate::error::CliError> { + let buffer_addr = write_buffer(so_path, authority, rpc_url, priority_fee)?; + + let (programdata, _) = programdata_pda(program_id); + let authority_addr = authority.address(); + + let mut ixs = Vec::new(); + if priority_fee > 0 { + ixs.push(set_compute_unit_price_ix(priority_fee)); + } + ixs.push(upgrade_ix( + &programdata, + program_id, + &buffer_addr, + &authority_addr, + &authority_addr, + )); + + let blockhash = get_latest_blockhash(rpc_url)?; + let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(&authority_addr), + &[authority], + blockhash, + ); + let tx_bytes = + bincode::serialize(&tx).map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + let sig = send_transaction(rpc_url, &tx_bytes)?; + let confirmed = confirm_transaction(rpc_url, &sig, 30)?; + if !confirmed { + return Err(anyhow::anyhow!("upgrade transaction timed out").into()); + } + + Ok(()) +} + +/// Transfer or revoke upgrade authority on an account. +/// +/// Pass `None` for `new_authority` to make the program immutable. +pub fn set_authority( + account: &Address, + current_authority: &Keypair, + new_authority: Option<&Address>, + rpc_url: &str, + priority_fee: u64, +) -> Result<(), crate::error::CliError> { + let mut ixs = Vec::new(); + if priority_fee > 0 { + ixs.push(set_compute_unit_price_ix(priority_fee)); + } + ixs.push(set_authority_ix( + account, + ¤t_authority.address(), + new_authority, + )); + + let blockhash = get_latest_blockhash(rpc_url)?; + let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(¤t_authority.address()), + &[current_authority], + blockhash, + ); + let tx_bytes = + bincode::serialize(&tx).map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + let sig = send_transaction(rpc_url, &tx_bytes)?; + let confirmed = confirm_transaction(rpc_url, &sig, 30)?; + if !confirmed { + return Err(anyhow::anyhow!("set authority transaction timed out").into()); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_bpf_loader_id() { + let expected = bs58::decode("BPFLoaderUpgradeab1e11111111111111111111111") + .into_vec() + .unwrap(); + assert_eq!(BPF_LOADER_UPGRADEABLE_ID.as_ref(), &expected[..]); + } + + #[test] + fn verify_sysvar_rent_id() { + let expected = bs58::decode("SysvarRent111111111111111111111111111111111") + .into_vec() + .unwrap(); + assert_eq!(SYSVAR_RENT_ID.as_ref(), &expected[..]); + } + + #[test] + fn verify_sysvar_clock_id() { + let expected = bs58::decode("SysvarC1ock11111111111111111111111111111111") + .into_vec() + .unwrap(); + assert_eq!(SYSVAR_CLOCK_ID.as_ref(), &expected[..]); + } + + #[test] + fn verify_compute_budget_program_id() { + let expected = bs58::decode("ComputeBudget111111111111111111111111111111") + .into_vec() + .unwrap(); + assert_eq!(COMPUTE_BUDGET_PROGRAM_ID.as_ref(), &expected[..]); + } + + #[test] + fn buffer_header_size() { + assert_eq!(BUFFER_HEADER_SIZE, 4 + 1 + 32); + } + + #[test] + fn initialize_buffer_ix_serialization() { + let buffer = Address::from([1u8; 32]); + let authority = Address::from([2u8; 32]); + let ix = initialize_buffer_ix(&buffer, &authority); + assert_eq!(ix.program_id, BPF_LOADER_UPGRADEABLE_ID); + assert_eq!(&ix.data[..4], &[0, 0, 0, 0]); + assert_eq!(ix.data.len(), 4); + assert_eq!(ix.accounts.len(), 2); + assert!(ix.accounts[0].is_writable); + assert!(!ix.accounts[1].is_writable); + } + + #[test] + fn write_ix_serialization() { + let buffer = Address::from([1u8; 32]); + let authority = Address::from([2u8; 32]); + let chunk = vec![0xAA; 100]; + let ix = write_ix(&buffer, &authority, 500, &chunk); + assert_eq!(ix.program_id, BPF_LOADER_UPGRADEABLE_ID); + assert_eq!(&ix.data[..4], &[1, 0, 0, 0]); + assert_eq!(&ix.data[4..8], &500u32.to_le_bytes()); + assert_eq!(&ix.data[8..12], &100u32.to_le_bytes()); + assert_eq!(&ix.data[12..], &chunk[..]); + assert_eq!(ix.accounts.len(), 2); + assert!(ix.accounts[0].is_writable); + assert!(ix.accounts[1].is_signer); + } + + #[test] + fn deploy_with_max_data_len_ix_serialization() { + let payer = Address::from([1u8; 32]); + let programdata = Address::from([2u8; 32]); + let program = Address::from([3u8; 32]); + let buffer = Address::from([4u8; 32]); + let authority = Address::from([5u8; 32]); + let ix = + deploy_with_max_data_len_ix(&payer, &programdata, &program, &buffer, &authority, 10000); + assert_eq!(ix.program_id, BPF_LOADER_UPGRADEABLE_ID); + assert_eq!(&ix.data[..4], &[2, 0, 0, 0]); + assert_eq!(&ix.data[4..12], &10000u64.to_le_bytes()); + assert_eq!(ix.data.len(), 12); + assert_eq!(ix.accounts.len(), 8); + // Verify account ordering + assert_eq!(ix.accounts[0].pubkey, payer); + assert_eq!(ix.accounts[1].pubkey, programdata); + assert_eq!(ix.accounts[2].pubkey, program); + assert_eq!(ix.accounts[3].pubkey, buffer); + assert_eq!(ix.accounts[4].pubkey, SYSVAR_RENT_ID); + assert_eq!(ix.accounts[5].pubkey, SYSVAR_CLOCK_ID); + assert_eq!(ix.accounts[6].pubkey, SYSTEM_PROGRAM_ID); + assert_eq!(ix.accounts[7].pubkey, authority); + assert!(ix.accounts[7].is_signer); + } + + #[test] + fn upgrade_ix_serialization() { + let programdata = Address::from([1u8; 32]); + let program = Address::from([2u8; 32]); + let buffer = Address::from([3u8; 32]); + let spill = Address::from([4u8; 32]); + let authority = Address::from([5u8; 32]); + let ix = upgrade_ix(&programdata, &program, &buffer, &spill, &authority); + assert_eq!(ix.program_id, BPF_LOADER_UPGRADEABLE_ID); + assert_eq!(&ix.data[..4], &[3, 0, 0, 0]); + assert_eq!(ix.data.len(), 4); + assert_eq!(ix.accounts.len(), 7); + assert!(ix.accounts[6].is_signer); + } + + #[test] + fn set_authority_ix_serialization() { + let account = Address::from([1u8; 32]); + let current = Address::from([2u8; 32]); + let new_auth = Address::from([3u8; 32]); + let ix = set_authority_ix(&account, ¤t, Some(&new_auth)); + assert_eq!(ix.program_id, BPF_LOADER_UPGRADEABLE_ID); + assert_eq!(&ix.data[..4], &[4, 0, 0, 0]); + assert_eq!(ix.data.len(), 4); + assert_eq!(ix.accounts.len(), 3); + + let ix2 = set_authority_ix(&account, ¤t, None); + assert_eq!(ix2.accounts.len(), 2); + } + + #[test] + fn set_compute_unit_price_ix_serialization() { + let ix = set_compute_unit_price_ix(1000); + assert_eq!(ix.program_id, COMPUTE_BUDGET_PROGRAM_ID); + assert_eq!(ix.data[0], 3); + assert_eq!(&ix.data[1..9], &1000u64.to_le_bytes()); + assert_eq!(ix.data.len(), 9); + assert!(ix.accounts.is_empty()); + } + + #[test] + fn chunk_count_calculation() { + assert_eq!(num_chunks(0), 0); + assert_eq!(num_chunks(1), 1); + assert_eq!(num_chunks(CHUNK_SIZE), 1); + assert_eq!(num_chunks(CHUNK_SIZE + 1), 2); + assert_eq!(num_chunks(CHUNK_SIZE * 3), 3); + assert_eq!(num_chunks(CHUNK_SIZE * 3 + 1), 4); + } + + #[test] + fn parse_programdata_authority_some() { + let mut data = vec![0u8; 45]; + data[0..4].copy_from_slice(&3u32.to_le_bytes()); + data[4..12].copy_from_slice(&100u64.to_le_bytes()); + data[12] = 1; + data[13..45].copy_from_slice(&[0xAA; 32]); + let authority = parse_programdata_authority(&data).unwrap(); + assert_eq!(authority, Some(Address::from([0xAA; 32]))); + } + + #[test] + fn parse_programdata_authority_none() { + let mut data = vec![0u8; 45]; + data[0..4].copy_from_slice(&3u32.to_le_bytes()); + data[4..12].copy_from_slice(&100u64.to_le_bytes()); + data[12] = 0; + let authority = parse_programdata_authority(&data).unwrap(); + assert!(authority.is_none()); + } + + #[test] + fn create_account_ix_serialization() { + let payer = Address::from([1u8; 32]); + let new_account = Address::from([2u8; 32]); + let owner = Address::from([3u8; 32]); + let ix = create_account_ix(&payer, &new_account, 1_000_000, 100, &owner); + assert_eq!(ix.program_id, SYSTEM_PROGRAM_ID); + assert_eq!(&ix.data[..4], &[0, 0, 0, 0]); + assert_eq!(&ix.data[4..12], &1_000_000u64.to_le_bytes()); + assert_eq!(&ix.data[12..20], &100u64.to_le_bytes()); + assert_eq!(&ix.data[20..52], owner.as_ref()); + assert_eq!(ix.data.len(), 52); + assert!(ix.accounts[0].is_signer); + assert!(ix.accounts[1].is_signer); + } +} diff --git a/cli/src/build.rs b/cli/src/build.rs index 600a2cd..e7ef403 100644 --- a/cli/src/build.rs +++ b/cli/src/build.rs @@ -20,6 +20,9 @@ fn run_once(debug: bool, features: Option<&str>) -> CliResult { let config = QuasarConfig::load()?; let start = Instant::now(); + // Auto-switch toolchain based on quasar-lang version + toolchain::ensure_toolchain(Path::new(".")); + crate::idl::generate(Path::new("."), config.has_typescript_tests())?; let sp = style::spinner("Building..."); diff --git a/cli/src/clean.rs b/cli/src/clean.rs index 3f4f6c9..c11e9a3 100644 --- a/cli/src/clean.rs +++ b/cli/src/clean.rs @@ -23,7 +23,12 @@ pub fn run(all: bool) -> CliResult { } for dir in &removed { - fs::remove_dir_all(Path::new(dir))?; + if *dir == "target/deploy" { + // Preserve keypair files — losing a keypair means losing your program address + clean_deploy_dir()?; + } else { + fs::remove_dir_all(Path::new(dir))?; + } } if all { @@ -44,3 +49,24 @@ pub fn run(all: bool) -> CliResult { println!(" {}", style::success("clean")); Ok(()) } + +/// Remove everything in target/deploy/ except keypair files. +fn clean_deploy_dir() -> Result<(), std::io::Error> { + let deploy = Path::new("target/deploy"); + for entry in fs::read_dir(deploy)?.flatten() { + let path = entry.path(); + let is_keypair = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.ends_with("-keypair.json")); + + if !is_keypair { + if path.is_dir() { + fs::remove_dir_all(&path)?; + } else { + fs::remove_file(&path)?; + } + } + } + Ok(()) +} diff --git a/cli/src/deploy.rs b/cli/src/deploy.rs index 5471f01..ba89a8e 100644 --- a/cli/src/deploy.rs +++ b/cli/src/deploy.rs @@ -1,45 +1,16 @@ use { crate::{config::QuasarConfig, error::CliResult, style, utils}, - std::{ - path::PathBuf, - process::{Command, Stdio}, - }, + std::path::PathBuf, }; -pub fn run( - program_keypair: Option, - upgrade_authority: Option, - keypair: Option, - url: Option, - skip_build: bool, -) -> CliResult { - let config = QuasarConfig::load()?; - let name = &config.project.name; - - // Build unless skipped - if !skip_build { - crate::build::run(false, false, None)?; - } - - // Find the .so binary - let so_path = utils::find_so(&config, false).unwrap_or_else(|| { - eprintln!( - "\n {}", - style::fail(&format!("no compiled binary found for \"{name}\"")) - ); - eprintln!(); - eprintln!(" Run {} first.", style::bold("quasar build")); - eprintln!(); - std::process::exit(1); - }); - - // Find the program keypair - let keypair_path = program_keypair.unwrap_or_else(|| { +/// Resolve the program keypair path, falling back to target/deploy/-keypair.json. +fn resolve_program_keypair(config: &QuasarConfig, program_keypair: Option) -> PathBuf { + program_keypair.unwrap_or_else(|| { + let name = &config.project.name; let default = PathBuf::from("target") .join("deploy") .join(format!("{}-keypair.json", name)); if !default.exists() { - // Try module name (underscores) let module = config.module_name(); let alt = PathBuf::from("target") .join("deploy") @@ -49,105 +20,246 @@ pub fn run( } } default - }); + }) +} - if !keypair_path.exists() { - eprintln!( - "\n {}", - style::fail(&format!( - "program keypair not found: {}", - keypair_path.display() - )) - ); - eprintln!(); - eprintln!( - " Run {} to generate one, or pass {} explicitly.", - style::bold("quasar build"), - style::bold("--program-keypair") - ); - eprintln!(); - std::process::exit(1); +/// Parse and validate a base58 multisig address. +fn parse_multisig_address(addr: &str) -> Result { + let bytes: [u8; 32] = bs58::decode(addr) + .into_vec() + .map_err(|e| anyhow::anyhow!("invalid multisig address: {e}"))? + .try_into() + .map_err(|_| anyhow::anyhow!("multisig address must be 32 bytes"))?; + Ok(solana_address::Address::from(bytes)) +} + +/// Build unless skipped, then locate the compiled .so binary. +fn build_and_find_so( + config: &QuasarConfig, + name: &str, + skip_build: bool, +) -> Result { + if !skip_build { + crate::build::run(false, false, None)?; + } + utils::find_so(config, false).ok_or_else(|| { + anyhow::anyhow!( + "no compiled binary found for \"{name}\". Run `quasar build` first." + ) + .into() + }) +} + +pub struct DeployOpts { + pub program_keypair: Option, + pub upgrade_authority: Option, + pub keypair: Option, + pub url: Option, + pub skip_build: bool, + pub multisig: Option, + pub status: bool, + pub upgrade: bool, + pub priority_fee: Option, +} + +pub fn run(opts: DeployOpts) -> CliResult { + let DeployOpts { + program_keypair, + upgrade_authority, + keypair, + url, + skip_build, + multisig, + status, + upgrade, + priority_fee, + } = opts; + let config = QuasarConfig::load()?; + let name = &config.project.name; + + // Resolve cluster URL once + let rpc_url = crate::rpc::solana_rpc_url(url.as_deref()); + + // Resolve priority fee: use override or auto-calculate + let fee = match priority_fee { + Some(f) => f, + None => { + let auto = crate::rpc::get_recent_prioritization_fees(&rpc_url).unwrap_or(0); + if auto > 0 { + println!( + " {} Auto priority fee: {} micro-lamports", + style::dim("i"), + auto + ); + } + auto + } + }; + + // --upgrade --multisig: Squads proposal flow + if upgrade { + if let Some(multisig_addr) = &multisig { + let multisig_key = parse_multisig_address(multisig_addr)?; + let payer_path = crate::rpc::solana_keypair_path(keypair.as_deref()); + + if status { + return crate::multisig::show_proposal_status( + &multisig_key, + &payer_path, + &rpc_url, + fee, + ); + } + + let so_path = build_and_find_so(&config, name, skip_build)?; + let prog_keypair_path = resolve_program_keypair(&config, program_keypair); + let program_id = + crate::rpc::read_program_id_from_keypair(&prog_keypair_path)?; + + return crate::multisig::propose_upgrade( + &so_path, + &program_id, + &multisig_key, + &payer_path, + &rpc_url, + 0, + fee, + ); + } } - let sp = style::spinner("Deploying..."); - - let mut cmd = Command::new("solana"); - cmd.args([ - "program", - "deploy", - so_path.to_str().unwrap_or_default(), - "--program-id", - keypair_path.to_str().unwrap_or_default(), - ]); - - if let Some(authority) = &upgrade_authority { - cmd.args([ - "--upgrade-authority", - authority.to_str().unwrap_or_default(), - ]); + // Everything below needs a build and a .so + let so_path = build_and_find_so(&config, name, skip_build)?; + let keypair_path = resolve_program_keypair(&config, program_keypair); + + if !keypair_path.exists() { + return Err(anyhow::anyhow!( + "program keypair not found: {}. Run `quasar keys new` to generate one, or pass `--program-keypair` explicitly.", + keypair_path.display() + ) + .into()); } - if let Some(payer) = &keypair { - cmd.args(["--keypair", payer.to_str().unwrap_or_default()]); + // Read program ID from the keypair for on-chain check + let program_id = crate::rpc::read_program_id_from_keypair(&keypair_path)?; + let exists = crate::rpc::program_exists_on_chain(&rpc_url, &program_id)?; + + // Forward check: deploy on existing program + if !upgrade && exists { + return Err(anyhow::anyhow!( + "program already deployed at {}. Use `quasar deploy --upgrade` to upgrade an existing program.", + bs58::encode(program_id).into_string() + ) + .into()); } - if let Some(cluster) = &url { - cmd.args(["--url", cluster]); + // Reverse check: --upgrade on non-existent program + if upgrade && !exists { + return Err(anyhow::anyhow!( + "program not found at {}. Drop `--upgrade` for a fresh deploy.", + bs58::encode(program_id).into_string() + ) + .into()); } - let output = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output(); + // Load the payer keypair + let payer_path = crate::rpc::solana_keypair_path(keypair.as_deref()); + let payer = crate::rpc::Keypair::read_from_file(&payer_path)?; - sp.finish_and_clear(); + if upgrade { + // Authority validation before buffer upload + let authority_keypair = if let Some(ref auth_path) = upgrade_authority { + crate::rpc::Keypair::read_from_file(auth_path)? + } else { + crate::rpc::Keypair::read_from_file(&payer_path)? + }; - match output { - Ok(o) if o.status.success() => { - let stdout = String::from_utf8_lossy(&o.stdout); + let sp = style::spinner("Verifying upgrade authority..."); + crate::bpf_loader::verify_upgrade_authority( + &rpc_url, + &program_id, + &authority_keypair.address(), + )?; + sp.finish_and_clear(); - // Extract program ID from solana CLI output - let program_id = stdout - .lines() - .find(|l| l.contains("Program Id:")) - .and_then(|l| l.split(':').nth(1)) - .map(|s| s.trim()) - .unwrap_or("(unknown)"); + // Upgrade + let sp = style::spinner("Uploading and upgrading..."); + crate::bpf_loader::upgrade_program( + &so_path, + &program_id, + &authority_keypair, + &rpc_url, + fee, + )?; + sp.finish_and_clear(); - println!( - "\n {}", - style::success(&format!("Deployed to {}", style::bold(program_id))) - ); - println!(); - Ok(()) - } - Ok(o) => { - let stderr = String::from_utf8_lossy(&o.stderr); - let stdout = String::from_utf8_lossy(&o.stdout); - if !stderr.is_empty() { - eprintln!(); - for line in stderr.lines() { - eprintln!(" {line}"); - } - } - if !stdout.is_empty() { - for line in stdout.lines() { - eprintln!(" {line}"); - } - } - eprintln!(); - eprintln!(" {}", style::fail("deploy failed")); - std::process::exit(o.status.code().unwrap_or(1)); - } - Err(e) => { - eprintln!( - "\n {}", - style::fail(&format!("failed to run solana program deploy: {e}")) - ); - eprintln!(); - eprintln!( - " Make sure the {} CLI is installed and configured.", - style::bold("solana") - ); - eprintln!(); - std::process::exit(1); - } + println!( + "\n {}", + style::success(&format!( + "Upgraded {}", + style::bold(&bs58::encode(program_id).into_string()) + )) + ); + } else { + // Fresh deploy + let program_kp = crate::rpc::Keypair::read_from_file(&keypair_path)?; + + let sp = style::spinner("Deploying..."); + let addr = crate::bpf_loader::deploy_program( + &so_path, + &program_kp, + &payer, + &rpc_url, + fee, + )?; + sp.finish_and_clear(); + + println!( + "\n {}", + style::success(&format!( + "Deployed to {}", + style::bold(&bs58::encode(addr).into_string()) + )) + ); } + + // --multisig without --upgrade: transfer authority to vault after deploy + if let Some(multisig_addr) = &multisig { + let multisig_key = parse_multisig_address(multisig_addr)?; + let (vault, _) = crate::multisig::vault_pda(&multisig_key, 0); + + let authority_keypair = if let Some(ref auth_path) = upgrade_authority { + crate::rpc::Keypair::read_from_file(auth_path)? + } else { + crate::rpc::Keypair::read_from_file(&payer_path)? + }; + + let sp = style::spinner("Transferring upgrade authority to multisig vault..."); + crate::bpf_loader::set_authority( + &crate::bpf_loader::programdata_pda(&program_id).0, + &authority_keypair, + Some(&vault), + &rpc_url, + fee, + )?; + sp.finish_and_clear(); + + println!( + " {}", + style::success(&format!( + "Upgrade authority transferred to vault {}", + style::bold(&crate::multisig::short_addr(&vault)) + )) + ); + println!(); + println!( + " Future upgrades: {}", + style::dim(&format!( + "quasar deploy --upgrade --multisig {multisig_addr}" + )) + ); + } + + println!(); + Ok(()) } diff --git a/cli/src/init.rs b/cli/src/init.rs index 5d957e8..ed655e2 100644 --- a/cli/src/init.rs +++ b/cli/src/init.rs @@ -545,7 +545,14 @@ pub fn run( ); } - scaffold(&name, &crate_name, toolchain, framework, template)?; + scaffold( + &name, + &crate_name, + toolchain, + framework, + template, + crate::toolchain::LATEST_KNOWN_VERSION, + )?; // git init (unless --no-git or already in a git repo) if !no_git { @@ -615,6 +622,7 @@ fn scaffold( toolchain: Toolchain, framework: Framework, template: Template, + quasar_version: &str, ) -> CliResult { let root = Path::new(dir); @@ -656,7 +664,7 @@ fn scaffold( // Cargo.toml fs::write( root.join("Cargo.toml"), - generate_cargo_toml(name, toolchain, framework), + generate_cargo_toml(name, toolchain, framework, quasar_version), ) .map_err(anyhow::Error::from)?; @@ -754,7 +762,12 @@ fn scaffold( // Generators // --------------------------------------------------------------------------- -fn generate_cargo_toml(name: &str, toolchain: Toolchain, framework: Framework) -> String { +fn generate_cargo_toml( + name: &str, + toolchain: Toolchain, + framework: Framework, + quasar_version: &str, +) -> String { let mut out = format!( r#"[package] name = "{name}" @@ -780,6 +793,15 @@ quasar-lang = {{ git = "https://github.com/blueshift-gg/quasar" }} "#, ); + // Once quasar-lang is published to crates.io, pin the version instead. + // For now, 0.0.0 always uses the git dependency. + if quasar_version != "0.0.0" { + out = out.replace( + "quasar-lang = { git = \"https://github.com/blueshift-gg/quasar\" }\n", + &format!("quasar-lang = \"{quasar_version}\"\n"), + ); + } + if matches!(toolchain, Toolchain::Solana) { out.push_str("solana-instruction = { version = \"3.2.0\" }\n"); } diff --git a/cli/src/keys.rs b/cli/src/keys.rs new file mode 100644 index 0000000..cfbf54a --- /dev/null +++ b/cli/src/keys.rs @@ -0,0 +1,174 @@ +use { + crate::{config::QuasarConfig, error::CliResult, style}, + std::{fs, path::PathBuf}, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Locate the program keypair in target/deploy/. +fn keypair_path(config: &QuasarConfig) -> PathBuf { + let name = &config.project.name; + let module = config.module_name(); + + let default = PathBuf::from("target/deploy").join(format!("{name}-keypair.json")); + if default.exists() { + return default; + } + let alt = PathBuf::from("target/deploy").join(format!("{module}-keypair.json")); + if alt.exists() { + return alt; + } + default +} + +/// Read the public key (program ID) from a Solana CLI-compatible keypair file. +/// The file contains a 64-byte JSON array: [secret(32) | public(32)]. +fn read_program_id(path: &PathBuf) -> Result { + let json = fs::read_to_string(path).map_err(anyhow::Error::from)?; + let bytes: Vec = serde_json::from_str(&json).map_err(anyhow::Error::from)?; + Ok(bs58::encode(&bytes[32..64]).into_string()) +} + +/// Find the current `declare_id!("...")` value in src/lib.rs using the IDL +/// parser. +fn current_program_id() -> Option { + let source = fs::read_to_string("src/lib.rs").ok()?; + let file = syn::parse_file(&source).ok()?; + quasar_idl::parser::program::extract_program_id(&file) +} + +/// Replace the address inside `declare_id!("...")` in src/lib.rs. +fn replace_program_id(old_id: &str, new_id: &str) -> Result<(), crate::error::CliError> { + let source = fs::read_to_string("src/lib.rs").map_err(anyhow::Error::from)?; + let updated = source.replace( + &format!("declare_id!(\"{old_id}\")"), + &format!("declare_id!(\"{new_id}\")"), + ); + fs::write("src/lib.rs", updated).map_err(anyhow::Error::from)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/// Print the program ID from the keypair file. +pub fn list() -> CliResult { + let config = QuasarConfig::load()?; + let path = keypair_path(&config); + + if !path.exists() { + eprintln!( + " {} keypair not found: {}", + style::fail(""), + path.display() + ); + eprintln!(" Run {} first.", style::bold("quasar keys new")); + std::process::exit(1); + } + + let id = read_program_id(&path)?; + println!(" {}", style::bold(&id)); + Ok(()) +} + +/// Update declare_id!() in src/lib.rs to match the keypair file. +pub fn sync() -> CliResult { + let config = QuasarConfig::load()?; + let path = keypair_path(&config); + + if !path.exists() { + eprintln!( + " {} keypair not found: {}", + style::fail(""), + path.display() + ); + eprintln!(" Run {} first.", style::bold("quasar keys new")); + std::process::exit(1); + } + + let keypair_id = read_program_id(&path)?; + + let current_id = match current_program_id() { + Some(id) => id, + None => { + eprintln!(" {}", style::fail("declare_id!() not found in src/lib.rs")); + std::process::exit(1); + } + }; + + if current_id == keypair_id { + println!( + " {} {}", + style::success("Already in sync:"), + style::bold(&keypair_id) + ); + return Ok(()); + } + + replace_program_id(¤t_id, &keypair_id)?; + + println!( + " {} {}", + style::success("Synced program ID:"), + style::bold(&keypair_id) + ); + Ok(()) +} + +/// Generate a new keypair and update declare_id!(). +pub fn new(force: bool) -> CliResult { + let config = QuasarConfig::load()?; + let path = keypair_path(&config); + + if path.exists() && !force { + eprintln!( + " {} keypair already exists: {}", + style::fail(""), + path.display() + ); + eprintln!(); + eprintln!( + " Use {} to overwrite it.", + style::bold("quasar keys new --force") + ); + eprintln!( + " {}", + style::dim("Warning: this will change your program address.") + ); + std::process::exit(1); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(anyhow::Error::from)?; + } + + let signing_key = ed25519_dalek::SigningKey::generate(&mut rand::thread_rng()); + let mut keypair_bytes = Vec::with_capacity(64); + keypair_bytes.extend_from_slice(signing_key.as_bytes()); + keypair_bytes.extend_from_slice(signing_key.verifying_key().as_bytes()); + let keypair_json = serde_json::to_string(&keypair_bytes).map_err(anyhow::Error::from)?; + + fs::write(&path, &keypair_json).map_err(anyhow::Error::from)?; + + let id = bs58::encode(signing_key.verifying_key().as_bytes()).into_string(); + println!( + " {} {}", + style::success("Generated keypair:"), + style::bold(&id) + ); + + // Auto-sync declare_id!() if src/lib.rs exists + if std::path::Path::new("src/lib.rs").exists() { + if let Some(current_id) = current_program_id() { + if current_id != id { + replace_program_id(¤t_id, &id)?; + println!(" {} declare_id!() updated", style::success("Synced:"),); + } + } + } + + Ok(()) +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 4719d4d..523f87c 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -12,10 +12,16 @@ pub mod dump; pub mod error; pub mod idl; pub mod init; +pub mod keys; +pub mod multisig; pub mod new; +pub mod rpc; +pub mod bpf_loader; pub mod style; +pub mod sync; pub mod test; pub mod toolchain; +pub mod update; pub mod utils; pub use error::CliResult; @@ -53,6 +59,12 @@ pub enum Command { Profile(ProfileCommand), /// Dump sBPF assembly Dump(DumpCommand), + /// Manage program keypair + Keys(KeysCommand), + /// Ensure the correct toolchain versions are installed + Sync, + /// Update the Quasar CLI to the latest version + Update, /// Generate shell completions Completions(CompletionsCommand), } @@ -163,6 +175,22 @@ pub struct DeployCommand { /// Skip the build step #[arg(long, action = ArgAction::SetTrue)] pub skip_build: bool, + + /// Upgrade an existing program (required if program is already deployed) + #[arg(long, action = ArgAction::SetTrue)] + pub upgrade: bool, + + /// Propose upgrade through a Squads multisig + #[arg(long, value_name = "ADDRESS")] + pub multisig: Option, + + /// Show approval status of the latest multisig proposal + #[arg(long, action = ArgAction::SetTrue, requires = "multisig", requires = "upgrade")] + pub status: bool, + + /// Priority fee in micro-lamports (auto-calculated if omitted) + #[arg(long, value_name = "MICRO_LAMPORTS")] + pub priority_fee: Option, } #[derive(Args, Debug, Default)] @@ -246,6 +274,26 @@ pub struct ProfileCommand { pub watch: bool, } +#[derive(Args, Debug)] +pub struct KeysCommand { + #[command(subcommand)] + pub action: KeysAction, +} + +#[derive(Subcommand, Debug)] +pub enum KeysAction { + /// Print the program ID from the keypair file + List, + /// Update declare_id!() to match the keypair + Sync, + /// Generate a new program keypair + New { + /// Overwrite existing keypair + #[arg(long, action = ArgAction::SetTrue)] + force: bool, + }, +} + #[derive(Args, Debug)] pub struct CompletionsCommand { /// Shell to generate completions for @@ -292,13 +340,17 @@ pub fn run(cli: Cli) -> CliResult { Command::Test(cmd) => { test::run(cmd.debug, cmd.filter, cmd.watch, cmd.no_build, cmd.features) } - Command::Deploy(cmd) => deploy::run( - cmd.program_keypair, - cmd.upgrade_authority, - cmd.keypair, - cmd.url, - cmd.skip_build, - ), + Command::Deploy(cmd) => deploy::run(deploy::DeployOpts { + program_keypair: cmd.program_keypair, + upgrade_authority: cmd.upgrade_authority, + keypair: cmd.keypair, + url: cmd.url, + skip_build: cmd.skip_build, + multisig: cmd.multisig, + status: cmd.status, + upgrade: cmd.upgrade, + priority_fee: cmd.priority_fee, + }), Command::Clean(cmd) => clean::run(cmd.all), Command::Config(cmd) => cfg::run(cmd.action), Command::Idl(cmd) => idl::run(cmd), @@ -312,6 +364,13 @@ pub fn run(cli: Cli) -> CliResult { ); Ok(()) } + Command::Keys(cmd) => match cmd.action { + KeysAction::List => keys::list(), + KeysAction::Sync => keys::sync(), + KeysAction::New { force } => keys::new(force), + }, + Command::Sync => sync::run(), + Command::Update => update::run(), Command::Profile(cmd) => { if cmd.watch { return profile_watch(cmd.expand); @@ -378,17 +437,20 @@ pub fn print_help() { "Run the test suite", ); print_cmd( - "deploy [-u url] [-k keypair] [--skip-build]", - "Deploy to a cluster", + "deploy [-u url] [-k keypair] [--upgrade] [--multisig addr] [--priority-fee n]", + "Deploy or upgrade a program", ); print_cmd("clean [-a]", "Remove build artifacts"); print_cmd("config [get|set|list|reset]", "Manage global settings"); + print_cmd("sync", "Ensure correct toolchain versions"); print_cmd("idl ", "Generate the program IDL"); print_cmd( "profile [elf] [--expand] [--diff] [-w]", "Measure compute-unit usage", ); + print_cmd("keys [list|sync|new]", "Manage program keypair"); print_cmd("dump [elf] [-f] [-S]", "Dump sBPF assembly"); + print_cmd("update", "Update the CLI to the latest version"); println!(); println!(" {}", style::bold("Options:")); print_cmd("-h, --help", "Print help"); diff --git a/cli/src/multisig.rs b/cli/src/multisig.rs new file mode 100644 index 0000000..b48249a --- /dev/null +++ b/cli/src/multisig.rs @@ -0,0 +1,1140 @@ +use { + crate::{ + bpf_loader::{ + self, BPF_LOADER_UPGRADEABLE_ID, SYSTEM_PROGRAM_ID, SYSVAR_CLOCK_ID, SYSVAR_RENT_ID, + programdata_pda, + }, + rpc::{confirm_transaction, get_account_data, get_latest_blockhash, send_transaction, Keypair}, + style, + }, + sha2::{Digest, Sha256}, + solana_address::Address, + solana_instruction::AccountMeta, + std::path::Path, +}; + +// --------------------------------------------------------------------------- +// Squads v4 PDAs +// --------------------------------------------------------------------------- + +/// Squads v4 program ID — SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf. +/// Verify with: +/// `bs58::decode("SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf").into_vec()` +/// These bytes MUST be verified at implementation time via the test in Task 8. +const SQUADS_PROGRAM_ID: Address = Address::new_from_array([ + 0x06, 0x81, 0xc4, 0xce, 0x47, 0xe2, 0x23, 0x68, 0xb8, 0xb1, 0x55, 0x5e, 0xc8, 0x87, 0xaf, 0x09, + 0x2e, 0xfc, 0x7e, 0xfb, 0xb6, 0x6c, 0xa3, 0xf5, 0x2f, 0xbf, 0x68, 0xd4, 0xac, 0x9c, 0xb7, 0xa8, +]); + + +pub fn vault_pda(multisig: &Address, vault_index: u8) -> (Address, u8) { + Address::find_program_address( + &[b"multisig", multisig.as_ref(), b"vault", &[vault_index]], + &SQUADS_PROGRAM_ID, + ) +} + +pub fn transaction_pda(multisig: &Address, transaction_index: u64) -> (Address, u8) { + Address::find_program_address( + &[ + b"multisig", + multisig.as_ref(), + b"transaction", + &transaction_index.to_le_bytes(), + ], + &SQUADS_PROGRAM_ID, + ) +} + +pub fn proposal_pda(multisig: &Address, transaction_index: u64) -> (Address, u8) { + Address::find_program_address( + &[ + b"multisig", + multisig.as_ref(), + b"transaction", + &transaction_index.to_le_bytes(), + b"proposal", + ], + &SQUADS_PROGRAM_ID, + ) +} + +/// Read the current transaction_index from a multisig account's on-chain data. +/// The field is at byte offset 78, u64 LE. +pub fn read_transaction_index(account_data: &[u8]) -> Result { + if account_data.len() < 86 { + return Err(anyhow::anyhow!( + "multisig account data too short ({} bytes)", + account_data.len() + ) + .into()); + } + let bytes: [u8; 8] = account_data[78..86].try_into().unwrap(); + Ok(u64::from_le_bytes(bytes)) +} + +/// A multisig member with their public key and permissions bitmask. +pub struct MultisigMember { + pub key: Address, + pub permissions: u8, +} + +impl MultisigMember { + /// Whether the member has Vote permission (bit 1). + pub fn can_vote(&self) -> bool { + self.permissions & 0x02 != 0 + } +} + +/// Parsed state from a multisig account. +pub struct MultisigState { + pub threshold: u16, + pub transaction_index: u64, + pub members: Vec, +} + +/// Parse a multisig account's threshold, transaction_index, and members. +pub fn parse_multisig_account(data: &[u8]) -> Result { + // Offsets: 8 disc + 32 create_key + 32 config_authority = 72 -> threshold (u16) + // 74 time_lock (u32), 78 transaction_index (u64), 86 stale_tx_index (u64) + // 94 rent_collector (33), 127 bump (1), 128 members vec len (u32) + if data.len() < 132 { + return Err(anyhow::anyhow!( + "multisig account data too short ({} bytes)", + data.len() + ) + .into()); + } + + let threshold = u16::from_le_bytes(data[72..74].try_into().unwrap()); + let transaction_index = u64::from_le_bytes(data[78..86].try_into().unwrap()); + let num_members = u32::from_le_bytes(data[128..132].try_into().unwrap()) as usize; + + let required_len = 132 + num_members * 33; + if data.len() < required_len { + return Err(anyhow::anyhow!( + "multisig account data too short for {} members ({} < {} bytes)", + num_members, + data.len(), + required_len, + ) + .into()); + } + + let mut members = Vec::with_capacity(num_members); + for i in 0..num_members { + let offset = 132 + i * 33; + let key = Address::from(<[u8; 32]>::try_from(&data[offset..offset + 32]).unwrap()); + let permissions = data[offset + 32]; + members.push(MultisigMember { key, permissions }); + } + + Ok(MultisigState { + threshold, + transaction_index, + members, + }) +} + +/// Proposal status variants from the on-chain enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProposalStatus { + Draft, + Active, + Rejected, + Approved, + Executing, + Executed, + Cancelled, +} + +impl ProposalStatus { + fn from_discriminant(d: u8) -> Result { + match d { + 0 => Ok(Self::Draft), + 1 => Ok(Self::Active), + 2 => Ok(Self::Rejected), + 3 => Ok(Self::Approved), + 4 => Ok(Self::Executing), + 5 => Ok(Self::Executed), + 6 => Ok(Self::Cancelled), + _ => Err(anyhow::anyhow!("unknown proposal status: {d}").into()), + } + } + + fn label(&self) -> &'static str { + match self { + Self::Draft => "Draft", + Self::Active => "Active", + Self::Rejected => "Rejected", + Self::Approved => "Approved", + Self::Executing => "Executing", + Self::Executed => "Executed", + Self::Cancelled => "Cancelled", + } + } +} + +/// Parsed state from a proposal account. +pub struct ProposalState { + pub transaction_index: u64, + pub status: ProposalStatus, + pub approved: Vec
, +} + +/// Parse a proposal account's status and approval list. +pub fn parse_proposal_account(data: &[u8]) -> Result { + // 8 disc + 32 multisig + 8 tx_index = 48 -> status (1 byte variant) + if data.len() < 62 { + return Err(anyhow::anyhow!( + "proposal account data too short ({} bytes)", + data.len() + ) + .into()); + } + + let transaction_index = u64::from_le_bytes(data[40..48].try_into().unwrap()); + let status = ProposalStatus::from_discriminant(data[48])?; + + // Status payload: all variants except Executing (4) have an i64 timestamp (8 bytes) + let status_size = if status == ProposalStatus::Executing { + 1 + } else { + 9 + }; + let bump_offset = 48 + status_size; + let approved_len_offset = bump_offset + 1; // skip bump byte + + if data.len() < approved_len_offset + 4 { + return Err(anyhow::anyhow!("proposal data too short for approved vec").into()); + } + + let num_approved = + u32::from_le_bytes(data[approved_len_offset..approved_len_offset + 4].try_into().unwrap()) + as usize; + let approved_start = approved_len_offset + 4; + let required = approved_start + num_approved * 32; + if data.len() < required { + return Err(anyhow::anyhow!("proposal data too short for {} approvals", num_approved).into()); + } + + let mut approved = Vec::with_capacity(num_approved); + for i in 0..num_approved { + let offset = approved_start + i * 32; + approved.push(Address::from( + <[u8; 32]>::try_from(&data[offset..offset + 32]).unwrap(), + )); + } + + Ok(ProposalState { + transaction_index, + status, + approved, + }) +} + +/// Format an address as "1234...5678". +pub fn short_addr(addr: &Address) -> String { + let s = bs58::encode(addr).into_string(); + if s.len() <= 8 { + return s; + } + format!("{}...{}", &s[..4], &s[s.len() - 4..]) +} + +// --------------------------------------------------------------------------- +// Instruction building +// --------------------------------------------------------------------------- + +/// Compute an Anchor instruction discriminator: first 8 bytes of +/// sha256("global:"). +fn anchor_discriminator(name: &str) -> [u8; 8] { + let mut hasher = Sha256::new(); + hasher.update(format!("global:{name}").as_bytes()); + let hash = hasher.finalize(); + hash[..8].try_into().unwrap() +} + +/// Build the inner TransactionMessage bytes for a BPF upgrade. +/// This is the Squads SmallVec-encoded message that gets stored on-chain. +/// +/// The inner instruction upgrades `program_id` using `buffer` with the +/// `vault` PDA as the upgrade authority. +pub fn build_upgrade_message( + vault: &Address, + program_id: &Address, + buffer: &Address, + spill: &Address, +) -> Vec { + let (programdata, _) = programdata_pda(program_id); + + // Account keys ordering: + // [0] vault (writable signer — the authority) + // [1] programdata (writable) + // [2] program_id (writable) + // [3] buffer (writable) + // [4] spill (writable) + // [5] rent sysvar (readonly) + // [6] clock sysvar (readonly) + // [7] BPF loader upgradeable (readonly, program) + let account_keys: Vec<&Address> = vec![ + vault, + &programdata, + program_id, + buffer, + spill, + &SYSVAR_RENT_ID, + &SYSVAR_CLOCK_ID, + &BPF_LOADER_UPGRADEABLE_ID, + ]; + + let num_signers: u8 = 1; // vault + let num_writable_signers: u8 = 1; // vault is writable + let num_writable_non_signers: u8 = 4; // programdata, program, buffer, spill + + // BPF upgrade instruction data: u32 LE = 3 (Upgrade variant) + let ix_data: [u8; 4] = [0x03, 0x00, 0x00, 0x00]; + + // Compiled instruction: program_id_index=7 (BPF loader), + // accounts=[1,2,3,4,5,6,0] Account order for upgrade(): programdata, + // program, buffer, spill, rent, clock, authority + let account_indexes: Vec = vec![1, 2, 3, 4, 5, 6, 0]; + + // Serialize TransactionMessage with SmallVec encoding + let mut msg = vec![ + num_signers, + num_writable_signers, + num_writable_non_signers, + // account_keys: SmallVec + account_keys.len() as u8, + ]; + for key in &account_keys { + msg.extend_from_slice(key.as_ref()); + } + + // instructions: SmallVec + msg.push(1u8); // 1 instruction + // CompiledInstruction: + msg.push(7u8); // program_id_index + // account_indexes: SmallVec + msg.push(account_indexes.len() as u8); + msg.extend_from_slice(&account_indexes); + // data: SmallVec + msg.extend_from_slice(&(ix_data.len() as u16).to_le_bytes()); + msg.extend_from_slice(&ix_data); + + // address_table_lookups: SmallVec — empty + msg.push(0u8); + + msg +} + +/// Build the VaultTransactionCreate instruction. +pub fn vault_transaction_create_ix( + multisig: &Address, + transaction: &Address, + creator: &Address, + rent_payer: &Address, + vault_index: u8, + transaction_message: Vec, +) -> solana_instruction::Instruction { + let discriminator = anchor_discriminator("vault_transaction_create"); + + let mut data = Vec::new(); + data.extend_from_slice(&discriminator); + data.push(vault_index); + data.push(0u8); // ephemeral_signers = 0 + // transaction_message: Borsh Vec = u32 LE length + bytes + data.extend_from_slice(&(transaction_message.len() as u32).to_le_bytes()); + data.extend_from_slice(&transaction_message); + data.push(0u8); // memo: Option = None + + solana_instruction::Instruction { + program_id: SQUADS_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(*multisig, false), + AccountMeta::new(*transaction, false), + AccountMeta::new_readonly(*creator, true), + AccountMeta::new(*rent_payer, true), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ], + data, + } +} + +/// Build the ProposalCreate instruction. +pub fn proposal_create_ix( + multisig: &Address, + proposal: &Address, + creator: &Address, + rent_payer: &Address, + transaction_index: u64, +) -> solana_instruction::Instruction { + let discriminator = anchor_discriminator("proposal_create"); + + let mut data = Vec::new(); + data.extend_from_slice(&discriminator); + data.extend_from_slice(&transaction_index.to_le_bytes()); + data.push(0u8); // draft = false (start as Active) + + solana_instruction::Instruction { + program_id: SQUADS_PROGRAM_ID, + accounts: vec![ + AccountMeta::new_readonly(*multisig, false), + AccountMeta::new(*proposal, false), + AccountMeta::new_readonly(*creator, true), + AccountMeta::new(*rent_payer, true), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ], + data, + } +} + +/// Build the ProposalApprove instruction. +pub fn proposal_approve_ix( + multisig: &Address, + member: &Address, + proposal: &Address, +) -> solana_instruction::Instruction { + let discriminator = anchor_discriminator("proposal_approve"); + + let mut data = Vec::new(); + data.extend_from_slice(&discriminator); + data.push(0u8); // memo: Option = None + + solana_instruction::Instruction { + program_id: SQUADS_PROGRAM_ID, + accounts: vec![ + AccountMeta::new_readonly(*multisig, false), + AccountMeta::new(*member, true), + AccountMeta::new(*proposal, false), + ], + data, + } +} + +// --------------------------------------------------------------------------- +// Top-level orchestrator +// --------------------------------------------------------------------------- + +/// Propose a program upgrade through a Squads multisig. +/// +/// 1. Uploads the .so as a buffer +/// 2. Builds the Squads vault transaction + proposal + approve +/// 3. Signs and sends the transaction +pub fn propose_upgrade( + so_path: &Path, + program_id: &Address, + multisig: &Address, + keypair_path: &Path, + rpc_url: &str, + vault_index: u8, + priority_fee: u64, +) -> crate::error::CliResult { + let keypair = Keypair::read_from_file(keypair_path)?; + let member = keypair.address(); + + // 1. Upload buffer + let sp = style::spinner("Uploading program buffer..."); + let buffer = bpf_loader::write_buffer(so_path, &keypair, rpc_url, priority_fee)?; + sp.finish_and_clear(); + println!( + " {} Buffer: {}", + style::dim("✓"), + bs58::encode(buffer).into_string() + ); + + // 2. Transfer buffer authority to the vault so Squads can use it + let (vault, _) = vault_pda(multisig, vault_index); + let sp = style::spinner("Transferring buffer authority to vault..."); + bpf_loader::set_authority(&buffer, &keypair, Some(&vault), rpc_url, priority_fee)?; + sp.finish_and_clear(); + println!(" {} Buffer authority transferred to vault", style::dim("✓")); + + // 3. Read multisig state to get next transaction index + let account_data = get_account_data(rpc_url, multisig)?.ok_or_else(|| { + anyhow::anyhow!( + "multisig account not found: {}", + bs58::encode(multisig).into_string() + ) + })?; + let current_index = read_transaction_index(&account_data)?; + let next_index = current_index + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("transaction index overflow"))?; + + // 4. Derive remaining PDAs + let (transaction, _) = transaction_pda(multisig, next_index); + let (proposal, _) = proposal_pda(multisig, next_index); + + // 5. Build inner upgrade message + let upgrade_msg = build_upgrade_message(&vault, program_id, &buffer, &member); + + // 6. Build Squads instructions + let ix_create = vault_transaction_create_ix( + multisig, + &transaction, + &member, + &member, + vault_index, + upgrade_msg, + ); + let ix_propose = proposal_create_ix(multisig, &proposal, &member, &member, next_index); + let ix_approve = proposal_approve_ix(multisig, &member, &proposal); + + // 7. Build, sign, send transaction + let sp = style::spinner("Submitting proposal..."); + + let mut ixs = vec![]; + if priority_fee > 0 { + ixs.push(bpf_loader::set_compute_unit_price_ix(priority_fee)); + } + ixs.push(ix_create); + ixs.push(ix_propose); + ixs.push(ix_approve); + + let blockhash = get_latest_blockhash(rpc_url)?; + let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(&member), + &[&keypair], + blockhash, + ); + + let tx_bytes = bincode::serialize(&tx) + .map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + + let sig = send_transaction(rpc_url, &tx_bytes)?; + let confirmed = confirm_transaction(rpc_url, &sig, 30)?; + + sp.finish_and_clear(); + + if !confirmed { + return Err(anyhow::anyhow!("proposal transaction timed out").into()); + } + + println!( + "\n {}", + style::success(&format!( + "Upgrade proposed (tx #{})", + style::bold(&next_index.to_string()) + )) + ); + println!(" {} {sig}", style::dim("Signature:")); + println!( + " {} https://v4.squads.so/transactions/{}/tx/{}", + style::dim("Squads:"), + bs58::encode(multisig).into_string(), + next_index + ); + println!(); + + Ok(()) +} + +/// Show the approval status of the latest multisig proposal. +/// +/// Displays each member's vote status with colored indicators, and prompts +/// the user to execute if the threshold has been reached. +pub fn show_proposal_status( + multisig: &Address, + keypair_path: &Path, + rpc_url: &str, + priority_fee: u64, +) -> crate::error::CliResult { + // 1. Fetch and parse the multisig account + let sp = style::spinner("Fetching multisig state..."); + let ms_data = get_account_data(rpc_url, multisig)?.ok_or_else(|| { + anyhow::anyhow!( + "multisig account not found: {}", + bs58::encode(multisig).into_string() + ) + })?; + let ms = parse_multisig_account(&ms_data)?; + sp.finish_and_clear(); + + if ms.transaction_index == 0 { + println!("\n {} No proposals found for this multisig.\n", style::dim("·")); + return Ok(()); + } + + // 2. Fetch the latest proposal + let (proposal_addr, _) = proposal_pda(multisig, ms.transaction_index); + let sp = style::spinner("Fetching proposal..."); + let prop_data = get_account_data(rpc_url, &proposal_addr)?.ok_or_else(|| { + anyhow::anyhow!( + "proposal account not found for tx #{}", + ms.transaction_index + ) + })?; + let proposal = parse_proposal_account(&prop_data)?; + sp.finish_and_clear(); + + // 3. Display header + let multisig_short = short_addr(multisig); + println!(); + println!( + " {} Multisig {} — Transaction #{}", + style::bold("▸"), + style::color(45, &multisig_short), + style::bold(&ms.transaction_index.to_string()), + ); + println!( + " {} Proposal status: {}", + style::dim("│"), + match proposal.status { + ProposalStatus::Active => style::color(220, proposal.status.label()), + ProposalStatus::Approved => style::color(83, proposal.status.label()), + ProposalStatus::Executed => style::color(83, proposal.status.label()), + ProposalStatus::Rejected => style::color(196, proposal.status.label()), + ProposalStatus::Cancelled => style::color(196, proposal.status.label()), + _ => style::dim(proposal.status.label()), + }, + ); + println!(" {}", style::dim("│")); + + // 4. Show each voting member's status + let voters: Vec<&MultisigMember> = ms.members.iter().filter(|m| m.can_vote()).collect(); + let approved_count = proposal.approved.len(); + + for member in &voters { + let addr = short_addr(&member.key); + let voted = proposal.approved.contains(&member.key); + if voted { + // Green checkmark + println!( + " {} {} {}", + style::dim("│"), + style::color(83, "✔"), + style::color(83, &addr), + ); + } else { + // Dim pending dot + println!( + " {} {} {}", + style::dim("│"), + style::dim("·"), + style::dim(&addr), + ); + } + } + + println!(" {}", style::dim("│")); + + // 5. Show threshold status + let threshold = ms.threshold as usize; + let remaining = threshold.saturating_sub(approved_count); + + if approved_count >= threshold { + println!( + " {} Status: {}/{} signed — {}", + style::dim("╰"), + style::color(83, &approved_count.to_string()), + threshold, + style::color(83, "ready to execute"), + ); + println!(); + + // Prompt user to execute + print!( + " {} Execute this transaction? [y/N] ", + style::color(45, "?"), + ); + use std::io::Write; + std::io::stdout().flush().ok(); + + let mut input = String::new(); + std::io::stdin().read_line(&mut input).ok(); + let input = input.trim().to_lowercase(); + + if input == "y" || input == "yes" { + execute_approved_proposal( + multisig, + &ms, + &proposal, + keypair_path, + rpc_url, + priority_fee, + )?; + } else { + println!(); + } + } else { + println!( + " {} Status: {}/{} signed — awaiting {} {}", + style::dim("╰"), + style::color(220, &approved_count.to_string()), + threshold, + style::bold(&remaining.to_string()), + if remaining == 1 { "signature" } else { "signatures" }, + ); + println!(); + } + + Ok(()) +} + +/// Execute an approved proposal by calling VaultTransactionExecute. +fn execute_approved_proposal( + multisig: &Address, + ms: &MultisigState, + proposal: &ProposalState, + keypair_path: &Path, + rpc_url: &str, + priority_fee: u64, +) -> crate::error::CliResult { + let keypair = Keypair::read_from_file(keypair_path)?; + let member = keypair.address(); + + let tx_index = proposal.transaction_index; + let (transaction_pda, _) = transaction_pda(multisig, tx_index); + let (proposal_pda, _) = proposal_pda(multisig, tx_index); + + // Fetch the VaultTransaction to get inner accounts for execute + let tx_data = get_account_data(rpc_url, &transaction_pda)?.ok_or_else(|| { + anyhow::anyhow!("vault transaction account not found for tx #{tx_index}") + })?; + + let sp = style::spinner("Executing transaction..."); + + // Build VaultTransactionExecute instruction + let ix = vault_transaction_execute_ix( + multisig, + &transaction_pda, + &proposal_pda, + &member, + &tx_data, + ms, + )?; + + let mut ixs = vec![]; + if priority_fee > 0 { + ixs.push(bpf_loader::set_compute_unit_price_ix(priority_fee)); + } + ixs.push(ix); + + let blockhash = get_latest_blockhash(rpc_url)?; + let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(&member), + &[&keypair], + blockhash, + ); + + let tx_bytes = bincode::serialize(&tx) + .map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + + let sig = send_transaction(rpc_url, &tx_bytes)?; + let confirmed = confirm_transaction(rpc_url, &sig, 30)?; + sp.finish_and_clear(); + + if !confirmed { + return Err(anyhow::anyhow!("execute transaction timed out").into()); + } + + println!( + "\n {}", + style::success(&format!( + "Transaction #{} executed", + style::bold(&tx_index.to_string()) + )) + ); + println!(" {} {sig}", style::dim("Signature:")); + println!(); + + Ok(()) +} + +/// Build the VaultTransactionExecute instruction. +/// +/// Parses the VaultTransaction account data to extract inner account keys +/// needed for the execute instruction's remaining accounts. +fn vault_transaction_execute_ix( + multisig: &Address, + transaction: &Address, + proposal: &Address, + member: &Address, + vault_tx_data: &[u8], + ms: &MultisigState, +) -> Result { + let discriminator = anchor_discriminator("vault_transaction_execute"); + + // VaultTransaction layout (Squads v4): + // 0: [u8; 8] Anchor discriminator + // 8: Pubkey multisig (32 bytes) + // 40: Pubkey creator (32 bytes) + // 72: u64 index ( 8 bytes) + // 80: u8 bump ( 1 byte) + // 81: u8 vault_index ( 1 byte) + // 82: u8 vault_bump ( 1 byte) + // 83: Vec ephemeral_signer_bumps (4 byte len + N bytes) + // 87+N: message (VaultTransactionMessage, variable) + if vault_tx_data.len() < 87 { + return Err(anyhow::anyhow!("vault transaction data too short").into()); + } + + let vault_index = vault_tx_data[81]; + let (vault, _) = vault_pda(multisig, vault_index); + + // Parse ephemeral_signer_bumps Vec to skip past it + let eph_bumps_len = + u32::from_le_bytes(vault_tx_data[83..87].try_into().unwrap()) as usize; + let msg_offset = 87 + eph_bumps_len; + + // The message field is serialized as a Borsh Vec: u32 LE length + bytes + if vault_tx_data.len() < msg_offset + 4 { + return Err( + anyhow::anyhow!("vault transaction data too short for message length").into(), + ); + } + let msg_len = u32::from_le_bytes( + vault_tx_data[msg_offset..msg_offset + 4] + .try_into() + .unwrap(), + ) as usize; + let msg_start = msg_offset + 4; + if vault_tx_data.len() < msg_start + msg_len { + return Err( + anyhow::anyhow!("vault transaction data too short for message bytes").into(), + ); + } + let msg = &vault_tx_data[msg_start..msg_start + msg_len]; + + // TransactionMessage layout (SmallVec): + // 3 header bytes, then u8 num_keys, then num_keys * 32 bytes of account keys + if msg.len() < 4 { + return Err(anyhow::anyhow!("inner message too short").into()); + } + let num_keys = msg[3] as usize; + if msg.len() < 4 + num_keys * 32 { + return Err(anyhow::anyhow!("inner message too short for account keys").into()); + } + + // Collect the inner account keys (skip index 0 which is the vault/signer) + let mut remaining_accounts = Vec::new(); + for i in 1..num_keys { + let offset = 4 + i * 32; + let key = Address::from(<[u8; 32]>::try_from(&msg[offset..offset + 32]).unwrap()); + // All non-vault accounts are passed as writable non-signers + remaining_accounts.push(AccountMeta::new(key, false)); + } + + // Also add program IDs from the inner instructions + // The compiled instructions reference program_id_index into the account_keys array, + // so those are already covered above. + + // Build the main accounts + let mut accounts = vec![ + AccountMeta::new(*multisig, false), + AccountMeta::new_readonly(*transaction, false), + AccountMeta::new(*proposal, false), + AccountMeta::new_readonly(*member, true), + ]; + + // Add all multisig members as non-signer readonly (required by Squads) + for m in &ms.members { + accounts.push(AccountMeta::new_readonly(m.key, false)); + } + + // Vault (ephemeral signer) + accounts.push(AccountMeta::new(vault, false)); + + // Remaining accounts from the inner transaction + accounts.extend(remaining_accounts); + + Ok(solana_instruction::Instruction { + program_id: SQUADS_PROGRAM_ID, + accounts, + data: discriminator.to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vault_pda_derivation() { + let multisig = Address::from([1u8; 32]); + let (vault, _bump) = vault_pda(&multisig, 0); + assert_ne!(vault, Address::default()); + } + + #[test] + fn transaction_index_parsing() { + let mut data = vec![0u8; 128]; + data[78..86].copy_from_slice(&42u64.to_le_bytes()); + assert_eq!(read_transaction_index(&data).unwrap(), 42); + } + + #[test] + fn transaction_index_too_short() { + let data = vec![0u8; 50]; + assert!(read_transaction_index(&data).is_err()); + } + + #[test] + fn anchor_discriminators() { + assert_eq!( + anchor_discriminator("vault_transaction_create"), + [0x30, 0xfa, 0x4e, 0xa8, 0xd0, 0xe2, 0xda, 0xd3] + ); + assert_eq!( + anchor_discriminator("proposal_create"), + [0xdc, 0x3c, 0x49, 0xe0, 0x1e, 0x6c, 0x4f, 0x9f] + ); + assert_eq!( + anchor_discriminator("proposal_approve"), + [0x90, 0x25, 0xa4, 0x88, 0xbc, 0xd8, 0x2a, 0xf8] + ); + } + + #[test] + fn verify_squads_program_id() { + let expected = bs58::decode("SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf") + .into_vec() + .unwrap(); + assert_eq!(SQUADS_PROGRAM_ID.as_ref(), &expected[..]); + } + + #[test] + fn short_addr_formatting() { + // Use a known address + let addr = Address::from([ + 0x06, 0x81, 0xc4, 0xce, 0x47, 0xe2, 0x23, 0x68, 0xb8, 0xb1, 0x55, 0x5e, 0xc8, 0x87, + 0xaf, 0x09, 0x2e, 0xfc, 0x7e, 0xfb, 0xb6, 0x6c, 0xa3, 0xf5, 0x2f, 0xbf, 0x68, 0xd4, + 0xac, 0x9c, 0xb7, 0xa8, + ]); + let short = short_addr(&addr); + assert!(short.contains("..."), "should contain ellipsis"); + assert_eq!(&short[..4], &bs58::encode(addr).into_string()[..4]); + } + + #[test] + fn parse_multisig_account_roundtrip() { + // Build a fake multisig account with 2 members + let mut data = vec![0u8; 132 + 2 * 33]; + // threshold at offset 72 + data[72..74].copy_from_slice(&3u16.to_le_bytes()); + // transaction_index at offset 78 + data[78..86].copy_from_slice(&5u64.to_le_bytes()); + // num_members at offset 128 + data[128..132].copy_from_slice(&2u32.to_le_bytes()); + // member 0: all 1s, permissions = 7 (all) + data[132..164].copy_from_slice(&[1u8; 32]); + data[164] = 0x07; + // member 1: all 2s, permissions = 4 (execute only) + data[165..197].copy_from_slice(&[2u8; 32]); + data[197] = 0x04; + + let ms = parse_multisig_account(&data).unwrap(); + assert_eq!(ms.threshold, 3); + assert_eq!(ms.transaction_index, 5); + assert_eq!(ms.members.len(), 2); + assert!(ms.members[0].can_vote()); + assert!(!ms.members[1].can_vote()); // execute-only can't vote + } + + #[test] + fn parse_proposal_account_active() { + // Build a fake proposal with 1 approval + let mut data = vec![0u8; 62 + 32]; // enough for 1 approval + // transaction_index at offset 40 + data[40..48].copy_from_slice(&7u64.to_le_bytes()); + // status = Active (1) at offset 48 + data[48] = 1; + // timestamp at 49..57 (don't care about value) + // bump at 57 + // approved vec len at 58 + data[58..62].copy_from_slice(&1u32.to_le_bytes()); + // approved[0] = [3u8; 32] + data[62..94].copy_from_slice(&[3u8; 32]); + + let prop = parse_proposal_account(&data).unwrap(); + assert_eq!(prop.transaction_index, 7); + assert_eq!(prop.status, ProposalStatus::Active); + assert_eq!(prop.approved.len(), 1); + assert_eq!(prop.approved[0], Address::from([3u8; 32])); + } + + #[test] + fn parse_proposal_account_no_approvals() { + let mut data = vec![0u8; 62]; + data[40..48].copy_from_slice(&1u64.to_le_bytes()); + data[48] = 0; // Draft + // approved vec len = 0 + data[58..62].copy_from_slice(&0u32.to_le_bytes()); + + let prop = parse_proposal_account(&data).unwrap(); + assert_eq!(prop.status, ProposalStatus::Draft); + assert!(prop.approved.is_empty()); + } + + #[test] + fn proposal_status_labels() { + assert_eq!(ProposalStatus::Active.label(), "Active"); + assert_eq!(ProposalStatus::Approved.label(), "Approved"); + assert_eq!(ProposalStatus::Executed.label(), "Executed"); + } + + #[test] + fn upgrade_message_is_valid() { + let vault = Address::from([1u8; 32]); + let program = Address::from([2u8; 32]); + let buffer = Address::from([3u8; 32]); + let spill = Address::from([4u8; 32]); + let msg = build_upgrade_message(&vault, &program, &buffer, &spill); + + // Check header + assert_eq!(msg[0], 1); // num_signers + assert_eq!(msg[1], 1); // num_writable_signers + assert_eq!(msg[2], 4); // num_writable_non_signers + + // Check 8 account keys + assert_eq!(msg[3], 8); + + // Total size: 3 header + 1 len + 8*32 keys + 1 ix_count + // + 1 program_id_index + 1 acct_idx_len + 7 acct_idxs + // + 2 data_len + 4 data + 1 lookups_len + assert_eq!(msg.len(), 3 + 1 + 256 + 1 + 1 + 1 + 7 + 2 + 4 + 1); + } + + #[test] + fn vault_transaction_execute_ix_parses_correct_offsets() { + // Build a synthetic VaultTransaction account matching Squads v4 layout: + // 0: discriminator (8) + // 8: multisig (32) + // 40: creator (32) + // 72: index (8) + // 80: bump (1) + // 81: vault_index (1) + // 82: vault_bump (1) + // 83: ephemeral_signer_bumps vec len (4) + data (0) + // 87: message vec len (4) + message data + + let multisig_addr = Address::from([0xAA; 32]); + + // Build an inner TransactionMessage with 2 account keys (vault + 1 extra) + let vault_index: u8 = 0; + let (vault, _) = vault_pda(&multisig_addr, vault_index); + let extra_key = Address::from([0xBB; 32]); + + let mut inner_msg = vec![ + 1u8, // num_signers + 1, // num_writable_signers + 0, // num_writable_non_signers + 2, // num_keys + ]; + inner_msg.extend_from_slice(vault.as_ref()); + inner_msg.extend_from_slice(extra_key.as_ref()); + // 1 compiled instruction + inner_msg.push(1); // num_instructions + inner_msg.push(0); // program_id_index + inner_msg.push(0); // account_indexes len + inner_msg.extend_from_slice(&0u16.to_le_bytes()); // data len + inner_msg.push(0); // address_table_lookups len + + // Build full account data + let mut data = vec![0u8; 87]; + // discriminator at 0..8 (zeroes fine) + data[8..40].copy_from_slice(&[0xAA; 32]); // multisig + data[40..72].copy_from_slice(&[0xCC; 32]); // creator + data[72..80].copy_from_slice(&1u64.to_le_bytes()); // index + data[80] = 255; // bump + data[81] = vault_index; + data[82] = 254; // vault_bump + // ephemeral_signer_bumps: empty vec (len=0) + data[83..87].copy_from_slice(&0u32.to_le_bytes()); + // message: Vec + data.extend_from_slice(&(inner_msg.len() as u32).to_le_bytes()); + data.extend_from_slice(&inner_msg); + + let ms = MultisigState { + threshold: 1, + transaction_index: 1, + members: vec![MultisigMember { + key: Address::from([0xDD; 32]), + permissions: 0x07, + }], + }; + + let transaction_addr = Address::from([0xEE; 32]); + let proposal_addr = Address::from([0xFF; 32]); + let member_addr = Address::from([0xDD; 32]); + + let ix = vault_transaction_execute_ix( + &multisig_addr, + &transaction_addr, + &proposal_addr, + &member_addr, + &data, + &ms, + ) + .unwrap(); + + // Verify the instruction was built with correct accounts: + // [multisig, transaction, proposal, member, ...members, vault, ...remaining] + assert_eq!(ix.program_id, SQUADS_PROGRAM_ID); + assert_eq!(ix.accounts[0].pubkey, multisig_addr); + assert_eq!(ix.accounts[1].pubkey, transaction_addr); + assert_eq!(ix.accounts[2].pubkey, proposal_addr); + assert_eq!(ix.accounts[3].pubkey, member_addr); + // member list (1 member) + assert_eq!(ix.accounts[4].pubkey, Address::from([0xDD; 32])); + // vault PDA + assert_eq!(ix.accounts[5].pubkey, vault); + // remaining accounts from inner message (skip index 0 which is vault) + assert_eq!(ix.accounts[6].pubkey, extra_key); + assert_eq!(ix.accounts.len(), 7); + } + + #[test] + fn vault_transaction_execute_ix_with_ephemeral_bumps() { + // Same as above but with 2 ephemeral signer bumps to verify offset math + let multisig_addr = Address::from([0xAA; 32]); + let vault_index: u8 = 0; + let (vault, _) = vault_pda(&multisig_addr, vault_index); + + // Minimal inner message: just the vault key, no extra accounts + let mut inner_msg = vec![1, 1, 0, 1]; // 1 key (vault only) + inner_msg.extend_from_slice(vault.as_ref()); + inner_msg.push(0); // 0 instructions + inner_msg.push(0); // 0 lookups + + let mut data = vec![0u8; 87]; + data[8..40].copy_from_slice(&[0xAA; 32]); // multisig + data[40..72].copy_from_slice(&[0xCC; 32]); // creator + data[72..80].copy_from_slice(&1u64.to_le_bytes()); + data[80] = 255; + data[81] = vault_index; + data[82] = 254; + // ephemeral_signer_bumps: 2 bumps + data[83..87].copy_from_slice(&2u32.to_le_bytes()); + data.push(200); // bump 0 + data.push(201); // bump 1 + // message comes after bumps + data.extend_from_slice(&(inner_msg.len() as u32).to_le_bytes()); + data.extend_from_slice(&inner_msg); + + let ms = MultisigState { + threshold: 1, + transaction_index: 1, + members: vec![], + }; + + let ix = vault_transaction_execute_ix( + &multisig_addr, + &Address::from([0xEE; 32]), + &Address::from([0xFF; 32]), + &Address::from([0xDD; 32]), + &data, + &ms, + ) + .unwrap(); + + // Should still find the vault correctly despite the bump offset + assert_eq!(ix.program_id, SQUADS_PROGRAM_ID); + // accounts: multisig, transaction, proposal, member, vault (no remaining, no members) + assert_eq!(ix.accounts[4].pubkey, vault); + } +} diff --git a/cli/src/rpc.rs b/cli/src/rpc.rs new file mode 100644 index 0000000..379ca52 --- /dev/null +++ b/cli/src/rpc.rs @@ -0,0 +1,475 @@ +use { + ed25519_dalek::SigningKey, + solana_address::Address, + solana_hash::Hash, + solana_signature::Signature, + solana_signer::{Signer, SignerError}, + std::{fs, path::Path}, +}; + +// --------------------------------------------------------------------------- +// Solana CLI config +// --------------------------------------------------------------------------- + +/// Resolve a cluster name or URL to a full RPC endpoint. +/// +/// Accepts `mainnet-beta`, `devnet`, `testnet`, `localnet`, or a full URL. +/// Falls back to the Solana CLI config if no override is provided. +pub fn solana_rpc_url(url_override: Option<&str>) -> String { + if let Some(url) = url_override { + return resolve_cluster(url); + } + read_config_field("json_rpc_url") + .unwrap_or_else(|| "https://api.mainnet-beta.solana.com".to_string()) +} + +pub fn resolve_cluster(input: &str) -> String { + match input { + "mainnet-beta" => "https://api.mainnet-beta.solana.com".to_string(), + "devnet" => "https://api.devnet.solana.com".to_string(), + "testnet" => "https://api.testnet.solana.com".to_string(), + "localnet" => "http://localhost:8899".to_string(), + url => url.to_string(), + } +} + +pub fn solana_keypair_path(keypair_override: Option<&Path>) -> std::path::PathBuf { + if let Some(p) = keypair_override { + return p.to_path_buf(); + } + read_config_field("keypair_path") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_default() + .join(".config/solana/id.json") + }) +} + +fn read_config_field(field: &str) -> Option { + let config_path = dirs::home_dir()?.join(".config/solana/cli/config.yml"); + let contents = fs::read_to_string(config_path).ok()?; + // Simple YAML parsing — find "field: value" line + contents.lines().find_map(|line| { + let line = line.trim(); + let prefix = format!("{field}:"); + if line.starts_with(&prefix) { + let value = line[prefix.len()..] + .trim() + .trim_matches('\'') + .trim_matches('"') + .to_string(); + Some(expand_tilde(&value)) + } else { + None + } + }) +} + +/// Expand a leading `~` to the user's home directory. +pub(crate) fn expand_tilde(path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return format!("{}/{rest}", home.display()); + } + } + path.to_string() +} + +// --------------------------------------------------------------------------- +// Keypair +// --------------------------------------------------------------------------- + +/// Thin wrapper around ed25519-dalek SigningKey that implements solana Signer. +pub struct Keypair(SigningKey); + +impl Keypair { + /// Read a Solana keypair JSON file (array of 64 bytes). + pub fn read_from_file(path: &Path) -> Result { + let contents = fs::read_to_string(path)?; + let bytes: Vec = serde_json::from_str(&contents).map_err(anyhow::Error::from)?; + if bytes.len() != 64 { + return Err(anyhow::anyhow!( + "keypair file must contain exactly 64 bytes, got {}", + bytes.len() + ) + .into()); + } + let secret: [u8; 32] = bytes[..32].try_into().unwrap(); + Ok(Self(SigningKey::from_bytes(&secret))) + } + + /// Generate a random keypair using the OS random number generator. + pub fn generate() -> Self { + let mut rng = rand::rngs::OsRng; + Self(SigningKey::generate(&mut rng)) + } + + pub fn address(&self) -> Address { + Address::from(self.0.verifying_key().to_bytes()) + } +} + +impl Signer for Keypair { + fn try_pubkey(&self) -> Result { + Ok(self.address()) + } + + fn try_sign_message(&self, message: &[u8]) -> Result { + use ed25519_dalek::Signer as _; + Ok(Signature::from(self.0.sign(message).to_bytes())) + } + + fn is_interactive(&self) -> bool { + false + } +} + +// --------------------------------------------------------------------------- +// RPC (raw JSON-RPC via ureq) +// --------------------------------------------------------------------------- + +/// Create a ureq agent with a 30-second global timeout. +fn rpc_agent() -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(30))) + .build() + .new_agent() +} + +/// Fetch the latest blockhash from the RPC. +pub fn get_latest_blockhash(rpc_url: &str) -> Result { + let resp: serde_json::Value = rpc_agent() + .post(rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getLatestBlockhash", + "params": [{"commitment": "confirmed"}] + })) + .map_err(anyhow::Error::from)? + .body_mut() + .read_json() + .map_err(anyhow::Error::from)?; + + if let Some(err) = resp.get("error") { + return Err(anyhow::anyhow!("RPC error: {}", err).into()); + } + + let hash_str = resp["result"]["value"]["blockhash"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing blockhash in RPC response"))?; + + let bytes: [u8; 32] = bs58::decode(hash_str) + .into_vec() + .map_err(|e| anyhow::anyhow!("invalid blockhash: {e}"))? + .try_into() + .map_err(|_| anyhow::anyhow!("blockhash wrong length"))?; + + Ok(Hash::from(bytes)) +} + +/// Send a signed transaction to the RPC. Returns the signature string. +pub fn send_transaction(rpc_url: &str, tx_bytes: &[u8]) -> Result { + use base64::{engine::general_purpose::STANDARD, Engine}; + let encoded = STANDARD.encode(tx_bytes); + + let resp: serde_json::Value = rpc_agent() + .post(rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "sendTransaction", + "params": [encoded, {"encoding": "base64", "skipPreflight": false}] + })) + .map_err(anyhow::Error::from)? + .body_mut() + .read_json() + .map_err(anyhow::Error::from)?; + + if let Some(err) = resp.get("error") { + return Err(anyhow::anyhow!("RPC error: {}", err).into()); + } + + resp["result"] + .as_str() + .map(String::from) + .ok_or_else(|| anyhow::anyhow!("missing signature in RPC response").into()) +} + +/// Fetch account data as raw bytes. Returns None if account doesn't exist. +pub fn get_account_data( + rpc_url: &str, + address: &Address, +) -> Result>, crate::error::CliError> { + let resp: serde_json::Value = rpc_agent() + .post(rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getAccountInfo", + "params": [bs58::encode(address).into_string(), {"encoding": "base64", "commitment": "confirmed"}] + })) + .map_err(anyhow::Error::from)? + .body_mut() + .read_json() + .map_err(anyhow::Error::from)?; + + if let Some(err) = resp.get("error") { + return Err(anyhow::anyhow!("RPC error: {}", err).into()); + } + + let value = &resp["result"]["value"]; + if value.is_null() { + return Ok(None); + } + + let data_str = value["data"][0] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing account data"))?; + + use base64::{engine::general_purpose::STANDARD, Engine}; + Ok(Some( + STANDARD.decode(data_str).map_err(anyhow::Error::from)?, + )) +} + +/// Check whether a program exists on-chain at the given address. +/// Returns `true` if the account exists and is owned by the BPF Loader Upgradeable. +pub fn program_exists_on_chain( + rpc_url: &str, + program_id: &Address, +) -> Result { + let resp: serde_json::Value = rpc_agent() + .post(rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getAccountInfo", + "params": [ + bs58::encode(program_id).into_string(), + {"encoding": "base64", "commitment": "confirmed"} + ] + })) + .map_err(anyhow::Error::from)? + .body_mut() + .read_json() + .map_err(anyhow::Error::from)?; + + if let Some(err) = resp.get("error") { + return Err(anyhow::anyhow!("RPC error: {}", err).into()); + } + + let value = &resp["result"]["value"]; + if value.is_null() { + return Ok(false); + } + + // Check if owned by BPF Loader Upgradeable + let owner = value["owner"].as_str().unwrap_or_default(); + Ok(owner == "BPFLoaderUpgradeab1e11111111111111111111111") +} + +/// Query recent prioritization fees and return the median in micro-lamports. +/// Returns 0 if no recent fees are available. +pub fn get_recent_prioritization_fees(rpc_url: &str) -> Result { + let resp: serde_json::Value = rpc_agent() + .post(rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getRecentPrioritizationFees", + "params": [] + })) + .map_err(anyhow::Error::from)? + .body_mut() + .read_json() + .map_err(anyhow::Error::from)?; + + if let Some(err) = resp.get("error") { + return Err(anyhow::anyhow!("RPC error: {}", err).into()); + } + + let entries = resp["result"] + .as_array() + .cloned() + .unwrap_or_default(); + + let mut fees: Vec = entries + .iter() + .filter_map(|e| e["prioritizationFee"].as_u64()) + .filter(|&f| f > 0) + .collect(); + + Ok(median_fee(&mut fees)) +} + +/// Poll `getSignatureStatuses` until the transaction reaches `confirmed` +/// commitment or the timeout expires. Returns true if confirmed. +pub fn confirm_transaction( + rpc_url: &str, + signature: &str, + timeout_secs: u64, +) -> Result { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(timeout_secs); + + loop { + if start.elapsed() >= timeout { + return Ok(false); + } + + let resp: serde_json::Value = rpc_agent() + .post(rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getSignatureStatuses", + "params": [[signature]] + })) + .map_err(anyhow::Error::from)? + .body_mut() + .read_json() + .map_err(anyhow::Error::from)?; + + if let Some(status) = resp["result"]["value"][0].as_object() { + if status.get("err").is_some() && !status["err"].is_null() { + return Err(anyhow::anyhow!( + "transaction failed: {}", + status["err"] + ) + .into()); + } + let confirmation = status + .get("confirmationStatus") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if confirmation == "confirmed" || confirmation == "finalized" { + return Ok(true); + } + } + + std::thread::sleep(std::time::Duration::from_millis(500)); + } +} + +/// Query the minimum balance for rent exemption for a given data length. +pub fn get_minimum_balance_for_rent_exemption( + rpc_url: &str, + data_len: usize, +) -> Result { + let resp: serde_json::Value = rpc_agent() + .post(rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getMinimumBalanceForRentExemption", + "params": [data_len] + })) + .map_err(anyhow::Error::from)? + .body_mut() + .read_json() + .map_err(anyhow::Error::from)?; + + if let Some(err) = resp.get("error") { + return Err(anyhow::anyhow!("RPC error: {}", err).into()); + } + + resp["result"] + .as_u64() + .ok_or_else(|| anyhow::anyhow!("missing rent exemption in RPC response").into()) +} + +fn median_fee(fees: &mut [u64]) -> u64 { + if fees.is_empty() { + return 0; + } + fees.sort_unstable(); + let mid = fees.len() / 2; + if fees.len().is_multiple_of(2) { + (fees[mid - 1] + fees[mid]) / 2 + } else { + fees[mid] + } +} + +/// Read a program ID (public key) from a Solana keypair file. +/// Public key is bytes 32..64 of the 64-byte keypair. +pub fn read_program_id_from_keypair(path: &Path) -> Result { + if !path.exists() { + return Err(anyhow::anyhow!( + "program keypair not found: {}", + path.display() + ) + .into()); + } + let contents = fs::read_to_string(path)?; + let bytes: Vec = serde_json::from_str(&contents).map_err(anyhow::Error::from)?; + if bytes.len() != 64 { + return Err(anyhow::anyhow!( + "program keypair must contain exactly 64 bytes, got {}", + bytes.len() + ) + .into()); + } + Ok(Address::from( + <[u8; 32]>::try_from(&bytes[32..64]).unwrap(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tilde_expansion() { + let expanded = expand_tilde("~/foo/bar"); + assert!(!expanded.starts_with('~'), "tilde should be expanded"); + assert!(expanded.ends_with("/foo/bar")); + + // Non-tilde paths are unchanged + assert_eq!(expand_tilde("/absolute/path"), "/absolute/path"); + assert_eq!(expand_tilde("relative/path"), "relative/path"); + } + + #[test] + fn cluster_name_resolution() { + assert_eq!( + resolve_cluster("mainnet-beta"), + "https://api.mainnet-beta.solana.com" + ); + assert_eq!( + resolve_cluster("devnet"), + "https://api.devnet.solana.com" + ); + assert_eq!( + resolve_cluster("testnet"), + "https://api.testnet.solana.com" + ); + assert_eq!(resolve_cluster("localnet"), "http://localhost:8899"); + assert_eq!( + resolve_cluster("https://my-rpc.example.com"), + "https://my-rpc.example.com" + ); + } + + #[test] + fn priority_fee_median_odd() { + assert_eq!(median_fee(&mut vec![100, 300, 200]), 200); + } + + #[test] + fn priority_fee_median_even() { + assert_eq!(median_fee(&mut vec![100, 200, 300, 400]), 250); + } + + #[test] + fn priority_fee_median_empty() { + assert_eq!(median_fee(&mut vec![]), 0); + } + + #[test] + fn priority_fee_median_single() { + assert_eq!(median_fee(&mut vec![500]), 500); + } +} diff --git a/cli/src/sync.rs b/cli/src/sync.rs new file mode 100644 index 0000000..e42119b --- /dev/null +++ b/cli/src/sync.rs @@ -0,0 +1,72 @@ +use { + crate::{error::CliResult, style, toolchain}, + std::{ + path::Path, + process::{Command, Stdio}, + }, +}; + +pub fn run() -> CliResult { + if !Path::new("Cargo.lock").exists() { + let sp = style::spinner("Generating lockfile..."); + let output = Command::new("cargo") + .arg("generate-lockfile") + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output(); + sp.finish_and_clear(); + + match output { + Ok(o) if o.status.success() => {} + _ => { + eprintln!( + " {}", + style::fail("Failed to generate Cargo.lock. Is this a valid Cargo project?") + ); + std::process::exit(1); + } + } + } + + let version = match toolchain::detect_quasar_lang_version(Path::new(".")) { + Some(v) => v, + None => { + eprintln!(" {}", style::fail("Could not detect quasar-lang version.")); + eprintln!(); + eprintln!(" Is this a Quasar project?"); + std::process::exit(1); + } + }; + + println!(); + println!(" {} quasar-lang v{version}", style::dim("Detected:"),); + + match toolchain::requirements_for(&version) { + Some(reqs) => { + println!( + " {} Solana CLI v{}, Rust >= v{}", + style::dim("Required:"), + reqs.solana_version, + reqs.rust_version, + ); + } + None => { + println!(" {} unknown (run quasar update)", style::dim("Required:"),); + } + } + + if let Some(ref installed) = toolchain::installed_solana_version() { + println!(" {} Solana CLI v{installed}", style::dim("Installed:"),); + } + if let Some(ref installed) = toolchain::installed_rust_version() { + println!(" {} Rust v{installed}", style::dim("Installed:"),); + } + println!(); + + toolchain::ensure_toolchain(Path::new(".")); + + println!(" {}", style::success("Toolchain is ready.")); + println!(); + + Ok(()) +} diff --git a/cli/src/toolchain.rs b/cli/src/toolchain.rs index 0c45144..90e28f2 100644 --- a/cli/src/toolchain.rs +++ b/cli/src/toolchain.rs @@ -1,4 +1,198 @@ -use std::process::Command; +use { + crate::style, + std::{path::Path, process::Command}, +}; + +// --------------------------------------------------------------------------- +// Compatibility matrix +// --------------------------------------------------------------------------- + +/// Required toolchain versions for a given quasar-lang version. +pub struct ToolchainRequirements { + pub solana_version: &'static str, + pub rust_version: &'static str, +} + +/// Compatibility matrix: maps quasar-lang versions to required toolchain +/// versions. Updated with each CLI release. +/// +/// `solana_version` is a concrete installable version (e.g. "2.1.21"). +/// Comparison uses major.minor only — any 2.1.x satisfies a "2.1.21" +/// requirement. `rust_version` is a minimum version (e.g. "1.87.0" means >= +/// 1.87.0). +const COMPAT_TABLE: &[(&str, ToolchainRequirements)] = &[ + ( + "0.0.0", + ToolchainRequirements { + solana_version: "3.0.0", + rust_version: "1.87.0", + }, + ), + // ("0.1.0", ToolchainRequirements { solana_version: "3.1.0", rust_version: "1.87.0" }), +]; + +/// The latest quasar-lang version this CLI knows about. +/// Used by `quasar init` to pin the framework version in new projects. +pub const LATEST_KNOWN_VERSION: &str = "0.0.0"; + +/// Look up toolchain requirements for a quasar-lang version. +/// Returns None if the version is unknown (CLI needs updating). +pub fn requirements_for(quasar_lang_version: &str) -> Option<&'static ToolchainRequirements> { + COMPAT_TABLE + .iter() + .find(|(v, _)| *v == quasar_lang_version) + .map(|(_, req)| req) +} + +// --------------------------------------------------------------------------- +// Version detection +// --------------------------------------------------------------------------- + +/// Read the quasar-lang version from a project's Cargo.toml. +/// Looks for `quasar-lang = "X.Y.Z"` or `quasar-lang = { version = "X.Y.Z", ... +/// }`. Falls back to Cargo.lock if Cargo.toml doesn't have a pinned version +/// (e.g. git dep). +pub fn detect_quasar_lang_version(project_root: &Path) -> Option { + if let Some(v) = read_version_from_cargo_toml(project_root) { + return Some(v); + } + read_version_from_cargo_lock(project_root) +} + +fn read_version_from_cargo_toml(project_root: &Path) -> Option { + let toml_path = project_root.join("Cargo.toml"); + let contents = std::fs::read_to_string(&toml_path).ok()?; + let parsed: toml::Value = contents.parse().ok()?; + + let deps = parsed.get("dependencies")?; + let quasar = deps.get("quasar-lang")?; + + match quasar { + toml::Value::String(v) => Some(v.clone()), + toml::Value::Table(t) => t.get("version")?.as_str().map(String::from), + _ => None, + } +} + +fn read_version_from_cargo_lock(project_root: &Path) -> Option { + let lock_path = project_root.join("Cargo.lock"); + let contents = std::fs::read_to_string(&lock_path).ok()?; + let lock: toml::Value = contents.parse().ok()?; + let packages = lock.get("package")?.as_array()?; + + packages.iter().find_map(|pkg| { + let name = pkg.get("name")?.as_str()?; + if name == "quasar-lang" { + pkg.get("version")?.as_str().map(String::from) + } else { + None + } + }) +} + +/// Get the installed Solana CLI version (e.g. "2.1.6"). +pub fn installed_solana_version() -> Option { + let output = Command::new("solana").arg("--version").output().ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .split_whitespace() + .nth(1) + .map(|v| v.trim().to_string()) +} + +/// Get the installed Rust compiler version (e.g. "1.87.0"). +pub fn installed_rust_version() -> Option { + let output = Command::new("rustc").arg("--version").output().ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .split_whitespace() + .nth(1) + .map(|v| v.split('-').next().unwrap_or(v).to_string()) +} + +// --------------------------------------------------------------------------- +// Toolchain auto-switch +// --------------------------------------------------------------------------- + +/// Ensure the project's toolchain requirements are met. +/// +/// - No version found: silently skips. +/// - Unknown version: warns and proceeds (CLI needs updating). +/// - Solana major.minor mismatch: auto-installs correct version. +/// - Rust below minimum: warns (user manages via rustup). +pub fn ensure_toolchain(project_root: &Path) { + let version = match detect_quasar_lang_version(project_root) { + Some(v) => v, + None => return, + }; + + let reqs = match requirements_for(&version) { + Some(r) => r, + None => { + eprintln!(); + eprintln!( + " {} quasar-lang v{version} is not recognized by this CLI version.", + style::warn(""), + ); + eprintln!(" Could not auto-switch Solana and Rust versions."); + eprintln!( + " Run {} to get the latest toolchain mappings.", + style::bold("quasar update") + ); + eprintln!(); + return; + } + }; + + match installed_solana_version() { + Some(ref installed) if major_minor(installed) == major_minor(reqs.solana_version) => {} + Some(ref installed) => { + eprintln!(); + eprintln!( + " {} Switching Solana CLI: v{installed} -> v{} (required by quasar-lang \ + v{version})", + style::dim(""), + reqs.solana_version, + ); + install_solana(reqs.solana_version); + } + None => { + eprintln!(); + eprintln!( + " {} Installing Solana CLI v{} (required by quasar-lang v{version})...", + style::dim(""), + reqs.solana_version, + ); + install_solana(reqs.solana_version); + } + } + + if let Some(ref installed) = installed_rust_version() { + if version_less_than(installed, reqs.rust_version) { + eprintln!(); + eprintln!( + " {} Rust v{installed} found, but quasar-lang v{version} requires >= v{}.", + style::warn(""), + reqs.rust_version, + ); + eprintln!( + " Run {} to update.", + style::bold(&format!("rustup install {}", reqs.rust_version)) + ); + eprintln!(); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- /// Check whether sbpf-linker is reachable on PATH. pub fn has_sbpf_linker() -> bool { @@ -8,3 +202,169 @@ pub fn has_sbpf_linker() -> bool { .ok() .is_some_and(|o| o.status.success()) } + +fn major_minor(v: &str) -> String { + let mut parts = v.split('.'); + let major = parts.next().unwrap_or("0"); + let minor = parts.next().unwrap_or("0"); + format!("{major}.{minor}") +} + +fn version_less_than(a: &str, b: &str) -> bool { + let parse = |v: &str| -> (u32, u32, u32) { + let mut parts = v.split('.').map(|p| p.parse::().unwrap_or(0)); + ( + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + ) + }; + parse(a) < parse(b) +} + +fn install_solana(version: &str) { + let result = Command::new("agave-install") + .args(["init", version]) + .status(); + + match result { + Ok(status) if status.success() => { + eprintln!( + " {}", + style::success(&format!("Solana CLI v{version} ready.")) + ); + } + _ => { + let result = Command::new("solana-install") + .args(["init", version]) + .status(); + + match result { + Ok(status) if status.success() => { + eprintln!( + " {}", + style::success(&format!("Solana CLI v{version} ready.")) + ); + } + _ => { + eprintln!(); + eprintln!( + " {}", + style::fail(&format!("Failed to install Solana CLI v{version}.")) + ); + eprintln!(); + eprintln!( + " Install manually: {}", + style::bold(&format!( + "sh -c \"$(curl -sSfL https://release.anza.xyz/v{version}/install)\"" + )) + ); + eprintln!(); + std::process::exit(1); + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_version_from_cargo_toml_string() { + let dir = tempfile::tempdir().unwrap(); + let content = r#" +[package] +name = "test" +version = "0.1.0" + +[dependencies] +quasar-lang = "0.2.0" +"#; + std::fs::write(dir.path().join("Cargo.toml"), content).unwrap(); + assert_eq!( + detect_quasar_lang_version(dir.path()), + Some("0.2.0".to_string()) + ); + } + + #[test] + fn detect_version_from_cargo_toml_table() { + let dir = tempfile::tempdir().unwrap(); + let content = r#" +[package] +name = "test" +version = "0.1.0" + +[dependencies] +quasar-lang = { version = "0.1.0", features = ["alloc"] } +"#; + std::fs::write(dir.path().join("Cargo.toml"), content).unwrap(); + assert_eq!( + detect_quasar_lang_version(dir.path()), + Some("0.1.0".to_string()) + ); + } + + #[test] + fn detect_version_git_dep_falls_back_to_lockfile() { + let dir = tempfile::tempdir().unwrap(); + let cargo_toml = r#" +[package] +name = "test" +version = "0.1.0" + +[dependencies] +quasar-lang = { git = "https://github.com/blueshift-gg/quasar" } +"#; + let cargo_lock = r#" +[[package]] +name = "quasar-lang" +version = "0.0.0" +source = "git+https://github.com/blueshift-gg/quasar" +"#; + std::fs::write(dir.path().join("Cargo.toml"), cargo_toml).unwrap(); + std::fs::write(dir.path().join("Cargo.lock"), cargo_lock).unwrap(); + assert_eq!( + detect_quasar_lang_version(dir.path()), + Some("0.0.0".to_string()) + ); + } + + #[test] + fn detect_version_no_files() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(detect_quasar_lang_version(dir.path()), None); + } + + #[test] + fn requirements_known_version() { + assert!(requirements_for("0.0.0").is_some()); + } + + #[test] + fn requirements_unknown_version() { + assert!(requirements_for("99.99.99").is_none()); + } + + #[test] + fn version_comparison() { + assert!(version_less_than("1.86.0", "1.87.0")); + assert!(version_less_than("1.87.0", "2.0.0")); + assert!(!version_less_than("1.87.0", "1.87.0")); + assert!(!version_less_than("1.88.0", "1.87.0")); + assert!(version_less_than("0.9.0", "1.0.0")); + } + + #[test] + fn major_minor_extraction() { + assert_eq!(major_minor("2.1.6"), "2.1"); + assert_eq!(major_minor("1.87.0"), "1.87"); + assert_eq!(major_minor("2.1"), "2.1"); + } +} diff --git a/cli/src/update.rs b/cli/src/update.rs new file mode 100644 index 0000000..3f3aa07 --- /dev/null +++ b/cli/src/update.rs @@ -0,0 +1,54 @@ +use { + crate::{error::CliResult, style}, + std::process::{Command, Stdio}, +}; + +pub fn run() -> CliResult { + let current = env!("CARGO_PKG_VERSION"); + eprintln!( + " {} Updating Quasar CLI (current: v{current})...", + style::dim(""), + ); + + let sp = style::spinner("Installing latest quasar-cli..."); + + let output = Command::new("cargo") + .args([ + "install", + "quasar-cli", + "--git", + "https://github.com/blueshift-gg/quasar", + "--force", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(); + + sp.finish_and_clear(); + + match output { + Ok(o) if o.status.success() => { + println!(" {}", style::success("Quasar CLI updated successfully.")); + println!(); + let _ = Command::new("quasar").arg("--version").status(); + Ok(()) + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr); + eprintln!(); + for line in stderr.lines() { + eprintln!(" {line}"); + } + eprintln!(); + eprintln!(" {}", style::fail("update failed")); + std::process::exit(o.status.code().unwrap_or(1)); + } + Err(e) => { + eprintln!( + " {}", + style::fail(&format!("failed to run cargo install: {e}")) + ); + std::process::exit(1); + } + } +} diff --git a/docs/superpowers/plans/2026-03-22-native-deploy.md b/docs/superpowers/plans/2026-03-22-native-deploy.md new file mode 100644 index 0000000..9ff76aa --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-native-deploy.md @@ -0,0 +1,1901 @@ +# Native Deploy with Priority Fees — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace all Solana CLI shell-outs in the deploy pipeline with native Rust RPC calls, add priority fee support, and add pre-deploy validation. + +**Architecture:** Extract shared RPC/keypair code from `multisig.rs` into `rpc.rs`. Create `bpf_loader.rs` for BPF Loader Upgradeable interactions. Update `deploy.rs` to call native orchestrators instead of shelling out. Add `--priority-fee` CLI flag with auto-calculation fallback. + +**Tech Stack:** Rust, ureq (JSON-RPC), solana-transaction/instruction/signer/address crates, ed25519-dalek, bincode, indicatif (progress bar) + +**Spec:** `docs/superpowers/specs/2026-03-22-native-deploy-design.md` + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|---------------| +| `cli/src/rpc.rs` | **Create** | RPC client, Keypair struct, Solana CLI config helpers, priority fee calculation, transaction confirmation | +| `cli/src/bpf_loader.rs` | **Create** | BPF Loader Upgradeable constants, instruction builders, buffer upload, deploy/upgrade orchestrators, authority validation | +| `cli/src/multisig.rs` | **Modify** | Remove all code moved to rpc.rs/bpf_loader.rs, import from new modules, replace shell-outs with native calls, add priority_fee params | +| `cli/src/deploy.rs` | **Modify** | Remove solana_deploy() shell-out, call native orchestrators, add reverse --upgrade check, add authority validation, propagate priority fee | +| `cli/src/lib.rs` | **Modify** | Add `pub mod rpc; pub mod bpf_loader;`, add `priority_fee: Option` to DeployCommand and DeployOpts | + +--- + +### Task 1: Create `rpc.rs` — Extract RPC & Config Code + +Move all RPC helpers, Keypair struct, and Solana CLI config functions from `multisig.rs` into a new `rpc.rs` module. This is a refactor — the only new code is `Keypair::generate()` (needed by Task 5). All existing tests must pass. + +**Files:** +- Create: `cli/src/rpc.rs` +- Modify: `cli/src/lib.rs` (add module declaration) +- Modify: `cli/src/multisig.rs` (remove moved code, add imports from rpc) +- Modify: `cli/src/deploy.rs` (update import paths) + +**What moves from `multisig.rs` to `rpc.rs`:** + +The entire "Solana CLI config" section (lines 17-84): +- `solana_rpc_url(url_override) -> String` +- `resolve_cluster(input) -> String` (make `pub` — needed by tests) +- `solana_keypair_path(keypair_override) -> PathBuf` +- `read_config_field(field) -> Option` +- `expand_tilde(path) -> String` (make `pub(crate)` — needed by tests) + +The entire "Keypair" section (lines 86-127): +- `pub struct Keypair(SigningKey)` + all impls + +The entire "RPC" section (lines 129-260): +- `get_latest_blockhash(rpc_url) -> Result` +- `send_transaction(rpc_url, tx_bytes) -> Result` +- `get_account_data(rpc_url, address) -> Result>>` +- `program_exists_on_chain(rpc_url, program_id) -> Result` + +The `read_program_id_from_keypair` function (lines 807-829). + +**Tests that move to `rpc.rs`:** +- `tilde_expansion` (line 1311) +- `cluster_name_resolution` (line 1322) + +- [ ] **Step 1: Create `cli/src/rpc.rs` with moved code** + +```rust +use { + ed25519_dalek::SigningKey, + solana_address::Address, + solana_hash::Hash, + solana_signature::Signature, + solana_signer::{Signer, SignerError}, + std::{fs, path::Path}, +}; + +// --------------------------------------------------------------------------- +// Solana CLI config +// --------------------------------------------------------------------------- + +/// Resolve a cluster name or URL to a full RPC endpoint. +pub fn solana_rpc_url(url_override: Option<&str>) -> String { + if let Some(url) = url_override { + return resolve_cluster(url); + } + read_config_field("json_rpc_url") + .unwrap_or_else(|| "https://api.mainnet-beta.solana.com".to_string()) +} + +pub fn resolve_cluster(input: &str) -> String { + match input { + "mainnet-beta" => "https://api.mainnet-beta.solana.com".to_string(), + "devnet" => "https://api.devnet.solana.com".to_string(), + "testnet" => "https://api.testnet.solana.com".to_string(), + "localnet" => "http://localhost:8899".to_string(), + url => url.to_string(), + } +} + +pub fn solana_keypair_path(keypair_override: Option<&Path>) -> std::path::PathBuf { + if let Some(p) = keypair_override { + return p.to_path_buf(); + } + read_config_field("keypair_path") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_default() + .join(".config/solana/id.json") + }) +} + +fn read_config_field(field: &str) -> Option { + let config_path = dirs::home_dir()?.join(".config/solana/cli/config.yml"); + let contents = fs::read_to_string(config_path).ok()?; + contents.lines().find_map(|line| { + let line = line.trim(); + let prefix = format!("{field}:"); + if line.starts_with(&prefix) { + let value = line[prefix.len()..] + .trim() + .trim_matches('\'') + .trim_matches('"') + .to_string(); + Some(expand_tilde(&value)) + } else { + None + } + }) +} + +pub(crate) fn expand_tilde(path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return format!("{}/{rest}", home.display()); + } + } + path.to_string() +} + +// --------------------------------------------------------------------------- +// Keypair +// --------------------------------------------------------------------------- + +/// Thin wrapper around ed25519-dalek SigningKey that implements solana Signer. +pub struct Keypair(SigningKey); + +impl Keypair { + pub fn read_from_file(path: &Path) -> Result { + let contents = fs::read_to_string(path)?; + let bytes: Vec = serde_json::from_str(&contents).map_err(anyhow::Error::from)?; + if bytes.len() != 64 { + return Err(anyhow::anyhow!( + "keypair file must contain exactly 64 bytes, got {}", + bytes.len() + ) + .into()); + } + let secret: [u8; 32] = bytes[..32].try_into().unwrap(); + Ok(Self(SigningKey::from_bytes(&secret))) + } + + /// Create a random keypair (for buffer accounts). + pub fn generate() -> Self { + use rand::rngs::OsRng; + Self(SigningKey::generate(&mut OsRng)) + } + + pub fn address(&self) -> Address { + Address::from(self.0.verifying_key().to_bytes()) + } +} + +impl Signer for Keypair { + fn try_pubkey(&self) -> Result { + Ok(self.address()) + } + + fn try_sign_message(&self, message: &[u8]) -> Result { + use ed25519_dalek::Signer as _; + Ok(Signature::from(self.0.sign(message).to_bytes())) + } + + fn is_interactive(&self) -> bool { + false + } +} + +// --------------------------------------------------------------------------- +// RPC (raw JSON-RPC via ureq) +// --------------------------------------------------------------------------- + +pub fn get_latest_blockhash(rpc_url: &str) -> Result { + // ... exact copy from multisig.rs lines 134-162 +} + +pub fn send_transaction(rpc_url: &str, tx_bytes: &[u8]) -> Result { + // ... exact copy from multisig.rs lines 165-189 +} + +pub fn get_account_data( + rpc_url: &str, + address: &Address, +) -> Result>, crate::error::CliError> { + // ... exact copy from multisig.rs lines 192-225 +} + +pub fn program_exists_on_chain( + rpc_url: &str, + program_id: &Address, +) -> Result { + // ... exact copy from multisig.rs lines 229-260 +} + +pub fn read_program_id_from_keypair(path: &Path) -> Result { + // ... exact copy from multisig.rs lines 809-829 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tilde_expansion() { + let expanded = expand_tilde("~/foo/bar"); + assert!(!expanded.starts_with('~'), "tilde should be expanded"); + assert!(expanded.ends_with("/foo/bar")); + assert_eq!(expand_tilde("/absolute/path"), "/absolute/path"); + assert_eq!(expand_tilde("relative/path"), "relative/path"); + } + + #[test] + fn cluster_name_resolution() { + assert_eq!(resolve_cluster("mainnet-beta"), "https://api.mainnet-beta.solana.com"); + assert_eq!(resolve_cluster("devnet"), "https://api.devnet.solana.com"); + assert_eq!(resolve_cluster("testnet"), "https://api.testnet.solana.com"); + assert_eq!(resolve_cluster("localnet"), "http://localhost:8899"); + assert_eq!(resolve_cluster("https://my-rpc.example.com"), "https://my-rpc.example.com"); + } +} +``` + +Note: Copy the full function bodies from `multisig.rs` — the `// ...` comments above are shorthand for the plan, not actual code. + +- [ ] **Step 2: Add `pub mod rpc;` to `lib.rs`** + +In `cli/src/lib.rs`, add after `pub mod multisig;` (line 16): + +```rust +pub mod rpc; +``` + +- [ ] **Step 3: Update `multisig.rs` — remove moved code, add imports** + +Remove the following sections from `multisig.rs`: +- Lines 1-15: Replace the `use` block imports (remove `ed25519_dalek::SigningKey`, `solana_hash::Hash`, `solana_signature::Signature`, `solana_signer::{Signer, SignerError}`, `std::fs`) +- Lines 17-84: Remove `solana_rpc_url`, `resolve_cluster`, `solana_keypair_path`, `read_config_field`, `expand_tilde` +- Lines 86-127: Remove `Keypair` struct and impl +- Lines 129-260: Remove `get_latest_blockhash`, `send_transaction`, `get_account_data`, `program_exists_on_chain` +- Lines 807-829: Remove `read_program_id_from_keypair` + +Replace with imports at the top: + +```rust +use { + crate::{ + rpc::{ + self, get_account_data, get_latest_blockhash, send_transaction, Keypair, + }, + style, + }, + sha2::{Digest, Sha256}, + solana_address::Address, + solana_instruction::AccountMeta, + std::{ + path::Path, + process::{Command, Stdio}, + }, +}; +``` + +All functions in multisig.rs that previously called `Keypair::read_from_file`, `get_latest_blockhash`, `send_transaction`, `get_account_data`, `read_program_id_from_keypair` now use the imported versions from `crate::rpc`. + +Remove moved tests from `multisig.rs::tests`: +- `tilde_expansion` +- `cluster_name_resolution` + +- [ ] **Step 4: Update `deploy.rs` import paths** + +In `deploy.rs`, change all `crate::multisig::` references for moved functions: +- `crate::multisig::solana_keypair_path` → `crate::rpc::solana_keypair_path` +- `crate::multisig::solana_rpc_url` → `crate::rpc::solana_rpc_url` +- `crate::multisig::read_program_id_from_keypair` → `crate::rpc::read_program_id_from_keypair` +- `crate::multisig::program_exists_on_chain` → `crate::rpc::program_exists_on_chain` + +Keep `crate::multisig::` for: `vault_pda`, `set_upgrade_authority`, `short_addr`, `propose_upgrade`, `show_proposal_status`. + +- [ ] **Step 5: Run tests to verify refactor** + +Run: `cargo test -p quasar-cli` +Expected: All 30 tests pass. Zero functionality changed. + +Run: `cargo clippy -p quasar-cli -- -D warnings` +Expected: Clean. + +- [ ] **Step 6: Commit** + +```bash +git add cli/src/rpc.rs cli/src/lib.rs cli/src/multisig.rs cli/src/deploy.rs +git commit -m "refactor: extract rpc.rs from multisig.rs" +``` + +--- + +### Task 2: Add New RPC Functions to `rpc.rs` + +Add priority fee calculation, transaction confirmation, and rent exemption query. + +**Files:** +- Modify: `cli/src/rpc.rs` + +- [ ] **Step 1: Write tests for priority fee median calculation** + +Add to the `tests` module in `rpc.rs`: + +```rust +#[test] +fn priority_fee_median_odd() { + assert_eq!(median_fee(&mut vec![100, 300, 200]), 200); +} + +#[test] +fn priority_fee_median_even() { + assert_eq!(median_fee(&mut vec![100, 200, 300, 400]), 250); +} + +#[test] +fn priority_fee_median_empty() { + assert_eq!(median_fee(&mut vec![]), 0); +} + +#[test] +fn priority_fee_median_single() { + assert_eq!(median_fee(&mut vec![500]), 500); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p quasar-cli median` +Expected: FAIL — `median_fee` not found. + +- [ ] **Step 3: Implement `median_fee` helper** + +Add to `rpc.rs` (private helper): + +```rust +fn median_fee(fees: &mut Vec) -> u64 { + if fees.is_empty() { + return 0; + } + fees.sort_unstable(); + let mid = fees.len() / 2; + if fees.len() % 2 == 0 { + (fees[mid - 1] + fees[mid]) / 2 + } else { + fees[mid] + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p quasar-cli median` +Expected: All 4 pass. + +- [ ] **Step 5: Implement `get_recent_prioritization_fees`** + +```rust +/// Query recent prioritization fees and return the median in micro-lamports. +/// Returns 0 if no recent fees are available. +pub fn get_recent_prioritization_fees(rpc_url: &str) -> Result { + let resp: serde_json::Value = ureq::post(rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getRecentPrioritizationFees", + "params": [] + })) + .map_err(anyhow::Error::from)? + .body_mut() + .read_json() + .map_err(anyhow::Error::from)?; + + if let Some(err) = resp.get("error") { + return Err(anyhow::anyhow!("RPC error: {}", err).into()); + } + + let entries = resp["result"] + .as_array() + .cloned() + .unwrap_or_default(); + + let mut fees: Vec = entries + .iter() + .filter_map(|e| e["prioritizationFee"].as_u64()) + .filter(|&f| f > 0) + .collect(); + + Ok(median_fee(&mut fees)) +} +``` + +- [ ] **Step 6: Implement `confirm_transaction`** + +```rust +/// Poll `getSignatureStatuses` until the transaction reaches `confirmed` +/// commitment or the timeout expires. Returns true if confirmed. +pub fn confirm_transaction( + rpc_url: &str, + signature: &str, + timeout_secs: u64, +) -> Result { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(timeout_secs); + + loop { + if start.elapsed() >= timeout { + return Ok(false); + } + + let resp: serde_json::Value = ureq::post(rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getSignatureStatuses", + "params": [[signature]] + })) + .map_err(anyhow::Error::from)? + .body_mut() + .read_json() + .map_err(anyhow::Error::from)?; + + if let Some(status) = resp["result"]["value"][0].as_object() { + if status.get("err").is_some() && !status["err"].is_null() { + return Err(anyhow::anyhow!( + "transaction failed: {}", + status["err"] + ).into()); + } + let confirmation = status + .get("confirmationStatus") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if confirmation == "confirmed" || confirmation == "finalized" { + return Ok(true); + } + } + + std::thread::sleep(std::time::Duration::from_millis(500)); + } +} +``` + +- [ ] **Step 7: Implement `get_minimum_balance_for_rent_exemption`** + +```rust +/// Query the minimum balance for rent exemption for a given data length. +pub fn get_minimum_balance_for_rent_exemption( + rpc_url: &str, + data_len: usize, +) -> Result { + let resp: serde_json::Value = ureq::post(rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getMinimumBalanceForRentExemption", + "params": [data_len] + })) + .map_err(anyhow::Error::from)? + .body_mut() + .read_json() + .map_err(anyhow::Error::from)?; + + if let Some(err) = resp.get("error") { + return Err(anyhow::anyhow!("RPC error: {}", err).into()); + } + + resp["result"] + .as_u64() + .ok_or_else(|| anyhow::anyhow!("missing rent exemption in RPC response").into()) +} +``` + +- [ ] **Step 8: Run all tests** + +Run: `cargo test -p quasar-cli` +Expected: All tests pass (30 existing + 4 new median tests = 34). + +Run: `cargo clippy -p quasar-cli -- -D warnings` +Expected: Clean. + +- [ ] **Step 9: Commit** + +```bash +git add cli/src/rpc.rs +git commit -m "feat: add priority fee, confirm_transaction, rent exemption RPC helpers" +``` + +--- + +### Task 3: Create `bpf_loader.rs` — Constants & PDA + +Move BPF Loader constants and `programdata_pda` from `multisig.rs`. Add new constants. + +**Files:** +- Create: `cli/src/bpf_loader.rs` +- Modify: `cli/src/lib.rs` (add module declaration) +- Modify: `cli/src/multisig.rs` (remove moved constants, import from bpf_loader) + +- [ ] **Step 1: Create `cli/src/bpf_loader.rs` with constants and PDA** + +```rust +use solana_address::Address; + +// --------------------------------------------------------------------------- +// Program IDs & Sysvars +// --------------------------------------------------------------------------- + +/// BPF Loader Upgradeable — BPFLoaderUpgradeab1e11111111111111111111111. +pub const BPF_LOADER_UPGRADEABLE_ID: Address = Address::new_from_array([ + 0x02, 0xa8, 0xf6, 0x91, 0x4e, 0x88, 0xa1, 0xb0, 0xe2, 0x10, 0x15, 0x3e, 0xf7, 0x63, 0xae, 0x2b, + 0x00, 0xc2, 0xb9, 0x3d, 0x16, 0xc1, 0x24, 0xd2, 0xc0, 0x53, 0x7a, 0x10, 0x04, 0x80, 0x00, 0x00, +]); + +/// System program ID. +pub const SYSTEM_PROGRAM_ID: Address = Address::new_from_array([0; 32]); + +/// Sysvar Rent — SysvarRent111111111111111111111111111111111. +pub const SYSVAR_RENT_ID: Address = Address::new_from_array([ + 6, 167, 213, 23, 25, 44, 92, 81, 33, 140, 201, 76, 61, 74, 241, 127, 88, 218, 238, 8, 155, 161, + 253, 68, 227, 219, 217, 138, 0, 0, 0, 0, +]); + +/// Sysvar Clock — SysvarC1ock11111111111111111111111111111111. +pub const SYSVAR_CLOCK_ID: Address = Address::new_from_array([ + 6, 167, 213, 23, 24, 199, 116, 201, 40, 86, 99, 152, 105, 29, 94, 182, 139, 94, 184, 163, 155, + 75, 109, 92, 115, 85, 91, 33, 0, 0, 0, 0, +]); + +/// ComputeBudget program — ComputeBudget111111111111111111111111111111. +pub const COMPUTE_BUDGET_PROGRAM_ID: Address = Address::new_from_array([ + 3, 6, 70, 111, 229, 33, 23, 50, 255, 236, 173, 186, 114, 195, 155, 231, + 188, 140, 229, 187, 197, 247, 18, 107, 44, 67, 155, 58, 64, 0, 0, 0, +]); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Bytes per Write transaction chunk. Accounts for tx overhead (~212 bytes) +/// plus ComputeBudget::SetComputeUnitPrice instruction (~45 bytes) within +/// the 1232-byte transaction limit. +pub const CHUNK_SIZE: usize = 950; + +/// Buffer account header size: 4 bytes (discriminant u32 LE = 1) + +/// 1 byte (Option tag) + 32 bytes (authority pubkey). +pub const BUFFER_HEADER_SIZE: usize = 37; + +// --------------------------------------------------------------------------- +// PDA derivation +// --------------------------------------------------------------------------- + +/// Derive the programdata PDA for a given program address. +pub fn programdata_pda(program_id: &Address) -> (Address, u8) { + Address::find_program_address(&[program_id.as_ref()], &BPF_LOADER_UPGRADEABLE_ID) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_bpf_loader_id() { + let expected = bs58::decode("BPFLoaderUpgradeab1e11111111111111111111111") + .into_vec() + .unwrap(); + assert_eq!(BPF_LOADER_UPGRADEABLE_ID.as_ref(), &expected[..]); + } + + #[test] + fn verify_sysvar_rent_id() { + let expected = bs58::decode("SysvarRent111111111111111111111111111111111") + .into_vec() + .unwrap(); + assert_eq!(SYSVAR_RENT_ID.as_ref(), &expected[..]); + } + + #[test] + fn verify_sysvar_clock_id() { + let expected = bs58::decode("SysvarC1ock11111111111111111111111111111111") + .into_vec() + .unwrap(); + assert_eq!(SYSVAR_CLOCK_ID.as_ref(), &expected[..]); + } + + #[test] + fn verify_compute_budget_program_id() { + let expected = bs58::decode("ComputeBudget111111111111111111111111111111") + .into_vec() + .unwrap(); + assert_eq!(COMPUTE_BUDGET_PROGRAM_ID.as_ref(), &expected[..]); + } + + #[test] + fn buffer_header_size() { + // 4 (discriminant) + 1 (Option tag) + 32 (authority) = 37 + assert_eq!(BUFFER_HEADER_SIZE, 4 + 1 + 32); + } +} +``` + +- [ ] **Step 2: Add `pub mod bpf_loader;` to `lib.rs`** + +In `cli/src/lib.rs`, add after the `pub mod rpc;` line added in Task 1: + +```rust +pub mod bpf_loader; +``` + +- [ ] **Step 3: Update `multisig.rs` — remove moved constants, import from bpf_loader** + +Remove from `multisig.rs`: +- `BPF_LOADER_UPGRADEABLE_ID` constant (lines 275-281) +- `SYSTEM_PROGRAM_ID` constant (line 284) +- `SYSVAR_RENT_ID` constant (lines 286-291) +- `SYSVAR_CLOCK_ID` constant (lines 293-298) +- `programdata_pda` function (lines 332-334) + +Add to the `use` block at the top of `multisig.rs`: + +```rust +use crate::bpf_loader::{ + BPF_LOADER_UPGRADEABLE_ID, SYSTEM_PROGRAM_ID, SYSVAR_CLOCK_ID, SYSVAR_RENT_ID, + programdata_pda, +}; +``` + +Remove moved tests from `multisig.rs::tests`: +- `verify_bpf_loader_id` +- `verify_sysvar_rent_id` +- `verify_sysvar_clock_id` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p quasar-cli` +Expected: All tests pass (moved tests now in rpc.rs + bpf_loader.rs). + +Run: `cargo clippy -p quasar-cli -- -D warnings` +Expected: Clean. + +- [ ] **Step 5: Commit** + +```bash +git add cli/src/bpf_loader.rs cli/src/lib.rs cli/src/multisig.rs +git commit -m "refactor: extract bpf_loader.rs constants and PDA from multisig.rs" +``` + +--- + +### Task 4: Add BPF Instruction Builders to `bpf_loader.rs` + +Add all 5 BPF Loader Upgradeable instruction builders and the ComputeBudget SetComputeUnitPrice instruction. + +**Files:** +- Modify: `cli/src/bpf_loader.rs` + +- [ ] **Step 1: Write tests for instruction serialization** + +Add to the `tests` module in `bpf_loader.rs`: + +```rust +#[test] +fn initialize_buffer_ix_serialization() { + let buffer = Address::from([1u8; 32]); + let authority = Address::from([2u8; 32]); + let ix = initialize_buffer_ix(&buffer, &authority); + assert_eq!(ix.program_id, BPF_LOADER_UPGRADEABLE_ID); + // Discriminant: 0u32 LE + assert_eq!(&ix.data[..4], &[0, 0, 0, 0]); + assert_eq!(ix.data.len(), 4); + assert_eq!(ix.accounts.len(), 2); + assert!(ix.accounts[0].is_writable); + assert!(!ix.accounts[1].is_writable); +} + +#[test] +fn write_ix_serialization() { + let buffer = Address::from([1u8; 32]); + let authority = Address::from([2u8; 32]); + let chunk = vec![0xAA; 100]; + let ix = write_ix(&buffer, &authority, 500, &chunk); + assert_eq!(ix.program_id, BPF_LOADER_UPGRADEABLE_ID); + // Discriminant: 1u32 LE + assert_eq!(&ix.data[..4], &[1, 0, 0, 0]); + // Offset: 500u32 LE + assert_eq!(&ix.data[4..8], &500u32.to_le_bytes()); + // Length: 100u32 LE + assert_eq!(&ix.data[8..12], &100u32.to_le_bytes()); + // Data + assert_eq!(&ix.data[12..], &chunk[..]); + assert_eq!(ix.accounts.len(), 2); + assert!(ix.accounts[0].is_writable); + assert!(ix.accounts[1].is_signer); +} + +#[test] +fn deploy_with_max_data_len_ix_serialization() { + let payer = Address::from([1u8; 32]); + let programdata = Address::from([2u8; 32]); + let program = Address::from([3u8; 32]); + let buffer = Address::from([4u8; 32]); + let authority = Address::from([5u8; 32]); + let ix = deploy_with_max_data_len_ix(&payer, &programdata, &program, &buffer, &authority, 10000); + assert_eq!(ix.program_id, BPF_LOADER_UPGRADEABLE_ID); + // Discriminant: 2u32 LE + assert_eq!(&ix.data[..4], &[2, 0, 0, 0]); + // max_data_len: 10000u64 LE + assert_eq!(&ix.data[4..12], &10000u64.to_le_bytes()); + assert_eq!(ix.data.len(), 12); + assert_eq!(ix.accounts.len(), 8); + // Verify account ordering: payer, programdata, program, buffer, rent, clock, system, authority + assert_eq!(ix.accounts[0].pubkey, payer); + assert_eq!(ix.accounts[1].pubkey, programdata); + assert_eq!(ix.accounts[2].pubkey, program); + assert_eq!(ix.accounts[3].pubkey, buffer); + assert_eq!(ix.accounts[4].pubkey, SYSVAR_RENT_ID); + assert_eq!(ix.accounts[5].pubkey, SYSVAR_CLOCK_ID); + assert_eq!(ix.accounts[6].pubkey, SYSTEM_PROGRAM_ID); + assert_eq!(ix.accounts[7].pubkey, authority); + assert!(ix.accounts[7].is_signer); // authority must sign +} + +#[test] +fn upgrade_ix_serialization() { + let programdata = Address::from([1u8; 32]); + let program = Address::from([2u8; 32]); + let buffer = Address::from([3u8; 32]); + let spill = Address::from([4u8; 32]); + let authority = Address::from([5u8; 32]); + let ix = upgrade_ix(&programdata, &program, &buffer, &spill, &authority); + assert_eq!(ix.program_id, BPF_LOADER_UPGRADEABLE_ID); + assert_eq!(&ix.data[..4], &[3, 0, 0, 0]); + assert_eq!(ix.data.len(), 4); + assert_eq!(ix.accounts.len(), 7); + assert!(ix.accounts[6].is_signer); // authority +} + +#[test] +fn set_authority_ix_serialization() { + let account = Address::from([1u8; 32]); + let current = Address::from([2u8; 32]); + let new_auth = Address::from([3u8; 32]); + let ix = set_authority_ix(&account, ¤t, Some(&new_auth)); + assert_eq!(ix.program_id, BPF_LOADER_UPGRADEABLE_ID); + assert_eq!(&ix.data[..4], &[4, 0, 0, 0]); + assert_eq!(ix.data.len(), 4); + assert_eq!(ix.accounts.len(), 3); + + // Without new authority (make immutable) + let ix2 = set_authority_ix(&account, ¤t, None); + assert_eq!(ix2.accounts.len(), 2); +} + +#[test] +fn set_compute_unit_price_ix_serialization() { + let ix = set_compute_unit_price_ix(1000); + assert_eq!(ix.program_id, COMPUTE_BUDGET_PROGRAM_ID); + // Discriminant: 3u8 + assert_eq!(ix.data[0], 3); + // micro_lamports: 1000u64 LE + assert_eq!(&ix.data[1..9], &1000u64.to_le_bytes()); + assert_eq!(ix.data.len(), 9); + assert!(ix.accounts.is_empty()); +} + +#[test] +fn chunk_count_calculation() { + assert_eq!(num_chunks(0), 0); + assert_eq!(num_chunks(1), 1); + assert_eq!(num_chunks(CHUNK_SIZE), 1); + assert_eq!(num_chunks(CHUNK_SIZE + 1), 2); + assert_eq!(num_chunks(CHUNK_SIZE * 3), 3); + assert_eq!(num_chunks(CHUNK_SIZE * 3 + 1), 4); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p quasar-cli bpf_loader` +Expected: FAIL — functions not found. + +- [ ] **Step 3: Implement instruction builders** + +Add to `bpf_loader.rs`: + +```rust +use solana_instruction::{AccountMeta, Instruction}; + +// --------------------------------------------------------------------------- +// BPF Loader Upgradeable instructions +// --------------------------------------------------------------------------- + +/// InitializeBuffer (discriminant 0). Accounts: [buffer (writable), authority (readonly)]. +pub fn initialize_buffer_ix(buffer: &Address, authority: &Address) -> Instruction { + Instruction { + program_id: BPF_LOADER_UPGRADEABLE_ID, + accounts: vec![ + AccountMeta::new(*buffer, false), + AccountMeta::new_readonly(*authority, false), + ], + data: 0u32.to_le_bytes().to_vec(), + } +} + +/// Write (discriminant 1). Accounts: [buffer (writable), authority (signer)]. +pub fn write_ix(buffer: &Address, authority: &Address, offset: u32, data: &[u8]) -> Instruction { + let mut ix_data = Vec::with_capacity(12 + data.len()); + ix_data.extend_from_slice(&1u32.to_le_bytes()); + ix_data.extend_from_slice(&offset.to_le_bytes()); + ix_data.extend_from_slice(&(data.len() as u32).to_le_bytes()); + ix_data.extend_from_slice(data); + Instruction { + program_id: BPF_LOADER_UPGRADEABLE_ID, + accounts: vec![ + AccountMeta::new(*buffer, false), + AccountMeta::new_readonly(*authority, true), + ], + data: ix_data, + } +} + +/// DeployWithMaxDataLen (discriminant 2). +/// Accounts: [payer, programdata, program, buffer, rent, clock, system, authority]. +pub fn deploy_with_max_data_len_ix( + payer: &Address, + programdata: &Address, + program: &Address, + buffer: &Address, + authority: &Address, + max_data_len: u64, +) -> Instruction { + let mut data = Vec::with_capacity(12); + data.extend_from_slice(&2u32.to_le_bytes()); + data.extend_from_slice(&max_data_len.to_le_bytes()); + Instruction { + program_id: BPF_LOADER_UPGRADEABLE_ID, + accounts: vec![ + AccountMeta::new(*payer, true), + AccountMeta::new(*programdata, true), + AccountMeta::new(*program, true), + AccountMeta::new(*buffer, true), + AccountMeta::new_readonly(SYSVAR_RENT_ID, false), + AccountMeta::new_readonly(SYSVAR_CLOCK_ID, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + AccountMeta::new_readonly(*authority, true), + ], + data, + } +} + +/// Upgrade (discriminant 3). +/// Accounts: [programdata, program, buffer, spill, rent, clock, authority]. +pub fn upgrade_ix( + programdata: &Address, + program: &Address, + buffer: &Address, + spill: &Address, + authority: &Address, +) -> Instruction { + Instruction { + program_id: BPF_LOADER_UPGRADEABLE_ID, + accounts: vec![ + AccountMeta::new(*programdata, false), + AccountMeta::new(*program, false), + AccountMeta::new(*buffer, false), + AccountMeta::new(*spill, false), + AccountMeta::new_readonly(SYSVAR_RENT_ID, false), + AccountMeta::new_readonly(SYSVAR_CLOCK_ID, false), + AccountMeta::new_readonly(*authority, true), + ], + data: 3u32.to_le_bytes().to_vec(), + } +} + +/// SetAuthority (discriminant 4). +/// Accounts: [account (writable), current_authority (signer), new_authority (optional readonly)]. +pub fn set_authority_ix( + account: &Address, + current_authority: &Address, + new_authority: Option<&Address>, +) -> Instruction { + let mut accounts = vec![ + AccountMeta::new(*account, false), + AccountMeta::new_readonly(*current_authority, true), + ]; + if let Some(new_auth) = new_authority { + accounts.push(AccountMeta::new_readonly(*new_auth, false)); + } + Instruction { + program_id: BPF_LOADER_UPGRADEABLE_ID, + accounts, + data: 4u32.to_le_bytes().to_vec(), + } +} + +// --------------------------------------------------------------------------- +// ComputeBudget +// --------------------------------------------------------------------------- + +/// SetComputeUnitPrice instruction. Discriminant: 3u8. +pub fn set_compute_unit_price_ix(micro_lamports: u64) -> Instruction { + let mut data = Vec::with_capacity(9); + data.push(3u8); + data.extend_from_slice(µ_lamports.to_le_bytes()); + Instruction { + program_id: COMPUTE_BUDGET_PROGRAM_ID, + accounts: vec![], + data, + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Calculate the number of chunks needed for a given file size. +pub fn num_chunks(file_size: usize) -> usize { + if file_size == 0 { + return 0; + } + (file_size + CHUNK_SIZE - 1) / CHUNK_SIZE +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p quasar-cli bpf_loader` +Expected: All new tests pass + existing constant tests pass. + +Run: `cargo clippy -p quasar-cli -- -D warnings` +Expected: Clean. + +- [ ] **Step 5: Commit** + +```bash +git add cli/src/bpf_loader.rs +git commit -m "feat: add BPF Loader instruction builders and ComputeBudget instruction" +``` + +--- + +### Task 5: Add Orchestrators to `bpf_loader.rs` + +Add buffer upload, deploy, upgrade, authority validation, and SystemProgram::CreateAccount. + +**Files:** +- Modify: `cli/src/bpf_loader.rs` + +- [ ] **Step 1: Write tests for programdata authority parsing** + +Add to `bpf_loader.rs::tests`: + +```rust +#[test] +fn parse_programdata_authority_some() { + let mut data = vec![0u8; 45]; + // discriminant = 3 (ProgramData) + data[0..4].copy_from_slice(&3u32.to_le_bytes()); + // slot + data[4..12].copy_from_slice(&100u64.to_le_bytes()); + // Option tag = 1 (Some) + data[12] = 1; + // authority pubkey + data[13..45].copy_from_slice(&[0xAA; 32]); + + let authority = parse_programdata_authority(&data).unwrap(); + assert_eq!(authority, Some(Address::from([0xAA; 32]))); +} + +#[test] +fn parse_programdata_authority_none() { + let mut data = vec![0u8; 45]; + data[0..4].copy_from_slice(&3u32.to_le_bytes()); + data[4..12].copy_from_slice(&100u64.to_le_bytes()); + data[12] = 0; // None — immutable + + let authority = parse_programdata_authority(&data).unwrap(); + assert!(authority.is_none()); +} + +#[test] +fn create_account_ix_serialization() { + let payer = Address::from([1u8; 32]); + let new_account = Address::from([2u8; 32]); + let owner = Address::from([3u8; 32]); + let ix = create_account_ix(&payer, &new_account, 1_000_000, 100, &owner); + assert_eq!(ix.program_id, SYSTEM_PROGRAM_ID); + // SystemProgram::CreateAccount discriminant = 0u32 LE + assert_eq!(&ix.data[..4], &[0, 0, 0, 0]); + // lamports + assert_eq!(&ix.data[4..12], &1_000_000u64.to_le_bytes()); + // space + assert_eq!(&ix.data[12..20], &100u64.to_le_bytes()); + // owner + assert_eq!(&ix.data[20..52], owner.as_ref()); + assert_eq!(ix.data.len(), 52); + assert!(ix.accounts[0].is_signer); + assert!(ix.accounts[1].is_signer); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p quasar-cli parse_programdata` +Expected: FAIL — function not found. + +- [ ] **Step 3: Implement helper functions** + +Add to `bpf_loader.rs`: + +```rust +use crate::rpc::{ + self, confirm_transaction, get_latest_blockhash, get_minimum_balance_for_rent_exemption, + send_transaction, Keypair, +}; + +/// Parse the authority from a programdata account. +/// Returns Some(address) if authority is set, None if immutable. +pub fn parse_programdata_authority( + data: &[u8], +) -> Result, crate::error::CliError> { + if data.len() < 45 { + return Err(anyhow::anyhow!("programdata account too short").into()); + } + if data[12] == 0 { + Ok(None) // immutable + } else { + let bytes: [u8; 32] = data[13..45].try_into().unwrap(); + Ok(Some(Address::from(bytes))) + } +} + +/// SystemProgram::CreateAccount instruction. +pub fn create_account_ix( + payer: &Address, + new_account: &Address, + lamports: u64, + space: u64, + owner: &Address, +) -> Instruction { + let mut data = Vec::with_capacity(52); + data.extend_from_slice(&0u32.to_le_bytes()); // CreateAccount discriminant + data.extend_from_slice(&lamports.to_le_bytes()); + data.extend_from_slice(&space.to_le_bytes()); + data.extend_from_slice(owner.as_ref()); + Instruction { + program_id: SYSTEM_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(*payer, true), + AccountMeta::new(*new_account, true), + ], + data, + } +} +``` + +- [ ] **Step 4: Run tests to verify helpers pass** + +Run: `cargo test -p quasar-cli parse_programdata create_account_ix` +Expected: All 3 pass. + +- [ ] **Step 5: Implement `verify_upgrade_authority`** + +```rust +/// Verify the on-chain upgrade authority matches the expected authority. +/// Errors if the program is immutable or the authority doesn't match. +pub fn verify_upgrade_authority( + rpc_url: &str, + program_id: &Address, + expected_authority: &Address, +) -> Result<(), crate::error::CliError> { + let (programdata_addr, _) = programdata_pda(program_id); + let data = rpc::get_account_data(rpc_url, &programdata_addr)? + .ok_or_else(|| anyhow::anyhow!("programdata account not found"))?; + + match parse_programdata_authority(&data)? { + None => Err(anyhow::anyhow!("program is immutable (no upgrade authority)").into()), + Some(authority) if authority != *expected_authority => { + Err(anyhow::anyhow!( + "upgrade authority mismatch: on-chain is {}, your keypair is {}", + bs58::encode(authority).into_string(), + bs58::encode(expected_authority).into_string(), + ).into()) + } + Some(_) => Ok(()), + } +} +``` + +- [ ] **Step 6: Implement `write_buffer`** + +```rust +/// Upload a .so binary to a new buffer account. Returns the buffer address. +pub fn write_buffer( + so_path: &std::path::Path, + payer: &Keypair, + rpc_url: &str, + priority_fee: u64, +) -> Result { + let program_data = std::fs::read(so_path)?; + let buffer_size = program_data.len() + BUFFER_HEADER_SIZE; + + // Generate random buffer keypair + let buffer_keypair = Keypair::generate(); + let buffer_addr = buffer_keypair.address(); + + // Get rent exemption + let lamports = get_minimum_balance_for_rent_exemption(rpc_url, buffer_size)?; + + // 1. Create buffer account + initialize + let mut ixs = vec![]; + if priority_fee > 0 { + ixs.push(set_compute_unit_price_ix(priority_fee)); + } + ixs.push(create_account_ix( + &payer.address(), + &buffer_addr, + lamports, + buffer_size as u64, + &BPF_LOADER_UPGRADEABLE_ID, + )); + ixs.push(initialize_buffer_ix(&buffer_addr, &payer.address())); + + let blockhash = get_latest_blockhash(rpc_url)?; + let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(&payer.address()), + &[payer, &buffer_keypair], + blockhash, + ); + let tx_bytes = bincode::serialize(&tx) + .map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + let sig = send_transaction(rpc_url, &tx_bytes)?; + + if !confirm_transaction(rpc_url, &sig, 30)? { + return Err(anyhow::anyhow!( + "buffer creation not confirmed within 30s (buffer: {})", + bs58::encode(&buffer_addr).into_string() + ).into()); + } + + // 2. Write chunks sequentially + let total_chunks = num_chunks(program_data.len()); + let pb = indicatif::ProgressBar::new(program_data.len() as u64); + pb.set_style( + indicatif::ProgressStyle::with_template( + " {bar:40.cyan/dim} {bytes}/{total_bytes} ({eta})", + ) + .unwrap() + .progress_chars("█▓░"), + ); + + for (i, chunk) in program_data.chunks(CHUNK_SIZE).enumerate() { + let offset = (i * CHUNK_SIZE) as u32; + let mut ixs = vec![]; + if priority_fee > 0 { + ixs.push(set_compute_unit_price_ix(priority_fee)); + } + ixs.push(write_ix(&buffer_addr, &payer.address(), offset, chunk)); + + let blockhash = get_latest_blockhash(rpc_url)?; + let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(&payer.address()), + &[payer], + blockhash, + ); + let tx_bytes = bincode::serialize(&tx) + .map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + let sig = send_transaction(rpc_url, &tx_bytes)?; + + if !confirm_transaction(rpc_url, &sig, 30)? { + return Err(anyhow::anyhow!( + "chunk {}/{} not confirmed within 30s (buffer: {})", + i + 1, + total_chunks, + bs58::encode(&buffer_addr).into_string() + ).into()); + } + + pb.set_position((offset as u64) + chunk.len() as u64); + } + + pb.finish_and_clear(); + Ok(buffer_addr) +} +``` + +- [ ] **Step 7: Implement `deploy_program`** + +```rust +/// Deploy a new program. Creates the program account and calls DeployWithMaxDataLen. +/// `program_keypair` is a Keypair because DeployWithMaxDataLen requires the +/// program account to sign its own CreateAccount. +pub fn deploy_program( + so_path: &std::path::Path, + program_keypair: &Keypair, + payer: &Keypair, + rpc_url: &str, + priority_fee: u64, +) -> Result { + let program_addr = program_keypair.address(); + + // 1. Upload buffer + let buffer = write_buffer(so_path, payer, rpc_url, priority_fee)?; + + // 2. Derive programdata PDA + let (programdata, _) = programdata_pda(&program_addr); + let so_len = std::fs::metadata(so_path)?.len() as usize; + let max_data_len = so_len * 2; // double size for future upgrades + + // Program account is 36 bytes: 4-byte discriminant + 32-byte programdata address + let program_account_size: u64 = 36; + let program_lamports = + get_minimum_balance_for_rent_exemption(rpc_url, program_account_size as usize)?; + + // 3. Create program account + deploy in one transaction + let mut ixs = vec![]; + if priority_fee > 0 { + ixs.push(set_compute_unit_price_ix(priority_fee)); + } + ixs.push(create_account_ix( + &payer.address(), + &program_addr, + program_lamports, + program_account_size, + &BPF_LOADER_UPGRADEABLE_ID, + )); + ixs.push(deploy_with_max_data_len_ix( + &payer.address(), + &programdata, + &program_addr, + &buffer, + &payer.address(), + max_data_len as u64, + )); + + let blockhash = get_latest_blockhash(rpc_url)?; + let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(&payer.address()), + &[payer, program_keypair], + blockhash, + ); + let tx_bytes = bincode::serialize(&tx) + .map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + let sig = send_transaction(rpc_url, &tx_bytes)?; + + if !confirm_transaction(rpc_url, &sig, 30)? { + return Err(anyhow::anyhow!("deploy transaction not confirmed within 30s").into()); + } + + Ok(program_addr) +} +``` + +- [ ] **Step 8: Implement `upgrade_program`** + +```rust +/// Upgrade an existing program. Uploads buffer then calls Upgrade. +pub fn upgrade_program( + so_path: &std::path::Path, + program_id: &Address, + authority: &Keypair, + rpc_url: &str, + priority_fee: u64, +) -> Result<(), crate::error::CliError> { + // 1. Upload buffer + let buffer = write_buffer(so_path, authority, rpc_url, priority_fee)?; + + // 2. Upgrade + let (programdata, _) = programdata_pda(program_id); + let authority_addr = authority.address(); + + let mut ixs = vec![]; + if priority_fee > 0 { + ixs.push(set_compute_unit_price_ix(priority_fee)); + } + ixs.push(upgrade_ix( + &programdata, + program_id, + &buffer, + &authority_addr, // spill = authority (reclaim buffer rent) + &authority_addr, + )); + + let blockhash = get_latest_blockhash(rpc_url)?; + let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(&authority_addr), + &[authority], + blockhash, + ); + let tx_bytes = bincode::serialize(&tx) + .map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + let sig = send_transaction(rpc_url, &tx_bytes)?; + + if !confirm_transaction(rpc_url, &sig, 30)? { + return Err(anyhow::anyhow!("upgrade transaction not confirmed within 30s").into()); + } + + Ok(()) +} +``` + +- [ ] **Step 9: Implement `set_authority` convenience wrapper** + +```rust +/// Transfer authority of a buffer or program to a new address (or revoke it). +pub fn set_authority( + account: &Address, + current_authority: &Keypair, + new_authority: Option<&Address>, + rpc_url: &str, + priority_fee: u64, +) -> Result<(), crate::error::CliError> { + let mut ixs = vec![]; + if priority_fee > 0 { + ixs.push(set_compute_unit_price_ix(priority_fee)); + } + ixs.push(set_authority_ix( + account, + ¤t_authority.address(), + new_authority, + )); + + let blockhash = get_latest_blockhash(rpc_url)?; + let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(¤t_authority.address()), + &[current_authority], + blockhash, + ); + let tx_bytes = bincode::serialize(&tx) + .map_err(|e| anyhow::anyhow!("failed to serialize transaction: {e}"))?; + let sig = send_transaction(rpc_url, &tx_bytes)?; + + if !confirm_transaction(rpc_url, &sig, 30)? { + return Err(anyhow::anyhow!("set-authority transaction not confirmed within 30s").into()); + } + + Ok(()) +} +``` + +- [ ] **Step 10: Run tests** + +Run: `cargo test -p quasar-cli` +Expected: All tests pass. + +Run: `cargo clippy -p quasar-cli -- -D warnings` +Expected: Clean. + +- [ ] **Step 11: Commit** + +```bash +git add cli/src/bpf_loader.rs +git commit -m "feat: add native buffer upload, deploy, upgrade, and authority orchestrators" +``` + +--- + +### Task 6: Update `multisig.rs` — Replace Shell-outs + Priority Fee + +Replace the three shell-out functions with native `bpf_loader` calls. Add `priority_fee` parameter to `propose_upgrade` and `execute_approved_proposal`. + +**Files:** +- Modify: `cli/src/multisig.rs` +- Modify: `cli/src/deploy.rs` (update `set_upgrade_authority` call site to keep code compiling) + +- [ ] **Step 1: Remove shell-out functions** + +Delete these three functions from `multisig.rs`: +- `write_buffer()` (lines 694-737) — the shell-out that calls `solana program write-buffer` +- `set_buffer_authority()` (lines 741-770) — the shell-out that calls `solana program set-buffer-authority` +- `set_upgrade_authority()` (lines 774-805) — the shell-out that calls `solana program set-upgrade-authority` + +**IMPORTANT:** Since `deploy.rs` currently calls `crate::multisig::set_upgrade_authority(...)` on line 268, you must also update that call site in this task to keep the code compiling. Change it to: + +```rust +let authority_keypair = crate::rpc::Keypair::read_from_file(&payer_path)?; +crate::bpf_loader::set_authority( + &crate::bpf_loader::programdata_pda(&program_id).0, + &authority_keypair, + Some(&vault), + &rpc_url, + 0, // priority fee not yet wired through; Task 7 adds it +)?; +``` + +This is a temporary bridge — Task 7 will rewrite this entire section of deploy.rs. + +- [ ] **Step 2: Remove `std::process` imports** + +Remove `Command` and `Stdio` from the `use` block since there are no more shell-outs: + +```rust +// Remove these from the use block: +// std::process::{Command, Stdio}, +``` + +The `use` block should now look like: + +```rust +use { + crate::{ + bpf_loader::{ + self, BPF_LOADER_UPGRADEABLE_ID, SYSTEM_PROGRAM_ID, SYSVAR_CLOCK_ID, + SYSVAR_RENT_ID, programdata_pda, + }, + rpc::{self, get_account_data, get_latest_blockhash, send_transaction, Keypair}, + style, + }, + sha2::{Digest, Sha256}, + solana_address::Address, + solana_instruction::AccountMeta, + std::path::Path, +}; +``` + +- [ ] **Step 3: Update `propose_upgrade` signature and body** + +Change the function signature to accept `priority_fee`: + +```rust +pub fn propose_upgrade( + so_path: &Path, + program_id: &Address, + multisig: &Address, + keypair_path: &Path, + rpc_url: &str, + vault_index: u8, + priority_fee: u64, +) -> crate::error::CliResult { +``` + +Replace the buffer upload (step 1) — was: +```rust +let buffer = write_buffer(so_path, keypair_path, rpc_url)?; +``` +becomes: +```rust +let buffer = bpf_loader::write_buffer(so_path, &keypair, rpc_url, priority_fee)?; +``` + +Note: `keypair` is already loaded 2 lines above. Remove the `keypair_path` usage for write_buffer. + +Replace the buffer authority transfer (step 2) — was: +```rust +set_buffer_authority(&buffer, &vault, keypair_path, rpc_url)?; +``` +becomes: +```rust +bpf_loader::set_authority(&buffer, &keypair, Some(&vault), rpc_url, priority_fee)?; +``` + +In step 7 (build transaction), prepend priority fee instruction if non-zero: + +```rust +let mut ixs = vec![]; +if priority_fee > 0 { + ixs.push(bpf_loader::set_compute_unit_price_ix(priority_fee)); +} +ixs.push(ix_create); +ixs.push(ix_propose); +ixs.push(ix_approve); + +let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(&member), + &[&keypair], + blockhash, +); +``` + +- [ ] **Step 4: Update `execute_approved_proposal` to accept priority fee** + +Change signature: + +```rust +fn execute_approved_proposal( + multisig: &Address, + ms: &MultisigState, + proposal: &ProposalState, + keypair_path: &Path, + rpc_url: &str, + priority_fee: u64, +) -> crate::error::CliResult { +``` + +Prepend priority fee instruction: + +```rust +let mut ixs = vec![]; +if priority_fee > 0 { + ixs.push(bpf_loader::set_compute_unit_price_ix(priority_fee)); +} +ixs.push(ix); + +let tx = solana_transaction::Transaction::new_signed_with_payer( + &ixs, + Some(&member), + &[&keypair], + blockhash, +); +``` + +- [ ] **Step 5: Update `show_proposal_status` to accept and pass priority fee** + +Change signature: + +```rust +pub fn show_proposal_status( + multisig: &Address, + keypair_path: &Path, + rpc_url: &str, + priority_fee: u64, +) -> crate::error::CliResult { +``` + +Update the call to `execute_approved_proposal`: + +```rust +execute_approved_proposal( + multisig, + &ms, + &proposal, + keypair_path, + rpc_url, + priority_fee, +)?; +``` + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p quasar-cli` +Expected: All tests pass. (Note: `multisig.rs` tests don't call the removed shell-out functions — they test parsing/building which is unchanged.) + +Run: `cargo clippy -p quasar-cli -- -D warnings` +Expected: Clean. + +- [ ] **Step 7: Commit** + +```bash +git add cli/src/multisig.rs cli/src/deploy.rs +git commit -m "refactor: replace multisig shell-outs with native bpf_loader calls, add priority fee" +``` + +--- + +### Task 7: Update `deploy.rs` + `lib.rs` — Native Calls + Validation + Priority Fee + +Replace `solana_deploy()` shell-out with native calls. Add the `--priority-fee` flag, reverse `--upgrade` check, and authority validation. + +**Files:** +- Modify: `cli/src/lib.rs` (add priority_fee field) +- Modify: `cli/src/deploy.rs` (replace shell-outs, add validation) + +- [ ] **Step 1: Add `priority_fee` to `DeployCommand` in `lib.rs`** + +In `cli/src/lib.rs`, add a new field to `DeployCommand` after the `status` field (line 187): + +```rust + /// Priority fee in micro-lamports (auto-calculated if omitted) + #[arg(long, value_name = "MICRO_LAMPORTS")] + pub priority_fee: Option, +``` + +Update the `deploy::run(deploy::DeployOpts { ... })` call (around line 337) to include: + +```rust +Command::Deploy(cmd) => deploy::run(deploy::DeployOpts { + program_keypair: cmd.program_keypair, + upgrade_authority: cmd.upgrade_authority, + keypair: cmd.keypair, + url: cmd.url, + skip_build: cmd.skip_build, + multisig: cmd.multisig, + status: cmd.status, + upgrade: cmd.upgrade, + priority_fee: cmd.priority_fee, +}), +``` + +- [ ] **Step 2: Add `priority_fee` to `DeployOpts` in `deploy.rs`** + +In `cli/src/deploy.rs`, add to the `DeployOpts` struct: + +```rust +pub struct DeployOpts { + pub program_keypair: Option, + pub upgrade_authority: Option, + pub keypair: Option, + pub url: Option, + pub skip_build: bool, + pub multisig: Option, + pub status: bool, + pub upgrade: bool, + pub priority_fee: Option, +} +``` + +- [ ] **Step 3: Rewrite `deploy.rs` — remove shell-out, add native calls** + +Replace the entire `use` block at the top: + +```rust +use { + crate::{config::QuasarConfig, error::CliResult, style, utils}, + std::path::PathBuf, +}; +``` + +(Remove `std::process::{Command, Stdio}` — no more shell-outs. Note: `bs58` is used via fully qualified `bs58::encode(...)` calls throughout, which doesn't require a `use` import.) + +Delete the `solana_deploy()` function entirely (lines 60-147). + +Rewrite the `run()` function body. Here's the complete new version: + +```rust +pub fn run(opts: DeployOpts) -> CliResult { + let DeployOpts { + program_keypair, + upgrade_authority, + keypair, + url, + skip_build, + multisig, + status, + upgrade, + priority_fee, + } = opts; + let config = QuasarConfig::load()?; + let name = &config.project.name; + + // Resolve cluster URL once + let rpc_url = crate::rpc::solana_rpc_url(url.as_deref()); + + // Resolve priority fee: use override or auto-calculate + let fee = match priority_fee { + Some(f) => f, + None => { + let auto = crate::rpc::get_recent_prioritization_fees(&rpc_url).unwrap_or(0); + if auto > 0 { + println!( + " {} Auto priority fee: {} micro-lamports", + style::dim("ℹ"), + auto + ); + } + auto + } + }; + + // --upgrade --multisig: Squads proposal flow + if upgrade { + if let Some(multisig_addr) = &multisig { + let multisig_key = parse_multisig_address(multisig_addr)?; + let payer_path = crate::rpc::solana_keypair_path(keypair.as_deref()); + + if status { + return crate::multisig::show_proposal_status( + &multisig_key, + &payer_path, + &rpc_url, + fee, + ); + } + + let so_path = build_and_find_so(&config, name, skip_build)?; + let prog_keypair_path = resolve_program_keypair(&config, program_keypair); + let program_id = + crate::rpc::read_program_id_from_keypair(&prog_keypair_path)?; + + return crate::multisig::propose_upgrade( + &so_path, + &program_id, + &multisig_key, + &payer_path, + &rpc_url, + 0, + fee, + ); + } + } + + // Everything below needs a build and a .so + let so_path = build_and_find_so(&config, name, skip_build)?; + let keypair_path = resolve_program_keypair(&config, program_keypair); + + if !keypair_path.exists() { + eprintln!( + "\n {}", + style::fail(&format!( + "program keypair not found: {}", + keypair_path.display() + )) + ); + eprintln!(); + eprintln!( + " Run {} to generate one, or pass {} explicitly.", + style::bold("quasar keys new"), + style::bold("--program-keypair") + ); + eprintln!(); + std::process::exit(1); + } + + // Read program ID from the keypair for on-chain check + let program_id = crate::rpc::read_program_id_from_keypair(&keypair_path)?; + let exists = crate::rpc::program_exists_on_chain(&rpc_url, &program_id)?; + + // Forward check: deploy on existing program + if !upgrade && exists { + eprintln!( + "\n {}", + style::fail(&format!( + "program already deployed at {}", + bs58::encode(program_id).into_string() + )) + ); + eprintln!(); + eprintln!( + " Use {} to upgrade an existing program.", + style::bold("quasar deploy --upgrade") + ); + eprintln!(); + std::process::exit(1); + } + + // Reverse check: --upgrade on non-existent program + if upgrade && !exists { + eprintln!( + "\n {}", + style::fail(&format!( + "program not found at {}", + bs58::encode(program_id).into_string() + )) + ); + eprintln!(); + eprintln!( + " Drop {} for a fresh deploy.", + style::bold("--upgrade") + ); + eprintln!(); + std::process::exit(1); + } + + // Load the payer keypair + let payer_path = crate::rpc::solana_keypair_path(keypair.as_deref()); + let payer = crate::rpc::Keypair::read_from_file(&payer_path)?; + + if upgrade { + // Authority validation before buffer upload + let authority_keypair = if let Some(ref auth_path) = upgrade_authority { + crate::rpc::Keypair::read_from_file(auth_path)? + } else { + crate::rpc::Keypair::read_from_file(&payer_path)? + }; + + let sp = style::spinner("Verifying upgrade authority..."); + crate::bpf_loader::verify_upgrade_authority( + &rpc_url, + &program_id, + &authority_keypair.address(), + )?; + sp.finish_and_clear(); + + // Upgrade + let sp = style::spinner("Uploading and upgrading..."); + crate::bpf_loader::upgrade_program( + &so_path, + &program_id, + &authority_keypair, + &rpc_url, + fee, + )?; + sp.finish_and_clear(); + + println!( + "\n {}", + style::success(&format!( + "Upgraded {}", + style::bold(&bs58::encode(program_id).into_string()) + )) + ); + } else { + // Fresh deploy + let program_kp = crate::rpc::Keypair::read_from_file(&keypair_path)?; + + let sp = style::spinner("Deploying..."); + let addr = crate::bpf_loader::deploy_program( + &so_path, + &program_kp, + &payer, + &rpc_url, + fee, + )?; + sp.finish_and_clear(); + + println!( + "\n {}", + style::success(&format!( + "Deployed to {}", + style::bold(&bs58::encode(addr).into_string()) + )) + ); + } + + // --multisig without --upgrade: transfer authority to vault after deploy + if let Some(multisig_addr) = &multisig { + let multisig_key = parse_multisig_address(multisig_addr)?; + let (vault, _) = crate::multisig::vault_pda(&multisig_key, 0); + + let authority_keypair = if let Some(ref auth_path) = upgrade_authority { + crate::rpc::Keypair::read_from_file(auth_path)? + } else { + crate::rpc::Keypair::read_from_file(&payer_path)? + }; + + let sp = style::spinner("Transferring upgrade authority to multisig vault..."); + crate::bpf_loader::set_authority( + &crate::bpf_loader::programdata_pda(&program_id).0, + &authority_keypair, + Some(&vault), + &rpc_url, + fee, + )?; + sp.finish_and_clear(); + + println!( + " {}", + style::success(&format!( + "Upgrade authority transferred to vault {}", + style::bold(&crate::multisig::short_addr(&vault)) + )) + ); + println!(); + println!( + " Future upgrades: {}", + style::dim(&format!( + "quasar deploy --upgrade --multisig {multisig_addr}" + )) + ); + } + + println!(); + Ok(()) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p quasar-cli` +Expected: All tests pass. + +Run: `cargo clippy -p quasar-cli -- -D warnings` +Expected: Clean. + +- [ ] **Step 5: Update help text in `print_help()`** + +In `lib.rs`, update the deploy help line to mention priority fee: + +```rust +print_cmd( + "deploy [-u url] [-k keypair] [--upgrade] [--multisig addr] [--priority-fee n]", + "Deploy or upgrade a program", +); +``` + +- [ ] **Step 6: Run full test suite one final time** + +Run: `cargo test -p quasar-cli` +Expected: All tests pass. + +Run: `cargo clippy -p quasar-cli -- -D warnings` +Expected: Clean. + +- [ ] **Step 7: Commit** + +```bash +git add cli/src/deploy.rs cli/src/lib.rs +git commit -m "feat: native deploy/upgrade with priority fees and pre-deploy validation" +``` diff --git a/docs/superpowers/specs/2026-03-22-native-deploy-design.md b/docs/superpowers/specs/2026-03-22-native-deploy-design.md new file mode 100644 index 0000000..765dbfc --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-native-deploy-design.md @@ -0,0 +1,280 @@ +# Native Deploy with Priority Fees + +## What We're Building + +Replace all four `solana` CLI shell-outs in the deploy pipeline with native Rust RPC calls. Add automatic priority fee calculation with manual override. Add pre-deploy validation that catches authority mismatches before wasting time and SOL on buffer uploads. + +After this work, `quasar deploy` no longer requires the Solana CLI to be installed. The only external dependency is a Solana RPC endpoint. + +## Why This Approach + +The current implementation shells out to `solana program deploy`, `write-buffer`, `set-buffer-authority`, and `set-upgrade-authority`. This works but has problems: + +- Requires the Solana CLI as a runtime dependency +- No priority fee support on the shell-out paths (only on the native Squads proposal path) +- Errors surface as parsed stdout/stderr strings rather than structured errors +- No pre-validation — a mismatched upgrade authority wastes the entire buffer upload before failing +- No progress visibility during buffer upload + +Anchor solved this by going fully native. We already have the infrastructure (ureq, solana-transaction, solana-instruction, ed25519-dalek, bincode) from the Squads integration. Extending it to cover BPF Loader Upgradeable instructions is straightforward. + +## Key Decisions + +- **Sequential buffer chunk uploads** for v1 (not parallel). Simpler, reliable, can add parallelism later. +- **Priority fees: auto-calculated with manual override.** `getRecentPrioritizationFees` → median, with `--priority-fee ` flag to override. +- **Strict `--upgrade` validation both directions.** `--upgrade` on a non-existent program errors. No `--upgrade` on an existing program errors. No silent auto-detection. +- **Split multisig.rs into three modules.** `rpc.rs` (RPC helpers + priority fees + Keypair), `bpf_loader.rs` (BPF loader instructions + buffer upload + deploy/upgrade), `multisig.rs` (Squads-only logic). + +## Module Structure + +### `rpc.rs` — RPC Client & Signing Helpers + +Extracted from current `multisig.rs`. Responsibilities: talk to a Solana RPC node, manage keypairs and config. + +**Moved from multisig.rs:** +- `Keypair` struct (ed25519-dalek wrapper implementing `solana_signer::Signer`) — used by both `bpf_loader.rs` and `multisig.rs` +- `read_program_id_from_keypair(path) -> Address` — reads public key from keypair file +- `get_latest_blockhash(rpc_url) -> Hash` +- `send_transaction(rpc_url, tx_bytes) -> String` (signature) +- `get_account_data(rpc_url, address) -> Option>` +- `program_exists_on_chain(rpc_url, program_id) -> bool` +- `solana_rpc_url(url_override) -> String` (cluster resolution) +- `solana_keypair_path(keypair_override) -> PathBuf` +- `read_config_field(field) -> Option` +- `expand_tilde(path) -> String` +- `resolve_cluster(input) -> String` + +**New:** +- `get_recent_prioritization_fees(rpc_url) -> u64` — calls `getRecentPrioritizationFees`, returns median fee in micro-lamports. Returns 0 if no recent fees. +- `confirm_transaction(rpc_url, signature, timeout_secs) -> bool` — polls `getSignatureStatuses` every 500ms until `confirmed` commitment or timeout +- `get_minimum_balance_for_rent_exemption(rpc_url, data_len) -> u64` — calls `getMinimumBalanceForRentExemption` + +### `bpf_loader.rs` — BPF Loader Upgradeable + +All interactions with the BPF Loader Upgradeable program. No Squads knowledge. + +**Constants (pub, moved from multisig.rs):** +- `BPF_LOADER_UPGRADEABLE_ID` +- `SYSTEM_PROGRAM_ID` +- `SYSVAR_RENT_ID` +- `SYSVAR_CLOCK_ID` +- `COMPUTE_BUDGET_PROGRAM_ID` (new: `ComputeBudget111111111111111111111111111111`) +- `CHUNK_SIZE: usize = 950` — bytes per Write transaction. Accounts for transaction overhead (~212 bytes) plus ComputeBudget::SetComputeUnitPrice instruction (~45 bytes) within the 1232-byte transaction limit. +- `BUFFER_HEADER_SIZE: usize = 37` — 4 bytes (UpgradeableLoaderState::Buffer discriminant, u32 LE = 1) + 1 byte (Option tag) + 32 bytes (authority pubkey) + +**BPF Loader instructions (5 variants, u32 LE discriminant):** +- `initialize_buffer_ix(buffer, authority) -> Instruction` + - Discriminant: `0u32` LE. Accounts: [0] buffer (writable), [1] authority (readonly) +- `write_ix(buffer, authority, offset, data) -> Instruction` + - Discriminant: `1u32` LE. Data: 4 bytes disc + 4 bytes offset (u32 LE) + 4 bytes len (u32 LE) + bytes. Accounts: [0] buffer (writable), [1] authority (signer) +- `deploy_with_max_data_len_ix(payer, programdata, program, buffer, authority, data_len) -> Instruction` + - Discriminant: `2u32` LE. Data: 4 bytes disc + 8 bytes max_data_len (usize/u64 LE). Accounts: [0] payer (writable signer), [1] programdata (writable), [2] program (writable), [3] buffer (writable), [4] rent sysvar, [5] clock sysvar, [6] system program, [7] BPF loader (program) +- `upgrade_ix(programdata, program, buffer, spill, authority) -> Instruction` + - Discriminant: `3u32` LE. Accounts: [0] programdata (writable), [1] program (writable), [2] buffer (writable), [3] spill (writable), [4] rent sysvar, [5] clock sysvar, [6] authority (signer) +- `set_authority_ix(account, current_authority, new_authority: Option
) -> Instruction` + - Discriminant: `4u32` LE. Accounts: [0] buffer or programdata (writable), [1] current authority (signer), [2] new authority (readonly, optional — present = transfer, absent = revoke/make immutable). Data: just the 4-byte discriminant. + +**Compute budget instruction:** +- `set_compute_unit_price_ix(micro_lamports: u64) -> Instruction` + - Program: `COMPUTE_BUDGET_PROGRAM_ID`. Instruction discriminant: `3u8`. Data: 1 byte disc + 8 bytes micro_lamports (u64 LE). No accounts. + +**Programdata account layout** (for authority verification): +``` +[0..4] u32 LE = 3 (UpgradeableLoaderState::ProgramData discriminant) +[4..12] u64 LE slot (deployment slot) +[12] u8 Option tag (0 = None/immutable, 1 = Some) +[13..45] [u8; 32] authority pubkey (only valid if Option tag = 1) +``` + +**Buffer upload:** +- `write_buffer(so_path, payer: &Keypair, rpc_url, priority_fee) -> Address` + 1. Reads .so file into memory + 2. Generates a random `Keypair` for the buffer account (using `ed25519-dalek` + `rand`) + 3. Queries `get_minimum_balance_for_rent_exemption(so_file.len() + BUFFER_HEADER_SIZE)` + 4. Sends a transaction with: [SetComputeUnitPrice, SystemProgram::CreateAccount, InitializeBuffer]. Signed by **both** payer and buffer keypair (buffer must sign its own CreateAccount). + 5. Calls `confirm_transaction` to wait for confirmation + 6. For each 950-byte chunk: sends [SetComputeUnitPrice, Write(buffer, payer, offset, chunk)]. Signed by payer only. Waits for confirmation before sending next chunk. + 7. Shows `indicatif` progress bar (bytes written / total) + 8. Returns buffer address + +**Deploy orchestrator:** +- `deploy_program(so_path, program_keypair: &Keypair, payer: &Keypair, rpc_url, priority_fee) -> Address` + 1. Calls `write_buffer` to upload the .so + 2. Derives programdata PDA from program address + 3. Sends a transaction with: [SetComputeUnitPrice, SystemProgram::CreateAccount(program, 36 bytes, BPF loader owner), DeployWithMaxDataLen]. The program account is 36 bytes: 4-byte discriminant + 32-byte programdata address. Signed by **both** payer and program keypair (program must sign its own CreateAccount). + 4. Confirms transaction + 5. Returns program address + + Note: `program_keypair` is a `Keypair` (signer), not an `Address`, because `DeployWithMaxDataLen` requires the program account to sign the `CreateAccount` that allocates it. + +**Upgrade orchestrator:** +- `upgrade_program(so_path, program_id: &Address, authority: &Keypair, rpc_url, priority_fee)` + 1. Calls `write_buffer` to upload the .so + 2. Derives programdata PDA from program address + 3. Sends a transaction with: [SetComputeUnitPrice, Upgrade(programdata, program, buffer, authority/spill, authority)]. Signed by authority. + 4. Confirms transaction + +**Validation:** +- `verify_upgrade_authority(rpc_url, program_id, expected_authority: &Address) -> Result<()>` + 1. Derives programdata PDA via `programdata_pda(program_id)` + 2. Fetches programdata account via `get_account_data` + 3. Reads Option tag at byte 12: if 0, error "program is immutable" + 4. Reads authority pubkey at bytes 13..45 + 5. Compares to `expected_authority`. If mismatch, error: "upgrade authority mismatch: on-chain is X, your keypair is Y" + +**PDA derivation (moved from multisig.rs):** +- `programdata_pda(program_id) -> (Address, u8)` — `Address::find_program_address(&[program_id], &BPF_LOADER_UPGRADEABLE_ID)` + +### `multisig.rs` — Squads Integration (Reduced) + +Keeps only Squads-specific logic. Imports from `crate::rpc` and `crate::bpf_loader`. + +**Stays:** +- Squads constants (`SQUADS_PROGRAM_ID`) +- PDA derivation (`vault_pda`, `transaction_pda`, `proposal_pda`) +- Squads instruction builders (`vault_transaction_create_ix`, `proposal_create_ix`, `proposal_approve_ix`, `vault_transaction_execute_ix`) +- `build_upgrade_message` (imports sysvar constants from `bpf_loader`) +- Account parsing (`parse_multisig_account`, `parse_proposal_account`, `read_transaction_index`) +- Data types (`MultisigMember`, `MultisigState`, `ProposalStatus`, `ProposalState`) +- `propose_upgrade` orchestrator — updated signature to accept `priority_fee: Option` +- `show_proposal_status` / `execute_approved_proposal` +- `short_addr` +- `anchor_discriminator` + +**Removed (moved to rpc.rs or bpf_loader.rs):** +- All RPC helpers → `rpc.rs` +- `Keypair` struct → `rpc.rs` +- `read_program_id_from_keypair` → `rpc.rs` +- `BPF_LOADER_UPGRADEABLE_ID`, `SYSTEM_PROGRAM_ID`, sysvar constants → `bpf_loader.rs` +- `programdata_pda` → `bpf_loader.rs` +- `write_buffer` shell-out → replaced by `bpf_loader::write_buffer()` +- `set_buffer_authority` shell-out → replaced by `bpf_loader::set_authority()` +- `set_upgrade_authority` shell-out → replaced by `bpf_loader::set_authority()` + +**Updated:** +- `propose_upgrade` gains `priority_fee: Option` parameter. Calls `bpf_loader::write_buffer()` and `bpf_loader::set_authority()` instead of shell-outs. Passes priority fee through. +- `build_upgrade_message` uses `bpf_loader::SYSVAR_RENT_ID`, `bpf_loader::SYSVAR_CLOCK_ID`, `bpf_loader::BPF_LOADER_UPGRADEABLE_ID` +- `execute_approved_proposal` gains `priority_fee: Option` parameter, passes through to transaction building + +### `deploy.rs` — Command Entry Point (Updated) + +Routing logic stays the same (4 code paths). Changes: +- `solana_deploy()` shell-out function **removed entirely** +- Fresh deploy path calls `bpf_loader::deploy_program()` +- Upgrade path calls `bpf_loader::upgrade_program()` +- Authority transfer calls `bpf_loader::set_authority()` +- **New reverse check:** `if upgrade && !program_exists_on_chain(...)` → error "program not found at X, drop --upgrade for a fresh deploy" +- **New authority validation:** before upgrade, calls `bpf_loader::verify_upgrade_authority()` to catch mismatch before buffer upload +- Resolves priority fee (auto or override) and passes through all code paths +- Destructures `priority_fee` from `DeployOpts` and propagates to all orchestrators +- Removes `use std::process::{Command, Stdio}` (no more shell-outs) + +### `lib.rs` — CLI Definition + +New module declarations: +```rust +pub mod bpf_loader; +pub mod rpc; +``` + +One new field on `DeployCommand`: +```rust +/// Priority fee in micro-lamports (auto-calculated if omitted) +#[arg(long, value_name = "MICRO_LAMPORTS")] +pub priority_fee: Option, +``` + +`DeployOpts` gets a matching `priority_fee: Option` field. `deploy::run()` destructures it and passes through. + +## Deploy Flow (After) + +### Fresh deploy: `quasar deploy` + +1. Build .so (unless `--skip-build`) +2. Resolve cluster URL +3. Check program doesn't exist on-chain → error if it does +4. Calculate priority fee (auto or override) +5. Create buffer account (random keypair) + write chunks sequentially with progress bar, confirming each +6. Create program account (from program keypair) + DeployWithMaxDataLen in one transaction +7. Print program ID + +### Upgrade: `quasar deploy --upgrade` + +1. Build .so +2. Resolve cluster URL +3. Check program exists on-chain → error if it doesn't (new) +4. Verify upgrade authority matches keypair → error if mismatch (new, before buffer upload) +5. Calculate priority fee +6. Create buffer + write chunks sequentially with progress bar, confirming each +7. Send Upgrade transaction, confirm +8. Print success + +### Multisig fresh deploy: `quasar deploy --multisig ` + +1. Build .so +2. Deploy via native `bpf_loader::deploy_program()` (was shell-out) +3. Transfer authority to vault via native `bpf_loader::set_authority()` (was shell-out) + +### Multisig upgrade: `quasar deploy --upgrade --multisig ` + +1. Build .so +2. Write buffer via native `bpf_loader::write_buffer()` (was shell-out) +3. Transfer buffer authority to vault via native `bpf_loader::set_authority()` (was shell-out) +4. Create Squads proposal (already native, now with priority fee) + +## Priority Fee Flow + +1. If `--priority-fee ` is set, use N micro-lamports +2. Otherwise, call `getRecentPrioritizationFees` RPC method with no accounts filter +3. Collect the `prioritizationFee` values from the response +4. Take the median (sort, pick middle value). If empty, use 0. +5. Prepend `SetComputeUnitPrice(fee)` as the first instruction in every transaction (buffer writes, deploy, upgrade, authority changes, Squads proposals) + +## Buffer Upload Detail + +The .so binary is written to a buffer account in chunks: + +1. **Generate buffer keypair:** Random `Keypair` via `ed25519-dalek` + `rand` (both already in Cargo.toml) +2. **Create buffer account:** Transaction with [SetComputeUnitPrice, SystemProgram::CreateAccount (rent-exempt for data_len + 37 header bytes, owned by BPF Loader), InitializeBuffer]. Signed by payer + buffer keypair. Wait for `confirmed` via `confirm_transaction`. +3. **Write chunks:** For each 950-byte chunk, send [SetComputeUnitPrice, Write(buffer, payer, offset, chunk)]. Signed by payer only. Call `confirm_transaction` after each to ensure sequential ordering. +4. **Progress bar:** `indicatif` ProgressBar showing bytes written / total bytes + +The 37-byte header is the BPF Loader buffer account structure: 4 bytes (UpgradeableLoaderState discriminant, u32 LE = 1 for Buffer) + 1 byte (Option tag) + 32 bytes (authority pubkey). + +If a chunk write fails, the error includes the buffer address so the user can close it with `solana program close ` to reclaim rent. + +## Error Paths + +| Scenario | Behavior | +|---|---| +| `quasar deploy` on existing program | Error: "program already deployed at X, use --upgrade" (existing) | +| `quasar deploy --upgrade` on non-existent program | Error: "program not found at X, drop --upgrade for a fresh deploy" (new) | +| `quasar deploy --upgrade` with wrong authority keypair | Error before buffer upload: "upgrade authority mismatch: on-chain is X, your keypair is Y" (new) | +| `quasar deploy --upgrade` on immutable program | Error before buffer upload: "program is immutable (no upgrade authority)" (new) | +| Chunk write fails mid-upload | Error with buffer address for manual cleanup | +| RPC unreachable | Error: "failed to connect to RPC at " | +| Transaction confirmation timeout | Error: "transaction not confirmed within N seconds" | + +## Testing Strategy + +**Unit tests (no RPC):** +- BPF Loader instruction serialization — all 5 variants compared against known byte patterns +- ComputeBudget SetComputeUnitPrice serialization +- Chunk calculation (file size → expected number of chunks at 950 bytes each) +- Priority fee median calculation (edge cases: empty, single value, even count, odd count) +- Programdata authority parsing (Option::Some and Option::None paths) +- Buffer header size constant validation + +**Existing tests preserved:** +- All 30 existing tests continue to pass +- Squads-specific tests stay in `multisig.rs` +- RPC helper tests (cluster resolution, tilde expansion) move to `rpc.rs` +- Address constant tests (BPF loader, sysvars) move to `bpf_loader.rs` + +## What's NOT In Scope + +- Parallel buffer uploads (future optimization) +- Transaction retry with backoff (future, sequential is fine for v1) +- `anchor verify` style binary verification +- Buffer cleanup on failure (user does `solana program close` manually) +- Compute unit limit estimation (use defaults)