Skip to content

Commit 0a2ef0e

Browse files
committed
Add stellar token clawback subcommand.
1 parent e79afa3 commit 0a2ef0e

6 files changed

Lines changed: 437 additions & 0 deletions

File tree

‎FULL_HELP_DOCS.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1956,6 +1956,7 @@ Interact with SEP-41 tokens and Stellar Asset Contracts
19561956
- `approve` — Approve an allowance for a spender to transfer on your behalf
19571957
- `allowance` — Read the allowance a spender has on an owner's behalf
19581958
- `mint` — Mint new tokens to an account or contract (SAC admin)
1959+
- `clawback` — Claw back tokens from an account or contract (SAC admin)
19591960

19601961
## `stellar token transfer`
19611962

@@ -2355,6 +2356,53 @@ Calls the token's Stellar Asset Contract `mint` function. A non-SAC contract wit
23552356
- `--fee <FEE>` — ⚠️ Deprecated, use `--inclusion-fee`. Fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm
23562357
- `--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
23572358

2359+
## `stellar token clawback`
2360+
2361+
Claw back tokens from an account or contract (SAC admin)
2362+
2363+
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.
2364+
2365+
**Usage:** `stellar token clawback [OPTIONS] --id <ID> --from <FROM> --amount <AMOUNT> --source-account <SOURCE_ACCOUNT>`
2366+
2367+
###### **Global Options:**
2368+
2369+
- `--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
2370+
2371+
###### **Options:**
2372+
2373+
- `--id <ID>` — The token to claw back: a contract id or alias, `native`, or a classic asset as `CODE:ISSUER`
2374+
- `--from <FROM>` — Account or contract to claw the tokens back from. Accepts a `G…`/`M…` account, a `C…` contract address, or an alias
2375+
- `--amount <AMOUNT>` — Amount to claw back, in the token's smallest unit (stroops for a Stellar Asset Contract)
2376+
- `--output <OUTPUT>` — Format of the output
2377+
2378+
Default value: `text`
2379+
2380+
Possible values:
2381+
- `text`: Human-readable text
2382+
- `json`: Compact, single-line JSON output
2383+
- `json-formatted`: Formatted (multiline) JSON output
2384+
2385+
###### **RPC Options:**
2386+
2387+
- `--rpc-url <RPC_URL>` — RPC server endpoint
2388+
- `--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
2389+
- `--network-passphrase <NETWORK_PASSPHRASE>` — Network passphrase to sign the transaction sent to the rpc server
2390+
- `-n`, `--network <NETWORK>` — Name of network to use from config
2391+
2392+
###### **Signing Options:**
2393+
2394+
- `--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
2395+
- `--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`
2396+
- `--sign-with-lab` — Sign with https://lab.stellar.org
2397+
- `--sign-with-ledger` — Sign with a ledger wallet
2398+
- `--auto-sign` — Sign without prompting for approval. Only applies to signatures that require user approval, like non-root Soroban auth entries
2399+
2400+
###### **Transaction Options:**
2401+
2402+
- `-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
2403+
- `--fee <FEE>` — ⚠️ Deprecated, use `--inclusion-fee`. Fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm
2404+
- `--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
2405+
23582406
## `stellar tx`
23592407

