mirror of
https://github.com/logos-co/nomos-node.git
synced 2026-08-27 17:41:11 +00:00
feat(zone-sdk): enable non zero gas price (tke) (#3109)
This commit is contained in:
@@ -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}"),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)]
|
||||
|
||||
@@ -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
@@ -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
@@ -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!()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user