From 75fcedff163bec008fac5a51d75b7dec3033c65b Mon Sep 17 00:00:00 2001 From: Nikolai Golub Date: Tue, 21 Apr 2026 22:02:58 +0200 Subject: [PATCH 1/8] Accounts Refactor PR 2: Adding target_account --- .../sov-rollup-apis/src/endpoints/simulate.rs | 1 + .../stf_blueprint/operator/auth_eip712.rs | 16 +- .../sov-accounts/tests/integration/main.rs | 251 +++++++++++++++++- .../sov-evm/src/authenticate.rs | 1 + .../tests/integration/hooks_tests.rs | 2 +- .../module-system/sov-capabilities/src/lib.rs | 40 ++- .../src/runtime/capabilities/authorization.rs | 7 + .../src/transaction/types/v0.rs | 1 + .../src/transaction/types/v1.rs | 7 + .../src/transaction/unsigned/v0.rs | 6 + .../src/transaction/unsigned/v1.rs | 6 + .../src/authentication/mod.rs | 2 + .../src/authentication/payload.rs | 1 + .../tests/resync/data/mock_da.sqlite | Bin 49152 -> 49152 bytes 14 files changed, 322 insertions(+), 19 deletions(-) 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..9d15da4870 100644 --- a/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs +++ b/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs @@ -320,6 +320,7 @@ impl> SovereignSimulate { credential_id, default_address: credential_id.into(), credentials: Credentials::new(credential_id), + address: None, } } 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..25bb9e2d0f 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 @@ -175,10 +175,13 @@ 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. +/// 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,20 @@ 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(); + // The multisig credential is authorized for `admin.address()` by the + // InsertCredentialId tx above. Target that address so the multisig tx pays + // gas from admin's balance; the multisig's own default address is unfunded. + let target = Some(admin.address()); 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 5657875773..5877fe75f5 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, @@ -204,7 +204,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 @@ -220,7 +221,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| { @@ -640,3 +641,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_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_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_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..93d950fbb6 100644 --- a/crates/module-system/sov-capabilities/src/lib.rs +++ b/crates/module-system/sov-capabilities/src/lib.rs @@ -60,6 +60,33 @@ impl<'a, S: Spec, T> StandardProvenRollupCapabilities<'a, S, T> { rewarded_token_holder } + + fn resolve_sender( + &mut self, + auth_data: &AuthorizationData, + state: &mut impl StateAccessor, + ) -> anyhow::Result { + match auth_data.address { + Some(requested) => { + if !self + .accounts + .is_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 +343,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 +362,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..61bf0eef48 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 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..701e83e756 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,11 @@ pub struct Version1, + /// Signer-declared target address for execution. `None` routes through + /// `resolve_sender_address` (default-address / legacy fallback). `Some(X)` requires + /// the multisig credential to be authorized for `X` in `account_owners`; the tx + /// is skipped otherwise. This field is part of the signed bytes. + pub target_address: Option, } impl Version1 { @@ -111,6 +116,7 @@ impl Version1 { uniqueness: self.uniqueness, details: self.details.clone(), credential_address: self.credential_address(), + target_address: self.target_address, }) } } @@ -188,6 +194,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..e0909ad942 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,14 @@ impl UnsignedTransactionV0 { } /// Creates a new `V1` transaction from this unsigned transaction. + /// + /// `target_address = None` routes the tx through `resolve_sender_address` + /// (default address / legacy fallback). `target_address = Some(X)` requires + /// `(X, credential_id) ∈ account_owners`, else the tx is skipped at authorization. pub fn to_multisig_tx( self, multisig: Multisig<::PublicKey>, + target_address: Option, ) -> Version1 { Version1 { signatures: SafeVec::new(), @@ -128,6 +133,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..c1bc851427 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 target address. `None` routes through `resolve_sender_address` + /// (default-address resolver). `Some(X)` requires the multisig credential to be + /// authorized for `X` via `account_owners`; otherwise the tx is skipped. + pub target_address: 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, + target_address: self.target_address, } } } @@ -49,6 +54,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..304559427d 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 @@ -154,6 +154,7 @@ fn build_auth_data( credential_id, credentials: Credentials::new(pub_key.clone()), default_address: credential_id.into(), + address: None, }) } UnpackedSolanaMessage::V1 { @@ -182,6 +183,7 @@ fn build_auth_data( credential_id, credentials: Credentials::new(multisig), default_address: credential_id.into(), + address: None, }) } } 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..c3e5061768 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 @@ -107,6 +107,7 @@ where uniqueness: self.uniqueness, details: self.details, credential_address: self.multisig_id, + target_address: None, }) } diff --git a/examples/demo-rollup/tests/resync/data/mock_da.sqlite b/examples/demo-rollup/tests/resync/data/mock_da.sqlite index bf8d66eb1aa112c89aa8f3b754ca799e2954e13b..ab0373e36540ab9ba725c72dd31e8530ff84497d 100644 GIT binary patch literal 49152 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 literal 49152 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 UQn9CX4?Haw)Kt(o^8WI_0F-b-P5=M^ From f03ed1ff2159ba4aef46028813937f36b7acab6d Mon Sep 17 00:00:00 2001 From: Nikolai Golub Date: Wed, 22 Apr 2026 11:31:48 +0200 Subject: [PATCH 2/8] Updating typescript to match new transaction --- crates/full-node/sov-api-spec/openapi-v3.yaml | 4 + .../sov-rollup-apis/src/endpoints/simulate.rs | 24 +- .../tests/integration/rest_api.rs | 93 ++++++- .../src/authentication/mod.rs | 24 +- .../src/authentication/payload.rs | 10 +- .../tests/integration/main.rs | 229 +++++++++++++++++- .../packages/multisig/src/index.test.ts | 27 +++ typescript/packages/multisig/src/index.ts | 3 +- typescript/packages/types/src/index.ts | 2 + .../src/rollup/solana-signable-rollup.test.ts | 224 +++++++++++++++++ .../web3/src/rollup/solana-signable-rollup.ts | 44 ++++ .../web3/src/rollup/standard-rollup.test.ts | 39 +++ .../web3/src/rollup/standard-rollup.ts | 17 +- 13 files changed, 712 insertions(+), 28 deletions(-) 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 9d15da4870..df0523ff3e 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(|_| { + SimulateError::InvalidInput("failed to parse target address".to_owned()) + })?; let uniqueness = params.uniqueness.unwrap_or_else(|| { let generation = Uniqueness::::default() .next_generation(&credential_id, state) @@ -313,15 +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: None, - } + address, + }) } fn outcome(&self, result: ApplyTxResult) -> SimulateOutcome { @@ -403,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 { @@ -428,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/sov-solana-offchain-auth/src/authentication/mod.rs b/crates/module-system/sov-solana-offchain-auth/src/authentication/mod.rs index 304559427d..362b3a597d 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> { @@ -147,6 +153,10 @@ fn build_auth_data( sov_modules_api::metered_credential::(pub_key, meter) .map_err(|e| AuthenticationError::OutOfGas(e.to_string()))?; + debug_assert!( + target_address.is_none(), + "V0 Solana transactions cannot carry a target_address" + ); Ok(AuthorizationData { uniqueness, tx_hash: raw_tx_hash, @@ -183,7 +193,7 @@ fn build_auth_data( credential_id, credentials: Credentials::new(multisig), default_address: credential_id.into(), - address: None, + address: target_address, }) } } @@ -280,11 +290,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, @@ -301,7 +311,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(), + ) } }; @@ -335,6 +350,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 c3e5061768..4bd2df658c 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,14 @@ 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. `None` routes through `resolve_sender_address` + /// (default-address resolver). `Some(X)` requires the multisig credential to be authorized + /// for `X` via `account_owners`; otherwise the tx is skipped. + /// + /// 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,7 +115,7 @@ where uniqueness: self.uniqueness, details: self.details, credential_address: self.multisig_id, - target_address: None, + 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/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..8abdf0043a 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,227 @@ 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 when targetAddress is provided (solanaSimple)", async () => { + 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!); + const parsed = JSON.parse(signedJson); + expect(parsed.target_address).toBe(bs58.encode(targetAddress)); + }); + + 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..6f8c01ec66 100644 --- a/typescript/packages/web3/src/rollup/solana-signable-rollup.ts +++ b/typescript/packages/web3/src/rollup/solana-signable-rollup.ts @@ -27,6 +27,14 @@ export type SolanaOffchainUnsignedTransaction = export type SolanaOffchainUnsignedTransactionV1 = SolanaOffchainUnsignedTransaction & { multisig_id: string; + /** + * Signer-declared target address. Omitted (or `null`) routes through + * `resolve_sender_address` on-chain. A concrete value requires the multisig credential to be + * authorized for that address in `account_owners`; otherwise the transaction is skipped. + * Omitted from the serialized JSON when not set, preserving byte equivalence with + * pre-change signed messages so existing signatures continue to verify. + */ + target_address?: string; version: number; }; @@ -79,6 +87,15 @@ export type SolanaMultisigSubmitParams = export type SolanaMultisigSignParams = SolanaMultisigSubmitParams & { signer: Signer; + /** + * Optional target address to embed in the signed V1 payload. When omitted, the signed message + * does not carry a `target_address` field, matching pre-change byte layout. When provided, the + * multisig credential must be authorized for this address in `account_owners` on-chain or the + * transaction will be skipped at authorization time. + * + * Unused by the `"standard"` authenticator. + */ + targetAddress?: Uint8Array; }; /** @@ -490,10 +507,15 @@ 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; @@ -511,6 +533,10 @@ export class SolanaSignableRollup { version: 1, }; + if (targetAddress !== undefined) { + solanaUnsignedTx.target_address = bs58.encode(targetAddress); + } + return new TextEncoder().encode(JSON.stringify(solanaUnsignedTx)); } @@ -521,10 +547,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 +572,7 @@ export class SolanaSignableRollup { signer: Signer, multisigAddress: Uint8Array, multisigPubkeys: Uint8Array[], + targetAddress?: Uint8Array, ): Promise> { const pubkey = await signer.publicKey(); const signerPubkeyHex = bytesToHex(pubkey); @@ -558,6 +587,7 @@ export class SolanaSignableRollup { const jsonBytes = await this.createMultisigJsonBytes( unsignedTx, multisigAddress, + targetAddress, ); const signature = await signer.sign(jsonBytes); @@ -578,6 +608,7 @@ export class SolanaSignableRollup { signer: Signer, multisigAddress: Uint8Array, multisigPubkeys: Uint8Array[], + targetAddress?: Uint8Array, ): Promise> { const pubkey = await signer.publicKey(); const signerPubkeyHex = bytesToHex(pubkey); @@ -594,6 +625,7 @@ export class SolanaSignableRollup { unsignedTx, multisigAddress, multisigPubkeys, + targetAddress, ); const signature = await signer.sign(signedMessageWithPreamble); @@ -627,6 +659,7 @@ export class SolanaSignableRollup { params.signer, params.multisigAddress, this.canonicalizeMultisigPubkeys(params.multisigPubkeys), + params.targetAddress, ); case "solana": return this.signForSolanaSpecMultisig( @@ -634,6 +667,7 @@ export class SolanaSignableRollup { params.signer, params.multisigAddress, this.canonicalizeMultisigPubkeys(params.multisigPubkeys), + params.targetAddress, ); } } @@ -728,6 +762,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 +781,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 +810,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..35ad31aa6b 100644 --- a/typescript/packages/web3/src/rollup/standard-rollup.test.ts +++ b/typescript/packages/web3/src/rollup/standard-rollup.test.ts @@ -254,4 +254,43 @@ 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", + gas_limit: null, + }; + + 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..09e1948505 100644 --- a/typescript/packages/web3/src/rollup/standard-rollup.ts +++ b/typescript/packages/web3/src/rollup/standard-rollup.ts @@ -84,10 +84,11 @@ export function standardTypeBuilder< /** * The parameters for simulating a runtime call transaction. */ -export type SimulateParams = Omit< - SovereignClient.RollupSimulateParams, - "call" | "sender" -> & +type RollupSimulateParams = SovereignClient.RollupSimulateParams & { + target_address?: string | null; +}; + +export type SimulateParams = Omit & SignerParams; export class StandardRollup extends Rollup< @@ -103,13 +104,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); } } From 900aa1dc4599ea98091967c6709d1e86f69e58b1 Mon Sep 17 00:00:00 2001 From: Nikolai Golub Date: Wed, 22 Apr 2026 12:09:09 +0200 Subject: [PATCH 3/8] Fixing typescript --- typescript/packages/web3/src/rollup/standard-rollup.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/typescript/packages/web3/src/rollup/standard-rollup.test.ts b/typescript/packages/web3/src/rollup/standard-rollup.test.ts index 35ad31aa6b..6c717c2781 100644 --- a/typescript/packages/web3/src/rollup/standard-rollup.test.ts +++ b/typescript/packages/web3/src/rollup/standard-rollup.test.ts @@ -275,7 +275,6 @@ describe("createStandardRollup", () => { }; const txDetails = { max_fee: "1234", - gas_limit: null, }; await rollup.simulate(runtimeCall, { From 736575be318d481996816464b2a4d6a3481bebf5 Mon Sep 17 00:00:00 2001 From: Nikolai Golub Date: Wed, 22 Apr 2026 14:11:12 +0200 Subject: [PATCH 4/8] Continue fixing typescript --- .../web3/src/rollup/solana-signable-rollup.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 8abdf0043a..efc8272540 100644 --- a/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts +++ b/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts @@ -842,9 +842,7 @@ describe("SolanaSignableRollup", () => { const multisigPubkeys = [pub1, pub2, pub3]; const sortedPubKeys = [pub1Hex, pub2Hex, pub3Hex].sort(); - const pubKeyBytes = sortedPubKeys.map((pk) => - Array.from(hexToBytes(pk)), - ); + 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; @@ -1006,7 +1004,9 @@ describe("SolanaSignableRollup", () => { 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}"`); + expect(submittedText).toContain( + `"target_address":"${targetAddressBs58}"`, + ); }); }); }); From 60864d832a059ccd52550c56d498b72abfe7fbd3 Mon Sep 17 00:00:00 2001 From: Nikolai Golub Date: Wed, 22 Apr 2026 14:44:22 +0200 Subject: [PATCH 5/8] Fixing lint --- .../tests/stf_blueprint/operator/auth_eip712.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 25bb9e2d0f..cd4e152d2c 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 @@ -307,10 +307,7 @@ 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(); - // The multisig credential is authorized for `admin.address()` by the - // InsertCredentialId tx above. Target that address so the multisig tx pays - // gas from admin's balance; the multisig's own default address is unfunded. - let target = Some(admin.address()); + let target = None; let make_multisig_tx = |generation| { let utx = create_utx_with_generation::(encode_message::<_, RT>(), generation); let signatures = multisig_keys From b397296fd89170e012348da18bd172be7bea8b8d Mon Sep 17 00:00:00 2001 From: Nikolai Golub Date: Wed, 22 Apr 2026 15:40:15 +0200 Subject: [PATCH 6/8] Updating README tests part 1 --- examples/demo-rollup/README.md | 6 +++--- examples/demo-rollup/README_CELESTIA.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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", From dbaaaa82599e7862ff22e6c7ba9dcbbae4d860cb Mon Sep 17 00:00:00 2001 From: Nikolai Golub Date: Wed, 22 Apr 2026 15:56:29 +0200 Subject: [PATCH 7/8] Documentation formatting --- .../tests/stf_blueprint/operator/auth_eip712.rs | 6 +++--- .../src/runtime/capabilities/authorization.rs | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) 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 cd4e152d2c..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 @@ -175,9 +175,9 @@ 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. +/// 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>, 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 61bf0eef48..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 @@ -103,10 +103,12 @@ 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; + /// 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, } From a887f49e2f50e7cfddcb3d60d986375064695d22 Mon Sep 17 00:00:00 2001 From: Nikolai Golub Date: Wed, 22 Apr 2026 16:34:40 +0200 Subject: [PATCH 8/8] Addressing the feedback --- .../sov-rollup-apis/src/endpoints/simulate.rs | 4 +-- .../module-system/sov-capabilities/src/lib.rs | 4 +++ .../src/rollup/solana-signable-rollup.test.ts | 28 +++++++++++++++++++ .../web3/src/rollup/solana-signable-rollup.ts | 11 +++++--- 4 files changed, 41 insertions(+), 6 deletions(-) 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 df0523ff3e..a5a9097a5c 100644 --- a/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs +++ b/crates/full-node/sov-rollup-apis/src/endpoints/simulate.rs @@ -313,8 +313,8 @@ impl> SovereignSimulate { .as_deref() .map(S::Address::from_str) .transpose() - .map_err(|_| { - SimulateError::InvalidInput("failed to parse target address".to_owned()) + .map_err(|e| { + SimulateError::InvalidInput(format!("failed to parse target address: {e:?}")) })?; let uniqueness = params.uniqueness.unwrap_or_else(|| { let generation = Uniqueness::::default() diff --git a/crates/module-system/sov-capabilities/src/lib.rs b/crates/module-system/sov-capabilities/src/lib.rs index 93d950fbb6..a7c79ff879 100644 --- a/crates/module-system/sov-capabilities/src/lib.rs +++ b/crates/module-system/sov-capabilities/src/lib.rs @@ -355,6 +355,10 @@ impl TransactionAuthorizer for StandardProvenRollupCapabilities<' )) } + /// V1 `target_address` semantics apply uniformly here: when `auth_data.address` is + /// `Some(X)`, `resolve_sender` enforces `is_authorized(X, credential_id)` and uses `X` as both + /// the sender and the sequencer's rollup address. V0 transactions set `address = None` and + /// hit the legacy `resolve_sender_address` path unchanged. fn resolve_unregistered_context( &mut self, auth_data: &AuthorizationData, 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 efc8272540..8f1a76816c 100644 --- a/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts +++ b/typescript/packages/web3/src/rollup/solana-signable-rollup.test.ts @@ -945,6 +945,34 @@ describe("SolanaSignableRollup", () => { expect(parsed.target_address).toBe(bs58.encode(targetAddress)); }); + it("places target_address before version in the signed JSON to match Rust field order", async () => { + // The Rust `SolanaOffchainUnsignedTransactionV1` struct declares `target_address` before + // `version`. Multisig signers sign the raw JSON bytes, so TS and Rust must serialize the + // fields in the same order — otherwise 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, + }); + + const signedJson = new TextDecoder().decode(captured.bytes!); + const targetIdx = signedJson.indexOf('"target_address"'); + const versionIdx = signedJson.indexOf('"version"'); + expect(targetIdx).toBeGreaterThanOrEqual(0); + expect(versionIdx).toBeGreaterThanOrEqual(0); + expect(targetIdx).toBeLessThan(versionIdx); + }); + it("forwards tx.target_address into the submitted JSON via submitMultisigTransaction", async () => { const { rollup, diff --git a/typescript/packages/web3/src/rollup/solana-signable-rollup.ts b/typescript/packages/web3/src/rollup/solana-signable-rollup.ts index 6f8c01ec66..21c3b3fb48 100644 --- a/typescript/packages/web3/src/rollup/solana-signable-rollup.ts +++ b/typescript/packages/web3/src/rollup/solana-signable-rollup.ts @@ -521,6 +521,10 @@ export class SolanaSignableRollup { 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, @@ -530,13 +534,12 @@ 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, }; - if (targetAddress !== undefined) { - solanaUnsignedTx.target_address = bs58.encode(targetAddress); - } - return new TextEncoder().encode(JSON.stringify(solanaUnsignedTx)); }