From 0376f15626038924b5abb79ae091456a94acdd87 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 11 Jun 2026 21:34:44 +0200 Subject: [PATCH 1/5] feat(swift-sdk): seed shielded pool notes from the example app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dash Platform enforces a 250-note anonymity-set minimum on every outgoing shielded transition. After a devnet reset without the DRIVE_SHIELDED_SNAPSHOT genesis ingest the pool starts empty and the whole shielded feature set is unusable. This adds a one-tap "Seed Pool Notes" utility to SwiftExampleApp that drives the pool up to a target note count in batches. - rs-dpp: `build_output_only_bundle` takes a `dummy_outputs` count and pads the bundle with zero-value outputs to fresh random addresses (unspendable anonymity-set fillers); threaded through both ShieldFromAssetLock builders. 0 preserves existing behavior exactly. - rs-platform-wallet: `shielded_seed_pool_notes` orchestrates ShieldFromAssetLock batches (1 real note to the wallet's own address + up to 5 fillers) until getShieldedNotesCount reaches the target, with per-batch progress, a hard mainnet gate, and retry-with-pause on FinalityTimeout (rapid batches can outrun core's unconfirmed-ancestor chain limit until a block lands). - Batch size is 6 actions, NOT the 16-action consensus cap: the Halo 2 proof grows ~2,681 B per on-wire action, so 6 actions (19,018 B) is the most that fits the 20 KiB max_state_transition_size / tenderdash max-tx-bytes (7 actions = 21,699 B is rejected as "Tx too large"). The stale "16 actions ≈ 11.8 KB" rationale comment in system_limits is corrected, and a new signing test pins the 6-action batch under max_state_transition_size with a real proof. - FFI + Swift: `platform_wallet_manager_shielded_seed_pool_notes` with a progress callback; SeedShieldedPoolView sheet (non-mainnet only). Verified live on devnet paloma (rc.1): seeded the pool 2 → 250 notes in 43 batches, after which the first IdentityCreateFromShieldedPool on the network executed cleanly (pool -0.1 DASH exactly, chain advancing). Co-Authored-By: Claude Fable 5 --- packages/rs-dpp/src/shielded/builder/mod.rs | 88 +++- .../rs-dpp/src/shielded/builder/shield.rs | 4 +- .../builder/shield_from_asset_lock.rs | 40 +- .../signing_tests.rs | 91 ++++ .../src/version/system_limits/v1.rs | 8 +- .../src/version/system_limits/v2.rs | 8 +- .../src/shielded_send.rs | 135 ++++++ .../wallet/shielded/fund_from_asset_lock.rs | 56 ++- .../src/wallet/shielded/mod.rs | 2 + .../src/wallet/shielded/seed_pool.rs | 408 ++++++++++++++++++ ...PlatformWalletManagerShieldedFunding.swift | 140 ++++++ .../Core/Views/WalletDetailView.swift | 22 + .../Views/SeedShieldedPoolView.swift | 358 +++++++++++++++ 13 files changed, 1327 insertions(+), 33 deletions(-) create mode 100644 packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SeedShieldedPoolView.swift diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index 872d8c4629..5d1216f082 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -50,9 +50,10 @@ pub use unshield::build_unshield_transition; use grovedb_commitment_tree::{ Anchor, Authorized, Builder, Bundle, BundleType, DashMemo, Flags as OrchardFlags, FullViewingKey, MerklePath, Note, NoteValue, OutgoingViewingKey, PaymentAddress, ProvingKey, - Scope, SpendAuthorizingKey, + Scope, SpendAuthorizingKey, SpendingKey, }; use rand::rngs::OsRng; +use rand::RngCore; use crate::address_funds::OrchardAddress; use crate::shielded::{compute_platform_sighash, SerializedAction}; @@ -143,22 +144,55 @@ pub fn serialize_authorized_bundle(bundle: &Bundle) - // Internal helpers // --------------------------------------------------------------------------- +/// Generates a fresh random Orchard payment address with no recoverable +/// spending authority retained by anyone. +/// +/// Draws 32 random bytes for an Orchard `SpendingKey` (retrying on the +/// rare invalid draw — `SpendingKey::from_bytes` returns a `CtOption`), +/// derives its `FullViewingKey`, and returns the External-scope address +/// at diversifier index 0. The spending key is dropped here, so the +/// resulting address is unspendable by this process — exactly what a +/// zero-value anonymity-set filler output wants. +fn random_orchard_payment_address() -> PaymentAddress { + let mut rng = OsRng; + loop { + let mut bytes = [0u8; 32]; + rng.fill_bytes(&mut bytes); + if let Some(sk) = Option::::from(SpendingKey::from_bytes(bytes)) { + let fvk = FullViewingKey::from(&sk); + return fvk.address_at(0u32, Scope::External); + } + } +} + /// Builds an output-only Orchard bundle (no spends). /// /// Used by Shield and ShieldFromAssetLock transitions where funds enter /// the shielded pool from transparent sources. /// -/// `sender_ovk` encrypts the output's `out_ciphertext` (Zcash +/// `sender_ovk` encrypts the real output's `out_ciphertext` (Zcash /// outgoing-transaction-history convention): with `Some`, the sender can /// later recover the note (recipient, value, memo) from chain data via /// `try_recover_outgoing_note` under that OVK. With `None`, a random /// outgoing cipher key is used and the sent note is unrecoverable by /// anyone. Orchard's padding outputs always use `None`. +/// +/// `dummy_outputs` adds that many extra **zero-value** outputs after the +/// real one, each to a fresh random Orchard address with `sender_ovk = +/// None` and an empty memo. They are unrecoverable by anyone (no party +/// holds the spending key) — they exist purely as anonymity-set filler +/// so a single transition can grow the on-chain note count. With +/// `dummy_outputs == 0` the bundle is byte-class identical to the +/// historical single-output form (Orchard still pads to its 2-action +/// minimum). The on-wire action count is +/// `max(1 + dummy_outputs, 2)` and the `value_balance` is unchanged +/// (the dummies contribute zero value). pub(crate) fn build_output_only_bundle( recipient: &OrchardAddress, amount: u64, memo: [u8; 36], sender_ovk: Option, + dummy_outputs: usize, prover: &P, ) -> Result, ProtocolError> { let payment_address = PaymentAddress::from(recipient); @@ -180,6 +214,17 @@ pub(crate) fn build_output_only_bundle( ) .map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to add output: {:?}", e)))?; + // Anonymity-set filler: zero-value outputs to fresh random addresses, + // each with `None` OVK and an empty memo (unrecoverable by anyone). + for _ in 0..dummy_outputs { + let filler_address = random_orchard_payment_address(); + builder + .add_output(None, filler_address, NoteValue::from_raw(0), [0u8; 36]) + .map_err(|e| { + ProtocolError::ShieldedBuildError(format!("failed to add dummy output: {:?}", e)) + })?; + } + prove_and_sign_bundle(builder, prover, &[], &[]) } @@ -409,7 +454,7 @@ mod mod_tests { #[test] fn output_only_bundle_flags_and_value_balance() { let recipient = test_orchard_address(); - let bundle = build_output_only_bundle(&recipient, 10_000, [0u8; 36], None, &TestProver) + let bundle = build_output_only_bundle(&recipient, 10_000, [0u8; 36], None, 0, &TestProver) .expect("bundle should build"); // Spends are disabled for Shield / ShieldFromAssetLock bundles. @@ -423,6 +468,40 @@ mod mod_tests { ); } + // ------------------------------------------------------------------ + // `build_output_only_bundle` dummy-output padding — the on-wire + // action count is `max(1 + dummy_outputs, 2)` (Orchard pads an + // output-only bundle to its 2-action minimum) and the dummies are + // zero-value, so the bundle's `value_balance` still equals exactly + // the real recipient amount. This is the invariant the pool-seeding + // flow relies on: one transition can publish up to 16 actions, all + // but one carrying no value. + // ------------------------------------------------------------------ + + #[test] + fn dummy_output_padding_action_count_and_value_balance() { + let recipient = test_orchard_address(); + let amount = 10_000u64; + + // (dummy_outputs, expected on-wire action count). + for (dummies, expected_actions) in [(0usize, 2usize), (1, 2), (15, 16)] { + let bundle = + build_output_only_bundle(&recipient, amount, [0u8; 36], None, dummies, &TestProver) + .expect("bundle should build"); + assert_eq!( + bundle.actions().len(), + expected_actions, + "dummy_outputs={dummies} should serialize to {expected_actions} actions" + ); + // Dummies are zero-value: net value entering the pool is unchanged. + assert_eq!( + *bundle.value_balance(), + -(amount as i64), + "value_balance must equal the real amount regardless of dummy_outputs ({dummies})" + ); + } + } + // ------------------------------------------------------------------ // `serialize_authorized_bundle` — verify the mapping from a fully // authorized bundle into the raw state-transition fields. @@ -431,7 +510,7 @@ mod mod_tests { #[test] fn serialize_authorized_bundle_preserves_fields() { let recipient = test_orchard_address(); - let bundle = build_output_only_bundle(&recipient, 7_777, [3u8; 36], None, &TestProver) + let bundle = build_output_only_bundle(&recipient, 7_777, [3u8; 36], None, 0, &TestProver) .expect("bundle should build"); let sb = serialize_authorized_bundle(&bundle); @@ -479,6 +558,7 @@ mod mod_tests { amount, memo, Some(sender_ovk.clone()), + 0, &TestProver, ) .expect("bundle should build"); diff --git a/packages/rs-dpp/src/shielded/builder/shield.rs b/packages/rs-dpp/src/shielded/builder/shield.rs index e1a329e559..7f8ffbbf8b 100644 --- a/packages/rs-dpp/src/shielded/builder/shield.rs +++ b/packages/rs-dpp/src/shielded/builder/shield.rs @@ -52,7 +52,9 @@ pub async fn build_shield_transition, P: OrchardProve )); } - let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?; + // Shield (Type 15) never pads with anonymity-set fillers — only the + // Type 18 ShieldFromAssetLock pool-seeding path does (`dummy_outputs`). + let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, 0, prover)?; let sb = serialize_authorized_bundle(&bundle); ShieldTransition::try_from_bundle_with_signer( diff --git a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs index c81766eec0..cb9a3d7050 100644 --- a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs +++ b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs @@ -28,6 +28,10 @@ use super::{build_output_only_bundle, serialize_authorized_bundle, OrchardProver /// - `surplus_output` - Optional platform address that receives the asset-lock surplus /// (`asset_lock_value − shield_amount − fee`); when `None`, the surplus is added to the fee /// pools, capped at `shielded_implicit_fee_cap` +/// - `dummy_outputs` - Number of extra zero-value anonymity-set filler outputs to append after +/// the real recipient output (unrecoverable random addresses, `None` OVK, empty memo). `0` +/// reproduces the historical single-output bundle exactly. The on-wire action count becomes +/// `max(1 + dummy_outputs, 2)`, which consensus prices the fee from — see the pool-seeding flow. /// - `platform_version` - Protocol version #[allow(clippy::too_many_arguments)] pub fn build_shield_from_asset_lock_transition( @@ -39,9 +43,17 @@ pub fn build_shield_from_asset_lock_transition( memo: [u8; 36], sender_ovk: Option, surplus_output: Option, + dummy_outputs: usize, platform_version: &PlatformVersion, ) -> Result { - let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?; + let bundle = build_output_only_bundle( + recipient, + shield_amount, + memo, + sender_ovk, + dummy_outputs, + prover, + )?; let sb = serialize_authorized_bundle(&bundle); // For output-only bundles, Orchard value_balance is negative (value flowing in). @@ -91,6 +103,10 @@ pub fn build_shield_from_asset_lock_transition( /// - `surplus_output` - Optional platform address that receives the asset-lock surplus /// (`asset_lock_value − shield_amount − fee`); when `None`, the surplus is added to the fee /// pools, capped at `shielded_implicit_fee_cap` +/// - `dummy_outputs` - Number of extra zero-value anonymity-set filler outputs to append after +/// the real recipient output (unrecoverable random addresses, `None` OVK, empty memo). `0` +/// reproduces the historical single-output bundle exactly. The on-wire action count becomes +/// `max(1 + dummy_outputs, 2)`, which consensus prices the fee from — see the pool-seeding flow. /// - `platform_version` - Protocol version #[cfg(feature = "core_key_wallet")] #[allow(clippy::too_many_arguments)] @@ -104,13 +120,21 @@ pub async fn build_shield_from_asset_lock_transition_with_signer( memo: [u8; 36], sender_ovk: Option, surplus_output: Option, + dummy_outputs: usize, platform_version: &PlatformVersion, ) -> Result where P: OrchardProver, AS: ::key_wallet::signer::Signer, { - let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?; + let bundle = build_output_only_bundle( + recipient, + shield_amount, + memo, + sender_ovk, + dummy_outputs, + prover, + )?; let sb = serialize_authorized_bundle(&bundle); // For output-only bundles, Orchard value_balance is negative (value flowing in). @@ -153,7 +177,7 @@ mod tests { let recipient = test_orchard_address(); let amount = 50_000u64; - let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], None, &TestProver) + let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], None, 0, &TestProver) .expect("bundle should build successfully"); let sb = serialize_authorized_bundle(&bundle); @@ -181,8 +205,9 @@ mod tests { #[test] fn test_output_only_bundle_serializes_to_min_actions() { let recipient = test_orchard_address(); - let bundle = build_output_only_bundle(&recipient, 50_000u64, [0u8; 36], None, &TestProver) - .expect("bundle should build"); + let bundle = + build_output_only_bundle(&recipient, 50_000u64, [0u8; 36], None, 0, &TestProver) + .expect("bundle should build"); let sb = serialize_authorized_bundle(&bundle); assert_eq!( sb.actions.len(), @@ -223,8 +248,9 @@ mod tests { // negative value_balance equal in magnitude to the requested amount. for amount in [1u64, 100, 1_000_000, u32::MAX as u64] { let recipient = test_orchard_address(); - let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], None, &TestProver) - .expect("bundle should build"); + let bundle = + build_output_only_bundle(&recipient, amount, [0u8; 36], None, 0, &TestProver) + .expect("bundle should build"); let sb = serialize_authorized_bundle(&bundle); assert_eq!( sb.value_balance, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index 23fadaf3c4..32be2c077d 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -239,6 +239,7 @@ async fn build_shield_from_asset_lock_transition_with_signer_end_to_end() { [0u8; 36], None, // sender_ovk None, // surplus_output + 0, // dummy_outputs PlatformVersion::latest(), ) .await @@ -259,3 +260,93 @@ async fn build_shield_from_asset_lock_transition_with_signer_end_to_end() { "output-only bundle must produce at least one Orchard action", ); } + +#[tokio::test] +async fn build_shield_from_asset_lock_transition_with_signer_dummy_outputs_pad_actions() { + // Pool-seeding path: `dummy_outputs = 15` makes a 16-action + // (1 real + 15 zero-value filler) ShieldFromAssetLock. The on-wire + // action count is what consensus prices the fee from, and the real + // recipient amount stays the transition's `value_balance` (the + // fillers carry no value). + use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver}; + + let recipient = test_orchard_address(); + let signer = FixedKeySigner::new([7u8; 32]); + let path = DerivationPath::default(); + let shield_amount = 50_000u64; + + let st = build_shield_from_asset_lock_transition_with_signer( + &recipient, + shield_amount, + make_chain_asset_lock_proof(), + &path, + &signer, + &TestProver, + [0u8; 36], + None, // sender_ovk + None, // surplus_output + 15, // dummy_outputs -> 16 on-wire actions + PlatformVersion::latest(), + ) + .await + .expect("builder should succeed"); + + let v0 = extract_v0(st); + assert_eq!( + v0.actions.len(), + 16, + "1 real + 15 dummy outputs must serialize to 16 Orchard actions", + ); + assert_eq!( + v0.value_balance, shield_amount, + "dummy outputs are zero-value: value_balance must equal the real amount", + ); +} + +#[tokio::test] +async fn seed_pool_batch_fits_max_state_transition_size() { + // The pool-seeding batch size (MAX_ACTIONS_PER_BATCH = 6 in + // rs-platform-wallet's seed_pool.rs) is bounded by the 20 KiB + // transaction-size limit, not the 16-action consensus cap: the Halo 2 + // proof grows ~2,681 bytes per action (measured: 2 actions → 8,294 B, + // 6 → 19,018 B, 7 → 21,699 B — the last rejected by tenderdash's + // `mempool.max-tx-bytes = 20480` as "Tx too large"). Pin the largest + // seeding batch under `system_limits.max_state_transition_size` so a + // proof- or action-encoding size change that breaks seeding fails here + // instead of on a devnet. + use crate::serialization::PlatformSerializable; + use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver}; + + let platform_version = PlatformVersion::latest(); + let recipient = test_orchard_address(); + let signer = FixedKeySigner::new([7u8; 32]); + let path = DerivationPath::default(); + + let st = build_shield_from_asset_lock_transition_with_signer( + &recipient, + 50_000u64, + make_chain_asset_lock_proof(), + &path, + &signer, + &TestProver, + [0u8; 36], + None, // sender_ovk + None, // surplus_output + 5, // dummy_outputs -> 6 on-wire actions, the seeding batch max + platform_version, + ) + .await + .expect("builder should succeed"); + + let bytes = st.serialize_to_bytes().expect("serialize"); + let max = platform_version.system_limits.max_state_transition_size as usize; + assert!( + bytes.len() <= max, + "a 6-action seeding batch must fit max_state_transition_size: {} > {}", + bytes.len(), + max, + ); + + let v0 = extract_v0(st); + assert_eq!(v0.actions.len(), 6); +} diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index d374c85802..3d3ab899d2 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -26,6 +26,12 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { min_withdrawal_amount: 190_000, max_contract_group_size: 256, max_token_redemption_cycles: 128, - // 16 actions x 408 bytes + ~5,305 bytes overhead = ~11,833 bytes (within 20 KiB max_state_transition_size) + // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on + // top of the 408 B serialized action), so a transition's on-wire size is + // ~2,681 B per action + ~2,930 B fixed (measured: 2 actions → 8,294 B, + // 6 → 19,018 B). The effective per-transition action bound under the + // 20 KiB `max_state_transition_size` is therefore 6, NOT this cap — 16 + // only becomes reachable if the size limit is raised. Pinned by dpp's + // `seed_pool_batch_fits_max_state_transition_size` signing test. max_shielded_transition_actions: 16, }; diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 68ae793ae6..04d2f49035 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -16,6 +16,12 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12) max_contract_group_size: 256, max_token_redemption_cycles: 128, - // 16 actions x 408 bytes + ~5,305 bytes overhead = ~11,833 bytes (within 20 KiB max_state_transition_size) + // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on + // top of the 408 B serialized action), so a transition's on-wire size is + // ~2,681 B per action + ~2,930 B fixed (measured: 2 actions → 8,294 B, + // 6 → 19,018 B). The effective per-transition action bound under the + // 20 KiB `max_state_transition_size` is therefore 6, NOT this cap — 16 + // only becomes reachable if the size limit is raised. Pinned by dpp's + // `seed_pool_batch_fits_max_state_transition_size` signing test. max_shielded_transition_actions: 16, }; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 503054c533..f8dcc7eca6 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -791,6 +791,9 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( &asset_lock_signer, &prover, surplus_output, + // Single real note, no anonymity-set fillers (the multi-note + // pool-seeding path uses its own dedicated FFI entry point). + 0, None, ) .await @@ -928,6 +931,8 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset &asset_lock_signer, &prover, surplus_output, + // Resuming a single-note fund (not a seeding batch). + 0, None, ) .await @@ -941,6 +946,136 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset PlatformWalletFFIResult::ok() } +/// Seed the shielded pool's anonymity set up to `target_total_notes` by +/// submitting a series of `ShieldFromAssetLock` (Type 18) batches, each +/// adding up to 16 notes (1 real note to the wallet's own default +/// shielded address + up to 15 zero-value anonymity-set fillers). +/// +/// Devnet/testnet ONLY — the Rust side hard-errors on `Network::Mainnet` +/// (the mainnet pool is seeded at genesis via `DRIVE_SHIELDED_SNAPSHOT`). +/// This exists so a freshly-reset devnet can satisfy the 250-note +/// outgoing-transition minimum from the example app in one action. +/// +/// The asset-lock-proof signature for each batch is produced by a +/// `MnemonicResolverHandle` — the raw key never crosses the FFI boundary. +/// +/// Batches run serially; each waits for proven execution before the next +/// starts (so a 250-note seed is roughly 16 batches and tens of minutes). +/// `progress_fn`, when non-null, is invoked before and after each batch +/// with the live counters so the host can render a progress UI. It is +/// called from a background worker thread — the host trampoline is +/// responsible for hopping to its own UI executor. +/// +/// `account` is the shielded BIP44 account whose default address receives +/// each real note (must be bound via `bind_shielded`). `funding_account_index` +/// is the Core BIP44 account whose UTXOs fund each per-batch asset lock. +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle` produced by +/// `dash_sdk_mnemonic_resolver_create`. The caller retains ownership and +/// must keep it alive for the duration of this (blocking) call. +/// - `progress_fn`, when non-null, must be a valid C function pointer for +/// the duration of the call; `progress_ctx` is passed to it opaquely and +/// must remain valid for the duration of the call (or be null). +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_shielded_seed_pool_notes( + handle: Handle, + wallet_id_bytes: *const u8, + account: u32, + target_total_notes: u64, + funding_account_index: u32, + core_signer_handle: *mut MnemonicResolverHandle, + progress_fn: Option< + unsafe extern "C" fn( + context: *mut std::os::raw::c_void, + batch_index: u64, + batches_total_estimate: u64, + pool_notes_now: u64, + target: u64, + ), + >, + progress_ctx: *mut std::os::raw::c_void, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(core_signer_handle); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + + let wallet = match resolve_wallet(handle, &wallet_id) { + Ok(w) => w, + Err(result) => return result, + }; + let network = wallet.network(); + + // Round-trip the resolver handle and the progress context through + // `usize` so the worker future's capture is `Send + 'static`. The + // caller's documented contract pins both alive for the (blocking) + // duration of this call, and `block_on_worker` blocks the calling + // frame until the task completes. + let core_signer_addr = core_signer_handle as usize; + let progress_ctx_addr = progress_ctx as usize; + + // Run the proof + broadcast loop on a worker thread (8 MB stack): + // Halo 2 circuit synthesis recurses past the ~512 KB iOS dispatch + // thread stack. + let result = block_on_worker(async move { + // SAFETY: see the fn-level safety doc — the resolver handle is + // pinned alive for the duration of this synchronously-awaited task. + let asset_lock_signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + + // Bridge the C progress callback into the Rust `Fn(SeedPoolProgress)`. + // The fn pointer is `Send` and the context is moved as a `usize`; + // both are re-materialized inside this task. A null `progress_fn` + // makes the closure a no-op. + let progress = move |p: platform_wallet::wallet::shielded::SeedPoolProgress| { + if let Some(cb) = progress_fn { + // SAFETY: `progress_ctx` (re-materialized from `progress_ctx_addr`) + // and `cb` are valid for the duration of this call per the + // fn-level contract. + unsafe { + cb( + progress_ctx_addr as *mut std::os::raw::c_void, + p.batch_index, + p.batches_total_estimate, + p.pool_notes_now, + p.target, + ); + } + } + }; + + wallet + .shielded_seed_pool_notes( + &wallet_id, + account, + target_total_notes, + funding_account_index, + &asset_lock_signer, + progress, + None, + ) + .await + }); + + match result { + Ok(_outcome) => PlatformWalletFFIResult::ok(), + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("shielded seed-pool-notes failed: {e}"), + ), + } +} + /// Resolve the wallet `Arc` for the given manager handle, or /// produce a `PlatformWalletFFIResult` describing why we couldn't. fn resolve_wallet( diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 5a41f80622..c9ad18f738 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -45,17 +45,23 @@ use crate::wallet::asset_lock::orchestration::{ use crate::wallet::PlatformWallet; use crate::PlatformWalletError; -/// Number of Orchard actions in a `ShieldFromAssetLock` (and `Shield`) bundle. +/// On-wire Orchard action count for a `ShieldFromAssetLock` bundle with +/// `dummy_outputs` anonymity-set fillers appended after the single real +/// output. /// -/// Both transitions build an *output-only* bundle with a single output via -/// `dpp::shielded::builder::build_output_only_bundle`, which configures Orchard's +/// `build_output_only_bundle` configures Orchard's /// `BundleType::Transactional { flags: SPENDS_DISABLED, bundle_required: false }`. -/// For one output and zero spends, Orchard's `num_actions` is -/// `max(max(0, 1), MIN_ACTIONS) == max(1, 2) == 2`, so the serialized bundle -/// always carries exactly two actions. Consensus prices the flat shielded fee -/// from the on-wire `actions.len()`, so this constant must match — see the +/// For `1 + dummy_outputs` outputs and zero spends, Orchard's `num_actions` +/// is `max(1 + dummy_outputs, MIN_ACTIONS)` where `MIN_ACTIONS == 2`. Consensus +/// prices the flat shielded fee from the on-wire `actions.len()` +/// (`transform_into_action` Step 3b), so the wallet's fee reservation MUST be +/// computed from this exact count or the transition is rejected — see the /// `validate_structure` / `transform_into_action` checks in rs-dpp / rs-drive-abci. -const SHIELD_FROM_ASSET_LOCK_NUM_ACTIONS: usize = 2; +/// +/// With `dummy_outputs == 0` this returns `2`, the historical single-output count. +pub(crate) fn shield_from_asset_lock_num_actions(dummy_outputs: usize) -> usize { + (1 + dummy_outputs).max(2) +} impl PlatformWallet { /// Fund the shielded pool from a Core L1 asset lock, with the @@ -122,12 +128,17 @@ impl PlatformWallet { asset_lock_signer: &AS, prover: P, surplus_output: Option, + dummy_outputs: usize, settings: Option, ) -> Result<(), PlatformWalletError> where AS: ::key_wallet::signer::Signer + Send + Sync, P: OrchardProver, { + // On-wire Orchard action count = max(1 real + dummy_outputs, 2). Consensus + // prices the flat shielded fee from this count, so the wallet's fee + // reservation below is derived from the SAME value (any mismatch is rejected). + let num_actions = shield_from_asset_lock_num_actions(dummy_outputs); // Step 1: pre-flight. Failing fast here avoids broadcasting // an unfundable asset-lock tx (or paying for an Orchard proof // build, ~30s, only to reject downstream). @@ -156,7 +167,7 @@ impl PlatformWallet { {CREDITS_PER_DUFF} credits/duff > u64::MAX)" )) })?; - let pool_fee_credits = self.shield_from_asset_lock_pool_fee()?; + let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; if lock_credits <= pool_fee_credits { return Err(PlatformWalletError::ShieldedBuildError(format!( "asset lock ({lock_credits} credits, from {amount_duffs} duffs) is at or \ @@ -241,7 +252,7 @@ impl PlatformWallet { // consensus charges (`transform_into_action` Step 3b). Deriving `shield_amount = // lock_value − pool_fee` reserves room for the fee and pins the consensus surplus // (`lock_value − shield_amount − pool_fee`) to exactly zero. - let pool_fee_credits = self.shield_from_asset_lock_pool_fee()?; + let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; let shield_amount = asset_lock_value_credits .checked_sub(pool_fee_credits) .ok_or_else(|| { @@ -336,6 +347,7 @@ impl PlatformWallet { &prover, sender_ovk.clone(), surplus_output, + dummy_outputs, s, ) }) @@ -373,6 +385,7 @@ impl PlatformWallet { &prover, sender_ovk.clone(), surplus_output, + dummy_outputs, s, ) }) @@ -436,12 +449,16 @@ impl PlatformWallet { /// + asset_lock_base_cost [L1 asset-lock processing] /// ``` /// - /// `num_actions` is fixed at [`SHIELD_FROM_ASSET_LOCK_NUM_ACTIONS`] (the - /// single-output bundle always serializes to 2 Orchard actions). + /// `num_actions` is the on-wire Orchard action count of the bundle + /// (`shield_from_asset_lock_num_actions(dummy_outputs)` — `2` for the + /// classic single-output bundle, up to `16` for a pool-seeding batch). /// `asset_lock_base_cost` (`albc`) is the same constant Type 14 (address /// funding) uses, read from `dpp.state_transitions.identities.asset_locks` /// and converted duffs→credits. - fn shield_from_asset_lock_pool_fee(&self) -> Result { + pub(crate) fn shield_from_asset_lock_pool_fee( + &self, + num_actions: usize, + ) -> Result { let pv = self.sdk.version(); let albc_duffs = pv .dpp @@ -455,12 +472,11 @@ impl PlatformWallet { ({albc_duffs} duffs * {CREDITS_PER_DUFF} credits/duff > u64::MAX)" )) })?; - let shielded_fee = compute_minimum_shielded_fee(SHIELD_FROM_ASSET_LOCK_NUM_ACTIONS, pv) - .map_err(|e| { - PlatformWalletError::ShieldedBuildError(format!( - "failed to compute minimum shielded fee for ShieldFromAssetLock: {e}" - )) - })?; + let shielded_fee = compute_minimum_shielded_fee(num_actions, pv).map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "failed to compute minimum shielded fee for ShieldFromAssetLock: {e}" + )) + })?; shielded_fee.checked_add(albc).ok_or_else(|| { PlatformWalletError::ShieldedBuildError(format!( "ShieldFromAssetLock pool fee overflowed credits conversion \ @@ -534,6 +550,7 @@ async fn build_and_broadcast_shielded( prover: &P, sender_ovk: Option, surplus_output: Option, + dummy_outputs: usize, settings: Option, ) -> Result<(), dash_sdk::Error> where @@ -550,6 +567,7 @@ where [0u8; 36], sender_ovk, surplus_output, + dummy_outputs, sdk.version(), ) .await?; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index a3fdbdf367..a33e19d917 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -38,6 +38,7 @@ pub mod keys; pub mod note_selection; pub mod operations; pub mod prover; +pub mod seed_pool; pub mod store; pub mod sync; @@ -45,6 +46,7 @@ pub use coordinator::NetworkShieldedCoordinator; pub use file_store::{FileBackedShieldedStore, FileShieldedStoreError}; pub use keys::{AccountViewingKeys, OrchardKeySet}; pub use prover::CachedOrchardProver; +pub use seed_pool::{SeedPoolOutcome, SeedPoolProgress, DEFAULT_SEED_POOL_TARGET_NOTES}; pub use store::{ InMemoryShieldedStore, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, SubwalletId, }; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs new file mode 100644 index 0000000000..b24f380fef --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs @@ -0,0 +1,408 @@ +//! Orchestrated batch seeding of the shielded pool's anonymity set. +//! +//! Dash Platform's shielded pool enforces a 250-note anonymity-set +//! minimum on every *outgoing* shielded transition (transfer / unshield +//! / withdrawal / identity-create-from-pool): `validate_minimum_pool_notes` +//! in rs-drive-abci rejects them with "pool has N notes but minimum 250 +//! required". After a devnet reset *without* the `DRIVE_SHIELDED_SNAPSHOT` +//! genesis ingest, the pool starts empty and the whole shielded feature +//! set is unusable until enough notes exist. +//! +//! This module seeds the pool from the example app in one user action: +//! it submits a series of `ShieldFromAssetLock` (Type 18) transitions, +//! each carrying one real note to the wallet's own default shielded +//! address plus up to 5 **zero-value anonymity-set filler** outputs +//! (`dummy_outputs`). Each batch publishes up to 6 Orchard actions — NOT +//! the 16-action consensus cap (`max_shielded_transition_actions`), but +//! the most that fits the 20 KiB transaction-size limit; see +//! [`MAX_ACTIONS_PER_BATCH`]. Inbound transitions (Type 15 Shield, Type +//! 18 ShieldFromAssetLock) are NOT subject to the 250-note minimum, so +//! seeding runs against an empty pool. +//! +//! ## Why this is a devnet/testnet-only utility +//! +//! Seeding burns real asset-lock value (one L1 lock + the per-action +//! shielded fee per batch) purely to inflate the note count. It is a +//! development/testing convenience; the mainnet pool is seeded at genesis +//! via `DRIVE_SHIELDED_SNAPSHOT`. [`shielded_seed_pool_notes`] therefore +//! hard-errors on `Network::Mainnet`. + +use dash_sdk::platform::transition::put_settings::PutSettings; +use dash_sdk::platform::types::shielded::fetch_shielded_notes_count; +use dpp::address_funds::OrchardAddress; +use dpp::balances::credits::CREDITS_PER_DUFF; + +use crate::wallet::asset_lock::orchestration::AssetLockFunding; +use crate::wallet::shielded::fund_from_asset_lock::shield_from_asset_lock_num_actions; +use crate::wallet::shielded::CachedOrchardProver; +use crate::wallet::PlatformWallet; +use crate::PlatformWalletError; + +/// Maximum Orchard actions per seeding batch. +/// +/// The binding constraint is NOT the 16-action consensus cap +/// (`max_shielded_transition_actions`) but the 20 KiB transaction-size +/// limit (`system_limits.max_state_transition_size`, mirrored by +/// tenderdash's `mempool.max-tx-bytes = 20480`): the Halo 2 proof grows +/// with the action count, ~2,681 bytes per action on the wire. Measured +/// serialized `ShieldFromAssetLock` sizes (signing_tests size probe): +/// 2 actions → 8,294 B, 6 → 19,018 B, 7 → 21,699 B (rejected by +/// tenderdash as "Tx too large"). 6 actions is the largest batch that +/// fits, with ~1.4 KiB headroom for asset-lock-proof variants. Pinned by +/// `seed_pool_batch_fits_max_state_transition_size` in dpp's +/// shield_from_asset_lock signing tests. +const MAX_ACTIONS_PER_BATCH: usize = 6; + +/// Default total pool-note target. Matches the consensus 250-note +/// anonymity-set minimum the seeding exists to satisfy. +pub const DEFAULT_SEED_POOL_TARGET_NOTES: u64 = 250; + +/// Value (in credits) of the single *real* note each batch shields to the +/// wallet's own default address. Kept small — the point of seeding is the +/// note count, not the balance — but non-zero so the real output is a +/// spendable, recoverable note for the seeding wallet (the fillers are +/// zero-value and unrecoverable). +const REAL_NOTE_VALUE_CREDITS: u64 = 1_000_000; + +/// Attempts per batch before a `FinalityTimeout` is treated as fatal. +/// Each retry pauses [`SEED_BATCH_RETRY_PAUSE`] first, long enough for a +/// core block to confirm the chained asset-lock change outputs that +/// caused the IS/CL proofs to stall (observed around ~25-30 rapid +/// back-to-back batches, core's unconfirmed-ancestor depth limit). +const SEED_BATCH_FINALITY_RETRIES: u32 = 3; + +/// Pause before retrying a batch whose finality proof timed out. +const SEED_BATCH_RETRY_PAUSE: std::time::Duration = std::time::Duration::from_secs(60); + +/// Progress update emitted once per batch (before and after submission). +#[derive(Debug, Clone, Copy)] +pub struct SeedPoolProgress { + /// 0-based index of the batch about to run / just completed. + pub batch_index: u64, + /// Estimated total number of batches needed to reach `target` from + /// the count observed when the operation started. An estimate only — + /// concurrent activity on the pool can shift the real count. + pub batches_total_estimate: u64, + /// Pool note count observed at this checkpoint. + pub pool_notes_now: u64, + /// The target total note count the operation is driving toward. + pub target: u64, +} + +/// Terminal outcome of a seeding run. +#[derive(Debug, Clone, Copy)] +pub struct SeedPoolOutcome { + /// Pool note count observed after the final batch (and the + /// already-satisfied early-return case). + pub final_pool_notes: u64, + /// Number of `ShieldFromAssetLock` batches actually submitted. + pub batches_submitted: u64, + /// The target the run was driving toward. + pub target: u64, +} + +impl PlatformWallet { + /// Seed the shielded pool up to `target_total_notes` by submitting a + /// series of `ShieldFromAssetLock` (Type 18) batches, each adding up + /// to [`MAX_ACTIONS_PER_BATCH`] notes (1 real + up to 15 zero-value + /// anonymity-set fillers). + /// + /// Devnet/testnet-only: hard-errors on `Network::Mainnet` (the mainnet + /// pool is seeded at genesis via `DRIVE_SHIELDED_SNAPSHOT`). + /// + /// # Arguments + /// + /// * `wallet_id` — the 32-byte id of the wallet that funds the seeding + /// and owns the real notes (and whose default shielded address at + /// `account` receives them). + /// * `account` — BIP44 account index whose default Orchard address + /// receives each batch's real note. Must be bound (`bind_shielded`). + /// * `target_total_notes` — drive the on-chain pool note count up to + /// (at least) this value. If the pool already has at least this + /// many notes, the run is a no-op that returns the current count. + /// * `funding_account_index` — BIP44 Core account whose UTXOs fund + /// each per-batch asset lock. + /// * `asset_lock_signer` — external signer for each batch's asset-lock + /// proof signature (the raw key never crosses the FFI boundary). + /// * `progress` — invoked before each batch (with the count observed + /// so far) and after each batch's proven execution. Lets the host + /// render a live "batch i/~n, M/target notes" counter during the + /// ~20–40 min run. + /// * `settings` — optional `PutSettings` forwarded to each batch's + /// broadcast. + /// + /// Batches run **serially**: each waits for proven execution (the + /// same `broadcast_and_wait` the single-note fund flow uses) before + /// the next starts. A batch failure aborts the run and returns the + /// error; notes from already-completed batches stay in the pool. + #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] + pub async fn shielded_seed_pool_notes( + &self, + wallet_id: &[u8; 32], + account: u32, + target_total_notes: u64, + funding_account_index: u32, + asset_lock_signer: &AS, + progress: F, + settings: Option, + ) -> Result + where + AS: ::key_wallet::signer::Signer + Send + Sync, + F: Fn(SeedPoolProgress) + Send + Sync, + { + // HARD GATE: this is a devnet/testnet seeding utility. The mainnet + // pool is seeded at genesis (DRIVE_SHIELDED_SNAPSHOT); seeding it + // from a client would burn real value to no purpose. + if self.network() == key_wallet::Network::Mainnet { + return Err(PlatformWalletError::ShieldedBuildError( + "shielded_seed_pool_notes is a devnet/testnet utility and is disabled on mainnet \ + (Network::Mainnet) — the mainnet shielded pool is seeded at genesis via \ + DRIVE_SHIELDED_SNAPSHOT" + .to_string(), + )); + } + + // The wallet must own this id and have its shielded sub-wallet + // bound, or there's no default address to send the real notes to. + if self.wallet_id() != *wallet_id { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "shielded_seed_pool_notes called with wallet_id {} but this wallet is {}", + hex::encode(wallet_id), + hex::encode(self.wallet_id()) + ))); + } + let recipient = self.seed_pool_recipient(account).await?; + + // Snapshot the starting count so the batch-total estimate is + // stable for the whole run. + let sdk = self.sdk_arc(); + let start_notes = fetch_shielded_notes_count(&sdk).await.map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "failed to fetch shielded notes count before seeding: {e}" + )) + })?; + + let batches_total_estimate = estimate_batches( + start_notes, + target_total_notes, + MAX_ACTIONS_PER_BATCH as u64, + ); + + // Already satisfied — nothing to do. + if start_notes >= target_total_notes { + let outcome = SeedPoolOutcome { + final_pool_notes: start_notes, + batches_submitted: 0, + target: target_total_notes, + }; + progress(SeedPoolProgress { + batch_index: 0, + batches_total_estimate, + pool_notes_now: start_notes, + target: target_total_notes, + }); + return Ok(outcome); + } + + // One prover handle for the whole run (zero-sized; shares the + // process-global cached proving key). + let prover = CachedOrchardProver::new(); + + let mut pool_notes_now = start_notes; + let mut batches_submitted = 0u64; + + while pool_notes_now < target_total_notes { + let remaining = target_total_notes - pool_notes_now; + + // Notes this batch adds: 1 real + `dummy_outputs` fillers, + // capped at the per-transition action limit. On the final + // stretch, only add as many as still needed. + let notes_this_batch = + std::cmp::min(remaining, MAX_ACTIONS_PER_BATCH as u64).max(1) as usize; + // `notes_this_batch` includes the real note; the rest are + // fillers. (`max(1, ..)` above guarantees `>= 1`.) + let dummy_outputs = notes_this_batch - 1; + + progress(SeedPoolProgress { + batch_index: batches_submitted, + batches_total_estimate, + pool_notes_now, + target: target_total_notes, + }); + + // Size the per-batch asset lock: pool_fee (priced from the + // SAME on-wire action count consensus charges) + the real + // note's value. `shielded_fund_from_asset_lock` re-derives + // `shield_amount = lock_value − pool_fee` internally, so the + // real note lands at ~REAL_NOTE_VALUE_CREDITS and the surplus + // is structurally zero. + let num_actions = shield_from_asset_lock_num_actions(dummy_outputs); + let pool_fee = self.shield_from_asset_lock_pool_fee(num_actions)?; + let lock_credits = pool_fee + .checked_add(REAL_NOTE_VALUE_CREDITS) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "seed batch lock amount overflowed credits".to_string(), + ) + })?; + let amount_duffs = lock_credits.div_ceil(CREDITS_PER_DUFF); + + // Rapid back-to-back batches chain unconfirmed L1 change + // outputs; around core's unconfirmed-ancestor depth limit + // (~25 chained txs) InstantSend/ChainLock proofs stop + // arriving until a core block confirms the chain, and the + // funding resolution surfaces `FinalityTimeout`. That's a + // transient pacing condition, not a failure — retry the + // batch a few times with a pause to let a block land + // instead of aborting the whole run. + let mut attempt = 0u32; + loop { + attempt += 1; + match self + .shielded_fund_from_asset_lock( + AssetLockFunding::FromWalletBalance { + amount_duffs, + account_index: funding_account_index, + }, + vec![(recipient, None)], + asset_lock_signer, + &prover, + None, + dummy_outputs, + settings, + ) + .await + { + Ok(()) => break, + Err(PlatformWalletError::FinalityTimeout(out_point)) + if attempt < SEED_BATCH_FINALITY_RETRIES => + { + tracing::warn!( + batch = batches_submitted, + attempt, + %out_point, + "seed batch finality timed out; pausing for a core block then retrying" + ); + tokio::time::sleep(SEED_BATCH_RETRY_PAUSE).await; + } + Err(e) => return Err(e), + } + } + + batches_submitted += 1; + + // Re-poll the on-chain count: this is the authoritative loop + // condition (the proven batch's notes are now committed) and + // the number the host shows. A poll failure here is fatal — + // we can't tell whether to keep going. + pool_notes_now = fetch_shielded_notes_count(&sdk).await.map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "failed to fetch shielded notes count after seed batch {batches_submitted}: {e}" + )) + })?; + + progress(SeedPoolProgress { + batch_index: batches_submitted, + batches_total_estimate, + pool_notes_now, + target: target_total_notes, + }); + + tracing::info!( + batch = batches_submitted, + notes_this_batch, + dummy_outputs, + pool_notes_now, + target = target_total_notes, + "shielded seed-pool batch committed" + ); + } + + Ok(SeedPoolOutcome { + final_pool_notes: pool_notes_now, + batches_submitted, + target: target_total_notes, + }) + } + + /// The wallet's own default Orchard address for `account`, as an + /// `OrchardAddress` ready for the bundle builder. Errors if the + /// shielded sub-wallet isn't bound for `account`. + #[cfg(feature = "shielded")] + async fn seed_pool_recipient( + &self, + account: u32, + ) -> Result { + let raw = self + .shielded_default_address(account) + .await + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "shielded sub-wallet is not bound for account {account}; call bind_shielded first" + )) + })?; + OrchardAddress::from_raw_bytes(&raw).map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "failed to convert default shielded address to OrchardAddress: {e:?}" + )) + }) + } +} + +/// Estimate the number of [`MAX_ACTIONS_PER_BATCH`]-note batches needed to +/// move the pool count from `current` to `target`, given `per_batch` notes +/// per batch. Returns `0` when already at/above target. Pure arithmetic so +/// it can be unit-tested without a wallet/SDK. +fn estimate_batches(current: u64, target: u64, per_batch: u64) -> u64 { + let per_batch = per_batch.max(1); + target.saturating_sub(current).div_ceil(per_batch) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn estimate_batches_rounds_up_and_floors_at_zero() { + // 16 notes/batch: 250 from empty -> ceil(250/16) = 16 batches. + assert_eq!(estimate_batches(0, 250, 16), 16); + // Exactly divisible. + assert_eq!(estimate_batches(0, 32, 16), 2); + // One past a boundary needs an extra batch. + assert_eq!(estimate_batches(0, 33, 16), 3); + // Partway there. + assert_eq!(estimate_batches(100, 250, 16), 10); // ceil(150/16) + // Already satisfied. + assert_eq!(estimate_batches(250, 250, 16), 0); + assert_eq!(estimate_batches(300, 250, 16), 0); + // Degenerate per_batch is clamped to 1 (no div-by-zero). + assert_eq!(estimate_batches(0, 5, 0), 5); + } + + #[test] + fn batch_size_math_matches_action_count() { + // The loop computes notes_this_batch = + // min(remaining, MAX_ACTIONS_PER_BATCH).max(1), dummy_outputs = + // notes_this_batch - 1, and the on-wire action count must equal + // max(1 + dummy_outputs, 2). Verify the coupling across the + // boundary cases the loop hits (MAX_ACTIONS_PER_BATCH == 6). + for (remaining, expected_notes, expected_actions) in [ + (250u64, 6usize, 6usize), // full batch + (6, 6, 6), // exactly a full batch + (4, 4, 4), // partial batch, > 2 actions + (2, 2, 2), // 1 real + 1 filler -> 2 actions + (1, 1, 2), // 1 real, 0 fillers -> Orchard MIN_ACTIONS=2 + ] { + let notes_this_batch = + std::cmp::min(remaining, MAX_ACTIONS_PER_BATCH as u64).max(1) as usize; + assert_eq!(notes_this_batch, expected_notes, "remaining={remaining}"); + let dummy_outputs = notes_this_batch - 1; + assert_eq!( + shield_from_asset_lock_num_actions(dummy_outputs), + expected_actions, + "remaining={remaining}" + ); + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift index 52b0703a61..f675855202 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -31,6 +31,57 @@ public struct ShieldedFundFromAssetLockRecipient: Sendable { } } +/// Live progress for `seedShieldedPoolNotes(...)`, emitted once before and +/// once after each `ShieldFromAssetLock` batch. +public struct SeedShieldedPoolProgress: Sendable { + /// 0-based index of the batch about to run / just completed. + public let batchIndex: UInt64 + /// Estimated total number of batches to reach `target` from the count + /// observed when seeding started. An estimate only. + public let batchesTotalEstimate: UInt64 + /// Pool note count observed at this checkpoint. + public let poolNotesNow: UInt64 + /// The target total note count seeding is driving toward. + public let target: UInt64 +} + +/// Box that carries the host's progress handler across the C ABI as an +/// opaque context pointer. Retained for the duration of the FFI call via +/// `Unmanaged.passRetained` and released in the calling Swift frame. +/// +/// `@unchecked Sendable`: the only stored value is an `@Sendable` closure, +/// and the box itself is constructed and consumed entirely inside the +/// off-actor detached task that drives the FFI call. +private final class SeedPoolProgressBox: @unchecked Sendable { + let handler: @Sendable (SeedShieldedPoolProgress) -> Void + init(_ handler: @escaping @Sendable (SeedShieldedPoolProgress) -> Void) { + self.handler = handler + } +} + +/// C trampoline matching the Rust +/// `platform_wallet_manager_shielded_seed_pool_notes` progress callback. +/// Re-materializes the `SeedPoolProgressBox` from the opaque context and +/// forwards the counters. Called from a background worker thread. +private func seedPoolProgressTrampoline( + context: UnsafeMutableRawPointer?, + batchIndex: UInt64, + batchesTotalEstimate: UInt64, + poolNotesNow: UInt64, + target: UInt64 +) { + guard let context else { return } + let box = Unmanaged.fromOpaque(context).takeUnretainedValue() + box.handler( + SeedShieldedPoolProgress( + batchIndex: batchIndex, + batchesTotalEstimate: batchesTotalEstimate, + poolNotesNow: poolNotesNow, + target: target + ) + ) +} + extension PlatformWalletManager { /// Fund the shielded pool from a Core L1 asset lock, orchestrated /// entirely on the Rust side (build asset-lock tx → wait for @@ -237,6 +288,95 @@ extension PlatformWalletManager { }.value } + /// Seed the shielded pool's anonymity set up to `targetTotalNotes` + /// by submitting a series of `ShieldFromAssetLock` (Type 18) batches, + /// each adding up to 16 notes (1 real note to the wallet's own default + /// shielded address + up to 15 zero-value anonymity-set fillers). + /// + /// **Devnet/testnet only** — the Rust side hard-errors on mainnet + /// (`Network.mainnet`). It exists so a freshly-reset devnet can satisfy + /// the 250-note outgoing-transition minimum from the example app in one + /// action, without a `DRIVE_SHIELDED_SNAPSHOT` genesis ingest. + /// + /// Batches run serially and each waits for proven execution, so a + /// 250-note seed is ~16 batches and can take tens of minutes. `progress` + /// is invoked before and after each batch with the live counters; it is + /// called from a background worker thread, so hop to your own UI executor + /// inside the handler if you touch UI state. + /// + /// - Parameters: + /// - walletId: 32-byte wallet identifier (the same key `bindShielded` + /// uses). Must match the wallet that funds the seeding. + /// - account: shielded BIP44 account whose default address receives + /// each batch's real note (must be bound). + /// - targetTotalNotes: drive the on-chain pool note count up to (at + /// least) this value. A no-op if the pool already has this many. + /// - fundingAccountIndex: Core BIP44 account whose UTXOs fund each + /// per-batch asset lock. + /// - progress: optional live-progress handler (see above). + public func seedShieldedPoolNotes( + walletId: Data, + account: UInt32 = 0, + targetTotalNotes: UInt64 = 250, + fundingAccountIndex: UInt32 = 0, + progress: (@Sendable (SeedShieldedPoolProgress) -> Void)? = nil + ) async throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be exactly 32 bytes (was \(walletId.count))" + ) + } + + let handle = self.handle + + try await Task.detached(priority: .userInitiated) { + // Constructed inside the detached task so nothing crosses back + // to the main actor. The `MnemonicResolver` and the progress + // box live only for this off-actor frame (same rationale as + // `shieldedFundFromAssetLock`'s resolver). + let coreSigner = MnemonicResolver() + // Box the progress handler (if any) so it crosses the C ABI as + // an opaque context. Retained for the FFI call, released after. + let progressBox = progress.map { SeedPoolProgressBox($0) } + + return try walletId.withUnsafeBytes { widRaw in + guard + let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter( + "walletId baseAddress is nil" + ) + } + + let ctx: UnsafeMutableRawPointer? = progressBox.map { + Unmanaged.passRetained($0).toOpaque() + } + defer { + if let ctx { Unmanaged.fromOpaque(ctx).release() } + } + + let result = withExtendedLifetime(coreSigner) { + platform_wallet_manager_shielded_seed_pool_notes( + handle, + widPtr, + account, + targetTotalNotes, + fundingAccountIndex, + coreSigner.handle, + progressBox == nil ? nil : seedPoolProgressTrampoline, + ctx + ) + } + try result.check() + } + }.value + } + /// Validate the recipient list before the FFI sees it. The Rust /// side enforces the same invariants — duplicating them here /// produces a synchronous, type-specific error before paying diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 79b22dc30a..fe64cd79c5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -30,6 +30,8 @@ struct WalletDetailView: View { @State private var showWalletInfo = false @State private var showFundPlatformAddress = false @State private var showShieldFromAssetLock = false + /// Devnet/testnet-only shielded pool seeding sheet (Seed Pool Notes). + @State private var showSeedShieldedPool = false /// Set by `PendingPlatformFundFromAssetLocksList`'s Resume tap. @State private var resumingAssetLock: PersistentAssetLock? @@ -111,6 +113,23 @@ struct WalletDetailView: View { } .padding(.horizontal) + // Devnet/testnet-only: seed the shielded pool's anonymity set + // so outgoing shielded transitions clear the 250-note minimum. + // Hidden on mainnet (the pool is seeded at genesis there, and + // the Rust side hard-errors on mainnet anyway). + if platformState.currentNetwork != .mainnet { + Button { + showSeedShieldedPool = true + } label: { + Label("Seed Pool Notes", systemImage: "square.stack.3d.up.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding(.horizontal) + .padding(.top, 8) + .accessibilityIdentifier("walletDetail.seedPoolNotesButton") + } + PendingPlatformFundFromAssetLocksList( coordinator: walletManager.addressFundFromAssetLockCoordinator, walletId: wallet.walletId, @@ -208,6 +227,9 @@ struct WalletDetailView: View { .sheet(isPresented: $showShieldFromAssetLock) { ShieldedFundFromAssetLockView(wallet: wallet) } + .sheet(isPresented: $showSeedShieldedPool) { + SeedShieldedPoolView(wallet: wallet) + } .onAppear { appUIState.showWalletsSyncDetails = false // Repoint the singleton ShieldedService at THIS wallet — diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SeedShieldedPoolView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SeedShieldedPoolView.swift new file mode 100644 index 0000000000..8d016e3f8e --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SeedShieldedPoolView.swift @@ -0,0 +1,358 @@ +// SeedShieldedPoolView.swift +// SwiftExampleApp +// +// Devnet/testnet-only utility: seed the shielded pool's anonymity set so +// outgoing shielded transitions (transfer / unshield / withdrawal / +// identity-create-from-pool) clear the consensus 250-note minimum. +// +// After a devnet reset WITHOUT the `DRIVE_SHIELDED_SNAPSHOT` genesis +// ingest, the pool starts empty and every outgoing shielded transition is +// rejected with "pool has N notes but minimum 250 required". This sheet +// drives `PlatformWalletManager.seedShieldedPoolNotes(...)`, which submits +// a series of `ShieldFromAssetLock` (Type 18) batches — each adding up to +// 6 notes (1 real note to the wallet's own default shielded address + up +// to 5 zero-value anonymity-set fillers) — until the on-chain pool note +// count reaches the target. 6 is the most actions that fit the 20 KiB +// transaction-size limit (the Halo 2 proof grows ~2.7 KB per action); see +// MAX_ACTIONS_PER_BATCH in rs-platform-wallet's seed_pool.rs. +// +// Batches run serially and each waits for proven execution, so a 250-note +// seed is ~42 batches and can take an hour or more; the live progress +// counter (driven by the FFI progress callback) keeps the UI honest. +// +// The Rust side hard-errors on mainnet (`Network.mainnet`); this sheet is +// only surfaced for non-mainnet wallets, but the guard is defence in depth. + +import SwiftUI +import SwiftDashSDK +import SwiftData + +struct SeedShieldedPoolView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var walletManager: PlatformWalletManager + + /// Wallet that funds the seeding and owns the real notes. + let wallet: PersistentWallet + + /// 1 DASH = 1e8 duffs (Core side) — used only to display the picked + /// account's balance. + private static let duffsPerDash: UInt64 = 100_000_000 + + // MARK: - Selection state + + @State private var fundingCoreAccountIndex: UInt32? = nil + @State private var targetNotesText: String = "250" + + // MARK: - Run state + + private enum Phase: Equatable { + case idle + case inFlight + case completed + case failed(String) + } + @State private var phase: Phase = .idle + @State private var progress: SeedShieldedPoolProgress? = nil + + var body: some View { + NavigationStack { + Form { + switch phase { + case .idle: + walletSection + fundingSection + targetSection + if canSubmit { + submitSection + } + case .inFlight: + progressSection + case .completed: + completedSection + case .failed(let message): + failedSection(message) + } + } + .navigationTitle("Seed Pool Notes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .disabled(phase == .inFlight) + } + } + .onAppear(perform: autoSelectDefaults) + } + } + + // MARK: - Sections + + private var walletSection: some View { + Section { + HStack { + Label("Wallet", systemImage: "wallet.pass") + Spacer() + Text(wallet.name ?? hexShort(wallet.walletId)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(.secondary) + } + } header: { + Text("Funding Wallet") + } footer: { + Text( + "Seeding burns asset-lock value (one L1 lock + the shielded fee " + + "per batch) purely to grow the on-chain note count. This is a " + + "devnet/testnet utility — the mainnet pool is seeded at genesis." + ) + } + } + + @ViewBuilder + private var fundingSection: some View { + let options = coreAccountOptions + Section { + if options.isEmpty { + Text("No spendable Core (BIP44 standard) accounts on this wallet.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Picker("Core Account", selection: $fundingCoreAccountIndex) { + Text("Select…").tag(Optional.none) + ForEach(options, id: \.accountIndex) { opt in + Text("Account #\(opt.accountIndex) — \(formatDuffs(opt.balanceDuffs))") + .tag(Optional(opt.accountIndex)) + } + } + } + } header: { + Text("Core Source") + } footer: { + Text( + "Each batch builds one asset lock from this account's UTXOs. Make " + + "sure it holds enough DASH to cover roughly one lock per 6 notes." + ) + } + } + + private var targetSection: some View { + Section { + HStack { + TextField("Target notes", text: $targetNotesText) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + Text("notes") + .foregroundColor(.secondary) + } + } header: { + Text("Target Pool Size") + } footer: { + if let target = parsedTarget { + // Mirrors MAX_ACTIONS_PER_BATCH (6) in seed_pool.rs — the + // most actions that fit the 20 KiB transition-size limit. + let batches = (target + 5) / 6 + Text( + "Drive the pool up to \(target) notes — about \(batches) " + + "ShieldFromAssetLock batches. Already-present notes count toward " + + "the target." + ) + } else { + Text("Consensus minimum for outgoing shielded transitions is 250.") + } + } + } + + private var submitSection: some View { + Section { + Button { + submit() + } label: { + HStack { + Text("Seed Pool") + Spacer() + } + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.accentColor) + } + } + + private var progressSection: some View { + Section { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + ProgressView() + Text("Seeding pool…") + .foregroundColor(.secondary) + } + if let p = progress { + ProgressView(value: progressFraction(p)) + Text( + "Batch \(p.batchIndex)/~\(p.batchesTotalEstimate) · " + + "\(p.poolNotesNow)/\(p.target) notes" + ) + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("Building proof for the first batch (~30s)…") + .font(.caption) + .foregroundColor(.secondary) + } + Text( + "Each batch waits for proven execution before the next — this can " + + "take tens of minutes. Keep the app foregrounded." + ) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + private var completedSection: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Pool seeded", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + if let p = progress { + Text("Pool now has \(p.poolNotesNow) notes (target \(p.target)).") + .font(.caption) + .foregroundColor(.secondary) + } + Text("Outgoing shielded transitions should now clear the 250-note minimum.") + .font(.caption) + .foregroundColor(.secondary) + Button { + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + } + + private func failedSection(_ message: String) -> some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Seeding failed", systemImage: "xmark.octagon.fill") + .foregroundColor(.red) + .font(.headline) + if let p = progress { + Text("Reached \(p.poolNotesNow)/\(p.target) notes before failing.") + .font(.caption) + .foregroundColor(.secondary) + } + Text(message) + .font(.callout) + .foregroundColor(.primary) + .textSelection(.enabled) + Button { + dismiss() + } label: { + Text("Dismiss") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding(.top, 4) + } + } + } + + // MARK: - Submit + + private func submit() { + guard + let fundingAccountIndex = fundingCoreAccountIndex, + let target = parsedTarget + else { return } + + let manager = walletManager + let walletId = wallet.walletId + phase = .inFlight + progress = nil + + Task { + do { + try await manager.seedShieldedPoolNotes( + walletId: walletId, + account: 0, + targetTotalNotes: target, + fundingAccountIndex: fundingAccountIndex, + progress: { p in + // The FFI callback fires on a background worker + // thread; hop to the main actor before touching + // SwiftUI state. + Task { @MainActor in + self.progress = p + } + } + ) + await MainActor.run { phase = .completed } + } catch { + await MainActor.run { + phase = .failed(error.localizedDescription) + } + } + } + } + + // MARK: - Derived + + private struct CoreAccountOption { + let accountIndex: UInt32 + let balanceDuffs: UInt64 + } + + private var coreAccountOptions: [CoreAccountOption] { + walletManager.accountBalances(for: wallet.walletId) + .filter { $0.typeTag == 0 && $0.standardTag == 0 && $0.confirmed > 0 } + .sorted { $0.index < $1.index } + .map { + CoreAccountOption(accountIndex: $0.index, balanceDuffs: $0.confirmed) + } + } + + private var parsedTarget: UInt64? { + let raw = targetNotesText.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = UInt64(raw), value > 0 else { return nil } + return value + } + + private var canSubmit: Bool { + fundingCoreAccountIndex != nil && parsedTarget != nil && phase == .idle + } + + private func progressFraction(_ p: SeedShieldedPoolProgress) -> Double { + guard p.target > 0 else { return 0 } + return min(1.0, Double(p.poolNotesNow) / Double(p.target)) + } + + // MARK: - Actions + + private func autoSelectDefaults() { + if fundingCoreAccountIndex == nil { + fundingCoreAccountIndex = coreAccountOptions + .first { $0.balanceDuffs > 0 }?.accountIndex + ?? coreAccountOptions.first?.accountIndex + } + } + + // MARK: - Formatting + + private func formatDuffs(_ duffs: UInt64) -> String { + let dash = Double(duffs) / Double(Self.duffsPerDash) + return String(format: "%.8f DASH", dash) + } + + private func hexShort(_ data: Data) -> String { + let hex = data.map { String(format: "%02x", $0) }.joined() + if hex.count <= 16 { return hex } + let prefix = hex.prefix(8) + let suffix = hex.suffix(8) + return "\(prefix)…\(suffix)" + } +} From 430e6ce02e2ddd55cb3e9e5f3814918852f6f3a9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 11 Jun 2026 22:11:55 +0200 Subject: [PATCH 2/5] fix: resume the timed-out asset lock on seed-batch retry; align docs to 6-action batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups (#3858): - The FinalityTimeout retry now resumes the already-broadcast tracked lock via AssetLockFunding::FromExistingAssetLock { out_point } instead of building a fresh lock from wallet balance — re-funding stranded the original lock, burned an extra UTXO per attempt, and chained the new lock onto the same unconfirmed-ancestor depth that caused the stall. - FFI extern, Swift wrapper, and Rust method docs updated from the stale 16-action/15-filler/~16-batch figures to the actual 6/5/~42 (6 actions is the most that fits the 20 KiB max_state_transition_size). Co-Authored-By: Claude Fable 5 --- .../src/shielded_send.rs | 10 +++++--- .../src/wallet/shielded/seed_pool.rs | 23 ++++++++++++++----- ...PlatformWalletManagerShieldedFunding.swift | 9 +++++--- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index f8dcc7eca6..5241cf6d9c 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -948,8 +948,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset /// Seed the shielded pool's anonymity set up to `target_total_notes` by /// submitting a series of `ShieldFromAssetLock` (Type 18) batches, each -/// adding up to 16 notes (1 real note to the wallet's own default -/// shielded address + up to 15 zero-value anonymity-set fillers). +/// adding up to 6 notes (1 real note to the wallet's own default +/// shielded address + up to 5 zero-value anonymity-set fillers). 6 is +/// `MAX_ACTIONS_PER_BATCH` in rs-platform-wallet's `seed_pool.rs` — the +/// most that fits the 20 KiB `max_state_transition_size`, NOT the +/// 16-action consensus cap. /// /// Devnet/testnet ONLY — the Rust side hard-errors on `Network::Mainnet` /// (the mainnet pool is seeded at genesis via `DRIVE_SHIELDED_SNAPSHOT`). @@ -960,7 +963,8 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset /// `MnemonicResolverHandle` — the raw key never crosses the FFI boundary. /// /// Batches run serially; each waits for proven execution before the next -/// starts (so a 250-note seed is roughly 16 batches and tens of minutes). +/// starts (so a 250-note seed is roughly 42 batches and can take an hour +/// or more). /// `progress_fn`, when non-null, is invoked before and after each batch /// with the live counters so the host can render a progress UI. It is /// called from a background worker thread — the host trampoline is diff --git a/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs index b24f380fef..b65cf765dc 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs @@ -104,7 +104,7 @@ pub struct SeedPoolOutcome { impl PlatformWallet { /// Seed the shielded pool up to `target_total_notes` by submitting a /// series of `ShieldFromAssetLock` (Type 18) batches, each adding up - /// to [`MAX_ACTIONS_PER_BATCH`] notes (1 real + up to 15 zero-value + /// to [`MAX_ACTIONS_PER_BATCH`] notes (1 real + up to 5 zero-value /// anonymity-set fillers). /// /// Devnet/testnet-only: hard-errors on `Network::Mainnet` (the mainnet @@ -256,15 +256,24 @@ impl PlatformWallet { // transient pacing condition, not a failure — retry the // batch a few times with a pause to let a block land // instead of aborting the whole run. + // + // The timed-out lock is already broadcast and tracked, so the + // retry MUST resume it (`FromExistingAssetLock` with the + // outpoint from the error) rather than build a fresh lock: + // re-funding from wallet balance would strand the original + // lock, burn another UTXO per attempt, and chain the new lock + // on top of the very unconfirmed-ancestor depth that caused + // the stall. let mut attempt = 0u32; + let mut funding = AssetLockFunding::FromWalletBalance { + amount_duffs, + account_index: funding_account_index, + }; loop { attempt += 1; match self .shielded_fund_from_asset_lock( - AssetLockFunding::FromWalletBalance { - amount_duffs, - account_index: funding_account_index, - }, + funding, vec![(recipient, None)], asset_lock_signer, &prover, @@ -282,8 +291,10 @@ impl PlatformWallet { batch = batches_submitted, attempt, %out_point, - "seed batch finality timed out; pausing for a core block then retrying" + "seed batch finality timed out; pausing for a core block then \ + resuming the tracked lock" ); + funding = AssetLockFunding::FromExistingAssetLock { out_point }; tokio::time::sleep(SEED_BATCH_RETRY_PAUSE).await; } Err(e) => return Err(e), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift index f675855202..93ae2a4361 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -290,8 +290,11 @@ extension PlatformWalletManager { /// Seed the shielded pool's anonymity set up to `targetTotalNotes` /// by submitting a series of `ShieldFromAssetLock` (Type 18) batches, - /// each adding up to 16 notes (1 real note to the wallet's own default - /// shielded address + up to 15 zero-value anonymity-set fillers). + /// each adding up to 6 notes (1 real note to the wallet's own default + /// shielded address + up to 5 zero-value anonymity-set fillers). 6 is + /// `MAX_ACTIONS_PER_BATCH` in rs-platform-wallet's `seed_pool.rs` — + /// the most that fits the 20 KiB `max_state_transition_size`, NOT the + /// 16-action consensus cap. /// /// **Devnet/testnet only** — the Rust side hard-errors on mainnet /// (`Network.mainnet`). It exists so a freshly-reset devnet can satisfy @@ -299,7 +302,7 @@ extension PlatformWalletManager { /// action, without a `DRIVE_SHIELDED_SNAPSHOT` genesis ingest. /// /// Batches run serially and each waits for proven execution, so a - /// 250-note seed is ~16 batches and can take tens of minutes. `progress` + /// 250-note seed is ~42 batches and can take an hour or more. `progress` /// is invoked before and after each batch with the live counters; it is /// called from a background worker thread, so hop to your own UI executor /// inside the handler if you touch UI state. From 4e2e672a273673764248690ef979709c96271e5a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 11 Jun 2026 22:27:06 +0200 Subject: [PATCH 3/5] fix: address CodeRabbit review on seed-pool flow - Resolve the seeding recipient only after the already-satisfied no-op check, so a wallet whose pool target is already met succeeds even when the shielded sub-wallet isn't bound. - SeedShieldedPoolView: block interactive swipe-to-dismiss while a run is in flight (matches the disabled Cancel button), compute the batch estimate without the `target + 5` overflow on a pasted UInt64.max, and present batchIndex as a completed-batch count instead of "Batch 0/~N". Co-Authored-By: Claude Fable 5 --- .../src/wallet/shielded/seed_pool.rs | 7 +++++-- .../Views/SeedShieldedPoolView.swift | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs index b65cf765dc..f2d236c160 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs @@ -172,8 +172,6 @@ impl PlatformWallet { hex::encode(self.wallet_id()) ))); } - let recipient = self.seed_pool_recipient(account).await?; - // Snapshot the starting count so the batch-total estimate is // stable for the whole run. let sdk = self.sdk_arc(); @@ -205,6 +203,11 @@ impl PlatformWallet { return Ok(outcome); } + // Resolved only once seeding is actually needed, so the + // already-satisfied no-op path above works even when the + // shielded sub-wallet isn't bound. + let recipient = self.seed_pool_recipient(account).await?; + // One prover handle for the whole run (zero-sized; shares the // process-global cached proving key). let prover = CachedOrchardProver::new(); diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SeedShieldedPoolView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SeedShieldedPoolView.swift index 8d016e3f8e..0e6334f8e5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SeedShieldedPoolView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SeedShieldedPoolView.swift @@ -82,6 +82,12 @@ struct SeedShieldedPoolView: View { } } .onAppear(perform: autoSelectDefaults) + // The seeding Task keeps running if the sheet goes away; with + // no progress/failure surface the user could also start a + // second concurrent run. Block swipe-to-dismiss while a run + // is in flight (the toolbar Cancel is disabled for the same + // reason). + .interactiveDismissDisabled(phase == .inFlight) } } @@ -150,7 +156,9 @@ struct SeedShieldedPoolView: View { if let target = parsedTarget { // Mirrors MAX_ACTIONS_PER_BATCH (6) in seed_pool.rs — the // most actions that fit the 20 KiB transition-size limit. - let batches = (target + 5) / 6 + // Ceiling division without `target + 5`, which would trap + // on a pasted UInt64.max target. + let batches = target / 6 + (target % 6 == 0 ? 0 : 1) Text( "Drive the pool up to \(target) notes — about \(batches) " + "ShieldFromAssetLock batches. Already-present notes count toward " @@ -188,8 +196,11 @@ struct SeedShieldedPoolView: View { } if let p = progress { ProgressView(value: progressFraction(p)) + // `batchIndex` counts COMPLETED batches (the Rust side + // emits it before and after each batch), so present it + // as a completed count, not a 1-based "current batch". Text( - "Batch \(p.batchIndex)/~\(p.batchesTotalEstimate) · " + "\(p.batchIndex)/~\(p.batchesTotalEstimate) batches completed · " + "\(p.poolNotesNow)/\(p.target) notes" ) .font(.caption) From f8d674ab42bbcdc9af6079039446b1954e6b321c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 12 Jun 2026 05:01:03 +0200 Subject: [PATCH 4/5] test: keep shielded real-proving tests inside the CI step budget The macOS coverage job's shielded step (30-minute timeout, llvm-cov instrumented) timed out after this PR added two 16-action real proofs. Seeding no longer publishes 16-action transitions (6 is the most that fits the 20 KiB size limit), so the heavyweight proofs weren't earning their cost: - the builder padding test now stops at 5 dummies (6 actions, the seeding maximum) instead of 15; - the 16-action signing test is removed, with its value_balance assertion folded into the 6-action size-pin test. Co-Authored-By: Claude Fable 5 --- packages/rs-dpp/src/shielded/builder/mod.rs | 9 ++-- .../signing_tests.rs | 52 ++++--------------- 2 files changed, 15 insertions(+), 46 deletions(-) diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index 5d1216f082..ae7b904775 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -474,8 +474,11 @@ mod mod_tests { // output-only bundle to its 2-action minimum) and the dummies are // zero-value, so the bundle's `value_balance` still equals exactly // the real recipient amount. This is the invariant the pool-seeding - // flow relies on: one transition can publish up to 16 actions, all - // but one carrying no value. + // flow relies on: one transition publishes up to 6 actions (the most + // that fits the 20 KiB transition-size limit), all but one carrying + // no value. The cases stop at 5 dummies — the seeding maximum — to + // keep this real-proving test inside the CI shielded-step budget + // (proof cost grows with the action count). // ------------------------------------------------------------------ #[test] @@ -484,7 +487,7 @@ mod mod_tests { let amount = 10_000u64; // (dummy_outputs, expected on-wire action count). - for (dummies, expected_actions) in [(0usize, 2usize), (1, 2), (15, 16)] { + for (dummies, expected_actions) in [(0usize, 2usize), (1, 2), (5, 6)] { let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], None, dummies, &TestProver) .expect("bundle should build"); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index 32be2c077d..c31e3b1fc1 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -261,48 +261,6 @@ async fn build_shield_from_asset_lock_transition_with_signer_end_to_end() { ); } -#[tokio::test] -async fn build_shield_from_asset_lock_transition_with_signer_dummy_outputs_pad_actions() { - // Pool-seeding path: `dummy_outputs = 15` makes a 16-action - // (1 real + 15 zero-value filler) ShieldFromAssetLock. The on-wire - // action count is what consensus prices the fee from, and the real - // recipient amount stays the transition's `value_balance` (the - // fillers carry no value). - use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver}; - - let recipient = test_orchard_address(); - let signer = FixedKeySigner::new([7u8; 32]); - let path = DerivationPath::default(); - let shield_amount = 50_000u64; - - let st = build_shield_from_asset_lock_transition_with_signer( - &recipient, - shield_amount, - make_chain_asset_lock_proof(), - &path, - &signer, - &TestProver, - [0u8; 36], - None, // sender_ovk - None, // surplus_output - 15, // dummy_outputs -> 16 on-wire actions - PlatformVersion::latest(), - ) - .await - .expect("builder should succeed"); - - let v0 = extract_v0(st); - assert_eq!( - v0.actions.len(), - 16, - "1 real + 15 dummy outputs must serialize to 16 Orchard actions", - ); - assert_eq!( - v0.value_balance, shield_amount, - "dummy outputs are zero-value: value_balance must equal the real amount", - ); -} - #[tokio::test] async fn seed_pool_batch_fits_max_state_transition_size() { // The pool-seeding batch size (MAX_ACTIONS_PER_BATCH = 6 in @@ -348,5 +306,13 @@ async fn seed_pool_batch_fits_max_state_transition_size() { ); let v0 = extract_v0(st); - assert_eq!(v0.actions.len(), 6); + assert_eq!( + v0.actions.len(), + 6, + "1 real + 5 dummy outputs must serialize to 6 Orchard actions", + ); + assert_eq!( + v0.value_balance, 50_000, + "dummy outputs are zero-value: value_balance must equal the real amount", + ); } From 92d6b4f369484cc643865be4e0eb22fd5ec8198a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 12 Jun 2026 05:21:11 +0200 Subject: [PATCH 5/5] docs: pool-seeding batches cap at 6 actions in the pool-fee doc Co-Authored-By: Claude Fable 5 --- .../src/wallet/shielded/fund_from_asset_lock.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index c9ad18f738..164e52a55c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -451,7 +451,10 @@ impl PlatformWallet { /// /// `num_actions` is the on-wire Orchard action count of the bundle /// (`shield_from_asset_lock_num_actions(dummy_outputs)` — `2` for the - /// classic single-output bundle, up to `16` for a pool-seeding batch). + /// classic single-output bundle, up to `6` for a pool-seeding batch + /// (the 20 KiB `max_state_transition_size` cap, see + /// `MAX_ACTIONS_PER_BATCH` in `seed_pool.rs`; not the 16-action + /// consensus cap)). /// `asset_lock_base_cost` (`albc`) is the same constant Type 14 (address /// funding) uses, read from `dpp.state_transitions.identities.asset_locks` /// and converted duffs→credits.