Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
33b6425
feat(platform)!: add ShieldFromIdentity state transition (identity ba…
QuantumExplorer Sep 12, 2026
9376d88
feat(sdk): Swift and Kotlin wrappers for ShieldFromIdentity
QuantumExplorer Sep 12, 2026
164d5d3
fix(platform): address review on ShieldFromIdentity (clippy, key look…
QuantumExplorer Sep 12, 2026
9b85aab
feat(platform)!: add IdentityTopUpFromShieldedPool state transition (…
QuantumExplorer Sep 12, 2026
d4ac5a7
style(wasm-dpp2): keep IdentityTopUpFromShieldedPool spec lines under…
QuantumExplorer Sep 12, 2026
20c903a
fix(platform-wallet): persist balance, drop floor fee, keep key-unava…
QuantumExplorer Sep 12, 2026
a04449d
Merge branch 'claude/identity-shielded-pool-transition-847713' into c…
QuantumExplorer Sep 12, 2026
c98944d
fix(platform-wallet): confirm ShieldFromIdentity only on the identity…
QuantumExplorer Sep 12, 2026
1ce2935
Merge branch 'claude/identity-shielded-pool-transition-847713' into c…
QuantumExplorer Sep 12, 2026
bb6836e
fix(platform)!: paid penalty and complete-fee floor for ShieldFromIde…
QuantumExplorer Sep 12, 2026
1437b73
Merge branch 'claude/identity-shielded-pool-transition-847713' into c…
QuantumExplorer Sep 12, 2026
7a3b657
fix(dpp): calibrate the ShieldFromIdentity identity-write allowance t…
QuantumExplorer Sep 12, 2026
98242ca
Merge branch 'claude/identity-shielded-pool-transition-847713' into c…
QuantumExplorer Sep 12, 2026
5b361b3
fix(dpp): calibrate the IdentityTopUpFromShieldedPool identity-write …
QuantumExplorer Sep 12, 2026
ead503c
fix(dpp): size the ShieldFromIdentity identity-write component as fla…
QuantumExplorer Sep 13, 2026
007f661
fix(dpp): size the top-up identity-write component as flat replace-on…
QuantumExplorer Sep 13, 2026
424d14c
fix(dpp): resolve merge markers left in shielded/mod.rs
QuantumExplorer Sep 13, 2026
6ab057e
fix(dpp): restore the top-up sighash exports dropped by the marker re…
QuantumExplorer Sep 13, 2026
3c81ea3
Merge branch 'v4.2-dev' into claude/identity-topup-from-shielded-pool
QuantumExplorer Sep 13, 2026
9f0d97e
fix(platform): classify top-up proofs as execution-proving and persis…
QuantumExplorer Sep 13, 2026
69d8939
fix(platform-wallet): hold the shield guard across a shielded identit…
QuantumExplorer Sep 13, 2026
ad57dd6
fix(platform): classify top-up proofs as affected state and wait acco…
QuantumExplorer Sep 13, 2026
c1c67a7
docs(platform-wallet): record why a shielded identity top-up confirms…
QuantumExplorer Sep 13, 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
25 changes: 20 additions & 5 deletions book/src/fees/shielded-fees.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ The fee is derived differently depending on the shielded transition type:
| **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-shieldfromassetlock-and-shieldfromidentity). |
| **IdentityCreateFromShieldedPool** | `total_fee = metered(insert_nullifiers + AddNewIdentity(identity + N keys)) + shielded_verification_fee`, **moved from the new identity's balance** | `value_balance` is a **fixed `denomination`** (a member of the versioned set `{0.1, 0.3, 0.5, 1.0}` DASH) and must equal it EXACTLY. The new identity is created holding the full `denomination`, funded by decrementing the shielded pool by exactly that amount — a move *between* two balance trees (like `Unshield`'s pool→address), so the global system-credit supply is unchanged (**no** `AddToSystemCredits`); the fee is then **moved** from that balance into the fee pools, so the identity ends with `denomination − total_fee`. Unlike the flat pool-paid transitions, the `AddNewIdentity` write grows with the key count, so the cost is **metered** (not a flat carve) — only the ZK compute fee (`compute_shielded_verification_fee`) is added on top, exactly like the transparent `Shield`. The client predicts it offline with `compute_shielded_identity_create_fee(num_actions, num_keys)`; consensus rejects `denomination < total_fee` with `IdentityInsufficientBalanceError`. |
| **ShieldFromIdentity** | `fee = metered(storage + processing) + shielded_verification_fee`, paid from the funding identity's balance | Identity balance to pool (protocol version 14). Charged exactly like `Shield`, but on the identity side: the identity signature covers the whole outputs-only bundle, the metered note writes and identity writes go through the standard identity-paid path (`IdentityCreditTransferToAddresses` model), and only the ZK compute fee is added as `additional_fixed_fee_cost`. `user_fee_increase` applies. The identity must hold `amount + fee`; consensus rejects a short balance with `IdentityInsufficientBalanceError`. The pool and the identity are both balance trees, so no system-credit adjustment is emitted. See [Entry-Transition Fees](#entry-transition-fees-shield-shieldfromassetlock-and-shieldfromidentity). |
| **IdentityTopUpFromShieldedPool** | `fee = compute_shielded_identity_top_up_fee(num_actions)` = `compute_minimum_shielded_fee(num_actions) + identity_balance_storage_fee`, carved from `value_balance` | Shielded pool to an EXISTING identity's balance (protocol version 14). `value_balance` (the transition's `topUpAmount`) is the gross amount leaving the pool; the identity receives `topUpAmount - fee` and validation requires `topUpAmount >= fee`. Same flat pool-paid model as `Unshield`, with the identity balance write as a flat component built like `Unshield`'s address write but calibrated to its measured cost: the top-up rewrites the existing identity's balance element and its Merk path (320 replaced bytes, 175,320 credits of processing, no storage), folded into one flat figure with headroom like the other shielded components, so `identity_balance_storage_fee = 8 x per_byte_rate` (`SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES`). The target identity and gross amount are bound into the Orchard sighash; the identity must already exist; no system-credit adjustment. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

