Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions FULL_HELP_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -2355,6 +2356,53 @@ Calls the token's Stellar Asset Contract `mint` function. A non-SAC contract wit
- `--fee <FEE>` — ⚠️ Deprecated, use `--inclusion-fee`. Fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm
- `--inclusion-fee <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 <ID> --from <FROM> --amount <AMOUNT> --source-account <SOURCE_ACCOUNT>`

###### **Global Options:**

- `--config-dir <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 <ID>` — The token to claw back: a contract id or alias, `native`, or a classic asset as `CODE:ISSUER`
- `--from <FROM>` — Account or contract to claw the tokens back from. Accepts a `G…` account, a `C…` contract address, or an alias
- `--amount <AMOUNT>` — Amount to claw back, in the token's smallest unit (stroops for a Stellar Asset Contract)
- `--output <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_URL>` — RPC server endpoint
- `--rpc-header <RPC_HEADERS>` — 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>` — Network passphrase to sign the transaction sent to the rpc server
- `-n`, `--network <NETWORK>` — Name of network to use from config

###### **Signing Options:**

- `--sign-with-key <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 <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 <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 <FEE>` — ⚠️ Deprecated, use `--inclusion-fee`. Fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm
- `--inclusion-fee <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
Expand Down
211 changes: 211 additions & 0 deletions cmd/crates/soroban-test/tests/it/integration/token/clawback.rs
Original file line number Diff line number Diff line change
@@ -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}"
);
}
1 change: 1 addition & 0 deletions cmd/crates/soroban-test/tests/it/integration/token/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions cmd/soroban-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ fn json_error_format(cmd: &commands::Cmd) -> Option<crate::output::Format> {
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,
};

Expand Down
13 changes: 13 additions & 0 deletions cmd/soroban-cli/src/commands/token/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i128, String> {
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::*;
Expand Down
Loading
Loading