From 0e96d0f52b0dcdb7e160f0f585028ed5bd5c97b9 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 8 Jul 2026 16:46:45 -0300 Subject: [PATCH 1/7] Add token transfer command for SEP-41 and asset contracts. --- FULL_HELP_DOCS.md | 51 +++++ .../soroban-test/tests/it/integration.rs | 1 + .../tests/it/integration/token/mod.rs | 81 +++++++ .../tests/it/integration/token/transfer.rs | 132 ++++++++++++ cmd/soroban-cli/src/cli.rs | 3 + .../src/commands/contract/invoke.rs | 47 ++++- cmd/soroban-cli/src/commands/mod.rs | 9 + cmd/soroban-cli/src/commands/token/args.rs | 126 +++++++++++ cmd/soroban-cli/src/commands/token/mod.rs | 25 +++ .../src/commands/token/transfer.rs | 199 ++++++++++++++++++ 10 files changed, 671 insertions(+), 3 deletions(-) create mode 100644 cmd/crates/soroban-test/tests/it/integration/token/mod.rs create mode 100644 cmd/crates/soroban-test/tests/it/integration/token/transfer.rs create mode 100644 cmd/soroban-cli/src/commands/token/args.rs create mode 100644 cmd/soroban-cli/src/commands/token/mod.rs create mode 100644 cmd/soroban-cli/src/commands/token/transfer.rs diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 05ff78a7eb..4b4aaea55a 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -51,6 +51,7 @@ Anything after the `--` double dash (the "slop") is parsed as arguments to the c - `container` — Start local networks in containers - `config` — Manage CLI configuration - `snapshot` — Download a snapshot of a ledger from an archive +- `token` — Interact with SEP-41 tokens and Stellar Asset Contracts - `tx` — Sign, Simulate, and Send transactions - `xdr` — Decode and encode XDR - `strkey` — Decode and encode strkey @@ -1839,6 +1840,56 @@ This allows combining snapshots from different contract deployments or manually Default value: `snapshot.json` +## `stellar token` + +Interact with SEP-41 tokens and Stellar Asset Contracts + +**Usage:** `stellar token ` + +###### **Subcommands:** + +- `transfer` — Transfer tokens from one account to another + +## `stellar token transfer` + +Transfer tokens from one account to another + +**Usage:** `stellar token transfer [OPTIONS] --id --from --to --amount ` + +###### **Global Options:** + +- `--config-dir ` — Location of config directory. By default, it uses `$XDG_CONFIG_HOME/stellar` if set, falling back to `~/.config/stellar` otherwise. Contains configuration files, aliases, and other persistent settings + +###### **Options:** + +- `--id ` — The token to transfer from: a contract id or alias, `native`, or a classic asset as `CODE:ISSUER` +- `--from ` — Account to transfer tokens from. Signs and authorizes the transfer, so it must be an identity or secret key you control +- `--to ` — Account to transfer the tokens to +- `--amount ` — Amount to transfer, in the token's smallest unit (stroops for a Stellar Asset Contract) +- `--output ` — Format of the output + + Default value: `text` + + Possible values: + - `text`: Human-readable text + - `json`: Compact, single-line JSON receipt + - `json-formatted`: Formatted (multiline) JSON receipt + +###### **RPC Options:** + +- `--rpc-url ` — RPC server endpoint +- `--rpc-header ` — RPC Header(s) to include in requests to the RPC provider, example: "X-API-Key: abc123". Multiple headers can be added by passing the option multiple times +- `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server +- `-n`, `--network ` — Name of network to use from config + +###### **Signing Options:** + +- `--sign-with-key ` — Sign with a local key or key saved in OS secure storage. Can be an identity (--sign-with-key alice), a secret key (--sign-with-key SC36…), or a seed phrase (--sign-with-key "kite urban…"). If using seed phrase, `--hd-path` defaults to the `0` path +- `--hd-path ` — If using a seed phrase to sign, sets which hierarchical deterministic path to use, e.g. `m/44'/148'/{hd_path}`. Example: `--hd-path 1`. Default: `0` +- `--sign-with-lab` — Sign with https://lab.stellar.org +- `--sign-with-ledger` — Sign with a ledger wallet +- `--auto-sign` — Sign without prompting for approval. Only applies to signatures that require user approval, like non-root Soroban auth entries + ## `stellar tx` Sign, Simulate, and Send transactions diff --git a/cmd/crates/soroban-test/tests/it/integration.rs b/cmd/crates/soroban-test/tests/it/integration.rs index 9fa5a46ad9..0aa2bfd864 100644 --- a/cmd/crates/soroban-test/tests/it/integration.rs +++ b/cmd/crates/soroban-test/tests/it/integration.rs @@ -14,5 +14,6 @@ mod ledger; mod network; mod secure_store; mod snapshot; +mod token; mod tx; mod util; diff --git a/cmd/crates/soroban-test/tests/it/integration/token/mod.rs b/cmd/crates/soroban-test/tests/it/integration/token/mod.rs new file mode 100644 index 0000000000..3f770c1357 --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/integration/token/mod.rs @@ -0,0 +1,81 @@ +pub mod transfer; + +use soroban_test::{AssertExt, TestEnv}; + +/// Establish (and auto-authorize) a trustline from `source` to `asset`. +pub fn add_trustline(sandbox: &TestEnv, source: &str, asset: &str) { + sandbox + .new_assert_cmd("tx") + .args(["new", "change-trust", "--line", asset, "--source", source]) + .assert() + .success(); +} + +/// Pay `amount` of `asset` from its issuer to `destination`. +pub fn issuer_pays(sandbox: &TestEnv, issuer: &str, destination: &str, asset: &str, amount: i128) { + sandbox + .new_assert_cmd("tx") + .args([ + "new", + "payment", + "--destination", + destination, + "--asset", + asset, + "--amount", + &amount.to_string(), + "--source", + issuer, + ]) + .assert() + .success(); +} + +/// Deploy the Stellar Asset Contract for `asset`, tolerating the case where a +/// prior run already deployed it (the SAC is global to the network). +pub fn deploy_sac(sandbox: &TestEnv, asset: &str, source: &str) { + sandbox + .new_assert_cmd("contract") + .args([ + "asset", + "deploy", + "--asset", + asset, + "--source-account", + source, + ]) + .output() + .expect("failed to run contract asset deploy"); +} + +/// The contract id of the SAC for `asset`. +pub fn sac_id(sandbox: &TestEnv, asset: &str) -> String { + sandbox + .new_assert_cmd("contract") + .args(["id", "asset", "--asset", asset]) + .assert() + .success() + .stdout_as_str() +} + +/// Read a token's balance for `account` through its Stellar Asset Contract, +/// returning the raw stroop amount. +pub fn sac_balance(sandbox: &TestEnv, contract_id: &str, account: &str) -> i128 { + let stdout = sandbox + .new_assert_cmd("contract") + .args([ + "invoke", + "--id", + contract_id, + "--source-account", + "test", + "--", + "balance", + "--id", + account, + ]) + .assert() + .success() + .stdout_as_str(); + stdout.trim().trim_matches('"').parse().unwrap() +} diff --git a/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs b/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs new file mode 100644 index 0000000000..a548d1088f --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs @@ -0,0 +1,132 @@ +use predicates::prelude::*; +use serde_json::Value; +use soroban_test::{AssertExt, TestEnv}; + +use crate::integration::{ + token::{add_trustline, deploy_sac, issuer_pays, sac_balance, sac_id}, + util::{new_account, test_address}, +}; + +/// Run `stellar token transfer ... --output json` and return the parsed receipt. +/// +/// Also asserts that JSON mode keeps stdout pure JSON and does not leak the +/// invoke pipeline's human-readable status logging onto stderr. +fn transfer_json(sandbox: &TestEnv, id: &str, to: &str, amount: i128) -> Value { + let stdout = sandbox + .new_assert_cmd("token") + .args([ + "transfer", + "--id", + id, + "--to", + to, + "--amount", + &amount.to_string(), + "--output", + "json", + "--from", + "test", + ]) + .assert() + .success() + .stderr(predicate::str::contains("Simulating transaction").not()) + .stderr(predicate::str::contains("Sending transaction").not()) + .stdout_as_str(); + serde_json::from_str(&stdout).unwrap() +} + +#[tokio::test] +async fn transfer_native_returns_receipt_and_moves_funds() { + let sandbox = &TestEnv::new(); + let recipient = new_account(sandbox, "recipient"); + + // Ensure the native SAC exists so `--id native` resolves to a live contract. + deploy_sac(sandbox, "native", "test"); + let native_id = sac_id(sandbox, "native"); + + let amount: i128 = 10_000_000; + let before = sac_balance(sandbox, &native_id, &recipient); + + let receipt = transfer_json(sandbox, "native", &recipient, amount); + assert!( + receipt["tx_hash"].as_str().is_some_and(|h| !h.is_empty()), + "expected a non-empty tx_hash, got: {receipt}" + ); + + let after = sac_balance(sandbox, &native_id, &recipient); + assert_eq!(after, before + amount, "recipient balance should increase"); +} + +#[tokio::test] +async fn transfer_issued_asset_succeeds_with_deployed_sac_and_trustlines() { + let sandbox = &TestEnv::new(); + let test = test_address(sandbox); + let issuer = new_account(sandbox, "issuer"); + let recipient = new_account(sandbox, "recipient"); + let asset = format!("USDC:{issuer}"); + + // Both the holder (`test`) and the recipient need trustlines; the holder is + // funded by the issuer so it has a balance to send. + add_trustline(sandbox, "test", &asset); + add_trustline(sandbox, "recipient", &asset); + issuer_pays(sandbox, "issuer", &test, &asset, 1_000); + + deploy_sac(sandbox, &asset, "issuer"); + let contract_id = sac_id(sandbox, &asset); + + let amount: i128 = 400; + let before = sac_balance(sandbox, &contract_id, &recipient); + + let receipt = transfer_json(sandbox, &asset, &recipient, amount); + assert!( + receipt["tx_hash"].as_str().is_some_and(|h| !h.is_empty()), + "expected a non-empty tx_hash, got: {receipt}" + ); + + let after = sac_balance(sandbox, &contract_id, &recipient); + assert_eq!(after, before + amount, "recipient balance should increase"); +} + +#[tokio::test] +async fn transfer_fails_when_sac_not_deployed() { + let sandbox = &TestEnv::new(); + let issuer = new_account(sandbox, "issuer"); + let recipient = new_account(sandbox, "recipient"); + let asset = format!("USDC:{issuer}"); + + // The SAC for this issued asset was never deployed, so the transfer must + // fail with a structured error pointing at `contract asset deploy` rather + // than silently succeeding or leaking a raw RPC error. + sandbox + .new_assert_cmd("token") + .args([ + "transfer", "--id", &asset, "--to", &recipient, "--amount", "1", "--from", "test", + ]) + .assert() + .failure() + .stderr(predicates::str::contains("contract asset deploy")); +} + +#[tokio::test] +async fn transfer_fails_when_recipient_trustline_missing() { + let sandbox = &TestEnv::new(); + let test = test_address(sandbox); + let issuer = new_account(sandbox, "issuer"); + let recipient = new_account(sandbox, "recipient"); + let asset = format!("USDC:{issuer}"); + + // The holder can send, but the recipient never established a trustline, so + // the SAC transfer must fail and name the missing trustline. + add_trustline(sandbox, "test", &asset); + issuer_pays(sandbox, "issuer", &test, &asset, 1_000); + deploy_sac(sandbox, &asset, "issuer"); + + sandbox + .new_assert_cmd("token") + .args([ + "transfer", "--id", &asset, "--to", &recipient, "--amount", "1", "--from", "test", + ]) + .assert() + .failure() + .stderr(predicates::str::contains("trustline entry is missing")); +} diff --git a/cmd/soroban-cli/src/cli.rs b/cmd/soroban-cli/src/cli.rs index fa7d4989d3..afb7929c94 100644 --- a/cmd/soroban-cli/src/cli.rs +++ b/cmd/soroban-cli/src/cli.rs @@ -135,10 +135,13 @@ fn json_error_format(cmd: &commands::Cmd) -> Option { use crate::commands::network; use crate::output::Format; + use crate::commands::token; + let format: Format = match cmd { commands::Cmd::Network(network::Cmd::Health(cmd)) => cmd.output.into(), commands::Cmd::Network(network::Cmd::Info(cmd)) => cmd.output.into(), commands::Cmd::Network(network::Cmd::Settings(cmd)) => cmd.output.into(), + commands::Cmd::Token(token::Cmd::Transfer(cmd)) => cmd.output.into(), _ => return None, }; diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 1093425304..30a48676a0 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -257,13 +257,33 @@ impl Cmd { .await?) } - #[allow(clippy::too_many_lines)] + /// Run the invocation and return only the decoded result, discarding the + /// transaction hash. Kept as the stable entry point for callers that just + /// need the rendered output (e.g. `contract invoke` itself). pub async fn execute( &self, config: &config::Args, quiet: bool, no_cache: bool, ) -> Result, Error> { + Ok( + match self.execute_with_receipt(config, quiet, no_cache).await? { + TxnResult::Txn(tx) => TxnResult::Txn(tx), + TxnResult::Res(receipt) => TxnResult::Res(receipt.output), + }, + ) + } + + /// Run the invocation and return a [`InvokeReceipt`] pairing the decoded + /// result with the submitted transaction's hash. Typed clients (such as + /// `stellar token`) use this to build machine-readable receipts. + #[allow(clippy::too_many_lines)] + pub async fn execute_with_receipt( + &self, + config: &config::Args, + quiet: bool, + no_cache: bool, + ) -> Result, Error> { self.auth_mode.validate_not_enforce()?; let print = print::Print::new(quiet); @@ -348,7 +368,13 @@ impl Cmd { // fall back to raw format since we only have the spec for the invoked contract. crate::log::event::contract_with_spec(&events, &print, Some(&spec)); - return Ok(output_to_string(&spec, &return_value[0].xdr, &function)?); + let output = output_to_string(&spec, &return_value[0].xdr, &function)? + .into_result() + .expect("output_to_string always returns a result"); + return Ok(TxnResult::Res(InvokeReceipt { + tx_hash: None, + output, + })); }; let sequence: i64 = account_details.seq_num.into(); @@ -377,6 +403,7 @@ impl Cmd { ) .await?; + let tx_hash = res.tx_hash.clone(); let return_value = res.return_value()?; let events = extract_events(&res.result_meta.unwrap_or_default()); @@ -386,10 +413,24 @@ impl Cmd { // fall back to raw format since we only have the spec for the invoked contract. crate::log::event::contract_with_spec(&events, &print, Some(&spec)); - Ok(output_to_string(&spec, &return_value, &function)?) + let output = output_to_string(&spec, &return_value, &function)? + .into_result() + .expect("output_to_string always returns a result"); + Ok(TxnResult::Res(InvokeReceipt { tx_hash, output })) } } +/// The outcome of a submitted contract invocation: the decoded return value +/// alongside the hash of the transaction that produced it. +#[derive(Debug, Clone)] +pub struct InvokeReceipt { + /// Hex-encoded hash of the submitted transaction, or `None` when the + /// invocation resolved by simulation only (read-only) and was never sent. + pub tx_hash: Option, + /// The decoded return value, rendered as a string (JSON for most types). + pub output: String, +} + const DEFAULT_ACCOUNT_ID: AccountId = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))); fn default_account_entry() -> AccountEntry { diff --git a/cmd/soroban-cli/src/commands/mod.rs b/cmd/soroban-cli/src/commands/mod.rs index 90e5d9683f..9367463625 100644 --- a/cmd/soroban-cli/src/commands/mod.rs +++ b/cmd/soroban-cli/src/commands/mod.rs @@ -21,6 +21,7 @@ pub mod message; pub mod network; pub mod plugin; pub mod snapshot; +pub mod token; pub mod tx; pub mod version; @@ -120,6 +121,7 @@ impl Root { Cmd::Snapshot(snapshot) => snapshot.run(&self.global_args).await?, Cmd::Version(version) => version.run(), Cmd::Keys(id) => id.run(&self.global_args).await?, + Cmd::Token(token) => token.run(&self.global_args).await?, Cmd::Tx(tx) => tx.run(&self.global_args).await?, Cmd::Ledger(ledger) => ledger.run(&self.global_args).await?, Cmd::Message(message) => message.run(&self.global_args).await?, @@ -186,6 +188,10 @@ pub enum Cmd { #[command(subcommand)] Snapshot(snapshot::Cmd), + /// Interact with SEP-41 tokens and Stellar Asset Contracts + #[command(subcommand)] + Token(token::Cmd), + /// Sign, Simulate, and Send transactions #[command(subcommand)] Tx(tx::Cmd), @@ -269,6 +275,9 @@ pub enum Error { #[error(transparent)] Snapshot(#[from] snapshot::Error), + #[error(transparent)] + Token(#[from] token::Error), + #[error(transparent)] Tx(#[from] tx::Error), diff --git a/cmd/soroban-cli/src/commands/token/args.rs b/cmd/soroban-cli/src/commands/token/args.rs new file mode 100644 index 0000000000..9c3b961ecf --- /dev/null +++ b/cmd/soroban-cli/src/commands/token/args.rs @@ -0,0 +1,126 @@ +use std::str::FromStr; + +use crate::{ + config::{alias::UnresolvedContract, locator}, + output::Format, + tx::builder, + utils::contract_id_hash_from_asset, +}; + +/// Output format shared by the `stellar token` subcommands. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum, Default)] +pub enum OutputFormat { + /// Human-readable text. + #[default] + Text, + /// Compact, single-line JSON receipt. + Json, + /// Formatted (multiline) JSON receipt. + JsonFormatted, +} + +impl From for Format { + fn from(value: OutputFormat) -> Self { + match value { + OutputFormat::Text => Format::Readable, + OutputFormat::Json => Format::Json, + OutputFormat::JsonFormatted => Format::JsonFormatted, + } + } +} + +/// A `stellar token` target, resolved from the `--id` value. +/// +/// The shape of the value decides how it is interpreted: +/// * `native` or `CODE:ISSUER` → a Stellar Asset Contract (SAC), resolved from +/// the asset. +/// * anything else → a contract, either a `C…` strkey or a saved alias. +#[derive(Clone, Debug)] +pub enum TokenTarget { + /// A SEP-41 contract addressed directly by id or alias. + Contract(UnresolvedContract), + /// A Stellar Asset Contract addressed by its underlying classic asset. + Asset(builder::Asset), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Locator(#[from] locator::Error), + #[error(transparent)] + Asset(#[from] builder::asset::Error), +} + +impl FromStr for TokenTarget { + type Err = builder::asset::Error; + + fn from_str(value: &str) -> Result { + // `native` and `CODE:ISSUER` are the two shapes an asset can take; both + // route to a SAC. Everything else is a contract id or alias. + if value == "native" || value.contains(':') { + Ok(TokenTarget::Asset(value.parse()?)) + } else { + // `UnresolvedContract::from_str` is infallible. + Ok(TokenTarget::Contract(value.parse().unwrap())) + } + } +} + +impl TokenTarget { + /// Resolve this target to a concrete contract id for the given network. + pub fn resolve_contract_id( + &self, + locator: &locator::Args, + network_passphrase: &str, + ) -> Result { + match self { + TokenTarget::Contract(contract) => { + Ok(contract.resolve_contract_id(locator, network_passphrase)?) + } + TokenTarget::Asset(asset) => { + let asset = asset.resolve(locator)?; + Ok(contract_id_hash_from_asset(&asset, network_passphrase)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // A valid contract strkey borrowed from the CLI's own help text. + const CONTRACT: &str = "CCR6QKTWZQYW6YUJ7UP7XXZRLWQPFRV6SWBLQS4ZQOSAF4BOUD77OTE2"; + + #[test] + fn native_parses_as_asset() { + assert!(matches!( + "native".parse::().unwrap(), + TokenTarget::Asset(builder::Asset::Native) + )); + } + + #[test] + fn code_issuer_parses_as_asset() { + assert!(matches!( + "USDC:issuer".parse::().unwrap(), + TokenTarget::Asset(builder::Asset::Asset(..)) + )); + } + + #[test] + fn contract_strkey_parses_as_resolved_contract() { + assert!(matches!( + CONTRACT.parse::().unwrap(), + TokenTarget::Contract(UnresolvedContract::Resolved(_)) + )); + } + + #[test] + fn bare_name_parses_as_contract_alias() { + assert!(matches!( + "alice".parse::().unwrap(), + TokenTarget::Contract(UnresolvedContract::Alias(_)) + )); + } +} diff --git a/cmd/soroban-cli/src/commands/token/mod.rs b/cmd/soroban-cli/src/commands/token/mod.rs new file mode 100644 index 0000000000..9330588f5f --- /dev/null +++ b/cmd/soroban-cli/src/commands/token/mod.rs @@ -0,0 +1,25 @@ +pub mod args; +pub mod transfer; + +use crate::commands::global; + +#[derive(Debug, clap::Subcommand)] +pub enum Cmd { + /// Transfer tokens from one account to another + Transfer(transfer::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Transfer(#[from] transfer::Error), +} + +impl Cmd { + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + match self { + Cmd::Transfer(cmd) => cmd.run(global_args).await?, + } + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/token/transfer.rs b/cmd/soroban-cli/src/commands/token/transfer.rs new file mode 100644 index 0000000000..4e829099e8 --- /dev/null +++ b/cmd/soroban-cli/src/commands/token/transfer.rs @@ -0,0 +1,199 @@ +use std::ffi::OsString; + +use clap::Parser; + +use crate::{ + commands::{ + contract::invoke, + global, + token::args::{self, OutputFormat, TokenTarget}, + }, + config::{self, locator, network, sign_with, UnresolvedContract, UnresolvedMuxedAccount}, + output::Output, +}; + +#[derive(Debug, Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// The token to transfer from: a contract id or alias, `native`, or a + /// classic asset as `CODE:ISSUER`. + #[arg(long = "id")] + pub id: TokenTarget, + + /// Account to transfer tokens from. Signs and authorizes the transfer, so it + /// must be an identity or secret key you control. + #[arg(long)] + pub from: UnresolvedMuxedAccount, + + /// Account to transfer the tokens to. + #[arg(long)] + pub to: UnresolvedMuxedAccount, + + /// Amount to transfer, in the token's smallest unit (stroops for a Stellar + /// Asset Contract). + #[arg(long)] + pub amount: i128, + + /// Format of the output. + #[arg(long, default_value = "text")] + pub output: OutputFormat, + + #[command(flatten)] + pub network: network::Args, + + #[command(flatten)] + pub locator: locator::Args, + + #[command(flatten)] + pub sign_with: sign_with::Args, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Config(#[from] config::Error), + #[error(transparent)] + Network(#[from] network::Error), + #[error(transparent)] + Args(#[from] args::Error), + #[error(transparent)] + Invoke(#[from] invoke::Error), + #[error(transparent)] + Serde(#[from] serde_json::Error), + + #[error( + "the Stellar Asset Contract {0} is not deployed on this network.\n\ + Deploy it first with `stellar contract asset deploy --asset `, then retry." + )] + SacNotDeployed(String), + + #[error("contract {0} was not found on this network")] + ContractNotFound(String), +} + +/// The machine-readable receipt of a token transfer. +#[derive(Debug, serde::Serialize)] +struct Receipt { + /// Hex-encoded hash of the submitted transaction. + tx_hash: Option, + /// The decoded contract return value (`null` for SEP-41 `transfer`, which + /// returns nothing). + result: serde_json::Value, +} + +impl Cmd { + /// Assemble a full [`config::Args`] for the underlying invocation, using + /// `--from` as the source account that signs and authorizes the transfer. + /// Fees are left unset so the pipeline applies its default inclusion fee — + /// this command intentionally exposes no fee or sequence knobs. + fn config(&self) -> config::Args { + config::Args { + network: self.network.clone(), + source_account: self.from.clone(), + locator: self.locator.clone(), + sign_with: self.sign_with.clone(), + fee: None, + inclusion_fee: None, + } + } + + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let output = Output::new(self.output.into(), global_args.quiet); + // In JSON mode the underlying invoke pipeline's human-readable status + // logging (which writes to stderr) would still fire; run it quietly so + // machine consumers get clean output without needing `--quiet`. + let quiet = global_args.quiet || output.is_json(); + let config = self.config(); + let network = config.get_network()?; + + let contract_id = self + .id + .resolve_contract_id(&config.locator, &network.network_passphrase)?; + + // SEP-41 `transfer(from, to, amount)`: `from` is the source account + // (which also signs and authorizes), `to` is the destination. + let from = config.source_account()?.to_string(); + let to = self + .to + .resolve_muxed_account(&config.locator, None) + .map_err(config::Error::from)? + .to_string(); + let amount = self.amount.to_string(); + + let slop: Vec = [ + "transfer", "--from", &from, "--to", &to, "--amount", &amount, + ] + .into_iter() + .map(OsString::from) + .collect(); + + let invoke_cmd = invoke::Cmd { + contract_id: UnresolvedContract::Resolved(contract_id), + slop, + config: config.clone(), + ..Default::default() + }; + + let receipt = invoke_cmd + .execute_with_receipt(&config, quiet, global_args.no_cache) + .await + .map_err(|e| self.map_invoke_error(e, &contract_id))? + .into_result(); + + // `transfer` always writes, so the invocation is submitted rather than + // resolved as a build-only transaction; a missing receipt would mean + // `--build-only`, which this command never sets. + let Some(receipt) = receipt else { + return Ok(()); + }; + + let result = if receipt.output.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_str(&receipt.output) + .unwrap_or(serde_json::Value::String(receipt.output.clone())) + }; + + // The pipeline already logs submission status and the explorer link to + // stderr; echo the hash to stdout so readable output is scriptable too. + if !output.is_json() { + if let Some(tx_hash) = &receipt.tx_hash { + println!("{tx_hash}"); + } + } + + output.json_value(&Receipt { + tx_hash: receipt.tx_hash, + result, + })?; + + Ok(()) + } + + /// Translate a raw invocation failure into a token-aware error where we can + /// recognize it. A missing contract instance surfaces as a + /// `Contract not found` RPC error while fetching the spec; for an asset + /// target that means the SAC has not been deployed yet, so we point the user + /// at `contract asset deploy`. Other failures pass through unchanged. + fn map_invoke_error( + &self, + err: invoke::Error, + contract_id: &stellar_strkey::Contract, + ) -> Error { + use crate::{get_spec, rpc}; + + if let invoke::Error::GetSpecError(get_spec::Error::Rpc(rpc::Error::NotFound(kind, _))) = + &err + { + if kind == "Contract" { + return if matches!(self.id, TokenTarget::Asset(_)) { + Error::SacNotDeployed(format!("{contract_id}")) + } else { + Error::ContractNotFound(format!("{contract_id}")) + }; + } + } + + Error::Invoke(err) + } +} From 54360eef604c155946d4e9f9a383b1eb04762cdb Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Mon, 13 Jul 2026 17:31:32 -0300 Subject: [PATCH 2/7] Reject negative token transfer amounts. --- .../tests/it/integration/token/transfer.rs | 62 +++++++++++++++++++ .../src/commands/token/transfer.rs | 15 ++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs b/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs index a548d1088f..0783269adb 100644 --- a/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs +++ b/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs @@ -130,3 +130,65 @@ async fn transfer_fails_when_recipient_trustline_missing() { .failure() .stderr(predicates::str::contains("trustline entry is missing")); } + +#[tokio::test] +async fn transfer_rejects_negative_amount_before_any_rpc() { + let sandbox = &TestEnv::new(); + + // A negative amount is nonsensical and is rejected at the clap layer before + // any account resolution or RPC work, so this needs no funded accounts and + // no network — a literal destination address is enough to parse the args. + let recipient = "GAF4UUODFGAAMYRTF5QKUZCCZPXF3S4PRU5NS2BBRVJGX4WLRVI4ZI4Z"; + sandbox + .new_assert_cmd("token") + .args([ + "transfer", + "--id", + "native", + "--to", + recipient, + "--amount=-1", + "--from", + "test", + ]) + .assert() + .failure() + .stderr(predicates::str::contains("amount must not be negative")); +} + +#[tokio::test] +async fn transfer_json_failure_returns_error_envelope_on_stdout() { + let sandbox = &TestEnv::new(); + let test = test_address(sandbox); + let issuer = new_account(sandbox, "issuer"); + let recipient = new_account(sandbox, "recipient"); + let asset = format!("USDC:{issuer}"); + + // Same missing-recipient-trustline failure as above, but in JSON mode: the + // failure must still surface as a parseable `{ "error": … }` envelope on + // stdout, and the trustline diagnostic must survive into that message + // (quiet only suppresses status logging, not the error itself). + add_trustline(sandbox, "test", &asset); + issuer_pays(sandbox, "issuer", &test, &asset, 1_000); + deploy_sac(sandbox, &asset, "issuer"); + + let stdout = sandbox + .new_assert_cmd("token") + .args([ + "transfer", "--id", &asset, "--to", &recipient, "--amount", "1", "--from", "test", + "--output", "json", + ]) + .assert() + .failure() + .stdout_as_str(); + + let value: Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("stdout should be valid JSON, got: {stdout:?} ({e})")); + let message = value["error"]["message"] + .as_str() + .unwrap_or_else(|| panic!("expected an error envelope with a message, got: {value}")); + assert!( + message.contains("trustline entry is missing"), + "expected the trustline diagnostic in the JSON error message, got: {message}" + ); +} diff --git a/cmd/soroban-cli/src/commands/token/transfer.rs b/cmd/soroban-cli/src/commands/token/transfer.rs index 4e829099e8..0a689719f5 100644 --- a/cmd/soroban-cli/src/commands/token/transfer.rs +++ b/cmd/soroban-cli/src/commands/token/transfer.rs @@ -31,7 +31,7 @@ pub struct Cmd { /// Amount to transfer, in the token's smallest unit (stroops for a Stellar /// Asset Contract). - #[arg(long)] + #[arg(long, value_parser = parse_nonneg_i128)] pub amount: i128, /// Format of the output. @@ -71,6 +71,19 @@ pub enum Error { ContractNotFound(String), } +/// Parse `--amount` as a non-negative `i128`. A negative transfer amount is +/// always invalid, so reject it at the clap layer instead of letting it reach +/// the contract and fail as an opaque `HostError` deep in simulation. +fn parse_nonneg_i128(value: &str) -> Result { + let amount: i128 = value + .parse() + .map_err(|_| format!("invalid amount: {value}"))?; + if amount < 0 { + return Err(format!("amount must not be negative: {value}")); + } + Ok(amount) +} + /// The machine-readable receipt of a token transfer. #[derive(Debug, serde::Serialize)] struct Receipt { From 1b52fd1a54ec7a6afb0107ec4082060f4c2de602 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Tue, 14 Jul 2026 10:20:31 -0300 Subject: [PATCH 3/7] Accept contract addresses as token transfer destinations. --- FULL_HELP_DOCS.md | 2 +- .../tests/it/integration/token/transfer.rs | 32 +++++++++++++++++++ .../src/commands/token/transfer.rs | 19 ++++++++--- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 4b4aaea55a..76e0fb8c46 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1864,7 +1864,7 @@ Transfer tokens from one account to another - `--id ` — The token to transfer from: a contract id or alias, `native`, or a classic asset as `CODE:ISSUER` - `--from ` — Account to transfer tokens from. Signs and authorizes the transfer, so it must be an identity or secret key you control -- `--to ` — Account to transfer the tokens to +- `--to ` — Account or contract to transfer the tokens to. Accepts a `G…`/`M…` account, a `C…` contract address, or an alias - `--amount ` — Amount to transfer, in the token's smallest unit (stroops for a Stellar Asset Contract) - `--output ` — Format of the output diff --git a/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs b/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs index 0783269adb..efaa0196a6 100644 --- a/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs +++ b/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs @@ -57,6 +57,38 @@ async fn transfer_native_returns_receipt_and_moves_funds() { assert_eq!(after, before + amount, "recipient balance should increase"); } +#[tokio::test] +async fn transfer_to_contract_destination_succeeds() { + let sandbox = &TestEnv::new(); + + deploy_sac(sandbox, "native", "test"); + let native_id = sac_id(sandbox, "native"); + + // Any deployed contract can hold SAC balances (no trustline needed), so use + // another SAC's contract id as a `C…` destination — this exercises the + // contract-address path that `--to` must accept. + let issuer = new_account(sandbox, "issuer"); + let asset = format!("USDC:{issuer}"); + deploy_sac(sandbox, &asset, "issuer"); + let contract_dest = sac_id(sandbox, &asset); + + let amount: i128 = 5_000_000; + let before = sac_balance(sandbox, &native_id, &contract_dest); + + let receipt = transfer_json(sandbox, "native", &contract_dest, amount); + assert!( + receipt["tx_hash"].as_str().is_some_and(|h| !h.is_empty()), + "expected a non-empty tx_hash, got: {receipt}" + ); + + let after = sac_balance(sandbox, &native_id, &contract_dest); + assert_eq!( + after, + before + amount, + "contract recipient balance should increase" + ); +} + #[tokio::test] async fn transfer_issued_asset_succeeds_with_deployed_sac_and_trustlines() { let sandbox = &TestEnv::new(); diff --git a/cmd/soroban-cli/src/commands/token/transfer.rs b/cmd/soroban-cli/src/commands/token/transfer.rs index 0a689719f5..83c75609dc 100644 --- a/cmd/soroban-cli/src/commands/token/transfer.rs +++ b/cmd/soroban-cli/src/commands/token/transfer.rs @@ -8,7 +8,10 @@ use crate::{ global, token::args::{self, OutputFormat, TokenTarget}, }, - config::{self, locator, network, sign_with, UnresolvedContract, UnresolvedMuxedAccount}, + config::{ + self, locator, network, sign_with, UnresolvedContract, UnresolvedMuxedAccount, + UnresolvedScAddress, + }, output::Output, }; @@ -25,9 +28,10 @@ pub struct Cmd { #[arg(long)] pub from: UnresolvedMuxedAccount, - /// Account to transfer the tokens to. + /// Account or contract to transfer the tokens to. Accepts a `G…`/`M…` + /// account, a `C…` contract address, or an alias. #[arg(long)] - pub to: UnresolvedMuxedAccount, + pub to: UnresolvedScAddress, /// Amount to transfer, in the token's smallest unit (stroops for a Stellar /// Asset Contract). @@ -57,6 +61,8 @@ pub enum Error { #[error(transparent)] Args(#[from] args::Error), #[error(transparent)] + ScAddress(#[from] config::sc_address::Error), + #[error(transparent)] Invoke(#[from] invoke::Error), #[error(transparent)] Serde(#[from] serde_json::Error), @@ -126,10 +132,13 @@ impl Cmd { // SEP-41 `transfer(from, to, amount)`: `from` is the source account // (which also signs and authorizes), `to` is the destination. let from = config.source_account()?.to_string(); + // `--to` may be an account (`G…`/`M…`), a contract (`C…`), or an alias; + // resolve it to an `ScAddress` and hand the strkey to the `transfer` + // arg, which accepts any of these destinations. let to = self .to - .resolve_muxed_account(&config.locator, None) - .map_err(config::Error::from)? + .clone() + .resolve(&config.locator, &network.network_passphrase, None)? .to_string(); let amount = self.amount.to_string(); From 9451463632bf354db296ba26fbb63cb7746e357f Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Tue, 14 Jul 2026 10:22:54 -0300 Subject: [PATCH 4/7] Always submit token transfers instead of simulating. --- cmd/soroban-cli/src/commands/token/transfer.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/soroban-cli/src/commands/token/transfer.rs b/cmd/soroban-cli/src/commands/token/transfer.rs index 83c75609dc..e430554cbc 100644 --- a/cmd/soroban-cli/src/commands/token/transfer.rs +++ b/cmd/soroban-cli/src/commands/token/transfer.rs @@ -153,6 +153,10 @@ impl Cmd { contract_id: UnresolvedContract::Resolved(contract_id), slop, config: config.clone(), + // A transfer always intends to submit. Force `Send::Yes` so a token + // whose `transfer` records no writes/events/auth can't be classified + // read-only and silently exit 0 without ever moving funds. + send: invoke::Send::Yes, ..Default::default() }; From 33f8bfd0d62e411723b7ec27e1486129322c1bdc Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Tue, 14 Jul 2026 10:27:01 -0300 Subject: [PATCH 5/7] Fail loudly when SAC deploy errors in tests. --- .../soroban-test/tests/it/integration/token/mod.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cmd/crates/soroban-test/tests/it/integration/token/mod.rs b/cmd/crates/soroban-test/tests/it/integration/token/mod.rs index 3f770c1357..599b42e92e 100644 --- a/cmd/crates/soroban-test/tests/it/integration/token/mod.rs +++ b/cmd/crates/soroban-test/tests/it/integration/token/mod.rs @@ -34,7 +34,7 @@ pub fn issuer_pays(sandbox: &TestEnv, issuer: &str, destination: &str, asset: &s /// Deploy the Stellar Asset Contract for `asset`, tolerating the case where a /// prior run already deployed it (the SAC is global to the network). pub fn deploy_sac(sandbox: &TestEnv, asset: &str, source: &str) { - sandbox + let output = sandbox .new_assert_cmd("contract") .args([ "asset", @@ -46,6 +46,18 @@ pub fn deploy_sac(sandbox: &TestEnv, asset: &str, source: &str) { ]) .output() .expect("failed to run contract asset deploy"); + + // A clean deploy succeeds; the only failure we tolerate is the SAC already + // existing on this (persistent) network. Any other failure is a real bug — + // surface the captured stderr instead of swallowing it and misdirecting the + // failure two steps downstream. + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("contract already exists"), + "contract asset deploy failed: {stderr}" + ); + } } /// The contract id of the SAC for `asset`. From 80d0e8dd51864515c75f1cdfe621d9b64ae2b570 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Tue, 14 Jul 2026 10:39:20 -0300 Subject: [PATCH 6/7] Reject muxed source accounts with a clear error. --- .../tests/it/integration/token/transfer.rs | 23 +++++++++++++++++++ .../src/commands/token/transfer.rs | 17 +++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs b/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs index efaa0196a6..28b8accdd1 100644 --- a/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs +++ b/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs @@ -188,6 +188,29 @@ async fn transfer_rejects_negative_amount_before_any_rpc() { .stderr(predicates::str::contains("amount must not be negative")); } +#[tokio::test] +async fn transfer_rejects_muxed_source_with_clear_error() { + let sandbox = &TestEnv::new(); + let recipient = new_account(sandbox, "recipient"); + + deploy_sac(sandbox, "native", "test"); + + // Muxed (M…) source accounts aren't supported by the invoke pipeline yet + // (see #2645). Until then the command must reject them up front with a clear + // message rather than a raw strkey decode error deep in the pipeline. + let muxed = "MA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAAAAAAAAAPCICBKU"; + sandbox + .new_assert_cmd("token") + .args([ + "transfer", "--id", "native", "--to", &recipient, "--amount", "1", "--from", muxed, + ]) + .assert() + .failure() + .stderr(predicates::str::contains( + "muxed (M…) source accounts are not yet supported", + )); +} + #[tokio::test] async fn transfer_json_failure_returns_error_envelope_on_stdout() { let sandbox = &TestEnv::new(); diff --git a/cmd/soroban-cli/src/commands/token/transfer.rs b/cmd/soroban-cli/src/commands/token/transfer.rs index e430554cbc..11cd9355cc 100644 --- a/cmd/soroban-cli/src/commands/token/transfer.rs +++ b/cmd/soroban-cli/src/commands/token/transfer.rs @@ -75,6 +75,12 @@ pub enum Error { #[error("contract {0} was not found on this network")] ContractNotFound(String), + + #[error( + "muxed (M…) source accounts are not yet supported for `token transfer`; \ + use the underlying G… account as `--from` instead" + )] + MuxedSourceNotSupported, } /// Parse `--amount` as a non-negative `i128`. A negative transfer amount is @@ -131,7 +137,16 @@ impl Cmd { // SEP-41 `transfer(from, to, amount)`: `from` is the source account // (which also signs and authorizes), `to` is the destination. - let from = config.source_account()?.to_string(); + // + // The invoke pipeline can't source a transaction from a muxed account + // yet (see #2645), and a muxed strkey in the `transfer` arg is rejected + // mid-simulation with an opaque host error; reject it up front with a + // clear message instead. + let source_account = config.source_account()?; + if matches!(source_account, crate::xdr::MuxedAccount::MuxedEd25519(_)) { + return Err(Error::MuxedSourceNotSupported); + } + let from = source_account.to_string(); // `--to` may be an account (`G…`/`M…`), a contract (`C…`), or an alias; // resolve it to an `ScAddress` and hand the strkey to the `transfer` // arg, which accepts any of these destinations. From 9ee7a6b1e73e05f72f21c97447bfebead0da053a Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Wed, 15 Jul 2026 12:40:17 -0300 Subject: [PATCH 7/7] Resolve native token target through the built-in alias. --- cmd/soroban-cli/src/commands/token/args.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/cmd/soroban-cli/src/commands/token/args.rs b/cmd/soroban-cli/src/commands/token/args.rs index 9c3b961ecf..de2314c31e 100644 --- a/cmd/soroban-cli/src/commands/token/args.rs +++ b/cmd/soroban-cli/src/commands/token/args.rs @@ -32,9 +32,10 @@ impl From for Format { /// A `stellar token` target, resolved from the `--id` value. /// /// The shape of the value decides how it is interpreted: -/// * `native` or `CODE:ISSUER` → a Stellar Asset Contract (SAC), resolved from -/// the asset. -/// * anything else → a contract, either a `C…` strkey or a saved alias. +/// * `CODE:ISSUER` → a Stellar Asset Contract (SAC), resolved from the asset. +/// * anything else → a contract, either a `C…` strkey or a saved alias. This +/// includes `native`, which resolves through the built-in reserved alias to +/// the native-asset SAC. #[derive(Clone, Debug)] pub enum TokenTarget { /// A SEP-41 contract addressed directly by id or alias. @@ -55,9 +56,10 @@ impl FromStr for TokenTarget { type Err = builder::asset::Error; fn from_str(value: &str) -> Result { - // `native` and `CODE:ISSUER` are the two shapes an asset can take; both - // route to a SAC. Everything else is a contract id or alias. - if value == "native" || value.contains(':') { + // `CODE:ISSUER` is the classic-asset shape, resolved to a SAC. + // Everything else is a contract id or alias — including `native`, which + // resolves through the built-in reserved alias to the native-asset SAC. + if value.contains(':') { Ok(TokenTarget::Asset(value.parse()?)) } else { // `UnresolvedContract::from_str` is infallible. @@ -93,10 +95,10 @@ mod tests { const CONTRACT: &str = "CCR6QKTWZQYW6YUJ7UP7XXZRLWQPFRV6SWBLQS4ZQOSAF4BOUD77OTE2"; #[test] - fn native_parses_as_asset() { + fn native_parses_as_contract_alias() { assert!(matches!( "native".parse::().unwrap(), - TokenTarget::Asset(builder::Asset::Native) + TokenTarget::Contract(UnresolvedContract::Alias(_)) )); }