Skip to main content

soroban_cli/commands/token/
transfer.rs

1use std::ffi::OsString;
2
3use clap::Parser;
4
5use crate::{
6    commands::{
7        contract::invoke,
8        global,
9        token::args::{self, OutputFormat},
10    },
11    config::{
12        self, locator, network, sign_with, token::UnresolvedToken, UnresolvedContract,
13        UnresolvedMuxedAccount, UnresolvedScAddress,
14    },
15    output::Output,
16};
17
18#[derive(Debug, Parser, Clone)]
19#[group(skip)]
20pub struct Cmd {
21    /// The token to transfer from: a contract id or alias, `native`, or a
22    /// classic asset as `CODE:ISSUER`.
23    #[arg(long = "id")]
24    pub id: UnresolvedToken,
25
26    /// Account to transfer tokens from. Signs and authorizes the transfer, so it
27    /// must be an identity or secret key you control.
28    #[arg(long)]
29    pub from: UnresolvedMuxedAccount,
30
31    /// Account or contract to transfer the tokens to. Accepts a `G…`/`M…`
32    /// account, a `C…` contract address, or an alias.
33    #[arg(long)]
34    pub to: UnresolvedScAddress,
35
36    /// Amount to transfer, in the token's smallest unit (stroops for a Stellar
37    /// Asset Contract).
38    #[arg(long, value_parser = parse_nonneg_i128)]
39    pub amount: i128,
40
41    /// Format of the output.
42    #[arg(long, default_value = "text")]
43    pub output: OutputFormat,
44
45    #[command(flatten)]
46    pub network: network::Args,
47
48    #[command(flatten)]
49    pub locator: locator::Args,
50
51    #[command(flatten)]
52    pub sign_with: sign_with::Args,
53}
54
55#[derive(thiserror::Error, Debug)]
56pub enum Error {
57    #[error(transparent)]
58    Config(#[from] config::Error),
59    #[error(transparent)]
60    Network(#[from] network::Error),
61    #[error(transparent)]
62    Args(#[from] args::Error),
63    #[error(transparent)]
64    Token(#[from] config::token::Error),
65    #[error(transparent)]
66    ScAddress(#[from] config::sc_address::Error),
67    #[error(transparent)]
68    Invoke(#[from] invoke::Error),
69    #[error(transparent)]
70    Serde(#[from] serde_json::Error),
71
72    #[error(
73        "muxed (M…) source accounts are not yet supported for `token transfer`; \
74         use the underlying G… account as `--from` instead"
75    )]
76    MuxedSourceNotSupported,
77}
78
79/// Parse `--amount` as a non-negative `i128`. A negative transfer amount is
80/// always invalid, so reject it at the clap layer instead of letting it reach
81/// the contract and fail as an opaque `HostError` deep in simulation.
82fn parse_nonneg_i128(value: &str) -> Result<i128, String> {
83    let amount: i128 = value
84        .parse()
85        .map_err(|_| format!("invalid amount: {value}"))?;
86    if amount < 0 {
87        return Err(format!("amount must not be negative: {value}"));
88    }
89    Ok(amount)
90}
91
92impl Error {
93    /// Machine-readable discriminator for the JSON error envelope's `type` field.
94    #[must_use]
95    pub fn error_type(&self) -> &'static str {
96        match self {
97            Error::Config(_) => "config",
98            Error::Network(_) => "network",
99            Error::Args(e) => e.error_type(),
100            Error::Token(e) => e.error_type(),
101            Error::ScAddress(_) => "invalid_address",
102            Error::Invoke(_) => "invoke",
103            Error::Serde(_) => "internal",
104            Error::MuxedSourceNotSupported => "unsupported",
105        }
106    }
107}
108
109/// The machine-readable receipt of a token transfer.
110#[derive(Debug, serde::Serialize)]
111struct Receipt {
112    /// Hex-encoded hash of the submitted transaction.
113    tx_hash: Option<String>,
114    /// The decoded contract return value (`null` for SEP-41 `transfer`, which
115    /// returns nothing).
116    result: serde_json::Value,
117}
118
119impl Cmd {
120    /// Assemble a full [`config::Args`] for the underlying invocation, using
121    /// `--from` as the source account that signs and authorizes the transfer.
122    /// Fees are left unset so the pipeline applies its default inclusion fee —
123    /// this command intentionally exposes no fee or sequence knobs.
124    fn config(&self) -> config::Args {
125        config::Args {
126            network: self.network.clone(),
127            source_account: self.from.clone(),
128            locator: self.locator.clone(),
129            sign_with: self.sign_with.clone(),
130            fee: None,
131            inclusion_fee: None,
132        }
133    }
134
135    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
136        let output = Output::new(self.output.into(), global_args.quiet);
137        // In JSON mode the underlying invoke pipeline's human-readable status
138        // logging (which writes to stderr) would still fire; run it quietly so
139        // machine consumers get clean output without needing `--quiet`.
140        let quiet = global_args.quiet || output.is_json();
141        let config = self.config();
142        let network = config.get_network()?;
143
144        let token = self
145            .id
146            .resolve(&config.locator, &network.network_passphrase)?;
147
148        // SEP-41 `transfer(from, to, amount)`: `from` is the source account
149        // (which also signs and authorizes), `to` is the destination.
150        //
151        // The invoke pipeline can't source a transaction from a muxed account
152        // yet (see #2645), and a muxed strkey in the `transfer` arg is rejected
153        // mid-simulation with an opaque host error; reject it up front with a
154        // clear message instead.
155        let source_account = config.source_account()?;
156        if matches!(source_account, crate::xdr::MuxedAccount::MuxedEd25519(_)) {
157            return Err(Error::MuxedSourceNotSupported);
158        }
159        let from = source_account.to_string();
160        // `--to` may be an account (`G…`/`M…`), a contract (`C…`), or an alias;
161        // resolve it to an `ScAddress` and hand the strkey to the `transfer`
162        // arg, which accepts any of these destinations.
163        let to = self
164            .to
165            .clone()
166            .resolve(&config.locator, &network.network_passphrase, None)?
167            .to_string();
168        let amount = self.amount.to_string();
169
170        let slop: Vec<OsString> = [
171            "transfer", "--from", &from, "--to", &to, "--amount", &amount,
172        ]
173        .into_iter()
174        .map(OsString::from)
175        .collect();
176
177        let invoke_cmd = invoke::Cmd {
178            contract_id: UnresolvedContract::Resolved(token.contract_id),
179            slop,
180            config: config.clone(),
181            // A transfer always intends to submit. Force `Send::Yes` so a token
182            // whose `transfer` records no writes/events/auth can't be classified
183            // read-only and silently exit 0 without ever moving funds.
184            send: invoke::Send::Yes,
185            ..Default::default()
186        };
187
188        let receipt = invoke_cmd
189            .execute_with_receipt(&config, quiet, global_args.no_cache)
190            .await
191            .map_err(|e| {
192                args::not_deployed_error(&token, &e).map_or(Error::Invoke(e), Error::Args)
193            })?
194            .into_result();
195
196        // `transfer` always writes, so the invocation is submitted rather than
197        // resolved as a build-only transaction; a missing receipt would mean
198        // `--build-only`, which this command never sets.
199        let Some(receipt) = receipt else {
200            return Ok(());
201        };
202
203        let result = if receipt.output.is_empty() {
204            serde_json::Value::Null
205        } else {
206            serde_json::from_str(&receipt.output)
207                .unwrap_or(serde_json::Value::String(receipt.output.clone()))
208        };
209
210        // The pipeline already logs submission status and the explorer link to
211        // stderr; echo the hash to stdout so readable output is scriptable too.
212        if !output.is_json() {
213            if let Some(tx_hash) = &receipt.tx_hash {
214                println!("{tx_hash}");
215            }
216        }
217
218        output.json_value(&Receipt {
219            tx_hash: receipt.tx_hash,
220            result,
221        })?;
222
223        Ok(())
224    }
225}