23602408
Sign, Simulate, and Send transactions
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
use serde_json::Value;
2+
use soroban_test::{AssertExt, TestEnv};
3+
4+
use crate::integration::{
5+
token::{add_trustline, deploy_sac, issuer_pays, sac_balance, sac_id},
6+
util::{deploy_hello, new_account, test_address},
7+
};
8+
9+
/// Enable the clawback flag on `issuer`, so trustlines created afterwards are
10+
/// clawback-enabled. `AUTH_CLAWBACK_ENABLED` requires `AUTH_REVOCABLE`, so set
11+
/// both together.
12+
fn enable_clawback(sandbox: &TestEnv, issuer: &str) {
13+
sandbox
14+
.new_assert_cmd("tx")
15+
.args([
16+
"new",
17+
"set-options",
18+
"--set-revocable",
19+
"--set-clawback-enabled",
20+
"--source",
21+
issuer,
22+
])
23+
.assert()
24+
.success();
25+
}
26+
27+
#[tokio::test]
28+
async fn clawback_removes_balance_and_returns_receipt() {
29+
let sandbox = &TestEnv::new();
30+
let test = test_address(sandbox);
31+
let issuer = new_account(sandbox, "issuer");
32+
let asset = format!("USDC:{issuer}");
33+
34+
// Clawback requires the issuer to enable the flag *before* the holder's
35+
// trustline exists, so the trustline is created clawback-enabled.
36+
enable_clawback(sandbox, "issuer");
37+
add_trustline(sandbox, "test", &asset);
38+
deploy_sac(sandbox, &asset, "issuer");
39+
issuer_pays(sandbox, "issuer", &test, &asset, 10_000_000);
40+
41+
let stdout = sandbox
42+
.new_assert_cmd("token")
43+
.args([
44+
"clawback", "--id", &asset, "--source", "issuer", "--from", &test, "--amount",
45+
"4000000", "--output", "json",
46+
])
47+
.assert()
48+
.success()
49+
.stdout_as_str();
50+
let receipt: Value = serde_json::from_str(&stdout).unwrap();
51+
assert!(
52+
receipt["tx_hash"].as_str().is_some(),
53+
"expected a tx hash, got: {receipt}"
54+
);
55+
56+
// 10_000_000 minted − 4_000_000 clawed back = 6_000_000 remaining.
57+
let sac = sac_id(sandbox, &asset);
58+
assert_eq!(
59+
sac_balance(sandbox, &sac, &test),
60+
6_000_000,
61+
"expected the remaining balance after clawback"
62+
);
63+
}
64+
65+
#[tokio::test]
66+
async fn clawback_fails_when_sac_not_deployed() {
67+
let sandbox = &TestEnv::new();
68+
let test = test_address(sandbox);
69+
let issuer = new_account(sandbox, "issuer");
70+
let asset = format!("USDC:{issuer}");
71+
72+
// No SAC deployed → structured deploy-pointer error with a typed discriminator.
73+
let stdout = sandbox
74+
.new_assert_cmd("token")
75+
.args([
76+
"clawback", "--id", &asset, "--source", "issuer", "--from", &test, "--amount", "1",
77+
"--output", "json",
78+
])
79+
.assert()
80+
.failure()
81+
.stdout_as_str();
82+
let value: Value = serde_json::from_str(&stdout).unwrap();
83+
assert_eq!(
84+
value["error"]["type"], "sac_not_deployed",
85+
"expected a typed error, got: {stdout}"
86+
);
87+
}
88+
89+
#[tokio::test]
90+
async fn clawback_rejects_muxed_source_with_clear_error() {
91+
let sandbox = &TestEnv::new();
92+
let holder = new_account(sandbox, "holder");
93+
94+
// Muxed (M…) source accounts aren't supported by the invoke pipeline yet
95+
// (see #2645). Until then the command must reject them up front with a clear
96+
// message rather than a raw strkey decode error deep in the pipeline.
97+
let muxed = "MA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAAAAAAAAAPCICBKU";
98+
sandbox
99+
.new_assert_cmd("token")
100+
.args([
101+
"clawback", "--id", "native", "--source", muxed, "--from", &holder, "--amount", "1",
102+
])
103+
.assert()
104+
.failure()
105+
.stderr(predicates::str::contains(
106+
"muxed (M…) source accounts are not yet supported",
107+
));
108+
}
109+
110+
#[tokio::test]
111+
async fn clawback_rejects_negative_amount_before_any_rpc() {
112+
let sandbox = &TestEnv::new();
113+
let test = test_address(sandbox);
114+
115+
// A negative clawback is rejected at the CLI layer, before any network call.
116+
// `=` form so clap reads `-1` as the value, not an unknown flag.
117+
sandbox
118+
.new_assert_cmd("token")
119+
.args([
120+
"clawback",
121+
"--id",
122+
"native",
123+
"--source",
124+
"test",
125+
"--from",
126+
&test,
127+
"--amount=-1",
128+
])
129+
.assert()
130+
.failure()
131+
.stderr(predicates::str::contains("must not be negative"));
132+
}
133+
134+
#[tokio::test]
135+
async fn clawback_warns_when_target_is_not_a_sac() {
136+
let sandbox = &TestEnv::new();
137+
let test = test_address(sandbox);
138+
let contract_id = deploy_hello(sandbox).await;
139+
140+
// Pointing a SAC-admin command at a plain wasm contract warns. The clawback
141+
// then fails (hello_world has no `clawback`), but the heads-up is the point.
142+
let stderr = sandbox
143+
.new_assert_cmd("token")
144+
.args([
145+
"clawback",
146+
"--id",
147+
&contract_id,
148+
"--source",
149+
"test",
150+
"--from",
151+
&test,
152+
"--amount",
153+
"1",
154+
])
155+
.assert()
156+
.failure()
157+
.stderr_as_str();
158+
assert!(
159+
stderr.contains("is not a Stellar Asset Contract"),
160+
"expected a non-SAC warning, got: {stderr}"
161+
);
162+
}
163+
164+
#[tokio::test]
165+
async fn clawback_does_not_warn_when_target_is_a_sac() {
166+
let sandbox = &TestEnv::new();
167+
let test = test_address(sandbox);
168+
let issuer = new_account(sandbox, "issuer");
169+
let asset = format!("USDC:{issuer}");
170+
171+
enable_clawback(sandbox, "issuer");
172+
add_trustline(sandbox, "test", &asset);
173+
deploy_sac(sandbox, &asset, "issuer");
174+
issuer_pays(sandbox, "issuer", &test, &asset, 10_000_000);
175+
// Reference the SAC by its contract id, not the asset, so the check can only
176+
// clear it by inspecting the on-chain executable — not the id's text form.
177+
let sac = sac_id(sandbox, &asset);
178+
179+
let stderr = sandbox
180+
.new_assert_cmd("token")
181+
.args([
182+
"clawback", "--id", &sac, "--source", "issuer", "--from", &test, "--amount", "4000000",
183+
])
184+
.assert()
185+
.success()
186+
.stderr_as_str();
187+
assert!(
188+
!stderr.contains("is not a Stellar Asset Contract"),
189+
"a genuine SAC should not warn, got: {stderr}"
190+
);
191+
}

‎cmd/crates/soroban-test/tests/it/integration/token/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod approve;
33
pub mod balance;
44
pub mod burn;
55
pub mod burn_from;
6+
pub mod clawback;
67
pub mod decimals;
78
pub mod mint;
89
pub mod name;

‎cmd/soroban-cli/src/cli.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ fn json_error_format(cmd: &commands::Cmd) -> Option<crate::output::Format> {
153153
commands::Cmd::Token(token::Cmd::Approve(cmd)) => cmd.output.into(),
154154
commands::Cmd::Token(token::Cmd::Allowance(cmd)) => cmd.output.into(),
155155
commands::Cmd::Token(token::Cmd::Mint(cmd)) => cmd.output.into(),
156+
commands::Cmd::Token(token::Cmd::Clawback(cmd)) => cmd.output.into(),
156157
_ => return None,
157158
};
158159

0 commit comments

Comments
 (0)