Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ea869d8
fix(drive): charge fees for unshield and shielded withdrawal
QuantumExplorer Jun 5, 2026
b7ff23c
test(drive): PR4 review cleanups — comment, net-zero skip, withdrawal…
QuantumExplorer Jun 5, 2026
a732b10
fix(drive-abci): enforce withdrawal dust floor on shielded withdrawal…
QuantumExplorer Jun 5, 2026
c05b470
test(drive-abci): give shielded-withdrawal validation fixtures a net …
QuantumExplorer Jun 5, 2026
e7c1eb6
docs(drive-abci): note shielded-withdrawal net is guaranteed >= dust …
QuantumExplorer Jun 5, 2026
7db77b4
docs(dpp): explain the 216-byte shielded encrypted-note layout in SHI…
QuantumExplorer Jun 5, 2026
f8ec408
fix(drive-abci): pin shielded-transfer fee to exactly the minimum (re…
QuantumExplorer Jun 5, 2026
e04dc7d
fix(dpp): pin shielded-transfer builder fee to exactly the minimum (a…
QuantumExplorer Jun 5, 2026
c3519de
test(drive): verify the flat shielded fee covers the actual GroveDB w…
QuantumExplorer Jun 5, 2026
055dd7b
test(drive-abci): add ignored benchmark for shielded proof-verificati…
QuantumExplorer Jun 6, 2026
4652b5b
fix(platform-version): price per-action Halo 2 verification in the sh…
QuantumExplorer Jun 6, 2026
667f4ce
fix(drive-abci): route shielded storage cost to storage_fee so it amo…
QuantumExplorer Jun 6, 2026
b472528
docs(dpp): fix clippy doc_lazy_continuation in SHIELDED_STORAGE_BYTES…
QuantumExplorer Jun 6, 2026
fba59b1
refactor(drive-abci): drop dead per-operation fee from shielded trans…
QuantumExplorer Jun 6, 2026
3695838
fix(dpp): address shielded fee-model review findings on #3800
QuantumExplorer Jun 6, 2026
1d06025
fix(dpp): bind core_fee_per_byte/pooling to withdrawal sighash; fix w…
QuantumExplorer Jun 6, 2026
a6582cc
docs(shielded-fees): correct unshield/withdrawal fee and pool-decreme…
QuantumExplorer Jun 6, 2026
cf926df
fix(dpp): address in-depth review findings (withdrawal cap, fee pin, …
QuantumExplorer Jun 6, 2026
07de324
test(shielded): seed RNG for determinism + cover select_notes_with_fee
QuantumExplorer Jun 6, 2026
b2beded
test(shielded): assert error variants on withdrawal-range tests + cov…
QuantumExplorer Jun 6, 2026
e558bc1
refactor(dpp): drop the fee param from shielded builders; return the …
QuantumExplorer Jun 6, 2026
c11eef5
refactor(dpp): version compute_minimum_shielded_fee with the mod + v0…
QuantumExplorer Jun 6, 2026
cbd0cd4
refactor(drive-abci): drop dead block_info/execution_context from shi…
QuantumExplorer Jun 6, 2026
a909cf1
refactor(drive-abci): rename shielded fee-validation locals for clarity
QuantumExplorer Jun 6, 2026
35d9cce
feat: version min_withdrawal_amount; raise to 1000 duffs in protocol …
QuantumExplorer Jun 6, 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
58 changes: 35 additions & 23 deletions book/src/fees/shielded-fees.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ 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. |
| **ShieldedTransfer** | `fee = value_balance` | The entire `value_balance` is fee — nothing leaves the pool except the fee going to proposers. |
| **Unshield** | `fee = value_balance − amount` | `amount` goes to the output address; the remainder is fee. |
| **ShieldedWithdrawal** | `fee = value_balance − amount` | `amount` goes to the withdrawal document; the remainder is fee. |
| **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`. |

For `ShieldedTransfer`, the client constructs the bundle so that `total_spent −
Expand Down Expand Up @@ -73,10 +73,13 @@ Each action in the bundle requires:
- Nullifier duplicate check (hash + tree lookup)
- Note commitment insertion into the Sinsemilla-based Merkle tree

The processing cost per action was calibrated at a 33:1 ratio against the proof
verification cost, based on benchmarks of signature verification and tree operations.
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.

**Current value:** `3,000,000` credits (3M)
**Current value:** `22,000,000` credits (22M)

### 3. Per-Action Storage Fee

Expand Down Expand Up @@ -108,9 +111,9 @@ Combining all three components:

| Actions | Proof Fee | Processing | Storage | Total Minimum Fee |
|---|---|---|---|---|
| 2 | 100,000,000 | 6,000,000 | 17,097,600 | **123,097,600** |
| 3 | 100,000,000 | 9,000,000 | 25,646,400 | **134,646,400** |
| 4 | 100,000,000 | 12,000,000 | 34,195,200 | **146,195,200** |
| 2 | 100,000,000 | 44,000,000 | 17,097,600 | **161,097,600** |
| 3 | 100,000,000 | 66,000,000 | 25,646,400 | **191,646,400** |
| 4 | 100,000,000 | 88,000,000 | 34,195,200 | **222,195,200** |

Note: The Orchard protocol requires a minimum of 2 actions per bundle for privacy
(even a single-input single-output transfer produces 2 actions with a dummy padding
Expand Down Expand Up @@ -159,7 +162,7 @@ pub struct DriveAbciValidationConstants {
pub maximum_contenders_to_consider: u16,
pub minimum_pool_notes_for_outgoing: u64,
pub shielded_proof_verification_fee: u64, // 100_000_000
pub shielded_per_action_processing_fee: u64, // 3_000_000
pub shielded_per_action_processing_fee: u64, // 22_000_000
}
```

Expand All @@ -176,23 +179,32 @@ This design means:
## How Fees Flow After Validation

Once the fee check passes and the transition is fully validated and executed, the
fee amount is deducted from the shielded pool's total balance and routed to block
proposers via the `PaidFromShieldedPool` execution event:
shielded pool's total balance is decremented and the fee is booked via the
`PaidFromShieldedPool` execution event:

```
ShieldedTransfer: pool_balance -= fee_amount
Unshield: pool_balance -= (amount + fee_amount)
ShieldedWithdrawal: pool_balance -= (amount + fee_amount)
ShieldedTransfer: pool_balance -= fee_amount // fee == value_balance
Unshield: pool_balance -= unshielding_amount // gross
ShieldedWithdrawal: pool_balance -= unshielding_amount // gross
```

For `Unshield`, the `amount` goes to the output platform address. For
`ShieldedWithdrawal`, the `amount` goes to a Core withdrawal document. In both
cases, the `fee_amount` goes to proposers.

For `ShieldedTransfer`, the total pool value decreases by exactly the fee amount.
The rest of the value stays inside the pool (the sender's notes are spent and the
recipient's notes are created, but the pool's aggregate balance only drops by the
fee).
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.

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
by the fee).

In all cases the booked `fee_amount` is split the same way as every other transition's
fee: the storage cost of the permanent shielded writes is routed to the storage pool
(amortized across epochs and subject to the per-epoch fee multiplier at payout), and the
remainder — proof verification plus per-action processing — is the processing fee paid to
the current block proposer.

## Cryptographic Binding

Expand Down
51 changes: 28 additions & 23 deletions packages/rs-dpp/src/shielded/builder/shielded_transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ use super::{prove_and_sign_bundle, serialize_authorized_bundle, OrchardProver, S
/// - `anchor` - Sinsemilla root of the note commitment tree (Orchard Anchor)
/// - `prover` - Orchard prover (holds the Halo 2 proving key)
/// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload)
/// - `fee` - Optional fee override; if `None`, the minimum fee is computed automatically.
/// If `Some`, must be >= the minimum fee.
/// - `fee` - Optional fee. A shielded transfer's `value_balance` IS the entire fee, and
/// consensus requires it to equal the minimum exactly (transfers cannot overpay), so this
/// must be `None` (use the minimum) or `Some(min_fee)`; any other value is rejected.
/// - `platform_version` - Protocol version
#[allow(clippy::too_many_arguments)]
pub fn build_shielded_transfer_transition<P: OrchardProver>(
Expand All @@ -52,22 +53,23 @@ pub fn build_shielded_transfer_transition<P: OrchardProver>(
// Conservative action count: at least (spends, 2) since we always have
// a recipient output and likely a change output.
let num_actions = spends.len().max(2);
let min_fee = compute_minimum_shielded_fee(num_actions, platform_version);
let min_fee = compute_minimum_shielded_fee(num_actions, platform_version)?;
// A shielded transfer's `value_balance` IS the entire fee, and consensus requires it to
// equal the minimum exactly: overpayment buys nothing (Platform has no fee-priority
// market) and would leak a distinguishing fee fingerprint that breaks shielded
// uniformity. The fee is therefore fixed — accept `None` (use the minimum) or
// `Some(min_fee)`, and reject anything else so the caller fails fast here instead of
// building a transition the network will reject.
let effective_fee = match fee {
Some(f) if f < min_fee => {
return Err(ProtocolError::ShieldedBuildError(format!(
"fee {} is below minimum required fee {}",
f, min_fee
)));
}
Some(f) if f > min_fee.saturating_mul(1000) => {
None => min_fee,
Some(f) if f == min_fee => min_fee,
Some(f) => {
return Err(ProtocolError::ShieldedBuildError(format!(
"fee {} exceeds 1000x the minimum fee {}",
f, min_fee
"shielded transfer fee must equal the minimum fee {} exactly \
(transfers cannot overpay); got {}",
min_fee, f
)));
}
Some(f) => f,
None => min_fee,
};

let required = transfer_amount.checked_add(effective_fee).ok_or_else(|| {
Expand Down Expand Up @@ -172,7 +174,7 @@ mod tests {
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("below minimum required fee"),
err.contains("must equal the minimum fee"),
"unexpected error: {}",
err
);
Expand Down Expand Up @@ -221,8 +223,9 @@ mod tests {
// --------------------------------------------------------------

#[test]
fn test_shielded_transfer_fee_above_upper_bound() {
// Fee > 1000x the minimum fee should be rejected.
fn test_shielded_transfer_fee_above_minimum_rejected() {
// A transfer's fee is fixed at the minimum; even 1 credit above it is rejected
// (overpayment is not allowed).
let platform_version = PlatformVersion::latest();
let recipient = test_orchard_address();
let change_address = test_orchard_address();
Expand All @@ -236,8 +239,8 @@ mod tests {
let ask = SpendAuthorizingKey::from(&sk);

// num_actions is max(spends.len(), 2) = 2.
let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version);
let excessive_fee = min_fee.saturating_mul(1000) + 1;
let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version)
.expect("fee computation should not overflow");

let result = build_shielded_transfer_transition(
spends,
Expand All @@ -249,14 +252,14 @@ mod tests {
Anchor::empty_tree(),
&TestProver,
[0u8; 36],
Some(excessive_fee),
Some(min_fee + 1),
platform_version,
);

assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("exceeds 1000x the minimum fee"),
err.contains("must equal the minimum fee"),
"unexpected error: {}",
err
);
Expand All @@ -280,7 +283,8 @@ mod tests {
// Compute min fee, then craft a fee that lies in [min_fee, 1000*min_fee]
// so we bypass the boundary checks, then pick transfer_amount = u64::MAX
// so amount + fee overflows.
let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version);
let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version)
.expect("fee computation should not overflow");

let result = build_shielded_transfer_transition(
spends,
Expand Down Expand Up @@ -350,7 +354,8 @@ mod tests {
let recipient = test_orchard_address();
let change_address = test_orchard_address();

let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version);
let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version)
.expect("fee computation should not overflow");
let transfer_amount = 10u64;
let note = test_spendable_note(transfer_amount + min_fee);
let spends = vec![note];
Expand Down
39 changes: 26 additions & 13 deletions packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,14 @@ pub fn build_shielded_withdrawal_transition<P: OrchardProver>(

let total_spent: u64 = spends.iter().map(|s| s.note.value().inner()).sum();

// Conservative action count: at least (spends, 1) since we have a change output.
let num_actions = spends.len().max(1);
let min_fee = compute_minimum_shielded_fee(num_actions, platform_version);
// Orchard's BundleType::DEFAULT pads every bundle to a 2-action minimum
// (MIN_ACTIONS), so even a single-spend withdrawal is serialized and proven with 2
// actions. Price the fee against that same floor (matching shielded_transfer);
// otherwise consensus recomputes min_fee from the on-wire actions.len() == 2 and
// rejects an honest single-spend withdrawal with InsufficientShieldedFeeError (or,
// post-fee, WithdrawalBelowMinAmountError).
let num_actions = spends.len().max(2);
let min_fee = compute_minimum_shielded_fee(num_actions, platform_version)?;
let effective_fee = match fee {
Some(f) if f < min_fee => {
return Err(ProtocolError::ShieldedBuildError(format!(
Expand Down Expand Up @@ -94,11 +99,15 @@ pub fn build_shielded_withdrawal_transition<P: OrchardProver>(

let change_amount = total_spent - required;

// ShieldedWithdrawal extra_data = output_script || value_balance (le bytes)
// value_balance = withdrawal_amount + fee, becomes v0.unshielding_amount in the state transition
// Must match server-side sighash in shielded_proof.rs
let mut extra_sighash_data = output_script.as_bytes().to_vec();
extra_sighash_data.extend_from_slice(&required.to_le_bytes());
// Bind every Core-facing withdrawal field into the Orchard sighash (output_script,
// unshielding_amount == required, core_fee_per_byte, pooling) so the binding signature
// authorizes them. Shared with the consensus verifier in shielded_proof.rs.
let extra_sighash_data = crate::shielded::shielded_withdrawal_extra_sighash_data(
output_script.as_bytes(),
required,
core_fee_per_byte,
pooling,
);

let bundle = build_spend_bundle(
spends,
Expand Down Expand Up @@ -186,8 +195,10 @@ mod tests {
let ask = SpendAuthorizingKey::from(&sk);

// Compute the minimum fee so we can exceed the 1000x bound
let num_actions = 1usize;
let min_fee = crate::shielded::compute_minimum_shielded_fee(num_actions, platform_version);
// Builder pads to max(spends, 2), so the threshold must be priced for 2 actions.
let num_actions = 2usize;
let min_fee = crate::shielded::compute_minimum_shielded_fee(num_actions, platform_version)
.expect("fee computation should not overflow");
let excessive_fee = min_fee.saturating_mul(1000) + 1;

let result = build_shielded_withdrawal_transition(
Expand Down Expand Up @@ -310,7 +321,8 @@ mod tests {
let fvk = FullViewingKey::from(&sk);
let ask = SpendAuthorizingKey::from(&sk);

let min_fee = crate::shielded::compute_minimum_shielded_fee(1, platform_version);
let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version)
.expect("fee computation should not overflow");
let fee_at_boundary = min_fee.saturating_mul(1000);

let result = build_shielded_withdrawal_transition(
Expand Down Expand Up @@ -356,7 +368,8 @@ mod tests {
let fvk = FullViewingKey::from(&sk);
let ask = SpendAuthorizingKey::from(&sk);

let min_fee = crate::shielded::compute_minimum_shielded_fee(1, platform_version);
let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version)
.expect("fee computation should not overflow");

let result = build_shielded_withdrawal_transition(
spends,
Expand Down Expand Up @@ -388,7 +401,7 @@ mod tests {

#[test]
fn test_shielded_withdrawal_zero_spends_errors() {
// Empty spends vec → total_spent = 0 and num_actions = 1 (max).
// Empty spends vec → total_spent = 0 and num_actions = 2 (max floor).
let platform_version = PlatformVersion::latest();
let change_address = test_orchard_address();

Expand Down
22 changes: 15 additions & 7 deletions packages/rs-dpp/src/shielded/builder/unshield.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,13 @@ pub fn build_unshield_transition<P: OrchardProver>(

let total_spent: u64 = spends.iter().map(|s| s.note.value().inner()).sum();

// Conservative action count: at least (spends, 1) since we have a change output.
let num_actions = spends.len().max(1);
let min_fee = compute_minimum_shielded_fee(num_actions, platform_version);
// Orchard's BundleType::DEFAULT pads every bundle to a 2-action minimum
// (MIN_ACTIONS), so even a single-spend unshield is serialized and proven with 2
// actions. Price the fee against that same floor (matching shielded_transfer);
// 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);
let min_fee = compute_minimum_shielded_fee(num_actions, platform_version)?;
let effective_fee = match fee {
Some(f) if f < min_fee => {
return Err(ProtocolError::ShieldedBuildError(format!(
Expand Down Expand Up @@ -176,8 +180,10 @@ mod tests {
let ask = SpendAuthorizingKey::from(&sk);

// Compute the minimum fee so we can exceed the 1000x bound
let num_actions = 1usize;
let min_fee = crate::shielded::compute_minimum_shielded_fee(num_actions, platform_version);
// Builder pads to max(spends, 2), so the threshold must be priced for 2 actions.
let num_actions = 2usize;
let min_fee = crate::shielded::compute_minimum_shielded_fee(num_actions, platform_version)
.expect("fee computation should not overflow");
let excessive_fee = min_fee.saturating_mul(1000) + 1;

let result = build_unshield_transition(
Expand Down Expand Up @@ -293,7 +299,8 @@ mod tests {
let fvk = FullViewingKey::from(&sk);
let ask = SpendAuthorizingKey::from(&sk);

let min_fee = crate::shielded::compute_minimum_shielded_fee(1, platform_version);
let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version)
.expect("fee computation should not overflow");
let boundary = min_fee.saturating_mul(1000);

let result = build_unshield_transition(
Expand Down Expand Up @@ -399,7 +406,8 @@ mod tests {
let change_address = test_orchard_address();
let output_address = PlatformAddress::P2pkh([1u8; 20]);

let min_fee = crate::shielded::compute_minimum_shielded_fee(1, platform_version);
let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version)
.expect("fee computation should not overflow");
let unshield_amount = 42u64;
let note = test_spendable_note(unshield_amount + min_fee);
let spends = vec![note];
Expand Down
Loading
Loading