Skip to content

feat: add IdentityCreateFromShieldedPool state transition (shielded-pool-funded identity creation) #3813

@QuantumExplorer

Description

@QuantumExplorer

Summary & motivation

This issue proposes a new Dash Platform state transition, IdentityCreateFromShieldedPool (StateTransitionType = 20), that spends Orchard shielded-pool notes (an Orchard proof + nullifiers, exactly like Unshield) to exit a fixed denomination of value and uses that value to create a brand-new Platform identity with a variable set of public keys (like IdentityCreate).

Today there are two ways to create an identity (IdentityCreate from an asset lock, IdentityCreateFromAddresses from transparent address inputs) and a family of shielded transitions (Shield=15, ShieldedTransfer=16, Unshield=17, ShieldFromAssetLock=18, ShieldedWithdrawal=19). There is no way to create an identity directly out of the shielded pool. This transition closes that gap: a user holding shielded notes can bootstrap a fresh identity without first unshielding to a transparent address (which would deanonymize the funding source and require a second transition).

Because the exit amount is restricted to a small versioned set of denominations (0.1 / 0.3 / 0.5 / 1.0 DASH), every identity-creation exit of a given size is indistinguishable on-chain, maximizing the anonymity set — mirroring the uniformity already enforced for ShieldedTransfer (whose value_balance must equal the min fee exactly so no fee fingerprint distinguishes senders).

It is version-gated to protocol v12 (SHIELDED_POOL_INITIAL_PROTOCOL_VERSION = 12, packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs:4), like every other shielded transition.

The transition is a hybrid: its spend mechanics come from Unshield/ShieldedWithdrawal, its output mechanics (variable-key identity insert) come from IdentityCreate, and its merged prove/verify proof is taken from the strict ShieldFromAssetLock surplus pattern.

High-level design — the Unshield-exit + IdentityCreate hybrid

Flow (in words):

  1. Client selects shielded notes summing to (at least) a chosen fixed denomination, generates the new identity's keys, and builds an Orchard spend bundle whose value_balance exits exactly the denomination from the pool. The transparent payload (identity id + the full public-key set + denomination) is committed into the Orchard sighash via extra_sighash_data, and any excess re-enters the pool as a change note.
  2. Drive-abci reconstructs and verifies the Orchard bundle (Halo2 proof + per-action spend-auth sigs + binding sig), checks the anchor exists, checks the nullifiers are unspent (and not duplicated intra-bundle), checks the pool has enough balance, validates denomination ∈ versioned set, validates the identity key structure + per-key proofs-of-possession, and re-derives + checks the identity id.
  3. The action is converted to GroveDB ops: insert nullifiers + AddNewIdentity (identity + N keys + balance) + AddToSystemCredits(denomination) + (optional) insert change notes + UpdateTotalBalance(pool − denomination).
  4. At execution, the metered cost of those writes plus the flat shielded verification fee is computed; that total_fee is deducted from the new identity's balance and routed to the fee pools. The new identity ends with denomination − total_fee.

Reused verbatim (from Unshield/shielded_common): Orchard bundle reconstruction + proof verify (reconstruct_and_verify_bundle, packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_common/mod.rs:83-231), nullifier/anchor/pool-notes-floor stateful checks, nullifier replay protection, insert_nullifiers/insert_notes/UpdateTotalBalance converter ops, the SerializedAction wire format.

Reused from IdentityCreate: the Vec<IdentityPublicKeyInCreation> payload, key structure validation, per-key proof-of-possession, and the AddNewIdentity op (identity + N keys + balance in one metered op).

Novel (must be built): a new transition struct + StateTransitionType = 20; a new extra_sighash_data helper binding the identity payload; an identity_create_from_shielded_pool fee function pricing the variable N-key write; a new ExecutionEvent variant (no existing variant both debits the pool AND credits a new identity); the strict merged identity+nullifier prove/verify and a new StateTransitionProofResult variant; the versioned denomination set; and a new identity-id derivation from nullifiers (no asset-lock outpoint exists).

Suggested implementation staging

This is a large feature; implement and review it in stages (multiple PRs), mirroring how the shielded family itself was built — and settle Open decisions (a) identity-id derivation and (b) the sighash binding first, since they shape the struct and the conservation:

  1. DPP foundation — the transition struct + StateTransitionType = 20 (TAIL) + serialization/PlatformSignable + the extra_sighash_data helper + the versioned denomination set + the fee function.
  2. Action + booking — the StateTransitionAction, the converter, the new ExecutionEvent variant, and the credit-conservation booking — gated by a block-level sum-tree conservation test before anything else lands.
  3. drive-abci validation — proof reconstruction/verify, key-structure + per-key PoP, id re-derivation, denomination + total_fee >= denomination gate.
  4. Strict merged prove/verify + the new StateTransitionProofResult variant (+ padded-proof negative test).
  5. SDK + wasm-dpp2 + FFI/wallet surface (Swift via the swift-rust-ffi-engineer agent).
  6. Book & docs.

