feat(wallet): add priority tip to support zone-sdk (#3116)

This commit is contained in:
Petar Radovic
2026-07-14 11:40:17 +02:00
committed by GitHub
parent b0ba8a4658
commit 80282ac590
14 changed files with 166 additions and 35 deletions
+5 -1
View File
@@ -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
///
+10 -7
View File
@@ -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<G: GasConstants>(
self,
context: &MantleTxContext,
change_pk: ZkPublicKey,
priority_fee: Value,
) -> Result<Option<Self>, 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::<G>(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::<G>(context)?, 0);
assert_eq!(tx_with_change.funding_delta::<G>(context)?, delta_target);
Ok(Some(tx_with_change))
}
@@ -495,7 +498,7 @@ mod tests {
// Add change note
let builder = builder
.return_change::<MainnetGasConstants>(&context, ZkPublicKey::zero())
.return_change::<MainnetGasConstants>(&context, ZkPublicKey::zero(), 0)
.unwrap()
.unwrap();
+3 -1
View File
@@ -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<ZkPublicKey>,
pub max_tx_fee: GasCost,
#[serde(default)]
pub priority_fee: Value,
}
#[derive(Serialize, Deserialize)]
+2
View File
@@ -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?;
@@ -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()))?;
+3 -1
View File
@@ -132,6 +132,7 @@ where
tx_builder: MantleTxBuilder,
change_pk: ZkPublicKey,
funding_pks: Vec<ZkPublicKey>,
priority_fee: Value,
) -> Result<TipResponse<MantleTxBuilder>, 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
}
+4
View File
@@ -142,6 +142,7 @@ pub enum WalletMsg {
tx_builder: MantleTxBuilder,
change_pk: ZkPublicKey,
funding_pks: Vec<ZkPublicKey>,
priority_fee: Value,
resp_tx: Sender<Result<TipResponse<MantleTxBuilder>, 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<NoteId> = funded_tx_builder.consumed_or_locked_notes().collect();
+5 -2
View File
@@ -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<G: GasConstants>(
&self,
tip: HeaderId,
@@ -369,6 +370,7 @@ impl<'u> ServiceState<'u> {
change_pk: ZkPublicKey,
funding_pks: impl IntoIterator<Item = impl Borrow<ZkPublicKey>>,
context: &MantleTxContext,
priority_fee: Value,
) -> Result<MantleTxBuilder, WalletError> {
self.wallet.fund_tx::<G>(
tip,
@@ -377,6 +379,7 @@ impl<'u> ServiceState<'u> {
funding_pks,
context,
&self.pending_notes.note_ids(),
priority_fee,
)
}
+2 -2
View File
@@ -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:
@@ -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::<MainnetGasConstants>(context, change_pk)?
.return_change::<MainnetGasConstants>(context, change_pk, 0)?
.map_or(
WalletFundingOutcome::NeedsMoreInputs,
WalletFundingOutcome::Funded,
@@ -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],
+1
View File
@@ -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 {
+97 -16
View File
@@ -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<G: GasConstants>(
&self,
tx_builder: &MantleTxBuilder,
@@ -204,6 +207,7 @@ impl WalletState {
pks: impl IntoIterator<Item = impl Borrow<ZkPublicKey>>,
context: &MantleTxContext,
excluded_notes: &HashSet<NoteId>,
priority_fee: Value,
) -> Result<MantleTxBuilder, WalletError> {
// 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::<G>(context)?.cmp(&0) {
match tx_builder.funding_delta::<G>(context)?.cmp(&delta_target) {
Ordering::Equal => return Ok(tx_builder.clone()),
Ordering::Greater => {
if let Some(tx_with_change) =
tx_builder.clone().return_change::<G>(context, change_pk)?
tx_builder
.clone()
.return_change::<G>(context, change_pk, priority_fee)?
{
return Ok(tx_with_change);
}
@@ -248,12 +258,13 @@ impl WalletState {
let funding_delta = funded_tx_builder.funding_delta::<G>(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::<G>(context, change_pk)?
funded_tx_builder.return_change::<G>(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<G: GasConstants>(
&self,
tip: HeaderId,
@@ -711,6 +726,7 @@ where
funding_pks: impl IntoIterator<Item = impl Borrow<ZkPublicKey>>,
context: &MantleTxContext,
excluded_notes: &HashSet<NoteId>,
priority_fee: Value,
) -> Result<MantleTxBuilder, WalletError> {
self.wallet_state_at(tip)?.fund_tx::<G>(
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::<Gas>(&tx_builder, alice, [alice], &context, &HashSet::new())
.fund_tx::<Gas>(&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::<Gas>(
&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::<Gas>(&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::<Gas>(&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::<Gas>(&context).unwrap());
let funded_tx_builder = wallet_state
.fund_tx::<Gas>(&tx_builder, alice, [alice], &context, &HashSet::new())
.fund_tx::<Gas>(&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::<Gas>(&tx_builder, alice, [alice], &context, &HashSet::new());
wallet_state.fund_tx::<Gas>(&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::<Gas>(&tx_builder, alice, [alice], &context, &HashSet::new());
wallet_state.fund_tx::<Gas>(&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::<Gas>(&tx_builder, alice, [alice], &context, &HashSet::new());
wallet_state.fund_tx::<Gas>(&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::<Gas>(&tx_builder, alice, [alice], &context, &HashSet::new());
wallet_state.fund_tx::<Gas>(&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::<Gas>(&tx_builder, bob, [bob], &context, &HashSet::new())
.fund_tx::<Gas>(&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::<Gas>(&tx_builder, alice, [alice], &context, &HashSet::new())
.fund_tx::<Gas>(&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::<Gas>(&tx_builder, alice, [alice], &context, &HashSet::new());
let fund_attempt = wallet_state.fund_tx::<Gas>(
&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::<Gas>(&tx_builder, alice, [alice], &context, &HashSet::new())
.fund_tx::<Gas>(&tx_builder, alice, [alice], &context, &HashSet::new(), 0)
.unwrap()
.build()
.unwrap(); // successfully funded the tx
+10 -1
View File
@@ -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}")))?;