diff --git a/crates/full-node/sov-api-spec/openapi-v3.yaml b/crates/full-node/sov-api-spec/openapi-v3.yaml index d0ab3ba33a..b90b9651f1 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 + target_address: + type: string + nullable: true + description: Optional target address for execution; null or omission uses default routing required: - sender - call 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..a5a9097a5c 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(|_| { + SimulateError::InvalidInput("failed to parse sender credential id".to_owned()) + })?; + let address = params + .target_address + .as_deref() + .map(S::Address::from_str) + .transpose() + .map_err(|e| { + SimulateError::InvalidInput(format!("failed to parse target address: {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, + }) } 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 target address for execution. If not provided, default routing is used. + pub target_address: 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..7dffa2bc48 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_target_address() { + 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 target_address = 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(), + "target_address": target_address.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": target_address + }, + "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/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..5b6940971e 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 @@ -176,9 +176,12 @@ pub fn sign_utx_in_place>( /// Signs a V1 (multisig) unsigned transaction with the given private key using EIP712. /// The credential_address is computed from the provided multisig. +/// `target_address` is forwarded into the signed `UnsignedTransactionV1`; +/// pass `None` to match pre-target routing or `Some(X)` to sign for a specific target account. pub fn sign_utx_v1_in_place>( utx: &UnsignedTransactionV0, multisig: &Multisig<::PublicKey>, + target_address: Option, private_key: &::PrivateKey, ) -> ::Signature { let schema = TestSchemaProvider::get_schema(); @@ -197,6 +200,7 @@ pub fn sign_utx_v1_in_place>( uniqueness: utx.uniqueness, details: utx.details.clone(), credential_address, + target_address, }); let utx_bytes = borsh::to_vec(&utx_enum).expect("Failed to serialize unsigned transaction"); let eip712_signing_data = schema @@ -303,16 +307,17 @@ fn test_multisig_signature_verification() { // 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 target = None; let make_multisig_tx = |generation| { let utx = create_utx_with_generation::(encode_message::<_, RT>(), generation); let signatures = multisig_keys .iter() - .map(|key| sign_utx_v1_in_place(&utx, &multisig, key)) + .map(|key| sign_utx_v1_in_place(&utx, &multisig, target, 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, target, &random_private_key); ( - utx.to_multisig_tx(multisig.clone()), + utx.to_multisig_tx(multisig.clone(), target), signatures, random_signature, ) 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 326e0acc73..f5e68a17f9 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, Response}; +use sov_accounts::{AccountData, Accounts, CallMessage, Response}; use sov_modules_api::transaction::{UnsignedTransactionV0, Version1}; use sov_modules_api::{ CryptoSpec, PrivateKey, PublicKey, RawTx, Runtime, SkippedTxContents, Spec, TxEffect, @@ -182,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 @@ -198,7 +199,7 @@ fn test_setup_multisig_and_act() { sov_modules_api::capabilities::UniquenessData::Generation(0), default_test_tx_details::(), ) - .to_multisig_tx(multisig.clone()) + .to_multisig_tx(multisig.clone(), None) }; let sign = |tx: &mut Version1, key: &TestPrivateKey| { @@ -619,3 +620,247 @@ 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, + target_address: Option<::Address>, +) -> Version1 { + UnsignedTransactionV0::::new_with_details( + TestAccountsRuntimeCall::Accounts(CallMessage::InsertCredentialId(inner_credential)), + sov_modules_api::capabilities::UniquenessData::Generation(0), + default_test_tx_details::(), + ) + .to_multisig_tx(multisig.clone(), target_address) +} + +fn sign_v1(tx: &mut Version1, key: &TestPrivateKey) { + tx.sign(key, &>::CHAIN_HASH).unwrap(); +} + +fn submit_v1(tx: Version1) -> TransactionType { + let tx = Transaction::::from(tx); + TransactionType::::PreSigned(RawTx { + data: borsh::to_vec(&tx).unwrap(), + }) +} + +/// V1 tx with `target_address = None` routes through `resolve_sender_address` +/// and executes as the multisig's default address. Evidence: the `InsertCredentialId` +/// call writes `(multisig_default, inner_credential)` to `account_owners`. +#[test] +fn test_v1_target_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(), + "target=None should route to multisig default; the InsertCredentialId \ + call must write the new credential under that address" + ); + }), + }); +} + +/// V1 tx with `target_address = 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_target_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 target=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 target_address, not multisig default" + ); + }), + }); +} + +/// V1 tx with `target_address = 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_target_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 target address"), + "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 target path must not auto-register any tuple" + ); + }), + }); +} + +/// `target_address` is part of the signed bytes: tampering with it after signing +/// invalidates the signature. +#[test] +fn test_v1_target_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.target_address = 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 target_address 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..4e55b205ee 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: 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..0e67bdaedd 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 @@ -161,7 +161,7 @@ fn send_tx_bad_generation_duplicate_with_malleated_v1_envelope() { UniquenessData::Generation(0), default_test_tx_details::(), ) - .to_multisig_tx(multisig); + .to_multisig_tx(multisig, None); original_tx .sign(&multisig_keys[0], &RT::CHAIN_HASH) .unwrap(); diff --git a/crates/module-system/sov-capabilities/src/lib.rs b/crates/module-system/sov-capabilities/src/lib.rs index b462f840d3..63a7b86218 100644 --- a/crates/module-system/sov-capabilities/src/lib.rs +++ b/crates/module-system/sov-capabilities/src/lib.rs @@ -60,6 +60,37 @@ impl<'a, S: Spec, T> StandardProvenRollupCapabilities<'a, S, T> { rewarded_token_holder } + + /// Resolves the sender address. When `auth_data.address` is `Some(X)`, requires + /// `is_explicitly_authorized(X, credential_id)`. When `None`, falls through to + /// `resolve_sender_address` (default-address / legacy mapping). + fn resolve_sender( + &mut self, + auth_data: &AuthorizationData, + state: &mut impl StateAccessor, + ) -> anyhow::Result { + match auth_data.address { + Some(requested) => { + if !self.accounts.is_explicitly_authorized( + &requested, + &auth_data.credential_id, + state, + )? { + anyhow::bail!( + "credential {} not authorized for target address {}", + auth_data.credential_id, + requested, + ); + } + Ok(requested) + } + None => Ok(self.accounts.resolve_sender_address( + &auth_data.default_address, + &auth_data.credential_id, + state, + )?), + } + } } trait HasGasPayer { @@ -316,12 +347,7 @@ impl TransactionAuthorizer for StandardProvenRollupCapabilities<' execution_context: ExecutionContext, sequencer_type: SequencerType, ) -> anyhow::Result> { - // This should be resolved by the sequencer registry during blob selection - let sender = self.accounts.resolve_sender_address( - &auth_data.default_address, - &auth_data.credential_id, - state, - )?; + let sender = self.resolve_sender(auth_data, state)?; Ok(Context::new( sender, auth_data.credentials.clone(), @@ -340,11 +366,7 @@ impl TransactionAuthorizer for StandardProvenRollupCapabilities<' state: &mut impl StateAccessor, execution_context: ExecutionContext, ) -> anyhow::Result> { - let sender = self.accounts.resolve_sender_address( - &auth_data.default_address, - &auth_data.credential_id, - state, - )?; + let sender = self.resolve_sender(auth_data, state)?; // The tx sender & sequencer are the same entity Ok(Context::new( sender, 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..fce2e7e364 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,13 @@ pub struct AuthorizationData { /// The default address. pub default_address: S::Address, + + /// Signer-declared target address for execution. + /// `None` routes the transaction through the default resolver + /// (default address + legacy fallback). + /// `Some(X)` requires `(X, credential_id) ∈ account_owners`; + /// the transaction is skipped otherwise. + /// Populated from V1 transactions' signed `target_address` field; + /// always `None` for V0 and non-sov-tx authenticators. + pub address: Option, } 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..47fcebcb47 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 @@ -71,6 +71,7 @@ impl Version0 { credential_id, credentials: Credentials::new(pub_key), default_address: credential_id.into(), + address: None, }) } } 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..ef826f6f97 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,9 @@ pub struct Version1, + /// Signer-declared target address; part of the signed bytes. + /// See [`crate::capabilities::AuthorizationData::address`] for routing semantics. + pub target_address: Option, } impl Version1 { @@ -111,6 +114,7 @@ impl Version1 { uniqueness: self.uniqueness, details: self.details.clone(), credential_address: self.credential_address(), + target_address: self.target_address, }) } } @@ -188,6 +192,7 @@ impl Version1 { credential_id, credentials: Credentials::new(multisig), default_address: credential_id.into(), + address: self.target_address, }) } } 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..7ac44e1135 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 @@ -114,9 +114,11 @@ impl UnsignedTransactionV0 { } /// Creates a new `V1` transaction from this unsigned transaction. + /// See [`crate::capabilities::AuthorizationData::address`] for `target_address` routing. pub fn to_multisig_tx( self, multisig: Multisig<::PublicKey>, + target_address: Option, ) -> Version1 { Version1 { signatures: SafeVec::new(), @@ -128,6 +130,7 @@ impl UnsignedTransactionV0 { runtime_call: self.runtime_call, uniqueness: self.uniqueness, details: self.details, + target_address, } } 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..04f02b2242 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,9 @@ pub struct UnsignedTransactionV1 { /// and prevent credential malleability from reusing signed bytes in a different /// multisig envelope. pub credential_address: S::Address, + /// Signer-declared target address. + /// See [`crate::capabilities::AuthorizationData::address`] for routing semantics. + pub target_address: Option, } impl Clone for UnsignedTransactionV1 { @@ -40,6 +43,7 @@ impl Clone for UnsignedTransactionV1 { uniqueness: self.uniqueness, details: self.details.clone(), credential_address: self.credential_address, + target_address: self.target_address, } } } @@ -49,6 +53,7 @@ impl PartialEq for UnsignedTransactionV1 && self.uniqueness == other.uniqueness && self.details == other.details && self.credential_address == other.credential_address + && self.target_address == other.target_address } } impl Eq for UnsignedTransactionV1 {} 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..b04f201a8c 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 @@ -122,9 +122,15 @@ fn verify_signatures( } /// Builds authorization data for either single-sig or multisig transactions. +/// +/// `target_address` is the signer-declared target extracted from the deserialized V1 JSON payload. +/// It must always be `None` for V0; for V1, it is forwarded from +/// `SolanaOffchainUnsignedTransactionV1::target_address` so that the authorization layer can route +/// execution to a pre-authorized account instead of the multisig's default address. fn build_auth_data( unpacked: &UnpackedSolanaMessage, uniqueness: UniquenessData, + target_address: Option, raw_tx_hash: TxHash, meter: &mut impl GasMeter, ) -> Result, AuthenticationError> { @@ -154,6 +160,7 @@ fn build_auth_data( credential_id, credentials: Credentials::new(pub_key.clone()), default_address: credential_id.into(), + address: None, }) } UnpackedSolanaMessage::V1 { @@ -182,6 +189,7 @@ fn build_auth_data( credential_id, credentials: Credentials::new(multisig), default_address: credential_id.into(), + address: target_address, }) } } @@ -278,11 +286,11 @@ where raw_tx_hash, ) }; - let (provided_chain_name, unsigned_tx) = match &unpacked_message { + let (provided_chain_name, target_address, 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()) + (tx.chain_name.to_string(), None, 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 target_address = tx.target_address; + ( + tx.chain_name.to_string(), + target_address, + tx.into_unsigned_tx(), + ) } }; @@ -333,6 +346,7 @@ where let authorization_data = build_auth_data::( &unpacked_message, unsigned_tx.uniqueness(), + target_address, 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..b4cc72032e 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 @@ -78,6 +78,13 @@ 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 target address; routing semantics in + /// [`sov_modules_api::capabilities::AuthorizationData::address`]. + /// + /// Omitted from the serialized JSON when `None`, so pre-change signed messages (which have no + /// `target_address` field) remain byte-identical and verify against the same signature. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_address: Option, /// Message format version. Must be `1` for this struct. #[serde(deserialize_with = "deserialize_version_1")] pub version: u8, @@ -107,6 +114,7 @@ where uniqueness: self.uniqueness, details: self.details, credential_address: self.multisig_id, + target_address: self.target_address, }) } 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..65c1563839 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 target address so V1 transactions carrying `target_address = 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, @@ -442,6 +467,7 @@ fn create_multisig_transfer_tx_json( amount: Amount, recipient: &str, multisig_id: ::Address, + target_address: Option<::Address>, ) -> String { let msg: TestRuntimeCall = TestRuntimeCall::Bank(BankCallMessage::Transfer { to: ::Address::from_str(recipient).unwrap(), @@ -464,6 +490,7 @@ fn create_multisig_transfer_tx_json( details: unsigned_tx.details, chain_name: config_value!("CHAIN_NAME").to_string().try_into().unwrap(), multisig_id, + target_address, version: 1, }; serde_json::to_string(&solana_unsigned_tx).unwrap() @@ -528,7 +555,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 +651,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 +716,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 +803,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 +911,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 +979,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 +1072,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 +1136,189 @@ 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 `target_address = 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 `target_address` +/// flows from the signed JSON through `authenticate`, `build_auth_data`, `AuthorizationData`, +/// and into `resolve_sender`. +#[tokio::test(flavor = "multi_thread")] +async fn test_submit_multisig_with_authorized_target_address() { + // 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-target_address 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 target-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 target-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, + target_address: 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()), + ); + 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, + target_address, + version: 1, + } +} + +/// `target_address: None` is omitted from the serialized JSON, so pre-change signed messages +/// remain byte-identical and existing signatures verify unchanged. +#[test] +fn test_v1_payload_omits_target_address_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("target_address"), + "target_address must not appear in JSON when it is None; got: {json}" + ); +} + +/// Pre-change JSON (no `target_address` key) deserializes into a payload with `target_address: +/// None`. Pins the `#[serde(default)]` promise: future refactors cannot silently break deployed +/// Solana signers. +#[test] +fn test_v1_payload_without_target_address_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("target_address"), + "guard: the canonical None-payload already omits target_address" + ); + + let parsed: SolanaOffchainUnsignedTransactionV1 = + serde_json::from_str(&canonical_json).expect("deserialize"); + assert_eq!( + parsed.target_address, None, + "missing target_address field must default to None" + ); +} + +/// A `target_address: Some(X)` round-trips through serialize/deserialize unchanged. +#[test] +fn test_v1_payload_with_target_address_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("target_address"), + "target_address must appear in JSON when it is Some; got: {json}" + ); + + let parsed: SolanaOffchainUnsignedTransactionV1 = + serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.target_address, Some(target)); + assert_eq!(parsed.multisig_id, multisig_address); +} diff --git a/examples/demo-rollup/README.md b/examples/demo-rollup/README.md index 19c6980573..0919259c92 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/0xbe16e6f31255f2f5f0a1e39530f91fd7f6480d73a3fbd3d3dcf7265962417db7/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": "0xbe16e6f31255f2f5f0a1e39530f91fd7f6480d73a3fbd3d3dcf7265962417db7" } ] ``` @@ -333,7 +333,7 @@ Adding the following transaction to batch: } } }, - "chain_hash": "0x01ae20b6512a6adf179acdcfe9340c5ee2bf77f02405c77f7ace574010adcf64", + "chain_hash": "0x65746dac667c302c76553208e916ba9a2f5738780f789014f8a900d6c2aeb8e5", "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..ca83aaca54 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": "0x65746dac667c302c76553208e916ba9a2f5738780f789014f8a900d6c2aeb8e5", "details": { "max_priority_fee_bips": 0, "max_fee": "100000000", diff --git a/examples/demo-rollup/tests/resync/data/mock_da.sqlite b/examples/demo-rollup/tests/resync/data/mock_da.sqlite index bf8d66eb1a..ab0373e365 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/typescript/packages/multisig/src/index.test.ts b/typescript/packages/multisig/src/index.test.ts index 0c645ecda8..73bd3a4373 100644 --- a/typescript/packages/multisig/src/index.test.ts +++ b/typescript/packages/multisig/src/index.test.ts @@ -67,6 +67,7 @@ describe("MultisigTransaction", () => { unused_pub_keys: ["pubkey2"], signatures: [{ pub_key: "pubkey1", signature: "sig1" }], min_signers: 1, + target_address: null, ...unsignedTx, }, }; @@ -245,6 +246,32 @@ describe("MultisigTransaction", () => { }); }); + describe("asTransaction", () => { + it("should include a null target address by default", () => { + const multisig = MultisigTransaction.empty(createUnsignedTx(), 1, [ + "pubkey1", + ]); + + const result = multisig.asTransaction() as TransactionV1; + + expect(result.V1.target_address).toBeNull(); + }); + + it("should include the provided target address", () => { + const multisig = MultisigTransaction.empty(createUnsignedTx(), 1, [ + "pubkey1", + ]); + + const result = multisig.asTransaction( + "sov1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq5k2jj4", + ) as TransactionV1; + + expect(result.V1.target_address).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..ab21c4899b 100644 --- a/typescript/packages/multisig/src/index.ts +++ b/typescript/packages/multisig/src/index.ts @@ -258,7 +258,7 @@ 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(targetAddress: string | null = null): Transaction { return { V1: { runtime_call: this.unsignedTx.runtime_call, @@ -267,6 +267,7 @@ export class MultisigTransaction { unused_pub_keys: Array.from(this.unusedPubKeys), signatures: this.signatures, min_signers: this.minSigners, + target_address: targetAddress, }, }; } diff --git a/typescript/packages/types/src/index.ts b/typescript/packages/types/src/index.ts index 8265d1c96e..8f1b97d72f 100644 --- a/typescript/packages/types/src/index.ts +++ b/typescript/packages/types/src/index.ts @@ -73,6 +73,8 @@ export type TransactionV1 = { signatures: SignatureAndPubKey[]; /** Minimum number of signatures required for transaction validity */ min_signers: number; + /** Optional target address for execution; null uses default routing */ + target_address: string | null; } & UnsignedTransaction; }; 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..9a0071401e 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 { @@ -785,4 +786,235 @@ describe("SolanaSignableRollup", () => { expect(decodedBody[0]).not.toBe(0xff); }); }); + + describe("V1 target_address", () => { + // 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, + }, + }; + + 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 target_address from the signed JSON when no targetAddress 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("target_address"); + const parsed = JSON.parse(signedJson); + expect(parsed).not.toHaveProperty("target_address"); + }); + + it("includes target_address in the signed JSON before version when targetAddress 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 targetAddress = new Uint8Array(32); + targetAddress.fill(0x42); + + const { mock, captured } = captureSignerInput(signers.signer1); + await rollup.signTransactionForMultisig(unsignedTx, { + signer: mock, + authenticator: "solanaSimple", + multisigAddress, + multisigPubkeys, + targetAddress, + }); + + expect(captured.bytes).toBeDefined(); + const signedJson = new TextDecoder().decode(captured.bytes!); + expect(JSON.parse(signedJson).target_address).toBe( + bs58.encode(targetAddress), + ); + const targetIdx = signedJson.indexOf('"target_address"'); + const versionIdx = signedJson.indexOf('"version"'); + expect(targetIdx).toBeGreaterThanOrEqual(0); + expect(targetIdx).toBeLessThan(versionIdx); + }); + + it("forwards tx.target_address into the submitted JSON via submitMultisigTransaction", async () => { + const { + rollup, + capturedPayloadRef, + multisigAddress, + multisigPubkeys, + pubHexes, + signers, + unsignedTx, + } = await setupMultisigContext(); + + const targetAddress = new Uint8Array(32); + targetAddress.fill(0x99); + const targetAddressBs58 = bs58.encode(targetAddress); + + // Each signer signs with the same target_address — the signatures cover the full JSON + // including the target_address field. + const signedTx3 = await rollup.signTransactionForMultisig(unsignedTx, { + signer: signers.signer3, + authenticator: "solanaSimple", + multisigAddress, + multisigPubkeys, + targetAddress, + }); + const signedTx1 = await rollup.signTransactionForMultisig(unsignedTx, { + signer: signers.signer1, + authenticator: "solanaSimple", + multisigAddress, + multisigPubkeys, + targetAddress, + }); + + 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, + target_address: targetAddressBs58, + }, + }; + + 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( + `"target_address":"${targetAddressBs58}"`, + ); + }); + }); }); diff --git a/typescript/packages/web3/src/rollup/solana-signable-rollup.ts b/typescript/packages/web3/src/rollup/solana-signable-rollup.ts index 27a9740eba..80f597e2c9 100644 --- a/typescript/packages/web3/src/rollup/solana-signable-rollup.ts +++ b/typescript/packages/web3/src/rollup/solana-signable-rollup.ts @@ -27,6 +27,12 @@ export type SolanaOffchainUnsignedTransaction = export type SolanaOffchainUnsignedTransactionV1 = SolanaOffchainUnsignedTransaction & { multisig_id: string; + /** + * Signer-declared target address. Omitted from the serialized JSON when not set, + * preserving byte equivalence with pre-change signed messages so existing signatures + * continue to verify. See `AuthorizationData::address` (Rust) for routing semantics. + */ + target_address?: string; version: number; }; @@ -79,6 +85,12 @@ export type SolanaMultisigSubmitParams = export type SolanaMultisigSignParams = SolanaMultisigSubmitParams & { signer: Signer; + /** + * Optional target address to embed in the signed V1 payload. Omitted from the JSON when + * unset to preserve byte equivalence with pre-change signed messages. Unused by the + * `"standard"` authenticator. See `AuthorizationData::address` (Rust) for routing semantics. + */ + targetAddress?: Uint8Array; }; /** @@ -490,15 +502,24 @@ export class SolanaSignableRollup { /** * Creates V1 JSON bytes for a multisig transaction, including multisig_id and version. * The resulting JSON is what each signer signs directly (no discriminator prefix). + * + * When `targetAddress` is omitted the JSON has no `target_address` key — this preserves + * byte equivalence with pre-change signed messages, so signatures produced by older clients + * keep verifying. */ private async createMultisigJsonBytes( unsignedTx: UnsignedTransaction, multisigAddress: Uint8Array, + targetAddress?: 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`): + // `target_address` 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 +529,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), + ...(targetAddress !== undefined && { + target_address: bs58.encode(targetAddress), + }), version: 1, }; @@ -521,10 +545,12 @@ export class SolanaSignableRollup { unsignedTx: UnsignedTransaction, multisigAddress: Uint8Array, multisigPubkeys: Uint8Array[], + targetAddress?: Uint8Array, ): Promise { const jsonBytes = await this.createMultisigJsonBytes( unsignedTx, multisigAddress, + targetAddress, ); const chainHash = await this.inner.chainHash(); const preamble = createSolanaPreamble( @@ -544,6 +570,7 @@ export class SolanaSignableRollup { signer: Signer, multisigAddress: Uint8Array, multisigPubkeys: Uint8Array[], + targetAddress?: Uint8Array, ): Promise> { const pubkey = await signer.publicKey(); const signerPubkeyHex = bytesToHex(pubkey); @@ -558,6 +585,7 @@ export class SolanaSignableRollup { const jsonBytes = await this.createMultisigJsonBytes( unsignedTx, multisigAddress, + targetAddress, ); const signature = await signer.sign(jsonBytes); @@ -578,6 +606,7 @@ export class SolanaSignableRollup { signer: Signer, multisigAddress: Uint8Array, multisigPubkeys: Uint8Array[], + targetAddress?: Uint8Array, ): Promise> { const pubkey = await signer.publicKey(); const signerPubkeyHex = bytesToHex(pubkey); @@ -594,6 +623,7 @@ export class SolanaSignableRollup { unsignedTx, multisigAddress, multisigPubkeys, + targetAddress, ); const signature = await signer.sign(signedMessageWithPreamble); @@ -627,6 +657,7 @@ export class SolanaSignableRollup { params.signer, params.multisigAddress, this.canonicalizeMultisigPubkeys(params.multisigPubkeys), + params.targetAddress, ); case "solana": return this.signForSolanaSpecMultisig( @@ -634,6 +665,7 @@ export class SolanaSignableRollup { params.signer, params.multisigAddress, this.canonicalizeMultisigPubkeys(params.multisigPubkeys), + params.targetAddress, ); } } @@ -728,6 +760,14 @@ export class SolanaSignableRollup { details: tx.details, }; + // Decode the signed `target_address` 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 targetAddress: Uint8Array | undefined = + tx.target_address !== null && tx.target_address !== undefined + ? bs58.decode(tx.target_address) + : undefined; + switch (params.authenticator) { case "standard": return this.inner.submitTransaction( @@ -739,6 +779,7 @@ export class SolanaSignableRollup { const jsonBytes = await this.createMultisigJsonBytes( unsignedTx, params.multisigAddress, + targetAddress, ); const wireBytes = new Uint8Array(1 + jsonBytes.length); wireBytes[0] = MULTISIG_SIMPLE_DISCRIMINATOR; @@ -767,6 +808,7 @@ export class SolanaSignableRollup { unsignedTx, params.multisigAddress, multisigPubkeys, + targetAddress, ); 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..6c717c2781 100644 --- a/typescript/packages/web3/src/rollup/standard-rollup.test.ts +++ b/typescript/packages/web3/src/rollup/standard-rollup.test.ts @@ -254,4 +254,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, + target_address: "sov1target", + tx_details: txDetails, + uniqueness: { nonce: 7 }, + }); + + expect(client.rollup.simulate).toHaveBeenCalledWith({ + sender: "abcd", + call: runtimeCall, + target_address: "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..02d41cef98 100644 --- a/typescript/packages/web3/src/rollup/standard-rollup.ts +++ b/typescript/packages/web3/src/rollup/standard-rollup.ts @@ -88,7 +88,7 @@ export type SimulateParams = Omit< SovereignClient.RollupSimulateParams, "call" | "sender" > & - SignerParams; + SignerParams & { target_address?: string | null }; export class StandardRollup extends Rollup< StandardRollupSpec, @@ -103,13 +103,17 @@ 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 }); + return this.rollup.simulate({ + ...params, + sender, + call, + } as SovereignClient.RollupSimulateParams); } }