Proposed transition structure

Create packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/, mirroring the unshield_transition/ layout. The V0 struct (modeled on UnshieldTransitionV0 at .../unshield_transition/v0/mod.rs:32-45 plus the IdentityCreate key set):

#[platform_serialize(unversioned)]
pub struct IdentityCreateFromShieldedPoolTransitionV0 {
    // Identity payload (signed/bound into the Orchard sighash — see Open decision (b))
    #[platform_signable(into = "Vec<IdentityPublicKeyInCreationSignable>")]
    pub public_keys: Vec<IdentityPublicKeyInCreation>,   // VARIABLE (1..=max_public_keys_in_creation)
    pub denomination: u64,                               // credits; MUST equal value_balance exactly

    // Orchard spend (mirrors Unshield)
    pub actions: Vec<SerializedAction>,                 // spend/output pairs
    pub anchor: [u8; 32],                               // Sinsemilla root
    pub proof: Vec<u8>,                                 // Halo2
    pub binding_signature: [u8; 64],                    // RedPallas

    // Derived, NOT serialized, NOT in sig hash (recomputed on deserialize)
    #[cfg_attr(feature = "serde-conversion", serde(skip))]
    #[platform_signable(exclude_from_sig_hash)]
    pub identity_id: Identifier,
}

Signing / signed-field notes:

  • There is NO platform identity signature field — like Unshield/ShieldedWithdrawal, authorization is 100% the Orchard proof + per-action spend_auth_sig + binding signature over the platform sighash. (This is the key divergence from ShieldFromAssetLock, which does carry an ECDSA signature for the asset-lock key.)
  • denomination and public_keys are the transparent state-determining fields; they MUST be committed into the Orchard extra_sighash_data so the bundle cannot be redirected to a different identity/keys (see Open decision (b)).
  • identity_id mirrors IdentityCreate's pattern: serde(skip), excluded from sig hash, decoded via an inner struct, and re-derived on deserialization (see Open decision (a)). Add an advanced-structure check that the supplied id matches the re-derived value.
  • unique_identifiers() (for mempool replay protection) = hex of each action nullifier, exactly as Unshield does (.../unshield_transition/v0/state_transition_like.rs:36-41).
  • user_fee_increase / signature / owner_id / inputs / required_number_of_private_keys are all 0/None (no fee bidding, no transparent inputs).

Version enum (mod.rs): pub enum IdentityCreateFromShieldedPoolTransition { V0(IdentityCreateFromShieldedPoolTransitionV0) }, derives PlatformVersioned, From, #[platform_version_path_bounds("dpp.state_transition_serialization_versions.identity_create_from_shielded_pool_state_transition")].

SerializedAction (packages/rs-dpp/src/shielded/mod.rs:214-258): nullifier[32], rk[32], cmx[32], encrypted_note(216), cv_net[32], spend_auth_sig[64] — reused unchanged.

Fixed exit denominations

The exit amount is restricted to a small versioned array of denominations. 1 DASH = 100,000,000 duffs; CREDITS_PER_DUFF = 1000, so:

Denomination Duffs Credits
0.1 DASH 10,000,000 10_000_000_000 (10 × 10⁹)
0.3 DASH 30,000,000 30_000_000_000 (30 × 10⁹)
0.5 DASH 50,000,000 50_000_000_000 (50 × 10⁹)
1.0 DASH 100,000,000 100_000_000_000 (100 × 10⁹)

Exact location — add to DriveAbciValidationConstants in packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs:27-53, alongside shielded_implicit_fee_cap (line 52):

/// Allowed exit denominations (in credits) for IdentityCreateFromShieldedPool.
/// 0.1, 0.3, 0.5, 1.0 DASH = {10, 30, 50, 100} × 10^9 credits.
pub shielded_identity_create_denominations: &'static [u64],

Use &'static [u64] (NOT Vec) because every DRIVE_ABCI_VALIDATION_VERSIONS_V* is a const. Populate it in v8.rs's event_constants block (the shielded constants live at v8.rs:317-329):

shielded_identity_create_denominations: &[10_000_000_000, 30_000_000_000, 50_000_000_000, 100_000_000_000],

Leave it empty/disabled in v1.rsv7.rs so it is gated off pre-v12.

event_constants is the correct home (over SystemLimits) because the validation path already reaches platform_version.drive_abci.validation_and_processing.event_constants for every other shielded constant (shielded_implicit_fee_cap, shielded_proof_verification_fee).

