feat(zone-sdk): enable non zero gas price (tke) (#3109)

This commit is contained in:
Petar Radovic
2026-07-09 11:00:22 +02:00
committed by GitHub
parent 53b41b8ba3
commit 6d9d8a748d
18 changed files with 637 additions and 138 deletions
+12
View File
@@ -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<String>,
/// 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)]
@@ -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 {
@@ -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<NodeHttpClient>,
@@ -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<NodeHttpClient>,
@@ -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<NodeHttpClient>,
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<NodeHttpClient>,
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}"),
}
+29 -2
View File
@@ -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<Option<FundingConfig>> {
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<SequencerConfig> {
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<ZoneSequencer<NodeHttpClient>> {
@@ -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);
}
+51 -10
View File
@@ -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:
+6 -2
View File
@@ -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<Utxo>,
outputs: Vec<Note>,
change_pk: ZkPublicKey,
fee: u64,
) -> Result<WalletFundedTransfer, WalletError> {
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 {
@@ -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(
@@ -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,
@@ -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"
)]
+29 -16
View File
@@ -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<Utxo>,
funding_public_key: ZkPublicKey,
amount: Value,
) -> Result<(TransferOp, Vec<Utxo>), 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())
}
+21 -1
View File
@@ -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<BoxStream<(ZoneMessage, Slot)>, 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<WalletFundResponseBody, Error>;
}
#[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<WalletFundResponseBody, Error> {
self.client.fund_tx(self.base_url.clone(), request).await
}
}
/// Returns true if `transactions` contains any deposit op on `channel_id`.
+11 -1
View File
@@ -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<WalletFundResponseBody, lb_common_http_client::Error> {
unimplemented!()
}
}
}
+50 -4
View File
@@ -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<Event> {
// 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<WalletFundResponseBody, lb_common_http_client::Error> {
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<WalletFundResponseBody, lb_common_http_client::Error> {
// 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<WalletFundResponseBody, lb_common_http_client::Error> {
// 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,
+41 -28
View File
@@ -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<WithdrawArg>,
) -> Result<(PublishResult, SequencerCheckpoint), Error> {
self.sequencer
.do_publish_atomic_withdraw(inscribe, withdraws)
.await
}
}
+3 -3
View File
@@ -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;
+123 -17
View File
@@ -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: &Node,
funding: Option<&FundingConfig>,
ops: Vec<Op>,
) -> Result<(MantleTx, Option<OpProof>), 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<OpProof>,
transfer_proof: Option<OpProof>,
) -> Result<Vec<OpProof>, 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<Vec<OpProof>, 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: &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: &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<SignedMantleTx, Error>
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(
+29 -6
View File
@@ -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<FundingConfig>,
}
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<FinalizedTx>,
},
/// 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
+77 -28
View File
@@ -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<Node> {
// 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<WithdrawArg>,
) -> 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.