diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index 8b3acbb76ca..d9c2c4f3be4 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -104,6 +104,14 @@ is funded by a consumed asset lock with no metering anchor, so it pays the flat is the on-wire action count of the bundle (a single-output, spends-disabled Orchard bundle pads to 2 actions, so the minimum is the 2-action fee). +Every action of an outputs-only bundle still reveals a nullifier, that of a dummy spend, +which becomes the new note's `rho`. From protocol version 14 the entry transitions record +those nullifiers and refuse one repeated inside the bundle or already recorded +(`NullifierAlreadySpentError`), as the spends do, so every revealed nullifier is recorded +once. `Shield` and `ShieldFromIdentity` meter the nullifier writes like the rest of their +storage; `ShieldFromAssetLock`'s flat fee already prices a note and a nullifier write per +action (see [Per-Action Storage Fee](#3-per-action-storage-fee)). + ### Shield `Shield` is charged like any other address-funded transition: GroveDB **meters** the @@ -135,8 +143,8 @@ compute` funding gate is `validate_fees_of_event`. `ShieldFromIdentity` (protocol version 14) is `Shield` with the identity balance as the funding side. It is identity-signed (TRANSFER key, identity nonce) like `IdentityCreditTransferToAddresses`, and carries the same outputs-only Orchard bundle -as `Shield`. The fee model is identical to `Shield`'s: GroveDB meters the note -inserts and the identity balance and nonce writes, and the shielded compute fee is +as `Shield`. The fee model is identical to `Shield`'s: GroveDB meters the note and +nullifier inserts and the identity balance and nonce writes, and the shielded compute fee is added as `additional_fixed_fee_cost`: ``` @@ -146,13 +154,18 @@ identity_balance_after = identity_balance_before - amount - fee `user_fee_increase` applies to the metered processing portion. The stateless floor requires `identity_balance >= amount + compute_shielded_identity_balance_write_fee`, -the conservative complete fee (`compute_minimum_shielded_fee` plus the flat -`SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES` identity-write component at the -storage rate: 20 effective bytes covering the nonce and balance rewrites, which add no -storage but replace 883 bytes of Merk path for a measured 466,760 credits of -processing, folded into one flat figure with headroom like the other shielded -components), so an identity that could not pay the complete fee is refused before the -Orchard proof is verified. The authoritative gate is the identity-paid fee +the conservative complete admission estimate: `compute_minimum_shielded_fee`, plus +PV14's versioned `shielded_identity_action_write_storage_bytes` (400 effective +bytes per action) and `shielded_identity_balance_write_storage_bytes` (500 flat +bytes), each priced at the storage rate. The allowances cover the complete +execution-event estimate: note/nullifier writes at the estimator's depth 16, +identity nonce/balance writes at its maximum-element depth, and the signature +and state-read validation context. They are admission reserves, not physical +payload sizes or changes to the actual metered charge. For two actions the +base wallet estimate is `114,140,000 + (2 × 400 + 500) × 27,400 = 149,760,000` +credits. Historical tables preserve the preceding zero per-action and 20-byte +flat allowance. An identity that could not pay the +complete fee is therefore refused before the Orchard proof is verified. The authoritative gate is the identity-paid fee validation of the execution event (`Paid`), which rejects with `IdentityInsufficientBalanceError`. The identity balance and the pool total are both terms of the block conservation equation, so the converter emits no @@ -225,11 +238,10 @@ min_fee = proof_verification_fee + num_actions × (processing_fee + storage_fee) ### 1. Proof Verification Fee (per bundle) A single Halo 2 ZK proof covers the entire bundle regardless of action count. -Verifying it is the most expensive operation — benchmarked at approximately -30× the cost of a per-action signature verification. This is a fixed cost per -bundle. +The bundle's base verification work is benchmarked at approximately 5 ms. +This is a fixed cost per bundle. -**Current value:** `100,000,000` credits (100M) +**Protocol version 14 value:** `40,000,000` credits (40M) ### 2. Per-Action Processing Fee @@ -241,17 +253,14 @@ batch verification. For a spend-bearing action that marginal work includes: - Nullifier duplicate check (hash + tree lookup) - Note commitment insertion into the Sinsemilla-based Merkle tree -Output-only entry transitions (Shield / ShieldFromAssetLock) do no spends or -nullifier checks, but each output action still enlarges the proof and so carries +Output-only entry transitions (Shield / ShieldFromAssetLock / ShieldFromIdentity) do +no spends, but each output action still enlarges the proof and so carries the same per-action processing charge — this fee tracks the marginal verification -work, not a fixed per-action checklist. +work, not a fixed per-action checklist. From protocol version 14 it also prices the +check of the nullifier each of their actions reveals. -The fee is calibrated at roughly a 4.5:1 ratio against the fixed -proof-verification fee (100M : 22M) rather than the looser ratio used before the -recalibration. (Note the two ratios on this page use different baselines: the -“30×” in §1 is the proof fee relative to a single RedPallas signature -verification, whereas this 4.5:1 is the proof fee relative to the per-action -processing fee.) +At protocol version 14, the proof and per-action fees are versioned independently. +Their numerical ratio is about 1.8:1 (40M : 22M). **Current value:** `22,000,000` credits (22M) @@ -261,33 +270,33 @@ Each action permanently stores data in two places: | Storage | Bytes | Contents | |---|---|---| -| BulkAppendTree (commitment tree) | 280 | 32 cmx + 32 rho + 216 encrypted note | +| BulkAppendTree (commitment tree) | 312 | 32 cmx + 32 rho + 32 cv_net + 216 encrypted note | | Nullifier tree | 32 | nullifier key (value is empty) | -| **Total** | **312** | | +| **Total physical payload** | **344** | | -The storage fee is derived from the platform's existing per-byte storage rates: +Protocol version 14 prices a 550-byte allowance per action, covering the physical +payload and database framing, at the platform's per-byte storage rates: ``` -storage_fee_per_action = 312 × (storage_disk_usage_credit_per_byte +storage_fee_per_action = 550 × (storage_disk_usage_credit_per_byte + storage_processing_credit_per_byte) - = 312 × (27,000 + 400) - = 312 × 27,400 - = 8,548,800 + = 550 × (27,000 + 400) + = 550 × 27,400 + = 15,070,000 ``` -This is not a separate constant — it is computed dynamically from the storage fee -version, ensuring shielded storage costs stay consistent with transparent storage -costs as fee parameters evolve. +The byte allowance is versioned. Its per-byte rates come from the storage fee +version, so the fee tracks changes to those rates. ## Fee Table -Combining all three components: +Combining all three components at protocol version 14: | Actions | Proof Fee | Processing | Storage | Total Minimum Fee | |---|---|---|---|---| -| 2 | 100,000,000 | 44,000,000 | 17,097,600 | **161,097,600** | -| 3 | 100,000,000 | 66,000,000 | 25,646,400 | **191,646,400** | -| 4 | 100,000,000 | 88,000,000 | 34,195,200 | **222,195,200** | +| 2 | 40,000,000 | 44,000,000 | 30,140,000 | **114,140,000** | +| 3 | 40,000,000 | 66,000,000 | 45,210,000 | **151,210,000** | +| 4 | 40,000,000 | 88,000,000 | 60,280,000 | **188,280,000** | Note: The Orchard protocol requires a minimum of 2 actions per bundle for privacy (even a single-input single-output transfer produces 2 actions with a dummy padding @@ -300,13 +309,13 @@ add a flat component on top of this base: - **`Unshield` adds the output-address write cost**: a flat `unshield_address_storage_fee = 222 × per_byte_rate = 222 × 27,400 = 6,082,800` credits, independent of action count. So the 2-action Unshield fee is - `161,097,600 + 6,082,800 = 167,180,400` credits (and likewise `+6,082,800` at every action + `114,140,000 + 6,082,800 = 120,222,800` credits (and likewise `+6,082,800` at every action count). See the [Fee Extraction](#fee-extraction-by-transition-type) Unshield row for why this component exists. - **`ShieldedWithdrawal` adds the Core withdrawal-document storage cost**: a flat `withdrawal_document_storage_fee = 4100 × per_byte_rate = 4100 × 27,400 = 112,340,000` credits, independent of action count. So the 2-action ShieldedWithdrawal fee is - `161,097,600 + 112,340,000 = 273,437,600` credits (and likewise `+112,340,000` at every action + `114,140,000 + 112,340,000 = 226,480,000` credits (and likewise `+112,340,000` at every action count). See the [Fee Extraction](#fee-extraction-by-transition-type) ShieldedWithdrawal row for why this component exists. - **`IdentityTopUpFromShieldedPool` adds the identity balance write cost**: a flat @@ -360,8 +369,9 @@ pub struct DriveAbciValidationConstants { pub minimum_pool_notes_for_outgoing: u64, pub shielded_anchor_retention_blocks: u64, pub shielded_anchor_pruning_interval: u64, - pub shielded_proof_verification_fee: u64, // 100_000_000 + pub shielded_proof_verification_fee: u64, // 40_000_000 at protocol 14 pub shielded_per_action_processing_fee: u64, // 22_000_000 + pub shielded_storage_bytes_per_action: u64, // 550 at protocol 14 pub shielded_implicit_fee_cap: u64, // 20_000_000_000 (0.2 DASH) } ``` @@ -370,15 +380,15 @@ The `shielded_implicit_fee_cap` bounds the surplus that a `ShieldFromAssetLock` implicitly donate to the fee pools when no `surplus_output` is set (see [Entry-Transition Fees](#entry-transition-fees-shield-shieldfromassetlock-and-shieldfromidentity)). -The storage component is not a separate constant — it is derived at runtime from +The storage component is derived at runtime from `fee_version.storage.storage_disk_usage_credit_per_byte` and `fee_version.storage.storage_processing_credit_per_byte`, multiplied by the -constant `SHIELDED_STORAGE_BYTES_PER_ACTION = 312`. +versioned `shielded_storage_bytes_per_action` allowance. This design means: - **Proof and processing fees** can be tuned independently via version bumps - **Storage fees** automatically track changes to the platform-wide storage rates -- No "magic number" for storage cost exists in the version constants +- **Storage allowances** can be calibrated independently of the per-byte rates ## How Fees Flow After Validation diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs index 3ef8fa52b89..e24db54c77f 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs @@ -2,8 +2,8 @@ mod v0; use crate::fee::Credits; use crate::shielded::{ - SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES, SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES, - SHIELDED_TOKEN_BALANCE_INSERT_STORAGE_BYTES, + SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES, SHIELDED_TOKEN_BALANCE_INSERT_STORAGE_BYTES, + SHIELDED_TOKEN_PURCHASE_OWNER_BALANCE_STORAGE_BYTES, }; use crate::ProtocolError; use platform_version::version::PlatformVersion; @@ -140,11 +140,11 @@ pub fn compute_shielded_unshield_fee( } } -/// Computes the conservative **admission floor** (in credits) of a shielded transition that also -/// writes an identity balance (`ShieldFromIdentity`): [`compute_minimum_shielded_fee`] plus the -/// flat identity-write component (`SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES` effective bytes -/// at the per-byte storage rate: the nonce and balance rewrites' replace-only tree work, folded -/// into one flat figure like the other shielded components). +/// Computes the conservative **admission floor** (in credits) for `ShieldFromIdentity`: +/// [`compute_minimum_shielded_fee`] plus the versioned per-action and flat +/// identity-write allowances, priced at the per-byte storage rate. The allowances +/// cover the complete execution-event admission estimate, including the estimated +/// note/nullifier and identity writes and the validation context. /// /// The transition's authoritative fee is metered at execution; this floor stands in for it where /// state is not yet available, so that an identity that could not pay the complete fee is refused @@ -300,8 +300,37 @@ pub fn compute_token_purchase_from_shielded_pool_fee( compute_token_pool_paid_shielded_fee( token_actions, fee_actions, - SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES + SHIELDED_TOKEN_PURCHASE_OWNER_BALANCE_STORAGE_BYTES .saturating_add(SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES), platform_version, ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_preserve_token_purchase_fee_when_identity_shield_floor_changes() { + let platform_version = PlatformVersion::latest(); + for (token_actions, fee_actions) in [(2, 2), (3, 4), (16, 16)] { + let historical_fee = compute_token_pool_paid_shielded_fee( + token_actions, + fee_actions, + 28, + platform_version, + ) + .expect("historical token purchase fee"); + assert_eq!( + compute_token_purchase_from_shielded_pool_fee( + token_actions, + fee_actions, + platform_version, + ) + .expect("token purchase fee"), + historical_fee, + "identity shielding must not change the token purchase allowance" + ); + } + } +} diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs index a67d9f32ce9..5fad93d87d7 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs @@ -1,7 +1,7 @@ use crate::fee::Credits; use crate::shielded::{ - SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES, SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES, - SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES, SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES, + SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES, SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES, + SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES, }; use crate::ProtocolError; use platform_version::version::PlatformVersion; @@ -15,9 +15,10 @@ use platform_version::version::PlatformVersion; /// `processing_fee` that prices the MARGINAL verification work each additional action adds to the /// bundle (a larger bundle is a larger circuit and a longer batch verification). For spend-bearing /// transitions that marginal work includes the per-action RedPallas spend-auth signature -/// verification and nullifier check; output-only entry transitions (Shield / ShieldFromAssetLock) -/// do no spends or nullifier checks, but each output action still enlarges the proof and so carries -/// the same per-action processing charge. +/// verification and nullifier check; output-only entry transitions (Shield / ShieldFromAssetLock / +/// ShieldFromIdentity) do no spends, but each output action still enlarges the proof and so carries +/// the same per-action processing charge, which from protocol version 14 also prices the check of +/// the nullifier the action reveals. /// /// It carries **no storage term**: storage is the real cost of the note/nullifier writes and is /// metered separately by GroveDB. @@ -195,24 +196,26 @@ pub fn compute_shielded_unshield_fee_v0( /// v0 of the `ShieldFromIdentity` **admission floor**: /// /// `floor = compute_minimum_shielded_fee_v0(num_actions) -/// + SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES × (disk + processing) credits/byte` +/// + (num_actions × shielded_identity_action_write_storage_bytes +/// + shielded_identity_balance_write_storage_bytes) × (disk + processing)` /// -/// [`compute_minimum_shielded_fee_v0`] plus one flat component for the identity-side writes -/// (the nonce and balance rewrites), built the same way as -/// [`compute_shielded_unshield_fee_v0`]'s address-write component: the writes' replace-only tree -/// work (they add no storage) folded into a flat effective-byte figure with headroom, see -/// `SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES`. The transition's real fee is metered at -/// execution; this floor is the conservative stand-in the stateless balance pre-check uses so -/// that a short identity is refused before the Orchard proof is verified, and the client-side -/// estimate of the total fee. +/// The versioned allowances cover the complete execution-event admission estimate, +/// including note/nullifier writes, identity nonce/balance writes and validation. +/// They are effective bytes priced at the full storage rate, not physical payload +/// sizes. Execution charges the actual metered fee, which may be lower. /// -/// All arithmetic is checked: an overflow (only reachable via pathological fee constants) -/// surfaces as `ProtocolError::Overflow` instead of silently wrapping. +/// Identity shielding is refused by `is_allowed` before protocol version 14; +/// historical tables retain the preceding wallet formula's zero per-action and +/// 20-byte flat allowance. All arithmetic is checked. pub fn compute_shielded_identity_balance_write_fee_v0( num_actions: usize, platform_version: &PlatformVersion, ) -> Result { let storage = &platform_version.fee_version.storage; + let constants = &platform_version + .drive_abci + .validation_and_processing + .event_constants; let base_fee = compute_minimum_shielded_fee_v0(num_actions, platform_version)?; @@ -222,8 +225,14 @@ pub fn compute_shielded_identity_balance_write_fee_v0( .ok_or(ProtocolError::Overflow( "shielded storage per-byte rate overflow", ))?; - let identity_write_fee = SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES - .checked_mul(per_byte_rate) + let action_write_bytes = (num_actions as u64) + .checked_mul(constants.shielded_identity_action_write_storage_bytes) + .ok_or(ProtocolError::Overflow( + "shielded identity action write bytes overflow", + ))?; + let identity_write_fee = action_write_bytes + .checked_add(constants.shielded_identity_balance_write_storage_bytes) + .and_then(|bytes| bytes.checked_mul(per_byte_rate)) .ok_or(ProtocolError::Overflow( "shielded identity balance write fee overflow", ))?; @@ -364,8 +373,9 @@ mod tests { /// version's own constant tables, and must decompose as /// `compute_fee + num_actions × storage_allowance` — the component split the pool-paid /// booking and the fee-floor tests rely on. - /// The `ShieldFromIdentity` admission floor is the minimum fee plus the flat - /// identity-write allowance at the storage rate, and strictly above the compute fee. + /// The `ShieldFromIdentity` admission floor is the minimum fee plus the per-action write + /// allowance and the flat identity-write allowance at the storage rate, and strictly above + /// the compute fee. #[test] fn compute_shielded_identity_balance_write_fee_v0_adds_identity_write_allowance() { let platform_version = PlatformVersion::latest(); @@ -382,12 +392,45 @@ mod tests { .expect("compute"); assert_eq!( floor, - minimum + SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES * per_byte_rate + minimum + + (num_actions as u64 + * platform_version + .drive_abci + .validation_and_processing + .event_constants + .shielded_identity_action_write_storage_bytes + + platform_version + .drive_abci + .validation_and_processing + .event_constants + .shielded_identity_balance_write_storage_bytes) + * per_byte_rate ); assert!(floor > compute); } } + #[test] + fn should_preserve_historical_identity_shield_wallet_estimates() { + // The transition is unavailable before protocol 14, but clients may still + // ask for the estimate at a historical protocol version. + for protocol in [12, 13] { + let platform_version = PlatformVersion::get(protocol).expect("historical version"); + let storage = &platform_version.fee_version.storage; + let per_byte_rate = storage.storage_disk_usage_credit_per_byte + + storage.storage_processing_credit_per_byte; + for num_actions in [1, 2, 6, 16] { + assert_eq!( + compute_shielded_identity_balance_write_fee_v0(num_actions, platform_version) + .expect("historical estimate"), + compute_minimum_shielded_fee_v0(num_actions, platform_version) + .expect("historical minimum") + + 20 * per_byte_rate, + ); + } + } + } + #[test] fn compute_minimum_shielded_fee_v0_equals_historical_formula() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index 212dadc1db7..41804cffe55 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -152,30 +152,9 @@ pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; /// [`compute_minimum_shielded_fee::compute_shielded_identity_top_up_fee`]. pub const SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES: u64 = 8; -/// Flat component (in effective bytes at the per-byte storage rate) for the identity-side writes a -/// `ShieldFromIdentity` performs on top of its per-action note inserts: the `UpdateIdentityNonce` -/// and `RemoveFromIdentityBalance` operations. -/// -/// The transition's real fee is metered and only known at execution, so its stateless admission -/// floor needs a conservative stand-in for the metered part: [`compute_minimum_shielded_fee`] -/// (compute plus the per-action note allowance, which covers the note inserts with headroom) -/// plus this component. Admission below `amount + floor` is refused BEFORE the Orchard proof is -/// verified, so a short identity never occupies a proof-verification slot. -/// -/// What the two writes do: the identity already exists, so neither adds storage. Each rewrites -/// its element and every Merk node on the path to the root (replaced bytes, charged at the -/// per-byte processing rate), loads the path, seeks, and rehashes the nodes. Measured at protocol -/// version 14: the nonce update replaces 563 bytes and the balance debit 320 bytes (883 in -/// total) for 466,760 credits of processing applied one at a time, 424,400 when batched together -/// (shared path work). Like every other flat shielded component (`shielded_storage_bytes_per_action`, -/// `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES`), that variable tree work is folded into ONE flat -/// effective-byte figure priced at the full storage rate so it tracks the rate as it evolves, -/// rather than modelled per replaced byte: 466,760 credits is 17.0 effective bytes at 27,400 -/// credits/byte, and 20 leaves headroom for the path growing by about a node (roughly 0.7 -/// effective bytes) each time the identity count doubles. The pool-total update is not priced -/// separately, exactly as for the other pool-paid transitions. See -/// [`compute_minimum_shielded_fee::compute_shielded_identity_balance_write_fee`]. -pub const SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES: u64 = 20; +/// Effective storage bytes for crediting the contract owner's existing identity +/// balance when tokens are bought from a shielded pool. +pub const SHIELDED_TOKEN_PURCHASE_OWNER_BALANCE_STORAGE_BYTES: u64 = 20; /// Flat component (in effective bytes at the per-byte storage rate) for the recipient's token /// balance item a `TokenUnshieldWithShieldedFee` writes on top of its per-action nullifier and diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs index 8ebba0e2676..84068d7d96c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs @@ -12,9 +12,9 @@ use platform_version::version::PlatformVersion; impl StateTransitionEstimatedFeeValidation for ShieldFromIdentityTransition { /// The stateless floor is the conservative complete fee: the shielded compute fee, - /// the per-action note storage allowance, and the flat identity balance write - /// allowance (`compute_shielded_identity_balance_write_fee`). Storage and processing - /// of the note and identity writes are metered by GroveDB, so the authoritative + /// the per-action note and nullifier write allowance, and the flat identity balance + /// write allowance (`compute_shielded_identity_balance_write_fee`). Storage and processing + /// of the note, nullifier and identity writes are metered by GroveDB, so the authoritative /// funding gate is still the identity-paid fee validation of the execution event; /// this floor exists so an identity that could not pay the complete fee is refused /// before the expensive Orchard proof verification runs. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/mod.rs index 9c840487a4a..ec20133669c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/mod.rs @@ -16,6 +16,7 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::shield::transform_into_action::v0::ShieldStateTransitionTransformIntoActionValidationV0; +use crate::execution::validation::state_transition::shield::transform_into_action::v1::ShieldStateTransitionTransformIntoActionValidationV1; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; @@ -60,9 +61,17 @@ impl StateTransitionShieldTransitionActionTransformer for ShieldTransition { execution_context, platform_version, ), + 1 => self.transform_into_action_v1( + platform.drive, + tx, + inputs_with_remaining_balance, + block_info, + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "shield transition: transform_into_action".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index ac65f78fb2d..b340786fd7a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -1401,6 +1401,352 @@ mod tests { } } + // ========================================== + // NULLIFIER TESTS (every revealed nullifier is recorded once) + // ========================================== + + mod nullifiers { + use super::*; + use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; + use crate::execution::validation::state_transition::shield::StateTransitionShieldTransitionActionTransformer; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + build_outputs_only_bundle, build_outputs_only_bundle_bound, has_recorded_nullifier, + process_transition_and_commit, setup_platform_at_protocol_version, + shielded_transfer_errors_revealing, OutputsOnlyBundle, + }; + use crate::platform_types::platform::PlatformRef; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::block::block_info::BlockInfo; + use dpp::validation::ConsensusValidationResult; + use dpp::version::DefaultForPlatformVersion; + use drive::state_transition_action::StateTransitionAction; + use std::sync::OnceLock; + + /// One proven, unbound bundle, shared by the protocol-version-13 test: 13 binds nothing, + /// so there a single bundle still verifies under any input set, which is what that test + /// submits twice. + fn unbound_bundle() -> &'static OutputsOnlyBundle { + static BUNDLE: OnceLock = OnceLock::new(); + BUNDLE.get_or_init(|| build_outputs_only_bundle(5_000)) + } + + /// A bundle proved against the addresses that fund the shield. `shield_funding_digest_v0` + /// hashes the input addresses and nothing else — neither the nonce nor the credits — so the + /// bundle verifies under no other set of funders, while one bundle still serves several + /// shields from the same funder. + fn bound_bundle( + address: PlatformAddress, + platform_version: &PlatformVersion, + ) -> OutputsOnlyBundle { + // `signed_shield` derives the inputs from the amount the bundle pays, so the amount is + // fixed here rather than read back off a bundle that would have to exist first. The + // nonce below reaches no hash; it is here because the map the digest reads is keyed + // this way. + const SHIELDED: u64 = 5_000; + let mut inputs = BTreeMap::new(); + inputs.insert( + address, + (1 as AddressNonce, SHIELDED + dash_to_credits!(0.01)), + ); + let extra_sighash_data = shield_extra_sighash_data(&inputs, platform_version) + .expect("the binding of the funding inputs"); + build_outputs_only_bundle_bound(SHIELDED, &extra_sighash_data) + } + + /// A shield of `bundle` from `address` at `nonce`, signed by `signer`. + async fn signed_shield( + signer: &TestAddressSigner, + address: PlatformAddress, + nonce: AddressNonce, + bundle: &OutputsOnlyBundle, + ) -> StateTransition { + let mut inputs = BTreeMap::new(); + inputs.insert(address, (nonce, bundle.amount + dash_to_credits!(0.01))); + + let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + inputs: inputs.clone(), + actions: bundle.actions.clone(), + amount: bundle.amount, + anchor: bundle.anchor, + proof: bundle.proof.clone(), + binding_signature: bundle.binding_signature, + fee_strategy: AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + user_fee_increase: 0, + input_witnesses: vec![], + })); + let signable_bytes = st.signable_bytes().expect("should compute signable bytes"); + let mut witnesses: Vec = Vec::with_capacity(inputs.len()); + for input in inputs.keys() { + witnesses.push( + signer + .sign_create_witness(input, &signable_bytes) + .await + .expect("should sign"), + ); + } + if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + v0.input_witnesses = witnesses; + } + st + } + + /// A funded address on `platform` and the signer for it. + fn funded_address( + platform: &mut TempPlatform, + ) -> (TestAddressSigner, PlatformAddress) { + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(platform, address, 0, dash_to_credits!(1.0)); + (signer, address) + } + + /// Runs the shield transform directly on a shield whose two actions reveal the same + /// nullifier. Block processing verifies the proof in the stateless step before this + /// transform, and no Orchard builder emits a bundle that repeats a nullifier, so the + /// check is reached here directly, as block processing reaches it after the proof. + fn transform_shield_revealing( + platform: &TempPlatform, + nullifiers: &[[u8; 32]], + platform_version: &PlatformVersion, + ) -> ConsensusValidationResult { + let address = create_platform_address(1); + let requested = dash_to_credits!(0.1); + let mut inputs = BTreeMap::new(); + inputs.insert(address, (1 as AddressNonce, requested)); + let transition = ShieldTransition::V0(ShieldTransitionV0 { + inputs, + actions: nullifiers + .iter() + .map(|nullifier| SerializedAction { + nullifier: *nullifier, + ..create_dummy_serialized_action() + }) + .collect(), + amount: 1_000, + anchor: [0u8; 32], + proof: vec![0u8; 100], + binding_signature: [0u8; 64], + fee_strategy: AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + user_fee_increase: 0, + input_witnesses: vec![], + }); + // The address held exactly `requested`, all of it debited by the balance check. + let mut remaining = BTreeMap::new(); + remaining.insert(address, (1 as AddressNonce, 0)); + + let platform_state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("execution context"); + transition + .transform_into_action_for_shield_transition( + &platform_ref, + remaining, + &BlockInfo::default(), + &mut execution_context, + None, + ) + .expect("expected the shield transform to run") + } + + #[tokio::test] + async fn should_record_the_nullifiers_a_shield_reveals() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let (signer, address) = funded_address(&mut platform); + + let bundle = bound_bundle(address, platform_version); + let st = signed_shield(&signer, address, 1, &bundle).await; + let result = process_transition_and_commit(&platform, st, platform_version); + + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + for nullifier in bundle.nullifiers() { + assert!( + has_recorded_nullifier(&platform, &nullifier), + "every nullifier the shield reveals must be recorded" + ); + } + } + + #[tokio::test] + async fn should_refuse_a_shield_repeating_a_recorded_nullifier() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let (signer, address) = funded_address(&mut platform); + + let bundle = bound_bundle(address, platform_version); + let first = signed_shield(&signer, address, 1, &bundle).await; + let result = process_transition_and_commit(&platform, first, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + // The same bundle again under the next address nonce: the same note, the same + // nullifiers. The binding does not stop this — it covers the funding addresses, not + // the transition carrying them — so the repeat travels the whole block path and the + // recorded nullifier is what refuses it. + let repeat = signed_shield(&signer, address, 2, &bundle).await; + let result = process_transition_and_commit(&platform, repeat, platform_version); + + let first_nullifier = bundle.nullifiers()[0]; + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::NullifierAlreadySpentError(e)) + )] if e.nullifier() == first_nullifier + ); + } + + #[test] + fn should_refuse_a_nullifier_repeated_inside_the_bundle() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let repeated = create_dummy_serialized_action().nullifier; + let result = + transform_shield_revealing(&platform, &[repeated, repeated], platform_version); + + assert!(!result.has_data(), "no action for a refused shield"); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::NullifierAlreadySpentError(e))] + if e.nullifier() == create_dummy_serialized_action().nullifier + ); + } + + #[tokio::test] + async fn should_refuse_a_shield_repeating_a_nullifier_in_the_same_block() { + let pv = PlatformVersion::latest(); + let mut platform = setup_platform(); + let (signer, address) = funded_address(&mut platform); + let bundle = bound_bundle(address, pv); + let first = signed_shield(&signer, address, 1, &bundle).await; + let repeat = signed_shield(&signer, address, 2, &bundle).await; + let state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &[ + first.serialize_to_bytes().expect("first shield"), + repeat.serialize_to_bytes().expect("repeated shield"), + ], + &state, + &BlockInfo::default(), + &transaction, + pv, + false, + None, + ) + .expect("block processing"); + assert_matches!( + result.execution_results().as_slice(), + [ + StateTransitionExecutionResult::SuccessfulExecution { .. }, + StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::NullifierAlreadySpentError(error)) + ) + ] if error.nullifier() == bundle.nullifiers()[0] + ); + assert_eq!( + platform + .drive + .read_shielded_pool_total_balance(Some(&transaction), &mut vec![], pv) + .expect("pool balance"), + bundle.amount, + "the repeated shield adds no credits" + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, Some(&transaction), pv) + .expect("address") + .expect("funded address") + .0, + 1, + "the nullifier refusal does not consume another nonce" + ); + } + + #[tokio::test] + async fn should_refuse_a_spend_revealing_a_nullifier_a_shield_recorded() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let (signer, address) = funded_address(&mut platform); + + let bundle = bound_bundle(address, platform_version); + let st = signed_shield(&signer, address, 1, &bundle).await; + let result = process_transition_and_commit(&platform, st, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + assert!( + shielded_transfer_errors_revealing(&platform, [0xEE; 32]).is_empty(), + "a spend revealing an unrecorded nullifier passes the spend-side check" + ); + let recorded = bundle.nullifiers()[1]; + assert_matches!( + shielded_transfer_errors_revealing(&platform, recorded).as_slice(), + [ConsensusError::StateError(StateError::NullifierAlreadySpentError(e))] + if e.nullifier() == recorded + ); + } + + #[tokio::test] + async fn should_neither_record_nor_check_nullifiers_before_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let mut platform = setup_platform_at_protocol_version(13); + let (signer, address) = funded_address(&mut platform); + let bundle = unbound_bundle(); + + let first = signed_shield(&signer, address, 1, bundle).await; + let result = process_transition_and_commit(&platform, first, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + for nullifier in bundle.nullifiers() { + assert!( + !has_recorded_nullifier(&platform, &nullifier), + "protocol version 13 records no shield nullifier" + ); + } + + let repeat = signed_shield(&signer, address, 2, bundle).await; + let result = process_transition_and_commit(&platform, repeat, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "protocol version 13 accepts a repeated shield" + ); + + let repeated = create_dummy_serialized_action().nullifier; + let result = + transform_shield_revealing(&platform, &[repeated, repeated], platform_version); + assert!( + result.is_valid_with_data(), + "protocol version 13 does not check a nullifier repeated inside the bundle" + ); + } + } + // ========================================== // CREDIT CONSERVATION TESTS (sum-tree balance) // ========================================== diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/mod.rs index 9a1925de7fc..008be12cc67 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/mod.rs @@ -1 +1,2 @@ pub(crate) mod v0; +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v0/mod.rs index 02b2e79c9f0..15322829922 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v0/mod.rs @@ -74,7 +74,7 @@ use std::collections::{BTreeMap, BTreeSet}; /// below is the load-bearing defense if a future path ever reaches this transform without that /// invariant — it would otherwise mint credits (pool credited `shield_amount`, addresses debited /// less). -fn reallocate_inputs_for_shield_amount( +pub(super) fn reallocate_inputs_for_shield_amount( transition: &ShieldTransitionV0, inputs_with_remaining_balance: BTreeMap, shield_amount: Credits, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v1/mod.rs new file mode 100644 index 00000000000..596eedfa96a --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v1/mod.rs @@ -0,0 +1,102 @@ +use super::v0::reallocate_inputs_for_shield_amount; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::state_transitions::shielded_common::{ + read_pool_total_balance, validate_nullifiers, +}; +use dpp::address_funds::PlatformAddress; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::prelude::{AddressNonce, ConsensusValidationResult}; +use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::shield::ShieldTransitionAction; +use drive::state_transition_action::StateTransitionAction; +use std::collections::BTreeMap; + +pub(in crate::execution::validation::state_transition::state_transitions::shield) trait ShieldStateTransitionTransformIntoActionValidationV1 +{ + fn transform_into_action_v1( + &self, + drive: &Drive, + transaction: TransactionArg, + inputs_with_remaining_balance: BTreeMap, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl ShieldStateTransitionTransformIntoActionValidationV1 for ShieldTransition { + /// Version 0, plus the nullifier checks every spend runs: each action of an outputs-only + /// bundle still reveals a nullifier (that of a dummy spend, which becomes the new note's + /// `rho`), and the action's operations record it, so a nullifier repeated inside the bundle + /// or already recorded by an earlier spend or shield is refused with + /// `NullifierAlreadySpentError`. + /// + /// The refusal is unpaid, like the spends' and like a failed proof of this transition: + /// the proof was verified by the stateless step before this transform, and no action is + /// returned, so proposers leave the transition out of the block and CheckTx refuses it. + fn transform_into_action_v1( + &self, + drive: &Drive, + transaction: TransactionArg, + inputs_with_remaining_balance: BTreeMap, + _block_info: &BlockInfo, + _execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let ShieldTransition::V0(transition_v0) = self; + let shield_amount: Credits = transition_v0.amount; + + // The shared address-balance validation debits the FULL per-input `requested` + // (a max contribution), but the shielded pool only receives `shield_amount`. + // Reallocate so the total debited across inputs equals exactly `shield_amount`, + // leaving the excess in the source addresses. This keeps credits conserved + // (addresses lose `shield_amount` + fee, pool gains `shield_amount`). + let inputs_with_remaining_balance = reallocate_inputs_for_shield_amount( + transition_v0, + inputs_with_remaining_balance, + shield_amount, + )?; + + // Read current shielded pool state from GroveDB. + // + // Shield is metered + compute: GroveDB meters the real storage and processing of the + // note/nullifier writes, and the execution-event layer adds the shielded COMPUTE fee + // `compute_shielded_verification_fee(num_actions)` (proof verification + per-action + // processing, which prices the per-action nullifier check) on top as + // `additional_fixed_fee_cost`. These validation reads are not charged separately, as in + // the spends. + let mut drive_operations = vec![]; + let current_total_balance = + read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; + + // Validate nullifiers: intra-bundle duplicates + already recorded in state + let nullifiers: Vec<[u8; 32]> = transition_v0 + .actions + .iter() + .map(|action| action.nullifier) + .collect(); + if let Some(consensus_error) = validate_nullifiers( + drive, + &nullifiers, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + let result = ShieldTransitionAction::try_from_transition( + self, + inputs_with_remaining_balance, + shield_amount, + current_total_balance, + ); + + Ok(result.map(|action| action.into())) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs index 6ebae6fa217..93e344e7d7a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs @@ -774,6 +774,381 @@ mod tests { } } + // ========================================== + // NULLIFIER TESTS (every revealed nullifier is recorded once) + // ========================================== + + mod nullifiers { + use super::*; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + build_outputs_only_bundle, build_outputs_only_bundle_bound, has_recorded_nullifier, + process_transition_and_commit, setup_platform_at_protocol_version, + shielded_transfer_errors_revealing, OutputsOnlyBundle, + }; + use std::sync::OnceLock; + + /// A shield funded by a fresh asset lock drawn from `seed`, its bundle proved against that + /// lock. The sighash binds the lock's identifier, so the proved bundle verifies under no + /// other lock and each test has to prove its own. Returns the transition together with the + /// nullifiers its actions reveal. + fn bound_shield( + seed: u64, + platform_version: &PlatformVersion, + ) -> (StateTransition, Vec<[u8; 32]>) { + let mut rng = StdRng::seed_from_u64(seed); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + let extra_sighash_data = + shield_from_asset_lock_extra_sighash_data(&asset_lock_proof, platform_version) + .expect("the binding of the funding asset lock"); + let bundle = build_outputs_only_bundle_bound(5_000, &extra_sighash_data); + let nullifiers = bundle.nullifiers(); + let transition = create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + bundle.actions, + bundle.amount, + bundle.anchor, + bundle.proof, + bundle.binding_signature, + ); + (transition, nullifiers) + } + + /// A shield whose actions reveal `nullifiers`, with unprovable proof bytes. The nullifier + /// check runs before the proof, so a bundle meant to be refused for its nullifier never + /// reaches verification and needs no binding to reach the check. + fn unprovable_shield_revealing(seed: u64, nullifiers: &[[u8; 32]]) -> StateTransition { + let mut rng = StdRng::seed_from_u64(seed); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + let actions = nullifiers + .iter() + .map(|nullifier| SerializedAction { + nullifier: *nullifier, + ..create_dummy_serialized_action() + }) + .collect(); + create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + actions, + 5_000, + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ) + } + + /// One proven, unbound bundle shared by the protocol-version-13 test: 13 binds nothing, so + /// there one bundle still verifies under any asset lock. + fn unbound_bundle() -> &'static OutputsOnlyBundle { + static BUNDLE: OnceLock = OnceLock::new(); + BUNDLE.get_or_init(|| build_outputs_only_bundle(5_000)) + } + + /// A version 0 shield of `bundle` from a fresh asset lock drawn from `seed`. Version 0 is + /// what protocol version 13 admits, and it binds nothing. + fn signed_shield_v0(seed: u64, bundle: &OutputsOnlyBundle) -> StateTransition { + let mut rng = StdRng::seed_from_u64(seed); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + let surplus_output = Some(dpp::address_funds::PlatformAddress::P2pkh([0x33; 20])); + let body = |signature: BinaryData| ShieldFromAssetLockTransitionV0 { + asset_lock_proof: asset_lock_proof.clone(), + actions: bundle.actions.clone(), + value_balance: bundle.amount, + anchor: bundle.anchor, + proof: bundle.proof.clone(), + binding_signature: bundle.binding_signature, + surplus_output, + signature, + }; + let unsigned: StateTransition = body(Default::default()).into(); + let signable_bytes = unsigned + .signable_bytes() + .expect("should compute signable bytes"); + let signature = dpp::dashcore::signer::sign(&signable_bytes, &asset_lock_pk).unwrap(); + body(BinaryData::new(signature.to_vec())).into() + } + + /// Two dummy actions revealing the same nullifier, with unprovable proof bytes. + fn shield_repeating_a_nullifier_inside_the_bundle() -> StateTransition { + let mut rng = StdRng::seed_from_u64(9); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + vec![ + create_dummy_serialized_action(), + create_dummy_serialized_action(), + ], + 5000, + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ) + } + + #[test] + fn should_record_the_nullifiers_a_shield_reveals() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let (st, nullifiers) = bound_shield(1, platform_version); + let result = process_transition_and_commit(&platform, st, platform_version); + + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + for nullifier in nullifiers { + assert!( + has_recorded_nullifier(&platform, &nullifier), + "every nullifier the shield reveals must be recorded" + ); + } + } + + #[test] + fn should_refuse_a_shield_repeating_a_recorded_nullifier() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let (first, nullifiers) = bound_shield(1, platform_version); + let result = process_transition_and_commit(&platform, first, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + // A second shield, from a second asset lock, revealing the nullifiers the first + // recorded. Its bundle cannot be the first's — the sighash binds the lock that funds + // it — but it does not have to be: the nullifier is refused before the proof is read. + let repeat = unprovable_shield_revealing(2, &nullifiers); + let result = process_transition_and_commit(&platform, repeat, platform_version); + + let first_nullifier = nullifiers[0]; + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::NullifierAlreadySpentError(e)) + )] if e.nullifier() == first_nullifier + ); + } + + /// The check runs before the proof, so the unprovable bundle is refused for its nullifier, + /// unpaid, instead of burning the asset lock for its proof. + #[test] + fn should_refuse_a_nullifier_repeated_inside_the_bundle() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let result = process_transition( + &platform, + shield_repeating_a_nullifier_inside_the_bundle(), + platform_version, + ); + + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::NullifierAlreadySpentError(e)) + )] if e.nullifier() == create_dummy_serialized_action().nullifier + ); + } + + #[test] + fn should_refuse_a_spend_revealing_a_nullifier_a_shield_recorded() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let (st, nullifiers) = bound_shield(1, platform_version); + let result = process_transition_and_commit(&platform, st, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + assert!( + shielded_transfer_errors_revealing(&platform, [0xEE; 32]).is_empty(), + "a spend revealing an unrecorded nullifier passes the spend-side check" + ); + let recorded = nullifiers[1]; + assert_matches!( + shielded_transfer_errors_revealing(&platform, recorded).as_slice(), + [ConsensusError::StateError(StateError::NullifierAlreadySpentError(e))] + if e.nullifier() == recorded + ); + } + + /// The flat `pool_fee` (`compute_minimum_shielded_fee` + the asset-lock base cost) must + /// still cover the estimated cost `validate_fees_of_event` checks it against, now that the + /// operations also record one nullifier per action. Checked at every action count up to + /// `max_shielded_transition_actions` (the 20 KiB size limit stops real transitions near 6), + /// with the signature verification the transform meters over a maximum-size transition. + #[test] + fn should_cover_the_recorded_nullifiers_with_the_flat_pool_fee() { + use crate::execution::types::execution_event::ExecutionEvent; + use crate::execution::types::execution_operation::signature_verification_operation::SignatureVerificationOperation; + use crate::execution::types::execution_operation::{ + ValidationOperation, SHA256_BLOCK_SIZE, + }; + use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, + }; + use dpp::block::block_info::BlockInfo; + use dpp::block::epoch::Epoch; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::identity::KeyType; + use dpp::shielded::compute_minimum_shielded_fee; + use dpp::state_transition::StateTransitionEstimatedFeeValidation; + use dpp::version::DefaultForPlatformVersion; + use drive::state_transition_action::shielded::shield_from_asset_lock::v0::ShieldFromAssetLockTransitionActionV0; + use drive::state_transition_action::shielded::shield_from_asset_lock::ShieldFromAssetLockTransitionAction; + use drive::state_transition_action::shielded::ShieldedActionNote; + use drive::state_transition_action::StateTransitionAction; + use drive::util::batch::drive_op_batch::ShieldedPoolOperationType; + use drive::util::batch::DriveOperation; + + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let StateTransition::ShieldFromAssetLock(transition) = + shield_repeating_a_nullifier_inside_the_bundle() + else { + panic!("expected a shield from asset lock"); + }; + let asset_lock_base_cost = transition + .calculate_min_required_fee(platform_version) + .expect("asset lock base cost"); + let max_actions = platform_version + .system_limits + .max_shielded_transition_actions as usize; + let signable_blocks = (platform_version.system_limits.max_state_transition_size + / SHA256_BLOCK_SIZE as u64) as u16; + + for num_actions in 1..=max_actions { + let notes: Vec = (0..num_actions as u8) + .map(|i| ShieldedActionNote { + nullifier: [i; 32], + cmx: [i.wrapping_add(100); 32], + cv_net: [i.wrapping_add(200); 32], + encrypted_note: vec![0x77; 216], + }) + .collect(); + let pool_fee = compute_minimum_shielded_fee(num_actions, platform_version) + .expect("shielded fee") + + asset_lock_base_cost; + let shield_amount = 5_000; + let action = ShieldFromAssetLockTransitionAction::V0( + ShieldFromAssetLockTransitionActionV0 { + asset_lock_outpoint: [num_actions as u8; 36], + asset_lock_value_to_be_consumed: shield_amount + pool_fee, + signable_bytes_hasher: [0; 32], + shield_amount, + notes, + current_total_balance: 0, + surplus_output: None, + surplus_amount: 0, + }, + ); + + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("execution context"); + execution_context.add_operation(ValidationOperation::DoubleSha256(signable_blocks)); + execution_context.add_operation(ValidationOperation::SignatureVerification( + SignatureVerificationOperation::new(KeyType::ECDSA_HASH160), + )); + let event = ExecutionEvent::create_from_state_transition_action( + StateTransitionAction::ShieldFromAssetLockAction(action), + None, + &Epoch::new(0).expect("epoch"), + execution_context, + platform_version, + ) + .expect("execution event"); + let ExecutionEvent::PaidFromAssetLockToPool { ref operations, .. } = event else { + panic!("expected a PaidFromAssetLockToPool event"); + }; + assert!( + operations.iter().any(|op| matches!( + op, + DriveOperation::ShieldedPoolOperation( + ShieldedPoolOperationType::InsertNullifiers { .. } + ) + )), + "the event records the nullifiers" + ); + + let result = platform + .platform + .validate_fees_of_event( + &event, + &BlockInfo::default(), + None, + platform_version, + &CachedEpochIndexFeeVersions::new(), + ) + .expect("fee validation runs"); + assert!( + result.is_valid(), + "{num_actions} actions: the pool fee {pool_fee} must cover the estimate: {:?}", + result.errors + ); + } + } + + #[test] + fn should_neither_record_nor_check_nullifiers_before_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let platform = setup_platform_at_protocol_version(13); + let bundle = unbound_bundle(); + + let first = signed_shield_v0(1, bundle); + let result = process_transition_and_commit(&platform, first, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + for nullifier in bundle.nullifiers() { + assert!( + !has_recorded_nullifier(&platform, &nullifier), + "protocol version 13 records no shield nullifier" + ); + } + + let repeat = signed_shield_v0(2, bundle); + let result = process_transition_and_commit(&platform, repeat, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "protocol version 13 accepts a repeated shield" + ); + + // Without the check, the unprovable bundle reaches its proof and pays the penalty. + let repeated_inside = OutputsOnlyBundle { + actions: vec![ + create_dummy_serialized_action(), + create_dummy_serialized_action(), + ], + amount: 5_000, + anchor: [42u8; 32], + proof: vec![0u8; 100], + binding_signature: [0u8; 64], + }; + let result = process_transition( + &platform, + signed_shield_v0(9, &repeated_inside), + platform_version, + ); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] + ); + } + } + // ========================================== // SECURITY AUDIT TESTS // ========================================== diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/v1/mod.rs index f7604cbd1c7..23ad804a857 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/v1/mod.rs @@ -8,7 +8,8 @@ use crate::execution::types::state_transition_execution_context::{ use crate::execution::validation::state_transition::common::asset_lock::proof::validate::AssetLockProofValidation; use crate::execution::validation::state_transition::common::asset_lock::transaction::fetch_asset_lock_transaction_output_sync::fetch_asset_lock_transaction_output_sync; use crate::execution::validation::state_transition::state_transitions::shielded_common::{ - read_pool_total_balance, reconstruct_and_verify_bundle, FLAGS_OUTPUTS_ONLY, + read_pool_total_balance, reconstruct_and_verify_bundle, validate_nullifiers, + FLAGS_OUTPUTS_ONLY, }; use crate::execution::validation::state_transition::ValidationMode; use crate::platform_types::platform::PlatformRef; @@ -54,6 +55,18 @@ pub(in crate::execution::validation::state_transition::state_transitions::shield impl ShieldFromAssetLockStateTransitionTransformIntoActionValidationV1 for ShieldFromAssetLockTransition { + /// Version 0's checks plus two additions. The bundle's sighash binds its kind tag and the + /// asset lock it is funded from, so proved bytes cannot be re-wrapped around an asset lock of + /// someone else's (Step 9). And every nullifier the bundle reveals is checked and recorded + /// (Step 8b): each action of an outputs-only bundle still reveals a nullifier, that of a dummy + /// spend, which becomes the new note's `rho`, and the action's operations record it, so a + /// nullifier repeated inside the bundle or already recorded by an earlier spend or shield is + /// refused with `NullifierAlreadySpentError`. + /// + /// The nullifier check runs after the asset lock, its signature, the funding floor and the fee + /// cap have passed, so a re-broadcast of an executed transition still reports its consumed + /// asset lock, and before the Orchard proof, so the refusal costs no proof verification. Like + /// the fee cap refusal it is unpaid: the asset lock stays unconsumed. fn transform_into_action_v1( &self, platform: &PlatformRef, @@ -293,8 +306,29 @@ impl ShieldFromAssetLockStateTransitionTransformIntoActionValidationV1 let current_total_balance = read_pool_total_balance(platform.drive, tx, &mut drive_operations, platform_version)?; + // Step 8b: Validate nullifiers: intra-bundle duplicates + already recorded in state. + // The flat `pool_fee` subsumes these reads too, as it does the pool read above. + let nullifiers: Vec<[u8; 32]> = match self { + ShieldFromAssetLockTransition::V0(v0) => { + v0.actions.iter().map(|action| action.nullifier).collect() + } + ShieldFromAssetLockTransition::V1(v1) => { + v1.actions.iter().map(|action| action.nullifier).collect() + } + }; + if let Some(consensus_error) = validate_nullifiers( + platform.drive, + &nullifiers, + tx, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + // CheckTx admits the expensive proof only after the asset lock, its - // signature, funding, fee cap, and current pool state have all passed. + // signature, funding, fee cap, current pool state and nullifiers have + // all passed. // Proposal and block processing pass `None` and retain the existing // penalty action when proof verification fails. let _check_tx_permit = match check_tx_proof_verifier { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs index 45bad7c65ff..c70680d2d21 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs @@ -1375,4 +1375,481 @@ mod tests { ); } } + + // ========================================== + // Nullifiers: every revealed nullifier is recorded once + // ========================================== + + mod nullifiers { + use super::*; + use crate::execution::types::execution_event::ExecutionEvent; + use crate::execution::types::execution_operation::signature_verification_operation::SignatureVerificationOperation; + use crate::execution::types::execution_operation::ValidationOperation; + use crate::execution::validation::state_transition::check_tx_verification::state_transition_to_execution_event_for_check_tx; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + build_outputs_only_bundle_bound, build_outputs_only_bundle_bound_with_outputs, + check_tx_errors, has_recorded_nullifier, shielded_transfer_errors_revealing, + OutputsOnlyBundle, + }; + use crate::platform_types::platform_state::PlatformStateV0Methods; + use drive::fees::op::LowLevelDriveOperation; + + /// A bundle proved against `identity_id`. The sighash binds the identity funding the + /// shield, so the bundle verifies for that identity's transitions and for no other's, and + /// each test proves its own. The binding does not cover the nonce, so one bundle still + /// serves several shields by the same identity. + fn bound_bundle( + identity_id: &[u8; 32], + platform_version: &PlatformVersion, + ) -> OutputsOnlyBundle { + let extra_sighash_data = + shield_from_identity_extra_sighash_data(identity_id, platform_version) + .expect("the binding of the funding identity"); + build_outputs_only_bundle_bound(5_000, &extra_sighash_data) + } + + fn proven(bundle: &OutputsOnlyBundle) -> ProvenBundle { + ProvenBundle { + actions: bundle.actions.clone(), + amount: bundle.amount, + anchor: bundle.anchor, + proof: bundle.proof.clone(), + binding_signature: bundle.binding_signature, + } + } + + /// A funded identity on `platform` and its signer. + fn funded_identity( + platform: &mut TempPlatform, + seed: u8, + platform_version: &PlatformVersion, + ) -> (Identity, SimpleSigner) { + let mut rng = StdRng::seed_from_u64(seed as u64); + let (identity, signer) = create_identity_with_transfer_key( + [seed; 32], + dash_to_credits!(1.0), + &mut rng, + platform_version, + ); + add_identity_to_drive(platform, &identity); + (identity, signer) + } + + fn pool_total(platform: &TempPlatform) -> u64 { + platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], PlatformVersion::latest()) + .expect("fetch pool total") + } + + fn populate_nullifier_tree( + platform: &TempPlatform, + platform_version: &PlatformVersion, + ) { + let nullifiers: Vec<_> = (0u16..512) + .map(|i| { + let mut nullifier = [0u8; 32]; + nullifier[..2].copy_from_slice(&i.to_le_bytes()); + nullifier + }) + .collect(); + let operations = platform + .drive + .insert_nullifiers(&nullifiers, platform_version) + .expect("nullifier operations"); + platform + .drive + .grove_apply_batch( + LowLevelDriveOperation::grovedb_operations_batch_consume(operations), + false, + None, + &platform_version.drive, + ) + .expect("populate nullifier tree"); + } + + /// Fee admission estimates the successful action before CheckTx verifies its proof. + /// Dummy proof bytes let every permitted action count exercise that complete estimate, + /// including the signature and state-read context, without proving sixteen circuits. + #[tokio::test] + async fn should_cover_complete_identity_shield_admission_estimates() { + let platform_version = PlatformVersion::latest(); + let mut shortfalls = vec![]; + for populated in [false, true] { + let mut platform = setup_platform(); + if populated { + populate_nullifier_tree(&platform, platform_version); + } + let (identity, signer) = funded_identity(&mut platform, 88, platform_version); + let state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + for action_count in 1..=platform_version + .system_limits + .max_shielded_transition_actions + as usize + { + let mut bundle = dummy_bundle(); + bundle.actions = (0..action_count) + .map(|i| { + let mut action = create_dummy_serialized_action(); + action.nullifier[0] = i as u8 + 1; + action + }) + .collect(); + let st = create_signed_transition( + &identity, + &signer, + bundle, + 1_000, + 1, + 0, + platform_version, + ) + .await; + let mut event = state_transition_to_execution_event_for_check_tx( + &platform_ref, + &st, + CheckTxLevel::FirstTimeCheck, + &platform.check_tx_proof_verifier, + platform_version, + ) + .expect("build complete admission event") + .into_data() + .expect("valid admission event") + .expect("first CheckTx produces an event"); + let ExecutionEvent::Paid { + execution_operations, + additional_fixed_fee_cost, + .. + } = &mut event + else { + panic!("identity shield must be identity-paid"); + }; + // BLS is the most expensive identity signature admitted by this transition. + let mut signatures = 0; + for operation in execution_operations { + if let ValidationOperation::SignatureVerification(signature) = operation { + *signature = SignatureVerificationOperation::new(KeyType::BLS12_381); + signatures += 1; + } + } + assert_eq!(signatures, 1); + let compute_fee = additional_fixed_fee_cost.expect("shield compute fee"); + let estimated = platform + .platform + .validate_fees_of_event( + &event, + state.last_block_info(), + None, + platform_version, + &Default::default(), + ) + .expect("estimate complete event") + .into_data() + .expect("funded estimate") + .total_base_fee() + + compute_fee; + let floor = dpp::shielded::compute_shielded_identity_balance_write_fee( + action_count, + platform_version, + ) + .expect("wallet estimate"); + eprintln!( + "actions={action_count} populated={populated} admission={estimated} floor={floor}" + ); + if estimated > floor { + shortfalls.push((action_count, populated, estimated, floor)); + } + } + } + assert!( + shortfalls.is_empty(), + "wallet estimates must cover complete fee admission: {shortfalls:?}" + ); + } + + #[tokio::test] + async fn should_admit_identity_shield_funded_at_wallet_estimate() { + let platform_version = PlatformVersion::latest(); + let extra_sighash_data = + shield_from_identity_extra_sighash_data(&[88; 32], platform_version) + .expect("funding identity binding"); + for action_count in [2, 3, 4, 6] { + let bundle = build_outputs_only_bundle_bound_with_outputs( + 5_000, + action_count, + &extra_sighash_data, + ); + let floor = dpp::shielded::compute_shielded_identity_balance_write_fee( + bundle.actions.len(), + platform_version, + ) + .expect("wallet estimate"); + for populated in [false, true] { + let mut platform = setup_platform(); + if populated { + populate_nullifier_tree(&platform, platform_version); + } + let mut rng = StdRng::seed_from_u64(88); + let (identity, signer) = create_identity_with_transfer_key( + [88; 32], + bundle.amount + floor, + &mut rng, + platform_version, + ); + add_identity_to_drive(&mut platform, &identity); + let st = create_signed_transition( + &identity, + &signer, + proven(&bundle), + bundle.amount, + 1, + 0, + platform_version, + ) + .await; + assert!( + st.serialize_to_bytes().expect("serialize").len() as u64 + <= platform_version.system_limits.max_state_transition_size + ); + let admission = check_tx_errors(&platform, &st); + assert!( + admission.is_empty(), + "exact wallet funding must enter CheckTx: actions={action_count}, populated={populated}: {admission:?}" + ); + let result = process_transition_and_commit(&platform, st, platform_version); + let charged = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::SuccessfulExecution { + fee_result, .. + }] => fee_result.total_base_fee(), + other => panic!("exact wallet funding must enter a block: {other:?}"), + }; + assert!(charged <= floor, "charged fee {charged} exceeds {floor}"); + assert_eq!(identity_balance(&platform, &identity), floor - charged); + assert_eq!(identity_nonce(&platform, &identity), 1); + assert_eq!(pool_total(&platform), bundle.amount); + for nullifier in bundle.nullifiers() { + assert!(has_recorded_nullifier(&platform, &nullifier)); + } + } + } + } + + #[tokio::test] + async fn should_record_the_nullifiers_a_shield_reveals() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let (identity, signer) = funded_identity(&mut platform, 71, platform_version); + let bundle = &bound_bundle(&identity.id().to_buffer(), platform_version); + + let st = create_signed_transition( + &identity, + &signer, + proven(bundle), + bundle.amount, + 1, + 0, + platform_version, + ) + .await; + let result = process_transition_and_commit(&platform, st, platform_version); + + let charged = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. }] => { + fee_result.total_base_fee() + } + other => panic!("expected a successful shield, got {other:?}"), + }; + for nullifier in bundle.nullifiers() { + assert!( + has_recorded_nullifier(&platform, &nullifier), + "every nullifier the shield reveals must be recorded" + ); + } + // The admission floor is the client's estimate of the complete fee; the nullifier + // writes must stay inside it. + let floor = dpp::shielded::compute_shielded_identity_balance_write_fee( + bundle.actions.len(), + platform_version, + ) + .expect("floor"); + assert!( + charged <= floor, + "the charged fee ({charged}) must stay within the admission floor ({floor})" + ); + } + + /// The repeat is refused at CheckTx, and in a block it is a paid failure: the identity + /// nonce is consumed and the reads are charged, and nothing reaches the pool. + #[tokio::test] + async fn should_refuse_a_shield_repeating_a_recorded_nullifier() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let (identity, signer) = funded_identity(&mut platform, 72, platform_version); + let bundle = &bound_bundle(&identity.id().to_buffer(), platform_version); + + let first = create_signed_transition( + &identity, + &signer, + proven(bundle), + bundle.amount, + 1, + 0, + platform_version, + ) + .await; + let result = process_transition_and_commit(&platform, first, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + let balance_before = identity_balance(&platform, &identity); + let pool_before = pool_total(&platform); + + // The same bundle under the next nonce: the same note, the same nullifiers. + let repeat = create_signed_transition( + &identity, + &signer, + proven(bundle), + bundle.amount, + 2, + 0, + platform_version, + ) + .await; + let first_nullifier = bundle.nullifiers()[0]; + + let raw = repeat.serialize_to_bytes().expect("serialize"); + { + let state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let check = platform + .check_tx( + &raw, + CheckTxLevel::FirstTimeCheck, + &platform_ref, + platform_version, + ) + .expect("check_tx runs"); + assert_matches!( + check.errors.as_slice(), + [ConsensusError::StateError(StateError::NullifierAlreadySpentError(e))] + if e.nullifier() == first_nullifier + ); + } + + let result = process_transition_and_commit(&platform, repeat, platform_version); + let charged = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::NullifierAlreadySpentError(e)), + actual_fees, + .. + }] if e.nullifier() == first_nullifier => actual_fees.total_base_fee(), + other => panic!("expected a paid nullifier refusal, got {other:?}"), + }; + assert!(charged > 0, "the refusal is paid"); + assert_eq!( + identity_balance(&platform, &identity), + balance_before - charged + ); + assert_eq!(identity_nonce(&platform, &identity), 2); + assert_eq!( + pool_total(&platform), + pool_before, + "nothing reaches the pool" + ); + } + + /// The check runs before the proof, so the unprovable bundle is refused for its nullifier, + /// without the proof-failure penalty. + #[tokio::test] + async fn should_refuse_a_nullifier_repeated_inside_the_bundle() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let (identity, signer) = funded_identity(&mut platform, 73, platform_version); + + let mut repeating = dummy_bundle(); + repeating.actions = vec![ + create_dummy_serialized_action(), + create_dummy_serialized_action(), + ]; + let st = create_signed_transition( + &identity, + &signer, + repeating, + 1_000, + 1, + 0, + platform_version, + ) + .await; + let result = process_transition_and_commit(&platform, st, platform_version); + + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + let charged = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::NullifierAlreadySpentError(e)), + actual_fees, + .. + }] if e.nullifier() == create_dummy_serialized_action().nullifier => { + actual_fees.total_base_fee() + } + other => panic!("expected a paid nullifier refusal, got {other:?}"), + }; + assert!( + charged < penalty, + "the refusal ({charged}) must not charge the proof-failure penalty ({penalty})" + ); + assert_eq!(identity_nonce(&platform, &identity), 1); + } + + #[tokio::test] + async fn should_refuse_a_spend_revealing_a_nullifier_a_shield_recorded() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let (identity, signer) = funded_identity(&mut platform, 74, platform_version); + let bundle = &bound_bundle(&identity.id().to_buffer(), platform_version); + + let st = create_signed_transition( + &identity, + &signer, + proven(bundle), + bundle.amount, + 1, + 0, + platform_version, + ) + .await; + let result = process_transition_and_commit(&platform, st, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + assert!( + shielded_transfer_errors_revealing(&platform, [0xEE; 32]).is_empty(), + "a spend revealing an unrecorded nullifier passes the spend-side check" + ); + let recorded = bundle.nullifiers()[1]; + assert_matches!( + shielded_transfer_errors_revealing(&platform, recorded).as_slice(), + [ConsensusError::StateError(StateError::NullifierAlreadySpentError(e))] + if e.nullifier() == recorded + ); + } + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs index dbe54045b81..905b1351878 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs @@ -4,7 +4,7 @@ use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; use crate::execution::validation::state_transition::state_transitions::shielded_common::{ - read_pool_total_balance, reconstruct_and_verify_bundle, FLAGS_OUTPUTS_ONLY, + read_pool_total_balance, reconstruct_and_verify_bundle, validate_nullifiers, FLAGS_OUTPUTS_ONLY, }; use crate::platform_types::check_tx_proof_verifier::CheckTxProofVerifier; use dpp::block::block_info::BlockInfo; @@ -42,6 +42,16 @@ impl ShieldFromIdentityStateTransitionTransformIntoActionValidationV0 /// so the action can bump it, and that read is metered into the execution context so /// the identity pays for it. /// + /// The nullifier each action reveals (that of a dummy spend, which becomes the new + /// note's `rho`) is checked as the spends check theirs: a nullifier repeated inside the + /// bundle or already recorded by an earlier spend or shield is refused with + /// `NullifierAlreadySpentError`, before proof verification. Those reads are metered with + /// the pool read, and the refusal returns a `BumpIdentityNonceAction`, a paid failure + /// like the failed proof below but without its penalty. The action's operations record + /// the nullifiers. This generation is selected only from protocol version 14, the first + /// version whose `is_allowed` admits the transition, so these checks were edited into it + /// in place. + /// /// The Orchard proof is verified HERE rather than in the shared stateless proof step /// (like `ShieldFromAssetLock`), because a failed proof must not be a free rejection: /// the transition is identity-signed, so a rejection that left the nonce and balance @@ -61,11 +71,23 @@ impl ShieldFromIdentityStateTransitionTransformIntoActionValidationV0 check_tx_proof_verifier: Option<&CheckTxProofVerifier>, platform_version: &PlatformVersion, ) -> Result, Error> { + let ShieldFromIdentityTransition::V0(v0) = self; + let mut drive_operations = vec![]; let current_total_balance = read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; - let pool_read_fee = Drive::calculate_fee( + // Validate nullifiers: intra-bundle duplicates + already recorded in state. + let nullifiers: Vec<[u8; 32]> = v0.actions.iter().map(|action| action.nullifier).collect(); + let nullifier_refusal = validate_nullifiers( + drive, + &nullifiers, + transaction, + &mut drive_operations, + platform_version, + )?; + + let state_read_fee = Drive::calculate_fee( None, Some(drive_operations), &block_info.epoch, @@ -73,9 +95,20 @@ impl ShieldFromIdentityStateTransitionTransformIntoActionValidationV0 platform_version, None, )?; - execution_context.add_operation(ValidationOperation::PrecalculatedOperation(pool_read_fee)); + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(state_read_fee)); - let ShieldFromIdentityTransition::V0(v0) = self; + // Refused before CheckTx returns and before the proof: CheckTx rejects the result + // (it carries an error), block processing charges the reads and bumps the nonce. + if let Some(refusal) = nullifier_refusal { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_shield_from_identity_transition(self), + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + refusal.errors, + )); + } // CheckTx must first build and meter the successful action so its full // identity-balance fee gate runs before expensive proof work. Its caller diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/test_helpers.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/test_helpers.rs index b2d1d0968df..983772d50ab 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/test_helpers.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/test_helpers.rs @@ -6,6 +6,7 @@ use crate::config::{PlatformConfig, PlatformTestConfig}; use crate::execution::check_tx::CheckTxLevel; +use crate::execution::validation::state_transition::state_transitions::shielded_common::compute_platform_sighash; use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; @@ -25,6 +26,7 @@ use dpp::serialization::PlatformSerializable; use dpp::shielded::builder::OrchardProver; use dpp::shielded::SerializedAction; use dpp::state_transition::StateTransition; +use dpp::version::ProtocolVersion; use dpp::ProtocolError; use drive::drive::shielded::paths::{ shielded_credit_pool_anchors_path, shielded_credit_pool_notes_path, @@ -32,8 +34,15 @@ use drive::drive::shielded::paths::{ SHIELDED_TOTAL_BALANCE_KEY, }; use drive::grovedb::Element; +use grovedb_commitment_tree::{ + Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, DashMemo, + Flags as OrchardFlags, FullViewingKey, NoteValue, ProvingKey, Scope, SpendingKey, +}; use platform_version::version::PlatformVersion; +use rand::rngs::StdRng; +use rand::SeedableRng; use std::collections::HashMap; +use std::sync::OnceLock; // Re-export commonly used types for convenience pub use dpp::dashcore::blockdata::opcodes::all::{ @@ -440,6 +449,202 @@ pub fn setup_platform() -> TempPlatform { .set_genesis_state() } +/// [`setup_platform`] at `protocol_version` instead of the latest one, so that validation reads +/// the tables of that version from the platform state. +pub fn setup_platform_at_protocol_version( + protocol_version: ProtocolVersion, +) -> TempPlatform { + let platform_config = PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }; + + TestPlatformBuilder::new() + .with_config(platform_config) + .with_initial_protocol_version(protocol_version) + .build_with_mock_rpc() + .set_genesis_state() +} + +/// [`process_transition`] with the block transaction COMMITTED, so that a later transition and +/// the reads of the test see what the transition wrote. +pub fn process_transition_and_commit( + platform: &TempPlatform, + transition: StateTransition, + platform_version: &PlatformVersion, +) -> StateTransitionsProcessingResult { + let transition_bytes = transition + .serialize_to_bytes() + .expect("should serialize transition"); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + + let result = platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + result +} + +/// Whether `nullifier` is recorded in the committed nullifier tree of the shielded pool. +pub fn has_recorded_nullifier( + platform: &TempPlatform, + nullifier: &[u8; 32], +) -> bool { + platform + .drive + .has_nullifier(nullifier, None, &mut vec![], PlatformVersion::latest()) + .expect("expected to read the nullifier tree") +} + +/// The fields of a proven outputs-only Orchard bundle, ready to put into a `Shield`, +/// `ShieldFromAssetLock` or `ShieldFromIdentity` transition. +#[derive(Clone)] +pub struct OutputsOnlyBundle { + /// The serialized actions; each reveals the nullifier of a dummy spend. + pub actions: Vec, + /// The amount entering the pool (the negated value balance). + pub amount: u64, + /// The anchor of the empty tree. + pub anchor: [u8; 32], + /// The Halo 2 proof. + pub proof: Vec, + /// The binding signature. + pub binding_signature: [u8; 64], +} + +impl OutputsOnlyBundle { + /// The nullifiers the actions reveal. + pub fn nullifiers(&self) -> Vec<[u8; 32]> { + self.actions.iter().map(|action| action.nullifier).collect() + } +} + +/// Builds and proves an outputs-only bundle paying `value` to a fixed recipient, with no extra +/// sighash data. Orchard pads it to two actions. Only the protocol versions that do not bind these +/// bundles accept one: from the version that does, use `build_outputs_only_bundle_bound` with the +/// funder's binding, or the bundle verifies nowhere. +pub fn build_outputs_only_bundle(value: u64) -> OutputsOnlyBundle { + build_outputs_only_bundle_bound(value, &[]) +} + +/// Builds and proves an outputs-only bundle paying `value` to a fixed recipient, committing +/// `extra_sighash_data` — the binding that ties the bundle to the addresses, identity or asset lock +/// funding it, so it cannot be lifted into a transition funded by anything else. Orchard pads it to +/// two actions. +pub fn build_outputs_only_bundle_bound(value: u64, extra_sighash_data: &[u8]) -> OutputsOnlyBundle { + build_outputs_only_bundle_bound_with_outputs(value, 1, extra_sighash_data) +} + +/// Builds a reproducible bundle with the requested number of outputs. Different +/// values, output counts and funder bindings produce distinct dummy nullifiers. +pub fn build_outputs_only_bundle_bound_with_outputs( + value: u64, + output_count: usize, + extra_sighash_data: &[u8], +) -> OutputsOnlyBundle { + assert!(output_count > 0 && value >= output_count as u64); + let mut seed_data = value.to_be_bytes().to_vec(); + seed_data.extend_from_slice(&(output_count as u64).to_be_bytes()); + seed_data.extend_from_slice(extra_sighash_data); + let mut rng = StdRng::from_seed(sha256::Hash::hash(&seed_data).to_byte_array()); + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let recipient = FullViewingKey::from(&sk).address_at(0u32, Scope::External); + + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + Anchor::empty_tree(), + ); + let per_output = value / output_count as u64; + for output in 0..output_count { + let amount = per_output + u64::from(output == 0) * (value % output_count as u64); + builder + .add_output(None, recipient, NoteValue::from_raw(amount), [0u8; 36]) + .unwrap(); + } + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, extra_sighash_data); + let proven = unauthorized + .create_proof(get_proving_key(), &mut rng) + .unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[]).unwrap(); + + let (actions, _flags, value_balance, anchor, proof, binding_signature) = + serialize_authorized_bundle_with_flags(&bundle); + assert_eq!(actions.len(), output_count.max(2)); + assert!(value_balance < 0, "value must enter the pool"); + OutputsOnlyBundle { + actions, + amount: value_balance.unsigned_abs(), + anchor, + proof, + binding_signature, + } +} + +/// The consensus errors the transform every shielded transfer runs returns for a transfer +/// revealing `nullifier`. +/// +/// The transfer carries dummy proof bytes: block processing verifies the proof in the stateless +/// step before this transform, and no one can prove a spend of a note whose nullifier is a +/// shield's dummy-spend nullifier, so the spend-side nullifier check is reached here directly. +/// Records an anchor for the transfer to name first; its fee is 0 so the pool covers it. +pub fn shielded_transfer_errors_revealing( + platform: &TempPlatform, + nullifier: [u8; 32], +) -> Vec { + use crate::execution::validation::state_transition::shielded_transfer::StateTransitionShieldedTransferTransitionActionTransformer; + use crate::platform_types::platform::PlatformRef; + use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; + use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; + + let anchor = [42u8; 32]; + insert_anchor_into_state(platform, &anchor); + + let mut action = create_dummy_serialized_action(); + action.nullifier = nullifier; + let transfer = ShieldedTransferTransition::V0(ShieldedTransferTransitionV0 { + actions: vec![action], + value_balance: 0, + anchor, + proof: vec![0u8; 100], + binding_signature: [0u8; 64], + }); + + let platform_state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + transfer + .transform_into_action_for_shielded_transfer_transition(&platform_ref, None) + .expect("expected the transfer transform to run") + .errors +} + /// Execute a state transition through the full processing pipeline and return the result. pub fn process_transition( platform: &TempPlatform, @@ -617,12 +822,6 @@ pub fn insert_dummy_encrypted_notes(platform: &TempPlatform, co // Shared Orchard Proving Key & Serialization // ========================================== -use grovedb_commitment_tree::{ - Authorized as OrchardAuthorized, Bundle, DashMemo, FullViewingKey, ProvingKey, Scope, - SpendingKey, -}; -use std::sync::OnceLock; - /// Single process-wide proving key shared by ALL shielded tests. /// /// `ProvingKey::build()` costs ~1-2 s. Previously every test module had its diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs index a8d237ac4c3..8402f09d04a 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs @@ -17,8 +17,10 @@ use crate::util::batch::drive_op_batch::ShieldedPoolOperationType; use crate::util::batch::DriveOperation; use dpp::fee::Credits; -/// Insert nullifiers into the permanent tree (double-spend prevention) and -/// per-block sync storage (catch-up RPCs). +/// Insert nullifiers into the permanent nullifier tree, which refuses a nullifier +/// revealed twice: every spend records the nullifiers its actions reveal, and from +/// protocol version 14 so does every shield (an outputs-only bundle's actions +/// reveal the nullifiers of dummy spends, each the `rho` of its new note). pub(super) fn insert_nullifiers<'a>( ops: &mut Vec>, notes: &[ShieldedActionNote], diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_asset_lock_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_asset_lock_transition.rs index 2ee2ac44946..9247eb1dc17 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_asset_lock_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_asset_lock_transition.rs @@ -1,4 +1,4 @@ -use super::{insert_notes, update_balance}; +use super::{insert_notes, insert_nullifiers, update_balance}; use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; @@ -86,10 +86,79 @@ impl DriveHighLevelOperationConverter for ShieldFromAssetLockTransitionAction { Ok(ops) } }, + // Version 1 also records the nullifier each action reveals, as the spends do, so a + // later shield or spend revealing it again is refused. + 1 => match self { + ShieldFromAssetLockTransitionAction::V0(v0) => { + let mut ops: Vec> = Vec::new(); + + // 1. Add the FULL consumed asset-lock value to system credits. It is + // distributed below: `shield_amount` -> shielded pool, `surplus_amount` -> + // `surplus_output` address (when set), and the remainder -> fee pools + // (computed by the execution event as consumed - shield_amount - surplus). + ops.push(DriveOperation::SystemOperation( + SystemOperationType::AddToSystemCredits { + amount: v0.asset_lock_value_to_be_consumed, + }, + )); + + // 2. Record asset lock as consumed (prevent replay) + let asset_lock_value = AssetLockValue::new( + v0.asset_lock_value_to_be_consumed, + vec![], // tx_out_script not needed for shielded + 0, // remaining_credit_value = 0 (fully consumed) + vec![], // no used tags for shielded + platform_version, + ) + .map_err(|e| Error::Protocol(Box::new(e)))?; + ops.push(DriveOperation::SystemOperation( + SystemOperationType::AddUsedAssetLock { + asset_lock_outpoint: v0.asset_lock_outpoint.into(), + asset_lock_value, + }, + )); + + // 3. Route the surplus to the optional platform-address output. When + // `surplus_output` is `None`, `surplus_amount` is 0 and the surplus is + // instead folded into the fee pools by the execution event. Conservation: + // AddToSystemCredits(consumed) == shield_amount (pool) + surplus_amount + // (address) + fee (pools). + if let Some(surplus_address) = v0.surplus_output { + if v0.surplus_amount > 0 { + ops.push(DriveOperation::AddressFundsOperation( + AddressFundsOperationType::AddBalanceToAddress { + address: surplus_address, + balance_to_add: v0.surplus_amount, + }, + )); + } + } + + // 4. Insert each nullifier (known to not exist after validation) + insert_nullifiers(&mut ops, &v0.notes); + + // 5. Insert notes into CommitmentTree + insert_notes(&mut ops, &v0.notes); + + // 6. Update total balance + let new_total_balance = + v0.current_total_balance + .checked_add(v0.shield_amount) + .ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState( + "shielded pool total balance overflow when adding shield_from_asset_lock amount" + .to_string(), + )) + })?; + update_balance(&mut ops, new_total_balance); + + Ok(ops) + } + }, version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "ShieldFromAssetLockTransitionAction::into_high_level_drive_operations" .to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -128,17 +197,48 @@ mod tests { } #[test] - fn test_produces_system_credits_asset_lock_notes_and_balance_ops() { + fn should_record_the_action_nullifiers_before_the_notes() { let action = make_action(); let epoch = Epoch::new(0).unwrap(); let platform_version = PlatformVersion::latest(); + let ops = action + .into_high_level_drive_operations(&epoch, platform_version) + .expect("expected operations"); + + // AddToSystemCredits + AddUsedAssetLock + InsertNullifiers + InsertNote (1) + // + UpdateTotalBalance + assert_eq!(ops.len(), 5); + match &ops[2] { + DriveOperation::ShieldedPoolOperation( + ShieldedPoolOperationType::InsertNullifiers { nullifiers }, + ) => assert_eq!(nullifiers, &vec![[0x11; 32]]), + other => panic!("expected InsertNullifiers, got {:?}", other), + } + assert!(matches!( + &ops[3], + DriveOperation::ShieldedPoolOperation(ShieldedPoolOperationType::InsertNote { .. }) + )); + } + + #[test] + fn should_not_record_nullifiers_before_protocol_version_14() { + let action = make_action(); + let epoch = Epoch::new(0).unwrap(); + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let ops = action .into_high_level_drive_operations(&epoch, platform_version) .expect("expected operations"); // AddToSystemCredits + AddUsedAssetLock + InsertNote (1) + UpdateTotalBalance assert_eq!(ops.len(), 4); + assert!(!ops.iter().any(|op| matches!( + op, + DriveOperation::ShieldedPoolOperation( + ShieldedPoolOperationType::InsertNullifiers { .. } + ) + ))); } #[test] @@ -237,8 +337,9 @@ mod tests { .into_high_level_drive_operations(&epoch, platform_version) .expect("expected operations"); - // AddToSystemCredits + AddUsedAssetLock + AddBalanceToAddress + InsertNote + UpdateTotalBalance - assert_eq!(ops.len(), 5); + // AddToSystemCredits + AddUsedAssetLock + AddBalanceToAddress + InsertNullifiers + // + InsertNote + UpdateTotalBalance + assert_eq!(ops.len(), 6); // The FULL consumed lock is added to system credits (not just the shield amount). match &ops[0] { @@ -278,7 +379,7 @@ mod tests { .into_high_level_drive_operations(&epoch, platform_version) .expect("expected operations"); - assert_eq!(ops.len(), 4); + assert_eq!(ops.len(), 5); assert!( !ops.iter() .any(|op| matches!(op, DriveOperation::AddressFundsOperation(_))), diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_identity_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_identity_transition.rs index 2746f560045..9d305889904 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_identity_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_identity_transition.rs @@ -1,4 +1,4 @@ -use super::{insert_notes, update_balance}; +use super::{insert_notes, insert_nullifiers, update_balance}; use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; @@ -39,6 +39,11 @@ impl DriveHighLevelOperationConverter for ShieldFromIdentityTransitionAction { }), ]; + // Record the nullifier each action reveals, as the spends do. Edited in + // place: `ShieldFromIdentity` is refused by `is_allowed` before protocol + // version 14, so no earlier version reaches this generation. + insert_nullifiers(&mut ops, &v0.notes); + insert_notes(&mut ops, &v0.notes); let new_total_balance = v0 @@ -102,10 +107,11 @@ mod tests { } #[test] - fn test_nonce_then_balance_removal_then_notes_then_pool_total() { + fn test_nonce_then_balance_removal_then_nullifiers_then_notes_then_pool_total() { let ops = ops(make_action(2)); - // UpdateIdentityNonce + RemoveFromIdentityBalance + 2 InsertNote + UpdateTotalBalance - assert_eq!(ops.len(), 5); + // UpdateIdentityNonce + RemoveFromIdentityBalance + InsertNullifiers + 2 InsertNote + // + UpdateTotalBalance + assert_eq!(ops.len(), 6); assert!(matches!( &ops[0], IdentityOperation(IdentityOperationType::UpdateIdentityNonce { nonce: 7, .. }) @@ -119,6 +125,12 @@ mod tests { )); assert!(matches!( &ops[2], + DriveOperation::ShieldedPoolOperation(ShieldedPoolOperationType::InsertNullifiers { + nullifiers + }) if nullifiers.len() == 2 + )); + assert!(matches!( + &ops[3], DriveOperation::ShieldedPoolOperation(ShieldedPoolOperationType::InsertNote { .. }) )); assert!(matches!( diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_transition.rs index cd5ab2ab649..c8533568e51 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_transition.rs @@ -1,4 +1,4 @@ -use super::{insert_notes, update_balance}; +use super::{insert_notes, insert_nullifiers, update_balance}; use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; @@ -54,9 +54,47 @@ impl DriveHighLevelOperationConverter for ShieldTransitionAction { Ok(ops) } }, + // Version 1 also records the nullifier each action reveals, as the spends do, so a + // later shield or spend revealing it again is refused. + 1 => match self { + ShieldTransitionAction::V0(v0) => { + let mut ops: Vec> = Vec::new(); + + // 1. Debit each input address: set remaining balance + for (address, (nonce, remaining_balance)) in v0.inputs_with_remaining_balance { + ops.push(DriveOperation::AddressFundsOperation( + AddressFundsOperationType::SetBalanceToAddress { + address, + nonce, + balance: remaining_balance, + }, + )); + } + + // 2. Insert each nullifier (known to not exist after validation) + insert_nullifiers(&mut ops, &v0.notes); + + // 3. Insert notes into CommitmentTree + insert_notes(&mut ops, &v0.notes); + + // 4. Update total balance + let new_total_balance = v0 + .current_total_balance + .checked_add(v0.shield_amount) + .ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState( + "shielded pool total balance overflow when adding shield amount" + .to_string(), + )) + })?; + update_balance(&mut ops, new_total_balance); + + Ok(ops) + } + }, version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "ShieldTransitionAction::into_high_level_drive_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -98,17 +136,48 @@ mod tests { } #[test] - fn test_produces_set_balance_insert_note_and_update_balance() { + fn should_record_the_action_nullifiers_before_the_notes() { let action = make_action(); let epoch = Epoch::new(0).unwrap(); let platform_version = PlatformVersion::latest(); + let ops = action + .into_high_level_drive_operations(&epoch, platform_version) + .expect("expected operations"); + + // SetBalanceToAddress (1 input) + InsertNullifiers + InsertNote (1 note) + // + UpdateTotalBalance + assert_eq!(ops.len(), 4); + match &ops[1] { + DriveOperation::ShieldedPoolOperation( + ShieldedPoolOperationType::InsertNullifiers { nullifiers }, + ) => assert_eq!(nullifiers, &vec![[0x11; 32]]), + other => panic!("expected InsertNullifiers, got {:?}", other), + } + assert!(matches!( + &ops[2], + DriveOperation::ShieldedPoolOperation(ShieldedPoolOperationType::InsertNote { .. }) + )); + } + + #[test] + fn should_not_record_nullifiers_before_protocol_version_14() { + let action = make_action(); + let epoch = Epoch::new(0).unwrap(); + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let ops = action .into_high_level_drive_operations(&epoch, platform_version) .expect("expected operations"); // SetBalanceToAddress (1 input) + InsertNote (1 note) + UpdateTotalBalance assert_eq!(ops.len(), 3); + assert!(!ops.iter().any(|op| matches!( + op, + DriveOperation::ShieldedPoolOperation( + ShieldedPoolOperationType::InsertNullifiers { .. } + ) + ))); } #[test] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index 9c879945cbb..fc61961a8a5 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -61,6 +61,13 @@ pub struct DriveAbciValidationConstants { /// (Merk node framing, dense path records, the amortized chunk-blob /// framing). pub shielded_storage_bytes_per_action: u64, + /// Effective bytes per action added by `compute_shielded_identity_balance_write_fee_v0` + /// to cover the estimated processing of identity-paid note and nullifier writes. + /// This is an admission allowance, not a physical payload size or a charged fee. + pub shielded_identity_action_write_storage_bytes: u64, + /// Flat effective-byte allowance read by `compute_shielded_identity_balance_write_fee_v0` + /// for estimated identity nonce/balance writes and the complete validation context. + pub shielded_identity_balance_write_storage_bytes: u64, /// Maximum surplus (in credits) that a `ShieldFromAssetLock` may implicitly /// donate to the fee pools when no `surplus_output` address is set. Above this /// cap the transition is rejected so a client cannot accidentally forfeit a diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs index 851fc6651f9..a4d3f23fd29 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs @@ -373,6 +373,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = // The declared physical payload (312 note bytes + 32 nullifier // bytes); locked — released versions replay what they charged. shielded_storage_bytes_per_action: 344, + shielded_identity_action_write_storage_bytes: 0, + shielded_identity_balance_write_storage_bytes: 20, shielded_implicit_fee_cap: 20_000_000_000, shielded_identity_create_denominations: &[], }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 3d76ead1cc4..74966d12af9 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -43,6 +43,9 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::{ // contenders and again for every `contested_document_contenders_per_fund_doubling` (50) more // (DocumentContestNotPaidForError), and charges one stating more only that fund. Structure // validation 1 leaves the amount to it where 0 wanted exactly the contest's fund. +// Shield and shield from asset lock transform_into_action 1 refuse an action +// nullifier repeated inside the bundle or already recorded in state +// (NullifierAlreadySpentError), the same check the spends run. // v9 remains unchanged for PROTOCOL_VERSION_13 chain replay. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = DriveAbciValidationVersions { @@ -353,7 +356,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = identity_signatures: None, nonce: None, state: 0, - transform_into_action: 0, + transform_into_action: 1, // changed: nullifier checks }, shielded_transfer_state_transition: DriveAbciStateTransitionValidationVersion { basic_structure: Some(0), @@ -377,7 +380,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = identity_signatures: None, nonce: None, state: 0, - transform_into_action: 1, // changed: the bundle's sighash binds its kind tag and the asset lock it is funded from + transform_into_action: 1, // changed: the bundle's sighash binds its kind tag and the asset lock it is funded from, and the nullifiers its actions reveal are checked against the bundle and the state and then recorded }, shielded_withdrawal_state_transition: DriveAbciStateTransitionValidationVersion { basic_structure: Some(0), @@ -491,6 +494,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = // so the storage component alone pays for the database work and // the compute fees above stay reserved for compute. shielded_storage_bytes_per_action: 550, + // Fee admission estimates note/nullifier paths at depth 16 and identity + // writes at the maximum-element depth. These effective-byte allowances + // cover that estimate and its validation context, including a BLS signature; + // they do not change the metered charge or the pool-paid fee formula. + shielded_identity_action_write_storage_bytes: 400, + shielded_identity_balance_write_storage_bytes: 500, shielded_implicit_fee_cap: 20_000_000_000, // 0.1, 0.3, 0.5, 1.0 DASH in credits (1 DASH = 10^8 duffs, CREDITS_PER_DUFF = 1000). // v13 revises the v8 set: adds 0.03 and 0.25 DASH, retires 0.3 DASH. diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs index f0a8ffc2bee..432bbaa06ec 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs @@ -373,6 +373,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = // The declared physical payload (312 note bytes + 32 nullifier // bytes); locked — released versions replay what they charged. shielded_storage_bytes_per_action: 344, + shielded_identity_action_write_storage_bytes: 0, + shielded_identity_balance_write_storage_bytes: 20, shielded_implicit_fee_cap: 20_000_000_000, shielded_identity_create_denominations: &[], }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs index 8cd245ceac8..a568addcbc8 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs @@ -373,6 +373,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = // The declared physical payload (312 note bytes + 32 nullifier // bytes); locked — released versions replay what they charged. shielded_storage_bytes_per_action: 344, + shielded_identity_action_write_storage_bytes: 0, + shielded_identity_balance_write_storage_bytes: 20, shielded_implicit_fee_cap: 20_000_000_000, shielded_identity_create_denominations: &[], }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs index ff193306256..f5c45d3f19c 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs @@ -376,6 +376,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = // The declared physical payload (312 note bytes + 32 nullifier // bytes); locked — released versions replay what they charged. shielded_storage_bytes_per_action: 344, + shielded_identity_action_write_storage_bytes: 0, + shielded_identity_balance_write_storage_bytes: 20, shielded_implicit_fee_cap: 20_000_000_000, shielded_identity_create_denominations: &[], }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs index 41d9af0c25e..dbd541308a2 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs @@ -377,6 +377,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = // The declared physical payload (312 note bytes + 32 nullifier // bytes); locked — released versions replay what they charged. shielded_storage_bytes_per_action: 344, + shielded_identity_action_write_storage_bytes: 0, + shielded_identity_balance_write_storage_bytes: 20, shielded_implicit_fee_cap: 20_000_000_000, shielded_identity_create_denominations: &[], }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs index 9750844cb8e..a1b57e3297e 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs @@ -380,6 +380,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = // The declared physical payload (312 note bytes + 32 nullifier // bytes); locked — released versions replay what they charged. shielded_storage_bytes_per_action: 344, + shielded_identity_action_write_storage_bytes: 0, + shielded_identity_balance_write_storage_bytes: 20, shielded_implicit_fee_cap: 20_000_000_000, shielded_identity_create_denominations: &[], }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs index 3f613268a2f..cf6c289f4ed 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs @@ -374,6 +374,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = // The declared physical payload (312 note bytes + 32 nullifier // bytes); locked — released versions replay what they charged. shielded_storage_bytes_per_action: 344, + shielded_identity_action_write_storage_bytes: 0, + shielded_identity_balance_write_storage_bytes: 20, shielded_implicit_fee_cap: 20_000_000_000, shielded_identity_create_denominations: &[], }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs index 6c7e442a094..633dc8c016f 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs @@ -427,6 +427,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = // The declared physical payload (312 note bytes + 32 nullifier // bytes); locked — released versions replay what they charged. shielded_storage_bytes_per_action: 344, + shielded_identity_action_write_storage_bytes: 0, + shielded_identity_balance_write_storage_bytes: 20, shielded_implicit_fee_cap: 20_000_000_000, // 0.1, 0.3, 0.5, 1.0 DASH in credits (1 DASH = 10^8 duffs, CREDITS_PER_DUFF = 1000). shielded_identity_create_denominations: &[ diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs index 00968f69e63..b31dea6dff2 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs @@ -423,6 +423,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V9: DriveAbciValidationVersions = // The declared physical payload (312 note bytes + 32 nullifier // bytes); locked — released versions replay what they charged. shielded_storage_bytes_per_action: 344, + shielded_identity_action_write_storage_bytes: 0, + shielded_identity_balance_write_storage_bytes: 20, shielded_implicit_fee_cap: 20_000_000_000, // 0.1, 0.3, 0.5, 1.0 DASH in credits (1 DASH = 10^8 duffs, CREDITS_PER_DUFF = 1000). // v13 revises the v8 set: adds 0.03 and 0.25 DASH, retires 0.3 DASH. diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs index 1b80d15397f..2b3e1d20f6c 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs @@ -66,8 +66,11 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4: DriveStateTransitionMethodV address_funds_transfer_transition: 0, address_credit_withdrawal_transition: 0, address_funding_from_asset_lock_transition: 0, - shield_transition: 0, - shield_from_asset_lock_transition: 0, + // PROTOCOL_VERSION_14: v1 also records the nullifiers the + // shield's actions reveal, as the spends do. v0 stays for + // PROTOCOL_VERSION_12 and 13 chain replay. + shield_transition: 1, // changed + shield_from_asset_lock_transition: 1, // changed shielded_transfer_transition: 0, unshield_transition: 0, shielded_withdrawal_transition: 0, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index a971031c750..5bb2c1e7b23 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1968,6 +1968,28 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// contract). Inert before this version: only parser generation 3 admits /// an indexOnly document type. /// +/// 76. **Every revealed nullifier is recorded once**: each action of an +/// outputs-only Orchard bundle reveals a nullifier (that of a dummy spend, +/// which becomes the new note's `rho`). The spends already recorded and +/// checked theirs; now `Shield`, `ShieldFromAssetLock` and +/// `ShieldFromIdentity` do too. `transform_into_action` 1 of the shield and +/// the shield from asset lock (`DRIVE_ABCI_VALIDATION_VERSIONS_V10`), and +/// `transform_into_action` 0 of the shield from identity in place, refuse a +/// nullifier repeated inside the bundle or already recorded, with +/// `NullifierAlreadySpentError`: unpaid for the first two, as for the +/// spends, and a paid nonce bump for the identity-signed one. The +/// high-level operations of the shield and the shield from asset lock 1 +/// (`DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4`), and of the shield from +/// identity 0 in place, record the nullifiers. Recording them is metered +/// storage for the shield and the shield from identity; the shield from +/// asset lock's flat pool fee already prices a note and a nullifier write +/// per action. The shield from identity's admission floor +/// (`compute_shielded_identity_balance_write_fee` 0, the client's estimate +/// of its complete fee) uses versioned allowances of 400 effective bytes +/// per action and 500 flat bytes, covering the complete execution-event +/// admission estimate. Actual fees remain metered. Nullifiers revealed by +/// shields before this version are not added. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index f0570b466d4..1b7628baedb 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -195,9 +195,10 @@ fn shielded_fee_formula( /// - `2` → ShieldedWithdrawal (`compute_shielded_withdrawal_fee` — base + /// the flat Core withdrawal-document cost), /// - `3` → ShieldFromIdentity (`compute_shielded_identity_balance_write_fee`: -/// the conservative complete-fee floor, compute + note storage allowance + -/// identity write allowance, that consensus requires the identity to hold on -/// top of the amount; the exact fee is metered at execution), +/// the conservative complete-fee floor, compute + note and nullifier write +/// allowance + identity write allowance, that consensus requires the +/// identity to hold on top of the amount; the exact fee is metered at +/// execution), /// - `4` → IdentityTopUpFromShieldedPool (`compute_shielded_identity_top_up_fee`: /// base + the flat identity-balance write cost, carved from the value balance). ///