From db5cec8b2d7786d45e619045882ae14ff08d6cf4 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Tue, 21 Nov 2023 11:33:36 -0500 Subject: [PATCH] feat: create soroban-rpc crate --- Cargo.lock | 55 +- Cargo.toml | 29 +- cmd/crates/soroban-rpc/Cargo.toml | 59 + cmd/crates/soroban-rpc/README.md | 3 + .../src/fixtures/event_response.json | 39 + cmd/crates/soroban-rpc/src/lib.rs | 1207 +++++++++++++++++ cmd/crates/soroban-rpc/src/log.rs | 2 + .../soroban-rpc/src/log/diagnostic_events.rs | 11 + cmd/crates/soroban-rpc/src/txn.rs | 637 +++++++++ cmd/crates/soroban-spec-tools/Cargo.toml | 2 + cmd/crates/soroban-spec-tools/src/contract.rs | 274 ++++ cmd/crates/soroban-spec-tools/src/lib.rs | 1 + cmd/soroban-cli/Cargo.toml | 20 +- .../src/commands/config/network/mod.rs | 8 +- .../src/commands/contract/deploy.rs | 14 +- .../src/commands/contract/extend.rs | 22 +- .../src/commands/contract/fetch.rs | 8 +- .../src/commands/contract/install.rs | 7 +- .../src/commands/contract/invoke.rs | 40 +- cmd/soroban-cli/src/commands/contract/read.rs | 9 +- .../src/commands/contract/restore.rs | 18 +- cmd/soroban-cli/src/commands/events.rs | 8 +- .../src/commands/lab/token/wrap.rs | 5 +- cmd/soroban-cli/src/lib.rs | 2 +- cmd/soroban-cli/src/utils.rs | 11 +- 25 files changed, 2382 insertions(+), 109 deletions(-) create mode 100644 cmd/crates/soroban-rpc/Cargo.toml create mode 100644 cmd/crates/soroban-rpc/README.md create mode 100644 cmd/crates/soroban-rpc/src/fixtures/event_response.json create mode 100644 cmd/crates/soroban-rpc/src/lib.rs create mode 100644 cmd/crates/soroban-rpc/src/log.rs create mode 100644 cmd/crates/soroban-rpc/src/log/diagnostic_events.rs create mode 100644 cmd/crates/soroban-rpc/src/txn.rs create mode 100644 cmd/crates/soroban-spec-tools/src/contract.rs diff --git a/Cargo.lock b/Cargo.lock index 33b6fd1a69..5f36065d4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1109,7 +1109,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.9", "indexmap 1.9.3", "slab", "tokio", @@ -1189,6 +1189,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-body" version = "0.4.5" @@ -1196,7 +1207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" dependencies = [ "bytes", - "http", + "http 0.2.9", "pin-project-lite", ] @@ -1223,7 +1234,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", + "http 0.2.9", "http-body", "httparse", "httpdate", @@ -1243,7 +1254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d78e1e73ec14cf7375674f74d7dde185c8206fd9dea6fb6295e8a98098aaa97" dependencies = [ "futures-util", - "http", + "http 0.2.9", "hyper", "log", "rustls", @@ -2511,7 +2522,7 @@ dependencies = [ "ethnum", "heck", "hex", - "http", + "http 0.2.9", "hyper", "hyper-tls", "itertools 0.10.5", @@ -2533,6 +2544,7 @@ dependencies = [ "shlex", "soroban-env-host", "soroban-ledger-snapshot", + "soroban-rpc", "soroban-sdk", "soroban-spec", "soroban-spec-json", @@ -2634,6 +2646,38 @@ dependencies = [ "thiserror", ] +[[package]] +name = "soroban-rpc" +version = "20.0.0-rc4" +dependencies = [ + "base64 0.21.4", + "clap", + "ed25519-dalek 2.0.0", + "ethnum", + "hex", + "http 1.0.0", + "itertools 0.10.5", + "jsonrpsee-core", + "jsonrpsee-http-client", + "serde", + "serde-aux", + "serde_json", + "sha2 0.10.7", + "soroban-env-host", + "soroban-sdk", + "soroban-spec", + "soroban-spec-tools", + "stellar-strkey 0.0.7 (registry+https://github.com/rust-lang/crates.io-index)", + "stellar-xdr", + "termcolor", + "termcolor_output", + "thiserror", + "tokio", + "tracing", + "wasmparser 0.90.0", + "which", +] + [[package]] name = "soroban-sdk" version = "20.0.0-rc2" @@ -2719,6 +2763,7 @@ dependencies = [ "hex", "itertools 0.10.5", "serde_json", + "soroban-env-host", "soroban-spec", "stellar-strkey 0.0.7 (registry+https://github.com/rust-lang/crates.io-index)", "stellar-xdr", diff --git a/Cargo.toml b/Cargo.toml index e2745a2d13..cceac78c8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,10 @@ rev = "fb422beae0d4944dc0e83559a8940b31f5ebd89d" version = "20.0.0-rc4" path = "cmd/soroban-cli" +[workspace.dependencies.soroban-rpc] +version = "20.0.0-rc4" +path = "cmd/crates/soroban-rpc" + [workspace.dependencies.stellar-xdr] version = "20.0.0-rc1" git = "https://github.com/stellar/rs-stellar-xdr" @@ -63,22 +67,43 @@ rev = "9c97e4fa909a0b6455547a4f4a95800696b2a69a" default-features = true [workspace.dependencies] +stellar-strkey = "0.0.7" +sep5 = "0.0.2" base64 = "0.21.2" thiserror = "1.0.46" sha2 = "0.10.7" ethnum = "1.3.2" hex = "0.4.3" itertools = "0.10.0" -sep5 = "0.0.2" + +serde-aux = "4.1.2" serde_json = "1.0.82" serde = "1.0.82" -stellar-strkey = "0.0.7" + +clap = { version = "4.1.8", features = [ + "derive", + "env", + "deprecated", + "string", +] } +clap_complete = "4.1.4" + tracing = "0.1.37" tracing-subscriber = "0.3.16" tracing-appender = "0.2.2" which = "4.4.0" wasmparser = "0.90.0" directories = "5.0.1" +termcolor = "1.1.3" +termcolor_output = "1.0.1" +ed25519-dalek = "2.0.0" + +# networking +http = "1.0.0" +jsonrpsee-http-client = "0.20.1" +jsonrpsee-core = "0.20.1" +tokio = "1.28.1" + # [patch."https://github.com/stellar/rs-soroban-env"] diff --git a/cmd/crates/soroban-rpc/Cargo.toml b/cmd/crates/soroban-rpc/Cargo.toml new file mode 100644 index 0000000000..5fe33c89d8 --- /dev/null +++ b/cmd/crates/soroban-rpc/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "soroban-rpc" +description = "Soroban RPC client for rust" +homepage = "https://github.com/stellar/soroban-tools" +repository = "https://github.com/stellar/soroban-tools" +authors = ["Stellar Development Foundation "] +license = "Apache-2.0" +readme = "README.md" +version.workspace = true +edition = "2021" +rust-version = "1.70" +autobins = false + + +[lib] +crate-type = ["rlib"] + + +[dependencies] +soroban-sdk = { workspace = true } +soroban-spec-tools = { workspace = true } + +soroban-env-host = { workspace = true } +stellar-strkey = { workspace = true } +stellar-xdr = { workspace = true, features = ["curr", "std", "serde"] } +soroban-spec = { workspace = true } + + +termcolor = { workspace = true } +termcolor_output = { wokspace = true } +clap = { workspace = true } + +serde_json = { workspace = true } +serde-aux = { workspace = true } +itertools = { workspace = true } +ethnum = { workspace = true } +hex = { workspace = true } +wasmparser = { workspace = true } +base64 = { workspace = true } +thiserror = { worspace = true } +serde = { workspace = true } +tokio = { workspace = true } +sha2 = { workspace = true } +ed25519-dalek = { workspace = true } +tracing = { workspace = true } + + +# networking +jsonrpsee-http-client = { workspace = true } +jsonrpsee-core = { workspace = true } +http = { workspace = true } + +# soroban-ledger-snapshot = { workspace = true } +# soroban-sdk = { workspace = true } +# sep5 = { workspace = true } + + +[dev-dependencies] +which = { workspace = true } diff --git a/cmd/crates/soroban-rpc/README.md b/cmd/crates/soroban-rpc/README.md new file mode 100644 index 0000000000..9185b7fd05 --- /dev/null +++ b/cmd/crates/soroban-rpc/README.md @@ -0,0 +1,3 @@ +# soroban-rpc + +Tools and utilities for soroban rpc. diff --git a/cmd/crates/soroban-rpc/src/fixtures/event_response.json b/cmd/crates/soroban-rpc/src/fixtures/event_response.json new file mode 100644 index 0000000000..6f520fdfd5 --- /dev/null +++ b/cmd/crates/soroban-rpc/src/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/crates/soroban-rpc/src/lib.rs b/cmd/crates/soroban-rpc/src/lib.rs new file mode 100644 index 0000000000..653e15b205 --- /dev/null +++ b/cmd/crates/soroban-rpc/src/lib.rs @@ -0,0 +1,1207 @@ +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::DepthLimitedRead; +use soroban_env_host::xdr::{ + self, AccountEntry, AccountId, ContractDataEntry, DiagnosticEvent, Error as XdrError, + LedgerEntryData, LedgerFootprint, LedgerKey, LedgerKeyAccount, PublicKey, ReadXdr, + SorobanAuthorizationEntry, SorobanResources, SorobanTransactionData, Transaction, + TransactionEnvelope, TransactionMeta, TransactionMetaV3, TransactionResult, Uint256, VecM, + WriteXdr, +}; +use soroban_sdk::token; +use std::{ + fmt::Display, + str::FromStr, + time::{Duration, Instant}, +}; +use stellar_xdr::curr::ContractEventType; +use termcolor::{Color, ColorChoice, StandardStream, WriteColor}; +use termcolor_output::colored; +use tokio::time::sleep; + +pub mod log; +mod txn; + +pub use 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] soroban_spec_tools::contract::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, + + #[error("Missing result for tnx")] + MissingOp, + #[error("A simulation is not a transaction")] + NotSignedTransaction, +} + +#[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", + deserialize_with = "deserialize_number_from_string" + )] + 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(ReadXdr::from_xdr_base64) + .transpose()?, + result: self.result_xdr.map(ReadXdr::from_xdr_base64).transpose()?, + result_meta: self + .result_meta_xdr + .map(ReadXdr::from_xdr_base64) + .transpose()?, + }) + } +} + +impl GetTransactionResponse { + pub fn return_value(&self) -> Result { + if let Some(xdr::TransactionMeta::V3(xdr::TransactionMetaV3 { + soroban_meta: Some(xdr::SorobanTransactionMeta { return_value, .. }), + .. + })) = self.result_meta.as_ref() + { + Ok(return_value.clone()) + } else { + Err(Error::MissingOp) + } + } + + pub fn events(&self) -> Result, Error> { + if let Some(meta) = self.result_meta.as_ref() { + Ok(extract_events(meta)) + } else { + Err(Error::MissingOp) + } + } + + pub fn contract_events(&self) -> Result, Error> { + Ok(self + .events()? + .into_iter() + .filter(|e| matches!(e.event.type_, ContractEventType::Contract)) + .collect::>()) + } +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct LedgerEntryResult { + pub key: String, + pub xdr: String, + #[serde( + rename = "lastModifiedLedgerSeq", + deserialize_with = "deserialize_number_from_string" + )] + 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", + deserialize_with = "deserialize_number_from_string" + )] + 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", + deserialize_with = "deserialize_number_from_string" + )] + pub protocol_version: u32, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct GetLatestLedgerResponse { + pub id: String, + #[serde( + rename = "protocolVersion", + deserialize_with = "deserialize_number_from_string" + )] + 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", + deserialize_with = "deserialize_number_from_string" + )] + 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)?)) + .collect::>()?, + xdr: xdr::ScVal::from_xdr_base64(&r.xdr)?, + }) + }) + .collect() + } + + pub fn events(&self) -> Result, Error> { + self.events + .iter() + .map(|e| Ok(DiagnosticEvent::from_xdr_base64(e)?)) + .collect() + } + + pub fn transaction_data(&self) -> Result { + Ok(SorobanTransactionData::from_xdr_base64( + &self.transaction_data, + )?) + } +} + +#[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", + deserialize_with = "deserialize_number_from_string" + )] + 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: String, + #[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).map_err(|_| std::fmt::Error)?; + writeln!(f, " {scval:?}")?; + } + let scval = xdr::ScVal::from_xdr_base64(&self.value).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)?; + colored!( + stdout, + " {}{:?}{}\n", + fg!(Some(Color::Green)), + scval, + reset!(), + )?; + } + + let scval = xdr::ScVal::from_xdr_base64(&self.value)?; + 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 depth_limit_read = DepthLimitedRead::new(ledger_entry.xdr.as_bytes(), 100); + if let LedgerEntryData::Account(entry) = + LedgerEntryData::read_xdr_base64(&mut depth_limit_read)? + { + tracing::trace!(account=?entry); + Ok(entry) + } else { + Err(Error::InvalidResponse) + } + } + + pub async fn send_transaction( + &self, + tx: &TransactionEnvelope, + ) -> Result { + 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()?]) + .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 DepthLimitedRead::new( + x.as_bytes(), + 100, + )) + .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:#?}"); + + return Ok(response); + } + "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()?; + let response: SimulateTransactionResponse = self + .client()? + .request("simulateTransaction", rpc_params![base64_tx]) + .await?; + tracing::trace!("Simulation response:\n {response:#?}"); + match response.error { + None => Ok(response), + Some(e) => { + log::diagnostic_events(&response.events, tracing::Level::ERROR); + Err(Error::TransactionSimulationFailed(e)) + } + } + } + + pub async fn send_assembled_transaction( + &self, + txn: txn::Assembled, + source_key: &ed25519_dalek::SigningKey, + signers: &[ed25519_dalek::SigningKey], + network_passphrase: &str, + log_events: Option, + log_resources: Option, + ) -> Result { + 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 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 { + let txn = txn::Assembled::new(tx_without_preflight, self).await?; + self.send_assembled_transaction( + txn, + source_key, + signers, + network_passphrase, + log_events, + log_resources, + ) + .await + } + + pub async fn create_assembled_transaction( + &self, + txn: &Transaction, + ) -> Result { + txn::Assembled::new(txn, self).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(); + if base64_result.is_err() { + return Err(Error::Xdr(XdrError::Invalid)); + } + base64_keys.push(k.to_xdr_base64().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)?, + val: LedgerEntryData::from_xdr_base64(xdr)?, + 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.to_string())?, + 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)? { + 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)? { + 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(soroban_spec_tools::contract::Contract::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/crates/soroban-rpc/src/log.rs b/cmd/crates/soroban-rpc/src/log.rs new file mode 100644 index 0000000000..3612681484 --- /dev/null +++ b/cmd/crates/soroban-rpc/src/log.rs @@ -0,0 +1,2 @@ +pub mod diagnostic_events; +pub use diagnostic_events::*; diff --git a/cmd/crates/soroban-rpc/src/log/diagnostic_events.rs b/cmd/crates/soroban-rpc/src/log/diagnostic_events.rs new file mode 100644 index 0000000000..68af67a4eb --- /dev/null +++ b/cmd/crates/soroban-rpc/src/log/diagnostic_events.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/crates/soroban-rpc/src/txn.rs b/cmd/crates/soroban-rpc/src/txn.rs new file mode 100644 index 0000000000..afb314e26d --- /dev/null +++ b/cmd/crates/soroban-rpc/src/txn.rs @@ -0,0 +1,637 @@ +use ed25519_dalek::Signer; +use sha2::{Digest, Sha256}; +use soroban_env_host::xdr::{ + self, AccountId, DecoratedSignature, ExtensionPoint, Hash, HashIdPreimage, + HashIdPreimageSorobanAuthorization, InvokeHostFunctionOp, LedgerFootprint, 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::{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()?).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) + } + } + + #[must_use] + pub fn bump_seq_num(mut self) -> Self { + self.txn.seq_num.0 += 1; + self + } + + pub fn auth(&self) -> VecM { + self.txn + .operations + .get(0) + .and_then(|op| match op.body { + OperationBody::InvokeHostFunction(ref body) => (matches!( + body.auth.get(0).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(()) + } + + pub fn requires_auth(&self) -> bool { + requires_auth(&self.txn) + } + + pub fn is_view(&self) -> bool { + if let TransactionExt::V1(SorobanTransactionData { + resources: + SorobanResources { + footprint: LedgerFootprint { read_write, .. }, + .. + }, + .. + }) = &self.txn.ext + { + if read_write.is_empty() { + return true; + } + }; + !self.requires_auth() + } +} + +// 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(SorobanAuthorizationEntry::from_xdr_base64) + .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 = 100; + // 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) +} + +fn requires_auth(txn: &Transaction) -> bool { + let [Operation { + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { auth, .. }), + .. + }] = txn.operations.as_slice() + else { + return false; + }; + matches!( + auth.get(0).map(|x| &x.root_invocation.function), + Some(&SorobanAuthorizedFunction::ContractFn(_)) + ) +} + +// 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 = if requires_auth(&tx) { + tx.operations[0].clone() + } else { + 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()?; + + 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)?; + 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().unwrap()], + xdr: ScVal::U32(0).to_xdr_base64().unwrap(), + }], + transaction_data: transaction_data().to_xdr_base64().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().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().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/crates/soroban-spec-tools/Cargo.toml b/cmd/crates/soroban-spec-tools/Cargo.toml index bed9d8e6b1..81b31487fc 100644 --- a/cmd/crates/soroban-spec-tools/Cargo.toml +++ b/cmd/crates/soroban-spec-tools/Cargo.toml @@ -20,6 +20,8 @@ crate-type = ["rlib"] soroban-spec = { workspace = true } stellar-strkey = { workspace = true } stellar-xdr = { workspace = true, features = ["curr", "std", "serde"] } +soroban-env-host = { workspace = true } + serde_json = { workspace = true } itertools = { workspace = true } ethnum = { workspace = true } diff --git a/cmd/crates/soroban-spec-tools/src/contract.rs b/cmd/crates/soroban-spec-tools/src/contract.rs new file mode 100644 index 0000000000..d73f5e1760 --- /dev/null +++ b/cmd/crates/soroban-spec-tools/src/contract.rs @@ -0,0 +1,274 @@ +use base64::{engine::general_purpose::STANDARD as base64, Engine as _}; +use std::{ + fmt::Display, + io::{self, Cursor}, +}; + +use soroban_env_host::xdr::{ + self, DepthLimitedRead, ReadXdr, ScEnvMetaEntry, ScMetaEntry, ScMetaV0, ScSpecEntry, + ScSpecFunctionV0, ScSpecUdtEnumV0, ScSpecUdtErrorEnumV0, ScSpecUdtStructV0, ScSpecUdtUnionV0, + StringM, WriteXdr, +}; + +pub struct Contract { + 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 Contract { + 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 depth_limit_read = DepthLimitedRead::new(cursor, 100); + ScEnvMetaEntry::read_xdr_iter(&mut depth_limit_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 = DepthLimitedRead::new(cursor, 100); + 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 depth_limit_read = DepthLimitedRead::new(cursor, 100); + ScSpecEntry::read_xdr_iter(&mut depth_limit_read) + .collect::, xdr::Error>>()? + } else { + vec![] + }; + + Ok(Contract { + 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()?))) + .collect::, Error>>()? + .join(",\n"); + Ok(format!("[{spec}]")) + } +} + +impl Display for Contract { + 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_string_lossy())?; + if func.doc.len() > 0 { + writeln!( + f, + " Docs: {}", + &indent(&func.doc.to_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_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_string_lossy(), 10).trim() + )?; + } + writeln!(f, " Fields:")?; + for field in udt.fields.iter() { + writeln!( + f, + " • {}: {}", + field.name.to_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_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_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_string_lossy(), name.to_string_lossy()) + } else { + name.to_string_lossy() + } +} diff --git a/cmd/crates/soroban-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index a95bc94549..de3c29a795 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -13,6 +13,7 @@ use stellar_xdr::curr::{ UInt128Parts, UInt256Parts, Uint256, VecM, }; +pub mod contract; pub mod utils; #[derive(thiserror::Error, Debug)] diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 95c6643932..cd5680f53b 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -41,34 +41,38 @@ soroban-spec-json = { workspace = true } soroban-spec-rust = { workspace = true } soroban-spec-tools = { workspace = true } soroban-spec-typescript = { workspace = true } +soroban-rpc = { workspace = true } soroban-ledger-snapshot = { workspace = true } stellar-strkey = { workspace = true } soroban-sdk = { workspace = true } -clap = { version = "4.1.8", features = [ + +clap = { workspace = true, features = [ "derive", "env", "deprecated", "string", ] } +clap_complete = {workspace = true} + 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" +serde-aux = { workspace = true } + 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" +termcolor = { workspace = true } +termcolor_output = { workspace = true } 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" +ed25519-dalek = { workspace = true } +jsonrpsee-http-client = { workspace = true } +jsonrpsee-core = { workspace = true } hyper = "0.14.27" hyper-tls = "0.5" http = "0.2.9" diff --git a/cmd/soroban-cli/src/commands/config/network/mod.rs b/cmd/soroban-cli/src/commands/config/network/mod.rs index 59b1d7971f..de75150857 100644 --- a/cmd/soroban-cli/src/commands/config/network/mod.rs +++ b/cmd/soroban-cli/src/commands/config/network/mod.rs @@ -3,12 +3,10 @@ use std::str::FromStr; use clap::{arg, Parser}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use soroban_rpc::Client; use stellar_strkey::ed25519::PublicKey; -use crate::{ - commands::HEADING_RPC, - rpc::{self, Client}, -}; +use crate::commands::HEADING_RPC; use super::locator; @@ -43,7 +41,7 @@ pub enum Error { #[error("network arg or rpc url and network passphrase are required if using the network")] Network, #[error(transparent)] - Rpc(#[from] rpc::Error), + Rpc(#[from] soroban_rpc::Error), #[error(transparent)] Hyper(#[from] hyper::Error), #[error("Failed to parse JSON from {0}, {1}")] diff --git a/cmd/soroban-cli/src/commands/contract/deploy.rs b/cmd/soroban-cli/src/commands/contract/deploy.rs index 79365bc858..f154282bd1 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy.rs @@ -15,10 +15,10 @@ use soroban_env_host::{ }, HostError, }; +use soroban_rpc::Client; use crate::{ commands::{config, contract::install, HEADING_RPC}, - rpc::{self, Client}, utils, wasm, }; @@ -80,7 +80,7 @@ pub enum Error { #[error("Must provide either --wasm or --wash-hash")] WasmNotProvided, #[error(transparent)] - Rpc(#[from] rpc::Error), + Rpc(#[from] soroban_rpc::Error), #[error(transparent)] Config(#[from] config::Error), #[error(transparent)] @@ -155,15 +155,7 @@ impl Cmd { &key, )?; client - .prepare_and_send_transaction( - &tx, - &key, - &[], - &network.network_passphrase, - None, - None, - true, - ) + .prepare_and_send_transaction(&tx, &key, &[], &network.network_passphrase, None, None) .await?; Ok(stellar_strkey::Contract(contract_id.0).to_string()) } diff --git a/cmd/soroban-cli/src/commands/contract/extend.rs b/cmd/soroban-cli/src/commands/contract/extend.rs index 971dc83a3b..8a3d071943 100644 --- a/cmd/soroban-cli/src/commands/contract/extend.rs +++ b/cmd/soroban-cli/src/commands/contract/extend.rs @@ -8,12 +8,9 @@ use soroban_env_host::xdr::{ TransactionMeta, TransactionMetaV3, TtlEntry, Uint256, }; -use crate::{ - commands::config, - key, - rpc::{self, Client}, - wasm, Pwd, -}; +use soroban_rpc::Client; + +use crate::{commands::config, key, wasm, Pwd}; const MAX_LEDGERS_TO_EXTEND: u32 = 535_679; @@ -70,7 +67,7 @@ pub enum Error { #[error("missing operation result")] MissingOperationResult, #[error(transparent)] - Rpc(#[from] rpc::Error), + Rpc(#[from] soroban_rpc::Error), #[error(transparent)] Wasm(#[from] wasm::Error), #[error(transparent)] @@ -145,15 +142,7 @@ impl Cmd { }; let res = client - .prepare_and_send_transaction( - &tx, - &key, - &[], - &network.network_passphrase, - None, - None, - true, - ) + .prepare_and_send_transaction(&tx, &key, &[], &network.network_passphrase, None, None) .await?; let events = res.events()?; @@ -161,7 +150,6 @@ impl Cmd { tracing::info!("Events:\n {events:#?}"); } let meta = res - .as_signed()? .result_meta .as_ref() .ok_or(Error::MissingOperationResult)?; diff --git a/cmd/soroban-cli/src/commands/contract/fetch.rs b/cmd/soroban-cli/src/commands/contract/fetch.rs index 9929f38320..812591c008 100644 --- a/cmd/soroban-cli/src/commands/contract/fetch.rs +++ b/cmd/soroban-cli/src/commands/contract/fetch.rs @@ -16,15 +16,13 @@ use soroban_env_host::{ }, }; +use soroban_rpc::Client; use soroban_spec::read::FromWasmError; use stellar_strkey::DecodeError; use super::super::config::{self, locator}; use crate::commands::config::network::{self, Network}; -use crate::{ - rpc::{self, Client}, - utils, Pwd, -}; +use crate::{utils, Pwd}; #[derive(Parser, Debug, Default, Clone)] #[allow(clippy::struct_excessive_bools)] @@ -60,7 +58,7 @@ impl Pwd for Cmd { #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] - Rpc(#[from] rpc::Error), + Rpc(#[from] soroban_rpc::Error), #[error(transparent)] Config(#[from] config::Error), #[error(transparent)] diff --git a/cmd/soroban-cli/src/commands/contract/install.rs b/cmd/soroban-cli/src/commands/contract/install.rs index d894b20473..be4791f322 100644 --- a/cmd/soroban-cli/src/commands/contract/install.rs +++ b/cmd/soroban-cli/src/commands/contract/install.rs @@ -8,10 +8,10 @@ use soroban_env_host::xdr::{ OperationBody, Preconditions, ScMetaEntry, ScMetaV0, SequenceNumber, Transaction, TransactionExt, TransactionResult, TransactionResultResult, Uint256, VecM, }; +use soroban_rpc::Client; use super::restore; use crate::key; -use crate::rpc::{self, Client}; use crate::{commands::config, utils, wasm}; const CONTRACT_META_SDK_KEY: &str = "rssdkver"; @@ -41,7 +41,7 @@ pub enum Error { #[error("jsonrpc error: {0}")] JsonRpc(#[from] jsonrpsee_core::Error), #[error(transparent)] - Rpc(#[from] rpc::Error), + Rpc(#[from] soroban_rpc::Error), #[error(transparent)] Config(#[from] config::Error), #[error(transparent)] @@ -116,11 +116,10 @@ impl Cmd { &network.network_passphrase, None, None, - true, ) .await? - .as_signed()? .result + .as_ref() { // Now just need to restore it and don't have to install again restore::Cmd { diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index d1f3ab5ff5..4a0a770015 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -30,10 +30,10 @@ use super::super::{ }; use crate::{ commands::global, - rpc::{self, Client}, utils::{self, contract_spec}, Pwd, }; +use soroban_rpc::Client; use soroban_spec_tools::Spec; #[derive(Parser, Debug, Default, Clone)] @@ -114,7 +114,7 @@ pub enum Error { #[error("error parsing int: {0}")] ParseIntError(#[from] ParseIntError), #[error(transparent)] - Rpc(#[from] rpc::Error), + Rpc(#[from] soroban_rpc::Error), #[error("unexpected contract code data type: {0:?}")] UnexpectedContractCodeDataType(LedgerEntryData), #[error("missing operation result")] @@ -300,20 +300,30 @@ impl Cmd { &key, )?; - let res = 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), - false, + let txn = client.create_assembled_transaction(&tx).await?; + + let (return_value, events) = if txn.is_view() { + ( + txn.sim_res().results()?[0].xdr.clone(), + txn.sim_res().events()?, ) - .await?; - crate::log::diagnostic_events(&res.contract_events()?, tracing::Level::INFO); - output_to_string(&spec, &res.return_value()?, &function) + } else { + let res = client + .send_assembled_transaction( + txn, + &key, + &signers, + &network.network_passphrase, + Some(log_events), + (global_args.verbose || global_args.very_verbose || self.cost) + .then_some(log_resources), + ) + .await?; + (res.return_value()?, res.contract_events()?) + }; + + crate::log::diagnostic_events(&events, tracing::Level::INFO); + output_to_string(&spec, &return_value, &function) } pub fn read_wasm(&self) -> Result>, Error> { diff --git a/cmd/soroban-cli/src/commands/contract/read.rs b/cmd/soroban-cli/src/commands/contract/read.rs index 8fa9432733..cb4262660e 100644 --- a/cmd/soroban-cli/src/commands/contract/read.rs +++ b/cmd/soroban-cli/src/commands/contract/read.rs @@ -11,12 +11,9 @@ use soroban_env_host::{ }, HostError, }; +use soroban_rpc::{Client, FullLedgerEntries, FullLedgerEntry}; -use crate::{ - commands::config, - key, - rpc::{self, Client, FullLedgerEntries, FullLedgerEntry}, -}; +use crate::{commands::config, key}; #[derive(Parser, Debug, Clone)] #[group(skip)] @@ -73,7 +70,7 @@ pub enum Error { #[error("either `--key` or `--key-xdr` are required when querying a network")] KeyIsRequired, #[error(transparent)] - Rpc(#[from] rpc::Error), + Rpc(#[from] soroban_rpc::Error), #[error(transparent)] Xdr(#[from] XdrError), #[error(transparent)] diff --git a/cmd/soroban-cli/src/commands/contract/restore.rs b/cmd/soroban-cli/src/commands/contract/restore.rs index 1cb6898b12..17a0f57308 100644 --- a/cmd/soroban-cli/src/commands/contract/restore.rs +++ b/cmd/soroban-cli/src/commands/contract/restore.rs @@ -7,6 +7,7 @@ use soroban_env_host::xdr::{ RestoreFootprintOp, SequenceNumber, SorobanResources, SorobanTransactionData, Transaction, TransactionExt, TransactionMeta, TransactionMetaV3, TtlEntry, Uint256, }; +use soroban_rpc::Client; use stellar_strkey::DecodeError; use crate::{ @@ -14,9 +15,7 @@ use crate::{ config::{self, locator}, contract::extend, }, - key, - rpc::{self, Client}, - wasm, Pwd, + key, wasm, Pwd, }; #[derive(Parser, Debug, Clone)] @@ -75,7 +74,7 @@ pub enum Error { #[error("missing operation result")] MissingOperationResult, #[error(transparent)] - Rpc(#[from] rpc::Error), + Rpc(#[from] soroban_rpc::Error), #[error(transparent)] Wasm(#[from] wasm::Error), #[error(transparent)] @@ -149,19 +148,10 @@ impl Cmd { }; let res = client - .prepare_and_send_transaction( - &tx, - &key, - &[], - &network.network_passphrase, - None, - None, - true, - ) + .prepare_and_send_transaction(&tx, &key, &[], &network.network_passphrase, None, None) .await?; let meta = res - .as_signed()? .result_meta .as_ref() .ok_or(Error::MissingOperationResult)?; diff --git a/cmd/soroban-cli/src/commands/events.rs b/cmd/soroban-cli/src/commands/events.rs index 002920de20..7e56c1f82b 100644 --- a/cmd/soroban-cli/src/commands/events.rs +++ b/cmd/soroban-cli/src/commands/events.rs @@ -1,10 +1,10 @@ use clap::{arg, command, Parser}; use std::io; -use soroban_env_host::xdr::{self, ReadXdr}; - use super::config::{locator, network}; -use crate::{rpc, utils}; +use crate::utils; +use soroban_env_host::xdr::{self, ReadXdr}; +use soroban_rpc as rpc; #[derive(Parser, Debug, Clone)] #[group(skip)] @@ -106,7 +106,7 @@ pub enum Error { #[error("missing target")] MissingTarget, #[error(transparent)] - Rpc(#[from] rpc::Error), + Rpc(#[from] soroban_rpc::Error), #[error(transparent)] Generic(#[from] Box), #[error(transparent)] diff --git a/cmd/soroban-cli/src/commands/lab/token/wrap.rs b/cmd/soroban-cli/src/commands/lab/token/wrap.rs index db5a91ea00..81318f9d7d 100644 --- a/cmd/soroban-cli/src/commands/lab/token/wrap.rs +++ b/cmd/soroban-cli/src/commands/lab/token/wrap.rs @@ -1,4 +1,5 @@ use clap::{arg, command, Parser}; +use rpc::{Client, Error as SorobanRpcError}; use soroban_env_host::{ xdr::{ Asset, ContractDataDurability, ContractExecutable, ContractIdPreimage, CreateContractArgs, @@ -8,12 +9,12 @@ use soroban_env_host::{ }, HostError, }; +use soroban_rpc as rpc; 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}, }; @@ -92,7 +93,7 @@ impl Cmd { )?; client - .prepare_and_send_transaction(&tx, &key, &[], network_passphrase, None, None, true) + .prepare_and_send_transaction(&tx, &key, &[], network_passphrase, None, None) .await?; Ok(stellar_strkey::Contract(contract_id.0).to_string()) diff --git a/cmd/soroban-cli/src/lib.rs b/cmd/soroban-cli/src/lib.rs index 3aad487c82..311ac43a19 100644 --- a/cmd/soroban-cli/src/lib.rs +++ b/cmd/soroban-cli/src/lib.rs @@ -7,7 +7,7 @@ pub mod commands; pub mod fee; pub mod key; pub mod log; -pub mod rpc; +// pub mod rpc; pub mod toid; pub mod utils; pub mod wasm; diff --git a/cmd/soroban-cli/src/utils.rs b/cmd/soroban-cli/src/utils.rs index 27faee7334..8a8a9296df 100644 --- a/cmd/soroban-cli/src/utils.rs +++ b/cmd/soroban-cli/src/utils.rs @@ -55,16 +55,7 @@ pub fn sign_transaction( /// /// 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) + Ok(stellar_strkey::Contract::from_string(contract_id)?.0) } /// # Errors