diff --git a/crates/full-node/sov-api-spec/openapi-v3.yaml b/crates/full-node/sov-api-spec/openapi-v3.yaml index d0ab3ba33a..6f5e111d8c 100644 --- a/crates/full-node/sov-api-spec/openapi-v3.yaml +++ b/crates/full-node/sov-api-spec/openapi-v3.yaml @@ -1218,6 +1218,10 @@ components: type: object description: Optional uniqueness data for the transaction additionalProperties: true + address_override: + type: string + nullable: true + description: Optional address override for execution; null uses default routing required: - sender - call diff --git a/crates/full-node/sov-ethereum/tests/integration/evm/evm_account_abstraction.rs b/crates/full-node/sov-ethereum/tests/integration/evm/evm_account_abstraction.rs index c74d067347..fead410128 100644 --- a/crates/full-node/sov-ethereum/tests/integration/evm/evm_account_abstraction.rs +++ b/crates/full-node/sov-ethereum/tests/integration/evm/evm_account_abstraction.rs @@ -67,6 +67,7 @@ fn create_insert_credentials( max_fee, UniquenessData::Nonce(nonce), gas_limit, + None, ), ) } diff --git a/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs b/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs index 4be0dae6e6..ffb28aa303 100644 --- a/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs +++ b/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs @@ -304,8 +304,18 @@ impl> SovereignSimulate { &self, params: &SimulateParameters, state: &mut StateCheckpoint, - ) -> AuthorizationData { - let credential_id = CredentialId::from_str(¶ms.sender).unwrap(); + ) -> Result, SimulateError> { + let credential_id = CredentialId::from_str(¶ms.sender).map_err(|e| { + SimulateError::InvalidInput(format!("failed to parse sender credential id: {e:?}")) + })?; + let address_override = params + .address_override + .as_deref() + .map(S::Address::from_str) + .transpose() + .map_err(|e| { + SimulateError::InvalidInput(format!("failed to parse address override: {e:?}")) + })?; let uniqueness = params.uniqueness.unwrap_or_else(|| { let generation = Uniqueness::::default() .next_generation(&credential_id, state) @@ -313,14 +323,15 @@ impl> SovereignSimulate { UniquenessData::Generation(generation) }); - AuthorizationData { + Ok(AuthorizationData { tx_hash: NULL_TX_HASH, non_malleable_hash: NULL_TX_HASH, uniqueness, credential_id, default_address: credential_id.into(), credentials: Credentials::new(credential_id), - } + address_override, + }) } fn outcome(&self, result: ApplyTxResult) -> SimulateOutcome { @@ -402,6 +413,8 @@ pub struct SimulateParameters { /// Optional uniqueness data for the transaction. /// If not provided a valid uniqueness will be used. pub uniqueness: Option, + /// Optional address override for execution; null uses default routing. + pub address_override: Option, } impl> SimulateEndpoint for SovereignSimulate { @@ -427,7 +440,7 @@ impl> SimulateEndpoint for SovereignSimulate { .chain_state() .base_fee_per_gas(&mut accessor) .ok_or(SimulateError::GasPriceRetrieval)?; - let auth_data = state.authorization_data(¶ms, &mut accessor); + let auth_data = state.authorization_data(¶ms, &mut accessor)?; let sequencer = state.sequencer(params.sequencer.unwrap_or_default())?; let auth_tx_data = AuthenticatedTransactionData(state.tx_details(params.tx_details.unwrap_or_default())?); diff --git a/crates/full-node/sov-rollup-apis/tests/integration/rest_api.rs b/crates/full-node/sov-rollup-apis/tests/integration/rest_api.rs index 6adfefc8c5..7650450324 100644 --- a/crates/full-node/sov-rollup-apis/tests/integration/rest_api.rs +++ b/crates/full-node/sov-rollup-apis/tests/integration/rest_api.rs @@ -1,10 +1,15 @@ use sov_api_spec::types; use sov_bank::{config_gas_token_id, Bank}; use sov_modules_api::prelude::tokio::{self}; -use sov_modules_api::{Amount, Gas, GasArray, GasSpec, Spec}; +use sov_modules_api::sov_universal_wallet::schema::RollupRoots; +use sov_modules_api::{ + get_runtime_schema, Amount, CredentialId, Gas, GasArray, GasSpec, Runtime, Spec, +}; use sov_rest_utils::json_obj; use sov_rollup_interface::node::SyncStatus; -use sov_test_utils::{AsUser, TestUser, TransactionTestCase}; +use sov_test_utils::{ + default_test_tx_details, AsUser, TestUser, TransactionTestCase, TransactionType, +}; use crate::{TestData, RT, S}; @@ -167,6 +172,90 @@ async fn test_simulation_success() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn test_simulation_success_with_address_override() { + let mut data = TestData::setup().await; + let delegated_credential: CredentialId = [9u8; 32].into(); + let registration_call = json_obj!({ + "accounts": { + "insert_credential_id": delegated_credential, + }, + }); + let schema = get_runtime_schema::().unwrap(); + let registration_call_bytes = schema + .json_to_borsh( + schema + .rollup_expected_index(RollupRoots::RuntimeCall) + .unwrap(), + &serde_json::to_string(®istration_call).unwrap(), + ) + .unwrap(); + + data.runner.execute_transaction(TransactionTestCase { + input: TransactionType::Plain { + message: RT::decode_call(®istration_call_bytes).unwrap(), + key: data.user.private_key().clone(), + details: default_test_tx_details::(), + }, + assert: Box::new(move |result, _state| { + assert!( + result.tx_receipt.is_successful(), + "The credential registration should have succeeded" + ); + }), + }); + data.send_storage(); + + let address_override = data.user.address(); + let receiver = TestUser::::generate_with_default_balance().address(); + let call = sov_bank::CallMessage::::Transfer { + to: receiver, + coins: sov_bank::Coins { + amount: Amount::new(1000), + token_id: config_gas_token_id(), + }, + }; + let params = json_obj!({ + "sender": delegated_credential.to_string(), + "address_override": address_override.to_string(), + "call": { + "bank": call, + }, + }); + let client = reqwest::Client::new(); + + let response = client + .post(format!("http://{}/rollup/simulate", data.axum_addr)) + .json(¶ms) + .send() + .await + .unwrap(); + let actual = response.json::().await.unwrap(); + + assert_eq!(actual["outcome"], "success"); + assert_eq!( + actual["events"][0], + serde_json::json!({ + "key": "Bank/TokenTransferred", + "module": "Bank", + "value": { + "token_transferred": { + "from": { + "user": address_override + }, + "to": { + "user": receiver + }, + "coins": { + "amount": "1000", + "token_id": "token_1nyl0e0yweragfsatygt24zmd8jrr2vqtvdfptzjhxkguz2xxx3vs0y07u7" + } + } + } + }) + ); +} + #[tokio::test(flavor = "multi_thread")] async fn test_simulation_fail() { let data = TestData::setup().await; diff --git a/crates/full-node/sov-sequencer/tests/integration/uniqueness.rs b/crates/full-node/sov-sequencer/tests/integration/uniqueness.rs index dcabcfc810..ac01c5cf3e 100644 --- a/crates/full-node/sov-sequencer/tests/integration/uniqueness.rs +++ b/crates/full-node/sov-sequencer/tests/integration/uniqueness.rs @@ -99,7 +99,7 @@ async fn test_mixed_nonce_and_generation_transactions() { let construct_tx = |uniqueness: UniquenessData| { let unsigned_tx = - UnsignedTransactionV0::new_with_details(msg.clone(), uniqueness, details.clone()); + UnsignedTransactionV0::new_with_details(msg.clone(), uniqueness, details.clone(), None); Transaction::::new_signed_tx( &test_user.private_key, &RT::CHAIN_HASH, diff --git a/crates/module-system/hyperlane/tests/integration/with_agent/helpers/mod.rs b/crates/module-system/hyperlane/tests/integration/with_agent/helpers/mod.rs index d09293c026..a6c3a2e4c5 100644 --- a/crates/module-system/hyperlane/tests/integration/with_agent/helpers/mod.rs +++ b/crates/module-system/hyperlane/tests/integration/with_agent/helpers/mod.rs @@ -215,9 +215,10 @@ impl HyperlaneBuilder { let docker_image = env::var("CUSTOM_HLP_DOCKER_IMAGE"); let has_custom_image = !matches!(docker_image, Err(env::VarError::NotPresent)); - // Current image is based on https://github.com/Sovereign-Labs/hyperlane-monorepo/tree/sovereign-lander-integration - let docker_image = docker_image - .unwrap_or_else(|_| "ghcr.io/theodorebugnet/hyperlane-agent:multisig_upgrade".into()); + // Current image is based on https://github.com/Sovereign-Labs/hyperlane-monorepo/tree/sovereign-lander-integration-for-multisig + let docker_image = docker_image.unwrap_or_else(|_| { + "ghcr.io/citizen-stig/hyperlane-agent:multisig-upgrade-address-override-2".into() + }); let (name, tag) = docker_image .split_once(':') .unwrap_or((&docker_image, "latest")); diff --git a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/mod.rs b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/mod.rs index c5fcbd69cf..99776cc8ce 100644 --- a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/mod.rs +++ b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/mod.rs @@ -19,7 +19,7 @@ use sov_test_utils::runtime::{config_gas_token_id, Payable, TestRunner}; type S = sov_test_utils::TestSpec; -use sov_modules_api::transaction::{Transaction, TxDetails, UnsignedTransactionV0}; +use sov_modules_api::transaction::{Transaction, TxDetails, UnsignedTransactionV0, Version0}; use sov_modules_api::{PrivateKey, RawTx}; use sov_test_utils::{EncodeCall, TestUser, TEST_DEFAULT_MAX_FEE}; use sov_value_setter::ValueSetter; @@ -243,6 +243,7 @@ pub fn create_tx_bad_sig>( TEST_DEFAULT_MAX_FEE, UniquenessData::Nonce(nonce), None, + None, ); let signed_tx = Transaction::::new_signed_tx(&signer.private_key, &RT::CHAIN_HASH, utx); @@ -251,18 +252,19 @@ pub fn create_tx_bad_sig>( let bad_signature = signer.private_key.sign(&[1, 2, 3]); match signed_tx { - Transaction::V0(inner) => Transaction::new_with_details_v0( - inner.pub_key, - inner.runtime_call, - bad_signature, - inner.uniqueness, - TxDetails { + Transaction::V0(inner) => Transaction::V0(Version0 { + signature: bad_signature, + pub_key: inner.pub_key, + runtime_call: inner.runtime_call, + uniqueness: inner.uniqueness, + details: TxDetails { max_priority_fee_bips, max_fee: Amount::new(200_000), gas_limit: None, chain_id, }, - ), + address_override: inner.address_override, + }), Transaction::V1(_inner) => { todo!("Bad signature generation for multisig transactions is not yet supported"); } @@ -283,6 +285,7 @@ pub fn create_tx_bad_sender>( Amount::new(200_000), UniquenessData::Nonce(nonce), None, + None, ); let signer = TestUser::::generate(Amount::ZERO); @@ -303,6 +306,7 @@ pub fn create_tx_valid>( TEST_DEFAULT_MAX_FEE, UniquenessData::Generation(generation), None, + None, ); Transaction::::new_signed_tx(signer.private_key(), &RT::CHAIN_HASH, utx) @@ -323,6 +327,7 @@ pub fn create_tx_out_of_gas>( Amount::new(200_000), UniquenessData::Nonce(nonce), Some(<::Gas as Gas>::zero()), + None, ); Transaction::::new_signed_tx(signer.private_key(), &RT::CHAIN_HASH, utx) diff --git a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/auth_eip712.rs b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/auth_eip712.rs index e8c7a369ed..654636d2bb 100644 --- a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/auth_eip712.rs +++ b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/operator/auth_eip712.rs @@ -8,6 +8,7 @@ use sov_modules_api::capabilities::{TransactionAuthenticator, UniquenessData}; use sov_modules_api::configurable_spec::ConfigurableSpec; use sov_modules_api::execution_mode::Native; use sov_modules_api::macros::config_value; +use sov_modules_api::prelude::UnwrapInfallible; use sov_modules_api::transaction::PubKeyAndSignature; use sov_modules_api::transaction::TxDetails; use sov_modules_api::transaction::{ @@ -133,12 +134,13 @@ fn setup() -> (TestRunner, TestUser) { } pub fn create_utx>(message: RT::Decodable) -> UnsignedTransactionV0 { - create_utx_with_generation::(message, 0) + create_utx_with_generation::(message, 0, None) } pub fn create_utx_with_generation>( message: RT::Decodable, generation: u64, + address_override: Option, ) -> UnsignedTransactionV0 { let details = TxDetails { max_priority_fee_bips: PriorityFeeBips::ZERO, @@ -150,6 +152,7 @@ pub fn create_utx_with_generation>( message, UniquenessData::Generation(generation), details, + address_override, ) } @@ -179,6 +182,7 @@ pub fn sign_utx_in_place>( pub fn sign_utx_v1_in_place>( utx: &UnsignedTransactionV0, multisig: &Multisig<::PublicKey>, + address_override: Option, private_key: &::PrivateKey, ) -> ::Signature { let schema = TestSchemaProvider::get_schema(); @@ -197,6 +201,7 @@ pub fn sign_utx_v1_in_place>( uniqueness: utx.uniqueness, details: utx.details.clone(), credential_address, + address_override, }); let utx_bytes = borsh::to_vec(&utx_enum).expect("Failed to serialize unsigned transaction"); let eip712_signing_data = schema @@ -222,11 +227,10 @@ pub fn create_tx>( sign_utx::(utx, signer) } -pub fn encode_message + EncodeCall>>() -> RT::Decodable { - let msg = CallMessage::SetValue { - value: 0, - gas: None, - }; +pub fn encode_message + EncodeCall>>( + value: u32, +) -> RT::Decodable { + let msg = CallMessage::SetValue { value, gas: None }; RT::to_decodable(msg) } @@ -261,7 +265,7 @@ fn execute_tx( #[test] fn correct_signature_is_accepted() { let (mut runner, admin) = setup(); - let call = encode_message::<_, RT>(); + let call = encode_message::<_, RT>(1); let tx = create_tx::<_, RT>(call, &admin); let receipt = execute_tx(&mut runner, tx); @@ -272,11 +276,9 @@ fn correct_signature_is_accepted() { #[test] fn test_multisig_signature_verification() { - // Build the multisig first so we can seed genesis with its canonical address - // funded. After the accounts refactor, `resolve_sender_address` no longer - // writes an `accounts` entry, so the multisig must either be a canonical- - // address owner with gas, or be covered by a legacy mapping. Here we pick - // the canonical path. + // `address_override` authorization correctness (positive + negative paths) is + // covered in sov-accounts/tests/integration/main.rs::test_v1_address_override_*. + // This test focuses on signature verification mechanics only. let multisig_keys = [ TestPrivateKey::generate(), TestPrivateKey::generate(), @@ -285,31 +287,41 @@ fn test_multisig_signature_verification() { let multisig = Multisig::new(2, multisig_keys.iter().map(|k| k.pub_key()).collect()); let multisig_credential_id = multisig.credential_id::<<::CryptoSpec as CryptoSpec>::Hasher>(); - let multisig_user = - TestUser::generate_with_default_balance().add_credential_id(multisig_credential_id); - // The multisig is the sender of every `SetValue` tx below, so ValueSetter's - // admin must be the multisig canonical address for the successful-path - // assertions to actually reach ValueSetter. - let multisig_address = multisig_user.address(); + // Alice is a regular funded user; she pays gas for the multisig's transactions. + // The multisig acts on her behalf via an `address_override` authorized in genesis. + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + let genesis_config = HighLevelOptimisticGenesisConfig::generate() - .add_accounts_with_default_balance(2) - .add_accounts(vec![multisig_user]); + .add_accounts_with_default_balance(1) + .add_accounts(vec![alice]); let module_config = sov_value_setter::ValueSetterConfig { - admin: multisig_address, + admin: alice_address, }; - let genesis = GenesisConfig::from_minimal_config(genesis_config.clone().into(), module_config); + let mut genesis = + GenesisConfig::from_minimal_config(genesis_config.clone().into(), module_config); + genesis.accounts.accounts.push(sov_accounts::AccountData { + credential_id: multisig_credential_id, + address: alice_address, + }); let mut runner = TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); // Generate a signature from a random private key that's not part of the multisig. We'll use this in some of the test cases. let random_private_key = TestPrivateKey::generate(); - let make_multisig_tx = |generation| { - let utx = create_utx_with_generation::(encode_message::<_, RT>(), generation); + let address_override = Some(alice_address); + let make_multisig_tx = |generation, value| { + let utx = create_utx_with_generation::( + encode_message::<_, RT>(value), + generation, + address_override, + ); let signatures = multisig_keys .iter() - .map(|key| sign_utx_v1_in_place(&utx, &multisig, key)) + .map(|key| sign_utx_v1_in_place(&utx, &multisig, address_override, key)) .collect::>(); - let random_signature = sign_utx_v1_in_place(&utx, &multisig, &random_private_key); + let random_signature = + sign_utx_v1_in_place(&utx, &multisig, address_override, &random_private_key); ( utx.to_multisig_tx(multisig.clone()), @@ -337,36 +349,46 @@ fn test_multisig_signature_verification() { "Expected error to contain {reason}, got: {error}" ); }; + let assert_stored_value = |runner: &mut TestRunner, expected: u32| { + runner.query_state(|state| { + let runtime = RT::default(); + assert_eq!( + runtime.value_setter.value.get(state).unwrap_infallible(), + Some(expected), + ); + }); + }; - // A transaction with three valid signatures should succeed + // A transaction with three valid signatures should succeed and write `value = 3`. let tx_with_three_sigs = { - let (mut tx, signatures, _) = make_multisig_tx(0); + let (mut tx, signatures, _) = make_multisig_tx(0, 3); for (key, signature) in multisig_keys.iter().zip(signatures.iter()) { tx.add_signature(signature.clone(), key.pub_key()).unwrap(); } Transaction::::from(tx) }; assert_tx_success(tx_with_three_sigs, &mut runner); + assert_stored_value(&mut runner, 3); - // A transaction with only two valid signatures should succeed + // A transaction with only two valid signatures should succeed and write `value = 2`. let tx_with_two_sigs = { - let (mut tx, signatures, _) = make_multisig_tx(1); + let (mut tx, signatures, _) = make_multisig_tx(1, 2); for (key, signature) in multisig_keys.iter().zip(signatures.iter().take(2)) { tx.add_signature(signature.clone(), key.pub_key()).unwrap(); } Transaction::::from(tx) }; assert_tx_success(tx_with_two_sigs, &mut runner); + assert_stored_value(&mut runner, 2); - let (base_skipped_tx, skipped_tx_signatures, random_signature) = make_multisig_tx(2); + // From here on, every case is expected to be skipped — `value` should remain at 2. + // Each case uses a distinct sentinel for `SetValue { value }` so that a regression + // letting any case slip through points to which one leaked. // A transaction with only one valid signature should be skipped, since this is a 2/3 multisig. let tx_with_one_sig = { - let mut tx = base_skipped_tx.clone(); - for (key, signature) in multisig_keys - .iter() - .zip(skipped_tx_signatures.iter().take(1)) - { + let (mut tx, signatures, _) = make_multisig_tx(2, 99); + for (key, signature) in multisig_keys.iter().zip(signatures.iter().take(1)) { tx.add_signature(signature.clone(), key.pub_key()).unwrap(); } Transaction::::from(tx) @@ -376,15 +398,16 @@ fn test_multisig_signature_verification() { &mut runner, "Not enough valid signatures. Required: 2, Got: 1", ); + assert_stored_value(&mut runner, 2); // A transaction where one of the signatures isn't from this account should be skipped, since this changes the computed credential ID. let tx_with_random_sig = { - let mut tx = base_skipped_tx.clone(); - tx.add_signature(skipped_tx_signatures[0].clone(), multisig_keys[0].pub_key()) + let (mut tx, signatures, random_signature) = make_multisig_tx(3, 98); + tx.add_signature(signatures[0].clone(), multisig_keys[0].pub_key()) .unwrap(); tx.signatures .try_push(PubKeyAndSignature { - signature: random_signature.clone(), + signature: random_signature, pub_key: random_private_key.pub_key(), }) .unwrap(); @@ -393,27 +416,29 @@ fn test_multisig_signature_verification() { // The random key changes the credential_address so it no longer matches the signed bytes, // so signature verification fails. assert_tx_skipped(tx_with_random_sig, &mut runner, "signature error"); + assert_stored_value(&mut runner, 2); // A transaction with a duplicate signature should be skipped — the duplicate changes // the credential_address, causing signature verification failure. let tx_with_duplicate_sig = { - let mut tx = base_skipped_tx.clone(); - tx.add_signature(skipped_tx_signatures[0].clone(), multisig_keys[0].pub_key()) + let (mut tx, signatures, _) = make_multisig_tx(4, 97); + tx.add_signature(signatures[0].clone(), multisig_keys[0].pub_key()) .unwrap(); tx.signatures .try_push(PubKeyAndSignature { - signature: skipped_tx_signatures[0].clone(), + signature: signatures[0].clone(), pub_key: multisig_keys[0].pub_key(), }) .unwrap(); Transaction::::from(tx) }; assert_tx_skipped(tx_with_duplicate_sig, &mut runner, "signature error"); + assert_stored_value(&mut runner, 2); - // A transaction with a bad signature should be skipped + // A transaction with a bad signature should be skipped. let tx_with_bad_sig = { - let mut tx = base_skipped_tx; - tx.add_signature(skipped_tx_signatures[0].clone(), multisig_keys[0].pub_key()) + let (mut tx, signatures, _) = make_multisig_tx(5, 96); + tx.add_signature(signatures[0].clone(), multisig_keys[0].pub_key()) .unwrap(); tx.add_signature( multisig_keys[1].sign(&[1, 2, 3]), @@ -423,4 +448,5 @@ fn test_multisig_signature_verification() { Transaction::::from(tx) }; assert_tx_skipped(tx_with_bad_sig, &mut runner, "signature error"); + assert_stored_value(&mut runner, 2); } diff --git a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/da_simulation.rs b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/da_simulation.rs index 48435c1a2b..da3fa55d4c 100644 --- a/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/da_simulation.rs +++ b/crates/module-system/module-implementations/integration-tests/tests/stf_blueprint/optimistic/da_simulation.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use sov_bank::Bank; use sov_modules_api::capabilities::{TransactionAuthenticator, UniquenessData}; -use sov_modules_api::transaction::{Transaction, UnsignedTransactionV0}; +use sov_modules_api::transaction::{Transaction, UnsignedTransactionV0, Version0}; use sov_modules_api::{Amount, EncodeCall, FullyBakedTx, PrivateKey, RawTx, Runtime}; use sov_test_utils::generators::bank::BankMessageGenerator; use sov_test_utils::generators::sequencer_registry::SequencerRegistryMessageGenerator; @@ -45,15 +45,16 @@ pub fn simulate_da_with_revert_msg(admin: TestPrivateKey) -> Vec { pub fn simulate_da_with_bad_sig(key: TestPrivateKey) -> Vec { let bank_generator: BankMessageGenerator = BankMessageGenerator::with_minter(key.clone()); let create_token_message = bank_generator.create_default_messages().remove(0); - let tx = Transaction::, S>::new_with_details_v0( - create_token_message.sender_key.pub_key(), - as EncodeCall>>::to_decodable(create_token_message.content), - // Use the signature of an empty message - key.sign(&[]), - UniquenessData::Generation(create_token_message.generation), - create_token_message.details, - ); - // Overwrite the signature with the signature of the empty message + let tx = Transaction::, S>::V0(Version0 { + signature: key.sign(&[]), + pub_key: create_token_message.sender_key.pub_key(), + runtime_call: as EncodeCall>>::to_decodable( + create_token_message.content, + ), + uniqueness: UniquenessData::Generation(create_token_message.generation), + details: create_token_message.details, + address_override: None, + }); vec![encode_with_auth(tx)] } @@ -86,6 +87,7 @@ pub fn simulate_da_with_bad_serialization(key: TestPrivateKey) -> Vec::generate(Amount::ZERO); @@ -290,6 +291,7 @@ mod helpers { TEST_DEFAULT_MAX_FEE, UniquenessData::Nonce(nonce), None, + None, ); Transaction::, S>::new_signed_tx( diff --git a/crates/module-system/module-implementations/sov-accounts/tests/integration/main.rs b/crates/module-system/module-implementations/sov-accounts/tests/integration/main.rs index de534a4773..6c112784ca 100644 --- a/crates/module-system/module-implementations/sov-accounts/tests/integration/main.rs +++ b/crates/module-system/module-implementations/sov-accounts/tests/integration/main.rs @@ -1,4 +1,4 @@ -use sov_accounts::{Accounts, CallMessage}; +use sov_accounts::{AccountData, Accounts, CallMessage}; use sov_modules_api::transaction::{UnsignedTransactionV0, Version1}; use sov_modules_api::{ CryptoSpec, PrivateKey, PublicKey, RawTx, Runtime, SkippedTxContents, Spec, TxEffect, @@ -21,16 +21,14 @@ type RT = TestAccountsRuntime; struct TestData { account_1: TestUser, - // `account_2` is intentionally kept in the fixture for the upcoming - // `target_address` PR. Until then it has no readers — silence the lint. - #[allow(dead_code)] account_2: TestUser, non_registered_account: TestUser, } /// We set up genesis with three accounts, two of which have custom credentials -/// authorized at genesis. -fn setup() -> (TestData, TestRunner) { +/// authorized at genesis. Returns the genesis so callers can extend it +/// (e.g. push extra `account_owners` mappings) before building the runner. +fn setup_genesis() -> (TestData, GenesisConfig) { let genesis_config = HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![ TestUser::generate_with_default_balance().add_credential_id([0u8; 32].into()), TestUser::generate_with_default_balance().add_credential_id([1u8; 32].into()), @@ -43,18 +41,22 @@ fn setup() -> (TestData, TestRunner) { let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); - let runner = TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); - ( TestData { account_1: user_1, account_2: user_2, non_registered_account: user_3, }, - runner, + genesis, ) } +fn setup() -> (TestData, TestRunner) { + let (data, genesis) = setup_genesis(); + let runner = TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + (data, runner) +} + fn setup_with_disable_custom_account_mappings() -> (TestUser, TestRunner) { let mut genesis_config = HighLevelOptimisticGenesisConfig::generate(); let user = TestUser::generate_with_default_balance(); @@ -180,7 +182,8 @@ fn test_setup_multisig_and_act() { let genesis_config = HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user.clone()]); let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); - let mut runner = TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); // Define utilities for... // - Generating a valid multisig (version 1) transaction @@ -195,6 +198,7 @@ fn test_setup_multisig_and_act() { )), sov_modules_api::capabilities::UniquenessData::Generation(0), default_test_tx_details::(), + None, ) .to_multisig_tx(multisig.clone()) }; @@ -485,3 +489,493 @@ fn test_disable_custom_account_mappings() { }), }); } + +/// A multisig test fixture: keys, envelope, credential id, and a funded `TestUser` +/// registered at the multisig's default address in genesis. +struct MultisigEnv { + keys: [TestPrivateKey; 3], + multisig: sov_modules_api::Multisig<<::CryptoSpec as CryptoSpec>::PublicKey>, + credential_id: sov_modules_api::CredentialId, + user: TestUser, +} + +fn make_multisig_env() -> MultisigEnv { + let keys = [ + TestPrivateKey::generate(), + TestPrivateKey::generate(), + TestPrivateKey::generate(), + ]; + let multisig = sov_modules_api::Multisig::new(2, keys.iter().map(|k| k.pub_key()).collect()); + let credential_id = multisig.credential_id::<<::CryptoSpec as CryptoSpec>::Hasher>(); + let user = TestUser::generate_with_default_balance().add_credential_id(credential_id); + MultisigEnv { + keys, + multisig, + credential_id, + user, + } +} + +/// Builds an unsigned V1 tx carrying an `InsertCredentialId` call. +fn make_v1_tx( + multisig: &sov_modules_api::Multisig<<::CryptoSpec as CryptoSpec>::PublicKey>, + inner_credential: sov_modules_api::CredentialId, + address_override: Option<::Address>, +) -> Version1 { + let details = default_test_tx_details::(); + UnsignedTransactionV0::::new( + TestAccountsRuntimeCall::Accounts(CallMessage::InsertCredentialId(inner_credential)), + details.chain_id, + details.max_priority_fee_bips, + details.max_fee, + sov_modules_api::capabilities::UniquenessData::Generation(0), + details.gas_limit, + address_override, + ) + .to_multisig_tx(multisig.clone()) +} + +fn make_v0_tx( + sender: &TestUser, + inner_credential: sov_modules_api::CredentialId, + address_override: Option<::Address>, +) -> Transaction { + let utx = UnsignedTransactionV0::::new_with_details( + TestAccountsRuntimeCall::Accounts(CallMessage::InsertCredentialId(inner_credential)), + sov_modules_api::capabilities::UniquenessData::Generation(0), + default_test_tx_details::(), + address_override, + ); + utx.sign(sender.private_key(), &>::CHAIN_HASH) +} + +fn sign_v1(tx: &mut Version1, key: &TestPrivateKey) { + tx.sign(key, &>::CHAIN_HASH).unwrap(); +} + +fn submit_v0(tx: Transaction) -> TransactionType { + TransactionType::::PreSigned(RawTx { + data: borsh::to_vec(&tx).unwrap(), + }) +} + +fn submit_v1(tx: Version1) -> TransactionType { + let tx = Transaction::::from(tx); + TransactionType::::PreSigned(RawTx { + data: borsh::to_vec(&tx).unwrap(), + }) +} + +/// V1 tx with `address_override = None` executes as the multisig's default +/// address. Evidence: the `InsertCredentialId` call writes +/// `(multisig_default, inner_credential)` to `account_owners`. +#[test] +fn test_v1_address_override_none_uses_default_resolver() { + let MultisigEnv { + keys, + multisig, + user: multisig_user, + .. + } = make_multisig_env(); + let multisig_default_address = multisig_user.address(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut tx = make_v1_tx(&multisig, inner_credential, None); + sign_v1(&mut tx, &keys[0]); + sign_v1(&mut tx, &keys[1]); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(tx), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!( + accounts + .is_explicitly_authorized(&multisig_default_address, &inner_credential, state) + .unwrap(), + "address_override=None should route to multisig default; the InsertCredentialId \ + call must write the new credential under that address" + ); + }), + }); +} + +/// V1 tx with `address_override = Some(X)` where `(X, multisig_credential_id)` is +/// authorized resolves context as `X`. Evidence: `InsertCredentialId` writes +/// `(X, inner_credential)` — not `(multisig_default, inner_credential)`. +#[test] +fn test_v1_address_override_some_authorized_succeeds() { + let MultisigEnv { + keys, + multisig, + credential_id: multisig_credential_id, + user: multisig_user, + } = make_multisig_env(); + + // Alice is a regular funded user; we authorize the multisig for her address. + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user, alice]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + genesis.accounts.accounts.push(AccountData { + credential_id: multisig_credential_id, + address: alice_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut tx = make_v1_tx(&multisig, inner_credential, Some(alice_address)); + sign_v1(&mut tx, &keys[0]); + sign_v1(&mut tx, &keys[1]); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(tx), + assert: Box::new(move |result, state| { + assert!( + result.tx_receipt.is_successful(), + "V1 address_override=Some(authorized) should succeed, got {:?}", + result.tx_receipt + ); + let accounts = Accounts::::default(); + assert!( + accounts + .is_explicitly_authorized(&alice_address, &inner_credential, state) + .unwrap(), + "InsertCredentialId should write under address_override, not multisig default" + ); + }), + }); +} + +/// V1 tx with `address_override = Some(Y)` where `(Y, credential_id) ∉ account_owners` +/// is skipped, not reverted. No state is mutated — in particular, no auto-register +/// on the `Some` path. +#[test] +fn test_v1_address_override_some_unauthorized_skipped() { + let MultisigEnv { + keys, + multisig, + user: multisig_user, + .. + } = make_multisig_env(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + // Unowned: nothing in `account_owners` points to this address. + let unowned_address = TestUser::::generate_with_default_balance().address(); + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + + let mut tx = make_v1_tx(&multisig, inner_credential, Some(unowned_address)); + sign_v1(&mut tx, &keys[0]); + sign_v1(&mut tx, &keys[1]); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(tx), + assert: Box::new(move |result, state| { + match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("not authorized for address override"), + "unexpected skip reason: {msg}" + ); + } + other => panic!("expected skipped tx, got {other:?}"), + } + // No auto-register on the `Some` path: the unauthorized lookup must + // not have created an entry. + let accounts = Accounts::::default(); + assert!( + !accounts + .is_explicitly_authorized(&unowned_address, &inner_credential, state) + .unwrap(), + "unauthorized address_override path must not auto-register any tuple" + ); + }), + }); +} + +/// `address_override = None` ignores any pre-existing `(X, multisig_credential_id)` +/// mapping and routes to the multisig's default address. The mapping is untouched. +#[test] +fn test_v1_address_override_none_ignores_existing_mapping() { + let MultisigEnv { + keys, + multisig, + credential_id: multisig_credential_id, + user: multisig_user, + } = make_multisig_env(); + let multisig_default_address = multisig_user.address(); + + // Alice has an authorization for the multisig credential, but the V1 tx below + // signs with `None` and so must not route through her address. + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user, alice]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + genesis.accounts.accounts.push(AccountData { + credential_id: multisig_credential_id, + address: alice_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut tx = make_v1_tx(&multisig, inner_credential, None); + sign_v1(&mut tx, &keys[0]); + sign_v1(&mut tx, &keys[1]); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(tx), + assert: Box::new(move |result, state| { + assert!( + result.tx_receipt.is_successful(), + "V1 address_override=None should succeed, got {:?}", + result.tx_receipt + ); + let accounts = Accounts::::default(); + assert!( + accounts + .is_explicitly_authorized(&multisig_default_address, &inner_credential, state) + .unwrap(), + "None must route to multisig default" + ); + assert!( + !accounts + .is_explicitly_authorized(&alice_address, &inner_credential, state) + .unwrap(), + "None must not write under alice's address" + ); + assert!( + accounts + .is_explicitly_authorized(&alice_address, &multisig_credential_id, state) + .unwrap(), + "pre-existing (alice, multisig_credential_id) authorization must be preserved" + ); + }), + }); +} + +/// `address_override` is part of the signed bytes: tampering with it after signing +/// invalidates the signature. +#[test] +fn test_v1_address_override_tamper_breaks_signature() { + let MultisigEnv { + keys, + multisig, + credential_id: multisig_credential_id, + user: multisig_user, + } = make_multisig_env(); + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![multisig_user, alice]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + // Authorize the multisig for both alice and a second, unrelated address so + // the "tampered-to" address is also in the account_owners map. This isolates + // the failure to signature verification rather than authorization. + let tampered_address = TestUser::::generate_with_default_balance().address(); + genesis.accounts.accounts.push(AccountData { + credential_id: multisig_credential_id, + address: alice_address, + }); + genesis.accounts.accounts.push(AccountData { + credential_id: multisig_credential_id, + address: tampered_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut tx = make_v1_tx(&multisig, inner_credential, Some(alice_address)); + sign_v1(&mut tx, &keys[0]); + sign_v1(&mut tx, &keys[1]); + // Mutate after signing. + tx.address_override = Some(tampered_address); + + runner.execute_transaction(TransactionTestCase { + input: submit_v1(tx), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("Verification equation was not satisfied"), + "expected signature failure after address_override tamper, got: {msg}" + ); + } + other => panic!("expected skipped tx, got {other:?}"), + }), + }); +} + +/// V0 tx with `address_override = None` executes as the signer's default address. +#[test] +fn test_v0_address_override_none_uses_default_resolver() { + let sender = TestUser::::generate_with_default_balance(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![sender.clone()]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let tx = make_v0_tx(&sender, inner_credential, None); + + runner.execute_transaction(TransactionTestCase { + input: submit_v0(tx), + assert: Box::new(move |result, state| { + assert!(result.tx_receipt.is_successful()); + let accounts = Accounts::::default(); + assert!( + accounts + .is_explicitly_authorized(&sender.address(), &inner_credential, state) + .unwrap(), + "address_override=None should route to the signer's default address" + ); + }), + }); +} + +/// V0 tx with `address_override = Some(X)` where `(X, credential_id)` is +/// authorized resolves context as `X`. +#[test] +fn test_v0_address_override_some_authorized_succeeds() { + let ( + TestData { + account_2, + non_registered_account, + .. + }, + mut genesis, + ) = setup_genesis(); + let alice_address = account_2.address(); + genesis.accounts.accounts.push(AccountData { + credential_id: non_registered_account.credential_id(), + address: alice_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let tx = make_v0_tx( + &non_registered_account, + inner_credential, + Some(alice_address), + ); + + runner.execute_transaction(TransactionTestCase { + input: submit_v0(tx), + assert: Box::new(move |result, state| { + assert!( + result.tx_receipt.is_successful(), + "V0 address_override=Some(authorized) should succeed, got {:?}", + result.tx_receipt + ); + let accounts = Accounts::::default(); + assert!( + accounts + .is_explicitly_authorized(&alice_address, &inner_credential, state) + .unwrap(), + "InsertCredentialId should write under address_override, not sender default" + ); + }), + }); +} + +/// V0 tx with `address_override = Some(Y)` where `(Y, credential_id) ∉ account_owners` +/// is skipped, not reverted. +#[test] +fn test_v0_address_override_some_unauthorized_skipped() { + let sender = TestUser::::generate_with_default_balance(); + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![sender.clone()]); + let genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let unowned_address = TestUser::::generate_with_default_balance().address(); + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let tx = make_v0_tx(&sender, inner_credential, Some(unowned_address)); + + runner.execute_transaction(TransactionTestCase { + input: submit_v0(tx), + assert: Box::new(move |result, state| { + match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("not authorized for address override"), + "unexpected skip reason: {msg}" + ); + } + other => panic!("expected skipped tx, got {other:?}"), + } + let accounts = Accounts::::default(); + assert!( + !accounts + .is_explicitly_authorized(&unowned_address, &inner_credential, state) + .unwrap(), + "unauthorized address_override path must not auto-register any tuple" + ); + }), + }); +} + +/// `address_override` is part of the signed bytes for V0 as well: tampering with it +/// after signing invalidates the signature. +#[test] +fn test_v0_address_override_tamper_breaks_signature() { + let sender = TestUser::::generate_with_default_balance(); + let alice = TestUser::::generate_with_default_balance(); + let alice_address = alice.address(); + let tampered_address = TestUser::::generate_with_default_balance().address(); + + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts(vec![sender.clone(), alice]); + let mut genesis = GenesisConfig::from_minimal_config(genesis_config.into()); + genesis.accounts.accounts.push(AccountData { + credential_id: sender.credential_id(), + address: alice_address, + }); + genesis.accounts.accounts.push(AccountData { + credential_id: sender.credential_id(), + address: tampered_address, + }); + let mut runner: TestRunner = + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()); + + let inner_credential = TestPrivateKey::generate().pub_key().credential_id(); + let mut tx = make_v0_tx(&sender, inner_credential, Some(alice_address)); + let Transaction::V0(inner) = &mut tx else { + panic!("expected v0 tx"); + }; + inner.address_override = Some(tampered_address); + + runner.execute_transaction(TransactionTestCase { + input: submit_v0(tx), + assert: Box::new(move |result, _state| match result.tx_receipt { + TxEffect::Skipped(SkippedTxContents { error, .. }) => { + let msg = error.to_string(); + assert!( + msg.contains("Verification equation was not satisfied"), + "expected signature failure after address_override tamper, got: {msg}" + ); + } + other => panic!("expected skipped tx, got {other:?}"), + }), + }); +} diff --git a/crates/module-system/module-implementations/sov-evm/src/authenticate.rs b/crates/module-system/module-implementations/sov-evm/src/authenticate.rs index 2caceecc71..2904241d96 100644 --- a/crates/module-system/module-implementations/sov-evm/src/authenticate.rs +++ b/crates/module-system/module-implementations/sov-evm/src/authenticate.rs @@ -220,6 +220,7 @@ where credential_id, credentials, default_address: S::Address::from_vm_address(ethereum_address), + address_override: None, } } diff --git a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs index 06c5e3e92f..087448e768 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs +++ b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs @@ -160,6 +160,7 @@ fn send_tx_bad_generation_duplicate_with_malleated_v1_envelope() { runtime_msg, UniquenessData::Generation(0), default_test_tx_details::(), + None, ) .to_multisig_tx(multisig); original_tx diff --git a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/utils.rs b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/utils.rs index 8f90a79570..e7aa51f127 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/utils.rs +++ b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/utils.rs @@ -100,6 +100,7 @@ pub(crate) fn generate_value_setter_tx( TEST_DEFAULT_MAX_FEE, UniquenessData::Generation(generation), None, + None, ); let transaction = Transaction::::new_signed_tx( diff --git a/crates/module-system/sov-capabilities/src/lib.rs b/crates/module-system/sov-capabilities/src/lib.rs index c0e1274958..42f5bf0c24 100644 --- a/crates/module-system/sov-capabilities/src/lib.rs +++ b/crates/module-system/sov-capabilities/src/lib.rs @@ -311,13 +311,28 @@ impl TransactionAuthorizer for StandardProvenRollupCapabilities<' auth_data: &AuthorizationData, sequencer: &::Address, sequencer_rollup_address: S::Address, - _state: &mut impl StateAccessor, + state: &mut impl StateAccessor, sequencing_data: Option, execution_context: ExecutionContext, sequencer_type: SequencerType, ) -> anyhow::Result> { + let sender = match auth_data.address_override { + Some(address_override) => { + anyhow::ensure!( + self.accounts.is_explicitly_authorized( + &address_override, + &auth_data.credential_id, + state, + )?, + "not authorized for address override" + ); + address_override + } + None => auth_data.default_address, + }; + Ok(Context::new( - auth_data.default_address, + sender, auth_data.credentials.clone(), sequencer_rollup_address, *sequencer, @@ -331,14 +346,29 @@ impl TransactionAuthorizer for StandardProvenRollupCapabilities<' &mut self, auth_data: &AuthorizationData, sequencer: &<::Da as DaSpec>::Address, - _state: &mut impl StateAccessor, + state: &mut impl StateAccessor, execution_context: ExecutionContext, ) -> anyhow::Result> { - // The tx sender & sequencer are the same entity + // The tx sender & sequencer are the same entity on this path. + let address = match auth_data.address_override { + Some(address_override) => { + anyhow::ensure!( + self.accounts.is_explicitly_authorized( + &address_override, + &auth_data.credential_id, + state, + )?, + "not authorized for address override" + ); + address_override + } + None => auth_data.default_address, + }; + Ok(Context::new( - auth_data.default_address, + address, auth_data.credentials.clone(), - auth_data.default_address, + address, *sequencer, None, execution_context, diff --git a/crates/module-system/sov-cli/src/lib.rs b/crates/module-system/sov-cli/src/lib.rs index 7f9b41eaae..29c6be6d2a 100644 --- a/crates/module-system/sov-cli/src/lib.rs +++ b/crates/module-system/sov-cli/src/lib.rs @@ -86,6 +86,7 @@ where self.details.max_fee, UniquenessData::Generation(generation), self.details.gas_limit, + None, ) } } diff --git a/crates/module-system/sov-cli/tests/integration/transactions.rs b/crates/module-system/sov-cli/tests/integration/transactions.rs index 8678959f39..8b465c991f 100644 --- a/crates/module-system/sov-cli/tests/integration/transactions.rs +++ b/crates/module-system/sov-cli/tests/integration/transactions.rs @@ -102,6 +102,7 @@ fn transaction_is_serialized_correctly() { max_fee, UniquenessData::Generation(initial_nonce + i as u64), gas_limit, + None, ), ); diff --git a/crates/module-system/sov-modules-api/src/runtime/capabilities/authorization.rs b/crates/module-system/sov-modules-api/src/runtime/capabilities/authorization.rs index a5eef7f565..618cd2ee60 100644 --- a/crates/module-system/sov-modules-api/src/runtime/capabilities/authorization.rs +++ b/crates/module-system/sov-modules-api/src/runtime/capabilities/authorization.rs @@ -102,4 +102,11 @@ pub struct AuthorizationData { /// The default address. pub default_address: S::Address, + + /// Signer-declared override of the default execution address. + /// + /// `None` => resolve to the credential's default address. + /// `Some(X)` => requires an explicit `(X, credential_id)` entry in `account_owners`; + /// the transaction is skipped otherwise. + pub address_override: Option, } diff --git a/crates/module-system/sov-modules-api/src/transaction/mod.rs b/crates/module-system/sov-modules-api/src/transaction/mod.rs index c9557a9c34..a099922d3e 100644 --- a/crates/module-system/sov-modules-api/src/transaction/mod.rs +++ b/crates/module-system/sov-modules-api/src/transaction/mod.rs @@ -23,7 +23,6 @@ pub use types::{ }; pub use unsigned::{UnsignedTransaction, UnsignedTransactionV0, UnsignedTransactionV1}; -use crate::capabilities::UniquenessData; use crate::{ CryptoSpecExt, DispatchCall, Gas, GasMeter, GasMeteringError, GasSpec, MeteredBorshDeserialize, MeteredBorshDeserializeError, MeteredSigVerificationError, MeteredSignature, Spec, @@ -219,23 +218,6 @@ impl Transaction { } } - /// Creates a new transaction with the provided metadata. - pub fn new_with_details_v0( - pub_key: C::PublicKey, - runtime_call: R::Call, - signature: C::Signature, - uniqueness: UniquenessData, - details: TxDetails, - ) -> Self { - Self::V0(Version0 { - signature, - pub_key, - runtime_call, - uniqueness, - details, - }) - } - /// Serialize the transaction, appending the runtime's chain_hash. /// This is the standard serialization for Sovereign signature signing. pub fn serialized_with_chain_hash( diff --git a/crates/module-system/sov-modules-api/src/transaction/types/v0.rs b/crates/module-system/sov-modules-api/src/transaction/types/v0.rs index c34734bbac..fa175f2d55 100644 --- a/crates/module-system/sov-modules-api/src/transaction/types/v0.rs +++ b/crates/module-system/sov-modules-api/src/transaction/types/v0.rs @@ -41,16 +41,21 @@ pub struct Version0, + /// Signer-declared address override. + /// See [`crate::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default)] + pub address_override: Option, } impl Version0 { /// Extracts the versioned unsigned transaction data from this signed envelope. pub fn as_unsigned(&self) -> UnsignedTransaction { - UnsignedTransaction::V0(UnsignedTransactionV0::new_with_details( - self.runtime_call.clone(), - self.uniqueness, - self.details.clone(), - )) + UnsignedTransaction::V0(UnsignedTransactionV0 { + runtime_call: self.runtime_call.clone(), + uniqueness: self.uniqueness, + details: self.details.clone(), + address_override: self.address_override, + }) } /// Extracts authorization data from this transaction. @@ -71,6 +76,7 @@ impl Version0 { credential_id, credentials: Credentials::new(pub_key), default_address: credential_id.into(), + address_override: self.address_override, }) } } diff --git a/crates/module-system/sov-modules-api/src/transaction/types/v1.rs b/crates/module-system/sov-modules-api/src/transaction/types/v1.rs index 2efba818cb..77ac5338fa 100644 --- a/crates/module-system/sov-modules-api/src/transaction/types/v1.rs +++ b/crates/module-system/sov-modules-api/src/transaction/types/v1.rs @@ -87,6 +87,10 @@ pub struct Version1, + /// Signer-declared address override. + /// See [`crate::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default)] + pub address_override: Option, } impl Version1 { @@ -111,6 +115,7 @@ impl Version1 { uniqueness: self.uniqueness, details: self.details.clone(), credential_address: self.credential_address(), + address_override: self.address_override, }) } } @@ -188,6 +193,7 @@ impl Version1 { credential_id, credentials: Credentials::new(multisig), default_address: credential_id.into(), + address_override: self.address_override, }) } } diff --git a/crates/module-system/sov-modules-api/src/transaction/unsigned/v0.rs b/crates/module-system/sov-modules-api/src/transaction/unsigned/v0.rs index b794137e14..b29a10a956 100644 --- a/crates/module-system/sov-modules-api/src/transaction/unsigned/v0.rs +++ b/crates/module-system/sov-modules-api/src/transaction/unsigned/v0.rs @@ -6,7 +6,9 @@ use sov_universal_wallet::UniversalWallet; use crate::{ capabilities::UniquenessData, - transaction::{PriorityFeeBips, Transaction, TransactionCallable, TxDetails, Version1}, + transaction::{ + PriorityFeeBips, Transaction, TransactionCallable, TxDetails, Version0, Version1, + }, Amount, CryptoSpecExt, Multisig, Spec, }; @@ -27,6 +29,10 @@ pub struct UnsignedTransactionV0 { pub uniqueness: UniquenessData, /// Data related to fees and gas handling. pub details: TxDetails, + /// Signer-declared address override. + /// See [`crate::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default)] + pub address_override: Option, } // Manually implemented to ensure correct trait bounds (derive would require R: Clone/PartialEq) @@ -36,6 +42,7 @@ impl Clone for UnsignedTransactionV0 { runtime_call: self.runtime_call.clone(), uniqueness: self.uniqueness, details: self.details.clone(), + address_override: self.address_override, } } } @@ -44,6 +51,7 @@ impl PartialEq for UnsignedTransactionV0 self.runtime_call == other.runtime_call && self.uniqueness == other.uniqueness && self.details == other.details + && self.address_override == other.address_override } } impl Eq for UnsignedTransactionV0 {} @@ -71,6 +79,7 @@ impl UnsignedTransactionV0 { max_fee: Amount, uniqueness: UniquenessData, gas_limit: Option, + address_override: Option, ) -> Self { Self { runtime_call, @@ -81,6 +90,7 @@ impl UnsignedTransactionV0 { gas_limit, chain_id, }, + address_override, } } @@ -89,11 +99,13 @@ impl UnsignedTransactionV0 { runtime_call: R::Call, uniqueness: UniquenessData, details: TxDetails, + address_override: Option, ) -> Self { Self { runtime_call, uniqueness, details, + address_override, } } @@ -104,13 +116,14 @@ impl UnsignedTransactionV0 { pub_key: C::PublicKey, signature: C::Signature, ) -> Transaction { - Transaction::new_with_details_v0( - pub_key, - self.runtime_call, + Transaction::V0(Version0 { signature, - self.uniqueness, - self.details, - ) + pub_key, + runtime_call: self.runtime_call, + uniqueness: self.uniqueness, + details: self.details, + address_override: self.address_override, + }) } /// Creates a new `V1` transaction from this unsigned transaction. @@ -128,6 +141,7 @@ impl UnsignedTransactionV0 { runtime_call: self.runtime_call, uniqueness: self.uniqueness, details: self.details, + address_override: self.address_override, } } diff --git a/crates/module-system/sov-modules-api/src/transaction/unsigned/v1.rs b/crates/module-system/sov-modules-api/src/transaction/unsigned/v1.rs index 362affe05a..574fadf9b1 100644 --- a/crates/module-system/sov-modules-api/src/transaction/unsigned/v1.rs +++ b/crates/module-system/sov-modules-api/src/transaction/unsigned/v1.rs @@ -31,6 +31,10 @@ pub struct UnsignedTransactionV1 { /// and prevent credential malleability from reusing signed bytes in a different /// multisig envelope. pub credential_address: S::Address, + /// Signer-declared address override. + /// See [`crate::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default)] + pub address_override: Option, } impl Clone for UnsignedTransactionV1 { @@ -40,6 +44,7 @@ impl Clone for UnsignedTransactionV1 { uniqueness: self.uniqueness, details: self.details.clone(), credential_address: self.credential_address, + address_override: self.address_override, } } } @@ -49,6 +54,7 @@ impl PartialEq for UnsignedTransactionV1 && self.uniqueness == other.uniqueness && self.details == other.details && self.credential_address == other.credential_address + && self.address_override == other.address_override } } impl Eq for UnsignedTransactionV1 {} diff --git a/crates/module-system/sov-modules-api/tests/integration/transaction.rs b/crates/module-system/sov-modules-api/tests/integration/transaction.rs index edf8319a13..651d433b1a 100644 --- a/crates/module-system/sov-modules-api/tests/integration/transaction.rs +++ b/crates/module-system/sov-modules-api/tests/integration/transaction.rs @@ -37,6 +37,7 @@ fn test_serde_serialize_tx() { runtime_call: TestOptimisticRuntimeCall::ValueSetter(native_call), uniqueness: uniq, details, + address_override: None, }; let native = Transaction::::V0(native_tx); let native_json = serde_json::to_value(&native).unwrap(); @@ -75,7 +76,8 @@ fn test_schema_and_native_serialization_consistency() { "max_fee": 10000, "gas_limit": [500, 500], "chain_id": 1337 - } + }, + "address_override": null } }"#; let schema = Schema::of_single_type::>().unwrap(); @@ -104,6 +106,7 @@ fn test_schema_and_native_serialization_consistency() { runtime_call: TestOptimisticRuntimeCall::ValueSetter(native_call), uniqueness: uniq, details, + address_override: None, }; let native = Transaction::::V0(native_tx); let native_bytes = borsh::to_vec(&native).unwrap(); @@ -157,7 +160,8 @@ mod web3_compatibility { "max_fee": 10000, "gas_limit": null, "chain_id": 1337 - } + }, + "address_override": null }}"#; let schema = Schema::of_single_type::>().unwrap(); @@ -183,7 +187,8 @@ mod web3_compatibility { "max_fee": 10000, "gas_limit": [500, 500], "chain_id": 1337 - } + }, + "address_override": null }}"#; let schema = Schema::of_single_type::>().unwrap(); @@ -213,7 +218,8 @@ mod web3_compatibility { "max_fee": 10000, "gas_limit": [500, 500], "chain_id": 1337 - } + }, + "address_override": null } }"#; let schema = Schema::of_single_type::>().unwrap(); @@ -244,7 +250,8 @@ mod web3_compatibility { "max_fee": 10000, "gas_limit": null, "chain_id": 1337 - } + }, + "address_override": null } }"#; let schema = Schema::of_single_type::>().unwrap(); diff --git a/crates/module-system/sov-solana-offchain-auth/src/authentication/mod.rs b/crates/module-system/sov-solana-offchain-auth/src/authentication/mod.rs index 6cd39bed10..38911f2a58 100644 --- a/crates/module-system/sov-solana-offchain-auth/src/authentication/mod.rs +++ b/crates/module-system/sov-solana-offchain-auth/src/authentication/mod.rs @@ -125,6 +125,7 @@ fn verify_signatures( fn build_auth_data( unpacked: &UnpackedSolanaMessage, uniqueness: UniquenessData, + address_override: Option, raw_tx_hash: TxHash, meter: &mut impl GasMeter, ) -> Result, AuthenticationError> { @@ -154,6 +155,7 @@ fn build_auth_data( credential_id, credentials: Credentials::new(pub_key.clone()), default_address: credential_id.into(), + address_override, }) } UnpackedSolanaMessage::V1 { @@ -182,6 +184,7 @@ fn build_auth_data( credential_id, credentials: Credentials::new(multisig), default_address: credential_id.into(), + address_override, }) } } @@ -278,11 +281,16 @@ where raw_tx_hash, ) }; - let (provided_chain_name, unsigned_tx) = match &unpacked_message { + let (provided_chain_name, address_override, unsigned_tx) = match &unpacked_message { UnpackedSolanaMessage::V0 { .. } => { let tx = SolanaOffchainUnsignedTransactionV0::::unmetered_deserialize(json_slice) .map_err(deser_err)?; - (tx.chain_name.to_string(), tx.into_unsigned_tx()) + let address_override = tx.address_override; + ( + tx.chain_name.to_string(), + address_override, + tx.into_unsigned_tx(), + ) } UnpackedSolanaMessage::V1 { signatures, @@ -299,7 +307,12 @@ where *min_signers, raw_tx_hash, )?; - (tx.chain_name.to_string(), tx.into_unsigned_tx()) + let address_override = tx.address_override; + ( + tx.chain_name.to_string(), + address_override, + tx.into_unsigned_tx(), + ) } }; @@ -333,6 +346,7 @@ where let authorization_data = build_auth_data::( &unpacked_message, unsigned_tx.uniqueness(), + address_override, raw_tx_hash, state, )?; diff --git a/crates/module-system/sov-solana-offchain-auth/src/authentication/payload.rs b/crates/module-system/sov-solana-offchain-auth/src/authentication/payload.rs index 4df63589ba..7f902b3b50 100644 --- a/crates/module-system/sov-solana-offchain-auth/src/authentication/payload.rs +++ b/crates/module-system/sov-solana-offchain-auth/src/authentication/payload.rs @@ -33,6 +33,10 @@ pub struct SolanaOffchainUnsignedTransactionV0 /// from malicious chains (if the chain name matches some other chain the use but didn't expect /// to be signing for right now). pub chain_name: SafeString, + /// Signer-declared address override. + /// See [`sov_modules_api::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub address_override: Option, } impl SolanaOffchainUnsignedTransactionV0 @@ -46,6 +50,7 @@ where runtime_call: self.runtime_call, uniqueness: self.uniqueness, details: self.details, + address_override: self.address_override, }) } @@ -78,6 +83,10 @@ pub struct SolanaOffchainUnsignedTransactionV1 /// This is the "multisig address" except if the credential is mapped to another address in /// `sov-accounts`. pub multisig_id: S::Address, + /// Signer-declared address override. + /// See [`sov_modules_api::capabilities::AuthorizationData::address_override`] for routing semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub address_override: Option, /// Message format version. Must be `1` for this struct. #[serde(deserialize_with = "deserialize_version_1")] pub version: u8, @@ -107,6 +116,7 @@ where uniqueness: self.uniqueness, details: self.details, credential_address: self.multisig_id, + address_override: self.address_override, }) } diff --git a/crates/module-system/sov-solana-offchain-auth/tests/integration/main.rs b/crates/module-system/sov-solana-offchain-auth/tests/integration/main.rs index 4e27cd1fd3..c44a57055e 100644 --- a/crates/module-system/sov-solana-offchain-auth/tests/integration/main.rs +++ b/crates/module-system/sov-solana-offchain-auth/tests/integration/main.rs @@ -93,6 +93,27 @@ type TestHasher = <::CryptoSpec as CryptoSpec>::Hasher; async fn create_test_rollup() -> anyhow::Result<( TestRollup>, TestUser, +)> { + create_test_rollup_with_extra_account_owners(|_| vec![]).await +} + +/// Variant of [`create_test_rollup`] that seeds additional `(credential_id, address)` pairs +/// into `sov-accounts` at genesis. Callers use this to authorize a multisig credential for a +/// specific address override so V1 transactions carrying `address_override = Some(X)` can resolve +/// their sender to `X` instead of the multisig's default address. +/// +/// `extra_account_owners_for` is a closure invoked with the generated admin user so the caller +/// can bind extra `AccountData` entries to admin's address without needing to materialize admin +/// before calling this helper. +async fn create_test_rollup_with_extra_account_owners( + extra_account_owners_for: impl FnOnce( + &TestUser, + ) -> Vec< + sov_test_utils::runtime::sov_accounts::AccountData<::Address>, + >, +) -> anyhow::Result<( + TestRollup>, + TestUser, )> { // Create genesis config let genesis_config = HighLevelOptimisticGenesisConfig::::generate() @@ -100,7 +121,7 @@ async fn create_test_rollup() -> anyhow::Result<( let sequencer = genesis_config.initial_sequencer.clone(); let admin = genesis_config.additional_accounts()[0].clone(); - let rt_genesis_config = >::GenesisConfig::from_minimal_config( + let mut rt_genesis_config = >::GenesisConfig::from_minimal_config( genesis_config.clone().into(), ValueSetterConfig { admin: admin.address(), @@ -126,6 +147,10 @@ async fn create_test_rollup() -> anyhow::Result<( .unwrap(), }, ); + rt_genesis_config + .accounts + .accounts + .extend(extra_account_owners_for(&admin)); let genesis_params = GenesisParams { runtime: rt_genesis_config, @@ -173,6 +198,14 @@ async fn create_test_rollup() -> anyhow::Result<( } fn create_transfer_tx_json(amount: Amount, recipient: &str) -> String { + create_transfer_tx_json_with_address_override(amount, recipient, None) +} + +fn create_transfer_tx_json_with_address_override( + amount: Amount, + recipient: &str, + address_override: Option<::Address>, +) -> String { let msg: TestRuntimeCall = TestRuntimeCall::Bank(BankCallMessage::Transfer { to: ::Address::from_str(recipient).unwrap(), coins: Coins { @@ -187,17 +220,34 @@ fn create_transfer_tx_json(amount: Amount, recipient: &str) -> String { TEST_DEFAULT_MAX_FEE, UniquenessData::Generation(0), Some(TEST_DEFAULT_GAS_LIMIT.into()), + None, ); let solana_unsigned_tx = SolanaOffchainUnsignedTransactionV0:: { runtime_call: unsigned_tx.runtime_call, uniqueness: unsigned_tx.uniqueness, details: unsigned_tx.details, chain_name: config_value!("CHAIN_NAME").to_string().try_into().unwrap(), + address_override, }; serde_json::to_string(&solana_unsigned_tx).unwrap() } +async fn submit_simple_json_tx( + client: &sov_api_spec::client::Client, + json: String, + signer: &Ed25519PrivateKey, +) -> reqwest::Response { + let signed_message = json.into_bytes(); + let message = SolanaOffchainSimpleMessage:: { + signed_message: signed_message.clone(), + chain_hash: RT::CHAIN_HASH, + pubkey: signer.pub_key(), + signature: signer.sign(&signed_message), + }; + submit_tx(client, borsh::to_vec(&message).unwrap()).await +} + async fn submit_tx( client: &sov_api_spec::client::Client, raw_tx_bytes: Vec, @@ -442,6 +492,7 @@ fn create_multisig_transfer_tx_json( amount: Amount, recipient: &str, multisig_id: ::Address, + address_override: Option<::Address>, ) -> String { let msg: TestRuntimeCall = TestRuntimeCall::Bank(BankCallMessage::Transfer { to: ::Address::from_str(recipient).unwrap(), @@ -457,6 +508,7 @@ fn create_multisig_transfer_tx_json( TEST_DEFAULT_MAX_FEE, UniquenessData::Nonce(0), Some(TEST_DEFAULT_GAS_LIMIT.into()), + None, ); let solana_unsigned_tx = SolanaOffchainUnsignedTransactionV1:: { runtime_call: unsigned_tx.runtime_call, @@ -464,6 +516,7 @@ fn create_multisig_transfer_tx_json( details: unsigned_tx.details, chain_name: config_value!("CHAIN_NAME").to_string().try_into().unwrap(), multisig_id, + address_override, version: 1, }; serde_json::to_string(&solana_unsigned_tx).unwrap() @@ -528,7 +581,7 @@ async fn test_submit_multisig_simple_message_transaction() { // Build a transfer from the multisig to the recipient, using V1 format which commits // to the credential_id in the signed message. let transfer_json = - create_multisig_transfer_tx_json(Amount(7_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(7_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); let wire_bytes = create_multisig_simple_wire_bytes(&transfer_json); @@ -624,7 +677,7 @@ async fn test_submit_multisig_insufficient_signatures() { // Only 1 signer for a 2-of-3 multisig — should fail let transfer_json = - create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); let wire_bytes = create_multisig_simple_wire_bytes(&transfer_json); let sig1 = key1.sign(json_bytes); @@ -689,7 +742,7 @@ async fn test_submit_multisig_invalid_signature() { // Corrupt the second signature let transfer_json = - create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); let wire_bytes = create_multisig_simple_wire_bytes(&transfer_json); let sig1 = key1.sign(json_bytes); @@ -776,7 +829,7 @@ async fn test_submit_multisig_spec_compliant_message_transaction() { // Build a transfer from the multisig using spec-compliant format. let transfer_json = - create_multisig_transfer_tx_json(Amount(7_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(7_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); // Build the multisig preamble with a canonical signer ordering so the emitted payload @@ -884,7 +937,7 @@ async fn test_submit_spec_compliant_multisig_insufficient_signatures() { // Only 1 signer when 2 are required let transfer_json = - create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); let preamble = make_multisig_preamble_for_message( @@ -952,7 +1005,7 @@ async fn test_submit_spec_compliant_multisig_invalid_signature() { } let transfer_json = - create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address, None); let json_bytes = transfer_json.as_bytes(); let preamble = make_multisig_preamble_for_message( @@ -1045,7 +1098,7 @@ async fn test_submit_ledger_signed_multisig_transaction() { // Build the V1 multisig transfer transaction let transfer_json_tx = - create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address); + create_multisig_transfer_tx_json(Amount(5_000), RECIPIENT_ADDRESS, multisig_address, None); let encoded_tx = transfer_json_tx.as_bytes(); // Build the multisig preamble with all 3 pubkeys (Ledger at index 0) @@ -1109,3 +1162,400 @@ async fn test_submit_ledger_signed_multisig_transaction() { "Expected recipient to have received 5,000 tokens" ); } + +/// End-to-end plumbing test: a V1 transaction carrying `address_override = Some(admin)` — where +/// genesis authorizes `(admin.address(), multisig_credential_id)` in `account_owners` — routes +/// execution as `admin`, not as the multisig's default address. Proves that `address_override` +/// flows from the signed JSON through `authenticate`, `build_auth_data`, `AuthorizationData`, +/// and into `resolve_context`. +#[tokio::test(flavor = "multi_thread")] +async fn test_submit_multisig_with_authorized_address_override() { + // Build a 2-of-3 multisig (its default address is never funded). + let key1 = Ed25519PrivateKey::generate(); + let key2 = Ed25519PrivateKey::generate(); + let key3 = Ed25519PrivateKey::generate(); + let pub1 = key1.pub_key(); + let pub2 = key2.pub_key(); + let pub3 = key3.pub_key(); + let min_signers: u8 = 2; + let multisig = + sov_modules_api::Multisig::new(min_signers, vec![pub1.clone(), pub2.clone(), pub3.clone()]); + let multisig_credential_id = multisig.credential_id::(); + let multisig_default_address: ::Address = multisig_credential_id.into(); + + // Bring up a rollup where admin's address is authorized for the multisig's credential. + let (test_rollup, admin) = create_test_rollup_with_extra_account_owners(|admin| { + vec![sov_test_utils::runtime::sov_accounts::AccountData { + credential_id: multisig_credential_id, + address: admin.address(), + }] + }) + .await + .expect("Failed to create rollup"); + + let admin_address_str = admin.address().to_string(); + let admin_balance_before = query_balance(&test_rollup.client, &admin_address_str).await; + + // Build a V1 multisig transfer targeting admin's address. + let transfer_json = create_multisig_transfer_tx_json( + Amount(7_000), + RECIPIENT_ADDRESS, + multisig_default_address, + Some(admin.address()), + ); + let json_bytes = transfer_json.as_bytes(); + let wire_bytes = create_multisig_simple_wire_bytes(&transfer_json); + + // Two of three signers sign. + let sig1 = key1.sign(json_bytes); + let sig2 = key2.sign(json_bytes); + let multisig_msg = SolanaOffchainSimpleMultisigMessage:: { + wire_bytes, + chain_hash: RT::CHAIN_HASH, + signatures: vec![ + PubKeyAndSignature { + signature: sig1, + pub_key: pub1, + }, + PubKeyAndSignature { + signature: sig2, + pub_key: pub2, + }, + ] + .try_into() + .unwrap(), + unused_pub_keys: vec![pub3].try_into().unwrap(), + min_signers, + }; + + let raw_tx_bytes = borsh::to_vec(&multisig_msg).unwrap(); + let response = submit_tx(test_rollup.api_client(), raw_tx_bytes).await; + assert!( + response.status().is_success(), + "Expected multisig-with-address_override transaction to succeed. Response: {response:?}" + ); + + // Recipient got the transfer. + let recipient_balance = query_balance(&test_rollup.client, RECIPIENT_ADDRESS).await; + assert_eq!( + recipient_balance, + Some(Amount::new(7_000)), + "Expected recipient to have received 7,000 tokens via override-routed multisig tx" + ); + + // Admin's balance decreased (fees + transferred amount). We only check the ordering + // invariant: post < pre. A strict equality is fragile because paymaster gas is deducted. + let admin_balance_after = query_balance(&test_rollup.client, &admin_address_str).await; + assert!( + admin_balance_after < admin_balance_before, + "Expected admin balance to decrease after override-routed tx; before={admin_balance_before:?}, after={admin_balance_after:?}" + ); + + // The multisig's default address was never funded and should still have no balance — + // proving that the tx did NOT route through the default resolver. + let multisig_default_balance = + query_balance(&test_rollup.client, &multisig_default_address.to_string()).await; + assert!( + multisig_default_balance.is_none() || multisig_default_balance == Some(Amount::new(0)), + "Multisig default address must not have been used as sender; got balance {multisig_default_balance:?}" + ); +} + +/// Helper: builds a `SolanaOffchainUnsignedTransactionV1` with an arbitrary recipient for the +/// serde round-trip / byte-equivalence tests below. +fn build_v1_payload( + recipient: &str, + multisig_id: ::Address, + address_override: Option<::Address>, +) -> SolanaOffchainUnsignedTransactionV1 { + let call: TestRuntimeCall = TestRuntimeCall::Bank(BankCallMessage::Transfer { + to: ::Address::from_str(recipient).unwrap(), + coins: Coins { + amount: Amount(1_000), + token_id: config_value!("GAS_TOKEN_ID"), + }, + }); + let unsigned_tx = UnsignedTransactionV0::::new( + call, + config_value!("CHAIN_ID"), + TEST_DEFAULT_MAX_PRIORITY_FEE, + TEST_DEFAULT_MAX_FEE, + UniquenessData::Nonce(0), + Some(TEST_DEFAULT_GAS_LIMIT.into()), + None, + ); + SolanaOffchainUnsignedTransactionV1:: { + runtime_call: unsigned_tx.runtime_call, + uniqueness: unsigned_tx.uniqueness, + details: unsigned_tx.details, + chain_name: config_value!("CHAIN_NAME").to_string().try_into().unwrap(), + multisig_id, + address_override, + version: 1, + } +} + +/// `address_override: None` is omitted from the serialized JSON. +#[test] +fn test_v1_payload_omits_address_override_when_none() { + let multisig_address: ::Address = [0xABu8; 32].into(); + let payload = build_v1_payload(RECIPIENT_ADDRESS, multisig_address, None); + + let json = serde_json::to_string(&payload).expect("serialize"); + assert!( + !json.contains("address_override"), + "address_override must not appear in JSON when it is None; got: {json}" + ); +} + +/// Pre-change JSON (no `address_override` key) deserializes into a payload with `address_override: +/// None`. Pins the `#[serde(default)]` promise: future refactors cannot silently break deployed +/// Solana signers. +#[test] +fn test_v1_payload_without_address_override_field_deserializes_as_none() { + let multisig_address: ::Address = [0xABu8; 32].into(); + let expected = build_v1_payload(RECIPIENT_ADDRESS, multisig_address, None); + let canonical_json = serde_json::to_string(&expected).expect("serialize baseline"); + assert!( + !canonical_json.contains("address_override"), + "guard: the canonical None-payload already omits address_override" + ); + + let parsed: SolanaOffchainUnsignedTransactionV1 = + serde_json::from_str(&canonical_json).expect("deserialize"); + assert_eq!( + parsed.address_override, None, + "missing address_override field must default to None" + ); +} + +/// A `address_override: Some(X)` round-trips through serialize/deserialize unchanged. +#[test] +fn test_v1_payload_with_address_override_round_trips() { + let multisig_address: ::Address = [0xABu8; 32].into(); + let target: ::Address = + ::Address::from_str(RECIPIENT_ADDRESS).expect("parse recipient"); + let original = build_v1_payload(RECIPIENT_ADDRESS, multisig_address, Some(target)); + + let json = serde_json::to_string(&original).expect("serialize"); + assert!( + json.contains("address_override"), + "address_override must appear in JSON when it is Some; got: {json}" + ); + + let parsed: SolanaOffchainUnsignedTransactionV1 = + serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.address_override, Some(target)); + assert_eq!(parsed.multisig_id, multisig_address); +} + +/// End-to-end plumbing test for single-sig V0: admin's credential is explicitly +/// authorized for a delegated address at genesis, that delegated address is funded, +/// and a V0 tx carrying `address_override = Some(delegated)` spends from it. +#[tokio::test(flavor = "multi_thread")] +async fn test_submit_single_sig_with_authorized_address_override() { + let delegated_user = TestUser::::generate_with_default_balance(); + let delegated_address = delegated_user.address(); + let delegated_address_str = delegated_address.to_string(); + let (test_rollup, admin) = create_test_rollup_with_extra_account_owners(|admin| { + vec![sov_test_utils::runtime::sov_accounts::AccountData { + credential_id: admin.credential_id(), + address: delegated_address, + }] + }) + .await + .expect("Failed to create rollup"); + + let response = submit_simple_json_tx( + test_rollup.api_client(), + create_transfer_tx_json(Amount(8_000), &delegated_address_str), + admin.private_key(), + ) + .await; + assert!( + response.status().is_success(), + "Expected delegated funding tx to succeed. Response: {response:?}" + ); + + let delegated_balance_before = query_balance(&test_rollup.client, &delegated_address_str).await; + assert_eq!( + delegated_balance_before, + Some(Amount::new(8_000)), + "Expected delegated address to be funded before override-routed transfer" + ); + + let response = submit_simple_json_tx( + test_rollup.api_client(), + create_transfer_tx_json_with_address_override( + Amount(7_000), + RECIPIENT_ADDRESS, + Some(delegated_address), + ), + admin.private_key(), + ) + .await; + assert!( + response.status().is_success(), + "Expected single-sig V0 override-routed tx to succeed. Response: {response:?}" + ); + + let recipient_balance = query_balance(&test_rollup.client, RECIPIENT_ADDRESS).await; + assert_eq!( + recipient_balance, + Some(Amount::new(7_000)), + "Expected recipient to receive 7,000 tokens via override-routed V0 tx" + ); + + let delegated_balance_after = query_balance(&test_rollup.client, &delegated_address_str).await; + assert_eq!( + delegated_balance_after, + Some(Amount::new(1_000)), + "Expected override-routed V0 tx to spend from delegated address" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_submit_single_sig_with_unauthorized_address_override_fails() { + let (test_rollup, admin) = create_test_rollup().await.expect("Failed to create rollup"); + let unowned_address = TestUser::::generate_with_default_balance().address(); + + let response = submit_simple_json_tx( + test_rollup.api_client(), + create_transfer_tx_json_with_address_override( + Amount(1_000), + RECIPIENT_ADDRESS, + Some(unowned_address), + ), + admin.private_key(), + ) + .await; + + assert_eq!( + response.status(), + 400, + "Expected 400 status for unauthorized address override" + ); + let response_text = response.text().await.expect("Failed to read response body"); + assert!( + response_text.contains("not authorized for address override"), + "Expected unauthorized address override error, got: {response_text}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_submit_single_sig_with_tampered_address_override_fails_signature() { + let delegated_user = TestUser::::generate_with_default_balance(); + let delegated_address = delegated_user.address(); + let (test_rollup, admin) = create_test_rollup_with_extra_account_owners(|admin| { + vec![sov_test_utils::runtime::sov_accounts::AccountData { + credential_id: admin.credential_id(), + address: delegated_address, + }] + }) + .await + .expect("Failed to create rollup"); + + let json = create_transfer_tx_json_with_address_override( + Amount(1_000), + RECIPIENT_ADDRESS, + Some(delegated_address), + ); + let signature = admin.private_key().sign(json.as_bytes()); + let mut payload: SolanaOffchainUnsignedTransactionV0 = + serde_json::from_str(&json).expect("deserialize"); + payload.address_override = Some(admin.address()); + let tampered_json = serde_json::to_string(&payload).expect("serialize"); + let message = SolanaOffchainSimpleMessage:: { + signed_message: tampered_json.into_bytes(), + chain_hash: RT::CHAIN_HASH, + pubkey: admin.private_key().pub_key(), + signature, + }; + + let response = submit_tx(test_rollup.api_client(), borsh::to_vec(&message).unwrap()).await; + assert_eq!( + response.status(), + 400, + "Expected 400 status for tampered address override" + ); + let response_text = response.text().await.expect("Failed to read response body"); + assert!( + response_text.contains("Signature verification failed") + || response_text.contains("Verification equation was not satisfied"), + "Expected signature verification error, got: {response_text}" + ); +} + +fn build_v0_payload( + recipient: &str, + address_override: Option<::Address>, +) -> SolanaOffchainUnsignedTransactionV0 { + let call: TestRuntimeCall = TestRuntimeCall::Bank(BankCallMessage::Transfer { + to: ::Address::from_str(recipient).unwrap(), + coins: Coins { + amount: Amount(1_000), + token_id: config_value!("GAS_TOKEN_ID"), + }, + }); + let unsigned_tx = UnsignedTransactionV0::::new( + call, + config_value!("CHAIN_ID"), + TEST_DEFAULT_MAX_PRIORITY_FEE, + TEST_DEFAULT_MAX_FEE, + UniquenessData::Nonce(0), + Some(TEST_DEFAULT_GAS_LIMIT.into()), + None, + ); + SolanaOffchainUnsignedTransactionV0:: { + runtime_call: unsigned_tx.runtime_call, + uniqueness: unsigned_tx.uniqueness, + details: unsigned_tx.details, + chain_name: config_value!("CHAIN_NAME").to_string().try_into().unwrap(), + address_override, + } +} + +#[test] +fn test_v0_payload_omits_address_override_when_none() { + let payload = build_v0_payload(RECIPIENT_ADDRESS, None); + + let json = serde_json::to_string(&payload).expect("serialize"); + assert!( + !json.contains("address_override"), + "address_override must not appear in JSON when it is None; got: {json}" + ); +} + +#[test] +fn test_v0_payload_without_address_override_field_deserializes_as_none() { + let expected = build_v0_payload(RECIPIENT_ADDRESS, None); + let canonical_json = serde_json::to_string(&expected).expect("serialize baseline"); + assert!( + !canonical_json.contains("address_override"), + "guard: the canonical None-payload already omits address_override" + ); + + let parsed: SolanaOffchainUnsignedTransactionV0 = + serde_json::from_str(&canonical_json).expect("deserialize"); + assert_eq!( + parsed.address_override, None, + "missing address_override field must default to None" + ); +} + +#[test] +fn test_v0_payload_with_address_override_round_trips() { + let target: ::Address = + ::Address::from_str(RECIPIENT_ADDRESS).expect("parse recipient"); + let original = build_v0_payload(RECIPIENT_ADDRESS, Some(target)); + + let json = serde_json::to_string(&original).expect("serialize"); + assert!( + json.contains("address_override"), + "address_override must appear in JSON when it is Some; got: {json}" + ); + + let parsed: SolanaOffchainUnsignedTransactionV0 = + serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.address_override, Some(target)); +} diff --git a/crates/utils/sov-test-utils/src/generators/mod.rs b/crates/utils/sov-test-utils/src/generators/mod.rs index 34f0841966..56f0ddc4e0 100644 --- a/crates/utils/sov-test-utils/src/generators/mod.rs +++ b/crates/utils/sov-test-utils/src/generators/mod.rs @@ -74,6 +74,7 @@ impl Message { self.details.max_fee, UniquenessData::Generation(self.generation), self.details.gas_limit, + None, ), ) } diff --git a/crates/utils/sov-test-utils/src/interface/inputs.rs b/crates/utils/sov-test-utils/src/interface/inputs.rs index 219e88bf89..02350574dd 100644 --- a/crates/utils/sov-test-utils/src/interface/inputs.rs +++ b/crates/utils/sov-test-utils/src/interface/inputs.rs @@ -157,6 +157,7 @@ impl, S: Spec> TransactionType { details.max_fee, UniquenessData::Nonce(nonce), details.gas_limit, + None, ), ) } diff --git a/crates/utils/sov-test-utils/src/lib.rs b/crates/utils/sov-test-utils/src/lib.rs index 16c8fe50a8..48342970d1 100644 --- a/crates/utils/sov-test-utils/src/lib.rs +++ b/crates/utils/sov-test-utils/src/lib.rs @@ -256,6 +256,7 @@ pub fn test_signed_transaction( tx_details.max_fee, uniqueness, tx_details.gas_limit, + None, ), ) } diff --git a/crates/web3/src/rust.rs b/crates/web3/src/rust.rs index 53e58e0795..20c32563cf 100644 --- a/crates/web3/src/rust.rs +++ b/crates/web3/src/rust.rs @@ -112,6 +112,7 @@ pub struct TransactionBuilder, max_fee: Option, gas_limit: Option>, + address_override: Option, _phantom: std::marker::PhantomData, } @@ -131,6 +132,7 @@ impl TransactionBui priority_fee_bips: None, max_fee: None, gas_limit: None, + address_override: None, _phantom: Default::default(), } } @@ -188,6 +190,12 @@ impl TransactionBui self } + /// Sets the explicit address override for the transaction. + pub fn address_override(mut self, address_override: S::Address) -> Self { + self.address_override = Some(address_override); + self + } + /// Builds an unsigned transaction with the configured parameters. /// /// Uses default values for any parameters that were not explicitly set: @@ -217,6 +225,7 @@ impl TransactionBui max_fee, uniqueness, gas_limit, + self.address_override, )) } diff --git a/crates/web3/src/schema.rs b/crates/web3/src/schema.rs index c7cb390591..f75cb82578 100644 --- a/crates/web3/src/schema.rs +++ b/crates/web3/src/schema.rs @@ -359,6 +359,8 @@ pub struct UnsignedTransactionV0 { pub uniqueness: UniquenessData, /// Transaction execution details including fees and gas limits. pub details: TxDetails, + /// Optional address override for execution; null uses default routing. + pub address_override: Option, } /// A versioned unsigned transaction envelope. @@ -392,6 +394,7 @@ impl UnsignedTransactionV0 { runtime_call: self.runtime_call.clone(), uniqueness: self.uniqueness, details: self.details.clone(), + address_override: self.address_override.clone(), }) } } @@ -417,6 +420,8 @@ pub struct TransactionV0 { pub uniqueness: UniquenessData, /// Transaction execution details including fees and gas limits. pub details: TxDetails, + /// Optional address override for execution; null uses default routing. + pub address_override: Option, } /// A versioned transaction envelope supporting different transaction formats. @@ -456,6 +461,7 @@ pub struct TransactionBuilder { max_fee: Option, gas_limit: Option>>, chain_id: Option, + address_override: Option, } impl TransactionBuilder { @@ -476,6 +482,7 @@ impl TransactionBuilder { max_fee: None, gas_limit: None, chain_id: None, + address_override: None, } } @@ -546,6 +553,12 @@ impl TransactionBuilder { self } + /// Sets the explicit address override for the transaction. + pub fn address_override(mut self, address_override: impl Into) -> Self { + self.address_override = Some(address_override.into()); + self + } + /// Builds an unsigned transaction with the configured parameters. /// /// Uses default values for any parameters that were not explicitly set: @@ -587,6 +600,7 @@ impl TransactionBuilder { gas_limit, chain_id, }, + address_override: self.address_override, }) } } diff --git a/examples/demo-rollup/README.md b/examples/demo-rollup/README.md index 19c6980573..939fa2e742 100644 --- a/examples/demo-rollup/README.md +++ b/examples/demo-rollup/README.md @@ -120,7 +120,7 @@ this case have the TokenCreated Event ```sh,test-ci,bashtestmd:compare-output $ sleep 5 -$ curl -sS http://127.0.0.1:12346/ledger/txs/0x4d5d2b90fefa8511c8d87384d7ead3d1113c9df0f3b7cf08fc7a87497cef91d4/events | jq +$ curl -sS http://127.0.0.1:12346/ledger/txs/0x050b4abd3bb8e3fe1ac2802c9dafc6374e6f2e4f35c0a4f2cd8533cf1d62bf80/events | jq [ { "type": "event", @@ -154,7 +154,7 @@ $ curl -sS http://127.0.0.1:12346/ledger/txs/0x4d5d2b90fefa8511c8d87384d7ead3d11 "type": "moduleRef", "name": "Bank" }, - "tx_hash": "0x4d5d2b90fefa8511c8d87384d7ead3d1113c9df0f3b7cf08fc7a87497cef91d4" + "tx_hash": "0x050b4abd3bb8e3fe1ac2802c9dafc6374e6f2e4f35c0a4f2cd8533cf1d62bf80" } ] ``` @@ -333,7 +333,7 @@ Adding the following transaction to batch: } } }, - "chain_hash": "0x01ae20b6512a6adf179acdcfe9340c5ee2bf77f02405c77f7ace574010adcf64", + "chain_hash": "0x48354f2a93fa684a0acde9593cf6d9a4e4c56f9fb531ce3e3570c1922e5982e9", "details": { "max_priority_fee_bips": 0, "max_fee": "100000000", diff --git a/examples/demo-rollup/README_CELESTIA.md b/examples/demo-rollup/README_CELESTIA.md index 6ac1a548a0..74505f8238 100644 --- a/examples/demo-rollup/README_CELESTIA.md +++ b/examples/demo-rollup/README_CELESTIA.md @@ -290,7 +290,7 @@ Adding the following transaction to batch: } } }, - "chain_hash": "0x01ae20b6512a6adf179acdcfe9340c5ee2bf77f02405c77f7ace574010adcf64", + "chain_hash": "0x48354f2a93fa684a0acde9593cf6d9a4e4c56f9fb531ce3e3570c1922e5982e9", "details": { "max_priority_fee_bips": 0, "max_fee": "100000000", diff --git a/examples/demo-rollup/tests/forced_sequencer_registration/mod.rs b/examples/demo-rollup/tests/forced_sequencer_registration/mod.rs index f55e18d839..6c99923991 100644 --- a/examples/demo-rollup/tests/forced_sequencer_registration/mod.rs +++ b/examples/demo-rollup/tests/forced_sequencer_registration/mod.rs @@ -118,6 +118,7 @@ fn build_register_sequencer_tx( max_fee, UniquenessData::Nonce(nonce), gas_limit, + None, ), ) } @@ -502,6 +503,7 @@ fn build_state_heavy_tx( max_fee, UniquenessData::Nonce(nonce), None, + None, ), ) } diff --git a/examples/demo-rollup/tests/resync/data/mock_da.sqlite b/examples/demo-rollup/tests/resync/data/mock_da.sqlite index bf8d66eb1a..5fcb99d19e 100644 Binary files a/examples/demo-rollup/tests/resync/data/mock_da.sqlite and b/examples/demo-rollup/tests/resync/data/mock_da.sqlite differ diff --git a/examples/demo-rollup/tests/wallet/mod.rs b/examples/demo-rollup/tests/wallet/mod.rs index 7ee6def5ec..5ccbf74cef 100644 --- a/examples/demo-rollup/tests/wallet/mod.rs +++ b/examples/demo-rollup/tests/wallet/mod.rs @@ -36,6 +36,7 @@ fn make_unsigned_tx() -> UnsignedTransactionV0, S> { TEST_DEFAULT_MAX_FEE, UniquenessData::Generation(0), Some(TEST_DEFAULT_GAS_LIMIT.into()), + None, ) } @@ -105,7 +106,7 @@ fn test_display_unsigned_tx() { &unsigned_data ) .unwrap(), - r#"V0 { runtime_call: Bank.Mint { coins: 0.01 coins of token ID token_1zut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzurq2akgf6, mint_to_address: sov1pv9skzctpv9skzctpv9skzctpv9skzctpv9skzctpv9skqm7ehv }, uniqueness: Generation(0), details: { max_priority_fee_bips: 0, max_fee: 100000000000, gas_limit: [1000000000, 1000000000], chain_id: 4321 } }"# + r#"V0 { runtime_call: Bank.Mint { coins: 0.01 coins of token ID token_1zut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzurq2akgf6, mint_to_address: sov1pv9skzctpv9skzctpv9skzctpv9skzctpv9skzctpv9skqm7ehv }, uniqueness: Generation(0), details: { max_priority_fee_bips: 0, max_fee: 100000000000, gas_limit: [1000000000, 1000000000], chain_id: 4321 }, address_override: None }"# ); } @@ -143,7 +144,7 @@ fn test_display_signed_tx() { &signed_data ) .unwrap(), - format!("V0 {{ signature: 0x{signature_display}, pub_key: 0x{pubkey_display}, runtime_call: Bank.Mint {{ coins: 0.01 coins of token ID token_1zut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzurq2akgf6, mint_to_address: sov1pv9skzctpv9skzctpv9skzctpv9skzctpv9skzctpv9skqm7ehv }}, uniqueness: Generation(0), details: {{ max_priority_fee_bips: 0, max_fee: 100000000000, gas_limit: [1000000000, 1000000000], chain_id: 4321 }} }}") + format!("V0 {{ signature: 0x{signature_display}, pub_key: 0x{pubkey_display}, runtime_call: Bank.Mint {{ coins: 0.01 coins of token ID token_1zut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzut3w9chzurq2akgf6, mint_to_address: sov1pv9skzctpv9skzctpv9skzctpv9skzctpv9skzctpv9skqm7ehv }}, uniqueness: Generation(0), details: {{ max_priority_fee_bips: 0, max_fee: 100000000000, gas_limit: [1000000000, 1000000000], chain_id: 4321 }}, address_override: None }}") ); } diff --git a/python/py_sovereign_web3/rust/src/lib.rs b/python/py_sovereign_web3/rust/src/lib.rs index c8d5b2f7a3..484477fbe1 100644 --- a/python/py_sovereign_web3/rust/src/lib.rs +++ b/python/py_sovereign_web3/rust/src/lib.rs @@ -118,11 +118,12 @@ struct PyUnsignedTransactionV0 { #[pymethods] impl PyUnsignedTransactionV0 { #[new] - #[pyo3(signature = (runtime_call, details, uniqueness=None))] + #[pyo3(signature = (runtime_call, details, uniqueness=None, address_override=None))] fn new( runtime_call: &Bound<'_, PyDict>, details: &PyTxDetails, uniqueness: Option<&PyUniquenessData>, + address_override: Option, ) -> PyResult { let call: serde_json::Value = pythonize::depythonize(runtime_call).map_err(|e| { PyValueError::new_err(format!("Failed to convert runtime_call dict to JSON: {e}")) @@ -139,6 +140,7 @@ impl PyUnsignedTransactionV0 { runtime_call: call, uniqueness, details: details.inner.clone(), + address_override, }; Ok(PyUnsignedTransactionV0 { inner: unsigned_tx }) diff --git a/python/py_sovereign_web3/src/sovereign_web3/sovereign_web3.pyi b/python/py_sovereign_web3/src/sovereign_web3/sovereign_web3.pyi index 2910d32135..79a5cd84ca 100644 --- a/python/py_sovereign_web3/src/sovereign_web3/sovereign_web3.pyi +++ b/python/py_sovereign_web3/src/sovereign_web3/sovereign_web3.pyi @@ -167,6 +167,7 @@ class UnsignedTransactionV0: runtime_call: Dict[str, Any], details: TxDetails, uniqueness: Optional[UniquenessData] = None, + address_override: Optional[str] = None, ) -> None: """Create V0 unsigned transaction. @@ -174,6 +175,7 @@ class UnsignedTransactionV0: runtime_call: Runtime call data as dictionary details: Transaction details uniqueness: Optional uniqueness data (defaults to default uniqueness) + address_override: Optional explicit address override Raises: ValueError: If runtime_call cannot be converted to JSON or uniqueness creation fails diff --git a/typescript/packages/multisig/src/index.test.ts b/typescript/packages/multisig/src/index.test.ts index 0c645ecda8..a78b5205b5 100644 --- a/typescript/packages/multisig/src/index.test.ts +++ b/typescript/packages/multisig/src/index.test.ts @@ -22,6 +22,7 @@ const createUnsignedTx = (nonce = 1): UnsignedTransaction => ({ gas_limit: null, chain_id: 1, }, + address_override: null, }); const createTransactionV0 = ( @@ -48,6 +49,7 @@ describe("MultisigTransaction", () => { gas_limit: null, chain_id: 1, }, + address_override: null, }; const tx = createTransactionV0(unsignedTx, "pubkey1", "sig1"); @@ -245,6 +247,32 @@ describe("MultisigTransaction", () => { }); }); + describe("asTransaction", () => { + it("should include a null address override by default", () => { + const multisig = MultisigTransaction.empty(createUnsignedTx(), 1, [ + "pubkey1", + ]); + + const result = multisig.asTransaction() as TransactionV1; + + expect(result.V1.address_override).toBeNull(); + }); + + it("should include the provided address override", () => { + const multisig = MultisigTransaction.empty(createUnsignedTx(), 1, [ + "pubkey1", + ]); + + const result = multisig.asTransaction( + "sov1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq5k2jj4", + ) as TransactionV1; + + expect(result.V1.address_override).toBe( + "sov1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq5k2jj4", + ); + }); + }); + describe("getMultisigAddress", () => { it("should match Rust implementation output for known test vector", () => { const multisig = MultisigTransaction.empty(createUnsignedTx(), 2, [ diff --git a/typescript/packages/multisig/src/index.ts b/typescript/packages/multisig/src/index.ts index b333e94ab8..e7ebeff578 100644 --- a/typescript/packages/multisig/src/index.ts +++ b/typescript/packages/multisig/src/index.ts @@ -258,7 +258,9 @@ export class MultisigTransaction { * Converts the multisig to a V1 transaction that can be submitted to the network. * @returns A V1 transaction containing all collected signatures and unused public keys */ - asTransaction(): Transaction { + asTransaction(addressOverride?: string | null): Transaction { + const resolvedAddressOverride = + addressOverride ?? this.unsignedTx.address_override; return { V1: { runtime_call: this.unsignedTx.runtime_call, @@ -267,6 +269,7 @@ export class MultisigTransaction { unused_pub_keys: Array.from(this.unusedPubKeys), signatures: this.signatures, min_signers: this.minSigners, + address_override: resolvedAddressOverride, }, }; } @@ -290,6 +293,7 @@ function asUnsignedTransaction( runtime_call: tx.V0.runtime_call, uniqueness: tx.V0.uniqueness, details: tx.V0.details, + address_override: tx.V0.address_override, }; } @@ -298,6 +302,7 @@ function asUnsignedTransaction( runtime_call: tx.V1.runtime_call, uniqueness: tx.V1.uniqueness, details: tx.V1.details, + address_override: tx.V1.address_override, }; } diff --git a/typescript/packages/multisig/tests/multisig.integration-test.ts b/typescript/packages/multisig/tests/multisig.integration-test.ts index 52cd33be00..51ec947b9b 100644 --- a/typescript/packages/multisig/tests/multisig.integration-test.ts +++ b/typescript/packages/multisig/tests/multisig.integration-test.ts @@ -59,6 +59,7 @@ describe("multisig", async () => { runtime_call, uniqueness: { nonce: 0 }, details: { ...DEFAULT_TX_DETAILS, chain_id }, + address_override: null, }; const requiredSigners = 3; diff --git a/typescript/packages/types/src/index.ts b/typescript/packages/types/src/index.ts index 8265d1c96e..40bb8758e3 100644 --- a/typescript/packages/types/src/index.ts +++ b/typescript/packages/types/src/index.ts @@ -35,6 +35,8 @@ export type UnsignedTransaction = { uniqueness: Uniqueness; /** Transaction execution details including fees and gas limits */ details: TxDetails; + /** Optional address override for execution; null uses default routing */ + address_override: string | null; }; /** diff --git a/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts b/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts index 49295a0848..a943b9be55 100644 --- a/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts +++ b/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts @@ -4,6 +4,7 @@ import { JsSerializer } from "@sovereign-sdk/serializers"; import { Ed25519Signer } from "@sovereign-sdk/signers"; import { LedgerSolanaSigner } from "@sovereign-sdk/signers/ledger-solana"; import { bytesToHex, hexToBytes } from "@sovereign-sdk/utils"; +import bs58 from "bs58"; import { describe, expect, it, vi } from "vitest"; import demoRollupSchema from "../../../__fixtures__/demo-rollup-schema.json"; import { @@ -159,6 +160,7 @@ describe("SolanaSignableRollup", () => { gas_limit: null, chain_id: 1, }, + address_override: null, } as any, { signer: createMockSigner(), @@ -204,6 +206,7 @@ describe("SolanaSignableRollup", () => { gas_limit: null, chain_id: fixtureChainId, }, + address_override: null, } as any, { signer: createMockSigner(), @@ -286,6 +289,7 @@ describe("SolanaSignableRollup", () => { gas_limit: [1000000000, 1000000000], chain_id: 4321, }, + address_override: null, }; await rollup.signAndSubmitTransaction(unsignedTx, { @@ -372,6 +376,7 @@ describe("SolanaSignableRollup", () => { gas_limit: [1000000000, 1000000000], chain_id: 4321, }, + address_override: null, }; await rollup.signAndSubmitTransaction(unsignedTx, { @@ -467,6 +472,7 @@ describe("SolanaSignableRollup", () => { gas_limit: [1000000000, 1000000000], chain_id: 4321, }, + address_override: null, }; // Each signer signs independently (same order as Rust: key3, key1) @@ -538,6 +544,7 @@ describe("SolanaSignableRollup", () => { gas_limit: null, chain_id: 1, }, + address_override: null, }; const signedTx = await rollup.signTransactionForMultisig( @@ -642,6 +649,7 @@ describe("SolanaSignableRollup", () => { gas_limit: [1000000000, 1000000000], chain_id: 4321, }, + address_override: null, }; const signedTx3 = await rollup.signTransactionForMultisig(unsignedTx, { @@ -718,6 +726,7 @@ describe("SolanaSignableRollup", () => { gas_limit: null, chain_id: 1, }, + address_override: null, } as any, { signer: ledgerSigner, @@ -769,6 +778,7 @@ describe("SolanaSignableRollup", () => { gas_limit: null, chain_id: 1, }, + address_override: null, } as any, { signer: ed25519Signer, @@ -785,4 +795,265 @@ describe("SolanaSignableRollup", () => { expect(decodedBody[0]).not.toBe(0xff); }); }); + + describe("V1 address_override", () => { + // Shared fixture: builds a rollup + multisig context the same way the byte-compatibility + // tests above do, then exposes the pieces each test needs. + async function setupMultisigContext(): Promise<{ + rollup: SolanaSignableRollup; + capturedPayloadRef: { current: any }; + multisigAddress: Uint8Array; + multisigPubkeys: Uint8Array[]; + pubHexes: { pub1: string; pub2: string; pub3: string }; + signers: { + signer1: Ed25519Signer; + signer2: Ed25519Signer; + signer3: Ed25519Signer; + }; + unsignedTx: any; + }> { + const key1PrivHex = + "09817894bf1e858df8d9bb3b931646c558ec4cacb9e4f9c05e91d0d788ec1142"; + const key2PrivHex = + "71d81253990513758c7014bec174b4405988c133fc616d6f3d633170858f3dc9"; + const key3PrivHex = + "90f1cca556a78435468bb17f116a923c8eb5c6074619a9bf39f28eb673a22a50"; + + const mockClient = createMockClient({ + chainId: 4321, + chainName: "TestChain", + chainHash: + "0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + }); + const capturedPayloadRef: { current: any } = { current: undefined }; + mockClient.post = vi + .fn() + .mockImplementation((_path: string, options: any) => { + capturedPayloadRef.current = options; + return Promise.resolve({ id: "test-tx-hash" }); + }); + + const rollup = await createSolanaSignableRollup({ + client: mockClient, + getSerializer: (schema: any) => ({ schema }) as any, + }); + + const signer1 = new Ed25519Signer(key1PrivHex); + const signer2 = new Ed25519Signer(key2PrivHex); + const signer3 = new Ed25519Signer(key3PrivHex); + const pub1 = await signer1.publicKey(); + const pub2 = await signer2.publicKey(); + const pub3 = await signer3.publicKey(); + const pub1Hex = bytesToHex(pub1); + const pub2Hex = bytesToHex(pub2); + const pub3Hex = bytesToHex(pub3); + const minSigners = 2; + const multisigPubkeys = [pub1, pub2, pub3]; + + const sortedPubKeys = [pub1Hex, pub2Hex, pub3Hex].sort(); + const pubKeyBytes = sortedPubKeys.map((pk) => Array.from(hexToBytes(pk))); + const borshData = new Uint8Array(1 + 4 + 3 * 32); + const dv = new DataView(borshData.buffer); + borshData[0] = minSigners; + dv.setUint32(1, 3, true); + pubKeyBytes.forEach((pk, i) => borshData.set(pk, 5 + i * 32)); + const multisigAddress = sha256(borshData); + + const unsignedTx = { + runtime_call: { + bank: { + transfer: { + to: "4zdwHNaEa5npHtRtaZ3RL1m6rptuQZ6RBLHG6cAyVHjL", + coins: { + amount: "7000", + token_id: + "token_1nyl0e0yweragfsatygt24zmd8jrr2vqtvdfptzjhxkguz2xxx3vs0y07u7", + }, + }, + }, + }, + uniqueness: { nonce: 0 }, + details: { + max_priority_fee_bips: 0, + max_fee: "100000000000", + gas_limit: [1000000000, 1000000000], + chain_id: 4321, + }, + address_override: null, + }; + + return { + rollup, + capturedPayloadRef, + multisigAddress, + multisigPubkeys, + pubHexes: { pub1: pub1Hex, pub2: pub2Hex, pub3: pub3Hex }, + signers: { signer1, signer2, signer3 }, + unsignedTx, + }; + } + + /** + * Extracts the JSON payload a signer signed. The `solanaSimple` flow has each signer sign + * the JSON bytes directly (no preamble), so the signer's `sign()` call receives exactly + * what was JSON-serialized. + */ + function captureSignerInput(signer: Ed25519Signer): { + mock: Ed25519Signer; + captured: { bytes?: Uint8Array }; + } { + const captured: { bytes?: Uint8Array } = {}; + const originalSign = signer.sign.bind(signer); + const mock = { + publicKey: () => signer.publicKey(), + sign: async (data: Uint8Array) => { + captured.bytes = new Uint8Array(data); + return originalSign(data); + }, + } as unknown as Ed25519Signer; + return { mock, captured }; + } + + it("omits address_override from the signed JSON when no addressOverride is provided (backward compat)", async () => { + const { rollup, multisigAddress, multisigPubkeys, signers, unsignedTx } = + await setupMultisigContext(); + + const { mock, captured } = captureSignerInput(signers.signer1); + await rollup.signTransactionForMultisig(unsignedTx, { + signer: mock, + authenticator: "solanaSimple", + multisigAddress, + multisigPubkeys, + }); + + expect(captured.bytes).toBeDefined(); + const signedJson = new TextDecoder().decode(captured.bytes!); + expect(signedJson).not.toContain("address_override"); + const parsed = JSON.parse(signedJson); + expect(parsed).not.toHaveProperty("address_override"); + }); + + it("includes tx.address_override in the signed JSON when params.addressOverride is omitted", async () => { + const { rollup, multisigAddress, multisigPubkeys, signers, unsignedTx } = + await setupMultisigContext(); + const txAddressOverride = new Uint8Array(32); + txAddressOverride.fill(0x24); + const txAddressOverrideBs58 = bs58.encode(txAddressOverride); + + const { mock, captured } = captureSignerInput(signers.signer1); + const signedTx = await rollup.signTransactionForMultisig( + { + ...unsignedTx, + address_override: txAddressOverrideBs58, + }, + { + signer: mock, + authenticator: "solanaSimple", + multisigAddress, + multisigPubkeys, + }, + ); + + expect(captured.bytes).toBeDefined(); + const signedJson = new TextDecoder().decode(captured.bytes!); + expect(JSON.parse(signedJson).address_override).toBe( + txAddressOverrideBs58, + ); + expect((signedTx as any).V0.address_override).toBe(txAddressOverrideBs58); + }); + + it("includes address_override in the signed JSON before version when addressOverride is provided", async () => { + // Field order matches Rust's `SolanaOffchainUnsignedTransactionV1`: signers sign the raw + // JSON bytes, so TS and Rust must emit fields in the same order or signatures produced + // in one language won't verify against JSON produced in the other. + const { rollup, multisigAddress, multisigPubkeys, signers, unsignedTx } = + await setupMultisigContext(); + + const addressOverride = new Uint8Array(32); + addressOverride.fill(0x42); + + const { mock, captured } = captureSignerInput(signers.signer1); + const signedTx = await rollup.signTransactionForMultisig(unsignedTx, { + signer: mock, + authenticator: "solanaSimple", + multisigAddress, + multisigPubkeys, + addressOverride, + }); + + expect(captured.bytes).toBeDefined(); + const signedJson = new TextDecoder().decode(captured.bytes!); + const addressOverrideBs58 = bs58.encode(addressOverride); + expect(JSON.parse(signedJson).address_override).toBe(addressOverrideBs58); + const overrideIdx = signedJson.indexOf('"address_override"'); + const versionIdx = signedJson.indexOf('"version"'); + expect(overrideIdx).toBeGreaterThanOrEqual(0); + expect(overrideIdx).toBeLessThan(versionIdx); + expect((signedTx as any).V0.address_override).toBe(addressOverrideBs58); + }); + + it("forwards tx.address_override into the submitted JSON via submitMultisigTransaction", async () => { + const { + rollup, + capturedPayloadRef, + multisigAddress, + multisigPubkeys, + pubHexes, + signers, + unsignedTx, + } = await setupMultisigContext(); + + const addressOverride = new Uint8Array(32); + addressOverride.fill(0x99); + const addressOverrideBs58 = bs58.encode(addressOverride); + + // Each signer signs with the same address_override — the signatures cover the full JSON + // including the address_override field. + const signedTx3 = await rollup.signTransactionForMultisig(unsignedTx, { + signer: signers.signer3, + authenticator: "solanaSimple", + multisigAddress, + multisigPubkeys, + addressOverride, + }); + const signedTx1 = await rollup.signTransactionForMultisig(unsignedTx, { + signer: signers.signer1, + authenticator: "solanaSimple", + multisigAddress, + multisigPubkeys, + addressOverride, + }); + + const v0_3 = (signedTx3 as any).V0; + const v0_1 = (signedTx1 as any).V0; + const multisigV1 = { + V1: { + ...unsignedTx, + signatures: [ + { pub_key: v0_3.pub_key, signature: v0_3.signature }, + { pub_key: v0_1.pub_key, signature: v0_1.signature }, + ], + unused_pub_keys: [pubHexes.pub2], + min_signers: 2, + address_override: addressOverrideBs58, + }, + }; + + await rollup.submitMultisigTransaction(multisigV1 as any, { + authenticator: "solanaSimple", + multisigAddress, + multisigPubkeys, + }); + + // The endpoint payload is a base64-wrapped borsh blob whose tail is the signed JSON. We + // don't need to re-parse the borsh layout — it's sufficient to check that the target + // address bytes appear in the submitted blob (via its base58 ASCII representation). + const submittedBody = capturedPayloadRef.current.body.body as string; + const submittedBytes = Buffer.from(submittedBody, "base64"); + const submittedText = new TextDecoder().decode(submittedBytes); + expect(submittedText).toContain( + `"address_override":"${addressOverrideBs58}"`, + ); + }); + }); }); diff --git a/typescript/packages/web3/src/rollup/solana-signable-rollup.ts b/typescript/packages/web3/src/rollup/solana-signable-rollup.ts index 27a9740eba..a03c046999 100644 --- a/typescript/packages/web3/src/rollup/solana-signable-rollup.ts +++ b/typescript/packages/web3/src/rollup/solana-signable-rollup.ts @@ -3,6 +3,8 @@ import { type Signer, isLedgerSolanaSigner } from "@sovereign-sdk/signers"; import type { Transaction, TransactionV1, + TxDetails, + Uniqueness, UnsignedTransaction, } from "@sovereign-sdk/types"; import { bytesToHex, hexToBytes } from "@sovereign-sdk/utils"; @@ -19,14 +21,30 @@ import { standardTypeBuilder, } from "./standard-rollup"; -export type SolanaOffchainUnsignedTransaction = - UnsignedTransaction & { - chain_name: string; +export type SolanaOffchainUnsignedTransaction = { + runtime_call: RuntimeCall; + uniqueness: Uniqueness; + details: TxDetails; + chain_name: string; +}; + +export type SolanaOffchainUnsignedTransactionV0 = + SolanaOffchainUnsignedTransaction & { + /** + * Signer-declared address override. + * See `AuthorizationData::address_override` (Rust) for routing semantics. + */ + address_override?: string; }; export type SolanaOffchainUnsignedTransactionV1 = SolanaOffchainUnsignedTransaction & { multisig_id: string; + /** + * Signer-declared address override. + * See `AuthorizationData::address_override` (Rust) for routing semantics. + */ + address_override?: string; version: number; }; @@ -79,6 +97,8 @@ export type SolanaMultisigSubmitParams = export type SolanaMultisigSignParams = SolanaMultisigSubmitParams & { signer: Signer; + /** Address override embedded in the V1 payload. Unused by the `"standard"` authenticator. */ + addressOverride?: Uint8Array; }; /** @@ -116,6 +136,39 @@ function compareByteArrays(left: Uint8Array, right: Uint8Array): number { return left.length - right.length; } +function resolveMultisigAddressOverride( + unsignedTx: UnsignedTransaction, + addressOverride?: Uint8Array, +): { + resolvedUnsignedTx: UnsignedTransaction; + resolvedAddressOverride?: Uint8Array; +} { + if (addressOverride !== undefined) { + return { + resolvedUnsignedTx: { + ...unsignedTx, + address_override: bs58.encode(addressOverride), + }, + resolvedAddressOverride: addressOverride, + }; + } + + if ( + unsignedTx.address_override !== null && + unsignedTx.address_override !== undefined + ) { + return { + resolvedUnsignedTx: unsignedTx, + resolvedAddressOverride: bs58.decode(unsignedTx.address_override), + }; + } + + return { + resolvedUnsignedTx: unsignedTx, + resolvedAddressOverride: undefined, + }; +} + function createSolanaPreamble( pubkeys: Uint8Array[], chainHash: Uint8Array, @@ -326,11 +379,15 @@ export class SolanaSignableRollup { const schema = serializer.schema; const chainName = schema.chain_data.chain_name || ""; - const solanaUnsignedTx: SolanaOffchainUnsignedTransaction = { + const solanaUnsignedTx: SolanaOffchainUnsignedTransactionV0 = { runtime_call: unsignedTx.runtime_call, uniqueness: unsignedTx.uniqueness, details: unsignedTx.details, chain_name: chainName, + ...(unsignedTx.address_override !== null && + unsignedTx.address_override !== undefined && { + address_override: unsignedTx.address_override, + }), }; // JSON serialize the Solana unsigned transaction @@ -494,11 +551,16 @@ export class SolanaSignableRollup { private async createMultisigJsonBytes( unsignedTx: UnsignedTransaction, multisigAddress: Uint8Array, + addressOverride?: Uint8Array, ): Promise { const serializer = await this.inner.serializer(); const schema = serializer.schema; const chainName = schema.chain_data.chain_name || ""; + // Field order matches the Rust `SolanaOffchainUnsignedTransactionV1` struct + // (`crates/module-system/sov-solana-offchain-auth/src/authentication/payload.rs`): + // `address_override` must sit between `multisig_id` and `version` so TS- and Rust-generated + // JSON bytes are byte-identical — the multisig signatures cover these bytes directly. const solanaUnsignedTx: SolanaOffchainUnsignedTransactionV1 = { runtime_call: unsignedTx.runtime_call, uniqueness: unsignedTx.uniqueness, @@ -508,6 +570,9 @@ export class SolanaSignableRollup { // primary address type. Will be replaced with rollup-aware address formatting once the // SDK supports flexible address encoding (see #2673). multisig_id: bs58.encode(multisigAddress), + ...(addressOverride !== undefined && { + address_override: bs58.encode(addressOverride), + }), version: 1, }; @@ -521,10 +586,12 @@ export class SolanaSignableRollup { unsignedTx: UnsignedTransaction, multisigAddress: Uint8Array, multisigPubkeys: Uint8Array[], + addressOverride?: Uint8Array, ): Promise { const jsonBytes = await this.createMultisigJsonBytes( unsignedTx, multisigAddress, + addressOverride, ); const chainHash = await this.inner.chainHash(); const preamble = createSolanaPreamble( @@ -544,7 +611,10 @@ export class SolanaSignableRollup { signer: Signer, multisigAddress: Uint8Array, multisigPubkeys: Uint8Array[], + addressOverride?: Uint8Array, ): Promise> { + const { resolvedUnsignedTx, resolvedAddressOverride } = + resolveMultisigAddressOverride(unsignedTx, addressOverride); const pubkey = await signer.publicKey(); const signerPubkeyHex = bytesToHex(pubkey); const multisigPubkeyHexes = multisigPubkeys.map(bytesToHex); @@ -556,14 +626,15 @@ export class SolanaSignableRollup { } const jsonBytes = await this.createMultisigJsonBytes( - unsignedTx, + resolvedUnsignedTx, multisigAddress, + resolvedAddressOverride, ); const signature = await signer.sign(jsonBytes); return this.typeBuilder.transaction({ - unsignedTx, + unsignedTx: resolvedUnsignedTx, sender: pubkey, signature, rollup: this.inner, @@ -578,7 +649,10 @@ export class SolanaSignableRollup { signer: Signer, multisigAddress: Uint8Array, multisigPubkeys: Uint8Array[], + addressOverride?: Uint8Array, ): Promise> { + const { resolvedUnsignedTx, resolvedAddressOverride } = + resolveMultisigAddressOverride(unsignedTx, addressOverride); const pubkey = await signer.publicKey(); const signerPubkeyHex = bytesToHex(pubkey); const multisigPubkeyHexes = multisigPubkeys.map(bytesToHex); @@ -591,14 +665,15 @@ export class SolanaSignableRollup { const signedMessageWithPreamble = await this.createSpecCompliantMultisigSignedMessage( - unsignedTx, + resolvedUnsignedTx, multisigAddress, multisigPubkeys, + resolvedAddressOverride, ); const signature = await signer.sign(signedMessageWithPreamble); return this.typeBuilder.transaction({ - unsignedTx, + unsignedTx: resolvedUnsignedTx, sender: pubkey, signature, rollup: this.inner, @@ -627,6 +702,7 @@ export class SolanaSignableRollup { params.signer, params.multisigAddress, this.canonicalizeMultisigPubkeys(params.multisigPubkeys), + params.addressOverride, ); case "solana": return this.signForSolanaSpecMultisig( @@ -634,6 +710,7 @@ export class SolanaSignableRollup { params.signer, params.multisigAddress, this.canonicalizeMultisigPubkeys(params.multisigPubkeys), + params.addressOverride, ); } } @@ -726,8 +803,17 @@ export class SolanaSignableRollup { runtime_call: tx.runtime_call, uniqueness: tx.uniqueness, details: tx.details, + address_override: tx.address_override, }; + // Decode the signed `address_override` back to bytes so we can pass it through the same + // `createMultisigJsonBytes` path used by signers. Re-encoding is idempotent under base58, so + // the produced JSON bytes match what the signer signed over. + const addressOverride: Uint8Array | undefined = + tx.address_override !== null && tx.address_override !== undefined + ? bs58.decode(tx.address_override) + : undefined; + switch (params.authenticator) { case "standard": return this.inner.submitTransaction( @@ -739,6 +825,7 @@ export class SolanaSignableRollup { const jsonBytes = await this.createMultisigJsonBytes( unsignedTx, params.multisigAddress, + addressOverride, ); const wireBytes = new Uint8Array(1 + jsonBytes.length); wireBytes[0] = MULTISIG_SIMPLE_DISCRIMINATOR; @@ -767,6 +854,7 @@ export class SolanaSignableRollup { unsignedTx, params.multisigAddress, multisigPubkeys, + addressOverride, ); const message = this.buildSpecCompliantMultisigEnvelope( tx, diff --git a/typescript/packages/web3/src/rollup/standard-rollup.test.ts b/typescript/packages/web3/src/rollup/standard-rollup.test.ts index 307ae1344e..332156932c 100644 --- a/typescript/packages/web3/src/rollup/standard-rollup.test.ts +++ b/typescript/packages/web3/src/rollup/standard-rollup.test.ts @@ -54,6 +54,7 @@ describe("standardTypeBuilder", () => { max_fee: "1000", chain_id: 1, }, + address_override: null, }); }); @@ -74,6 +75,7 @@ describe("standardTypeBuilder", () => { max_fee: "1000", chain_id: 1, }, + address_override: null, }); }); @@ -100,6 +102,7 @@ describe("standardTypeBuilder", () => { gas_limit: [1000000, 1000000], chain_id: 1, }, + address_override: null, }); }); }); @@ -118,6 +121,7 @@ describe("standardTypeBuilder", () => { chain_id: 1, gas_limit: null, }, + address_override: null, }, sender: new Uint8Array([4, 5, 6]), signature: new Uint8Array([7, 8, 9]), @@ -138,6 +142,7 @@ describe("standardTypeBuilder", () => { chain_id: 1, gas_limit: null, }, + address_override: null, }, }); }); @@ -254,4 +259,42 @@ describe("createStandardRollup", () => { }, }); }); + + it("should pass optional simulation parameters to the client", async () => { + const client = new SovereignClient({ fetch: vi.fn() }); + client.rollup.simulate = vi.fn().mockResolvedValue({ outcome: "success" }); + const rollup = await createStandardRollup({ + ...mockConfig, + client, + }); + const signer = { + publicKey: vi.fn().mockResolvedValue(new Uint8Array([0xab, 0xcd])), + }; + const runtimeCall = { + bank: { + transfer: { + to: "receiver", + coins: { amount: "1", token_id: "token" }, + }, + }, + }; + const txDetails = { + max_fee: "1234", + }; + + await rollup.simulate(runtimeCall, { + signer: signer as any, + address_override: "sov1target", + tx_details: txDetails, + uniqueness: { nonce: 7 }, + }); + + expect(client.rollup.simulate).toHaveBeenCalledWith({ + sender: "abcd", + call: runtimeCall, + address_override: "sov1target", + tx_details: txDetails, + uniqueness: { nonce: 7 }, + }); + }); }); diff --git a/typescript/packages/web3/src/rollup/standard-rollup.ts b/typescript/packages/web3/src/rollup/standard-rollup.ts index ef16cadca2..cf89606374 100644 --- a/typescript/packages/web3/src/rollup/standard-rollup.ts +++ b/typescript/packages/web3/src/rollup/standard-rollup.ts @@ -63,6 +63,7 @@ export function standardTypeBuilder< runtime_call: runtimeCall, uniqueness, details, + address_override: overrides.address_override ?? null, } as S["UnsignedTransaction"]; }, async transaction({ @@ -83,12 +84,15 @@ export function standardTypeBuilder< /** * The parameters for simulating a runtime call transaction. + * + * Adds `address_override` until the regenerated `@sovereign-sdk/client` carries it natively; + * drop this extension and the cast in `simulate` once the client is republished. */ export type SimulateParams = Omit< SovereignClient.RollupSimulateParams, "call" | "sender" > & - SignerParams; + SignerParams & { address_override?: string | null }; export class StandardRollup extends Rollup< StandardRollupSpec, @@ -103,13 +107,18 @@ export class StandardRollup extends Rollup< */ async simulate( runtimeMessage: StandardRollupSpec["RuntimeCall"], - { signer }: SimulateParams, + { signer, ...params }: SimulateParams, ): Promise { const publicKey = await signer.publicKey(); const sender = bytesToHex(publicKey); const call = runtimeMessage as { [key: string]: unknown }; - return this.rollup.simulate({ sender, call }); + // Cast bridges the `address_override` extension; see SimulateParams JSDoc. + return this.rollup.simulate({ + ...params, + sender, + call, + } as SovereignClient.RollupSimulateParams); } }