Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
65 changes: 41 additions & 24 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,18 +73,24 @@ 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. (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:** `3,000,000` credits (3M)
**Current value:** `22,000,000` credits (22M)

### 3. Per-Action Storage Fee

Each action permanently stores data in two places:

| Storage | Bytes | Contents |
|---|---|---|
| BulkAppendTree (commitment tree) | 280 | 32 cmx + 32 nullifier + 216 encrypted note |
| BulkAppendTree (commitment tree) | 280 | 32 cmx + 32 rho + 216 encrypted note |
| Nullifier tree | 32 | nullifier key (value is empty) |
| **Total** | **312** | |

Expand All @@ -108,9 +114,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 @@ -158,8 +164,10 @@ pub struct DriveAbciValidationConstants {
pub maximum_vote_polls_to_process: u16,
pub maximum_contenders_to_consider: u16,
pub minimum_pool_notes_for_outgoing: u64,
pub shielded_anchor_retention_blocks: u64,
pub shielded_anchor_pruning_interval: u64,
pub shielded_proof_verification_fee: u64, // 100_000_000
pub shielded_per_action_processing_fee: u64, // 3_000_000
pub shielded_per_action_processing_fee: u64, // 22_000_000
}
```

Expand All @@ -176,23 +184,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
141 changes: 22 additions & 119 deletions packages/rs-dpp/src/shielded/builder/shielded_transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ 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.
/// - `platform_version` - Protocol version
///
/// The fee is not a parameter: a shielded transfer's `value_balance` IS the fee and consensus
/// pins it to exactly `compute_minimum_shielded_fee`, so there is nothing for the caller to
/// choose. Returns the built transition together with the fee (in credits) that was applied.
#[allow(clippy::too_many_arguments)]
pub fn build_shielded_transfer_transition<P: OrchardProver>(
spends: Vec<SpendableNote>,
Expand All @@ -44,39 +46,25 @@ pub fn build_shielded_transfer_transition<P: OrchardProver>(
anchor: Anchor,
prover: &P,
memo: [u8; 36],
fee: Option<Credits>,
platform_version: &PlatformVersion,
) -> Result<StateTransition, ProtocolError> {
) -> Result<(StateTransition, Credits), ProtocolError> {
let total_spent: u64 = spends.iter().map(|s| s.note.value().inner()).sum();

// 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 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) => {
return Err(ProtocolError::ShieldedBuildError(format!(
"fee {} exceeds 1000x the minimum fee {}",
f, min_fee
)));
}
Some(f) => f,
None => min_fee,
};
// The fee is fixed at the minimum: a transfer's `value_balance` IS the fee and consensus
// pins it to exactly this amount (overpayment buys nothing and would leak a distinguishing
// fee fingerprint that breaks shielded uniformity).
let fee = compute_minimum_shielded_fee(num_actions, platform_version)?;

let required = transfer_amount.checked_add(effective_fee).ok_or_else(|| {
let required = transfer_amount.checked_add(fee).ok_or_else(|| {
ProtocolError::ShieldedBuildError("fee + transfer_amount overflows u64".to_string())
})?;
if required > total_spent {
return Err(ProtocolError::ShieldedBuildError(format!(
"transfer amount {} + fee {} = {} exceeds total spendable value {}",
transfer_amount, effective_fee, required, total_spent
transfer_amount, fee, required, total_spent
)));
}

Expand Down Expand Up @@ -123,15 +111,16 @@ pub fn build_shielded_transfer_transition<P: OrchardProver>(
let bundle = prove_and_sign_bundle(builder, prover, std::slice::from_ref(ask), &[])?;
let sb = serialize_authorized_bundle(&bundle);

// value_balance = effective_fee (the amount leaving the shielded pool as fee)
ShieldedTransferTransition::try_from_bundle(
// value_balance = fee (the amount leaving the shielded pool as fee)
let state_transition = ShieldedTransferTransition::try_from_bundle(
sb.actions,
sb.value_balance as u64,
sb.anchor,
sb.proof,
sb.binding_signature,
platform_version,
)
)?;
Ok((state_transition, fee))
}

#[cfg(test)]
Expand All @@ -141,43 +130,6 @@ mod tests {
test_orchard_address, test_spendable_note, TestProver,
};

#[test]
fn test_shielded_transfer_fee_below_minimum() {
let platform_version = PlatformVersion::latest();
let recipient = test_orchard_address();
let change_address = test_orchard_address();

let note = test_spendable_note(1_000_000);
let spends = vec![note];

let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
.expect("valid spending key bytes");
let fvk = FullViewingKey::from(&sk);
let ask = SpendAuthorizingKey::from(&sk);

let result = build_shielded_transfer_transition(
spends,
&recipient,
100,
&change_address,
&fvk,
&ask,
Anchor::empty_tree(),
&TestProver,
[0u8; 36],
Some(1), // fee = 1, should be below minimum
platform_version,
);

assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("below minimum required fee"),
"unexpected error: {}",
err
);
}

#[test]
fn test_shielded_transfer_insufficient_funds() {
let platform_version = PlatformVersion::latest();
Expand All @@ -203,7 +155,6 @@ mod tests {
Anchor::empty_tree(),
&TestProver,
[0u8; 36],
None,
platform_version,
);

Expand All @@ -220,48 +171,6 @@ mod tests {
// Extra coverage — error/overflow branches
// --------------------------------------------------------------

#[test]
fn test_shielded_transfer_fee_above_upper_bound() {
// Fee > 1000x the minimum fee should be rejected.
let platform_version = PlatformVersion::latest();
let recipient = test_orchard_address();
let change_address = test_orchard_address();

let note = test_spendable_note(u64::MAX);
let spends = vec![note];

let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32])
.expect("valid spending key bytes");
let fvk = FullViewingKey::from(&sk);
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 result = build_shielded_transfer_transition(
spends,
&recipient,
10,
&change_address,
&fvk,
&ask,
Anchor::empty_tree(),
&TestProver,
[0u8; 36],
Some(excessive_fee),
platform_version,
);

assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("exceeds 1000x the minimum fee"),
"unexpected error: {}",
err
);
}

#[test]
fn test_shielded_transfer_fee_plus_amount_overflow_errors() {
// transfer_amount + fee overflows u64 → dedicated error branch.
Expand All @@ -277,11 +186,8 @@ mod tests {
let fvk = FullViewingKey::from(&sk);
let ask = SpendAuthorizingKey::from(&sk);

// 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);

// transfer_amount = u64::MAX so amount + the (internally-computed) minimum fee
// overflows u64, hitting the checked_add error branch.
let result = build_shielded_transfer_transition(
spends,
&recipient,
Expand All @@ -292,7 +198,6 @@ mod tests {
Anchor::empty_tree(),
&TestProver,
[0u8; 36],
Some(min_fee), // within [min, 1000*min]
platform_version,
);

Expand Down Expand Up @@ -328,7 +233,6 @@ mod tests {
Anchor::empty_tree(),
&TestProver,
[0u8; 36],
None,
platform_version,
);
assert!(result.is_err());
Expand All @@ -341,16 +245,16 @@ mod tests {
}

#[test]
fn test_shielded_transfer_fee_default_is_min_fee() {
// When fee is None, the default min fee is computed — verify that a
// note *exactly* equal to `transfer_amount + min_fee` on the default
// branch does not spuriously fail the "exceeds total" check (it
fn test_shielded_transfer_uses_min_fee() {
// The fee is always the minimum. Verify that a note *exactly* equal to
// `transfer_amount + min_fee` proceeds past the "exceeds total" check (it then
// fails later in add_spend due to anchor mismatch).
let platform_version = PlatformVersion::latest();
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 All @@ -369,7 +273,6 @@ mod tests {
Anchor::empty_tree(),
&TestProver,
[0u8; 36],
None,
platform_version,
);

Expand Down
Loading
Loading