For `ShieldedTransfer`, the client constructs the bundle so that `total_spent −
total_output = desired_fee`. The Orchard circuit proves that value is conserved
Expand Down Expand Up @@ -257,8 +258,8 @@ Note: The Orchard protocol requires a minimum of 2 actions per bundle for privac
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:
`ShieldedTransfer`. The three pool-paid transitions that write one extra per-transition output
add a flat 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,
Expand All @@ -272,6 +273,12 @@ flat storage component on top of this base:
`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.
- **`IdentityTopUpFromShieldedPool` adds the identity balance write cost**: a flat
`identity_balance_storage_fee = 8 × per_byte_rate = 8 × 27,400 = 219,200` credits,
independent of action count, so the top-up fee at any action count is the base plus
`219,200`. See the [Fee Extraction](#fee-extraction-by-transition-type) IdentityTopUpFromShieldedPool
row for why this component is so much smaller than the address write: it rewrites an existing
balance element instead of storing a new entry.

## Where Fee Validation Runs

Expand Down Expand Up @@ -344,9 +351,10 @@ shielded pool's total balance is decremented and the fee is booked via the
`PaidFromShieldedPool` execution event:

```
ShieldedTransfer: pool_balance -= fee_amount // fee == value_balance
Unshield: pool_balance -= unshielding_amount // gross
ShieldedWithdrawal: pool_balance -= unshielding_amount // gross
ShieldedTransfer: pool_balance -= fee_amount // fee == value_balance
Unshield: pool_balance -= unshielding_amount // gross
ShieldedWithdrawal: pool_balance -= unshielding_amount // gross
IdentityTopUpFromShieldedPool: pool_balance -= top_up_amount // gross
```

For `Unshield` and `ShieldedWithdrawal`, `unshielding_amount` is the **gross** amount
Expand All @@ -365,6 +373,13 @@ extra write, the booking split (storage routed to the storage pool, the remainde
the proposer) covers that write instead of zeroing the proposer's processing reward to
cover it.

For `IdentityTopUpFromShieldedPool`, `top_up_amount` is likewise the gross amount leaving the
pool: `top_up_amount − fee_amount` is added to the existing identity's balance and
`fee_amount` (`compute_shielded_identity_top_up_fee`, the base fee plus the flat identity
balance write component) is booked as the transition fee; validation guarantees
`top_up_amount ≥ fee_amount`. The identity balance and the pool total are both terms of the
block conservation equation, so no system-credit adjustment is emitted.

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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ private fun kindLabel(kindTag: Int): String = when (kindTag) {
5 -> "Withdrawn"
6 -> "Identity Created"
8 -> "Shielded from Identity"
9 -> "Identity Top-Up from Pool"
else -> "Shielded Spend"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ internal object FundingNative {
/**
* The flat shielded fee in credits for a transition of [kind]
* (0 = ShieldedTransfer/Shield, 1 = Unshield, 2 = ShieldedWithdrawal,
* 3 = ShieldFromIdentity: the compute-only floor, no storage term)
* 3 = ShieldFromIdentity: the compute-only floor, no storage term,
* 4 = IdentityTopUpFromShieldedPool: base plus the flat
* identity-balance write cost)
* and Orchard action count [numActions], computed at [managerHandle]'s
* network-tracked platform version. No network round-trip; throws on
* an unknown kind, an invalid manager handle, or overflow.
Expand Down Expand Up @@ -242,6 +244,25 @@ internal object FundingNative {
amount: Long,
)

/**
* Shielded to existing-identity top-up, Type 22 (bridges
* `platform_wallet_manager_shielded_identity_top_up_from_pool`).
* [identityId] is the 32-byte id of an EXISTING Platform identity (it
* need not be one this wallet manages); [amount] is the credits the
* identity receives, and the flat pool-paid fee ([estimateShieldedFee]
* kind 4) is spent from the notes on top of it. [resolverHandle]
* supplies the transient spend authority exactly as for
* [shieldedUnshield].
*/
external fun shieldedIdentityTopUpFromPool(
managerHandle: Long,
walletId: ByteArray,
resolverHandle: Long,
account: Int,
identityId: ByteArray,
amount: Long,
)

/**
* Shielded → Core L1 withdrawal, Type 19 (bridges
* `platform_wallet_manager_shielded_withdraw`). [toCoreAddress] is a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ object ShieldedProver {
* [org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedShieldFromIdentity].
*/
ShieldFromIdentity(3),

/**
* IdentityTopUpFromShieldedPool (Type 22): base plus the flat
* identity-balance write cost, carved from the value balance like
* the other pool-paid kinds. Backs
* [org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedIdentityTopUpFromPool].
*/
IdentityTopUpFromPool(4),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Kick the ~30s Halo 2 proving-key build onto a background thread. Idempotent. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ data class ShieldedActivityEntity(
/**
* `ShieldedActivityKind::tag`: 0 Shield, 1 ShieldFromAssetLock,
* 2 Received, 3 Sent, 4 Unshield, 5 Withdrawal, 6 IdentityCreate,
* 7 ShieldedSpend, 8 ShieldFromIdentity.
* 7 ShieldedSpend, 8 ShieldFromIdentity, 9 IdentityTopUpFromPool.
*/
val kindTag: Int,
/** 0 In, 1 Out, 2 Self. */
Expand All @@ -49,7 +49,8 @@ data class ShieldedActivityEntity(
/**
* Identity id (32 bytes) when the kind carries one: the created
* identity for kindTag == 6 (IdentityCreate), the funding identity for
* kindTag == 8 (ShieldFromIdentity); empty otherwise.
* kindTag == 8 (ShieldFromIdentity), the topped-up identity for
* kindTag == 9 (IdentityTopUpFromPool); empty otherwise.
*/
val identityId: ByteArray = ByteArray(0),
/** Counterparty bytes (43B Orchard / 21B PlatformAddress / Core script). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1931,6 +1931,46 @@ class PlatformWalletManager(
}
}

/**
* Shielded to existing-identity top-up (Type 22), a port of Swift's
* `PlatformWalletManager.shieldedIdentityTopUpFromPool`
* (`PlatformWalletManagerShieldedSync.swift`). Spends notes from
* [account] on [walletId] and credits [identityId]'s Platform balance.
*
* The identity only has to exist on Platform; it does not have to be
* one this wallet manages. The flat pool-paid fee
* ([ShieldedProver.FeeKind.IdentityTopUpFromPool], i.e.
* [estimateShieldedFee] kind 4) is spent from the notes on top of
* [amount].
*
* @param walletId the 32-byte wallet id.
* @param identityId the 32-byte id of the identity being topped up.
* @param amount credits the identity receives (1 DASH = 1e11 credits).
* @param account the ZIP-32 shielded account to spend from (usually 0).
*/
suspend fun shieldedIdentityTopUpFromPool(
walletId: ByteArray,
identityId: ByteArray,
amount: Long,
account: Int = 0,
): Unit = teardownGate.op {
require(amount > 0) { "amount must be positive, got $amount" }
require(account >= 0) { "account must be non-negative, got $account" }
require(identityId.size == 32) {
"identityId must be exactly 32 bytes, got ${identityId.size}"
}
mapNativeErrors {
FundingNative.shieldedIdentityTopUpFromPool(
managerHandle,
walletId,
mnemonicResolver.nativeHandle,
account,
identityId,
amount,
)
}
}

/**
* Shielded → Core L1 withdrawal (Type 19) — port of Swift's
* `PlatformWalletManager.shieldedWithdraw(walletId:account:toCoreAddress:amount:coreFeePerByte:)`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
use grovedb_commitment_tree::{Anchor, FullViewingKey, SpendAuthorizingKey};

use crate::address_funds::OrchardAddress;
use crate::fee::Credits;
use crate::shielded::compute_shielded_identity_top_up_fee;
use crate::state_transition::identity_top_up_from_shielded_pool_transition::methods::IdentityTopUpFromShieldedPoolTransitionMethodsV0;
use crate::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition;
use crate::state_transition::StateTransition;
use crate::ProtocolError;
use platform_value::Identifier;
use platform_version::version::PlatformVersion;

use super::{build_spend_bundle, serialize_authorized_bundle, OrchardProver, SpendableNote};

/// Build an `IdentityTopUpFromShieldedPool` transition: spend `spends` so that exactly
/// `top_up_amount + fee` leaves the pool, sending change back to `change_address`.
/// The identity receives `top_up_amount`; the flat fee is carved from the value
/// balance exactly as `Unshield` does. Returns the transition and the fee used.
#[allow(clippy::too_many_arguments)]
pub fn build_identity_top_up_from_shielded_pool_transition<P: OrchardProver>(
spends: Vec<SpendableNote>,
identity_id: Identifier,
top_up_amount: u64,
change_address: &OrchardAddress,
fvk: &FullViewingKey,
ask: &SpendAuthorizingKey,
anchor: Anchor,
prover: &P,
memo: [u8; 36],
platform_version: &PlatformVersion,
) -> Result<(StateTransition, Credits), ProtocolError> {
if top_up_amount > i64::MAX as u64 {
return Err(ProtocolError::ShieldedBuildError(format!(
"top up amount {} exceeds maximum allowed value {}",
top_up_amount,
i64::MAX as u64
)));
}

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

let num_actions = spends.len().max(2);
let fee = compute_shielded_identity_top_up_fee(num_actions, platform_version)?;

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

let change_amount = total_spent - required;

let extra_sighash_data = crate::shielded::identity_top_up_from_shielded_extra_sighash_data(
&identity_id.to_buffer(),
required,
platform_version,
)?;

let bundle = build_spend_bundle(
spends,
change_address,
change_amount,
memo,
fvk,
ask,
anchor,
prover,
&extra_sighash_data,
)?;

let sb = serialize_authorized_bundle(&bundle);

let state_transition = IdentityTopUpFromShieldedPoolTransition::try_from_bundle(
identity_id,
sb.actions,
sb.value_balance as u64,
sb.anchor,
sb.proof,
sb.binding_signature,
platform_version,
)?;
Ok((state_transition, fee))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::shielded::builder::test_helpers::{
test_orchard_address, test_spendable_note, TestProver,
};

#[test]
fn test_identity_top_up_insufficient_funds() {
let platform_version = PlatformVersion::latest();
let change_address = test_orchard_address();
let spends = vec![test_spendable_note(100)];
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_identity_top_up_from_shielded_pool_transition(
spends,
Identifier::from([1u8; 32]),
1_000_000,
&change_address,
&fvk,
&ask,
Anchor::empty_tree(),
&TestProver,
[0u8; 36],
platform_version,
);
let err = result
.expect_err("must fail on insufficient funds")
.to_string();
assert!(
err.contains("exceeds total spendable value"),
"unexpected error: {err}"
);
}
}
2 changes: 2 additions & 0 deletions packages/rs-dpp/src/shielded/builder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
//! ```

