diff --git a/book/src/addresses/platform-addresses.md b/book/src/addresses/platform-addresses.md index dcf323bb9d3..904fc107739 100644 --- a/book/src/addresses/platform-addresses.md +++ b/book/src/addresses/platform-addresses.md @@ -223,6 +223,8 @@ pub struct ShieldedWithdrawalTransitionV0 { An asset lock proof from the Core chain funds the shielded pool directly. The recipient is an `OrchardAddress` inside the Orchard bundle. No `PlatformAddress` inputs are needed -- the asset lock proof substitutes for them. +The transition also carries an optional `surplus_output: Option`. When the consumed asset lock exceeds `shield_amount + pool_fee`, the leftover *surplus* is credited to this platform address; if it is unset, the surplus folds into the fee pools (bounded by `shielded_implicit_fee_cap`). See [Entry-Transition Fees](../fees/shielded-fees.md#entry-transition-fees-shield-and-shieldfromassetlock). Unlike the transparent recipients of `Unshield`/`ShieldedWithdrawal` (which are bound through the Orchard sighash `extra_data` above), `surplus_output` is bound through the state transition's **own** `platform_signable` signature -- it sits before the `signature` field, so it is part of the signed payload and cannot be substituted or truncated after signing. The Orchard `extra_data` therefore remains empty for this transition. + ## The Platform Sighash When transparent fields need to be bound to an Orchard bundle's proof, the platform uses a custom sighash computation defined in `packages/rs-dpp/src/shielded/mod.rs`: diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index d52a45c832a..1d4f60406b6 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -58,6 +58,7 @@ Error codes are organized into ranges that correspond to error categories and su | 10600-10603 | State Transition | `InvalidStateTransitionTypeError` (10600), `StateTransitionMaxSizeExceededError` (10602) | | 10700-10700 | General | `OverflowError` (10700) | | 10800-10818 | Address | `TransitionOverMaxInputsError` (10800), `WithdrawalBelowMinAmountError` (10818) | +| 10819-10826 | Shielded | `ShieldedNoActionsError` (10819), `ShieldedTooManyActionsError` (10825), `ShieldedImplicitFeeCapExceededError` (10826) | ### SignatureError codes (20000-20012) diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index b2987ad8ac5..5b11b998c55 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -159,14 +159,15 @@ fn apply_user_fee_increase(&mut self, user_fee_increase: UserFeeIncrease) { ## ExecutionEvent Variants The `ExecutionEvent` enum (in `rs-drive-abci`) determines how fees are collected -for each state transition. There are six variants: +for each state transition. There are seven variants: | Variant | Fee Source | Used By | |---|---|---| | `Paid` | Identity credit balance | Most identity-based transitions | | `PaidFromAssetLock` | Asset lock transaction value | IdentityCreate, IdentityTopUp | | `PaidFromAssetLockWithoutIdentity` | Asset lock (fixed amount) | PartiallyUseAssetLock | -| `PaidFromAddressInputs` | Platform address balances | All address-based transitions | +| `PaidFromAssetLockToPool` | Asset lock value; fee routed to the fee pools | ShieldFromAssetLock | +| `PaidFromAddressInputs` | Platform address balances | All address-based transitions; `Shield` (metered + a ZK compute fee via `additional_fixed_fee_cost`) | | `PaidFixedCost` | Fixed fee to pool | MasternodeVote | | `PaidFromShieldedPool` | Shielded pool value_balance | ShieldedTransfer, Unshield, ShieldedWithdrawal | diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index 692f462b4a6..53c895232a6 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -37,11 +37,11 @@ The fee is derived differently depending on the shielded transition type: | Transition | Fee Formula | Explanation | |---|---|---| -| **Shield** | Paid from transparent address inputs | Fee comes from the transparent side, not from `value_balance`. Skipped by shielded fee validation. | +| **Shield** | `fee = metered(storage + processing) + shielded_verification_fee`, paid from transparent address inputs | Charged on the transparent side (not from `value_balance`), on top of the shielded amount. The storage and processing of the note/nullifier writes are **metered** by GroveDB; only the ZK compute fee (`proof + num_actions × per_action_processing`) is added on top. Skipped by the `value_balance`-based shielded fee validation; enforced through the address-input fee path. See [Entry-Transition Fees](#entry-transition-fees-shield-and-shieldfromassetlock). | | **ShieldedTransfer** | `fee = value_balance` (pinned to the minimum) | The entire `value_balance` is the fee and must equal `compute_minimum_shielded_fee(num_actions)` exactly (overpayment is rejected). Nothing leaves the pool except the fee. | -| **Unshield** | `fee = compute_minimum_shielded_fee(num_actions)` | `value_balance` (the transition's `unshielding_amount`) is the **gross** amount leaving the pool. The output address receives `unshielding_amount − fee`; the `fee` is the flat minimum. Validation requires `unshielding_amount ≥ fee`. | -| **ShieldedWithdrawal** | `fee = compute_minimum_shielded_fee(num_actions)` | `value_balance` (`unshielding_amount`) is the **gross** amount leaving the pool. The Core withdrawal document receives `unshielding_amount − fee` (which must also clear `MIN_WITHDRAWAL_AMOUNT`); the `fee` is the flat minimum. | -| **ShieldFromAssetLock** | Paid from asset lock | Fee comes from the asset lock mechanism, not from `value_balance`. | +| **Unshield** | `fee = compute_minimum_shielded_fee(num_actions) + unshield_address_storage_fee` | `value_balance` (the transition's `unshielding_amount`) is the **gross** amount leaving the pool. The output address receives `unshielding_amount − fee`; validation requires `unshielding_amount ≥ fee`. Unshield also writes the net to the output platform address (`AddBalanceToAddress`), a real storage write priced on top of the base shielded minimum (`unshield_address_storage_fee = 222 × per_byte_rate`, ≈6.08M credits, flat regardless of action count — 222 bytes is the *storage* portion of the ≈6.24M metered address write) so the address write is covered and the proof fee isn't diverted to pay for it. See [Per-Action Storage Fee](#3-per-action-storage-fee). | +| **ShieldedWithdrawal** | `fee = compute_minimum_shielded_fee(num_actions) + withdrawal_document_storage_fee` | `value_balance` (`unshielding_amount`) is the **gross** amount leaving the pool. The Core withdrawal document receives `unshielding_amount − fee` (which must also clear `MIN_WITHDRAWAL_AMOUNT`). Unlike the other pool-paid transitions, ShieldedWithdrawal also **writes a Core withdrawal document** — a real document insert into the withdrawals contract plus its index entries (`AddWithdrawalDocument`), with a real metered cost of ≈110M credits that is **flat regardless of action count**. That cost is priced on top of the base shielded minimum as a flat ~4,100-byte storage component (`withdrawal_document_storage_fee = 4100 × per_byte_rate`), so the document write is covered and the proof-verification fee isn't diverted from the proposer to pay for it. See [Per-Action Storage Fee](#3-per-action-storage-fee). | +| **ShieldFromAssetLock** | `pool_fee = compute_minimum_shielded_fee(num_actions) + asset_lock_base_cost`, paid from the asset lock | The flat shielded minimum plus the asset-lock processing base cost is routed to the fee pools. Any remaining asset-lock value (the *surplus*) goes to an optional signed `surplus_output` platform address, or — if none is set — folds into the fee pools up to `shielded_implicit_fee_cap`. See [Entry-Transition Fees](#entry-transition-fees-shield-and-shieldfromassetlock). | For `ShieldedTransfer`, the client constructs the bundle so that `total_spent − total_output = desired_fee`. The Orchard circuit proves that value is conserved @@ -49,6 +49,92 @@ total_output = desired_fee`. The Orchard circuit proves that value is conserved commits to the `value_balance`. Mutating `value_balance` after signing will cause the binding signature to fail verification. +## Entry-Transition Fees (Shield and ShieldFromAssetLock) + +The two *entry* transitions — `Shield` (transparent → shielded) and +`ShieldFromAssetLock` (Core asset lock → shielded) — move value **into** the pool, so +there is no spent note from which `value_balance` could carry a fee. Their fees are +therefore charged from the funding side, and both cover the same Halo 2 proof +verification and per-action work the other shielded transitions pay for — but they +account for it differently. **`Shield`** debits a state-queryable transparent address +balance, so GroveDB *meters* its real storage/processing and only the compute portion +(`compute_shielded_verification_fee`, no storage term) is added on top. **`ShieldFromAssetLock`** +is funded by a consumed asset lock with no metering anchor, so it pays the flat +`compute_minimum_shielded_fee(num_actions)` (plus the asset-lock base cost). `num_actions` +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). + +### Shield + +`Shield` is charged like any other address-funded transition: GroveDB **meters** the +real storage and processing cost of applying it (the note-commitment and nullifier +writes plus the address-balance updates), and the **shielded compute fee** is added on +top: + +``` +fee = metered_storage + metered_processing + shielded_verification_fee +shielded_verification_fee = proof_verification_fee + num_actions × per_action_processing_fee +``` + +`shielded_verification_fee` is the ZK-verification cost (Halo 2 proof + per-action spend-auth +verification) that GroveDB metering cannot see. It is added as the transition's +`additional_fixed_fee_cost` — exactly the mechanism `IdentityCreateFromAddresses` uses +for its registration cost. It carries **no storage term**: storage comes entirely from +metering, so it is never double-counted. The address inputs must cover `shield_amount + +fee`, and the booked storage/processing equals the deducted amount, so credits are +conserved by the standard machinery (no special-case override). + +`Shield` is skipped by the `value_balance`-based minimum-fee validation (its +`value_balance` is the amount entering the pool, not a fee). The stateless structure +floor requires only `shield_amount + shielded_verification_fee` (a conservative lower bound, +since metered storage is unknowable without state); the authoritative `metered + +compute` funding gate is `validate_fees_of_event`. + +### ShieldFromAssetLock + +The asset lock funds the pool, so the fee is taken from the consumed asset-lock value. +The pool fee is: + +``` +pool_fee = compute_minimum_shielded_fee(num_actions) + asset_lock_base_cost +``` + +`asset_lock_base_cost` is the same asset-lock-proof processing base cost charged to +every asset-lock-funded transition (e.g. `IdentityCreate`): +`required_asset_lock_duff_balance_for_processing_start_for_address_funding` +(50,000 duffs) × `CREDITS_PER_DUFF` (1,000) = **50,000,000 credits**. Adding it makes +the `ShieldFromAssetLock` pool fee strictly greater than the bare `F` paid by the +transparent `Shield`, pricing the extra cost of verifying the Core asset-lock proof. + +The asset lock must cover `shield_amount + pool_fee`; the remainder is the **surplus**: + +``` +surplus = consumed_asset_lock_value − shield_amount − pool_fee (always ≥ 0) +``` + +The surplus is disposed of in one of two ways: + +- **`surplus_output` set** — the transition carries an optional `Option` + `surplus_output`. When present, `surplus` is credited to that platform address (via an + `AddBalanceToAddress` drive operation). This field is **part of the signed payload** + (it sits before the `signature` field, which alone is excluded from the sighash), so a + surplus recipient cannot be substituted or truncated after signing. +- **`surplus_output` unset** — the surplus folds into the fee pools, but only up to + `shielded_implicit_fee_cap` (**20,000,000,000 credits = 0.2 DASH**, a versioned + constant). If the unclaimed surplus would exceed the cap, the transition is rejected + with `ShieldedImplicitFeeCapExceededError` so a client cannot accidentally donate a + large remainder to proposers. To intentionally over-fund, the client must set + `surplus_output` (which has no cap). + +Value conservation across the whole transition is exact: + +``` +consumed_asset_lock_value = shield_amount + surplus_amount + fee_amount +``` + +where `surplus_amount` is `surplus` when `surplus_output` is set and `0` otherwise (in +which case the surplus is part of `fee_amount`). + ## The Three-Component Fee Model The minimum shielded fee has three components: @@ -68,19 +154,25 @@ bundle. ### 2. Per-Action Processing Fee -Each action in the bundle requires: +The per-action processing fee prices the marginal Halo 2 verification work that +each additional action adds to the bundle (≈1.1 ms/action measured against a +≈5 ms bundle base): a bundle with more actions is a larger circuit and a longer +batch verification. For a spend-bearing action that marginal work includes: - RedPallas spend authorization signature verification - Nullifier duplicate check (hash + tree lookup) - Note commitment insertion into the Sinsemilla-based Merkle tree -The per-action processing fee prices the marginal Halo 2 verification work that -each additional action adds to the bundle (≈1.1 ms/action measured against a -≈5 ms bundle base), so it 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.) +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 — this fee tracks the marginal verification +work, not a fixed per-action checklist. + +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.) **Current value:** `22,000,000` credits (22M) @@ -122,6 +214,23 @@ Note: The Orchard protocol requires a minimum of 2 actions per bundle for privac (even a single-input single-output transfer produces 2 actions with a dummy padding action). Bundles with 1 action are structurally invalid. +The totals above are the **base** `compute_minimum_shielded_fee` and apply directly to +`ShieldedTransfer`. The two pool-paid transitions that write one extra per-transition output add a +flat storage 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 + 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 + count). See the [Fee Extraction](#fee-extraction-by-transition-type) ShieldedWithdrawal row for + why this component exists. + ## Where Fee Validation Runs Fee validation is integrated into the processor pipeline (see @@ -168,9 +277,14 @@ pub struct DriveAbciValidationConstants { pub shielded_anchor_pruning_interval: u64, pub shielded_proof_verification_fee: u64, // 100_000_000 pub shielded_per_action_processing_fee: u64, // 22_000_000 + pub shielded_implicit_fee_cap: u64, // 20_000_000_000 (0.2 DASH) } ``` +The `shielded_implicit_fee_cap` bounds the surplus that a `ShieldFromAssetLock` may +implicitly donate to the fee pools when no `surplus_output` is set (see +[Entry-Transition Fees](#entry-transition-fees-shield-and-shieldfromassetlock)). + The storage component is not a separate constant — it 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 @@ -196,10 +310,18 @@ ShieldedWithdrawal: pool_balance -= unshielding_amount // gross For `Unshield` and `ShieldedWithdrawal`, `unshielding_amount` is the **gross** amount leaving the pool. Of that, `unshielding_amount − fee_amount` is credited to the output platform address (`Unshield`) or written into the Core withdrawal document -(`ShieldedWithdrawal`), and `fee_amount` — the flat `compute_minimum_shielded_fee` — is -booked as the transition fee. Validation guarantees `unshielding_amount ≥ fee_amount` -(and, for `ShieldedWithdrawal`, that the net also clears `MIN_WITHDRAWAL_AMOUNT`), so the -subtraction never underflows. +(`ShieldedWithdrawal`), and `fee_amount` is booked as the transition fee. For `Unshield` +that fee is `compute_shielded_unshield_fee` — the base fee **plus** the flat +`AddBalanceToAddress` output-write storage cost (`+6,082,800` credits), since `Unshield` +also writes the net to a transparent platform address. For `ShieldedWithdrawal` it is +`compute_shielded_withdrawal_fee` — the same base fee **plus** the flat Core +withdrawal-document storage cost (`+112,340,000` credits), since ShieldedWithdrawal also +writes a real document into the withdrawals contract. Validation guarantees +`unshielding_amount ≥ fee_amount` (and, for `ShieldedWithdrawal`, that the net also clears +`MIN_WITHDRAWAL_AMOUNT`), so the subtraction never underflows. Because each fee prices its +extra write, the booking split (storage routed to the storage pool, the remainder paid to +the proposer) covers that write instead of zeroing the proposer's processing reward to +cover it. For `ShieldedTransfer`, the pool decreases by exactly the fee (the sender's notes are spent and the recipient's notes are created, but the pool's aggregate balance only drops @@ -211,6 +333,21 @@ fee: the storage cost of the permanent shielded writes is routed to the storage remainder — proof verification plus per-action processing — is the processing fee paid to the current block proposer. +The two entry transitions do not decrement the pool (they add to it), so their fees are +booked from the funding side instead: + +``` +Shield: fee_amount = metered + shielded_verification_fee // from transparent address inputs +ShieldFromAssetLock: fee_amount = pool_fee (+ unclaimed surplus) // from the consumed asset lock +``` + +For `Shield`, the fee is deducted from the transparent address inputs and booked through +the standard `PaidFromAddressInputs` event (deducted == booked, no override): metered +storage and processing, plus the `shielded_verification_fee` folded into processing. For +`ShieldFromAssetLock`, the consumed asset-lock value is partitioned into `shield_amount` +(into the pool), `surplus_amount` (to `surplus_output`, or `0`), and `fee_amount` (to the +fee pools); see [Entry-Transition Fees](#entry-transition-fees-shield-and-shieldfromassetlock). + ## Cryptographic Binding The fee is not just a field that the platform trusts. It is cryptographically bound diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index f969346ebf3..29713a500f8 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -79,11 +79,11 @@ use crate::consensus::basic::state_transition::{ InvalidRemainderOutputCountError, InvalidStateTransitionTypeError, MissingStateTransitionTypeError, OutputAddressAlsoInputError, OutputBelowMinimumError, OutputsNotGreaterThanInputsError, ShieldedEmptyProofError, - ShieldedEncryptedNoteSizeMismatchError, ShieldedInvalidValueBalanceError, - ShieldedNoActionsError, ShieldedTooManyActionsError, ShieldedZeroAnchorError, - StateTransitionMaxSizeExceededError, StateTransitionNotActiveError, TransitionNoInputsError, - TransitionNoOutputsError, TransitionOverMaxInputsError, TransitionOverMaxOutputsError, - WithdrawalBalanceMismatchError, WithdrawalBelowMinAmountError, + ShieldedEncryptedNoteSizeMismatchError, ShieldedImplicitFeeCapExceededError, + ShieldedInvalidValueBalanceError, ShieldedNoActionsError, ShieldedTooManyActionsError, + ShieldedZeroAnchorError, StateTransitionMaxSizeExceededError, StateTransitionNotActiveError, + TransitionNoInputsError, TransitionNoOutputsError, TransitionOverMaxInputsError, + TransitionOverMaxOutputsError, WithdrawalBalanceMismatchError, WithdrawalBelowMinAmountError, }; use crate::consensus::basic::{ IncompatibleProtocolVersionError, UnsupportedFeatureError, UnsupportedProtocolVersionError, @@ -681,6 +681,13 @@ pub enum BasicError { #[error(transparent)] IdentityAssetLockTransactionTooManyInputsError(IdentityAssetLockTransactionTooManyInputsError), + + // NOTE: `BasicError` is bincode-encoded positionally (no explicit discriminants), so new + // variants MUST be appended at the tail — inserting mid-enum would shift the wire discriminants + // of every following variant and mis-decode previously-encoded errors. The error-code integer + // (codes.rs) is independent of variant order. + #[error(transparent)] + ShieldedImplicitFeeCapExceededError(ShieldedImplicitFeeCapExceededError), } impl From for ConsensusError { diff --git a/packages/rs-dpp/src/errors/consensus/basic/state_transition/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/state_transition/mod.rs index 4b549af9155..20f152f0e26 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/state_transition/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/state_transition/mod.rs @@ -15,6 +15,7 @@ mod output_below_minimum_error; mod outputs_not_greater_than_inputs_error; mod shielded_empty_proof_error; mod shielded_encrypted_note_size_mismatch_error; +mod shielded_implicit_fee_cap_exceeded_error; mod shielded_invalid_value_balance_error; mod shielded_no_actions_error; mod shielded_too_many_actions_error; @@ -45,6 +46,7 @@ pub use output_below_minimum_error::*; pub use outputs_not_greater_than_inputs_error::*; pub use shielded_empty_proof_error::*; pub use shielded_encrypted_note_size_mismatch_error::*; +pub use shielded_implicit_fee_cap_exceeded_error::*; pub use shielded_invalid_value_balance_error::*; pub use shielded_no_actions_error::*; pub use shielded_too_many_actions_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/state_transition/shielded_implicit_fee_cap_exceeded_error.rs b/packages/rs-dpp/src/errors/consensus/basic/state_transition/shielded_implicit_fee_cap_exceeded_error.rs new file mode 100644 index 00000000000..23c6b1a4180 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/state_transition/shielded_implicit_fee_cap_exceeded_error.rs @@ -0,0 +1,44 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::fee::Credits; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("asset-lock surplus {surplus} exceeds the implicit fee cap {cap}; set a surplus_output address to receive the remainder")] +#[platform_serialize(unversioned)] +pub struct ShieldedImplicitFeeCapExceededError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + /// The asset-lock surplus (in credits) that would be implicitly donated to the fee pools. + surplus: Credits, + /// The maximum surplus (in credits) that may be implicitly donated without a `surplus_output`. + cap: Credits, +} + +impl ShieldedImplicitFeeCapExceededError { + pub fn new(surplus: Credits, cap: Credits) -> Self { + Self { surplus, cap } + } + + pub fn surplus(&self) -> Credits { + self.surplus + } + + pub fn cap(&self) -> Credits { + self.cap + } +} + +impl From for ConsensusError { + fn from(err: ShieldedImplicitFeeCapExceededError) -> Self { + Self::BasicError(BasicError::ShieldedImplicitFeeCapExceededError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index cece174c4ae..a7424f3c121 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -233,13 +233,14 @@ impl ErrorWithCode for BasicError { Self::OutputAddressAlsoInputError(_) => 10816, Self::InvalidRemainderOutputCountError(_) => 10817, Self::WithdrawalBelowMinAmountError(_) => 10818, - // Shielded transition errors (10819-10825) + // Shielded transition errors (10819-10826) Self::ShieldedNoActionsError(_) => 10819, Self::ShieldedEmptyProofError(_) => 10820, Self::ShieldedZeroAnchorError(_) => 10821, Self::ShieldedInvalidValueBalanceError(_) => 10822, Self::ShieldedEncryptedNoteSizeMismatchError(_) => 10823, Self::ShieldedTooManyActionsError(_) => 10825, + Self::ShieldedImplicitFeeCapExceededError(_) => 10826, } } } diff --git a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs index c71e678c29c..5d54198d3e4 100644 --- a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs +++ b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs @@ -1,4 +1,4 @@ -use crate::address_funds::OrchardAddress; +use crate::address_funds::{OrchardAddress, PlatformAddress}; use crate::prelude::AssetLockProof; use crate::state_transition::shield_from_asset_lock_transition::methods::ShieldFromAssetLockTransitionMethodsV0; use crate::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; @@ -20,6 +20,9 @@ use super::{build_output_only_bundle, serialize_authorized_bundle, OrchardProver /// - `asset_lock_private_key` - Private key for the asset lock (signs the transition) /// - `prover` - Orchard prover (holds the Halo 2 proving key) /// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload) +/// - `surplus_output` - Optional platform address that receives the asset-lock surplus +/// (`asset_lock_value − shield_amount − fee`); when `None`, the surplus is added to the fee +/// pools, capped at `shielded_implicit_fee_cap` /// - `platform_version` - Protocol version #[allow(clippy::too_many_arguments)] pub fn build_shield_from_asset_lock_transition( @@ -29,6 +32,7 @@ pub fn build_shield_from_asset_lock_transition( asset_lock_private_key: &[u8], prover: &P, memo: [u8; 36], + surplus_output: Option, platform_version: &PlatformVersion, ) -> Result { let bundle = build_output_only_bundle(recipient, shield_amount, memo, prover)?; @@ -54,6 +58,7 @@ pub fn build_shield_from_asset_lock_transition( sb.anchor, sb.proof, sb.binding_signature, + surplus_output, platform_version, ) } @@ -72,6 +77,9 @@ pub fn build_shield_from_asset_lock_transition( /// - `asset_lock_signer` - External signer that produces the outer ECDSA signature /// - `prover` - Orchard prover (holds the Halo 2 proving key) /// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload) +/// - `surplus_output` - Optional platform address that receives the asset-lock surplus +/// (`asset_lock_value − shield_amount − fee`); when `None`, the surplus is added to the fee +/// pools, capped at `shielded_implicit_fee_cap` /// - `platform_version` - Protocol version #[cfg(feature = "core_key_wallet")] #[allow(clippy::too_many_arguments)] @@ -83,6 +91,7 @@ pub async fn build_shield_from_asset_lock_transition_with_signer( asset_lock_signer: &AS, prover: &P, memo: [u8; 36], + surplus_output: Option, platform_version: &PlatformVersion, ) -> Result where @@ -113,6 +122,7 @@ where sb.anchor, sb.proof, sb.binding_signature, + surplus_output, platform_version, ) .await @@ -151,6 +161,24 @@ mod tests { assert_eq!(abs_balance, amount); } + /// Consensus prices the shielded fee from the on-wire `actions.len()`, and the wallet reserves + /// the fee for exactly 2 actions (Orchard's `MIN_ACTIONS`). A single-output, spends-disabled + /// bundle must therefore serialize to exactly 2 on-wire actions. If a future Orchard or builder + /// change alters that padding, the hardcoded wallet reservation would diverge from what consensus + /// charges (a valid client tx would be rejected); this test fails loudly if that invariant breaks. + #[test] + fn test_output_only_bundle_serializes_to_min_actions() { + let recipient = test_orchard_address(); + let bundle = build_output_only_bundle(&recipient, 50_000u64, [0u8; 36], &TestProver) + .expect("bundle should build"); + let sb = serialize_authorized_bundle(&bundle); + assert_eq!( + sb.actions.len(), + 2, + "single-output shield bundle must pad to exactly 2 on-wire actions" + ); + } + // ------------------------------------------------------------- // Arithmetic edge cases on the value_balance conversion branch // (the `checked_neg().and_then(u64::try_from)` chain). diff --git a/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs b/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs index 329baa880f8..c4e4e633619 100644 --- a/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs +++ b/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs @@ -3,7 +3,7 @@ use grovedb_commitment_tree::{Anchor, FullViewingKey, SpendAuthorizingKey}; use crate::address_funds::OrchardAddress; use crate::fee::Credits; use crate::identity::core_script::CoreScript; -use crate::shielded::compute_minimum_shielded_fee; +use crate::shielded::compute_shielded_withdrawal_fee; use crate::state_transition::shielded_withdrawal_transition::methods::ShieldedWithdrawalTransitionMethodsV0; use crate::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition; use crate::state_transition::StateTransition; @@ -34,8 +34,9 @@ use super::{build_spend_bundle, serialize_authorized_bundle, OrchardProver, Spen /// - `platform_version` - Protocol version /// /// The fee is not a parameter: consensus always charges exactly -/// `compute_minimum_shielded_fee` and ignores any surplus. Returns the built transition -/// together with the fee (in credits) that was applied. +/// `compute_shielded_withdrawal_fee` (the base shielded minimum fee PLUS the flat storage cost of +/// the Core withdrawal document this transition inserts) and ignores any surplus. Returns the built +/// transition together with the fee (in credits) that was applied. #[allow(clippy::too_many_arguments)] pub fn build_shielded_withdrawal_transition( spends: Vec, @@ -68,10 +69,11 @@ pub fn build_shielded_withdrawal_transition( // rejects an honest single-spend withdrawal with InsufficientShieldedFeeError (or, // post-fee, WithdrawalBelowMinAmountError). let num_actions = spends.len().max(2); - // The fee is fixed at the minimum: consensus always carves exactly - // `compute_minimum_shielded_fee` from the pool, and the net (`withdrawal_amount`) goes to - // the Core withdrawal document. - let fee = compute_minimum_shielded_fee(num_actions, platform_version)?; + // The fee is fixed at the withdrawal minimum: consensus always carves exactly + // `compute_shielded_withdrawal_fee` from the pool — the base shielded minimum fee PLUS the + // flat storage cost of the Core withdrawal document this transition inserts — and the net + // (`withdrawal_amount`) goes to that Core withdrawal document. + let fee = compute_shielded_withdrawal_fee(num_actions, platform_version)?; let required = withdrawal_amount.checked_add(fee).ok_or_else(|| { ProtocolError::ShieldedBuildError("fee + withdrawal_amount overflows u64".to_string()) diff --git a/packages/rs-dpp/src/shielded/builder/unshield.rs b/packages/rs-dpp/src/shielded/builder/unshield.rs index 02c26fa6092..0433d2681de 100644 --- a/packages/rs-dpp/src/shielded/builder/unshield.rs +++ b/packages/rs-dpp/src/shielded/builder/unshield.rs @@ -2,7 +2,7 @@ use grovedb_commitment_tree::{Anchor, FullViewingKey, SpendAuthorizingKey}; use crate::address_funds::{OrchardAddress, PlatformAddress}; use crate::fee::Credits; -use crate::shielded::compute_minimum_shielded_fee; +use crate::shielded::compute_shielded_unshield_fee; use crate::state_transition::unshield_transition::methods::UnshieldTransitionMethodsV0; use crate::state_transition::unshield_transition::UnshieldTransition; use crate::state_transition::StateTransition; @@ -30,8 +30,10 @@ use super::{build_spend_bundle, serialize_authorized_bundle, OrchardProver, Spen /// - `platform_version` - Protocol version /// /// The fee is not a parameter: consensus always charges exactly -/// `compute_minimum_shielded_fee` and ignores any surplus. Returns the built transition -/// together with the fee (in credits) that was applied. +/// `compute_shielded_unshield_fee` (the base shielded minimum fee PLUS the flat storage cost of the +/// single `AddBalanceToAddress` write this transition performs crediting the net to the output +/// address) and ignores any surplus. Returns the built transition together with the fee (in +/// credits) that was applied. #[allow(clippy::too_many_arguments)] pub fn build_unshield_transition( spends: Vec, @@ -61,10 +63,11 @@ pub fn build_unshield_transition( // otherwise consensus recomputes min_fee from the on-wire actions.len() == 2 and // rejects an honest single-spend unshield with InsufficientShieldedFeeError. let num_actions = spends.len().max(2); - // The fee is fixed at the minimum: consensus always carves exactly - // `compute_minimum_shielded_fee` from the pool, and the net (`unshield_amount`) is credited - // to the output address. - let fee = compute_minimum_shielded_fee(num_actions, platform_version)?; + // The fee is fixed at the unshield minimum: consensus always carves exactly + // `compute_shielded_unshield_fee` from the pool — the base shielded minimum fee PLUS the flat + // storage cost of the single `AddBalanceToAddress` write this transition performs — and the net + // (`unshield_amount`) is credited to the output address. + let fee = compute_shielded_unshield_fee(num_actions, platform_version)?; let required = unshield_amount.checked_add(fee).ok_or_else(|| { ProtocolError::ShieldedBuildError("fee + unshield_amount overflows u64".to_string()) @@ -266,7 +269,7 @@ mod tests { let change_address = test_orchard_address(); let output_address = PlatformAddress::P2pkh([1u8; 20]); - let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version) + let min_fee = crate::shielded::compute_shielded_unshield_fee(2, platform_version) .expect("fee computation should not overflow"); let unshield_amount = 42u64; let note = test_spendable_note(unshield_amount + min_fee); 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 bfded47732b..77f93cba69d 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 @@ -4,16 +4,30 @@ use crate::fee::Credits; use crate::ProtocolError; use platform_version::version::PlatformVersion; use v0::compute_minimum_shielded_fee_v0; +use v0::compute_shielded_unshield_fee_v0; +use v0::compute_shielded_verification_fee_v0; +use v0::compute_shielded_withdrawal_fee_v0; -/// Computes the minimum fee (in credits) for a shielded state transition. +/// Computes the minimum **flat** fee (in credits) for a pool-paid / asset-lock shielded +/// transition. /// /// Dispatches on the platform-versioned `dpp.methods.compute_minimum_shielded_fee` so the /// fee formula can evolve across protocol versions without breaking older ones. /// -/// This is the **single source of truth** for the shielded fee formula: the SDK builders, -/// the unshield/withdrawal transformers (for the fee actually carved from the pool), and the -/// consensus gate `validate_minimum_shielded_fee` all call it, so the carved fee and the -/// validation threshold can never drift. +/// This is the **base** flat shielded fee for the pool-paid / asset-lock transitions whose storage +/// cannot be metered against an address balance. ShieldedTransfer and ShieldFromAssetLock charge +/// exactly this base (ShieldFromAssetLock adds the asset-lock base cost on the asset-lock side); the +/// other two pool-paid transitions add one flat per-transition storage component on top of this +/// base — Unshield via [`compute_shielded_unshield_fee`] (the `AddBalanceToAddress` output write) +/// and ShieldedWithdrawal via [`compute_shielded_withdrawal_fee`] (the Core withdrawal document). +/// For each transition, its SDK builder, its transformer (for the fee actually carved from the +/// pool), and the consensus gate `validate_minimum_shielded_fee` all call the SAME one of these +/// functions, so the carved fee and the validation threshold can never drift. +/// +/// The transparent `Shield` is the exception: it meters its note/nullifier storage via GroveDB and +/// adds only the COMPUTE portion via the sibling [`compute_shielded_verification_fee`] (which carries no +/// storage term). Both functions dispatch on the SAME version key, so the flat fee and the compute +/// fee always evolve together and cannot drift. /// /// # Parameters /// - `num_actions` — number of Orchard actions in the bundle @@ -31,3 +45,102 @@ pub fn compute_minimum_shielded_fee( }), } } + +/// Computes the **ShieldedWithdrawal** fee (in credits): [`compute_minimum_shielded_fee`] PLUS the +/// flat storage cost of the Core withdrawal document a `ShieldedWithdrawal` inserts. +/// +/// A `ShieldedWithdrawal` additionally writes a Core withdrawal document into the withdrawals +/// contract (the document plus its index entries — `AddWithdrawalDocument`), a real, +/// GroveDB-metered insert (≈110M credits, FLAT regardless of action count) that +/// [`compute_minimum_shielded_fee`] does NOT price. This function adds that document cost as a +/// flat `SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES`-byte storage component (priced at the same +/// per-byte storage rate the per-action note storage uses). +/// +/// Used ONLY by `ShieldedWithdrawal`: its SDK builder, the withdrawal transformer (for the fee +/// carved from the pool), and the consensus gate `validate_minimum_shielded_fee` all call this +/// function, so the carved fee and the validation threshold can never drift. ShieldedTransfer keeps +/// using [`compute_minimum_shielded_fee`], Unshield uses [`compute_shielded_unshield_fee`], and the +/// entry transitions use [`compute_minimum_shielded_fee`] / [`compute_shielded_verification_fee`]. +/// +/// Dispatches on the SAME version key (`dpp.methods.compute_minimum_shielded_fee`) as +/// [`compute_minimum_shielded_fee`] so the two formulas evolve together across protocol versions. +/// +/// # Parameters +/// - `num_actions` — number of Orchard actions in the bundle +/// - `platform_version` — protocol version (determines the formula version and fee constants) +pub fn compute_shielded_withdrawal_fee( + num_actions: usize, + platform_version: &PlatformVersion, +) -> Result { + match platform_version.dpp.methods.compute_minimum_shielded_fee { + 0 => compute_shielded_withdrawal_fee_v0(num_actions, platform_version), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "compute_shielded_withdrawal_fee".to_string(), + known_versions: vec![0], + received: version, + }), + } +} + +/// Computes the **Unshield** fee (in credits): [`compute_minimum_shielded_fee`] PLUS the flat +/// storage cost of the single `AddBalanceToAddress` write an `Unshield` performs. +/// +/// An `Unshield` additionally credits the net (`unshielding_amount − fee`) to the output platform +/// address via `AddBalanceToAddress`, a real, GroveDB-metered write (≈6.24M credits, FLAT +/// regardless of action count) that [`compute_minimum_shielded_fee`] does NOT price. This function +/// adds that address cost as a flat `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES`-byte storage +/// component (priced at the same per-byte storage rate the per-action note storage uses). +/// +/// Used ONLY by `Unshield`: its SDK builder, the unshield transformer (for the fee carved from the +/// pool), and the consensus gate `validate_minimum_shielded_fee` all call this function, so the +/// carved fee and the validation threshold can never drift. ShieldedTransfer, ShieldedWithdrawal, +/// and the entry transitions keep using [`compute_minimum_shielded_fee`] / +/// [`compute_shielded_withdrawal_fee`] / [`compute_shielded_verification_fee`]. +/// +/// Dispatches on the SAME version key (`dpp.methods.compute_minimum_shielded_fee`) as +/// [`compute_minimum_shielded_fee`] so the two formulas evolve together across protocol versions. +/// +/// # Parameters +/// - `num_actions` — number of Orchard actions in the bundle +/// - `platform_version` — protocol version (determines the formula version and fee constants) +pub fn compute_shielded_unshield_fee( + num_actions: usize, + platform_version: &PlatformVersion, +) -> Result { + match platform_version.dpp.methods.compute_minimum_shielded_fee { + 0 => compute_shielded_unshield_fee_v0(num_actions, platform_version), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "compute_shielded_unshield_fee".to_string(), + known_versions: vec![0], + received: version, + }), + } +} + +/// Computes the **compute-only** shielded fee (in credits): the ZK-compute portion (Halo 2 proof +/// verification + per-action spend-auth/nullifier processing) that GroveDB metering cannot see. +/// +/// Unlike [`compute_minimum_shielded_fee`] this carries **no storage term**. It is used by the +/// transparent `Shield`, which meters its note/nullifier storage writes via GroveDB and adds only +/// this compute fee on top (as the event's `additional_fixed_fee_cost`), so storage is never +/// double-counted. +/// +/// Dispatches on the SAME version key (`dpp.methods.compute_minimum_shielded_fee`) as +/// [`compute_minimum_shielded_fee`] so the two formulas evolve together across protocol versions. +/// +/// # Parameters +/// - `num_actions` — number of Orchard actions in the bundle +/// - `platform_version` — protocol version (determines the formula version and fee constants) +pub fn compute_shielded_verification_fee( + num_actions: usize, + platform_version: &PlatformVersion, +) -> Result { + match platform_version.dpp.methods.compute_minimum_shielded_fee { + 0 => compute_shielded_verification_fee_v0(num_actions, platform_version), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "compute_shielded_verification_fee".to_string(), + known_versions: vec![0], + received: version, + }), + } +} 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 5fe28e9df80..57f4b4e56cf 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,17 +1,30 @@ use crate::fee::Credits; -use crate::shielded::SHIELDED_STORAGE_BYTES_PER_ACTION; +use crate::shielded::{ + SHIELDED_STORAGE_BYTES_PER_ACTION, SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES, + SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES, +}; use crate::ProtocolError; use platform_version::version::PlatformVersion; -/// v0 of the shielded minimum-fee formula: +/// v0 of the shielded **compute-only** fee: +/// +/// `compute_fee = proof_verification_fee + num_actions × processing_fee` /// -/// `min_fee = proof_verification_fee + num_actions × (processing_fee + storage_fee)` +/// This is the ZK-compute portion of a shielded transition that GroveDB metering cannot see. It has +/// two parts: a per-bundle `proof_verification_fee` for the single Halo 2 proof, and a per-action +/// `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. /// -/// where `storage_fee = SHIELDED_STORAGE_BYTES_PER_ACTION × (disk + processing) credits/byte`. +/// It carries **no storage term**: storage is the real cost of the note/nullifier writes and is +/// metered separately by GroveDB. /// /// All arithmetic is checked: an overflow (only reachable via pathological fee constants) /// surfaces as `ProtocolError::Overflow` instead of silently wrapping. -pub fn compute_minimum_shielded_fee_v0( +pub fn compute_shielded_verification_fee_v0( num_actions: usize, platform_version: &PlatformVersion, ) -> Result { @@ -19,28 +32,274 @@ pub fn compute_minimum_shielded_fee_v0( .drive_abci .validation_and_processing .event_constants; + + let actions_fee = (num_actions as u64) + .checked_mul(constants.shielded_per_action_processing_fee) + .ok_or(ProtocolError::Overflow( + "shielded compute actions fee overflow", + ))?; + constants + .shielded_proof_verification_fee + .checked_add(actions_fee) + .ok_or(ProtocolError::Overflow("shielded compute fee overflow")) +} + +/// v0 of the shielded minimum-fee formula: +/// +/// `min_fee = compute_fee + num_actions × storage_fee_per_action` +/// +/// where `compute_fee = proof_verification_fee + num_actions × processing_fee` +/// (see [`compute_shielded_verification_fee_v0`]) and +/// `storage_fee_per_action = SHIELDED_STORAGE_BYTES_PER_ACTION × (disk + processing) credits/byte`. +/// +/// Expanding, this equals the historical formula +/// `proof_verification_fee + num_actions × (processing_fee + 312×rate)`: the compute fee already +/// contributes `proof + num_actions × processing`, and this function adds +/// `num_actions × (312×rate)` storage on top, so the returned numeric value is unchanged. +/// +/// This is the fee carved from the shielded **pool** by the pool-paid transitions +/// (ShieldedTransfer / Unshield / ShieldedWithdrawal), which cannot meter their writes against an +/// address balance and so must price a flat storage estimate into the carved fee. The transparent +/// `Shield` instead meters storage via GroveDB and only adds [`compute_shielded_verification_fee_v0`]. +/// +/// All arithmetic is checked: an overflow (only reachable via pathological fee constants) +/// surfaces as `ProtocolError::Overflow` instead of silently wrapping. +pub fn compute_minimum_shielded_fee_v0( + num_actions: usize, + platform_version: &PlatformVersion, +) -> Result { let storage = &platform_version.fee_version.storage; + let compute_fee = compute_shielded_verification_fee_v0(num_actions, platform_version)?; + let per_byte_rate = storage .storage_disk_usage_credit_per_byte .checked_add(storage.storage_processing_credit_per_byte) .ok_or(ProtocolError::Overflow( "shielded storage per-byte rate overflow", ))?; - let storage_fee = SHIELDED_STORAGE_BYTES_PER_ACTION + let storage_fee_per_action = SHIELDED_STORAGE_BYTES_PER_ACTION .checked_mul(per_byte_rate) .ok_or(ProtocolError::Overflow( "shielded per-action storage fee overflow", ))?; - let per_action = constants - .shielded_per_action_processing_fee + let storage_fee = (num_actions as u64) + .checked_mul(storage_fee_per_action) + .ok_or(ProtocolError::Overflow( + "shielded actions storage fee overflow", + ))?; + compute_fee .checked_add(storage_fee) - .ok_or(ProtocolError::Overflow("shielded per-action fee overflow"))?; - let actions_fee = (num_actions as u64) - .checked_mul(per_action) - .ok_or(ProtocolError::Overflow("shielded actions fee overflow"))?; - constants - .shielded_proof_verification_fee - .checked_add(actions_fee) .ok_or(ProtocolError::Overflow("shielded minimum fee overflow")) } + +/// v0 of the shielded **withdrawal** fee formula: +/// +/// `withdrawal_fee = compute_minimum_shielded_fee_v0(num_actions) +/// + SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES × (disk + processing) credits/byte` +/// +/// This is [`compute_minimum_shielded_fee_v0`] PLUS one flat storage component for the Core +/// withdrawal document a `ShieldedWithdrawal` inserts (the document plus its +/// withdrawals-contract index entries — `AddWithdrawalDocument`). That document insert has a +/// real, GroveDB-metered cost of ≈110M credits that is FLAT regardless of action count and is +/// NOT priced by `compute_minimum_shielded_fee_v0` (which only covers per-action note/nullifier +/// storage and the per-bundle ZK compute). Pricing it as `SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES` +/// effective bytes at the SAME per-byte storage rate the per-action note storage uses keeps the +/// document write covered (so `execute_event/v0`'s `storage = min(real_storage, fee)` booking +/// split never zeroes the proposer's processing reward) and lets the component track the storage +/// rate as it evolves. +/// +/// This fee is used ONLY by `ShieldedWithdrawal`. The other pool-paid transitions +/// (ShieldedTransfer / Unshield) and the entry transitions keep using +/// [`compute_minimum_shielded_fee_v0`] / [`compute_shielded_verification_fee_v0`]. +/// +/// All arithmetic is checked: an overflow (only reachable via pathological fee constants) +/// surfaces as `ProtocolError::Overflow` instead of silently wrapping. The `per_byte_rate` is +/// computed exactly as in [`compute_minimum_shielded_fee_v0`]. +pub fn compute_shielded_withdrawal_fee_v0( + num_actions: usize, + platform_version: &PlatformVersion, +) -> Result { + let storage = &platform_version.fee_version.storage; + + let base_fee = compute_minimum_shielded_fee_v0(num_actions, platform_version)?; + + let per_byte_rate = storage + .storage_disk_usage_credit_per_byte + .checked_add(storage.storage_processing_credit_per_byte) + .ok_or(ProtocolError::Overflow( + "shielded storage per-byte rate overflow", + ))?; + let document_storage_fee = SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES + .checked_mul(per_byte_rate) + .ok_or(ProtocolError::Overflow( + "shielded withdrawal document storage fee overflow", + ))?; + base_fee + .checked_add(document_storage_fee) + .ok_or(ProtocolError::Overflow("shielded withdrawal fee overflow")) +} + +/// v0 of the shielded **unshield** fee formula: +/// +/// `unshield_fee = compute_minimum_shielded_fee_v0(num_actions) +/// + SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES × (disk + processing) credits/byte` +/// +/// This is [`compute_minimum_shielded_fee_v0`] PLUS one flat storage component for the single +/// `AddBalanceToAddress` write an `Unshield` performs, crediting the net +/// (`unshielding_amount − fee`) to the output platform address. That address write has a real, +/// GroveDB-metered cost of ≈6.24M credits that is FLAT regardless of action count and is NOT +/// priced by `compute_minimum_shielded_fee_v0` (which only covers per-action note/nullifier storage +/// and the per-bundle ZK compute). Pricing it as `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES` +/// effective bytes at the SAME per-byte storage rate the per-action note storage uses keeps the +/// address write covered (so `execute_event/v0`'s `storage = min(real_storage, fee)` booking split +/// never zeroes the proposer's processing reward) and lets the component track the storage rate as +/// it evolves. +/// +/// This fee is used ONLY by `Unshield`. The other pool-paid transitions (ShieldedTransfer / +/// ShieldedWithdrawal) and the entry transitions keep using their own formulas +/// ([`compute_minimum_shielded_fee_v0`] / [`compute_shielded_withdrawal_fee_v0`] / +/// [`compute_shielded_verification_fee_v0`]). +/// +/// All arithmetic is checked: an overflow (only reachable via pathological fee constants) +/// surfaces as `ProtocolError::Overflow` instead of silently wrapping. The `per_byte_rate` is +/// computed exactly as in [`compute_minimum_shielded_fee_v0`]. +pub fn compute_shielded_unshield_fee_v0( + num_actions: usize, + platform_version: &PlatformVersion, +) -> Result { + let storage = &platform_version.fee_version.storage; + + let base_fee = compute_minimum_shielded_fee_v0(num_actions, platform_version)?; + + let per_byte_rate = storage + .storage_disk_usage_credit_per_byte + .checked_add(storage.storage_processing_credit_per_byte) + .ok_or(ProtocolError::Overflow( + "shielded storage per-byte rate overflow", + ))?; + let address_storage_fee = SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES + .checked_mul(per_byte_rate) + .ok_or(ProtocolError::Overflow( + "shielded unshield address storage fee overflow", + ))?; + base_fee + .checked_add(address_storage_fee) + .ok_or(ProtocolError::Overflow("shielded unshield fee overflow")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The refactored `compute_minimum_shielded_fee_v0` must return the EXACT same value as the + /// historical formula `proof + num_actions × (processing + 312×rate)`. The refactor splits the + /// computation into `compute_fee + num_actions × (312×rate)`; this asserts the two are equal + /// across a range of action counts so the consensus fee is byte-for-byte unchanged. + #[test] + fn compute_minimum_shielded_fee_v0_equals_historical_formula() { + let platform_version = PlatformVersion::latest(); + let constants = &platform_version + .drive_abci + .validation_and_processing + .event_constants; + 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 [0usize, 1, 2, 5, 16] { + // Historical: proof + num_actions × (processing + 312×rate) + let per_action = constants.shielded_per_action_processing_fee + + SHIELDED_STORAGE_BYTES_PER_ACTION * per_byte_rate; + let historical = + constants.shielded_proof_verification_fee + (num_actions as u64) * per_action; + + let refactored = compute_minimum_shielded_fee_v0(num_actions, platform_version) + .expect("minimum shielded fee"); + assert_eq!( + refactored, historical, + "refactored minimum fee must match historical formula for {num_actions} actions" + ); + + // And min_fee == compute_fee + num_actions × storage_estimate. + let compute_fee = compute_shielded_verification_fee_v0(num_actions, platform_version) + .expect("compute fee"); + assert_eq!( + refactored, + compute_fee + + (num_actions as u64) * SHIELDED_STORAGE_BYTES_PER_ACTION * per_byte_rate, + "minimum fee must equal compute fee plus the per-action storage estimate" + ); + } + } + + /// Pin the exact relationship between the ShieldedWithdrawal fee and the base shielded fee: + /// the withdrawal fee MUST be `compute_minimum_shielded_fee_v0(n)` plus exactly one flat + /// `SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES × per_byte_rate` document component (the same + /// `per_byte_rate = disk + processing` the per-action note storage uses), independent of `n`. + /// This locks the document cost as a flat add-on so it cannot silently drift from the base + /// fee or accidentally scale with the action count. + #[test] + fn compute_shielded_withdrawal_fee_v0_equals_base_plus_flat_document_cost() { + let platform_version = PlatformVersion::latest(); + let storage = &platform_version.fee_version.storage; + let per_byte_rate = + storage.storage_disk_usage_credit_per_byte + storage.storage_processing_credit_per_byte; + let document_cost = SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES * per_byte_rate; + + for num_actions in [0usize, 1, 2, 5, 16] { + let base = compute_minimum_shielded_fee_v0(num_actions, platform_version) + .expect("minimum shielded fee"); + let withdrawal = compute_shielded_withdrawal_fee_v0(num_actions, platform_version) + .expect("withdrawal shielded fee"); + assert_eq!( + withdrawal, + base + document_cost, + "withdrawal fee must equal the base minimum fee plus the flat \ + {SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES}-byte document storage cost for \ + {num_actions} actions" + ); + // The document component is flat: the delta over the base must not depend on n. + assert_eq!( + withdrawal - base, + document_cost, + "the withdrawal-document component must be flat (independent of action count)" + ); + } + } + + /// Pin the exact relationship between the Unshield fee and the base shielded fee: + /// the unshield fee MUST be `compute_minimum_shielded_fee_v0(n)` plus exactly one flat + /// `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES × per_byte_rate` address-write component (the same + /// `per_byte_rate = disk + processing` the per-action note storage uses), independent of `n`. + /// This locks the address-write cost as a flat add-on so it cannot silently drift from the base + /// fee or accidentally scale with the action count. + #[test] + fn compute_shielded_unshield_fee_v0_equals_base_plus_flat_address_cost() { + let platform_version = PlatformVersion::latest(); + let storage = &platform_version.fee_version.storage; + let per_byte_rate = + storage.storage_disk_usage_credit_per_byte + storage.storage_processing_credit_per_byte; + let address_cost = SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES * per_byte_rate; + + for num_actions in [0usize, 1, 2, 5, 16] { + let base = compute_minimum_shielded_fee_v0(num_actions, platform_version) + .expect("minimum shielded fee"); + let unshield = compute_shielded_unshield_fee_v0(num_actions, platform_version) + .expect("unshield shielded fee"); + assert_eq!( + unshield, + base + address_cost, + "unshield fee must equal the base minimum fee plus the flat \ + {SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES}-byte address-write storage cost for \ + {num_actions} actions" + ); + // The address-write component is flat: the delta over the base must not depend on n. + assert_eq!( + unshield - base, + address_cost, + "the unshield address-write component must be flat (independent of action count)" + ); + } + } +} diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index e48ee56ff1b..cb9d32ac9e7 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -12,7 +12,10 @@ use crate::withdrawal::Pooling; // Re-exported so the public path stays `dpp::shielded::compute_minimum_shielded_fee` (the // module and the function share a name but live in different namespaces). -pub use compute_minimum_shielded_fee::compute_minimum_shielded_fee; +pub use compute_minimum_shielded_fee::{ + compute_minimum_shielded_fee, compute_shielded_unshield_fee, compute_shielded_verification_fee, + compute_shielded_withdrawal_fee, +}; /// Permanent storage bytes per shielded action: 312 bytes total. /// @@ -41,6 +44,53 @@ pub use compute_minimum_shielded_fee::compute_minimum_shielded_fee; /// (`ENCRYPTED_NOTE_SIZE`) rather than Zcash Orchard's ~692. pub const SHIELDED_STORAGE_BYTES_PER_ACTION: u64 = 312; +/// Calibrated effective storage-byte cost of the Core withdrawal document a +/// `ShieldedWithdrawal` creates. +/// +/// A `ShieldedWithdrawal` does not only write notes/nullifiers like the other pool-paid +/// transitions — it ALSO inserts a Core withdrawal document into the withdrawals contract +/// (`AddWithdrawalDocument`), which writes the document plus its withdrawals-contract index +/// entries. That insert has a real, GroveDB-metered cost of ≈110,085,900 credits, which is +/// ~98% storage and is FLAT regardless of the bundle's action count (the document and its +/// indexes are the same size whether the withdrawal spends one note or sixteen). +/// +/// `compute_minimum_shielded_fee` prices only the per-action note/nullifier storage and the +/// per-bundle ZK compute, so it does NOT cover this document insert. We therefore add the +/// document cost to the ShieldedWithdrawal fee as a flat BYTE-BASED component, sized at +/// `SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES` effective bytes priced at the SAME per-byte +/// storage rate the per-action note storage uses (`disk + processing` credits/byte). The +/// measured ≈110M cost corresponds to ≈4017 effective bytes at that rate; 4100 covers it with +/// a small (~2%) margin, and — because it is priced off the same rate — it tracks the storage +/// rate as it evolves, exactly like the per-action note storage does. See +/// [`compute_minimum_shielded_fee::compute_shielded_withdrawal_fee`]. +pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100; + +/// Calibrated effective storage-byte cost of the single `AddBalanceToAddress` write an `Unshield` +/// performs, crediting the net (`unshielding_amount − fee`) to the output platform address. +/// +/// Like the other pool-paid transitions, an `Unshield` writes its change notes and nullifiers — but +/// it ALSO credits a transparent platform address with `AddBalanceToAddress`. In the new-address +/// worst case that write touches the address subtree (the address path plus its balance/nonce +/// entries), a real, GroveDB-metered cost of ≈6,239,100 credits (≈222 of those bytes are storage) +/// that is FLAT regardless of the bundle's action count (the address write is the same size whether +/// the unshield spends one note or sixteen). +/// +/// `compute_minimum_shielded_fee` prices only the per-action note/nullifier storage and the +/// per-bundle ZK compute, so it does NOT cover this address write. We therefore add the address +/// cost to the Unshield fee as a flat BYTE-BASED component, sized at +/// `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES` effective bytes priced at the SAME per-byte storage +/// rate the per-action note storage uses (`disk + processing` credits/byte). +/// +/// The constant is the **storage** portion of the address write: the metered `AddBalanceToAddress` +/// op costs ≈6,239,100 credits total, of which the *storage* part is ≈6,075,000 ≈ **222 effective +/// bytes** at the storage rate. We size the component to that storage figure — because it is a +/// `bytes × per_byte_rate` term it is booked as storage, so it should match the address write's +/// storage cost, not its total. The small remaining op-processing (~164K) is already covered by the +/// per-action processing fee. Pricing it off the same rate means it tracks the storage rate as it +/// evolves, exactly like the per-action note storage does. See +/// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`]. +pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; + /// Domain separator for Platform sighash computation. const SIGHASH_DOMAIN: &[u8] = b"DashPlatformSighash"; diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index dc9d1240fff..43d7d0fb6c1 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -2918,6 +2918,7 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + surplus_output: None, signature: BinaryData::new(vec![0x55; 65]), }, )) diff --git a/packages/rs-dpp/src/state_transition/proof_result.rs b/packages/rs-dpp/src/state_transition/proof_result.rs index 620de85446c..4732764e0b3 100644 --- a/packages/rs-dpp/src/state_transition/proof_result.rs +++ b/packages/rs-dpp/src/state_transition/proof_result.rs @@ -66,4 +66,15 @@ pub enum StateTransitionProofResult { Vec<(Vec, bool)>, BTreeMap>, ), + /// Returned by `ShieldFromAssetLock` when a `surplus_output` is set. Carries the consumed + /// asset-lock info AND the proven balance of the surplus-output address, so a light/SDK + /// client can cryptographically confirm the asset-lock surplus credit landed at the signed + /// `surplus_output` address. The plain [`VerifiedAssetLockConsumed`] is still returned when + /// no `surplus_output` is set. + /// + /// [`VerifiedAssetLockConsumed`]: StateTransitionProofResult::VerifiedAssetLockConsumed + VerifiedAssetLockConsumedWithAddressInfos( + StoredAssetLockInfo, + BTreeMap>, + ), } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/mod.rs index 5aa8f574d30..41a50003a4e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/mod.rs @@ -2,6 +2,8 @@ mod v0; pub use v0::*; +#[cfg(feature = "state-transition-signing")] +use crate::address_funds::PlatformAddress; #[cfg(feature = "state-transition-signing")] use crate::prelude::AssetLockProof; #[cfg(feature = "state-transition-signing")] @@ -27,6 +29,7 @@ impl ShieldFromAssetLockTransitionMethodsV0 for ShieldFromAssetLockTransition { anchor: [u8; 32], proof: Vec, binding_signature: [u8; 64], + surplus_output: Option, platform_version: &PlatformVersion, ) -> Result { match platform_version @@ -43,6 +46,7 @@ impl ShieldFromAssetLockTransitionMethodsV0 for ShieldFromAssetLockTransition { anchor, proof, binding_signature, + surplus_output, platform_version, ), version => Err(ProtocolError::UnknownVersionMismatch { @@ -64,6 +68,7 @@ impl ShieldFromAssetLockTransitionMethodsV0 for ShieldFromAssetLockTransition { anchor: [u8; 32], proof: Vec, binding_signature: [u8; 64], + surplus_output: Option, platform_version: &PlatformVersion, ) -> Result where @@ -85,6 +90,7 @@ impl ShieldFromAssetLockTransitionMethodsV0 for ShieldFromAssetLockTransition { anchor, proof, binding_signature, + surplus_output, platform_version, ) .await diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/v0/mod.rs index 266955429f9..19d8b2fe329 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/methods/v0/mod.rs @@ -1,4 +1,6 @@ #[cfg(feature = "state-transition-signing")] +use crate::address_funds::PlatformAddress; +#[cfg(feature = "state-transition-signing")] use crate::prelude::AssetLockProof; #[cfg(feature = "state-transition-signing")] use crate::shielded::SerializedAction; @@ -19,6 +21,7 @@ pub trait ShieldFromAssetLockTransitionMethodsV0 { anchor: [u8; 32], proof: Vec, binding_signature: [u8; 64], + surplus_output: Option, platform_version: &PlatformVersion, ) -> Result; @@ -44,6 +47,7 @@ pub trait ShieldFromAssetLockTransitionMethodsV0 { anchor: [u8; 32], proof: Vec, binding_signature: [u8; 64], + surplus_output: Option, platform_version: &PlatformVersion, ) -> Result where diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index eabc914911e..b2563604e64 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -119,6 +119,7 @@ async fn try_from_asset_lock_with_bundle_and_signer_produces_recoverable_compact anchor, proof, binding_signature, + None, PlatformVersion::latest(), ) .await @@ -150,6 +151,7 @@ async fn try_from_asset_lock_with_bundle_and_signer_via_outer_dispatcher() { [0u8; 32], vec![], [0u8; 64], + None, PlatformVersion::latest(), ) .await @@ -188,6 +190,7 @@ async fn outer_dispatcher_rejects_unknown_serialization_version() { [0u8; 32], vec![], [0u8; 64], + None, &bad_version, ) .await @@ -229,6 +232,7 @@ async fn build_shield_from_asset_lock_transition_with_signer_end_to_end() { &signer, &TestProver, [0u8; 36], + None, PlatformVersion::latest(), ) .await diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/state_transition_estimated_fee_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/state_transition_estimated_fee_validation.rs index 1141003afc6..bf8b288b217 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/state_transition_estimated_fee_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/state_transition_estimated_fee_validation.rs @@ -10,13 +10,20 @@ impl StateTransitionEstimatedFeeValidation for ShieldFromAssetLockTransition { &self, platform_version: &PlatformVersion, ) -> Result { + // This value (the asset-lock base cost, `albc`) is now folded into the ShieldFromAssetLock + // pool fee on the consensus path, so use `checked_mul` to match the rest of the + // fully-checked shielded-fee arithmetic (overflow is unreachable with the current versioned + // constant, but must never silently wrap). let asset_lock_base_cost = platform_version .dpp .state_transitions .identities .asset_locks .required_asset_lock_duff_balance_for_processing_start_for_address_funding - * CREDITS_PER_DUFF; + .checked_mul(CREDITS_PER_DUFF) + .ok_or(ProtocolError::Overflow( + "asset_lock_base_cost credits conversion overflow", + ))?; Ok(asset_lock_base_cost) } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/mod.rs index 14eb575d586..8dbf0f3c949 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/mod.rs @@ -5,6 +5,7 @@ mod types; pub(super) mod v0_methods; mod version; +use crate::address_funds::PlatformAddress; use crate::identity::state_transition::asset_lock_proof::AssetLockProof; use crate::shielded::SerializedAction; use crate::ProtocolError; @@ -45,6 +46,14 @@ pub struct ShieldFromAssetLockTransitionV0 { pub proof: Vec, /// RedPallas binding signature pub binding_signature: [u8; 64], + /// Optional platform-address output that receives the asset-lock surplus + /// (`asset_lock_value − value_balance − fee`, where `fee = compute_minimum_shielded_fee + /// + asset_lock_base_cost`). When `None`, the surplus is instead added to the fee pools, + /// but only up to `shielded_implicit_fee_cap` (otherwise the transition is rejected so a + /// client cannot accidentally forfeit a large remainder). Placed before `signature` so it + /// is covered by the signable bytes — the ECDSA signature commits to the surplus + /// destination, which therefore cannot be redirected without invalidating the transition. + pub surplus_output: Option, /// ECDSA signature over the signable bytes (excluded from sig hash) #[platform_signable(exclude_from_sig_hash)] pub signature: BinaryData, @@ -90,6 +99,7 @@ mod tests { anchor: [7u8; 32], proof: vec![8u8; 100], binding_signature: [9u8; 64], + surplus_output: None, signature: BinaryData::new(vec![10u8; 65]), }; @@ -114,6 +124,7 @@ mod tests { anchor: [7u8; 32], proof: vec![8u8; 100], binding_signature: [9u8; 64], + surplus_output: None, signature: BinaryData::new(vec![10u8; 65]), } } @@ -188,4 +199,77 @@ mod tests { use crate::state_transition::FeatureVersioned; assert_eq!(make_v0().feature_version(), 0); } + + #[test] + fn test_round_trip_with_surplus_output() { + // The optional surplus_output field must serialize/deserialize identically. It sits before + // the sig-excluded `signature` field, so it is part of the signable bytes (the asset-lock + // ECDSA signature commits to it — a missing/desynced field would change the wire layout). + let mut transition = make_v0(); + transition.surplus_output = Some(crate::address_funds::PlatformAddress::P2pkh([0x44; 20])); + test_round_trip(transition); + } + + #[test] + fn test_signable_bytes_commit_to_surplus_output() { + // The asset-lock ECDSA signature must commit to the surplus destination: redirecting the + // surplus to a different address (or toggling it on/off) MUST change the signable bytes, so a + // valid signature cannot be replayed against a different `surplus_output`. + // + // The `PlatformSignable` derive excludes fields by NAME (only `signature`, via + // `exclude_from_sig_hash`) — NOT by position. So the invariant under test is precisely + // "surplus_output is NOT excluded from the sighash", which we confirm by observing the + // signable bytes differ. The `signature` field itself is held constant across all variants + // below, so any difference is attributable solely to `surplus_output`. + use crate::serialization::Signable; + + let addr_a = crate::address_funds::PlatformAddress::P2pkh([0x44; 20]); + let addr_b = crate::address_funds::PlatformAddress::P2pkh([0x55; 20]); + + let mut none_variant = make_v0(); + none_variant.surplus_output = None; + + let mut some_a = make_v0(); + some_a.surplus_output = Some(addr_a.clone()); + + let mut some_b = make_v0(); + some_b.surplus_output = Some(addr_b); + + let none_bytes = none_variant + .signable_bytes() + .expect("should compute signable bytes for None variant"); + let some_a_bytes = some_a + .signable_bytes() + .expect("should compute signable bytes for Some(addr_a) variant"); + let some_b_bytes = some_b + .signable_bytes() + .expect("should compute signable bytes for Some(addr_b) variant"); + + // None vs Some(addr): toggling the surplus output on changes the sighash. + assert_ne!( + none_bytes, some_a_bytes, + "signable bytes must differ between surplus_output None and Some(addr) \ + (signature commits to the presence of a surplus destination)" + ); + + // Some(addrA) vs Some(addrB): redirecting to a different address changes the sighash. + assert_ne!( + some_a_bytes, some_b_bytes, + "signable bytes must differ between two distinct surplus_output addresses \ + (signature commits to the surplus destination, not just its presence)" + ); + + // Sanity: an unrelated change to the excluded `signature` field must NOT change the signable + // bytes — this isolates the difference above to `surplus_output` rather than to incidental + // field churn, and pins that `signature` IS excluded from the sighash. + let mut some_a_other_sig = some_a.clone(); + some_a_other_sig.signature = BinaryData::new(vec![0xEE; 65]); + assert_eq!( + some_a_bytes, + some_a_other_sig + .signable_bytes() + .expect("should compute signable bytes after mutating the excluded signature"), + "mutating the sig-excluded `signature` field must not change the signable bytes" + ); + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/state_transition_validation.rs index 063061a4b4f..2a27aca1599 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/state_transition_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/state_transition_validation.rs @@ -105,6 +105,7 @@ mod tests { anchor: [7u8; 32], proof: vec![8u8; 100], binding_signature: [9u8; 64], + surplus_output: None, signature: BinaryData::new(vec![10u8; 65]), } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/v0_methods.rs index 98535f65650..253119f8e9c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/v0/v0_methods.rs @@ -1,4 +1,6 @@ #[cfg(feature = "state-transition-signing")] +use crate::address_funds::PlatformAddress; +#[cfg(feature = "state-transition-signing")] use crate::prelude::AssetLockProof; #[cfg(feature = "state-transition-signing")] use crate::serialization::Signable; @@ -23,6 +25,7 @@ impl ShieldFromAssetLockTransitionMethodsV0 for ShieldFromAssetLockTransitionV0 anchor: [u8; 32], proof: Vec, binding_signature: [u8; 64], + surplus_output: Option, _platform_version: &PlatformVersion, ) -> Result { // Create the unsigned transition @@ -33,6 +36,7 @@ impl ShieldFromAssetLockTransitionMethodsV0 for ShieldFromAssetLockTransitionV0 anchor, proof, binding_signature, + surplus_output, signature: Default::default(), }; @@ -57,6 +61,7 @@ impl ShieldFromAssetLockTransitionMethodsV0 for ShieldFromAssetLockTransitionV0 anchor: [u8; 32], proof: Vec, binding_signature: [u8; 64], + surplus_output: Option, _platform_version: &PlatformVersion, ) -> Result where @@ -73,6 +78,7 @@ impl ShieldFromAssetLockTransitionMethodsV0 for ShieldFromAssetLockTransitionV0 anchor, proof, binding_signature, + surplus_output, signature: Default::default(), }; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs index d8a5d6ce98b..06c7d5c4e33 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs @@ -1,20 +1,35 @@ use crate::fee::Credits; +use crate::shielded::compute_shielded_verification_fee; use crate::state_transition::shield_transition::ShieldTransition; -use crate::state_transition::{ - StateTransitionEstimatedFeeValidation, StateTransitionWitnessSigned, -}; +use crate::state_transition::StateTransitionEstimatedFeeValidation; use crate::ProtocolError; use platform_version::version::PlatformVersion; impl StateTransitionEstimatedFeeValidation for ShieldTransition { + /// Returns an **advisory** lower bound on a transparent `Shield`'s fee: the predictable + /// COMPUTE portion `compute_shielded_verification_fee(num_actions)` (the per-bundle Halo 2 + /// proof-verification fee + the per-action processing fee). + /// + /// This is NOT the funding floor and is NOT the full fee. Unlike the asset-lock transitions, + /// the transparent `Shield` is excluded from the address-minimum-balance pre-check + /// (`StateTransition::required_asset_lock_balance_for_processing_start` does not dispatch to it), + /// so no consensus or funding path consumes this value — it is only reachable via the generic + /// SDK advisory `StateTransition::calculate_estimated_fee()`. + /// + /// `Shield` is metered + compute: GroveDB meters the real storage and processing of the + /// note/nullifier writes, and the execution-event layer adds only this compute fee on top (see + /// `validate_fees_of_event` / the `ExecutionEvent` construction). The metered storage/processing + /// portion depends on live GroveDB tree state and therefore cannot be known statelessly here, so + /// the real fee a `Shield` will be charged is strictly greater than this number. A caller that + /// needs the actual required balance must price the metered portion against state (e.g. via the + /// fee-validation pass), not rely on this estimate. fn calculate_min_required_fee( &self, platform_version: &PlatformVersion, ) -> Result { - let min_fees = &platform_version.fee_version.state_transition_min_fees; - let input_count = self.inputs().len(); - Ok(min_fees - .address_funds_transfer_input_cost - .saturating_mul(input_count as u64)) + // The on-wire Orchard `actions` count is what the compute fee is priced against (matching + // the consensus structure-validation floor and the execution-event compute charge). + let ShieldTransition::V0(v0) = self; + compute_shielded_verification_fee(v0.actions.len(), platform_version) } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v0/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v0/state_transition_validation.rs index 1ee250576fc..2331ae687f6 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v0/state_transition_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v0/state_transition_validation.rs @@ -1,7 +1,8 @@ +use crate::address_funds::AddressFundsFeeStrategyStep; use crate::consensus::basic::state_transition::{ - FeeStrategyDuplicateError, FeeStrategyEmptyError, FeeStrategyTooManyStepsError, - InputBelowMinimumError, InputWitnessCountMismatchError, ShieldedInvalidValueBalanceError, - TransitionNoInputsError, + FeeStrategyDuplicateError, FeeStrategyEmptyError, FeeStrategyIndexOutOfBoundsError, + FeeStrategyTooManyStepsError, InputBelowMinimumError, InputWitnessCountMismatchError, + ShieldedInvalidValueBalanceError, TransitionNoInputsError, }; use crate::consensus::basic::BasicError; use crate::state_transition::shield_transition::v0::ShieldTransitionV0; @@ -96,21 +97,64 @@ impl StateTransitionStructureValidation for ShieldTransitionV0 { ); } - // Total input amounts must cover the shield amount. - // Without this check, an attacker could provide small inputs but a large - // shield amount, crediting the pool more than the inputs debited. + // Total input amounts must cover the shield amount PLUS the shielded COMPUTE fee + // (`compute_shielded_verification_fee`: proof verification + per-action processing, NO storage + // term). This is a stateless lower bound: the real per-action storage cost is metered by + // GroveDB at execution and is unknowable here, so we deliberately do NOT add the 312-byte + // storage ESTIMATE — doing so would falsely reject otherwise-valid transitions whose actual + // metered storage is below the estimate. + // + // This is a cheap early reject. The AUTHORITATIVE funding gate is `validate_fees_of_event` + // (drive-abci), which re-checks `metered_storage + metered_processing + compute_fee` against + // the per-input balances AFTER the shield-amount reallocation. Anti-mint safety does NOT + // depend on this `+fee` term: it depends on `Σrequested >= amount` enforced by the + // reallocation plus that authoritative gate. `input_sum` here only bounds the sum of max + // contributions. + // + // `compute_shielded_verification_fee` returns a `Result`, but this validator returns a + // `SimpleConsensusValidationResult`, so we cannot `?`-propagate; we map an overflow to a + // consensus error (reachable only via pathological fee constants). + let minimum_fee = match crate::shielded::compute_shielded_verification_fee( + self.actions.len(), + platform_version, + ) { + Ok(fee) => fee, + Err(_) => { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "minimum shielded fee computation overflowed".to_string(), + ), + ) + .into(), + ); + } + }; + let required_input = match self.amount.checked_add(minimum_fee) { + Some(required) => required, + None => { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "shield amount plus minimum shielded fee overflows".to_string(), + ), + ) + .into(), + ); + } + }; let input_sum = self .inputs .values() .try_fold(0u64, |acc, (_, amount)| acc.checked_add(*amount)); match input_sum { - Some(sum) if sum >= self.amount => {} + Some(sum) if sum >= required_input => {} Some(sum) => { return SimpleConsensusValidationResult::new_with_error( BasicError::ShieldedInvalidValueBalanceError( ShieldedInvalidValueBalanceError::new(format!( - "total input amount ({}) is less than shield amount ({})", - sum, self.amount + "total input amount ({}) is less than shield amount ({}) plus minimum shielded fee ({})", + sum, self.amount, minimum_fee )), ) .into(), @@ -168,6 +212,38 @@ impl StateTransitionStructureValidation for ShieldTransitionV0 { BasicError::FeeStrategyDuplicateError(FeeStrategyDuplicateError::new()).into(), ); } + + // Reject structurally-unusable fee-strategy steps here — cheaply, before Orchard proof + // verification — rather than letting an out-of-range/no-op step slip through and only + // surface later as a generic `AddressesNotEnoughFundsError`. A `Shield` has transparent + // inputs but NO transparent outputs, so `DeductFromInput` must index a real input and + // `ReduceOutput` can never apply. Mirrors `AddressFundingFromAssetLock`'s validator. + match step { + AddressFundsFeeStrategyStep::DeductFromInput(index) => { + if *index as usize >= self.inputs.len() { + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyIndexOutOfBoundsError( + FeeStrategyIndexOutOfBoundsError::new( + "DeductFromInput", + *index, + self.inputs.len().min(u16::MAX as usize) as u16, + ), + ) + .into(), + ); + } + } + AddressFundsFeeStrategyStep::ReduceOutput(index) => { + // A Shield has no transparent outputs (count 0), so any `ReduceOutput` is out of + // bounds. + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyIndexOutOfBoundsError( + FeeStrategyIndexOutOfBoundsError::new("ReduceOutput", *index, 0), + ) + .into(), + ); + } + } } SimpleConsensusValidationResult::new() @@ -194,9 +270,13 @@ mod tests { } /// Creates a valid ShieldTransitionV0 that passes all validation checks. + /// + /// The input must cover `amount + compute_shielded_verification_fee(num_actions)`. The shielded + /// compute fee is dominated by the ~100M-credit proof-verification fee, so the input is set + /// well above the amount to clear the fee floor. fn valid_shield_transition() -> ShieldTransitionV0 { let mut inputs = BTreeMap::new(); - inputs.insert(PlatformAddress::P2pkh([1u8; 20]), (0u32, 1_000_000u64)); + inputs.insert(PlatformAddress::P2pkh([1u8; 20]), (0u32, 1_000_000_000u64)); ShieldTransitionV0 { inputs, @@ -225,6 +305,36 @@ mod tests { ); } + #[test] + fn should_reject_deduct_from_input_index_out_of_bounds() { + let platform_version = PlatformVersion::latest(); + let mut transition = valid_shield_transition(); + // Only one input (index 0); index 1 is out of range and must be rejected structurally. + transition.fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(1)]; + let result = transition.validate_structure(platform_version); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::FeeStrategyIndexOutOfBoundsError(_) + )] + ); + } + + #[test] + fn should_reject_reduce_output_fee_strategy() { + let platform_version = PlatformVersion::latest(); + let mut transition = valid_shield_transition(); + // A Shield has no transparent outputs, so any ReduceOutput step is structurally invalid. + transition.fee_strategy = vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]; + let result = transition.validate_structure(platform_version); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::FeeStrategyIndexOutOfBoundsError(_) + )] + ); + } + #[test] fn should_reject_invalid_encrypted_note_size() { let platform_version = PlatformVersion::latest(); @@ -359,11 +469,16 @@ mod tests { } #[test] - fn should_reject_input_sum_less_than_shield_amount() { + fn should_reject_input_sum_less_than_shield_amount_plus_fee() { let platform_version = PlatformVersion::latest(); let mut transition = valid_shield_transition(); - // Input = 1_000_000 but amount = 2_000_000 + // The input covers the shield amount but NOT the amount + the shielded compute fee + // (`compute_shielded_verification_fee`, ~100M credits). It must be rejected. transition.amount = 2_000_000; + transition.inputs.clear(); + transition + .inputs + .insert(PlatformAddress::P2pkh([1u8; 20]), (0u32, 2_500_000u64)); let result = transition.validate_structure(platform_version); assert_matches!( @@ -465,9 +580,12 @@ mod tests { let mut hash = [0u8; 20]; hash[0] = (i & 0xFF) as u8; hash[1] = ((i >> 8) & 0xFF) as u8; + // Each input is large enough that the total clears the amount + shielded-fee floor + // (~130M for one action), so validation reaches the fee-strategy step-count check + // rather than tripping the input-sum check first. transition .inputs - .insert(PlatformAddress::P2pkh(hash), (0u32, 1_000_000u64)); + .insert(PlatformAddress::P2pkh(hash), (0u32, 50_000_000u64)); transition.input_witnesses.push(AddressWitness::P2pkh { signature: vec![0u8; 65].into(), }); @@ -504,15 +622,21 @@ mod tests { } #[test] - fn should_accept_input_sum_exactly_equal_to_amount() { + fn should_accept_input_sum_exactly_equal_to_amount_plus_fee() { let platform_version = PlatformVersion::latest(); let mut transition = valid_shield_transition(); - // Set input exactly equal to amount + let amount = 500_000u64; + let fee = crate::shielded::compute_shielded_verification_fee( + transition.actions.len(), + platform_version, + ) + .expect("shielded compute fee"); + // Input exactly equal to amount + the shielded compute fee (the boundary): must be accepted. transition.inputs.clear(); transition .inputs - .insert(PlatformAddress::P2pkh([1u8; 20]), (0u32, 500_000u64)); - transition.amount = 500_000; + .insert(PlatformAddress::P2pkh([1u8; 20]), (0u32, amount + fee)); + transition.amount = amount; let result = transition.validate_structure(platform_version); assert!( @@ -521,4 +645,30 @@ mod tests { result.errors ); } + + #[test] + fn should_reject_input_sum_one_below_amount_plus_fee() { + let platform_version = PlatformVersion::latest(); + let mut transition = valid_shield_transition(); + let amount = 500_000u64; + let fee = crate::shielded::compute_shielded_verification_fee( + transition.actions.len(), + platform_version, + ) + .expect("shielded compute fee"); + // One credit below the amount + fee boundary: must be rejected. + transition.inputs.clear(); + transition + .inputs + .insert(PlatformAddress::P2pkh([1u8; 20]), (0u32, amount + fee - 1)); + transition.amount = amount; + + let result = transition.validate_structure(platform_version); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedInvalidValueBalanceError(_) + )] + ); + } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index 3f1a4d5d112..10d5490222d 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -1,3 +1,4 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::execution_event::ExecutionEvent; use crate::execution::types::execution_operation::ValidationOperation; @@ -144,7 +145,25 @@ where .saturating_add(additional_fixed_fee_cost); } - // Get the total fee to deduct + // Address-input events have no identity to attribute storage refunds to, so their + // metered ops must not free identity-attributed storage (`Shield` / + // `IdentityCreateFromAddresses` only insert or overwrite). A non-empty `fee_refunds` + // here would have no owner to refund at block fee distribution; assert it in debug/test + // builds so any future op that frees such storage is caught before it can misbook. + debug_assert!( + individual_fee_result.fee_refunds.0.is_empty(), + "PaidFromAddressInputs ops must not free identity-attributed storage (fee_refunds must be empty)" + ); + + // The total fee to deduct is the metered base fee (storage + processing), where + // processing already includes any `additional_fixed_fee_cost` added just above. For the + // transparent `Shield` that fixed cost is the shielded COMPUTE fee + // (`compute_shielded_verification_fee`), so the fee is `metered_storage + metered_processing + + // compute_fee` — storage comes entirely from GroveDB metering of the note/nullifier + // writes, never double-counted. This is identical to every other `PaidFromAddressInputs` + // event (e.g. `IdentityCreateFromAddresses`), so conservation (deduct == book) holds by + // the standard machinery: the same `total_fee` is deducted from inputs and booked to the + // pools. `validate_fees_of_event` rejects an under-funded transition before execution. let total_fee = individual_fee_result.total_base_fee(); // Deduct fee from outputs or remaining balance of inputs according to strategy @@ -156,6 +175,20 @@ where platform_version, )?; + // Defense in depth: the deduction min-caps each step, so an under-funded input set would + // remove < `total_fee` from the inputs while the full `total_fee` is still booked to the + // fee pools — minting the difference (`CorruptedCreditsNotBalanced` -> chain halt). + // `validate_fees_of_event` is re-run on this exact state immediately before execution and + // already rejects an under-funded transition, so this cannot trigger today. Guard anyway: + // if those two paths ever diverged, fail closed at the source with an actionable error + // rather than committing a mint that only surfaces as an opaque end-of-block sum-tree + // imbalance. + if !fee_deduction_result.fee_fully_covered { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "address-input fee not fully covered at execution; validate_fees_of_event should have rejected the under-funded transition", + ))); + } + let adjusted_inputs = fee_deduction_result.remaining_input_balances; let adjusted_outputs = fee_deduction_result.adjusted_outputs; @@ -435,7 +468,6 @@ where execution_operations, additional_fixed_fee_cost, user_fee_increase, - .. } => { // We can unwrap here because we have the match right above let fee_validation_result = maybe_fee_validation_result.unwrap(); @@ -571,19 +603,14 @@ where // Route the real storage cost of the shielded writes to the storage pool // (amortised over time, epoch fee multiplier applied at payout); the - // remainder (the asset-lock excess over the shield amount) is the - // processing fee. Conservation: storage + processing == fees_to_add_to_pool. + // remainder is the processing fee paid to the proposer. Conservation: + // storage + processing == fees_to_add_to_pool. // - // EDGE CASE: `fees_to_add_to_pool` here is the asset-lock excess the shield - // declares (gated by a flat min-fee that does NOT scale with action count), - // not `compute_minimum_shielded_fee`. If that excess is smaller than the real - // storage cost of the writes (≈ SHIELDED_STORAGE_BYTES_PER_ACTION per action), - // `storage_fee` saturates via `min(..)` and `processing_fee` becomes 0 — the - // proposer earns nothing for the proof verification it ran. This has no - // soundness/conservation impact (the pool is never over- or under-credited), - // but a high-action shield funded at exactly the minimum is a proposer-incentive - // edge; the flat shield min-fee should be revisited if it ceases to cover the - // per-action write cost. + // `fees_to_add_to_pool` is `pool_fee = compute_minimum_shielded_fee + albc` + // (plus any surplus folded in when no surplus_output is set). Since that flat + // fee includes the 100M proof-verification fee, it comfortably exceeds the + // per-action storage cost, so `processing_fee` is strictly positive — the + // proposer is always paid for the proof verification it ran. let storage_fee = applied_fees.storage_fee.min(fees_to_add_to_pool); let processing_fee = fees_to_add_to_pool - storage_fee; diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs index caaee1a2061..fe0670737e3 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs @@ -170,7 +170,6 @@ where execution_operations, additional_fixed_fee_cost, user_fee_increase, - .. } => { let mut estimated_fee_result = self .drive @@ -192,11 +191,22 @@ where estimated_fee_result.apply_user_fee_increase(*user_fee_increase); - let mut required_balance = estimated_fee_result.total_base_fee(); - + // Advertise the fee that block execution will actually charge: the metered base fee + // PLUS any `additional_fixed_fee_cost`. For the transparent `Shield` that fixed cost is + // the ZK compute fee `compute_shielded_verification_fee` (added on top of the metered + // storage/processing of the note/nullifier writes); for `IdentityCreateFromAddresses` + // it is the registration cost. Execution folds this fixed cost into `processing_fee` + // (see `paid_from_address_inputs_and_outputs`), so we fold it into the advertised + // `FeeResult` here too — otherwise CheckTx `gas_wanted` would under-report the fee + // relative to what is booked. The required funding balance is exactly this fee + // (storage + processing), unchanged by the fold. + let mut result_fee_result = estimated_fee_result; if let Some(additional_fixed_fee_cost) = additional_fixed_fee_cost { - required_balance = required_balance.saturating_add(*additional_fixed_fee_cost); + result_fee_result.processing_fee = result_fee_result + .processing_fee + .saturating_add(*additional_fixed_fee_cost); } + let required_balance = result_fee_result.total_base_fee(); let fee_deduction_result = deduct_fee_from_outputs_or_remaining_balance_of_inputs( input_current_balances.clone(), @@ -207,12 +217,10 @@ where )?; if fee_deduction_result.fee_fully_covered { - Ok(ConsensusValidationResult::new_with_data( - estimated_fee_result, - )) + Ok(ConsensusValidationResult::new_with_data(result_fee_result)) } else { Ok(ConsensusValidationResult::new_with_data_and_errors( - estimated_fee_result, + result_fee_result, vec![StateError::AddressesNotEnoughFundsError( AddressesNotEnoughFundsError::new( input_current_balances.clone(), @@ -247,13 +255,22 @@ where )?; let required_fee = estimated_fee_result.total_base_fee(); + // Advertise the authoritative pool fee for `gas_wanted`, not the metered estimate + // (the executor books `fees_to_add_to_pool` regardless). + let storage_fee = estimated_fee_result.storage_fee.min(*fees_to_add_to_pool); + let authoritative_fee_result = FeeResult { + storage_fee, + processing_fee: *fees_to_add_to_pool - storage_fee, + fee_refunds: Default::default(), + removed_bytes_from_system: 0, + }; if *fees_to_add_to_pool >= required_fee { Ok(ConsensusValidationResult::new_with_data( - estimated_fee_result, + authoritative_fee_result, )) } else { Ok(ConsensusValidationResult::new_with_data_and_errors( - estimated_fee_result, + authoritative_fee_result, vec![StateError::InvalidShieldedProofError( InvalidShieldedProofError::new(format!( "shield_from_asset_lock fee insufficient: provided {} but minimum required {}", @@ -437,6 +454,12 @@ mod tests { /// `ConsensusValidationResult` with NO errors. With empty `operations` /// and `execution_operations`, the required fee is zero so any /// non-negative `fees_to_add_to_pool` satisfies `>= required_fee`. + /// + /// It must ALSO advertise the authoritative pool fee for `gas_wanted`: the + /// arm books `fees_to_add_to_pool` regardless of the metered estimate, so the + /// advertised `FeeResult` (split storage/processing) must total exactly + /// `fees_to_add_to_pool`. Mirrors the `additional_fixed_fee_cost` advertisement + /// assertion in the `PaidFromAddressInputs` test above. #[test] fn validate_fees_of_event_v0_paid_from_asset_lock_to_pool_sufficient_fee_is_valid() { let platform = TestPlatformBuilder::new() @@ -448,8 +471,9 @@ mod tests { let block_info = dpp::block::block_info::BlockInfo::default(); let previous_fee_versions = Default::default(); + let fees_to_add_to_pool = 1_000_000u64; let event = ExecutionEvent::PaidFromAssetLockToPool { - fees_to_add_to_pool: 1_000_000, + fees_to_add_to_pool, operations: vec![], execution_operations: vec![], }; @@ -469,6 +493,82 @@ mod tests { result.errors.is_empty(), "sufficient fees_to_add_to_pool must not produce consensus errors" ); + // The advertised fee must equal the authoritative pool fee (`fees_to_add_to_pool`), + // not the metered estimate (which is 0 here with empty operations). The arm splits + // the total into storage/processing, so assert on the total. + let fee = result.into_data().expect("fee result present"); + assert_eq!( + fee.total_base_fee(), + fees_to_add_to_pool, + "advertised pool fee must equal fees_to_add_to_pool (gas_wanted parity)" + ); + } + + /// `ExecutionEvent::PaidFromAddressInputs` (the transparent `Shield` / + /// `IdentityCreateFromAddresses` path) must ADVERTISE the same fee block execution charges: + /// the metered base fee PLUS any `additional_fixed_fee_cost`. CheckTx turns the advertised + /// `FeeResult` into `gas_wanted`, and execution folds the fixed cost into `processing_fee`, so + /// the advertised fee must include it too — otherwise `gas_wanted` under-reports the fee. With + /// empty operations the metered fee is 0, so the advertised fee must equal exactly the fixed + /// cost (for a `Shield` this is the shielded compute fee). + #[test] + fn validate_fees_of_event_v0_paid_from_address_inputs_advertises_additional_fixed_fee_cost() { + use dpp::address_funds::fee_strategy::AddressFundsFeeStrategyStep; + use dpp::address_funds::PlatformAddress; + use std::collections::BTreeMap; + + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let platform_version = PlatformVersion::latest(); + let block_info = dpp::block::block_info::BlockInfo::default(); + let previous_fee_versions = Default::default(); + + let fixed_cost = 100_000_000u64; + let address = PlatformAddress::P2pkh([0x11; 20]); + let mut input_current_balances = BTreeMap::new(); + // Balance comfortably covers the fixed cost so the deduction fully succeeds. + input_current_balances.insert(address, (1u32, fixed_cost + 1_000_000)); + + let event = ExecutionEvent::PaidFromAddressInputs { + input_current_balances, + added_to_balance_outputs: BTreeMap::new(), + fee_strategy: vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + operations: vec![], + execution_operations: vec![], + additional_fixed_fee_cost: Some(fixed_cost), + user_fee_increase: 0, + }; + + let result = platform + .platform + .validate_fees_of_event_v0( + &event, + &block_info, + None, + platform_version, + &previous_fee_versions, + ) + .expect("validation must be Ok"); + + assert!( + result.errors.is_empty(), + "sufficient balance must not produce consensus errors" + ); + let fee = result.into_data().expect("fee result present"); + // Empty operations => metered storage/processing are 0, so the advertised fee is exactly the + // fixed cost. Before the gas_wanted fix, processing_fee was 0 here and CheckTx under-reported. + assert_eq!( + fee.storage_fee, 0, + "no metered storage with empty operations" + ); + assert_eq!( + fee.processing_fee, fixed_cost, + "advertised processing fee must include additional_fixed_fee_cost (gas_wanted parity)" + ); + assert_eq!(fee.total_base_fee(), fixed_cost); } /// `ExecutionEvent::Paid` where the identity balance is smaller than diff --git a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs index 22667e6dcb0..b64aede39e7 100644 --- a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs +++ b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs @@ -55,7 +55,13 @@ pub(in crate::execution) enum ExecutionEvent<'a> { operations: Vec>, /// the execution operations that we must also pay for execution_operations: Vec, - /// Additional fee cost, these are processing fees where the user fee increase does not apply + /// Additional fee cost, these are processing fees where the user fee increase does not apply. + /// + /// `Shield` (transparent shield) sets this to the shielded COMPUTE fee + /// (`compute_shielded_verification_fee`: proof verification + per-action processing), which is + /// added to the metered processing fee on top of the metered GroveDB storage of the + /// note/nullifier writes — exactly like `IdentityCreateFromAddresses` adds its registration + /// cost. No storage term is added here; storage comes entirely from metering. additional_fixed_fee_cost: Option, /// the fee multiplier that the user agreed to, 0 means 100% of the base fee, 1 means 101% user_fee_increase: UserFeeIncrease, @@ -476,6 +482,20 @@ impl ExecutionEvent<'_> { let input_current_balances = shield_action.inputs_with_remaining_balance().clone(); let added_to_balance_outputs = BTreeMap::new(); let fee_strategy = shield_action.fee_strategy().clone(); + // Transparent shield is metered + compute: GroveDB meters the real storage and + // processing of the note/nullifier writes (via `into_high_level_drive_operations`), + // and we add ONLY the shielded COMPUTE fee + // `compute_shielded_verification_fee(num_actions)` (Halo 2 proof verification + + // per-action processing) that GroveDB cannot see, as `additional_fixed_fee_cost`. + // This is exactly the `IdentityCreateFromAddresses` model: a fixed cost added to + // processing on top of the metered fee. No storage term is added here, so storage is + // never double-counted. `notes` are built 1:1 from the on-wire Orchard `actions` + // (see the shield action transformer), so `notes().len()` is the on-wire action + // count that the structure-validation floor also prices the compute fee against. + let shielded_verification_fee = dpp::shielded::compute_shielded_verification_fee( + shield_action.notes().len(), + platform_version, + )?; let operations = action.into_high_level_drive_operations(epoch, platform_version)?; Ok(ExecutionEvent::PaidFromAddressInputs { @@ -484,7 +504,7 @@ impl ExecutionEvent<'_> { fee_strategy, operations, execution_operations: execution_context.operations_consume(), - additional_fixed_fee_cost: None, + additional_fixed_fee_cost: Some(shielded_verification_fee), user_fee_increase, }) } @@ -507,13 +527,21 @@ impl ExecutionEvent<'_> { }) } StateTransitionAction::ShieldFromAssetLockAction(ref shield_from_asset_lock_action) => { - // Fee = asset_lock_value - shield_amount (excess from asset lock) + // The fully-consumed asset lock (added to system credits by the converter) is + // distributed three ways: `shield_amount` -> shielded pool, `surplus_amount` -> + // the `surplus_output` address (via the converter, when set), and the remainder -> + // the fee pools (this value). Computing the fee by subtraction from the single + // source of truth (the action) keeps conservation by construction: + // AddToSystemCredits(consumed) == shield_amount + surplus_amount + fee_amount. + // When `surplus_output` is unset, `surplus_amount == 0` and the surplus folds into + // the fee here (bounded by the implicit-fee cap enforced in the transform). let fee_amount = shield_from_asset_lock_action .asset_lock_value_to_be_consumed() .checked_sub(shield_from_asset_lock_action.shield_amount()) + .and_then(|v| v.checked_sub(shield_from_asset_lock_action.surplus_amount())) .ok_or(Error::Execution( ExecutionError::CorruptedCodeExecution( - "shield amount exceeds asset lock value to be consumed in ShieldFromAssetLock fee computation", + "shield amount + surplus exceeds asset lock value to be consumed in ShieldFromAssetLock fee computation", ), ))?; let operations = diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index 37f946bf1c5..63b8840a1b4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -325,6 +325,7 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + surplus_output: None, signature: Default::default(), }, )) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs index f9a620f8663..190afb512fb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs @@ -211,6 +211,7 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + surplus_output: None, signature: Default::default(), }, )) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index c4e01603ae4..c31966ffa96 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -81,9 +81,12 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { /// because their excess is the recipient/net amount.) /// - Unshield / ShieldedWithdrawal: `unshielding_amount` is the TOTAL value leaving the /// shielded pool (recipient/net amount + fee). The fee actually charged at execution time -/// is `compute_minimum_shielded_fee` (carved out of `unshielding_amount`), and the -/// recipient/net receives `unshielding_amount - fee`. Requiring `unshielding_amount >= min_fee` -/// guarantees that net amount is non-negative. +/// is carved out of `unshielding_amount` — `compute_shielded_unshield_fee` (the base fee plus +/// the flat `AddBalanceToAddress` output-write storage cost) for Unshield, and +/// `compute_shielded_withdrawal_fee` (the base fee plus the flat Core withdrawal-document +/// storage cost) for ShieldedWithdrawal — and the recipient/net receives +/// `unshielding_amount - fee`. Requiring `unshielding_amount >= min_fee` guarantees that net +/// amount is non-negative. /// - ShieldedWithdrawal additionally requires the net (`unshielding_amount - min_fee`) to /// fall within `[min_withdrawal_amount, max_withdrawal_amount]` — the same two-sided range /// the transparent withdrawal paths enforce — because that net becomes a Core `TxOut`: the @@ -98,6 +101,24 @@ pub(crate) trait StateTransitionShieldedMinimumFeeValidationV0 { ) -> Result; } +/// Which flat shielded fee formula this transition's minimum is computed with. +/// +/// The three pool-paid transitions price the same per-action note/nullifier storage and per-bundle +/// ZK compute, but two of them add one flat per-transition storage component on top: Unshield prices +/// its single `AddBalanceToAddress` output write, and ShieldedWithdrawal prices its Core withdrawal +/// document insert. The gate MUST use the SAME formula the SDK builder and the transformer use to +/// carve the fee from the pool, otherwise the validation threshold would drift from the fee actually +/// charged. +#[derive(Clone, Copy)] +enum ShieldedMinFeeKind { + /// `compute_minimum_shielded_fee` — ShieldedTransfer (the base; no extra write). + Base, + /// `compute_shielded_unshield_fee` — Unshield (base + the flat `AddBalanceToAddress` cost). + Unshield, + /// `compute_shielded_withdrawal_fee` — ShieldedWithdrawal (base + the flat withdrawal-document cost). + Withdrawal, +} + impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { fn validate_minimum_shielded_fee( &self, @@ -122,31 +143,41 @@ impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { // except ShieldedWithdrawal, whose net becomes a Core `TxOut` and must clear // the same `[min_withdrawal_amount, max_withdrawal_amount]` range the // transparent withdrawal paths enforce. - let (validated_amount, num_actions, min_net_amount, max_net_amount, amount_is_pure_fee): (i64, usize, u64, u64, bool) = match self { + // - `fee_kind`: which flat fee formula this transition's minimum uses. `Base` for + // ShieldedTransfer, `Unshield` for Unshield (adds the `AddBalanceToAddress` output + // write cost), `Withdrawal` for ShieldedWithdrawal (adds the Core withdrawal + // document cost). MUST match what the builder/transformer carve from the pool. + let (validated_amount, num_actions, min_net_amount, max_net_amount, amount_is_pure_fee, fee_kind): (i64, usize, u64, u64, bool, ShieldedMinFeeKind) = match self { // Shield: fee is paid from transparent address inputs, not from value_balance. StateTransition::Shield(_) => { return Ok(SimpleConsensusValidationResult::new()) } - // ShieldedTransfer: value_balance (u64) IS the fee. + // ShieldedTransfer: value_balance (u64) IS the fee. It writes no extra + // per-transition output, so its fee is the `Base` shielded minimum. StateTransition::ShieldedTransfer(st) => match st { dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition::V0(v0) => { - (v0.value_balance as i64, v0.actions.len(), 0, u64::MAX, true) + (v0.value_balance as i64, v0.actions.len(), 0, u64::MAX, true, ShieldedMinFeeKind::Base) } }, // Unshield: `unshielding_amount` is the TOTAL leaving the pool // (recipient/net + fee). We check it against `min_fee` so the net - // (`unshielding_amount - compute_minimum_shielded_fee`) credited to + // (`unshielding_amount - compute_shielded_unshield_fee`) credited to // the recipient at execution time is non-negative. The net is credited - // to a platform address (not Core), so no withdrawal range applies. + // to a platform address (not Core), so no withdrawal range applies. It + // writes a single `AddBalanceToAddress` output, so its fee is the `Unshield` + // flavor (base + that write's flat storage cost). StateTransition::Unshield(st) => match st { dpp::state_transition::unshield_transition::UnshieldTransition::V0( v0, - ) => (v0.unshielding_amount as i64, v0.actions.len(), 0, u64::MAX, false), + ) => (v0.unshielding_amount as i64, v0.actions.len(), 0, u64::MAX, false, ShieldedMinFeeKind::Unshield), }, // ShieldedWithdrawal: the net (`unshielding_amount - min_fee`) becomes a // Core `TxOut`, so it must fall within the same // `[min_withdrawal_amount, max_withdrawal_amount]` range the transparent // withdrawal paths enforce (dust floor and the per-transition policy cap). + // It is the ONLY transition that also inserts a Core withdrawal document, so + // its fee is the `Withdrawal` flavor: it must price that document write (via + // `compute_shielded_withdrawal_fee`), not just the base shielded fee. StateTransition::ShieldedWithdrawal(st) => match st { dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition::V0(v0) => { ( @@ -155,6 +186,7 @@ impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { platform_version.system_limits.min_withdrawal_amount, platform_version.system_limits.max_withdrawal_amount, false, + ShieldedMinFeeKind::Withdrawal, ) } }, @@ -186,12 +218,32 @@ impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { .validation_and_processing .event_constants; - // Single source of truth for the consensus fee formula. The SDK builders - // and the unshield/withdrawal transformers carve the fee with this exact - // checked computation, so the validation threshold here can never drift from - // the fee that is actually charged. See `dpp::shielded::compute_minimum_shielded_fee`. - let minimum_shielded_fee = - dpp::shielded::compute_minimum_shielded_fee(num_actions, platform_version)?; + // Single source of truth for the consensus fee formula. Each pool-paid transition's + // SDK builder and transformer carve the fee with the SAME function this gate uses, + // so the validation threshold here can never drift from the fee actually charged: + // - ShieldedTransfer carves `compute_minimum_shielded_fee` (the base). + // - Unshield carves `compute_shielded_unshield_fee` (= the base PLUS the flat + // `AddBalanceToAddress` output-write storage cost). + // - ShieldedWithdrawal carves `compute_shielded_withdrawal_fee` (= the base PLUS the + // flat Core withdrawal-document storage cost). + // All three use the same checked computations the carving sites use. See + // `dpp::shielded::compute_minimum_shielded_fee` / + // `dpp::shielded::compute_shielded_unshield_fee` / + // `dpp::shielded::compute_shielded_withdrawal_fee`. + let minimum_shielded_fee = match fee_kind { + ShieldedMinFeeKind::Base => { + dpp::shielded::compute_minimum_shielded_fee(num_actions, platform_version)? + } + ShieldedMinFeeKind::Unshield => { + dpp::shielded::compute_shielded_unshield_fee(num_actions, platform_version)? + } + ShieldedMinFeeKind::Withdrawal => { + dpp::shielded::compute_shielded_withdrawal_fee( + num_actions, + platform_version, + )? + } + }; if (validated_amount as u64) < minimum_shielded_fee { return Ok(SimpleConsensusValidationResult::new_with_error( @@ -445,6 +497,7 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + surplus_output: None, signature: Default::default(), }, )) @@ -579,7 +632,11 @@ mod tests { #[test] fn should_reject_shielded_withdrawal_with_net_below_min_withdrawal_amount() { let platform_version = PlatformVersion::latest(); - let min_fee = dpp::shielded::compute_minimum_shielded_fee(0, platform_version) + // ShieldedWithdrawal validation carves `compute_shielded_withdrawal_fee` (base + + // document cost), so the net the gate checks is `unshielding_amount - withdrawal_fee`. + // Use the SAME fee here so the constructed gross lands the net exactly at the + // withdrawal-range boundary under test. + let min_fee = dpp::shielded::compute_shielded_withdrawal_fee(0, platform_version) .expect("fee computation should not overflow"); let min_withdrawal = platform_version.system_limits.min_withdrawal_amount; // net = min_withdrawal_amount - 1 → just below the Core dust floor. @@ -603,7 +660,11 @@ mod tests { #[test] fn should_accept_shielded_withdrawal_with_net_at_min_withdrawal_amount() { let platform_version = PlatformVersion::latest(); - let min_fee = dpp::shielded::compute_minimum_shielded_fee(0, platform_version) + // ShieldedWithdrawal validation carves `compute_shielded_withdrawal_fee` (base + + // document cost), so the net the gate checks is `unshielding_amount - withdrawal_fee`. + // Use the SAME fee here so the constructed gross lands the net exactly at the + // withdrawal-range boundary under test. + let min_fee = dpp::shielded::compute_shielded_withdrawal_fee(0, platform_version) .expect("fee computation should not overflow"); let min_withdrawal = platform_version.system_limits.min_withdrawal_amount; // net = min_withdrawal_amount exactly → at the floor, accepted. @@ -620,7 +681,11 @@ mod tests { #[test] fn should_reject_shielded_withdrawal_with_net_above_max_withdrawal_amount() { let platform_version = PlatformVersion::latest(); - let min_fee = dpp::shielded::compute_minimum_shielded_fee(0, platform_version) + // ShieldedWithdrawal validation carves `compute_shielded_withdrawal_fee` (base + + // document cost), so the net the gate checks is `unshielding_amount - withdrawal_fee`. + // Use the SAME fee here so the constructed gross lands the net exactly at the + // withdrawal-range boundary under test. + let min_fee = dpp::shielded::compute_shielded_withdrawal_fee(0, platform_version) .expect("fee computation should not overflow"); let max = platform_version.system_limits.max_withdrawal_amount; // net = max_withdrawal_amount + 1 → just over the per-transition policy cap. @@ -646,7 +711,11 @@ mod tests { #[test] fn should_accept_shielded_withdrawal_with_net_at_max_withdrawal_amount() { let platform_version = PlatformVersion::latest(); - let min_fee = dpp::shielded::compute_minimum_shielded_fee(0, platform_version) + // ShieldedWithdrawal validation carves `compute_shielded_withdrawal_fee` (base + + // document cost), so the net the gate checks is `unshielding_amount - withdrawal_fee`. + // Use the SAME fee here so the constructed gross lands the net exactly at the + // withdrawal-range boundary under test. + let min_fee = dpp::shielded::compute_shielded_withdrawal_fee(0, platform_version) .expect("fee computation should not overflow"); let max = platform_version.system_limits.max_withdrawal_amount; // net = max_withdrawal_amount exactly → at the cap, accepted. 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 d8c2974996b..9f337b5ffb2 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 @@ -5,7 +5,8 @@ mod tests { use crate::execution::validation::state_transition::state_transitions::test_helpers::{ create_dummy_serialized_action, create_dummy_witness, create_platform_address, get_proving_key, process_transition, serialize_authorized_bundle_with_flags, - setup_address_with_balance, setup_platform, TestAddressSigner, + setup_address_with_balance, setup_address_with_balance_and_system_credits, setup_platform, + TestAddressSigner, }; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; use assert_matches::assert_matches; @@ -1354,4 +1355,361 @@ mod tests { ); } } + + // ========================================== + // CREDIT CONSERVATION TESTS (sum-tree balance) + // ========================================== + + /// Regression test for the shield credit-destruction chain-halt bug. + /// + /// A `Shield` whose inputs declare a per-input `requested` (max contribution) + /// larger than the shielded `amount` must NOT destroy the excess credits. The + /// shared address-balance validation debits the FULL `requested` per input, but + /// the pool is only credited `amount`. Without the reallocation fix in + /// `transform_into_action_v0`, the addresses lose `Σrequested + fee` while the + /// pool gains only `amount`, destroying `Σrequested - amount` credits and tripping + /// the block-end sum-tree conservation check (`CorruptedCreditsNotBalanced`), + /// which halts the chain. + /// + /// This drives a real shield (real Halo2 proof via the shared process-wide + /// proving key — no fresh ~30s proof) through the FULL block pipeline via + /// `process_state_transitions`, which runs + /// `process_block_fees_and_validate_sum_trees`. That block-end routine calls + /// `calculate_total_credits_balance(...).ok()` and returns the + /// `CorruptedCreditsNotBalanced` error (the chain-halt) if the sum of all credit + /// sub-trees no longer equals the platform total. With `verify_sum_trees` + /// enabled (the default), the helper's `.expect("expected to process block fees")` + /// therefore panics if the shield destroyed credits — so a passing test is a + /// genuine block-level proof of conservation. + mod credit_conservation { + use super::*; + use crate::execution::validation::state_transition::tests::process_state_transitions; + use dpp::block::block_info::BlockInfo; + // `Anchor`, `Builder`, `BundleType`, `DashMemo`, `OrchardFlags`, `FullViewingKey`, + // `NoteValue`, `Scope`, `SpendingKey` and `OsRng` are already in scope via `use super::*` + // (the parent module's imports), so they are intentionally not re-imported here. + + #[tokio::test] + async fn test_shield_with_inputs_greater_than_amount_conserves_credits() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + // --- Set up input address with a generous balance --- + // Use the system-credits variant so the fixture starts in a *balanced* + // state (total_credits_in_platform == sum of sub-trees). The block-end + // sum-tree validation inside `process_state_transitions` then asserts the + // exact production invariant, faithfully reproducing the chain-halt check. + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance_and_system_credits( + &mut platform, + input_address, + 0, + dash_to_credits!(1.0), + ); + + // Platform total credits before the shield. A shield only moves credits + // between addresses, the shielded pool, and fee pools — it never mints or + // burns system credits — so this total must be unchanged afterwards and + // the sum-tree conservation invariant must continue to hold. + let credits_before = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("should calculate total credits before shield"); + assert!( + credits_before + .ok() + .expect("credit balance check should not overflow"), + "credits must be balanced before the shield: {}", + credits_before + ); + + // --- Build a valid Orchard shield bundle (outputs only) --- + let mut rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let anchor = Anchor::empty_tree(); + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + anchor, + ); + + let shield_value = 5_000u64; + builder + .add_output( + None, + recipient, + NoteValue::from_raw(shield_value), + [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, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[]).unwrap(); + + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle_with_flags(&bundle); + + assert!(value_balance < 0); + let shield_amount = (-value_balance) as u64; + // Action count used to price the shielded compute fee (asserted on the booked fee below). + let num_actions_in_shield = actions.len(); + + // CRITICAL: requested (max contribution) is MUCH larger than the shielded + // amount. This is the routine builder behavior that triggered the bug: the + // full `requested` would be debited from the address while the pool only + // gains `shield_amount`. With the fix, only `shield_amount` is debited and + // the excess stays in the address. + let requested = shield_amount + dash_to_credits!(0.2); + assert!( + requested > shield_amount, + "test must exercise Σinputs > amount" + ); + + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, requested)); + + let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + inputs: inputs.clone(), + actions, + amount: shield_amount, + anchor: anchor_bytes, + proof: proof_bytes, + binding_signature: binding_sig, + 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 address in inputs.keys() { + let witness = signer + .sign_create_witness(address, &signable_bytes) + .await + .expect("should sign"); + witnesses.push(witness); + } + + if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + v0.input_witnesses = witnesses; + } + + // --- Run the FULL block pipeline (execute + distribute fees + validate + // sum trees). With `verify_sum_trees` enabled (the default), + // `process_block_fees_and_validate_sum_trees` would return + // `CorruptedCreditsNotBalanced` and the helper's `.expect(...)` would + // panic here if the shield destroyed credits. This is the block-level + // conservation assertion. --- + let platform_state = platform.state.load(); + let (fee_results, _processed_block_fees) = + process_state_transitions(&platform, &[st], BlockInfo::default(), &platform_state); + + // --- Assert the metered + compute fee model directly on the booked fee. --- + // The Shield fee is `metered_storage + metered_processing + shielded_verification_fee`. + // A positive `storage_fee` proves the metered GroveDB storage of the note/nullifier + // writes is captured (it is not discarded in favor of a flat estimate), and + // `processing_fee >= shielded_verification_fee` proves the ZK compute fee is added on + // top of the metered processing. + let shielded_verification_fee = dpp::shielded::compute_shielded_verification_fee( + num_actions_in_shield, + platform_version, + ) + .expect("shielded compute fee"); + let booked = &fee_results[0]; + assert!( + booked.storage_fee > 0, + "metered storage of the note/nullifier writes must be captured (storage_fee > 0), got {}", + booked.storage_fee + ); + assert!( + booked.processing_fee >= shielded_verification_fee, + "processing fee ({}) must include the shielded compute fee ({}) on top of metered processing", + booked.processing_fee, + shielded_verification_fee + ); + + // --- Additionally assert the invariant directly post-block --- + let credits_after = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("should calculate total credits after shield"); + + assert!( + credits_after + .ok() + .expect("credit balance check should not overflow"), + "credits must remain balanced after shield with Σinputs > amount \ + (this is the invariant whose failure halts the chain): {}", + credits_after + ); + + // The shield neither mints nor burns system credits. + assert_eq!( + credits_after.total_credits_in_platform, credits_before.total_credits_in_platform, + "shield must not change total platform credits" + ); + + // The shielded pool gained exactly `shield_amount`. + assert_eq!( + credits_after.total_in_shielded_balances + - credits_before.total_in_shielded_balances, + shield_amount as i64, + "shielded pool must gain exactly the shield amount" + ); + } + + /// Multi-input variant: two funded addresses with Σrequested > amount and a + /// fee strategy pointing at input 0. The shield amount is chosen to exceed a + /// single input's `requested`, so the reallocation must spill across BOTH + /// inputs. Runs the full block pipeline (execute + fee distribution + sum-tree + /// validation) and asserts conservation holds with more than one input — the + /// path the single-input test cannot exercise. + #[tokio::test] + async fn test_multi_input_shield_conserves_credits() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let addr_a = signer.add_p2pkh([1u8; 32]); + let addr_b = signer.add_p2pkh([2u8; 32]); + setup_address_with_balance_and_system_credits( + &mut platform, + addr_a, + 0, + dash_to_credits!(1.0), + ); + setup_address_with_balance_and_system_credits( + &mut platform, + addr_b, + 0, + dash_to_credits!(1.0), + ); + + let credits_before = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("should calculate total credits before shield"); + assert!( + credits_before + .ok() + .expect("credit balance check should not overflow"), + "credits must be balanced before the shield: {}", + credits_before + ); + + // Orchard bundle whose value is large enough that the shield amount spans + // both inputs (forces cross-input consumption / spillover). + let mut rng = OsRng; + let pk = get_proving_key(); + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let anchor = Anchor::empty_tree(); + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + anchor, + ); + let shield_value = 15_000_000_000u64; // 0.15 DASH + builder + .add_output( + None, + recipient, + NoteValue::from_raw(shield_value), + [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, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[]).unwrap(); + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle_with_flags(&bundle); + assert!(value_balance < 0); + let shield_amount = (-value_balance) as u64; + + // Each input contributes up to 0.1 DASH. Σrequested = 0.2 DASH > amount, + // and amount (0.15) exceeds a single input's requested (0.1), so the + // reallocation must consume from both inputs. + let requested = dash_to_credits!(0.1); + assert!( + requested < shield_amount, + "amount must span more than one input" + ); + assert!(requested * 2 > shield_amount, "Σinputs must exceed amount"); + + let mut inputs = BTreeMap::new(); + inputs.insert(addr_a, (1 as AddressNonce, requested)); + inputs.insert(addr_b, (1 as AddressNonce, requested)); + + let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + inputs: inputs.clone(), + actions, + amount: shield_amount, + anchor: anchor_bytes, + proof: proof_bytes, + binding_signature: binding_sig, + 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 address in inputs.keys() { + let witness = signer + .sign_create_witness(address, &signable_bytes) + .await + .expect("should sign"); + witnesses.push(witness); + } + if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + v0.input_witnesses = witnesses; + } + + let platform_state = platform.state.load(); + let (_fee_results, _processed_block_fees) = + process_state_transitions(&platform, &[st], BlockInfo::default(), &platform_state); + + let credits_after = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("should calculate total credits after shield"); + assert!( + credits_after + .ok() + .expect("credit balance check should not overflow"), + "credits must remain balanced after a multi-input shield: {}", + credits_after + ); + assert_eq!( + credits_after.total_credits_in_platform, credits_before.total_credits_in_platform, + "shield must not change total platform credits" + ); + assert_eq!( + credits_after.total_in_shielded_balances + - credits_before.total_in_shielded_balances, + shield_amount as i64, + "shielded pool must gain exactly the shield amount" + ); + } + } } 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 d0ed91f8d78..02b2e79c9f0 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 @@ -1,20 +1,182 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; -use crate::execution::types::execution_operation::ValidationOperation; -use crate::execution::types::state_transition_execution_context::{ - StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, -}; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::state_transitions::shielded_common::read_pool_total_balance; -use dpp::address_funds::PlatformAddress; +use dpp::address_funds::{AddressFundsFeeStrategyStep, PlatformAddress}; use dpp::block::block_info::BlockInfo; use dpp::fee::Credits; use dpp::prelude::{AddressNonce, ConsensusValidationResult}; +use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; 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; +use std::collections::{BTreeMap, BTreeSet}; + +/// Reallocates the per-input remaining balances so that the TOTAL amount debited +/// from the source addresses equals exactly `shield_amount` (the value entering the +/// shielded pool), instead of the sum of every input's `requested` value. +/// +/// # Why this is needed +/// +/// Each `ShieldTransitionV0::inputs` entry maps an address to `(nonce, requested)`, +/// where `requested` is a **max contribution**, not an exact debit (see the field +/// doc on `ShieldTransitionV0::inputs`: "The total across all inputs must cover +/// |value_balance| + fees. Excess credits remain in the source addresses."). The +/// shielded pool is only credited `shield_amount` (the Orchard `value_balance`), and +/// the Orchard binding signature only constrains that the pool receives exactly +/// `shield_amount` — it does **not** constrain how the transparent side is split +/// across the individual transparent inputs. +/// +/// The shared address-balance validation +/// (`validate_address_balances_and_nonces_internal_validation`) produces +/// `inputs_with_remaining_balance[addr] = actual - requested`, i.e. it debits the +/// FULL `requested` from every input. If we used that directly, the addresses would +/// lose `Σrequested` while the pool gains only `shield_amount`, destroying the excess +/// `Σrequested - shield_amount` credits and tripping the block-end sum-tree +/// conservation check (`CorruptedCreditsNotBalanced`), halting the chain. +/// +/// # The allocation +/// +/// We consume `shield_amount` across the inputs deterministically (so all nodes +/// agree), in two ordered passes: +/// 1. inputs **not** referenced by the fee strategy, then +/// 2. inputs the fee strategy will deduct the fee from, +/// +/// each pass in `BTreeMap` (address) order. Consuming the fee-payer input(s) last +/// leaves them with the maximum residue to cover the fee, so a client that did not +/// pre-reserve fee headroom on its fee-strategy input is far less likely to be +/// spuriously rejected for insufficient funds. This ordering does **not** affect +/// conservation — the total consumed is still exactly `shield_amount`; it only +/// changes *which* addresses are debited. +/// +/// Each input is debited at most its own `requested` (so no input is over-spent beyond +/// what its witness authorized): we consume `consumed = min(remaining_to_consume, +/// requested)` from it and leave the unconsumed `requested - consumed` in the address. +/// The structure validation has already enforced `Σrequested >= shield_amount`, so the +/// passes always consume the full `shield_amount`; the trailing check is a fail-closed +/// backstop (see `# Invariant` below). +/// +/// The remaining `requested - consumed` per input is simply left in the source +/// address. The existing `PaidFromAddressInputs` fee machinery then deducts the fee +/// on top of these adjusted balances, so addresses lose exactly +/// `shield_amount + fee`, the pool gains `shield_amount`, and the fee pools gain +/// `fee` — credits are conserved. +/// +/// # Invariant +/// +/// Correctness depends on `Σrequested >= shield_amount`, enforced by the structure +/// validation in +/// `rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v0/state_transition_validation.rs` +/// (`ShieldTransitionV0::validate_structure`). The trailing `remaining_to_consume != 0` guard +/// 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( + transition: &ShieldTransitionV0, + inputs_with_remaining_balance: BTreeMap, + shield_amount: Credits, +) -> Result, Error> { + let requested_inputs = &transition.inputs; + let fee_strategy = &transition.fee_strategy; + + // Identify which input addresses the fee strategy deducts the fee from. A + // `DeductFromInput(index)` step refers to the address at that position in the + // input set's `BTreeMap` (address) order — the SAME order the fee machinery + // (`deduct_fee_from_outputs_or_remaining_balance_of_inputs`) resolves the index + // in, because this reallocation never adds or removes addresses. (`ReduceOutput` + // is not applicable to shields, which have no outputs.) + let input_addresses: Vec = + inputs_with_remaining_balance.keys().copied().collect(); + let mut fee_input_addresses: BTreeSet = BTreeSet::new(); + for step in fee_strategy { + if let AddressFundsFeeStrategyStep::DeductFromInput(index) = step { + if let Some(addr) = input_addresses.get(*index as usize) { + fee_input_addresses.insert(*addr); + } + } + } + + let mut adjusted = BTreeMap::new(); + let mut remaining_to_consume = shield_amount; + + // Pass 1: non-fee-strategy inputs; Pass 2: fee-strategy inputs. Both in + // BTreeMap (address) order — fully deterministic. + // + // NOTE: Pass 2 drains the fee inputs in BTreeMap (address) order, which does not follow the fee + // strategy's own priority order. This only changes WHICH fee input keeps the leftover residue, + // never whether the fee is covered: pass 1 consumes all non-fee inputs first, so the amount + // drawn from the fee set is fixed at `shield_amount - Σ(non-fee requested)`, and the total + // residue left across the fee inputs (`Σ(fee-input actual) - that amount`) is identical for any + // intra-pass order. The fee machinery then deducts the fee from that residue in the strategy's + // own priority order. So fee coverage is order-invariant — this ordering has no conservation and + // no fee-coverage (spurious-rejection) effect. + for consume_fee_inputs in [false, true] { + for (addr, (nonce, remaining_after_full_debit)) in &inputs_with_remaining_balance { + if fee_input_addresses.contains(addr) != consume_fee_inputs { + continue; + } + + // Look up the per-input max contribution (`requested`) from the transition. + let (input_nonce, requested) = requested_inputs.get(addr).ok_or_else(|| { + Error::Execution(ExecutionError::CorruptedCodeExecution( + "shield input address present in remaining balances is missing from transition inputs", + )) + })?; + // The shared address-balance validation keeps the transition-side nonce and the + // remaining-balance nonce in lock-step; assert it so any future divergence (an upstream + // bug or a new version dispatcher) surfaces in debug/test builds. Free in release, where + // we proceed with the remaining-balance nonce as before. + debug_assert_eq!( + *input_nonce, *nonce, + "shield input nonce mismatch between transition inputs and remaining balances" + ); + let requested = *requested; + + // Consume up to this input's max contribution. + let consumed = remaining_to_consume.min(requested); + remaining_to_consume -= consumed; + + // Leave the unconsumed portion of this input's authorized contribution in the address. + // `remaining_after_full_debit` already had the *full* `requested` debited, so add back the + // part we did NOT consume: `new_remaining = remaining_after_full_debit + (requested - + // consumed)`. Since `consumed = min(remaining_to_consume, requested) <= requested`, the + // inner subtraction never underflows, and the result equals the pre-shield balance minus + // `consumed` (<= the original balance), so the add cannot overflow. Both are checked + // defensively in case of corrupted state. + let unconsumed = requested.checked_sub(consumed).ok_or_else(|| { + Error::Execution(ExecutionError::CorruptedCodeExecution( + "consumed exceeds requested computing unconsumed shield contribution", + )) + })?; + let new_remaining = remaining_after_full_debit + .checked_add(unconsumed) + .ok_or_else(|| { + Error::Execution(ExecutionError::Overflow( + "overflow leaving unconsumed shield contribution in remaining balance", + )) + })?; + + adjusted.insert(*addr, (*nonce, new_remaining)); + } + } + + // Structure validation enforces `Σrequested >= shield_amount` before this runs on the block and + // FirstTimeCheck paths, so the passes consume the entire amount there. This is the fail-closed + // backstop: a non-zero remainder would mean the pool is credited more than the addresses are + // debited (minting), so we reject rather than mint. It is load-bearing on any path that reaches + // this transform without basic-structure validation (e.g. the check_tx Recheck path, which is + // mempool-only with no state commit). + if remaining_to_consume != 0 { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "shield amount exceeds the sum of input contributions after reallocation", + ))); + } + + Ok(adjusted) +} pub(in crate::execution::validation::state_transition::state_transitions::shield) trait ShieldStateTransitionTransformIntoActionValidationV0 { @@ -35,30 +197,35 @@ impl ShieldStateTransitionTransformIntoActionValidationV0 for ShieldTransition { drive: &Drive, transaction: TransactionArg, inputs_with_remaining_balance: BTreeMap, - block_info: &BlockInfo, - execution_context: &mut StateTransitionExecutionContext, + _block_info: &BlockInfo, + _execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result, Error> { - let shield_amount: Credits = match self { - ShieldTransition::V0(v0) => v0.amount, - }; + let ShieldTransition::V0(transition_v0) = self; + let shield_amount: Credits = transition_v0.amount; - // Read current shielded pool state from GroveDB + // 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) + // on top as `additional_fixed_fee_cost` (see `ExecutionEvent` construction). These pool + // reads flow through the standard metering with the rest of the operations. let mut drive_operations = vec![]; let current_total_balance = read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; - // Calculate fees from the GroveDB operations - let fee = Drive::calculate_fee( - None, - Some(drive_operations), - &block_info.epoch, - drive.config.epochs_per_era, - platform_version, - None, - )?; - execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); - let result = ShieldTransitionAction::try_from_transition( self, inputs_with_remaining_balance, @@ -69,3 +236,316 @@ impl ShieldStateTransitionTransformIntoActionValidationV0 for ShieldTransition { Ok(result.map(|action| action.into())) } } + +#[cfg(test)] +mod tests { + use super::*; + use dpp::address_funds::PlatformAddress; + use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; + use std::collections::BTreeMap; + + /// Builds a minimal `ShieldTransitionV0` whose `inputs` map carries the per-input + /// `(nonce, requested)` pairs the reallocation needs. All cryptographic/structural + /// fields are dummy values — they are irrelevant to the reallocation logic. + fn shield_transition_with_inputs( + inputs: BTreeMap, + amount: Credits, + ) -> ShieldTransitionV0 { + ShieldTransitionV0 { + inputs, + actions: vec![], + amount, + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + fee_strategy: vec![], + user_fee_increase: 0, + input_witnesses: vec![], + } + } + + /// For a single input where `actual == requested`, exactly `shield_amount` should be + /// debited, leaving `actual - shield_amount` in the address. + #[test] + fn test_reallocation_single_input_debits_exactly_shield_amount() { + let addr = PlatformAddress::P2pkh([0xAA; 20]); + let actual: Credits = 1_000_000; + let requested: Credits = 1_000_000; + let shield_amount: Credits = 500_000; + + // Transition inputs carry `requested` as the max contribution. + let mut transition_inputs = BTreeMap::new(); + transition_inputs.insert(addr, (1u32, requested)); + let transition = shield_transition_with_inputs(transition_inputs, shield_amount); + + // `inputs_with_remaining_balance` from the shared validation = actual - requested. + let mut remaining = BTreeMap::new(); + remaining.insert(addr, (1u32, actual - requested)); + + let adjusted = + reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount).unwrap(); + + let (nonce, new_remaining) = adjusted.get(&addr).copied().unwrap(); + assert_eq!(nonce, 1u32, "nonce must be preserved"); + assert_eq!( + new_remaining, + actual - shield_amount, + "single input must retain actual - shield_amount (={})", + actual - shield_amount + ); + assert_eq!(new_remaining, 500_000); + + // Total debited across inputs equals exactly shield_amount. + let total_debited = actual - new_remaining; + assert_eq!(total_debited, shield_amount); + } + + /// Builds a `ShieldTransitionV0` with an explicit fee strategy (the default + /// fixture uses an empty strategy). + fn shield_transition_with_fee_strategy( + inputs: BTreeMap, + amount: Credits, + fee_strategy: Vec, + ) -> ShieldTransitionV0 { + ShieldTransitionV0 { + inputs, + actions: vec![], + amount, + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + fee_strategy, + user_fee_increase: 0, + input_witnesses: vec![], + } + } + + /// The reallocation consumes the shield amount from NON-fee-strategy inputs + /// first, so the fee-payer input keeps maximum residue to cover the fee. Here + /// input 0 (the fee-strategy input) has `actual == requested == shield_amount`: + /// the naive "lowest address first" greedy would drain it to 0 (leaving nothing + /// for the fee), but the fee-aware ordering funds the whole amount from input 1 + /// and leaves input 0 fully intact. Conservation is preserved. + #[test] + fn test_reallocation_consumes_non_fee_inputs_first() { + let addr_a = PlatformAddress::P2pkh([0x01; 20]); // index 0 = fee input + let addr_b = PlatformAddress::P2pkh([0x02; 20]); // index 1 = non-fee + + let shield_amount: Credits = 500_000; + + // A: actual == requested == shield_amount (the naive greedy would drain it). + let (req_a, actual_a): (Credits, Credits) = (500_000, 500_000); + // B: plenty of headroom. + let (req_b, actual_b): (Credits, Credits) = (500_000, 1_000_000); + + let mut transition_inputs = BTreeMap::new(); + transition_inputs.insert(addr_a, (1u32, req_a)); + transition_inputs.insert(addr_b, (1u32, req_b)); + // Fee strategy deducts from input 0 (addr_a, the lowest address). + let transition = shield_transition_with_fee_strategy( + transition_inputs, + shield_amount, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + ); + + let mut remaining = BTreeMap::new(); + remaining.insert(addr_a, (1u32, actual_a - req_a)); // 0 + remaining.insert(addr_b, (1u32, actual_b - req_b)); // 500_000 + + let adjusted = + reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount).unwrap(); + + let (_n_a, adj_a) = adjusted.get(&addr_a).copied().unwrap(); + let (_n_b, adj_b) = adjusted.get(&addr_b).copied().unwrap(); + + // The fee-payer input (A) is left fully intact — the amount came from B. + assert_eq!( + adj_a, actual_a, + "fee-strategy input must NOT be drained when a non-fee input can cover the amount" + ); + // Input B funded the whole shield amount. + assert_eq!(adj_b, actual_b - shield_amount); + + // Conservation: total debited == shield_amount. + assert_eq!((actual_a - adj_a) + (actual_b - adj_b), shield_amount); + } + + /// For multiple inputs, the SUM of (actual - adjusted) across inputs must equal + /// exactly `shield_amount`, each adjusted balance must be >= its full-debit remaining, + /// and the greedy allocation must consume the entire amount (remaining_to_consume == 0). + #[test] + fn test_reallocation_multi_input_sum_of_debits_equals_shield_amount() { + let addr_a = PlatformAddress::P2pkh([0x01; 20]); + let addr_b = PlatformAddress::P2pkh([0x02; 20]); + let addr_c = PlatformAddress::P2pkh([0x03; 20]); + + // requested (max contributions): 100k / 200k / 300k, total 600k. + let req_a: Credits = 100_000; + let req_b: Credits = 200_000; + let req_c: Credits = 300_000; + + // Give each address a generous actual balance so `actual = remaining + requested`. + let actual_a: Credits = 100_000; + let actual_b: Credits = 250_000; + let actual_c: Credits = 400_000; + + let shield_amount: Credits = 50_000; + + let mut transition_inputs = BTreeMap::new(); + transition_inputs.insert(addr_a, (1u32, req_a)); + transition_inputs.insert(addr_b, (1u32, req_b)); + transition_inputs.insert(addr_c, (1u32, req_c)); + let transition = shield_transition_with_inputs(transition_inputs, shield_amount); + + // remaining_after_full_debit = actual - requested per input. + let rem_a = actual_a - req_a; // 0 + let rem_b = actual_b - req_b; // 50_000 + let rem_c = actual_c - req_c; // 100_000 + + let mut remaining = BTreeMap::new(); + remaining.insert(addr_a, (1u32, rem_a)); + remaining.insert(addr_b, (1u32, rem_b)); + remaining.insert(addr_c, (1u32, rem_c)); + + let adjusted = + reallocate_inputs_for_shield_amount(&transition, remaining.clone(), shield_amount) + .unwrap(); + + // The SUM of (actual - adjusted) across inputs == shield_amount exactly. + let actuals = [(addr_a, actual_a), (addr_b, actual_b), (addr_c, actual_c)]; + let mut total_debited: Credits = 0; + for (addr, actual) in actuals { + let (_nonce, adj) = adjusted.get(&addr).copied().unwrap(); + total_debited += actual - adj; + } + assert_eq!( + total_debited, shield_amount, + "sum of debits across inputs must equal shield_amount exactly" + ); + + // Each adjusted balance >= its full-debit remaining (we debit <= requested). + for (addr, (_n, full_debit_remaining)) in &remaining { + let (_nonce, adj) = adjusted.get(addr).copied().unwrap(); + assert!( + adj >= *full_debit_remaining, + "adjusted remaining ({}) must be >= full-debit remaining ({}) for {:?}", + adj, + full_debit_remaining, + addr + ); + } + + // Greedy allocation: first input (addr_a, requested 100k) covers the whole 50k. + let (_n_a, adj_a) = adjusted.get(&addr_a).copied().unwrap(); + assert_eq!( + adj_a, + actual_a - shield_amount, + "first input absorbs all 50k" + ); + // The other two inputs are untouched (left at their actual balances). + let (_n_b, adj_b) = adjusted.get(&addr_b).copied().unwrap(); + let (_n_c, adj_c) = adjusted.get(&addr_c).copied().unwrap(); + assert_eq!(adj_b, actual_b, "second input untouched"); + assert_eq!(adj_c, actual_c, "third input untouched"); + } + + /// When the amount spills past the first input's max contribution, allocation + /// continues greedily into subsequent inputs in BTreeMap order. + #[test] + fn test_reallocation_multi_input_spillover_across_inputs() { + let addr_a = PlatformAddress::P2pkh([0x01; 20]); + let addr_b = PlatformAddress::P2pkh([0x02; 20]); + + let req_a: Credits = 100_000; + let req_b: Credits = 200_000; + let actual_a: Credits = 100_000; + let actual_b: Credits = 200_000; + + // 250k needs all of addr_a (100k) plus 150k of addr_b. + let shield_amount: Credits = 250_000; + + let mut transition_inputs = BTreeMap::new(); + transition_inputs.insert(addr_a, (1u32, req_a)); + transition_inputs.insert(addr_b, (1u32, req_b)); + let transition = shield_transition_with_inputs(transition_inputs, shield_amount); + + let mut remaining = BTreeMap::new(); + remaining.insert(addr_a, (1u32, actual_a - req_a)); + remaining.insert(addr_b, (1u32, actual_b - req_b)); + + let adjusted = + reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount).unwrap(); + + let (_n_a, adj_a) = adjusted.get(&addr_a).copied().unwrap(); + let (_n_b, adj_b) = adjusted.get(&addr_b).copied().unwrap(); + + // addr_a fully consumed (100k), addr_b consumed 150k -> 50k left. + assert_eq!(adj_a, 0); + assert_eq!(adj_b, actual_b - 150_000); + + let total_debited = (actual_a - adj_a) + (actual_b - adj_b); + assert_eq!(total_debited, shield_amount); + } + + /// Missing transition input for an address present in remaining balances is an error. + #[test] + fn test_reallocation_missing_transition_input_errors() { + let addr = PlatformAddress::P2pkh([0xAA; 20]); + // Transition has NO inputs. + let transition = shield_transition_with_inputs(BTreeMap::new(), 1000); + + let mut remaining = BTreeMap::new(); + remaining.insert(addr, (1u32, 5000)); + + let result = reallocate_inputs_for_shield_amount(&transition, remaining, 1000); + assert!(result.is_err(), "missing transition input must error"); + } + + /// The anti-mint backstop: if `shield_amount` exceeds the sum of input contributions + /// (`Σrequested`) — only possible if the structure-validation invariant is bypassed — the + /// reallocation rejects rather than minting credits (it would otherwise credit the pool more + /// than the addresses are debited). Directly covers the trailing `remaining_to_consume != 0` + /// guard, which the end-to-end tests never reach (structure validation rejects first). + #[test] + fn test_reallocation_rejects_when_shield_amount_exceeds_input_sum() { + let addr = PlatformAddress::P2pkh([0xAA; 20]); + let requested: Credits = 1_000; + let shield_amount: Credits = 5_000; // > Σrequested = 1_000 + + let mut transition_inputs = BTreeMap::new(); + transition_inputs.insert(addr, (1u32, requested)); + let transition = shield_transition_with_inputs(transition_inputs, shield_amount); + + let mut remaining = BTreeMap::new(); + remaining.insert(addr, (1u32, 0)); // actual - requested + + let err = reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount) + .expect_err("shield_amount > Σrequested must be rejected, not minted"); + match err { + Error::Execution(ExecutionError::CorruptedCodeExecution(msg)) => assert!( + msg.contains("exceeds the sum of input contributions"), + "unexpected error message: {msg}" + ), + other => panic!("expected the CorruptedCodeExecution anti-mint guard, got {other:?}"), + } + } + + /// In debug builds, a nonce mismatch between the transition inputs and the remaining-balance + /// map trips the lock-step `debug_assert_eq!`. Free in release (the assert compiles out and the + /// remaining-balance nonce is used); this exercises the failing direction so the contract is + /// guarded in CI. + #[cfg(debug_assertions)] + #[test] + #[should_panic(expected = "shield input nonce mismatch")] + fn test_reallocation_diverging_nonce_trips_debug_assert() { + let addr = PlatformAddress::P2pkh([0xAA; 20]); + let mut transition_inputs = BTreeMap::new(); + transition_inputs.insert(addr, (1u32, 1_000)); // transition-side nonce = 1 + let transition = shield_transition_with_inputs(transition_inputs, 500); + + let mut remaining = BTreeMap::new(); + remaining.insert(addr, (2u32, 0)); // remaining-balance nonce = 2 (diverges) + + let _ = reallocate_inputs_for_shield_amount(&transition, remaining, 500); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/mod.rs index 0d842f23284..e6bfd2679d1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/mod.rs @@ -40,7 +40,7 @@ impl StateTransitionShieldFromAssetLockTransitionActionTransformer platform: &PlatformRef, signable_bytes: Vec, validation_mode: ValidationMode, - block_info: &BlockInfo, + _block_info: &BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -57,7 +57,6 @@ impl StateTransitionShieldFromAssetLockTransitionActionTransformer platform, signable_bytes, validation_mode, - block_info, execution_context, tx, ), 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 7e9b028a8dc..59fee552721 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 @@ -55,6 +55,29 @@ mod tests { (asset_lock_proof, pk.to_vec()) } + /// Like [`create_asset_lock_proof_with_key`], but funds the asset lock with a caller-chosen + /// `amount` of duffs. Used by the implicit-fee-cap boundary tests to land the surplus exactly + /// on the cap. + fn create_asset_lock_proof_with_key_and_amount( + rng: &mut StdRng, + amount_duffs: u64, + ) -> ( + dpp::identity::state_transition::asset_lock_proof::AssetLockProof, + Vec, + ) { + let platform_version = PlatformVersion::latest(); + let (_, pk) = ECDSA_SECP256K1 + .random_public_and_private_key_data(rng, platform_version) + .unwrap(); + + let asset_lock_proof = instant_asset_lock_proof_fixture( + Some(PrivateKey::from_byte_array(&pk, Network::Testnet).unwrap()), + Some(amount_duffs), + ); + + (asset_lock_proof, pk.to_vec()) + } + /// Build a ShieldFromAssetLock StateTransition with the given fields and ECDSA signature. /// The `signature` field is computed over the signable bytes using `dashcore::signer::sign`. fn create_signed_shield_from_asset_lock_transition( @@ -74,6 +97,12 @@ mod tests { anchor, proof: proof.clone(), binding_signature, + // Route the asset-lock surplus to a platform address. The fixture lock is ~1 Dash + // while the shield amount is small, so the surplus exceeds the 0.2-Dash implicit-fee + // cap; a surplus_output is required for the transition to be accepted (and it + // exercises the surplus-to-address path end-to-end). Must match the signed literal + // below so the ECDSA signature commits to the same destination. + surplus_output: Some(dpp::address_funds::PlatformAddress::P2pkh([0x33; 20])), signature: Default::default(), }; @@ -94,6 +123,54 @@ mod tests { anchor, proof, binding_signature, + surplus_output: Some(dpp::address_funds::PlatformAddress::P2pkh([0x33; 20])), + signature: BinaryData::new(signature.to_vec()), + }, + )) + } + + /// Build a ShieldFromAssetLock StateTransition with `surplus_output: None` and a valid ECDSA + /// signature over the signable bytes. Used by the implicit-fee-cap boundary tests, which need + /// the no-`surplus_output` path (where the surplus is implicitly donated to the fee pools and + /// must not exceed `shielded_implicit_fee_cap`). + fn create_signed_shield_from_asset_lock_transition_no_surplus( + asset_lock_proof: dpp::identity::state_transition::asset_lock_proof::AssetLockProof, + asset_lock_private_key: &[u8], + actions: Vec, + value_balance: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + ) -> StateTransition { + // Create unsigned transition to compute signable bytes + let unsigned = ShieldFromAssetLockTransitionV0 { + asset_lock_proof: asset_lock_proof.clone(), + actions: actions.clone(), + value_balance, + anchor, + proof: proof.clone(), + binding_signature, + surplus_output: None, + signature: Default::default(), + }; + + let state_transition: StateTransition = unsigned.into(); + let signable_bytes = state_transition + .signable_bytes() + .expect("should compute signable bytes"); + + let signature = + dpp::dashcore::signer::sign(&signable_bytes, asset_lock_private_key).unwrap(); + + StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0( + ShieldFromAssetLockTransitionV0 { + asset_lock_proof, + actions, + value_balance, + anchor, + proof, + binding_signature, + surplus_output: None, signature: BinaryData::new(signature.to_vec()), }, )) @@ -117,6 +194,7 @@ mod tests { anchor, proof, binding_signature, + surplus_output: None, signature: BinaryData::new(vec![0u8; 65]), // dummy signature }, )) @@ -179,6 +257,7 @@ mod tests { anchor: [42u8; 32], proof: vec![0u8; 100], binding_signature: [0u8; 64], + surplus_output: None, signature: Default::default(), }; @@ -344,6 +423,7 @@ mod tests { anchor: [42u8; 32], proof: vec![0u8; 100], binding_signature: [0u8; 64], + surplus_output: None, signature: BinaryData::new(vec![0u8; 65]), // zeroed invalid signature }), ); @@ -817,9 +897,21 @@ mod tests { assert_ne!(root_hash, [0u8; 32], "root hash should not be zeroed"); - // --- Assert result is VerifiedAssetLockConsumed --- - let StateTransitionProofResult::VerifiedAssetLockConsumed(info) = proof_result else { - panic!("expected VerifiedAssetLockConsumed, got {:?}", proof_result); + // --- Assert result is VerifiedAssetLockConsumedWithAddressInfos --- + // The transition built by `create_signed_shield_from_asset_lock_transition` sets + // `surplus_output: Some(P2pkh([0x33; 20]))` (the fixture lock funds a surplus well above + // the implicit-fee cap, so an explicit surplus output is required). The verifier + // therefore proves BOTH the consumed asset-lock outpoint AND the surplus-output address + // balance, returning the combined variant. + let StateTransitionProofResult::VerifiedAssetLockConsumedWithAddressInfos( + info, + balances, + ) = proof_result + else { + panic!( + "expected VerifiedAssetLockConsumedWithAddressInfos, got {:?}", + proof_result + ); }; // ShieldFromAssetLock always fully consumes the asset lock @@ -829,6 +921,1019 @@ mod tests { "expected FullyConsumed, got {:?}", info ); + + // The surplus credit must be provably present at the signed surplus-output address. + let surplus_address = dpp::address_funds::PlatformAddress::P2pkh([0x33; 20]); + let surplus_balance = balances.get(&surplus_address).unwrap_or_else(|| { + panic!( + "surplus address missing from proven balances: {:?}", + balances + ) + }); + let (_nonce, credits) = surplus_balance + .unwrap_or_else(|| panic!("surplus address has no credited balance in the proof")); + assert!( + credits > 0, + "expected a positive surplus credit at the surplus-output address, got {}", + credits + ); + } + + /// NEGATIVE test for the strict-merged tightening: a proof that carries EXTRA data + /// beyond `{outpoint, surplus-address}` MUST be rejected by the production verifier. + /// + /// The production verify path rebuilds the merged `{outpoint, single surplus address}` + /// query and verifies it with the STRICT `verify_query_with_absence_proof`, whose + /// succinctness check rejects any proof that descends into a subtree (a lower layer) the + /// query did not require. Here we commit the same surplus state, then have the (honest, but + /// over-broad) prover generate a SUPERSET proof for `{outpoint, surplus-address, + the + /// genesis-populated Pools subtree}` and verify it against the production single-address + /// merged query. The strict verifier must reject it because the proof carries an extra + /// root-level lower layer (`Pools`) the production query never touches. + /// + /// For contrast we also confirm the SUBSET verifier (the looser primitive this change + /// replaced) would have ACCEPTED the same padded proof — demonstrating exactly the hole + /// the strict merged verification closes. + /// + /// (Note: padding with an extra *leaf key* in an already-descended subtree — e.g. a second + /// address under the same clear-address pool — is NOT what the succinctness check guards + /// against; such a key is either filtered by the per-layer merk query or surfaced as a + /// proven absence, and is rejected/ignored regardless of strict-vs-subset. The tightening + /// specifically removes the tolerance for extra *subtree branches*.) + #[test] + fn test_shield_from_asset_lock_padded_proof_is_rejected_by_strict_verify() { + use drive::drive::RootTree; + use drive::grovedb::{GroveDb, PathQuery, Query, SizedQuery}; + + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + + // --- Build a valid Orchard bundle (shield = outputs only) --- + let mut orchard_rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let anchor = Anchor::empty_tree(); + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + anchor, + ); + + let shield_value = 5_000u64; + builder + .add_output( + None, + recipient, + NoteValue::from_raw(shield_value), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut orchard_rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut orchard_rng).unwrap(); + let bundle = proven.apply_signatures(orchard_rng, sighash, &[]).unwrap(); + + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle_with_flags(&bundle); + + assert!(value_balance < 0); + let shield_amount = (-value_balance) as u64; + + // This helper sets `surplus_output: Some(P2pkh([0x33; 20]))`. + let transition = create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + actions, + shield_amount, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let outpoint = { + use dpp::identity::state_transition::OptionallyAssetLockProved; + let op = transition + .optional_asset_lock_proof() + .expect("transition must carry an asset lock proof") + .out_point() + .expect("transition must carry an outpoint"); + let bytes: [u8; 36] = op.into(); + bytes + }; + + 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 processing_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"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // --- Reconstruct the PRODUCTION merged query exactly as the verifier does --- + // {outpoint} ∪ {real surplus address [0x33; 20]}, both with cleared limits, then a + // limit that can never truncate the legitimate result set. + let real_surplus = dpp::address_funds::PlatformAddress::P2pkh([0x33; 20]); + + let mut outpoint_query = Query::new(); + outpoint_query.insert_key(outpoint.to_vec()); + let mut outpoint_pq = PathQuery::new( + vec![vec![RootTree::SpentAssetLockTransactions as u8]], + SizedQuery::new(outpoint_query, None, None), + ); + outpoint_pq.query.limit = None; + + let mut real_address_pq = + Drive::balances_for_clear_addresses_query(std::iter::once(&real_surplus)); + real_address_pq.query.limit = None; + + let mut production_pq = PathQuery::merge( + vec![&outpoint_pq, &real_address_pq], + &platform_version.drive.grove_version, + ) + .expect("merge production query"); + production_pq.query.limit = Some(u16::MAX); + + // Sanity: an HONEST proof for the production query verifies strictly (liveness). + let honest_proof = platform + .drive + .grove_get_proved_path_query( + &production_pq, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("honest production proof"); + GroveDb::verify_query_with_absence_proof( + &honest_proof, + &production_pq, + &platform_version.drive.grove_version, + ) + .expect("strict verify of honest production proof must succeed"); + + // --- Build a SUPERSET (padded) proof: {outpoint, real address, + an extra subtree} --- + // The succinctness check the strict verifier runs rejects a proof that descends into a + // subtree (a lower layer) the query did not require. So we pad by ALSO descending the + // genesis-populated `Pools` root subtree, which the production query never touches. The + // padded proof therefore carries an extra root-level lower layer. + let mut pools_top = Query::new(); + pools_top.insert_key(vec![RootTree::Pools as u8]); + pools_top.set_subquery(Query::new_range_full()); + let pools_pq = PathQuery::new(vec![], SizedQuery::new(pools_top, None, None)); + + let mut superset_pq = PathQuery::merge( + vec![&outpoint_pq, &real_address_pq, &pools_pq], + &platform_version.drive.grove_version, + ) + .expect("merge superset query"); + superset_pq.query.limit = Some(u16::MAX); + + let padded_proof = platform + .drive + .grove_get_proved_path_query( + &superset_pq, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("padded superset proof"); + + // The STRICT verifier (production behavior) MUST reject the padded proof: it carries + // an extra address layer the production query did not request. + let strict_result = GroveDb::verify_query_with_absence_proof( + &padded_proof, + &production_pq, + &platform_version.drive.grove_version, + ); + assert!( + strict_result.is_err(), + "strict verifier must reject a proof padded with an extra address layer, got {:?}", + strict_result + ); + + // Contrast: the SUBSET verifier (the looser primitive this change replaced) tolerates + // the extra layer and ACCEPTS the same padded proof — the exact hole now closed. + let subset_result = GroveDb::verify_subset_query_with_absence_proof( + &padded_proof, + &production_pq, + &platform_version.drive.grove_version, + ); + assert!( + subset_result.is_ok(), + "subset verifier was expected to tolerate the padded proof, got {:?}", + subset_result + ); + } + + /// Round-trip prove/verify for the `surplus_output: None` path. The surplus is implicitly + /// donated to the fee pools (sized to land exactly on the cap so it is accepted), so the + /// verifier proves ONLY the consumed asset-lock outpoint and returns the plain + /// `VerifiedAssetLockConsumed` variant — byte-for-byte the pre-change behavior. + #[test] + fn test_shield_from_asset_lock_no_surplus_prove_and_verify() { + use dpp::balances::credits::CREDITS_PER_DUFF; + use dpp::shielded::compute_minimum_shielded_fee; + use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition as SfalTransition; + use dpp::state_transition::StateTransitionEstimatedFeeValidation; + + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // Build a valid bundle and size the asset-lock funding so the surplus lands exactly on + // the implicit-fee cap (the largest surplus the no-`surplus_output` path accepts). + let cap = platform_version + .drive_abci + .validation_and_processing + .event_constants + .shielded_implicit_fee_cap; + + // --- Build a valid single-output Orchard bundle --- + let mut orchard_rng = OsRng; + let pk = get_proving_key(); + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + // A round base shield value, corrected below so the required lock value is a whole + // number of duffs. + const SHIELD_VALUE_BASE: u64 = 1_000_000; + + let mut build_bundle = |shield_value: u64| { + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + Anchor::empty_tree(), + ); + builder + .add_output( + None, + recipient, + NoteValue::from_raw(shield_value), + [0u8; 36], + ) + .unwrap(); + let (unauthorized, _) = builder.build::(&mut orchard_rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut orchard_rng).unwrap(); + let bundle = proven.apply_signatures(orchard_rng, sighash, &[]).unwrap(); + serialize_authorized_bundle_with_flags(&bundle) + }; + + // Probe to learn the (value-independent) action count, then the pool fee. + let (probe_actions, _, _, _, _, _) = build_bundle(SHIELD_VALUE_BASE); + let num_actions = probe_actions.len(); + let shielded_fee = compute_minimum_shielded_fee(num_actions, platform_version) + .expect("should compute minimum shielded fee"); + let albc = SfalTransition::V0(ShieldFromAssetLockTransitionV0 { + asset_lock_proof: instant_asset_lock_proof_fixture(None, None), + actions: vec![create_dummy_serialized_action()], + value_balance: 1, + anchor: [1u8; 32], + proof: vec![0u8; 1], + binding_signature: [0u8; 64], + surplus_output: None, + signature: Default::default(), + }) + .calculate_min_required_fee(platform_version) + .expect("should compute asset-lock base cost"); + let pool_fee = shielded_fee.checked_add(albc).expect("pool fee overflow"); + + // Choose `shield_value` so `cap + shield_value + pool_fee` is a whole number of duffs. + let correction = (CREDITS_PER_DUFF + - ((cap + SHIELD_VALUE_BASE + pool_fee) % CREDITS_PER_DUFF)) + % CREDITS_PER_DUFF; + let shield_value = SHIELD_VALUE_BASE + correction; + + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + build_bundle(shield_value); + assert!(value_balance < 0); + let shield_amount = (-value_balance) as u64; + + let lock_credits = cap + shield_amount + pool_fee; + assert_eq!(lock_credits % CREDITS_PER_DUFF, 0); + let lock_amount_duffs = lock_credits / CREDITS_PER_DUFF; + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = + create_asset_lock_proof_with_key_and_amount(&mut rng, lock_amount_duffs); + + let transition = create_signed_shield_from_asset_lock_transition_no_surplus( + asset_lock_proof, + &asset_lock_pk, + actions, + shield_amount, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + 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 processing_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"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let proof_result = platform + .drive + .prove_state_transition(&transition, None, platform_version) + .expect("expected to generate proof for shield_from_asset_lock"); + + let proof_bytes = proof_result + .into_data() + .expect("expected proof data, not an error"); + + let (root_hash, proof_result) = Drive::verify_state_transition_was_executed_with_proof( + &transition, + &BlockInfo::default(), + &proof_bytes, + &|_| Ok(None), + platform_version, + ) + .expect("expected to verify shield_from_asset_lock proof"); + + assert_ne!(root_hash, [0u8; 32], "root hash should not be zeroed"); + + // --- No surplus output → plain VerifiedAssetLockConsumed (unchanged behavior) --- + let StateTransitionProofResult::VerifiedAssetLockConsumed(info) = proof_result else { + panic!("expected VerifiedAssetLockConsumed, got {:?}", proof_result); + }; + + assert!( + matches!(info, StoredAssetLockInfo::FullyConsumed), + "expected FullyConsumed, got {:?}", + info + ); + } + } + + // ========================================== + // IMPLICIT FEE CAP BOUNDARY TESTS + // ========================================== + + /// Boundary tests for the pre-action implicit-fee-cap gate in `transform_into_action` + /// (Step 7b). When no `surplus_output` is set, the asset-lock surplus is implicitly donated + /// to the fee pools, but only up to `shielded_implicit_fee_cap`; above the cap the transition + /// is rejected with `ShieldedImplicitFeeCapExceededError`, forcing the client to set an + /// explicit `surplus_output`. + /// + /// The cap check runs BEFORE ZK proof verification (so an over-cap transition is rejected + /// cheaply, without paying ~100ms of Halo2 verification). The accepted-case test still needs a + /// valid proof to succeed, so these tests build a real (valid) Orchard bundle and size the + /// asset-lock funding so the surplus lands exactly on the boundary: + /// + /// surplus = lock_value_credits − shield_amount − pool_fee + /// + /// `pool_fee = compute_minimum_shielded_fee(num_actions) + albc` is independent of the shielded + /// amount, so we compute it dynamically from the platform version (rather than hardcoding the + /// fee constants) and then choose `shield_value` to place the surplus precisely at `cap` or + /// `cap + 1`. The asset-lock funding (`amount_duffs`) is fixed at a round value comfortably + /// above the cap. + mod implicit_fee_cap { + use super::*; + use dpp::balances::credits::CREDITS_PER_DUFF; + use dpp::shielded::compute_minimum_shielded_fee; + use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition as SfalTransition; + use dpp::state_transition::StateTransitionEstimatedFeeValidation; + + /// Compute the flat `pool_fee` (in credits) that `transform_into_action` charges for a + /// `ShieldFromAssetLock` with `num_actions` Orchard actions, mirroring the production + /// computation exactly: `compute_minimum_shielded_fee(num_actions) + albc`. The fee depends + /// only on the action count (not the shielded amount), so once we know the action count of + /// the built bundle we can solve for the funding that lands the surplus on the cap boundary. + fn pool_fee_for_actions(num_actions: usize, platform_version: &PlatformVersion) -> u64 { + let shielded_fee = compute_minimum_shielded_fee(num_actions, platform_version) + .expect("should compute minimum shielded fee"); + + // `albc` is the asset-lock base cost, exposed via the estimated-fee validation trait. + // Build a throwaway transition just to invoke `calculate_min_required_fee` (the value + // depends only on the platform version, not on the transition contents). + let dummy = ShieldFromAssetLockTransitionV0 { + asset_lock_proof: instant_asset_lock_proof_fixture(None, None), + actions: vec![create_dummy_serialized_action()], + value_balance: 1, + anchor: [1u8; 32], + proof: vec![0u8; 1], + binding_signature: [0u8; 64], + surplus_output: None, + signature: Default::default(), + }; + let albc = SfalTransition::V0(dummy) + .calculate_min_required_fee(platform_version) + .expect("should compute asset-lock base cost"); + + shielded_fee + .checked_add(albc) + .expect("pool fee should not overflow") + } + + /// Build a valid single-output Orchard bundle that shields exactly `shield_value` credits, + /// returning the serialized pieces consumed by the transition builder plus the bundle's + /// action count. + /// + /// (Real proving — uses the cached `get_proving_key`, so each build is a few seconds.) + fn build_valid_shield_bundle( + shield_value: u64, + ) -> ( + Vec, + u64, + usize, + [u8; 32], + Vec, + [u8; 64], + ) { + let mut orchard_rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let anchor = Anchor::empty_tree(); + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + anchor, + ); + + builder + .add_output( + None, + recipient, + NoteValue::from_raw(shield_value), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut orchard_rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + // No extra_sighash_data for shield_from_asset_lock (empty, like shield). + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut orchard_rng).unwrap(); + let bundle = proven.apply_signatures(orchard_rng, sighash, &[]).unwrap(); + + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle_with_flags(&bundle); + + assert!(value_balance < 0, "shield value_balance must be negative"); + let shield_amount = (-value_balance) as u64; + assert_eq!( + shield_amount, shield_value, + "the bundle must shield exactly the requested value" + ); + let num_actions = actions.len(); + + ( + actions, + shield_amount, + num_actions, + anchor_bytes, + proof_bytes, + binding_sig, + ) + } + + /// Pieces needed to drive a boundary case end-to-end: a valid bundle whose surplus lands + /// exactly on `target_surplus`, plus the asset-lock funding (in duffs) that achieves it. + struct BoundaryCase { + actions: Vec, + shield_amount: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + lock_amount_duffs: u64, + } + + /// Build a valid `ShieldFromAssetLock` bundle and matching asset-lock funding such that the + /// transform's surplus (`lock_credits − shield_amount − pool_fee`) equals `target_surplus` + /// exactly. + /// + /// The asset-lock value is quantised to whole duffs (× `CREDITS_PER_DUFF`), so the surplus + /// can only land on a multiple-of-`CREDITS_PER_DUFF` lattice unless we tune the shielded + /// amount. We therefore: + /// 1. Build a probe bundle to learn the (value-independent) action count → `pool_fee`. + /// 2. Pick `shield_value ≡ −(target_surplus + pool_fee) (mod CREDITS_PER_DUFF)` so that + /// `target_surplus + shield_value + pool_fee` is an exact number of duffs. + /// 3. Rebuild the bundle with that `shield_value` (the action count is independent of the + /// value, so `pool_fee` is unchanged) and derive the funding `amount_duffs`. + fn build_boundary_case( + target_surplus: u64, + platform_version: &PlatformVersion, + ) -> BoundaryCase { + // A round base shield value, comfortably positive after the modular correction below. + const SHIELD_VALUE_BASE: u64 = 1_000_000; + + // Step 1: probe to learn the action count and thus the pool fee. + let (_, _, probe_num_actions, _, _, _) = build_valid_shield_bundle(SHIELD_VALUE_BASE); + let pool_fee = pool_fee_for_actions(probe_num_actions, platform_version); + + // Step 2: choose `shield_value` so the required lock value is a whole number of duffs. + let correction = (CREDITS_PER_DUFF + - ((target_surplus + SHIELD_VALUE_BASE + pool_fee) % CREDITS_PER_DUFF)) + % CREDITS_PER_DUFF; + let shield_value = SHIELD_VALUE_BASE + correction; + + // Step 3: build the real bundle and derive the funding. + let (actions, shield_amount, num_actions, anchor, proof, binding_signature) = + build_valid_shield_bundle(shield_value); + assert_eq!( + num_actions, probe_num_actions, + "action count must not depend on the shielded value" + ); + + let lock_credits = target_surplus + shield_amount + pool_fee; + assert_eq!( + lock_credits % CREDITS_PER_DUFF, + 0, + "lock value must be a whole number of duffs" + ); + let lock_amount_duffs = lock_credits / CREDITS_PER_DUFF; + + // The surplus the transform will compute (`lock_credits − shield_amount − pool_fee`) + // lands exactly on the target by construction. + assert_eq!( + lock_credits - shield_amount - pool_fee, + target_surplus, + "surplus must land exactly on the target" + ); + + BoundaryCase { + actions, + shield_amount, + anchor, + proof, + binding_signature, + lock_amount_duffs, + } + } + + fn implicit_fee_cap(platform_version: &PlatformVersion) -> u64 { + platform_version + .drive_abci + .validation_and_processing + .event_constants + .shielded_implicit_fee_cap + } + + /// `surplus == cap` with `surplus_output == None` is ACCEPTED (the surplus is implicitly + /// donated to the fee pools, exactly at the cap). + #[test] + fn test_surplus_equal_to_cap_is_accepted() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let cap = implicit_fee_cap(platform_version); + let case = build_boundary_case(cap, platform_version); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = + create_asset_lock_proof_with_key_and_amount(&mut rng, case.lock_amount_duffs); + + let transition = create_signed_shield_from_asset_lock_transition_no_surplus( + asset_lock_proof, + &asset_lock_pk, + case.actions, + case.shield_amount, + case.anchor, + case.proof, + case.binding_signature, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + /// `surplus == cap + 1` with `surplus_output == None` is REJECTED with + /// `ShieldedImplicitFeeCapExceededError`. + #[test] + fn test_surplus_one_over_cap_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let cap = implicit_fee_cap(platform_version); + let case = build_boundary_case(cap + 1, platform_version); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = + create_asset_lock_proof_with_key_and_amount(&mut rng, case.lock_amount_duffs); + + let transition = create_signed_shield_from_asset_lock_transition_no_surplus( + asset_lock_proof, + &asset_lock_pk, + case.actions, + case.shield_amount, + case.anchor, + case.proof, + case.binding_signature, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // The cap check now runs at Step 7b, BEFORE proof verification, so the over-cap + // transition is rejected there as an UnpaidConsensusError (the cap check precedes both + // the proof and action construction, so no penalty is applied to the asset lock — the + // proof is never even verified). + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedImplicitFeeCapExceededError(_)) + )] + ); + } + } + + // ========================================== + // CREDIT CONSERVATION TESTS (block-level sum-tree balance) + // ========================================== + + /// Block-level credit-conservation regression tests for the `ShieldFromAssetLock` SURPLUS path. + /// + /// The other surplus tests in this file drive the transition through `process_transition`, which + /// runs only `process_raw_state_transitions` — they never reach + /// `process_block_fees_and_validate_sum_trees`, the end-of-block credit-conservation check whose + /// `CorruptedCreditsNotBalanced` failure is the chain-halt this whole PR exists to prevent. These + /// tests instead run the FULL block pipeline via `process_state_transitions`, which calls + /// `process_block_fees_and_validate_sum_trees`; with `verify_sum_trees` enabled (the default), + /// the helper's `.expect("expected to process block fees")` panics if the consumed asset-lock + /// value, the shielded-pool credit, the surplus-address credit, and the fee-pool credit do not + /// sum-tree-balance — so a passing test is a genuine block-level proof of conservation. + /// + /// A `ShieldFromAssetLock` is NOT credit-neutral the way a transparent `Shield` is: it INJECTS + /// new system credits (the consumed asset-lock value) and distributes them across the shielded + /// pool (`shield_amount`), the surplus-output address (`surplus_amount`, when set), and the fee + /// pools (`pool_fee`, plus any surplus folded in when no `surplus_output` is set). The invariant + /// is therefore: `total_credits_in_platform` rises by exactly the consumed lock value, which must + /// equal `shield_amount + surplus_amount + pool_fee`, and the sum trees stay balanced. + mod credit_conservation { + use super::*; + use crate::execution::validation::state_transition::tests::process_state_transitions; + use dpp::balances::credits::CREDITS_PER_DUFF; + use dpp::block::block_info::BlockInfo; + use dpp::shielded::compute_minimum_shielded_fee; + use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition as SfalTransition; + use dpp::state_transition::StateTransitionEstimatedFeeValidation; + + /// The default `instant_asset_lock_proof_fixture` funds a single 1-DASH credit output. + const DEFAULT_FIXTURE_LOCK_DUFFS: u64 = 100_000_000; + + /// Compute the flat `pool_fee` (`compute_minimum_shielded_fee(num_actions) + albc`) exactly + /// as `transform_into_action` does, so the conservation assertions use the production fee. + fn pool_fee_for_actions(num_actions: usize, platform_version: &PlatformVersion) -> u64 { + let shielded_fee = compute_minimum_shielded_fee(num_actions, platform_version) + .expect("should compute minimum shielded fee"); + let albc = SfalTransition::V0(ShieldFromAssetLockTransitionV0 { + asset_lock_proof: instant_asset_lock_proof_fixture(None, None), + actions: vec![create_dummy_serialized_action()], + value_balance: 1, + anchor: [1u8; 32], + proof: vec![0u8; 1], + binding_signature: [0u8; 64], + surplus_output: None, + signature: Default::default(), + }) + .calculate_min_required_fee(platform_version) + .expect("should compute asset-lock base cost"); + shielded_fee.checked_add(albc).expect("pool fee overflow") + } + + /// Build a real, valid single-output Orchard shield bundle of `shield_value` credits. + fn build_valid_shield_bundle( + shield_value: u64, + ) -> (Vec, u64, [u8; 32], Vec, [u8; 64]) { + let mut orchard_rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + Anchor::empty_tree(), + ); + builder + .add_output( + None, + recipient, + NoteValue::from_raw(shield_value), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut orchard_rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut orchard_rng).unwrap(); + let bundle = proven.apply_signatures(orchard_rng, sighash, &[]).unwrap(); + + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle_with_flags(&bundle); + assert!(value_balance < 0, "shield value_balance must be negative"); + let shield_amount = (-value_balance) as u64; + assert_eq!( + shield_amount, shield_value, + "the bundle must shield exactly the requested value" + ); + ( + actions, + shield_amount, + anchor_bytes, + proof_bytes, + binding_sig, + ) + } + + /// SURPLUS-TO-ADDRESS path: a `ShieldFromAssetLock` with `surplus_output: Some(addr)` and a + /// non-zero surplus must (a) keep all credits sum-tree balanced through the full block + /// pipeline, (b) raise the shielded pool by exactly `shield_amount`, (c) raise the surplus + /// address balance by exactly `surplus_amount`, and (d) raise `total_credits_in_platform` by + /// exactly the consumed asset-lock value (`= shield_amount + surplus_amount + pool_fee`). + #[tokio::test] + async fn test_shield_from_asset_lock_with_surplus_output_conserves_credits() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // Platform starts balanced; this is the production invariant the block-end check enforces. + let credits_before = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("should calculate total credits before"); + assert!( + credits_before + .ok() + .expect("credit balance check should not overflow"), + "credits must be balanced before the shield_from_asset_lock: {credits_before}" + ); + + // Build a real valid bundle (shield 5000) over the default 1-DASH fixture lock. The + // surplus (≈1 DASH minus the small shield + fee) far exceeds the implicit-fee cap, so an + // explicit `surplus_output` is required — exactly what this builder sets. + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + + let shield_value = 5_000u64; + let (actions, shield_amount, anchor_bytes, proof_bytes, binding_sig) = + build_valid_shield_bundle(shield_value); + + // Production fee + the consumed lock value, so we can pin the surplus exactly. + let pool_fee = pool_fee_for_actions(actions.len(), platform_version); + let consumed = DEFAULT_FIXTURE_LOCK_DUFFS * CREDITS_PER_DUFF; + let surplus_amount = consumed + .checked_sub(shield_amount) + .and_then(|v| v.checked_sub(pool_fee)) + .expect("surplus must be non-negative"); + assert!( + surplus_amount > 0, + "this test must exercise a non-zero surplus routed to the address" + ); + + let transition = create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + actions, + shield_amount, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + // --- Run the FULL block pipeline (execute + distribute fees + validate sum trees). + // `process_state_transitions` calls `process_block_fees_and_validate_sum_trees`; with + // `verify_sum_trees` enabled (the default) its `.expect(...)` panics here on a + // `CorruptedCreditsNotBalanced` — the block-level conservation assertion. --- + let platform_state = platform.state.load(); + let (_fee_results, _processed_block_fees) = process_state_transitions( + &platform, + &[transition], + BlockInfo::default(), + &platform_state, + ); + + let credits_after = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("should calculate total credits after"); + + // (a) Credits remain sum-tree balanced (the invariant whose failure halts the chain). + assert!( + credits_after + .ok() + .expect("credit balance check should not overflow"), + "credits must remain balanced after shield_from_asset_lock with surplus output: \ + {credits_after}" + ); + + // (b) The shielded pool rose by exactly `shield_amount`. + assert_eq!( + credits_after.total_in_shielded_balances + - credits_before.total_in_shielded_balances, + shield_amount as i64, + "shielded pool must gain exactly the shield amount" + ); + + // (c) The surplus-output address balance rose by exactly `surplus_amount`. The fresh + // platform has no other addresses, so the address-tree delta equals the surplus + // credited to the signed surplus-output address (`P2pkh([0x33; 20])`). + assert_eq!( + credits_after.total_in_addresses - credits_before.total_in_addresses, + surplus_amount as i64, + "the surplus_output address must gain exactly the surplus amount" + ); + + // (d) `total_credits_in_platform` rose by exactly the consumed asset-lock value, which + // equals `shield_amount + surplus_amount + pool_fee`. + assert_eq!( + credits_after.total_credits_in_platform - credits_before.total_credits_in_platform, + consumed, + "platform total must rise by exactly the consumed asset-lock value" + ); + assert_eq!( + consumed, + shield_amount + surplus_amount + pool_fee, + "consumed lock value must split exactly into shield + surplus + pool fee" + ); + } + + /// NO-SURPLUS-OUTPUT path: a `ShieldFromAssetLock` with `surplus_output: None` folds the + /// surplus into the fee pools (allowed only up to the implicit-fee cap). It must keep credits + /// sum-tree balanced through the full block pipeline, raise the shielded pool by exactly + /// `shield_amount`, create NO address balance, and raise `total_credits_in_platform` by + /// exactly the consumed asset-lock value. + #[tokio::test] + async fn test_shield_from_asset_lock_without_surplus_output_conserves_credits() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let credits_before = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("should calculate total credits before"); + assert!( + credits_before + .ok() + .expect("credit balance check should not overflow"), + "credits must be balanced before the shield_from_asset_lock: {credits_before}" + ); + + // Size the lock so the surplus lands exactly on the implicit-fee cap — the largest + // surplus the no-`surplus_output` path accepts. Build the bundle, then derive the funding. + let cap = platform_version + .drive_abci + .validation_and_processing + .event_constants + .shielded_implicit_fee_cap; + + // Probe to learn the action count → pool_fee, then choose `shield_value` so the required + // lock value is a whole number of duffs (the fixture quantises funding to duffs). + const SHIELD_VALUE_BASE: u64 = 1_000_000; + let (probe_actions, _, _, _, _) = build_valid_shield_bundle(SHIELD_VALUE_BASE); + let pool_fee = pool_fee_for_actions(probe_actions.len(), platform_version); + let correction = (CREDITS_PER_DUFF + - ((cap + SHIELD_VALUE_BASE + pool_fee) % CREDITS_PER_DUFF)) + % CREDITS_PER_DUFF; + let shield_value = SHIELD_VALUE_BASE + correction; + + let (actions, shield_amount, anchor_bytes, proof_bytes, binding_sig) = + build_valid_shield_bundle(shield_value); + + let consumed = cap + shield_amount + pool_fee; + assert_eq!( + consumed % CREDITS_PER_DUFF, + 0, + "lock value must be a whole number of duffs" + ); + let lock_amount_duffs = consumed / CREDITS_PER_DUFF; + // The surplus the transform computes lands exactly on the cap by construction. + assert_eq!(consumed - shield_amount - pool_fee, cap); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = + create_asset_lock_proof_with_key_and_amount(&mut rng, lock_amount_duffs); + + let transition = create_signed_shield_from_asset_lock_transition_no_surplus( + asset_lock_proof, + &asset_lock_pk, + actions, + shield_amount, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let platform_state = platform.state.load(); + let (_fee_results, _processed_block_fees) = process_state_transitions( + &platform, + &[transition], + BlockInfo::default(), + &platform_state, + ); + + let credits_after = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("should calculate total credits after"); + + // Credits remain sum-tree balanced. + assert!( + credits_after + .ok() + .expect("credit balance check should not overflow"), + "credits must remain balanced after shield_from_asset_lock without surplus output: \ + {credits_after}" + ); + + // The shielded pool rose by exactly `shield_amount`. + assert_eq!( + credits_after.total_in_shielded_balances + - credits_before.total_in_shielded_balances, + shield_amount as i64, + "shielded pool must gain exactly the shield amount" + ); + + // NO address balance was created for the (absent) surplus — it folded into the fee pools. + assert_eq!( + credits_after.total_in_addresses - credits_before.total_in_addresses, + 0, + "no surplus_output means no address balance is created" + ); + + // `total_credits_in_platform` rose by exactly the consumed asset-lock value. + assert_eq!( + credits_after.total_credits_in_platform - credits_before.total_credits_in_platform, + consumed, + "platform total must rise by exactly the consumed asset-lock value" + ); } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/v0/mod.rs index 64bcb80c0dc..79f9afd4f95 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/v0/mod.rs @@ -15,14 +15,15 @@ use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::rpc::core::CoreRPCLike; use dpp::asset_lock::reduced_asset_lock_value::{AssetLockValue, AssetLockValueGettersV0}; -use dpp::block::block_info::BlockInfo; use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointNotEnoughBalanceError; +use dpp::consensus::basic::state_transition::ShieldedImplicitFeeCapExceededError; use dpp::consensus::signature::{BasicECDSAError, SignatureError}; use dpp::consensus::state::state_error::StateError; use dpp::dashcore::hashes::Hash; use dpp::dashcore::{signer, ScriptBuf, Txid}; use dpp::fee::Credits; +use dpp::shielded::compute_minimum_shielded_fee; use dpp::identity::state_transition::AssetLockProved; use dpp::identity::KeyType; use dpp::platform_value::{Bytes32, Bytes36}; @@ -30,7 +31,6 @@ use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; use dpp::state_transition::signable_bytes_hasher::SignableBytesHasher; use dpp::state_transition::{StateTransitionEstimatedFeeValidation, StateTransitionSingleSigned}; -use drive::drive::Drive; use drive::grovedb::TransactionArg; use drive::state_transition_action::shielded::shield_from_asset_lock::ShieldFromAssetLockTransitionAction; use drive::state_transition_action::system::partially_use_asset_lock_action::PartiallyUseAssetLockActionV0; @@ -44,7 +44,6 @@ pub(in crate::execution::validation::state_transition::state_transitions::shield platform: &PlatformRef, signable_bytes: Vec, validation_mode: ValidationMode, - block_info: &BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -58,7 +57,6 @@ impl ShieldFromAssetLockStateTransitionTransformIntoActionValidationV0 platform: &PlatformRef, signable_bytes: Vec, validation_mode: ValidationMode, - block_info: &BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -69,9 +67,36 @@ impl ShieldFromAssetLockStateTransitionTransformIntoActionValidationV0 ShieldFromAssetLockTransition::V0(v0) => v0.value_balance, }; - // Step 3: Calculate minimum required fee from platform_version + // Step 3: Calculate minimum required fee from platform_version. + // `required_balance` (= asset_lock_base_cost, `albc`) is the L1 asset-lock-proof viability + // floor, reused unchanged in Step 4's `validate(...)` call below. let required_balance = self.calculate_min_required_fee(platform_version)?; + // Step 3b: Compute the flat fee charged to the fee pools. ShieldFromAssetLock does strictly + // more work than a transparent shield, so it pays BOTH the shielded operation cost and the + // L1 asset-lock processing cost, mirroring the established asset-lock fee composition + // (operation base cost + asset_lock_base_cost): + // + // pool_fee = compute_minimum_shielded_fee(num_actions) [Halo2 proof + per-action] + // + albc [asset-lock processing] + // + // Set outside GroveDB; booked at the execution-event layer (PaidFromAssetLockToPool). + let albc = required_balance; + let num_actions = match self { + ShieldFromAssetLockTransition::V0(v0) => v0.actions.len(), + }; + let shielded_fee = compute_minimum_shielded_fee(num_actions, platform_version)?; + let pool_fee = + shielded_fee + .checked_add(albc) + .ok_or(Error::Execution(ExecutionError::Overflow( + "shielded fee + asset_lock_base_cost overflow in shield_from_asset_lock", + )))?; + // The asset lock must cover `shield_amount + pool_fee`, so the surplus is always >= 0. + let required_lock_value = shield_amount.checked_add(pool_fee).ok_or(Error::Execution( + ExecutionError::Overflow("shield_amount + pool_fee overflow in shield_from_asset_lock"), + ))?; + let signable_bytes_len = signable_bytes.len(); let mut signable_bytes_hasher = SignableBytesHasher::Bytes(signable_bytes); @@ -123,8 +148,9 @@ impl ShieldFromAssetLockStateTransitionTransformIntoActionValidationV0 let tx_out_credit_value = tx_out.value.saturating_mul(CREDITS_PER_DUFF); - // Verify locked amount >= shield_amount + min_fee - let required_total = shield_amount.saturating_add(required_balance); + // Verify locked amount >= shield_amount + pool_fee (so the surplus is >= 0). This is an + // early reject; Step 7 re-applies the same floor authoritatively on every path. + let required_total = required_lock_value; if tx_out_credit_value < required_total { let asset_lock_proof = AssetLockProved::asset_lock_proof(self); return Ok(ConsensusValidationResult::new_with_error( @@ -188,9 +214,12 @@ impl ShieldFromAssetLockStateTransitionTransformIntoActionValidationV0 } } - // Step 7: Also check that the remaining asset lock balance covers shield_amount + // Step 7: The remaining asset-lock balance must cover `shield_amount + pool_fee`. + // This is the AUTHORITATIVE funding floor: the fresh-fetch, cached `AssetLockValue`, and + // recheck paths all converge here, so a lock that under-funds the fee can never reach the + // action (which would mint credits at booking). It also guarantees `surplus >= 0` below. let remaining_credit_value = asset_lock_value_to_be_consumed.remaining_credit_value(); - if remaining_credit_value < shield_amount { + if remaining_credit_value < required_lock_value { let asset_lock_proof = AssetLockProved::asset_lock_proof(self); return Ok(ConsensusValidationResult::new_with_error( IdentityAssetLockTransactionOutPointNotEnoughBalanceError::new( @@ -201,28 +230,59 @@ impl ShieldFromAssetLockStateTransitionTransformIntoActionValidationV0 asset_lock_proof.output_index() as usize, remaining_credit_value, remaining_credit_value, - shield_amount, + required_lock_value, ) .into(), )); } - // Step 8: Read current shielded pool total balance from GroveDB + // Step 7b: Compute the surplus and reject an over-cap implicit donation BEFORE the expensive + // Orchard proof verification (Step 9). The surplus is fully derivable here — it depends only + // on the (now-validated) `remaining_credit_value`, the `shield_amount`, the flat `pool_fee`, + // and whether a `surplus_output` is set — none of which depend on the ZK proof or the pool + // read. Rejecting an over-cap transition here avoids ~100ms of Halo2 verification for a + // transition that can never succeed. + // + // Distribute the fully-consumed lock: + // shield_amount -> shielded pool + // pool_fee -> fee pools + // surplus -> surplus_output address (when set), else folded into the fee pools. + // `surplus >= 0` is guaranteed by the Step 7 floor; `checked_sub` is a defensive guard. + let surplus = remaining_credit_value + .checked_sub(shield_amount) + .and_then(|v| v.checked_sub(pool_fee)) + .ok_or(Error::Execution(ExecutionError::Overflow( + "asset lock value underflow computing shield_from_asset_lock surplus (should be guarded by the Step 7 funding floor)", + )))?; + + let surplus_output = match self { + ShieldFromAssetLockTransition::V0(v0) => &v0.surplus_output, + }; + + // When no surplus_output is set, the surplus is donated to the fee pools — but only up to + // `shielded_implicit_fee_cap`, so a client cannot accidentally forfeit a large remainder. + if surplus_output.is_none() { + let implicit_fee_cap = platform_version + .drive_abci + .validation_and_processing + .event_constants + .shielded_implicit_fee_cap; + if surplus > implicit_fee_cap { + return Ok(ConsensusValidationResult::new_with_error( + ShieldedImplicitFeeCapExceededError::new(surplus, implicit_fee_cap).into(), + )); + } + } + + // Step 8: Read current shielded pool total balance from GroveDB. + // + // ShieldFromAssetLock pays the flat `pool_fee` (computed in Step 3b) at the execution-event + // layer (PaidFromAssetLockToPool). We do NOT derive a GroveDB fee from these read operations + // — the flat fee subsumes them. let mut drive_operations = vec![]; let current_total_balance = read_pool_total_balance(platform.drive, tx, &mut drive_operations, platform_version)?; - // Calculate fees from the GroveDB operations - let fee = Drive::calculate_fee( - None, - Some(drive_operations), - &block_info.epoch, - platform.drive.config.epochs_per_era, - platform_version, - None, - )?; - execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); - // Step 9: Verify Orchard ZK proof via reconstruct_and_verify_bundle() // Use EMPTY extra_sighash_data -- no transparent binding needed since // the asset lock proof authenticates the source of funds. @@ -305,6 +365,11 @@ impl ShieldFromAssetLockStateTransitionTransformIntoActionValidationV0 let asset_lock_value_credits = asset_lock_value_to_be_consumed.remaining_credit_value(); let signable_bytes_hash: [u8; 32] = signable_bytes_hasher.into_hashed_bytes().0; + // The action routes `surplus_amount` to `surplus_output` (when set); otherwise 0 and the + // surplus folds into the fee pools at the execution event. The surplus and the implicit-fee + // cap were already computed and enforced in Step 7b (before proof verification). + let surplus_amount = if surplus_output.is_some() { surplus } else { 0 }; + let result = ShieldFromAssetLockTransitionAction::try_from_transition( self, asset_lock_outpoint.into(), @@ -312,6 +377,7 @@ impl ShieldFromAssetLockStateTransitionTransformIntoActionValidationV0 signable_bytes_hash, shield_amount, current_total_balance, + surplus_amount, ); Ok(result.map(|action| action.into())) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/tests.rs index d20e4db4dae..7d0ecbba154 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/tests.rs @@ -682,6 +682,146 @@ mod tests { )] ); } + + /// Builds a REAL spend+output Orchard bundle whose output policy mirrors the dpp SDK + /// builder `build_shielded_transfer_transition`: `num_spends` spends, then 1 recipient + /// output and (when there is change left over) 1 change output — i.e. at most 2 outputs. + /// Returns the serialized on-wire actions plus the fee that bundle's `value_balance` + /// encodes. + /// + /// The total spent is sized so `value_balance == compute_minimum_shielded_fee(num_spends + /// .max(2))` exactly, and a positive change output is emitted for `num_spends >= 2` (so the + /// `> 2 spends` case exercises the change-output branch too) — the same shape the SDK + /// builder produces. + fn build_transfer_like_bundle(num_spends: usize) -> (Vec, u64) { + assert!(num_spends >= 1, "need at least one spend"); + let platform_version = PlatformVersion::latest(); + + // The SDK builder carves the fee from spends.len().max(2), BEFORE Orchard padding. + let carved_actions = num_spends.max(2); + let fee = dpp::shielded::compute_minimum_shielded_fee(carved_actions, platform_version) + .expect("fee computation"); + + let mut rng = StdRng::seed_from_u64(0); + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + // Each note holds a generous, equal value so the bundle is value-balanced. + let value_each = 200_000_000u64; + let total_spent = value_each * num_spends as u64; + + // Build every note, append every commitment into ONE tree (single shared anchor — + // the builder uses exactly one anchor), then witness each note at its position. + let notes: Vec = (0..num_spends) + .map(|i| { + let mut rho_bytes = [0u8; 32]; + rho_bytes[0] = (i as u8).wrapping_add(1); // distinct, non-zero, valid pallas + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + Note::from_parts(recipient, NoteValue::from_raw(value_each), rho, rseed) + .unwrap() + }) + .collect(); + + let mut tree = ClientMemoryCommitmentTree::new(100); + for note in ¬es { + let cmx = ExtractedNoteCommitment::from(note.commitment()); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + } + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + for (i, note) in notes.into_iter().enumerate() { + let merkle_path: MerklePath = + tree.witness(Position::from(i as u64), 0).unwrap().unwrap(); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + } + + // Mirror the SDK builder's output policy: 1 recipient output + optional 1 change. + // recipient_amount + fee + change == total_spent. Send a small recipient amount and + // route the rest to change (positive whenever num_spends >= 2 with these values). + let recipient_amount = 1_000u64; + let change_amount = total_spent - recipient_amount - fee; + builder + .add_output( + None, + recipient, + NoteValue::from_raw(recipient_amount), + [0u8; 36], + ) + .unwrap(); + if change_amount > 0 { + builder + .add_output( + None, + recipient, + NoteValue::from_raw(change_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, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + let (actions, value_balance, _anchor, _proof, _binding) = + serialize_authorized_bundle_u64(&bundle); + (actions, value_balance) + } + + /// Pins the dpp SDK builder's fee-vs-actions coupling end-to-end. + /// + /// The SDK builder `build_shielded_transfer_transition` carves the fee from + /// `num_actions = spends.len().max(2)` BEFORE Orchard padding, then emits 1 recipient + /// output + (optionally) 1 change output. Consensus prices the shielded fee from the + /// on-wire `actions.len()` and pins `value_balance == compute_minimum_shielded_fee(actions + /// .len())` EXACTLY. The carved-fee count and the on-wire count agree ONLY because the + /// builder emits at most 2 outputs, so `max(spends, outputs, 2) == max(spends, 2)`. + /// + /// We build REAL bundles whose output policy mirrors the SDK builder (1 recipient + optional + /// 1 change) for spend counts {1, 3} and assert the serialized `actions.len()` equals + /// `spends.len().max(2)`, and that the bundle's `value_balance` equals + /// `compute_minimum_shielded_fee(actions.len())`. If a future change makes the carved-fee + /// action count diverge from the on-wire action count (e.g. a 3rd output added with <=2 + /// spends), this fails loudly — without it, every such transfer would be silently rejected by + /// consensus. This is the spend-side analogue of the output-only Shield invariant pinned by + /// `dpp`'s `test_output_only_bundle_serializes_to_min_actions`. + #[test] + fn test_builder_output_policy_actions_match_carved_fee_count() { + let platform_version = PlatformVersion::latest(); + + // 1 spend -> on-wire actions padded to 2 (Orchard MIN_ACTIONS), fee carved for 2. + // 3 spends -> on-wire actions = 3, fee carved for 3. + for (num_spends, expected_actions) in [(1usize, 2usize), (3, 3)] { + let (actions, value_balance) = build_transfer_like_bundle(num_spends); + + assert_eq!( + actions.len(), + expected_actions, + "on-wire actions.len() ({}) must equal spends.len().max(2) = {expected_actions} \ + for {num_spends} spends; the SDK builder carves the fee for {expected_actions} \ + actions and consensus pins value_balance to exactly \ + compute_minimum_shielded_fee(actions.len())", + actions.len() + ); + + let expected_fee = + dpp::shielded::compute_minimum_shielded_fee(expected_actions, platform_version) + .expect("fee computation"); + assert_eq!( + value_balance, expected_fee, + "value_balance must equal compute_minimum_shielded_fee(on-wire actions.len())" + ); + } + } } // ========================================== diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs index 24662b55a6b..2f978377c2e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs @@ -59,14 +59,23 @@ mod tests { /// Shorthand for creating a structurally valid (but cryptographically invalid) shielded /// withdrawal transition. Has a non-zero anchor, valid field sizes, positive unshielding_amount. fn create_default_shielded_withdrawal_transition() -> StateTransition { + // unshielding_amount = the 1-action ShieldedWithdrawal fee (base + flat withdrawal-document + // storage cost) + the min_withdrawal_amount floor for the net. Sizing it from the live fee + // function keeps the gross above the fee gate's minimum so these state-validation tests + // (anchor/nullifier/proof) reach the stage they intend to exercise instead of short-circuiting + // on InsufficientShieldedFeeError, and stays correct if the fee constants change. + let platform_version = PlatformVersion::latest(); + let fee = dpp::shielded::compute_shielded_withdrawal_fee(1, platform_version) + .expect("fee computation should not overflow"); + let unshielding_amount = fee + platform_version.system_limits.min_withdrawal_amount; create_shielded_withdrawal_transition( vec![create_dummy_serialized_action()], - 131_548_800, // unshielding_amount: recipient (1_000_000, at the v12 min_withdrawal_amount floor) + min fee for 1 action - [42u8; 32], // non-zero anchor - vec![0u8; 100], // dummy proof bytes - [0u8; 64], // dummy binding signature - 1, // core_fee_per_byte - Pooling::Never, // pooling strategy + unshielding_amount, + [42u8; 32], // non-zero anchor + vec![0u8; 100], // dummy proof bytes + [0u8; 64], // dummy binding signature + 1, // core_fee_per_byte + Pooling::Never, // pooling strategy create_output_script(), // P2PKH output script ) } @@ -855,9 +864,17 @@ mod tests { let mut action2 = create_dummy_serialized_action(); action2.cmx = [99u8; 32]; // Different commitment but same nullifier + // unshielding_amount = the 2-action ShieldedWithdrawal fee (base + flat + // withdrawal-document storage cost) + the min_withdrawal_amount floor for the net, so the + // gross clears the fee gate and the flow reaches the (earlier) proof-verification stage + // this test exercises rather than short-circuiting on InsufficientShieldedFeeError. + let fee = dpp::shielded::compute_shielded_withdrawal_fee(2, platform_version) + .expect("fee computation should not overflow"); + let unshielding_amount = fee + platform_version.system_limits.min_withdrawal_amount; + let transition = create_shielded_withdrawal_transition( vec![action1, action2], // Both have nullifier [1u8; 32] - 162_097_600, // unshielding_amount: recipient (1_000_000, at the v12 min_withdrawal_amount floor) + min fee for 2 actions + unshielding_amount, anchor, vec![0u8; 100], [0u8; 64], @@ -1244,7 +1261,7 @@ mod tests { // the carved minimum shielded fee. let fee = &fee_results[0]; let expected_total = - dpp::shielded::compute_minimum_shielded_fee(num_actions, platform_version) + dpp::shielded::compute_shielded_withdrawal_fee(num_actions, platform_version) .expect("fee computation should not overflow"); assert!( fee.storage_fee > 0, @@ -1284,7 +1301,7 @@ mod tests { // The system-credit counter dropped by the NET (unshielding_amount - fee): // only the net leaves the platform to Core; the fee stays in the fee pools. - let fee = dpp::shielded::compute_minimum_shielded_fee(num_actions, platform_version) + let fee = dpp::shielded::compute_shielded_withdrawal_fee(num_actions, platform_version) .expect("fee computation should not overflow"); assert_eq!( credits_before.total_credits_in_platform - credits_after.total_credits_in_platform, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/transform_into_action/v0/mod.rs index 31f4860ed0c..4a90c4c4191 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/transform_into_action/v0/mod.rs @@ -110,22 +110,26 @@ impl ShieldedWithdrawalStateTransitionTransformIntoActionValidationV0 } // Shielded transitions do NOT meter the GroveDB operation cost as a fee. They - // pay a flat, client-predictable fee (`compute_minimum_shielded_fee`, computed + // pay a flat, client-predictable fee (`compute_shielded_withdrawal_fee`, computed // below): the client must know the exact fee offline to build its proof and // cannot run `Drive::calculate_fee` (which needs server-side state). The flat fee // subsumes these validation reads, so the cost accumulated in `drive_operations` // is intentionally not charged — `PaidFromShieldedPool` carves the fee straight // from the pool and never consumes the execution context. - // The fee charged to the shielded pool is the minimum shielded fee computed from the - // same `num_actions` that `validate_minimum_shielded_fee` enforced the net range - // against. Because that check passed, the net amount withdrawn to Core - // (`unshielding_amount - fee_amount`) is guaranteed to fall within - // `[MIN_WITHDRAWAL_AMOUNT, max_withdrawal_amount]` for ShieldedWithdrawal; the action - // transformer re-checks that same range with `checked_sub` as defense in depth, so - // those rejection paths are unreachable for validated input. + // The fee charged to the shielded pool is the ShieldedWithdrawal fee computed from the + // same `num_actions` that `validate_minimum_shielded_fee` enforced the net range against. + // Unlike the base `compute_minimum_shielded_fee`, this fee ALSO includes the flat storage + // cost of the Core withdrawal document this transition inserts (`AddWithdrawalDocument` — + // a real document write into the withdrawals contract plus its index entries), so the + // booking covers that write instead of diverting the proposer's processing reward to it. + // Because the validation gate passed using this same `compute_shielded_withdrawal_fee`, + // the net amount withdrawn to Core (`unshielding_amount - fee_amount`) is guaranteed to + // fall within `[MIN_WITHDRAWAL_AMOUNT, max_withdrawal_amount]`; the action transformer + // re-checks that same range with `checked_sub` as defense in depth, so those rejection + // paths are unreachable for validated input. let fee_amount = - dpp::shielded::compute_minimum_shielded_fee(num_actions, platform_version)?; + dpp::shielded::compute_shielded_withdrawal_fee(num_actions, platform_version)?; // Build the action, which includes creating the withdrawal document let creation_time_ms = block_info.time_ms; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs index 018df60a5cb..3fe96a5e19b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs @@ -57,10 +57,13 @@ mod tests { create_unshield_transition( create_output_address(), vec![create_dummy_serialized_action()], - 130_549_800, // unshielding_amount: recipient amount + minimum fee for 1 action - [42u8; 32], // non-zero anchor + // unshielding_amount: recipient amount + unshield fee for 1 action. The fee gate runs + // before proof verification, so this must clear `compute_shielded_unshield_fee(1)` + // (136,631,600) for these tests to reach the proof-verification stage they assert on. + 136_632_600, + [42u8; 32], // non-zero anchor vec![0u8; 100], // dummy proof bytes - [0u8; 64], // dummy binding signature + [0u8; 64], // dummy binding signature ) } @@ -313,6 +316,80 @@ mod tests { } } + // ========================================== + // FEE VALIDATION TESTS (InsufficientShieldedFeeError) + // ========================================== + // + // The Unshield fee gate (`validate_minimum_shielded_fee`) enforces + // `unshielding_amount >= compute_shielded_unshield_fee(num_actions)`, NOT the base + // `compute_minimum_shielded_fee(num_actions)`. The unshield fee is the base PLUS the flat + // 222-byte `AddBalanceToAddress` output-write storage cost, so there is a non-empty half-open + // range `[compute_minimum_shielded_fee(n), compute_shielded_unshield_fee(n))` of amounts that + // cover the base but NOT the unshield fee. Any amount in that range must be rejected with + // `InsufficientShieldedFeeError` — this pins that the gate uses `compute_shielded_unshield_fee`. + + mod fee_validation { + use super::*; + + /// An `unshielding_amount` strictly inside `[base_fee, unshield_fee)` — enough for the BASE + /// shielded fee but NOT the Unshield fee (which adds the flat 222-byte address-write cost) — + /// must be rejected with `InsufficientShieldedFeeError`. This is the boundary that proves the + /// gate uses `compute_shielded_unshield_fee`, not `compute_minimum_shielded_fee`. + /// + /// Mirrors the equivalent ShieldedTransfer (Base) and ShieldedWithdrawal boundary tests. + #[test] + fn test_fee_meets_base_but_below_unshield_fee_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // One action, matching `create_dummy_serialized_action()` below. + let num_actions = 1usize; + let base_fee = + dpp::shielded::compute_minimum_shielded_fee(num_actions, platform_version) + .expect("base shielded fee should not overflow"); + let unshield_fee = + dpp::shielded::compute_shielded_unshield_fee(num_actions, platform_version) + .expect("unshield fee should not overflow"); + + // The unshield fee MUST strictly exceed the base (the flat 222-byte address-write cost), + // otherwise the half-open range this test exercises would be empty. + assert!( + unshield_fee > base_fee, + "unshield fee ({unshield_fee}) must exceed the base fee ({base_fee}) so the \ + [base, unshield) range is non-empty" + ); + + // Pick an amount strictly inside `[base_fee, unshield_fee)`: it clears the base fee but + // falls one credit short of the Unshield fee. If the gate (incorrectly) used the base + // fee, this would pass; because it uses `compute_shielded_unshield_fee`, it is rejected. + let unshielding_amount = unshield_fee - 1; + assert!( + unshielding_amount >= base_fee, + "the chosen amount must still cover the base fee" + ); + + let transition = create_unshield_transition( + create_output_address(), + vec![create_dummy_serialized_action()], + unshielding_amount, + [42u8; 32], // non-zero anchor (structurally valid) + vec![0u8; 100], // dummy proof bytes (fee gate runs before proof verification) + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // The fee gate runs before proof verification, so the insufficient fee is caught here + // (not the dummy proof). + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InsufficientShieldedFeeError(_)) + )] + ); + } + } + // ========================================== // ZK PROOF VERIFICATION TESTS (InvalidShieldedProofError) // ========================================== @@ -458,7 +535,9 @@ mod tests { let transition = create_unshield_transition( create_output_address(), vec![bad_action], - 130_549_800, // unshielding_amount: recipient amount + minimum fee for 1 action + // unshielding_amount is not load-bearing here: the bad 100-byte encrypted note + // fails basic structure validation, which runs before the fee gate. + 136_632_600, anchor, vec![0u8; 100], [0u8; 64], @@ -667,7 +746,11 @@ mod tests { let transition = create_unshield_transition( create_output_address(), vec![action1, action2], // Both have nullifier [1u8; 32] - 161_098_600, // unshielding_amount: recipient amount + minimum fee for 2 actions + // unshielding_amount: recipient amount + unshield fee for 2 actions. The fee gate + // runs before proof verification, so this must clear + // `compute_shielded_unshield_fee(2)` (167,180,400) for this test to reach the + // proof-verification stage it asserts on. + 167_181_400, anchor, vec![0u8; 100], [0u8; 64], diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/transform_into_action/v0/mod.rs index 9b5fd326360..7096af5f51a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/transform_into_action/v0/mod.rs @@ -83,7 +83,7 @@ impl UnshieldStateTransitionTransformIntoActionValidationV0 for UnshieldTransiti } // Shielded transitions do NOT meter the GroveDB operation cost as a fee. They - // pay a flat, client-predictable fee (`compute_minimum_shielded_fee`, computed + // pay a flat, client-predictable fee (`compute_shielded_unshield_fee`, computed // below): the client must know the exact fee offline to build its proof and // cannot run `Drive::calculate_fee` (which needs server-side state). The flat fee // subsumes these validation reads, so the cost accumulated in `drive_operations` @@ -109,12 +109,16 @@ impl UnshieldStateTransitionTransformIntoActionValidationV0 for UnshieldTransiti )); } - // The fee charged to the shielded pool is the minimum shielded fee computed - // from the same `num_actions` that `validate_minimum_shielded_fee` enforced - // `unshielding_amount >=` against. Because that check passed, the net recipient - // amount (`unshielding_amount - fee_amount`) is guaranteed to be non-negative. + // The fee charged to the shielded pool is the Unshield fee computed from the same + // `num_actions` that `validate_minimum_shielded_fee` enforced `unshielding_amount >=` + // against. Unlike the base `compute_minimum_shielded_fee`, this fee ALSO includes the flat + // storage cost of the single `AddBalanceToAddress` write this transition performs crediting + // the net to the output platform address, so the booking covers that write instead of + // diverting the proposer's processing reward to it. Because the validation gate passed using + // this same `compute_shielded_unshield_fee`, the net recipient amount + // (`unshielding_amount - fee_amount`) is guaranteed to be non-negative. let fee_amount = - dpp::shielded::compute_minimum_shielded_fee(num_actions, platform_version)?; + dpp::shielded::compute_shielded_unshield_fee(num_actions, platform_version)?; let result = UnshieldTransitionAction::try_from_transition(self, current_total_balance, fee_amount); diff --git a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs index 7139df4bd30..535745056df 100644 --- a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs +++ b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs @@ -389,6 +389,7 @@ impl Drive { } StateTransition::ShieldFromAssetLock(st) => { use dpp::identity::state_transition::AssetLockProved; + use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; let outpoint = st.asset_lock_proof().out_point().ok_or_else(|| { Error::Proof(ProofError::InvalidTransition( @@ -400,10 +401,37 @@ impl Drive { let mut query = grovedb::Query::new(); query.insert_key(outpoint_bytes.to_vec()); - PathQuery::new( + let outpoint_pq = PathQuery::new( vec![vec![RootTree::SpentAssetLockTransactions as u8]], grovedb::SizedQuery::new(query, Some(1), None), - ) + ); + + // No accessor trait exposes `surplus_output`, so read it directly off the V0 body. + let ShieldFromAssetLockTransition::V0(v0) = st; + match &v0.surplus_output { + Some(surplus_address) => { + // Mirror the Unshield arm: also prove the balance of the signed + // surplus-output address so a light client can confirm the surplus + // credit landed there. `PathQuery::merge` rejects sub-queries that carry + // limits, so clear both before merging. The verifier rebuilds this exact + // merged query (same sub-queries, same cleared limits, same merge) and + // verifies it STRICTLY, so the proof cannot carry any extra data beyond + // {outpoint, surplus-address}. + let mut outpoint_pq = outpoint_pq; + outpoint_pq.query.limit = None; + + let mut address_pq = Drive::balances_for_clear_addresses_query( + std::iter::once(surplus_address), + ); + address_pq.query.limit = None; + + PathQuery::merge( + vec![&outpoint_pq, &address_pq], + &platform_version.drive.grove_version, + )? + } + None => outpoint_pq, + } } }; 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 49edadeadf5..368fd137577 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 @@ -3,7 +3,7 @@ use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; use crate::state_transition_action::shielded::shield_from_asset_lock::ShieldFromAssetLockTransitionAction; -use crate::util::batch::drive_op_batch::SystemOperationType; +use crate::util::batch::drive_op_batch::{AddressFundsOperationType, SystemOperationType}; use crate::util::batch::DriveOperation; use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; use dpp::block::epoch::Epoch; @@ -26,7 +26,10 @@ impl DriveHighLevelOperationConverter for ShieldFromAssetLockTransitionAction { ShieldFromAssetLockTransitionAction::V0(v0) => { let mut ops: Vec> = Vec::new(); - // 1. Add credits to system from the asset lock + // 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, @@ -49,10 +52,26 @@ impl DriveHighLevelOperationConverter for ShieldFromAssetLockTransitionAction { }, )); - // 3. Insert notes into CommitmentTree + // 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 notes into CommitmentTree insert_notes(&mut ops, &v0.notes); - // 4. Update total balance + // 5. Update total balance let new_total_balance = v0.current_total_balance .checked_add(v0.shield_amount) @@ -102,6 +121,8 @@ mod tests { shield_amount: 5000, notes: vec![make_note()], current_total_balance: 10000, + surplus_output: None, + surplus_amount: 0, }) } @@ -183,6 +204,8 @@ mod tests { shield_amount: u64::MAX, notes: vec![], current_total_balance: 1, + surplus_output: None, + surplus_amount: 0, }); let epoch = Epoch::new(0).unwrap(); let platform_version = PlatformVersion::latest(); @@ -190,4 +213,75 @@ mod tests { let result = action.into_high_level_drive_operations(&epoch, platform_version); assert!(result.is_err()); } + + #[test] + fn test_routes_surplus_to_address() { + use dpp::address_funds::PlatformAddress; + let surplus_addr = PlatformAddress::P2pkh([0x42; 20]); + let action = + ShieldFromAssetLockTransitionAction::V0(ShieldFromAssetLockTransitionActionV0 { + asset_lock_outpoint: [0xDD; 36], + asset_lock_value_to_be_consumed: 10_000, + signable_bytes_hasher: [0xEE; 32], + shield_amount: 5_000, + notes: vec![make_note()], + current_total_balance: 10_000, + surplus_output: Some(surplus_addr), + surplus_amount: 2_000, + }); + 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 + AddBalanceToAddress + InsertNote + UpdateTotalBalance + assert_eq!(ops.len(), 5); + + // The FULL consumed lock is added to system credits (not just the shield amount). + match &ops[0] { + DriveOperation::SystemOperation(SystemOperationType::AddToSystemCredits { amount }) => { + assert_eq!(*amount, 10_000); + } + other => panic!("expected AddToSystemCredits, got {:?}", other), + } + + // The surplus is routed to the surplus_output address. + let has_surplus_op = ops.iter().any(|op| { + matches!( + op, + DriveOperation::AddressFundsOperation( + AddressFundsOperationType::AddBalanceToAddress { + address, + balance_to_add, + }, + ) if *address == surplus_addr && *balance_to_add == 2_000 + ) + }); + assert!( + has_surplus_op, + "expected AddBalanceToAddress(surplus_addr, 2000)" + ); + } + + #[test] + fn test_without_surplus_output_emits_no_address_op() { + // surplus_output None => no AddBalanceToAddress op; the surplus folds into the fee pools + // at the execution-event layer instead. `make_action` has surplus_output: None. + 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"); + + assert_eq!(ops.len(), 4); + assert!( + !ops.iter() + .any(|op| matches!(op, DriveOperation::AddressFundsOperation(_))), + "no AddressFundsOperation expected when surplus_output is None" + ); + } } diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_transfer_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_transfer_transition.rs index 0ef537802b4..126bf1cff7e 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_transfer_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_transfer_transition.rs @@ -151,7 +151,7 @@ mod tests { assert!(result.is_err()); } - /// Audit: the flat `compute_minimum_shielded_fee` must cover the *actual* GroveDB write + /// Invariant: the flat `compute_minimum_shielded_fee` must cover the *actual* GroveDB write /// cost (`Drive::calculate_fee`) of a shielded transition's operations. /// /// A shielded transfer is the cleanest per-action case — insert nullifiers + notes + @@ -220,6 +220,24 @@ mod tests { fee_result.storage_fee, fee_result.processing_fee ); + + // Pin the booking-split invariant directly. The pool-paid booking in + // `execute_event/v0` splits the flat carved fee as + // storage_fee = min(real_metered_storage, flat_fee) + // processing_fee = flat_fee - storage_fee + // The `min()` only ever binds — zeroing the proposer's processing reward and + // undercharging storage — if the real metered storage EXCEEDS the flat fee. Asserting + // `flat_fee > real_metered_storage` here is exactly the condition that guarantees the + // `min()` is a no-op, so the proposer is always paid the processing remainder and + // storage is never undercharged. (Strict `>` because the flat fee also bundles the 100M + // proof-verification fee that GroveDB never meters.) + assert!( + fee_amount > fee_result.storage_fee, + "compute_minimum_shielded_fee({num_actions}) = {fee_amount} must strictly exceed the \ + real metered storage {} so the booking split's min(real_storage, flat_fee) never \ + binds (proposer processing reward never zeroed, storage never undercharged)", + fee_result.storage_fee + ); } } } diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_withdrawal_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_withdrawal_transition.rs index 0483c7c693a..1e49943926c 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_withdrawal_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_withdrawal_transition.rs @@ -102,7 +102,7 @@ mod tests { use dpp::document::{Document, DocumentV0, DocumentV0Getters}; use dpp::identity::core_script::CoreScript; use dpp::platform_value::Identifier; - use dpp::shielded::{compute_minimum_shielded_fee, SerializedAction}; + use dpp::shielded::{compute_shielded_withdrawal_fee, SerializedAction}; use dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition; use dpp::state_transition::state_transitions::shielded::shielded_withdrawal_transition::v0::ShieldedWithdrawalTransitionV0; use dpp::version::PlatformVersion; @@ -348,9 +348,9 @@ mod tests { // fixture — so the test fails if the fee is computed as zero or dropped on the way // into the action. let platform_version = PlatformVersion::latest(); - let fee = compute_minimum_shielded_fee(1, platform_version) + let fee = compute_shielded_withdrawal_fee(1, platform_version) .expect("fee computation should not overflow"); - assert!(fee > 0, "computed minimum shielded fee must be non-zero"); + assert!(fee > 0, "computed shielded withdrawal fee must be non-zero"); // Net (= unshielding_amount - fee) must clear the dust floor for the transform to // succeed; pad comfortably above it. @@ -380,7 +380,7 @@ mod tests { // A gross amount that covers the fee but leaves a net below the Core dust floor must // be rejected by the transformer, not turned into a zero/dust withdrawal document. let platform_version = PlatformVersion::latest(); - let fee = compute_minimum_shielded_fee(1, platform_version) + let fee = compute_shielded_withdrawal_fee(1, platform_version) .expect("fee computation should not overflow"); let min_withdrawal = platform_version.system_limits.min_withdrawal_amount; @@ -436,6 +436,151 @@ mod tests { assert_eq!(system_credit_delta, -((amount - fee) as i128)); } + /// Invariant: the flat `compute_shielded_withdrawal_fee` must cover the *actual* GroveDB write + /// cost of a ShieldedWithdrawal's operations, AND strictly exceed the metered storage so the + /// pool-paid booking split never undercharges. + /// + /// ShieldedWithdrawal is pool-paid: `execute_event/v0` carves the flat fee from the shielded + /// pool and splits it as `storage_fee = min(real_metered_storage, flat_fee); processing_fee = + /// flat_fee - storage_fee`. The `min()` only binds (zeroing the proposer's processing reward and + /// undercharging storage) if real metered storage EXCEEDS the flat fee. Withdrawal is the + /// heaviest pool-paid case because it ALSO writes a Core withdrawal document, so its metered + /// storage is the most likely to be non-trivial — which is exactly why the withdrawal fee + /// (`compute_shielded_withdrawal_fee`) prices that document insert as a flat storage component + /// on top of the base shielded fee. With that component the flat fee now exceeds the real + /// metered storage by a wide margin, so this `fee > storage` check passes comfortably. + /// + /// Unlike the ShieldedTransfer / Unshield metering tests (which use estimation mode, + /// `apply = false`), this test measures the **real apply cost** (`apply = true` against a + /// transaction). That is deliberate and is what makes this test faithful to the booking: + /// `execute_event/v0` books the split from `apply_drive_operations(.., true, ..)` — the REAL + /// metered cost — not from an estimate. For Withdrawal the two diverge sharply: estimation mode + /// charges worst-case tree-layer costs for the `AddWithdrawalDocument` insert into the + /// (near-empty) withdrawals contract document tree, over-counting storage well above the real + /// cost (measured: est storage 118M vs real 124.8M at n=1 looks close, but est *total* 152.9M + /// exceeds the flat fee while the real total 129.1M stays under it). Pinning the booking + /// invariant therefore requires the real cost. We build the action through the REAL transformer + /// (`try_from_transition`) so the `prepared_withdrawal_document` is schema-valid, insert the + /// withdrawals contract so the document tree exists, use production-sized 216-byte notes with + /// valid Pallas `cmx` elements, and assert both `fee >= real total` and `fee > real storage`. + #[test] + fn test_minimum_shielded_fee_covers_actual_grovedb_write_cost() { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + + let platform_version = PlatformVersion::latest(); + let epoch = Epoch::new(0).unwrap(); + + // Production-sized serialized action: 216-byte encrypted note, distinct nullifier and a + // distinct, VALID Pallas-field-element `cmx` (small little-endian integer < modulus) so the + // REAL commitment-tree insert accepts each change note. + let realistic_action = |i: u8| { + let mut cmx = [0u8; 32]; + cmx[0] = i.wrapping_add(1); + SerializedAction { + nullifier: [i.wrapping_add(1); 32], + rk: [0x33; 32], + cmx, + encrypted_note: vec![0x77; 216], + cv_net: [0x44; 32], + spend_auth_sig: [0x55; 64], + } + }; + + for num_actions in [1usize, 8, 16] { + // Fresh drive per iteration so each measurement is independent and the + // commitment/nullifier trees start empty (the production per-transition case). + let drive = setup_drive_with_initial_state_structure(None); + // Seed the global system-credit counter so the action's `RemoveFromSystemCredits` op + // (the NET amount leaving the platform to Core) does not underflow from zero. + drive + .add_to_system_credits(1_000_000_000_000, None, platform_version) + .expect("seed system credits"); + + let fee = compute_shielded_withdrawal_fee(num_actions, platform_version) + .expect("fee computation should not overflow"); + // Net (= unshielding_amount - fee) must clear the dust floor for the transform to + // succeed; pad comfortably above it. + let unshielding_amount = fee + 1_000_000; + + let actions: Vec = + (0..num_actions as u8).map(realistic_action).collect(); + let transition = ShieldedWithdrawalTransition::V0(ShieldedWithdrawalTransitionV0 { + actions, + unshielding_amount, + anchor: [0xAA; 32], + proof: vec![], + binding_signature: [0u8; 64], + core_fee_per_byte: 1, + pooling: Pooling::Never, + output_script: CoreScript::from_bytes(vec![0x76, 0xA9]), + }); + + // Build the action via the REAL transformer so the withdrawal document is schema-valid. + let result = ShieldedWithdrawalTransitionAction::try_from_transition( + &transition, + unshielding_amount + 1_000_000, // current_total_balance + 0, // creation_time_ms + fee, + platform_version, + ); + let action = result.into_data().expect("transform should succeed"); + + // Insert the withdrawals contract so the `AddWithdrawalDocument` op has a real document + // tree to write into during the apply. + let tx = drive.grove.start_transaction(); + let withdrawals = drive.cache.system_data_contracts.load_withdrawals(); + drive + .apply_contract( + &withdrawals, + BlockInfo::default(), + true, + None, + Some(&tx), + platform_version, + ) + .expect("apply withdrawals contract"); + + let ops = action + .into_high_level_drive_operations(&epoch, platform_version) + .expect("operations"); + + // apply = true → REAL cost, exactly what the booking in `execute_event/v0` measures. + let fee_result = drive + .apply_drive_operations( + ops, + true, + &BlockInfo::default(), + Some(&tx), + platform_version, + None, + ) + .expect("apply write ops"); + let actual_cost = fee_result.total_base_fee(); + + assert!( + fee >= actual_cost, + "compute_shielded_withdrawal_fee({num_actions}) = {fee} must cover the real \ + ShieldedWithdrawal GroveDB write cost {actual_cost} (storage {} + processing {})", + fee_result.storage_fee, + fee_result.processing_fee + ); + + // The booking-split invariant: flat fee must strictly exceed real metered storage so + // `storage_fee = min(real_storage, flat_fee)` never binds (proposer processing reward + // never zeroed, storage never undercharged). This is the heaviest pool-paid case (it + // also writes a Core withdrawal document), so it is the strongest guard. See + // `execute_event/v0`. + assert!( + fee > fee_result.storage_fee, + "compute_shielded_withdrawal_fee({num_actions}) = {fee} must strictly exceed the \ + real metered ShieldedWithdrawal storage {} (incl. the Core withdrawal document) so \ + the booking split's min(real_storage, flat_fee) never binds", + fee_result.storage_fee + ); + } + } + #[test] fn test_pool_underflow_returns_error() { // Pool has less than `amount`; pool decrement must error. diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/unshield_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/unshield_transition.rs index 4684ba8a9d7..a1ce0d98006 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/unshield_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/unshield_transition.rs @@ -257,6 +257,92 @@ mod tests { assert!(result.is_err()); } + /// Invariant: the flat `compute_shielded_unshield_fee` must cover the *actual* GroveDB write + /// cost of an Unshield's operations, AND strictly exceed the metered storage so the pool-paid + /// booking split never undercharges. + /// + /// Unshield is pool-paid: `execute_event/v0` carves the flat fee from the shielded pool and + /// splits it as `storage_fee = min(real_metered_storage, flat_fee); processing_fee = flat_fee - + /// storage_fee`. The `min()` only binds (zeroing the proposer's processing reward and + /// undercharging storage) if real metered storage EXCEEDS the flat fee. Unshield also writes the + /// net to the output platform address (`AddBalanceToAddress`), so its fee + /// (`compute_shielded_unshield_fee`) prices that write as a flat storage component on top of the + /// base shielded fee — which is exactly why the `fee > storage` margin below holds with room to + /// spare. This test meters the real cost in estimation mode (`apply = false`, production-sized + /// 216-byte notes) and asserts both `fee >= total cost` and `fee > storage` — the latter being + /// exactly the condition that keeps the `min()` a no-op. Mirrors the ShieldedTransfer metering + /// test. + #[test] + fn test_minimum_shielded_fee_covers_actual_grovedb_write_cost() { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::shielded::compute_shielded_unshield_fee; + + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let epoch = Epoch::new(0).unwrap(); + + // Production-sized change note: 216-byte encrypted note, distinct nullifier/cmx per action. + let realistic_note = |i: u8| ShieldedActionNote { + nullifier: [i.wrapping_add(1); 32], + cmx: [i.wrapping_add(101); 32], + encrypted_note: vec![0x77; 216], + }; + + for num_actions in [1usize, 8, 16] { + let fee_amount = compute_shielded_unshield_fee(num_actions, platform_version) + .expect("fee computation should not overflow"); + let notes: Vec<_> = (0..num_actions as u8).map(realistic_note).collect(); + // `amount` (unshielding_amount) must cover the fee with a positive net to the output + // address (so the AddBalanceToAddress write is exercised, the heaviest extra op). + let amount = fee_amount + 1_000_000; + let action = UnshieldTransitionAction::V0(UnshieldTransitionActionV0 { + output_address: PlatformAddress::P2pkh([0xBB; 20]), + amount, + notes, + anchor: [0xAA; 32], + fee_amount, + current_total_balance: amount + 1_000_000, + }); + + let ops = action + .into_high_level_drive_operations(&epoch, platform_version) + .expect("operations"); + + // apply = false → estimation mode: no DB mutation, returns the real cost. + let fee_result = drive + .apply_drive_operations( + ops, + false, + &BlockInfo::default(), + None, + platform_version, + None, + ) + .expect("estimate write cost"); + let actual_cost = fee_result.total_base_fee(); + + assert!( + fee_amount >= actual_cost, + "compute_shielded_unshield_fee({num_actions}) = {fee_amount} must cover the actual \ + Unshield GroveDB write cost {actual_cost} (storage {} + processing {})", + fee_result.storage_fee, + fee_result.processing_fee + ); + + // The booking-split invariant: flat fee must strictly exceed real metered storage so + // `storage_fee = min(real_storage, flat_fee)` never binds (proposer processing reward + // never zeroed, storage never undercharged). See `execute_event/v0`. + assert!( + fee_amount > fee_result.storage_fee, + "compute_shielded_unshield_fee({num_actions}) = {fee_amount} must strictly exceed the \ + real metered Unshield storage {} so the booking split's min(real_storage, flat_fee) \ + never binds", + fee_result.storage_fee + ); + } + } + #[test] fn test_net_zero_unshield_skips_address_credit() { // When the whole `unshielding_amount` is consumed by the fee (net == 0), diff --git a/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/mod.rs index 2adfd5902ef..fd4a7f16bd9 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/mod.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/mod.rs @@ -6,6 +6,7 @@ pub mod v0; use crate::state_transition_action::shielded::shield_from_asset_lock::v0::ShieldFromAssetLockTransitionActionV0; use crate::state_transition_action::shielded::ShieldedActionNote; use derive_more::From; +use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; /// Shield from asset lock transition action @@ -50,6 +51,18 @@ impl ShieldFromAssetLockTransitionAction { ShieldFromAssetLockTransitionAction::V0(transition) => &transition.notes, } } + /// Get the optional surplus-output platform address (receives the asset-lock surplus). + pub fn surplus_output(&self) -> &Option { + match self { + ShieldFromAssetLockTransitionAction::V0(transition) => &transition.surplus_output, + } + } + /// Get the surplus credits routed to `surplus_output` (0 when `surplus_output` is `None`). + pub fn surplus_amount(&self) -> Credits { + match self { + ShieldFromAssetLockTransitionAction::V0(transition) => transition.surplus_amount, + } + } } #[cfg(test)] @@ -72,6 +85,8 @@ mod tests { shield_amount: 45000, notes: vec![make_note()], current_total_balance: 200000, + surplus_output: None, + surplus_amount: 0, }; ShieldFromAssetLockTransitionAction::from(v0) } @@ -125,6 +140,8 @@ mod tests { shield_amount: 0, notes: vec![], current_total_balance: 0, + surplus_output: None, + surplus_amount: 0, }; let action = ShieldFromAssetLockTransitionAction::from(v0); assert_eq!(action.asset_lock_value_to_be_consumed(), 0); diff --git a/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/transformer.rs index c05ec853283..f90ccf14c5d 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/transformer.rs @@ -6,6 +6,7 @@ use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLoc impl ShieldFromAssetLockTransitionAction { /// Transforms the state transition into an action + #[allow(clippy::too_many_arguments)] pub fn try_from_transition( value: &ShieldFromAssetLockTransition, asset_lock_outpoint: [u8; 36], @@ -13,6 +14,7 @@ impl ShieldFromAssetLockTransitionAction { signable_bytes_hasher: [u8; 32], shield_amount: Credits, current_total_balance: Credits, + surplus_amount: Credits, ) -> ConsensusValidationResult { match value { ShieldFromAssetLockTransition::V0(v0) => { @@ -23,6 +25,7 @@ impl ShieldFromAssetLockTransitionAction { signable_bytes_hasher, shield_amount, current_total_balance, + surplus_amount, ); result.map(|action| action.into()) } diff --git a/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/v0/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/v0/mod.rs index 3bb14e3f942..badab117490 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/v0/mod.rs @@ -1,6 +1,7 @@ mod transformer; use crate::state_transition_action::shielded::ShieldedActionNote; +use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; /// Shield from asset lock transition action v0 @@ -8,7 +9,9 @@ use dpp::fee::Credits; pub struct ShieldFromAssetLockTransitionActionV0 { /// Asset lock outpoint bytes (txid + vout) pub asset_lock_outpoint: [u8; 36], - /// Remaining asset lock value to be consumed + /// Full asset-lock value consumed by this transition (added to system credits). + /// It is distributed as: `shield_amount` -> shielded pool, `surplus_amount` -> + /// `surplus_output` address (when set), and the remainder -> fee pools. pub asset_lock_value_to_be_consumed: Credits, /// SHA256(signable_bytes) for replay protection pub signable_bytes_hasher: [u8; 32], @@ -18,4 +21,11 @@ pub struct ShieldFromAssetLockTransitionActionV0 { pub notes: Vec, /// Current total balance of the shielded pool pub current_total_balance: Credits, + /// Optional platform address that receives the asset-lock surplus + /// (`asset_lock_value_to_be_consumed - shield_amount - fee`). When `None`, the surplus is + /// folded into the fee pools instead (capped at `shielded_implicit_fee_cap`). + pub surplus_output: Option, + /// Credits routed to `surplus_output` (the surplus). `0` when `surplus_output` is `None`, + /// in which case the surplus is added to the fee pools by the execution event. + pub surplus_amount: Credits, } diff --git a/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/v0/transformer.rs index d5cff6adf65..2c0f92877ff 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/shield_from_asset_lock/v0/transformer.rs @@ -6,6 +6,7 @@ use dpp::state_transition::state_transitions::shielded::shield_from_asset_lock_t impl ShieldFromAssetLockTransitionActionV0 { /// Transforms the shield from asset lock transition into an action + #[allow(clippy::too_many_arguments)] pub fn try_from_transition( value: &ShieldFromAssetLockTransitionV0, asset_lock_outpoint: [u8; 36], @@ -13,6 +14,7 @@ impl ShieldFromAssetLockTransitionActionV0 { signable_bytes_hasher: [u8; 32], shield_amount: Credits, current_total_balance: Credits, + surplus_amount: Credits, ) -> ConsensusValidationResult { let notes: Vec = value.actions.iter().map(ShieldedActionNote::from).collect(); @@ -24,6 +26,8 @@ impl ShieldFromAssetLockTransitionActionV0 { shield_amount, notes, current_total_balance, + surplus_output: value.surplus_output, + surplus_amount, }) } } diff --git a/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/v0/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/v0/mod.rs index 29e256d73d2..29b480c248b 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/v0/mod.rs @@ -22,7 +22,8 @@ pub struct ShieldedWithdrawalTransitionActionV0 { /// Core address receiving funds pub output_script: CoreScript, /// Shielded fee paid to proposers, carved out of `amount` (the net amount - /// withdrawn to Core is `amount - fee_amount`). Equals `compute_minimum_shielded_fee`. + /// withdrawn to Core is `amount - fee_amount`). Equals `compute_shielded_withdrawal_fee` + /// (the base shielded minimum fee plus the flat Core withdrawal-document storage cost). pub fee_amount: Credits, /// Current total balance of the shielded pool pub current_total_balance: Credits, diff --git a/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/mod.rs index ae629b427ce..ca30c4162ef 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/unshield/v0/mod.rs @@ -16,7 +16,9 @@ pub struct UnshieldTransitionActionV0 { /// The anchor used for verification pub anchor: [u8; 32], /// Shielded fee paid to proposers, carved out of `amount` (the recipient - /// receives `amount - fee_amount`). Equals `compute_minimum_shielded_fee`. + /// receives `amount - fee_amount`). Equals `compute_shielded_unshield_fee` + /// (the base shielded minimum fee plus the flat `AddBalanceToAddress` + /// output-write storage cost). pub fee_amount: Credits, /// Current total balance of the shielded pool pub current_total_balance: Credits, diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 1a0f56cac1d..3b8caf885cc 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -1291,11 +1291,15 @@ impl Drive { )) } StateTransition::ShieldFromAssetLock(st) => { + use crate::drive::RootTree; use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; use dpp::asset_lock::StoredAssetLockInfo; use dpp::identity::state_transition::AssetLockProved; use dpp::serialization::PlatformDeserializable; - use dpp::state_transition::proof_result::StateTransitionProofResult::VerifiedAssetLockConsumed; + use dpp::state_transition::proof_result::StateTransitionProofResult::{ + VerifiedAssetLockConsumed, VerifiedAssetLockConsumedWithAddressInfos, + }; + use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; use grovedb::Element; let outpoint = st.asset_lock_proof().out_point().ok_or_else(|| { @@ -1305,57 +1309,201 @@ impl Drive { })?; let outpoint_bytes: [u8; 36] = outpoint.into(); - // Build the same PathQuery as the prove side - let mut query = grovedb::Query::new(); - query.insert_key(outpoint_bytes.to_vec()); - let path_query = grovedb::PathQuery::new( - vec![vec![72u8]], // RootTree::SpentAssetLockTransactions - grovedb::SizedQuery::new(query, Some(1), None), + // No accessor trait exposes `surplus_output`, so read it directly off the V0 body. + let ShieldFromAssetLockTransition::V0(v0) = st; + let surplus_output = &v0.surplus_output; + + // Build the outpoint sub-query exactly as the prove side does (same path, same key). + let mut outpoint_query = grovedb::Query::new(); + outpoint_query.insert_key(outpoint_bytes.to_vec()); + let outpoint_pq = grovedb::PathQuery::new( + vec![vec![RootTree::SpentAssetLockTransactions as u8]], + grovedb::SizedQuery::new(outpoint_query, Some(1), None), ); - let (root_hash, mut proved_key_values) = - grovedb::GroveDb::verify_query_with_absence_proof( - proof, - &path_query, - &platform_version.drive.grove_version, - )?; + match surplus_output { + Some(surplus_address) => { + // The prove side merged the outpoint sub-query with the surplus-address + // balance sub-query into a SINGLE multi-root proof (clearing each sub-query + // limit before the merge, because `PathQuery::merge` rejects limited + // sub-queries). Reconstruct the byte-identical merged query here and verify + // it STRICTLY: the strict verifier accepts a proof that matches the merged + // query exactly and REJECTS any proof carrying extra branches, so a + // malicious/buggy prover cannot pad the proof with unrelated data. + let mut outpoint_pq = outpoint_pq; + outpoint_pq.query.limit = None; + + let mut address_pq = Drive::balances_for_clear_addresses_query( + std::iter::once(surplus_address), + ); + address_pq.query.limit = None; + + let mut merged_pq = grovedb::PathQuery::merge( + vec![&outpoint_pq, &address_pq], + &platform_version.drive.grove_version, + )?; - if proved_key_values.len() > 1 { - return Err(Error::Proof(ProofError::TooManyElements( - "expected at most 1 element for asset lock outpoint", - ))); - } + // `verify_query_with_absence_proof` (the STRICT verifier) requires a limit + // to be set, but `PathQuery::merge` leaves the merged limit at `None`. The + // merged query targets a fixed, tiny set of explicit keys ({outpoint} ∪ + // {surplus address}), so we set a limit that can never be exhausted by the + // legitimate result set. This is load-bearing for soundness: the succinctness + // check that rejects extra proof layers runs per-layer AFTER that layer's + // result loop, and the result loop only breaks early once the limit hits 0. + // A limit smaller than the real result count could break before every layer's + // succinctness check runs (falsely rejecting honest proofs); an unreachable + // limit guarantees every layer is fully traversed and every extra branch is + // caught. The limit does NOT relax the extra-data rejection — that is the + // succinctness check, which is independent of the limit value. + merged_pq.query.limit = Some(u16::MAX); + + let (root_hash, proved_key_values) = + grovedb::GroveDb::verify_query_with_absence_proof( + proof, + &merged_pq, + &platform_version.drive.grove_version, + )?; - let info = if let Some(proved) = proved_key_values.pop() { - match proved.2 { - Some(Element::Item(bytes, _)) => { - if bytes.is_empty() { - StoredAssetLockInfo::FullyConsumed - } else { - StoredAssetLockInfo::PartiallyConsumed( - AssetLockValue::deserialize_from_bytes(&bytes)?, - ) + // Partition the proved key/values: exactly one entry is the asset-lock + // outpoint (36-byte key), every other entry is a surplus-address balance + // (21-byte key). Anything else is a corrupted proof. + let outpoint_key = outpoint_bytes.to_vec(); + let mut outpoint_element: Option> = None; + let mut balances: BTreeMap< + PlatformAddress, + Option<(AddressNonce, Credits)>, + > = BTreeMap::new(); + + for (_path, key, element) in proved_key_values { + if key == outpoint_key { + outpoint_element = Some(element); + continue; } + + // Mirror `verify_addresses_infos_v0`: reconstruct the address from the + // key and decode the `ItemWithSumItem` (nonce, balance) element. + let address = PlatformAddress::from_bytes(&key).map_err(|e| { + Error::Proof(ProofError::CorruptedProof(format!( + "failed to deserialize surplus PlatformAddress: {}", + e + ))) + })?; + + let balance_info = element + .map(|element| { + let Element::ItemWithSumItem(nonce_vec, balance_i64, _) = + element + else { + return Err(Error::Proof(ProofError::CorruptedProof( + "expected an item with sum item element".to_string(), + ))); + }; + + let nonce_bytes: [u8; 4] = + nonce_vec.try_into().map_err(|_| { + Error::Proof(ProofError::IncorrectValueSize( + "nonce should be 4 bytes", + )) + })?; + let nonce = AddressNonce::from_be_bytes(nonce_bytes); + + if balance_i64 < 0 { + return Err(Error::Proof(ProofError::CorruptedProof( + "balance cannot be negative".to_string(), + ))); + } + let balance = balance_i64 as Credits; + + Ok((nonce, balance)) + }) + .transpose()?; + + balances.insert(address, balance_info); } - Some(_) => { - return Err(Error::Proof(ProofError::CorruptedProof( - "expected an item element for asset lock outpoint".to_string(), + + // The asset-lock outpoint MUST be present in the merged proof. + let outpoint_element = outpoint_element.ok_or_else(|| { + Error::Proof(ProofError::CorruptedProof( + "shield from asset lock was executed but asset lock outpoint is absent from proof".to_string(), + )) + })?; + + let info = match outpoint_element { + Some(Element::Item(bytes, _)) => { + if bytes.is_empty() { + StoredAssetLockInfo::FullyConsumed + } else { + StoredAssetLockInfo::PartiallyConsumed( + AssetLockValue::deserialize_from_bytes(&bytes)?, + ) + } + } + Some(_) => { + return Err(Error::Proof(ProofError::CorruptedProof( + "expected an item element for asset lock outpoint".to_string(), + ))); + } + None => { + return Err(Error::Proof(ProofError::CorruptedProof( + "shield from asset lock was executed but asset lock outpoint is absent from proof".to_string(), + ))); + } + }; + + Ok(( + root_hash, + VerifiedAssetLockConsumedWithAddressInfos(info, balances), + )) + } + None => { + // No surplus output: the proof covers only the outpoint, verified strictly + // with the `Some(1)` limit exactly as before (unchanged behavior). + let (root_hash, mut proved_key_values) = + grovedb::GroveDb::verify_query_with_absence_proof( + proof, + &outpoint_pq, + &platform_version.drive.grove_version, + )?; + + if proved_key_values.len() > 1 { + return Err(Error::Proof(ProofError::TooManyElements( + "expected at most 1 element for asset lock outpoint", ))); } - None => { + + let info = if let Some(proved) = proved_key_values.pop() { + match proved.2 { + Some(Element::Item(bytes, _)) => { + if bytes.is_empty() { + StoredAssetLockInfo::FullyConsumed + } else { + StoredAssetLockInfo::PartiallyConsumed( + AssetLockValue::deserialize_from_bytes(&bytes)?, + ) + } + } + Some(_) => { + return Err(Error::Proof(ProofError::CorruptedProof( + "expected an item element for asset lock outpoint" + .to_string(), + ))); + } + None => { + return Err(Error::Proof(ProofError::CorruptedProof( + "shield from asset lock was executed but asset lock outpoint is absent from proof".to_string(), + ))); + } + } + } else { return Err(Error::Proof(ProofError::CorruptedProof( - "shield from asset lock was executed but asset lock outpoint is absent from proof".to_string(), + "shield from asset lock was executed but no proved key values returned" + .to_string(), ))); - } - } - } else { - return Err(Error::Proof(ProofError::CorruptedProof( - "shield from asset lock was executed but no proved key values returned" - .to_string(), - ))); - }; + }; - Ok((root_hash, VerifiedAssetLockConsumed(info))) + Ok((root_hash, VerifiedAssetLockConsumed(info))) + } + } } } } @@ -3035,6 +3183,7 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + surplus_output: None, signature: BinaryData::new(vec![]), }, )); @@ -3084,6 +3233,7 @@ mod tests { anchor: [0u8; 32], proof: vec![], binding_signature: [0u8; 64], + surplus_output: None, signature: BinaryData::new(vec![]), }, )); 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 75a72eb680d..8059700e873 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 @@ -45,6 +45,11 @@ pub struct DriveAbciValidationConstants { /// Per-action fee (in credits) for processing: RedPallas spend auth signature /// verification, nullifier duplicate check, and tree insertion. pub shielded_per_action_processing_fee: 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 + /// large asset-lock remainder. 20,000,000,000 credits = 0.2 Dash. + pub shielded_implicit_fee_cap: u64, } #[derive(Clone, Debug, Default)] 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 1ff25e46d71..32ca6a7a44b 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 @@ -267,5 +267,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, + shielded_implicit_fee_cap: 20_000_000_000, }, }; 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 b50a183fa5e..c8d261ae03f 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 @@ -267,5 +267,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, + shielded_implicit_fee_cap: 20_000_000_000, }, }; 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 db1b6eb9c6f..622b01c96ef 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 @@ -267,5 +267,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, + shielded_implicit_fee_cap: 20_000_000_000, }, }; 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 1731709db20..80aac501f21 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 @@ -270,5 +270,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, + shielded_implicit_fee_cap: 20_000_000_000, }, }; 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 825e41c20df..d0d6f3ab5e3 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 @@ -271,5 +271,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, + shielded_implicit_fee_cap: 20_000_000_000, }, }; 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 f0f2307635c..579e6f6b675 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 @@ -274,5 +274,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, + shielded_implicit_fee_cap: 20_000_000_000, }, }; 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 eb2518ff40d..105c04949aa 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 @@ -268,5 +268,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, + shielded_implicit_fee_cap: 20_000_000_000, }, }; 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 2fdec2b06ca..bf832b4bbbf 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 @@ -325,5 +325,6 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = // same rate the flat fee prices the ~5 ms base (100M ≈ 4.5× this), so the fee // tracks the per-action cost and the margin stays uniform as actions grow. shielded_per_action_processing_fee: 22_000_000, + shielded_implicit_fee_cap: 20_000_000_000, }, }; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index c9898f693ea..398b5e57e10 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -34,7 +34,7 @@ use std::ffi::CStr; use std::os::raw::c_char; use dashcore::hashes::Hash; -use dpp::address_funds::OrchardAddress; +use dpp::address_funds::{OrchardAddress, PlatformAddress}; use platform_wallet::wallet::asset_lock::AssetLockFunding; use platform_wallet::wallet::shielded::CachedOrchardProver; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; @@ -45,6 +45,49 @@ use crate::error::*; use crate::handle::*; use crate::runtime::{block_on_worker, runtime}; +/// Parse an optional surplus-output platform address supplied as raw +/// `PlatformAddress` storage bytes (21 bytes: 1-byte variant tag + +/// 20-byte hash — the encoding `PlatformAddress::to_bytes()` produces +/// and `PlatformAddressWasm`/the Swift wrapper expose). +/// +/// `ptr == null` (or `len == 0`) means "no surplus output" → `Ok(None)`. +/// A non-null pointer is read for `len` bytes and decoded; a malformed +/// address is surfaced as an `Err(PlatformWalletFFIResult)` so the +/// caller fails fast rather than building a transition the wallet would +/// reject. +/// +/// # Safety +/// When `ptr` is non-null it must point to at least `len` readable +/// bytes for the duration of this call. +unsafe fn parse_optional_surplus_output( + ptr: *const u8, + len: usize, +) -> Result, PlatformWalletFFIResult> { + const PLATFORM_ADDRESS_LEN: usize = 21; + if ptr.is_null() || len == 0 { + return Ok(None); + } + // A serialized PlatformAddress is exactly 21 bytes (1-byte variant tag + 20-byte hash). + // `from_bytes` decodes via bincode, which does NOT require full-slice consumption, so an + // over-length buffer with a valid 21-byte prefix would otherwise be silently accepted (and the + // trailing bytes dropped). Reject any non-21-byte input so a malformed/padded address fails fast + // here rather than being silently truncated before signing. + if len != PLATFORM_ADDRESS_LEN { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("surplus_output must be exactly {PLATFORM_ADDRESS_LEN} bytes, got {len}"), + )); + } + let bytes = std::slice::from_raw_parts(ptr, len); + match PlatformAddress::from_bytes(bytes) { + Ok(addr) => Ok(Some(addr)), + Err(e) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("invalid surplus_output platform address: {e}"), + )), + } +} + /// Kick off the Halo 2 proving-key build on a background tokio /// worker if it hasn't been built yet. Returns immediately — /// hosts can call this at app startup without blocking the UI @@ -338,12 +381,23 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( /// `account_index` selects the BIP44 Core account whose UTXOs /// fund the asset lock. `amount_duffs` is the L1 amount to lock. /// The wallet derives the shielded credit amount internally -/// (`lock_value − protocol_min_fee`) — callers don't need to know -/// about Type 18's Halo 2 fee math. +/// (`lock_value − pool_fee`, where `pool_fee = shielded fee + +/// asset_lock_base_cost`) — callers don't need to know about +/// Type 18's Halo 2 fee math. /// /// `recipient_raw_43` is the single Orchard recipient (same shape /// `platform_wallet_manager_shielded_default_address` returns); it -/// receives the full `lock_value − min_fee` credits. +/// receives the full `lock_value − pool_fee` credits. +/// +/// `surplus_output_ptr` / `surplus_output_len` optionally supply a +/// platform address (raw `PlatformAddress` bytes: 1-byte variant tag + +/// 20-byte hash) to receive the asset-lock surplus +/// (`lock_value − shield_amount − pool_fee`). Pass `null` / `0` for +/// none. In this single-recipient "remainder" flow the wallet derives +/// `shield_amount = lock_value − pool_fee`, so the surplus is always +/// **zero** and a `null` surplus output is always valid; the parameter +/// is plumbed for API completeness and forward-compatibility with +/// multi-output / explicit-amount bundles. /// /// Multi-recipient with explicit per-recipient amounts is reserved /// for a future DPP-side Orchard multi-output bundle change; today @@ -353,6 +407,8 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( /// - `wallet_id_bytes` must point to 32 readable bytes. /// - `recipient_raw_43` must point to 43 readable bytes (raw /// Orchard payment address: 11-byte diversifier + 32-byte pk_d). +/// - `surplus_output_ptr`, when non-null, must point to +/// `surplus_output_len` readable bytes for the duration of the call. /// - `core_signer_handle` must be a valid, non-destroyed /// `*mut MnemonicResolverHandle` produced by /// `dash_sdk_mnemonic_resolver_create`. The caller retains @@ -365,6 +421,8 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( account_index: u32, amount_duffs: u64, recipient_raw_43: *const u8, + surplus_output_ptr: *const u8, + surplus_output_len: usize, core_signer_handle: *mut MnemonicResolverHandle, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); @@ -386,6 +444,12 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( } }; + let surplus_output = match parse_optional_surplus_output(surplus_output_ptr, surplus_output_len) + { + Ok(s) => s, + Err(result) => return result, + }; + let wallet = match resolve_wallet(handle, &wallet_id) { Ok(w) => w, Err(result) => return result, @@ -421,6 +485,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( vec![(recipient, None)], &asset_lock_signer, &prover, + surplus_output, None, ) .await @@ -444,10 +509,44 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( /// left the lock in storage at `Broadcast` / `InstantSendLocked` / /// `ChainLocked` but the shield ST never completed. /// +/// ## Resume / surplus-output desync — why no extra persistence is needed +/// +/// The surplus destination is **signed over** on-chain (it sits before +/// the ECDSA `signature` in the signable bytes), so two resume attempts +/// that disagreed on the surplus would produce two different +/// transitions. We avoid that desync by construction rather than by +/// persisting the address with the in-flight lock: +/// +/// - The orchestrated single-recipient flow always sets +/// `shield_amount = lock_value − pool_fee`, which pins the consensus +/// surplus (`lock_value − shield_amount − pool_fee`) to exactly +/// **zero** on every attempt — fresh build or resume. +/// - `shield_amount` is re-derived deterministically from the on-chain +/// lock value (read back from the tracked lock / IS proof) and the +/// versioned fee constants, so it is identical across restarts and +/// independent of any per-call input. +/// - With a zero surplus the `surplus_output` has no on-chain effect +/// (the action routes 0 credits to it), and `null` is always +/// consensus-valid (`0 ≤ shielded_implicit_fee_cap`). Each resume +/// re-signs a freshly-randomized bundle anyway (the Halo 2 proof draws +/// `OsRng` per build), so there is no "original signed transition" to +/// replay — only a stream of consensus-equivalent ones. +/// +/// Net: a resume cannot strand or misdirect a surplus regardless of the +/// `surplus_output` passed here, so the surplus address is *not* +/// persisted on the `TrackedAssetLock`. The parameter is accepted for +/// signature parity with the fresh-build entry point; pass the same +/// value (typically `null`) on resume. If a future change introduces a +/// non-zero residual (e.g. explicit recipient amounts), the surplus +/// address would have to be persisted on the tracked lock and read back +/// here instead of trusted from the resume call. +/// /// # Safety /// - `wallet_id_bytes` must point to 32 readable bytes. /// - `out_point` must be a valid, non-null pointer to an /// `OutPointFFI` for the duration of the call. +/// - `surplus_output_ptr`, when non-null, must point to +/// `surplus_output_len` readable bytes for the duration of the call. /// - `recipient_raw_43` / `core_signer_handle` — see /// [`platform_wallet_manager_shielded_fund_from_asset_lock`]. #[no_mangle] @@ -457,6 +556,8 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset wallet_id_bytes: *const u8, out_point: *const OutPointFFI, recipient_raw_43: *const u8, + surplus_output_ptr: *const u8, + surplus_output_len: usize, core_signer_handle: *mut MnemonicResolverHandle, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); @@ -479,6 +580,12 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset } }; + let surplus_output = match parse_optional_surplus_output(surplus_output_ptr, surplus_output_len) + { + Ok(s) => s, + Err(result) => return result, + }; + let out_point_ffi = *out_point; let resume_outpoint = dashcore::OutPoint { txid: dashcore::Txid::from_byte_array(out_point_ffi.txid), @@ -512,6 +619,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset vec![(recipient, None)], &asset_lock_signer, &prover, + surplus_output, None, ) .await diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index f2fe60c090f..fe4b93d58c1 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -770,9 +770,12 @@ impl PlatformWallet { // unclaimed balance specifically on input 0 (the // BTreeMap-smallest address). // - // Empty-mempool fees on Type 15 transitions land at ~20M - // credits (~0.0002 DASH). Reserve 1e9 credits (0.01 DASH) — - // 50× headroom, still trivial relative to typical balances. + // The flat shielded fee `F = compute_minimum_shielded_fee(2)` + // on a Type 15 transition lands at ~1.23e8 credits (~0.0012 + // DASH); `operations::shield` loads exactly `F` onto input 0's + // claim from this reserved headroom. Reserve 1e9 credits + // (0.01 DASH) — ~8× headroom over `F`, still trivial relative + // to typical balances. const FEE_RESERVE_CREDITS: u64 = 1_000_000_000; // Build the inputs map under the wallet-manager read lock, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 86f0964834d..515a4d3a8c2 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -26,11 +26,12 @@ use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::transition::put_settings::PutSettings; -use dpp::address_funds::OrchardAddress; +use dpp::address_funds::{OrchardAddress, PlatformAddress}; use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::fee::Credits; use dpp::prelude::AssetLockProof; use dpp::shielded::builder::{build_shield_from_asset_lock_transition_with_signer, OrchardProver}; +use dpp::shielded::compute_minimum_shielded_fee; use dpp::state_transition::proof_result::StateTransitionProofResult; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; @@ -44,6 +45,18 @@ use crate::wallet::asset_lock::orchestration::{ use crate::wallet::PlatformWallet; use crate::PlatformWalletError; +/// Number of Orchard actions in a `ShieldFromAssetLock` (and `Shield`) bundle. +/// +/// Both transitions build an *output-only* bundle with a single output via +/// `dpp::shielded::builder::build_output_only_bundle`, which configures Orchard's +/// `BundleType::Transactional { flags: SPENDS_DISABLED, bundle_required: false }`. +/// For one output and zero spends, Orchard's `num_actions` is +/// `max(max(0, 1), MIN_ACTIONS) == max(1, 2) == 2`, so the serialized bundle +/// always carries exactly two actions. Consensus prices the flat shielded fee +/// from the on-wire `actions.len()`, so this constant must match — see the +/// `validate_structure` / `transform_into_action` checks in rs-dpp / rs-drive-abci. +const SHIELD_FROM_ASSET_LOCK_NUM_ACTIONS: usize = 2; + impl PlatformWallet { /// Fund the shielded pool from a Core L1 asset lock, with the /// asset-lock proof signed by an external @@ -60,8 +73,8 @@ impl PlatformWallet { /// `Vec<(OrchardAddress, Option)>` mirroring the /// platform-address Type 14 API. Today the pre-flight enforces /// exactly one recipient with `None` credits — that recipient - /// receives the lock value minus the protocol minimum fee - /// (`required_asset_lock_duff_balance_for_processing_start_for_address_funding`). + /// receives the lock value minus the flat `pool_fee` + /// (`compute_minimum_shielded_fee(2) + asset_lock_base_cost`). /// /// When DPP grows multi-output Orchard bundles for Type 18, /// `Some(_)` values will be honored (explicit credit amounts @@ -78,15 +91,37 @@ impl PlatformWallet { /// signature on the state transition. The raw key never crosses /// the FFI boundary. /// * `prover` — Orchard prover (holds the Halo 2 proving key). + /// * `surplus_output` — Optional platform address that receives the + /// asset-lock surplus (`lock_value − shield_amount − pool_fee`). + /// + /// In this orchestrated single-recipient "remainder" flow the + /// surplus is structurally **zero**: `shield_amount` is derived as + /// `lock_value − pool_fee` (see Step 3), so the consensus surplus + /// `lock_value − shield_amount − pool_fee == 0`. With a zero + /// surplus, `None` is always consensus-valid (`0 ≤ + /// shielded_implicit_fee_cap`) and any `surplus_output` the caller + /// supplies simply receives 0 credits. + /// + /// It is threaded through to the DPP builder for API completeness + /// and forward-compatibility (multi-output / explicit-amount + /// bundles, where a real surplus can arise). Because `shield_amount` + /// is re-derived deterministically from the on-chain lock value and + /// the versioned fee constants, a fresh build and any subsequent + /// resume commit to the same `shield_amount` (hence the same zero + /// surplus) regardless of the resume call's `surplus_output` — so + /// the surplus destination cannot desync the in-flight operation + /// even though each attempt re-signs a freshly-randomized bundle. /// * `settings` — Optional `PutSettings`; `user_fee_increase` is /// bumped by the CL-height retry wrapper on consensus 10506. #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] pub async fn shielded_fund_from_asset_lock( &self, funding: AssetLockFunding, recipients: Vec<(OrchardAddress, Option)>, asset_lock_signer: &AS, prover: P, + surplus_output: Option, settings: Option, ) -> Result<(), PlatformWalletError> where @@ -121,12 +156,13 @@ impl PlatformWallet { {CREDITS_PER_DUFF} credits/duff > u64::MAX)" )) })?; - let min_fee_credits = self.shield_from_asset_lock_min_fee()?; - if lock_credits <= min_fee_credits { + let pool_fee_credits = self.shield_from_asset_lock_pool_fee()?; + if lock_credits <= pool_fee_credits { return Err(PlatformWalletError::ShieldedBuildError(format!( "asset lock ({lock_credits} credits, from {amount_duffs} duffs) is at or \ - below the protocol min fee ({min_fee_credits} credits) — refusing to \ - broadcast a single-use L1 outpoint that would be unrecoverable on resume" + below the ShieldFromAssetLock pool fee ({pool_fee_credits} credits = \ + shielded fee + asset_lock_base_cost) — refusing to broadcast a single-use \ + L1 outpoint that would be unrecoverable on resume" ))); } } @@ -194,20 +230,25 @@ impl PlatformWallet { // protocol min-fee constant (from `PlatformVersion`). // // Single-recipient + `None` semantics today: the recipient - // receives `lock_value - min_fee`. Future multi-recipient + // receives `lock_value - pool_fee`. Future multi-recipient // would honor `Some(_)` values explicitly and route the // residual to the (sole) `None` bucket; the preflight will // change in lockstep with the DPP-side multi-output bundle // builder. let asset_lock_value_credits = lookup_asset_lock_value_credits(self, &proof, tracked_out_point.as_ref()).await?; - let min_fee_credits = self.shield_from_asset_lock_min_fee()?; + // `pool_fee = compute_minimum_shielded_fee(2) + asset_lock_base_cost` — the SAME flat fee + // consensus charges (`transform_into_action` Step 3b). Deriving `shield_amount = + // lock_value − pool_fee` reserves room for the fee and pins the consensus surplus + // (`lock_value − shield_amount − pool_fee`) to exactly zero. + let pool_fee_credits = self.shield_from_asset_lock_pool_fee()?; let shield_amount = asset_lock_value_credits - .checked_sub(min_fee_credits) + .checked_sub(pool_fee_credits) .ok_or_else(|| { PlatformWalletError::ShieldedBuildError(format!( "asset lock value ({asset_lock_value_credits} credits) is below the \ - minimum required fee ({min_fee_credits} credits) for ShieldFromAssetLock" + ShieldFromAssetLock pool fee ({pool_fee_credits} credits = shielded fee + \ + asset_lock_base_cost)" )) })?; if shield_amount == 0 { @@ -215,6 +256,32 @@ impl PlatformWallet { "shield amount after fee is zero".to_string(), )); } + + // Surplus is structurally zero in this remainder flow (`shield_amount == lock_value − + // pool_fee`), so `None` is always consensus-valid. Defensively assert the cap invariant + // and surface a clear error rather than building a transition consensus would reject — + // this guards future code paths that might leave a non-zero residual. + let surplus = asset_lock_value_credits + .checked_sub(shield_amount) + .and_then(|v| v.checked_sub(pool_fee_credits)) + .unwrap_or(0); + if surplus_output.is_none() { + let implicit_fee_cap = self + .sdk + .version() + .drive_abci + .validation_and_processing + .event_constants + .shielded_implicit_fee_cap; + if surplus > implicit_fee_cap { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "ShieldFromAssetLock surplus ({surplus} credits) exceeds the implicit fee cap \ + ({implicit_fee_cap} credits) and no surplus_output address was supplied — \ + consensus would reject this transition; pass a surplus_output to receive the \ + remainder" + ))); + } + } let (recipient, _) = *recipients.first().expect("preflight enforces len() == 1"); // Step 4: submit. Two Platform-side fallback layers — matching @@ -247,6 +314,7 @@ impl PlatformWallet { path.clone(), asset_lock_signer, &prover, + surplus_output, s, ) }) @@ -282,6 +350,7 @@ impl PlatformWallet { path.clone(), asset_lock_signer, &prover, + surplus_output, s, ) }) @@ -328,30 +397,52 @@ impl PlatformWallet { tracing::info!( shield_amount, asset_lock_value_credits, - min_fee_credits, + pool_fee_credits, "Shielded fund-from-asset-lock succeeded" ); Ok(()) } - /// Minimum fee for a `ShieldFromAssetLock` (Type 18) state - /// transition, in credits. Read from - /// `dpp.state_transitions.identities.asset_locks` — the same - /// constant Type 14 (address funding) and Platform's - /// `StateTransitionEstimatedFeeValidation` use for Type 18. - fn shield_from_asset_lock_min_fee(&self) -> Result { + /// The flat pool fee for a `ShieldFromAssetLock` (Type 18) state + /// transition, in credits. + /// + /// Mirrors the consensus fee (`transform_into_action` Step 3b): + /// + /// ```text + /// pool_fee = compute_minimum_shielded_fee(num_actions) [Halo2 proof + per-action] + /// + asset_lock_base_cost [L1 asset-lock processing] + /// ``` + /// + /// `num_actions` is fixed at [`SHIELD_FROM_ASSET_LOCK_NUM_ACTIONS`] (the + /// single-output bundle always serializes to 2 Orchard actions). + /// `asset_lock_base_cost` (`albc`) is the same constant Type 14 (address + /// funding) uses, read from `dpp.state_transitions.identities.asset_locks` + /// and converted duffs→credits. + fn shield_from_asset_lock_pool_fee(&self) -> Result { let pv = self.sdk.version(); - let min_fee_duffs = pv + let albc_duffs = pv .dpp .state_transitions .identities .asset_locks .required_asset_lock_duff_balance_for_processing_start_for_address_funding; - min_fee_duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { + let albc = albc_duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "asset_lock_base_cost constant overflowed credits conversion \ + ({albc_duffs} duffs * {CREDITS_PER_DUFF} credits/duff > u64::MAX)" + )) + })?; + let shielded_fee = compute_minimum_shielded_fee(SHIELD_FROM_ASSET_LOCK_NUM_ACTIONS, pv) + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "failed to compute minimum shielded fee for ShieldFromAssetLock: {e}" + )) + })?; + shielded_fee.checked_add(albc).ok_or_else(|| { PlatformWalletError::ShieldedBuildError(format!( - "protocol min-fee constant overflowed credits conversion \ - ({min_fee_duffs} duffs * {CREDITS_PER_DUFF} credits/duff > u64::MAX)" + "ShieldFromAssetLock pool fee overflowed credits conversion \ + (shielded_fee {shielded_fee} + asset_lock_base_cost {albc} > u64::MAX)" )) }) } @@ -419,6 +510,7 @@ async fn build_and_broadcast_shielded( path: ::key_wallet::bip32::DerivationPath, asset_lock_signer: &AS, prover: &P, + surplus_output: Option, settings: Option, ) -> Result<(), dash_sdk::Error> where @@ -433,6 +525,7 @@ where asset_lock_signer, prover, [0u8; 36], + surplus_output, sdk.version(), ) .await?; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs index 3ddfcb704e9..05e0f3d9e4b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs @@ -6,8 +6,50 @@ use super::store::ShieldedNote; use crate::error::PlatformWalletError; -use dpp::shielded::compute_minimum_shielded_fee; +use dpp::fee::Credits; +use dpp::shielded::{ + compute_minimum_shielded_fee, compute_shielded_unshield_fee, compute_shielded_withdrawal_fee, +}; use dpp::version::PlatformVersion; +use dpp::ProtocolError; + +/// Which consensus fee formula the wallet must reserve notes against for a spend. +/// +/// The flat shielded fee charged at execution time differs by transition: ShieldedTransfer is +/// carved with [`compute_minimum_shielded_fee`] (the base), Unshield with +/// [`compute_shielded_unshield_fee`] (the base PLUS the flat `AddBalanceToAddress` output-write +/// cost), and ShieldedWithdrawal with [`compute_shielded_withdrawal_fee`] (the base PLUS the flat +/// Core withdrawal-document storage cost). Note selection must reserve against the SAME formula the +/// builder/consensus will charge, otherwise it under-funds the spend (the builder then rejects +/// it, or — in debug — the `fee_used == exact_fee` assertion fails). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShieldedFeeKind { + /// `compute_minimum_shielded_fee` — ShieldedTransfer (the base). + Base, + /// `compute_shielded_unshield_fee` — Unshield (adds the flat `AddBalanceToAddress` output-write cost). + Unshield, + /// `compute_shielded_withdrawal_fee` — ShieldedWithdrawal (adds the flat withdrawal-document cost). + Withdrawal, +} + +impl ShieldedFeeKind { + /// Compute the flat shielded fee for `num_actions` under this transition's formula. + fn compute( + self, + num_actions: usize, + platform_version: &PlatformVersion, + ) -> Result { + match self { + ShieldedFeeKind::Base => compute_minimum_shielded_fee(num_actions, platform_version), + ShieldedFeeKind::Unshield => { + compute_shielded_unshield_fee(num_actions, platform_version) + } + ShieldedFeeKind::Withdrawal => { + compute_shielded_withdrawal_fee(num_actions, platform_version) + } + } + } +} /// Select unspent notes to cover `amount + fee` using a greedy algorithm. /// @@ -71,21 +113,31 @@ pub fn select_notes( /// 3. Compute exact fee from actual note count /// 4. If insufficient, re-select with exact fee; repeat (converges in 2-3 iterations) /// +/// `fee_kind` selects which consensus fee formula to reserve against: pass +/// [`ShieldedFeeKind::Withdrawal`] for a ShieldedWithdrawal (so the flat Core +/// withdrawal-document cost is reserved too), [`ShieldedFeeKind::Unshield`] for an Unshield (so the +/// flat `AddBalanceToAddress` output-write cost is reserved too), and [`ShieldedFeeKind::Base`] for +/// ShieldedTransfer. This MUST match the fee the builder/consensus will charge, otherwise the spend +/// is under-funded. +/// /// Returns the selected notes, total input value, and the exact fee. pub fn select_notes_with_fee<'a>( unspent: &'a [ShieldedNote], amount: u64, min_actions: usize, + fee_kind: ShieldedFeeKind, platform_version: &PlatformVersion, ) -> Result<(Vec<&'a ShieldedNote>, u64, u64), PlatformWalletError> { - let mut fee_estimate = compute_minimum_shielded_fee(min_actions, platform_version) + let mut fee_estimate = fee_kind + .compute(min_actions, platform_version) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; for _ in 0..5 { let selected = select_notes(unspent, amount, fee_estimate)?; let total: u64 = selected.iter().map(|n| n.value).sum(); let num_actions = selected.len().max(min_actions); - let exact_fee = compute_minimum_shielded_fee(num_actions, platform_version) + let exact_fee = fee_kind + .compute(num_actions, platform_version) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; if total >= amount.saturating_add(exact_fee) { @@ -99,7 +151,8 @@ pub fn select_notes_with_fee<'a>( let selected = select_notes(unspent, amount, fee_estimate)?; let total: u64 = selected.iter().map(|n| n.value).sum(); let num_actions = selected.len().max(min_actions); - let exact_fee = compute_minimum_shielded_fee(num_actions, platform_version) + let exact_fee = fee_kind + .compute(num_actions, platform_version) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; if total < amount.saturating_add(exact_fee) { @@ -203,7 +256,8 @@ mod tests { let notes = vec![test_note(amount + min_fee_2 + 5, 0)]; let (selected, total, exact_fee) = - select_notes_with_fee(¬es, amount, 2, platform_version).expect("selection ok"); + select_notes_with_fee(¬es, amount, 2, ShieldedFeeKind::Base, platform_version) + .expect("selection ok"); assert_eq!(selected.len(), 1); assert_eq!(total, amount + min_fee_2 + 5); @@ -222,7 +276,8 @@ mod tests { let notes: Vec = (0..20).map(|i| test_note(note_val, i)).collect(); let (selected, total, exact_fee) = - select_notes_with_fee(¬es, amount, 2, platform_version).expect("selection ok"); + select_notes_with_fee(¬es, amount, 2, ShieldedFeeKind::Base, platform_version) + .expect("selection ok"); let expected_fee = compute_minimum_shielded_fee(selected.len().max(2), platform_version).unwrap(); @@ -236,4 +291,77 @@ mod tests { ); assert!(selected.len() >= 2, "expected multiple notes selected"); } + + #[test] + fn test_select_notes_with_fee_withdrawal_reserves_document_cost() { + // A ShieldedWithdrawal must reserve against `compute_shielded_withdrawal_fee` (base + + // flat document cost), which is strictly larger than `compute_minimum_shielded_fee`. + // Reserving with `ShieldedFeeKind::Withdrawal` must therefore return the higher fee. + let platform_version = PlatformVersion::latest(); + let base_fee_2 = compute_minimum_shielded_fee(2, platform_version) + .expect("fee computation should not overflow"); + let withdrawal_fee_2 = compute_shielded_withdrawal_fee(2, platform_version) + .expect("fee computation should not overflow"); + assert!( + withdrawal_fee_2 > base_fee_2, + "withdrawal fee must exceed the base shielded fee (it includes the document cost)" + ); + + let amount = 1_000_000u64; + // A single note that covers amount + the 2-action WITHDRAWAL fee exactly. + let notes = vec![test_note(amount + withdrawal_fee_2, 0)]; + + let (selected, total, exact_fee) = select_notes_with_fee( + ¬es, + amount, + 2, + ShieldedFeeKind::Withdrawal, + platform_version, + ) + .expect("selection ok"); + + assert_eq!(selected.len(), 1); + assert_eq!(total, amount + withdrawal_fee_2); + assert_eq!( + exact_fee, withdrawal_fee_2, + "withdrawal note selection must reserve the withdrawal-inclusive fee" + ); + } + + #[test] + fn test_select_notes_with_fee_unshield_reserves_address_write_cost() { + // An Unshield must reserve against `compute_shielded_unshield_fee` (base + flat + // `AddBalanceToAddress` output-write cost), which is strictly larger than + // `compute_minimum_shielded_fee`. Reserving with `ShieldedFeeKind::Unshield` must therefore + // return the higher fee. + let platform_version = PlatformVersion::latest(); + let base_fee_2 = compute_minimum_shielded_fee(2, platform_version) + .expect("fee computation should not overflow"); + let unshield_fee_2 = compute_shielded_unshield_fee(2, platform_version) + .expect("fee computation should not overflow"); + assert!( + unshield_fee_2 > base_fee_2, + "unshield fee must exceed the base shielded fee (it includes the address-write cost)" + ); + + let amount = 1_000_000u64; + // A single note that covers amount + the 2-action UNSHIELD fee exactly. + let notes = vec![test_note(amount + unshield_fee_2, 0)]; + + let (selected, total, exact_fee) = select_notes_with_fee( + ¬es, + amount, + 2, + ShieldedFeeKind::Unshield, + platform_version, + ) + .expect("selection ok"); + + assert_eq!(selected.len(), 1); + assert_eq!(total, amount + unshield_fee_2); + assert_eq!( + exact_fee, unshield_fee_2, + "unshield note selection must reserve the unshield-inclusive fee" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 0cfe730a983..7e2588ad8c4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -18,7 +18,7 @@ //! - **Withdraw** (Type 19): shielded pool → Core L1 address use super::keys::OrchardKeySet; -use super::note_selection::select_notes_with_fee; +use super::note_selection::{select_notes_with_fee, ShieldedFeeKind}; use super::store::{ShieldedNote, ShieldedStore, SubwalletId}; use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; use crate::error::PlatformWalletError; @@ -39,12 +39,25 @@ use dpp::shielded::builder::{ build_shield_transition, build_shielded_transfer_transition, build_shielded_withdrawal_transition, build_unshield_transition, OrchardProver, SpendableNote, }; +use dpp::shielded::compute_minimum_shielded_fee; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::withdrawal::Pooling; use grovedb_commitment_tree::{Anchor, PaymentAddress}; use tokio::sync::RwLock; use tracing::{info, trace, warn}; +/// Number of Orchard actions in a `Shield` (Type 15) bundle. +/// +/// `build_shield_transition` builds an *output-only* bundle with a single +/// output (`build_output_only_bundle`), configured as Orchard's +/// `BundleType::Transactional { flags: SPENDS_DISABLED, bundle_required: false }`. +/// For one output and zero spends, Orchard's `num_actions` is +/// `max(max(0, 1), MIN_ACTIONS) == max(1, 2) == 2`, so the serialized bundle +/// always carries exactly two actions. Consensus prices the flat shielded fee +/// `F = compute_minimum_shielded_fee(actions.len())` from the on-wire action +/// count, so the wallet's fee reservation must use the same count. +const SHIELD_NUM_ACTIONS: usize = 2; + /// Try to extract a structured `AddressesNotEnoughFundsError` from /// a broadcast error so the shield path can format a diagnostic /// that includes Platform's actual per-input view (nonce + balance) @@ -143,6 +156,37 @@ fn queue_shielded_changeset( // Shield: platform addresses -> shielded pool (Type 15) // ------------------------------------------------------------------------- +/// Add the flat shielded fee `fee` to the smallest-key input's claim. +/// +/// The Shield fee strategy is `DeductFromInput(0)`, where "input 0" is the +/// BTreeMap-smallest address. Consensus requires the input claims to sum to at +/// least `amount + fee`; the caller's selection claims exactly `amount` and +/// reserves the fee headroom as *unclaimed* balance on input 0, so loading the +/// fee onto that same input both satisfies the `Σ inputs >= amount + fee` +/// structure check and keeps the fee-payer aligned with the fee strategy. +/// +/// Errors if `inputs` is empty (no input to carry the fee) or the addition +/// overflows. +fn reserve_shield_fee_on_input_0( + mut inputs: BTreeMap, + fee: Credits, +) -> Result, PlatformWalletError> { + let Some((&input_0_addr, _)) = inputs.iter().next() else { + return Err(PlatformWalletError::ShieldedBuildError( + "shield has no inputs to carry the shielded fee".to_string(), + )); + }; + let claim = inputs + .get_mut(&input_0_addr) + .expect("input_0_addr was just read from the map"); + *claim = claim.checked_add(fee).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "input 0 claim + shielded fee overflows u64".to_string(), + ) + })?; + Ok(inputs) +} + /// Shield credits from transparent platform addresses into the /// shielded pool, with the resulting note assigned to `account`'s /// default Orchard payment address derived from `keys`. @@ -158,6 +202,27 @@ pub async fn shield, P: OrchardProver>( ) -> Result<(), PlatformWalletError> { let recipient_addr = default_orchard_address(keys)?; + // Reserve the flat shielded fee `F` on top of `amount` in the input + // claims. Consensus `validate_structure` (rs-dpp) now rejects a Shield + // unless `Σ inputs >= amount + F`, where + // `F = compute_minimum_shielded_fee(num_actions)` and `num_actions` is + // the serialized output-only bundle's action count. That bundle has a + // single output and spends disabled, so Orchard pads it to exactly + // SHIELD_NUM_ACTIONS == 2 actions (see the constant doc below). We + // mirror `note_selection.rs`'s spend-side fee math. + // + // The fee is loaded onto the smallest-key input — the `DeductFromInput(0)` + // fee-strategy payer (input 0 == BTreeMap-smallest address). The caller + // (`shielded_shield_from_account`) reserves ~1e9 credits of unclaimed + // headroom on input 0 specifically for this, and `F` (~1.2e8 credits) + // fits well within it. Inflating the claim BEFORE the fetch lets the + // single hard balance check below validate the fee-inclusive claim + // against the on-chain balance in one shot — no second round-trip and + // no claim that outruns its balance check. + let fee = compute_minimum_shielded_fee(SHIELD_NUM_ACTIONS, sdk.version()) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + let inputs = reserve_shield_fee_on_input_0(inputs, fee)?; + // Reuse rs-sdk's canonical fetch + hard balance check rather than // re-implementing the fetch-and-validate dance. Unlike the old // warn-and-proceed path, `fetch_inputs_with_nonce` errors with @@ -286,8 +351,13 @@ pub async fn unshield( // Reserve against the 2-action floor: Orchard's BundleType::DEFAULT pads single-spend // bundles to 2 actions, and the builder prices the fee at spends.len().max(2). Reserving // for 1 would under-fee a single-note transition and the builder would reject it locally. + // Unshield is carved with `compute_shielded_unshield_fee` (the base fee PLUS the flat storage + // cost of the single `AddBalanceToAddress` write crediting the net to the output address), so + // reserve against `ShieldedFeeKind::Unshield` — reserving the base fee here would under-fund the + // address-write cost and the builder would reject the spend (and the `fee_used == exact_fee` + // debug assert below would fire). let (selected_notes, total_input, exact_fee) = - reserve_unspent_notes(sdk, store, id, amount, 2).await?; + reserve_unspent_notes(sdk, store, id, amount, 2, ShieldedFeeKind::Unshield).await?; info!( account, @@ -319,10 +389,10 @@ pub async fn unshield( ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; // The builder's fee and the wallet's reserved `exact_fee` both come from - // compute_minimum_shielded_fee with the same action count; lock that they agree. + // compute_shielded_unshield_fee with the same action count; lock that they agree. debug_assert_eq!( fee_used, exact_fee, - "builder fee must match the reserved minimum fee" + "builder fee must match the reserved unshield fee" ); trace!("Unshield: state transition built, broadcasting..."); @@ -391,8 +461,10 @@ pub async fn transfer( let change_addr = default_orchard_address(keys)?; let id = SubwalletId::new(wallet_id, account); + // ShieldedTransfer is carved with the base `compute_minimum_shielded_fee`, so reserve + // against `ShieldedFeeKind::Base`. let (selected_notes, total_input, exact_fee) = - reserve_unspent_notes(sdk, store, id, amount, 2).await?; + reserve_unspent_notes(sdk, store, id, amount, 2, ShieldedFeeKind::Base).await?; info!( account, @@ -488,8 +560,13 @@ pub async fn withdraw( // Reserve against the 2-action floor: Orchard's BundleType::DEFAULT pads single-spend // bundles to 2 actions, and the builder prices the fee at spends.len().max(2). Reserving // for 1 would under-fee a single-note transition and the builder would reject it locally. + // ShieldedWithdrawal is carved with `compute_shielded_withdrawal_fee` (the base fee PLUS the + // flat Core withdrawal-document storage cost), so reserve against + // `ShieldedFeeKind::Withdrawal` — reserving the base fee here would under-fund the document + // cost and the builder would reject the spend (and the `fee_used == exact_fee` debug assert + // below would fire). let (selected_notes, total_input, exact_fee) = - reserve_unspent_notes(sdk, store, id, amount, 2).await?; + reserve_unspent_notes(sdk, store, id, amount, 2, ShieldedFeeKind::Withdrawal).await?; info!( account, @@ -522,10 +599,10 @@ pub async fn withdraw( ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; // The builder's fee and the wallet's reserved `exact_fee` both come from - // compute_minimum_shielded_fee with the same action count; lock that they agree. + // compute_shielded_withdrawal_fee with the same action count; lock that they agree. debug_assert_eq!( fee_used, exact_fee, - "builder fee must match the reserved minimum fee" + "builder fee must match the reserved withdrawal fee" ); trace!("Shielded withdrawal: state transition built, broadcasting..."); @@ -707,13 +784,14 @@ async fn reserve_unspent_notes( id: SubwalletId, amount: u64, outputs: usize, + fee_kind: ShieldedFeeKind, ) -> Result<(Vec, u64, u64), PlatformWalletError> { let mut store = store.write().await; let unspent = store .get_unspent_notes(id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; let (selected, total_input, exact_fee) = - select_notes_with_fee(&unspent, amount, outputs, sdk.version())?.into_owned(); + select_notes_with_fee(&unspent, amount, outputs, fee_kind, sdk.version())?.into_owned(); for note in &selected { store .mark_pending(id, ¬e.nullifier) @@ -807,3 +885,48 @@ fn deserialize_note(data: &[u8]) -> Option { Note::from_parts(recipient, value, rho, rseed).into_option() } + +#[cfg(test)] +mod reserve_shield_fee_tests { + use super::*; + + fn addr(b: u8) -> PlatformAddress { + PlatformAddress::P2pkh([b; 20]) + } + + #[test] + fn loads_fee_onto_smallest_key_input() { + // Input 0 is the BTreeMap-smallest address (addr(1)); the fee must + // land there, matching the `DeductFromInput(0)` fee strategy. + let mut inputs = BTreeMap::new(); + inputs.insert(addr(2), 5_000_000u64); + inputs.insert(addr(1), 1_000_000u64); + + let fee = 123_097_600u64; + let out = reserve_shield_fee_on_input_0(inputs, fee).expect("non-empty inputs"); + + assert_eq!(out.get(&addr(1)), Some(&(1_000_000 + fee))); + assert_eq!( + out.get(&addr(2)), + Some(&5_000_000), + "other inputs untouched" + ); + // Σ claims grew by exactly `fee`, satisfying `Σ inputs >= amount + F`. + assert_eq!(out.values().sum::(), 6_000_000 + fee); + } + + #[test] + fn errors_on_empty_inputs() { + let inputs: BTreeMap = BTreeMap::new(); + let err = reserve_shield_fee_on_input_0(inputs, 1).expect_err("empty must reject"); + assert!(matches!(err, PlatformWalletError::ShieldedBuildError(_))); + } + + #[test] + fn errors_on_claim_plus_fee_overflow() { + let mut inputs = BTreeMap::new(); + inputs.insert(addr(1), u64::MAX); + let err = reserve_shield_fee_on_input_0(inputs, 1).expect_err("overflow must reject"); + assert!(matches!(err, PlatformWalletError::ShieldedBuildError(_))); + } +} diff --git a/packages/rs-sdk/src/platform/transition/shield_from_asset_lock.rs b/packages/rs-sdk/src/platform/transition/shield_from_asset_lock.rs index 7e3f56e302e..7448bc3dc73 100644 --- a/packages/rs-sdk/src/platform/transition/shield_from_asset_lock.rs +++ b/packages/rs-sdk/src/platform/transition/shield_from_asset_lock.rs @@ -13,12 +13,19 @@ pub trait ShieldFromAssetLock { /// Shield funds from an L1 asset lock into the shielded pool. /// The asset lock proof proves ownership of L1 funds, and the ECDSA signature /// binds those funds to this specific Orchard bundle. + /// + /// `surplus_output` optionally routes the asset-lock remainder (lock value minus + /// the shielded amount and the pool fee) to a platform address. When `None`, the + /// surplus is implicitly donated to the fee pools, which consensus only permits up + /// to `shielded_implicit_fee_cap` — supply an address to receive a larger remainder. + #[allow(clippy::too_many_arguments)] async fn shield_from_asset_lock( &self, asset_lock_proof: AssetLockProof, asset_lock_proof_private_key: &[u8], bundle: OrchardBundleParams, value_balance: u64, + surplus_output: Option, settings: Option, ) -> Result<(), Error>; } @@ -31,6 +38,7 @@ impl ShieldFromAssetLock for Sdk { asset_lock_proof_private_key: &[u8], bundle: OrchardBundleParams, value_balance: u64, + surplus_output: Option, settings: Option, ) -> Result<(), Error> { let OrchardBundleParams { @@ -48,6 +56,7 @@ impl ShieldFromAssetLock for Sdk { anchor, proof, binding_signature, + surplus_output, self.version(), )?; ensure_valid_state_transition_structure(&state_transition, self.version())?; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift index 85e5d6cb8df..52b0703a613 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -66,11 +66,21 @@ extension PlatformWalletManager { /// empty or multi-recipient lists). Each recipient's /// `credits` becomes the Orchard `value_balance` for that /// output. + /// - surplusOutput: Optional platform address (raw 21-byte + /// `PlatformAddress` storage bytes: 1-byte variant tag + + /// 20-byte hash) to receive the asset-lock surplus + /// (`lock_value − shield_amount − pool_fee`). Pass `nil` for + /// none. In today's single-recipient "remainder" flow the + /// surplus is structurally zero (the recipient receives + /// `lock_value − pool_fee`), so `nil` is always valid; the + /// parameter is exposed for parity with the Rust builder and + /// forward-compatibility with multi-output bundles. public func shieldedFundFromAssetLock( walletId: Data, fundingAccountIndex: UInt32, amountDuffs: UInt64, - recipients: [ShieldedFundFromAssetLockRecipient] + recipients: [ShieldedFundFromAssetLockRecipient], + surplusOutput: Data? = nil ) async throws { try shieldedFundFromAssetLockPreflight( walletId: walletId, @@ -106,17 +116,21 @@ extension PlatformWalletManager { "recipient baseAddress is nil" ) } - let result = withExtendedLifetime(coreSigner) { - platform_wallet_manager_shielded_fund_from_asset_lock( - handle, - widPtr, - fundingAccountIndex, - amountDuffs, - recipientPtr, - coreSigner.handle - ) + try Self.withOptionalSurplusOutput(surplusOutput) { surplusPtr, surplusLen in + let result = withExtendedLifetime(coreSigner) { + platform_wallet_manager_shielded_fund_from_asset_lock( + handle, + widPtr, + fundingAccountIndex, + amountDuffs, + recipientPtr, + surplusPtr, + surplusLen, + coreSigner.handle + ) + } + try result.check() } - try result.check() } } }.value @@ -142,11 +156,18 @@ extension PlatformWalletManager { /// locks built by this wallet, but kept for generality). /// - recipients: Destination addresses (single-entry today; /// same preflight as the fresh-build variant). + /// - surplusOutput: Optional surplus-output platform address — + /// see `shieldedFundFromAssetLock`. The surplus is structurally + /// zero in this flow and the Rust side re-derives an identical + /// `shield_amount` on every attempt, so a resume cannot desync + /// the surplus destination regardless of this value; pass the + /// same value used on the original attempt (typically `nil`). public func shieldedResumeFundFromAssetLock( walletId: Data, outPointTxid: Data, outPointVout: UInt32, - recipients: [ShieldedFundFromAssetLockRecipient] + recipients: [ShieldedFundFromAssetLockRecipient], + surplusOutput: Data? = nil ) async throws { guard outPointTxid.count == 32 else { throw PlatformWalletError.invalidParameter( @@ -197,16 +218,20 @@ extension PlatformWalletManager { "recipient baseAddress is nil" ) } - let result = withExtendedLifetime(coreSigner) { - platform_wallet_manager_shielded_resume_fund_from_asset_lock( - handle, - widPtr, - &outPoint, - recipientPtr, - coreSigner.handle - ) + try Self.withOptionalSurplusOutput(surplusOutput) { surplusPtr, surplusLen in + let result = withExtendedLifetime(coreSigner) { + platform_wallet_manager_shielded_resume_fund_from_asset_lock( + handle, + widPtr, + &outPoint, + recipientPtr, + surplusPtr, + surplusLen, + coreSigner.handle + ) + } + try result.check() } - try result.check() } } }.value @@ -265,4 +290,36 @@ extension PlatformWalletManager { } } } + + /// Run `body` with a `(pointer, length)` view of the optional + /// surplus-output address bytes. + /// + /// `nil` (or empty) yields `(nil, 0)`, which the FFI reads as "no + /// surplus output". A non-nil value is pinned for the call via + /// `withUnsafeBytes` so the pointer is valid for the duration of + /// `body`. The FFI expects raw `PlatformAddress` storage bytes + /// (1-byte variant tag + 20-byte hash); it validates the encoding + /// and returns an error for malformed input, so no length check is + /// duplicated here. + /// + /// `nonisolated` because `PlatformWalletManager` is + /// `@MainActor`-isolated by default and the call sites run inside + /// the synchronous, off-main-actor `Task.detached` bodies — this is + /// pure byte marshalling that reads no `PlatformWalletManager` state. + nonisolated private static func withOptionalSurplusOutput( + _ surplusOutput: Data?, + _ body: (UnsafePointer?, UInt) throws -> R + ) throws -> R { + guard let surplusOutput, !surplusOutput.isEmpty else { + return try body(nil, 0) + } + return try surplusOutput.withUnsafeBytes { raw in + guard let ptr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + throw PlatformWalletError.invalidParameter( + "surplusOutput baseAddress is nil" + ) + } + return try body(ptr, UInt(raw.count)) + } + } } diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index d1966361478..f7c96ff8fb1 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -95,7 +95,7 @@ use dpp::consensus::state::shielded::insufficient_shielded_fee_error::Insufficie use dpp::consensus::state::shielded::invalid_anchor_error::InvalidAnchorError; use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; -use dpp::consensus::basic::state_transition::{StateTransitionNotActiveError, TransitionOverMaxInputsError, TransitionOverMaxOutputsError, InputWitnessCountMismatchError, TransitionNoInputsError, TransitionNoOutputsError, FeeStrategyEmptyError, FeeStrategyDuplicateError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, OutputBelowMinimumError, InputOutputBalanceMismatchError, OutputsNotGreaterThanInputsError, WithdrawalBalanceMismatchError, InsufficientFundingAmountError, InputsNotLessThanOutputsError, OutputAddressAlsoInputError, InvalidRemainderOutputCountError, WithdrawalBelowMinAmountError, ShieldedNoActionsError, ShieldedTooManyActionsError, ShieldedEmptyProofError, ShieldedZeroAnchorError, ShieldedInvalidValueBalanceError, ShieldedEncryptedNoteSizeMismatchError}; +use dpp::consensus::basic::state_transition::{StateTransitionNotActiveError, TransitionOverMaxInputsError, TransitionOverMaxOutputsError, InputWitnessCountMismatchError, TransitionNoInputsError, TransitionNoOutputsError, FeeStrategyEmptyError, FeeStrategyDuplicateError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, OutputBelowMinimumError, InputOutputBalanceMismatchError, OutputsNotGreaterThanInputsError, WithdrawalBalanceMismatchError, InsufficientFundingAmountError, InputsNotLessThanOutputsError, OutputAddressAlsoInputError, InvalidRemainderOutputCountError, WithdrawalBelowMinAmountError, ShieldedNoActionsError, ShieldedTooManyActionsError, ShieldedEmptyProofError, ShieldedZeroAnchorError, ShieldedInvalidValueBalanceError, ShieldedEncryptedNoteSizeMismatchError, ShieldedImplicitFeeCapExceededError}; use dpp::consensus::state::voting::masternode_incorrect_voter_identity_id_error::MasternodeIncorrectVoterIdentityIdError; use dpp::consensus::state::voting::masternode_incorrect_voting_address_error::MasternodeIncorrectVotingAddressError; use dpp::consensus::state::voting::masternode_not_found_error::MasternodeNotFoundError; @@ -964,6 +964,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::ShieldedEncryptedNoteSizeMismatchError(e) => { generic_consensus_error!(ShieldedEncryptedNoteSizeMismatchError, e).into() } + BasicError::ShieldedImplicitFeeCapExceededError(e) => { + generic_consensus_error!(ShieldedImplicitFeeCapExceededError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/platform_address/address.rs b/packages/wasm-dpp2/src/platform_address/address.rs index 546a12dc3dc..0b3377a4d73 100644 --- a/packages/wasm-dpp2/src/platform_address/address.rs +++ b/packages/wasm-dpp2/src/platform_address/address.rs @@ -113,6 +113,20 @@ impl TryFrom for PlatformAddressWasm { let uint8_array = Uint8Array::from(value.clone()); let bytes = uint8_array.to_vec(); + // A serialized PlatformAddress is exactly 21 bytes (1-byte variant tag + 20-byte hash). + // `from_bytes` decodes via bincode, which does NOT require full-slice consumption, so an + // over-length buffer with a valid 21-byte prefix would otherwise be silently truncated. + // Since `surplus_output` is part of the signed `ShieldFromAssetLock` transition body, a + // caller passing 22+ bytes would sign over the truncated 21-byte prefix — routing funds + // to a different destination with a still-valid signature. Reject any non-21-byte input + // (matching the C FFI's `parse_optional_surplus_output`). + if bytes.len() != 21 { + return Err(WasmDppError::invalid_argument(format!( + "PlatformAddress must be exactly 21 bytes, got {}", + bytes.len() + ))); + } + return PlatformAddress::from_bytes(&bytes) .map(PlatformAddressWasm) .map_err(|e| WasmDppError::invalid_argument(e.to_string())); @@ -148,6 +162,14 @@ impl TryFrom<&str> for PlatformAddressWasm { e )) })?; + // Exactly 21 bytes (1-byte variant tag + 20-byte hash); reject over-length input that + // bincode would silently truncate (see the Uint8Array branch for the full rationale). + if bytes.len() != 21 { + return Err(WasmDppError::invalid_argument(format!( + "PlatformAddress must be exactly 21 bytes, got {}", + bytes.len() + ))); + } PlatformAddress::from_bytes(&bytes) .map(PlatformAddressWasm) .map_err(|e| WasmDppError::invalid_argument(e.to_string())) @@ -312,6 +334,15 @@ impl PlatformAddressWasm { /// Creates a PlatformAddress from raw bytes (21 bytes: type byte + 20-byte hash). #[wasm_bindgen(js_name = "fromBytes")] pub fn from_bytes(bytes: Vec) -> WasmDppResult { + // Exactly 21 bytes; reject over-length input that bincode would silently truncate. Since + // surplus_output is part of the signed transition body, a truncated address would route + // funds to a different destination than submitted (see the TryFrom impls / the C FFI). + if bytes.len() != 21 { + return Err(WasmDppError::invalid_argument(format!( + "PlatformAddress must be exactly 21 bytes, got {}", + bytes.len() + ))); + } PlatformAddress::from_bytes(&bytes) .map(PlatformAddressWasm) .map_err(|e| WasmDppError::invalid_argument(e.to_string())) @@ -324,6 +355,14 @@ impl PlatformAddressWasm { ) -> WasmDppResult { let bytes = hex::decode(hex_string) .map_err(|e| WasmDppError::invalid_argument(format!("Invalid hex: {}", e)))?; + // Exactly 21 bytes; reject over-length input that bincode would silently truncate (same + // truncated-surplus_output signing hazard as fromBytes). + if bytes.len() != 21 { + return Err(WasmDppError::invalid_argument(format!( + "PlatformAddress must be exactly 21 bytes, got {}", + bytes.len() + ))); + } PlatformAddress::from_bytes(&bytes) .map(PlatformAddressWasm) .map_err(|e| WasmDppError::invalid_argument(e.to_string())) diff --git a/packages/wasm-dpp2/src/shielded/shield_from_asset_lock_transition.rs b/packages/wasm-dpp2/src/shielded/shield_from_asset_lock_transition.rs index 9ca6f903a9e..580e6dc170b 100644 --- a/packages/wasm-dpp2/src/shielded/shield_from_asset_lock_transition.rs +++ b/packages/wasm-dpp2/src/shielded/shield_from_asset_lock_transition.rs @@ -1,8 +1,9 @@ use crate::asset_lock_proof::AssetLockProofWasm; use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::IdentifierWasm; +use crate::platform_address::PlatformAddressWasm; use crate::shielded::orchard_action::{SerializedOrchardActionWasm, actions_from_js_options}; -use crate::utils::{try_from_options, try_vec_to_fixed_bytes}; +use crate::utils::{try_from_options, try_from_options_optional, try_vec_to_fixed_bytes}; use crate::{impl_wasm_conversions_serde, impl_wasm_type_info}; use dpp::platform_value::BinaryData; use dpp::serialization::{PlatformDeserializable, PlatformSerializable}; @@ -41,6 +42,13 @@ export interface ShieldFromAssetLockTransitionOptions { proof: Uint8Array; bindingSignature: Uint8Array; signature: Uint8Array; + /** + * Optional platform address that receives the asset-lock surplus + * (`assetLockValue − valueBalance − fee`). When omitted, the surplus is + * folded into the fee pools, but only up to the implicit fee cap. + * Accepts a PlatformAddress, a 21-byte Uint8Array, or a bech32m string. + */ + surplusOutput?: PlatformAddressLike; } /** @@ -55,6 +63,7 @@ export interface ShieldFromAssetLockTransitionObject { proof: Uint8Array; bindingSignature: Uint8Array; signature: Uint8Array; + surplusOutput?: Uint8Array; } /** @@ -69,6 +78,7 @@ export interface ShieldFromAssetLockTransitionJSON { proof: string; bindingSignature: string; signature: string; + surplusOutput?: string; } "#; @@ -110,6 +120,11 @@ impl ShieldFromAssetLockTransitionWasm { // Extract WASM class instances (borrow &options) let asset_lock: AssetLockProofWasm = try_from_options(&options, "assetLockProof")?; let actions = actions_from_js_options(options.as_ref(), "actions")?; + // Optional platform-address output for the asset-lock surplus. + // Accepts a PlatformAddress, a 21-byte Uint8Array, or a bech32m string; + // absent / null / undefined → `None`. + let surplus_output: Option = + try_from_options_optional(options.as_ref(), "surplusOutput")?; // Extract remaining simple fields via serde (consumes options) let fields: ShieldFromAssetLockTransitionSimpleFields = @@ -128,6 +143,7 @@ impl ShieldFromAssetLockTransitionWasm { anchor, proof: fields.proof, binding_signature, + surplus_output: surplus_output.map(Into::into), signature: BinaryData::from(fields.signature), }), )) @@ -164,6 +180,17 @@ impl ShieldFromAssetLockTransitionWasm { } } + /// Returns the optional surplus-output platform address, or + /// `undefined` when the surplus folds into the fee pools. + #[wasm_bindgen(getter = "surplusOutput")] + pub fn surplus_output(&self) -> Option { + match &self.0 { + ShieldFromAssetLockTransition::V0(v0) => { + v0.surplus_output.map(PlatformAddressWasm::from) + } + } + } + /// Returns the anchor (32-byte Merkle root). #[wasm_bindgen(getter = "anchor")] pub fn anchor(&self) -> Vec { diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs index 85638e707db..3859416d039 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs @@ -15,8 +15,8 @@ use super::identity::{ VerifiedBalanceTransferWasm, VerifiedIdentityWasm, VerifiedPartialIdentityWasm, }; use super::shielded::{ - VerifiedAssetLockConsumedWasm, VerifiedShieldedNullifiersWasm, - VerifiedShieldedNullifiersWithAddressInfosWasm, + VerifiedAssetLockConsumedWasm, VerifiedAssetLockConsumedWithAddressInfosWasm, + VerifiedShieldedNullifiersWasm, VerifiedShieldedNullifiersWithAddressInfosWasm, VerifiedShieldedNullifiersWithWithdrawalDocumentWasm, }; use super::token::{ @@ -64,6 +64,7 @@ export type StateTransitionProofResultType = | VerifiedIdentityFullWithAddressInfos | VerifiedIdentityWithAddressInfos | VerifiedAssetLockConsumed + | VerifiedAssetLockConsumedWithAddressInfos | VerifiedShieldedNullifiers | VerifiedShieldedNullifiersWithAddressInfos | VerifiedShieldedNullifiersWithWithdrawalDocument; @@ -255,6 +256,27 @@ pub fn convert_proof_result( VerifiedAssetLockConsumedWasm::new(status, initial, remaining).into() } + StateTransitionProofResult::VerifiedAssetLockConsumedWithAddressInfos(info, infos) => { + use dpp::asset_lock::StoredAssetLockInfo; + use dpp::asset_lock::reduced_asset_lock_value::AssetLockValueGettersV0; + let (status, initial, remaining) = match info { + StoredAssetLockInfo::FullyConsumed => ("FullyConsumed".to_string(), None, None), + StoredAssetLockInfo::PartiallyConsumed(val) => ( + "PartiallyConsumed".to_string(), + Some(val.initial_credit_value()), + Some(val.remaining_credit_value()), + ), + StoredAssetLockInfo::NotPresent => ("NotPresent".to_string(), None, None), + }; + VerifiedAssetLockConsumedWithAddressInfosWasm::new( + status, + initial, + remaining, + build_address_infos_map(infos), + ) + .into() + } + StateTransitionProofResult::VerifiedShieldedNullifiers(nullifiers) => { VerifiedShieldedNullifiersWasm::from_map(build_nullifier_map(nullifiers)).into() } diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs index efec44d6c7b..d2da7bbfcc6 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/shielded.rs @@ -17,7 +17,26 @@ use wasm_bindgen::prelude::*; fn read_map_property(value: &JsValue, name: &str) -> WasmDppResult { let raw = js_sys::Reflect::get(value, &name.into()) .map_err(|_| WasmDppError::generic(format!("Missing property: {}", name)))?; - Ok(raw.unchecked_into()) + // `to_json` normalizes the `Map` to a plain object so it survives `JSON.stringify`. A value + // that round-tripped through `JSON.parse(JSON.stringify(...))` therefore arrives here as a + // plain object, not a `Map`. Accept both: use a real `Map` directly, otherwise rebuild one + // from the plain object's entries so `.size`/`.get()`/iteration behave as a `Map`. + if raw.is_instance_of::() { + Ok(raw.unchecked_into()) + } else if raw.is_object() { + let entries = js_sys::Object::entries(raw.unchecked_ref()); + let map = Map::new(); + for entry in entries.iter() { + let pair: js_sys::Array = entry.unchecked_into(); + map.set(&pair.get(0), &pair.get(1)); + } + Ok(map) + } else { + Err(WasmDppError::generic(format!( + "Property {} must be a Map or plain object", + name + ))) + } } // --- VerifiedShieldedPoolState --- @@ -91,6 +110,129 @@ impl VerifiedAssetLockConsumedWasm { impl_wasm_type_info!(VerifiedAssetLockConsumedWasm, VerifiedAssetLockConsumed); impl_wasm_conversions_serde!(VerifiedAssetLockConsumedWasm, VerifiedAssetLockConsumed); +// --- VerifiedAssetLockConsumedWithAddressInfos --- + +#[wasm_bindgen(js_name = "VerifiedAssetLockConsumedWithAddressInfos")] +#[derive(Clone)] +pub struct VerifiedAssetLockConsumedWithAddressInfosWasm { + status: String, + initial_credit_value: Option, + remaining_credit_value: Option, + address_infos: Map, +} + +#[wasm_bindgen(js_class = VerifiedAssetLockConsumedWithAddressInfos)] +impl VerifiedAssetLockConsumedWithAddressInfosWasm { + #[wasm_bindgen(getter)] + pub fn status(&self) -> String { + self.status.clone() + } + + #[wasm_bindgen(getter, js_name = "initialCreditValue")] + pub fn initial_credit_value(&self) -> JsValue { + match self.initial_credit_value { + Some(v) => BigInt::from(v).into(), + None => JsValue::undefined(), + } + } + + #[wasm_bindgen(getter, js_name = "remainingCreditValue")] + pub fn remaining_credit_value(&self) -> JsValue { + match self.remaining_credit_value { + Some(v) => BigInt::from(v).into(), + None => JsValue::undefined(), + } + } + + #[wasm_bindgen(getter = "addressInfos")] + pub fn address_infos(&self) -> Map { + self.address_infos.clone() + } + + #[wasm_bindgen(js_name = toObject)] + pub fn to_object(&self) -> JsValue { + js_obj(&[ + ("status", self.status.clone().into()), + ("initialCreditValue", self.initial_credit_value()), + ("remainingCreditValue", self.remaining_credit_value()), + ("addressInfos", self.address_infos.clone().into()), + ]) + } + + /// Returns a `JSON.stringify`-friendly form: the `Map` is normalised to a + /// plain object so its entries survive serialisation. + #[wasm_bindgen(js_name = toJSON)] + pub fn to_json(&self) -> WasmDppResult { + normalize_js_value_for_json(&self.to_object()) + } + + #[wasm_bindgen(js_name = fromObject)] + pub fn from_object( + value: JsValue, + ) -> WasmDppResult { + let status = js_sys::Reflect::get(&value, &"status".into()) + .ok() + .and_then(|v| v.as_string()) + .ok_or_else(|| WasmDppError::generic("Missing property: status".to_string()))?; + // A credit value may arrive as a BigInt (`toObject`), a base-10 string (`toJSON` + // normalizes BigInt to a string so it survives `JSON.stringify`), or a plain number + // (a hand-built object). Accept all three so `fromJSON(JSON.parse(JSON.stringify(...)))` + // round-trips instead of dropping the value to `None`. + let read_opt_u64 = |name: &str| -> Option { + js_sys::Reflect::get(&value, &name.into()) + .ok() + .and_then(|v| { + if v.is_undefined() || v.is_null() { + None + } else if let Ok(b) = u64::try_from(v.clone()) { + Some(b) + } else if let Some(s) = v.as_string() { + s.parse::().ok() + } else { + v.as_f64().and_then(|n| { + (n >= 0.0 && n.fract() == 0.0 && n <= u64::MAX as f64) + .then_some(n as u64) + }) + } + }) + }; + Ok(VerifiedAssetLockConsumedWithAddressInfosWasm { + status, + initial_credit_value: read_opt_u64("initialCreditValue"), + remaining_credit_value: read_opt_u64("remainingCreditValue"), + address_infos: read_map_property(&value, "addressInfos")?, + }) + } + + #[wasm_bindgen(js_name = fromJSON)] + pub fn from_json( + value: JsValue, + ) -> WasmDppResult { + Self::from_object(value) + } +} + +impl VerifiedAssetLockConsumedWithAddressInfosWasm { + pub fn new( + status: String, + initial_credit_value: Option, + remaining_credit_value: Option, + address_infos: Map, + ) -> Self { + Self { + status, + initial_credit_value, + remaining_credit_value, + address_infos, + } + } +} + +impl_wasm_type_info!( + VerifiedAssetLockConsumedWithAddressInfosWasm, + VerifiedAssetLockConsumedWithAddressInfos +); + // --- VerifiedShieldedNullifiers --- #[wasm_bindgen(js_name = "VerifiedShieldedNullifiers")]