From ffdd3156f3c7156dd2ea031ebfdfbf28096f1b0b Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 4 Sep 2026 17:06:21 -0300 Subject: [PATCH] Add stellar token set-authorized subcommand. --- FULL_HELP_DOCS.md | 51 ++++ .../tests/it/integration/token/mod.rs | 1 + .../it/integration/token/set_authorized.rs | 217 ++++++++++++++++++ cmd/soroban-cli/src/cli.rs | 1 + cmd/soroban-cli/src/commands/token/mod.rs | 12 + .../src/commands/token/set_authorized.rs | 178 ++++++++++++++ 6 files changed, 460 insertions(+) create mode 100644 cmd/crates/soroban-test/tests/it/integration/token/set_authorized.rs create mode 100644 cmd/soroban-cli/src/commands/token/set_authorized.rs diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index e33f5f47f6..f03f1d9830 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1958,6 +1958,7 @@ Interact with SEP-41 tokens and Stellar Asset Contracts - `mint` — Mint new tokens to an account or contract (SAC admin) - `clawback` — Claw back tokens from an account or contract (SAC admin) - `set-admin` — Transfer administration of the token to a new admin (SAC admin) +- `set-authorized` — Authorize or deauthorize an account to hold the token (SAC admin) ## `stellar token transfer` @@ -2450,6 +2451,56 @@ Calls the token's Stellar Asset Contract `set_admin` function. A non-SAC contrac - `--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 set-authorized` + +Authorize or deauthorize an account to hold the token (SAC admin) + +Calls the token's Stellar Asset Contract `set_authorized` 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 set-authorized [OPTIONS] --id --account --authorize --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 whose authorization to set: a contract id or alias, or a classic asset as `CODE:ISSUER` +- `--account ` — Account or contract whose authorization to set. Accepts a `G…`/`M…` account, a `C…` contract address, or an alias +- `--authorize ` — Whether the account is authorized (`true`) to hold and transact the token, or deauthorized/frozen (`false`) + + Possible values: `true`, `false` + +- `--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/mod.rs b/cmd/crates/soroban-test/tests/it/integration/token/mod.rs index 6815f083b3..d55df28fac 100644 --- a/cmd/crates/soroban-test/tests/it/integration/token/mod.rs +++ b/cmd/crates/soroban-test/tests/it/integration/token/mod.rs @@ -9,6 +9,7 @@ pub mod mint; pub mod name; pub mod renamed; pub mod set_admin; +pub mod set_authorized; pub mod symbol; pub mod transfer; pub mod transfer_from; diff --git a/cmd/crates/soroban-test/tests/it/integration/token/set_authorized.rs b/cmd/crates/soroban-test/tests/it/integration/token/set_authorized.rs new file mode 100644 index 0000000000..f8aabb81ac --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/integration/token/set_authorized.rs @@ -0,0 +1,217 @@ +use serde_json::Value; +use soroban_test::{AssertExt, TestEnv}; + +use crate::integration::{ + token::{add_trustline, deploy_sac, sac_id}, + util::{deploy_hello, new_account, test_address}, +}; + +/// Enable the revocable flag on `issuer`, required to deauthorize an existing +/// trustline. +fn enable_revocable(sandbox: &TestEnv, issuer: &str) { + sandbox + .new_assert_cmd("tx") + .args(["new", "set-options", "--set-revocable", "--source", issuer]) + .assert() + .success(); +} + +/// Read whether `account` is authorized on the token through its SAC. +fn sac_authorized(sandbox: &TestEnv, contract_id: &str, account: &str) -> bool { + let stdout = sandbox + .new_assert_cmd("contract") + .args([ + "invoke", + "--id", + contract_id, + "--source-account", + "test", + "--", + "authorized", + "--id", + account, + ]) + .assert() + .success() + .stdout_as_str(); + stdout.trim().parse().unwrap() +} + +#[tokio::test] +async fn set_authorized_toggles_authorization_and_returns_receipt() { + let sandbox = &TestEnv::new(); + let test = test_address(sandbox); + let issuer = new_account(sandbox, "issuer"); + let asset = format!("USDC:{issuer}"); + + // Deauthorizing an existing trustline requires the issuer to be revocable. + enable_revocable(sandbox, "issuer"); + add_trustline(sandbox, "test", &asset); + deploy_sac(sandbox, &asset, "issuer"); + let sac = sac_id(sandbox, &asset); + + // A fresh trustline starts authorized. + assert!( + sac_authorized(sandbox, &sac, &test), + "trustline should start authorized" + ); + + let stdout = sandbox + .new_assert_cmd("token") + .args([ + "set-authorized", + "--id", + &asset, + "--source", + "issuer", + "--account", + &test, + "--authorize", + "false", + "--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}" + ); + + // The account is now deauthorized on-chain. + assert!( + !sac_authorized(sandbox, &sac, &test), + "account should be deauthorized after set-authorized false" + ); +} + +#[tokio::test] +async fn set_authorized_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([ + "set-authorized", + "--id", + &asset, + "--source", + "issuer", + "--account", + &test, + "--authorize", + "true", + "--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 set_authorized_rejects_muxed_source_with_clear_error() { + let sandbox = &TestEnv::new(); + let test = test_address(sandbox); + + // 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([ + "set-authorized", + "--id", + "native", + "--source", + muxed, + "--account", + &test, + "--authorize", + "true", + ]) + .assert() + .failure() + .stderr(predicates::str::contains( + "muxed (M…) source accounts are not yet supported", + )); +} + +#[tokio::test] +async fn set_authorized_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 call then + // fails (hello_world has no `set_authorized`), but the heads-up is the point. + let stderr = sandbox + .new_assert_cmd("token") + .args([ + "set-authorized", + "--id", + &contract_id, + "--source", + "test", + "--account", + &test, + "--authorize", + "true", + ]) + .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 set_authorized_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}"); + + add_trustline(sandbox, "test", &asset); + deploy_sac(sandbox, &asset, "issuer"); + // 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); + + // Re-authorizing an already-authorized trustline is a no-op success and needs + // no revocable flag — enough to exercise the SAC path without warning. + let stderr = sandbox + .new_assert_cmd("token") + .args([ + "set-authorized", + "--id", + &sac, + "--source", + "issuer", + "--account", + &test, + "--authorize", + "true", + ]) + .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/soroban-cli/src/cli.rs b/cmd/soroban-cli/src/cli.rs index 2720210eb4..30171ee713 100644 --- a/cmd/soroban-cli/src/cli.rs +++ b/cmd/soroban-cli/src/cli.rs @@ -155,6 +155,7 @@ fn json_error_format(cmd: &commands::Cmd) -> Option { commands::Cmd::Token(token::Cmd::Mint(cmd)) => cmd.output.into(), commands::Cmd::Token(token::Cmd::Clawback(cmd)) => cmd.output.into(), commands::Cmd::Token(token::Cmd::SetAdmin(cmd)) => cmd.output.into(), + commands::Cmd::Token(token::Cmd::SetAuthorized(cmd)) => cmd.output.into(), _ => return None, }; diff --git a/cmd/soroban-cli/src/commands/token/mod.rs b/cmd/soroban-cli/src/commands/token/mod.rs index 04ac76c412..185122b2d3 100644 --- a/cmd/soroban-cli/src/commands/token/mod.rs +++ b/cmd/soroban-cli/src/commands/token/mod.rs @@ -9,6 +9,7 @@ pub mod decimals; pub mod mint; pub mod name; pub mod set_admin; +pub mod set_authorized; pub mod symbol; pub mod transfer; pub mod transfer_from; @@ -67,6 +68,13 @@ pub enum Cmd { /// contract with a same-named function that takes different arguments will /// fail or misbehave — use `stellar contract invoke` for those. SetAdmin(set_admin::Cmd), + + /// Authorize or deauthorize an account to hold the token (SAC admin) + /// + /// Calls the token's Stellar Asset Contract `set_authorized` function. A + /// non-SAC contract with a same-named function that takes different arguments + /// will fail or misbehave — use `stellar contract invoke` for those. + SetAuthorized(set_authorized::Cmd), } #[derive(thiserror::Error, Debug)] @@ -97,6 +105,8 @@ pub enum Error { Clawback(#[from] clawback::Error), #[error(transparent)] SetAdmin(#[from] set_admin::Error), + #[error(transparent)] + SetAuthorized(#[from] set_authorized::Error), } impl Error { @@ -117,6 +127,7 @@ impl Error { Error::Mint(e) => e.error_type(), Error::Clawback(e) => e.error_type(), Error::SetAdmin(e) => e.error_type(), + Error::SetAuthorized(e) => e.error_type(), } } } @@ -137,6 +148,7 @@ impl Cmd { Cmd::Mint(cmd) => cmd.run(global_args).await?, Cmd::Clawback(cmd) => cmd.run(global_args).await?, Cmd::SetAdmin(cmd) => cmd.run(global_args).await?, + Cmd::SetAuthorized(cmd) => cmd.run(global_args).await?, } Ok(()) } diff --git a/cmd/soroban-cli/src/commands/token/set_authorized.rs b/cmd/soroban-cli/src/commands/token/set_authorized.rs new file mode 100644 index 0000000000..3b684be658 --- /dev/null +++ b/cmd/soroban-cli/src/commands/token/set_authorized.rs @@ -0,0 +1,178 @@ +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 whose authorization to set: a contract id or alias, or a + /// classic asset as `CODE:ISSUER`. + #[arg(long = "id")] + pub id: UnresolvedToken, + + /// Account or contract whose authorization to set. Accepts a `G…`/`M…` + /// account, a `C…` contract address, or an alias. + #[arg(long)] + pub account: UnresolvedScAddress, + + /// Whether the account is authorized (`true`) to hold and transact the + /// token, or deauthorized/frozen (`false`). + #[arg(long, action = clap::ArgAction::Set)] + pub authorize: bool, + + /// 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 set-authorized`")] + MuxedSourceNotSupported, +} + +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 => "unsupported", + } + } +} + +/// The machine-readable receipt of a set-authorized change. +#[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 `set_authorized`, + /// 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 change; it is not itself a + // `set_authorized` 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); + } + // `set_authorized` 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(), + "set_authorized", + &token.contract_id, + &network, + ) + .await; + } + // `--account` may be an account (`G…`/`M…`), a contract (`C…`), or an + // alias; resolve it to an `ScAddress` and hand the strkey to the + // `set_authorized` arg, which accepts any of these targets. + let account = self + .account + .clone() + .resolve(&config.locator, &network.network_passphrase, None)? + .to_string(); + let authorize = self.authorize.to_string(); + + // SAC `set_authorized(id, authorize)` — supply the values in that order + // and let the contract's parameters be matched by position. A + // set-authorized always intends to submit, so force `Send::Yes`: a token + // whose `set_authorized` records no writes/events/auth can't be + // classified read-only and silently exit 0 without ever changing the + // authorization. + let receipt = args::invoke_by_position( + config, + quiet, + global_args.no_cache, + &token, + "set_authorized", + vec![account, authorize], + invoke::Send::Yes, + ) + .await + .map_err(|e| args::not_deployed_error(&token, &e).map_or(Error::Invoke(e), Error::Args))? + .into_result(); + + // `set_authorized` 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(()) + } +}