diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 32e12d0759..0791da6f35 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1709,6 +1709,7 @@ Create a new transaction * `bump-sequence` — Bump sequence number to invalidate older transactions * `change-trust` — Create, update, or delete a trustline * `claim-claimable-balance` — Claim a claimable balance by its balance ID +* `clawback` — Clawback an asset from an account * `clawback-claimable-balance` — Clawback a claimable balance by its balance ID * `create-account` — Create and fund a new account * `create-claimable-balance` — Create a claimable balance that can be claimed by specified accounts @@ -1843,6 +1844,37 @@ Claim a claimable balance by its balance ID +## `stellar tx new clawback` + +Clawback an asset from an account + +**Usage:** `stellar tx new clawback [OPTIONS] --source-account --from --asset --amount ` + +###### **Options:** + +* `--fee ` — fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm + + Default value: `100` +* `--cost` — Output the cost execution to stderr +* `--instructions ` — Number of instructions to simulate +* `--build-only` — Build the transaction and only write the base64 xdr to stdout +* `--rpc-url ` — RPC server endpoint +* `--rpc-header ` — RPC Header(s) to include in requests to the RPC provider +* `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server +* `-n`, `--network ` — Name of network to use from config +* `-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` or `--sim-only` flags were NOT provided, this key will also be used to sign the final transaction. In that case, trying to sign with public key will fail +* `--global` — ⚠️ Deprecated: global config is always on +* `--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 +* `--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 +* `--from ` — Account to clawback assets from, e.g. `GBX...` +* `--asset ` — Asset to clawback +* `--amount ` — Amount of the asset to clawback, in stroops. 1 stroop = 0.0000001 of the asset + + + ## `stellar tx new clawback-claimable-balance` Clawback a claimable balance by its balance ID @@ -2280,6 +2312,7 @@ Add Operation to a transaction * `bump-sequence` — Bump sequence number to invalidate older transactions * `change-trust` — Create, update, or delete a trustline * `claim-claimable-balance` — Claim a claimable balance by its balance ID +* `clawback` — Clawback an asset from an account * `clawback-claimable-balance` — Clawback a claimable balance by its balance ID * `create-account` — Create and fund a new account * `create-claimable-balance` — Create a claimable balance that can be claimed by specified accounts @@ -2434,6 +2467,42 @@ Claim a claimable balance by its balance ID +## `stellar tx operation add clawback` + +Clawback an asset from an account + +**Usage:** `stellar tx operation add clawback [OPTIONS] --source-account --from --asset --amount [TX_XDR]` + +###### **Arguments:** + +* `` — Base-64 transaction envelope XDR or file containing XDR to decode, or stdin if empty + +###### **Options:** + +* `--operation-source-account ` [alias: `op-source`] — Source account used for the operation +* `--fee ` — fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm + + Default value: `100` +* `--cost` — Output the cost execution to stderr +* `--instructions ` — Number of instructions to simulate +* `--build-only` — Build the transaction and only write the base64 xdr to stdout +* `--rpc-url ` — RPC server endpoint +* `--rpc-header ` — RPC Header(s) to include in requests to the RPC provider +* `--network-passphrase ` — Network passphrase to sign the transaction sent to the rpc server +* `-n`, `--network ` — Name of network to use from config +* `-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` or `--sim-only` flags were NOT provided, this key will also be used to sign the final transaction. In that case, trying to sign with public key will fail +* `--global` — ⚠️ Deprecated: global config is always on +* `--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 +* `--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 +* `--from ` — Account to clawback assets from, e.g. `GBX...` +* `--asset ` — Asset to clawback +* `--amount ` — Amount of the asset to clawback, in stroops. 1 stroop = 0.0000001 of the asset + + + ## `stellar tx operation add clawback-claimable-balance` Clawback a claimable balance by its balance ID diff --git a/cmd/crates/soroban-test/tests/it/integration/tx/operations.rs b/cmd/crates/soroban-test/tests/it/integration/tx/operations.rs index d742b05c8f..94e1a779fa 100644 --- a/cmd/crates/soroban-test/tests/it/integration/tx/operations.rs +++ b/cmd/crates/soroban-test/tests/it/integration/tx/operations.rs @@ -1429,3 +1429,154 @@ async fn clawback_claimable_balance() { .assert() .failure(); // This should fail because the balance was clawed back } + +#[tokio::test] +async fn clawback() { + let sandbox = &TestEnv::new(); + let (test, issuer) = setup_accounts(sandbox); + + // Enable revocable flag first, then clawback on the issuer account + sandbox + .new_assert_cmd("tx") + .args(["new", "set-options", "--set-revocable", "--source", "test1"]) + .assert() + .success(); + + sandbox + .new_assert_cmd("tx") + .args([ + "new", + "set-options", + "--set-clawback-enabled", + "--source", + "test1", + ]) + .assert() + .success(); + + // Create asset for clawback test + let asset = format!("USDC:{issuer}"); + let limit = 100_000_000_000; + let initial_balance = 50_000_000_000; + issue_asset(sandbox, &test, &asset, limit, initial_balance).await; + + // Create holder account for clawback + let holder = new_account(sandbox, "holder"); + + // Setup trustline for holder + sandbox + .new_assert_cmd("tx") + .args([ + "new", + "change-trust", + "--source", + "holder", + "--line", + &asset, + ]) + .assert() + .success(); + + // Authorize holder's trustline and enable clawback + sandbox + .new_assert_cmd("tx") + .args([ + "new", + "set-trustline-flags", + "--asset", + &asset, + "--trustor", + &holder, + "--set-authorize", + "--source", + "test1", + ]) + .assert() + .success(); + + // Send some assets to the holder account + let payment_amount = "10000000000"; // 1000 USDC + sandbox + .new_assert_cmd("tx") + .args([ + "new", + "payment", + "--destination", + &holder, + "--asset", + &asset, + "--amount", + payment_amount, + "--source", + "test1", + ]) + .assert() + .success(); + + // Test clawback command + // this should succeed for the issuer + let clawback_amount = "5000000000"; // 500 USDC + sandbox + .new_assert_cmd("tx") + .args([ + "new", + "clawback", + "--from", + &holder, + "--asset", + &asset, + "--amount", + clawback_amount, + "--source", + "test1", // issuer should be able to clawback + ]) + .assert() + .success(); + + // Verify holder's balance after clawback (should be 500 USDC: 1000 sent - 500 clawed back) + let horizon_url = format!("http://localhost:8000/accounts/{}", holder); + let response = reqwest::get(&horizon_url) + .await + .expect("Failed to fetch account from Horizon"); + let json: serde_json::Value = response + .json() + .await + .expect("Failed to parse Horizon response"); + + let final_balance = json["balances"] + .as_array() + .unwrap() + .iter() + .find(|balance| { + balance["asset_code"].as_str() == Some("USDC") + && balance["asset_issuer"].as_str() == Some(&issuer) + }) + .expect("USDC balance not found after clawback")["balance"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + + assert_eq!( + final_balance, 500.0, + "Holder should have 500 USDC remaining after clawback (1000 sent - 500 clawed back)" + ); + + // Verify that a non-issuer cannot perform clawback + sandbox + .new_assert_cmd("tx") + .args([ + "new", + "clawback", + "--from", + &holder, + "--asset", + &asset, + "--amount", + "1000000000", // 100 USDC + "--source", + "holder", // non-issuer should not be able to clawback + ]) + .assert() + .failure(); +} diff --git a/cmd/soroban-cli/src/commands/tx/help.rs b/cmd/soroban-cli/src/commands/tx/help.rs index 076cc0dd85..fd611026e0 100644 --- a/cmd/soroban-cli/src/commands/tx/help.rs +++ b/cmd/soroban-cli/src/commands/tx/help.rs @@ -2,6 +2,7 @@ pub const ACCOUNT_MERGE: &str = "Transfer XLM balance to another account and rem pub const BUMP_SEQUENCE: &str = "Bump sequence number to invalidate older transactions"; pub const CHANGE_TRUST: &str = "Create, update, or delete a trustline"; pub const CLAIM_CLAIMABLE_BALANCE: &str = "Claim a claimable balance by its balance ID"; +pub const CLAWBACK: &str = "Clawback an asset from an account"; pub const CLAWBACK_CLAIMABLE_BALANCE: &str = "Clawback a claimable balance by its balance ID"; pub const CREATE_ACCOUNT: &str = "Create and fund a new account"; pub const CREATE_CLAIMABLE_BALANCE: &str = diff --git a/cmd/soroban-cli/src/commands/tx/new/clawback.rs b/cmd/soroban-cli/src/commands/tx/new/clawback.rs new file mode 100644 index 0000000000..0e3225f143 --- /dev/null +++ b/cmd/soroban-cli/src/commands/tx/new/clawback.rs @@ -0,0 +1,46 @@ +use clap::Parser; + +use crate::{commands::tx, config::address, tx::builder, xdr}; + +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + pub tx: tx::Args, + #[clap(flatten)] + pub op: Args, +} + +#[derive(Debug, clap::Args, Clone)] +pub struct Args { + /// Account to clawback assets from, e.g. `GBX...` + #[arg(long)] + pub from: address::UnresolvedMuxedAccount, + /// Asset to clawback + #[arg(long)] + pub asset: builder::Asset, + /// Amount of the asset to clawback, in stroops. 1 stroop = 0.0000001 of the asset + #[arg(long)] + pub amount: builder::Amount, +} + +impl TryFrom<&Cmd> for xdr::OperationBody { + type Error = tx::args::Error; + fn try_from( + Cmd { + tx, + op: + Args { + from, + asset, + amount, + }, + }: &Cmd, + ) -> Result { + Ok(xdr::OperationBody::Clawback(xdr::ClawbackOp { + from: tx.resolve_muxed_address(from)?, + asset: tx.resolve_asset(asset)?, + amount: amount.into(), + })) + } +} diff --git a/cmd/soroban-cli/src/commands/tx/new/mod.rs b/cmd/soroban-cli/src/commands/tx/new/mod.rs index 04a9dddb22..dd4410c745 100644 --- a/cmd/soroban-cli/src/commands/tx/new/mod.rs +++ b/cmd/soroban-cli/src/commands/tx/new/mod.rs @@ -7,6 +7,7 @@ pub mod account_merge; pub mod bump_sequence; pub mod change_trust; pub mod claim_claimable_balance; +pub mod clawback; pub mod clawback_claimable_balance; pub mod create_account; pub mod create_claimable_balance; @@ -31,6 +32,8 @@ pub enum Cmd { ChangeTrust(change_trust::Cmd), #[command(about = super::help::CLAIM_CLAIMABLE_BALANCE)] ClaimClaimableBalance(claim_claimable_balance::Cmd), + #[command(about = super::help::CLAWBACK)] + Clawback(clawback::Cmd), #[command(about = super::help::CLAWBACK_CLAIMABLE_BALANCE)] ClawbackClaimableBalance(clawback_claimable_balance::Cmd), #[command(about = super::help::CREATE_ACCOUNT)] @@ -71,6 +74,7 @@ impl TryFrom<&Cmd> for OperationBody { Cmd::BumpSequence(cmd) => cmd.into(), Cmd::ChangeTrust(cmd) => cmd.try_into()?, Cmd::ClaimClaimableBalance(cmd) => cmd.try_into()?, + Cmd::Clawback(cmd) => cmd.try_into()?, Cmd::ClawbackClaimableBalance(cmd) => cmd.try_into()?, Cmd::CreateAccount(cmd) => cmd.try_into()?, Cmd::CreateClaimableBalance(cmd) => cmd.try_into()?, @@ -95,6 +99,7 @@ impl Cmd { Cmd::BumpSequence(cmd) => cmd.tx.handle_and_print(op, global_args).await, Cmd::ChangeTrust(cmd) => cmd.tx.handle_and_print(op, global_args).await, Cmd::ClaimClaimableBalance(cmd) => cmd.tx.handle_and_print(op, global_args).await, + Cmd::Clawback(cmd) => cmd.tx.handle_and_print(op, global_args).await, Cmd::ClawbackClaimableBalance(cmd) => cmd.tx.handle_and_print(op, global_args).await, Cmd::CreateAccount(cmd) => cmd.tx.handle_and_print(op, global_args).await, Cmd::CreateClaimableBalance(cmd) => cmd.tx.handle_and_print(op, global_args).await, diff --git a/cmd/soroban-cli/src/commands/tx/op/add/clawback.rs b/cmd/soroban-cli/src/commands/tx/op/add/clawback.rs new file mode 100644 index 0000000000..29b8302679 --- /dev/null +++ b/cmd/soroban-cli/src/commands/tx/op/add/clawback.rs @@ -0,0 +1,10 @@ +use crate::commands::tx::new::clawback; + +#[derive(clap::Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + #[command(flatten)] + pub args: super::args::Args, + #[command(flatten)] + pub op: clawback::Cmd, +} diff --git a/cmd/soroban-cli/src/commands/tx/op/add/mod.rs b/cmd/soroban-cli/src/commands/tx/op/add/mod.rs index 64e3191efe..d3bc06365d 100644 --- a/cmd/soroban-cli/src/commands/tx/op/add/mod.rs +++ b/cmd/soroban-cli/src/commands/tx/op/add/mod.rs @@ -8,6 +8,7 @@ mod args; mod bump_sequence; mod change_trust; mod claim_claimable_balance; +mod clawback; mod clawback_claimable_balance; mod create_account; mod create_claimable_balance; @@ -32,6 +33,8 @@ pub enum Cmd { ChangeTrust(change_trust::Cmd), #[command(about = help::CLAIM_CLAIMABLE_BALANCE)] ClaimClaimableBalance(claim_claimable_balance::Cmd), + #[command(about = help::CLAWBACK)] + Clawback(clawback::Cmd), #[command(about = help::CLAWBACK_CLAIMABLE_BALANCE)] ClawbackClaimableBalance(clawback_claimable_balance::Cmd), #[command(about = help::CREATE_ACCOUNT)] @@ -78,6 +81,7 @@ impl TryFrom<&Cmd> for OperationBody { Cmd::BumpSequence(bump_sequence::Cmd { op, .. }) => op.into(), Cmd::ChangeTrust(change_trust::Cmd { op, .. }) => op.try_into()?, Cmd::ClaimClaimableBalance(claim_claimable_balance::Cmd { op, .. }) => op.try_into()?, + Cmd::Clawback(clawback::Cmd { op, .. }) => op.try_into()?, Cmd::ClawbackClaimableBalance(clawback_claimable_balance::Cmd { op, .. }) => { op.try_into()? } @@ -128,6 +132,11 @@ impl Cmd { tx_envelope_from_input(&cmd.args.tx_xdr)?, cmd.args.source(), ), + Cmd::Clawback(cmd) => cmd.op.tx.add_op( + op, + tx_envelope_from_input(&cmd.args.tx_xdr)?, + cmd.args.source(), + ), Cmd::ClawbackClaimableBalance(cmd) => cmd.op.tx.add_op( op, tx_envelope_from_input(&cmd.args.tx_xdr)?,