diff --git a/cmd/crates/soroban-test/tests/it/integration/hello_world.rs b/cmd/crates/soroban-test/tests/it/integration/hello_world.rs index 394e510a36..08ff3eca6b 100644 --- a/cmd/crates/soroban-test/tests/it/integration/hello_world.rs +++ b/cmd/crates/soroban-test/tests/it/integration/hello_world.rs @@ -23,6 +23,43 @@ async fn invoke_view_with_non_existent_source_account() { assert_eq!(res, TxnResult::Res(format!(r#"["Hello",{world:?}]"#))); } +#[tokio::test] +async fn invoke_with_muxed_source_account() { + let sandbox = &TestEnv::new(); + let id = &deploy_hello(sandbox).await; + + // A muxed (M…) source is the funded `test` identity's G… account plus a + // mux id (#2645). Sending used to fail at the sequence-number lookup, + // which handed the M… strkey to a G-only parser; `--send=yes` forces the + // send path even though `hello` is read-only. An M-literal carries no + // secret, so signing goes through the identity via `--sign-with-key`. + let g_addr = crate::integration::util::test_address(sandbox); + let ed25519 = stellar_strkey::ed25519::PublicKey::from_string(&g_addr) + .unwrap() + .0; + let muxed = format!( + "{}", + stellar_strkey::ed25519::MuxedAccount { ed25519, id: 7 } + ); + + sandbox + .new_assert_cmd("contract") + .arg("invoke") + .arg("--id") + .arg(id) + .arg("--source-account") + .arg(&muxed) + .arg("--sign-with-key") + .arg("test") + .arg("--send=yes") + .arg("--") + .arg("hello") + .arg("--world=muxed") + .assert() + .success() + .stdout(predicates::str::contains(r#"["Hello","muxed"]"#)); +} + #[tokio::test] #[allow(clippy::too_many_lines)] async fn invoke_contract() { diff --git a/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs b/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs index 3115a1648f..629ca9feff 100644 --- a/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs +++ b/cmd/crates/soroban-test/tests/it/integration/token/transfer.rs @@ -219,26 +219,45 @@ async fn transfer_rejects_negative_amount_before_any_rpc() { } #[tokio::test] -async fn transfer_rejects_muxed_source_with_clear_error() { +async fn transfer_from_muxed_source() { let sandbox = &TestEnv::new(); let recipient = new_account(sandbox, "recipient"); deploy_sac(sandbox, "native", "test"); + let sac = sac_id(sandbox, "native"); + + // A muxed (M…) source is the funded `test` identity's G… account plus a + // mux id (#2645). An M-literal carries no secret, so signing goes through + // the identity via `--sign-with-key`. + let g_addr = test_address(sandbox); + let ed25519 = stellar_strkey::ed25519::PublicKey::from_string(&g_addr) + .unwrap() + .0; + let muxed = format!( + "{}", + stellar_strkey::ed25519::MuxedAccount { ed25519, id: 1 } + ); - // 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"; + let recipient_before = sac_balance(sandbox, &sac, &recipient); sandbox .new_assert_cmd("token") .args([ - "transfer", "--id", "native", "--to", &recipient, "--amount", "1", "--from", muxed, + "transfer", + "--id", + "native", + "--to", + &recipient, + "--amount", + "1", + "--from", + &muxed, + "--sign-with-key", + "test", ]) .assert() - .failure() - .stderr(predicates::str::contains( - "muxed (M…) source accounts are not yet supported", - )); + .success(); + + assert_eq!(sac_balance(sandbox, &sac, &recipient), recipient_before + 1); } #[tokio::test] diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 7db83a2a4d..efe7659333 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -348,8 +348,11 @@ impl Cmd { .verify_network_passphrase(Some(&network.network_passphrase)) .await?; + // A muxed (M…) source is an underlying G… account plus a mux id; + // only the underlying account exists on ledger, so the sequence + // number lookup must use its G… form (#2645). client - .get_account(&config.source_account()?.to_string()) + .get_account(&config.source_account()?.account_id().to_string()) .await? } else { if should_send == ShouldSend::DefaultNo { diff --git a/cmd/soroban-cli/src/commands/token/transfer.rs b/cmd/soroban-cli/src/commands/token/transfer.rs index 9d6e1efc6d..387b297a3b 100644 --- a/cmd/soroban-cli/src/commands/token/transfer.rs +++ b/cmd/soroban-cli/src/commands/token/transfer.rs @@ -68,12 +68,6 @@ pub enum Error { Invoke(#[from] invoke::Error), #[error(transparent)] Serde(#[from] serde_json::Error), - - #[error( - "muxed (M…) source accounts are not yet supported for `token transfer`; \ - use the underlying G… account as `--from` instead" - )] - MuxedSourceNotSupported, } /// Parse `--amount` as a non-negative `i128`. A negative transfer amount is @@ -101,7 +95,6 @@ impl Error { Error::ScAddress(_) => "invalid_address", Error::Invoke(_) => "invoke", Error::Serde(_) => "internal", - Error::MuxedSourceNotSupported => "unsupported", } } } @@ -146,17 +139,13 @@ impl Cmd { .resolve(&config.locator, &network.network_passphrase)?; // SEP-41 `transfer(from, to, amount)`: `from` is the source account - // (which also signs and authorizes), `to` is the destination. - // - // The invoke pipeline can't source a transaction from a muxed account - // yet (see #2645), and a muxed strkey in the `transfer` arg is rejected - // mid-simulation with an opaque host error; reject it up front with a - // clear message instead. - let source_account = config.source_account()?; - if matches!(source_account, crate::xdr::MuxedAccount::MuxedEd25519(_)) { - return Err(Error::MuxedSourceNotSupported); - } - let from = source_account.to_string(); + // (which also signs and authorizes), `to` is the destination. A muxed + // (M…) `from` resolves to its underlying G… account: `from` must + // authorize the transfer, muxed accounts are not valid authorizers, + // and the host rejects a muxed `from` in simulation with an opaque + // `Error(Value, UnexpectedType)` (#2645). A muxed `--to` flows through + // as-is, preserving the mux id in the `transfer` argument and events. + let from = config.source_account()?.account_id().to_string(); // `--to` may be an account (`G…`/`M…`), a contract (`C…`), or an alias; // resolve it to an `ScAddress` and hand the strkey to the `transfer` // arg, which accepts any of these destinations.