From 59fa0107006da3b68c25a87b6141ea8a6dd6d7c0 Mon Sep 17 00:00:00 2001 From: "Tyler.S" Date: Wed, 17 Jan 2024 17:58:23 -0800 Subject: [PATCH] Revert "Use external soroban-cli crate" This reverts commit a7d2b154f2d1ac6be675faf29f03d5dac90a190b. --- .github/workflows/rust.yml | 2 +- Cargo.lock | 457 ++++--- Cargo.toml | 8 +- Makefile | 1 + cmd/soroban-cli/Cargo.toml | 106 ++ cmd/soroban-cli/README.md | 28 + cmd/soroban-cli/build.rs | 3 + cmd/soroban-cli/src/bin/doc-gen.rs | 36 + cmd/soroban-cli/src/bin/main.rs | 51 + cmd/soroban-cli/src/commands/completion.rs | 32 + .../src/commands/config/locator.rs | 358 ++++++ cmd/soroban-cli/src/commands/config/mod.rs | 95 ++ cmd/soroban-cli/src/commands/config/secret.rs | 143 +++ .../src/commands/contract/asset.rs | 27 + .../src/commands/contract/bindings.rs | 38 + .../src/commands/contract/bindings/json.rs | 29 + .../src/commands/contract/bindings/rust.rs | 39 + .../commands/contract/bindings/typescript.rs | 131 ++ .../src/commands/contract/build.rs | 194 +++ .../src/commands/contract/deploy.rs | 28 + .../src/commands/contract/deploy/asset.rs | 155 +++ .../src/commands/contract/deploy/wasm.rs | 228 ++++ .../src/commands/contract/extend.rs | 192 +++ .../src/commands/contract/fetch.rs | 185 +++ cmd/soroban-cli/src/commands/contract/id.rs | 28 + .../src/commands/contract/id/asset.rs | 36 + .../src/commands/contract/id/wasm.rs | 68 + .../src/commands/contract/inspect.rs | 49 + .../src/commands/contract/install.rs | 223 ++++ .../src/commands/contract/invoke.rs | 484 +++++++ cmd/soroban-cli/src/commands/contract/mod.rs | 158 +++ .../src/commands/contract/optimize.rs | 80 ++ cmd/soroban-cli/src/commands/contract/read.rs | 180 +++ .../src/commands/contract/restore.rs | 206 +++ cmd/soroban-cli/src/commands/events.rs | 221 ++++ cmd/soroban-cli/src/commands/global.rs | 61 + cmd/soroban-cli/src/commands/keys/add.rs | 33 + cmd/soroban-cli/src/commands/keys/address.rs | 54 + cmd/soroban-cli/src/commands/keys/fund.rs | 34 + cmd/soroban-cli/src/commands/keys/generate.rs | 75 ++ cmd/soroban-cli/src/commands/keys/ls.rs | 45 + cmd/soroban-cli/src/commands/keys/mod.rs | 63 + cmd/soroban-cli/src/commands/keys/rm.rs | 25 + cmd/soroban-cli/src/commands/keys/show.rs | 43 + cmd/soroban-cli/src/commands/lab/mod.rs | 31 + cmd/soroban-cli/src/commands/lab/token/mod.rs | 38 + cmd/soroban-cli/src/commands/mod.rs | 160 +++ cmd/soroban-cli/src/commands/network/add.rs | 32 + cmd/soroban-cli/src/commands/network/ls.rs | 44 + cmd/soroban-cli/src/commands/network/mod.rs | 197 +++ cmd/soroban-cli/src/commands/network/rm.rs | 24 + cmd/soroban-cli/src/commands/plugin.rs | 96 ++ cmd/soroban-cli/src/commands/version.rs | 32 + cmd/soroban-cli/src/fee.rs | 16 + cmd/soroban-cli/src/key.rs | 110 ++ cmd/soroban-cli/src/lib.rs | 53 + cmd/soroban-cli/src/log.rs | 13 + cmd/soroban-cli/src/log/auth.rs | 7 + cmd/soroban-cli/src/log/budget.rs | 5 + cmd/soroban-cli/src/log/cost.rs | 27 + cmd/soroban-cli/src/log/diagnostic_event.rs | 11 + cmd/soroban-cli/src/log/footprint.rs | 5 + cmd/soroban-cli/src/log/host_event.rs | 7 + .../src/rpc/fixtures/event_response.json | 39 + cmd/soroban-cli/src/rpc/mod.rs | 1141 +++++++++++++++++ cmd/soroban-cli/src/rpc/txn.rs | 610 +++++++++ cmd/soroban-cli/src/toid.rs | 69 + cmd/soroban-cli/src/utils.rs | 244 ++++ cmd/soroban-cli/src/utils/contract_spec.rs | 276 ++++ cmd/soroban-cli/src/wasm.rs | 93 ++ 70 files changed, 7878 insertions(+), 234 deletions(-) create mode 100644 cmd/soroban-cli/Cargo.toml create mode 100644 cmd/soroban-cli/README.md create mode 100644 cmd/soroban-cli/build.rs create mode 100644 cmd/soroban-cli/src/bin/doc-gen.rs create mode 100644 cmd/soroban-cli/src/bin/main.rs create mode 100644 cmd/soroban-cli/src/commands/completion.rs create mode 100644 cmd/soroban-cli/src/commands/config/locator.rs create mode 100644 cmd/soroban-cli/src/commands/config/mod.rs create mode 100644 cmd/soroban-cli/src/commands/config/secret.rs create mode 100644 cmd/soroban-cli/src/commands/contract/asset.rs create mode 100644 cmd/soroban-cli/src/commands/contract/bindings.rs create mode 100644 cmd/soroban-cli/src/commands/contract/bindings/json.rs create mode 100644 cmd/soroban-cli/src/commands/contract/bindings/rust.rs create mode 100644 cmd/soroban-cli/src/commands/contract/bindings/typescript.rs create mode 100644 cmd/soroban-cli/src/commands/contract/build.rs create mode 100644 cmd/soroban-cli/src/commands/contract/deploy.rs create mode 100644 cmd/soroban-cli/src/commands/contract/deploy/asset.rs create mode 100644 cmd/soroban-cli/src/commands/contract/deploy/wasm.rs create mode 100644 cmd/soroban-cli/src/commands/contract/extend.rs create mode 100644 cmd/soroban-cli/src/commands/contract/fetch.rs create mode 100644 cmd/soroban-cli/src/commands/contract/id.rs create mode 100644 cmd/soroban-cli/src/commands/contract/id/asset.rs create mode 100644 cmd/soroban-cli/src/commands/contract/id/wasm.rs create mode 100644 cmd/soroban-cli/src/commands/contract/inspect.rs create mode 100644 cmd/soroban-cli/src/commands/contract/install.rs create mode 100644 cmd/soroban-cli/src/commands/contract/invoke.rs create mode 100644 cmd/soroban-cli/src/commands/contract/mod.rs create mode 100644 cmd/soroban-cli/src/commands/contract/optimize.rs create mode 100644 cmd/soroban-cli/src/commands/contract/read.rs create mode 100644 cmd/soroban-cli/src/commands/contract/restore.rs create mode 100644 cmd/soroban-cli/src/commands/events.rs create mode 100644 cmd/soroban-cli/src/commands/global.rs create mode 100644 cmd/soroban-cli/src/commands/keys/add.rs create mode 100644 cmd/soroban-cli/src/commands/keys/address.rs create mode 100644 cmd/soroban-cli/src/commands/keys/fund.rs create mode 100644 cmd/soroban-cli/src/commands/keys/generate.rs create mode 100644 cmd/soroban-cli/src/commands/keys/ls.rs create mode 100644 cmd/soroban-cli/src/commands/keys/mod.rs create mode 100644 cmd/soroban-cli/src/commands/keys/rm.rs create mode 100644 cmd/soroban-cli/src/commands/keys/show.rs create mode 100644 cmd/soroban-cli/src/commands/lab/mod.rs create mode 100644 cmd/soroban-cli/src/commands/lab/token/mod.rs create mode 100644 cmd/soroban-cli/src/commands/mod.rs create mode 100644 cmd/soroban-cli/src/commands/network/add.rs create mode 100644 cmd/soroban-cli/src/commands/network/ls.rs create mode 100644 cmd/soroban-cli/src/commands/network/mod.rs create mode 100644 cmd/soroban-cli/src/commands/network/rm.rs create mode 100644 cmd/soroban-cli/src/commands/plugin.rs create mode 100644 cmd/soroban-cli/src/commands/version.rs create mode 100644 cmd/soroban-cli/src/fee.rs create mode 100644 cmd/soroban-cli/src/key.rs create mode 100644 cmd/soroban-cli/src/lib.rs create mode 100644 cmd/soroban-cli/src/log.rs create mode 100644 cmd/soroban-cli/src/log/auth.rs create mode 100644 cmd/soroban-cli/src/log/budget.rs create mode 100644 cmd/soroban-cli/src/log/cost.rs create mode 100644 cmd/soroban-cli/src/log/diagnostic_event.rs create mode 100644 cmd/soroban-cli/src/log/footprint.rs create mode 100644 cmd/soroban-cli/src/log/host_event.rs create mode 100644 cmd/soroban-cli/src/rpc/fixtures/event_response.json create mode 100644 cmd/soroban-cli/src/rpc/mod.rs create mode 100644 cmd/soroban-cli/src/rpc/txn.rs create mode 100644 cmd/soroban-cli/src/toid.rs create mode 100644 cmd/soroban-cli/src/utils.rs create mode 100644 cmd/soroban-cli/src/utils/contract_spec.rs create mode 100644 cmd/soroban-cli/src/wasm.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e06d913c..c96167ad 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -77,7 +77,7 @@ jobs: - if: startsWith(matrix.target, 'x86_64') # specify directories explicitly to avoid building the preflight library (otherwise it will fail with missing symbols) run: | - for I in cmd/crates/* cmd/crates/soroban-test/tests/fixtures/test-wasms/hello_world ; do + for I in cmd/soroban-cli cmd/crates/* cmd/crates/soroban-test/tests/fixtures/test-wasms/hello_world ; do cargo test --target ${{ matrix.target }} --manifest-path $I/Cargo.toml done diff --git a/Cargo.lock b/Cargo.lock index 432aff82..45bd18dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,7 +142,7 @@ checksum = "531b97fb4cd3dfdce92c35dedbfdc1f0b9d8091c8ca943d6dae340ef5012d514" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -273,7 +273,7 @@ dependencies = [ "num-bigint", "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -314,6 +314,7 @@ version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" dependencies = [ + "jobserver", "libc", ] @@ -348,6 +349,15 @@ dependencies = [ "clap_derive", ] +[[package]] +name = "clap-markdown" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325f50228f76921784b6d9f2d62de6778d834483248eefecd27279174797e579" +dependencies = [ + "clap", +] + [[package]] name = "clap_builder" version = "4.4.18" @@ -378,7 +388,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -387,6 +397,16 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + [[package]] name = "colorchoice" version = "1.0.0" @@ -540,7 +560,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d2b3721e861707777e3195b0158f950ae6dc4a27e4d02ff9f67e3eb3de199e" dependencies = [ "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -581,7 +601,51 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", +] + +[[package]] +name = "cxx" +version = "1.0.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ab30434ea0ff6aa640a08dda5284026a366d47565496fd40b6cbfbdd7e31a2" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b649d7dfae8268450d53d109388b337b9352c7cba1fc10db4a1bc23c3dc189fb" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn 2.0.39", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42281b20eba5218c539295c667c18e2f50211bb11902419194c6ed1ae808e547" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45506e3c66512b0a65d291a6b452128b7b1dd9841e20d1e151addbd2c00ea50" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", ] [[package]] @@ -605,7 +669,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.39", ] [[package]] @@ -616,7 +680,7 @@ checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -647,7 +711,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1305,6 +1369,15 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +[[package]] +name = "jobserver" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" version = "0.3.67" @@ -1419,6 +1492,15 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "link-cplusplus" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d240c6f7e1ba3a28b0249f774e6a9dd0175054b52dfbb61b16eb8505c3785c9" +dependencies = [ + "cc", +] + [[package]] name = "linux-raw-sys" version = "0.4.13" @@ -1529,7 +1611,7 @@ checksum = "cfb77679af88f8b125209d354a202862602672222e7f2313fdd6dc349bad4712" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1605,7 +1687,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1709,7 +1791,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1806,7 +1888,7 @@ dependencies = [ "base64 0.21.7", "libc", "sha2 0.10.8", - "soroban-env-host 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", + "soroban-env-host", "soroban-simulation", ] @@ -1817,7 +1899,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.39", ] [[package]] @@ -2060,6 +2142,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + [[package]] name = "ryu" version = "1.0.16" @@ -2090,6 +2178,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scratch" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cf7c11c38cb994f3d40e8a8cde3bbd1f72a435e4c49e85d6553d8312306152" + [[package]] name = "sct" version = "0.7.1" @@ -2186,7 +2280,7 @@ checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -2226,7 +2320,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -2339,18 +2433,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "soroban-builtin-sdk-macros" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aa4d0718c6bb74d5b9f77d54dde6cc08a7eb6ef0e76b524f723a48f1cf5db2c" -dependencies = [ - "itertools 0.11.0", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "soroban-builtin-sdk-macros" version = "20.1.0" @@ -2359,19 +2441,20 @@ dependencies = [ "itertools 0.11.0", "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] name = "soroban-cli" version = "20.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e995a047a684547f1962d1064329c291632ed035b67ca7c423c4978cc231bd8" dependencies = [ + "assert_cmd", + "assert_fs", "base64 0.21.7", "cargo_metadata", "chrono", "clap", + "clap-markdown", "clap_complete", "crate-git-revision 0.0.4", "csv", @@ -2390,6 +2473,7 @@ dependencies = [ "num-bigint", "openssl", "pathdiff", + "predicates 2.1.5", "rand", "regex", "rpassword", @@ -2400,12 +2484,12 @@ dependencies = [ "serde_json", "sha2 0.10.8", "shlex", - "soroban-env-host 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-ledger-snapshot 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-sdk 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-spec 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "soroban-env-host", + "soroban-ledger-snapshot", + "soroban-sdk", + "soroban-spec 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", "soroban-spec-json", - "soroban-spec-rust 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "soroban-spec-rust", "soroban-spec-tools", "soroban-spec-typescript", "stellar-strkey 0.0.7", @@ -2419,27 +2503,11 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "wasm-opt", "wasmparser 0.90.0", "which", ] -[[package]] -name = "soroban-env-common" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0e3c4b5ea7936e814706f2a5da6af574260319bcc66fbf5517b39f19b5fa365" -dependencies = [ - "crate-git-revision 0.0.6", - "ethnum", - "num-derive", - "num-traits", - "serde", - "soroban-env-macros 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-wasmi 0.31.1-soroban.20.0.0 (registry+https://github.com/rust-lang/crates.io-index)", - "static_assertions", - "stellar-xdr", -] - [[package]] name = "soroban-env-common" version = "20.1.0" @@ -2451,57 +2519,21 @@ dependencies = [ "num-derive", "num-traits", "serde", - "soroban-env-macros 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", - "soroban-wasmi 0.31.1-soroban.20.0.0 (git+https://github.com/stellar/wasmi?rev=ab29800224d85ee64d4ac127bac84cdbb0276721)", + "soroban-env-macros", + "soroban-wasmi", "static_assertions", "stellar-xdr", ] -[[package]] -name = "soroban-env-guest" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ff111936103653515b4471b951b6325b966c3bb31c4c1bd5892ea741aa028ca" -dependencies = [ - "soroban-env-common 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "static_assertions", -] - [[package]] name = "soroban-env-guest" version = "20.1.0" source = "git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4#36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4" dependencies = [ - "soroban-env-common 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", + "soroban-env-common", "static_assertions", ] -[[package]] -name = "soroban-env-host" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3d7de1f83760dddf9302dcc32f8adfebaff41946045ea67196511aca8d9f00" -dependencies = [ - "curve25519-dalek 4.1.1", - "ed25519-dalek 2.0.0", - "getrandom", - "hex-literal", - "hmac 0.12.1", - "k256", - "num-derive", - "num-integer", - "num-traits", - "rand", - "rand_chacha", - "sha2 0.10.8", - "sha3", - "soroban-builtin-sdk-macros 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-env-common 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-wasmi 0.31.1-soroban.20.0.0 (registry+https://github.com/rust-lang/crates.io-index)", - "static_assertions", - "stellar-strkey 0.0.8", -] - [[package]] name = "soroban-env-host" version = "20.1.0" @@ -2521,28 +2553,13 @@ dependencies = [ "rand_chacha", "sha2 0.10.8", "sha3", - "soroban-builtin-sdk-macros 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", - "soroban-env-common 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", - "soroban-wasmi 0.31.1-soroban.20.0.0 (git+https://github.com/stellar/wasmi?rev=ab29800224d85ee64d4ac127bac84cdbb0276721)", + "soroban-builtin-sdk-macros", + "soroban-env-common", + "soroban-wasmi", "static_assertions", "stellar-strkey 0.0.8", ] -[[package]] -name = "soroban-env-macros" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff774562cca63a173127b0b6679dce9afde49fd34474eec041dc5612134dda7" -dependencies = [ - "itertools 0.11.0", - "proc-macro2", - "quote", - "serde", - "serde_json", - "stellar-xdr", - "syn", -] - [[package]] name = "soroban-env-macros" version = "20.1.0" @@ -2554,27 +2571,13 @@ dependencies = [ "serde", "serde_json", "stellar-xdr", - "syn", + "syn 2.0.39", ] [[package]] name = "soroban-hello" version = "20.2.0" -[[package]] -name = "soroban-ledger-snapshot" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d0f4b117c50bec49f02eab898957f92f13cae78ee3d17d7660821742251b8c2" -dependencies = [ - "serde", - "serde_json", - "serde_with", - "soroban-env-common 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-env-host 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "thiserror", -] - [[package]] name = "soroban-ledger-snapshot" version = "20.1.0" @@ -2583,28 +2586,11 @@ dependencies = [ "serde", "serde_json", "serde_with", - "soroban-env-common 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", - "soroban-env-host 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", + "soroban-env-common", + "soroban-env-host", "thiserror", ] -[[package]] -name = "soroban-sdk" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6c13c0f657bec67785cb7d204fcaccca553f937a42743c6acedfd993ad69f6" -dependencies = [ - "bytes-lit", - "rand", - "serde", - "serde_json", - "soroban-env-guest 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-env-host 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-ledger-snapshot 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-sdk-macros 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "stellar-strkey 0.0.8", -] - [[package]] name = "soroban-sdk" version = "20.1.0" @@ -2617,33 +2603,13 @@ dependencies = [ "rand", "serde", "serde_json", - "soroban-env-guest 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", - "soroban-env-host 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", - "soroban-ledger-snapshot 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", - "soroban-sdk-macros 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", + "soroban-env-guest", + "soroban-env-host", + "soroban-ledger-snapshot", + "soroban-sdk-macros", "stellar-strkey 0.0.8", ] -[[package]] -name = "soroban-sdk-macros" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc7e5d144250bbea143f0863462e58200e2a82e37fa611b09f239f3d8e849a83" -dependencies = [ - "crate-git-revision 0.0.6", - "darling", - "itertools 0.11.0", - "proc-macro2", - "quote", - "rustc_version", - "sha2 0.10.8", - "soroban-env-common 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-spec 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "soroban-spec-rust 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "stellar-xdr", - "syn", -] - [[package]] name = "soroban-sdk-macros" version = "20.1.0" @@ -2656,11 +2622,11 @@ dependencies = [ "quote", "rustc_version", "sha2 0.10.8", - "soroban-env-common 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", + "soroban-env-common", "soroban-spec 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", - "soroban-spec-rust 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", + "soroban-spec-rust", "stellar-xdr", - "syn", + "syn 2.0.39", ] [[package]] @@ -2670,7 +2636,7 @@ source = "git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a920 dependencies = [ "anyhow", "rand", - "soroban-env-host 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", + "soroban-env-host", "static_assertions", "thiserror", ] @@ -2713,22 +2679,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "soroban-spec-rust" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a1bf5452f0d97cf280f14b66de98571e819e198b124b33472e49dba1128ed9d" -dependencies = [ - "prettyplease", - "proc-macro2", - "quote", - "sha2 0.10.8", - "soroban-spec 20.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "stellar-xdr", - "syn", - "thiserror", -] - [[package]] name = "soroban-spec-rust" version = "20.1.0" @@ -2740,7 +2690,7 @@ dependencies = [ "sha2 0.10.8", "soroban-spec 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", "stellar-xdr", - "syn", + "syn 2.0.39", "thiserror", ] @@ -2794,9 +2744,9 @@ dependencies = [ "serde_json", "sha2 0.10.8", "soroban-cli", - "soroban-env-host 20.1.0 (git+https://github.com/stellar/rs-soroban-env?rev=36d33cb6c986c9a8a9200b7eb04cf02e2c3f0ef4)", - "soroban-ledger-snapshot 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", - "soroban-sdk 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", + "soroban-env-host", + "soroban-ledger-snapshot", + "soroban-sdk", "soroban-spec 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", "soroban-spec-tools", "stellar-strkey 0.0.7", @@ -2810,20 +2760,7 @@ name = "soroban-token-sdk" version = "20.1.0" source = "git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb#e6c2c900ab82b5f6eec48f69cb2cb519e19819cb" dependencies = [ - "soroban-sdk 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", -] - -[[package]] -name = "soroban-wasmi" -version = "0.31.1-soroban.20.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1aaa682a67cbd2173f1d60cb1e7b951d490d7c4e0b7b6f5387cbb952e963c46" -dependencies = [ - "smallvec", - "spin", - "wasmi_arena 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", - "wasmi_core 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", - "wasmparser-nostd", + "soroban-sdk", ] [[package]] @@ -2833,8 +2770,8 @@ source = "git+https://github.com/stellar/wasmi?rev=ab29800224d85ee64d4ac127bac84 dependencies = [ "smallvec", "spin", - "wasmi_arena 0.4.0 (git+https://github.com/stellar/wasmi?rev=ab29800224d85ee64d4ac127bac84cdbb0276721)", - "wasmi_core 0.13.0 (git+https://github.com/stellar/wasmi?rev=ab29800224d85ee64d4ac127bac84cdbb0276721)", + "wasmi_arena", + "wasmi_core", "wasmparser-nostd", ] @@ -2906,12 +2843,42 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + [[package]] name = "subtle" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.39" @@ -2971,28 +2938,28 @@ checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" name = "test_custom_types" version = "20.2.0" dependencies = [ - "soroban-sdk 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", + "soroban-sdk", ] [[package]] name = "test_hello_world" version = "20.2.0" dependencies = [ - "soroban-sdk 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", + "soroban-sdk", ] [[package]] name = "test_swap" version = "20.2.0" dependencies = [ - "soroban-sdk 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", + "soroban-sdk", ] [[package]] name = "test_token" version = "20.2.0" dependencies = [ - "soroban-sdk 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", + "soroban-sdk", "soroban-token-sdk", ] @@ -3000,7 +2967,7 @@ dependencies = [ name = "test_udt" version = "20.2.0" dependencies = [ - "soroban-sdk 20.1.0 (git+https://github.com/stellar/rs-soroban-sdk?rev=e6c2c900ab82b5f6eec48f69cb2cb519e19819cb)", + "soroban-sdk", ] [[package]] @@ -3020,7 +2987,7 @@ checksum = "268026685b2be38d7103e9e507c938a1fcb3d7e6eb15e87870b617bf37b6d581" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -3123,7 +3090,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -3228,7 +3195,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -3303,6 +3270,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + [[package]] name = "untrusted" version = "0.9.0" @@ -3399,7 +3372,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.39", "wasm-bindgen-shared", ] @@ -3421,7 +3394,7 @@ checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3433,28 +3406,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b" [[package]] -name = "wasmi_arena" -version = "0.4.0" +name = "wasm-opt" +version = "0.114.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "401c1f35e413fac1846d4843745589d9ec678977ab35a384db8ae7830525d468" +checksum = "effbef3bd1dde18acb401f73e740a6f3d4a1bc651e9773bddc512fe4d8d68f67" +dependencies = [ + "anyhow", + "libc", + "strum", + "strum_macros", + "tempfile", + "thiserror", + "wasm-opt-cxx-sys", + "wasm-opt-sys", +] [[package]] -name = "wasmi_arena" -version = "0.4.0" -source = "git+https://github.com/stellar/wasmi?rev=ab29800224d85ee64d4ac127bac84cdbb0276721#ab29800224d85ee64d4ac127bac84cdbb0276721" +name = "wasm-opt-cxx-sys" +version = "0.114.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09e24eb283919ace2ed5733bda4842a59ce4c8de110ef5c6d98859513d17047" +dependencies = [ + "anyhow", + "cxx", + "cxx-build", + "wasm-opt-sys", +] [[package]] -name = "wasmi_core" -version = "0.13.0" +name = "wasm-opt-sys" +version = "0.114.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" +checksum = "36f2f817bed2e8d65eb779fa37317e74de15585751f903c9118342d1970703a4" dependencies = [ - "downcast-rs", - "libm", - "num-traits", - "paste", + "anyhow", + "cc", + "cxx", + "cxx-build", ] +[[package]] +name = "wasmi_arena" +version = "0.4.0" +source = "git+https://github.com/stellar/wasmi?rev=ab29800224d85ee64d4ac127bac84cdbb0276721#ab29800224d85ee64d4ac127bac84cdbb0276721" + [[package]] name = "wasmi_core" version = "0.13.0" @@ -3695,5 +3690,5 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] diff --git a/Cargo.toml b/Cargo.toml index 73dc70c4..9da4ca95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,13 @@ [workspace] resolver = "2" members = [ + "cmd/soroban-cli", "cmd/crates/*", "cmd/crates/soroban-test/tests/fixtures/test-wasms/*", "cmd/crates/soroban-test/tests/fixtures/hello", "cmd/soroban-rpc/lib/preflight", ] -default-members = ["cmd/crates/soroban-test"] +default-members = ["cmd/soroban-cli", "cmd/crates/soroban-test"] exclude = ["cmd/crates/soroban-test/tests/fixtures/hello"] [workspace.package] @@ -52,6 +53,10 @@ version = "=20.1.0" git = "https://github.com/stellar/rs-soroban-sdk" rev = "e6c2c900ab82b5f6eec48f69cb2cb519e19819cb" +[workspace.dependencies.soroban-cli] +version = "20.2.0" +path = "cmd/soroban-cli" + [workspace.dependencies.stellar-xdr] version = "=20.0.2" default-features = true @@ -75,7 +80,6 @@ wasmparser = "0.90.0" soroban-spec-json = "20.2.0" soroban-spec-tools = "20.2.0" soroban-spec-typescript = "20.2.0" -soroban-cli = "20.2.0" # [patch."https://github.com/stellar/rs-soroban-env"] diff --git a/Makefile b/Makefile index fdc88efb..23187151 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,7 @@ Cargo.lock: Cargo.toml cargo update --workspace install_rust: Cargo.lock + cargo install --path ./cmd/soroban-cli --debug cargo install --path ./cmd/crates/soroban-test/tests/fixtures/hello --root ./target --debug --quiet install: install_rust build-libpreflight diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml new file mode 100644 index 00000000..ad60c827 --- /dev/null +++ b/cmd/soroban-cli/Cargo.toml @@ -0,0 +1,106 @@ +[package] +name = "soroban-cli" +description = "Soroban CLI" +homepage = "https://github.com/stellar/soroban-cli" +repository = "https://github.com/stellar/soroban-cli" +authors = ["Stellar Development Foundation "] +license = "Apache-2.0" +readme = "README.md" +version = "20.2.0" +edition = "2021" +rust-version.workspace = true +autobins = false +default-run = "soroban" + +[[bin]] +name = "soroban" +path = "src/bin/main.rs" + +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/{ name }-{ version }-{ target }{ archive-suffix }" +bin-dir = "{ bin }{ binary-ext }" + +[[bin]] +name = "doc-gen" +path = "src/bin/doc-gen.rs" +required-features = ["clap-markdown"] + +[lib] +name = "soroban_cli" +path = "src/lib.rs" +doctest = false + +[features] +default = [] +opt = ["dep:wasm-opt"] + +[dependencies] +stellar-xdr = { workspace = true, features = ["cli"] } +soroban-env-host = { workspace = true } +soroban-spec = { workspace = true } +soroban-spec-json = { workspace = true } +soroban-spec-rust = { workspace = true } +soroban-spec-tools = { workspace = true } +soroban-spec-typescript = { workspace = true } +soroban-ledger-snapshot = { workspace = true } +stellar-strkey = { workspace = true } +soroban-sdk = { workspace = true } +clap = { version = "4.1.8", features = [ + "derive", + "env", + "deprecated", + "string", +] } +base64 = { workspace = true } +thiserror = { workspace = true } +serde = "1.0.82" +serde_derive = "1.0.82" +serde_json = "1.0.82" +serde-aux = "4.1.2" +hex = { workspace = true } +num-bigint = "0.4" +tokio = { version = "1", features = ["full"] } +termcolor = "1.1.3" +termcolor_output = "1.0.1" +clap_complete = "4.1.4" +rand = "0.8.5" +wasmparser = { workspace = true } +sha2 = { workspace = true } +csv = "1.1.6" +ed25519-dalek = "=2.0.0" +jsonrpsee-http-client = "0.20.1" +jsonrpsee-core = "0.20.1" +hyper = "0.14.27" +hyper-tls = "0.5" +http = "0.2.9" +regex = "1.6.0" +wasm-opt = { version = "0.114.0", optional = true } +chrono = "0.4.27" +rpassword = "7.2.0" +dirs = "4.0.0" +toml = "0.5.9" +itertools = "0.10.5" +shlex = "1.1.0" +sep5 = { workspace = true } +ethnum = { workspace = true } +clap-markdown = { version = "0.1.3", optional = true } +which = { workspace = true, features = ["regex"] } +strsim = "0.10.0" +heck = "0.4.1" +tracing = { workspace = true } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +cargo_metadata = "0.15.4" +pathdiff = "0.2.1" +dotenvy = "0.15.7" +# For hyper-tls +[target.'cfg(unix)'.dependencies] +openssl = { version = "0.10.55", features = ["vendored"] } + +[build-dependencies] +crate-git-revision = "0.0.4" + +[dev-dependencies] +assert_cmd = "2.0.4" +assert_fs = "1.0.7" +predicates = "2.1.5" diff --git a/cmd/soroban-cli/README.md b/cmd/soroban-cli/README.md new file mode 100644 index 00000000..d17261b1 --- /dev/null +++ b/cmd/soroban-cli/README.md @@ -0,0 +1,28 @@ +# soroban-cli + +CLI for running Soroban contracts locally in a test VM. Executes WASM files built using the [rs-soroban-sdk](https://github.com/stellar/rs-soroban-sdk). + +Soroban: https://soroban.stellar.org + +## Install + +``` +cargo install --locked soroban-cli +``` + +To install with the `opt` feature, which includes a WASM optimization feature and wasm-opt built in: + +``` +cargo install --locked soroban-cli --features opt +``` + +## Usage + +Can invoke a contract method as a subcommand with different arguments. Anything after the slop (`--`) is passed to the contract's CLI. You can use `--help` to learn about which methods are available and what their arguments are including an example of the type of the input. + +## Example + +``` +soroban invoke --id --wasm -- --help +soroban invoke --id --network futurenet -- --help +``` diff --git a/cmd/soroban-cli/build.rs b/cmd/soroban-cli/build.rs new file mode 100644 index 00000000..b6e6dd92 --- /dev/null +++ b/cmd/soroban-cli/build.rs @@ -0,0 +1,3 @@ +fn main() { + crate_git_revision::init(); +} diff --git a/cmd/soroban-cli/src/bin/doc-gen.rs b/cmd/soroban-cli/src/bin/doc-gen.rs new file mode 100644 index 00000000..096f9681 --- /dev/null +++ b/cmd/soroban-cli/src/bin/doc-gen.rs @@ -0,0 +1,36 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +type DynError = Box; + +fn main() -> Result<(), DynError> { + doc_gen()?; + Ok(()) +} + +fn doc_gen() -> std::io::Result<()> { + let out_dir = docs_dir(); + + fs::create_dir_all(out_dir.clone())?; + + std::fs::write( + out_dir.join("soroban-cli-full-docs.md"), + clap_markdown::help_markdown::(), + )?; + + Ok(()) +} + +fn project_root() -> PathBuf { + Path::new(&env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap() + .to_path_buf() +} + +fn docs_dir() -> PathBuf { + project_root().join("docs") +} diff --git a/cmd/soroban-cli/src/bin/main.rs b/cmd/soroban-cli/src/bin/main.rs new file mode 100644 index 00000000..7a87099c --- /dev/null +++ b/cmd/soroban-cli/src/bin/main.rs @@ -0,0 +1,51 @@ +use clap::CommandFactory; +use dotenvy::dotenv; +use tracing_subscriber::{fmt, EnvFilter}; + +use soroban_cli::{commands, Root}; + +#[tokio::main] +async fn main() { + let _ = dotenv().unwrap_or_default(); + let mut root = Root::new().unwrap_or_else(|e| match e { + commands::Error::Clap(e) => { + let mut cmd = Root::command(); + e.format(&mut cmd).exit(); + } + e => { + eprintln!("{e}"); + std::process::exit(1); + } + }); + // Now use root to setup the logger + if let Some(level) = root.global_args.log_level() { + let mut e_filter = EnvFilter::from_default_env() + .add_directive("hyper=off".parse().unwrap()) + .add_directive(format!("soroban_cli={level}").parse().unwrap()); + + for filter in &root.global_args.filter_logs { + e_filter = e_filter.add_directive( + filter + .parse() + .map_err(|e| { + eprintln!("{e}: {filter}"); + std::process::exit(1); + }) + .unwrap(), + ); + } + + let builder = fmt::Subscriber::builder() + .with_env_filter(e_filter) + .with_writer(std::io::stderr); + + let subscriber = builder.finish(); + tracing::subscriber::set_global_default(subscriber) + .expect("Failed to set the global tracing subscriber"); + } + + if let Err(e) = root.run().await { + eprintln!("error: {e}"); + std::process::exit(1); + } +} diff --git a/cmd/soroban-cli/src/commands/completion.rs b/cmd/soroban-cli/src/commands/completion.rs new file mode 100644 index 00000000..f64386b4 --- /dev/null +++ b/cmd/soroban-cli/src/commands/completion.rs @@ -0,0 +1,32 @@ +use clap::{arg, CommandFactory, Parser}; +use clap_complete::{generate, Shell}; +use std::io; + +use crate::commands::Root; + +pub const LONG_ABOUT: &str = "\ +Print shell completion code for the specified shell + +Ensure the completion package for your shell is installed, +e.g., bash-completion for bash. + +To enable autocomplete in the current bash shell, run: + source <(soroban completion --shell bash) + +To enable autocomplete permanently, run: + echo \"source <(soroban completion --shell bash)\" >> ~/.bashrc"; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// The shell type + #[arg(long, value_enum)] + shell: Shell, +} + +impl Cmd { + pub fn run(&self) { + let cmd = &mut Root::command(); + generate(self.shell, cmd, "soroban", &mut io::stdout()); + } +} diff --git a/cmd/soroban-cli/src/commands/config/locator.rs b/cmd/soroban-cli/src/commands/config/locator.rs new file mode 100644 index 00000000..2688b043 --- /dev/null +++ b/cmd/soroban-cli/src/commands/config/locator.rs @@ -0,0 +1,358 @@ +use clap::arg; +use serde::de::DeserializeOwned; +use std::{ + ffi::OsStr, + fmt::Display, + fs, io, + path::{Path, PathBuf}, + str::FromStr, +}; + +use crate::{utils::find_config_dir, Pwd}; + +use super::{network::Network, secret::Secret}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Failed to find home directory")] + HomeDirNotFound, + #[error("Failed read current directory")] + CurrentDirNotFound, + #[error("Failed read current directory and no SOROBAN_CONFIG_HOME is set")] + NoConfigEnvVar, + #[error("Failed to create directory: {path:?}")] + DirCreationFailed { path: PathBuf }, + #[error( + "Failed to read secret's file: {path}.\nProbably need to use `soroban config identity add`" + )] + SecretFileRead { path: PathBuf }, + #[error( + "Failed to read network file: {path};\nProbably need to use `soroban config network add`" + )] + NetworkFileRead { path: PathBuf }, + #[error(transparent)] + Toml(#[from] toml::de::Error), + #[error("Seceret file failed to deserialize")] + Deserialization, + #[error("Failed to write identity file:{filepath}: {error}")] + IdCreationFailed { filepath: PathBuf, error: io::Error }, + #[error("Seceret file failed to deserialize")] + NetworkDeserialization, + #[error("Failed to write network file: {0}")] + NetworkCreationFailed(std::io::Error), + #[error("Error Identity directory is invalid: {name}")] + IdentityList { name: String }, + // #[error("Config file failed to deserialize")] + // CannotReadConfigFile, + #[error("Config file failed to serialize")] + ConfigSerialization, + // #[error("Config file failed write")] + // CannotWriteConfigFile, + #[error("XDG_CONFIG_HOME env variable is not a valid path. Got {0}")] + XdgConfigHome(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("Failed to remove {0}: {1}")] + ConfigRemoval(String, String), + #[error("Failed to find config {0} for {1}")] + ConfigMissing(String, String), + #[error(transparent)] + String(#[from] std::string::FromUtf8Error), + #[error(transparent)] + Secret(#[from] crate::commands::config::secret::Error), +} + +#[derive(Debug, clap::Args, Default, Clone)] +#[group(skip)] +pub struct Args { + /// Use global config + #[arg(long)] + pub global: bool, + + /// Location of config directory, default is "." + #[arg(long, help_heading = "TESTING_OPTIONS")] + pub config_dir: Option, +} + +pub enum Location { + Local(PathBuf), + Global(PathBuf), +} + +impl Display for Location { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} {:?}", + match self { + Location::Local(_) => "Local", + Location::Global(_) => "Global", + }, + self.as_ref().parent().unwrap().parent().unwrap() + ) + } +} + +impl AsRef for Location { + fn as_ref(&self) -> &Path { + match self { + Location::Local(p) | Location::Global(p) => p.as_path(), + } + } +} + +impl Location { + #[must_use] + pub fn wrap(&self, p: PathBuf) -> Self { + match self { + Location::Local(_) => Location::Local(p), + Location::Global(_) => Location::Global(p), + } + } +} + +impl Args { + pub fn config_dir(&self) -> Result { + if self.global { + global_config_path() + } else { + self.local_config() + } + } + + pub fn local_and_global(&self) -> Result<[Location; 2], Error> { + Ok([ + Location::Local(self.local_config()?), + Location::Global(global_config_path()?), + ]) + } + + pub fn local_config(&self) -> Result { + let pwd = self.current_dir()?; + Ok(find_config_dir(pwd.clone()).unwrap_or_else(|_| pwd.join(".soroban"))) + } + + pub fn current_dir(&self) -> Result { + self.config_dir.as_ref().map_or_else( + || std::env::current_dir().map_err(|_| Error::CurrentDirNotFound), + |pwd| Ok(pwd.clone()), + ) + } + + pub fn write_identity(&self, name: &str, secret: &Secret) -> Result<(), Error> { + KeyType::Identity.write(name, secret, &self.config_dir()?) + } + + pub fn write_network(&self, name: &str, network: &Network) -> Result<(), Error> { + KeyType::Network.write(name, network, &self.config_dir()?) + } + + pub fn list_identities(&self) -> Result, Error> { + Ok(KeyType::Identity + .list_paths(&self.local_and_global()?)? + .into_iter() + .map(|(name, _)| name) + .collect()) + } + + pub fn list_identities_long(&self) -> Result, Error> { + Ok(KeyType::Identity + .list_paths(&self.local_and_global()?) + .into_iter() + .flatten() + .map(|(name, location)| { + let path = match location { + Location::Local(path) | Location::Global(path) => path, + }; + (name, format!("{}", path.display())) + }) + .collect()) + } + + pub fn list_networks(&self) -> Result, Error> { + Ok(KeyType::Network + .list_paths(&self.local_and_global()?) + .into_iter() + .flatten() + .map(|x| x.0) + .collect()) + } + + pub fn list_networks_long(&self) -> Result, Error> { + Ok(KeyType::Network + .list_paths(&self.local_and_global()?) + .into_iter() + .flatten() + .filter_map(|(name, location)| { + Some(( + name, + KeyType::read_from_path::(location.as_ref()).ok()?, + location, + )) + }) + .collect::>()) + } + pub fn read_identity(&self, name: &str) -> Result { + KeyType::Identity.read_with_global(name, &self.local_config()?) + } + + pub fn read_network(&self, name: &str) -> Result { + let res = KeyType::Network.read_with_global(name, &self.local_config()?); + if let Err(Error::ConfigMissing(_, _)) = &res { + if name == "futurenet" { + let network = Network::futurenet(); + self.write_network(name, &network)?; + return Ok(network); + } + } + res + } + + pub fn remove_identity(&self, name: &str) -> Result<(), Error> { + KeyType::Identity.remove(name, &self.config_dir()?) + } + + pub fn remove_network(&self, name: &str) -> Result<(), Error> { + KeyType::Network.remove(name, &self.config_dir()?) + } +} + +fn ensure_directory(dir: PathBuf) -> Result { + let parent = dir.parent().ok_or(Error::HomeDirNotFound)?; + std::fs::create_dir_all(parent).map_err(|_| dir_creation_failed(parent))?; + Ok(dir) +} + +fn dir_creation_failed(p: &Path) -> Error { + Error::DirCreationFailed { + path: p.to_path_buf(), + } +} + +fn read_dir(dir: &Path) -> Result, Error> { + let contents = std::fs::read_dir(dir)?; + let mut res = vec![]; + for entry in contents.filter_map(Result::ok) { + let path = entry.path(); + if let Some("toml") = path.extension().and_then(OsStr::to_str) { + if let Some(os_str) = path.file_stem() { + res.push((os_str.to_string_lossy().trim().to_string(), path)); + } + } + } + res.sort(); + Ok(res) +} + +pub enum KeyType { + Identity, + Network, +} + +impl Display for KeyType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + KeyType::Identity => "identity", + KeyType::Network => "network", + } + ) + } +} + +impl KeyType { + pub fn read(&self, key: &str, pwd: &Path) -> Result { + let path = self.path(pwd, key); + Self::read_from_path(&path) + } + + pub fn read_from_path(path: &Path) -> Result { + let data = fs::read(path).map_err(|_| Error::NetworkFileRead { + path: path.to_path_buf(), + })?; + let res = toml::from_slice(data.as_slice()); + Ok(res?) + } + + pub fn read_with_global(&self, key: &str, pwd: &Path) -> Result { + for path in [pwd, global_config_path()?.as_path()] { + match self.read(key, path) { + Ok(t) => return Ok(t), + _ => continue, + } + } + Err(Error::ConfigMissing(self.to_string(), key.to_string())) + } + + pub fn write( + &self, + key: &str, + value: &T, + pwd: &Path, + ) -> Result<(), Error> { + let filepath = ensure_directory(self.path(pwd, key))?; + let data = toml::to_string(value).map_err(|_| Error::ConfigSerialization)?; + std::fs::write(&filepath, data).map_err(|error| Error::IdCreationFailed { filepath, error }) + } + + fn root(&self, pwd: &Path) -> PathBuf { + pwd.join(self.to_string()) + } + + fn path(&self, pwd: &Path, key: &str) -> PathBuf { + let mut path = self.root(pwd).join(key); + path.set_extension("toml"); + path + } + + pub fn list_paths(&self, paths: &[Location]) -> Result, Error> { + Ok(paths + .iter() + .flat_map(|p| self.list(p).unwrap_or_default()) + .collect()) + } + + pub fn list(&self, pwd: &Location) -> Result, Error> { + let path = self.root(pwd.as_ref()); + if path.exists() { + let mut files = read_dir(&path)?; + files.sort(); + + Ok(files + .into_iter() + .map(|(name, p)| (name, pwd.wrap(p))) + .collect()) + } else { + Ok(vec![]) + } + } + + pub fn remove(&self, key: &str, pwd: &Path) -> Result<(), Error> { + let path = self.path(pwd, key); + if path.exists() { + std::fs::remove_file(&path) + .map_err(|_| Error::ConfigRemoval(self.to_string(), key.to_string())) + } else { + Ok(()) + } + } +} + +fn global_config_path() -> Result { + Ok(if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from_str(&config_home).map_err(|_| Error::XdgConfigHome(config_home))? + } else { + dirs::home_dir() + .ok_or(Error::HomeDirNotFound)? + .join(".config") + } + .join("soroban")) +} + +impl Pwd for Args { + fn set_pwd(&mut self, pwd: &Path) { + self.config_dir = Some(pwd.to_path_buf()); + } +} diff --git a/cmd/soroban-cli/src/commands/config/mod.rs b/cmd/soroban-cli/src/commands/config/mod.rs new file mode 100644 index 00000000..be76e77f --- /dev/null +++ b/cmd/soroban-cli/src/commands/config/mod.rs @@ -0,0 +1,95 @@ +use std::path::PathBuf; + +use clap::{arg, command, Parser}; +use serde::{Deserialize, Serialize}; + +use crate::Pwd; + +use self::{network::Network, secret::Secret}; + +use super::{keys, network}; + +pub mod locator; +pub mod secret; + +#[derive(Debug, Parser)] +pub enum Cmd { + /// Configure different networks. Depraecated, use `soroban network` instead. + #[command(subcommand)] + Network(network::Cmd), + /// Identity management. Deprecated, use `soroban keys` instead. + #[command(subcommand)] + Identity(keys::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Identity(#[from] keys::Error), + #[error(transparent)] + Network(#[from] network::Error), + #[error(transparent)] + Secret(#[from] secret::Error), + #[error(transparent)] + Config(#[from] locator::Error), +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + match &self { + Cmd::Identity(identity) => identity.run().await?, + Cmd::Network(network) => network.run()?, + } + Ok(()) + } +} + +#[derive(Debug, clap::Args, Clone, Default)] +#[group(skip)] +pub struct Args { + #[command(flatten)] + pub network: network::Args, + + #[arg(long, visible_alias = "source", env = "SOROBAN_ACCOUNT")] + /// Account that signs the final transaction. Alias `source`. Can be an identity (--source alice), a secret key (--source SC36…), or a seed phrase (--source "kite urban…"). Default: `identity generate --default-seed` + pub source_account: String, + + #[arg(long)] + /// If using a seed phrase, which hierarchical deterministic path to use, e.g. `m/44'/148'/{hd_path}`. Example: `--hd-path 1`. Default: `0` + pub hd_path: Option, + + #[command(flatten)] + pub locator: locator::Args, +} + +impl Args { + pub fn key_pair(&self) -> Result { + let key = self.account(&self.source_account)?; + Ok(key.key_pair(self.hd_path)?) + } + + pub fn account(&self, account_str: &str) -> Result { + if let Ok(secret) = self.locator.read_identity(account_str) { + Ok(secret) + } else { + Ok(account_str.parse::()?) + } + } + + pub fn get_network(&self) -> Result { + Ok(self.network.get(&self.locator)?) + } + + pub fn config_dir(&self) -> Result { + Ok(self.locator.config_dir()?) + } +} + +impl Pwd for Args { + fn set_pwd(&mut self, pwd: &std::path::Path) { + self.locator.set_pwd(pwd); + } +} + +#[derive(Default, Serialize, Deserialize)] +pub struct Config {} diff --git a/cmd/soroban-cli/src/commands/config/secret.rs b/cmd/soroban-cli/src/commands/config/secret.rs new file mode 100644 index 00000000..4684e2a8 --- /dev/null +++ b/cmd/soroban-cli/src/commands/config/secret.rs @@ -0,0 +1,143 @@ +use clap::arg; +use serde::{Deserialize, Serialize}; +use std::{io::Write, str::FromStr}; +use stellar_strkey::ed25519::{PrivateKey, PublicKey}; + +use crate::utils; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("invalid secret key")] + InvalidSecretKey, + // #[error("seed_phrase must be 12 words long, found {len}")] + // InvalidSeedPhrase { len: usize }, + #[error("seceret input error")] + PasswordRead, + #[error(transparent)] + Secret(#[from] stellar_strkey::DecodeError), + #[error(transparent)] + SeedPhrase(#[from] sep5::error::Error), + #[error(transparent)] + Ed25519(#[from] ed25519_dalek::SignatureError), + #[error("Invalid address {0}")] + InvalidAddress(String), +} + +#[derive(Debug, clap::Args, Clone)] +#[group(skip)] +pub struct Args { + /// Add using secret_key + /// Can provide with SOROBAN_SECRET_KEY + #[arg(long, conflicts_with = "seed_phrase")] + pub secret_key: bool, + /// Add using 12 word seed phrase to generate secret_key + #[arg(long, conflicts_with = "secret_key")] + pub seed_phrase: bool, +} + +impl Args { + pub fn read_secret(&self) -> Result { + if let Ok(secret_key) = std::env::var("SOROBAN_SECRET_KEY") { + Ok(Secret::SecretKey { secret_key }) + } else if self.secret_key { + println!("Type a secret key: "); + let secret_key = read_password()?; + let secret_key = PrivateKey::from_string(&secret_key) + .map_err(|_| Error::InvalidSecretKey)? + .to_string(); + Ok(Secret::SecretKey { secret_key }) + } else if self.seed_phrase { + println!("Type a 12 word seed phrase: "); + let seed_phrase = read_password()?; + let seed_phrase: Vec<&str> = seed_phrase.split_whitespace().collect(); + // if seed_phrase.len() != 12 { + // let len = seed_phrase.len(); + // return Err(Error::InvalidSeedPhrase { len }); + // } + Ok(Secret::SeedPhrase { + seed_phrase: seed_phrase + .into_iter() + .map(ToString::to_string) + .collect::>() + .join(" "), + }) + } else { + Err(Error::PasswordRead {}) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Secret { + SecretKey { secret_key: String }, + SeedPhrase { seed_phrase: String }, +} + +impl FromStr for Secret { + type Err = Error; + + fn from_str(s: &str) -> Result { + if PrivateKey::from_string(s).is_ok() { + Ok(Secret::SecretKey { + secret_key: s.to_string(), + }) + } else if sep5::SeedPhrase::from_str(s).is_ok() { + Ok(Secret::SeedPhrase { + seed_phrase: s.to_string(), + }) + } else { + Err(Error::InvalidAddress(s.to_string())) + } + } +} + +impl From for Secret { + fn from(value: PrivateKey) -> Self { + Secret::SecretKey { + secret_key: value.to_string(), + } + } +} + +impl Secret { + pub fn private_key(&self, index: Option) -> Result { + Ok(match self { + Secret::SecretKey { secret_key } => PrivateKey::from_string(secret_key)?, + Secret::SeedPhrase { seed_phrase } => sep5::SeedPhrase::from_str(seed_phrase)? + .from_path_index(index.unwrap_or_default(), None)? + .private(), + }) + } + + pub fn public_key(&self, index: Option) -> Result { + let key = self.key_pair(index)?; + Ok(stellar_strkey::ed25519::PublicKey::from_payload( + key.verifying_key().as_bytes(), + )?) + } + + pub fn key_pair(&self, index: Option) -> Result { + Ok(utils::into_signing_key(&self.private_key(index)?)) + } + + pub fn from_seed(seed: Option<&str>) -> Result { + let seed_phrase = if let Some(seed) = seed.map(str::as_bytes) { + sep5::SeedPhrase::from_entropy(seed) + } else { + sep5::SeedPhrase::random(sep5::MnemonicType::Words12) + }? + .seed_phrase + .into_phrase(); + Ok(Secret::SeedPhrase { seed_phrase }) + } + + pub fn test_seed_phrase() -> Result { + Self::from_seed(Some("0000000000000000")) + } +} + +fn read_password() -> Result { + std::io::stdout().flush().map_err(|_| Error::PasswordRead)?; + rpassword::read_password().map_err(|_| Error::PasswordRead) +} diff --git a/cmd/soroban-cli/src/commands/contract/asset.rs b/cmd/soroban-cli/src/commands/contract/asset.rs new file mode 100644 index 00000000..ad7be020 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/asset.rs @@ -0,0 +1,27 @@ +use super::{deploy, id}; + +#[derive(Debug, clap::Subcommand)] +pub enum Cmd { + /// Get Id of builtin Soroban Asset Contract. Deprecated, use `soroban contract id asset` instead + Id(id::asset::Cmd), + /// Deploy builtin Soroban Asset Contract + Deploy(deploy::asset::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Id(#[from] id::asset::Error), + #[error(transparent)] + Deploy(#[from] deploy::asset::Error), +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + match &self { + Cmd::Id(id) => id.run()?, + Cmd::Deploy(asset) => asset.run().await?, + } + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/bindings.rs b/cmd/soroban-cli/src/commands/contract/bindings.rs new file mode 100644 index 00000000..1da94697 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/bindings.rs @@ -0,0 +1,38 @@ +pub mod json; +pub mod rust; +pub mod typescript; + +#[derive(Debug, clap::Subcommand)] +pub enum Cmd { + /// Generate Json Bindings + Json(json::Cmd), + + /// Generate Rust bindings + Rust(rust::Cmd), + + /// Generate a TypeScript / JavaScript package + Typescript(typescript::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Json(#[from] json::Error), + + #[error(transparent)] + Rust(#[from] rust::Error), + + #[error(transparent)] + Typescript(#[from] typescript::Error), +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + match &self { + Cmd::Json(json) => json.run()?, + Cmd::Rust(rust) => rust.run()?, + Cmd::Typescript(ts) => ts.run().await?, + } + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/bindings/json.rs b/cmd/soroban-cli/src/commands/contract/bindings/json.rs new file mode 100644 index 00000000..060f9064 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/bindings/json.rs @@ -0,0 +1,29 @@ +use std::fmt::Debug; + +use clap::{command, Parser}; +use soroban_spec_json; + +use crate::wasm; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + wasm: wasm::Args, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("generate json from file: {0}")] + GenerateJsonFromFile(soroban_spec_json::GenerateFromFileError), +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + let wasm_path_str = self.wasm.wasm.to_string_lossy(); + let json = soroban_spec_json::generate_from_file(&wasm_path_str, None) + .map_err(Error::GenerateJsonFromFile)?; + println!("{json}"); + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/bindings/rust.rs b/cmd/soroban-cli/src/commands/contract/bindings/rust.rs new file mode 100644 index 00000000..176732ec --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/bindings/rust.rs @@ -0,0 +1,39 @@ +use std::fmt::Debug; + +use clap::{command, Parser}; +use soroban_spec_rust::{self, ToFormattedString}; + +use crate::wasm; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + wasm: wasm::Args, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("generate rust from file: {0}")] + GenerateRustFromFile(soroban_spec_rust::GenerateFromFileError), + #[error("format rust error: {0}")] + FormatRust(String), +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + let wasm_path_str = self.wasm.wasm.to_string_lossy(); + let code = soroban_spec_rust::generate_from_file(&wasm_path_str, None) + .map_err(Error::GenerateRustFromFile)?; + match code.to_formatted_string() { + Ok(formatted) => { + println!("{formatted}"); + Ok(()) + } + Err(e) => { + println!("{code}"); + Err(Error::FormatRust(e.to_string())) + } + } + } +} diff --git a/cmd/soroban-cli/src/commands/contract/bindings/typescript.rs b/cmd/soroban-cli/src/commands/contract/bindings/typescript.rs new file mode 100644 index 00000000..19c7eecd --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/bindings/typescript.rs @@ -0,0 +1,131 @@ +use std::{ffi::OsString, fmt::Debug, path::PathBuf}; + +use clap::{command, Parser}; +use soroban_spec_typescript::{self as typescript, boilerplate::Project}; + +use crate::wasm; +use crate::{ + commands::{ + config::locator, + contract::{self, fetch}, + network::{self, Network}, + }, + utils::contract_spec::{self, ContractSpec}, +}; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// Path to optional wasm binary + #[arg(long)] + pub wasm: Option, + /// Where to place generated project + #[arg(long)] + output_dir: PathBuf, + /// Whether to overwrite output directory if it already exists + #[arg(long)] + overwrite: bool, + /// The contract ID/address on the network + #[arg(long, visible_alias = "id")] + contract_id: String, + #[command(flatten)] + locator: locator::Args, + #[command(flatten)] + network: network::Args, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("failed generate TS from file: {0}")] + GenerateTSFromFile(typescript::GenerateFromFileError), + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error("--output-dir cannot be a file: {0:?}")] + IsFile(PathBuf), + + #[error("--output-dir already exists and you did not specify --overwrite: {0:?}")] + OutputDirExists(PathBuf), + + #[error("--output-dir filepath not representable as utf-8: {0:?}")] + NotUtf8(OsString), + + #[error(transparent)] + Network(#[from] network::Error), + + #[error(transparent)] + Locator(#[from] locator::Error), + #[error(transparent)] + Fetch(#[from] fetch::Error), + #[error(transparent)] + Spec(#[from] contract_spec::Error), + #[error(transparent)] + Wasm(#[from] wasm::Error), + #[error("Failed to get file name from path: {0:?}")] + FailedToGetFileName(PathBuf), +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + let spec = if let Some(wasm) = &self.wasm { + let wasm: wasm::Args = wasm.into(); + wasm.parse()?.spec + } else { + let fetch = contract::fetch::Cmd { + contract_id: self.contract_id.clone(), + out_file: None, + locator: self.locator.clone(), + network: self.network.clone(), + }; + let bytes = fetch.get_bytes().await?; + ContractSpec::new(&bytes)?.spec + }; + if self.output_dir.is_file() { + return Err(Error::IsFile(self.output_dir.clone())); + } + if self.output_dir.exists() { + if self.overwrite { + std::fs::remove_dir_all(&self.output_dir)?; + } else { + return Err(Error::OutputDirExists(self.output_dir.clone())); + } + } + std::fs::create_dir_all(&self.output_dir)?; + let p: Project = self.output_dir.clone().try_into()?; + let Network { + rpc_url, + network_passphrase, + .. + } = self + .network + .get(&self.locator) + .ok() + .unwrap_or_else(Network::futurenet); + let absolute_path = self.output_dir.canonicalize()?; + let file_name = absolute_path + .file_name() + .ok_or_else(|| Error::FailedToGetFileName(absolute_path.clone()))?; + let contract_name = &file_name + .to_str() + .ok_or_else(|| Error::NotUtf8(file_name.to_os_string()))?; + p.init( + contract_name, + &self.contract_id, + &rpc_url, + &network_passphrase, + &spec, + )?; + std::process::Command::new("npm") + .arg("install") + .current_dir(&self.output_dir) + .spawn()? + .wait()?; + std::process::Command::new("npm") + .arg("run") + .arg("build") + .current_dir(&self.output_dir) + .spawn()? + .wait()?; + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs new file mode 100644 index 00000000..ba17bd1b --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -0,0 +1,194 @@ +use clap::Parser; +use itertools::Itertools; +use std::{ + collections::HashSet, + env, + ffi::OsStr, + fmt::Debug, + fs, io, + path::Path, + process::{Command, ExitStatus, Stdio}, +}; + +use cargo_metadata::{Metadata, MetadataCommand, Package}; + +/// Build a contract from source +/// +/// Builds all crates that are referenced by the cargo manifest (Cargo.toml) +/// that have cdylib as their crate-type. Crates are built for the wasm32 +/// target. Unless configured otherwise, crates are built with their default +/// features and with their release profile. +/// +/// To view the commands that will be executed, without executing them, use the +/// --print-commands-only option. +#[derive(Parser, Debug, Clone)] +pub struct Cmd { + /// Path to Cargo.toml + #[arg(long, default_value = "Cargo.toml")] + pub manifest_path: std::path::PathBuf, + /// Package to build + /// + /// If omitted, all packages that build for crate-type cdylib are built. + #[arg(long)] + pub package: Option, + /// Build with the specified profile + #[arg(long, default_value = "release")] + pub profile: String, + /// Build with the list of features activated, space or comma separated + #[arg(long, help_heading = "Features")] + pub features: Option, + /// Build with the all features activated + #[arg( + long, + conflicts_with = "features", + conflicts_with = "no_default_features", + help_heading = "Features" + )] + pub all_features: bool, + /// Build with the default feature not activated + #[arg(long, help_heading = "Features")] + pub no_default_features: bool, + /// Directory to copy wasm files to + /// + /// If provided, wasm files can be found in the cargo target directory, and + /// the specified directory. + /// + /// If ommitted, wasm files are written only to the cargo target directory. + #[arg(long)] + pub out_dir: Option, + /// Print commands to build without executing them + #[arg(long, conflicts_with = "out_dir", help_heading = "Other")] + pub print_commands_only: bool, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Metadata(#[from] cargo_metadata::Error), + #[error(transparent)] + CargoCmd(io::Error), + #[error("exit status {0}")] + Exit(ExitStatus), + #[error("package {package} not found")] + PackageNotFound { package: String }, + #[error("creating out directory: {0}")] + CreatingOutDir(io::Error), + #[error("copying wasm file: {0}")] + CopyingWasmFile(io::Error), + #[error("getting the current directory: {0}")] + GettingCurrentDir(io::Error), +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?; + + let metadata = self.metadata()?; + let packages = self.packages(&metadata); + let target_dir = &metadata.target_directory; + + if let Some(package) = &self.package { + if packages.is_empty() { + return Err(Error::PackageNotFound { + package: package.clone(), + }); + } + } + + for p in packages { + let mut cmd = Command::new("cargo"); + cmd.stdout(Stdio::piped()); + cmd.arg("rustc"); + let manifest_path = pathdiff::diff_paths(&p.manifest_path, &working_dir) + .unwrap_or(p.manifest_path.clone().into()); + cmd.arg(format!( + "--manifest-path={}", + manifest_path.to_string_lossy() + )); + cmd.arg("--crate-type=cdylib"); + cmd.arg("--target=wasm32-unknown-unknown"); + if self.profile == "release" { + cmd.arg("--release"); + } else { + cmd.arg(format!("--profile={}", self.profile)); + } + if self.all_features { + cmd.arg("--all-features"); + } + if self.no_default_features { + cmd.arg("--no-default-features"); + } + if let Some(features) = self.features() { + let requested: HashSet = features.iter().cloned().collect(); + let available = p.features.iter().map(|f| f.0).cloned().collect(); + let activate = requested.intersection(&available).join(","); + if !activate.is_empty() { + cmd.arg(format!("--features={activate}")); + } + } + let cmd_str = format!( + "cargo {}", + cmd.get_args().map(OsStr::to_string_lossy).join(" ") + ); + + if self.print_commands_only { + println!("{cmd_str}"); + } else { + eprintln!("{cmd_str}"); + let status = cmd.status().map_err(Error::CargoCmd)?; + if !status.success() { + return Err(Error::Exit(status)); + } + + if let Some(out_dir) = &self.out_dir { + fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?; + + let file = format!("{}.wasm", p.name.replace('-', "_")); + let target_file_path = Path::new(target_dir) + .join("wasm32-unknown-unknown") + .join(&self.profile) + .join(&file); + let out_file_path = Path::new(out_dir).join(&file); + fs::copy(target_file_path, out_file_path).map_err(Error::CopyingWasmFile)?; + } + } + } + + Ok(()) + } + + fn features(&self) -> Option> { + self.features + .as_ref() + .map(|f| f.split(&[',', ' ']).map(String::from).collect()) + } + + fn packages(&self, metadata: &Metadata) -> Vec { + metadata + .packages + .iter() + .filter(|p| + // Filter by the package name if one is provided. + self.package.is_none() || Some(&p.name) == self.package.as_ref()) + .filter(|p| { + // Filter crates by those that build to cdylib (wasm), unless a + // package is provided. + self.package.is_some() + || p.targets + .iter() + .any(|t| t.crate_types.iter().any(|c| c == "cdylib")) + }) + .cloned() + .collect() + } + + fn metadata(&self) -> Result { + let mut cmd = MetadataCommand::new(); + cmd.no_deps(); + cmd.manifest_path(&self.manifest_path); + // Do not configure features on the metadata command, because we are + // only collecting non-dependency metadata, features have no impact on + // the output. + cmd.exec() + } +} diff --git a/cmd/soroban-cli/src/commands/contract/deploy.rs b/cmd/soroban-cli/src/commands/contract/deploy.rs new file mode 100644 index 00000000..9baf4459 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/deploy.rs @@ -0,0 +1,28 @@ +pub mod asset; +pub mod wasm; + +#[derive(Debug, clap::Subcommand)] +pub enum Cmd { + /// Deploy builtin Soroban Asset Contract + Asset(asset::Cmd), + /// Deploy normal Wasm Contract + Wasm(wasm::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Asset(#[from] asset::Error), + #[error(transparent)] + Wasm(#[from] wasm::Error), +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + match &self { + Cmd::Asset(asset) => asset.run().await?, + Cmd::Wasm(wasm) => wasm.run().await?, + } + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/deploy/asset.rs b/cmd/soroban-cli/src/commands/contract/deploy/asset.rs new file mode 100644 index 00000000..c10bf816 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/deploy/asset.rs @@ -0,0 +1,155 @@ +use clap::{arg, command, Parser}; +use soroban_env_host::{ + xdr::{ + Asset, ContractDataDurability, ContractExecutable, ContractIdPreimage, CreateContractArgs, + Error as XdrError, Hash, HostFunction, InvokeHostFunctionOp, LedgerKey::ContractData, + LedgerKeyContractData, Memo, MuxedAccount, Operation, OperationBody, Preconditions, + ScAddress, ScVal, SequenceNumber, Transaction, TransactionExt, Uint256, VecM, + }, + HostError, +}; +use std::convert::Infallible; +use std::{array::TryFromSliceError, fmt::Debug, num::ParseIntError}; + +use crate::{ + commands::config, + rpc::{Client, Error as SorobanRpcError}, + utils::{contract_id_hash_from_asset, parsing::parse_asset}, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + // TODO: the Display impl of host errors is pretty user-unfriendly + // (it just calls Debug). I think we can do better than that + Host(#[from] HostError), + #[error("error parsing int: {0}")] + ParseIntError(#[from] ParseIntError), + #[error(transparent)] + Client(#[from] SorobanRpcError), + #[error("internal conversion error: {0}")] + TryFromSliceError(#[from] TryFromSliceError), + #[error("xdr processing error: {0}")] + Xdr(#[from] XdrError), + #[error(transparent)] + Config(#[from] config::Error), + #[error(transparent)] + ParseAssetError(#[from] crate::utils::parsing::Error), +} + +impl From for Error { + fn from(_: Infallible) -> Self { + unreachable!() + } +} + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// ID of the Stellar classic asset to wrap, e.g. "USDC:G...5" + #[arg(long)] + pub asset: String, + + #[command(flatten)] + pub config: config::Args, + #[command(flatten)] + pub fee: crate::fee::Args, +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + // Parse asset + let asset = parse_asset(&self.asset)?; + + let res_str = self.run_against_rpc_server(asset).await?; + println!("{res_str}"); + Ok(()) + } + + async fn run_against_rpc_server(&self, asset: Asset) -> Result { + let network = self.config.get_network()?; + let client = Client::new(&network.rpc_url)?; + client + .verify_network_passphrase(Some(&network.network_passphrase)) + .await?; + let key = self.config.key_pair()?; + + // Get the account sequence number + let public_strkey = + stellar_strkey::ed25519::PublicKey(key.verifying_key().to_bytes()).to_string(); + // TODO: use symbols for the method names (both here and in serve) + let account_details = client.get_account(&public_strkey).await?; + let sequence: i64 = account_details.seq_num.into(); + let network_passphrase = &network.network_passphrase; + let contract_id = contract_id_hash_from_asset(&asset, network_passphrase)?; + let tx = build_wrap_token_tx( + &asset, + &contract_id, + sequence + 1, + self.fee.fee, + network_passphrase, + &key, + )?; + + client + .prepare_and_send_transaction(&tx, &key, &[], network_passphrase, None, None) + .await?; + + Ok(stellar_strkey::Contract(contract_id.0).to_string()) + } +} + +fn build_wrap_token_tx( + asset: &Asset, + contract_id: &Hash, + sequence: i64, + fee: u32, + _network_passphrase: &str, + key: &ed25519_dalek::SigningKey, +) -> Result { + let contract = ScAddress::Contract(contract_id.clone()); + let mut read_write = vec![ + ContractData(LedgerKeyContractData { + contract: contract.clone(), + key: ScVal::LedgerKeyContractInstance, + durability: ContractDataDurability::Persistent, + }), + ContractData(LedgerKeyContractData { + contract: contract.clone(), + key: ScVal::Vec(Some( + vec![ScVal::Symbol("Metadata".try_into().unwrap())].try_into()?, + )), + durability: ContractDataDurability::Persistent, + }), + ]; + if asset != &Asset::Native { + read_write.push(ContractData(LedgerKeyContractData { + contract, + key: ScVal::Vec(Some( + vec![ScVal::Symbol("Admin".try_into().unwrap())].try_into()?, + )), + durability: ContractDataDurability::Persistent, + })); + } + + let op = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function: HostFunction::CreateContract(CreateContractArgs { + contract_id_preimage: ContractIdPreimage::Asset(asset.clone()), + executable: ContractExecutable::StellarAsset, + }), + auth: VecM::default(), + }), + }; + + Ok(Transaction { + source_account: MuxedAccount::Ed25519(Uint256(key.verifying_key().to_bytes())), + fee, + seq_num: SequenceNumber(sequence), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![op].try_into()?, + ext: TransactionExt::V0, + }) +} diff --git a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs new file mode 100644 index 00000000..76c13017 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs @@ -0,0 +1,228 @@ +use std::array::TryFromSliceError; +use std::fmt::Debug; +use std::num::ParseIntError; + +use clap::{arg, command, Parser}; +use rand::Rng; +use soroban_env_host::{ + xdr::{ + AccountId, ContractExecutable, ContractIdPreimage, ContractIdPreimageFromAddress, + CreateContractArgs, Error as XdrError, Hash, HostFunction, InvokeHostFunctionOp, Memo, + MuxedAccount, Operation, OperationBody, Preconditions, PublicKey, ScAddress, + SequenceNumber, Transaction, TransactionExt, Uint256, VecM, + }, + HostError, +}; + +use crate::commands::contract::{self, id::wasm::get_contract_id}; +use crate::{ + commands::{config, contract::install, HEADING_RPC}, + rpc::{self, Client}, + utils, wasm, +}; + +#[derive(Parser, Debug, Clone)] +#[command(group( + clap::ArgGroup::new("wasm_src") + .required(true) + .args(&["wasm", "wasm_hash"]), +))] +#[group(skip)] +pub struct Cmd { + /// WASM file to deploy + #[arg(long, group = "wasm_src")] + wasm: Option, + /// Hash of the already installed/deployed WASM file + #[arg(long = "wasm-hash", conflicts_with = "wasm", group = "wasm_src")] + wasm_hash: Option, + /// Custom salt 32-byte salt for the token id + #[arg( + long, + help_heading = HEADING_RPC, + )] + salt: Option, + #[command(flatten)] + config: config::Args, + #[command(flatten)] + pub fee: crate::fee::Args, + #[arg(long, short = 'i', default_value = "false")] + /// Whether to ignore safety checks when deploying contracts + pub ignore_checks: bool, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Install(#[from] install::Error), + #[error(transparent)] + Host(#[from] HostError), + #[error("error parsing int: {0}")] + ParseIntError(#[from] ParseIntError), + #[error("internal conversion error: {0}")] + TryFromSliceError(#[from] TryFromSliceError), + #[error("xdr processing error: {0}")] + Xdr(#[from] XdrError), + #[error("jsonrpc error: {0}")] + JsonRpc(#[from] jsonrpsee_core::Error), + #[error("cannot parse salt: {salt}")] + CannotParseSalt { salt: String }, + #[error("cannot parse contract ID {contract_id}: {error}")] + CannotParseContractId { + contract_id: String, + error: stellar_strkey::DecodeError, + }, + #[error("cannot parse WASM hash {wasm_hash}: {error}")] + CannotParseWasmHash { + wasm_hash: String, + error: stellar_strkey::DecodeError, + }, + #[error("Must provide either --wasm or --wash-hash")] + WasmNotProvided, + #[error(transparent)] + Rpc(#[from] rpc::Error), + #[error(transparent)] + Config(#[from] config::Error), + #[error(transparent)] + StrKey(#[from] stellar_strkey::DecodeError), + #[error(transparent)] + Infallible(#[from] std::convert::Infallible), + #[error(transparent)] + WasmId(#[from] contract::id::wasm::Error), +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + let res_str = self.run_and_get_contract_id().await?; + println!("{res_str}"); + Ok(()) + } + + pub async fn run_and_get_contract_id(&self) -> Result { + let wasm_hash = if let Some(wasm) = &self.wasm { + let hash = install::Cmd { + wasm: wasm::Args { wasm: wasm.clone() }, + config: self.config.clone(), + fee: self.fee.clone(), + ignore_checks: self.ignore_checks, + } + .run_and_get_hash() + .await?; + hex::encode(hash) + } else { + self.wasm_hash + .as_ref() + .ok_or(Error::WasmNotProvided)? + .to_string() + }; + + let hash = Hash(utils::contract_id_from_str(&wasm_hash).map_err(|e| { + Error::CannotParseWasmHash { + wasm_hash: wasm_hash.clone(), + error: e, + } + })?); + + self.run_against_rpc_server(hash).await + } + + async fn run_against_rpc_server(&self, wasm_hash: Hash) -> Result { + let network = self.config.get_network()?; + let salt: [u8; 32] = match &self.salt { + Some(h) => soroban_spec_tools::utils::padded_hex_from_str(h, 32) + .map_err(|_| Error::CannotParseSalt { salt: h.clone() })? + .try_into() + .map_err(|_| Error::CannotParseSalt { salt: h.clone() })?, + None => rand::thread_rng().gen::<[u8; 32]>(), + }; + + let client = Client::new(&network.rpc_url)?; + client + .verify_network_passphrase(Some(&network.network_passphrase)) + .await?; + let key = self.config.key_pair()?; + + // Get the account sequence number + let public_strkey = + stellar_strkey::ed25519::PublicKey(key.verifying_key().to_bytes()).to_string(); + + let account_details = client.get_account(&public_strkey).await?; + let sequence: i64 = account_details.seq_num.into(); + let (tx, contract_id) = build_create_contract_tx( + wasm_hash, + sequence + 1, + self.fee.fee, + &network.network_passphrase, + salt, + &key, + )?; + client + .prepare_and_send_transaction(&tx, &key, &[], &network.network_passphrase, None, None) + .await?; + Ok(stellar_strkey::Contract(contract_id.0).to_string()) + } +} + +fn build_create_contract_tx( + hash: Hash, + sequence: i64, + fee: u32, + network_passphrase: &str, + salt: [u8; 32], + key: &ed25519_dalek::SigningKey, +) -> Result<(Transaction, Hash), Error> { + let source_account = AccountId(PublicKey::PublicKeyTypeEd25519( + key.verifying_key().to_bytes().into(), + )); + + let contract_id_preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress { + address: ScAddress::Account(source_account), + salt: Uint256(salt), + }); + let contract_id = get_contract_id(contract_id_preimage.clone(), network_passphrase)?; + + let op = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function: HostFunction::CreateContract(CreateContractArgs { + contract_id_preimage, + executable: ContractExecutable::Wasm(hash), + }), + auth: VecM::default(), + }), + }; + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(key.verifying_key().to_bytes())), + fee, + seq_num: SequenceNumber(sequence), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![op].try_into()?, + ext: TransactionExt::V0, + }; + + Ok((tx, Hash(contract_id.into()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_create_contract() { + let hash = hex::decode("0000000000000000000000000000000000000000000000000000000000000000") + .unwrap() + .try_into() + .unwrap(); + let result = build_create_contract_tx( + Hash(hash), + 300, + 1, + "Public Global Stellar Network ; September 2015", + [0u8; 32], + &utils::parse_secret_key("SBFGFF27Y64ZUGFAIG5AMJGQODZZKV2YQKAVUUN4HNE24XZXD2OEUVUP") + .unwrap(), + ); + + assert!(result.is_ok()); + } +} diff --git a/cmd/soroban-cli/src/commands/contract/extend.rs b/cmd/soroban-cli/src/commands/contract/extend.rs new file mode 100644 index 00000000..7e9f1e98 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/extend.rs @@ -0,0 +1,192 @@ +use std::{fmt::Debug, path::Path, str::FromStr}; + +use clap::{command, Parser}; +use soroban_env_host::xdr::{ + Error as XdrError, ExtendFootprintTtlOp, ExtensionPoint, LedgerEntry, LedgerEntryChange, + LedgerEntryData, LedgerFootprint, Memo, MuxedAccount, Operation, OperationBody, Preconditions, + SequenceNumber, SorobanResources, SorobanTransactionData, Transaction, TransactionExt, + TransactionMeta, TransactionMetaV3, TtlEntry, Uint256, +}; + +use crate::{ + commands::config, + key, + rpc::{self, Client}, + wasm, Pwd, +}; + +const MAX_LEDGERS_TO_EXTEND: u32 = 535_679; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// Number of ledgers to extend the entries + #[arg(long, required = true)] + pub ledgers_to_extend: u32, + /// Only print the new Time To Live ledger + #[arg(long)] + pub ttl_ledger_only: bool, + #[command(flatten)] + pub key: key::Args, + #[command(flatten)] + pub config: config::Args, + #[command(flatten)] + pub fee: crate::fee::Args, +} + +impl FromStr for Cmd { + type Err = clap::error::Error; + + fn from_str(s: &str) -> Result { + use clap::{CommandFactory, FromArgMatches}; + Self::from_arg_matches_mut(&mut Self::command().get_matches_from(s.split_whitespace())) + } +} + +impl Pwd for Cmd { + fn set_pwd(&mut self, pwd: &Path) { + self.config.set_pwd(pwd); + } +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("parsing key {key}: {error}")] + CannotParseKey { + key: String, + error: soroban_spec_tools::Error, + }, + #[error("parsing XDR key {key}: {error}")] + CannotParseXdrKey { key: String, error: XdrError }, + + #[error(transparent)] + Config(#[from] config::Error), + #[error("either `--key` or `--key-xdr` are required")] + KeyIsRequired, + #[error("xdr processing error: {0}")] + Xdr(#[from] XdrError), + #[error("Ledger entry not found")] + LedgerEntryNotFound, + #[error("missing operation result")] + MissingOperationResult, + #[error(transparent)] + Rpc(#[from] rpc::Error), + #[error(transparent)] + Wasm(#[from] wasm::Error), + #[error(transparent)] + Key(#[from] key::Error), +} + +impl Cmd { + #[allow(clippy::too_many_lines)] + pub async fn run(&self) -> Result<(), Error> { + let ttl_ledger = self.run_against_rpc_server().await?; + if self.ttl_ledger_only { + println!("{ttl_ledger}"); + } else { + println!("New ttl ledger: {ttl_ledger}"); + } + + Ok(()) + } + + fn ledgers_to_extend(&self) -> u32 { + let res = u32::min(self.ledgers_to_extend, MAX_LEDGERS_TO_EXTEND); + if res < self.ledgers_to_extend { + tracing::warn!( + "Ledgers to extend is too large, using max value of {MAX_LEDGERS_TO_EXTEND}" + ); + } + res + } + + async fn run_against_rpc_server(&self) -> Result { + let network = self.config.get_network()?; + tracing::trace!(?network); + let keys = self.key.parse_keys()?; + let network = &self.config.get_network()?; + let client = Client::new(&network.rpc_url)?; + let key = self.config.key_pair()?; + let extend_to = self.ledgers_to_extend(); + + // Get the account sequence number + let public_strkey = + stellar_strkey::ed25519::PublicKey(key.verifying_key().to_bytes()).to_string(); + let account_details = client.get_account(&public_strkey).await?; + let sequence: i64 = account_details.seq_num.into(); + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(key.verifying_key().to_bytes())), + fee: self.fee.fee, + seq_num: SequenceNumber(sequence + 1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::ExtendFootprintTtl(ExtendFootprintTtlOp { + ext: ExtensionPoint::V0, + extend_to, + }), + }] + .try_into()?, + ext: TransactionExt::V1(SorobanTransactionData { + ext: ExtensionPoint::V0, + resources: SorobanResources { + footprint: LedgerFootprint { + read_only: keys.clone().try_into()?, + read_write: vec![].try_into()?, + }, + instructions: 0, + read_bytes: 0, + write_bytes: 0, + }, + resource_fee: 0, + }), + }; + + let (result, meta, events) = client + .prepare_and_send_transaction(&tx, &key, &[], &network.network_passphrase, None, None) + .await?; + + tracing::trace!(?result); + tracing::trace!(?meta); + if !events.is_empty() { + tracing::info!("Events:\n {events:#?}"); + } + + // The transaction from core will succeed regardless of whether it actually found & extended + // the entry, so we have to inspect the result meta to tell if it worked or not. + let TransactionMeta::V3(TransactionMetaV3 { operations, .. }) = meta else { + return Err(Error::LedgerEntryNotFound); + }; + + // Simply check if there is exactly one entry here. We only support extending a single + // entry via this command (which we should fix separately, but). + if operations.len() == 0 { + return Err(Error::LedgerEntryNotFound); + } + + if operations[0].changes.is_empty() { + let entry = client.get_full_ledger_entries(&keys).await?; + let extension = entry.entries[0].live_until_ledger_seq; + if entry.latest_ledger + i64::from(extend_to) < i64::from(extension) { + return Ok(extension); + } + } + + match (&operations[0].changes[0], &operations[0].changes[1]) { + ( + LedgerEntryChange::State(_), + LedgerEntryChange::Updated(LedgerEntry { + data: + LedgerEntryData::Ttl(TtlEntry { + live_until_ledger_seq, + .. + }), + .. + }), + ) => Ok(*live_until_ledger_seq), + _ => Err(Error::LedgerEntryNotFound), + } + } +} diff --git a/cmd/soroban-cli/src/commands/contract/fetch.rs b/cmd/soroban-cli/src/commands/contract/fetch.rs new file mode 100644 index 00000000..61a82fc4 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/fetch.rs @@ -0,0 +1,185 @@ +use std::convert::Infallible; + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::{fmt::Debug, fs, io}; + +use clap::{arg, command, Parser}; +use soroban_env_host::{ + budget::Budget, + storage::Storage, + xdr::{ + self, ContractCodeEntry, ContractDataDurability, ContractDataEntry, ContractExecutable, + Error as XdrError, LedgerEntryData, LedgerKey, LedgerKeyContractCode, + LedgerKeyContractData, ScAddress, ScContractInstance, ScVal, + }, +}; + +use soroban_spec::read::FromWasmError; +use stellar_strkey::DecodeError; + +use super::super::config::{self, locator}; +use crate::commands::network::{self, Network}; +use crate::{ + rpc::{self, Client}, + utils, Pwd, +}; + +#[derive(Parser, Debug, Default, Clone)] +#[allow(clippy::struct_excessive_bools)] +#[group(skip)] +pub struct Cmd { + /// Contract ID to fetch + #[arg(long = "id", env = "SOROBAN_CONTRACT_ID")] + pub contract_id: String, + /// Where to write output otherwise stdout is used + #[arg(long, short = 'o')] + pub out_file: Option, + #[command(flatten)] + pub locator: locator::Args, + #[command(flatten)] + pub network: network::Args, +} + +impl FromStr for Cmd { + type Err = clap::error::Error; + + fn from_str(s: &str) -> Result { + use clap::{CommandFactory, FromArgMatches}; + Self::from_arg_matches_mut(&mut Self::command().get_matches_from(s.split_whitespace())) + } +} + +impl Pwd for Cmd { + fn set_pwd(&mut self, pwd: &Path) { + self.locator.set_pwd(pwd); + } +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Rpc(#[from] rpc::Error), + #[error(transparent)] + Config(#[from] config::Error), + #[error(transparent)] + Locator(#[from] locator::Error), + #[error(transparent)] + Xdr(#[from] XdrError), + #[error(transparent)] + Spec(#[from] soroban_spec::read::FromWasmError), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("missing result")] + MissingResult, + #[error("unexpected contract code data type: {0:?}")] + UnexpectedContractCodeDataType(LedgerEntryData), + #[error("reading file {0:?}: {1}")] + CannotWriteContractFile(PathBuf, io::Error), + #[error("cannot parse contract ID {0}: {1}")] + CannotParseContractId(String, DecodeError), + #[error("network details not provided")] + NetworkNotProvided, + #[error(transparent)] + Network(#[from] network::Error), + #[error("cannot create contract directory for {0:?}")] + CannotCreateContractDir(PathBuf), +} + +impl From for Error { + fn from(_: Infallible) -> Self { + unreachable!() + } +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + let bytes = self.get_bytes().await?; + if let Some(out_file) = &self.out_file { + if let Some(parent) = out_file.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|_| Error::CannotCreateContractDir(out_file.clone()))?; + } + } + fs::write(out_file, bytes) + .map_err(|io| Error::CannotWriteContractFile(out_file.clone(), io)) + } else { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + handle.write_all(&bytes)?; + handle.flush()?; + Ok(()) + } + } + + pub async fn get_bytes(&self) -> Result, Error> { + self.run_against_rpc_server().await + } + + pub fn network(&self) -> Result { + Ok(self.network.get(&self.locator)?) + } + + pub async fn run_against_rpc_server(&self) -> Result, Error> { + let network = self.network()?; + tracing::trace!(?network); + let contract_id = self.contract_id()?; + let client = Client::new(&network.rpc_url)?; + client + .verify_network_passphrase(Some(&network.network_passphrase)) + .await?; + // async closures are not yet stable + Ok(client.get_remote_wasm(&contract_id).await?) + } + + fn contract_id(&self) -> Result<[u8; 32], Error> { + utils::contract_id_from_str(&self.contract_id) + .map_err(|e| Error::CannotParseContractId(self.contract_id.clone(), e)) + } +} + +pub fn get_contract_wasm_from_storage( + storage: &mut Storage, + contract_id: [u8; 32], +) -> Result, FromWasmError> { + let key = LedgerKey::ContractData(LedgerKeyContractData { + contract: ScAddress::Contract(contract_id.into()), + key: ScVal::LedgerKeyContractInstance, + durability: ContractDataDurability::Persistent, + }); + match storage.get(&key.into(), &Budget::default()) { + Ok(rc) => match rc.as_ref() { + xdr::LedgerEntry { + data: + LedgerEntryData::ContractData(ContractDataEntry { + val: ScVal::ContractInstance(ScContractInstance { executable, .. }), + .. + }), + .. + } => match executable { + ContractExecutable::Wasm(hash) => { + if let Ok(rc) = storage.get( + &LedgerKey::ContractCode(LedgerKeyContractCode { hash: hash.clone() }) + .into(), + &Budget::default(), + ) { + match rc.as_ref() { + xdr::LedgerEntry { + data: LedgerEntryData::ContractCode(ContractCodeEntry { code, .. }), + .. + } => Ok(code.to_vec()), + _ => Err(FromWasmError::NotFound), + } + } else { + Err(FromWasmError::NotFound) + } + } + ContractExecutable::StellarAsset => todo!(), + }, + _ => Err(FromWasmError::NotFound), + }, + _ => Err(FromWasmError::NotFound), + } +} diff --git a/cmd/soroban-cli/src/commands/contract/id.rs b/cmd/soroban-cli/src/commands/contract/id.rs new file mode 100644 index 00000000..bb8744d5 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/id.rs @@ -0,0 +1,28 @@ +pub mod asset; +pub mod wasm; + +#[derive(Debug, clap::Subcommand)] +pub enum Cmd { + /// Deploy builtin Soroban Asset Contract + Asset(asset::Cmd), + /// Deploy normal Wasm Contract + Wasm(wasm::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Asset(#[from] asset::Error), + #[error(transparent)] + Wasm(#[from] wasm::Error), +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + match &self { + Cmd::Asset(asset) => asset.run()?, + Cmd::Wasm(wasm) => wasm.run()?, + } + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/id/asset.rs b/cmd/soroban-cli/src/commands/contract/id/asset.rs new file mode 100644 index 00000000..34e5767a --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/id/asset.rs @@ -0,0 +1,36 @@ +use clap::{arg, command, Parser}; + +use crate::commands::config; + +use crate::utils::contract_id_hash_from_asset; +use crate::utils::parsing::parse_asset; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// ID of the Stellar classic asset to wrap, e.g. "USDC:G...5" + #[arg(long)] + pub asset: String, + + #[command(flatten)] + pub config: config::Args, +} +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + ParseError(#[from] crate::utils::parsing::Error), + #[error(transparent)] + ConfigError(#[from] crate::commands::config::Error), + #[error(transparent)] + Xdr(#[from] soroban_env_host::xdr::Error), +} +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + let asset = parse_asset(&self.asset)?; + let network = self.config.get_network()?; + let contract_id = contract_id_hash_from_asset(&asset, &network.network_passphrase)?; + let strkey_contract_id = stellar_strkey::Contract(contract_id.0).to_string(); + println!("{strkey_contract_id}"); + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/id/wasm.rs b/cmd/soroban-cli/src/commands/contract/id/wasm.rs new file mode 100644 index 00000000..9c02f07d --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/id/wasm.rs @@ -0,0 +1,68 @@ +use clap::{arg, command, Parser}; +use sha2::{Digest, Sha256}; +use soroban_env_host::xdr::{ + self, AccountId, ContractIdPreimage, ContractIdPreimageFromAddress, Hash, HashIdPreimage, + HashIdPreimageContractId, Limits, PublicKey, ScAddress, Uint256, WriteXdr, +}; + +use crate::commands::config; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// ID of the Soroban contract + #[arg(long)] + pub salt: String, + + #[command(flatten)] + pub config: config::Args, +} +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + ParseError(#[from] crate::utils::parsing::Error), + #[error(transparent)] + ConfigError(#[from] crate::commands::config::Error), + #[error(transparent)] + Xdr(#[from] xdr::Error), + #[error("cannot parse salt {0}")] + CannotParseSalt(String), +} +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + let salt: [u8; 32] = soroban_spec_tools::utils::padded_hex_from_str(&self.salt, 32) + .map_err(|_| Error::CannotParseSalt(self.salt.clone()))? + .try_into() + .map_err(|_| Error::CannotParseSalt(self.salt.clone()))?; + let contract_id_preimage = + contract_preimage(&self.config.key_pair()?.verifying_key(), salt); + let contract_id = get_contract_id( + contract_id_preimage.clone(), + &self.config.get_network()?.network_passphrase, + )?; + let strkey_contract_id = stellar_strkey::Contract(contract_id.0).to_string(); + println!("{strkey_contract_id}"); + Ok(()) + } +} + +pub fn contract_preimage(key: &ed25519_dalek::VerifyingKey, salt: [u8; 32]) -> ContractIdPreimage { + let source_account = AccountId(PublicKey::PublicKeyTypeEd25519(key.to_bytes().into())); + ContractIdPreimage::Address(ContractIdPreimageFromAddress { + address: ScAddress::Account(source_account), + salt: Uint256(salt), + }) +} + +pub fn get_contract_id( + contract_id_preimage: ContractIdPreimage, + network_passphrase: &str, +) -> Result { + let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into()); + let preimage = HashIdPreimage::ContractId(HashIdPreimageContractId { + network_id, + contract_id_preimage, + }); + let preimage_xdr = preimage.to_xdr(Limits::none())?; + Ok(Hash(Sha256::digest(preimage_xdr).into())) +} diff --git a/cmd/soroban-cli/src/commands/contract/inspect.rs b/cmd/soroban-cli/src/commands/contract/inspect.rs new file mode 100644 index 00000000..355c18ca --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/inspect.rs @@ -0,0 +1,49 @@ +use clap::{command, Parser}; +use soroban_env_host::xdr; +use std::{fmt::Debug, path::PathBuf}; +use tracing::debug; + +use super::SpecOutput; +use crate::{commands::config::locator, wasm}; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + wasm: wasm::Args, + /// Output just XDR in base64 + #[arg(long, default_value = "docs")] + output: SpecOutput, + + #[clap(flatten)] + locator: locator::Args, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Wasm(#[from] wasm::Error), + #[error("missing spec for {0:?}")] + MissingSpec(PathBuf), + #[error(transparent)] + Xdr(#[from] xdr::Error), + #[error(transparent)] + Spec(#[from] crate::utils::contract_spec::Error), +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + let wasm = self.wasm.parse()?; + debug!("File: {}", self.wasm.wasm.to_string_lossy()); + let output = match self.output { + SpecOutput::XdrBase64 => wasm + .spec_base64 + .clone() + .ok_or_else(|| Error::MissingSpec(self.wasm.wasm.clone()))?, + SpecOutput::XdrBase64Array => wasm.spec_as_json_array()?, + SpecOutput::Docs => wasm.to_string(), + }; + println!("{output}"); + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/install.rs b/cmd/soroban-cli/src/commands/contract/install.rs new file mode 100644 index 00000000..90577529 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/install.rs @@ -0,0 +1,223 @@ +use std::array::TryFromSliceError; +use std::fmt::Debug; +use std::num::ParseIntError; + +use clap::{command, Parser}; +use soroban_env_host::xdr::{ + Error as XdrError, Hash, HostFunction, InvokeHostFunctionOp, Memo, MuxedAccount, Operation, + OperationBody, Preconditions, ScMetaEntry, ScMetaV0, SequenceNumber, Transaction, + TransactionExt, TransactionResult, TransactionResultResult, Uint256, VecM, +}; + +use super::restore; +use crate::key; +use crate::rpc::{self, Client}; +use crate::{commands::config, utils, wasm}; + +const CONTRACT_META_SDK_KEY: &str = "rssdkver"; +const PUBLIC_NETWORK_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015"; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + pub config: config::Args, + #[command(flatten)] + pub fee: crate::fee::Args, + #[command(flatten)] + pub wasm: wasm::Args, + #[arg(long, short = 'i', default_value = "false")] + /// Whether to ignore safety checks when deploying contracts + pub ignore_checks: bool, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("error parsing int: {0}")] + ParseIntError(#[from] ParseIntError), + #[error("internal conversion error: {0}")] + TryFromSliceError(#[from] TryFromSliceError), + #[error("xdr processing error: {0}")] + Xdr(#[from] XdrError), + #[error("jsonrpc error: {0}")] + JsonRpc(#[from] jsonrpsee_core::Error), + #[error(transparent)] + Rpc(#[from] rpc::Error), + #[error(transparent)] + Config(#[from] config::Error), + #[error(transparent)] + Wasm(#[from] wasm::Error), + #[error("unexpected ({length}) simulate transaction result length")] + UnexpectedSimulateTransactionResultSize { length: usize }, + #[error(transparent)] + Restore(#[from] restore::Error), + #[error("cannot parse WASM file {wasm}: {error}")] + CannotParseWasm { + wasm: std::path::PathBuf, + error: wasm::Error, + }, + #[error("the deployed smart contract {wasm} was built with Soroban Rust SDK v{version}, a release candidate version not intended for use with the Stellar Public Network. To deploy anyway, use --ignore-checks")] + ContractCompiledWithReleaseCandidateSdk { + wasm: std::path::PathBuf, + version: String, + }, +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + let res_str = hex::encode(self.run_and_get_hash().await?); + println!("{res_str}"); + Ok(()) + } + + pub async fn run_and_get_hash(&self) -> Result { + self.run_against_rpc_server(&self.wasm.read()?).await + } + + async fn run_against_rpc_server(&self, contract: &[u8]) -> Result { + let network = self.config.get_network()?; + let client = Client::new(&network.rpc_url)?; + client + .verify_network_passphrase(Some(&network.network_passphrase)) + .await?; + let wasm_spec = &self.wasm.parse().map_err(|e| Error::CannotParseWasm { + wasm: self.wasm.wasm.clone(), + error: e, + })?; + // Check Rust SDK version if using the public network. + if let Some(rs_sdk_ver) = get_contract_meta_sdk_version(wasm_spec) { + if rs_sdk_ver.contains("rc") + && !self.ignore_checks + && network.network_passphrase == PUBLIC_NETWORK_PASSPHRASE + { + return Err(Error::ContractCompiledWithReleaseCandidateSdk { + wasm: self.wasm.wasm.clone(), + version: rs_sdk_ver, + }); + } else if rs_sdk_ver.contains("rc") + && network.network_passphrase == PUBLIC_NETWORK_PASSPHRASE + { + tracing::warn!("the deployed smart contract {path} was built with Soroban Rust SDK v{rs_sdk_ver}, a release candidate version not intended for use with the Stellar Public Network", path = self.wasm.wasm.display()); + } + } + let key = self.config.key_pair()?; + + // Get the account sequence number + let public_strkey = + stellar_strkey::ed25519::PublicKey(key.verifying_key().to_bytes()).to_string(); + let account_details = client.get_account(&public_strkey).await?; + let sequence: i64 = account_details.seq_num.into(); + + let (tx_without_preflight, hash) = + build_install_contract_code_tx(contract, sequence + 1, self.fee.fee, &key)?; + + // Currently internal errors are not returned if the contract code is expired + if let ( + TransactionResult { + result: TransactionResultResult::TxInternalError, + .. + }, + _, + _, + ) = client + .prepare_and_send_transaction( + &tx_without_preflight, + &key, + &[], + &network.network_passphrase, + None, + None, + ) + .await? + { + // Now just need to restore it and don't have to install again + restore::Cmd { + key: key::Args { + contract_id: None, + key: None, + key_xdr: None, + wasm: Some(self.wasm.wasm.clone()), + wasm_hash: None, + durability: super::Durability::Persistent, + }, + config: self.config.clone(), + fee: self.fee.clone(), + ledgers_to_extend: None, + ttl_ledger_only: true, + } + .run_against_rpc_server() + .await?; + } + + Ok(hash) + } +} + +fn get_contract_meta_sdk_version(wasm_spec: &utils::contract_spec::ContractSpec) -> Option { + let rs_sdk_version_option = if let Some(_meta) = &wasm_spec.meta_base64 { + wasm_spec.meta.iter().find(|entry| match entry { + ScMetaEntry::ScMetaV0(ScMetaV0 { key, .. }) => { + key.to_utf8_string_lossy().contains(CONTRACT_META_SDK_KEY) + } + }) + } else { + None + }; + if let Some(rs_sdk_version_entry) = &rs_sdk_version_option { + match rs_sdk_version_entry { + ScMetaEntry::ScMetaV0(ScMetaV0 { val, .. }) => { + return Some(val.to_utf8_string_lossy()); + } + } + } + None +} + +pub(crate) fn build_install_contract_code_tx( + source_code: &[u8], + sequence: i64, + fee: u32, + key: &ed25519_dalek::SigningKey, +) -> Result<(Transaction, Hash), XdrError> { + let hash = utils::contract_hash(source_code)?; + + let op = Operation { + source_account: Some(MuxedAccount::Ed25519(Uint256( + key.verifying_key().to_bytes(), + ))), + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function: HostFunction::UploadContractWasm(source_code.try_into()?), + auth: VecM::default(), + }), + }; + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(key.verifying_key().to_bytes())), + fee, + seq_num: SequenceNumber(sequence), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![op].try_into()?, + ext: TransactionExt::V0, + }; + + Ok((tx, hash)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_install_contract_code() { + let result = build_install_contract_code_tx( + b"foo", + 300, + 1, + &utils::parse_secret_key("SBFGFF27Y64ZUGFAIG5AMJGQODZZKV2YQKAVUUN4HNE24XZXD2OEUVUP") + .unwrap(), + ); + + assert!(result.is_ok()); + } +} diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs new file mode 100644 index 00000000..669342b0 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -0,0 +1,484 @@ +use std::collections::HashMap; +use std::convert::{Infallible, TryInto}; +use std::ffi::OsString; +use std::num::ParseIntError; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::{fmt::Debug, fs, io}; + +use clap::{arg, command, value_parser, Parser}; +use ed25519_dalek::SigningKey; +use heck::ToKebabCase; + +use soroban_env_host::{ + xdr::{ + self, Error as XdrError, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, + LedgerEntryData, LedgerFootprint, Memo, MuxedAccount, Operation, OperationBody, + Preconditions, ScAddress, ScSpecEntry, ScSpecFunctionV0, ScSpecTypeDef, ScVal, ScVec, + SequenceNumber, SorobanAuthorizationEntry, SorobanResources, Transaction, TransactionExt, + Uint256, VecM, + }, + HostError, +}; + +use soroban_spec::read::FromWasmError; +use stellar_strkey::DecodeError; + +use super::super::{ + config::{self, locator}, + events, +}; +use crate::{ + commands::global, + rpc::{self, Client}, + utils::{self, contract_spec}, + Pwd, +}; +use soroban_spec_tools::Spec; + +#[derive(Parser, Debug, Default, Clone)] +#[allow(clippy::struct_excessive_bools)] +#[group(skip)] +pub struct Cmd { + /// Contract ID to invoke + #[arg(long = "id", env = "SOROBAN_CONTRACT_ID")] + pub contract_id: String, + // For testing only + #[arg(skip)] + pub wasm: Option, + /// Output the cost execution to stderr + #[arg(long = "cost")] + pub cost: bool, + /// Function name as subcommand, then arguments for that function as `--arg-name value` + #[arg(last = true, id = "CONTRACT_FN_AND_ARGS")] + pub slop: Vec, + #[command(flatten)] + pub config: config::Args, + #[command(flatten)] + pub fee: crate::fee::Args, +} + +impl FromStr for Cmd { + type Err = clap::error::Error; + + fn from_str(s: &str) -> Result { + use clap::{CommandFactory, FromArgMatches}; + Self::from_arg_matches_mut(&mut Self::command().get_matches_from(s.split_whitespace())) + } +} + +impl Pwd for Cmd { + fn set_pwd(&mut self, pwd: &Path) { + self.config.set_pwd(pwd); + } +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("parsing argument {arg}: {error}")] + CannotParseArg { + arg: String, + error: soroban_spec_tools::Error, + }, + #[error("cannot add contract to ledger entries: {0}")] + CannotAddContractToLedgerEntries(XdrError), + #[error(transparent)] + // TODO: the Display impl of host errors is pretty user-unfriendly + // (it just calls Debug). I think we can do better than that + Host(#[from] HostError), + #[error("reading file {0:?}: {1}")] + CannotReadContractFile(PathBuf, io::Error), + #[error("committing file {filepath}: {error}")] + CannotCommitEventsFile { + filepath: std::path::PathBuf, + error: events::Error, + }, + #[error("cannot parse contract ID {0}: {1}")] + CannotParseContractId(String, DecodeError), + #[error("function {0} was not found in the contract")] + FunctionNotFoundInContractSpec(String), + #[error("parsing contract spec: {0}")] + CannotParseContractSpec(FromWasmError), + // }, + #[error("function name {0} is too long")] + FunctionNameTooLong(String), + #[error("argument count ({current}) surpasses maximum allowed count ({maximum})")] + MaxNumberOfArgumentsReached { current: usize, maximum: usize }, + #[error("cannot print result {result:?}: {error}")] + CannotPrintResult { + result: ScVal, + error: soroban_spec_tools::Error, + }, + #[error(transparent)] + Xdr(#[from] XdrError), + #[error("error parsing int: {0}")] + ParseIntError(#[from] ParseIntError), + #[error(transparent)] + Rpc(#[from] rpc::Error), + #[error("unexpected contract code data type: {0:?}")] + UnexpectedContractCodeDataType(LedgerEntryData), + #[error("missing operation result")] + MissingOperationResult, + #[error("missing result")] + MissingResult, + #[error(transparent)] + StrVal(#[from] soroban_spec_tools::Error), + #[error("error loading signing key: {0}")] + SignatureError(#[from] ed25519_dalek::SignatureError), + #[error(transparent)] + Config(#[from] config::Error), + #[error("unexpected ({length}) simulate transaction result length")] + UnexpectedSimulateTransactionResultSize { length: usize }, + #[error("Missing argument {0}")] + MissingArgument(String), + #[error(transparent)] + Clap(#[from] clap::Error), + #[error(transparent)] + Locator(#[from] locator::Error), + #[error("Contract Error\n{0}: {1}")] + ContractInvoke(String, String), + #[error(transparent)] + StrKey(#[from] stellar_strkey::DecodeError), + #[error(transparent)] + ContractSpec(#[from] contract_spec::Error), + #[error("")] + MissingFileArg(PathBuf), +} + +impl From for Error { + fn from(_: Infallible) -> Self { + unreachable!() + } +} + +impl Cmd { + fn build_host_function_parameters( + &self, + contract_id: [u8; 32], + spec_entries: &[ScSpecEntry], + ) -> Result<(String, Spec, InvokeContractArgs, Vec), Error> { + let spec = Spec(Some(spec_entries.to_vec())); + let mut cmd = clap::Command::new(self.contract_id.clone()) + .no_binary_name(true) + .term_width(300) + .max_term_width(300); + + for ScSpecFunctionV0 { name, .. } in spec.find_functions()? { + cmd = cmd.subcommand(build_custom_cmd(&name.to_utf8_string_lossy(), &spec)?); + } + cmd.build(); + let long_help = cmd.render_long_help(); + let mut matches_ = cmd.get_matches_from(&self.slop); + let Some((function, matches_)) = &matches_.remove_subcommand() else { + println!("{long_help}"); + std::process::exit(1); + }; + + let func = spec.find_function(function)?; + // create parsed_args in same order as the inputs to func + let mut signers: Vec = vec![]; + let parsed_args = func + .inputs + .iter() + .map(|i| { + let name = i.name.to_utf8_string()?; + if let Some(mut val) = matches_.get_raw(&name) { + let mut s = val.next().unwrap().to_string_lossy().to_string(); + if matches!(i.type_, ScSpecTypeDef::Address) { + let cmd = crate::commands::keys::address::Cmd { + name: s.clone(), + hd_path: Some(0), + locator: self.config.locator.clone(), + }; + if let Ok(address) = cmd.public_key() { + s = address.to_string(); + } + if let Ok(key) = cmd.private_key() { + signers.push(key); + } + } + spec.from_string(&s, &i.type_) + .map_err(|error| Error::CannotParseArg { arg: name, error }) + } else if matches!(i.type_, ScSpecTypeDef::Option(_)) { + Ok(ScVal::Void) + } else if let Some(arg_path) = + matches_.get_one::(&fmt_arg_file_name(&name)) + { + if matches!(i.type_, ScSpecTypeDef::Bytes | ScSpecTypeDef::BytesN(_)) { + Ok(ScVal::try_from( + &std::fs::read(arg_path) + .map_err(|_| Error::MissingFileArg(arg_path.clone()))?, + ) + .map_err(|()| Error::CannotParseArg { + arg: name.clone(), + error: soroban_spec_tools::Error::Unknown, + })?) + } else { + let file_contents = std::fs::read_to_string(arg_path) + .map_err(|_| Error::MissingFileArg(arg_path.clone()))?; + tracing::debug!( + "file {arg_path:?}, has contents:\n{file_contents}\nAnd type {:#?}\n{}", + i.type_, + file_contents.len() + ); + spec.from_string(&file_contents, &i.type_) + .map_err(|error| Error::CannotParseArg { arg: name, error }) + } + } else { + Err(Error::MissingArgument(name)) + } + }) + .collect::, Error>>()?; + + let contract_address_arg = ScAddress::Contract(Hash(contract_id)); + let function_symbol_arg = function + .try_into() + .map_err(|()| Error::FunctionNameTooLong(function.clone()))?; + + let final_args = + parsed_args + .clone() + .try_into() + .map_err(|_| Error::MaxNumberOfArgumentsReached { + current: parsed_args.len(), + maximum: ScVec::default().max_len(), + })?; + + let invoke_args = InvokeContractArgs { + contract_address: contract_address_arg, + function_name: function_symbol_arg, + args: final_args, + }; + + Ok((function.clone(), spec, invoke_args, signers)) + } + + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let res = self.invoke(global_args).await?; + println!("{res}"); + Ok(()) + } + + pub async fn invoke(&self, global_args: &global::Args) -> Result { + self.run_against_rpc_server(global_args).await + } + + pub async fn run_against_rpc_server( + &self, + global_args: &global::Args, + ) -> Result { + let network = self.config.get_network()?; + tracing::trace!(?network); + let contract_id = self.contract_id()?; + let spec_entries = self.spec_entries()?; + if let Some(spec_entries) = &spec_entries { + // For testing wasm arg parsing + let _ = self.build_host_function_parameters(contract_id, spec_entries)?; + } + let client = Client::new(&network.rpc_url)?; + client + .verify_network_passphrase(Some(&network.network_passphrase)) + .await?; + let key = self.config.key_pair()?; + + // Get the account sequence number + let public_strkey = + stellar_strkey::ed25519::PublicKey(key.verifying_key().to_bytes()).to_string(); + let account_details = client.get_account(&public_strkey).await?; + let sequence: i64 = account_details.seq_num.into(); + + // Get the contract + let spec_entries = client.get_remote_contract_spec(&contract_id).await?; + + // Get the ledger footprint + let (function, spec, host_function_params, signers) = + self.build_host_function_parameters(contract_id, &spec_entries)?; + let tx = build_invoke_contract_tx( + host_function_params.clone(), + sequence + 1, + self.fee.fee, + &key, + )?; + + let (result, meta, events) = client + .prepare_and_send_transaction( + &tx, + &key, + &signers, + &network.network_passphrase, + Some(log_events), + (global_args.verbose || global_args.very_verbose || self.cost) + .then_some(log_resources), + ) + .await?; + + tracing::debug!(?result); + crate::log::diagnostic_events(&events, tracing::Level::INFO); + let xdr::TransactionMeta::V3(xdr::TransactionMetaV3 { + soroban_meta: Some(xdr::SorobanTransactionMeta { return_value, .. }), + .. + }) = meta + else { + return Err(Error::MissingOperationResult); + }; + + output_to_string(&spec, &return_value, &function) + } + + pub fn read_wasm(&self) -> Result>, Error> { + Ok(if let Some(wasm) = self.wasm.as_ref() { + Some(fs::read(wasm).map_err(|e| Error::CannotReadContractFile(wasm.clone(), e))?) + } else { + None + }) + } + + pub fn spec_entries(&self) -> Result>, Error> { + self.read_wasm()? + .map(|wasm| { + soroban_spec::read::from_wasm(&wasm).map_err(Error::CannotParseContractSpec) + }) + .transpose() + } +} + +impl Cmd { + fn contract_id(&self) -> Result<[u8; 32], Error> { + utils::contract_id_from_str(&self.contract_id) + .map_err(|e| Error::CannotParseContractId(self.contract_id.clone(), e)) + } +} + +fn log_events( + footprint: &LedgerFootprint, + auth: &[VecM], + events: &[xdr::DiagnosticEvent], +) { + crate::log::auth(auth); + crate::log::diagnostic_events(events, tracing::Level::TRACE); + crate::log::footprint(footprint); +} + +fn log_resources(resources: &SorobanResources) { + crate::log::cost(resources); +} + +pub fn output_to_string(spec: &Spec, res: &ScVal, function: &str) -> Result { + let mut res_str = String::new(); + if let Some(output) = spec.find_function(function)?.outputs.first() { + res_str = spec + .xdr_to_json(res, output) + .map_err(|e| Error::CannotPrintResult { + result: res.clone(), + error: e, + })? + .to_string(); + } + Ok(res_str) +} + +fn build_invoke_contract_tx( + parameters: InvokeContractArgs, + sequence: i64, + fee: u32, + key: &SigningKey, +) -> Result { + let op = Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(parameters), + auth: VecM::default(), + }), + }; + Ok(Transaction { + source_account: MuxedAccount::Ed25519(Uint256(key.verifying_key().to_bytes())), + fee, + seq_num: SequenceNumber(sequence), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![op].try_into()?, + ext: TransactionExt::V0, + }) +} + +fn build_custom_cmd(name: &str, spec: &Spec) -> Result { + let func = spec + .find_function(name) + .map_err(|_| Error::FunctionNotFoundInContractSpec(name.to_string()))?; + + // Parse the function arguments + let inputs_map = &func + .inputs + .iter() + .map(|i| (i.name.to_utf8_string().unwrap(), i.type_.clone())) + .collect::>(); + let name: &'static str = Box::leak(name.to_string().into_boxed_str()); + let mut cmd = clap::Command::new(name) + .no_binary_name(true) + .term_width(300) + .max_term_width(300); + let kebab_name = name.to_kebab_case(); + if kebab_name != name { + cmd = cmd.alias(kebab_name); + } + let doc: &'static str = Box::leak(func.doc.to_utf8_string_lossy().into_boxed_str()); + let long_doc: &'static str = Box::leak(arg_file_help(doc).into_boxed_str()); + + cmd = cmd.about(Some(doc)).long_about(long_doc); + for (name, type_) in inputs_map { + let mut arg = clap::Arg::new(name); + let file_arg_name = fmt_arg_file_name(name); + let mut file_arg = clap::Arg::new(&file_arg_name); + arg = arg + .long(name) + .alias(name.to_kebab_case()) + .num_args(1) + .value_parser(clap::builder::NonEmptyStringValueParser::new()) + .long_help(spec.doc(name, type_)?); + + file_arg = file_arg + .long(&file_arg_name) + .alias(file_arg_name.to_kebab_case()) + .num_args(1) + .hide(true) + .value_parser(value_parser!(PathBuf)) + .conflicts_with(name); + + if let Some(value_name) = spec.arg_value_name(type_, 0) { + let value_name: &'static str = Box::leak(value_name.into_boxed_str()); + arg = arg.value_name(value_name); + } + + // Set up special-case arg rules + arg = match type_ { + xdr::ScSpecTypeDef::Bool => arg + .num_args(0..1) + .default_missing_value("true") + .default_value("false") + .num_args(0..=1), + xdr::ScSpecTypeDef::Option(_val) => arg.required(false), + xdr::ScSpecTypeDef::I256 + | xdr::ScSpecTypeDef::I128 + | xdr::ScSpecTypeDef::I64 + | xdr::ScSpecTypeDef::I32 => arg.allow_hyphen_values(true), + _ => arg, + }; + + cmd = cmd.arg(arg); + cmd = cmd.arg(file_arg); + } + Ok(cmd) +} + +fn fmt_arg_file_name(name: &str) -> String { + format!("{name}-file-path") +} + +fn arg_file_help(docs: &str) -> String { + format!( + r#"{docs} +Usage Notes: +Each arg has a corresponding ---file-path which is a path to a file containing the corresponding JSON argument. +Note: The only types which aren't JSON are Bytes and Bytes which are raw bytes"# + ) +} diff --git a/cmd/soroban-cli/src/commands/contract/mod.rs b/cmd/soroban-cli/src/commands/contract/mod.rs new file mode 100644 index 00000000..35be97a7 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/mod.rs @@ -0,0 +1,158 @@ +pub mod asset; +pub mod bindings; +pub mod build; +pub mod deploy; +pub mod extend; +pub mod fetch; +pub mod id; +pub mod inspect; +pub mod install; +pub mod invoke; +pub mod optimize; +pub mod read; +pub mod restore; + +use crate::commands::global; + +#[derive(Debug, clap::Subcommand)] +pub enum Cmd { + /// Utilities to deploy a Stellar Asset Contract or get its id + #[command(subcommand)] + Asset(asset::Cmd), + /// Generate code client bindings for a contract + #[command(subcommand)] + Bindings(bindings::Cmd), + + Build(build::Cmd), + + /// Extend the time to live ledger of a contract-data ledger entry. + /// + /// If no keys are specified the contract itself is extended. + Extend(extend::Cmd), + + /// Deploy a wasm contract + Deploy(deploy::wasm::Cmd), + + /// Fetch a contract's Wasm binary + Fetch(fetch::Cmd), + + /// Generate the contract id for a given contract or asset + #[command(subcommand)] + Id(id::Cmd), + + /// Inspect a WASM file listing contract functions, meta, etc + Inspect(inspect::Cmd), + + /// Install a WASM file to the ledger without creating a contract instance + Install(install::Cmd), + + /// Invoke a contract function + /// + /// Generates an "implicit CLI" for the specified contract on-the-fly using the contract's + /// schema, which gets embedded into every Soroban contract. The "slop" in this command, + /// everything after the `--`, gets passed to this implicit CLI. Get in-depth help for a given + /// contract: + /// + /// soroban contract invoke ... -- --help + Invoke(invoke::Cmd), + + /// Optimize a WASM file + Optimize(optimize::Cmd), + + /// Print the current value of a contract-data ledger entry + Read(read::Cmd), + + /// Restore an evicted value for a contract-data legder entry. + /// + /// If no keys are specificed the contract itself is restored. + Restore(restore::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Asset(#[from] asset::Error), + + #[error(transparent)] + Bindings(#[from] bindings::Error), + + #[error(transparent)] + Build(#[from] build::Error), + + #[error(transparent)] + Extend(#[from] extend::Error), + + #[error(transparent)] + Deploy(#[from] deploy::wasm::Error), + + #[error(transparent)] + Fetch(#[from] fetch::Error), + #[error(transparent)] + Id(#[from] id::Error), + + #[error(transparent)] + Inspect(#[from] inspect::Error), + + #[error(transparent)] + Install(#[from] install::Error), + + #[error(transparent)] + Invoke(#[from] invoke::Error), + + #[error(transparent)] + Optimize(#[from] optimize::Error), + + #[error(transparent)] + Read(#[from] read::Error), + + #[error(transparent)] + Restore(#[from] restore::Error), +} + +impl Cmd { + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + match &self { + Cmd::Asset(asset) => asset.run().await?, + Cmd::Bindings(bindings) => bindings.run().await?, + Cmd::Build(build) => build.run()?, + Cmd::Extend(extend) => extend.run().await?, + Cmd::Deploy(deploy) => deploy.run().await?, + Cmd::Id(id) => id.run()?, + Cmd::Inspect(inspect) => inspect.run()?, + Cmd::Install(install) => install.run().await?, + Cmd::Invoke(invoke) => invoke.run(global_args).await?, + Cmd::Optimize(optimize) => optimize.run()?, + Cmd::Fetch(fetch) => fetch.run().await?, + Cmd::Read(read) => read.run().await?, + Cmd::Restore(restore) => restore.run().await?, + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)] +pub enum Durability { + /// Persistent + Persistent, + /// Temporary + Temporary, +} + +impl From<&Durability> for soroban_env_host::xdr::ContractDataDurability { + fn from(d: &Durability) -> Self { + match d { + Durability::Persistent => soroban_env_host::xdr::ContractDataDurability::Persistent, + Durability::Temporary => soroban_env_host::xdr::ContractDataDurability::Temporary, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)] +pub enum SpecOutput { + /// XDR of array of contract spec entries + XdrBase64, + /// Array of xdr of contract spec entries + XdrBase64Array, + /// Pretty print of contract spec entries + Docs, +} diff --git a/cmd/soroban-cli/src/commands/contract/optimize.rs b/cmd/soroban-cli/src/commands/contract/optimize.rs new file mode 100644 index 00000000..751dabb1 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/optimize.rs @@ -0,0 +1,80 @@ +use clap::{arg, command, Parser}; +use std::fmt::Debug; +#[cfg(feature = "opt")] +use wasm_opt::{Feature, OptimizationError, OptimizationOptions}; + +use crate::wasm; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + wasm: wasm::Args, + /// Path to write the optimized WASM file to (defaults to same location as --wasm with .optimized.wasm suffix) + #[arg(long)] + wasm_out: Option, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Wasm(#[from] wasm::Error), + #[cfg(feature = "opt")] + #[error("optimization error: {0}")] + OptimizationError(OptimizationError), + #[cfg(not(feature = "opt"))] + #[error("Must install with \"opt\" feature, e.g. `cargo install soroban-cli --features opt")] + Install, +} + +impl Cmd { + #[cfg(not(feature = "opt"))] + pub fn run(&self) -> Result<(), Error> { + Err(Error::Install) + } + + #[cfg(feature = "opt")] + pub fn run(&self) -> Result<(), Error> { + let wasm_size = self.wasm.len()?; + + println!( + "Reading: {} ({} bytes)", + self.wasm.wasm.to_string_lossy(), + wasm_size + ); + + let wasm_out = self.wasm_out.as_ref().cloned().unwrap_or_else(|| { + let mut wasm_out = self.wasm.wasm.clone(); + wasm_out.set_extension("optimized.wasm"); + wasm_out + }); + println!("Writing to: {}...", wasm_out.to_string_lossy()); + + let mut options = OptimizationOptions::new_optimize_for_size_aggressively(); + options.converge = true; + + // Explicitly set to MVP + sign-ext + mutable-globals, which happens to + // also be the default featureset, but just to be extra clear we set it + // explicitly. + // + // Formerly Soroban supported only the MVP feature set, but Rust 1.70 as + // well as Clang generate code with sign-ext + mutable-globals enabled, + // so Soroban has taken a change to support them also. + options.mvp_features_only(); + options.enable_feature(Feature::MutableGlobals); + options.enable_feature(Feature::SignExt); + + options + .run(&self.wasm.wasm, &wasm_out) + .map_err(Error::OptimizationError)?; + + let wasm_out_size = wasm::len(&wasm_out)?; + println!( + "Optimized: {} ({} bytes)", + wasm_out.to_string_lossy(), + wasm_out_size + ); + + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/read.rs b/cmd/soroban-cli/src/commands/contract/read.rs new file mode 100644 index 00000000..842832d5 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/read.rs @@ -0,0 +1,180 @@ +use std::{ + fmt::Debug, + io::{self, stdout}, +}; + +use clap::{command, Parser, ValueEnum}; +use soroban_env_host::{ + xdr::{ + ContractDataEntry, Error as XdrError, LedgerEntryData, LedgerKey, LedgerKeyContractData, + ScVal, WriteXdr, + }, + HostError, +}; +use soroban_sdk::xdr::Limits; + +use crate::{ + commands::config, + key, + rpc::{self, Client, FullLedgerEntries, FullLedgerEntry}, +}; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// Type of output to generate + #[arg(long, value_enum, default_value("string"))] + pub output: Output, + #[command(flatten)] + pub key: key::Args, + #[command(flatten)] + config: config::Args, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, ValueEnum)] +pub enum Output { + /// String + String, + /// Json + Json, + /// XDR + Xdr, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("parsing key {key}: {error}")] + CannotParseKey { + key: String, + error: soroban_spec_tools::Error, + }, + #[error("parsing XDR key {key}: {error}")] + CannotParseXdrKey { key: String, error: XdrError }, + #[error("cannot parse contract ID {contract_id}: {error}")] + CannotParseContractId { + contract_id: String, + error: stellar_strkey::DecodeError, + }, + #[error("cannot print result {result:?}: {error}")] + CannotPrintResult { + result: ScVal, + error: soroban_spec_tools::Error, + }, + #[error("cannot print result {result:?}: {error}")] + CannotPrintJsonResult { + result: ScVal, + error: serde_json::Error, + }, + #[error("cannot print as csv: {error}")] + CannotPrintAsCsv { error: csv::Error }, + #[error("cannot print: {error}")] + CannotPrintFlush { error: io::Error }, + #[error(transparent)] + Config(#[from] config::Error), + #[error("either `--key` or `--key-xdr` are required when querying a network")] + KeyIsRequired, + #[error(transparent)] + Rpc(#[from] rpc::Error), + #[error(transparent)] + Xdr(#[from] XdrError), + #[error(transparent)] + // TODO: the Display impl of host errors is pretty user-unfriendly + // (it just calls Debug). I think we can do better than that + Host(#[from] HostError), + #[error("no matching contract data entries were found for the specified contract id")] + NoContractDataEntryFoundForContractID, + #[error(transparent)] + Key(#[from] key::Error), + #[error("Only contract data and code keys are allowed")] + OnlyDataAllowed, +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + let entries = self.run_against_rpc_server().await?; + self.output_entries(&entries) + } + + async fn run_against_rpc_server(&self) -> Result { + let network = self.config.get_network()?; + tracing::trace!(?network); + let network = &self.config.get_network()?; + let client = Client::new(&network.rpc_url)?; + let keys = self.key.parse_keys()?; + Ok(client.get_full_ledger_entries(&keys).await?) + } + + fn output_entries(&self, entries: &FullLedgerEntries) -> Result<(), Error> { + if entries.entries.is_empty() { + return Err(Error::NoContractDataEntryFoundForContractID); + } + tracing::trace!("{entries:#?}"); + let mut out = csv::Writer::from_writer(stdout()); + for FullLedgerEntry { + key, + val, + live_until_ledger_seq, + last_modified_ledger, + } in &entries.entries + { + let ( + LedgerKey::ContractData(LedgerKeyContractData { key, .. }), + LedgerEntryData::ContractData(ContractDataEntry { val, .. }), + ) = (key, val) + else { + return Err(Error::OnlyDataAllowed); + }; + let output = match self.output { + Output::String => [ + soroban_spec_tools::to_string(key).map_err(|e| Error::CannotPrintResult { + result: key.clone(), + error: e, + })?, + soroban_spec_tools::to_string(val).map_err(|e| Error::CannotPrintResult { + result: val.clone(), + error: e, + })?, + last_modified_ledger.to_string(), + live_until_ledger_seq.to_string(), + ], + Output::Json => [ + serde_json::to_string_pretty(&key).map_err(|error| { + Error::CannotPrintJsonResult { + result: key.clone(), + error, + } + })?, + serde_json::to_string_pretty(&val).map_err(|error| { + Error::CannotPrintJsonResult { + result: val.clone(), + error, + } + })?, + serde_json::to_string_pretty(&last_modified_ledger).map_err(|error| { + Error::CannotPrintJsonResult { + result: val.clone(), + error, + } + })?, + serde_json::to_string_pretty(&live_until_ledger_seq).map_err(|error| { + Error::CannotPrintJsonResult { + result: val.clone(), + error, + } + })?, + ], + Output::Xdr => [ + key.to_xdr_base64(Limits::none())?, + val.to_xdr_base64(Limits::none())?, + last_modified_ledger.to_xdr_base64(Limits::none())?, + live_until_ledger_seq.to_xdr_base64(Limits::none())?, + ], + }; + out.write_record(output) + .map_err(|e| Error::CannotPrintAsCsv { error: e })?; + } + out.flush() + .map_err(|e| Error::CannotPrintFlush { error: e })?; + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/restore.rs b/cmd/soroban-cli/src/commands/contract/restore.rs new file mode 100644 index 00000000..38b8a84a --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/restore.rs @@ -0,0 +1,206 @@ +use std::{fmt::Debug, path::Path, str::FromStr}; + +use clap::{command, Parser}; +use soroban_env_host::xdr::{ + Error as XdrError, ExtensionPoint, LedgerEntry, LedgerEntryChange, LedgerEntryData, + LedgerFootprint, Memo, MuxedAccount, Operation, OperationBody, OperationMeta, Preconditions, + RestoreFootprintOp, SequenceNumber, SorobanResources, SorobanTransactionData, Transaction, + TransactionExt, TransactionMeta, TransactionMetaV3, TtlEntry, Uint256, +}; +use stellar_strkey::DecodeError; + +use crate::{ + commands::{ + config::{self, locator}, + contract::extend, + }, + key, + rpc::{self, Client}, + wasm, Pwd, +}; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + pub key: key::Args, + /// Number of ledgers to extend the entry + #[arg(long)] + pub ledgers_to_extend: Option, + /// Only print the new Time To Live ledger + #[arg(long)] + pub ttl_ledger_only: bool, + #[command(flatten)] + pub config: config::Args, + #[command(flatten)] + pub fee: crate::fee::Args, +} + +impl FromStr for Cmd { + type Err = clap::error::Error; + + fn from_str(s: &str) -> Result { + use clap::{CommandFactory, FromArgMatches}; + Self::from_arg_matches_mut(&mut Self::command().get_matches_from(s.split_whitespace())) + } +} + +impl Pwd for Cmd { + fn set_pwd(&mut self, pwd: &Path) { + self.config.set_pwd(pwd); + } +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("parsing key {key}: {error}")] + CannotParseKey { + key: String, + error: soroban_spec_tools::Error, + }, + #[error("parsing XDR key {key}: {error}")] + CannotParseXdrKey { key: String, error: XdrError }, + #[error("cannot parse contract ID {0}: {1}")] + CannotParseContractId(String, DecodeError), + #[error(transparent)] + Config(#[from] config::Error), + #[error("either `--key` or `--key-xdr` are required")] + KeyIsRequired, + #[error("xdr processing error: {0}")] + Xdr(#[from] XdrError), + #[error("Ledger entry not found")] + LedgerEntryNotFound, + #[error(transparent)] + Locator(#[from] locator::Error), + #[error("missing operation result")] + MissingOperationResult, + #[error(transparent)] + Rpc(#[from] rpc::Error), + #[error(transparent)] + Wasm(#[from] wasm::Error), + #[error(transparent)] + Key(#[from] key::Error), + #[error(transparent)] + Extend(#[from] extend::Error), +} + +impl Cmd { + #[allow(clippy::too_many_lines)] + pub async fn run(&self) -> Result<(), Error> { + let expiration_ledger_seq = self.run_against_rpc_server().await?; + + if let Some(ledgers_to_extend) = self.ledgers_to_extend { + extend::Cmd { + key: self.key.clone(), + ledgers_to_extend, + config: self.config.clone(), + fee: self.fee.clone(), + ttl_ledger_only: false, + } + .run() + .await?; + } else { + println!("New ttl ledger: {expiration_ledger_seq}"); + } + + Ok(()) + } + + pub async fn run_against_rpc_server(&self) -> Result { + let network = self.config.get_network()?; + tracing::trace!(?network); + let entry_keys = self.key.parse_keys()?; + let network = &self.config.get_network()?; + let client = Client::new(&network.rpc_url)?; + let key = self.config.key_pair()?; + + // Get the account sequence number + let public_strkey = + stellar_strkey::ed25519::PublicKey(key.verifying_key().to_bytes()).to_string(); + let account_details = client.get_account(&public_strkey).await?; + let sequence: i64 = account_details.seq_num.into(); + + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(key.verifying_key().to_bytes())), + fee: self.fee.fee, + seq_num: SequenceNumber(sequence + 1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::RestoreFootprint(RestoreFootprintOp { + ext: ExtensionPoint::V0, + }), + }] + .try_into()?, + ext: TransactionExt::V1(SorobanTransactionData { + ext: ExtensionPoint::V0, + resources: SorobanResources { + footprint: LedgerFootprint { + read_only: vec![].try_into()?, + read_write: entry_keys.try_into()?, + }, + instructions: 0, + read_bytes: 0, + write_bytes: 0, + }, + resource_fee: 0, + }), + }; + + let (result, meta, events) = client + .prepare_and_send_transaction(&tx, &key, &[], &network.network_passphrase, None, None) + .await?; + + tracing::trace!(?result); + tracing::trace!(?meta); + if !events.is_empty() { + tracing::info!("Events:\n {events:#?}"); + } + + // The transaction from core will succeed regardless of whether it actually found & + // restored the entry, so we have to inspect the result meta to tell if it worked or not. + let TransactionMeta::V3(TransactionMetaV3 { operations, .. }) = meta else { + return Err(Error::LedgerEntryNotFound); + }; + tracing::debug!("Operations:\nlen:{}\n{operations:#?}", operations.len()); + + // Simply check if there is exactly one entry here. We only support extending a single + // entry via this command (which we should fix separately, but). + if operations.len() == 0 { + return Err(Error::LedgerEntryNotFound); + } + + if operations.len() != 1 { + tracing::warn!( + "Unexpected number of operations: {}. Currently only handle one.", + operations[0].changes.len() + ); + } + parse_operations(&operations).ok_or(Error::MissingOperationResult) + } +} + +fn parse_operations(ops: &[OperationMeta]) -> Option { + ops.first().and_then(|op| { + op.changes.iter().find_map(|entry| match entry { + LedgerEntryChange::Updated(LedgerEntry { + data: + LedgerEntryData::Ttl(TtlEntry { + live_until_ledger_seq, + .. + }), + .. + }) + | LedgerEntryChange::Created(LedgerEntry { + data: + LedgerEntryData::Ttl(TtlEntry { + live_until_ledger_seq, + .. + }), + .. + }) => Some(*live_until_ledger_seq), + _ => None, + }) + }) +} diff --git a/cmd/soroban-cli/src/commands/events.rs b/cmd/soroban-cli/src/commands/events.rs new file mode 100644 index 00000000..aa46bbe2 --- /dev/null +++ b/cmd/soroban-cli/src/commands/events.rs @@ -0,0 +1,221 @@ +use clap::{arg, command, Parser}; +use std::io; + +use soroban_env_host::xdr::{self, Limits, ReadXdr}; + +use super::{config::locator, network}; +use crate::{rpc, utils}; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// The first ledger sequence number in the range to pull events + /// https://developers.stellar.org/docs/encyclopedia/ledger-headers#ledger-sequence + #[arg(long, conflicts_with = "cursor", required_unless_present = "cursor")] + start_ledger: Option, + /// The cursor corresponding to the start of the event range. + #[arg( + long, + conflicts_with = "start_ledger", + required_unless_present = "start_ledger" + )] + cursor: Option, + /// Output formatting options for event stream + #[arg(long, value_enum, default_value = "pretty")] + output: OutputFormat, + /// The maximum number of events to display (defer to the server-defined limit). + #[arg(short, long, default_value = "10")] + count: usize, + /// A set of (up to 5) contract IDs to filter events on. This parameter can + /// be passed multiple times, e.g. `--id C123.. --id C456..`, or passed with + /// multiple parameters, e.g. `--id C123 C456`. + /// + /// Though the specification supports multiple filter objects (i.e. + /// combinations of type, IDs, and topics), only one set can be specified on + /// the command-line today, though that set can have multiple IDs/topics. + #[arg( + long = "id", + num_args = 1..=6, + help_heading = "FILTERS" + )] + contract_ids: Vec, + /// A set of (up to 4) topic filters to filter event topics on. A single + /// topic filter can contain 1-4 different segment filters, separated by + /// commas, with an asterisk (* character) indicating a wildcard segment. + /// + /// For example, this is one topic filter with two segments: + /// + /// --topic "AAAABQAAAAdDT1VOVEVSAA==,*" + /// + /// This is two topic filters with one and two segments each: + /// + /// --topic "AAAABQAAAAdDT1VOVEVSAA==" --topic '*,*' + /// + /// Note that all of these topic filters are combined with the contract IDs + /// into a single filter (i.e. combination of type, IDs, and topics). + #[arg( + long = "topic", + num_args = 1..=5, + help_heading = "FILTERS" + )] + topic_filters: Vec, + /// Specifies which type of contract events to display. + #[arg( + long = "type", + value_enum, + default_value = "all", + help_heading = "FILTERS" + )] + event_type: rpc::EventType, + #[command(flatten)] + locator: locator::Args, + #[command(flatten)] + network: network::Args, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("cursor is not valid")] + InvalidCursor, + #[error("filepath does not exist: {path}")] + InvalidFile { path: String }, + #[error("filepath ({path}) cannot be read: {error}")] + CannotReadFile { path: String, error: String }, + #[error("cannot parse topic filter {topic} into 1-4 segments")] + InvalidTopicFilter { topic: String }, + #[error("invalid segment ({segment}) in topic filter ({topic}): {error}")] + InvalidSegment { + topic: String, + segment: String, + error: xdr::Error, + }, + #[error("cannot parse contract ID {contract_id}: {error}")] + InvalidContractId { + contract_id: String, + error: stellar_strkey::DecodeError, + }, + #[error("invalid JSON string: {error} ({debug})")] + InvalidJson { + debug: String, + error: serde_json::Error, + }, + #[error("invalid timestamp in event: {ts}")] + InvalidTimestamp { ts: String }, + #[error("missing start_ledger and cursor")] + MissingStartLedgerAndCursor, + #[error("missing target")] + MissingTarget, + #[error(transparent)] + Rpc(#[from] rpc::Error), + #[error(transparent)] + Generic(#[from] Box), + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Xdr(#[from] xdr::Error), + #[error(transparent)] + Serde(#[from] serde_json::Error), + #[error(transparent)] + Network(#[from] network::Error), + #[error(transparent)] + Locator(#[from] locator::Error), +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)] +pub enum OutputFormat { + /// Colorful, human-oriented console output + Pretty, + /// Human-oriented console output without colors + Plain, + /// JSONified console output + Json, +} + +impl Cmd { + pub async fn run(&mut self) -> Result<(), Error> { + // Validate that topics are made up of segments. + for topic in &self.topic_filters { + for (i, segment) in topic.split(',').enumerate() { + if i > 4 { + return Err(Error::InvalidTopicFilter { + topic: topic.to_string(), + }); + } + + if segment != "*" { + if let Err(e) = xdr::ScVal::from_xdr_base64(segment, Limits::none()) { + return Err(Error::InvalidSegment { + topic: topic.to_string(), + segment: segment.to_string(), + error: e, + }); + } + } + } + } + + // Validate contract_ids + for id in &mut self.contract_ids { + utils::contract_id_from_str(id).map_err(|e| Error::InvalidContractId { + contract_id: id.clone(), + error: e, + })?; + } + + let response = self.run_against_rpc_server().await?; + + for event in &response.events { + match self.output { + // Should we pretty-print the JSON like we're doing here or just + // dump an event in raw JSON on each line? The latter is easier + // to consume programmatically. + OutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&event).map_err(|e| { + Error::InvalidJson { + debug: format!("{event:#?}"), + error: e, + } + })?, + ); + } + OutputFormat::Plain => println!("{event}"), + OutputFormat::Pretty => event.pretty_print()?, + } + } + println!("Latest Ledger: {}", response.latest_ledger); + + Ok(()) + } + + async fn run_against_rpc_server(&self) -> Result { + let start = self.start()?; + let network = self.network.get(&self.locator)?; + + let client = rpc::Client::new(&network.rpc_url)?; + client + .verify_network_passphrase(Some(&network.network_passphrase)) + .await?; + client + .get_events( + start, + Some(self.event_type), + &self.contract_ids, + &self.topic_filters, + Some(self.count), + ) + .await + .map_err(Error::Rpc) + } + + fn start(&self) -> Result { + let start = match (self.start_ledger, self.cursor.clone()) { + (Some(start), _) => rpc::EventStart::Ledger(start), + (_, Some(c)) => rpc::EventStart::Cursor(c), + // should never happen because of required_unless_present flags + _ => return Err(Error::MissingStartLedgerAndCursor), + }; + Ok(start) + } +} diff --git a/cmd/soroban-cli/src/commands/global.rs b/cmd/soroban-cli/src/commands/global.rs new file mode 100644 index 00000000..c606bd1b --- /dev/null +++ b/cmd/soroban-cli/src/commands/global.rs @@ -0,0 +1,61 @@ +use clap::arg; +use std::path::PathBuf; + +use super::config; + +#[derive(Debug, clap::Args, Clone, Default)] +#[group(skip)] +#[allow(clippy::struct_excessive_bools)] +pub struct Args { + #[clap(flatten)] + pub locator: config::locator::Args, + + /// Filter logs output. To turn on "soroban_cli::log::footprint=debug" or off "=off". Can also use env var `RUST_LOG`. + #[arg(long, short = 'f')] + pub filter_logs: Vec, + + /// Do not write logs to stderr including `INFO` + #[arg(long, short = 'q')] + pub quiet: bool, + + /// Log DEBUG events + #[arg(long, short = 'v')] + pub verbose: bool, + + /// Log DEBUG and TRACE events + #[arg(long, visible_alias = "vv")] + pub very_verbose: bool, + + /// List installed plugins. E.g. `soroban-hello` + #[arg(long)] + pub list: bool, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("reading file {filepath}: {error}")] + CannotReadLedgerFile { + filepath: PathBuf, + error: soroban_ledger_snapshot::Error, + }, + + #[error("committing file {filepath}: {error}")] + CannotCommitLedgerFile { + filepath: PathBuf, + error: soroban_ledger_snapshot::Error, + }, +} + +impl Args { + pub fn log_level(&self) -> Option { + if self.quiet { + None + } else if self.very_verbose { + Some(tracing::Level::TRACE) + } else if self.verbose { + Some(tracing::Level::DEBUG) + } else { + Some(tracing::Level::INFO) + } + } +} diff --git a/cmd/soroban-cli/src/commands/keys/add.rs b/cmd/soroban-cli/src/commands/keys/add.rs new file mode 100644 index 00000000..2868c737 --- /dev/null +++ b/cmd/soroban-cli/src/commands/keys/add.rs @@ -0,0 +1,33 @@ +use clap::command; + +use super::super::config::{locator, secret}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Secret(#[from] secret::Error), + + #[error(transparent)] + Config(#[from] locator::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// Name of identity + pub name: String, + + #[command(flatten)] + pub secrets: secret::Args, + + #[command(flatten)] + pub config_locator: locator::Args, +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + Ok(self + .config_locator + .write_identity(&self.name, &self.secrets.read_secret()?)?) + } +} diff --git a/cmd/soroban-cli/src/commands/keys/address.rs b/cmd/soroban-cli/src/commands/keys/address.rs new file mode 100644 index 00000000..d13381b4 --- /dev/null +++ b/cmd/soroban-cli/src/commands/keys/address.rs @@ -0,0 +1,54 @@ +use crate::commands::config::secret; + +use super::super::config::locator; +use clap::arg; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Config(#[from] locator::Error), + + #[error(transparent)] + Secret(#[from] secret::Error), + + #[error(transparent)] + StrKey(#[from] stellar_strkey::DecodeError), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// Name of identity to lookup, default test identity used if not provided + pub name: String, + + /// If identity is a seed phrase use this hd path, default is 0 + #[arg(long)] + pub hd_path: Option, + + #[command(flatten)] + pub locator: locator::Args, +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + println!("{}", self.public_key()?); + Ok(()) + } + + pub fn private_key(&self) -> Result { + Ok(self + .locator + .read_identity(&self.name)? + .key_pair(self.hd_path)?) + } + + pub fn public_key(&self) -> Result { + if let Ok(key) = stellar_strkey::ed25519::PublicKey::from_string(&self.name) { + Ok(key) + } else { + Ok(stellar_strkey::ed25519::PublicKey::from_payload( + self.private_key()?.verifying_key().as_bytes(), + )?) + } + } +} diff --git a/cmd/soroban-cli/src/commands/keys/fund.rs b/cmd/soroban-cli/src/commands/keys/fund.rs new file mode 100644 index 00000000..b6c088f1 --- /dev/null +++ b/cmd/soroban-cli/src/commands/keys/fund.rs @@ -0,0 +1,34 @@ +use clap::command; + +use crate::commands::network; + +use super::address; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Address(#[from] address::Error), + #[error(transparent)] + Network(#[from] network::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + pub network: network::Args, + /// Address to fund + #[command(flatten)] + pub address: address::Cmd, +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + let addr = self.address.public_key()?; + self.network + .get(&self.address.locator)? + .fund_address(&addr) + .await?; + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/keys/generate.rs b/cmd/soroban-cli/src/commands/keys/generate.rs new file mode 100644 index 00000000..07782b21 --- /dev/null +++ b/cmd/soroban-cli/src/commands/keys/generate.rs @@ -0,0 +1,75 @@ +use clap::{arg, command}; + +use crate::commands::network; + +use super::super::config::{ + locator, + secret::{self, Secret}, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Config(#[from] locator::Error), + #[error(transparent)] + Secret(#[from] secret::Error), + #[error(transparent)] + Network(#[from] network::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// Name of identity + pub name: String, + /// Do not fund address + #[arg(long)] + pub no_fund: bool, + /// Optional seed to use when generating seed phrase. + /// Random otherwise. + #[arg(long, conflicts_with = "default_seed")] + pub seed: Option, + + /// Output the generated identity as a secret key + #[arg(long, short = 's')] + pub as_secret: bool, + + #[command(flatten)] + pub config_locator: locator::Args, + + /// When generating a secret key, which hd_path should be used from the original seed_phrase. + #[arg(long)] + pub hd_path: Option, + + /// Generate the default seed phrase. Useful for testing. + /// Equivalent to --seed 0000000000000000 + #[arg(long, short = 'd', conflicts_with = "seed")] + pub default_seed: bool, + + #[command(flatten)] + pub network: network::Args, +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + let seed_phrase = if self.default_seed { + Secret::test_seed_phrase() + } else { + Secret::from_seed(self.seed.as_deref()) + }?; + let secret = if self.as_secret { + seed_phrase.private_key(self.hd_path)?.into() + } else { + seed_phrase + }; + self.config_locator.write_identity(&self.name, &secret)?; + if !self.no_fund { + let addr = secret.public_key(self.hd_path)?; + let network = self.network.get(&self.config_locator)?; + network.fund_address(&addr).await.unwrap_or_else(|_| { + tracing::warn!("Failed to fund address: {addr} on at {}", network.rpc_url); + }); + } + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/keys/ls.rs b/cmd/soroban-cli/src/commands/keys/ls.rs new file mode 100644 index 00000000..bc46ffcd --- /dev/null +++ b/cmd/soroban-cli/src/commands/keys/ls.rs @@ -0,0 +1,45 @@ +use clap::command; + +use super::super::config::locator; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Config(#[from] locator::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + pub config_locator: locator::Args, + + #[arg(long, short = 'l')] + pub long: bool, +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + let res = if self.long { self.ls_l() } else { self.ls() }?.join("\n"); + println!("{res}"); + Ok(()) + } + + pub fn ls(&self) -> Result, Error> { + let mut list = self.config_locator.list_identities()?; + let test = "test".to_string(); + if !list.contains(&test) { + list.push(test); + } + Ok(list) + } + + pub fn ls_l(&self) -> Result, Error> { + Ok(self + .config_locator + .list_identities_long()? + .into_iter() + .map(|(name, location)| format!("{location}\nName: {name}\n")) + .collect::>()) + } +} diff --git a/cmd/soroban-cli/src/commands/keys/mod.rs b/cmd/soroban-cli/src/commands/keys/mod.rs new file mode 100644 index 00000000..42814092 --- /dev/null +++ b/cmd/soroban-cli/src/commands/keys/mod.rs @@ -0,0 +1,63 @@ +use clap::Parser; + +pub mod add; +pub mod address; +pub mod fund; +pub mod generate; +pub mod ls; +pub mod rm; +pub mod show; + +#[derive(Debug, Parser)] +pub enum Cmd { + /// Add a new identity (keypair, ledger, macOS keychain) + Add(add::Cmd), + /// Given an identity return its address (public key) + Address(address::Cmd), + /// Fund an identity on a test network + Fund(fund::Cmd), + /// Generate a new identity with a seed phrase, currently 12 words + Generate(generate::Cmd), + /// List identities + Ls(ls::Cmd), + /// Remove an identity + Rm(rm::Cmd), + /// Given an identity return its private key + Show(show::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Add(#[from] add::Error), + + #[error(transparent)] + Address(#[from] address::Error), + #[error(transparent)] + Fund(#[from] fund::Error), + + #[error(transparent)] + Generate(#[from] generate::Error), + #[error(transparent)] + Rm(#[from] rm::Error), + #[error(transparent)] + Ls(#[from] ls::Error), + + #[error(transparent)] + Show(#[from] show::Error), +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + match self { + Cmd::Add(cmd) => cmd.run()?, + Cmd::Address(cmd) => cmd.run()?, + Cmd::Fund(cmd) => cmd.run().await?, + Cmd::Generate(cmd) => cmd.run().await?, + Cmd::Ls(cmd) => cmd.run()?, + Cmd::Rm(cmd) => cmd.run()?, + Cmd::Show(cmd) => cmd.run()?, + }; + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/keys/rm.rs b/cmd/soroban-cli/src/commands/keys/rm.rs new file mode 100644 index 00000000..df48108d --- /dev/null +++ b/cmd/soroban-cli/src/commands/keys/rm.rs @@ -0,0 +1,25 @@ +use clap::command; + +use super::super::config::locator; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Locator(#[from] locator::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// Identity to remove + pub name: String, + + #[command(flatten)] + pub config: locator::Args, +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + Ok(self.config.remove_identity(&self.name)?) + } +} diff --git a/cmd/soroban-cli/src/commands/keys/show.rs b/cmd/soroban-cli/src/commands/keys/show.rs new file mode 100644 index 00000000..b99478cb --- /dev/null +++ b/cmd/soroban-cli/src/commands/keys/show.rs @@ -0,0 +1,43 @@ +use clap::arg; + +use super::super::config::{locator, secret}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Config(#[from] locator::Error), + + #[error(transparent)] + Secret(#[from] secret::Error), + + #[error(transparent)] + StrKey(#[from] stellar_strkey::DecodeError), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// Name of identity to lookup, default is test identity + pub name: String, + + /// If identity is a seed phrase use this hd path, default is 0 + #[arg(long)] + pub hd_path: Option, + + #[command(flatten)] + pub locator: locator::Args, +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + println!("{}", self.private_key()?.to_string()); + Ok(()) + } + + pub fn private_key(&self) -> Result { + Ok(self + .locator + .read_identity(&self.name)? + .private_key(self.hd_path)?) + } +} diff --git a/cmd/soroban-cli/src/commands/lab/mod.rs b/cmd/soroban-cli/src/commands/lab/mod.rs new file mode 100644 index 00000000..f405efe6 --- /dev/null +++ b/cmd/soroban-cli/src/commands/lab/mod.rs @@ -0,0 +1,31 @@ +use clap::Subcommand; +use stellar_xdr::cli as xdr; + +pub mod token; + +#[derive(Debug, Subcommand)] +pub enum Cmd { + /// Wrap, create, and manage token contracts + Token(token::Root), + + /// Decode xdr + Xdr(xdr::Root), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Token(#[from] token::Error), + #[error(transparent)] + Xdr(#[from] xdr::Error), +} + +impl Cmd { + pub async fn run(&self) -> Result<(), Error> { + match &self { + Cmd::Token(token) => token.run().await?, + Cmd::Xdr(xdr) => xdr.run()?, + } + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/lab/token/mod.rs b/cmd/soroban-cli/src/commands/lab/token/mod.rs new file mode 100644 index 00000000..bd7eacf3 --- /dev/null +++ b/cmd/soroban-cli/src/commands/lab/token/mod.rs @@ -0,0 +1,38 @@ +use std::fmt::Debug; + +use crate::commands::contract::{deploy, id}; +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug)] +pub struct Root { + #[clap(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Deploy a token contract to wrap an existing Stellar classic asset for smart contract usage + /// Deprecated, use `soroban contract deploy asset` instead + Wrap(deploy::asset::Cmd), + /// Compute the expected contract id for the given asset + /// Deprecated, use `soroban contract id asset` instead + Id(id::asset::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Wrap(#[from] deploy::asset::Error), + #[error(transparent)] + Id(#[from] id::asset::Error), +} + +impl Root { + pub async fn run(&self) -> Result<(), Error> { + match &self.cmd { + Cmd::Wrap(wrap) => wrap.run().await?, + Cmd::Id(id) => id.run()?, + } + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/mod.rs b/cmd/soroban-cli/src/commands/mod.rs new file mode 100644 index 00000000..952869af --- /dev/null +++ b/cmd/soroban-cli/src/commands/mod.rs @@ -0,0 +1,160 @@ +use std::str::FromStr; + +use clap::{command, error::ErrorKind, CommandFactory, FromArgMatches, Parser}; + +pub mod completion; +pub mod config; +pub mod contract; +pub mod events; +pub mod global; +pub mod keys; +pub mod lab; +pub mod network; +pub mod plugin; +pub mod version; + +pub const HEADING_RPC: &str = "Options (RPC)"; +const ABOUT: &str = "Build, deploy, & interact with contracts; set identities to sign with; configure networks; generate keys; and more. + +Intro: https://soroban.stellar.org +CLI Reference: https://github.com/stellar/soroban-tools/tree/main/docs/soroban-cli-full-docs.md"; + +// long_about is shown when someone uses `--help`; short help when using `-h` +const LONG_ABOUT: &str = " + +The easiest way to get started is to generate a new identity: + + soroban config identity generate alice + +You can use identities with the `--source` flag in other commands later. + +Commands that relate to smart contract interactions are organized under the `contract` subcommand. List them: + + soroban contract --help + +A Soroban contract has its interface schema types embedded in the binary that gets deployed on-chain, making it possible to dynamically generate a custom CLI for each. `soroban contract invoke` makes use of this: + + soroban contract invoke --id CCR6QKTWZQYW6YUJ7UP7XXZRLWQPFRV6SWBLQS4ZQOSAF4BOUD77OTE2 --source alice --network testnet -- \ + --help + +Anything after the `--` double dash (the \"slop\") is parsed as arguments to the contract-specific CLI, generated on-the-fly from the embedded schema. For the hello world example, with a function called `hello` that takes one string argument `to`, here's how you invoke it: + + soroban contract invoke --id CCR6QKTWZQYW6YUJ7UP7XXZRLWQPFRV6SWBLQS4ZQOSAF4BOUD77OTE2 --source alice --network testnet -- \ + hello --to world + +Full CLI reference: https://github.com/stellar/soroban-tools/tree/main/docs/soroban-cli-full-docs.md"; + +#[derive(Parser, Debug)] +#[command( + name = "soroban", + about = ABOUT, + version = version::long(), + long_about = ABOUT.to_string() + LONG_ABOUT, + disable_help_subcommand = true, +)] +pub struct Root { + #[clap(flatten)] + pub global_args: global::Args, + + #[command(subcommand)] + pub cmd: Cmd, +} + +impl Root { + pub fn new() -> Result { + Self::try_parse().map_err(|e| { + if std::env::args().any(|s| s == "--list") { + let plugins = plugin::list().unwrap_or_default(); + if plugins.is_empty() { + println!("No Plugins installed. E.g. soroban-hello"); + } else { + println!("Installed Plugins:\n {}", plugins.join("\n ")); + } + std::process::exit(0); + } + match e.kind() { + ErrorKind::InvalidSubcommand => match plugin::run() { + Ok(()) => Error::Clap(e), + Err(e) => Error::Plugin(e), + }, + _ => Error::Clap(e), + } + }) + } + + pub fn from_arg_matches(itr: I) -> Result + where + I: IntoIterator, + T: Into + Clone, + { + Self::from_arg_matches_mut(&mut Self::command().get_matches_from(itr)) + } + pub async fn run(&mut self) -> Result<(), Error> { + match &mut self.cmd { + Cmd::Completion(completion) => completion.run(), + Cmd::Contract(contract) => contract.run(&self.global_args).await?, + Cmd::Events(events) => events.run().await?, + Cmd::Lab(lab) => lab.run().await?, + Cmd::Network(network) => network.run()?, + Cmd::Version(version) => version.run(), + Cmd::Keys(id) => id.run().await?, + Cmd::Config(c) => c.run().await?, + }; + Ok(()) + } +} + +impl FromStr for Root { + type Err = clap::Error; + + fn from_str(s: &str) -> Result { + Self::from_arg_matches(s.split_whitespace()) + } +} + +#[derive(Parser, Debug)] +pub enum Cmd { + /// Print shell completion code for the specified shell. + #[command(long_about = completion::LONG_ABOUT)] + Completion(completion::Cmd), + /// Deprecated, use `soroban keys` and `soroban network` instead + #[command(subcommand)] + Config(config::Cmd), + /// Tools for smart contract developers + #[command(subcommand)] + Contract(contract::Cmd), + /// Watch the network for contract events + Events(events::Cmd), + /// Create and manage identities including keys and addresses + #[command(subcommand)] + Keys(keys::Cmd), + /// Experiment with early features and expert tools + #[command(subcommand)] + Lab(lab::Cmd), + /// Start and configure networks + #[command(subcommand)] + Network(network::Cmd), + /// Print version information + Version(version::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + // TODO: stop using Debug for displaying errors + #[error(transparent)] + Contract(#[from] contract::Error), + #[error(transparent)] + Events(#[from] events::Error), + #[error(transparent)] + Keys(#[from] keys::Error), + #[error(transparent)] + Lab(#[from] lab::Error), + #[error(transparent)] + Config(#[from] config::Error), + #[error(transparent)] + Clap(#[from] clap::error::Error), + #[error(transparent)] + Plugin(#[from] plugin::Error), + #[error(transparent)] + Network(#[from] network::Error), +} diff --git a/cmd/soroban-cli/src/commands/network/add.rs b/cmd/soroban-cli/src/commands/network/add.rs new file mode 100644 index 00000000..b6a2ddd3 --- /dev/null +++ b/cmd/soroban-cli/src/commands/network/add.rs @@ -0,0 +1,32 @@ +use super::super::config::{locator, secret}; +use clap::command; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Secret(#[from] secret::Error), + + #[error(transparent)] + Config(#[from] locator::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// Name of network + pub name: String, + + #[command(flatten)] + pub network: super::Network, + + #[command(flatten)] + pub config_locator: locator::Args, +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + Ok(self + .config_locator + .write_network(&self.name, &self.network)?) + } +} diff --git a/cmd/soroban-cli/src/commands/network/ls.rs b/cmd/soroban-cli/src/commands/network/ls.rs new file mode 100644 index 00000000..cc542b3e --- /dev/null +++ b/cmd/soroban-cli/src/commands/network/ls.rs @@ -0,0 +1,44 @@ +use clap::command; + +use super::locator; +use crate::commands::config::locator::Location; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Config(#[from] locator::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + pub config_locator: locator::Args, + /// Get more info about the networks + #[arg(long, short = 'l')] + pub long: bool, +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + let res = if self.long { self.ls_l() } else { self.ls() }?.join("\n"); + println!("{res}"); + Ok(()) + } + + pub fn ls(&self) -> Result, Error> { + Ok(self.config_locator.list_networks()?) + } + + pub fn ls_l(&self) -> Result, Error> { + Ok(self + .config_locator + .list_networks_long()? + .iter() + .filter_map(|(name, network, location)| { + (!self.config_locator.global || matches!(location, Location::Global(_))) + .then(|| Some(format!("{location}\nName: {name}\n{network:#?}\n")))? + }) + .collect()) + } +} diff --git a/cmd/soroban-cli/src/commands/network/mod.rs b/cmd/soroban-cli/src/commands/network/mod.rs new file mode 100644 index 00000000..22cba190 --- /dev/null +++ b/cmd/soroban-cli/src/commands/network/mod.rs @@ -0,0 +1,197 @@ +use std::str::FromStr; + +use clap::{arg, Parser}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use stellar_strkey::ed25519::PublicKey; + +use crate::{ + commands::HEADING_RPC, + rpc::{self, Client}, +}; + +use super::config::locator; + +pub mod add; +pub mod ls; +pub mod rm; + +#[derive(Debug, Parser)] +pub enum Cmd { + /// Add a new network + Add(add::Cmd), + /// Remove a network + Rm(rm::Cmd), + /// List networks + Ls(ls::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Add(#[from] add::Error), + + #[error(transparent)] + Rm(#[from] rm::Error), + + #[error(transparent)] + Ls(#[from] ls::Error), + + #[error(transparent)] + Config(#[from] locator::Error), + + #[error("network arg or rpc url and network passphrase are required if using the network")] + Network, + #[error(transparent)] + Rpc(#[from] rpc::Error), + #[error(transparent)] + Hyper(#[from] hyper::Error), + #[error("Failed to parse JSON from {0}, {1}")] + FailedToParseJSON(String, serde_json::Error), + #[error("Invalid URL {0}")] + InvalidUrl(String), + #[error("Inproper response {0}")] + InproperResponse(String), + #[error("Currently not supported on windows. Please visit:\n{0}")] + WindowsNotSupported(String), +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + match self { + Cmd::Add(cmd) => cmd.run()?, + Cmd::Rm(new) => new.run()?, + Cmd::Ls(cmd) => cmd.run()?, + }; + Ok(()) + } +} + +#[derive(Debug, clap::Args, Clone, Default)] +#[group(skip)] +pub struct Args { + /// RPC server endpoint + #[arg( + long = "rpc-url", + requires = "network_passphrase", + required_unless_present = "network", + env = "SOROBAN_RPC_URL", + help_heading = HEADING_RPC, + )] + pub rpc_url: Option, + /// Network passphrase to sign the transaction sent to the rpc server + #[arg( + long = "network-passphrase", + requires = "rpc_url", + required_unless_present = "network", + env = "SOROBAN_NETWORK_PASSPHRASE", + help_heading = HEADING_RPC, + )] + pub network_passphrase: Option, + /// Name of network to use from config + #[arg( + long, + required_unless_present = "rpc_url", + env = "SOROBAN_NETWORK", + help_heading = HEADING_RPC, + )] + pub network: Option, +} + +impl Args { + pub fn get(&self, locator: &locator::Args) -> Result { + if let Some(name) = self.network.as_deref() { + if let Ok(network) = locator.read_network(name) { + return Ok(network); + } + } + if let (Some(rpc_url), Some(network_passphrase)) = + (self.rpc_url.clone(), self.network_passphrase.clone()) + { + Ok(Network { + rpc_url, + network_passphrase, + }) + } else { + Err(Error::Network) + } + } +} + +#[derive(Debug, clap::Args, Serialize, Deserialize, Clone)] +#[group(skip)] +pub struct Network { + /// RPC server endpoint + #[arg( + long = "rpc-url", + env = "SOROBAN_RPC_URL", + help_heading = HEADING_RPC, + )] + pub rpc_url: String, + /// Network passphrase to sign the transaction sent to the rpc server + #[arg( + long, + env = "SOROBAN_NETWORK_PASSPHRASE", + help_heading = HEADING_RPC, + )] + pub network_passphrase: String, +} + +impl Network { + pub async fn helper_url(&self, addr: &str) -> Result { + tracing::debug!("address {addr:?}"); + let client = Client::new(&self.rpc_url)?; + let helper_url_root = client.friendbot_url().await?; + let uri = http::Uri::from_str(&helper_url_root) + .map_err(|_| Error::InvalidUrl(helper_url_root.to_string()))?; + http::Uri::from_str(&format!("{uri:?}?addr={addr}")) + .map_err(|_| Error::InvalidUrl(helper_url_root.to_string())) + } + + #[allow(clippy::similar_names)] + pub async fn fund_address(&self, addr: &PublicKey) -> Result<(), Error> { + let uri = self.helper_url(&addr.to_string()).await?; + tracing::debug!("URL {uri:?}"); + let response = match uri.scheme_str() { + Some("http") => hyper::Client::new().get(uri.clone()).await?, + Some("https") => { + #[cfg(target_os = "windows")] + { + return Err(Error::WindowsNotSupported(uri.to_string())); + } + #[cfg(not(target_os = "windows"))] + { + let https = hyper_tls::HttpsConnector::new(); + hyper::Client::builder() + .build::<_, hyper::Body>(https) + .get(uri.clone()) + .await? + } + } + _ => { + return Err(Error::InvalidUrl(uri.to_string())); + } + }; + let body = hyper::body::to_bytes(response.into_body()).await?; + let res = serde_json::from_slice::(&body) + .map_err(|e| Error::FailedToParseJSON(uri.to_string(), e))?; + tracing::debug!("{res:#?}"); + if let Some(detail) = res.get("detail").and_then(Value::as_str) { + if detail.contains("createAccountAlreadyExist") { + tracing::warn!("Account already exists"); + } + } else if res.get("successful").is_none() { + return Err(Error::InproperResponse(res.to_string())); + } + Ok(()) + } +} + +impl Network { + pub fn futurenet() -> Self { + Network { + rpc_url: "https://rpc-futurenet.stellar.org:443".to_owned(), + network_passphrase: "Test SDF Future Network ; October 2022".to_owned(), + } + } +} diff --git a/cmd/soroban-cli/src/commands/network/rm.rs b/cmd/soroban-cli/src/commands/network/rm.rs new file mode 100644 index 00000000..7051dc6b --- /dev/null +++ b/cmd/soroban-cli/src/commands/network/rm.rs @@ -0,0 +1,24 @@ +use super::locator; +use clap::command; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Locator(#[from] locator::Error), +} + +#[derive(Debug, clap::Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// Network to remove + pub name: String, + + #[command(flatten)] + pub config: locator::Args, +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + Ok(self.config.remove_network(&self.name)?) + } +} diff --git a/cmd/soroban-cli/src/commands/plugin.rs b/cmd/soroban-cli/src/commands/plugin.rs new file mode 100644 index 00000000..27c191f0 --- /dev/null +++ b/cmd/soroban-cli/src/commands/plugin.rs @@ -0,0 +1,96 @@ +use std::process::Command; + +use clap::CommandFactory; +use which::which; + +use crate::{utils, Root}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Plugin not provided. Should be `soroban plugin` for a binary `soroban-plugin`")] + MissingSubcommand, + #[error(transparent)] + IO(#[from] std::io::Error), + #[error( + r#"error: no such command: `{0}` + + {1}View all installed plugins with `soroban --list`"# + )] + ExecutableNotFound(String, String), + #[error(transparent)] + Which(#[from] which::Error), + #[error(transparent)] + Regex(#[from] regex::Error), +} + +const SUBCOMMAND_TOLERANCE: f64 = 0.75; +const PLUGIN_TOLERANCE: f64 = 0.75; +const MIN_LENGTH: usize = 4; + +/// Tries to run a plugin, if the plugin's name is similar enough to any of the current subcommands return Ok. +/// Otherwise only errors can be returned because this process will exit with the plugin. +pub fn run() -> Result<(), Error> { + let (name, args) = { + let mut args = std::env::args().skip(1); + let name = args.next().ok_or(Error::MissingSubcommand)?; + (name, args) + }; + + if Root::command().get_subcommands().any(|c| { + let sc_name = c.get_name(); + sc_name.starts_with(&name) + || (name.len() >= MIN_LENGTH && strsim::jaro(sc_name, &name) >= SUBCOMMAND_TOLERANCE) + }) { + return Ok(()); + } + + let bin = which(format!("soroban-{name}")).map_err(|_| { + let suggestion = if let Ok(bins) = list() { + let suggested_name = bins + .iter() + .map(|b| (b, strsim::jaro_winkler(&name, b))) + .filter(|(_, i)| *i > PLUGIN_TOLERANCE) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(a, _)| a.to_string()) + .unwrap_or_default(); + if suggested_name.is_empty() { + suggested_name + } else { + format!( + r#"Did you mean `{suggested_name}`? + "# + ) + } + } else { + String::new() + }; + Error::ExecutableNotFound(name, suggestion) + })?; + std::process::exit( + Command::new(bin) + .args(args) + .spawn()? + .wait()? + .code() + .unwrap(), + ); +} + +const MAX_HEX_LENGTH: usize = 10; + +pub fn list() -> Result, Error> { + let re_str = if cfg!(target_os = "windows") { + r"^soroban-.*.exe$" + } else { + r"^soroban-.*" + }; + let re = regex::Regex::new(re_str)?; + Ok(which::which_re(re)? + .filter_map(|b| { + let s = b.file_name()?.to_str()?; + Some(s.strip_suffix(".exe").unwrap_or(s).to_string()) + }) + .filter(|s| !(utils::is_hex_string(s) && s.len() > MAX_HEX_LENGTH)) + .map(|s| s.replace("soroban-", "")) + .collect()) +} diff --git a/cmd/soroban-cli/src/commands/version.rs b/cmd/soroban-cli/src/commands/version.rs new file mode 100644 index 00000000..d9fc091b --- /dev/null +++ b/cmd/soroban-cli/src/commands/version.rs @@ -0,0 +1,32 @@ +use clap::Parser; +use soroban_env_host::meta; +use std::fmt::Debug; + +const GIT_REVISION: &str = env!("GIT_REVISION"); + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd; + +impl Cmd { + #[allow(clippy::unused_self)] + pub fn run(&self) { + println!("soroban {}", long()); + } +} + +pub fn long() -> String { + let env = soroban_env_host::VERSION; + let xdr = soroban_env_host::VERSION.xdr; + [ + format!("{} ({GIT_REVISION})", env!("CARGO_PKG_VERSION")), + format!("soroban-env {} ({})", env.pkg, env.rev), + format!("soroban-env interface version {}", meta::INTERFACE_VERSION), + format!( + "stellar-xdr {} ({}) +xdr curr ({})", + xdr.pkg, xdr.rev, xdr.xdr_curr, + ), + ] + .join("\n") +} diff --git a/cmd/soroban-cli/src/fee.rs b/cmd/soroban-cli/src/fee.rs new file mode 100644 index 00000000..ee8b9614 --- /dev/null +++ b/cmd/soroban-cli/src/fee.rs @@ -0,0 +1,16 @@ +use crate::commands::HEADING_RPC; +use clap::arg; + +#[derive(Debug, clap::Args, Clone)] +#[group(skip)] +pub struct Args { + /// fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm + #[arg(long, default_value = "100", env = "SOROBAN_FEE", help_heading = HEADING_RPC)] + pub fee: u32, +} + +impl Default for Args { + fn default() -> Self { + Self { fee: 100 } + } +} diff --git a/cmd/soroban-cli/src/key.rs b/cmd/soroban-cli/src/key.rs new file mode 100644 index 00000000..e9901abd --- /dev/null +++ b/cmd/soroban-cli/src/key.rs @@ -0,0 +1,110 @@ +use clap::arg; +use soroban_env_host::xdr::{ + self, LedgerKey, LedgerKeyContractCode, LedgerKeyContractData, Limits, ReadXdr, ScAddress, + ScVal, +}; +use std::path::PathBuf; + +use crate::{ + commands::contract::Durability, + utils::{self}, + wasm, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Spec(#[from] soroban_spec_tools::Error), + #[error(transparent)] + Xdr(#[from] xdr::Error), + #[error("cannot parse contract ID {0}: {1}")] + CannotParseContractId(String, stellar_strkey::DecodeError), + #[error(transparent)] + Wasm(#[from] wasm::Error), +} + +#[derive(Debug, clap::Args, Clone)] +#[group(skip)] +pub struct Args { + /// Contract ID to which owns the data entries. + /// If no keys provided the Contract's instance will be extended + #[arg( + long = "id", + required_unless_present = "wasm", + required_unless_present = "wasm_hash" + )] + pub contract_id: Option, + /// Storage key (symbols only) + #[arg(long = "key", conflicts_with = "key_xdr")] + pub key: Option>, + /// Storage key (base64-encoded XDR) + #[arg(long = "key-xdr", conflicts_with = "key")] + pub key_xdr: Option>, + /// Path to Wasm file of contract code to extend + #[arg( + long, + conflicts_with = "contract_id", + conflicts_with = "key", + conflicts_with = "key_xdr", + conflicts_with = "wasm_hash" + )] + pub wasm: Option, + /// Path to Wasm file of contract code to extend + #[arg( + long, + conflicts_with = "contract_id", + conflicts_with = "key", + conflicts_with = "key_xdr", + conflicts_with = "wasm" + )] + pub wasm_hash: Option, + /// Storage entry durability + #[arg(long, value_enum, required = true)] + pub durability: Durability, +} + +impl Args { + pub fn parse_keys(&self) -> Result, Error> { + let keys = if let Some(keys) = &self.key { + keys.iter() + .map(|key| { + Ok(soroban_spec_tools::from_string_primitive( + key, + &xdr::ScSpecTypeDef::Symbol, + )?) + }) + .collect::, Error>>()? + } else if let Some(keys) = &self.key_xdr { + keys.iter() + .map(|s| Ok(ScVal::from_xdr_base64(s, Limits::none())?)) + .collect::, Error>>()? + } else if let Some(wasm) = &self.wasm { + return Ok(vec![crate::wasm::Args { wasm: wasm.clone() }.try_into()?]); + } else if let Some(wasm_hash) = &self.wasm_hash { + return Ok(vec![LedgerKey::ContractCode(LedgerKeyContractCode { + hash: xdr::Hash( + utils::contract_id_from_str(wasm_hash) + .map_err(|e| Error::CannotParseContractId(wasm_hash.clone(), e))?, + ), + })]); + } else { + vec![ScVal::LedgerKeyContractInstance] + }; + let contract_id = contract_id(self.contract_id.as_ref().unwrap())?; + + Ok(keys + .into_iter() + .map(|key| { + LedgerKey::ContractData(LedgerKeyContractData { + contract: ScAddress::Contract(xdr::Hash(contract_id)), + durability: (&self.durability).into(), + key, + }) + }) + .collect()) + } +} + +fn contract_id(s: &str) -> Result<[u8; 32], Error> { + utils::contract_id_from_str(s).map_err(|e| Error::CannotParseContractId(s.to_string(), e)) +} diff --git a/cmd/soroban-cli/src/lib.rs b/cmd/soroban-cli/src/lib.rs new file mode 100644 index 00000000..3aad487c --- /dev/null +++ b/cmd/soroban-cli/src/lib.rs @@ -0,0 +1,53 @@ +#![allow( + clippy::missing_errors_doc, + clippy::must_use_candidate, + clippy::missing_panics_doc +)] +pub mod commands; +pub mod fee; +pub mod key; +pub mod log; +pub mod rpc; +pub mod toid; +pub mod utils; +pub mod wasm; + +use std::path::Path; + +pub use commands::Root; + +pub fn parse_cmd(s: &str) -> Result +where + T: clap::CommandFactory + clap::FromArgMatches, +{ + let input = shlex::split(s).ok_or_else(|| { + clap::Error::raw( + clap::error::ErrorKind::InvalidValue, + format!("Invalid input for command:\n{s}"), + ) + })?; + T::from_arg_matches_mut(&mut T::command().no_binary_name(true).get_matches_from(input)) +} + +pub trait CommandParser { + fn parse(s: &str) -> Result; + + fn parse_arg_vec(s: &[&str]) -> Result; +} + +impl CommandParser for T +where + T: clap::CommandFactory + clap::FromArgMatches, +{ + fn parse(s: &str) -> Result { + parse_cmd(s) + } + + fn parse_arg_vec(args: &[&str]) -> Result { + T::from_arg_matches_mut(&mut T::command().no_binary_name(true).get_matches_from(args)) + } +} + +pub trait Pwd { + fn set_pwd(&mut self, pwd: &Path); +} diff --git a/cmd/soroban-cli/src/log.rs b/cmd/soroban-cli/src/log.rs new file mode 100644 index 00000000..16121982 --- /dev/null +++ b/cmd/soroban-cli/src/log.rs @@ -0,0 +1,13 @@ +pub mod auth; +pub mod budget; +pub mod cost; +pub mod diagnostic_event; +pub mod footprint; +pub mod host_event; + +pub use auth::*; +pub use budget::*; +pub use cost::*; +pub use diagnostic_event::*; +pub use footprint::*; +pub use host_event::*; diff --git a/cmd/soroban-cli/src/log/auth.rs b/cmd/soroban-cli/src/log/auth.rs new file mode 100644 index 00000000..c37e7ed3 --- /dev/null +++ b/cmd/soroban-cli/src/log/auth.rs @@ -0,0 +1,7 @@ +use soroban_env_host::xdr::{SorobanAuthorizationEntry, VecM}; + +pub fn auth(auth: &[VecM]) { + if !auth.is_empty() { + tracing::debug!("{auth:#?}"); + } +} diff --git a/cmd/soroban-cli/src/log/budget.rs b/cmd/soroban-cli/src/log/budget.rs new file mode 100644 index 00000000..59ff4aad --- /dev/null +++ b/cmd/soroban-cli/src/log/budget.rs @@ -0,0 +1,5 @@ +use soroban_env_host::budget::Budget; + +pub fn budget(budget: &Budget) { + tracing::debug!("{budget:#?}"); +} diff --git a/cmd/soroban-cli/src/log/cost.rs b/cmd/soroban-cli/src/log/cost.rs new file mode 100644 index 00000000..3e049a6c --- /dev/null +++ b/cmd/soroban-cli/src/log/cost.rs @@ -0,0 +1,27 @@ +use soroban_env_host::xdr::SorobanResources; +use std::fmt::{Debug, Display}; + +struct Cost<'a>(&'a SorobanResources); + +impl Debug for Cost<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // TODO: Should we output the footprint here? + writeln!(f, "==================== Cost ====================")?; + writeln!(f, "CPU used: {}", self.0.instructions,)?; + writeln!(f, "Bytes read: {}", self.0.read_bytes,)?; + writeln!(f, "Bytes written: {}", self.0.write_bytes,)?; + writeln!(f, "==============================================")?; + Ok(()) + } +} + +impl Display for Cost<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self, f) + } +} + +pub fn cost(resources: &SorobanResources) { + let cost = Cost(resources); + tracing::debug!(?cost); +} diff --git a/cmd/soroban-cli/src/log/diagnostic_event.rs b/cmd/soroban-cli/src/log/diagnostic_event.rs new file mode 100644 index 00000000..68af67a4 --- /dev/null +++ b/cmd/soroban-cli/src/log/diagnostic_event.rs @@ -0,0 +1,11 @@ +pub fn diagnostic_events(events: &[impl std::fmt::Debug], level: tracing::Level) { + for (i, event) in events.iter().enumerate() { + if level == tracing::Level::TRACE { + tracing::trace!("{i}: {event:#?}"); + } else if level == tracing::Level::INFO { + tracing::info!("{i}: {event:#?}"); + } else if level == tracing::Level::ERROR { + tracing::error!("{i}: {event:#?}"); + } + } +} diff --git a/cmd/soroban-cli/src/log/footprint.rs b/cmd/soroban-cli/src/log/footprint.rs new file mode 100644 index 00000000..bfbc9f7a --- /dev/null +++ b/cmd/soroban-cli/src/log/footprint.rs @@ -0,0 +1,5 @@ +use soroban_env_host::xdr::LedgerFootprint; + +pub fn footprint(footprint: &LedgerFootprint) { + tracing::debug!("{footprint:#?}"); +} diff --git a/cmd/soroban-cli/src/log/host_event.rs b/cmd/soroban-cli/src/log/host_event.rs new file mode 100644 index 00000000..4238a74c --- /dev/null +++ b/cmd/soroban-cli/src/log/host_event.rs @@ -0,0 +1,7 @@ +use soroban_env_host::events::HostEvent; + +pub fn host_events(events: &[HostEvent]) { + for (i, event) in events.iter().enumerate() { + tracing::info!("{i}: {event:#?}"); + } +} diff --git a/cmd/soroban-cli/src/rpc/fixtures/event_response.json b/cmd/soroban-cli/src/rpc/fixtures/event_response.json new file mode 100644 index 00000000..6f520fdf --- /dev/null +++ b/cmd/soroban-cli/src/rpc/fixtures/event_response.json @@ -0,0 +1,39 @@ +{ + "events": [{ + "eventType": "system", + "ledger": "43601283", + "ledgerClosedAt": "2022-11-16T16:10:41Z", + "contractId": "CDR6QKTWZQYW6YUJ7UP7XXZRLWQPFRV6SWBLQS4ZQOSAF4BOUD77OO5Z", + "id": "0164090849041387521-0000000003", + "pagingToken": "164090849041387521-3", + "topic": [ + "AAAABQAAAAh0cmFuc2Zlcg==", + "AAAAAQB6Mcc=" + ], + "value": "AAAABQAAAApHaWJNb255UGxzAAA=" + }, { + "eventType": "contract", + "ledger": "43601284", + "ledgerClosedAt": "2022-11-16T16:10:41Z", + "contractId": "CDR6QKTWZQYW6YUJ7UP7XXZRLWQPFRV6SWBLQS4ZQOSAF4BOUD77OO5Z", + "id": "0164090849041387521-0000000003", + "pagingToken": "164090849041387521-3", + "topic": [ + "AAAABQAAAAh0cmFuc2Zlcg==", + "AAAAAQB6Mcc=" + ], + "value": "AAAABQAAAApHaWJNb255UGxzAAA=" + }, { + "eventType": "system", + "ledger": "43601285", + "ledgerClosedAt": "2022-11-16T16:10:41Z", + "contractId": "CCR6QKTWZQYW6YUJ7UP7XXZRLWQPFRV6SWBLQS4ZQOSAF4BOUD77OTE2", + "id": "0164090849041387521-0000000003", + "pagingToken": "164090849041387521-3", + "topic": [ + "AAAABQAAAAh0cmFuc2Zlcg==", + "AAAAAQB6Mcc=" + ], + "value": "AAAABQAAAApHaWJNb255UGxzAAA=" + }] +} \ No newline at end of file diff --git a/cmd/soroban-cli/src/rpc/mod.rs b/cmd/soroban-cli/src/rpc/mod.rs new file mode 100644 index 00000000..53542629 --- /dev/null +++ b/cmd/soroban-cli/src/rpc/mod.rs @@ -0,0 +1,1141 @@ +use http::{uri::Authority, Uri}; +use itertools::Itertools; +use jsonrpsee_core::params::ObjectParams; +use jsonrpsee_core::{self, client::ClientT, rpc_params}; +use jsonrpsee_http_client::{HeaderMap, HttpClient, HttpClientBuilder}; +use serde_aux::prelude::{ + deserialize_default_from_null, deserialize_number_from_string, + deserialize_option_number_from_string, +}; +use soroban_env_host::xdr::{ + self, AccountEntry, AccountId, ContractDataEntry, DiagnosticEvent, Error as XdrError, + LedgerEntryData, LedgerFootprint, LedgerKey, LedgerKeyAccount, Limited, PublicKey, ReadXdr, + SorobanAuthorizationEntry, SorobanResources, SorobanTransactionData, Transaction, + TransactionEnvelope, TransactionMeta, TransactionMetaV3, TransactionResult, Uint256, VecM, + WriteXdr, +}; +use soroban_sdk::token; +use soroban_sdk::xdr::Limits; +use std::{ + fmt::Display, + str::FromStr, + time::{Duration, Instant}, +}; +use termcolor::{Color, ColorChoice, StandardStream, WriteColor}; +use termcolor_output::colored; +use tokio::time::sleep; + +use crate::utils::contract_spec; + +mod txn; + +const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION"); + +pub type LogEvents = fn( + footprint: &LedgerFootprint, + auth: &[VecM], + events: &[DiagnosticEvent], +) -> (); + +pub type LogResources = fn(resources: &SorobanResources) -> (); + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + InvalidAddress(#[from] stellar_strkey::DecodeError), + #[error("invalid response from server")] + InvalidResponse, + #[error("provided network passphrase {expected:?} does not match the server: {server:?}")] + InvalidNetworkPassphrase { expected: String, server: String }, + #[error("xdr processing error: {0}")] + Xdr(#[from] XdrError), + #[error("invalid rpc url: {0}")] + InvalidRpcUrl(http::uri::InvalidUri), + #[error("invalid rpc url: {0}")] + InvalidRpcUrlFromUriParts(http::uri::InvalidUriParts), + #[error("invalid friendbot url: {0}")] + InvalidUrl(String), + #[error("jsonrpc error: {0}")] + JsonRpc(#[from] jsonrpsee_core::Error), + #[error("json decoding error: {0}")] + Serde(#[from] serde_json::Error), + #[error("transaction failed: {0}")] + TransactionFailed(String), + #[error("transaction submission failed: {0}")] + TransactionSubmissionFailed(String), + #[error("expected transaction status: {0}")] + UnexpectedTransactionStatus(String), + #[error("transaction submission timeout")] + TransactionSubmissionTimeout, + #[error("transaction simulation failed: {0}")] + TransactionSimulationFailed(String), + #[error("{0} not found: {1}")] + NotFound(String, String), + #[error("Missing result in successful response")] + MissingResult, + #[error("Failed to read Error response from server")] + MissingError, + #[error("Missing signing key for account {address}")] + MissingSignerForAddress { address: String }, + #[error("cursor is not valid")] + InvalidCursor, + #[error("unexpected ({length}) simulate transaction result length")] + UnexpectedSimulateTransactionResultSize { length: usize }, + #[error("unexpected ({count}) number of operations")] + UnexpectedOperationCount { count: usize }, + #[error("Transaction contains unsupported operation type")] + UnsupportedOperationType, + #[error("unexpected contract code data type: {0:?}")] + UnexpectedContractCodeDataType(LedgerEntryData), + #[error(transparent)] + CouldNotParseContractSpec(#[from] contract_spec::Error), + #[error("unexpected contract code got token")] + UnexpectedToken(ContractDataEntry), + #[error(transparent)] + Spec(#[from] soroban_spec::read::FromWasmError), + #[error(transparent)] + SpecBase64(#[from] soroban_spec::read::ParseSpecBase64Error), + #[error("Fee was too large {0}")] + LargeFee(u64), + #[error("Cannot authorize raw transactions")] + CannotAuthorizeRawTransaction, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct SendTransactionResponse { + pub hash: String, + pub status: String, + #[serde( + rename = "errorResultXdr", + skip_serializing_if = "Option::is_none", + default + )] + pub error_result_xdr: Option, + #[serde(rename = "latestLedger")] + pub latest_ledger: u32, + #[serde( + rename = "latestLedgerCloseTime", + deserialize_with = "deserialize_number_from_string" + )] + pub latest_ledger_close_time: u32, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct GetTransactionResponseRaw { + pub status: String, + #[serde( + rename = "envelopeXdr", + skip_serializing_if = "Option::is_none", + default + )] + pub envelope_xdr: Option, + #[serde(rename = "resultXdr", skip_serializing_if = "Option::is_none", default)] + pub result_xdr: Option, + #[serde( + rename = "resultMetaXdr", + skip_serializing_if = "Option::is_none", + default + )] + pub result_meta_xdr: Option, + // TODO: add ledger info and application order +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct GetTransactionResponse { + pub status: String, + pub envelope: Option, + pub result: Option, + pub result_meta: Option, +} + +impl TryInto for GetTransactionResponseRaw { + type Error = xdr::Error; + + fn try_into(self) -> Result { + Ok(GetTransactionResponse { + status: self.status, + envelope: self + .envelope_xdr + .map(|v| ReadXdr::from_xdr_base64(v, Limits::none())) + .transpose()?, + result: self + .result_xdr + .map(|v| ReadXdr::from_xdr_base64(v, Limits::none())) + .transpose()?, + result_meta: self + .result_meta_xdr + .map(|v| ReadXdr::from_xdr_base64(v, Limits::none())) + .transpose()?, + }) + } +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct LedgerEntryResult { + pub key: String, + pub xdr: String, + #[serde(rename = "lastModifiedLedgerSeq")] + pub last_modified_ledger: u32, + #[serde( + rename = "liveUntilLedgerSeq", + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_option_number_from_string", + default + )] + pub live_until_ledger_seq_ledger_seq: Option, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct GetLedgerEntriesResponse { + pub entries: Option>, + #[serde(rename = "latestLedger")] + pub latest_ledger: i64, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct GetNetworkResponse { + #[serde( + rename = "friendbotUrl", + skip_serializing_if = "Option::is_none", + default + )] + pub friendbot_url: Option, + pub passphrase: String, + #[serde(rename = "protocolVersion")] + pub protocol_version: u32, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct GetLatestLedgerResponse { + pub id: String, + #[serde(rename = "protocolVersion")] + pub protocol_version: u32, + pub sequence: u32, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Default)] +pub struct Cost { + #[serde( + rename = "cpuInsns", + deserialize_with = "deserialize_number_from_string" + )] + pub cpu_insns: u64, + #[serde( + rename = "memBytes", + deserialize_with = "deserialize_number_from_string" + )] + pub mem_bytes: u64, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct SimulateHostFunctionResultRaw { + #[serde(deserialize_with = "deserialize_default_from_null")] + pub auth: Vec, + pub xdr: String, +} + +#[derive(Debug)] +pub struct SimulateHostFunctionResult { + pub auth: Vec, + pub xdr: xdr::ScVal, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Default)] +pub struct SimulateTransactionResponse { + #[serde( + rename = "minResourceFee", + deserialize_with = "deserialize_number_from_string", + default + )] + pub min_resource_fee: u64, + #[serde(default)] + pub cost: Cost, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub results: Vec, + #[serde(rename = "transactionData", default)] + pub transaction_data: String, + #[serde( + deserialize_with = "deserialize_default_from_null", + skip_serializing_if = "Vec::is_empty", + default + )] + pub events: Vec, + #[serde( + rename = "restorePreamble", + skip_serializing_if = "Option::is_none", + default + )] + pub restore_preamble: Option, + #[serde(rename = "latestLedger")] + pub latest_ledger: u32, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub error: Option, +} + +impl SimulateTransactionResponse { + pub fn results(&self) -> Result, Error> { + self.results + .iter() + .map(|r| { + Ok(SimulateHostFunctionResult { + auth: r + .auth + .iter() + .map(|a| { + Ok(SorobanAuthorizationEntry::from_xdr_base64( + a, + Limits::none(), + )?) + }) + .collect::>()?, + xdr: xdr::ScVal::from_xdr_base64(&r.xdr, Limits::none())?, + }) + }) + .collect() + } + + pub fn events(&self) -> Result, Error> { + self.events + .iter() + .map(|e| Ok(DiagnosticEvent::from_xdr_base64(e, Limits::none())?)) + .collect() + } + + pub fn transaction_data(&self) -> Result { + Ok(SorobanTransactionData::from_xdr_base64( + &self.transaction_data, + Limits::none(), + )?) + } +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Default)] +pub struct RestorePreamble { + #[serde(rename = "transactionData")] + pub transaction_data: String, + #[serde( + rename = "minResourceFee", + deserialize_with = "deserialize_number_from_string" + )] + pub min_resource_fee: u64, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct GetEventsResponse { + #[serde(deserialize_with = "deserialize_default_from_null")] + pub events: Vec, + #[serde(rename = "latestLedger")] + pub latest_ledger: u32, +} + +// Determines whether or not a particular filter matches a topic based on the +// same semantics as the RPC server: +// +// - for an exact segment match, the filter is a base64-encoded ScVal +// - for a wildcard, single-segment match, the string "*" matches exactly one +// segment +// +// The expectation is that a `filter` is a comma-separated list of segments that +// has previously been validated, and `topic` is the list of segments applicable +// for this event. +// +// [API +// Reference](https://docs.google.com/document/d/1TZUDgo_3zPz7TiPMMHVW_mtogjLyPL0plvzGMsxSz6A/edit#bookmark=id.35t97rnag3tx) +// [Code +// Reference](https://github.com/stellar/soroban-tools/blob/bac1be79e8c2590c9c35ad8a0168aab0ae2b4171/cmd/soroban-rpc/internal/methods/get_events.go#L182-L203) +pub fn does_topic_match(topic: &[String], filter: &[String]) -> bool { + filter.len() == topic.len() + && filter + .iter() + .enumerate() + .all(|(i, s)| *s == "*" || topic[i] == *s) +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct Event { + #[serde(rename = "type")] + pub event_type: String, + + pub ledger: u32, + #[serde(rename = "ledgerClosedAt")] + pub ledger_closed_at: String, + + pub id: String, + #[serde(rename = "pagingToken")] + pub paging_token: String, + + #[serde(rename = "contractId")] + pub contract_id: String, + pub topic: Vec, + pub value: String, +} + +impl Display for Event { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "Event {} [{}]:", + self.paging_token, + self.event_type.to_ascii_uppercase() + )?; + writeln!( + f, + " Ledger: {} (closed at {})", + self.ledger, self.ledger_closed_at + )?; + writeln!(f, " Contract: {}", self.contract_id)?; + writeln!(f, " Topics:")?; + for topic in &self.topic { + let scval = + xdr::ScVal::from_xdr_base64(topic, Limits::none()).map_err(|_| std::fmt::Error)?; + writeln!(f, " {scval:?}")?; + } + let scval = xdr::ScVal::from_xdr_base64(&self.value, Limits::none()) + .map_err(|_| std::fmt::Error)?; + writeln!(f, " Value: {scval:?}") + } +} + +impl Event { + pub fn parse_cursor(&self) -> Result<(u64, i32), Error> { + parse_cursor(&self.id) + } + + pub fn pretty_print(&self) -> Result<(), Box> { + let mut stdout = StandardStream::stdout(ColorChoice::Auto); + if !stdout.supports_color() { + println!("{self}"); + return Ok(()); + } + + let color = match self.event_type.as_str() { + "system" => Color::Yellow, + _ => Color::Blue, + }; + colored!( + stdout, + "{}Event{} {}{}{} [{}{}{}{}]:\n", + bold!(true), + bold!(false), + fg!(Some(Color::Green)), + self.paging_token, + reset!(), + bold!(true), + fg!(Some(color)), + self.event_type.to_ascii_uppercase(), + reset!(), + )?; + + colored!( + stdout, + " Ledger: {}{}{} (closed at {}{}{})\n", + fg!(Some(Color::Green)), + self.ledger, + reset!(), + fg!(Some(Color::Green)), + self.ledger_closed_at, + reset!(), + )?; + + colored!( + stdout, + " Contract: {}{}{}\n", + fg!(Some(Color::Green)), + self.contract_id, + reset!(), + )?; + + colored!(stdout, " Topics:\n")?; + for topic in &self.topic { + let scval = xdr::ScVal::from_xdr_base64(topic, Limits::none())?; + colored!( + stdout, + " {}{:?}{}\n", + fg!(Some(Color::Green)), + scval, + reset!(), + )?; + } + + let scval = xdr::ScVal::from_xdr_base64(&self.value, Limits::none())?; + colored!( + stdout, + " Value: {}{:?}{}\n", + fg!(Some(Color::Green)), + scval, + reset!(), + )?; + + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)] +pub enum EventType { + All, + Contract, + System, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum EventStart { + Ledger(u32), + Cursor(String), +} + +#[derive(Debug)] +pub struct FullLedgerEntry { + pub key: LedgerKey, + pub val: LedgerEntryData, + pub last_modified_ledger: u32, + pub live_until_ledger_seq: u32, +} + +#[derive(Debug)] +pub struct FullLedgerEntries { + pub entries: Vec, + pub latest_ledger: i64, +} + +pub struct Client { + base_url: String, +} + +impl Client { + pub fn new(base_url: &str) -> Result { + // Add the port to the base URL if there is no port explicitly included + // in the URL and the scheme allows us to infer a default port. + // Jsonrpsee requires a port to always be present even if one can be + // inferred. This may change: https://github.com/paritytech/jsonrpsee/issues/1048. + let uri = base_url.parse::().map_err(Error::InvalidRpcUrl)?; + let mut parts = uri.into_parts(); + if let (Some(scheme), Some(authority)) = (&parts.scheme, &parts.authority) { + if authority.port().is_none() { + let port = match scheme.as_str() { + "http" => Some(80), + "https" => Some(443), + _ => None, + }; + if let Some(port) = port { + let host = authority.host(); + parts.authority = Some( + Authority::from_str(&format!("{host}:{port}")) + .map_err(Error::InvalidRpcUrl)?, + ); + } + } + } + let uri = Uri::from_parts(parts).map_err(Error::InvalidRpcUrlFromUriParts)?; + tracing::trace!(?uri); + Ok(Self { + base_url: uri.to_string(), + }) + } + + fn client(&self) -> Result { + let url = self.base_url.clone(); + let mut headers = HeaderMap::new(); + headers.insert("X-Client-Name", "soroban-cli".parse().unwrap()); + let version = VERSION.unwrap_or("devel"); + headers.insert("X-Client-Version", version.parse().unwrap()); + Ok(HttpClientBuilder::default() + .set_headers(headers) + .build(url)?) + } + + pub async fn friendbot_url(&self) -> Result { + let network = self.get_network().await?; + tracing::trace!("{network:#?}"); + network.friendbot_url.ok_or_else(|| { + Error::NotFound( + "Friendbot".to_string(), + "Friendbot is not available on this network".to_string(), + ) + }) + } + + pub async fn verify_network_passphrase(&self, expected: Option<&str>) -> Result { + let server = self.get_network().await?.passphrase; + if let Some(expected) = expected { + if expected != server { + return Err(Error::InvalidNetworkPassphrase { + expected: expected.to_string(), + server, + }); + } + } + Ok(server) + } + + pub async fn get_network(&self) -> Result { + tracing::trace!("Getting network"); + Ok(self.client()?.request("getNetwork", rpc_params![]).await?) + } + + pub async fn get_latest_ledger(&self) -> Result { + tracing::trace!("Getting latest ledger"); + Ok(self + .client()? + .request("getLatestLedger", rpc_params![]) + .await?) + } + + pub async fn get_account(&self, address: &str) -> Result { + tracing::trace!("Getting address {}", address); + let key = LedgerKey::Account(LedgerKeyAccount { + account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256( + stellar_strkey::ed25519::PublicKey::from_string(address)?.0, + ))), + }); + let keys = Vec::from([key]); + let response = self.get_ledger_entries(&keys).await?; + let entries = response.entries.unwrap_or_default(); + if entries.is_empty() { + return Err(Error::NotFound( + "Account".to_string(), + format!( + r#"{address} +Might need to fund account like: +soroban config identity fund {address} --network +soroban config identity fund {address} --helper-url "# + ), + )); + } + let ledger_entry = &entries[0]; + let mut read = Limited::new(ledger_entry.xdr.as_bytes(), Limits::none()); + if let LedgerEntryData::Account(entry) = LedgerEntryData::read_xdr_base64(&mut read)? { + tracing::trace!(account=?entry); + Ok(entry) + } else { + Err(Error::InvalidResponse) + } + } + + pub async fn send_transaction( + &self, + tx: &TransactionEnvelope, + ) -> Result<(TransactionResult, TransactionMeta, Vec), Error> { + let client = self.client()?; + tracing::trace!("Sending:\n{tx:#?}"); + let SendTransactionResponse { + hash, + error_result_xdr, + status, + .. + } = client + .request( + "sendTransaction", + rpc_params![tx.to_xdr_base64(Limits::none())?], + ) + .await + .map_err(|err| { + Error::TransactionSubmissionFailed(format!("No status yet:\n {err:#?}")) + })?; + + if status == "ERROR" { + let error = error_result_xdr + .ok_or(Error::MissingError) + .and_then(|x| { + TransactionResult::read_xdr_base64(&mut Limited::new( + x.as_bytes(), + Limits::none(), + )) + .map_err(|_| Error::InvalidResponse) + }) + .map(|r| r.result); + tracing::error!("TXN failed:\n {error:#?}"); + return Err(Error::TransactionSubmissionFailed(format!("{:#?}", error?))); + } + // even if status == "success" we need to query the transaction status in order to get the result + + // Poll the transaction status + let start = Instant::now(); + loop { + let response: GetTransactionResponse = self.get_transaction(&hash).await?.try_into()?; + match response.status.as_str() { + "SUCCESS" => { + // TODO: the caller should probably be printing this + tracing::trace!("{response:#?}"); + let GetTransactionResponse { + result, + result_meta, + .. + } = response; + let meta = result_meta.ok_or(Error::MissingResult)?; + let events = extract_events(&meta); + return Ok((result.ok_or(Error::MissingResult)?, meta, events)); + } + "FAILED" => { + tracing::error!("{response:#?}"); + // TODO: provide a more elaborate error + return Err(Error::TransactionSubmissionFailed(format!( + "{:#?}", + response.result + ))); + } + "NOT_FOUND" => (), + _ => { + return Err(Error::UnexpectedTransactionStatus(response.status)); + } + }; + let duration = start.elapsed(); + // TODO: parameterize the timeout instead of using a magic constant + if duration.as_secs() > 10 { + return Err(Error::TransactionSubmissionTimeout); + } + sleep(Duration::from_secs(1)).await; + } + } + + pub async fn simulate_transaction( + &self, + tx: &TransactionEnvelope, + ) -> Result { + tracing::trace!("Simulating:\n{tx:#?}"); + let base64_tx = tx.to_xdr_base64(Limits::none())?; + let mut builder = ObjectParams::new(); + builder.insert("transaction", base64_tx)?; + let response: SimulateTransactionResponse = self + .client()? + .request("simulateTransaction", builder) + .await?; + tracing::trace!("Simulation response:\n {response:#?}"); + match response.error { + None => Ok(response), + Some(e) => { + crate::log::diagnostic_events(&response.events, tracing::Level::ERROR); + Err(Error::TransactionSimulationFailed(e)) + } + } + } + + pub async fn prepare_and_send_transaction( + &self, + tx_without_preflight: &Transaction, + source_key: &ed25519_dalek::SigningKey, + signers: &[ed25519_dalek::SigningKey], + network_passphrase: &str, + log_events: Option, + log_resources: Option, + ) -> Result<(TransactionResult, TransactionMeta, Vec), Error> { + let txn = txn::Assembled::new(tx_without_preflight, self).await?; + let seq_num = txn.sim_res().latest_ledger + 60; //5 min; + let authorized = txn + .handle_restore(self, source_key, network_passphrase) + .await? + .authorize(self, source_key, signers, seq_num, network_passphrase) + .await?; + authorized.log(log_events, log_resources)?; + let tx = authorized.sign(source_key, network_passphrase)?; + self.send_transaction(&tx).await + } + + pub async fn get_transaction(&self, tx_id: &str) -> Result { + Ok(self + .client()? + .request("getTransaction", rpc_params![tx_id]) + .await?) + } + + pub async fn get_ledger_entries( + &self, + keys: &[LedgerKey], + ) -> Result { + let mut base64_keys: Vec = vec![]; + for k in keys { + let base64_result = k.to_xdr_base64(Limits::none()); + if base64_result.is_err() { + return Err(Error::Xdr(XdrError::Invalid)); + } + base64_keys.push(k.to_xdr_base64(Limits::none()).unwrap()); + } + Ok(self + .client()? + .request("getLedgerEntries", rpc_params![base64_keys]) + .await?) + } + + pub async fn get_full_ledger_entries( + &self, + ledger_keys: &[LedgerKey], + ) -> Result { + let keys = ledger_keys + .iter() + .filter(|key| !matches!(key, LedgerKey::Ttl(_))) + .map(Clone::clone) + .collect::>(); + tracing::trace!("keys: {keys:#?}"); + let GetLedgerEntriesResponse { + entries, + latest_ledger, + } = self.get_ledger_entries(&keys).await?; + tracing::trace!("raw: {entries:#?}"); + let entries = entries + .unwrap_or_default() + .iter() + .map( + |LedgerEntryResult { + key, + xdr, + last_modified_ledger, + live_until_ledger_seq_ledger_seq, + }| { + Ok(FullLedgerEntry { + key: LedgerKey::from_xdr_base64(key, Limits::none())?, + val: LedgerEntryData::from_xdr_base64(xdr, Limits::none())?, + live_until_ledger_seq: live_until_ledger_seq_ledger_seq.unwrap_or_default(), + last_modified_ledger: *last_modified_ledger, + }) + }, + ) + .collect::, Error>>()?; + tracing::trace!("parsed: {entries:#?}"); + Ok(FullLedgerEntries { + entries, + latest_ledger, + }) + } + + pub async fn get_events( + &self, + start: EventStart, + event_type: Option, + contract_ids: &[String], + topics: &[String], + limit: Option, + ) -> Result { + let mut filters = serde_json::Map::new(); + + event_type + .and_then(|t| match t { + EventType::All => None, // all is the default, so avoid incl. the param + EventType::Contract => Some("contract"), + EventType::System => Some("system"), + }) + .map(|t| filters.insert("type".to_string(), t.into())); + + filters.insert("topics".to_string(), topics.into()); + filters.insert("contractIds".to_string(), contract_ids.into()); + + let mut pagination = serde_json::Map::new(); + if let Some(limit) = limit { + pagination.insert("limit".to_string(), limit.into()); + } + + let mut oparams = ObjectParams::new(); + match start { + EventStart::Ledger(l) => oparams.insert("startLedger", l)?, + EventStart::Cursor(c) => { + pagination.insert("cursor".to_string(), c.into()); + } + }; + oparams.insert("filters", vec![filters])?; + oparams.insert("pagination", pagination)?; + + Ok(self.client()?.request("getEvents", oparams).await?) + } + + pub async fn get_contract_data( + &self, + contract_id: &[u8; 32], + ) -> Result { + // Get the contract from the network + let contract_key = LedgerKey::ContractData(xdr::LedgerKeyContractData { + contract: xdr::ScAddress::Contract(xdr::Hash(*contract_id)), + key: xdr::ScVal::LedgerKeyContractInstance, + durability: xdr::ContractDataDurability::Persistent, + }); + let contract_ref = self.get_ledger_entries(&[contract_key]).await?; + let entries = contract_ref.entries.unwrap_or_default(); + if entries.is_empty() { + let contract_address = stellar_strkey::Contract(*contract_id).to_string(); + return Err(Error::NotFound("Contract".to_string(), contract_address)); + } + let contract_ref_entry = &entries[0]; + match LedgerEntryData::from_xdr_base64(&contract_ref_entry.xdr, Limits::none())? { + LedgerEntryData::ContractData(contract_data) => Ok(contract_data), + scval => Err(Error::UnexpectedContractCodeDataType(scval)), + } + } + + pub async fn get_remote_wasm(&self, contract_id: &[u8; 32]) -> Result, Error> { + match self.get_contract_data(contract_id).await? { + xdr::ContractDataEntry { + val: + xdr::ScVal::ContractInstance(xdr::ScContractInstance { + executable: xdr::ContractExecutable::Wasm(hash), + .. + }), + .. + } => self.get_remote_wasm_from_hash(hash).await, + scval => Err(Error::UnexpectedToken(scval)), + } + } + + pub async fn get_remote_wasm_from_hash(&self, hash: xdr::Hash) -> Result, Error> { + let code_key = LedgerKey::ContractCode(xdr::LedgerKeyContractCode { hash: hash.clone() }); + let contract_data = self.get_ledger_entries(&[code_key]).await?; + let entries = contract_data.entries.unwrap_or_default(); + if entries.is_empty() { + return Err(Error::NotFound( + "Contract Code".to_string(), + hex::encode(hash), + )); + } + let contract_data_entry = &entries[0]; + match LedgerEntryData::from_xdr_base64(&contract_data_entry.xdr, Limits::none())? { + LedgerEntryData::ContractCode(xdr::ContractCodeEntry { code, .. }) => Ok(code.into()), + scval => Err(Error::UnexpectedContractCodeDataType(scval)), + } + } + + pub async fn get_remote_contract_spec( + &self, + contract_id: &[u8; 32], + ) -> Result, Error> { + let contract_data = self.get_contract_data(contract_id).await?; + match contract_data.val { + xdr::ScVal::ContractInstance(xdr::ScContractInstance { + executable: xdr::ContractExecutable::Wasm(hash), + .. + }) => Ok(contract_spec::ContractSpec::new( + &self.get_remote_wasm_from_hash(hash).await?, + ) + .map_err(Error::CouldNotParseContractSpec)? + .spec), + xdr::ScVal::ContractInstance(xdr::ScContractInstance { + executable: xdr::ContractExecutable::StellarAsset, + .. + }) => Ok(soroban_spec::read::parse_raw( + &token::StellarAssetSpec::spec_xdr(), + )?), + _ => Err(Error::Xdr(XdrError::Invalid)), + } + } +} + +fn extract_events(tx_meta: &TransactionMeta) -> Vec { + match tx_meta { + TransactionMeta::V3(TransactionMetaV3 { + soroban_meta: Some(meta), + .. + }) => { + // NOTE: we assume there can only be one operation, since we only send one + if meta.diagnostic_events.len() == 1 { + meta.diagnostic_events.clone().into() + } else if meta.events.len() == 1 { + meta.events + .iter() + .map(|e| DiagnosticEvent { + in_successful_contract_call: true, + event: e.clone(), + }) + .collect() + } else { + Vec::new() + } + } + _ => Vec::new(), + } +} + +pub fn parse_cursor(c: &str) -> Result<(u64, i32), Error> { + let (toid_part, event_index) = c.split('-').collect_tuple().ok_or(Error::InvalidCursor)?; + let toid_part: u64 = toid_part.parse().map_err(|_| Error::InvalidCursor)?; + let start_index: i32 = event_index.parse().map_err(|_| Error::InvalidCursor)?; + Ok((toid_part, start_index)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simulation_transaction_response_parsing() { + let s = r#"{ + "minResourceFee": "100000000", + "cost": { "cpuInsns": "1000", "memBytes": "1000" }, + "transactionData": "", + "latestLedger": 1234 + }"#; + + let resp: SimulateTransactionResponse = serde_json::from_str(s).unwrap(); + assert_eq!(resp.min_resource_fee, 100_000_000); + } + + #[test] + fn simulation_transaction_response_parsing_mostly_empty() { + let s = r#"{ + "latestLedger": 1234 + }"#; + + let resp: SimulateTransactionResponse = serde_json::from_str(s).unwrap(); + assert_eq!(resp.latest_ledger, 1_234); + } + + #[test] + fn test_rpc_url_default_ports() { + // Default ports are added. + let client = Client::new("http://example.com").unwrap(); + assert_eq!(client.base_url, "http://example.com:80/"); + let client = Client::new("https://example.com").unwrap(); + assert_eq!(client.base_url, "https://example.com:443/"); + + // Ports are not added when already present. + let client = Client::new("http://example.com:8080").unwrap(); + assert_eq!(client.base_url, "http://example.com:8080/"); + let client = Client::new("https://example.com:8080").unwrap(); + assert_eq!(client.base_url, "https://example.com:8080/"); + + // Paths are not modified. + let client = Client::new("http://example.com/a/b/c").unwrap(); + assert_eq!(client.base_url, "http://example.com:80/a/b/c"); + let client = Client::new("https://example.com/a/b/c").unwrap(); + assert_eq!(client.base_url, "https://example.com:443/a/b/c"); + let client = Client::new("http://example.com/a/b/c/").unwrap(); + assert_eq!(client.base_url, "http://example.com:80/a/b/c/"); + let client = Client::new("https://example.com/a/b/c/").unwrap(); + assert_eq!(client.base_url, "https://example.com:443/a/b/c/"); + let client = Client::new("http://example.com/a/b:80/c/").unwrap(); + assert_eq!(client.base_url, "http://example.com:80/a/b:80/c/"); + let client = Client::new("https://example.com/a/b:80/c/").unwrap(); + assert_eq!(client.base_url, "https://example.com:443/a/b:80/c/"); + } + + #[test] + // Taken from [RPC server + // tests](https://github.com/stellar/soroban-tools/blob/main/cmd/soroban-rpc/internal/methods/get_events_test.go#L21). + fn test_does_topic_match() { + struct TestCase<'a> { + name: &'a str, + filter: Vec<&'a str>, + includes: Vec>, + excludes: Vec>, + } + + let xfer = "AAAABQAAAAh0cmFuc2Zlcg=="; + let number = "AAAAAQB6Mcc="; + let star = "*"; + + for tc in vec![ + // No filter means match nothing. + TestCase { + name: "", + filter: vec![], + includes: vec![], + excludes: vec![vec![xfer]], + }, + // "*" should match "transfer/" but not "transfer/transfer" or + // "transfer/amount", because * is specified as a SINGLE segment + // wildcard. + TestCase { + name: "*", + filter: vec![star], + includes: vec![vec![xfer]], + excludes: vec![vec![xfer, xfer], vec![xfer, number]], + }, + // "*/transfer" should match anything preceding "transfer", but + // nothing that isn't exactly two segments long. + TestCase { + name: "*/transfer", + filter: vec![star, xfer], + includes: vec![vec![number, xfer], vec![xfer, xfer]], + excludes: vec![ + vec![number], + vec![number, number], + vec![number, xfer, number], + vec![xfer], + vec![xfer, number], + vec![xfer, xfer, xfer], + ], + }, + // The inverse case of before: "transfer/*" should match any single + // segment after a segment that is exactly "transfer", but no + // additional segments. + TestCase { + name: "transfer/*", + filter: vec![xfer, star], + includes: vec![vec![xfer, number], vec![xfer, xfer]], + excludes: vec![ + vec![number], + vec![number, number], + vec![number, xfer, number], + vec![xfer], + vec![number, xfer], + vec![xfer, xfer, xfer], + ], + }, + // Here, we extend to exactly two wild segments after transfer. + TestCase { + name: "transfer/*/*", + filter: vec![xfer, star, star], + includes: vec![vec![xfer, number, number], vec![xfer, xfer, xfer]], + excludes: vec![ + vec![number], + vec![number, number], + vec![number, xfer], + vec![number, xfer, number, number], + vec![xfer], + vec![xfer, xfer, xfer, xfer], + ], + }, + // Here, we ensure wildcards can be in the middle of a filter: only + // exact matches happen on the ends, while the middle can be + // anything. + TestCase { + name: "transfer/*/number", + filter: vec![xfer, star, number], + includes: vec![vec![xfer, number, number], vec![xfer, xfer, number]], + excludes: vec![ + vec![number], + vec![number, number], + vec![number, number, number], + vec![number, xfer, number], + vec![xfer], + vec![number, xfer], + vec![xfer, xfer, xfer], + vec![xfer, number, xfer], + ], + }, + ] { + for topic in tc.includes { + assert!( + does_topic_match( + &topic + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + &tc.filter + .iter() + .map(std::string::ToString::to_string) + .collect::>() + ), + "test: {}, topic ({:?}) should be matched by filter ({:?})", + tc.name, + topic, + tc.filter + ); + } + + for topic in tc.excludes { + assert!( + !does_topic_match( + // make deep copies of the vecs + &topic + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + &tc.filter + .iter() + .map(std::string::ToString::to_string) + .collect::>() + ), + "test: {}, topic ({:?}) should NOT be matched by filter ({:?})", + tc.name, + topic, + tc.filter + ); + } + } + } +} diff --git a/cmd/soroban-cli/src/rpc/txn.rs b/cmd/soroban-cli/src/rpc/txn.rs new file mode 100644 index 00000000..9e36938d --- /dev/null +++ b/cmd/soroban-cli/src/rpc/txn.rs @@ -0,0 +1,610 @@ +use ed25519_dalek::Signer; +use sha2::{Digest, Sha256}; +use soroban_env_host::xdr::{ + self, AccountId, DecoratedSignature, ExtensionPoint, Hash, HashIdPreimage, + HashIdPreimageSorobanAuthorization, InvokeHostFunctionOp, Limits, Memo, Operation, + OperationBody, Preconditions, PublicKey, ReadXdr, RestoreFootprintOp, ScAddress, ScMap, + ScSymbol, ScVal, Signature, SignatureHint, SorobanAddressCredentials, + SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanCredentials, SorobanResources, + SorobanTransactionData, Transaction, TransactionEnvelope, TransactionExt, + TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, + TransactionV1Envelope, Uint256, VecM, WriteXdr, +}; + +use crate::rpc::{Client, Error, RestorePreamble, SimulateTransactionResponse}; + +use super::{LogEvents, LogResources}; + +pub struct Assembled { + txn: Transaction, + sim_res: SimulateTransactionResponse, +} + +impl Assembled { + pub async fn new(txn: &Transaction, client: &Client) -> Result { + let sim_res = Self::simulate(txn, client).await?; + let txn = assemble(txn, &sim_res)?; + Ok(Self { txn, sim_res }) + } + + pub fn hash(&self, network_passphrase: &str) -> Result<[u8; 32], xdr::Error> { + let signature_payload = TransactionSignaturePayload { + network_id: Hash(Sha256::digest(network_passphrase).into()), + tagged_transaction: TransactionSignaturePayloadTaggedTransaction::Tx(self.txn.clone()), + }; + Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into()) + } + + pub fn sign( + self, + key: &ed25519_dalek::SigningKey, + network_passphrase: &str, + ) -> Result { + let tx = self.txn(); + let tx_hash = self.hash(network_passphrase)?; + let tx_signature = key.sign(&tx_hash); + + let decorated_signature = DecoratedSignature { + hint: SignatureHint(key.verifying_key().to_bytes()[28..].try_into()?), + signature: Signature(tx_signature.to_bytes().try_into()?), + }; + + Ok(TransactionEnvelope::Tx(TransactionV1Envelope { + tx: tx.clone(), + signatures: vec![decorated_signature].try_into()?, + })) + } + + pub async fn simulate( + tx: &Transaction, + client: &Client, + ) -> Result { + client + .simulate_transaction(&TransactionEnvelope::Tx(TransactionV1Envelope { + tx: tx.clone(), + signatures: VecM::default(), + })) + .await + } + + pub async fn handle_restore( + self, + client: &Client, + source_key: &ed25519_dalek::SigningKey, + network_passphrase: &str, + ) -> Result { + if let Some(restore_preamble) = &self.sim_res.restore_preamble { + // Build and submit the restore transaction + client + .send_transaction( + &Assembled::new(&restore(self.txn(), restore_preamble)?, client) + .await? + .sign(source_key, network_passphrase)?, + ) + .await?; + Ok(self.bump_seq_num()) + } else { + Ok(self) + } + } + + pub fn txn(&self) -> &Transaction { + &self.txn + } + + pub fn sim_res(&self) -> &SimulateTransactionResponse { + &self.sim_res + } + + pub async fn authorize( + self, + client: &Client, + source_key: &ed25519_dalek::SigningKey, + signers: &[ed25519_dalek::SigningKey], + seq_num: u32, + network_passphrase: &str, + ) -> Result { + if let Some(txn) = sign_soroban_authorizations( + self.txn(), + source_key, + signers, + seq_num, + network_passphrase, + )? { + Self::new(&txn, client).await + } else { + Ok(self) + } + } + + pub fn bump_seq_num(mut self) -> Self { + self.txn.seq_num.0 += 1; + self + } + + pub fn auth(&self) -> VecM { + self.txn + .operations + .first() + .and_then(|op| match op.body { + OperationBody::InvokeHostFunction(ref body) => (matches!( + body.auth.first().map(|x| &x.root_invocation.function), + Some(&SorobanAuthorizedFunction::ContractFn(_)) + )) + .then_some(body.auth.clone()), + _ => None, + }) + .unwrap_or_default() + } + + pub fn log( + &self, + log_events: Option, + log_resources: Option, + ) -> Result<(), Error> { + if let TransactionExt::V1(SorobanTransactionData { + resources: resources @ SorobanResources { footprint, .. }, + .. + }) = &self.txn.ext + { + if let Some(log) = log_resources { + log(resources); + } + if let Some(log) = log_events { + log(footprint, &[self.auth()], &self.sim_res.events()?); + }; + } + Ok(()) + } +} + +// Apply the result of a simulateTransaction onto a transaction envelope, preparing it for +// submission to the network. +pub fn assemble( + raw: &Transaction, + simulation: &SimulateTransactionResponse, +) -> Result { + let mut tx = raw.clone(); + + // Right now simulate.results is one-result-per-function, and assumes there is only one + // operation in the txn, so we need to enforce that here. I (Paul) think that is a bug + // in soroban-rpc.simulateTransaction design, and we should fix it there. + // TODO: We should to better handling so non-soroban txns can be a passthrough here. + if tx.operations.len() != 1 { + return Err(Error::UnexpectedOperationCount { + count: tx.operations.len(), + }); + } + + let transaction_data = simulation.transaction_data()?; + + let mut op = tx.operations[0].clone(); + if let OperationBody::InvokeHostFunction(ref mut body) = &mut op.body { + if body.auth.is_empty() { + if simulation.results.len() != 1 { + return Err(Error::UnexpectedSimulateTransactionResultSize { + length: simulation.results.len(), + }); + } + + let auths = simulation + .results + .iter() + .map(|r| { + VecM::try_from( + r.auth + .iter() + .map(|v| SorobanAuthorizationEntry::from_xdr_base64(v, Limits::none())) + .collect::, _>>()?, + ) + }) + .collect::, _>>()?; + if !auths.is_empty() { + body.auth = auths[0].clone(); + } + } + } + + // update the fees of the actual transaction to meet the minimum resource fees. + let classic_transaction_fees = crate::fee::Args::default().fee; + // Pad the fees up by 15% for a bit of wiggle room. + tx.fee = (tx.fee.max( + classic_transaction_fees + + u32::try_from(simulation.min_resource_fee) + .map_err(|_| Error::LargeFee(simulation.min_resource_fee))?, + ) * 115) + / 100; + + tx.operations = vec![op].try_into()?; + tx.ext = TransactionExt::V1(transaction_data); + Ok(tx) +} + +// Use the given source_key and signers, to sign all SorobanAuthorizationEntry's in the given +// transaction. If unable to sign, return an error. +fn sign_soroban_authorizations( + raw: &Transaction, + source_key: &ed25519_dalek::SigningKey, + signers: &[ed25519_dalek::SigningKey], + signature_expiration_ledger: u32, + network_passphrase: &str, +) -> Result, Error> { + let mut tx = raw.clone(); + let mut op = match tx.operations.as_slice() { + [op @ Operation { + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { auth, .. }), + .. + }] if matches!( + auth.first().map(|x| &x.root_invocation.function), + Some(&SorobanAuthorizedFunction::ContractFn(_)) + ) => + { + op.clone() + } + _ => return Ok(None), + }; + + let Operation { + body: OperationBody::InvokeHostFunction(ref mut body), + .. + } = op + else { + return Ok(None); + }; + + let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into()); + + let verification_key = source_key.verifying_key(); + let source_address = verification_key.as_bytes(); + + let signed_auths = body + .auth + .as_slice() + .iter() + .map(|raw_auth| { + let mut auth = raw_auth.clone(); + let SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(ref mut credentials), + .. + } = auth + else { + // Doesn't need special signing + return Ok(auth); + }; + let SorobanAddressCredentials { ref address, .. } = credentials; + + // See if we have a signer for this authorizationEntry + // If not, then we Error + let needle = match address { + ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(ref a)))) => a, + ScAddress::Contract(Hash(c)) => { + // This address is for a contract. This means we're using a custom + // smart-contract account. Currently the CLI doesn't support that yet. + return Err(Error::MissingSignerForAddress { + address: stellar_strkey::Strkey::Contract(stellar_strkey::Contract(*c)) + .to_string(), + }); + } + }; + let signer = if let Some(s) = signers + .iter() + .find(|s| needle == s.verifying_key().as_bytes()) + { + s + } else if needle == source_address { + // This is the source address, so we can sign it + source_key + } else { + // We don't have a signer for this address + return Err(Error::MissingSignerForAddress { + address: stellar_strkey::Strkey::PublicKeyEd25519( + stellar_strkey::ed25519::PublicKey(*needle), + ) + .to_string(), + }); + }; + + sign_soroban_authorization_entry( + raw_auth, + signer, + signature_expiration_ledger, + &network_id, + ) + }) + .collect::, Error>>()?; + + body.auth = signed_auths.try_into()?; + tx.operations = vec![op].try_into()?; + Ok(Some(tx)) +} + +fn sign_soroban_authorization_entry( + raw: &SorobanAuthorizationEntry, + signer: &ed25519_dalek::SigningKey, + signature_expiration_ledger: u32, + network_id: &Hash, +) -> Result { + let mut auth = raw.clone(); + let SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(ref mut credentials), + .. + } = auth + else { + // Doesn't need special signing + return Ok(auth); + }; + let SorobanAddressCredentials { nonce, .. } = credentials; + + let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id: network_id.clone(), + invocation: auth.root_invocation.clone(), + nonce: *nonce, + signature_expiration_ledger, + }) + .to_xdr(Limits::none())?; + + let payload = Sha256::digest(preimage); + let signature = signer.sign(&payload); + + let map = ScMap::sorted_from(vec![ + ( + ScVal::Symbol(ScSymbol("public_key".try_into()?)), + ScVal::Bytes( + signer + .verifying_key() + .to_bytes() + .to_vec() + .try_into() + .map_err(Error::Xdr)?, + ), + ), + ( + ScVal::Symbol(ScSymbol("signature".try_into()?)), + ScVal::Bytes( + signature + .to_bytes() + .to_vec() + .try_into() + .map_err(Error::Xdr)?, + ), + ), + ]) + .map_err(Error::Xdr)?; + credentials.signature = ScVal::Vec(Some( + vec![ScVal::Map(Some(map))].try_into().map_err(Error::Xdr)?, + )); + credentials.signature_expiration_ledger = signature_expiration_ledger; + auth.credentials = SorobanCredentials::Address(credentials.clone()); + Ok(auth) +} + +pub fn restore(parent: &Transaction, restore: &RestorePreamble) -> Result { + let transaction_data = + SorobanTransactionData::from_xdr_base64(&restore.transaction_data, Limits::none())?; + let fee = u32::try_from(restore.min_resource_fee) + .map_err(|_| Error::LargeFee(restore.min_resource_fee))?; + Ok(Transaction { + source_account: parent.source_account.clone(), + fee: parent + .fee + .checked_add(fee) + .ok_or(Error::LargeFee(restore.min_resource_fee))?, + seq_num: parent.seq_num.clone(), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::RestoreFootprint(RestoreFootprintOp { + ext: ExtensionPoint::V0, + }), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V1(transaction_data), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use super::super::SimulateHostFunctionResultRaw; + use soroban_env_host::xdr::{ + self, AccountId, ChangeTrustAsset, ChangeTrustOp, ExtensionPoint, Hash, HostFunction, + InvokeContractArgs, InvokeHostFunctionOp, LedgerFootprint, Memo, MuxedAccount, Operation, + Preconditions, PublicKey, ScAddress, ScSymbol, ScVal, SequenceNumber, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanResources, + SorobanTransactionData, Uint256, WriteXdr, + }; + use stellar_strkey::ed25519::PublicKey as Ed25519PublicKey; + + const SOURCE: &str = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + + fn transaction_data() -> SorobanTransactionData { + SorobanTransactionData { + resources: SorobanResources { + footprint: LedgerFootprint { + read_only: VecM::default(), + read_write: VecM::default(), + }, + instructions: 0, + read_bytes: 5, + write_bytes: 0, + }, + resource_fee: 0, + ext: ExtensionPoint::V0, + } + } + + fn simulation_response() -> SimulateTransactionResponse { + let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0; + let fn_auth = &SorobanAuthorizationEntry { + credentials: xdr::SorobanCredentials::Address(xdr::SorobanAddressCredentials { + address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256( + source_bytes, + )))), + nonce: 0, + signature_expiration_ledger: 0, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(Hash([0; 32])), + function_name: ScSymbol("fn".try_into().unwrap()), + args: VecM::default(), + }), + sub_invocations: VecM::default(), + }, + }; + + SimulateTransactionResponse { + min_resource_fee: 115, + latest_ledger: 3, + results: vec![SimulateHostFunctionResultRaw { + auth: vec![fn_auth.to_xdr_base64(Limits::none()).unwrap()], + xdr: ScVal::U32(0).to_xdr_base64(Limits::none()).unwrap(), + }], + transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(), + ..Default::default() + } + } + + fn single_contract_fn_transaction() -> Transaction { + let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0; + Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_bytes)), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function: HostFunction::InvokeContract(InvokeContractArgs { + contract_address: ScAddress::Contract(Hash([0x0; 32])), + function_name: ScSymbol::default(), + args: VecM::default(), + }), + auth: VecM::default(), + }), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V0, + } + } + + #[test] + fn test_assemble_transaction_updates_tx_data_from_simulation_response() { + let sim = simulation_response(); + let txn = single_contract_fn_transaction(); + let Ok(result) = assemble(&txn, &sim) else { + panic!("assemble failed"); + }; + + // validate it auto updated the tx fees from sim response fees + // since it was greater than tx.fee + assert_eq!(247, result.fee); + + // validate it updated sorobantransactiondata block in the tx ext + assert_eq!(TransactionExt::V1(transaction_data()), result.ext); + } + + #[test] + fn test_assemble_transaction_adds_the_auth_to_the_host_function() { + let sim = simulation_response(); + let txn = single_contract_fn_transaction(); + let Ok(result) = assemble(&txn, &sim) else { + panic!("assemble failed"); + }; + + assert_eq!(1, result.operations.len()); + let OperationBody::InvokeHostFunction(ref op) = result.operations[0].body else { + panic!("unexpected operation type: {:#?}", result.operations[0]); + }; + + assert_eq!(1, op.auth.len()); + let auth = &op.auth[0]; + + let xdr::SorobanAuthorizedFunction::ContractFn(xdr::InvokeContractArgs { + ref function_name, + .. + }) = auth.root_invocation.function + else { + panic!("unexpected function type"); + }; + assert_eq!("fn".to_string(), format!("{}", function_name.0)); + + let xdr::SorobanCredentials::Address(xdr::SorobanAddressCredentials { + address: + xdr::ScAddress::Account(xdr::AccountId(xdr::PublicKey::PublicKeyTypeEd25519(address))), + .. + }) = &auth.credentials + else { + panic!("unexpected credentials type"); + }; + assert_eq!( + SOURCE.to_string(), + stellar_strkey::ed25519::PublicKey(address.0).to_string() + ); + } + + #[test] + fn test_assemble_transaction_errors_for_non_invokehostfn_ops() { + let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0; + let txn = Transaction { + source_account: MuxedAccount::Ed25519(Uint256(source_bytes)), + fee: 100, + seq_num: SequenceNumber(0), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::ChangeTrust(ChangeTrustOp { + line: ChangeTrustAsset::Native, + limit: 0, + }), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V0, + }; + + let result = assemble( + &txn, + &SimulateTransactionResponse { + min_resource_fee: 115, + transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(), + latest_ledger: 3, + ..Default::default() + }, + ); + + match result { + Ok(_) => {} + Err(e) => panic!("expected assembled operation, got: {e:#?}"), + } + } + + #[test] + fn test_assemble_transaction_errors_for_errors_for_mismatched_simulation() { + let txn = single_contract_fn_transaction(); + + let result = assemble( + &txn, + &SimulateTransactionResponse { + min_resource_fee: 115, + transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(), + latest_ledger: 3, + ..Default::default() + }, + ); + + match result { + Err(Error::UnexpectedSimulateTransactionResultSize { length }) => { + assert_eq!(0, length); + } + r => panic!("expected UnexpectedSimulateTransactionResultSize error, got: {r:#?}"), + } + } +} diff --git a/cmd/soroban-cli/src/toid.rs b/cmd/soroban-cli/src/toid.rs new file mode 100644 index 00000000..55c89049 --- /dev/null +++ b/cmd/soroban-cli/src/toid.rs @@ -0,0 +1,69 @@ +/// A barebones implementation of Total Order IDs (TOIDs) from +/// [SEP-35](https://stellar.org/protocol/sep-35), using the reference +/// implementation from the Go +/// [`stellar/go/toid`](https://github.com/stellar/go/blob/b4ba6f8e67f274bf84d21b0effb01ea8a914b766/toid/main.go#L8-L56) +/// package. +#[derive(Copy, Clone)] +pub struct Toid { + ledger_sequence: u32, + transaction_order: u32, + operation_order: u32, +} + +const LEDGER_MASK: u64 = (1 << 32) - 1; +const TRANSACTION_MASK: u64 = (1 << 20) - 1; +const OPERATION_MASK: u64 = (1 << 12) - 1; +const LEDGER_SHIFT: u64 = 32; +const TRANSACTION_SHIFT: u64 = 12; +const OPERATION_SHIFT: u64 = 0; + +impl Toid { + pub fn new(ledger: u32, tx_order: u32, op_order: u32) -> Toid { + Toid { + ledger_sequence: ledger, + transaction_order: tx_order, + operation_order: op_order, + } + } + + pub fn to_paging_token(self) -> String { + let u: u64 = self.into(); + format!("{u:019}") + } +} + +impl From for Toid { + fn from(item: u64) -> Self { + let ledger: u32 = ((item & LEDGER_MASK) >> LEDGER_SHIFT).try_into().unwrap(); + let tx_order: u32 = ((item & TRANSACTION_MASK) >> TRANSACTION_SHIFT) + .try_into() + .unwrap(); + let op_order: u32 = ((item & OPERATION_MASK) >> OPERATION_SHIFT) + .try_into() + .unwrap(); + + Toid::new(ledger, tx_order, op_order) + } +} + +impl From for u64 { + fn from(item: Toid) -> Self { + let l: u64 = item.ledger_sequence.into(); + let t: u64 = item.transaction_order.into(); + let o: u64 = item.operation_order.into(); + + let mut result: u64 = 0; + result |= (l & LEDGER_MASK) << LEDGER_SHIFT; + result |= (t & TRANSACTION_MASK) << TRANSACTION_SHIFT; + result |= (o & OPERATION_MASK) << OPERATION_SHIFT; + + result + } +} + +impl ToString for Toid { + fn to_string(&self) -> String { + let u: u64 = (*self).into(); + u.to_string() + } +} diff --git a/cmd/soroban-cli/src/utils.rs b/cmd/soroban-cli/src/utils.rs new file mode 100644 index 00000000..ff0018a9 --- /dev/null +++ b/cmd/soroban-cli/src/utils.rs @@ -0,0 +1,244 @@ +use ed25519_dalek::Signer; +use sha2::{Digest, Sha256}; +use stellar_strkey::ed25519::PrivateKey; + +use soroban_env_host::xdr::{ + Asset, ContractIdPreimage, DecoratedSignature, Error as XdrError, Hash, HashIdPreimage, + HashIdPreimageContractId, Limits, Signature, SignatureHint, Transaction, TransactionEnvelope, + TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, + TransactionV1Envelope, WriteXdr, +}; + +pub mod contract_spec; + +/// # Errors +/// +/// Might return an error +pub fn contract_hash(contract: &[u8]) -> Result { + Ok(Hash(Sha256::digest(contract).into())) +} + +/// # Errors +/// +/// Might return an error +pub fn transaction_hash(tx: &Transaction, network_passphrase: &str) -> Result<[u8; 32], XdrError> { + let signature_payload = TransactionSignaturePayload { + network_id: Hash(Sha256::digest(network_passphrase).into()), + tagged_transaction: TransactionSignaturePayloadTaggedTransaction::Tx(tx.clone()), + }; + Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into()) +} + +/// # Errors +/// +/// Might return an error +pub fn sign_transaction( + key: &ed25519_dalek::SigningKey, + tx: &Transaction, + network_passphrase: &str, +) -> Result { + let tx_hash = transaction_hash(tx, network_passphrase)?; + let tx_signature = key.sign(&tx_hash); + + let decorated_signature = DecoratedSignature { + hint: SignatureHint(key.verifying_key().to_bytes()[28..].try_into()?), + signature: Signature(tx_signature.to_bytes().try_into()?), + }; + + Ok(TransactionEnvelope::Tx(TransactionV1Envelope { + tx: tx.clone(), + signatures: vec![decorated_signature].try_into()?, + })) +} + +/// # Errors +/// +/// Might return an error +pub fn contract_id_from_str(contract_id: &str) -> Result<[u8; 32], stellar_strkey::DecodeError> { + stellar_strkey::Contract::from_string(contract_id) + .map(|strkey| strkey.0) + .or_else(|_| { + // strkey failed, try to parse it as a hex string, for backwards compatibility. + soroban_spec_tools::utils::padded_hex_from_str(contract_id, 32) + .map_err(|_| stellar_strkey::DecodeError::Invalid)? + .try_into() + .map_err(|_| stellar_strkey::DecodeError::Invalid) + }) + .map_err(|_| stellar_strkey::DecodeError::Invalid) +} + +/// # Errors +/// May not find a config dir +pub fn find_config_dir(mut pwd: std::path::PathBuf) -> std::io::Result { + let soroban_dir = |p: &std::path::Path| p.join(".soroban"); + while !soroban_dir(&pwd).exists() { + if !pwd.pop() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "soroban directory not found", + )); + } + } + Ok(soroban_dir(&pwd)) +} + +pub(crate) fn into_signing_key(key: &PrivateKey) -> ed25519_dalek::SigningKey { + let secret: ed25519_dalek::SecretKey = key.0; + ed25519_dalek::SigningKey::from_bytes(&secret) +} + +/// Used in tests +#[allow(unused)] +pub(crate) fn parse_secret_key( + s: &str, +) -> Result { + Ok(into_signing_key(&PrivateKey::from_string(s)?)) +} + +pub fn is_hex_string(s: &str) -> bool { + s.chars().all(|s| s.is_ascii_hexdigit()) +} + +pub fn contract_id_hash_from_asset( + asset: &Asset, + network_passphrase: &str, +) -> Result { + let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into()); + let preimage = HashIdPreimage::ContractId(HashIdPreimageContractId { + network_id, + contract_id_preimage: ContractIdPreimage::Asset(asset.clone()), + }); + let preimage_xdr = preimage.to_xdr(Limits::none())?; + Ok(Hash(Sha256::digest(preimage_xdr).into())) +} + +pub mod parsing { + + use regex::Regex; + use soroban_env_host::xdr::{ + AccountId, AlphaNum12, AlphaNum4, Asset, AssetCode12, AssetCode4, PublicKey, + }; + + #[derive(thiserror::Error, Debug)] + pub enum Error { + #[error("invalid asset code: {asset}")] + InvalidAssetCode { asset: String }, + #[error("cannot parse account id: {account_id}")] + CannotParseAccountId { account_id: String }, + #[error("cannot parse asset: {asset}")] + CannotParseAsset { asset: String }, + #[error(transparent)] + Regex(#[from] regex::Error), + } + + pub fn parse_asset(str: &str) -> Result { + if str == "native" { + return Ok(Asset::Native); + } + let split: Vec<&str> = str.splitn(2, ':').collect(); + if split.len() != 2 { + return Err(Error::CannotParseAsset { + asset: str.to_string(), + }); + } + let code = split[0]; + let issuer = split[1]; + let re = Regex::new("^[[:alnum:]]{1,12}$")?; + if !re.is_match(code) { + return Err(Error::InvalidAssetCode { + asset: str.to_string(), + }); + } + if code.len() <= 4 { + let mut asset_code: [u8; 4] = [0; 4]; + for (i, b) in code.as_bytes().iter().enumerate() { + asset_code[i] = *b; + } + Ok(Asset::CreditAlphanum4(AlphaNum4 { + asset_code: AssetCode4(asset_code), + issuer: parse_account_id(issuer)?, + })) + } else { + let mut asset_code: [u8; 12] = [0; 12]; + for (i, b) in code.as_bytes().iter().enumerate() { + asset_code[i] = *b; + } + Ok(Asset::CreditAlphanum12(AlphaNum12 { + asset_code: AssetCode12(asset_code), + issuer: parse_account_id(issuer)?, + })) + } + } + + pub fn parse_account_id(str: &str) -> Result { + let pk_bytes = stellar_strkey::ed25519::PublicKey::from_string(str) + .map_err(|_| Error::CannotParseAccountId { + account_id: str.to_string(), + })? + .0; + Ok(AccountId(PublicKey::PublicKeyTypeEd25519(pk_bytes.into()))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_contract_id_from_str() { + // strkey + match contract_id_from_str("CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE") { + Ok(contract_id) => assert_eq!( + contract_id, + [ + 0x36, 0x3e, 0xaa, 0x38, 0x67, 0x84, 0x1f, 0xba, 0xd0, 0xf4, 0xed, 0x88, 0xc7, + 0x79, 0xe4, 0xfe, 0x66, 0xe5, 0x6a, 0x24, 0x70, 0xdc, 0x98, 0xc0, 0xec, 0x9c, + 0x07, 0x3d, 0x05, 0xc7, 0xb1, 0x03, + ] + ), + Err(err) => panic!("Failed to parse contract id: {err}"), + } + + // hex + match contract_id_from_str( + "363eaa3867841fbad0f4ed88c779e4fe66e56a2470dc98c0ec9c073d05c7b103", + ) { + Ok(contract_id) => assert_eq!( + contract_id, + [ + 0x36, 0x3e, 0xaa, 0x38, 0x67, 0x84, 0x1f, 0xba, 0xd0, 0xf4, 0xed, 0x88, 0xc7, + 0x79, 0xe4, 0xfe, 0x66, 0xe5, 0x6a, 0x24, 0x70, 0xdc, 0x98, 0xc0, 0xec, 0x9c, + 0x07, 0x3d, 0x05, 0xc7, 0xb1, 0x03, + ] + ), + Err(err) => panic!("Failed to parse contract id: {err}"), + } + + // unpadded-hex + match contract_id_from_str("1") { + Ok(contract_id) => assert_eq!( + contract_id, + [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + ] + ), + Err(err) => panic!("Failed to parse contract id: {err}"), + } + + // invalid hex + match contract_id_from_str("foobar") { + Ok(_) => panic!("Expected parsing to fail"), + Err(err) => assert_eq!(err, stellar_strkey::DecodeError::Invalid), + } + + // hex too long (33 bytes) + match contract_id_from_str( + "000000000000000000000000000000000000000000000000000000000000000000", + ) { + Ok(_) => panic!("Expected parsing to fail"), + Err(err) => assert_eq!(err, stellar_strkey::DecodeError::Invalid), + } + } +} diff --git a/cmd/soroban-cli/src/utils/contract_spec.rs b/cmd/soroban-cli/src/utils/contract_spec.rs new file mode 100644 index 00000000..b4f24abe --- /dev/null +++ b/cmd/soroban-cli/src/utils/contract_spec.rs @@ -0,0 +1,276 @@ +use base64::{engine::general_purpose::STANDARD as base64, Engine as _}; +use std::{ + fmt::Display, + io::{self, Cursor}, +}; + +use soroban_env_host::xdr::{ + self, Limited, Limits, ReadXdr, ScEnvMetaEntry, ScMetaEntry, ScMetaV0, ScSpecEntry, + ScSpecFunctionV0, ScSpecUdtEnumV0, ScSpecUdtErrorEnumV0, ScSpecUdtStructV0, ScSpecUdtUnionV0, + StringM, WriteXdr, +}; + +pub struct ContractSpec { + pub env_meta_base64: Option, + pub env_meta: Vec, + pub meta_base64: Option, + pub meta: Vec, + pub spec_base64: Option, + pub spec: Vec, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("reading file {filepath}: {error}")] + CannotReadContractFile { + filepath: std::path::PathBuf, + error: io::Error, + }, + #[error("cannot parse wasm file {file}: {error}")] + CannotParseWasm { + file: std::path::PathBuf, + error: wasmparser::BinaryReaderError, + }, + #[error("xdr processing error: {0}")] + Xdr(#[from] xdr::Error), + + #[error(transparent)] + Parser(#[from] wasmparser::BinaryReaderError), +} + +impl ContractSpec { + pub fn new(bytes: &[u8]) -> Result { + let mut env_meta: Option<&[u8]> = None; + let mut meta: Option<&[u8]> = None; + let mut spec: Option<&[u8]> = None; + for payload in wasmparser::Parser::new(0).parse_all(bytes) { + let payload = payload?; + if let wasmparser::Payload::CustomSection(section) = payload { + let out = match section.name() { + "contractenvmetav0" => &mut env_meta, + "contractmetav0" => &mut meta, + "contractspecv0" => &mut spec, + _ => continue, + }; + *out = Some(section.data()); + }; + } + + let mut env_meta_base64 = None; + let env_meta = if let Some(env_meta) = env_meta { + env_meta_base64 = Some(base64.encode(env_meta)); + let cursor = Cursor::new(env_meta); + let mut read = Limited::new(cursor, Limits::none()); + ScEnvMetaEntry::read_xdr_iter(&mut read).collect::, xdr::Error>>()? + } else { + vec![] + }; + + let mut meta_base64 = None; + let meta = if let Some(meta) = meta { + meta_base64 = Some(base64.encode(meta)); + let cursor = Cursor::new(meta); + let mut depth_limit_read = Limited::new(cursor, Limits::none()); + ScMetaEntry::read_xdr_iter(&mut depth_limit_read) + .collect::, xdr::Error>>()? + } else { + vec![] + }; + + let mut spec_base64 = None; + let spec = if let Some(spec) = spec { + spec_base64 = Some(base64.encode(spec)); + let cursor = Cursor::new(spec); + let mut read = Limited::new(cursor, Limits::none()); + ScSpecEntry::read_xdr_iter(&mut read).collect::, xdr::Error>>()? + } else { + vec![] + }; + + Ok(ContractSpec { + env_meta_base64, + env_meta, + meta_base64, + meta, + spec_base64, + spec, + }) + } + + pub fn spec_as_json_array(&self) -> Result { + let spec = self + .spec + .iter() + .map(|e| Ok(format!("\"{}\"", e.to_xdr_base64(Limits::none())?))) + .collect::, Error>>()? + .join(",\n"); + Ok(format!("[{spec}]")) + } +} + +impl Display for ContractSpec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(env_meta) = &self.env_meta_base64 { + writeln!(f, "Env Meta: {env_meta}")?; + for env_meta_entry in &self.env_meta { + match env_meta_entry { + ScEnvMetaEntry::ScEnvMetaKindInterfaceVersion(v) => { + writeln!(f, " • Interface Version: {v}")?; + } + } + } + writeln!(f)?; + } else { + writeln!(f, "Env Meta: None\n")?; + } + + if let Some(_meta) = &self.meta_base64 { + writeln!(f, "Contract Meta:")?; + for meta_entry in &self.meta { + match meta_entry { + ScMetaEntry::ScMetaV0(ScMetaV0 { key, val }) => { + writeln!(f, " • {key}: {val}")?; + } + } + } + writeln!(f)?; + } else { + writeln!(f, "Contract Meta: None\n")?; + } + + if let Some(_spec_base64) = &self.spec_base64 { + writeln!(f, "Contract Spec:")?; + for spec_entry in &self.spec { + match spec_entry { + ScSpecEntry::FunctionV0(func) => write_func(f, func)?, + ScSpecEntry::UdtUnionV0(udt) => write_union(f, udt)?, + ScSpecEntry::UdtStructV0(udt) => write_struct(f, udt)?, + ScSpecEntry::UdtEnumV0(udt) => write_enum(f, udt)?, + ScSpecEntry::UdtErrorEnumV0(udt) => write_error(f, udt)?, + } + } + } else { + writeln!(f, "Contract Spec: None")?; + } + Ok(()) + } +} + +fn write_func(f: &mut std::fmt::Formatter<'_>, func: &ScSpecFunctionV0) -> std::fmt::Result { + writeln!(f, " • Function: {}", func.name.to_utf8_string_lossy())?; + if func.doc.len() > 0 { + writeln!( + f, + " Docs: {}", + &indent(&func.doc.to_utf8_string_lossy(), 11).trim() + )?; + } + writeln!( + f, + " Inputs: {}", + indent(&format!("{:#?}", func.inputs), 5).trim() + )?; + writeln!( + f, + " Output: {}", + indent(&format!("{:#?}", func.outputs), 5).trim() + )?; + writeln!(f)?; + Ok(()) +} + +fn write_union(f: &mut std::fmt::Formatter<'_>, udt: &ScSpecUdtUnionV0) -> std::fmt::Result { + writeln!(f, " • Union: {}", format_name(&udt.lib, &udt.name))?; + if udt.doc.len() > 0 { + writeln!( + f, + " Docs: {}", + indent(&udt.doc.to_utf8_string_lossy(), 10).trim() + )?; + } + writeln!(f, " Cases:")?; + for case in udt.cases.iter() { + writeln!(f, " • {}", indent(&format!("{case:#?}"), 8).trim())?; + } + writeln!(f)?; + Ok(()) +} + +fn write_struct(f: &mut std::fmt::Formatter<'_>, udt: &ScSpecUdtStructV0) -> std::fmt::Result { + writeln!(f, " • Struct: {}", format_name(&udt.lib, &udt.name))?; + if udt.doc.len() > 0 { + writeln!( + f, + " Docs: {}", + indent(&udt.doc.to_utf8_string_lossy(), 10).trim() + )?; + } + writeln!(f, " Fields:")?; + for field in udt.fields.iter() { + writeln!( + f, + " • {}: {}", + field.name.to_utf8_string_lossy(), + indent(&format!("{:#?}", field.type_), 8).trim() + )?; + if field.doc.len() > 0 { + writeln!(f, "{}", indent(&format!("{:#?}", field.doc), 8))?; + } + } + writeln!(f)?; + Ok(()) +} + +fn write_enum(f: &mut std::fmt::Formatter<'_>, udt: &ScSpecUdtEnumV0) -> std::fmt::Result { + writeln!(f, " • Enum: {}", format_name(&udt.lib, &udt.name))?; + if udt.doc.len() > 0 { + writeln!( + f, + " Docs: {}", + indent(&udt.doc.to_utf8_string_lossy(), 10).trim() + )?; + } + writeln!(f, " Cases:")?; + for case in udt.cases.iter() { + writeln!(f, " • {}", indent(&format!("{case:#?}"), 8).trim())?; + } + writeln!(f)?; + Ok(()) +} + +fn write_error(f: &mut std::fmt::Formatter<'_>, udt: &ScSpecUdtErrorEnumV0) -> std::fmt::Result { + writeln!(f, " • Error: {}", format_name(&udt.lib, &udt.name))?; + if udt.doc.len() > 0 { + writeln!( + f, + " Docs: {}", + indent(&udt.doc.to_utf8_string_lossy(), 10).trim() + )?; + } + writeln!(f, " Cases:")?; + for case in udt.cases.iter() { + writeln!(f, " • {}", indent(&format!("{case:#?}"), 8).trim())?; + } + writeln!(f)?; + Ok(()) +} + +fn indent(s: &str, n: usize) -> String { + let pad = " ".repeat(n); + s.lines() + .map(|line| format!("{pad}{line}")) + .collect::>() + .join("\n") +} + +fn format_name(lib: &StringM<80>, name: &StringM<60>) -> String { + if lib.len() > 0 { + format!( + "{}::{}", + lib.to_utf8_string_lossy(), + name.to_utf8_string_lossy() + ) + } else { + name.to_utf8_string_lossy() + } +} diff --git a/cmd/soroban-cli/src/wasm.rs b/cmd/soroban-cli/src/wasm.rs new file mode 100644 index 00000000..fce44c7c --- /dev/null +++ b/cmd/soroban-cli/src/wasm.rs @@ -0,0 +1,93 @@ +use clap::arg; +use soroban_env_host::xdr::{self, LedgerKey, LedgerKeyContractCode}; +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +use crate::utils::{self, contract_spec::ContractSpec}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("reading file {filepath}: {error}")] + CannotReadContractFile { + filepath: std::path::PathBuf, + error: io::Error, + }, + #[error("cannot parse wasm file {file}: {error}")] + CannotParseWasm { + file: std::path::PathBuf, + error: wasmparser::BinaryReaderError, + }, + #[error("xdr processing error: {0}")] + Xdr(#[from] xdr::Error), + + #[error(transparent)] + Parser(#[from] wasmparser::BinaryReaderError), + #[error(transparent)] + ContractSpec(#[from] crate::utils::contract_spec::Error), +} + +#[derive(Debug, clap::Args, Clone)] +#[group(skip)] +pub struct Args { + /// Path to wasm binary + #[arg(long)] + pub wasm: PathBuf, +} + +impl Args { + /// # Errors + /// May fail to read wasm file + pub fn read(&self) -> Result, Error> { + fs::read(&self.wasm).map_err(|e| Error::CannotReadContractFile { + filepath: self.wasm.clone(), + error: e, + }) + } + + /// # Errors + /// May fail to read wasm file + pub fn len(&self) -> Result { + len(&self.wasm) + } + + /// # Errors + /// May fail to read wasm file + pub fn is_empty(&self) -> Result { + self.len().map(|len| len == 0) + } + + /// # Errors + /// May fail to read wasm file or parse xdr section + pub fn parse(&self) -> Result { + let contents = self.read()?; + Ok(ContractSpec::new(&contents)?) + } +} + +impl From<&PathBuf> for Args { + fn from(wasm: &PathBuf) -> Self { + Self { wasm: wasm.clone() } + } +} + +impl TryInto for Args { + type Error = Error; + fn try_into(self) -> Result { + Ok(LedgerKey::ContractCode(LedgerKeyContractCode { + hash: utils::contract_hash(&self.read()?)?, + })) + } +} + +/// # Errors +/// May fail to read wasm file +pub fn len(p: &Path) -> Result { + Ok(std::fs::metadata(p) + .map_err(|e| Error::CannotReadContractFile { + filepath: p.to_path_buf(), + error: e, + })? + .len()) +}