The Orchard value_balance MUST equal the chosen denomination EXACTLY (not >=). This follows the ShieldedTransfer amount_is_pure_fee exact-equality model (shielded_proof.rs:269-284), NOT the Unshield >= model. The basic-structure validator checks denomination ∈ shielded_identity_create_denominations; the proof verifier passes value_balance = denomination as i64 to reconstruct_and_verify_bundle, and the binding signature proves the value commitments sum to exactly that. Pinning the pool debit, the identity credit + fee, and the ZK-proven balance to the same fixed number leaves no slack to leak a fingerprint.

Fee & credit model + conservation

Total fee:

total_fee = compute_shielded_verification_fee(num_actions)      // flat, compute-only (Halo2 + per-action)
          + metered_grovedb_cost( insert_nullifiers
                                + AddNewIdentity(identity + N keys + balance) )
  • compute_shielded_verification_fee_v0 (packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs:27-45) = shielded_proof_verification_fee + num_actions × shielded_per_action_processing_fee. GroveDB cannot meter the proof/sig verification, so this is added on top as a fixed processing cost (the pattern transparent Shield and IdentityCreateFromAddresses use via additional_fixed_fee_cost).
  • The metered part is obtained from apply_drive_operations(...)apply=false for the pre-execution affordability estimate, apply=true for the authoritative execution cost. It grows monotonically with N keys (each IdentityPublicKey adds GroveDB inserts into the key subtrees), which is exactly why the flat PaidFromShieldedPool model does NOT fit and a metered booking is required.

A new fee function compute_shielded_identity_create_fee(num_actions, num_keys, platform_version) should be added next to compute_shielded_unshield_fee in compute_minimum_shielded_fee/v0/mod.rs and its mod.rs dispatcher (keyed on dpp.methods.compute_minimum_shielded_fee), so the client can predict the fee offline and size its bundle. Model it on compute_shielded_unshield_fee_v0 (:167-189): base shielded fee + flat identity-byte component + num_keys × per-key-byte component, OR meter AddNewIdentity directly and add only the compute fee. New byte constants go in packages/rs-dpp/src/shielded/mod.rs alongside SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES = 222 (:92), calibrated the same way.

Conservation deltas (must hold at the sum-tree level):

  • Shielded pool sum tree: −= denomination (UpdateTotalBalance { current_total_balance − denomination }).
  • System/identity credits: += denomination (AddToSystemCredits(denomination)), then identity balance reduced by total_fee → net identity credits += (denomination − total_fee).
  • Fee pools: += total_fee (the returned FeeResult, distributed by process_block_fees_and_validate_sum_trees).

Net: −denomination + (denomination − total_fee) + total_fee = 0. Credits conserved.

CRITICAL — AddToSystemCredits must equal denomination, not denomination − total_fee. The identity is created holding the full denomination; the fee is then moved from the identity balance into the fee pools (it never leaves the credit supply). This mirrors IdentityCreate's AddToSystemCredits { amount: initial_balance } (packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/identity_create_transition.rs:46-48). Note this differs subtly from Unshield, which does NOT call AddToSystemCredits (its sink is a transparent address already in circulation). Here, because the identity is freshly created and the pool is decremented, the credit accounting requires the AddToSystemCredits(denomination) link. Getting this wrong mints/burns credits and halts the chain (the exact class of bug fixed by the shield credit-conservation work).

Reject-if total_fee >= denomination: if the metered + compute fee meets or exceeds the fixed denomination, the new identity would be credited ≤ 0. This must be a clean consensus rejection (no nullifier written), not an underflow.

  • Recommended error: reuse IdentityInsufficientBalanceError (dpp::consensus::state::identity), via the PaidFromAssetLock-style affordability check at validate_fees_of_event/v0/mod.rs:84-100 with previous_balance = denomination, added_balance = 0. Zero new error surface.
  • Alternative: a dedicated ShieldedDenominationCannotCoverIdentityFeeError under dpp::consensus::state::shielded (clearer reason string; costs a new state-error variant + code 40905 + version gating).

Design implication: the smallest denomination (0.1 DASH = 10×10⁹ credits) practically caps N — shielded_proof_verification_fee alone is ~100M credits, and large N could approach the floor. Document the per-denomination key budget and consider capping keys for the smallest denomination in basic-structure validation so the client fails fast at build time.

