Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a597479
fix(drive): conserve credits in shield, debit only the shielded amount
QuantumExplorer Jun 4, 2026
063decf
fix(drive): make shield reallocation fee-aware + multi-input conserva…
QuantumExplorer Jun 5, 2026
c4a4055
Merge branch 'v3.1-dev' into claude/fix-shield-credit-conservation
QuantumExplorer Jun 6, 2026
efe3292
fix(drive): satisfy clippy doc_lazy_continuation in shield reallocati…
QuantumExplorer Jun 7, 2026
35588c6
refactor(drive): address shield reallocation review nitpicks
QuantumExplorer Jun 7, 2026
062c47a
docs(drive): correct shield reallocation comments + cover the anti-mi…
QuantumExplorer Jun 7, 2026
5b0ff35
feat(drive): charge the shielded minimum fee on Shield and ShieldFrom…
QuantumExplorer Jun 7, 2026
43d6a4c
fix(swift): mark withOptionalSurplusOutput nonisolated
QuantumExplorer Jun 7, 2026
0a23a4e
refactor(drive): address shielded-fee review nitpicks
QuantumExplorer Jun 7, 2026
1f12528
fix(wasm-dpp): map the new ShieldedImplicitFeeCapExceededError to JS
QuantumExplorer Jun 7, 2026
f8a18a9
fix(dpp): append BasicError variant at the tail + harden surplus_outp…
QuantumExplorer Jun 7, 2026
1fd1bbb
fix(wasm-dpp2): enforce 21-byte length in PlatformAddress fromBytes/f…
QuantumExplorer Jun 7, 2026
b0025d2
docs(book): document shielded entry-transition fees
QuantumExplorer Jun 7, 2026
172c48a
refactor(drive): charge the transparent Shield a metered + ZK-compute…
QuantumExplorer Jun 7, 2026
0d5bdaf
fix(drive): price the Core withdrawal document into the ShieldedWithd…
QuantumExplorer Jun 7, 2026
beec036
refactor(drive): collapse the unshipped shield_from_asset_lock conver…
QuantumExplorer Jun 7, 2026
438de4f
test(drive): pin shielded fee invariants (transfer action count, pool…
QuantumExplorer Jun 7, 2026
b8edb01
docs(book): update the shielded fee chapter for the metered Shield + …
QuantumExplorer Jun 7, 2026
933edcf
refactor(dpp): rename compute_shielded_compute_fee → compute_shielded…
QuantumExplorer Jun 7, 2026
23e0c01
fix(drive): advertise the full Shield fee in CheckTx gas_wanted
QuantumExplorer Jun 7, 2026
8ab9cee
refactor(drive): simplify shield reallocation balance bookkeeping
QuantumExplorer Jun 7, 2026
09cf77f
refactor(drive): drop unused block_info from ShieldFromAssetLock tran…
QuantumExplorer Jun 7, 2026
792d1a5
fix(drive): price the output-address write into the Unshield fee
QuantumExplorer Jun 8, 2026
282c838
fix(drive): size the Unshield address fee to its storage bytes (222, …
QuantumExplorer Jun 8, 2026
086b91d
fix(dpp): validate Shield fee-strategy indices structurally
QuantumExplorer Jun 8, 2026
78310f1
feat(drive): cover the ShieldFromAssetLock surplus_output credit in t…
QuantumExplorer Jun 8, 2026
cfd6ea1
test(drive): block-level conservation test for ShieldFromAssetLock + …
QuantumExplorer Jun 8, 2026
238f2b4
fix(wasm-dpp2): make proof-result address-info fromJSON round-trip it…
QuantumExplorer Jun 8, 2026
5c0d27d
fix(wasm-dpp2): preserve credit values in proof-result fromJSON round…
QuantumExplorer Jun 8, 2026
89efcbb
Merge branch 'v3.1-dev' into claude/fix-shield-credit-conservation
QuantumExplorer Jun 8, 2026
63c31f7
fix(drive): verify the ShieldFromAssetLock surplus proof with one str…
QuantumExplorer Jun 8, 2026
c2f0ed6
docs(drive): scrub ephemeral review-finding references from shielded …
QuantumExplorer Jun 8, 2026
788f515
fix(drive-abci): fail closed if a PaidFromAddressInputs fee is under-…
QuantumExplorer Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions book/src/addresses/platform-addresses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlatformAddress>`. 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`:
Expand Down
1 change: 1 addition & 0 deletions book/src/error-handling/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 3 additions & 2 deletions book/src/fees/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
171 changes: 154 additions & 17 deletions book/src/fees/shielded-fees.md

Large diffs are not rendered by default.

17 changes: 12 additions & 5 deletions packages/rs-dpp/src/errors/consensus/basic/basic_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<BasicError> for ConsensusError {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::*;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ShieldedImplicitFeeCapExceededError> for ConsensusError {
fn from(err: ShieldedImplicitFeeCapExceededError) -> Self {
Self::BasicError(BasicError::ShieldedImplicitFeeCapExceededError(err))
}
}
3 changes: 2 additions & 1 deletion packages/rs-dpp/src/errors/consensus/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}
Expand Down
30 changes: 29 additions & 1 deletion packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<P: OrchardProver>(
Expand All @@ -29,6 +32,7 @@ pub fn build_shield_from_asset_lock_transition<P: OrchardProver>(
asset_lock_private_key: &[u8],
prover: &P,
memo: [u8; 36],
surplus_output: Option<PlatformAddress>,
platform_version: &PlatformVersion,
) -> Result<StateTransition, ProtocolError> {
let bundle = build_output_only_bundle(recipient, shield_amount, memo, prover)?;
Expand All @@ -54,6 +58,7 @@ pub fn build_shield_from_asset_lock_transition<P: OrchardProver>(
sb.anchor,
sb.proof,
sb.binding_signature,
surplus_output,
platform_version,
)
}
Expand All @@ -72,6 +77,9 @@ pub fn build_shield_from_asset_lock_transition<P: OrchardProver>(
/// - `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)]
Expand All @@ -83,6 +91,7 @@ pub async fn build_shield_from_asset_lock_transition_with_signer<P, AS>(
asset_lock_signer: &AS,
prover: &P,
memo: [u8; 36],
surplus_output: Option<PlatformAddress>,
platform_version: &PlatformVersion,
) -> Result<StateTransition, ProtocolError>
where
Expand Down Expand Up @@ -113,6 +122,7 @@ where
sb.anchor,
sb.proof,
sb.binding_signature,
surplus_output,
platform_version,
)
.await
Expand Down Expand Up @@ -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).
Expand Down
16 changes: 9 additions & 7 deletions packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<P: OrchardProver>(
spends: Vec<SpendableNote>,
Expand Down Expand Up @@ -68,10 +69,11 @@ pub fn build_shielded_withdrawal_transition<P: OrchardProver>(
// 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())
Expand Down
19 changes: 11 additions & 8 deletions packages/rs-dpp/src/shielded/builder/unshield.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<P: OrchardProver>(
spends: Vec<SpendableNote>,
Expand Down Expand Up @@ -61,10 +63,11 @@ pub fn build_unshield_transition<P: OrchardProver>(
// 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())
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading