Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 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
158 changes: 142 additions & 16 deletions book/src/fees/shielded-fees.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,104 @@ 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`. |
| **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
(inputs = outputs + value_balance), and the binding signature cryptographically
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<PlatformAddress>`
`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:
Expand All @@ -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)

Expand Down Expand Up @@ -122,6 +214,14 @@ 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` and `Unshield`. **`ShieldedWithdrawal` adds the Core withdrawal-document
storage cost on top**: 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
Expand Down Expand Up @@ -168,9 +268,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
Expand All @@ -196,10 +301,16 @@ 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 the flat `compute_minimum_shielded_fee`; 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 the withdrawal fee
prices the document 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
Expand All @@ -211,6 +322,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
Expand Down
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
Loading
Loading