Guards:

  • Per-transition fee-coverage guard at execution (model: paid_from_address_inputs_and_outputs fee_fully_covered check, execute_event/v0/mod.rs:186-190; here the IdentityInsufficientBalanceError path is the analog).
  • Block-level sum-tree conservation check process_block_fees_and_validate_sum_trees_v0 (packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/process_block_fees_and_validate_sum_trees/v0/mod.rs:171-190): calculate_total_credits_balance(...).ok()CorruptedCreditsNotBalanced chain-halt if violated. A full-block test MUST exercise and pass this (see Testing).

Execution proof — merged identity + nullifier proof (STRICT)

The proof must attest both the created identity and the spent nullifiers in a single merged multi-root GroveDB proof. Build it on the STRICT merged-query pattern from day one — NOT the Unshield subset pattern.

The Unshield verify side uses GroveDb::verify_subset_query_with_absence_proof (packages/rs-drive/src/verify/shielded/verify_shielded_nullifiers/v0/mod.rs:32-44, subset flag true), which accepts a proof containing the queried keys but does NOT reject extra branches — a malicious prover can pad the proof. Issue #3812 tracks tightening these. The correct reference is the STRICT ShieldFromAssetLock surplus path introduced in PR #3793, commit 63c31f7c6e (verify_state_transition_was_executed_with_proof/v0/mod.rs:1293-1507), which uses GroveDb::verify_query_with_absence_proof against the prover's exact merged query.

PROVE side (packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs, new arm modeled on the Unshield arm :322 and the ShieldFromAssetLock surplus-merge :411):

  • Build the nullifier PathQuery over shielded_credit_pool_nullifiers_path_vec().
  • Build the identity PathQuery via Drive::full_identity_query(&identity_id, ...) (as IdentityCreateFromAddresses does at :252).
  • Clear each sub-query's limit to None before mergingPathQuery::merge rejects limited sub-queries.
  • PathQuery::merge(vec![&nullifier_pq, &identity_pq], grove_version), feed to grove_get_proved_path_query.

