diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index c184b553f6..f959de04b8 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1956,6 +1956,7 @@ Interact with SEP-41 tokens and Stellar Asset Contracts - `approve` — Approve an allowance for a spender to transfer on your behalf - `allowance` — Read the allowance a spender has on an owner's behalf - `mint` — Mint new tokens to an account or contract (SAC admin) +- `clawback` — Claw back tokens from an account or contract (SAC admin) ## `stellar token transfer` @@ -2355,6 +2356,53 @@ Calls the token's Stellar Asset Contract `mint` function. A non-SAC contract wit - `--fee ` — ⚠️ Deprecated, use `--inclusion-fee`. Fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm - `--inclusion-fee ` — Maximum fee amount for transaction inclusion, in stroops. 1 stroop = 0.0000001 xlm. Defaults to 100 if no arg, env, or config value is provided +## `stellar token clawback` + +Claw back tokens from an account or contract (SAC admin) + +Calls the token's Stellar Asset Contract `clawback` function. A non-SAC contract with a same-named function that takes different arguments will fail or misbehave — use `stellar contract invoke` for those. + +**Usage:** `stellar token clawback [OPTIONS] --id --from --amount --source-account ` + +###### **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 claw back: a contract id or alias, `native`, or a classic asset as `CODE:ISSUER` +- `--from ` — Account or contract to claw the tokens back from. Accepts a `G…` account, a `C…` contract address, or an alias +- `--amount ` — Amount to claw back, 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 output + - `json-formatted`: Formatted (multiline) JSON output + +###### **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 + +###### **Transaction Options:** + +- `-s`, `--source-account ` [alias: `source`] — Account that where transaction originates from. Alias `source`. Can be an identity (--source alice), a public key (--source GDKW...), a muxed account (--source MDA…), a secret key (--source SC36…), or a seed phrase (--source "kite urban…"). If `--build-only` was NOT provided, this key will also be used to sign the final transaction. In that case, trying to sign with public key will fail +- `--fee ` — ⚠️ Deprecated, use `--inclusion-fee`. Fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm +- `--inclusion-fee ` — Maximum fee amount for transaction inclusion, in stroops. 1 stroop = 0.0000001 xlm. Defaults to 100 if no arg, env, or config value is provided + ## `stellar tx` Sign, Simulate, and Send transactions diff --git a/cmd/crates/soroban-test/tests/it/integration/token/clawback.rs b/cmd/crates/soroban-test/tests/it/integration/token/clawback.rs new file mode 100644 index 0000000000..8655756a76 --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/integration/token/clawback.rs @@ -0,0 +1,211 @@ +use serde_json::Value; +use soroban_test::{AssertExt, TestEnv}; + +use crate::integration::{ + token::{add_trustline, deploy_sac, issuer_pays, sac_balance, sac_id}, + util::{deploy_hello, new_account, test_address}, +}; + +/// Enable the clawback flag on `issuer`, so trustlines created afterwards are +/// clawback-enabled. `AUTH_CLAWBACK_ENABLED` requires `AUTH_REVOCABLE`, so set +/// both together. +fn enable_clawback(sandbox: &TestEnv, issuer: &str) { + sandbox + .new_assert_cmd("tx") + .args([ + "new", + "set-options", + "--set-revocable", + "--set-clawback-enabled", + "--source", + issuer, + ]) + .assert() + .success(); +} + +#[tokio::test] +async fn clawback_removes_balance_and_returns_receipt() { + let sandbox = &TestEnv::new(); + let test = test_address(sandbox); + let issuer = new_account(sandbox, "issuer"); + let asset = format!("USDC:{issuer}"); + + // Clawback requires the issuer to enable the flag *before* the holder's + // trustline exists, so the trustline is created clawback-enabled. + enable_clawback(sandbox, "issuer"); + add_trustline(sandbox, "test", &asset); + deploy_sac(sandbox, &asset, "issuer"); + issuer_pays(sandbox, "issuer", &test, &asset, 10_000_000); + + let stdout = sandbox + .new_assert_cmd("token") + .args([ + "clawback", "--id", &asset, "--source", "issuer", "--from", &test, "--amount", + "4000000", "--output", "json", + ]) + .assert() + .success() + .stdout_as_str(); + let receipt: Value = serde_json::from_str(&stdout).unwrap(); + assert!( + receipt["tx_hash"].as_str().is_some(), + "expected a tx hash, got: {receipt}" + ); + + // 10_000_000 minted − 4_000_000 clawed back = 6_000_000 remaining. + let sac = sac_id(sandbox, &asset); + assert_eq!( + sac_balance(sandbox, &sac, &test), + 6_000_000, + "expected the remaining balance after clawback" + ); +} + +#[tokio::test] +async fn clawback_fails_when_sac_not_deployed() { + let sandbox = &TestEnv::new(); + let test = test_address(sandbox); + let issuer = new_account(sandbox, "issuer"); + let asset = format!("USDC:{issuer}"); + + // No SAC deployed → structured deploy-pointer error with a typed discriminator. + let stdout = sandbox + .new_assert_cmd("token") + .args([ + "clawback", "--id", &asset, "--source", "issuer", "--from", &test, "--amount", "1", + "--output", "json", + ]) + .assert() + .failure() + .stdout_as_str(); + let value: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + value["error"]["type"], "sac_not_deployed", + "expected a typed error, got: {stdout}" + ); +} + +#[tokio::test] +async fn clawback_rejects_muxed_source_with_clear_error() { + let sandbox = &TestEnv::new(); + let holder = new_account(sandbox, "holder"); + + // 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([ + "clawback", "--id", "native", "--source", muxed, "--from", &holder, "--amount", "1", + ]) + .assert() + .failure() + .stderr(predicates::str::contains( + "muxed (M…) source accounts are not yet supported", + )); +} + +#[tokio::test] +async fn clawback_rejects_muxed_from_with_clear_error() { + let sandbox = &TestEnv::new(); + + // A muxed (M…) holder isn't a valid `clawback` target — the host rejects it + // mid-simulation with an opaque error — so the command rejects it up front + // with a clear message. + let muxed = "MA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAAAAAAAAAPCICBKU"; + sandbox + .new_assert_cmd("token") + .args([ + "clawback", "--id", "native", "--source", "test", "--from", muxed, "--amount", "1", + ]) + .assert() + .failure() + .stderr(predicates::str::contains( + "muxed (M…) holder accounts are not yet supported", + )); +} + +#[tokio::test] +async fn clawback_rejects_negative_amount_before_any_rpc() { + let sandbox = &TestEnv::new(); + let test = test_address(sandbox); + + // A negative clawback is rejected at the CLI layer, before any network call. + // `=` form so clap reads `-1` as the value, not an unknown flag. + sandbox + .new_assert_cmd("token") + .args([ + "clawback", + "--id", + "native", + "--source", + "test", + "--from", + &test, + "--amount=-1", + ]) + .assert() + .failure() + .stderr(predicates::str::contains("must not be negative")); +} + +#[tokio::test] +async fn clawback_warns_when_target_is_not_a_sac() { + let sandbox = &TestEnv::new(); + let test = test_address(sandbox); + let contract_id = deploy_hello(sandbox).await; + + // Pointing a SAC-admin command at a plain wasm contract warns. The clawback + // then fails (hello_world has no `clawback`), but the heads-up is the point. + let stderr = sandbox + .new_assert_cmd("token") + .args([ + "clawback", + "--id", + &contract_id, + "--source", + "test", + "--from", + &test, + "--amount", + "1", + ]) + .assert() + .failure() + .stderr_as_str(); + assert!( + stderr.contains("is not a Stellar Asset Contract"), + "expected a non-SAC warning, got: {stderr}" + ); +} + +#[tokio::test] +async fn clawback_does_not_warn_when_target_is_a_sac() { + let sandbox = &TestEnv::new(); + let test = test_address(sandbox); + let issuer = new_account(sandbox, "issuer"); + let asset = format!("USDC:{issuer}"); + + enable_clawback(sandbox, "issuer"); + add_trustline(sandbox, "test", &asset); + deploy_sac(sandbox, &asset, "issuer"); + issuer_pays(sandbox, "issuer", &test, &asset, 10_000_000); + // Reference the SAC by its contract id, not the asset, so the check can only + // clear it by inspecting the on-chain executable — not the id's text form. + let sac = sac_id(sandbox, &asset); + + let stderr = sandbox + .new_assert_cmd("token") + .args([ + "clawback", "--id", &sac, "--source", "issuer", "--from", &test, "--amount", "4000000", + ]) + .assert() + .success() + .stderr_as_str(); + assert!( + !stderr.contains("is not a Stellar Asset Contract"), + "a genuine SAC should not warn, got: {stderr}" + ); +} 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 ca87d258e5..57c528e754 100644 --- a/cmd/crates/soroban-test/tests/it/integration/token/mod.rs +++ b/cmd/crates/soroban-test/tests/it/integration/token/mod.rs @@ -3,6 +3,7 @@ pub mod approve; pub mod balance; pub mod burn; pub mod burn_from; +pub mod clawback; pub mod decimals; pub mod mint; pub mod name; diff --git a/cmd/soroban-cli/src/cli.rs b/cmd/soroban-cli/src/cli.rs index 642ff0dcaf..a442b0d735 100644 --- a/cmd/soroban-cli/src/cli.rs +++ b/cmd/soroban-cli/src/cli.rs @@ -153,6 +153,7 @@ fn json_error_format(cmd: &commands::Cmd) -> Option { commands::Cmd::Token(token::Cmd::Approve(cmd)) => cmd.output.into(), commands::Cmd::Token(token::Cmd::Allowance(cmd)) => cmd.output.into(), commands::Cmd::Token(token::Cmd::Mint(cmd)) => cmd.output.into(), + commands::Cmd::Token(token::Cmd::Clawback(cmd)) => cmd.output.into(), _ => return None, }; diff --git a/cmd/soroban-cli/src/commands/token/args.rs b/cmd/soroban-cli/src/commands/token/args.rs index 76963fb956..15fe74bbd0 100644 --- a/cmd/soroban-cli/src/commands/token/args.rs +++ b/cmd/soroban-cli/src/commands/token/args.rs @@ -169,6 +169,19 @@ pub async fn warn_if_not_sac( )); } +/// Parse a token `--amount` as a non-negative `i128`. A negative 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. +pub 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) +} + #[cfg(test)] mod tests { use super::*; diff --git a/cmd/soroban-cli/src/commands/token/clawback.rs b/cmd/soroban-cli/src/commands/token/clawback.rs new file mode 100644 index 0000000000..9c3a2165ae --- /dev/null +++ b/cmd/soroban-cli/src/commands/token/clawback.rs @@ -0,0 +1,183 @@ +use clap::Parser; + +use crate::{ + commands::{ + contract::invoke, + global, + token::args::{self, OutputFormat}, + }, + config::{self, network, token::UnresolvedToken, UnresolvedScAddress}, + output::Output, +}; + +#[derive(Debug, Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// The token to claw back: a contract id or alias, `native`, or a classic + /// asset as `CODE:ISSUER`. + #[arg(long = "id")] + pub id: UnresolvedToken, + + /// Account or contract to claw the tokens back from. Accepts a `G…` account, + /// a `C…` contract address, or an alias. + #[arg(long)] + pub from: UnresolvedScAddress, + + /// Amount to claw back, in the token's smallest unit (stroops for a Stellar + /// Asset Contract). + #[arg(long, value_parser = args::parse_nonneg_i128)] + pub amount: i128, + + /// Format of the output. + #[arg(long, default_value = "text")] + pub output: OutputFormat, + + #[command(flatten)] + pub config: config::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)] + Token(#[from] config::token::Error), + #[error(transparent)] + ScAddress(#[from] config::sc_address::Error), + #[error(transparent)] + Invoke(#[from] invoke::Error), + #[error(transparent)] + Serde(#[from] serde_json::Error), + + #[error("muxed (M…) source accounts are not yet supported for `token clawback`")] + MuxedSourceNotSupported, + + #[error("muxed (M…) holder accounts are not yet supported for `token clawback`")] + MuxedFromNotSupported, +} + +impl Error { + /// Machine-readable discriminator for the JSON error envelope's `type` field. + #[must_use] + pub fn error_type(&self) -> &'static str { + match self { + Error::Config(_) => "config", + Error::Network(_) => "network", + Error::Args(e) => e.error_type(), + Error::Token(e) => e.error_type(), + Error::ScAddress(_) => "invalid_address", + Error::Invoke(_) => "invoke", + Error::Serde(_) => "internal", + Error::MuxedSourceNotSupported | Error::MuxedFromNotSupported => "unsupported", + } + } +} + +/// The machine-readable receipt of a token clawback. +#[derive(Debug, serde::Serialize)] +struct Receipt { + /// Hex-encoded hash of the submitted transaction. + tx_hash: Option, + /// The decoded contract return value (`null` for the SAC `clawback`, which + /// returns nothing). + result: serde_json::Value, +} + +impl Cmd { + 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 token = self + .id + .resolve(&config.locator, &network.network_passphrase)?; + + // The `--source` account only authorizes the clawback; it is not itself a + // `clawback` argument. The invoke pipeline can't source a transaction from + // a muxed account yet (see #2645), so reject one up front with a clear + // message. + let source_account = config.source_account()?; + if matches!(source_account, crate::xdr::MuxedAccount::MuxedEd25519(_)) { + return Err(Error::MuxedSourceNotSupported); + } + // `clawback` is a SAC-admin function; warn (in human-readable mode) if the + // target isn't actually a Stellar Asset Contract. + if !output.is_json() { + args::warn_if_not_sac(output.print(), "clawback", &token.contract_id, &network).await; + } + // `--from` may be an account (`G…`), a contract (`C…`), or an alias. The + // host rejects a muxed (`M…`) holder mid-simulation with an opaque error, + // so reject one up front with a clear message — whether supplied as a + // direct `M…` strkey or an alias resolving to a muxed key. + if self + .from + .is_muxed(&config.locator, &network.network_passphrase) + { + return Err(Error::MuxedFromNotSupported); + } + // Resolve it to an `ScAddress` and hand the strkey to the `clawback` arg, + // which accepts any of these holders. + let from = self + .from + .clone() + .resolve(&config.locator, &network.network_passphrase, None)? + .to_string(); + let amount = self.amount.to_string(); + + // SAC `clawback(from, amount)` — supply the values in that order and let + // the contract's parameters be matched by position. A clawback always + // intends to submit, so force `Send::Yes`: a token whose `clawback` + // records no writes/events/auth can't be classified read-only and + // silently exit 0 without ever removing the balance. + let receipt = args::invoke_by_position( + config, + quiet, + global_args.no_cache, + &token, + "clawback", + vec![from, amount], + invoke::Send::Yes, + ) + .await + .map_err(|e| args::not_deployed_error(&token, &e).map_or(Error::Invoke(e), Error::Args))? + .into_result(); + + // `clawback` 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(()) + } +} diff --git a/cmd/soroban-cli/src/commands/token/mint.rs b/cmd/soroban-cli/src/commands/token/mint.rs index 04f4ae087d..160eaaefa3 100644 --- a/cmd/soroban-cli/src/commands/token/mint.rs +++ b/cmd/soroban-cli/src/commands/token/mint.rs @@ -53,10 +53,7 @@ pub enum Error { #[error(transparent)] Serde(#[from] serde_json::Error), - #[error( - "muxed (M…) source accounts are not yet supported for `token mint`; \ - use the underlying G… account as `--source` instead" - )] + #[error("muxed (M…) source accounts are not yet supported for `token mint`")] MuxedSourceNotSupported, } diff --git a/cmd/soroban-cli/src/commands/token/mod.rs b/cmd/soroban-cli/src/commands/token/mod.rs index 81a92de717..2f2bbb16e6 100644 --- a/cmd/soroban-cli/src/commands/token/mod.rs +++ b/cmd/soroban-cli/src/commands/token/mod.rs @@ -4,6 +4,7 @@ pub mod args; pub mod balance; pub mod burn; pub mod burn_from; +pub mod clawback; pub mod decimals; pub mod mint; pub mod name; @@ -51,6 +52,13 @@ pub enum Cmd { /// contract with a same-named function that takes different arguments will /// fail or misbehave — use `stellar contract invoke` for those. Mint(mint::Cmd), + + /// Claw back tokens from an account or contract (SAC admin) + /// + /// Calls the token's Stellar Asset Contract `clawback` function. A non-SAC + /// contract with a same-named function that takes different arguments will + /// fail or misbehave — use `stellar contract invoke` for those. + Clawback(clawback::Cmd), } #[derive(thiserror::Error, Debug)] @@ -77,6 +85,8 @@ pub enum Error { Allowance(#[from] allowance::Error), #[error(transparent)] Mint(#[from] mint::Error), + #[error(transparent)] + Clawback(#[from] clawback::Error), } impl Error { @@ -95,6 +105,7 @@ impl Error { Error::Approve(e) => e.error_type(), Error::Allowance(e) => e.error_type(), Error::Mint(e) => e.error_type(), + Error::Clawback(e) => e.error_type(), } } } @@ -113,6 +124,7 @@ impl Cmd { Cmd::Approve(cmd) => cmd.run(global_args).await?, Cmd::Allowance(cmd) => cmd.run(global_args).await?, Cmd::Mint(cmd) => cmd.run(global_args).await?, + Cmd::Clawback(cmd) => cmd.run(global_args).await?, } Ok(()) }