diff --git a/deployment/tui-zone/src/cli.rs b/deployment/tui-zone/src/cli.rs index ee7b897c2..f6d78913c 100644 --- a/deployment/tui-zone/src/cli.rs +++ b/deployment/tui-zone/src/cli.rs @@ -90,6 +90,18 @@ pub struct NodeKeyArgs { /// Path to the signing key file (created if it doesn't exist) #[arg(long, default_value = "sequencer.key", env = "KEY_PATH")] pub key_path: String, + + /// Node wallet public key (hex, 32 bytes) used to pay transaction fees. + /// The value comes from the node's own configuration. When absent, + /// transactions are built fee-less (only valid while gas prices are + /// zero). + #[arg(long, env = "FUNDING_PK")] + pub funding_pk: Option, + + /// Cap on a single transaction's fee (in gas units) when funding via + /// `--funding-pk`. + #[arg(long, default_value_t = 1_000_000, env = "MAX_TX_FEE")] + pub max_tx_fee: u64, } #[derive(Args, Debug)] diff --git a/deployment/tui-zone/src/run_commands/run_config.rs b/deployment/tui-zone/src/run_commands/run_config.rs index 2e75a431a..4e3d2a00c 100644 --- a/deployment/tui-zone/src/run_commands/run_config.rs +++ b/deployment/tui-zone/src/run_commands/run_config.rs @@ -50,13 +50,16 @@ pub(crate) async fn run_config(args: ConfigArgs) -> RunResult<()> { start_cli_sequencer_with_channel_state(&args.node_key).await?; print_channel_state("zone_config before", &channel_id, channel_state.as_ref()); let status_rx = sequencer.subscribe_tx_status(); - let (_result, _checkpoint, signed_tx) = sequencer.handle().channel_config( - Keys::try_from(authorized_keys)?, - args.posting_timeframe.into(), - args.posting_timeout.into(), - args.configuration_threshold, - args.withdraw_threshold, - )?; + let (_result, _checkpoint, signed_tx) = sequencer + .handle() + .channel_config( + Keys::try_from(authorized_keys)?, + args.posting_timeframe.into(), + args.posting_timeout.into(), + args.configuration_threshold, + args.withdraw_threshold, + ) + .await?; let tx_hash = signed_tx.hash(); let goal = CommandGoal::Tx { tx_hash }; let wait_for = if args.wait_finalized { diff --git a/deployment/tui-zone/src/run_commands/run_inscribe.rs b/deployment/tui-zone/src/run_commands/run_inscribe.rs index 8541b0ef7..bd653d760 100644 --- a/deployment/tui-zone/src/run_commands/run_inscribe.rs +++ b/deployment/tui-zone/src/run_commands/run_inscribe.rs @@ -42,7 +42,14 @@ pub async fn run_inscribe(args: NodeKeyArgs) { InMemoryZoneState::for_channel(channel_id, channel_exists).expect("invalid checkpoint"); let checkpoint = state.load_checkpoint().cloned(); - let mut sequencer = ZoneSequencer::init(channel_id, signing_key, node, checkpoint); + let sequencer_config = utils::cli_sequencer_config(&args).expect("invalid funding pk"); + let mut sequencer = ZoneSequencer::init_with_config( + channel_id, + signing_key, + node, + sequencer_config, + checkpoint, + ); let view_rx = sequencer.subscribe_channel_view(); let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); @@ -55,7 +62,7 @@ pub async fn run_inscribe(args: NodeKeyArgs) { tokio::select! { event = sequencer.next_event() => { state.set_channel_view(view_rx.borrow().clone()); - handle_event(event, &mut state, &mut sequencer, &mut ready_tx); + handle_event(event, &mut state, &mut sequencer, &mut ready_tx).await; } input = stdin_rx.recv() => { @@ -70,7 +77,7 @@ pub async fn run_inscribe(args: NodeKeyArgs) { error!("Message is too large to fit in an inscription"); continue; }; - match sequencer.handle().publish(inscription) { + match sequencer.handle().publish(inscription).await { Ok((result, checkpoint)) => { let info = result.tx.inscription(); debug!(msg_id = %hex::encode(info.this_msg.as_ref()), "Published"); @@ -104,7 +111,7 @@ pub async fn run_inscribe(args: NodeKeyArgs) { println!("Goodbye!"); } -fn handle_event( +async fn handle_event( event: Event, state: &mut InMemoryZoneState, sequencer: &mut ZoneSequencer, @@ -119,7 +126,7 @@ fn handle_event( } => { // Pin finalized payloads before the re-publish dedup below. apply_finalized(&finalized, state); - apply_channel_update(channel_update, state, sequencer); + apply_channel_update(channel_update, state, sequencer).await; state.save_checkpoint(checkpoint); } Event::MempoolPending(..) | Event::TurnNotification { .. } => {} @@ -145,7 +152,7 @@ fn apply_finalized(items: &[FinalizedTx], state: &mut InMemoryZoneState) { ui::prompt(); } -fn apply_channel_update( +async fn apply_channel_update( update: ChannelUpdate, state: &mut InMemoryZoneState, sequencer: &mut ZoneSequencer, @@ -158,11 +165,11 @@ fn apply_channel_update( // the channel reappears in `adopted`, so don't republish it. let adopted_payloads: HashSet<&[u8]> = adopted.iter().map(|i| i.payload.as_slice()).collect(); for entry in &orphaned { - handle_orphan(state, sequencer, entry, &adopted_payloads); + handle_orphan(state, sequencer, entry, &adopted_payloads).await; } } -fn handle_orphan( +async fn handle_orphan( state: &mut InMemoryZoneState, sequencer: &mut ZoneSequencer, entry: &OrphanedTx, @@ -180,16 +187,16 @@ fn handle_orphan( debug!(msg_id = %hex::encode(info.this_msg.as_ref()), "orphan already finalized; not republishing"); return; } - republish_orphan(state, sequencer, info); + republish_orphan(state, sequencer, info).await; } -fn republish_orphan( +async fn republish_orphan( state: &mut InMemoryZoneState, sequencer: &mut ZoneSequencer, info: &InscriptionInfo, ) { debug!(msg_id = %hex::encode(info.this_msg.as_ref()), "Auto-republishing orphan"); - match sequencer.handle().publish(info.payload.clone()) { + match sequencer.handle().publish(info.payload.clone()).await { Ok((_, checkpoint)) => state.save_checkpoint(checkpoint), Err(e) => error!("failed to auto-republish: {e}"), } diff --git a/deployment/tui-zone/src/run_commands/utils.rs b/deployment/tui-zone/src/run_commands/utils.rs index 5ebcc4e9f..eab0970d2 100644 --- a/deployment/tui-zone/src/run_commands/utils.rs +++ b/deployment/tui-zone/src/run_commands/utils.rs @@ -25,7 +25,7 @@ use lb_key_management_system_service::keys::{ use lb_zone_sdk::{ CommonHttpClient, adapter::{Node as _, NodeHttpClient}, - sequencer::{Event, SequencerCheckpoint, ZoneSequencer}, + sequencer::{Event, FundingConfig, SequencerCheckpoint, SequencerConfig, ZoneSequencer}, }; use reqwest::Url; use serde::{Deserialize, Serialize}; @@ -334,6 +334,27 @@ pub fn build_deposit_op( }) } +/// Build the sequencer funding config from CLI args. `--funding-pk` enables +/// funding transactions from the node's wallet; absent means fee-less +/// transactions. +pub fn funding_config(args: &NodeKeyArgs) -> RunResult> { + let Some(funding_pk_hex) = &args.funding_pk else { + return Ok(None); + }; + Ok(Some(FundingConfig { + funding_pk: decode_zk_public_key_hex(funding_pk_hex)?, + max_tx_fee: args.max_tx_fee.into(), + })) +} + +/// Sequencer config for CLI commands, with funding taken from the args. +pub fn cli_sequencer_config(args: &NodeKeyArgs) -> RunResult { + Ok(SequencerConfig { + funding: funding_config(args)?, + ..SequencerConfig::default() + }) +} + /// Start a zone sequencer for non-interactive CLI commands and wait for /// readiness. pub async fn start_cli_sequencer(args: &NodeKeyArgs) -> RunResult> { @@ -351,7 +372,13 @@ pub async fn start_cli_sequencer_with_channel_state( let node = node_client(&args.node_url)?; let channel_exists = query_channel_exists(&node, channel_id).await; let checkpoint = load_cli_checkpoint(&channel_id, channel_exists)?; - let mut sequencer = ZoneSequencer::init(channel_id, signing_key, node.clone(), checkpoint); + let mut sequencer = ZoneSequencer::init_with_config( + channel_id, + signing_key, + node.clone(), + cli_sequencer_config(args)?, + checkpoint, + ); while !sequencer.is_ready() { drop(sequencer.next_event().await); } diff --git a/tests/cucumber_tests/features/zone.feature b/tests/cucumber_tests/features/zone.feature index e6d07764e..f9d0b9216 100644 --- a/tests/cucumber_tests/features/zone.feature +++ b/tests/cucumber_tests/features/zone.feature @@ -11,6 +11,8 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A, SEQ_B | When node "NODE_1" is at height 1 in 120 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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" publishes the following zone messages: | alias | data | @@ -47,6 +49,8 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A | When node "NODE_1" is at height 1 in 120 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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" publishes the following zone messages: | alias | data | @@ -79,6 +83,8 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A | When node "NODE_1" is at height 1 in 120 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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" publishes the following zone messages: | alias | data | @@ -109,7 +115,7 @@ Feature: Zone SDK And I stop all nodes @zone_ci - Scenario: Publishes issued while the node is down are accepted locally and posted on reconnect + Scenario: Publishes issued while the node is down fail fast and succeed after reconnect Given the genesis block has the following wallet resources: | account_index | token_count | token_amount | | 1 | 3 | 100000 | @@ -118,18 +124,23 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A | When node "NODE_1" is at height 1 in 120 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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 # Take the node down: the sequencer enters its reconnect loop, but its - # in-process SequencerClient stays alive. + # in-process SequencerClient stays alive. With funding configured, + # publishing needs the node's wallet, so publishes are rejected while + # it is down; a fresh Ready event fires once the reconnect completes. When I stop node "NODE_1" - And sequencer "SEQ_A" submits the following zone messages without waiting for inclusion: - | alias | data | - | MSG_1 | While down (1) | - | MSG_2 | While down (2) | - | MSG_3 | While down (3) | - # Bring the node back; the locally-queued inscriptions are posted, mined - # and adopted, preserving publish order. + Then publishing zone message with data "while down" via sequencer "SEQ_A" fails while the node is down + # Bring the node back; publishes retry through the reconnect window and + # succeed once the sequencer re-emits Ready. When I restart node "NODE_1" + And sequencer "SEQ_A" publishes the following zone messages: + | alias | data | + | MSG_1 | After down (1) | + | MSG_2 | After down (2) | + | MSG_3 | After down (3) | Then all zone messages are safe in 120 seconds And all zone messages are finalized in 180 seconds And the zone indexer returns messages in this order: @@ -153,6 +164,8 @@ Feature: Zone SDK | alias | | SEQ_B | When node "NODE_1" is at height 1 in 120 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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" publishes the following zone messages: | alias | data | @@ -219,6 +232,8 @@ 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 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" And each listed zone sequencer publishes 20 generated zone messages concurrently with republish policy: @@ -240,6 +255,8 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A, SEQ_B | When node "NODE_1" is at height 1 in 120 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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: | config_name | posting_timeframe | posting_timeout | authorized_sequencers | @@ -271,6 +288,8 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A, SEQ_B | When node "NODE_1" is at height 1 in 120 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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 sequencers: | alias | indexer | pending_submit_depth | passive_republish_orphans | | SEQ_A | true | 2 | false | @@ -327,6 +346,8 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A, SEQ_B | When node "NODE_1" is at height 1 in 120 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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 sequencers: | alias | indexer | pending_submit_depth | passive_republish_orphans | | SEQ_A | true | unlimited | false | @@ -383,6 +404,8 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A, SEQ_B | When node "NODE_1" is at height 1 in 120 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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: | config_name | posting_timeframe | posting_timeout | authorized_sequencers | @@ -409,6 +432,8 @@ 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 30 notes of 1000 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 sequencers: | alias | indexer | pending_submit_depth | passive_republish_orphans | | SEQ_A | true | default | true | @@ -460,6 +485,8 @@ Feature: Zone SDK | bob | 10 | | charlie | 10 | When node "NODE_1" is at height 1 in 120 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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: | config_name | posting_timeframe | posting_timeout | authorized_sequencers | @@ -491,6 +518,8 @@ 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 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: | config_name | posting_timeframe | posting_timeout | authorized_sequencers | @@ -516,6 +545,8 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A | When node "NODE_1" is at height 2 in 300 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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 do a coin split for "WALLET_1A" of 3 UTXOs valued at 1 LGO tokens each And I start zone sequencer "SEQ_A" with indexer And sequencer "SEQ_A" publishes the following zone messages: @@ -539,6 +570,8 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A | When node "NODE_1" is at height 1 in 120 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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" publishes the following zone messages: | alias | data | @@ -554,7 +587,11 @@ Feature: Zone SDK | MSG_2 | And I stop all nodes - @zone_ci + # Ignored: this flow uses the manual `prepare_tx` path, which builds + # fee-less transactions — valid only while gas prices are zero and broken + # once they go non-zero. Kept out of @zone_ci so the gas-price flip needs + # no test changes; restore @zone_ci when prepare-time funding lands. + @zone_prepare_flow_pending_funding # [tests/src/tests/zone_sdk/e2e.rs] test_subscribe_to_finalized_withdraw Scenario: Finalized withdraws are returned by the zone indexer and sequencer Given the genesis block has the following wallet resources: @@ -565,6 +602,8 @@ Feature: Zone SDK | node_name | account_index | wallet_name | connected_to | sequencers | | NODE_1 | 1 | WALLET_1A | | SEQ_A | When node "NODE_1" is at height 2 in 300 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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 do a coin split for "WALLET_1A" of 3 UTXOs valued at 3 LGO tokens each And I start zone sequencer "SEQ_A" with indexer And sequencer "SEQ_A" publishes the following zone messages: @@ -600,6 +639,8 @@ Feature: Zone SDK | alias | | SEQ_B | When node "NODE_1" is at height 2 in 300 seconds + And wallet "WALLET_1A" sends 30 notes of 1000 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 do a coin split for "WALLET_1A" of 3 UTXOs valued at 5 LGO tokens each And I start zone sequencer "SEQ_A" with indexer And sequencer "SEQ_A" publishes the following zone messages: diff --git a/tests/src/common/wallet/funding.rs b/tests/src/common/wallet/funding.rs index 5991a136d..424507cd7 100644 --- a/tests/src/common/wallet/funding.rs +++ b/tests/src/common/wallet/funding.rs @@ -217,17 +217,21 @@ impl WalletFundedTransfer { /// Build a transfer operation and change output from available UTXOs. /// /// Inputs are selected largest-first, and change is returned to `change_pk`. +/// `fee` is deliberately left unreturned — it becomes the transaction's +/// excess balance, which pays the mandatory fees at non-zero gas prices. pub fn build_wallet_funded_transfer( available_utxos: Vec, outputs: Vec, change_pk: ZkPublicKey, + fee: u64, ) -> Result { let output_total = outputs .iter() .fold(0u64, |total, output| total.saturating_add(output.value)); + let funding_target = output_total.saturating_add(fee); let selected_inputs = - WalletSelectedInputs::largest_first_covering(available_utxos, output_total)?; - let change = selected_inputs.total() - output_total; + WalletSelectedInputs::largest_first_covering(available_utxos, funding_target)?; + let change = selected_inputs.total() - funding_target; let mut transfer_outputs = outputs; if change > 0 { diff --git a/tests/src/cucumber/steps/manual_transactions/steps.rs b/tests/src/cucumber/steps/manual_transactions/steps.rs index 88fd81a10..999592461 100644 --- a/tests/src/cucumber/steps/manual_transactions/steps.rs +++ b/tests/src/cucumber/steps/manual_transactions/steps.rs @@ -31,7 +31,10 @@ use crate::{ }, }, }, - wallet::sync::{WalletSendReadiness, wait_wallet_send_ready}, + wallet::{ + submissions::create_and_submit_transaction_hashes_with_utxo_cache, + sync::{WalletSendReadiness, wait_wallet_send_ready}, + }, world::{CucumberWorld, WalletInfo}, }, non_zero, @@ -89,6 +92,79 @@ async fn step_do_coin_split( Ok(()) } +/// Tops up a node's funding wallet from a user wallet with `note_count` +/// notes of `note_value` LGO each, in a single transaction. +/// +/// Funding a transaction reserves one wallet note until the transaction is +/// mined, so a funding wallet supports at most `note count` concurrent +/// in-flight funded transactions — and its single `10_000` genesis note cannot +/// pay the fees of scenarios that publish many messages at non-zero gas +/// prices. This mirrors the workflow of a real node operator provisioning +/// their sequencer's funding wallet. +#[when( + expr = "wallet {string} sends {int} notes of {int} LGO to node {string} funding wallet as {string}" +)] +async fn step_topup_node_funding_wallet( + world: &mut CucumberWorld, + step: &Step, + wallet_name: String, + note_count: usize, + note_value: u64, + node_name: String, + transaction_alias: String, +) -> StepResult { + let funding_wallet = world + .resolve_wallet(&format!("{node_name}_WALLET")) + .inspect_err(|e| { + warn!(target: TARGET, "Step `{}` error: {e}", step.value); + })?; + let funding_pk = funding_wallet.public_key().inspect_err(|e| { + warn!(target: TARGET, "Step `{}` error: {e}", step.value); + })?; + + let mut available_utxos = WalletUtxos::new(); + let best_node_info = wait_wallet_send_ready( + world, + &step.value, + &wallet_name, + 180, + note_count as u64 * note_value, + WalletSendReadiness::TotalValueOnly, + &mut available_utxos, + &HashSet::new(), + ) + .await?; + + let receivers = vec![(funding_pk, note_value); note_count]; + let tx_hashes = create_and_submit_transaction_hashes_with_utxo_cache( + world, + &step.value, + &wallet_name, + &receivers, + Some(&best_node_info), + Some(&mut available_utxos), + ) + .await + .inspect_err(|e| { + warn!(target: TARGET, "Step `{}` error: {e}", step.value); + })?; + let tx_hash = tx_hashes + .first() + .copied() + .ok_or_else(|| StepError::LogicalError { + message: "funding wallet top-up produced no transaction".to_owned(), + })?; + world.remember_submitted_transaction(transaction_alias.clone(), tx_hash); + + info!( + target: TARGET, + "Submitted funding wallet top-up `{transaction_alias}`: {note_count} notes of \ + {note_value} LGO from `{wallet_name}` to `{node_name}_WALLET`", + ); + + Ok(()) +} + #[when(expr = "wallet {string} has {int} or more outputs in {int} seconds")] #[then(expr = "wallet {string} has {int} or more outputs in {int} seconds")] async fn step_wallet_has_at_least_coins( diff --git a/tests/src/cucumber/steps/manual_zone/actions.rs b/tests/src/cucumber/steps/manual_zone/actions.rs index d73c45e6d..dd45d283e 100644 --- a/tests/src/cucumber/steps/manual_zone/actions.rs +++ b/tests/src/cucumber/steps/manual_zone/actions.rs @@ -5,12 +5,15 @@ use futures::future::join_all; use lb_common_http_client::CommonHttpClient; use lb_core::mantle::{ TxHash, Utxo, + gas::GasCost, ops::channel::{config::Keys, deposit::Metadata, inscribe::Inscription}, }; use lb_key_management_system_service::keys::Ed25519Key; use lb_testing_framework::NodeHttpClient; use lb_zone_sdk::{ - adapter::NodeHttpClient as ZoneNodeHttpClient, indexer::ZoneIndexer, sequencer::ZoneSequencer, + adapter::NodeHttpClient as ZoneNodeHttpClient, + indexer::ZoneIndexer, + sequencer::{FundingConfig, ZoneSequencer}, }; use tokio::{ sync::broadcast, @@ -763,6 +766,20 @@ async fn start_named_sequencer_with_config( world.zone_node_http_client_for_sequencer(&sequencer_alias), )?; let node_url = log_step_error(step, world.zone_node_url_for_sequencer(&sequencer_alias))?; + // Fund sequencer transactions from the node's own funding wallet. Falls + // back to fee-less transactions when the node has no registered funding + // wallet (only viable on zero-gas-price clusters). + let funding = world + .zone + .sequencer_node_name(&sequencer_alias) + .and_then(|node_name| world.resolve_wallet(&format!("{node_name}_WALLET"))) + .and_then(|wallet| wallet.public_key()) + .map(|funding_pk| FundingConfig { + funding_pk, + max_tx_fee: GasCost::new(u64::MAX), + }) + .ok(); + let config = lb_zone_sdk::sequencer::SequencerConfig { funding, ..config }; let sequencer = ZoneSequencer::init_with_config( world.zone.sequencer_channel_id(&sequencer_alias)?, signing_key, diff --git a/tests/src/cucumber/steps/manual_zone/steps.rs b/tests/src/cucumber/steps/manual_zone/steps.rs index 4a4a149aa..30f44f9da 100644 --- a/tests/src/cucumber/steps/manual_zone/steps.rs +++ b/tests/src/cucumber/steps/manual_zone/steps.rs @@ -260,6 +260,38 @@ async fn step_publish_zone_messages_for_sequencer( publish_zone_messages(world, step, sequencer_alias, zone_message_rows(step)?).await } +/// Publishing while the sequencer's node is down must be rejected: with +/// funding configured, building a transaction needs the node's wallet, so +/// the sequencer fails fast with `Unavailable` once the stream drop is +/// noticed (or surfaces the funding error in the brief window before). A +/// fresh `Ready` event fires once the node is back and a live block +/// confirms the reconnect. +#[cucumber::then( + expr = "publishing zone message with data {string} via sequencer {string} fails while the node is down" +)] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step functions require the world as the first `&mut` argument" +)] +async fn step_publish_fails_while_node_down( + world: &mut CucumberWorld, + step: &Step, + data: String, + sequencer_alias: String, +) -> StepResult { + let _ = step; + let payload = make_inscription(&data); + let client = world.zone.sequencer_client(&sequencer_alias)?.clone(); + match client.publish(payload).await { + Ok(_) => Err(StepError::LogicalError { + message: format!( + "Zone publish unexpectedly succeeded for sequencer '{sequencer_alias}' while its node is down" + ), + }), + Err(_expected) => Ok(()), + } +} + #[when( expr = "I submit zone message {string} to sequencer {string} with data {string} immediately" )] diff --git a/tests/src/cucumber/steps/manual_zone/support.rs b/tests/src/cucumber/steps/manual_zone/support.rs index aa0f3f98f..48e86122b 100644 --- a/tests/src/cucumber/steps/manual_zone/support.rs +++ b/tests/src/cucumber/steps/manual_zone/support.rs @@ -298,7 +298,7 @@ where if adopted.contains(&info.payload) || self.finalized.contains(&info.payload) { continue; } - if let Err(error) = sequencer.handle().publish(info.payload.clone()) { + if let Err(error) = sequencer.handle().publish(info.payload.clone()).await { warn!(%error, "Failed to re-publish orphaned zone payload"); } } @@ -407,7 +407,7 @@ where Event::Ready if !self.published_initial => { self.published_initial = true; for payload in self.planned.clone() { - match sequencer.handle().publish(payload) { + match sequencer.handle().publish(payload).await { Ok((result, _checkpoint)) => { self.lineage .record_publish(result.tx.inscription().this_msg); @@ -433,7 +433,7 @@ where { continue; } - match sequencer.handle().publish(info.payload.clone()) { + match sequencer.handle().publish(info.payload.clone()).await { Ok((result, _checkpoint)) => { self.lineage .record_republish(info.this_msg, result.tx.inscription().this_msg); @@ -488,7 +488,7 @@ where if !self.balances.should_republish(&info.payload) { continue; } - if let Err(error) = sequencer.handle().publish(info.payload.clone()) { + if let Err(error) = sequencer.handle().publish(info.payload.clone()).await { warn!(%error, "Failed to re-publish balance-aware zone payload"); continue; } @@ -503,7 +503,7 @@ where if !self.balances.should_republish(&payload) { continue; } - if let Err(error) = sequencer.handle().publish(payload.clone()) { + if let Err(error) = sequencer.handle().publish(payload.clone()).await { warn!(%error, "Failed to publish planned balance-aware zone payload"); self.planned.push_front(payload); break; @@ -570,7 +570,7 @@ where continue; } if self.state.preserves_order(&payload) { - if let Err(error) = sequencer.handle().publish(payload.clone()) { + if let Err(error) = sequencer.handle().publish(payload.clone()).await { warn!(%error, "Failed to re-publish sorted zone payload"); continue; } @@ -782,10 +782,11 @@ pub fn sequencer_config_with_pending_submit_depth( } } -/// Publishes a zone payload synchronously through the runner and returns the -/// SDK's [`PublishResult`] inline. Retries transient publish errors until -/// the deadline elapses. No "wait for event" — the new SDK publishes -/// synchronously and the runner forwards the call through the drive task. +/// Publishes a zone payload through the runner and returns the SDK's +/// [`PublishResult`] inline. Retries transient publish errors until the +/// deadline elapses. No "wait for event" — the SDK accepts the publish +/// inline (funding it via the node when configured) and the runner forwards +/// the call through the drive task. pub async fn publish_message_with_retry( client: &SequencerClient, data: &Inscription, @@ -1428,6 +1429,10 @@ pub fn build_zone_deposit( }) } +/// Generous cap on channel transaction fees at genesis gas prices; actual +/// fees are a few hundred gas units for these small transactions. +const MAX_ZONE_DEPOSIT_TX_FEE: u64 = 10_000; + /// Submits a regular channel deposit through the node wallet API. pub async fn submit_zone_deposit( node_url: &Url, @@ -1439,7 +1444,7 @@ pub async fn submit_zone_deposit( deposit: deposit.clone(), change_public_key: funding_public_key, funding_public_keys: vec![funding_public_key], - max_tx_fee: 10.into(), + max_tx_fee: MAX_ZONE_DEPOSIT_TX_FEE.into(), }; let request_url = @@ -1517,17 +1522,25 @@ pub async fn submit_atomic_zone_deposit( /// Builds the funding transfer that creates the note consumed by an atomic /// zone deposit. +/// Generous fee margin for the atomic `[Transfer, Deposit, Inscribe]` +/// transaction; the actual cost is a few hundred gas units at genesis prices. +const ATOMIC_DEPOSIT_FEE_MARGIN: u64 = 2_000; + fn build_atomic_deposit_transfer( available_utxos: Vec, funding_public_key: ZkPublicKey, amount: Value, ) -> Result<(TransferOp, Vec), ZoneTestError> { let deposit_note = Note::new(amount, funding_public_key); - let funded_transfer = - build_wallet_funded_transfer(available_utxos, vec![deposit_note], funding_public_key) - .map_err(|error| ZoneTestError::BuildAtomicDeposit { - message: error.to_string(), - })?; + let funded_transfer = build_wallet_funded_transfer( + available_utxos, + vec![deposit_note], + funding_public_key, + ATOMIC_DEPOSIT_FEE_MARGIN, + ) + .map_err(|error| ZoneTestError::BuildAtomicDeposit { + message: error.to_string(), + })?; Ok(funded_transfer.into_parts()) } diff --git a/zone-sdk/src/adapter.rs b/zone-sdk/src/adapter.rs index 6c40053f5..79b648557 100644 --- a/zone-sdk/src/adapter.rs +++ b/zone-sdk/src/adapter.rs @@ -16,7 +16,10 @@ use lb_core::{ ops::{OpId as _, channel::ChannelId}, }, }; -use lb_http_api_common::queries::BlocksStreamQuery; +use lb_http_api_common::{ + bodies::wallet::fund::{WalletFundRequestBody, WalletFundResponseBody}, + queries::BlocksStreamQuery, +}; use lb_log_targets::zone_sdk; use reqwest::Url; use tracing::warn; @@ -69,6 +72,16 @@ pub trait Node { ) -> Result, Error>; async fn post_transaction(&self, tx: SignedMantleTx) -> Result<(), Error>; + + /// Fund a transaction from the node's wallet. + /// + /// The node adds fee inputs and change from its own wallet, signs only + /// the appended fee transfer, and returns the funded — still unsigned — + /// transaction together with the transfer proof. + async fn fund_tx( + &self, + request: WalletFundRequestBody, + ) -> Result; } #[derive(Clone)] @@ -215,6 +228,13 @@ impl Node for NodeHttpClient { .post_transaction(self.base_url.clone(), tx) .await } + + async fn fund_tx( + &self, + request: WalletFundRequestBody, + ) -> Result { + self.client.fund_tx(self.base_url.clone(), request).await + } } /// Returns true if `transactions` contains any deposit op on `channel_id`. diff --git a/zone-sdk/src/indexer.rs b/zone-sdk/src/indexer.rs index cc3310b30..cc18a3efa 100644 --- a/zone-sdk/src/indexer.rs +++ b/zone-sdk/src/indexer.rs @@ -135,7 +135,10 @@ mod tests { }, }; use lb_groth16::Fr; - use lb_http_api_common::queries::BlocksStreamQuery; + use lb_http_api_common::{ + bodies::wallet::fund::{WalletFundRequestBody, WalletFundResponseBody}, + queries::BlocksStreamQuery, + }; use super::*; use crate::{Deposit, ZoneBlock, adapter::BoxStream}; @@ -430,5 +433,12 @@ mod tests { ) -> Result<(), lb_common_http_client::Error> { unimplemented!() } + + async fn fund_tx( + &self, + _request: WalletFundRequestBody, + ) -> Result { + unimplemented!() + } } } diff --git a/zone-sdk/src/sequencer/actor.rs b/zone-sdk/src/sequencer/actor.rs index fb629e885..5215054e9 100644 --- a/zone-sdk/src/sequencer/actor.rs +++ b/zone-sdk/src/sequencer/actor.rs @@ -91,12 +91,14 @@ where /// Convert a successfully-ingested block into the public event. Handles /// the readiness-transition special case: when this is the block that - /// flips the sequencer to ready, emit `Ready` first and buffer the - /// `BlocksProcessed` for the next drive turn. + /// flips the sequencer to ready — or the one that completes a mid-life + /// reconnect — emit `Ready` first and buffer the `BlocksProcessed` for + /// the next drive turn. fn finish_block_processing(&mut self, result: BlockEventResult) -> Option { // We just processed a live block end-to-end — cached `channel_state`, // `current_tip`, and `lib_slot` reflect chain state up to this block, // so callers may rely on them. + let reconnected = !self.connected; self.connected = true; let became_ready = self.maybe_signal_ready(); let (channel_update, finalized, mined) = self.apply_block_result(result); @@ -121,7 +123,11 @@ where finalized, }); - if became_ready { + // Re-announce readiness after a mid-life reconnect: with funding + // configured, publishes fail fast with `Unavailable` while + // disconnected, so consumers need a positive "you can publish again" + // signal once a live block confirms the connection. + if became_ready || (reconnected && self.is_ready()) { if let Some(ev) = block_event { self.buffered_events.push_back(ev); } @@ -611,7 +617,10 @@ mod tests { }, proofs::leader_proof::Groth16LeaderProof, }; - use lb_http_api_common::queries::BlocksStreamQuery; + use lb_http_api_common::{ + bodies::wallet::fund::{WalletFundRequestBody, WalletFundResponseBody}, + queries::BlocksStreamQuery, + }; use lb_key_management_system_service::keys::{Ed25519Key, Ed25519Signature, ZkKey}; use num_bigint::BigUint; use rand::{RngCore as _, thread_rng}; @@ -886,6 +895,13 @@ mod tests { ) -> Result<(), lb_common_http_client::Error> { self.inner.post_transaction(tx).await } + + async fn fund_tx( + &self, + request: WalletFundRequestBody, + ) -> Result { + self.inner.fund_tx(request).await + } } /// Comment #1 regression guard for client publishes during reconnect. @@ -1183,6 +1199,21 @@ mod tests { self.posted_transactions_sender.send(tx).await.unwrap(); Ok(()) } + + async fn fund_tx( + &self, + request: WalletFundRequestBody, + ) -> Result { + // Fee-less passthrough: build the request's ops unchanged, as the + // node would at zero gas price. + Ok(WalletFundResponseBody { + tip: HeaderId::from([0; 32]), + funded_tx: request.tx_builder.build().map_err(|e| { + lb_common_http_client::Error::Server(format!("mock funding failed: {e:?}")) + })?, + transfer_proof: None, + }) + } } /// Mock node that serves a single genesis-slot block with a channel @@ -1300,6 +1331,21 @@ mod tests { Ok(()) } + async fn fund_tx( + &self, + request: WalletFundRequestBody, + ) -> Result { + // Fee-less passthrough: build the request's ops unchanged, as the + // node would at zero gas price. + Ok(WalletFundResponseBody { + tip: HeaderId::from([0; 32]), + funded_tx: request.tx_builder.build().map_err(|e| { + lb_common_http_client::Error::Server(format!("mock funding failed: {e:?}")) + })?, + transfer_proof: None, + }) + } + async fn channel_state( &self, _channel_id: ChannelId, diff --git a/zone-sdk/src/sequencer/handle.rs b/zone-sdk/src/sequencer/handle.rs index 34937484c..0382d71b7 100644 --- a/zone-sdk/src/sequencer/handle.rs +++ b/zone-sdk/src/sequencer/handle.rs @@ -51,27 +51,33 @@ where { /// Enqueue an inscription onto the zone's channel. /// - /// Synchronously mutates state to record the inscription as pending and - /// queues a `post_transaction` future onto the drive loop's in-flight - /// pool — the post itself happens asynchronously the next time the drive - /// loop polls `next_event`. The returned [`PublishResult`] reflects this - /// queued state, not a network acknowledgement; the tx may not have - /// reached the node yet. The accompanying [`SequencerCheckpoint`] - /// captures the new pending state so the caller can persist outbox + - /// checkpoint atomically. + /// With funding configured ([`SequencerConfig::funding`]), first funds + /// the transaction from the node's wallet (one HTTP round-trip) and + /// signs the funded hash; a funding failure returns an error without + /// mutating state. Then records the inscription as pending and queues a + /// `post_transaction` future onto the drive loop's in-flight pool — the + /// post itself happens asynchronously the next time the drive loop polls + /// `next_event`. The returned [`PublishResult`] reflects this queued + /// state, not a network acknowledgement; the tx may not have reached the + /// node yet. The accompanying [`SequencerCheckpoint`] captures the new + /// pending state so the caller can persist outbox + checkpoint + /// atomically. /// - /// Returns [`Error::Unavailable`] only if cold-start backfill is still - /// in progress (the sequencer hasn't emitted [`super::Event::Ready`] - /// yet). After the first `Ready`, publishes are always accepted: - /// during a mid-life reconnect the tx is queued locally and posted - /// when the stream resumes (or when our turn comes back). To wait for - /// readiness asynchronously, subscribe via + /// Returns [`Error::Unavailable`] if cold-start backfill is still in + /// progress (the sequencer hasn't emitted [`super::Event::Ready`] yet) + /// — or, with funding configured, while the node is disconnected + /// (funding needs the node; a fresh `Ready` event is emitted when the + /// reconnect completes, signalling it is safe to retry). Fee-less + /// sequencers keep the old contract: after the first `Ready`, publishes + /// are always accepted — during a mid-life reconnect the tx is queued + /// locally and posted when the stream resumes (or when our turn comes + /// back). To wait for readiness asynchronously, subscribe via /// [`ZoneSequencer::subscribe_ready`]. - pub fn publish( + pub async fn publish( &mut self, data: Inscription, ) -> Result<(PublishResult, SequencerCheckpoint), Error> { - self.sequencer.do_publish(data) + self.sequencer.do_publish(data).await } /// Build a [`MantleTx`] for the given ops and an inscription message, @@ -120,12 +126,14 @@ where /// sequencer rotation (see Mantle spec). Pass `0` for both to keep a /// single fixed sequencer at index 0. /// + /// With funding configured ([`SequencerConfig::funding`]), first funds + /// the transaction from the node's wallet and signs the funded hash. /// Enqueues the config tx onto the drive loop's in-flight pool — the /// post runs the next time the drive loop polls `next_event`. The /// returned [`PublishResult`] reflects the queued state, not a network /// acknowledgement. The signed tx is also returned for callers that want /// to observe finalization via the event stream. - pub fn channel_config( + pub async fn channel_config( &mut self, keys: Keys, posting_timeframe: SlotTimeframe, @@ -133,13 +141,15 @@ where configuration_threshold: u16, withdraw_threshold: u16, ) -> Result<(PublishResult, SequencerCheckpoint, SignedMantleTx), Error> { - self.sequencer.do_channel_config( - keys, - posting_timeframe, - posting_timeout, - configuration_threshold, - withdraw_threshold, - ) + self.sequencer + .do_channel_config( + keys, + posting_timeframe, + posting_timeout, + configuration_threshold, + withdraw_threshold, + ) + .await } /// Publish an atomic inscription+withdraw bundle. @@ -147,9 +157,11 @@ where /// Reads the current on-chain `withdraw_nonce` and this sequencer's /// accredited-key index from cached channel state (kept fresh by the /// drive loop). Selects the inscription's `parent_msg` from the current - /// canonical tip, builds the bundled `MantleTx`, signs locally with the - /// sequencer's key, and submits. Scoped to single-sequencer (centralized) - /// channels — only the sequencer's own signature is used. + /// canonical tip, builds the bundled `MantleTx` (funding it from the + /// node's wallet when [`SequencerConfig::funding`] is set), signs the + /// funded hash locally with the sequencer's key, and submits. Scoped to + /// single-sequencer (centralized) channels — only the sequencer's own + /// signature is used. /// /// Returns [`Error::Unavailable`] only if cold-start backfill is still /// in progress (see [`Self::publish`] for the latched readiness @@ -158,12 +170,13 @@ where /// stream resumes and our turn is current. Returns [`Error::Network`] if /// the channel's `withdraw_threshold > 1` (which would require multi-sig /// orchestration this API doesn't support). - pub fn publish_atomic_withdraw( + pub async fn publish_atomic_withdraw( &mut self, inscribe: Inscription, withdraws: Vec, ) -> Result<(PublishResult, SequencerCheckpoint), Error> { self.sequencer .do_publish_atomic_withdraw(inscribe, withdraws) + .await } } diff --git a/zone-sdk/src/sequencer/mod.rs b/zone-sdk/src/sequencer/mod.rs index 0026d74d4..030155c1b 100644 --- a/zone-sdk/src/sequencer/mod.rs +++ b/zone-sdk/src/sequencer/mod.rs @@ -79,8 +79,8 @@ pub use client::SequencerClient; pub use handle::SequencerHandle; pub use types::{ AtomicWithdrawInfo, ChannelUpdate, DepositInfo, Error, Event, FinalizedOp, FinalizedTx, - InscriptionId, InscriptionInfo, OrphanedTx, PendingTx, PublishResult, SequencerChannelView, - SequencerCheckpoint, SequencerConfig, TurnNotification, TxSource, TxStatus, TxStatusUpdate, - WithdrawArg, WithdrawInfo, + FundingConfig, InscriptionId, InscriptionInfo, OrphanedTx, PendingTx, PublishResult, + SequencerChannelView, SequencerCheckpoint, SequencerConfig, TurnNotification, TxSource, + TxStatus, TxStatusUpdate, WithdrawArg, WithdrawInfo, }; pub use zone_sequencer::ZoneSequencer; diff --git a/zone-sdk/src/sequencer/tx_builder.rs b/zone-sdk/src/sequencer/tx_builder.rs index 4d603a9cd..549e8a81a 100644 --- a/zone-sdk/src/sequencer/tx_builder.rs +++ b/zone-sdk/src/sequencer/tx_builder.rs @@ -10,22 +10,96 @@ use lb_core::{ inscribe::{Inscription, InscriptionOp}, }, }, - transactions::{Ops, TxHash}, + transactions::{MantleTxBuilder, Ops, TxHash}, }, proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature}, }; +use lb_http_api_common::bodies::wallet::fund::WalletFundRequestBody; use lb_key_management_system_service::keys::{Ed25519Key, Ed25519Signature}; -use super::types::Error; +use super::types::{Error, FundingConfig}; +use crate::adapter; + +/// Assemble the ops for a transaction, funding it from the node's wallet when +/// a [`FundingConfig`] is present. +/// +/// With funding, the node appends a fee transfer (paid from +/// `funding.funding_pk`, change back to it) and returns the proof for that +/// transfer; all other ops must be proven by the caller over the funded +/// transaction hash. Without funding the ops become a fee-less transaction +/// (only valid while gas prices are zero). +pub(super) async fn fund_ops( + node: &Node, + funding: Option<&FundingConfig>, + ops: Vec, +) -> Result<(MantleTx, Option), Error> +where + Node: adapter::Node + Sync, +{ + let Some(funding) = funding else { + let ops = Ops::try_from(ops) + .map_err(|e| Error::Network(format!("too many ops in transaction: {e:?}")))?; + return Ok((MantleTx(ops), None)); + }; + + let tx_builder = MantleTxBuilder::new() + .extend_ops(ops) + .map_err(|e| Error::Network(format!("too many ops in transaction: {e:?}")))?; + let response = node + .fund_tx(WalletFundRequestBody { + // Fund against the node's latest tip. + tip: None, + tx_builder, + change_public_key: funding.funding_pk, + funding_public_keys: vec![funding.funding_pk], + max_tx_fee: funding.max_tx_fee, + }) + .await + .map_err(|e| Error::Network(format!("funding failed: {e}")))?; + + Ok((response.funded_tx, response.transfer_proof)) +} + +/// Append the fee transfer's proof to the channel-op proofs, matching the +/// funded transaction's op layout (funding appends the transfer as the last +/// op; a fee-less transaction carries none). +pub(super) fn attach_transfer_proof( + tx: &MantleTx, + mut channel_proofs: Vec, + transfer_proof: Option, +) -> Result, Error> { + let transfer_count = tx + .ops() + .iter() + .filter(|op| matches!(op, Op::Transfer(_))) + .count(); + match (transfer_count, transfer_proof) { + (0, _) => {} + (1, Some(proof)) => channel_proofs.push(proof), + (1, None) => { + return Err(Error::Network( + "funded transaction carries a fee transfer but no transfer proof".into(), + )); + } + (n, _) => { + return Err(Error::Network(format!( + "unexpected transfer op count in funded transaction: {n}" + ))); + } + } + Ok(channel_proofs) +} /// Build per-op proofs for an atomic withdraw bundle. The same single-signer /// `ChannelMultiSigProof` is reused for every `ChannelWithdraw` op (all sign -/// the same tx hash with the same key) and the inscription op carries an -/// `Ed25519Sig` proof. +/// the same tx hash with the same key), the inscription op carries an +/// `Ed25519Sig` proof and the fee transfer — when the transaction was funded +/// — carries the wallet's proof. pub(super) fn build_atomic_withdraw_ops_proofs( tx: &MantleTx, own_key_index: ChannelKeyIndex, own_sig: Ed25519Signature, + transfer_proof: Option<&OpProof>, ) -> Result, Error> { let withdraw_proof = ChannelMultiSigProof::try_new([IndexedSignature::new(own_key_index, own_sig)].into()) @@ -37,6 +111,14 @@ pub(super) fn build_atomic_withdraw_ops_proofs( ops_proofs.push(OpProof::ChannelMultiSigProof(withdraw_proof.clone())); } Op::ChannelInscribe(_) => ops_proofs.push(OpProof::Ed25519Sig(own_sig)), + Op::Transfer(_) => match transfer_proof { + Some(proof) => ops_proofs.push(proof.clone()), + None => { + return Err(Error::Network( + "funded transaction carries a fee transfer but no transfer proof".into(), + )); + } + }, _ => { return Err(Error::Network(format!( "unexpected op in atomic withdraw bundle: {op:?}" @@ -63,12 +145,17 @@ pub(super) fn find_own_key_index( .ok_or_else(|| Error::Network("sequencer key not in channel accredited_keys".into())) } -pub(super) fn create_inscribe_tx( +pub(super) async fn create_inscribe_tx( + node: &Node, + funding: Option<&FundingConfig>, channel_id: ChannelId, signing_key: &Ed25519Key, inscription: Inscription, parent: MsgId, -) -> (SignedMantleTx, MsgId) { +) -> Result<(SignedMantleTx, MsgId), Error> +where + Node: adapter::Node + Sync, +{ let signer = signing_key.public_key(); let inscribe_op = InscriptionOp { @@ -79,21 +166,32 @@ pub(super) fn create_inscribe_tx( }; let msg_id = inscribe_op.id(); - // TODO: set realistic gas prices and fund tx - let inscribe_tx = MantleTx([Op::ChannelInscribe(inscribe_op)].into()); + let (inscribe_tx, transfer_proof) = + fund_ops(node, funding, vec![Op::ChannelInscribe(inscribe_op)]).await?; let tx_hash = inscribe_tx.hash(); let signature = sign_tx(tx_hash, signing_key); + let ops_proofs = attach_transfer_proof( + &inscribe_tx, + vec![OpProof::Ed25519Sig(signature)], + transfer_proof, + )?; let signed_tx = SignedMantleTx { - ops_proofs: vec![OpProof::Ed25519Sig(signature)], + ops_proofs, mantle_tx: inscribe_tx, }; - (signed_tx, msg_id) + Ok((signed_tx, msg_id)) } -pub(super) fn create_channel_config_tx( +#[expect( + clippy::too_many_arguments, + reason = "mirrors the channel config op fields plus the funding context" +)] +pub(super) async fn create_channel_config_tx( + node: &Node, + funding: Option<&FundingConfig>, channel_id: ChannelId, signing_keys: &[&Ed25519Key], keys: Keys, @@ -101,7 +199,10 @@ pub(super) fn create_channel_config_tx( posting_timeout: SlotTimeout, configuration_threshold: u16, withdraw_threshold: u16, -) -> SignedMantleTx { +) -> Result +where + Node: adapter::Node + Sync, +{ let config_op = ChannelConfigOp { channel: channel_id, keys, @@ -111,8 +212,8 @@ pub(super) fn create_channel_config_tx( withdraw_threshold, }; - // TODO: fund tx - let config_tx = MantleTx([Op::ChannelConfig(config_op)].into()); + let (config_tx, transfer_proof) = + fund_ops(node, funding, vec![Op::ChannelConfig(config_op)]).await?; let tx_hash = config_tx.hash(); let signatures = signing_keys @@ -128,11 +229,16 @@ pub(super) fn create_channel_config_tx( .try_into() .unwrap(); let proof = ChannelMultiSigProof::try_new(signatures).unwrap(); + let ops_proofs = attach_transfer_proof( + &config_tx, + vec![OpProof::ChannelMultiSigProof(proof)], + transfer_proof, + )?; - SignedMantleTx { - ops_proofs: vec![OpProof::ChannelMultiSigProof(proof)], + Ok(SignedMantleTx { + ops_proofs, mantle_tx: config_tx, - } + }) } pub(super) fn prepare_tx( diff --git a/zone-sdk/src/sequencer/types.rs b/zone-sdk/src/sequencer/types.rs index a1a45e4e1..ff492b61f 100644 --- a/zone-sdk/src/sequencer/types.rs +++ b/zone-sdk/src/sequencer/types.rs @@ -7,6 +7,7 @@ use lb_core::{ mantle::{ SignedMantleTx, Value, channel::ChannelState, + gas::GasCost, ledger::{Inputs, Outputs}, ops::channel::{ ChannelId, MsgId, deposit::Metadata, inscribe::Inscription, withdraw::ChannelWithdrawOp, @@ -14,6 +15,7 @@ use lb_core::{ transactions::TxHash, }, }; +use lb_key_management_system_service::keys::ZkPublicKey; const DEFAULT_RESUBMIT_INTERVAL: Duration = Duration::from_secs(30); const DEFAULT_RECONNECT_DELAY: Duration = Duration::from_secs(5); @@ -94,6 +96,19 @@ impl OrphanedTx { } } +/// Configuration for funding transactions from the node's wallet. +/// +/// Mirrors the node's SDP wallet config: both values come from the operator's +/// own node configuration (the node sponsors the gas; change returns to +/// `funding_pk`). +#[derive(Clone, Debug)] +pub struct FundingConfig { + /// The node wallet key that pays transaction fees. + pub funding_pk: ZkPublicKey, + /// Hard cap on the fee of a single transaction. + pub max_tx_fee: GasCost, +} + /// Configuration for the zone sequencer. #[derive(Clone)] pub struct SequencerConfig { @@ -103,6 +118,9 @@ pub struct SequencerConfig { pub min_slots_remaining_in_turn: u64, pub max_pending_publish_depth: usize, pub max_local_tx_tracking: usize, + /// Fund transactions from the node's wallet before signing. `None` + /// builds fee-less transactions (only valid while gas prices are zero). + pub funding: Option, } impl Default for SequencerConfig { @@ -114,6 +132,7 @@ impl Default for SequencerConfig { min_slots_remaining_in_turn: 1, max_pending_publish_depth: 10, max_local_tx_tracking: DEFAULT_MAX_LOCAL_TX_TRACKING, + funding: None, } } } @@ -188,12 +207,16 @@ pub enum Event { channel_update: ChannelUpdate, finalized: Vec, }, - /// Cold-start backfill is complete and the sequencer has a baseline - /// channel view — publishes are now meaningful. Emitted exactly once - /// per sequencer lifetime. Stream drops and reconnects after this - /// point are invisible on the event stream: in-memory state stays - /// valid, publishes keep flowing, and any tx invalidated by the - /// catch-up surfaces via [`ChannelUpdate::orphaned`] on the next + /// The sequencer is connected and ready to publish. Emitted once when + /// cold-start backfill completes, and again after every mid-life + /// reconnect once a live block confirms the connection. + /// + /// With funding configured ([`SequencerConfig::funding`]), publishes + /// fail fast with [`Error::Unavailable`] while disconnected (funding + /// needs the node), so the re-emission is the signal to retry. Fee-less + /// sequencers accept publishes locally throughout a disconnect; + /// in-memory state stays valid either way, and any tx invalidated by + /// the catch-up surfaces via [`ChannelUpdate::orphaned`] on the next /// `BlocksProcessed` once the stream resumes. Ready, /// Transaction was accepted by the node post API and is expected to be in diff --git a/zone-sdk/src/sequencer/zone_sequencer.rs b/zone-sdk/src/sequencer/zone_sequencer.rs index b59b2bbee..5bc86ea19 100644 --- a/zone-sdk/src/sequencer/zone_sequencer.rs +++ b/zone-sdk/src/sequencer/zone_sequencer.rs @@ -34,7 +34,7 @@ use super::{ state::TxState, tx_builder::{ build_atomic_withdraw_ops_proofs, create_channel_config_tx, create_inscribe_tx, - find_own_key_index, prepare_tx as build_prepare_tx, sign_tx as build_sign_tx, + find_own_key_index, fund_ops, prepare_tx as build_prepare_tx, sign_tx as build_sign_tx, }, types::{ AtomicWithdrawInfo, Error, Event, InscriptionInfo, PendingTx, PublishResult, @@ -78,7 +78,9 @@ pub struct ZoneSequencer { // operations that depend on cached on-chain state (inscription turn // check, atomic withdraw nonce, channel config) so they fail-fast with // `Error::Unavailable` during reconnect rather than building txs from - // stale state. + // stale state. With funding configured it also gates every publish-type + // operation (funding needs the node); a fresh `Event::Ready` is emitted + // when the reconnect completes. pub(super) connected: bool, // Resubmission @@ -290,9 +292,11 @@ where /// Obtain a borrowing handle for issuing commands to the sequencer. /// /// The handle's `&mut self` borrow means only the drive task can hold - /// one. Methods on the handle mutate state synchronously and return the - /// resulting [`SequencerCheckpoint`] inline, so the caller can persist - /// the publish + checkpoint atomically. + /// one. Methods on the handle mutate state directly on the drive task + /// and return the resulting [`SequencerCheckpoint`] inline, so the + /// caller can persist the publish + checkpoint atomically. Publish-type + /// methods await one funding round-trip first when + /// [`SequencerConfig::funding`] is set. pub const fn handle(&mut self) -> SequencerHandle<'_, Node> { SequencerHandle::new(self) } @@ -468,23 +472,23 @@ where self.buffered_events.pop_front().map(|event| self.emit_now(event)) } Some(request) = self.request_rx.recv() => { - self.handle_request(request); + self.handle_request(request).await; None } } } - fn handle_request(&mut self, request: ActorRequest) { + async fn handle_request(&mut self, request: ActorRequest) { match request { ActorRequest::Publish { data, response_tx } => { - drop(response_tx.send(self.do_publish(data))); + drop(response_tx.send(self.do_publish(data).await)); } ActorRequest::PublishAtomicWithdraw { inscribe, withdraws, response_tx, } => { - drop(response_tx.send(self.do_publish_atomic_withdraw(inscribe, withdraws))); + drop(response_tx.send(self.do_publish_atomic_withdraw(inscribe, withdraws).await)); } ActorRequest::ChannelConfig { keys, @@ -494,13 +498,18 @@ where withdraw_threshold, response_tx, } => { - drop(response_tx.send(self.do_channel_config( - keys, - posting_timeframe, - posting_timeout, - configuration_threshold, - withdraw_threshold, - ))); + drop( + response_tx.send( + self.do_channel_config( + keys, + posting_timeframe, + posting_timeout, + configuration_threshold, + withdraw_threshold, + ) + .await, + ), + ); } ActorRequest::SubmitSignedTx { tx, @@ -544,11 +553,26 @@ where loop { tokio::select! { () = &mut sleep => break, - Some(request) = self.request_rx.recv() => self.handle_request(request), + Some(request) = self.request_rx.recv() => self.handle_request(request).await, } } } + /// With funding configured, building a transaction requires a round-trip + /// to the node's wallet — fail fast with [`Error::Unavailable`] while + /// disconnected instead of surfacing an HTTP error from the fund call. + /// A fresh [`Event::Ready`] is emitted once the reconnect completes, so + /// callers have a positive signal to retry. Fee-less sequencers + /// (`funding: None`) keep the accept-locally-while-disconnected contract. + const fn ensure_fundable(&self) -> Result<(), Error> { + if self.config.funding.is_some() && !self.connected { + return Err(Error::Unavailable { + reason: "node disconnected; funding a transaction requires a connected node", + }); + } + Ok(()) + } + fn ensure_ready(&self) -> Result<(), Error> { if !self.is_ready() { return Err(Error::Unavailable { @@ -566,15 +590,23 @@ where /// Core publish logic. Shared by [`SequencerHandle::publish`] (called /// from the drive task) and the actor's [`ActorRequest::Publish`] handler /// (called from outside the drive task via [`SequencerClient`]). - pub(super) fn do_publish( + pub(super) async fn do_publish( &mut self, data: Inscription, ) -> Result<(PublishResult, SequencerCheckpoint), Error> { self.ensure_ready()?; + self.ensure_fundable()?; let parent = self.compute_publish_parent(); - let (signed_tx, new_msg_id) = - create_inscribe_tx(self.channel_id, &self.signing_key, data.clone(), parent); + let (signed_tx, new_msg_id) = create_inscribe_tx( + &self.node, + self.config.funding.as_ref(), + self.channel_id, + &self.signing_key, + data.clone(), + parent, + ) + .await?; let id = signed_tx.mantle_tx.hash(); debug!(target: TARGET, @@ -616,12 +648,13 @@ where )) } - pub(super) fn do_publish_atomic_withdraw( + pub(super) async fn do_publish_atomic_withdraw( &mut self, inscribe: Inscription, withdraws: Vec, ) -> Result<(PublishResult, SequencerCheckpoint), Error> { self.ensure_ready()?; + self.ensure_fundable()?; if withdraws.is_empty() { return Err(Error::Network( @@ -672,11 +705,10 @@ where let msg_id = inscription_op.id(); ops.push(Op::ChannelInscribe(inscription_op)); - let tx = MantleTx(Ops::try_from(ops).map_err(|e| { - Error::Network(format!("atomic withdraw bundle exceeds op limit: {e:?}")) - })?); + let (tx, transfer_proof) = fund_ops(&self.node, self.config.funding.as_ref(), ops).await?; let own_sig = build_sign_tx(tx.hash(), &self.signing_key); - let ops_proofs = build_atomic_withdraw_ops_proofs(&tx, own_key_index, own_sig)?; + let ops_proofs = + build_atomic_withdraw_ops_proofs(&tx, own_key_index, own_sig, transfer_proof.as_ref())?; let signed_tx = SignedMantleTx::new(tx, ops_proofs) .map_err(|e| Error::Network(format!("signed tx assembly failed: {e:?}")))?; @@ -725,7 +757,7 @@ where )) } - pub(super) fn do_channel_config( + pub(super) async fn do_channel_config( &mut self, keys: Keys, posting_timeframe: SlotTimeframe, @@ -734,16 +766,33 @@ where withdraw_threshold: u16, ) -> Result<(PublishResult, SequencerCheckpoint, SignedMantleTx), Error> { self.ensure_ready()?; + self.ensure_fundable()?; + + // Per the Mantle spec, configuring an unclaimed channel requires no + // signatures — validation skips the signature check entirely — so + // claim with an empty proof. This also keeps the node wallet's fee + // prediction exact: it predicts a threshold-0 multi-sig proof for a + // channel it cannot see yet, and a superfluous signature would make + // the funded fee undershoot the actual storage cost. + let own_key = [&self.signing_key]; + let signing_keys: &[&Ed25519Key] = if self.channel_state.is_some() { + &own_key + } else { + &[] + }; let signed_tx = create_channel_config_tx( + &self.node, + self.config.funding.as_ref(), self.channel_id, - &[&self.signing_key], + signing_keys, keys, posting_timeframe, posting_timeout, configuration_threshold, withdraw_threshold, - ); + ) + .await?; let tx_hash = signed_tx.mantle_tx.hash(); // Safe to unwrap — `ensure_ready` checks state.