VERIFY side (verify_state_transition_was_executed_with_proof/v0/mod.rs, new arm modeled on the strict ShieldFromAssetLock arm :1293-1507):

  • Rebuild the byte-identical merged PathQuery (same roots, same sub-queries, limit = None).
  • Set the merged query limit = Some(u16::MAX) (the :1346-1358 gotcha): verify_query_with_absence_proof requires a limit, and merge leaves it None. Use an unreachable u16::MAX so the per-layer succinctness check (which rejects extra proof branches) runs fully on every layer. A smaller limit would break the result loop early and falsely reject honest proofs; the limit does NOT relax extra-data rejection.
  • GroveDb::verify_query_with_absence_proof(proof, &merged_pq, grove_version).
  • Partition the proved key/values. Unlike the ShieldFromAssetLock reference (which partitions by key length, 36-byte outpoint vs 21-byte address), the nullifier keys and the identity id are BOTH 32 bytes — so partition by PATH/root prefix (the (path, key, element) tuple's path segment: nullifier tree vs RootTree::Identities), not key length. This is the one place the new transition diverges from the reference.
  • Assert root-hash unity, require the identity element present, and return a new StateTransitionProofResult variant.

New proof-result variant (packages/rs-dpp/src/state_transition/proof_result.rs, append at TAIL after :76): e.g. VerifiedIdentityWithShieldedNullifiers(Identity, Vec<(Vec<u8>, bool)>). The closest existing template is VerifiedShieldedNullifiersWithWithdrawalDocument (:65-68) — replace the document map with the verified Identity.

Open design decisions

(a) Identity-id derivation — HARD, UNRESOLVED

There is no asset-lock outpoint (IdentityCreate) and no address+nonce inputs (IdentityCreateFromAddresses). The id must be unique, deterministic, non-malleable, and bound to the Orchard authorization.

Option Tradeoffs
A. id = double_sha256(sorted nullifiers) (RECOMMENDED) + Unique (nullifiers are globally-unique one-time spend tags, enforced by validate_nullifiers, shielded_common/mod.rs:278-316). + Deterministic. + Already covered by the Orchard bundle commitment (reconstruct_and_verify_bundle:211), so binding is free. + Sorting the nullifier set makes action-ordering non-malleable. − Minor: links the id to a public nullifier (the nullifier is public anyway).
B. id = double_sha256(platform_sighash) + Unique per bundle, automatically bound. − The id is derived FROM the sighash, so it must NOT also be inside extra_data (avoid circularity).
C. Dedicated committed identity_id field, required ∈ extra_sighash_data + Client can choose a vanity/known id. − Requires a uniqueness-against-state GroveDB read (client controls it) + more validation surface.

Recommended: Option A — re-derive at consensus and reject a mismatch exactly like IdentityCreate (identity_create/advanced_structure/v0/mod.rs:43-78), but since there is no asset lock to penalize, a mismatch is a plain consensus rejection (no nullifier consumed). hash_double is at crate::util::hash::hash_double.

(b) Authorization / signing + sighash-binding requirement — HARD

Two authorization boundaries must BOTH hold and be cross-bound:

  1. Orchard authorizes the SPEND — proof + per-action spend_auth_sig + binding sig (no platform signature field). Transparent fields are bound via extra_sighash_data fed into compute_platform_sighash (packages/rs-dpp/src/shielded/mod.rs:111-117).
  2. Each identity key proves possession — per-key signatures over signable_bytes (model: identity_create/identity_and_signatures/v0/mod.rs:21-42).

CRITICAL binding (the surplus_output lesson): in ShieldFromAssetLock, surplus_output is a signed field placed BEFORE the excluded signature so the ECDSA sig commits to it and it cannot be redirected (.../shield_from_asset_lock_transition/v0/mod.rs:49-59). The Orchard signature here is the analog of that ECDSA signature. The identity id AND the full public-key set AND the denomination MUST be inside the Orchard extra_sighash_data. Otherwise a relayer/validator could take a valid bundle exiting a denomination and re-point it at a different identity id or swap in different keys they control, stealing the credited balance — the exact redirection attack the unshield output_address and surplus_output bindings prevent. Per-key proofs-of-possession alone do NOT prevent this (a relayer keeps valid PoP sigs for their own keys while swapping the bundle); the extra_sighash_data binding is what makes (this spend → these exact keys → this id) atomic.

Add a new helper identity_create_from_shielded_extra_sighash_data(...) in packages/rs-dpp/src/shielded/mod.rs (model: unshield_extra_sighash_data:161-166, shielded_withdrawal_extra_sighash_data:140-152) producing:

identity_id (32) || denomination (u64 LE) || num_keys (u16 LE)
  || for each key in canonical order: key_id || purpose || security_level || key_type || len-prefixed key_data

Length-prefix the variable key list (unlike the withdrawal helper, whose output_script is fixed-length). Call it from both the builder and the verifier.

Penalty model: there is no asset lock to burn (unlike IdentityCreate). A bad Orchard proof or id/key mismatch is simply rejected (transition invalid, no nullifier written) — the Unshield model. Decide whether proof verification runs in validate_shielded_proof (Unshield) or inside transform_into_action (ShieldFromAssetLock); since there is no collateral to penalize, the Unshield validate_shielded_proof placement fits, BUT note Research finding 4 argues for keeping it in transform_into_action so a bad proof can't be spammed cheaply against pool state — this placement is itself an open sub-decision to settle during implementation.

(c) Fixed denominations

Restrict the exit to {0.1, 0.3, 0.5, 1.0} DASH (versioned). Recommended: yes — a variable exit amount is a distinguishing fingerprint; a small fixed set maximizes the anonymity set (mirrors ShieldedTransfer's exact-fee uniformity). Tradeoff: clients must hold/select notes that cover a denomination, and the smallest denomination caps N keys (decision (b)/fee section). The set is versioned so it can grow without a hard fork beyond a protocol bump.

(d) Change-note vs no-change

Option Tradeoffs
Change note back to pool (RECOMMENDED) + Client can spend any notes ≥ denomination (no pre-splitting). + Mirrors Unshield, which already insert_notes for change (unshield_transition.rs:59). + The change note is an ordinary indistinguishable Orchard output. − Extra output action raises num_actions → higher compute + note-storage cost.
No change + Simplest, smallest proof, lowest fee → more of the denomination reaches the identity. + Most uniform bundle shape. − Client must hold notes summing exactly to a denomination.

Recommended: allow a change note — it matches the established Unshield mechanics and avoids forcing exact-denomination note pre-splitting. Either way, value_balance (the net leaving the pool) must equal the denomination exactly; the change stays internal to the bundle.

(e) Replay

Resolved: nullifiers provide replay protection; no outpoint needed. The converter writes each spent note's nullifier (insert_nullifiers, using insert_only_known_to_not_already_exist_op); validate_nullifiers (shielded_common/mod.rs:278-316) rejects any nullifier already in state or duplicated intra-bundle (read-your-own-writes within the block). Re-submitting fails at the nullifier check. Because the id (decision (a), Option A) is derived from those same nullifiers, it is single-use by construction. unique_identifiers() = nullifier hexes gives mempool-level dedup.

Implementation checklist

Two cross-cutting rules apply throughout:

  1. bincode TAIL-APPEND. StateTransition, StateTransitionType, BasicError, StateError are encoded by positional discriminant under bincode (Encode, Decode derives). A new variant MUST be appended at the TAIL or every later variant's wire discriminant shifts, breaking deserialization of all historical/in-flight data. StateTransitionProofResult / StateTransitionAction / ExecutionEvent are not bincode-positional but append at tail anyway by convention.
  2. v12 version-gating. Every new validation/serialization/method version field must be enabled only from the v12-activation version file and disabled (None/empty) earlier; runtime gating goes through is_allowed.rs.

DPP

  • New transition source tree packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/ (mirror the shield_from_asset_lock_transition/ 17-file layout: mod.rs, version.rs, fields.rs, state_transition_like.rs, state_transition_validation.rs, state_transition_estimated_fee_validation.rs, proved.rs, signing_tests.rs, methods/{mod,v0/mod}.rs, v0/{mod,types,version,v0_methods,state_transition_like,state_transition_validation,proved}.rs).
  • Register pub mod identity_create_from_shielded_pool_transition; in .../shielded/mod.rs.
  • StateTransition enum + ALL macro/match arms in packages/rs-dpp/src/state_transition/mod.rs: import + ...Signable (~:151); call_method! arms (:160-209); identity-signed macros → None/no-op/CorruptedCodeExecution (:211-411); append the enum variant at TAIL after ShieldedWithdrawal (:451); active_version_range 12..=LATEST shielded group (:535-539); is_identity_signed exclusion (:543); name"IdentityCreateFromShieldedPool" (:588); signatureNone; required_number_of_private_keys0; user_fee_increase0; owner_id/inputsNone; set_signature/set_user_fee_increase no-ops.
  • StateTransitionType enum (packages/rs-dpp/src/state_transition/state_transition_types.rs:20): add IdentityCreateFromShieldedPool = 20 at TAIL, plus Display, TryFrom<u8>, all-variants list, and update the in-file roundtrip tests.
  • New extra_sighash_data helper in packages/rs-dpp/src/shielded/mod.rs (decision (b)).
  • New compute_shielded_identity_create_fee in compute_minimum_shielded_fee/{v0/mod.rs,mod.rs} + new storage-byte constants in shielded/mod.rs (keyed on dpp.methods.compute_minimum_shielded_fee).
  • PlatformSignable derive on the V0 struct; mirror ShieldFromAssetLock/Unshield field attributes. Add a "signable_bytes differ when public_keys / denomination differ" test.

rs-drive

  • New action tree packages/rs-drive/src/state_transition_action/shielded/identity_create_from_shielded_pool/{mod,transformer,v0/mod,v0/transformer}.rs (carry built Identity/keys/balance, nullifiers, denomination, fee_amount); register in .../shielded/mod.rs.
  • StateTransitionAction enum (packages/rs-drive/src/state_transition_action/mod.rs): import (~:35), append IdentityCreateFromShieldedPoolAction(...) at TAIL after :109, user_fee_increase arm (:114).
  • New converter action_convert_to_operations/shielded/identity_create_from_shielded_pool_transition.rs emitting: insert_nullifiers + AddNewIdentity{ balance: denomination } + AddToSystemCredits(denomination) + (optional) insert_notes change + UpdateTotalBalance(pool − denomination). NO AddToSystemCredits mistake: it IS needed here and equals denomination (see Conservation). Register in .../shielded/mod.rs and add a dispatch arm in action_convert_to_operations/mod.rs:35. Add a new converter version key identity_create_from_shielded_pool_transition to drive_state_transition_method_versions.
  • Prove arm (prove/prove_state_transition/v0/mod.rs) and strict verify arm (verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs) — see Execution proof. (Verify side builds under --features verify; check with cargo check -p drive --no-default-features --features verify.)

rs-drive-abci

  • New validation module execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/{mod,tests,transform_into_action/{mod,v0/mod}}.rs (reuse shielded_common helpers + reconstruct_and_verify_bundle; do anchor/nullifier/pool checks, denomination check, key-structure + PoP validation, id re-derivation, fee computation). Register in state_transitions/mod.rs.
  • Transformer dispatch arm in execution/validation/state_transition/transformer/mod.rs (~:270).
  • Processor predicate/dispatch traits (processor/traits/*.rs): add an arm to each — is_allowed.rs (true, gate on SHIELDED_POOL_INITIAL_PROTOCOL_VERSION in both match sites), basic_structure.rs (call V0 validate_structure), shielded_proof.rs (has_shielded_proof_validation / has_shielded_minimum_fee_validation / validate_shielded_proof arm with FLAGS_SPENDS_AND_OUTPUTS, value_balance = denomination as i64, new extra_sighash_data; new ShieldedMinFeeKind::IdentityCreate tuple arm checking denomination ∈ set and denomination >= compute_shielded_identity_create_fee(...)), state.rs/identity_based_signature.rs/identity_nonces.rs/address_*/identity_balance.rs (all false/not-identity-signed), advanced_structure_with_state.rs (decide true if key-uniqueness-in-state is done there).
  • New ExecutionEvent variant PaidFromShieldedPoolToNewIdentity { identity, operations, execution_operations, denomination, additional_fixed_fee_cost } in execution/types/execution_event/mod.rs (append at tail) + a create_from_state_transition_action arm (after :555).
  • validate_fees_of_event/v0/mod.rs arm (~:50): affordability check denomination >= total_feeIdentityInsufficientBalanceError (or new error); update no_fee_events tests.
  • execute_event/v0/mod.rs: classify the new variant in maybe_fee_validation_result (~:367); main match event arm (after :624) that applies ops, meters, adds additional_fixed_fee_cost to processing, deducts total_fee from the new identity via into_balance_change + apply_balance_change_from_fee_to_identity, returns SuccessfulPaidExecution.

rs-platform-version

  • Add shielded_identity_create_denominations: &'static [u64] to DriveAbciValidationConstants (drive_abci_validation_versions/mod.rs:27-53); populate in v8.rs event_constants, empty in v1–v7.
  • Add identity_create_from_shielded_pool_state_transition: DriveAbciStateTransitionValidationVersion to DriveAbciStateTransitionValidationVersions (mod.rs:66); set per-version in v1.rsv8.rs (disabled pre-v12, Some(0)/0 in v8).
  • Add identity_create_from_shielded_pool_state_transition: FeatureVersionBounds to DPPStateTransitionSerializationVersions (dpp_state_transition_serialization_versions/{mod,v1,v2}.rs).
  • Add the converter method version key to drive_state_transition_method_versions/v2.rs.

Consensus errors

  • If using a dedicated denomination error: ShieldedInvalidDenominationError (basic) — struct under packages/rs-dpp/src/consensus/basic/state_transition/ (clone ShieldedInvalidValueBalanceError); append to BasicError at TAIL (basic/basic_error.rs); code 10827 (next free; 10819–10826 used, codes.rs:236-243).
  • If using a dedicated under-coverage error: ShieldedDenominationCannotCoverIdentityFeeError (state) — under packages/rs-dpp/src/consensus/state/shielded/; append to StateError at TAIL (state/state_error.rs); code 40905 (next free, codes.rs:383-388). Otherwise reuse IdentityInsufficientBalanceError (zero new surface — recommended).
  • Register From/code mappings in codes.rs.

Proof prove/verify

rs-sdk

  • New packages/rs-sdk/src/platform/transition/identity_create_from_shielded_pool.rs (clone unshield.rs: build via Orchard prover → ensure_valid_state_transition_structure → broadcast, returns the new proof-result variant); register in transition/mod.rs. No new Fetch query is added, so the README.md Fetch/FetchMany checklist (:125-162) does not apply.

wasm-dpp2

  • New packages/wasm-dpp2/src/shielded/identity_create_from_shielded_pool_transition.rs (clone unshield_transition.rs); declare in shielded/mod.rs; re-export in lib.rs:71.
  • state_transitions/base/state_transition.rs: type-code map (:313, => 20) + the five grouping matches that list Unshield(_) (:402,:429,:571,:648,:745).
  • packages/wasm-dpp/src/state_transition/state_transition_factory.rs:84 — add to the shielded group.

FFI + platform-wallet

  • rs-platform-wallet: new pub async fn identity_create_from_shielded_pool(...) in wallet/shielded/operations.rs (clone unshield/withdraw note-reservation + build + broadcast); add a ShieldedFeeKind variant in note_selection.rs for the denomination − fee reservation math; update module doc header.
  • rs-platform-wallet-ffi: new #[no_mangle] extern "C" fn platform_wallet_manager_shielded_identity_create_from_pool(...) in src/shielded_send.rs (clone platform_wallet_manager_shielded_unshield).
  • Swift surface MUST go through the swift-rust-ffi-engineer agent (per CLAUDE.md). Keep all business logic in rs-platform-wallet behind the FFI; Swift only persists/loads/bridges. No DAPI/.proto change is needed — broadcastStateTransition is opaque bytes dispatched by variant.

Book & docs

  • book/src/state-transitions/{lifecycle,validation-pipeline,transform-into-action,drive-operations}.md — add the transition to the per-transition tables.
  • book/src/fees/shielded-fees.md — document total_fee composition + the denomination set.
  • book/src/error-handling/{error-codes,consensus-errors}.md — add any new error codes.
  • book/src/evo-sdk/state-transitions.md — add the SDK usage entry.

Testing requirements

  • Block-level sum-tree conservation (full block pipeline). Drive a full block containing the transition and assert process_block_fees_and_validate_sum_trees_v0 passes (:171-190): pool −= denomination, new identity balance == denomination − total_fee, fee pools += total_fee, fee_refunds empty. Model on the ShieldFromAssetLock surplus block-level conservation test and strategy_tests/test_cases/shielded_tests.rs (run_chain_unshield_transitions) + verify_state_transitions.rs. Also keep an op-level delta test (model unshield_transition.rs:190-222).
  • Denomination validity / boundary. Each of {10,30,50,100}×10⁹ accepted; any other amount rejected with the denomination error; value_balance != denomination rejected.
  • total_fee >= denomination rejection. Construct a case (large N and/or smallest denomination) where the metered + verification fee meets/exceeds the denomination; assert clean rejection (no nullifier written), not underflow.
  • Proof prove/verify roundtrip + padded-proof negative test. Full process → commit → prove → verify roundtrip (clone test_unshield_prove_and_verify_nullifiers_and_address). A padded-proof negative test that confirms the STRICT verifier rejects a proof with extra branches (the very thing verify_subset_query_with_absence_proof would have accepted — Tighten Unshield & ShieldedWithdrawal execution proofs to a single strict merged query (parity with ShieldFromAssetLock) #3812).
  • Variable key-count coverage. Exercise N = 1, a mid count, and max_public_keys_in_creation; assert metered total_fee grows monotonically with N and conservation holds at each.
  • Malleability / redirect-rejection. Mutating the bound identity payload (id or any key) while keeping the same valid Orchard bundle is rejected (model: test_different_output_address_with_same_valid_bundle_is_rejected, unshield/tests.rs:688). Also test_valid_proof_with_mutated_value_balance_is_rejected and test_duplicate_nullifiers_in_same_bundle analogs.
  • Fee-metering invariant. Clone test_minimum_shielded_fee_covers_actual_grovedb_write_cost (unshield_transition.rs:276-344): meter real GroveDB cost in estimation mode (apply=false) for the variable-N identity write and assert fee >= cost and fee > storage so the booking-split min() never binds.
  • Structure/serialization. Round-trip ser/de (TAIL-append discriminant correctness), structure-validation cases (note size, action count, empty proof, zero anchor), key-structure cases (cloned from IdentityCreate).

Version gating & rollout (v12)

  • Activates at SHIELDED_POOL_INITIAL_PROTOCOL_VERSION = 12 (feature_initial_protocol_versions.rs:4), the same gate as the entire shielded family.
  • Runtime enforcement: add StateTransition::IdentityCreateFromShieldedPool(_) to both has_is_allowed_validation and validate_is_allowed (processor/traits/is_allowed.rs:35-39,:77-94); pre-v12 it returns StateTransitionNotActiveError.
  • active_version_range in state_transition/mod.rs places it in the 12..=LATEST_VERSION shielded group.
  • All new version fields are enabled only in the v12-activation version file (v8.rs / STATE_TRANSITION_SERIALIZATION_VERSIONS_V2 / STATE_TRANSITION_VERSIONS_V3 / DPP_METHOD_VERSIONS_V2, wired by packages/rs-platform-version/src/version/v12.rs) and disabled in earlier version files.
  • Post-implementation checks: cargo check -p dpp then --workspace; cargo check -p drive --no-default-features --features verify; cargo fmt --all before committing.

References

  • Unshield (pool-spend exit template; spend mechanics): packages/rs-dpp/src/state_transition/state_transitions/shielded/unshield_transition/, packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/, converter packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/unshield_transition.rs.
  • IdentityCreate / IdentityCreateFromAddresses (variable-key identity-creation back-end): packages/rs-dpp/src/state_transition/state_transitions/identity/identity_create_transition/, .../identity_create_from_addresses_transition/, converter .../action_convert_to_operations/identity/identity_create_transition.rs, advanced structure packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/advanced_structure/v0/mod.rs.
  • ShieldFromAssetLock (strict merged prove/verify + signed-field/surplus_output binding pattern): packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/, .../state_transitions/shield_from_asset_lock/transform_into_action/v0/mod.rs, strict verify packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs:1293-1507.
  • PR feat(drive): shielded fees for Shield/ShieldFromAssetLock + shield credit conservation #3793 (commit 63c31f7c6e) — the single STRICT merged-query verification pattern (GroveDb::verify_query_with_absence_proof, limit=Some(u16::MAX), partition-by-key) that this transition's proof must follow.
  • Issue Tighten Unshield & ShieldedWithdrawal execution proofs to a single strict merged query (parity with ShieldFromAssetLock) #3812 — tracks migrating the remaining shielded verifies (incl. Unshield) off subset verification; build this transition strict from day one to avoid that debt.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions