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 5f3f083..05f3843 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -841,6 +841,41 @@ 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. 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, + }, /// Parse and display the contents of a proof file (for debugging) ParseProof { /// Path to the proof file (hex-encoded) @@ -1132,6 +1167,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 { @@ -1282,8 +1349,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 @@ -1300,8 +1366,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:?}" )) @@ -1483,6 +1548,197 @@ 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; + +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. +#[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 + ))); + } + + 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!( + "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?; + + 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}"); + 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}")) + })?; + + 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"); + refuse_existing_path(&aggregated_file)?; + aggregate_proofs(proof_files, aggregated_file.clone()).await?; + + refuse_existing_path(&public_batch_file)?; + 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 @@ -1783,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, .. })) = @@ -2326,17 +2582,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| { @@ -3774,14 +4035,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| { @@ -4677,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 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 5d9ff08..a41c1a8 100644 Binary files a/src/quantus_metadata.scale and b/src/quantus_metadata.scale differ