From 80282ac5902ce4ece61b5518ebd90380feff7c2e Mon Sep 17 00:00:00 2001 From: Petar Radovic Date: Tue, 14 Jul 2026 11:40:17 +0200 Subject: [PATCH] feat(wallet): add priority tip to support zone-sdk (#3116) --- c-bindings/src/api/wallet.rs | 6 +- core/src/mantle/transactions/builder.rs | 17 +-- nodes/api-common/src/bodies/wallet.rs | 4 +- nodes/node/binary/src/api/handlers.rs | 2 + .../binary/src/generic_services/sdp/wallet.rs | 24 +++- services/wallet/src/api.rs | 4 +- services/wallet/src/lib.rs | 4 + services/wallet/src/states.rs | 7 +- tests/cucumber_tests/features/zone.feature | 4 +- .../wallet/transaction/builder_funding.rs | 3 +- .../src/cucumber/steps/manual_zone/support.rs | 1 + tests/src/cucumber/steps/wallet_fund.rs | 1 + wallet/src/lib.rs | 113 +++++++++++++++--- zone-sdk/src/sequencer/tx_builder.rs | 11 +- 14 files changed, 166 insertions(+), 35 deletions(-) diff --git a/c-bindings/src/api/wallet.rs b/c-bindings/src/api/wallet.rs index 07f46efb4..50fb832f1 100644 --- a/c-bindings/src/api/wallet.rs +++ b/c-bindings/src/api/wallet.rs @@ -965,6 +965,7 @@ pub(crate) fn channel_deposit_with_notes_sync( tx_builder, change_public_key, funding_public_keys, + 0, ) .await .map_err(|error| { @@ -1465,6 +1466,7 @@ pub(crate) fn wallet_fund_tx_sync( request.tx_builder, request.change_public_key, request.funding_public_keys, + request.priority_fee, ) .await .map_err(|error| { @@ -1539,7 +1541,9 @@ pub type FfiWalletFundResult = FfiStatusResult<*mut c_char>; /// and submits the result via [`submit_signed_transaction`]. /// /// The request and response are JSON strings with the exact same schemas as -/// the node's `POST /wallet/fund` HTTP request and response bodies. +/// the node's `POST /wallet/fund` HTTP request and response bodies. The +/// optional `priority_fee` field (default 0) is left as excess balance above +/// the mandatory fee, paid to the block producer as the execution tip. /// /// # Arguments /// diff --git a/core/src/mantle/transactions/builder.rs b/core/src/mantle/transactions/builder.rs index 5fa1d6484..a2235e19c 100644 --- a/core/src/mantle/transactions/builder.rs +++ b/core/src/mantle/transactions/builder.rs @@ -7,7 +7,7 @@ use thiserror::Error; use crate::{ mantle::{ - GasCalculator as _, GasConstants, Note, NoteId, Op, Utxo, + GasCalculator as _, GasConstants, Note, NoteId, Op, Utxo, Value, gas::{GasCost, GasOverflow}, ledger::{BoundedUtxos, Inputs, Outputs}, ops::{channel::withdraw::ChannelWithdrawOp, transfer::TransferOp}, @@ -148,16 +148,20 @@ impl MantleTxBuilder { Ok(self) } + /// `priority_fee` is deliberately left unreturned: the resulting excess + /// balance above the mandatory fee is the transaction's execution tip. pub fn return_change( self, context: &MantleTxContext, change_pk: ZkPublicKey, + priority_fee: Value, ) -> Result, TxBuilderError> { // Calculate the funding delta with a dummy change note to account for // the gas cost increase from adding the output let delta_with_change = self.with_dummy_change_note()?.funding_delta::(context)?; + let delta_target = i128::from(priority_fee); - match delta_with_change.cmp(&0) { + match delta_with_change.cmp(&delta_target) { Ordering::Less | Ordering::Equal => { // NOTE: the `Equal` is important here since we // cannot create zero-valued outputs. @@ -170,16 +174,15 @@ impl MantleTxBuilder { // We have enough balance to cover the increase in cost from the change // note. Use return_change which properly accounts for the gas cost // increase from adding the change output. - let change = - u64::try_from(delta_with_change).expect("Positive delta must fit in u64"); + let change = u64::try_from(delta_with_change - delta_target) + .expect("Positive delta must fit in u64"); let tx_with_change = self.add_ledger_output(Note { value: change, pk: change_pk, })?; - // Now the net balance should exactly equal the gas cost. - assert_eq!(tx_with_change.funding_delta::(context)?, 0); + assert_eq!(tx_with_change.funding_delta::(context)?, delta_target); Ok(Some(tx_with_change)) } @@ -495,7 +498,7 @@ mod tests { // Add change note let builder = builder - .return_change::(&context, ZkPublicKey::zero()) + .return_change::(&context, ZkPublicKey::zero(), 0) .unwrap() .unwrap(); diff --git a/nodes/api-common/src/bodies/wallet.rs b/nodes/api-common/src/bodies/wallet.rs index 56761f20b..a60bcd1ec 100644 --- a/nodes/api-common/src/bodies/wallet.rs +++ b/nodes/api-common/src/bodies/wallet.rs @@ -147,7 +147,7 @@ pub mod fund { use lb_core::{ header::HeaderId, mantle::{ - OpProof, + OpProof, Value, gas::GasCost, transactions::{MantleTx, builder::MantleTxBuilder}, }, @@ -162,6 +162,8 @@ pub mod fund { pub change_public_key: ZkPublicKey, pub funding_public_keys: Vec, pub max_tx_fee: GasCost, + #[serde(default)] + pub priority_fee: Value, } #[derive(Serialize, Deserialize)] diff --git a/nodes/node/binary/src/api/handlers.rs b/nodes/node/binary/src/api/handlers.rs index 7451d76ec..31fed46c7 100644 --- a/nodes/node/binary/src/api/handlers.rs +++ b/nodes/node/binary/src/api/handlers.rs @@ -988,6 +988,7 @@ where tx_builder, req.change_public_key, req.funding_public_keys, + 0, ) .await?; @@ -1941,6 +1942,7 @@ pub mod wallet { req.tx_builder, req.change_public_key, req.funding_public_keys, + req.priority_fee, ) .await?; diff --git a/nodes/node/binary/src/generic_services/sdp/wallet.rs b/nodes/node/binary/src/generic_services/sdp/wallet.rs index 29d668242..970520dec 100644 --- a/nodes/node/binary/src/generic_services/sdp/wallet.rs +++ b/nodes/node/binary/src/generic_services/sdp/wallet.rs @@ -46,7 +46,13 @@ where response: funded, } = self .api - .fund_tx(None, tx_builder, config.funding_pk, vec![config.funding_pk]) + .fund_tx( + None, + tx_builder, + config.funding_pk, + vec![config.funding_pk], + 0, + ) .await .map_err(|e| SdpWalletError::WalletApi(e.into()))?; @@ -81,7 +87,13 @@ where response: funded, } = self .api - .fund_tx(None, tx_builder, config.funding_pk, vec![config.funding_pk]) + .fund_tx( + None, + tx_builder, + config.funding_pk, + vec![config.funding_pk], + 0, + ) .await .map_err(|e| SdpWalletError::WalletApi(e.into()))?; @@ -116,7 +128,13 @@ where response: funded, } = self .api - .fund_tx(None, tx_builder, config.funding_pk, vec![config.funding_pk]) + .fund_tx( + None, + tx_builder, + config.funding_pk, + vec![config.funding_pk], + 0, + ) .await .map_err(|e| SdpWalletError::WalletApi(e.into()))?; diff --git a/services/wallet/src/api.rs b/services/wallet/src/api.rs index 64b64b0db..6a368f116 100644 --- a/services/wallet/src/api.rs +++ b/services/wallet/src/api.rs @@ -132,6 +132,7 @@ where tx_builder: MantleTxBuilder, change_pk: ZkPublicKey, funding_pks: Vec, + priority_fee: Value, ) -> Result, WalletApiError> { let (resp_tx, rx) = oneshot::channel(); @@ -141,6 +142,7 @@ where tx_builder, change_pk, funding_pks, + priority_fee, resp_tx, }) .await?; @@ -194,7 +196,7 @@ where let mantle_tx_builder = MantleTxBuilder::new().add_ledger_output(Note::new(amount, recipient_pk))?; let funded_tx_builder = self - .fund_tx(tip, mantle_tx_builder, change_pk, funding_pks) + .fund_tx(tip, mantle_tx_builder, change_pk, funding_pks, 0) .await?; self.sign_tx(tip, funded_tx_builder.response).await } diff --git a/services/wallet/src/lib.rs b/services/wallet/src/lib.rs index 8c8a0f788..606d1187b 100644 --- a/services/wallet/src/lib.rs +++ b/services/wallet/src/lib.rs @@ -142,6 +142,7 @@ pub enum WalletMsg { tx_builder: MantleTxBuilder, change_pk: ZkPublicKey, funding_pks: Vec, + priority_fee: Value, resp_tx: Sender, WalletServiceError>>, }, BuildLeaderClaimTx { @@ -508,6 +509,7 @@ where tx_builder, change_pk, funding_pks, + priority_fee, resp_tx, } => { let tip = match Self::msg_tip_or_latest(tip, cryptarchia).await { @@ -535,6 +537,7 @@ where change_pk, funding_pks, &context, + priority_fee, ) { Ok(funded) => funded, Err(err) => { @@ -1237,6 +1240,7 @@ where request.funding_pk, [request.funding_pk], &context, + 0, )?; let funded_notes: Vec = funded_tx_builder.consumed_or_locked_notes().collect(); diff --git a/services/wallet/src/states.rs b/services/wallet/src/states.rs index 941ba247c..03d238661 100644 --- a/services/wallet/src/states.rs +++ b/services/wallet/src/states.rs @@ -6,7 +6,7 @@ use std::{ use lb_core::{ header::HeaderId, mantle::{ - GasConstants, NoteId, + GasConstants, NoteId, Value, ops::leader_claim::{VoucherCm, VoucherNullifier}, transactions::{MantleTxBuilder, MantleTxContext}, }, @@ -361,7 +361,8 @@ impl<'u> ServiceState<'u> { } /// Fund `tx_builder` from the wallet's UTXOs at `tip`, excluding notes - /// already reserved for in-flight transactions. + /// already reserved for in-flight transactions. `priority_fee` is left + /// as excess balance above the mandatory fee (the execution tip). pub fn fund_tx( &self, tip: HeaderId, @@ -369,6 +370,7 @@ impl<'u> ServiceState<'u> { change_pk: ZkPublicKey, funding_pks: impl IntoIterator>, context: &MantleTxContext, + priority_fee: Value, ) -> Result { self.wallet.fund_tx::( tip, @@ -377,6 +379,7 @@ impl<'u> ServiceState<'u> { funding_pks, context, &self.pending_notes.note_ids(), + priority_fee, ) } diff --git a/tests/cucumber_tests/features/zone.feature b/tests/cucumber_tests/features/zone.feature index dca38e238..bb525de6f 100644 --- a/tests/cucumber_tests/features/zone.feature +++ b/tests/cucumber_tests/features/zone.feature @@ -232,7 +232,7 @@ Feature: Zone SDK | SEQ_B | | SEQ_C | When node "NODE_1" is at height 1 in 120 seconds - And wallet "WALLET_1A" sends 100 notes of 1000 LGO to node "NODE_1" funding wallet as "FUNDING_TOPUP" + And wallet "WALLET_1A" sends 100 notes of 1500 LGO to node "NODE_1" funding wallet as "FUNDING_TOPUP" And transaction "FUNDING_TOPUP" is included on node "NODE_1" in 180 seconds And I start zone sequencer "SEQ_A" with indexer When I stop zone sequencer "SEQ_A" @@ -518,7 +518,7 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A, SEQ_B, SEQ_C | When node "NODE_1" is at height 1 in 120 seconds - And wallet "WALLET_1A" sends 100 notes of 1000 LGO to node "NODE_1" funding wallet as "FUNDING_TOPUP" + And wallet "WALLET_1A" sends 100 notes of 1500 LGO to node "NODE_1" funding wallet as "FUNDING_TOPUP" And transaction "FUNDING_TOPUP" is included on node "NODE_1" in 180 seconds And I start zone sequencer "SEQ_A" with indexer And sequencer "SEQ_A" submits zone config transaction: diff --git a/tests/src/common/wallet/transaction/builder_funding.rs b/tests/src/common/wallet/transaction/builder_funding.rs index a90563078..4ec2f1aff 100644 --- a/tests/src/common/wallet/transaction/builder_funding.rs +++ b/tests/src/common/wallet/transaction/builder_funding.rs @@ -31,6 +31,7 @@ pub fn fund_builder_from_wallet_source( [source.public_key()], context, &HashSet::new(), + 0, ) } @@ -195,7 +196,7 @@ fn evaluate_standard_funding_inputs( Ordering::Less => Ok(WalletFundingOutcome::NeedsMoreInputs), Ordering::Equal => Ok(WalletFundingOutcome::Funded(funded_builder)), Ordering::Greater => Ok(funded_builder - .return_change::(context, change_pk)? + .return_change::(context, change_pk, 0)? .map_or( WalletFundingOutcome::NeedsMoreInputs, WalletFundingOutcome::Funded, diff --git a/tests/src/cucumber/steps/manual_zone/support.rs b/tests/src/cucumber/steps/manual_zone/support.rs index 4d0707acf..62f2f3dca 100644 --- a/tests/src/cucumber/steps/manual_zone/support.rs +++ b/tests/src/cucumber/steps/manual_zone/support.rs @@ -1514,6 +1514,7 @@ async fn build_funded_custom_tx( let response = node_client .fund_tx(WalletFundRequestBody { tip: None, + priority_fee: 0, tx_builder, change_public_key: funding_pk, funding_public_keys: vec![funding_pk], diff --git a/tests/src/cucumber/steps/wallet_fund.rs b/tests/src/cucumber/steps/wallet_fund.rs index c4a094391..341f334d2 100644 --- a/tests/src/cucumber/steps/wallet_fund.rs +++ b/tests/src/cucumber/steps/wallet_fund.rs @@ -208,6 +208,7 @@ async fn fund_via_node( change_public_key: funding_pk, funding_public_keys: vec![funding_pk], max_tx_fee: GasCost::new(u64::MAX), + priority_fee: 0, }) .await .map_err(|source| StepError::StepFail { diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 6e3599625..a804438c7 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -197,6 +197,9 @@ impl WalletState { .collect() } + /// Funds the transaction so its excess balance is exactly + /// `priority_fee` above the mandatory fee — the excess is the + /// transaction's execution tip. `0` funds to the exact minimum. pub fn fund_tx( &self, tx_builder: &MantleTxBuilder, @@ -204,6 +207,7 @@ impl WalletState { pks: impl IntoIterator>, context: &MantleTxContext, excluded_notes: &HashSet, + priority_fee: Value, ) -> Result { // Get all UTXOs owned by the provided PKs, excluding the following notes: // - Notes that are being consumed/locked by the tx @@ -223,15 +227,21 @@ impl WalletState { // Consume large valued notes first to ensure we converge. utxos.sort_by_key(|utxo| -i128::from(utxo.note.value)); + // The funding target: the tx's excess balance over the mandatory fee + // must end up exactly at `priority_fee` (the execution tip). + let delta_target = i128::from(priority_fee); + // The transaction may already be funded before we add any of the // wallet's UTXOs, for example when it pays no fee or the caller already // supplied inputs. In that case we must not pull in an extra input (and // the change note it would require). - match tx_builder.funding_delta::(context)?.cmp(&0) { + match tx_builder.funding_delta::(context)?.cmp(&delta_target) { Ordering::Equal => return Ok(tx_builder.clone()), Ordering::Greater => { if let Some(tx_with_change) = - tx_builder.clone().return_change::(context, change_pk)? + tx_builder + .clone() + .return_change::(context, change_pk, priority_fee)? { return Ok(tx_with_change); } @@ -248,12 +258,13 @@ impl WalletState { let funding_delta = funded_tx_builder.funding_delta::(context)?; - match funding_delta.cmp(&0) { + match funding_delta.cmp(&delta_target) { Ordering::Less => { // Insufficient funds, need more UTXO's. } Ordering::Equal => { - // We can exactly pay the tx cost, no change note needed. + // We can exactly pay the tx cost plus the tip, no change + // note needed. return Ok(funded_tx_builder); } Ordering::Greater => { @@ -261,7 +272,7 @@ impl WalletState { // The change note will slightly increase the storage cost of the tx so there is // a chance that we will not be able to fund the tx with the change note. if let Some(tx_with_change) = - funded_tx_builder.return_change::(context, change_pk)? + funded_tx_builder.return_change::(context, change_pk, priority_fee)? { // We were able to fund the tx with change note added. return Ok(tx_with_change); @@ -703,6 +714,10 @@ where Ok(self.wallet_state_at(tip)?.balance(pk)) } + #[expect( + clippy::too_many_arguments, + reason = "thin passthrough to `WalletState::fund_tx` plus the tip" + )] pub fn fund_tx( &self, tip: HeaderId, @@ -711,6 +726,7 @@ where funding_pks: impl IntoIterator>, context: &MantleTxContext, excluded_notes: &HashSet, + priority_fee: Value, ) -> Result { self.wallet_state_at(tip)?.fund_tx::( tx_builder, @@ -718,6 +734,7 @@ where funding_pks, context, excluded_notes, + priority_fee, ) } @@ -1248,7 +1265,7 @@ mod tests { // Fund the transaction let funded_tx_builder = wallet_state - .fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new()) + .fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new(), 0) .unwrap(); assert_eq!( @@ -1279,6 +1296,64 @@ mod tests { } } + #[test] + fn test_fund_tx_with_priority_fee() { + let alice = pk(1); + let utxo = Utxo::new(tx_hash(0), 0, Note::new(5000, alice)); + let ledger_state = LedgerState::from_utxos([utxo], &ledger_config()); + let wallet_state = + WalletState::from_ledger(&HashMap::from_iter([(alice, 1)]), &ledger_state); + + let context = MantleTxContext { + gas_context: MantleTxGasContext::from_channels( + &Channels::default(), + GasPrices::new(1, 1), + ), + leader_reward_amount: 0, + }; + let tx_builder = MantleTxBuilder::new(); + let priority_fee = 200; + + let funded_tx_builder = wallet_state + .fund_tx::( + &tx_builder, + alice, + [alice], + &context, + &HashSet::new(), + priority_fee, + ) + .unwrap(); + + // The tip is left as excess balance above the mandatory fee. + let gas_cost = funded_tx_builder + .gas_cost::(&context) + .unwrap() + .into_inner(); + assert_eq!( + i128::from(gas_cost + priority_fee), + funded_tx_builder.net_balance() + ); + assert_eq!( + i128::from(priority_fee), + funded_tx_builder.funding_delta::(&context).unwrap() + ); + + // The change output shrank by exactly the tip. + let funded_tx = funded_tx_builder.build().unwrap(); + if let Op::Transfer(transfer_op) = &funded_tx.ops()[funded_tx.ops().len() - 1] { + assert_eq!( + transfer_op.outputs, + Outputs::new([Note { + value: 5000 - gas_cost - priority_fee, + pk: alice, + }]) + ); + } else { + panic!("last op must be a transfer") + } + } + #[test] fn test_fund_tx_no_fee_adds_no_input() { let alice = pk(1); @@ -1312,7 +1387,7 @@ mod tests { assert_eq!(0, tx_builder.funding_delta::(&context).unwrap()); let funded_tx_builder = wallet_state - .fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new()) + .fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new(), 0) .unwrap(); // No input was pulled in (an added input would push the net balance to @@ -1368,7 +1443,7 @@ mod tests { // Fund the transaction let fund_attempt = - wallet_state.fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new()); + wallet_state.fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new(), 0); assert_eq!( fund_attempt.unwrap_err(), @@ -1397,7 +1472,7 @@ mod tests { // Fund the transaction let fund_attempt = - wallet_state.fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new()); + wallet_state.fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new(), 0); assert_eq!( fund_attempt.unwrap_err(), @@ -1429,7 +1504,7 @@ mod tests { // Fund the transaction let fund_attempt = - wallet_state.fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new()); + wallet_state.fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new(), 0); assert_eq!( fund_attempt.unwrap_err(), @@ -1462,7 +1537,7 @@ mod tests { // Attempt to fund the transaction with Alice's notes. let fund_attempt = - wallet_state.fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new()); + wallet_state.fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new(), 0); assert_eq!( fund_attempt.unwrap_err(), @@ -1471,7 +1546,7 @@ mod tests { // Fund the transaction with Bob's notes. wallet_state - .fund_tx::(&tx_builder, bob, [bob], &context, &HashSet::new()) + .fund_tx::(&tx_builder, bob, [bob], &context, &HashSet::new(), 0) .unwrap(); // succesfully funded; } @@ -1511,7 +1586,7 @@ mod tests { ); let funded_tx_wo_change = wallet_state - .fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new()) + .fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new(), 0) .unwrap() .build() .unwrap(); // successfully funded the tx @@ -1551,8 +1626,14 @@ mod tests { ), ); - let fund_attempt = - wallet_state.fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new()); + let fund_attempt = wallet_state.fund_tx::( + &tx_builder, + alice, + [alice], + &context, + &HashSet::new(), + 0, + ); assert_eq!( fund_attempt.unwrap_err(), @@ -1570,7 +1651,7 @@ mod tests { ); let funded_tx_wo_change = wallet_state - .fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new()) + .fund_tx::(&tx_builder, alice, [alice], &context, &HashSet::new(), 0) .unwrap() .build() .unwrap(); // successfully funded the tx diff --git a/zone-sdk/src/sequencer/tx_builder.rs b/zone-sdk/src/sequencer/tx_builder.rs index 549e8a81a..e1b7cddc0 100644 --- a/zone-sdk/src/sequencer/tx_builder.rs +++ b/zone-sdk/src/sequencer/tx_builder.rs @@ -1,6 +1,6 @@ use lb_core::{ mantle::{ - MantleTx, SignedMantleTx, Transaction as _, + MantleTx, SignedMantleTx, Transaction as _, Value, channel::{ChannelState, SlotTimeframe, SlotTimeout}, ops::{ Op, OpProof, @@ -20,6 +20,14 @@ use lb_key_management_system_service::keys::{Ed25519Key, Ed25519Signature}; use super::types::{Error, FundingConfig}; use crate::adapter; +/// Execution tip paid on top of the mandatory fee when funding a transaction, +/// buffering gas-price movement between funding and inclusion: in the current +/// spec the base fee moves at most 12.5% per block, so this covers a few +/// blocks of drift at current fee levels. +/// +/// TODO: promote to [`FundingConfig`] if clients need to tune it. +const PRIORITY_FEE: Value = 200; + /// Assemble the ops for a transaction, funding it from the node's wallet when /// a [`FundingConfig`] is present. /// @@ -53,6 +61,7 @@ where change_public_key: funding.funding_pk, funding_public_keys: vec![funding.funding_pk], max_tx_fee: funding.max_tx_fee, + priority_fee: PRIORITY_FEE, }) .await .map_err(|e| Error::Network(format!("funding failed: {e}")))?;