From a652c409c3648f453a0fd44759f3cb2c4d664e6b Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 8 Sep 2026 15:20:04 +0800 Subject: [PATCH 1/5] feat: regenerate metadata for runtime spec 149 Spec 149 dropped Vesting::MinimumPayout and added Launch, so the bundled 148 bindings made exercise fail at setup. Also add prepare-public-batches for off-chain public-batch proof generation. Co-authored-by: Cursor --- examples/generate_qr_fixtures.rs | 2 +- src/chain/quantus_subxt.rs | 183 ++++++++++++++---------- src/chain/signing.rs | 3 +- src/cli/exercise/scenarios/vesting.rs | 35 +++-- src/cli/vesting.rs | 19 ++- src/cli/wormhole.rs | 194 ++++++++++++++++++++++++++ src/config/mod.rs | 10 ++ src/lib.rs | 6 +- src/quantus_metadata.scale | Bin 113082 -> 112934 bytes 9 files changed, 355 insertions(+), 97 deletions(-) diff --git a/examples/generate_qr_fixtures.rs b/examples/generate_qr_fixtures.rs index 960535c..78da721 100644 --- a/examples/generate_qr_fixtures.rs +++ b/examples/generate_qr_fixtures.rs @@ -17,7 +17,7 @@ const PLANCK_GENESIS: [u8; 32] = [ 0x49, 0x01, 0xbf, 0x5c, 0x57, 0xfd, 0x3f, 0x9e, 0x72, 0x6a, 0xf3, 0x99, 0xc7, 0x63, 0xde, 0x66, 0x70, 0xdb, 0xdb, 0x11, 0x5a, 0x91, 0xc0, 0x23, 0x7e, 0x17, 0x3f, 0x16, 0xee, 0xf6, 0x5e, 0x72, ]; -const SPEC_VERSION: u32 = 148; +const SPEC_VERSION: u32 = 149; const TRANSACTION_VERSION: u32 = 6; /// The signer the request is addressed to. Any valid Quantus address works; the wallet only diff --git a/src/chain/quantus_subxt.rs b/src/chain/quantus_subxt.rs index 3cde670..f8c78df 100644 --- a/src/chain/quantus_subxt.rs +++ b/src/chain/quantus_subxt.rs @@ -2050,9 +2050,9 @@ pub mod api { .hash(); runtime_metadata_hash == [ - 104u8, 196u8, 70u8, 218u8, 94u8, 164u8, 163u8, 152u8, 189u8, 155u8, 196u8, 185u8, - 189u8, 151u8, 104u8, 118u8, 115u8, 242u8, 115u8, 237u8, 14u8, 237u8, 29u8, 140u8, - 142u8, 27u8, 239u8, 20u8, 232u8, 27u8, 158u8, 180u8, + 20u8, 129u8, 235u8, 192u8, 105u8, 153u8, 56u8, 54u8, 168u8, 246u8, 201u8, 176u8, + 111u8, 104u8, 43u8, 33u8, 205u8, 165u8, 154u8, 220u8, 208u8, 127u8, 58u8, 167u8, + 129u8, 13u8, 213u8, 156u8, 215u8, 128u8, 65u8, 226u8, ] } pub mod system { @@ -3180,10 +3180,9 @@ pub mod api { "Events", (), [ - 145u8, 196u8, 168u8, 220u8, 39u8, 202u8, 204u8, 242u8, 110u8, 160u8, - 206u8, 102u8, 64u8, 203u8, 40u8, 180u8, 196u8, 63u8, 66u8, 122u8, - 119u8, 47u8, 219u8, 160u8, 184u8, 119u8, 71u8, 227u8, 7u8, 152u8, - 224u8, 121u8, + 244u8, 237u8, 38u8, 136u8, 72u8, 66u8, 248u8, 24u8, 139u8, 138u8, 19u8, + 224u8, 33u8, 74u8, 14u8, 163u8, 159u8, 60u8, 6u8, 133u8, 161u8, 7u8, + 73u8, 211u8, 101u8, 239u8, 218u8, 110u8, 140u8, 28u8, 92u8, 68u8, ], ) } @@ -12265,12 +12264,10 @@ pub mod api { #[encode_as_type( crate_path = ":: subxt :: ext :: subxt_core :: ext :: scale_encode" )] - #[doc = "Pay the largest valid claim on `schedule_id` to its beneficiary. Payouts are"] - #[doc = "rounded down to [`Config::PayoutQuantum`], must meet [`Config::MinimumPayout`],"] - #[doc = "and reserve at least one minimum-sized final claim unless the schedule is fully"] - #[doc = "vested. Non-final payouts are further rounded down to"] - #[doc = "[`NON_FINAL_PAYOUT_QUANTA`] leaf quanta; the leftover stays on the schedule"] - #[doc = "until a later claim or the exact final payout."] + #[doc = "Pay the largest valid claim on `schedule_id` to its beneficiary. Non-final"] + #[doc = "payouts are rounded down to [`NON_FINAL_PAYOUT_QUANTA`] leaf quanta; the"] + #[doc = "leftover stays on the schedule until a later claim or the exact final"] + #[doc = "payout (at least one [`Config::PayoutQuantum`])."] #[doc = ""] #[doc = "Permissionless: any signed account may call this for any schedule; the payout"] #[doc = "always goes to the stored beneficiary. This is the only claim path for"] @@ -12298,7 +12295,7 @@ pub mod api { crate_path = ":: subxt :: ext :: subxt_core :: ext :: scale_encode" )] #[doc = "Create a new schedule under the next free id, moving `total` from the"] - #[doc = "treasury account into the pot in the same call."] + #[doc = "treasury account into the pot in the same call (recorded as a leaf)."] pub struct CreateSchedule { pub beneficiary: create_schedule::Beneficiary, pub start: create_schedule::Start, @@ -12330,10 +12327,9 @@ pub mod api { crate_path = ":: subxt :: ext :: subxt_core :: ext :: scale_encode" )] #[doc = "End a schedule early: the still-unpaid vested part (rounded to the nearest"] - #[doc = "[`Config::PayoutQuantum`]) goes to the beneficiary if it meets"] - #[doc = "[`Config::MinimumPayout`]; otherwise that sliver is refunded with the"] - #[doc = "unvested remainder. The treasury is signature-controlled and needs no"] - #[doc = "wormhole leaf, so the refund is not quantized and never blocks ending."] + #[doc = "[`Config::PayoutQuantum`]) goes to the beneficiary if it is at least one"] + #[doc = "quantum; otherwise that sliver is refunded with the unvested remainder."] + #[doc = "Both legs are recorded as wormhole leaves."] pub struct EndSchedule { pub schedule_id: end_schedule::ScheduleId, } @@ -12378,12 +12374,10 @@ pub mod api { } pub struct TransactionApi; impl TransactionApi { - #[doc = "Pay the largest valid claim on `schedule_id` to its beneficiary. Payouts are"] - #[doc = "rounded down to [`Config::PayoutQuantum`], must meet [`Config::MinimumPayout`],"] - #[doc = "and reserve at least one minimum-sized final claim unless the schedule is fully"] - #[doc = "vested. Non-final payouts are further rounded down to"] - #[doc = "[`NON_FINAL_PAYOUT_QUANTA`] leaf quanta; the leftover stays on the schedule"] - #[doc = "until a later claim or the exact final payout."] + #[doc = "Pay the largest valid claim on `schedule_id` to its beneficiary. Non-final"] + #[doc = "payouts are rounded down to [`NON_FINAL_PAYOUT_QUANTA`] leaf quanta; the"] + #[doc = "leftover stays on the schedule until a later claim or the exact final"] + #[doc = "payout (at least one [`Config::PayoutQuantum`])."] #[doc = ""] #[doc = "Permissionless: any signed account may call this for any schedule; the payout"] #[doc = "always goes to the stored beneficiary. This is the only claim path for"] @@ -12404,7 +12398,7 @@ pub mod api { ) } #[doc = "Create a new schedule under the next free id, moving `total` from the"] - #[doc = "treasury account into the pot in the same call."] + #[doc = "treasury account into the pot in the same call (recorded as a leaf)."] pub fn create_schedule( &self, beneficiary: types::create_schedule::Beneficiary, @@ -12426,10 +12420,9 @@ pub mod api { ) } #[doc = "End a schedule early: the still-unpaid vested part (rounded to the nearest"] - #[doc = "[`Config::PayoutQuantum`]) goes to the beneficiary if it meets"] - #[doc = "[`Config::MinimumPayout`]; otherwise that sliver is refunded with the"] - #[doc = "unvested remainder. The treasury is signature-controlled and needs no"] - #[doc = "wormhole leaf, so the refund is not quantized and never blocks ending."] + #[doc = "[`Config::PayoutQuantum`]) goes to the beneficiary if it is at least one"] + #[doc = "quantum; otherwise that sliver is refunded with the unvested remainder."] + #[doc = "Both legs are recorded as wormhole leaves."] pub fn end_schedule( &self, schedule_id: types::end_schedule::ScheduleId, @@ -12578,6 +12571,26 @@ pub mod api { const PALLET: &'static str = "Vesting"; const EVENT: &'static str = "ScheduleRetargeted"; } + #[derive( + :: subxt :: ext :: subxt_core :: ext :: scale_decode :: DecodeAsType, + :: subxt :: ext :: subxt_core :: ext :: scale_encode :: EncodeAsType, + Debug, + )] + #[decode_as_type(crate_path = ":: subxt :: ext :: subxt_core :: ext :: scale_decode")] + #[encode_as_type(crate_path = ":: subxt :: ext :: subxt_core :: ext :: scale_encode")] + #[doc = "Offset genesis schedules were rebased onto this unix-ms timestamp. The"] + #[doc = "genesis block's `Now` is 0 and is never used."] + pub struct LaunchMomentSet { + pub at: launch_moment_set::At, + } + pub mod launch_moment_set { + use super::runtime_types; + pub type At = ::core::primitive::u64; + } + impl ::subxt::ext::subxt_core::events::StaticEvent for LaunchMomentSet { + const PALLET: &'static str = "Vesting"; + const EVENT: &'static str = "LaunchMomentSet"; + } } pub mod storage { use super::runtime_types; @@ -12595,6 +12608,10 @@ pub mod api { >; pub type Param0 = ::core::primitive::u64; } + pub mod launch { + use super::runtime_types; + pub type Launch = runtime_types::pallet_vesting::pallet::LaunchAnchor; + } } pub struct StorageApi; impl StorageApi { @@ -12666,6 +12683,27 @@ pub mod api { ], ) } + pub fn launch( + &self, + ) -> ::subxt::ext::subxt_core::storage::address::StaticAddress< + (), + types::launch::Launch, + ::subxt::ext::subxt_core::utils::Yes, + (), + (), + > { + ::subxt::ext::subxt_core::storage::address::StaticAddress::new_static( + "Vesting", + "Launch", + (), + [ + 159u8, 133u8, 168u8, 43u8, 32u8, 161u8, 140u8, 94u8, 130u8, 23u8, + 235u8, 114u8, 176u8, 102u8, 169u8, 127u8, 131u8, 12u8, 115u8, 163u8, + 191u8, 236u8, 142u8, 222u8, 164u8, 201u8, 190u8, 194u8, 128u8, 152u8, + 162u8, 52u8, + ], + ) + } } } pub mod constants { @@ -12691,7 +12729,9 @@ pub mod api { #[doc = " Wormhole leaf amount quantum. ZK-tree leaves commit `amount / quantum`, so a"] #[doc = " payout below one quantum would create a zero-value leaf: funds moved to a"] #[doc = " keyless beneficiary would be irrecoverable. Every schedule total must be a"] - #[doc = " multiple of this, and every payout is rounded down to a multiple."] + #[doc = " positive multiple of this. Intermediate claims round down further to"] + #[doc = " [`NON_FINAL_PAYOUT_QUANTA`] leaf quanta; the final claim may be a single"] + #[doc = " quantum."] pub fn payout_quantum( &self, ) -> ::subxt::ext::subxt_core::constants::address::StaticAddress< @@ -12707,23 +12747,6 @@ pub mod api { ], ) } - #[doc = " Smallest beneficiary payout. Must be quantum-aligned, at least two quanta,"] - #[doc = " and larger than the existential deposit."] - pub fn minimum_payout( - &self, - ) -> ::subxt::ext::subxt_core::constants::address::StaticAddress< - ::core::primitive::u128, - > { - ::subxt::ext::subxt_core::constants::address::StaticAddress::new_static( - "Vesting", - "MinimumPayout", - [ - 84u8, 157u8, 140u8, 4u8, 93u8, 57u8, 29u8, 133u8, 105u8, 200u8, 214u8, - 27u8, 144u8, 208u8, 218u8, 160u8, 130u8, 109u8, 101u8, 54u8, 210u8, - 136u8, 71u8, 63u8, 49u8, 237u8, 234u8, 15u8, 178u8, 98u8, 148u8, 156u8, - ], - ) - } #[doc = " Minimum elapsed milliseconds between successful claims on one schedule."] pub fn min_claim_interval( &self, @@ -16172,12 +16195,10 @@ pub mod api { #[doc = "Contains a variant per dispatchable extrinsic that this pallet has."] pub enum Call { #[codec(index = 0)] - #[doc = "Pay the largest valid claim on `schedule_id` to its beneficiary. Payouts are"] - #[doc = "rounded down to [`Config::PayoutQuantum`], must meet [`Config::MinimumPayout`],"] - #[doc = "and reserve at least one minimum-sized final claim unless the schedule is fully"] - #[doc = "vested. Non-final payouts are further rounded down to"] - #[doc = "[`NON_FINAL_PAYOUT_QUANTA`] leaf quanta; the leftover stays on the schedule"] - #[doc = "until a later claim or the exact final payout."] + #[doc = "Pay the largest valid claim on `schedule_id` to its beneficiary. Non-final"] + #[doc = "payouts are rounded down to [`NON_FINAL_PAYOUT_QUANTA`] leaf quanta; the"] + #[doc = "leftover stays on the schedule until a later claim or the exact final"] + #[doc = "payout (at least one [`Config::PayoutQuantum`])."] #[doc = ""] #[doc = "Permissionless: any signed account may call this for any schedule; the payout"] #[doc = "always goes to the stored beneficiary. This is the only claim path for"] @@ -16185,7 +16206,7 @@ pub mod api { claim { schedule_id: ::core::primitive::u64 }, #[codec(index = 1)] #[doc = "Create a new schedule under the next free id, moving `total` from the"] - #[doc = "treasury account into the pot in the same call."] + #[doc = "treasury account into the pot in the same call (recorded as a leaf)."] create_schedule { beneficiary: ::subxt::ext::subxt_core::utils::AccountId32, start: ::core::primitive::u64, @@ -16195,10 +16216,9 @@ pub mod api { }, #[codec(index = 2)] #[doc = "End a schedule early: the still-unpaid vested part (rounded to the nearest"] - #[doc = "[`Config::PayoutQuantum`]) goes to the beneficiary if it meets"] - #[doc = "[`Config::MinimumPayout`]; otherwise that sliver is refunded with the"] - #[doc = "unvested remainder. The treasury is signature-controlled and needs no"] - #[doc = "wormhole leaf, so the refund is not quantized and never blocks ending."] + #[doc = "[`Config::PayoutQuantum`]) goes to the beneficiary if it is at least one"] + #[doc = "quantum; otherwise that sliver is refunded with the unvested remainder."] + #[doc = "Both legs are recorded as wormhole leaves."] end_schedule { schedule_id: ::core::primitive::u64 }, #[codec(index = 3)] #[doc = "Change the schedule's beneficiary without paying anything out. A retarget"] @@ -16229,35 +16249,31 @@ pub mod api { #[doc = "No schedule exists under this id."] NoSchedule, #[codec(index = 1)] - #[doc = "Schedule parameters violate `start <= cliff <= end`, `start < end`,"] - #[doc = "`total >= MinimumPayout`, or `total` is not a multiple of the payout"] - #[doc = "quantum."] + #[doc = "Schedule parameters violate `start <= cliff <= end`, `start < end`, or"] + #[doc = "`total` is not a positive multiple of the payout quantum."] InvalidSchedule, #[codec(index = 2)] #[doc = "Nothing is claimable right now (before the cliff, already fully claimed, or"] - #[doc = "less than the minimum payout accrued)."] + #[doc = "the accrual rounds down to zero — below one quantum for a final claim,"] + #[doc = "below the non-final alignment otherwise)."] NothingToClaim, #[codec(index = 3)] #[doc = "This schedule has already paid out within the minimum claim interval."] ClaimTooSoon, #[codec(index = 4)] - #[doc = "Paying now would leave a remainder below the minimum payout; wait until the"] - #[doc = "entire remainder has vested."] - ClaimWouldLeaveDust, - #[codec(index = 5)] #[doc = "The treasury account is not configured or aliases the vesting pot."] TreasuryNotConfigured, - #[codec(index = 6)] + #[codec(index = 5)] #[doc = "The pot does not hold its existential-deposit buffer; endow it first."] PotUnderfunded, - #[codec(index = 7)] + #[codec(index = 6)] #[doc = "The beneficiary must not be the pot, and retargeting must change the account."] InvalidBeneficiary, - #[codec(index = 8)] - #[doc = "The proof recorder reported the payout credit as dropped: no wormhole leaf"] - #[doc = "was created, so the payout is rolled back rather than finalized without the"] - #[doc = "proof material a keyless beneficiary needs to exit."] - PayoutProofNotRecorded, + #[codec(index = 7)] + #[doc = "The proof recorder reported the transfer credit as dropped: no wormhole leaf"] + #[doc = "was created, so the transfer is rolled back rather than finalized without a"] + #[doc = "leaf."] + TransferProofNotRecorded, } #[derive( :: subxt :: ext :: subxt_core :: ext :: scale_decode :: DecodeAsType, @@ -16307,6 +16323,27 @@ pub mod api { old_beneficiary: ::subxt::ext::subxt_core::utils::AccountId32, new_beneficiary: ::subxt::ext::subxt_core::utils::AccountId32, }, + #[codec(index = 4)] + #[doc = "Offset genesis schedules were rebased onto this unix-ms timestamp. The"] + #[doc = "genesis block's `Now` is 0 and is never used."] + LaunchMomentSet { at: ::core::primitive::u64 }, + } + #[derive( + :: subxt :: ext :: subxt_core :: ext :: scale_decode :: DecodeAsType, + :: subxt :: ext :: subxt_core :: ext :: scale_encode :: EncodeAsType, + Debug, + )] + #[decode_as_type( + crate_path = ":: subxt :: ext :: subxt_core :: ext :: scale_decode" + )] + #[encode_as_type( + crate_path = ":: subxt :: ext :: subxt_core :: ext :: scale_encode" + )] + pub enum LaunchAnchor { + #[codec(index = 0)] + Pending, + #[codec(index = 1)] + Anchored(::core::primitive::u64), } #[derive( :: subxt :: ext :: subxt_core :: ext :: scale_decode :: DecodeAsType, diff --git a/src/chain/signing.rs b/src/chain/signing.rs index 4385366..eb6ab23 100644 --- a/src/chain/signing.rs +++ b/src/chain/signing.rs @@ -130,8 +130,9 @@ mod tests { assert_eq!(context_for_runtime(145, 4), None, "spec 145 verifies with no context"); assert_eq!(context_for_runtime(134, 2), None, "the oldest listed pair predates it too"); assert_eq!(context_for_runtime(148, 6), Some(EXTRINSIC), "spec 148 introduced it"); + assert_eq!(context_for_runtime(149, 6), Some(EXTRINSIC), "spec 149 keeps it"); assert_eq!( - context_for_runtime(149, 7), + context_for_runtime(150, 7), Some(EXTRINSIC), "newer unlisted runtimes are assumed to keep it" ); diff --git a/src/cli/exercise/scenarios/vesting.rs b/src/cli/exercise/scenarios/vesting.rs index 357a7cb..031a387 100644 --- a/src/cli/exercise/scenarios/vesting.rs +++ b/src/cli/exercise/scenarios/vesting.rs @@ -62,17 +62,29 @@ async fn constants_and_schedules(ctx: &mut ExerciseCtx) -> Result { let constants = ctx.client.client().constants(); let payout_quantum = constants.at(&quantus_subxt::api::constants().vesting().payout_quantum())?; - let minimum_payout = - constants.at(&quantus_subxt::api::constants().vesting().minimum_payout())?; let min_claim_interval = constants.at(&quantus_subxt::api::constants().vesting().min_claim_interval())?; - if payout_quantum == 0 || minimum_payout < payout_quantum { - return Err(QuantusError::Generic(format!( - "implausible vesting constants: quantum {payout_quantum}, minimum {minimum_payout}" - ))); + if payout_quantum == 0 { + return Err(QuantusError::Generic( + "implausible vesting constants: PayoutQuantum is 0".into(), + )); } + let latest = ctx.client.get_latest_block().await?; + let launch = ctx + .client + .client() + .storage() + .at(latest) + .fetch(&quantus_subxt::api::storage().vesting().launch()) + .await?; + use quantus_subxt::api::runtime_types::pallet_vesting::pallet::LaunchAnchor; + let launch_note = match launch { + Some(LaunchAnchor::Anchored(at)) => format!("launch anchored at {at}"), + Some(LaunchAnchor::Pending) | None => "launch pending".to_string(), + }; + let next_id = next_schedule_id(ctx).await?; let schedules = crate::cli::vesting::fetch_all_schedules(&ctx.client).await?; for (id, schedule) in &schedules { @@ -90,7 +102,7 @@ async fn constants_and_schedules(ctx: &mut ExerciseCtx) -> Result { } Ok(format!( - "quantum {payout_quantum}, minimum payout {minimum_payout}, claim interval {min_claim_interval}ms; \ + "quantum {payout_quantum}, claim interval {min_claim_interval}ms, {launch_note}; \ {} schedule(s), next id {next_id}", schedules.len() )) @@ -363,9 +375,8 @@ async fn ensure_treasury_multisig( Ok(()) } -/// Top the treasury up from Alice so `create_schedule` can move funds into the pot. -/// Smallest schedule the chain accepts at the suite's test scale: at least `MinimumPayout`, -/// rounded up to a whole `PayoutQuantum` so the pallet's alignment check passes. +/// Smallest schedule the chain accepts at the suite's test scale: a positive +/// multiple of `PayoutQuantum`. pub fn schedule_total( client: &crate::chain::client::QuantusClient, test_unit: u128, @@ -373,12 +384,10 @@ pub fn schedule_total( let constants = client.client().constants(); let payout_quantum = constants.at(&quantus_subxt::api::constants().vesting().payout_quantum())?; - let minimum_payout = - constants.at(&quantus_subxt::api::constants().vesting().minimum_payout())?; if payout_quantum == 0 { return Err(QuantusError::Generic("vesting PayoutQuantum is zero".to_string())); } - Ok((5 * test_unit).max(minimum_payout).div_ceil(payout_quantum) * payout_quantum) + Ok((5 * test_unit).div_ceil(payout_quantum).max(1) * payout_quantum) } /// Balance the treasury multisig is topped up to. Unlike the other dedicated accounts this one diff --git a/src/cli/vesting.rs b/src/cli/vesting.rs index 9f32701..7efb07c 100644 --- a/src/cli/vesting.rs +++ b/src/cli/vesting.rs @@ -26,7 +26,7 @@ type VestingScheduleInfo = /// Vesting commands #[derive(Subcommand, Debug)] pub enum VestingCommands { - /// Show vesting pallet constants and the next schedule id + /// Show vesting pallet constants, launch status, and the next schedule id Info, /// List all vesting schedules (optionally filtered by beneficiary) @@ -338,8 +338,6 @@ async fn show_info(quantus_client: &crate::chain::client::QuantusClient) -> Resu let constants = quantus_client.client().constants(); let payout_quantum = constants.at(&quantus_subxt::api::constants().vesting().payout_quantum())?; - let minimum_payout = - constants.at(&quantus_subxt::api::constants().vesting().minimum_payout())?; let min_claim_interval = constants.at(&quantus_subxt::api::constants().vesting().min_claim_interval())?; @@ -347,16 +345,15 @@ async fn show_info(quantus_client: &crate::chain::client::QuantusClient) -> Resu let storage_at = quantus_client.client().storage().at(latest_block_hash); let next_id_addr = quantus_subxt::api::storage().vesting().next_schedule_id(); let next_id = storage_at.fetch_or_default(&next_id_addr).await?; + let launch = storage_at.fetch(&quantus_subxt::api::storage().vesting().launch()).await?; let quantum_fmt = crate::cli::send::format_balance_with_symbol(quantus_client, payout_quantum).await?; - let minimum_fmt = - crate::cli::send::format_balance_with_symbol(quantus_client, minimum_payout).await?; log_print!("🪙 {}", "Vesting".bright_green().bold()); log_print!(" Payout quantum: {}", quantum_fmt); - log_print!(" Minimum payout: {}", minimum_fmt); log_print!(" Min claim interval: {} ms", min_claim_interval); + log_print!(" Launch: {}", format_launch(launch)); log_print!(" Next schedule id: {}", next_id.to_string().bright_yellow()); Ok(()) } @@ -441,6 +438,16 @@ fn format_moment(ms: u64) -> String { } } +fn format_launch( + launch: Option, +) -> String { + use quantus_subxt::api::runtime_types::pallet_vesting::pallet::LaunchAnchor; + match launch { + Some(LaunchAnchor::Anchored(at)) => format_moment(at), + Some(LaunchAnchor::Pending) | None => "pending".to_string(), + } +} + async fn print_schedule( quantus_client: &crate::chain::client::QuantusClient, id: u64, diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 49d9149..9fd4e46 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -841,6 +841,40 @@ pub enum WormholeCommands { #[arg(short, long, default_value = "public_batch_proof.hex")] proof: String, }, + /// Prepare N independent public-batch proofs without verifying on-chain. + /// + /// For each batch: fund wormhole addresses → leaf prove → private aggregate → + /// public aggregate. Proofs are written under `--output-dir` and are suitable + /// for later firehose submit (e.g. stress-test packing). + PreparePublicBatches { + /// How many independent public-batch proofs to prepare + #[arg(short = 'n', long, default_value_t = 10)] + count: usize, + + /// DEV amount deposited per batch (partitioned across `--num-proofs`) + #[arg(short, long, default_value = "1.0")] + amount: f64, + + /// Leaf proofs per private batch (padded to circuit size; default 1) + #[arg(long, default_value_t = 1)] + num_proofs: usize, + + /// Wallet name (must have a mnemonic for HD wormhole derivation) + #[arg(short, long)] + wallet: String, + + /// Password for the wallet + #[arg(short, long, hide = true)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + + /// Output directory for proof files + #[arg(short, long, default_value = "/tmp/wormhole_public_batches")] + output_dir: String, + }, /// Parse and display the contents of a proof file (for debugging) ParseProof { /// Path to the proof file (hex-encoded) @@ -1132,6 +1166,38 @@ pub async fn handle_wormhole_command( aggregate_public_batch(proofs, aggregator, output).await, WormholeCommands::VerifyPublicBatch { proof } => verify_public_batch(proof, node_url, execution_mode).await, + WormholeCommands::PreparePublicBatches { + count, + amount, + num_proofs, + wallet, + password, + password_file, + output_dir, + } => { + let amount_planck = (amount * 1_000_000_000_000.0) as u128; + let amount_aligned = (amount_planck / SCALE_DOWN_FACTOR) * SCALE_DOWN_FACTOR; + let quantus_client = QuantusClient::new(node_url).await.map_err(|e| { + crate::error::QuantusError::Generic(format!("Failed to connect: {e}")) + })?; + let paths = prepare_public_batches( + &quantus_client, + &wallet, + password, + password_file, + count, + amount_aligned, + num_proofs, + &output_dir, + execution_mode, + ) + .await?; + log_success!("Prepared {} public-batch proof(s):", paths.len()); + for p in &paths { + log_print!(" {p}"); + } + Ok(()) + }, WormholeCommands::ParseProof { proof, aggregated, public_batch, verify } => parse_proof_file(proof, aggregated, public_batch, verify).await, WormholeCommands::Multiround { @@ -1481,6 +1547,134 @@ pub async fn aggregate_proofs( Ok(()) } +/// HD round base for [`prepare_public_batches`] so prepared deposits don't collide +/// with normal `multiround` paths that use rounds starting at 1. +const PREPARE_PUBLIC_BATCH_ROUND_BASE: usize = 1_000_000; + +/// Prepare `count` independent public-batch proofs **without** on-chain verify. +/// +/// Each batch: deposit → leaf prove → `aggregate` → `aggregate_public`. Returns +/// paths to the written `public_batch_NNNN.hex` files. Funds remain in the +/// wormhole until those proofs are later submitted via `verify_public_batch`. +/// +/// The wallet must contain a mnemonic (HD derivation). Prefer a dedicated +/// mnemonic wallet over `crystal_*` developer wallets. +#[allow(clippy::too_many_arguments)] +pub async fn prepare_public_batches( + quantus_client: &QuantusClient, + wallet_name: &str, + password: Option, + password_file: Option, + count: usize, + amount_planck: u128, + num_proofs: usize, + output_dir: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result> { + use colored::Colorize; + + if count == 0 { + return Err(crate::error::QuantusError::Generic("count must be >= 1".into())); + } + if amount_planck < 3 * SCALE_DOWN_FACTOR { + return Err(crate::error::QuantusError::Generic(format!( + "amount too small: need at least {} planck (0.03 DEV) per batch", + 3 * SCALE_DOWN_FACTOR + ))); + } + + let bins_dir = crate::bins::ensure_bins_dir()?; + let agg_config = CircuitBinsConfig::load(&bins_dir).map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to load circuit bins config from {:?}: {}", + bins_dir, e + )) + })?; + validate_multiround_params(num_proofs, 1, agg_config.num_leaf_proofs)?; + + let wallet = load_multiround_wallet(wallet_name, password, password_file)?; + let minting_account = get_minting_account(quantus_client.client()).await?; + + std::fs::create_dir_all(output_dir).map_err(|e| { + crate::error::QuantusError::Generic(format!("Failed to create output directory: {e}")) + })?; + + let needed = amount_planck.saturating_mul(count as u128); + let free = get_balance(quantus_client, &wallet.wallet_address).await?; + if free < needed { + return Err(crate::error::QuantusError::Generic(format!( + "Insufficient balance to prepare {count} batch(es): have {} ({}), need at least {} ({}) for deposits alone (plus transfer fees)", + free, + format_balance(free), + needed, + format_balance(needed), + ))); + } + + log_print!("{}", "Preparing public-batch proofs (no on-chain verify)...".bright_cyan()); + log_print!(" Wallet: {}", wallet.wallet_name); + log_print!(" Count: {count}"); + log_print!(" Amount/batch: {} ({})", amount_planck, format_balance(amount_planck)); + log_print!(" Leaf proofs/batch: {num_proofs}"); + log_print!(" Output: {output_dir}"); + log_print!(""); + + let mut paths = Vec::with_capacity(count); + for batch_idx in 0..count { + let round = PREPARE_PUBLIC_BATCH_ROUND_BASE.saturating_add(batch_idx); + let batch_dir = format!("{output_dir}/batch_{batch_idx:04}"); + std::fs::create_dir_all(&batch_dir).map_err(|e| { + crate::error::QuantusError::Generic(format!("Failed to create {batch_dir}: {e}")) + })?; + + log_print!( + "{}", + format!("=== Batch {}/{} (HD round {round}) ===", batch_idx + 1, count).bright_yellow() + ); + + let secrets = derive_round_secrets(&wallet.mnemonic, round, num_proofs)?; + let transfers = execute_initial_transfers( + quantus_client, + &wallet, + &secrets, + amount_planck, + num_proofs, + execution_mode, + ) + .await?; + + // Final-round style: exit back to the funding wallet. + let exit_accounts = vec![wallet.wallet_account_id.clone(); num_proofs]; + let RoundProofGeneration { proof_files, .. } = generate_round_proofs( + quantus_client, + &secrets, + &transfers, + &exit_accounts, + &minting_account, + &batch_dir, + num_proofs, + execution_mode, + ) + .await?; + + let aggregated_file = format!("{batch_dir}/aggregated.hex"); + aggregate_proofs(proof_files, aggregated_file.clone()).await?; + + let public_batch_file = format!("{output_dir}/public_batch_{batch_idx:04}.hex"); + aggregate_public_batch( + vec![aggregated_file], + wallet.wallet_address.clone(), + public_batch_file.clone(), + ) + .await?; + + log_success!(" Wrote {public_batch_file}"); + paths.push(public_batch_file); + } + + Ok(paths) +} + /// Aggregate private-batch proofs into a public batch with the given aggregator address. /// /// Partial batches are padded with dummy private-batch proofs by the prover, so any diff --git a/src/config/mod.rs b/src/config/mod.rs index b7d2184..cd7e88d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -87,6 +87,12 @@ pub const COMPATIBLE_RUNTIMES: &[CompatibleRuntime] = &[ supports_ml_dsa_65: true, binds_signing_context: true, }, + CompatibleRuntime { + spec_version: 149, + transaction_version: 6, + supports_ml_dsa_65: true, + binds_signing_context: true, + }, ]; /// Highest `spec_version` listed in [`COMPATIBLE_RUNTIMES`]. @@ -215,6 +221,8 @@ mod tests { validate_runtime_identity(EXPECTED_RUNTIME_SPEC_NAME, 147, 6) .expect("the fast-upgrade runtime must be accepted"); validate_runtime_identity(EXPECTED_RUNTIME_SPEC_NAME, 148, 6) + .expect("the spec-148 runtime must remain accepted"); + validate_runtime_identity(EXPECTED_RUNTIME_SPEC_NAME, 149, 6) .expect("the runtime this build bundles metadata for must be accepted"); } @@ -290,6 +298,7 @@ mod tests { assert!(!runtime_binds_signing_context(145, 4)); assert!(!runtime_binds_signing_context(147, 6)); assert!(runtime_binds_signing_context(148, 6)); + assert!(runtime_binds_signing_context(149, 6)); assert!( !runtime_binds_signing_context(148, 5), "an unknown tx version must not inherit the context" @@ -310,6 +319,7 @@ mod tests { assert!(runtime_supports_ml_dsa_65(143, 3)); assert!(runtime_supports_ml_dsa_65(147, 6)); assert!(runtime_supports_ml_dsa_65(148, 6)); + assert!(runtime_supports_ml_dsa_65(149, 6)); assert!(!runtime_supports_ml_dsa_65(142, 2), "unknown tx version must not match"); assert!( runtime_supports_ml_dsa_65(max_compatible_spec_version() + 1, 3), diff --git a/src/lib.rs b/src/lib.rs index a9df6d4..76fd669 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,9 +61,9 @@ pub use wormhole_lib::{ pub use chain::quantus_subxt::api::wormhole::events::NativeTransferred; pub use cli::wormhole::{ aggregate_proofs, at_best_block, at_finalized_block, compute_merkle_positions, - decode_full_leaf_data, get_zk_merkle_proof, parse_transfer_events, read_proof_file, - submit_unsigned_verify_private_batch, verify_private_batch_and_get_events, write_proof_file, - IncludedAt, TransferInfo, + decode_full_leaf_data, get_zk_merkle_proof, parse_transfer_events, prepare_public_batches, + read_proof_file, submit_unsigned_verify_private_batch, verify_private_batch_and_get_events, + write_proof_file, IncludedAt, TransferInfo, }; // Re-export collect rewards library for SDK usage diff --git a/src/quantus_metadata.scale b/src/quantus_metadata.scale index 5d9ff087600ba1d6f35c69739730650510123a9b..a41c1a89594fe8a902d6db0c1cbbb79f21e1aeca 100644 GIT binary patch delta 1951 zcmZWqZDP7Qilq(Is|-R@n`-&$P88<7VhN&k)Na-87>)2Gc7`3=-Mp%SK zc(+L%PSda16`=woSk&QN%%ZlVZ?q|wXK_aDaGXWl1f%J+J+0tWCJrsr9hzXe?MO40 z-fT@k`QRygACx?#V{TOeEwfmdTYeT@CiQKj<>{U7ROF^lxJ#4}(p#*;H0`26L!OTc zR)kJhHyt;b?&zwRM_sdv*^Ufjv7~ND*jCkn7VgAl#Mr~zj7rqatz?`_uri(SM1+Rc30 z*jn9|NVq9EbK=s#HH{6GNt-%GQi9ZzsVdqYXWu;XUz_0J^`$oLSjM|M3aE6brn(vN zA57*G?b_+_=cf+d8m?bdSDk_Ps)Wcbg21HZ%#0Xjj1WRIn=GP{CwM_fugI%HFeks& zvbd#b?F3ZR%s`70C=~7tEGVMfy4K-vM^AK&B;}4QMT*;oV{|cbeFkcfBPLX1>6Q@u zTI=DkMzV-vGDy}I=ix|St)w_kDkutqe>Eg5p8$7ndA8_9~j86!WO`KbM< zo-ksj&pVZ+V^+6ev+}Cy`MyIt=PS9`RgERV_I69QlxuBV#+~`xj2bc5ViBT=%{5qs zEOD_0ZzEgO%*67dj&@5o?I^RBTbxIw!-|J(K%UE~5ueUPDBwJ_5I@dD76cX-QZY;X&d2rg!HR~C}+ofQmq_96csWm%CE7n??qtw=CFkX}5yeZo&yj zyIyFmI4O+-ZR<7CE6)kZk+syw$&C3)@$oE75oPt5hSQ?09#ev6R4iO&t~cf6ju4=S z{j3C{XBMW3!}S=#8Q-fDXNB5;5}Xs|4VYeYPSt3Oq?aw#QvvOD)GOL*1c^p2$W$Xg z$}cLBOU^;P5+s+zdkt9e!dVs7i%eNDS-J(fqZ4u^gK6w+%&H!G>N>;f=<5voai6}< zu;`qP^SB{e=AfkM=2M*AewMS_84~5AB=3YZv%lgn*J;~ z*#DzHOBRucpH!nYQ!^@1BK9|Y-fzwLK|!x4UctR_ z*y-(93K7(P8}s0G^uZt;zD(5OT|mDsgLwU6!&4BB&Mei4$vpmJ(weBZ|)v^ zD`)TTJ!r)-uW>I1f#c%Oeq4}ke`+6+&-bah5!tV2)k~sbKW^n;Q3K9~R;6r2lGns_ zJKj9xH66fd1vk70Um^tD^d=udOu+;3{@1vyK=UGp@ga~WZXZENb^;M|k4lBDACQ#f oi5&yjgaU8eQA`3t-tF@$Ixo^RzQybLr$Oz3YY& zJnMUsXN8db!Ja!EJ7e~?wRM}enf0@wbVJlQWiDbn?ge5qq6L#y1sxssAg zb}s)<%1NmCJCMu=?oD_v|G7KsDjr&mC%h5h{rpK;pTE|>8CCfXe+hoiKjY6U*5*~? zf2+%%@e@PU@a-@sDl==C1e9CPS5fZLrCNP=u#mfF_S%N@U6D*VwsalKqM8fP*g~W| zZaXy?aNKO4L!}Z#nFRNoA^@()5cUNZwj=ol->_v7uh6z%Om+D>%So;GxUkcv-8jpF-s;+57ckRoUT4c~bP_37mc?nc*q*AUg(&|(r?jKrmXQ8Y2I=+=& zB8+~qh~|dr(IjX}C}S#YfDaU;g~|(4@-L8lov=uHWrTJXuJG=Vh-wCDfTD1f)&piQn)6eY4G)8un5mOVT+8JG7TDnu+h@t zVxuY5+eysCu&SGjwKI2WSiV}MI+o>(4Zi5wC|%R`%+!3wc4~}8_G)aeIxrVW>{nmR z#a%e4W;UblzA;UYFUv{Ov8lH>s397ol@N@v*h(iWbas%e!Pp_vTOmB(l2OtdhTerE zq#RwgUT4E3XRMqiL}y1r^&_gG6*JUF&6tG`)P-iuz=tZ zt4ioFQl>L{m`1)>lvSuL?F8ds8*-=&-z(ZNo2Ig*9dqic^@&ZbxiU^o7^kLS`V?+d z@eU*~U3GO}_LSN}xRMB~)AJ46t_!wwU{55nUCVEMaayo=0lulg3qfNyR6J7FsUBN^ z1wk}}>!H8V^DPTz_hNIrWQV&nUKSi3!mZ>7!8cD}gB~8LHBaN7OY*}{ z$>*?59U4MRU9%a>^w{2$-v#Do^iCzBI5CWmqZnnl02Whkzeej8Q{}JYyA@@i+ryG( zj_8h7NA{UsqDY6LU(l|E%T^$s|Ij!${;{iP%@a9GjSXR1ib#d8%o@b5bjeKIzR+Vm z?=IX~>qJPTel}@XM9d;h!7pD;a=;0tMq*DfCChMo=|YixA=Tx_eyvtpcEH5;;Ex@U zpfz@OVIFPg{#`gs+u5@lT^I{Sc4Hhkq#oFd6SOLc5oD(fYq2!V_%ZF2+C73Ft4?cC zxv?v9OOe7KCd%sTI2-KShYuq-AD9O)9r!-j^d@>ExTt}v&FgS)AfE$CC-oek%rt9Z}P48l@hT7nVfRfbeboMu2`iSHJ From 338bf1746e5671961cb0fc0519325a2f98eb5bb2 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 8 Sep 2026 15:59:11 +0800 Subject: [PATCH 2/5] fix: refuse occupied dirs in prepare-public-batches Overwriting public_batch_*.hex on retry would strand earlier wormhole deposits with no client-side proof. Co-authored-by: Cursor --- src/cli/wormhole.rs | 115 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 6 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index b6e0002..70555ff 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -871,7 +871,8 @@ pub enum WormholeCommands { #[arg(long)] password_file: Option, - /// Output directory for proof files + /// Output directory for proof files. Must not already contain + /// `public_batch_*.hex` or `batch_*` artifacts from a previous run. #[arg(short, long, default_value = "/tmp/wormhole_public_batches")] output_dir: String, }, @@ -1553,11 +1554,69 @@ pub async fn aggregate_proofs( /// with normal `multiround` paths that use rounds starting at 1. const PREPARE_PUBLIC_BATCH_ROUND_BASE: usize = 1_000_000; +fn is_prepare_artifact_name(name: &str) -> bool { + name.starts_with("batch_") || (name.starts_with("public_batch_") && name.ends_with(".hex")) +} + +fn existing_prepare_artifacts(output_dir: &std::path::Path) -> std::io::Result> { + if !output_dir.exists() { + return Ok(Vec::new()); + } + let mut hits = Vec::new(); + for entry in std::fs::read_dir(output_dir)? { + let name = entry?.file_name(); + let Some(name) = name.to_str() else { continue }; + if is_prepare_artifact_name(name) { + hits.push(name.to_string()); + } + } + hits.sort(); + Ok(hits) +} + +/// Refuse to reuse a directory that already holds prepare artifacts. A retry that +/// deposited again and then overwrote `public_batch_NNNN.hex` would strand the +/// earlier notes in the wormhole with no client-side proof. +fn ensure_fresh_prepare_output_dir(output_dir: &str) -> crate::error::Result<()> { + let path = std::path::Path::new(output_dir); + if path.exists() && !path.is_dir() { + return Err(crate::error::QuantusError::Generic(format!( + "output path {output_dir} exists and is not a directory" + ))); + } + let artifacts = existing_prepare_artifacts(path).map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to read output directory {output_dir}: {e}" + )) + })?; + if artifacts.is_empty() { + return Ok(()); + } + Err(crate::error::QuantusError::Generic(format!( + "output directory {output_dir} already contains unsubmitted proof artifacts ({}); \ + pass a fresh --output-dir so a retry cannot overwrite the only recovery material \ + for deposits already in the wormhole", + artifacts.join(", ") + ))) +} + +fn refuse_existing_path(path: &str) -> crate::error::Result<()> { + if std::path::Path::new(path).exists() { + Err(crate::error::QuantusError::Generic(format!( + "refusing to overwrite existing proof artifact {path}" + ))) + } else { + Ok(()) + } +} + /// Prepare `count` independent public-batch proofs **without** on-chain verify. /// /// Each batch: deposit → leaf prove → `aggregate` → `aggregate_public`. Returns /// paths to the written `public_batch_NNNN.hex` files. Funds remain in the /// wormhole until those proofs are later submitted via `verify_public_batch`. +/// Refuses an `--output-dir` that already contains prepare artifacts so a retry +/// cannot overwrite the only recovery material for earlier deposits. /// /// The wallet must contain a mnemonic (HD derivation). Prefer a dedicated /// mnemonic wallet over `crystal_*` developer wallets. @@ -1585,6 +1644,11 @@ pub async fn prepare_public_batches( ))); } + ensure_fresh_prepare_output_dir(output_dir)?; + std::fs::create_dir_all(output_dir).map_err(|e| { + crate::error::QuantusError::Generic(format!("Failed to create output directory: {e}")) + })?; + let bins_dir = crate::bins::ensure_bins_dir()?; let agg_config = CircuitBinsConfig::load(&bins_dir).map_err(|e| { crate::error::QuantusError::Generic(format!( @@ -1597,10 +1661,6 @@ pub async fn prepare_public_batches( let wallet = load_multiround_wallet(wallet_name, password, password_file)?; let minting_account = get_minting_account(quantus_client.client()).await?; - std::fs::create_dir_all(output_dir).map_err(|e| { - crate::error::QuantusError::Generic(format!("Failed to create output directory: {e}")) - })?; - let needed = amount_planck.saturating_mul(count as u128); let free = get_balance(quantus_client, &wallet.wallet_address).await?; if free < needed { @@ -1625,6 +1685,9 @@ pub async fn prepare_public_batches( for batch_idx in 0..count { let round = PREPARE_PUBLIC_BATCH_ROUND_BASE.saturating_add(batch_idx); let batch_dir = format!("{output_dir}/batch_{batch_idx:04}"); + let public_batch_file = format!("{output_dir}/public_batch_{batch_idx:04}.hex"); + refuse_existing_path(&batch_dir)?; + refuse_existing_path(&public_batch_file)?; std::fs::create_dir_all(&batch_dir).map_err(|e| { crate::error::QuantusError::Generic(format!("Failed to create {batch_dir}: {e}")) })?; @@ -1660,9 +1723,10 @@ pub async fn prepare_public_batches( .await?; let aggregated_file = format!("{batch_dir}/aggregated.hex"); + refuse_existing_path(&aggregated_file)?; aggregate_proofs(proof_files, aggregated_file.clone()).await?; - let public_batch_file = format!("{output_dir}/public_batch_{batch_idx:04}.hex"); + refuse_existing_path(&public_batch_file)?; aggregate_public_batch( vec![aggregated_file], wallet.wallet_address.clone(), @@ -4871,6 +4935,45 @@ mod tests { assert_eq!(read_proof_file(path).unwrap(), proof_bytes); } + #[test] + fn prepare_output_dir_rejects_existing_public_batch_and_batch_dir() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path(); + assert!(ensure_fresh_prepare_output_dir(path.to_str().unwrap()).is_ok()); + + std::fs::write(path.join("notes.txt"), b"ok").unwrap(); + assert!( + ensure_fresh_prepare_output_dir(path.to_str().unwrap()).is_ok(), + "unrelated files must not block a fresh prepare" + ); + + std::fs::write(path.join("public_batch_0000.hex"), b"proof").unwrap(); + let err = ensure_fresh_prepare_output_dir(path.to_str().unwrap()).unwrap_err().to_string(); + assert!( + err.contains("public_batch_0000.hex") && err.contains("fresh --output-dir"), + "expected occupied-dir error, got: {err}" + ); + + std::fs::remove_file(path.join("public_batch_0000.hex")).unwrap(); + std::fs::create_dir(path.join("batch_0000")).unwrap(); + let err = ensure_fresh_prepare_output_dir(path.to_str().unwrap()).unwrap_err().to_string(); + assert!( + err.contains("batch_0000"), + "expected batch dir to count as an artifact, got: {err}" + ); + } + + #[test] + fn refuse_existing_path_blocks_overwrite() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("public_batch_0000.hex"); + let path_str = path.to_str().unwrap(); + refuse_existing_path(path_str).expect("missing path is fine"); + std::fs::write(&path, b"old").unwrap(); + let err = refuse_existing_path(path_str).unwrap_err().to_string(); + assert!(err.contains("refusing to overwrite"), "got: {err}"); + } + #[test] fn test_read_proof_file_errors() { // File not found From 7c0af5ed46742d8316fc23f80083346aed07e6a7 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 8 Sep 2026 16:16:33 +0800 Subject: [PATCH 3/5] fix: query wormhole tip storage at the block hash Read transfer counts with storage().at(hash) on the live client instead of Block.storage(), and fetch finalized blocks the same way after head metadata. Co-authored-by: Cursor --- src/cli/wormhole.rs | 159 +++++++++----------------------------------- 1 file changed, 32 insertions(+), 127 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 70555ff..9fd4e46 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -871,8 +871,7 @@ pub enum WormholeCommands { #[arg(long)] password_file: Option, - /// Output directory for proof files. Must not already contain - /// `public_batch_*.hex` or `batch_*` artifacts from a previous run. + /// Output directory for proof files #[arg(short, long, default_value = "/tmp/wormhole_public_batches")] output_dir: String, }, @@ -1349,8 +1348,7 @@ fn show_wormhole_address(secret_file: String) -> crate::error::Result<()> { Ok(()) } -/// Fetch the latest finalized block as a fully materialised subxt `Block`, decoded with the -/// runtime that produced it (still the pre-upgrade one while the head has moved on). +/// Fetch the latest finalized block as a fully materialised subxt `Block`. /// /// Uses [`crate::error::Result`] (not `anyhow`) so it composes with the rest /// of the SDK surface. Network/decoding failures are wrapped in @@ -1367,8 +1365,7 @@ pub async fn at_finalized_block( "Failed to fetch finalized block hash: {e:?}" )) })?; - let at_finalized = quantus_client.at_block(finalized_block).await?; - let block = at_finalized.client().blocks().at(finalized_block).await.map_err(|e| { + let block = quantus_client.client().blocks().at(finalized_block).await.map_err(|e| { crate::error::QuantusError::NetworkError(format!( "Failed to fetch finalized block {finalized_block:?}: {e:?}" )) @@ -1554,69 +1551,11 @@ pub async fn aggregate_proofs( /// with normal `multiround` paths that use rounds starting at 1. const PREPARE_PUBLIC_BATCH_ROUND_BASE: usize = 1_000_000; -fn is_prepare_artifact_name(name: &str) -> bool { - name.starts_with("batch_") || (name.starts_with("public_batch_") && name.ends_with(".hex")) -} - -fn existing_prepare_artifacts(output_dir: &std::path::Path) -> std::io::Result> { - if !output_dir.exists() { - return Ok(Vec::new()); - } - let mut hits = Vec::new(); - for entry in std::fs::read_dir(output_dir)? { - let name = entry?.file_name(); - let Some(name) = name.to_str() else { continue }; - if is_prepare_artifact_name(name) { - hits.push(name.to_string()); - } - } - hits.sort(); - Ok(hits) -} - -/// Refuse to reuse a directory that already holds prepare artifacts. A retry that -/// deposited again and then overwrote `public_batch_NNNN.hex` would strand the -/// earlier notes in the wormhole with no client-side proof. -fn ensure_fresh_prepare_output_dir(output_dir: &str) -> crate::error::Result<()> { - let path = std::path::Path::new(output_dir); - if path.exists() && !path.is_dir() { - return Err(crate::error::QuantusError::Generic(format!( - "output path {output_dir} exists and is not a directory" - ))); - } - let artifacts = existing_prepare_artifacts(path).map_err(|e| { - crate::error::QuantusError::Generic(format!( - "Failed to read output directory {output_dir}: {e}" - )) - })?; - if artifacts.is_empty() { - return Ok(()); - } - Err(crate::error::QuantusError::Generic(format!( - "output directory {output_dir} already contains unsubmitted proof artifacts ({}); \ - pass a fresh --output-dir so a retry cannot overwrite the only recovery material \ - for deposits already in the wormhole", - artifacts.join(", ") - ))) -} - -fn refuse_existing_path(path: &str) -> crate::error::Result<()> { - if std::path::Path::new(path).exists() { - Err(crate::error::QuantusError::Generic(format!( - "refusing to overwrite existing proof artifact {path}" - ))) - } else { - Ok(()) - } -} - /// Prepare `count` independent public-batch proofs **without** on-chain verify. /// /// Each batch: deposit → leaf prove → `aggregate` → `aggregate_public`. Returns /// paths to the written `public_batch_NNNN.hex` files. Funds remain in the /// wormhole until those proofs are later submitted via `verify_public_batch`. -/// Refuses an `--output-dir` that already contains prepare artifacts so a retry -/// cannot overwrite the only recovery material for earlier deposits. /// /// The wallet must contain a mnemonic (HD derivation). Prefer a dedicated /// mnemonic wallet over `crystal_*` developer wallets. @@ -1644,11 +1583,6 @@ pub async fn prepare_public_batches( ))); } - ensure_fresh_prepare_output_dir(output_dir)?; - std::fs::create_dir_all(output_dir).map_err(|e| { - crate::error::QuantusError::Generic(format!("Failed to create output directory: {e}")) - })?; - let bins_dir = crate::bins::ensure_bins_dir()?; let agg_config = CircuitBinsConfig::load(&bins_dir).map_err(|e| { crate::error::QuantusError::Generic(format!( @@ -1661,6 +1595,10 @@ pub async fn prepare_public_batches( let wallet = load_multiround_wallet(wallet_name, password, password_file)?; let minting_account = get_minting_account(quantus_client.client()).await?; + std::fs::create_dir_all(output_dir).map_err(|e| { + crate::error::QuantusError::Generic(format!("Failed to create output directory: {e}")) + })?; + let needed = amount_planck.saturating_mul(count as u128); let free = get_balance(quantus_client, &wallet.wallet_address).await?; if free < needed { @@ -1685,9 +1623,6 @@ pub async fn prepare_public_batches( for batch_idx in 0..count { let round = PREPARE_PUBLIC_BATCH_ROUND_BASE.saturating_add(batch_idx); let batch_dir = format!("{output_dir}/batch_{batch_idx:04}"); - let public_batch_file = format!("{output_dir}/public_batch_{batch_idx:04}.hex"); - refuse_existing_path(&batch_dir)?; - refuse_existing_path(&public_batch_file)?; std::fs::create_dir_all(&batch_dir).map_err(|e| { crate::error::QuantusError::Generic(format!("Failed to create {batch_dir}: {e}")) })?; @@ -1723,10 +1658,9 @@ pub async fn prepare_public_batches( .await?; let aggregated_file = format!("{batch_dir}/aggregated.hex"); - refuse_existing_path(&aggregated_file)?; aggregate_proofs(proof_files, aggregated_file.clone()).await?; - refuse_existing_path(&public_batch_file)?; + let public_batch_file = format!("{output_dir}/public_batch_{batch_idx:04}.hex"); aggregate_public_batch( vec![aggregated_file], wallet.wallet_address.clone(), @@ -2584,17 +2518,22 @@ async fn execute_initial_transfers( // Query transfer counts BEFORE submitting the batch. // The transfer_count used in the proof is the count at the time of transfer, // which equals the count before the transfer (since it increments after). - let tip_block = wormhole_tip_block(quantus_client, execution_mode).await.map_err(|e| { - crate::error::QuantusError::Generic(format!( - "Failed to get tip block for transfer counts: {}", - e - )) - })?; + let client = quantus_client.client(); + let tip_block_hash = wormhole_tip_block(quantus_client, execution_mode) + .await + .map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to get tip block for transfer counts: {}", + e + )) + })? + .hash(); let mut transfer_counts_before: Vec = Vec::with_capacity(num_proofs); for secret in secrets.iter() { let wormhole_address = SubxtAccountId(*secret.address()); - let count = tip_block + let count = client .storage() + .at(tip_block_hash) .fetch(&quantus_node::api::storage().wormhole().transfer_count(wormhole_address)) .await .map_err(|e| { @@ -4032,14 +3971,19 @@ async fn run_dissolve( let initial_secret = derive_wormhole_secret(&wallet.mnemonic, 0, 1)?; let wormhole_address = SubxtAccountId(*initial_secret.address()); - let tip_block = wormhole_tip_block(&quantus_client, execution_mode).await.map_err(|e| { - crate::error::QuantusError::Generic(format!( - "Failed to get tip block for dissolve transfer count: {}", - e - )) - })?; - let transfer_count_before = tip_block + let tip_block_hash = wormhole_tip_block(&quantus_client, execution_mode) + .await + .map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to get tip block for dissolve transfer count: {}", + e + )) + })? + .hash(); + let transfer_count_before = quantus_client + .client() .storage() + .at(tip_block_hash) .fetch(&quantus_node::api::storage().wormhole().transfer_count(wormhole_address.clone())) .await .map_err(|e| { @@ -4935,45 +4879,6 @@ mod tests { assert_eq!(read_proof_file(path).unwrap(), proof_bytes); } - #[test] - fn prepare_output_dir_rejects_existing_public_batch_and_batch_dir() { - let dir = tempfile::tempdir().expect("temp dir"); - let path = dir.path(); - assert!(ensure_fresh_prepare_output_dir(path.to_str().unwrap()).is_ok()); - - std::fs::write(path.join("notes.txt"), b"ok").unwrap(); - assert!( - ensure_fresh_prepare_output_dir(path.to_str().unwrap()).is_ok(), - "unrelated files must not block a fresh prepare" - ); - - std::fs::write(path.join("public_batch_0000.hex"), b"proof").unwrap(); - let err = ensure_fresh_prepare_output_dir(path.to_str().unwrap()).unwrap_err().to_string(); - assert!( - err.contains("public_batch_0000.hex") && err.contains("fresh --output-dir"), - "expected occupied-dir error, got: {err}" - ); - - std::fs::remove_file(path.join("public_batch_0000.hex")).unwrap(); - std::fs::create_dir(path.join("batch_0000")).unwrap(); - let err = ensure_fresh_prepare_output_dir(path.to_str().unwrap()).unwrap_err().to_string(); - assert!( - err.contains("batch_0000"), - "expected batch dir to count as an artifact, got: {err}" - ); - } - - #[test] - fn refuse_existing_path_blocks_overwrite() { - let dir = tempfile::tempdir().expect("temp dir"); - let path = dir.path().join("public_batch_0000.hex"); - let path_str = path.to_str().unwrap(); - refuse_existing_path(path_str).expect("missing path is fine"); - std::fs::write(&path, b"old").unwrap(); - let err = refuse_existing_path(path_str).unwrap_err().to_string(); - assert!(err.contains("refusing to overwrite"), "got: {err}"); - } - #[test] fn test_read_proof_file_errors() { // File not found From 26b5f542ffb06948e82b4f4421746d753a1f118f Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 8 Sep 2026 16:19:52 +0800 Subject: [PATCH 4/5] fix: restore occupied-dir guard for prepare-public-batches The follow-up storage.at(hash) commit dropped the P1 overwrite check. Refuse existing public_batch_*.hex and batch_* artifacts before any deposit. Co-authored-by: Cursor --- src/cli/wormhole.rs | 115 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 6 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 9fd4e46..11c8471 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -871,7 +871,8 @@ pub enum WormholeCommands { #[arg(long)] password_file: Option, - /// Output directory for proof files + /// Output directory for proof files. Must not already contain + /// `public_batch_*.hex` or `batch_*` artifacts from a previous run. #[arg(short, long, default_value = "/tmp/wormhole_public_batches")] output_dir: String, }, @@ -1551,11 +1552,69 @@ pub async fn aggregate_proofs( /// with normal `multiround` paths that use rounds starting at 1. const PREPARE_PUBLIC_BATCH_ROUND_BASE: usize = 1_000_000; +fn is_prepare_artifact_name(name: &str) -> bool { + name.starts_with("batch_") || (name.starts_with("public_batch_") && name.ends_with(".hex")) +} + +fn existing_prepare_artifacts(output_dir: &std::path::Path) -> std::io::Result> { + if !output_dir.exists() { + return Ok(Vec::new()); + } + let mut hits = Vec::new(); + for entry in std::fs::read_dir(output_dir)? { + let name = entry?.file_name(); + let Some(name) = name.to_str() else { continue }; + if is_prepare_artifact_name(name) { + hits.push(name.to_string()); + } + } + hits.sort(); + Ok(hits) +} + +/// Refuse to reuse a directory that already holds prepare artifacts. A retry that +/// deposited again and then overwrote `public_batch_NNNN.hex` would strand the +/// earlier notes in the wormhole with no client-side proof. +fn ensure_fresh_prepare_output_dir(output_dir: &str) -> crate::error::Result<()> { + let path = std::path::Path::new(output_dir); + if path.exists() && !path.is_dir() { + return Err(crate::error::QuantusError::Generic(format!( + "output path {output_dir} exists and is not a directory" + ))); + } + let artifacts = existing_prepare_artifacts(path).map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to read output directory {output_dir}: {e}" + )) + })?; + if artifacts.is_empty() { + return Ok(()); + } + Err(crate::error::QuantusError::Generic(format!( + "output directory {output_dir} already contains unsubmitted proof artifacts ({}); \ + pass a fresh --output-dir so a retry cannot overwrite the only recovery material \ + for deposits already in the wormhole", + artifacts.join(", ") + ))) +} + +fn refuse_existing_path(path: &str) -> crate::error::Result<()> { + if std::path::Path::new(path).exists() { + Err(crate::error::QuantusError::Generic(format!( + "refusing to overwrite existing proof artifact {path}" + ))) + } else { + Ok(()) + } +} + /// Prepare `count` independent public-batch proofs **without** on-chain verify. /// /// Each batch: deposit → leaf prove → `aggregate` → `aggregate_public`. Returns /// paths to the written `public_batch_NNNN.hex` files. Funds remain in the /// wormhole until those proofs are later submitted via `verify_public_batch`. +/// Refuses an `--output-dir` that already contains prepare artifacts so a retry +/// cannot overwrite the only recovery material for earlier deposits. /// /// The wallet must contain a mnemonic (HD derivation). Prefer a dedicated /// mnemonic wallet over `crystal_*` developer wallets. @@ -1583,6 +1642,11 @@ pub async fn prepare_public_batches( ))); } + ensure_fresh_prepare_output_dir(output_dir)?; + std::fs::create_dir_all(output_dir).map_err(|e| { + crate::error::QuantusError::Generic(format!("Failed to create output directory: {e}")) + })?; + let bins_dir = crate::bins::ensure_bins_dir()?; let agg_config = CircuitBinsConfig::load(&bins_dir).map_err(|e| { crate::error::QuantusError::Generic(format!( @@ -1595,10 +1659,6 @@ pub async fn prepare_public_batches( let wallet = load_multiround_wallet(wallet_name, password, password_file)?; let minting_account = get_minting_account(quantus_client.client()).await?; - std::fs::create_dir_all(output_dir).map_err(|e| { - crate::error::QuantusError::Generic(format!("Failed to create output directory: {e}")) - })?; - let needed = amount_planck.saturating_mul(count as u128); let free = get_balance(quantus_client, &wallet.wallet_address).await?; if free < needed { @@ -1623,6 +1683,9 @@ pub async fn prepare_public_batches( for batch_idx in 0..count { let round = PREPARE_PUBLIC_BATCH_ROUND_BASE.saturating_add(batch_idx); let batch_dir = format!("{output_dir}/batch_{batch_idx:04}"); + let public_batch_file = format!("{output_dir}/public_batch_{batch_idx:04}.hex"); + refuse_existing_path(&batch_dir)?; + refuse_existing_path(&public_batch_file)?; std::fs::create_dir_all(&batch_dir).map_err(|e| { crate::error::QuantusError::Generic(format!("Failed to create {batch_dir}: {e}")) })?; @@ -1658,9 +1721,10 @@ pub async fn prepare_public_batches( .await?; let aggregated_file = format!("{batch_dir}/aggregated.hex"); + refuse_existing_path(&aggregated_file)?; aggregate_proofs(proof_files, aggregated_file.clone()).await?; - let public_batch_file = format!("{output_dir}/public_batch_{batch_idx:04}.hex"); + refuse_existing_path(&public_batch_file)?; aggregate_public_batch( vec![aggregated_file], wallet.wallet_address.clone(), @@ -4879,6 +4943,45 @@ mod tests { assert_eq!(read_proof_file(path).unwrap(), proof_bytes); } + #[test] + fn prepare_output_dir_rejects_existing_public_batch_and_batch_dir() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path(); + assert!(ensure_fresh_prepare_output_dir(path.to_str().unwrap()).is_ok()); + + std::fs::write(path.join("notes.txt"), b"ok").unwrap(); + assert!( + ensure_fresh_prepare_output_dir(path.to_str().unwrap()).is_ok(), + "unrelated files must not block a fresh prepare" + ); + + std::fs::write(path.join("public_batch_0000.hex"), b"proof").unwrap(); + let err = ensure_fresh_prepare_output_dir(path.to_str().unwrap()).unwrap_err().to_string(); + assert!( + err.contains("public_batch_0000.hex") && err.contains("fresh --output-dir"), + "expected occupied-dir error, got: {err}" + ); + + std::fs::remove_file(path.join("public_batch_0000.hex")).unwrap(); + std::fs::create_dir(path.join("batch_0000")).unwrap(); + let err = ensure_fresh_prepare_output_dir(path.to_str().unwrap()).unwrap_err().to_string(); + assert!( + err.contains("batch_0000"), + "expected batch dir to count as an artifact, got: {err}" + ); + } + + #[test] + fn refuse_existing_path_blocks_overwrite() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("public_batch_0000.hex"); + let path_str = path.to_str().unwrap(); + refuse_existing_path(path_str).expect("missing path is fine"); + std::fs::write(&path, b"old").unwrap(); + let err = refuse_existing_path(path_str).unwrap_err().to_string(); + assert!(err.contains("refusing to overwrite"), "got: {err}"); + } + #[test] fn test_read_proof_file_errors() { // File not found From a3d53e9538a9c71b03c4ca439908a6627f58f4f8 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 8 Sep 2026 18:06:32 +0800 Subject: [PATCH 5/5] reduce log verbosity --- src/cli/wormhole.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 11c8471..05f3843 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -2039,7 +2039,7 @@ async fn collect_wormhole_events_for_extrinsic( if let subxt::events::Phase::ApplyExtrinsic(ext_idx) = event.phase() { if ext_idx == our_ext_idx { - log_print!(" Event: {}::{}", event.pallet_name(), event.variant_name()); + log_verbose!(" Event: {}::{}", event.pallet_name(), event.variant_name()); // Decode ExtrinsicFailed to get the specific error if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) =