From 26fc0dce8384c968f2114fb76a410dfe377def84 Mon Sep 17 00:00:00 2001 From: xianlinc Date: Tue, 26 May 2026 13:22:41 +0800 Subject: [PATCH] SWAP-449 fix: let callers override the wrapped tx fee payer Adds an optional `fee_payer` field to `SquadsWrapConfig`. When set it must be a multisig member or a signer pulled from the swap instructions. Defaults to `members[0]` so existing callers are unaffected. This is what the RFQ squads path needs: the maker is a signer on the inner Fill but was never the outer fee payer, so the multisig member ended up paying gas and ultra-api's confirmation lookup couldn't match the maker's signature against the message. --- squads-sdk/examples/wrap_and_unwrap.rs | 1 + squads-sdk/src/config.rs | 4 + squads-sdk/src/lib.rs | 1 + squads-sdk/src/unwrap.rs | 2 + squads-sdk/src/wrap.rs | 100 ++++++++++++++++++++++++- 5 files changed, 107 insertions(+), 1 deletion(-) diff --git a/squads-sdk/examples/wrap_and_unwrap.rs b/squads-sdk/examples/wrap_and_unwrap.rs index 07b563d..3208190 100644 --- a/squads-sdk/examples/wrap_and_unwrap.rs +++ b/squads-sdk/examples/wrap_and_unwrap.rs @@ -33,6 +33,7 @@ async fn main() { vault_pda, members: vec![member_a, member_b], threshold: 2, + fee_payer: None, }; // Whatever swap/transfer the vault needs to execute. diff --git a/squads-sdk/src/config.rs b/squads-sdk/src/config.rs index c51374b..54de39f 100644 --- a/squads-sdk/src/config.rs +++ b/squads-sdk/src/config.rs @@ -10,6 +10,9 @@ pub struct SquadsWrapConfig { pub vault_pda: Pubkey, pub members: Vec, pub threshold: u8, + /// Outer-tx fee payer override. Must be a multisig member or a signer on + /// the swap instructions (e.g. the RFQ maker). Defaults to `members[0]`. + pub fee_payer: Option, } impl SquadsWrapConfig { @@ -71,6 +74,7 @@ impl SquadsWrapConfig { vault_pda, members: signer_pubkeys.to_vec(), threshold: settings.threshold as u8, + fee_payer: None, }; config.validate()?; Ok(config) diff --git a/squads-sdk/src/lib.rs b/squads-sdk/src/lib.rs index 42c01c9..24ea025 100644 --- a/squads-sdk/src/lib.rs +++ b/squads-sdk/src/lib.rs @@ -96,6 +96,7 @@ mod tests { vault_pda: vault, members: vec![member], threshold: 1, + fee_payer: None, }, Hash::new_unique(), 400_000, diff --git a/squads-sdk/src/unwrap.rs b/squads-sdk/src/unwrap.rs index 4cd37a8..343c769 100644 --- a/squads-sdk/src/unwrap.rs +++ b/squads-sdk/src/unwrap.rs @@ -218,6 +218,7 @@ mod tests { vault_pda: vault, members: vec![member_a, member_b], threshold: 2, + fee_payer: None, }; let wrapped_tx = build_squads_wrapped_transaction( @@ -336,6 +337,7 @@ mod tests { vault_pda: vault, members: vec![member_a, member_b], threshold: 2, + fee_payer: None, }; let wrapped = build_squads_wrapped_transaction( diff --git a/squads-sdk/src/wrap.rs b/squads-sdk/src/wrap.rs index dd914ad..e9a7f5b 100644 --- a/squads-sdk/src/wrap.rs +++ b/squads-sdk/src/wrap.rs @@ -92,8 +92,20 @@ pub fn build_squads_wrapped_transaction( data, }; + let fee_payer = match config.fee_payer { + Some(pk) => { + if !config.members.contains(&pk) && !other_signer_pubkeys.contains(&pk) { + return Err(SquadsSdkError::InvalidConfig(format!( + "fee_payer {pk} must be a multisig member or a signer on the swap instructions" + ))); + } + pk + } + None => config.members[0], + }; + let message = message::v0::Message::try_compile( - &config.members[0], + &fee_payer, &[ ComputeBudgetInstruction::set_compute_unit_limit(compute_unit_limit), ComputeBudgetInstruction::set_compute_unit_price(compute_unit_price), @@ -281,6 +293,7 @@ mod tests { vault_pda: vault, members: vec![member_a, member_b], threshold: 2, + fee_payer: None, }, Hash::new_unique(), 400_000, @@ -317,6 +330,7 @@ mod tests { vault_pda: vault, members: vec![member_a, member_b, member_c], threshold: 2, // 2-of-3 + fee_payer: None, }, Hash::new_unique(), 400_000, @@ -411,6 +425,7 @@ mod tests { vault_pda: vault, members: vec![member_a, member_b], threshold: 2, + fee_payer: None, }, Hash::new_unique(), 400_000, @@ -458,6 +473,7 @@ mod tests { vault_pda: vault, members: vec![member_a, member_b], threshold: 2, + fee_payer: None, }, Hash::new_unique(), 400_000, @@ -488,6 +504,7 @@ mod tests { vault_pda: Pubkey::new_unique(), members: vec![], threshold: 1, + fee_payer: None, }; assert!(config.validate().is_err()); } @@ -499,6 +516,7 @@ mod tests { vault_pda: Pubkey::new_unique(), members: vec![Pubkey::new_unique()], threshold: 2, + fee_payer: None, }; assert!(config.validate().is_err()); } @@ -510,6 +528,7 @@ mod tests { vault_pda: Pubkey::new_unique(), members: vec![Pubkey::new_unique()], threshold: 0, + fee_payer: None, }; assert!(config.validate().is_err()); } @@ -541,6 +560,7 @@ mod tests { vault_pda: vault, members: vec![member_a, member_b], threshold: 2, + fee_payer: None, }, Hash::new_unique(), 400_000, @@ -565,6 +585,83 @@ mod tests { } } + #[test] + fn fee_payer_override_makes_non_member_signer_the_fee_payer() { + // RFQ squads flow: the RFQ maker is a signer on the inner Fill instruction + // and should pay the outer-tx gas (not the multisig members). The caller + // sets `fee_payer: Some(maker)` to opt into that ordering. + let (settings, vault, member_a, member_b, _, swap_program, token_program, user_ata) = + test_pubkeys(); + let maker = pubkey!("BfvJHsm36WTTbMXFqBUfKZJGDqnSrvGnRWAT4WHQFcVP"); + + let swap_ix = Instruction { + program_id: swap_program, + accounts: vec![ + AccountMeta::new(vault, true), + AccountMeta::new(maker, true), + AccountMeta::new(user_ata, false), + AccountMeta::new_readonly(token_program, false), + ], + data: vec![1, 2, 3], + }; + + let tx = build_squads_wrapped_transaction( + &[swap_ix], + &SquadsWrapConfig { + settings_pda: settings, + vault_pda: vault, + members: vec![member_a, member_b], + threshold: 2, + fee_payer: Some(maker), + }, + Hash::new_unique(), + 400_000, + 500_000, + ) + .expect("build should succeed with explicit fee_payer override"); + + match &tx.message { + VersionedMessage::V0(message) => { + assert_eq!( + message.account_keys[0], maker, + "fee_payer override should make maker signer[0]" + ); + let signer_keys = + &message.account_keys[..message.header.num_required_signatures as usize]; + assert!(signer_keys.contains(&member_a)); + assert!(signer_keys.contains(&member_b)); + } + _ => panic!("expected v0 message"), + } + } + + #[test] + fn fee_payer_override_rejects_unrelated_pubkey() { + let (settings, vault, member_a, member_b, _, swap_program, token_program, user_ata) = + test_pubkeys(); + let unrelated = pubkey!("BfvJHsm36WTTbMXFqBUfKZJGDqnSrvGnRWAT4WHQFcVP"); + let swap_ix = simple_swap_ix(vault, user_ata, token_program, swap_program); + + let err = build_squads_wrapped_transaction( + &[swap_ix], + &SquadsWrapConfig { + settings_pda: settings, + vault_pda: vault, + members: vec![member_a, member_b], + threshold: 2, + fee_payer: Some(unrelated), + }, + Hash::new_unique(), + 400_000, + 500_000, + ) + .expect_err("fee_payer not in members or swap signers should be rejected"); + assert!( + matches!(err, SquadsSdkError::InvalidConfig(_)), + "expected InvalidConfig, got {err:?}" + ); + } + #[test] fn wrap_transaction_base64_rejects_alt_transactions() { use solana_sdk::{ @@ -602,6 +699,7 @@ mod tests { vault_pda: vault, members: vec![member_a, member_b], threshold: 2, + fee_payer: None, }; let err = wrap_transaction_base64("e_b64, &config, &WrapOptions::default())