mod identity_create_from_shielded_pool;
mod identity_top_up_from_shielded_pool;
mod shield;
mod shield_from_asset_lock;
mod shield_from_identity;
Expand All @@ -41,6 +42,7 @@ pub use self::shield::build_shield_transition;
pub use identity_create_from_shielded_pool::{
build_identity_create_from_shielded_pool_transition, IdentityCreateFromShieldedPoolBuildResult,
};
pub use identity_top_up_from_shielded_pool::build_identity_top_up_from_shielded_pool_transition;
pub use shield_from_asset_lock::build_shield_from_asset_lock_transition;
#[cfg(feature = "core_key_wallet")]
pub use shield_from_asset_lock::build_shield_from_asset_lock_transition_with_signer;
Expand Down
17 changes: 17 additions & 0 deletions packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use platform_version::version::PlatformVersion;
use v0::compute_minimum_shielded_fee_v0;
use v0::compute_shielded_identity_balance_write_fee_v0;
use v0::compute_shielded_identity_create_fee_v0;
use v0::compute_shielded_identity_top_up_fee_v0;
use v0::compute_shielded_unshield_fee_v0;
use v0::compute_shielded_verification_fee_v0;
use v0::compute_shielded_withdrawal_fee_v0;
Expand Down Expand Up @@ -84,6 +85,22 @@ pub fn compute_shielded_withdrawal_fee(
}
}

/// Computes the flat fee for `IdentityTopUpFromShieldedPool` (base minimum plus the flat
/// identity-balance write component).
pub fn compute_shielded_identity_top_up_fee(
num_actions: usize,
platform_version: &PlatformVersion,
) -> Result<Credits, ProtocolError> {
match platform_version.dpp.methods.compute_minimum_shielded_fee {
0 => compute_shielded_identity_top_up_fee_v0(num_actions, platform_version),
version => Err(ProtocolError::UnknownVersionMismatch {
method: "compute_shielded_identity_top_up_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.
///
Expand Down
Loading
Loading