From 33b642553fa766f52487ff2c5d19e217950c4a5e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 12 Sep 2026 18:01:00 +0700 Subject: [PATCH 01/17] feat(platform)!: add ShieldFromIdentity state transition (identity balance to shielded pool) Adds state transition type 21, `ShieldFromIdentity`, which moves credits from a Platform identity's balance directly into the Orchard shielded pool in one identity-signed transition. Today the same result needs two transitions (IdentityCreditTransferToAddresses then Shield), two fees, and a throw-away platform address that links the identity to the shield. The transition is a hybrid of two shipped ones: the funding side is exactly IdentityCreditTransferToAddresses (identity id, identity nonce, TRANSFER key, identity-paid fee path); the pool side is exactly Shield (outputs-only Orchard bundle, FLAGS_OUTPUTS_ONLY proof verification with value_balance = -amount, note inserts, pool total update, shielded compute fee as the fixed add-on). Consensus: - activates at protocol version 14 via SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION (is_allowed gate and 14..=LATEST active range); older tables carry inactive rows - fee = metered storage/processing + compute_shielded_verification_fee, paid from the identity through the existing Paid execution event; user_fee_increase applies - ops: UpdateIdentityNonce, RemoveFromIdentityBalance, InsertNote per action, UpdateTotalBalance; no system-credit adjustment (identity balance and pool are both terms of the block conservation equation) - no extra Orchard sighash data: the identity signature covers every bundle field - proof result reuses VerifiedPartialIdentity (post-debit balance), as withdrawals do Clients: rs-sdk ShieldFromIdentity trait on Identity, platform-wallet operation and PlatformWallet wrappers with a new ShieldFromIdentity activity kind (tag 8), FFI entry point platform_wallet_manager_shielded_shield_from_identity and fee kind 3, wasm-dpp2 ShieldFromIdentityTransition wrapper and spec, legacy wasm-dpp arm, and the shielded fees book chapter. Tests: dpp structure/serialization/signable-bytes/JSON tests and builder tests, drive converter tests, and drive-abci tests with real Halo2 proofs covering the rejection matrix, a full block run with credit conservation and exact identity debit, fee increase, prove/verify round trip, and pre-14 rejection. Co-Authored-By: Claude Fable 5.1 --- book/src/fees/shielded-fees.md | 28 +- packages/rs-dpp/src/shielded/builder/mod.rs | 2 + .../shielded/builder/shield_from_identity.rs | 179 +++++ packages/rs-dpp/src/state_transition/mod.rs | 39 + .../state_transition_types.rs | 9 +- .../state_transitions/shielded/mod.rs | 1 + .../accessors/mod.rs | 94 +++ .../accessors/v0/mod.rs | 56 ++ .../shield_from_identity_transition/fields.rs | 15 + .../identity_signed.rs | 33 + .../methods/mod.rs | 64 ++ .../methods/v0/mod.rs | 39 + .../shield_from_identity_transition/mod.rs | 190 +++++ ...ate_transition_estimated_fee_validation.rs | 48 ++ .../state_transition_like.rs | 80 ++ .../state_transition_validation.rs | 15 + .../v0/identity_signed.rs | 22 + .../shield_from_identity_transition/v0/mod.rs | 160 ++++ .../v0/state_transition_like.rs | 76 ++ .../v0/state_transition_validation.rs | 200 +++++ .../v0/types.rs | 17 + .../v0/v0_methods.rs | 88 +++ .../v0/version.rs | 9 + .../version.rs | 11 + .../execution/types/execution_event/mod.rs | 29 + .../traits/address_balances_and_nonces.rs | 2 + .../processor/traits/address_witnesses.rs | 2 + .../traits/addresses_minimum_balance.rs | 2 + .../processor/traits/basic_structure.rs | 32 + .../processor/traits/identity_balance.rs | 4 + .../traits/identity_based_signature.rs | 3 + .../processor/traits/identity_nonces.rs | 8 + .../processor/traits/is_allowed.rs | 20 +- .../processor/traits/shielded_proof.rs | 23 +- .../processor/traits/state.rs | 6 + .../state_transition/state_transitions/mod.rs | 2 + .../shield_from_identity/mod.rs | 55 ++ .../shield_from_identity/nonce/mod.rs | 49 ++ .../shield_from_identity/nonce/v0/mod.rs | 70 ++ .../shield_from_identity/tests.rs | 730 ++++++++++++++++++ .../transform_into_action/mod.rs | 1 + .../transform_into_action/v0/mod.rs | 43 ++ .../state_transition/transformer/mod.rs | 8 + .../verify_state_transitions.rs | 3 +- .../prove/prove_state_transition/v0/mod.rs | 6 + .../action_convert_to_operations/mod.rs | 3 + .../shielded/mod.rs | 1 + .../shield_from_identity_transition.rs | 164 ++++ .../src/state_transition_action/mod.rs | 4 + .../state_transition_action/shielded/mod.rs | 2 + .../shielded/shield_from_identity/mod.rs | 95 +++ .../shield_from_identity/transformer.rs | 20 + .../shielded/shield_from_identity/v0/mod.rs | 23 + .../shield_from_identity/v0/transformer.rs | 25 + .../v0/mod.rs | 28 + .../mod.rs | 1 + .../v1.rs | 5 + .../v2.rs | 5 + .../v3.rs | 5 + .../drive_abci_validation_versions/mod.rs | 1 + .../drive_abci_validation_versions/v1.rs | 8 + .../drive_abci_validation_versions/v10.rs | 8 + .../drive_abci_validation_versions/v2.rs | 8 + .../drive_abci_validation_versions/v3.rs | 8 + .../drive_abci_validation_versions/v4.rs | 8 + .../drive_abci_validation_versions/v5.rs | 8 + .../drive_abci_validation_versions/v6.rs | 8 + .../drive_abci_validation_versions/v7.rs | 8 + .../drive_abci_validation_versions/v8.rs | 8 + .../drive_abci_validation_versions/v9.rs | 8 + .../mod.rs | 1 + .../v1.rs | 1 + .../v2.rs | 1 + .../v3.rs | 1 + .../v4.rs | 1 + .../feature_initial_protocol_versions.rs | 2 + .../rs-platform-version/src/version/v14.rs | 10 + .../rs-platform-wallet-ffi/src/persistence.rs | 6 + .../src/shielded_send.rs | 83 +- .../src/wallet/platform_wallet.rs | 149 ++++ .../src/wallet/shielded/activity.rs | 9 + .../src/wallet/shielded/operations.rs | 198 ++++- packages/rs-sdk/src/platform/transition.rs | 2 + .../transition/shield_from_identity.rs | 109 +++ .../state_transition_factory.rs | 3 +- packages/wasm-dpp2/src/lib.rs | 4 +- packages/wasm-dpp2/src/shielded/mod.rs | 2 + .../shield_from_identity_transition.rs | 339 ++++++++ .../base/state_transition.rs | 13 + .../unit/ShieldFromIdentityTransition.spec.ts | 116 +++ 90 files changed, 4070 insertions(+), 15 deletions(-) create mode 100644 packages/rs-dpp/src/shielded/builder/shield_from_identity.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/accessors/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/accessors/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/fields.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/identity_signed.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/methods/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/methods/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_like.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_validation.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/identity_signed.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/state_transition_like.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/state_transition_validation.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/types.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/v0_methods.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/version.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/version.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/nonce/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/nonce/v0/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_identity_transition.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/transformer.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/v0/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/v0/transformer.rs create mode 100644 packages/rs-sdk/src/platform/transition/shield_from_identity.rs create mode 100644 packages/wasm-dpp2/src/shielded/shield_from_identity_transition.rs create mode 100644 packages/wasm-dpp2/tests/unit/ShieldFromIdentityTransition.spec.ts diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index e8aec3c3183..60fe01b84bf 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -43,6 +43,7 @@ The fee is derived differently depending on the shielded transition type: | **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). | | **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-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 @@ -52,8 +53,9 @@ 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 +The *entry* transitions — `Shield` (transparent → shielded), `ShieldFromAssetLock` +(Core asset lock → shielded), and, from protocol version 14, `ShieldFromIdentity` +(identity balance → 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 @@ -91,6 +93,28 @@ floor requires only `shield_amount + shielded_verification_fee` (a conservative since metered storage is unknowable without state); the authoritative `metered + compute` funding gate is `validate_fees_of_event`. +### ShieldFromIdentity + +`ShieldFromIdentity` (protocol version 14) is `Shield` with the identity balance as +the funding side. It is identity-signed (TRANSFER key, identity nonce) like +`IdentityCreditTransferToAddresses`, and carries the same outputs-only Orchard bundle +as `Shield`. The fee model is identical to `Shield`'s: GroveDB meters the note +inserts and the identity balance and nonce writes, and the shielded compute fee is +added as `additional_fixed_fee_cost`: + +``` +fee = metered_storage + metered_processing + shielded_verification_fee +identity_balance_after = identity_balance_before - amount - fee +``` + +`user_fee_increase` applies to the metered processing portion. The stateless +floor requires `identity_balance >= amount + shielded_verification_fee`; the +authoritative gate is the identity-paid fee validation of the execution event +(`Paid`), which rejects with `IdentityInsufficientBalanceError`. The identity +balance and the pool total are both terms of the block conservation equation, so +the converter emits no `AddToSystemCredits` (the same rule `Unshield` and +`IdentityCreateFromShieldedPool` follow). + ### ShieldFromAssetLock The asset lock funds the pool, so the fee is taken from the consumed asset-lock value. diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index ae7b9047752..c423d8d1d72 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -32,6 +32,7 @@ mod identity_create_from_shielded_pool; mod shield; mod shield_from_asset_lock; +mod shield_from_identity; mod shielded_transfer; mod shielded_withdrawal; mod unshield; @@ -43,6 +44,7 @@ pub use identity_create_from_shielded_pool::{ 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; +pub use shield_from_identity::build_shield_from_identity_transition; pub use shielded_transfer::build_shielded_transfer_transition; pub use shielded_withdrawal::build_shielded_withdrawal_transition; pub use unshield::build_unshield_transition; diff --git a/packages/rs-dpp/src/shielded/builder/shield_from_identity.rs b/packages/rs-dpp/src/shielded/builder/shield_from_identity.rs new file mode 100644 index 00000000000..29486d10518 --- /dev/null +++ b/packages/rs-dpp/src/shielded/builder/shield_from_identity.rs @@ -0,0 +1,179 @@ +use crate::address_funds::OrchardAddress; +use crate::identity::signer::Signer; +use crate::identity::{Identity, IdentityPublicKey}; +use crate::prelude::{IdentityNonce, UserFeeIncrease}; +use crate::state_transition::shield_from_identity_transition::methods::ShieldFromIdentityTransitionMethodsV0; +use crate::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use crate::state_transition::StateTransition; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +use super::{build_output_only_bundle, serialize_authorized_bundle, OrchardProver}; + +/// Build a `ShieldFromIdentity` transition: prove an outputs-only Orchard bundle +/// paying `shield_amount` to `recipient`, then identity-sign it with a TRANSFER key. +/// +/// The identity nonce must already be the next nonce for `identity` (the SDK +/// fetches it); `amount + fee` is debited from the identity balance at execution. +#[allow(clippy::too_many_arguments)] +pub async fn build_shield_from_identity_transition< + S: Signer, + P: OrchardProver, +>( + identity: &Identity, + recipient: &OrchardAddress, + shield_amount: u64, + nonce: IdentityNonce, + signer: &S, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + user_fee_increase: UserFeeIncrease, + prover: &P, + memo: [u8; 36], + sender_ovk: Option, + platform_version: &PlatformVersion, +) -> Result { + if shield_amount == 0 { + return Err(ProtocolError::ShieldedBuildError( + "shield amount must be greater than zero".to_string(), + )); + } + + let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, 0, prover)?; + let sb = serialize_authorized_bundle(&bundle); + + ShieldFromIdentityTransition::try_from_bundle_with_identity_signer( + identity, + sb.value_balance.unsigned_abs(), + sb.actions, + sb.anchor, + sb.proof, + sb.binding_signature, + user_fee_increase, + signer, + signing_transfer_key_to_use, + nonce, + platform_version, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::address_funds::AddressWitness; + use crate::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver}; + use crate::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; + use crate::state_transition::StateTransitionIdentitySigned; + use platform_value::BinaryData; + use rand::rngs::StdRng; + use rand::SeedableRng; + + #[derive(Debug)] + struct DummySigner; + + #[async_trait::async_trait] + impl Signer for DummySigner { + async fn sign( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Ok(BinaryData::new(vec![0u8; 65])) + } + + async fn sign_create_witness( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Err(ProtocolError::ShieldedBuildError( + "identity signer never creates address witnesses".to_string(), + )) + } + + fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool { + true + } + } + + fn identity_with_transfer_key() -> Identity { + let platform_version = PlatformVersion::latest(); + let mut identity = + Identity::random_identity(0, Some(1), platform_version).expect("identity"); + let mut rng = StdRng::seed_from_u64(42); + let (key, _) = IdentityPublicKey::random_key_with_known_attributes( + 0, + &mut rng, + Purpose::TRANSFER, + SecurityLevel::CRITICAL, + KeyType::ECDSA_SECP256K1, + None, + platform_version, + ) + .expect("transfer key"); + identity.add_public_key(key); + identity + } + + #[tokio::test] + async fn test_build_shield_from_identity_rejects_zero_amount() { + let identity = identity_with_transfer_key(); + let result = build_shield_from_identity_transition( + &identity, + &test_orchard_address(), + 0, + 1, + &DummySigner, + None, + 0, + &TestProver, + [0u8; 36], + None, + PlatformVersion::latest(), + ) + .await; + let err = result + .expect_err("zero amount must be rejected") + .to_string(); + assert!(err.contains("greater than zero"), "unexpected error: {err}"); + } + + #[tokio::test] + async fn test_build_shield_from_identity_transition_valid() { + let identity = identity_with_transfer_key(); + let result = build_shield_from_identity_transition( + &identity, + &test_orchard_address(), + 50_000, + 7, + &DummySigner, + None, + 3, + &TestProver, + [0u8; 36], + None, + PlatformVersion::latest(), + ) + .await; + let st = result.expect("expected a built transition"); + let StateTransition::ShieldFromIdentity(t) = st else { + panic!("expected ShieldFromIdentity, got {st:?}"); + }; + assert_eq!(t.identity_id(), identity.id()); + assert_eq!(t.amount(), 50_000); + assert_eq!(t.nonce(), 7); + assert!(!t.actions().is_empty()); + let key = identity + .get_first_public_key_matching( + Purpose::TRANSFER, + SecurityLevel::full_range().into(), + KeyType::all_key_types().into(), + true, + ) + .expect("transfer key"); + assert_eq!(t.signature_public_key_id(), key.id()); + } +} diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 5c048ae84ef..f6a60e2e97d 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -139,6 +139,9 @@ use crate::state_transition::masternode_vote_transition::MasternodeVoteTransitio use crate::state_transition::shield_from_asset_lock_transition::{ ShieldFromAssetLockTransition, ShieldFromAssetLockTransitionSignable, }; +use crate::state_transition::shield_from_identity_transition::{ + ShieldFromIdentityTransition, ShieldFromIdentityTransitionSignable, +}; use crate::state_transition::shield_transition::{ShieldTransition, ShieldTransitionSignable}; use crate::state_transition::shielded_transfer_transition::{ ShieldedTransferTransition, ShieldedTransferTransitionSignable, @@ -181,6 +184,7 @@ macro_rules! call_method { StateTransition::ShieldFromAssetLock(st) => st.$method($args), StateTransition::ShieldedWithdrawal(st) => st.$method($args), StateTransition::IdentityCreateFromShieldedPool(st) => st.$method($args), + StateTransition::ShieldFromIdentity(st) => st.$method($args), } }; ($state_transition:expr, $method:ident ) => { @@ -206,6 +210,7 @@ macro_rules! call_method { StateTransition::ShieldFromAssetLock(st) => st.$method(), StateTransition::ShieldedWithdrawal(st) => st.$method(), StateTransition::IdentityCreateFromShieldedPool(st) => st.$method(), + StateTransition::ShieldFromIdentity(st) => st.$method(), } }; } @@ -234,6 +239,7 @@ macro_rules! call_getter_method_identity_signed { StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, StateTransition::IdentityCreateFromShieldedPool(_) => None, + StateTransition::ShieldFromIdentity(st) => Some(st.$method($args)), } }; ($state_transition:expr, $method:ident ) => { @@ -259,6 +265,7 @@ macro_rules! call_getter_method_identity_signed { StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, StateTransition::IdentityCreateFromShieldedPool(_) => None, + StateTransition::ShieldFromIdentity(st) => Some(st.$method()), } }; } @@ -287,6 +294,7 @@ macro_rules! call_method_identity_signed { StateTransition::ShieldFromAssetLock(_) => {} StateTransition::ShieldedWithdrawal(_) => {} StateTransition::IdentityCreateFromShieldedPool(_) => {} + StateTransition::ShieldFromIdentity(st) => st.$method($args), } }; ($state_transition:expr, $method:ident ) => { @@ -312,6 +320,7 @@ macro_rules! call_method_identity_signed { StateTransition::ShieldFromAssetLock(_) => {} StateTransition::ShieldedWithdrawal(_) => {} StateTransition::IdentityCreateFromShieldedPool(_) => {} + StateTransition::ShieldFromIdentity(st) => st.$method(), } }; } @@ -367,6 +376,7 @@ macro_rules! call_errorable_method_identity_signed { StateTransition::IdentityCreateFromShieldedPool(_) => Err(ProtocolError::CorruptedCodeExecution( "identity create from shielded pool transition can not be called for identity signing".to_string(), )), + StateTransition::ShieldFromIdentity(st) => st.$method($( $arg ),*), } }; ($state_transition:expr, $method:ident) => { @@ -418,6 +428,7 @@ macro_rules! call_errorable_method_identity_signed { StateTransition::IdentityCreateFromShieldedPool(_) => Err(ProtocolError::CorruptedCodeExecution( "identity create from shielded pool transition can not be called for identity signing".to_string(), )), + StateTransition::ShieldFromIdentity(st) => st.$method(), } }; } @@ -481,6 +492,7 @@ pub enum StateTransition { ShieldFromAssetLock(ShieldFromAssetLockTransition), ShieldedWithdrawal(ShieldedWithdrawalTransition), IdentityCreateFromShieldedPool(IdentityCreateFromShieldedPoolTransition), + ShieldFromIdentity(ShieldFromIdentityTransition), } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] @@ -769,6 +781,15 @@ mod json_convertible_tests { "identityCreateFromShieldedPool", ); } + + #[test] + fn umbrella_shield_from_identity() { + let inner = crate::state_transition::shield_from_identity_transition::json_convertible_tests::fixture(); + assert_umbrella_round_trip( + StateTransition::ShieldFromIdentity(inner), + "shieldFromIdentity", + ); + } } impl OptionallyAssetLockProved for StateTransition { @@ -865,6 +886,7 @@ impl StateTransition { | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) => 12..=LATEST_VERSION, + StateTransition::ShieldFromIdentity(_) => 14..=LATEST_VERSION, } } @@ -987,6 +1009,7 @@ impl StateTransition { Self::ShieldFromAssetLock(_) => "ShieldFromAssetLock".to_string(), Self::ShieldedWithdrawal(_) => "ShieldedWithdrawal".to_string(), Self::IdentityCreateFromShieldedPool(_) => "IdentityCreateFromShieldedPool".to_string(), + Self::ShieldFromIdentity(_) => "ShieldFromIdentity".to_string(), } } @@ -1014,6 +1037,7 @@ impl StateTransition { StateTransition::ShieldFromAssetLock(st) => Some(st.signature()), StateTransition::ShieldedWithdrawal(_) => None, StateTransition::IdentityCreateFromShieldedPool(_) => None, + StateTransition::ShieldFromIdentity(st) => Some(st.signature()), } } @@ -1059,6 +1083,7 @@ impl StateTransition { StateTransition::Unshield(_) => 0, StateTransition::ShieldedWithdrawal(_) => 0, StateTransition::IdentityCreateFromShieldedPool(_) => 0, + StateTransition::ShieldFromIdentity(st) => st.user_fee_increase(), } } @@ -1127,6 +1152,7 @@ impl StateTransition { StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, StateTransition::IdentityCreateFromShieldedPool(_) => None, + StateTransition::ShieldFromIdentity(st) => Some(st.owner_id()), } } @@ -1154,6 +1180,7 @@ impl StateTransition { StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, StateTransition::IdentityCreateFromShieldedPool(_) => None, + StateTransition::ShieldFromIdentity(_) => None, } } @@ -1218,6 +1245,10 @@ impl StateTransition { | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) => false, + StateTransition::ShieldFromIdentity(st) => { + st.set_signature(signature); + true + } StateTransition::AddressFundingFromAssetLock(st) => { st.set_signature(signature); true @@ -1271,6 +1302,7 @@ impl StateTransition { StateTransition::Unshield(_) => {} StateTransition::ShieldedWithdrawal(_) => {} StateTransition::IdentityCreateFromShieldedPool(_) => {} + StateTransition::ShieldFromIdentity(st) => st.set_user_fee_increase(user_fee_increase), } } @@ -1452,6 +1484,10 @@ impl StateTransition { .to_string(), )) } + StateTransition::ShieldFromIdentity(st) => { + st.verify_public_key_level_and_purpose(identity_public_key, options)?; + st.verify_public_key_is_enabled(identity_public_key)?; + } } let data = self.signable_bytes()?; self.set_signature(signer.sign(identity_public_key, data.as_slice()).await?); @@ -1961,6 +1997,9 @@ impl StateTransitionStructureValidation for StateTransition { StateTransition::IdentityCreateFromShieldedPool(transition) => { transition.validate_structure(platform_version) } + StateTransition::ShieldFromIdentity(transition) => { + transition.validate_structure(platform_version) + } } } } diff --git a/packages/rs-dpp/src/state_transition/state_transition_types.rs b/packages/rs-dpp/src/state_transition/state_transition_types.rs index 8dbe3883029..d066feb38ed 100644 --- a/packages/rs-dpp/src/state_transition/state_transition_types.rs +++ b/packages/rs-dpp/src/state_transition/state_transition_types.rs @@ -41,6 +41,7 @@ pub enum StateTransitionType { ShieldFromAssetLock = 18, ShieldedWithdrawal = 19, IdentityCreateFromShieldedPool = 20, + ShieldFromIdentity = 21, } impl std::fmt::Display for StateTransitionType { @@ -123,6 +124,10 @@ mod tests { StateTransitionType::IdentityCreateFromShieldedPool, "IdentityCreateFromShieldedPool", ), + ( + StateTransitionType::ShieldFromIdentity, + "ShieldFromIdentity", + ), ]; for (variant, expected) in cases { assert_eq!( @@ -158,6 +163,7 @@ mod tests { (18, StateTransitionType::ShieldFromAssetLock), (19, StateTransitionType::ShieldedWithdrawal), (20, StateTransitionType::IdentityCreateFromShieldedPool), + (21, StateTransitionType::ShieldFromIdentity), ]; for (val, expected) in pairs { let result = StateTransitionType::try_from(val).unwrap(); @@ -167,7 +173,7 @@ mod tests { #[test] fn test_try_from_u8_invalid() { - assert!(StateTransitionType::try_from(21u8).is_err()); + assert!(StateTransitionType::try_from(22u8).is_err()); assert!(StateTransitionType::try_from(255u8).is_err()); } @@ -195,6 +201,7 @@ mod tests { StateTransitionType::ShieldFromAssetLock, StateTransitionType::ShieldedWithdrawal, StateTransitionType::IdentityCreateFromShieldedPool, + StateTransitionType::ShieldFromIdentity, ]; for variant in all_variants { let val: u8 = variant.into(); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs index 7167cb6a81c..e3c0f15409f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs @@ -1,6 +1,7 @@ pub mod common_validation; pub mod identity_create_from_shielded_pool_transition; pub mod shield_from_asset_lock_transition; +pub mod shield_from_identity_transition; pub mod shield_transition; pub mod shielded_transfer_transition; pub mod shielded_withdrawal_transition; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/accessors/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/accessors/mod.rs new file mode 100644 index 00000000000..67700b8a833 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/accessors/mod.rs @@ -0,0 +1,94 @@ +mod v0; + +pub use v0::*; + +use crate::prelude::IdentityNonce; +use crate::shielded::SerializedAction; +use crate::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use platform_value::Identifier; + +impl ShieldFromIdentityTransitionAccessorsV0 for ShieldFromIdentityTransition { + fn identity_id(&self) -> Identifier { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.identity_id, + } + } + + fn set_identity_id(&mut self, identity_id: Identifier) { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.identity_id = identity_id, + } + } + + fn amount(&self) -> u64 { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.amount, + } + } + + fn set_amount(&mut self, amount: u64) { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.amount = amount, + } + } + + fn actions(&self) -> &[SerializedAction] { + match self { + ShieldFromIdentityTransition::V0(v0) => &v0.actions, + } + } + + fn set_actions(&mut self, actions: Vec) { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.actions = actions, + } + } + + fn anchor(&self) -> [u8; 32] { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.anchor, + } + } + + fn set_anchor(&mut self, anchor: [u8; 32]) { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.anchor = anchor, + } + } + + fn proof(&self) -> &[u8] { + match self { + ShieldFromIdentityTransition::V0(v0) => &v0.proof, + } + } + + fn set_proof(&mut self, proof: Vec) { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.proof = proof, + } + } + + fn binding_signature(&self) -> [u8; 64] { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.binding_signature, + } + } + + fn set_binding_signature(&mut self, binding_signature: [u8; 64]) { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.binding_signature = binding_signature, + } + } + + fn nonce(&self) -> IdentityNonce { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.nonce, + } + } + + fn set_nonce(&mut self, nonce: IdentityNonce) { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.nonce = nonce, + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/accessors/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/accessors/v0/mod.rs new file mode 100644 index 00000000000..206c7646b50 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/accessors/v0/mod.rs @@ -0,0 +1,56 @@ +use crate::prelude::IdentityNonce; +use crate::shielded::SerializedAction; +use platform_value::Identifier; + +/// Accessors for the fields of a `ShieldFromIdentityTransition`. +/// +/// `signature` / `signature_public_key_id` are exposed through +/// [`StateTransitionSingleSigned`](crate::state_transition::StateTransitionSingleSigned) and +/// [`StateTransitionIdentitySigned`](crate::state_transition::StateTransitionIdentitySigned), +/// and `user_fee_increase` through +/// [`StateTransitionHasUserFeeIncrease`](crate::state_transition::StateTransitionHasUserFeeIncrease), +/// so they are intentionally not duplicated here. +pub trait ShieldFromIdentityTransitionAccessorsV0 { + /// The identity whose balance funds the shield. + fn identity_id(&self) -> Identifier; + /// Set the funding identity. + fn set_identity_id(&mut self, identity_id: Identifier); + + /// Credits leaving the identity balance and entering the shielded pool. + fn amount(&self) -> u64; + /// Set the shielded amount. + fn set_amount(&mut self, amount: u64); + + /// Get the serialized Orchard actions (spend/output pairs). + fn actions(&self) -> &[SerializedAction]; + /// Replace the serialized Orchard actions. + fn set_actions(&mut self, actions: Vec); + + /// Get the Orchard anchor (Sinsemilla root of the note commitment tree). + fn anchor(&self) -> [u8; 32]; + /// Set the Orchard anchor. + fn set_anchor(&mut self, anchor: [u8; 32]); + + /// Get the Halo2 proof bytes. + fn proof(&self) -> &[u8]; + /// Set the Halo2 proof bytes. + fn set_proof(&mut self, proof: Vec); + + /// Get the RedPallas binding signature. + fn binding_signature(&self) -> [u8; 64]; + /// Set the RedPallas binding signature. + fn set_binding_signature(&mut self, binding_signature: [u8; 64]); + + /// The identity nonce (replay protection). + fn nonce(&self) -> IdentityNonce; + /// Set the identity nonce. + fn set_nonce(&mut self, nonce: IdentityNonce); + + /// Extract nullifier bytes from each action. + fn nullifiers>(&self) -> Vec { + self.actions() + .iter() + .map(|a| T::from(a.nullifier)) + .collect() + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/fields.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/fields.rs new file mode 100644 index 00000000000..39e001622d9 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/fields.rs @@ -0,0 +1,15 @@ +use crate::state_transition::state_transitions; + +pub use state_transitions::common_fields::property_names::{ + IDENTITY_NONCE, SIGNATURE, SIGNATURE_PUBLIC_KEY_ID, STATE_TRANSITION_PROTOCOL_VERSION, + TRANSITION_TYPE, +}; + +pub(crate) mod property_names { + pub const IDENTITY_ID: &str = "identityId"; +} +pub use property_names::IDENTITY_ID; + +pub const IDENTIFIER_FIELDS: [&str; 1] = [IDENTITY_ID]; +pub const BINARY_FIELDS: [&str; 1] = [SIGNATURE]; +pub const U32_FIELDS: [&str; 1] = [STATE_TRANSITION_PROTOCOL_VERSION]; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/identity_signed.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/identity_signed.rs new file mode 100644 index 00000000000..c86a58ac990 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/identity_signed.rs @@ -0,0 +1,33 @@ +use crate::identity::{KeyID, Purpose, SecurityLevel}; +use crate::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use crate::state_transition::StateTransitionIdentitySigned; + +impl StateTransitionIdentitySigned for ShieldFromIdentityTransition { + fn signature_public_key_id(&self) -> KeyID { + match self { + ShieldFromIdentityTransition::V0(transition) => transition.signature_public_key_id(), + } + } + + fn set_signature_public_key_id(&mut self, key_id: KeyID) { + match self { + ShieldFromIdentityTransition::V0(transition) => { + transition.set_signature_public_key_id(key_id) + } + } + } + + fn security_level_requirement(&self, purpose: Purpose) -> Vec { + match self { + ShieldFromIdentityTransition::V0(transition) => { + transition.security_level_requirement(purpose) + } + } + } + + fn purpose_requirement(&self) -> Vec { + match self { + ShieldFromIdentityTransition::V0(transition) => transition.purpose_requirement(), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/methods/mod.rs new file mode 100644 index 00000000000..c54dfb80c66 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/methods/mod.rs @@ -0,0 +1,64 @@ +mod v0; + +pub use v0::*; + +use crate::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +#[cfg(feature = "state-transition-signing")] +use crate::{ + identity::{signer::Signer, Identity, IdentityPublicKey}, + prelude::{IdentityNonce, UserFeeIncrease}, + shielded::SerializedAction, + state_transition::{ + shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0, StateTransition, + }, + ProtocolError, +}; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +impl ShieldFromIdentityTransitionMethodsV0 for ShieldFromIdentityTransition { + #[cfg(feature = "state-transition-signing")] + async fn try_from_bundle_with_identity_signer>( + identity: &Identity, + amount: u64, + actions: Vec, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + user_fee_increase: UserFeeIncrease, + signer: &S, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + nonce: IdentityNonce, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .state_transition_serialization_versions + .shield_from_identity_state_transition + .default_current_version + { + 0 => { + ShieldFromIdentityTransitionV0::try_from_bundle_with_identity_signer( + identity, + amount, + actions, + anchor, + proof, + binding_signature, + user_fee_increase, + signer, + signing_transfer_key_to_use, + nonce, + platform_version, + ) + .await + } + version => Err(ProtocolError::UnknownVersionMismatch { + method: "ShieldFromIdentityTransition::try_from_bundle_with_identity_signer" + .to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/methods/v0/mod.rs new file mode 100644 index 00000000000..130a6766139 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/methods/v0/mod.rs @@ -0,0 +1,39 @@ +use crate::state_transition::StateTransitionType; +#[cfg(feature = "state-transition-signing")] +use crate::{ + identity::{signer::Signer, Identity, IdentityPublicKey}, + prelude::{IdentityNonce, UserFeeIncrease}, + shielded::SerializedAction, + state_transition::StateTransition, + ProtocolError, +}; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +pub trait ShieldFromIdentityTransitionMethodsV0 { + /// Build and identity-sign a shield-from-identity transition from an already + /// proven outputs-only Orchard bundle. + /// + /// `signing_transfer_key_to_use` selects the TRANSFER key; when `None` the first + /// TRANSFER key the signer can use is picked, as for credit transfers. + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn try_from_bundle_with_identity_signer>( + identity: &Identity, + amount: u64, + actions: Vec, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + user_fee_increase: UserFeeIncrease, + signer: &S, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + nonce: IdentityNonce, + platform_version: &PlatformVersion, + ) -> Result; + + /// Get State Transition Type + fn get_type() -> StateTransitionType { + StateTransitionType::ShieldFromIdentity + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/mod.rs new file mode 100644 index 00000000000..3a8f7d3c5f4 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/mod.rs @@ -0,0 +1,190 @@ +pub mod accessors; +pub mod fields; +mod identity_signed; +pub mod methods; +mod state_transition_estimated_fee_validation; +mod state_transition_like; +mod state_transition_validation; +pub mod v0; +mod version; + +use crate::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; +use crate::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0Signable; +use crate::state_transition::StateTransitionFieldTypes; + +pub type ShieldFromIdentityTransitionLatest = ShieldFromIdentityTransitionV0; + +use crate::identity::state_transition::OptionallyAssetLockProved; +#[cfg(feature = "json-conversion")] +use crate::serialization::JsonConvertible; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use derive_more::From; +use fields::*; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize, PlatformSignable}; +use platform_versioning::PlatformVersioned; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +/// Moves credits from a Platform identity's balance directly into the Orchard +/// shielded pool. +/// +/// The funding side is identity-signed (identity id, identity nonce, TRANSFER key), +/// exactly like `IdentityCreditTransferToAddresses`; the pool side is an +/// outputs-only Orchard bundle, exactly like `Shield`. +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformDeserialize, + PlatformSerialize, + PlatformSignable, + PlatformVersioned, + From, + PartialEq, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +#[cfg_attr( + all(feature = "json-conversion", feature = "serde-conversion"), + derive(JsonConvertible) +)] +#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] +#[platform_serialize(unversioned)] //versioned directly, no need to use platform_version +#[platform_version_path_bounds( + "dpp.state_transition_serialization_versions.shield_from_identity_state_transition" +)] +pub enum ShieldFromIdentityTransition { + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(ShieldFromIdentityTransitionV0), +} + +impl OptionallyAssetLockProved for ShieldFromIdentityTransition {} + +impl StateTransitionFieldTypes for ShieldFromIdentityTransition { + fn signature_property_paths() -> Vec<&'static str> { + vec![SIGNATURE] + } + + fn identifiers_property_paths() -> Vec<&'static str> { + vec![IDENTITY_ID] + } + + fn binary_property_paths() -> Vec<&'static str> { + vec![] + } +} + +#[cfg(all( + test, + feature = "json-conversion", + feature = "value-conversion", + feature = "serde-conversion" +))] +pub(crate) mod json_convertible_tests { + use super::*; + use crate::shielded::SerializedAction; + use crate::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; + use platform_value::{platform_value, BinaryData, Bytes32, Identifier}; + use serde_json::json; + + fn fixture_action() -> SerializedAction { + SerializedAction { + nullifier: [0x11; 32], + rk: [0x22; 32], + cmx: [0x33; 32], + encrypted_note: vec![0x44; 216], + cv_net: [0x55; 32], + spend_auth_sig: [0x66; 64], + } + } + + pub(crate) fn fixture() -> ShieldFromIdentityTransition { + ShieldFromIdentityTransition::V0(ShieldFromIdentityTransitionV0 { + identity_id: Identifier::new([0xaa; 32]), + amount: 250_000, + actions: vec![fixture_action()], + anchor: [0x77; 32], + proof: vec![0x88; 192], + binding_signature: [0x99; 64], + nonce: 13, + user_fee_increase: 5, + signature_public_key_id: 2, + signature: BinaryData::new(vec![0xbb; 65]), + }) + } + + #[test] + fn json_round_trip_with_full_wire_shape() { + use crate::serialization::JsonConvertible; + let original = fixture(); + let json = original.to_json().expect("to_json"); + // Sized-int fields lose their size on the JSON wire: `amount` (u64), + // `nonce` (u64 IdentityNonce), `userFeeIncrease` (u16), + // `signaturePublicKeyId` (u32 KeyID). Identifier is base58, byte fields + // are base64. + assert_eq!( + json, + json!({ + "$formatVersion": "0", + "identityId": "CVDFLCAjXhVWiPXH9nTCTpCgVzmDVoiPzNJYuccr1dqB", + "amount": 250_000, + "actions": [{ + "nullifier": "ERERERERERERERERERERERERERERERERERERERERERE=", + "rk": "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=", + "cmx": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "encryptedNote": "RERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERE", + "cvNet": "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=", + "spendAuthSig": "ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZg==", + }], + "anchor": "d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3c=", + "proof": "iIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI", + "bindingSignature": "mZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmQ==", + "nonce": 13, + "userFeeIncrease": 5, + "signaturePublicKeyId": 2, + "signature": "u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7s=", + }) + ); + let recovered = ShieldFromIdentityTransition::from_json(json).expect("from_json"); + assert_eq!(original, recovered); + } + + #[test] + fn value_round_trip_with_full_wire_shape() { + use crate::serialization::ValueConvertible; + let original = fixture(); + let value = original.to_object().expect("to_object"); + assert_eq!( + value, + platform_value!({ + "$formatVersion": "0", + "identityId": Identifier::new([0xaa; 32]), + "amount": 250_000u64, + "actions": [{ + "nullifier": Bytes32::new([0x11; 32]), + "rk": Bytes32::new([0x22; 32]), + "cmx": Bytes32::new([0x33; 32]), + "encryptedNote": platform_value::Value::Bytes(vec![0x44; 216]), + "cvNet": Bytes32::new([0x55; 32]), + "spendAuthSig": platform_value::Value::Bytes(vec![0x66; 64]), + }], + "anchor": Bytes32::new([0x77; 32]), + "proof": platform_value::Value::Bytes(vec![0x88; 192]), + "bindingSignature": platform_value::Value::Bytes(vec![0x99; 64]), + "nonce": 13u64, + "userFeeIncrease": 5u16, + "signaturePublicKeyId": 2u32, + "signature": BinaryData::new(vec![0xbb; 65]), + }) + ); + let recovered = ShieldFromIdentityTransition::from_object(value).expect("from_object"); + assert_eq!(original, recovered); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs new file mode 100644 index 00000000000..c1181ac0122 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs @@ -0,0 +1,48 @@ +use crate::consensus::state::identity::IdentityInsufficientBalanceError; +use crate::fee::Credits; +use crate::shielded::compute_shielded_verification_fee; +use crate::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; +use crate::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use crate::state_transition::{ + StateTransitionEstimatedFeeValidation, StateTransitionIdentityEstimatedFeeValidation, +}; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +impl StateTransitionEstimatedFeeValidation for ShieldFromIdentityTransition { + /// The stateless floor is the shielded compute fee only (proof verification plus + /// per-action processing), exactly as for `Shield`. Storage and processing of the + /// note and identity writes are metered by GroveDB, so the authoritative funding + /// gate is the identity-paid fee validation of the execution event. + fn calculate_min_required_fee( + &self, + platform_version: &PlatformVersion, + ) -> Result { + compute_shielded_verification_fee(self.actions().len(), platform_version) + } +} + +impl StateTransitionIdentityEstimatedFeeValidation for ShieldFromIdentityTransition { + fn validate_estimated_fee( + &self, + identity_known_balance: Credits, + platform_version: &PlatformVersion, + ) -> Result { + let required_fee = self.calculate_min_required_fee(platform_version)?; + let outbound_amount = self.amount().saturating_add(required_fee); + + if identity_known_balance < outbound_amount { + return Ok(SimpleConsensusValidationResult::new_with_error( + IdentityInsufficientBalanceError::new( + self.identity_id(), + identity_known_balance, + outbound_amount, + ) + .into(), + )); + } + + Ok(SimpleConsensusValidationResult::new()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_like.rs new file mode 100644 index 00000000000..e161f209dba --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_like.rs @@ -0,0 +1,80 @@ +use crate::prelude::UserFeeIncrease; +use crate::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use crate::state_transition::StateTransitionHasUserFeeIncrease; +use crate::state_transition::{ + StateTransitionLike, StateTransitionOwned, StateTransitionSingleSigned, StateTransitionType, +}; +use crate::version::FeatureVersion; +use platform_value::{BinaryData, Identifier}; + +impl StateTransitionLike for ShieldFromIdentityTransition { + fn modified_data_ids(&self) -> Vec { + match self { + ShieldFromIdentityTransition::V0(transition) => transition.modified_data_ids(), + } + } + + fn state_transition_protocol_version(&self) -> FeatureVersion { + match self { + ShieldFromIdentityTransition::V0(_) => 0, + } + } + + fn state_transition_type(&self) -> StateTransitionType { + match self { + ShieldFromIdentityTransition::V0(transition) => transition.state_transition_type(), + } + } + + fn unique_identifiers(&self) -> Vec { + match self { + ShieldFromIdentityTransition::V0(transition) => transition.unique_identifiers(), + } + } +} + +impl StateTransitionHasUserFeeIncrease for ShieldFromIdentityTransition { + fn user_fee_increase(&self) -> UserFeeIncrease { + match self { + ShieldFromIdentityTransition::V0(transition) => transition.user_fee_increase(), + } + } + + fn set_user_fee_increase(&mut self, user_fee_increase: UserFeeIncrease) { + match self { + ShieldFromIdentityTransition::V0(transition) => { + transition.set_user_fee_increase(user_fee_increase) + } + } + } +} + +impl StateTransitionSingleSigned for ShieldFromIdentityTransition { + fn signature(&self) -> &BinaryData { + match self { + ShieldFromIdentityTransition::V0(transition) => transition.signature(), + } + } + + fn set_signature(&mut self, signature: BinaryData) { + match self { + ShieldFromIdentityTransition::V0(transition) => transition.set_signature(signature), + } + } + + fn set_signature_bytes(&mut self, signature: Vec) { + match self { + ShieldFromIdentityTransition::V0(transition) => { + transition.set_signature_bytes(signature) + } + } + } +} + +impl StateTransitionOwned for ShieldFromIdentityTransition { + fn owner_id(&self) -> Identifier { + match self { + ShieldFromIdentityTransition::V0(transition) => transition.owner_id(), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_validation.rs new file mode 100644 index 00000000000..b5fa82371ba --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_validation.rs @@ -0,0 +1,15 @@ +use crate::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use crate::state_transition::StateTransitionStructureValidation; +use crate::validation::SimpleConsensusValidationResult; +use platform_version::version::PlatformVersion; + +impl StateTransitionStructureValidation for ShieldFromIdentityTransition { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.validate_structure(platform_version), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/identity_signed.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/identity_signed.rs new file mode 100644 index 00000000000..1132a091117 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/identity_signed.rs @@ -0,0 +1,22 @@ +use crate::identity::SecurityLevel::CRITICAL; +use crate::identity::{KeyID, Purpose, SecurityLevel}; +use crate::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; +use crate::state_transition::StateTransitionIdentitySigned; + +impl StateTransitionIdentitySigned for ShieldFromIdentityTransitionV0 { + fn signature_public_key_id(&self) -> KeyID { + self.signature_public_key_id + } + + fn set_signature_public_key_id(&mut self, key_id: KeyID) { + self.signature_public_key_id = key_id + } + + fn security_level_requirement(&self, _purpose: Purpose) -> Vec { + vec![CRITICAL] + } + + fn purpose_requirement(&self) -> Vec { + vec![Purpose::TRANSFER] + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/mod.rs new file mode 100644 index 00000000000..269239642de --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/mod.rs @@ -0,0 +1,160 @@ +mod identity_signed; +mod state_transition_like; +mod state_transition_validation; +mod types; +pub(super) mod v0_methods; +mod version; + +use crate::fee::Credits; +use crate::identity::KeyID; +use crate::prelude::{Identifier, IdentityNonce, UserFeeIncrease}; +#[cfg(feature = "json-conversion")] +use crate::serialization::json_safe_fields; +use crate::shielded::SerializedAction; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize, PlatformSignable}; +use platform_value::BinaryData; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +/// Identity balance to shielded pool. +/// +/// Every field before `signature_public_key_id` is covered by the identity signature, +/// which binds the Orchard bundle to this identity and nonce. The bundle itself is an +/// outputs-only Orchard bundle whose value balance equals `-amount` (value entering +/// the pool), verified exactly like `Shield`. +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformSerialize, + PlatformDeserialize, + PlatformSignable, + PartialEq, +)] +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +#[platform_serialize(unversioned)] +pub struct ShieldFromIdentityTransitionV0 { + /// The identity whose balance funds the shield. + pub identity_id: Identifier, + /// Credits leaving the identity balance and entering the shielded pool + /// (the absolute value of the bundle's value balance). + pub amount: Credits, + /// Orchard actions (spend-output pairs; spends disabled) + pub actions: Vec, + /// Sinsemilla root of the note commitment tree (Orchard Anchor) + pub anchor: [u8; 32], + /// Halo2 proof bytes + pub proof: Vec, + /// RedPallas binding signature + pub binding_signature: [u8; 64], + /// Identity nonce (replay protection) + pub nonce: IdentityNonce, + /// Fee multiplier + pub user_fee_increase: UserFeeIncrease, + #[platform_signable(exclude_from_sig_hash)] + pub signature_public_key_id: KeyID, + #[platform_signable(exclude_from_sig_hash)] + pub signature: BinaryData, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::serialization::{PlatformDeserializable, PlatformSerializable, Signable}; + use crate::state_transition::StateTransition; + use std::fmt::Debug; + + fn test_round_trip( + transition: T, + ) where + ::Error: std::fmt::Debug, + { + let serialized = T::serialize_to_bytes(&transition).expect("expected to serialize"); + let deserialized = + T::deserialize_from_bytes(serialized.as_slice()).expect("expected to deserialize"); + assert_eq!(transition, deserialized); + } + + pub(crate) fn make_v0() -> ShieldFromIdentityTransitionV0 { + ShieldFromIdentityTransitionV0 { + identity_id: Identifier::from([7u8; 32]), + amount: 1000u64, + actions: vec![SerializedAction { + nullifier: [1u8; 32], + rk: [2u8; 32], + cmx: [3u8; 32], + encrypted_note: vec![4u8; 216], + cv_net: [5u8; 32], + spend_auth_sig: [6u8; 64], + }], + anchor: [7u8; 32], + proof: vec![8u8; 100], + binding_signature: [9u8; 64], + nonce: 3, + user_fee_increase: 1, + signature_public_key_id: 2, + signature: BinaryData::new(vec![0u8; 65]), + } + } + + #[test] + fn test_shield_from_identity_transition_v0_serialization_round_trip() { + test_round_trip(make_v0()); + } + + #[test] + fn signable_bytes_change_when_any_bundle_field_changes() { + let base: StateTransition = make_v0().into(); + let base_bytes = base.signable_bytes().expect("signable bytes"); + + let mut amount = make_v0(); + amount.amount += 1; + let mut anchor = make_v0(); + anchor.anchor[0] ^= 1; + let mut proof = make_v0(); + proof.proof[0] ^= 1; + let mut binding = make_v0(); + binding.binding_signature[0] ^= 1; + let mut action = make_v0(); + action.actions[0].cmx[0] ^= 1; + let mut nonce = make_v0(); + nonce.nonce += 1; + let mut identity = make_v0(); + identity.identity_id = Identifier::from([8u8; 32]); + + for (name, mutated) in [ + ("amount", amount), + ("anchor", anchor), + ("proof", proof), + ("binding_signature", binding), + ("action", action), + ("nonce", nonce), + ("identity_id", identity), + ] { + let st: StateTransition = mutated.into(); + assert_ne!( + st.signable_bytes().expect("signable bytes"), + base_bytes, + "{name} must be covered by the identity signature" + ); + } + + let mut signature_only = make_v0(); + signature_only.signature = BinaryData::new(vec![1u8; 65]); + signature_only.signature_public_key_id = 9; + let st: StateTransition = signature_only.into(); + assert_eq!( + st.signable_bytes().expect("signable bytes"), + base_bytes, + "signature fields must be excluded from the sig hash" + ); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/state_transition_like.rs new file mode 100644 index 00000000000..12ea0ec7984 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/state_transition_like.rs @@ -0,0 +1,76 @@ +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use platform_value::BinaryData; + +use crate::prelude::UserFeeIncrease; +use crate::state_transition::StateTransitionHasUserFeeIncrease; +use crate::{ + prelude::Identifier, + state_transition::{StateTransitionLike, StateTransitionOwned, StateTransitionType}, +}; + +use crate::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; +use crate::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; + +use crate::state_transition::{StateTransition, StateTransitionSingleSigned}; +use crate::version::FeatureVersion; + +impl From for StateTransition { + fn from(value: ShieldFromIdentityTransitionV0) -> Self { + let transition: ShieldFromIdentityTransition = value.into(); + transition.into() + } +} + +impl StateTransitionLike for ShieldFromIdentityTransitionV0 { + fn state_transition_protocol_version(&self) -> FeatureVersion { + 0 + } + + fn state_transition_type(&self) -> StateTransitionType { + StateTransitionType::ShieldFromIdentity + } + + fn modified_data_ids(&self) -> Vec { + vec![self.identity_id] + } + + /// Unique by identity and nonce, like every identity-signed transition. + fn unique_identifiers(&self) -> Vec { + vec![format!( + "{}-{:x}", + BASE64_STANDARD.encode(self.identity_id), + self.nonce + )] + } +} + +impl StateTransitionHasUserFeeIncrease for ShieldFromIdentityTransitionV0 { + fn user_fee_increase(&self) -> UserFeeIncrease { + self.user_fee_increase + } + + fn set_user_fee_increase(&mut self, user_fee_increase: UserFeeIncrease) { + self.user_fee_increase = user_fee_increase + } +} + +impl StateTransitionSingleSigned for ShieldFromIdentityTransitionV0 { + fn signature(&self) -> &BinaryData { + &self.signature + } + + fn set_signature(&mut self, signature: BinaryData) { + self.signature = signature + } + + fn set_signature_bytes(&mut self, signature: Vec) { + self.signature = BinaryData::new(signature) + } +} + +impl StateTransitionOwned for ShieldFromIdentityTransitionV0 { + fn owner_id(&self) -> Identifier { + self.identity_id + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/state_transition_validation.rs new file mode 100644 index 00000000000..5f090911f06 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/state_transition_validation.rs @@ -0,0 +1,200 @@ +use crate::consensus::basic::state_transition::ShieldedInvalidValueBalanceError; +use crate::consensus::basic::BasicError; +use crate::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; +use crate::state_transition::state_transitions::shielded::common_validation::{ + validate_actions_count, validate_anchor_not_zero, validate_encrypted_note_sizes, + validate_proof_not_empty, +}; +use crate::state_transition::StateTransitionStructureValidation; +use crate::validation::SimpleConsensusValidationResult; +use platform_version::version::PlatformVersion; + +impl StateTransitionStructureValidation for ShieldFromIdentityTransitionV0 { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + let result = validate_actions_count( + &self.actions, + platform_version + .system_limits + .max_shielded_transition_actions, + ); + if !result.is_valid() { + return result; + } + + let result = validate_encrypted_note_sizes(&self.actions); + if !result.is_valid() { + return result; + } + + if self.amount == 0 { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "shield amount must be greater than zero".to_string(), + ), + ) + .into(), + ); + } + + if self.amount > i64::MAX as u64 { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "shield amount exceeds maximum allowed value".to_string(), + ), + ) + .into(), + ); + } + + let result = validate_proof_not_empty(&self.proof); + if !result.is_valid() { + return result; + } + + validate_anchor_not_zero(&self.anchor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consensus::ConsensusError; + use crate::shielded::SerializedAction; + use assert_matches::assert_matches; + use platform_value::Identifier; + + fn action() -> SerializedAction { + SerializedAction { + nullifier: [1u8; 32], + rk: [2u8; 32], + cmx: [3u8; 32], + encrypted_note: vec![4u8; 216], + cv_net: [5u8; 32], + spend_auth_sig: [6u8; 64], + } + } + + fn valid() -> ShieldFromIdentityTransitionV0 { + ShieldFromIdentityTransitionV0 { + identity_id: Identifier::from([1u8; 32]), + amount: 1_000, + actions: vec![action()], + anchor: [7u8; 32], + proof: vec![8u8; 100], + binding_signature: [9u8; 64], + nonce: 1, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + } + + #[test] + fn should_accept_a_well_formed_transition() { + let result = valid().validate_structure(PlatformVersion::latest()); + assert!(result.is_valid(), "{:?}", result.errors); + } + + #[test] + fn should_reject_no_actions() { + let mut t = valid(); + t.actions = vec![]; + let result = t.validate_structure(PlatformVersion::latest()); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedNoActionsError(_) + )] + ); + } + + #[test] + fn should_reject_too_many_actions() { + let platform_version = PlatformVersion::latest(); + let mut t = valid(); + t.actions = vec![ + action(); + platform_version + .system_limits + .max_shielded_transition_actions as usize + + 1 + ]; + let result = t.validate_structure(platform_version); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedTooManyActionsError(_) + )] + ); + } + + #[test] + fn should_reject_wrong_encrypted_note_size() { + let mut t = valid(); + t.actions[0].encrypted_note = vec![0u8; 10]; + let result = t.validate_structure(PlatformVersion::latest()); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedEncryptedNoteSizeMismatchError(_) + )] + ); + } + + #[test] + fn should_reject_zero_amount() { + let mut t = valid(); + t.amount = 0; + let result = t.validate_structure(PlatformVersion::latest()); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedInvalidValueBalanceError(_) + )] + ); + } + + #[test] + fn should_reject_amount_above_i64_max() { + let mut t = valid(); + t.amount = i64::MAX as u64 + 1; + let result = t.validate_structure(PlatformVersion::latest()); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedInvalidValueBalanceError(_) + )] + ); + } + + #[test] + fn should_reject_empty_proof() { + let mut t = valid(); + t.proof = vec![]; + let result = t.validate_structure(PlatformVersion::latest()); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedEmptyProofError(_) + )] + ); + } + + #[test] + fn should_reject_zero_anchor() { + let mut t = valid(); + t.anchor = [0u8; 32]; + let result = t.validate_structure(PlatformVersion::latest()); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedZeroAnchorError(_) + )] + ); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/types.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/types.rs new file mode 100644 index 00000000000..4256fad7d6c --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/types.rs @@ -0,0 +1,17 @@ +use crate::state_transition::shield_from_identity_transition::fields::*; +use crate::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; +use crate::state_transition::StateTransitionFieldTypes; + +impl StateTransitionFieldTypes for ShieldFromIdentityTransitionV0 { + fn signature_property_paths() -> Vec<&'static str> { + vec![SIGNATURE] + } + + fn identifiers_property_paths() -> Vec<&'static str> { + vec![IDENTITY_ID] + } + + fn binary_property_paths() -> Vec<&'static str> { + vec![] + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/v0_methods.rs new file mode 100644 index 00000000000..7db26902742 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/v0_methods.rs @@ -0,0 +1,88 @@ +use crate::state_transition::shield_from_identity_transition::methods::ShieldFromIdentityTransitionMethodsV0; +use crate::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; +#[cfg(feature = "state-transition-signing")] +use crate::{ + identity::{ + accessors::IdentityGettersV0, + identity_public_key::accessors::v0::IdentityPublicKeyGettersV0, signer::Signer, Identity, + IdentityPublicKey, KeyType, Purpose, SecurityLevel, + }, + prelude::{IdentityNonce, UserFeeIncrease}, + shielded::SerializedAction, + state_transition::{GetDataContractSecurityLevelRequirementFn, StateTransition}, + ProtocolError, +}; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +impl ShieldFromIdentityTransitionMethodsV0 for ShieldFromIdentityTransitionV0 { + #[cfg(feature = "state-transition-signing")] + async fn try_from_bundle_with_identity_signer>( + identity: &Identity, + amount: u64, + actions: Vec, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + user_fee_increase: UserFeeIncrease, + signer: &S, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + nonce: IdentityNonce, + _platform_version: &PlatformVersion, + ) -> Result { + let mut transition: StateTransition = ShieldFromIdentityTransitionV0 { + identity_id: identity.id(), + amount, + actions, + anchor, + proof, + binding_signature, + nonce, + user_fee_increase, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + + let identity_public_key = match signing_transfer_key_to_use { + Some(key) => { + if signer.can_sign_with(key) { + key + } else { + return Err( + ProtocolError::DesiredKeyWithTypePurposeSecurityLevelMissing( + "specified transfer public key cannot be used for signing".to_string(), + ), + ); + } + } + None => identity + .get_first_public_key_matching( + Purpose::TRANSFER, + SecurityLevel::full_range().into(), + KeyType::all_key_types().into(), + true, + ) + .ok_or_else(|| { + ProtocolError::DesiredKeyWithTypePurposeSecurityLevelMissing( + "no transfer public key".to_string(), + ) + })?, + }; + + tracing::debug!( + key_id = identity_public_key.id(), + "shield from identity: signing with transfer key" + ); + + transition + .sign_external( + identity_public_key, + signer, + None::, + ) + .await?; + + Ok(transition) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/version.rs new file mode 100644 index 00000000000..4b09fb12ea5 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/version.rs @@ -0,0 +1,9 @@ +use crate::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; +use crate::state_transition::FeatureVersioned; +use crate::version::FeatureVersion; + +impl FeatureVersioned for ShieldFromIdentityTransitionV0 { + fn feature_version(&self) -> FeatureVersion { + 0 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/version.rs new file mode 100644 index 00000000000..2aae5dcf7c8 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/version.rs @@ -0,0 +1,11 @@ +use crate::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use crate::state_transition::FeatureVersioned; +use crate::version::FeatureVersion; + +impl FeatureVersioned for ShieldFromIdentityTransition { + fn feature_version(&self) -> FeatureVersion { + match self { + ShieldFromIdentityTransition::V0(v0) => v0.feature_version(), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs index 4595c1c1ddf..82db6ad1069 100644 --- a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs +++ b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs @@ -552,6 +552,35 @@ impl ExecutionEvent<'_> { user_fee_increase, }) } + StateTransitionAction::ShieldFromIdentityAction(ref shield_action) => { + // Identity-paid, exactly the IdentityCreditTransferToAddresses model, plus the + // shielded COMPUTE fee (proof verification + per-action processing) that GroveDB + // cannot meter, added as `additional_fixed_fee_cost` exactly like `Shield`. The + // note inserts and identity writes are metered through the operations. + let user_fee_increase = shield_action.user_fee_increase(); + let removed_balance = shield_action.shield_amount(); + let shielded_verification_fee = dpp::shielded::compute_shielded_verification_fee( + shield_action.notes().len(), + platform_version, + )?; + let operations = + action.into_high_level_drive_operations(epoch, platform_version)?; + if let Some(identity) = identity { + Ok(ExecutionEvent::Paid { + identity, + removed_balance: Some(removed_balance), + added_to_balance_outputs: None, + operations, + execution_operations: execution_context.operations_consume(), + additional_fixed_fee_cost: Some(shielded_verification_fee), + user_fee_increase, + }) + } else { + Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "partial identity should be present for shield from identity action", + ))) + } + } StateTransitionAction::ShieldedTransferAction(ref shielded_transfer_action) => { let fee_amount = shielded_transfer_action.fee_amount(); let operations = diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs index 0cd70a2dd7c..f16d15a46bb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs @@ -177,6 +177,7 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { | StateTransition::IdentityTopUp(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) @@ -246,6 +247,7 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs index 78cdb5ec1c6..104498eb133 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs @@ -79,6 +79,7 @@ impl StateTransitionAddressWitnessValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) @@ -197,6 +198,7 @@ impl StateTransitionHasAddressWitnessValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs index 85d1ab03600..82ce2363453 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs @@ -68,6 +68,7 @@ impl StateTransitionAddressesMinimumBalanceValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::IdentityCreditWithdrawal(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::Batch(_) | StateTransition::MasternodeVote(_) @@ -100,6 +101,7 @@ impl StateTransitionAddressesMinimumBalanceValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::IdentityCreditWithdrawal(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::Batch(_) | StateTransition::MasternodeVote(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs index 09119bd05e3..e36b10ee0bf 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs @@ -283,6 +283,30 @@ impl StateTransitionBasicStructureValidationV0 for StateTransition { })), } } + StateTransition::ShieldFromIdentity(st) => { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shield_from_identity_state_transition + .basic_structure + { + Some(0) => Ok(st.validate_structure(platform_version)), + Some(version) => { + Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "shield from identity transition: validate_basic_structure" + .to_string(), + known_versions: vec![0], + received: version, + })) + } + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "shield from identity transition: validate_basic_structure" + .to_string(), + known_versions: vec![0], + })), + } + } StateTransition::ShieldedTransfer(st) => { match platform_version .drive_abci @@ -455,6 +479,13 @@ impl StateTransitionBasicStructureValidationV0 for StateTransition { .shield_state_transition .basic_structure .is_some(), + StateTransition::ShieldFromIdentity(_) => platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shield_from_identity_state_transition + .basic_structure + .is_some(), StateTransition::ShieldedTransfer(_) => platform_version .drive_abci .validation_and_processing @@ -843,6 +874,7 @@ mod tests { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::IdentityCreateFromAddresses(_) | StateTransition::IdentityTopUpFromAddresses(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs index 61745a0af30..bff62207ce9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs @@ -60,6 +60,9 @@ impl StateTransitionIdentityBalanceValidationV0 for StateTransition { StateTransition::IdentityCreditTransferToAddresses(st) => st .validate_estimated_fee(balance, platform_version) .map_err(Error::Protocol), + StateTransition::ShieldFromIdentity(st) => st + .validate_estimated_fee(balance, platform_version) + .map_err(Error::Protocol), StateTransition::DataContractCreate(st) => st .validate_estimated_fee(balance, platform_version) .map_err(Error::Protocol), @@ -97,6 +100,7 @@ impl StateTransitionIdentityBalanceValidationV0 for StateTransition { | StateTransition::DataContractUpdate(_) | StateTransition::Batch(_) | StateTransition::IdentityUpdate(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) ) } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index bdf5da140d3..33c535af4ea 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -64,6 +64,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::DataContractUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::Batch(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) => { //Basic signature verification Ok(self.validate_state_transition_identity_signed( @@ -188,6 +189,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::IdentityTopUpFromAddresses(_) => true, } @@ -216,6 +218,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) => true, } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs index 7dbd3980ef8..bce9200b7cf 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs @@ -108,6 +108,13 @@ impl StateTransitionIdentityNonceValidationV0 for StateTransition { execution_context, platform_version, ), + StateTransition::ShieldFromIdentity(st) => st.validate_identity_nonces( + platform, + block_info, + tx, + execution_context, + platform_version, + ), StateTransition::AddressCreditWithdrawal(_) | StateTransition::AddressFundingFromAssetLock(_) | StateTransition::IdentityCreateFromAddresses(_) @@ -161,6 +168,7 @@ impl StateTransitionHasIdentityNonceValidationV0 for StateTransition { | StateTransition::IdentityCreditTransfer(_) | StateTransition::IdentityCreditWithdrawal(_) | StateTransition::MasternodeVote(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) => true, StateTransition::IdentityCreate(_) | StateTransition::IdentityTopUp(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs index 60e752130d5..2d0b8728709 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs @@ -7,6 +7,7 @@ use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::StateTransition; use dpp::version::feature_initial_protocol_versions::{ ADDRESS_FUNDS_INITIAL_PROTOCOL_VERSION, SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION, }; use dpp::version::PlatformVersion; @@ -37,7 +38,8 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) - | StateTransition::IdentityCreateFromShieldedPool(_) => Ok(true), + | StateTransition::IdentityCreateFromShieldedPool(_) + | StateTransition::ShieldFromIdentity(_) => Ok(true), StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::IdentityCreate(_) @@ -94,6 +96,22 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { ])) } } + StateTransition::ShieldFromIdentity(_) => { + if platform_version.protocol_version + >= SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION + { + Ok(ConsensusValidationResult::new()) + } else { + Ok(ConsensusValidationResult::new_with_errors(vec![ + StateTransitionNotActiveError::new( + self.state_transition_type().to_string(), + platform_version.protocol_version, + SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION, + ) + .into(), + ])) + } + } _ => Err(Error::Execution(ExecutionError::CorruptedCodeExecution( "validate_is_allowed is not implemented for this state transition", ))), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index 76e71b9b4f8..50772e6ffe6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -14,6 +14,7 @@ use dpp::serialization::{PlatformMessageSignable, Signable}; use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; use dpp::state_transition::StateTransition; use dpp::validation::SimpleConsensusValidationResult; use dpp::version::PlatformVersion; @@ -60,6 +61,7 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) + | StateTransition::ShieldFromIdentity(_) ) } @@ -70,6 +72,9 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { v0.actions.len() } }, + StateTransition::ShieldFromIdentity(st) => match st { + ShieldFromIdentityTransition::V0(v0) => v0.actions.len(), + }, StateTransition::ShieldedTransfer(st) => match st { dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition::V0(v0) => { v0.actions.len() @@ -198,7 +203,7 @@ impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { // document cost). MUST match what the builder/transformer carve from the pool. let (validated_amount, num_actions, min_net_amount, max_net_amount, amount_is_pure_fee, fee_kind): (i64, usize, u64, u64, bool, ShieldedMinFeeKind) = match self { // Shield: fee is paid from transparent address inputs, not from value_balance. - StateTransition::Shield(_) => { + StateTransition::Shield(_) | StateTransition::ShieldFromIdentity(_) => { return Ok(SimpleConsensusValidationResult::new()) } // ShieldedTransfer: value_balance (u64) IS the fee. It writes no extra @@ -465,6 +470,22 @@ impl StateTransitionShieldedProofValidationV0 for StateTransition { ) } }, + StateTransition::ShieldFromIdentity(st) => match st { + ShieldFromIdentityTransition::V0(v0) => { + // Outputs-only bundle entering the pool, exactly like `Shield`. The + // identity ECDSA signature already binds every bundle field to the + // identity and nonce, so no extra sighash data is needed. + reconstruct_and_verify_bundle( + &v0.actions, + FLAGS_OUTPUTS_ONLY, + -(v0.amount as i64), + &v0.anchor, + v0.proof.as_slice(), + &v0.binding_signature, + &[], + ) + } + }, StateTransition::ShieldedTransfer(st) => match st { dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition::V0(v0) => { reconstruct_and_verify_bundle( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index 6d881a6d248..a9f9c12ae19 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -149,6 +149,11 @@ impl StateTransitionStateValidation for StateTransition { "identity credit transfer to addresses should not have state validation", ))) } + StateTransition::ShieldFromIdentity(_) => { + Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "shield from identity should not have state validation", + ))) + } StateTransition::IdentityCreateFromAddresses(st) => { let action = action.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( @@ -271,6 +276,7 @@ impl StateTransitionStateValidation for StateTransition { | StateTransition::IdentityTopUpFromAddresses(_) | StateTransition::IdentityCreditWithdrawal(_) | StateTransition::AddressCreditWithdrawal(_) + | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 31b3596bc4f..2b3ae20ecb9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -50,6 +50,8 @@ pub mod identity_create_from_shielded_pool; pub mod shield; /// Module for shield from asset lock transition validation pub mod shield_from_asset_lock; +/// Shield from identity (identity balance to shielded pool) +pub mod shield_from_identity; /// Common validation logic shared by shielded transitions (proof verification) pub mod shielded_common; /// Module for shielded transfer transition validation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs new file mode 100644 index 00000000000..54e3c139ecf --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs @@ -0,0 +1,55 @@ +mod nonce; +#[cfg(test)] +mod tests; +mod transform_into_action; + +use dpp::address_funds::PlatformAddress; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::prelude::AddressNonce; +use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use dpp::validation::ConsensusValidationResult; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::StateTransitionAction; +use std::collections::BTreeMap; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::shield_from_identity::transform_into_action::v0::ShieldFromIdentityStateTransitionTransformIntoActionValidationV0; +use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; +use crate::execution::validation::state_transition::ValidationMode; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::rpc::core::CoreRPCLike; + +impl StateTransitionActionTransformer for ShieldFromIdentityTransition { + fn transform_into_action( + &self, + platform: &PlatformRef, + _block_info: &BlockInfo, + _remaining_address_input_balances: &Option< + BTreeMap, + >, + _validation_mode: ValidationMode, + _execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shield_from_identity_state_transition + .transform_into_action + { + 0 => self.transform_into_action_v0(platform.drive, tx, platform_version), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "shield from identity transition: transform_into_action".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/nonce/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/nonce/mod.rs new file mode 100644 index 00000000000..9d3221f9cd0 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/nonce/mod.rs @@ -0,0 +1,49 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::processor::identity_nonces::StateTransitionIdentityNonceValidationV0; +use crate::execution::validation::state_transition::shield_from_identity::nonce::v0::ShieldFromIdentityTransitionIdentityNonceV0; +use crate::platform_types::platform::PlatformStateRef; +use dpp::block::block_info::BlockInfo; +use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; + +pub(crate) mod v0; + +impl StateTransitionIdentityNonceValidationV0 for ShieldFromIdentityTransition { + fn validate_identity_nonces( + &self, + platform: &PlatformStateRef, + block_info: &BlockInfo, + tx: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shield_from_identity_state_transition + .nonce + { + Some(0) => self.validate_nonce_v0( + platform, + block_info, + tx, + execution_context, + platform_version, + ), + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "shield from identity transition: validate_nonces".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "shield from identity transition: validate_nonces".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/nonce/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/nonce/v0/mod.rs new file mode 100644 index 00000000000..e379bdd1904 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/nonce/v0/mod.rs @@ -0,0 +1,70 @@ +use crate::error::Error; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::document::NonceOutOfBoundsError; +use dpp::consensus::basic::BasicError; +use dpp::identity::identity_nonce::{ + validate_identity_nonce_update, validate_new_identity_nonce, MISSING_IDENTITY_REVISIONS_FILTER, +}; +use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; +use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use dpp::validation::SimpleConsensusValidationResult; + +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::platform_types::platform::PlatformStateRef; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; + +pub(in crate::execution::validation::state_transition::state_transitions) trait ShieldFromIdentityTransitionIdentityNonceV0 +{ + fn validate_nonce_v0( + &self, + platform: &PlatformStateRef, + block_info: &BlockInfo, + tx: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl ShieldFromIdentityTransitionIdentityNonceV0 for ShieldFromIdentityTransition { + fn validate_nonce_v0( + &self, + platform: &PlatformStateRef, + block_info: &BlockInfo, + tx: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result { + let revision_nonce = self.nonce(); + + if revision_nonce & MISSING_IDENTITY_REVISIONS_FILTER > 0 { + return Ok(SimpleConsensusValidationResult::new_with_error( + BasicError::NonceOutOfBoundsError(NonceOutOfBoundsError::new(revision_nonce)) + .into(), + )); + } + + let identity_id = self.identity_id(); + + let (existing_nonce, fee) = platform.drive.fetch_identity_nonce_with_fees( + identity_id.to_buffer(), + block_info, + true, + tx, + platform_version, + )?; + + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + let result = if let Some(existing_nonce) = existing_nonce { + validate_identity_nonce_update(existing_nonce, revision_nonce, identity_id) + } else { + validate_new_identity_nonce(revision_nonce, identity_id) + }; + + Ok(result) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs new file mode 100644 index 00000000000..f5cfb2e69a0 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs @@ -0,0 +1,730 @@ +#[cfg(test)] +#[allow(clippy::module_inception)] +mod tests { + use crate::config::{PlatformConfig, PlatformTestConfig}; + use crate::execution::validation::state_transition::state_transitions::shielded_common::compute_platform_sighash; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + create_dummy_serialized_action, get_proving_key, process_transition, + serialize_authorized_bundle_with_flags, setup_platform, + }; + use crate::execution::validation::state_transition::state_transitions::tests::process_state_transitions; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use assert_matches::assert_matches; + use dpp::block::block_info::BlockInfo; + use dpp::consensus::basic::BasicError; + use dpp::consensus::signature::SignatureError; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::dash_to_credits; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{Identity, IdentityPublicKey, IdentityV0, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use dpp::prelude::IdentityNonce; + use dpp::serialization::PlatformSerializable; + use dpp::shielded::SerializedAction; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use dpp::state_transition::shield_from_identity_transition::methods::ShieldFromIdentityTransitionMethodsV0; + use dpp::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; + use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; + use dpp::state_transition::StateTransition; + use drive::drive::Drive; + use grovedb_commitment_tree::{ + Anchor, Builder, BundleType, DashMemo, Flags as OrchardFlags, FullViewingKey, NoteValue, + Scope, SpendingKey, + }; + use platform_version::version::PlatformVersion; + use rand::rngs::{OsRng, StdRng}; + use rand::SeedableRng; + use simple_signer::signer::SimpleSigner; + use std::collections::BTreeMap; + + // ========================================== + // Helpers + // ========================================== + + fn create_identity_with_transfer_key( + id: [u8; 32], + balance: u64, + rng: &mut StdRng, + platform_version: &PlatformVersion, + ) -> (Identity, SimpleSigner) { + let mut signer = SimpleSigner::default(); + + let (auth_key, auth_private_key) = + IdentityPublicKey::random_ecdsa_master_authentication_key_with_rng( + 0, + rng, + platform_version, + ) + .expect("should create auth key"); + + let (transfer_key, transfer_private_key) = + IdentityPublicKey::random_key_with_known_attributes( + 1, + rng, + Purpose::TRANSFER, + SecurityLevel::CRITICAL, + KeyType::ECDSA_SECP256K1, + None, + platform_version, + ) + .expect("should create transfer key"); + + signer.add_identity_public_key(auth_key.clone(), auth_private_key); + signer.add_identity_public_key(transfer_key.clone(), transfer_private_key); + + let mut public_keys = BTreeMap::new(); + public_keys.insert(auth_key.id(), auth_key); + public_keys.insert(transfer_key.id(), transfer_key); + + let identity: Identity = IdentityV0 { + id: id.into(), + revision: 0, + balance, + public_keys, + } + .into(); + + (identity, signer) + } + + /// Adds the identity and books its balance into system credits so the fixture + /// starts balanced (the block-end sum-tree check then asserts conservation). + fn add_identity_to_drive(platform: &mut TempPlatform, identity: &Identity) { + let platform_version = PlatformVersion::latest(); + + platform + .drive + .add_to_system_credits(identity.balance(), None, platform_version) + .expect("expected to add to system credits"); + + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("should add identity"); + } + + struct ProvenBundle { + actions: Vec, + amount: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + } + + /// Builds and proves a real outputs-only Orchard bundle paying `shield_value` + /// to a fresh recipient. Identical to the `Shield` fixture: no extra sighash data. + fn build_valid_bundle(shield_value: u64) -> ProvenBundle { + let mut rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + Anchor::empty_tree(), + ); + builder + .add_output( + None, + recipient, + NoteValue::from_raw(shield_value), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[]).unwrap(); + + let (actions, _flags, value_balance, anchor, proof, binding_signature) = + serialize_authorized_bundle_with_flags(&bundle); + assert!(value_balance < 0, "value must enter the pool"); + + ProvenBundle { + actions, + amount: (-value_balance) as u64, + anchor, + proof, + binding_signature, + } + } + + #[allow(clippy::too_many_arguments)] + async fn create_signed_transition( + identity: &Identity, + signer: &SimpleSigner, + bundle: ProvenBundle, + amount: u64, + nonce: IdentityNonce, + user_fee_increase: u16, + platform_version: &PlatformVersion, + ) -> StateTransition { + ShieldFromIdentityTransition::try_from_bundle_with_identity_signer( + identity, + amount, + bundle.actions, + bundle.anchor, + bundle.proof, + bundle.binding_signature, + user_fee_increase, + signer, + None, + nonce, + platform_version, + ) + .await + .expect("should create signed transition") + } + + /// A structurally valid transition carrying dummy (unprovable) bundle bytes. + fn dummy_bundle() -> ProvenBundle { + ProvenBundle { + actions: vec![create_dummy_serialized_action()], + amount: 1_000, + anchor: [42u8; 32], + proof: vec![7u8; 100], + binding_signature: [0u8; 64], + } + } + + fn identity_balance(platform: &TempPlatform, identity: &Identity) -> u64 { + platform + .drive + .fetch_identity_balance(identity.id().to_buffer(), None, PlatformVersion::latest()) + .expect("should fetch") + .expect("identity should exist") + } + + // ========================================== + // Rejections + // ========================================== + + #[tokio::test] + async fn test_zero_amount_is_rejected_by_structure_validation() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut rng = StdRng::seed_from_u64(1); + let (identity, signer) = create_identity_with_transfer_key( + [1u8; 32], + dash_to_credits!(1.0), + &mut rng, + platform_version, + ); + add_identity_to_drive(&mut platform, &identity); + + let st = create_signed_transition( + &identity, + &signer, + dummy_bundle(), + 0, + 1, + 0, + platform_version, + ) + .await; + let result = process_transition(&platform, st, platform_version); + + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + #[tokio::test] + async fn test_unknown_identity_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let mut rng = StdRng::seed_from_u64(2); + let (identity, signer) = create_identity_with_transfer_key( + [2u8; 32], + dash_to_credits!(1.0), + &mut rng, + platform_version, + ); + + let st = create_signed_transition( + &identity, + &signer, + dummy_bundle(), + 1_000, + 1, + 0, + platform_version, + ) + .await; + let result = process_transition(&platform, st, platform_version); + + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::SignatureError(SignatureError::IdentityNotFoundError(_)) + )] + ); + } + + #[tokio::test] + async fn test_insufficient_balance_is_rejected_before_proof_verification() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut rng = StdRng::seed_from_u64(3); + let (identity, signer) = create_identity_with_transfer_key( + [3u8; 32], + dash_to_credits!(0.001), + &mut rng, + platform_version, + ); + add_identity_to_drive(&mut platform, &identity); + + // amount alone exceeds the balance; the dummy proof never gets verified + let st = create_signed_transition( + &identity, + &signer, + dummy_bundle(), + dash_to_credits!(1.0), + 1, + 0, + platform_version, + ) + .await; + let result = process_transition(&platform, st, platform_version); + + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::IdentityInsufficientBalanceError(_)) + )] + ); + } + + #[tokio::test] + async fn test_invalid_identity_signature_is_rejected() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut rng = StdRng::seed_from_u64(4); + let (identity, _signer) = create_identity_with_transfer_key( + [4u8; 32], + dash_to_credits!(1.0), + &mut rng, + platform_version, + ); + add_identity_to_drive(&mut platform, &identity); + + let transfer_key = identity + .get_first_public_key_matching( + Purpose::TRANSFER, + SecurityLevel::full_range().into(), + KeyType::all_key_types().into(), + true, + ) + .expect("should have transfer key"); + let bundle = dummy_bundle(); + let st: StateTransition = ShieldFromIdentityTransitionV0 { + identity_id: identity.id(), + amount: 1_000, + actions: bundle.actions, + anchor: bundle.anchor, + proof: bundle.proof, + binding_signature: bundle.binding_signature, + nonce: 1, + user_fee_increase: 0, + signature_public_key_id: transfer_key.id(), + signature: BinaryData::new(vec![0x30; 65]), + } + .into(); + let result = process_transition(&platform, st, platform_version); + + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::SignatureError(_) + )] + ); + } + + #[tokio::test] + async fn test_invalid_orchard_proof_is_rejected() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut rng = StdRng::seed_from_u64(5); + let (identity, signer) = create_identity_with_transfer_key( + [5u8; 32], + dash_to_credits!(1.0), + &mut rng, + platform_version, + ); + add_identity_to_drive(&mut platform, &identity); + + let st = create_signed_transition( + &identity, + &signer, + dummy_bundle(), + 1_000, + 1, + 0, + platform_version, + ) + .await; + let result = process_transition(&platform, st, platform_version); + + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + #[tokio::test] + async fn test_valid_bundle_with_mutated_amount_is_rejected() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut rng = StdRng::seed_from_u64(6); + let (identity, signer) = create_identity_with_transfer_key( + [6u8; 32], + dash_to_credits!(1.0), + &mut rng, + platform_version, + ); + add_identity_to_drive(&mut platform, &identity); + + let bundle = build_valid_bundle(5_000); + let mutated_amount = bundle.amount + 1; + // The identity signature is over the mutated amount, so it verifies; the Orchard + // binding signature commits to the real value balance and must reject it. + let st = create_signed_transition( + &identity, + &signer, + bundle, + mutated_amount, + 1, + 0, + platform_version, + ) + .await; + let result = process_transition(&platform, st, platform_version); + + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + #[tokio::test] + async fn test_wrong_nonce_is_rejected() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut rng = StdRng::seed_from_u64(7); + let (identity, signer) = create_identity_with_transfer_key( + [7u8; 32], + dash_to_credits!(1.0), + &mut rng, + platform_version, + ); + add_identity_to_drive(&mut platform, &identity); + + // nonce 0 is never a valid next nonce for a fresh identity + let st = create_signed_transition( + &identity, + &signer, + dummy_bundle(), + 1_000, + 0, + 0, + platform_version, + ) + .await; + let result = process_transition(&platform, st, platform_version); + + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidIdentityNonceError(_)) + )] + ); + } + + #[tokio::test] + async fn test_rejected_before_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let platform_config = PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }; + let mut platform = TestPlatformBuilder::new() + .with_config(platform_config) + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + let mut rng = StdRng::seed_from_u64(8); + let (identity, signer) = create_identity_with_transfer_key( + [8u8; 32], + dash_to_credits!(1.0), + &mut rng, + platform_version, + ); + add_identity_to_drive(&mut platform, &identity); + + let st = create_signed_transition( + &identity, + &signer, + dummy_bundle(), + 1_000, + 1, + 0, + platform_version, + ) + .await; + let result = process_transition(&platform, st, platform_version); + + // Below the activation version the transition is refused at decode time by the + // `active_version_range` check (`14..=LATEST`), before any consensus validation; + // the `is_allowed` gate is the second line of defence for a decodable transition. + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::InternalError(message)] + if message.contains("ShieldFromIdentity") && message.contains("not active") + ); + } + + // ========================================== + // Success, fees, and conservation + // ========================================== + + #[tokio::test] + async fn test_valid_shield_from_identity_moves_credits_and_conserves_supply() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut rng = StdRng::seed_from_u64(9); + let initial_balance = dash_to_credits!(1.0); + let (identity, signer) = create_identity_with_transfer_key( + [9u8; 32], + initial_balance, + &mut rng, + platform_version, + ); + add_identity_to_drive(&mut platform, &identity); + + let credits_before = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("should calculate total credits before"); + assert!( + credits_before.ok().expect("no overflow"), + "fixture must start balanced" + ); + + let bundle = build_valid_bundle(5_000); + let shield_amount = bundle.amount; + let num_actions = bundle.actions.len(); + let st = create_signed_transition( + &identity, + &signer, + bundle, + shield_amount, + 1, + 0, + platform_version, + ) + .await; + + // CheckTx must never mutate committed state for this transition type. + { + let guard_bytes = st.serialize_to_bytes().expect("serialize for guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_bytes, + "shield from identity", + ); + } + + // Full block pipeline: execute, distribute fees, validate sum trees. + let platform_state = platform.state.load(); + let (fee_results, _) = + process_state_transitions(&platform, &[st], BlockInfo::default(), &platform_state); + + let booked = &fee_results[0]; + let shielded_verification_fee = + dpp::shielded::compute_shielded_verification_fee(num_actions, platform_version) + .expect("compute fee"); + assert!( + booked.storage_fee > 0, + "metered note storage must be captured, got {}", + booked.storage_fee + ); + assert!( + booked.processing_fee >= shielded_verification_fee, + "processing fee ({}) must include the shielded compute fee ({})", + booked.processing_fee, + shielded_verification_fee + ); + + let credits_after = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("should calculate total credits after"); + assert!( + credits_after.ok().expect("no overflow"), + "credits must remain balanced after the shield: {credits_after}" + ); + assert_eq!( + credits_after.total_credits_in_platform, credits_before.total_credits_in_platform, + "identity to pool must not mint or burn system credits" + ); + assert_eq!( + credits_after.total_in_shielded_balances - credits_before.total_in_shielded_balances, + shield_amount as i64, + "shielded pool must gain exactly the shield amount" + ); + + let final_balance = identity_balance(&platform, &identity); + assert_eq!( + final_balance, + initial_balance - shield_amount - booked.total_base_fee(), + "identity must be debited exactly amount + booked fee" + ); + + let nonce = platform + .drive + .fetch_identity_nonce(identity.id().to_buffer(), true, None, platform_version) + .expect("fetch nonce") + .expect("nonce must be stored"); + assert_eq!(nonce, 1, "identity nonce must advance"); + } + + #[tokio::test] + async fn test_user_fee_increase_raises_the_booked_fee() { + let platform_version = PlatformVersion::latest(); + let mut fees = vec![]; + for (seed, user_fee_increase) in [(10u64, 0u16), (11u64, 100u16)] { + let mut platform = setup_platform(); + let mut rng = StdRng::seed_from_u64(seed); + let (identity, signer) = create_identity_with_transfer_key( + [seed as u8; 32], + dash_to_credits!(1.0), + &mut rng, + platform_version, + ); + add_identity_to_drive(&mut platform, &identity); + let bundle = build_valid_bundle(5_000); + let amount = bundle.amount; + let st = create_signed_transition( + &identity, + &signer, + bundle, + amount, + 1, + user_fee_increase, + platform_version, + ) + .await; + let platform_state = platform.state.load(); + let (fee_results, _) = + process_state_transitions(&platform, &[st], BlockInfo::default(), &platform_state); + fees.push(fee_results[0].total_base_fee()); + } + assert!( + fees[1] > fees[0], + "a user fee increase must raise the booked fee ({} vs {})", + fees[1], + fees[0] + ); + } + + #[tokio::test] + async fn test_prove_and_verify_returns_post_debit_identity_balance() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut rng = StdRng::seed_from_u64(12); + let initial_balance = dash_to_credits!(1.0); + let (identity, signer) = create_identity_with_transfer_key( + [12u8; 32], + initial_balance, + &mut rng, + platform_version, + ); + add_identity_to_drive(&mut platform, &identity); + + let bundle = build_valid_bundle(5_000); + let amount = bundle.amount; + let st = + create_signed_transition(&identity, &signer, bundle, amount, 1, 0, platform_version) + .await; + + let transition_bytes = st.serialize_to_bytes().expect("serialize"); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commit"); + + let proof_bytes = platform + .drive + .prove_state_transition(&st, None, platform_version) + .expect("prove") + .into_data() + .expect("proof data"); + + let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &proof_bytes, + &|_| Ok(None), + platform_version, + ) + .expect("verify"); + assert_ne!(root_hash, [0u8; 32]); + + let result = outcome.into_result(); + let StateTransitionProofResult::VerifiedPartialIdentity(partial) = result else { + panic!("expected VerifiedPartialIdentity, got {result:?}"); + }; + assert_eq!(partial.id, identity.id()); + let proven_balance = partial.balance.expect("balance must be proven"); + assert_eq!(proven_balance, identity_balance(&platform, &identity)); + assert!(proven_balance < initial_balance - amount); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/mod.rs new file mode 100644 index 00000000000..9a1925de7fc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/mod.rs @@ -0,0 +1 @@ +pub(crate) mod v0; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs new file mode 100644 index 00000000000..028f2ab294b --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs @@ -0,0 +1,43 @@ +use crate::error::Error; +use crate::execution::validation::state_transition::state_transitions::shielded_common::read_pool_total_balance; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::shield_from_identity::ShieldFromIdentityTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::shield_from_identity) trait ShieldFromIdentityStateTransitionTransformIntoActionValidationV0 +{ + fn transform_into_action_v0( + &self, + drive: &Drive, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl ShieldFromIdentityStateTransitionTransformIntoActionValidationV0 + for ShieldFromIdentityTransition +{ + /// The identity signature, nonce, balance floor, structure, and Orchard proof + /// have all been checked by the processor before this point. The identity is + /// debited exactly `amount`, so unlike `Shield` there is no per-input slack to + /// reallocate; only the pool total is read so the action can bump it. + fn transform_into_action_v0( + &self, + drive: &Drive, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut drive_operations = vec![]; + let current_total_balance = + read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; + + let result = + ShieldFromIdentityTransitionAction::try_from_transition(self, current_total_balance); + + Ok(result.map(|action| action.into())) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs index 775f42e76a2..878c813c0a0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs @@ -253,6 +253,14 @@ impl StateTransitionActionTransformer for StateTransition { StateTransition::ShieldedTransfer(st) => { st.transform_into_action_for_shielded_transfer_transition(platform, tx) } + StateTransition::ShieldFromIdentity(st) => st.transform_into_action( + platform, + block_info, + remaining_address_input_balances, + validation_mode, + execution_context, + tx, + ), StateTransition::Unshield(st) => { st.transform_into_action_for_unshield_transition(platform, tx) } diff --git a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs index fd9f0385d07..5400ba13743 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs @@ -1312,7 +1312,8 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( | StateTransitionAction::UnshieldAction(_) | StateTransitionAction::ShieldFromAssetLockAction(_) | StateTransitionAction::ShieldedWithdrawalAction(_) - | StateTransitionAction::IdentityCreateFromShieldedPoolAction(_) => { + | StateTransitionAction::IdentityCreateFromShieldedPoolAction(_) + | StateTransitionAction::ShieldFromIdentityAction(_) => { // The strategy harness does not generate shielded transitions (no shielded // `OperationType`), so their proof-verification roundtrip isn't exercised here. // IdentityCreateFromShieldedPool's strict prove/verify is covered by the unit diff --git a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs index cd8fd573663..27e03387c5f 100644 --- a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs +++ b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs @@ -507,6 +507,12 @@ impl Drive { &platform_version.drive.grove_version, )? } + StateTransition::ShieldFromIdentity(st) => { + // The identity's post-debit balance; the shielded note is not proven + // (the client learns it through shielded sync, as after `Shield`). + use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; + Drive::identity_balance_query(&st.identity_id().to_buffer()) + } }; let proof = self.grove_get_proved_path_query( diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs index 7db2c8d5390..39b92801898 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs @@ -110,6 +110,9 @@ impl DriveHighLevelOperationConverter for StateTransitionAction { StateTransitionAction::ShieldAction(shield_action) => { shield_action.into_high_level_drive_operations(epoch, platform_version) } + StateTransitionAction::ShieldFromIdentityAction(action) => { + action.into_high_level_drive_operations(epoch, platform_version) + } StateTransitionAction::ShieldedTransferAction(shielded_transfer_action) => { shielded_transfer_action.into_high_level_drive_operations(epoch, platform_version) } diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs index 2f76727173c..7ac1560e78c 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs @@ -1,5 +1,6 @@ mod identity_create_from_shielded_pool_transition; mod shield_from_asset_lock_transition; +mod shield_from_identity_transition; mod shield_transition; mod shielded_transfer_transition; mod shielded_withdrawal_transition; diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_identity_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_identity_transition.rs new file mode 100644 index 00000000000..2746f560045 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shield_from_identity_transition.rs @@ -0,0 +1,164 @@ +use super::{insert_notes, update_balance}; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; +use crate::state_transition_action::shielded::shield_from_identity::ShieldFromIdentityTransitionAction; +use crate::util::batch::DriveOperation::IdentityOperation; +use crate::util::batch::{DriveOperation, IdentityOperationType}; +use dpp::block::epoch::Epoch; +use dpp::version::PlatformVersion; + +impl DriveHighLevelOperationConverter for ShieldFromIdentityTransitionAction { + fn into_high_level_drive_operations<'a>( + self, + _epoch: &Epoch, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .shield_from_identity_transition + { + 0 => match self { + ShieldFromIdentityTransitionAction::V0(v0) => { + let identity_id = v0.identity_id.to_buffer(); + + // The identity balance and the shielded pool are both right-hand-side + // terms of the block credit-conservation equation, so this is a move + // between two buckets: no system-credit adjustment is emitted. + let mut ops: Vec> = vec![ + IdentityOperation(IdentityOperationType::UpdateIdentityNonce { + identity_id, + nonce: v0.nonce, + }), + IdentityOperation(IdentityOperationType::RemoveFromIdentityBalance { + identity_id, + balance_to_remove: v0.shield_amount, + }), + ]; + + insert_notes(&mut ops, &v0.notes); + + let new_total_balance = v0 + .current_total_balance + .checked_add(v0.shield_amount) + .ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState( + "shielded pool total balance overflow when adding shield from identity amount" + .to_string(), + )) + })?; + update_balance(&mut ops, new_total_balance); + + Ok(ops) + } + }, + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "ShieldFromIdentityTransitionAction::into_high_level_drive_operations" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state_transition_action::shielded::shield_from_identity::v0::ShieldFromIdentityTransitionActionV0; + use crate::state_transition_action::shielded::ShieldedActionNote; + use crate::util::batch::drive_op_batch::{ShieldedPoolOperationType, SystemOperationType}; + use dpp::block::epoch::Epoch; + use dpp::platform_value::Identifier; + use dpp::version::PlatformVersion; + + fn make_note() -> ShieldedActionNote { + ShieldedActionNote { + nullifier: [0x11; 32], + cmx: [0x22; 32], + cv_net: [0x33; 32], + encrypted_note: vec![1, 2, 3], + } + } + + fn make_action(notes: usize) -> ShieldFromIdentityTransitionAction { + ShieldFromIdentityTransitionAction::V0(ShieldFromIdentityTransitionActionV0 { + identity_id: Identifier::from([0xAA; 32]), + nonce: 7, + shield_amount: 3000, + notes: vec![make_note(); notes], + user_fee_increase: 0, + current_total_balance: 10000, + }) + } + + fn ops(action: ShieldFromIdentityTransitionAction) -> Vec> { + action + .into_high_level_drive_operations(&Epoch::new(0).unwrap(), PlatformVersion::latest()) + .expect("expected operations") + } + + #[test] + fn test_nonce_then_balance_removal_then_notes_then_pool_total() { + let ops = ops(make_action(2)); + // UpdateIdentityNonce + RemoveFromIdentityBalance + 2 InsertNote + UpdateTotalBalance + assert_eq!(ops.len(), 5); + assert!(matches!( + &ops[0], + IdentityOperation(IdentityOperationType::UpdateIdentityNonce { nonce: 7, .. }) + )); + assert!(matches!( + &ops[1], + IdentityOperation(IdentityOperationType::RemoveFromIdentityBalance { + balance_to_remove: 3000, + .. + }) + )); + assert!(matches!( + &ops[2], + DriveOperation::ShieldedPoolOperation(ShieldedPoolOperationType::InsertNote { .. }) + )); + assert!(matches!( + ops.last().unwrap(), + DriveOperation::ShieldedPoolOperation(ShieldedPoolOperationType::UpdateTotalBalance { + new_total_balance: 13000 + }) + )); + } + + #[test] + fn test_no_system_credit_adjustment_is_emitted() { + let ops = ops(make_action(1)); + assert!( + !ops.iter().any(|op| matches!( + op, + DriveOperation::SystemOperation(SystemOperationType::AddToSystemCredits { .. }) + | DriveOperation::SystemOperation( + SystemOperationType::RemoveFromSystemCredits { .. } + ) + )), + "identity to pool is a move between two balance trees; system credits must not change" + ); + } + + #[test] + fn test_pool_total_overflow_is_an_error() { + let action = ShieldFromIdentityTransitionAction::V0(ShieldFromIdentityTransitionActionV0 { + identity_id: Identifier::from([0xAA; 32]), + nonce: 1, + shield_amount: 10, + notes: vec![make_note()], + user_fee_increase: 0, + current_total_balance: u64::MAX - 5, + }); + let result = action + .into_high_level_drive_operations(&Epoch::new(0).unwrap(), PlatformVersion::latest()); + assert!(matches!( + result, + Err(Error::Drive(DriveError::CorruptedDriveState(_))) + )); + } +} diff --git a/packages/rs-drive/src/state_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/mod.rs index 51baa617dcb..eb28b0542aa 100644 --- a/packages/rs-drive/src/state_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/mod.rs @@ -32,6 +32,7 @@ use crate::state_transition_action::identity::masternode_vote::MasternodeVoteTra use crate::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; use crate::state_transition_action::shielded::shield::ShieldTransitionAction; use crate::state_transition_action::shielded::shield_from_asset_lock::ShieldFromAssetLockTransitionAction; +use crate::state_transition_action::shielded::shield_from_identity::ShieldFromIdentityTransitionAction; use crate::state_transition_action::shielded::shielded_transfer::ShieldedTransferTransitionAction; use crate::state_transition_action::shielded::shielded_withdrawal::ShieldedWithdrawalTransitionAction; use crate::state_transition_action::shielded::unshield::UnshieldTransitionAction; @@ -110,6 +111,8 @@ pub enum StateTransitionAction { ShieldedWithdrawalAction(ShieldedWithdrawalTransitionAction), /// identity create from shielded pool (shielded pool -> new identity) IdentityCreateFromShieldedPoolAction(IdentityCreateFromShieldedPoolTransitionAction), + /// identity balance to shielded pool + ShieldFromIdentityAction(ShieldFromIdentityTransitionAction), } impl StateTransitionAction { @@ -171,6 +174,7 @@ impl StateTransitionAction { StateTransitionAction::IdentityCreateFromShieldedPoolAction(_) => { UserFeeIncrease::default() // 0 (fee is locked by Orchard binding signature) } + StateTransitionAction::ShieldFromIdentityAction(action) => action.user_fee_increase(), } } } diff --git a/packages/rs-drive/src/state_transition_action/shielded/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/mod.rs index ee3831d4025..9057ce00962 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/mod.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/mod.rs @@ -4,6 +4,8 @@ pub mod identity_create_from_shielded_pool; pub mod shield; /// Shield from asset lock transition action pub mod shield_from_asset_lock; +/// Shield from identity transition action +pub mod shield_from_identity; /// Shielded transfer transition action pub mod shielded_transfer; /// Shielded withdrawal transition action diff --git a/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/mod.rs new file mode 100644 index 00000000000..a3ad2c6bdf4 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/mod.rs @@ -0,0 +1,95 @@ +/// transformer +pub mod transformer; +/// v0 +pub mod v0; + +use crate::state_transition_action::shielded::shield_from_identity::v0::ShieldFromIdentityTransitionActionV0; +use crate::state_transition_action::shielded::ShieldedActionNote; +use derive_more::From; +use dpp::fee::Credits; +use dpp::platform_value::Identifier; +use dpp::prelude::{IdentityNonce, UserFeeIncrease}; + +/// Shield from identity transition action +#[derive(Debug, Clone, From)] +pub enum ShieldFromIdentityTransitionAction { + /// v0 + V0(ShieldFromIdentityTransitionActionV0), +} + +impl ShieldFromIdentityTransitionAction { + /// The identity whose balance funds the shield + pub fn identity_id(&self) -> Identifier { + match self { + ShieldFromIdentityTransitionAction::V0(transition) => transition.identity_id, + } + } + /// The identity nonce + pub fn nonce(&self) -> IdentityNonce { + match self { + ShieldFromIdentityTransitionAction::V0(transition) => transition.nonce, + } + } + /// Get the shield amount + pub fn shield_amount(&self) -> Credits { + match self { + ShieldFromIdentityTransitionAction::V0(transition) => transition.shield_amount, + } + } + /// Get notes + pub fn notes(&self) -> &[ShieldedActionNote] { + match self { + ShieldFromIdentityTransitionAction::V0(transition) => &transition.notes, + } + } + /// fee multiplier + pub fn user_fee_increase(&self) -> UserFeeIncrease { + match self { + ShieldFromIdentityTransitionAction::V0(transition) => transition.user_fee_increase, + } + } + /// The pool total balance read at transform time + pub fn current_total_balance(&self) -> Credits { + match self { + ShieldFromIdentityTransitionAction::V0(transition) => transition.current_total_balance, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_note() -> ShieldedActionNote { + ShieldedActionNote { + nullifier: [0x11; 32], + cmx: [0x22; 32], + cv_net: [0x33; 32], + encrypted_note: vec![0xAB, 0xCD, 0xEF], + } + } + + fn make_action() -> ShieldFromIdentityTransitionAction { + ShieldFromIdentityTransitionAction::from(ShieldFromIdentityTransitionActionV0 { + identity_id: Identifier::from([0xAA; 32]), + nonce: 3, + shield_amount: 5000, + notes: vec![make_note(), make_note()], + user_fee_increase: 2, + current_total_balance: 50000, + }) + } + + #[test] + fn test_accessors() { + let action = make_action(); + assert!(matches!(action, ShieldFromIdentityTransitionAction::V0(_))); + assert_eq!(action.identity_id(), Identifier::from([0xAA; 32])); + assert_eq!(action.nonce(), 3); + assert_eq!(action.shield_amount(), 5000); + assert_eq!(action.notes().len(), 2); + assert_eq!(action.notes()[0].cmx, [0x22; 32]); + assert_eq!(action.user_fee_increase(), 2); + assert_eq!(action.current_total_balance(), 50000); + } +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/transformer.rs new file mode 100644 index 00000000000..b7363e1b05f --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/transformer.rs @@ -0,0 +1,20 @@ +use crate::state_transition_action::shielded::shield_from_identity::v0::ShieldFromIdentityTransitionActionV0; +use crate::state_transition_action::shielded::shield_from_identity::ShieldFromIdentityTransitionAction; +use dpp::fee::Credits; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; + +impl ShieldFromIdentityTransitionAction { + /// Transforms the state transition into an action + pub fn try_from_transition( + value: &ShieldFromIdentityTransition, + current_total_balance: Credits, + ) -> ConsensusValidationResult { + match value { + ShieldFromIdentityTransition::V0(v0) => { + ShieldFromIdentityTransitionActionV0::try_from_transition(v0, current_total_balance) + .map(|action| action.into()) + } + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/v0/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/v0/mod.rs new file mode 100644 index 00000000000..ddc69e508a4 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/v0/mod.rs @@ -0,0 +1,23 @@ +mod transformer; + +use crate::state_transition_action::shielded::ShieldedActionNote; +use dpp::fee::Credits; +use dpp::platform_value::Identifier; +use dpp::prelude::{IdentityNonce, UserFeeIncrease}; + +/// Identity balance to shielded pool, version 0. +#[derive(Debug, Clone)] +pub struct ShieldFromIdentityTransitionActionV0 { + /// The identity whose balance funds the shield + pub identity_id: Identifier, + /// The identity nonce to store + pub nonce: IdentityNonce, + /// Credits removed from the identity and added to the pool + pub shield_amount: Credits, + /// Notes built 1:1 from the on-wire Orchard actions + pub notes: Vec, + /// Fee multiplier + pub user_fee_increase: UserFeeIncrease, + /// Pool total balance read at transform time + pub current_total_balance: Credits, +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/v0/transformer.rs new file mode 100644 index 00000000000..55f99edd9db --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/shield_from_identity/v0/transformer.rs @@ -0,0 +1,25 @@ +use crate::state_transition_action::shielded::shield_from_identity::v0::ShieldFromIdentityTransitionActionV0; +use crate::state_transition_action::shielded::ShieldedActionNote; +use dpp::fee::Credits; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::state_transitions::shielded::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; + +impl ShieldFromIdentityTransitionActionV0 { + /// Builds the v0 action from the v0 transition and the pool total read at transform time + pub fn try_from_transition( + value: &ShieldFromIdentityTransitionV0, + current_total_balance: Credits, + ) -> ConsensusValidationResult { + let notes: Vec = + value.actions.iter().map(ShieldedActionNote::from).collect(); + + ConsensusValidationResult::new_with_data(ShieldFromIdentityTransitionActionV0 { + identity_id: value.identity_id, + nonce: value.nonce, + shield_amount: value.amount, + notes, + user_fee_increase: value.user_fee_increase, + current_total_balance, + }) + } +} diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 15ee8913fd7..5216e16cf27 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -2080,6 +2080,31 @@ impl Drive { VerifiedIdentityWithShieldedNullifiers(identity, statuses), )) } + StateTransition::ShieldFromIdentity(st) => { + use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; + // snapshot of the identity's balance at the proof's block + let identity_id = st.identity_id(); + let (root_hash, balance) = Drive::verify_identity_balance_for_identity_id( + proof, + identity_id.into_buffer(), + false, + platform_version, + )?; + let balance = balance.ok_or(Error::Proof(ProofError::IncorrectProof(format!( + "proof did not contain balance for identity {} expected to exist because of state transition (shield from identity)", + identity_id + ))))?; + Ok(( + root_hash, + VerifiedPartialIdentity(PartialIdentity { + id: identity_id, + loaded_public_keys: Default::default(), + balance: Some(balance), + revision: None, + not_found_public_keys: Default::default(), + }), + )) + } }?; let outcome = if Self::state_transition_proof_binds_execution( @@ -2214,6 +2239,9 @@ impl Drive { // complete Orchard request, denomination context, or fallback // address. StateTransition::IdentityCreateFromShieldedPool(_) => false, + // Only the identity's post-debit balance is proven; the shielded note + // and the requested amount are not bound. + StateTransition::ShieldFromIdentity(_) => false, }; Ok(binds) diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs index b697b2af5c6..35d25ede04a 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs @@ -40,6 +40,7 @@ pub struct DPPStateTransitionSerializationVersions { pub shield_from_asset_lock_state_transition: FeatureVersionBounds, pub shielded_withdrawal_state_transition: FeatureVersionBounds, pub identity_create_from_shielded_pool_state_transition: FeatureVersionBounds, + pub shield_from_identity_state_transition: FeatureVersionBounds, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs index 125c0ef0bb1..ba8921e677f 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs @@ -164,4 +164,9 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V1: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + shield_from_identity_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs index 1b4f4fabd67..946eb7ae48e 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs @@ -164,4 +164,9 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V2: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + shield_from_identity_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs index cc126a11447..bfaa8a83320 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs @@ -176,4 +176,9 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V3: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + shield_from_identity_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index 8adafa12325..86849403440 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -148,6 +148,7 @@ pub struct DriveAbciStateTransitionValidationVersions { pub shielded_withdrawal_state_transition: DriveAbciStateTransitionValidationVersion, pub identity_create_from_shielded_pool_state_transition: DriveAbciStateTransitionValidationVersion, + pub shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs index ec3772827bb..c84b90599ca 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs @@ -257,6 +257,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 0, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 96aef2ddb79..d0417048f8a 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -315,6 +315,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: Some(0), + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs index 80c58ba3606..8f2b966167c 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs @@ -257,6 +257,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 0, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs index f56dd557a72..c829d0fe04f 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs @@ -257,6 +257,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 0, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs index 336f67dd590..19fbd08ff67 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs @@ -260,6 +260,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, // <---- changed this has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs index 3d00561c2c6..c50bcd2be88 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs @@ -261,6 +261,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs index 013cccb86ba..a6fa2d367c2 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs @@ -264,6 +264,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs index e8b68bf7734..f09e54dbff3 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs @@ -258,6 +258,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs index 21c0507631e..0a61d8cffa3 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs @@ -312,6 +312,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs index 6f05b2b2577..eedfa5cfed3 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs @@ -308,6 +308,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V9: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs index 042f5bb2887..0fb1f79b263 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs @@ -69,6 +69,7 @@ pub struct DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions pub unshield_transition: FeatureVersion, pub shielded_withdrawal_transition: FeatureVersion, pub identity_create_from_shielded_pool_transition: FeatureVersion, + pub shield_from_identity_transition: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs index e0c98c98295..c4186666224 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs @@ -57,6 +57,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V1: DriveStateTransitionMethodV unshield_transition: 0, shielded_withdrawal_transition: 0, identity_create_from_shielded_pool_transition: 0, + shield_from_identity_transition: 0, }, document_from_action: DriveDocumentFromActionVersions { document_from_create_transition_action: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs index 53b1dce57d0..a5c8e26d2e5 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs @@ -58,6 +58,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V2: DriveStateTransitionMethodV unshield_transition: 0, shielded_withdrawal_transition: 0, identity_create_from_shielded_pool_transition: 0, + shield_from_identity_transition: 0, }, document_from_action: DriveDocumentFromActionVersions { document_from_create_transition_action: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs index 8bde4ce07a9..3e153bebef4 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs @@ -62,6 +62,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V3: DriveStateTransitionMethodV unshield_transition: 0, shielded_withdrawal_transition: 0, identity_create_from_shielded_pool_transition: 0, + shield_from_identity_transition: 0, }, document_from_action: DriveDocumentFromActionVersions { document_from_create_transition_action: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs index 85d8260a015..5b00fcb6a4b 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs @@ -62,6 +62,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4: DriveStateTransitionMethodV unshield_transition: 0, shielded_withdrawal_transition: 0, identity_create_from_shielded_pool_transition: 0, + shield_from_identity_transition: 0, }, document_from_action: DriveDocumentFromActionVersions { document_from_create_transition_action: 1, // changed diff --git a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs index e004582a10e..8f4d7b79f8c 100644 --- a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs +++ b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs @@ -2,3 +2,5 @@ use crate::version::ProtocolVersion; pub const ADDRESS_FUNDS_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 11; pub const SHIELDED_POOL_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 12; +/// `ShieldFromIdentity` (identity balance to shielded pool) activates with protocol version 14. +pub const SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index fd384bcac9c..02348a38758 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -204,6 +204,16 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// formats 0–2 (all pre-v14 documents) deserialize exactly as before with /// an unstamped (pre-annotation) layout. /// +/// * `ShieldFromIdentity` (state transition type 21) activates: +/// `SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION = 14` gates it in +/// `is_allowed`, and `DRIVE_ABCI_VALIDATION_VERSIONS_V10` is the first +/// table whose `shield_from_identity_state_transition` row enables basic +/// structure, identity signature, and nonce validation. It moves credits +/// from an identity balance straight into the shielded pool: the funding +/// side is identity-signed like `IdentityCreditTransferToAddresses`, the +/// pool side is an outputs-only Orchard bundle like `Shield`, and the fee +/// is metered plus the shielded compute fee, paid from the identity. +/// /// The wire surface changes only additively: `GetDocumentsRequestV1` /// already carries `selects` / `group_by` / `order_by` / `limit` / /// `offset`; the ranked response is an additive `ResultData.ranked` diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 06096c1aa96..981c10da7d1 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -2879,6 +2879,9 @@ impl PlatformWalletPersistence for FFIPersister { let (identity_id, has_identity_id) = match &e.kind { platform_wallet::wallet::shielded::ShieldedActivityKind::IdentityCreate { identity_id, + } + | platform_wallet::wallet::shielded::ShieldedActivityKind::ShieldFromIdentity { + identity_id, } => (*identity_id, 1u8), _ => ([0u8; 32], 0u8), }; @@ -3390,6 +3393,9 @@ impl PlatformWalletPersistence for FFIPersister { 6 => ShieldedActivityKind::IdentityCreate { identity_id: ffi.identity_id, }, + 8 => ShieldedActivityKind::ShieldFromIdentity { + identity_id: ffi.identity_id, + }, // 7 and any unknown tag fall back to the // residual — a forward-compat tag we don't yet // model still loads as an opaque spend rather diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index c1cec289755..52f1bbee64c 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -45,8 +45,8 @@ use std::os::raw::c_char; use dashcore::hashes::Hash; use dpp::address_funds::{OrchardAddress, PlatformAddress}; use dpp::shielded::{ - compute_minimum_shielded_fee, compute_shielded_unshield_fee, compute_shielded_withdrawal_fee, - ShieldedMemo, + compute_minimum_shielded_fee, compute_shielded_unshield_fee, compute_shielded_verification_fee, + compute_shielded_withdrawal_fee, ShieldedMemo, }; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use platform_wallet::wallet::asset_lock::AssetLockFunding; @@ -61,6 +61,8 @@ use crate::handle::*; use crate::identity_registration_with_signer::{decode_identity_pubkeys, IdentityPubkeyFFI}; use crate::runtime::{block_on_worker, runtime}; use crate::shielded_types::ShieldedShieldPreflightFFI; +use crate::types::read_identifier; +use crate::unwrap_result_or_return; /// A serialized `PlatformAddress` is exactly 21 bytes (1-byte variant tag + 20-byte hash). const PLATFORM_ADDRESS_LEN: usize = 21; @@ -170,6 +172,7 @@ fn shielded_fee_formula( 0 => Some(compute_minimum_shielded_fee), 1 => Some(compute_shielded_unshield_fee), 2 => Some(compute_shielded_withdrawal_fee), + 3 => Some(compute_shielded_verification_fee), _ => None, } } @@ -216,7 +219,7 @@ pub unsafe extern "C" fn platform_wallet_shielded_estimate_fee( let Some(formula) = shielded_fee_formula(kind) else { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("unknown shielded fee kind {kind} (expected 0/1/2)"), + format!("unknown shielded fee kind {kind} (expected 0/1/2/3)"), ); }; let Some(platform_version) = @@ -1160,6 +1163,80 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( map_spend_result(result, "shielded shield") } +/// Shield credits from one of the wallet's Platform identities straight into +/// the wallet's shielded pool: the Type 21 `ShieldFromIdentity` transition. +/// The note lands on `shielded_account`'s default Orchard address; the identity +/// is debited `amount` plus the metered fee plus the shielded compute fee. +/// +/// `identity_id` is the 32-byte identity id; the identity must be managed by +/// this wallet. `signer_identity_handle` is a `*const SignerHandle` produced by +/// `dash_sdk_signer_create_with_ctx` that can sign with the identity's TRANSFER +/// key (the same handle credit transfers use). The caller retains ownership. +/// +/// `out_new_balance`, when non-null, receives the identity's proven +/// post-debit balance (0 when the result proof carried none). +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `identity_id` must point to 32 readable bytes. +/// - `signer_identity_handle` must be a valid, non-destroyed `*const SignerHandle` +/// that outlives this call and points at a `VTableSigner` with the callback +/// variant. +/// - `out_new_balance` must be null or point to writable `u64` storage. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_shield_from_identity( + handle: Handle, + wallet_id_bytes: *const u8, + shielded_account: u32, + identity_id: *const u8, + amount: u64, + signer_identity_handle: *const SignerHandle, + out_new_balance: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(identity_id); + check_ptr!(signer_identity_handle); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let identity_id = unwrap_result_or_return!(read_identifier(identity_id)); + + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, + Err(result) => return result, + }; + + // Same pointer round-trip as `platform_wallet_manager_shielded_shield`: the + // borrow is re-materialized inside the synchronously-awaited worker task. + let signer_addr = signer_identity_handle as usize; + + let result = block_on_worker(async move { + // SAFETY: valid for the duration of this synchronously-awaited task per + // the caller's documented lifetime contract. + let identity_signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let prover = CachedOrchardProver::new(); + wallet + .shielded_shield_from_identity( + &coordinator, + shielded_account, + &identity_id, + amount, + identity_signer, + &prover, + ) + .await + }); + match result { + Ok(new_balance) => { + if !out_new_balance.is_null() { + *out_new_balance = new_balance.unwrap_or(0); + } + PlatformWalletFFIResult::ok() + } + Err(e) => map_spend_result(Err(e), "shielded shield from identity"), + } +} + /// Shield: spend credits from a Platform Payment account into a /// THIRD-PARTY shielded pool — the Type 15 shield with the note /// assigned to `recipient_raw_43` (the recipient's raw 43-byte diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 5072356c5c8..1de721ca7eb 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1838,6 +1838,155 @@ impl PlatformWallet { ) .await } + + /// Shield credits from one of this wallet's Platform identities straight into + /// the wallet's shielded pool (`ShieldFromIdentity`, type 21). The note is + /// assigned to `shielded_account`'s default Orchard address; `identity_id` must + /// be an identity this wallet manages, and `signer` must hold its TRANSFER key + /// (typically the same `Signer` used for credit transfers). + /// + /// The identity is debited `amount` plus the metered fee plus the shielded + /// compute fee. Returns the proven post-debit balance when the result proof + /// carried one. + #[cfg(feature = "shielded")] + pub async fn shielded_shield_from_identity( + &self, + coordinator: &Arc, + shielded_account: u32, + identity_id: &Identifier, + amount: u64, + signer: &S, + prover: P, + ) -> Result, PlatformWalletError> + where + S: dpp::identity::signer::Signer + Send + Sync, + P: dpp::shielded::builder::OrchardProver, + { + self.shielded_shield_from_identity_impl( + coordinator, + shielded_account, + None, + identity_id, + amount, + [0u8; 36], + signer, + prover, + ) + .await + } + + /// [`shielded_shield_from_identity`](Self::shielded_shield_from_identity) with + /// the note paid to a THIRD-PARTY Orchard address (`recipient_raw_43`, same shape + /// as [`shielded_transfer_to`](Self::shielded_transfer_to)) and an optional memo. + /// An address this account's own IVK recognizes is rejected; self-shields use the + /// entry point without a recipient. + #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] + pub async fn shielded_shield_from_identity_to_recipient( + &self, + coordinator: &Arc, + shielded_account: u32, + identity_id: &Identifier, + recipient_raw_43: &[u8; 43], + amount: u64, + memo: [u8; 36], + signer: &S, + prover: P, + ) -> Result, PlatformWalletError> + where + S: dpp::identity::signer::Signer + Send + Sync, + P: dpp::shielded::builder::OrchardProver, + { + let recipient = Option::::from( + grovedb_commitment_tree::PaymentAddress::from_raw_address_bytes(recipient_raw_43), + ) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "invalid Orchard payment address bytes".to_string(), + ) + })?; + self.shielded_shield_from_identity_impl( + coordinator, + shielded_account, + Some(recipient), + identity_id, + amount, + memo, + signer, + prover, + ) + .await + } + + #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] + async fn shielded_shield_from_identity_impl( + &self, + coordinator: &Arc, + shielded_account: u32, + recipient: Option, + identity_id: &Identifier, + amount: u64, + memo: [u8; 36], + signer: &S, + prover: P, + ) -> Result, PlatformWalletError> + where + S: dpp::identity::signer::Signer + Send + Sync, + P: dpp::shielded::builder::OrchardProver, + { + if amount == 0 { + return Err(PlatformWalletError::ShieldedBuildError( + "amount must be > 0".to_string(), + )); + } + + // Single-flight with the address-funded shields: the identity nonce is + // fetched inside the operation, and two concurrent builds would race it. + let _shield_guard = self.shield_guard.lock().await; + + let identity = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + info.identity_manager + .identity(identity_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? + }; + + let keyset = { + let guard = self.shielded_keys.read().await; + let keys = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + keys.get(&shielded_account) + .ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {shielded_account} not bound" + )) + })? + .clone() + }; + super::shielded::operations::shield_from_identity_to( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + &keyset, + shielded_account, + recipient.as_ref(), + &identity, + amount, + memo, + signer, + &prover, + ) + .await + } } impl PlatformWallet { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs index 61d7f513db4..dd1d5f08c74 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs @@ -100,6 +100,11 @@ pub enum ShieldedActivityKind { /// The created identity's id (32 bytes). identity_id: [u8; 32], }, + /// Type 21: a Platform identity's balance → shielded pool. + ShieldFromIdentity { + /// The funding identity's id (32 bytes). + identity_id: [u8; 32], + }, /// Own spend whose outputs are all self-change and which no /// correlation arm could refine. The honest residual on the restore /// path. Carries `fee: None` because the exact fee is underivable @@ -122,6 +127,7 @@ impl ShieldedActivityKind { ShieldedActivityKind::Withdrawal => 5, ShieldedActivityKind::IdentityCreate { .. } => 6, ShieldedActivityKind::ShieldedSpend => 7, + ShieldedActivityKind::ShieldFromIdentity { .. } => 8, } } } @@ -1178,6 +1184,9 @@ mod tests { identity_id: [0u8; 32], }, ShieldedActivityKind::ShieldedSpend, + ShieldedActivityKind::ShieldFromIdentity { + identity_id: [0u8; 32], + }, ]; let tags: Set = kinds.iter().map(|k| k.tag()).collect(); assert_eq!(tags.len(), kinds.len(), "every kind tag must be distinct"); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index aab60566879..0754bde09eb 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -51,9 +51,9 @@ use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::prelude::Identifier; use dpp::shielded::builder::{ - build_identity_create_from_shielded_pool_transition, build_shield_transition, - build_shielded_transfer_transition, build_shielded_withdrawal_transition, - build_unshield_transition, OrchardProver, SpendableNote, + build_identity_create_from_shielded_pool_transition, build_shield_from_identity_transition, + build_shield_transition, build_shielded_transfer_transition, + build_shielded_withdrawal_transition, build_unshield_transition, OrchardProver, SpendableNote, }; use dpp::shielded::compute_minimum_shielded_fee; use dpp::state_transition::proof_result::StateTransitionProofResult; @@ -265,6 +265,7 @@ fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] use dpp::state_transition::shielded_transfer_transition::accessors::ShieldedTransferTransitionAccessorsV0; use dpp::state_transition::shielded_withdrawal_transition::accessors::ShieldedWithdrawalTransitionAccessorsV0; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; + use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; use dpp::state_transition::unshield_transition::accessors::UnshieldTransitionAccessorsV0; match st { @@ -274,6 +275,7 @@ fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) => &v0.actions, StateTransition::ShieldedWithdrawal(t) => t.actions(), StateTransition::IdentityCreateFromShieldedPool(t) => t.actions(), + StateTransition::ShieldFromIdentity(t) => t.actions(), _ => &[], } } @@ -788,6 +790,196 @@ pub async fn shield_to, P: Orchar Ok(()) } +// ------------------------------------------------------------------------- +// ShieldFromIdentity: identity balance -> shielded pool (Type 21) +// ------------------------------------------------------------------------- + +/// Shield credits from a Platform identity's balance straight into the shielded +/// pool, with the resulting note assigned to `account`'s default Orchard address +/// (`recipient` `None`) or to a third-party Orchard address. +/// +/// Unlike [`shield_to`] there are no transparent inputs, no fee strategy, and no +/// address witnesses: the identity's TRANSFER key signs the whole transition and +/// consensus debits `amount` plus the metered fee (note writes + identity writes) +/// plus the shielded compute fee from the identity balance. `nonce` is fetched +/// from Platform here, so the caller only supplies the identity. +/// +/// Returns the identity's proven post-debit balance when the result proof carried +/// it (`None` when the wait confirmed execution without a balance). +#[allow(clippy::too_many_arguments)] +pub async fn shield_from_identity_to< + S: ShieldedStore, + Sig: Signer, + P: OrchardProver, +>( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &AccountViewingKeys, + account: u32, + recipient: Option<&PaymentAddress>, + identity: &Identity, + amount: u64, + memo: [u8; 36], + signer: &Sig, + prover: &P, +) -> Result, PlatformWalletError> { + let ShieldRecipient { + address: recipient_addr, + counterparty: external_counterparty, + kind, + direction, + } = resolve_shield_recipient(keys, recipient)?; + let id = SubwalletId::new(wallet_id, account); + let identity_id = identity.id(); + + // A self-shield funded by an identity is its own activity kind so the + // history shows which identity paid; a third-party recipient stays `Sent`. + let kind = match kind { + ShieldedActivityKind::Shield => ShieldedActivityKind::ShieldFromIdentity { + identity_id: identity_id.to_buffer(), + }, + other => other, + }; + + // The metered part of the fee is only known at execution; the shielded + // compute fee is the consensus floor and what the activity row records. + let fee_floor = + dpp::shielded::compute_shielded_verification_fee(SHIELD_NUM_ACTIONS, sdk.version()) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + let nonce = sdk + .get_identity_nonce(identity_id, true, None) + .await + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!("fetch identity nonce: {e}")) + })?; + + info!( + account, + credits = amount, + identity = %identity_id, + external = external_counterparty.is_some(), + "ShieldFromIdentity: building proof" + ); + + let state_transition = build_shield_from_identity_transition( + identity, + &recipient_addr, + amount, + nonce, + signer, + None, + 0, // user_fee_increase + prover, + memo, + Some(keys.outgoing_viewing_key.clone()), + sdk.version(), + ) + .await + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("ShieldFromIdentity: state transition built, broadcasting..."); + + let pending_entry = record_pending_activity( + store, + persister, + wallet_id, + id, + keys, + LiveEntryParams { + kind, + direction, + amount, + fee: Some(fee_floor), + counterparty: external_counterparty, + memo: non_zero_memo(&memo), + actions: shielded_actions(&state_transition), + spent_notes: &[], + }, + ) + .await; + + let enrich = |e: &dash_sdk::Error| -> PlatformWalletError { + crate::error::promote_address_nonce_error(e) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string())) + }; + + match state_transition.broadcast(sdk, None).await { + Ok(()) => {} + Err(e) if broadcast_definitely_failed(&e) => { + record_activity_status( + store, + persister, + wallet_id, + id, + &pending_entry, + ShieldedActivityStatus::Failed, + None, + ) + .await; + return Err(enrich(&e)); + } + Err(e) => { + warn!( + account, + error = %e, + "ShieldFromIdentity broadcast returned no verdict; the transition may have \ + been admitted; falling through to the result wait" + ); + } + } + + // The proof authenticates the identity's post-debit balance (an affected-state + // snapshot, not execution evidence); a consensus rejection surfaces as an error. + let new_balance = match state_transition + .wait_for_affected_state::(sdk, None) + .await + { + Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) => partial.balance, + Ok(_) => None, + Err(wait_err) => { + if carries_consensus_rejection(&wait_err) { + record_activity_status( + store, + persister, + wallet_id, + id, + &pending_entry, + ShieldedActivityStatus::Failed, + None, + ) + .await; + return Err(enrich(&wait_err)); + } + warn!( + account, + error = %wait_err, + "ShieldFromIdentity broadcast accepted but result confirmation failed; \ + leaving the activity row pending" + ); + return Err(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "shield from identity", + reason: wait_err.to_string(), + }); + } + }; + + record_activity_status( + store, + persister, + wallet_id, + id, + &pending_entry, + ShieldedActivityStatus::Confirmed, + None, + ) + .await; + info!(account, credits = amount, identity = %identity_id, "ShieldFromIdentity broadcast succeeded"); + Ok(new_balance) +} + // ------------------------------------------------------------------------- // ShieldFromAssetLock: Core L1 asset lock -> shielded pool (Type 18) // (orchestrated entry point lives in `wallet/shielded/fund_from_asset_lock.rs`) diff --git a/packages/rs-sdk/src/platform/transition.rs b/packages/rs-sdk/src/platform/transition.rs index 65a5a746e52..74a823667d2 100644 --- a/packages/rs-sdk/src/platform/transition.rs +++ b/packages/rs-sdk/src/platform/transition.rs @@ -20,6 +20,8 @@ pub mod put_settings; pub mod shield; #[cfg(feature = "shielded")] pub mod shield_from_asset_lock; +/// Identity balance to shielded pool. +pub mod shield_from_identity; #[cfg(feature = "shielded")] pub mod shielded_transfer; #[cfg(feature = "shielded")] diff --git a/packages/rs-sdk/src/platform/transition/shield_from_identity.rs b/packages/rs-sdk/src/platform/transition/shield_from_identity.rs new file mode 100644 index 00000000000..a67f81f4168 --- /dev/null +++ b/packages/rs-sdk/src/platform/transition/shield_from_identity.rs @@ -0,0 +1,109 @@ +//! Shield credits from an identity balance straight into the Orchard pool. + +use super::broadcast::BroadcastStateTransition; +use super::put_settings::PutSettings; +use super::validation::ensure_valid_state_transition_structure; +use crate::platform::transition::waitable::Waitable; +use crate::{Error, Sdk}; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::{Identity, IdentityPublicKey}; +use dpp::shielded::OrchardBundleParams; +use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::shield_from_identity_transition::methods::ShieldFromIdentityTransitionMethodsV0; +use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; + +/// Move `amount` credits from this identity's balance into the shielded pool using an +/// already proven outputs-only Orchard bundle (see +/// `dpp::shielded::builder::build_shield_from_identity_transition` for the full +/// build-and-sign path). +/// +/// Returns the identity's proven post-debit balance and the proof height. +#[async_trait::async_trait] +pub trait ShieldFromIdentity: Waitable { + async fn shield_from_identity + Send>( + &self, + sdk: &Sdk, + amount: u64, + bundle: OrchardBundleParams, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + signer: &S, + settings: Option, + ) -> Result<(Credits, u64), Error>; +} + +#[async_trait::async_trait] +impl ShieldFromIdentity for Identity { + async fn shield_from_identity + Send>( + &self, + sdk: &Sdk, + amount: u64, + bundle: OrchardBundleParams, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + signer: &S, + settings: Option, + ) -> Result<(Credits, u64), Error> { + if amount == 0 { + return Err(Error::Generic( + "shield amount must be greater than zero".to_string(), + )); + } + + let new_identity_nonce = sdk.get_identity_nonce(self.id(), true, settings).await?; + let user_fee_increase = settings + .as_ref() + .and_then(|settings| settings.user_fee_increase) + .unwrap_or_default(); + + let OrchardBundleParams { + actions, + anchor, + proof, + binding_signature, + } = bundle; + + let state_transition = ShieldFromIdentityTransition::try_from_bundle_with_identity_signer( + self, + amount, + actions, + anchor, + proof, + binding_signature, + user_fee_increase, + signer, + signing_transfer_key_to_use, + new_identity_nonce, + sdk.version(), + ) + .await?; + ensure_valid_state_transition_structure(&state_transition, sdk.version())?; + + let (st_result, metadata) = state_transition + .broadcast_and_wait_for_affected_state_with_metadata::( + sdk, settings, + ) + .await?; + match st_result { + StateTransitionProofResult::VerifiedPartialIdentity(identity) => { + if identity.id != self.id() { + return Err(Error::InvalidProvedResponse(format!( + "proof returned identity {} but {} initiated the shield", + identity.id, + self.id() + ))); + } + let balance = identity.balance.ok_or_else(|| { + Error::InvalidProvedResponse( + "identity proof did not include updated balance".to_string(), + ) + })?; + Ok((balance, metadata.height)) + } + other => Err(Error::InvalidProvedResponse(format!( + "identity balance proof was expected for {:?}, but received {:?}", + state_transition, other + ))), + } + } +} diff --git a/packages/wasm-dpp/src/state_transition/state_transition_factory.rs b/packages/wasm-dpp/src/state_transition/state_transition_factory.rs index 26a439121de..c2d316cdca4 100644 --- a/packages/wasm-dpp/src/state_transition/state_transition_factory.rs +++ b/packages/wasm-dpp/src/state_transition/state_transition_factory.rs @@ -84,7 +84,8 @@ impl StateTransitionFactoryWasm { | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) - | StateTransition::IdentityCreateFromShieldedPool(_) => Err(JsValue::from_str( + | StateTransition::IdentityCreateFromShieldedPool(_) + | StateTransition::ShieldFromIdentity(_) => Err(JsValue::from_str( "shielded transitions are not yet supported in wasm-dpp StateTransitionFactory", )), }, diff --git a/packages/wasm-dpp2/src/lib.rs b/packages/wasm-dpp2/src/lib.rs index fa7a7772367..3270d7dbd23 100644 --- a/packages/wasm-dpp2/src/lib.rs +++ b/packages/wasm-dpp2/src/lib.rs @@ -71,8 +71,8 @@ pub use platform_address::{ }; pub use shielded::{ AddressWitnessWasm, SerializedOrchardActionWasm, ShieldFromAssetLockTransitionWasm, - ShieldTransitionWasm, ShieldedTransferTransitionWasm, ShieldedWithdrawalTransitionWasm, - UnshieldTransitionWasm, + ShieldFromIdentityTransitionWasm, ShieldTransitionWasm, ShieldedTransferTransitionWasm, + ShieldedWithdrawalTransitionWasm, UnshieldTransitionWasm, }; pub use state_transitions::base::{GroupStateTransitionInfoWasm, StateTransitionWasm}; pub use state_transitions::proof_result::{StateTransitionProofResultTypeJs, convert_proof_result}; diff --git a/packages/wasm-dpp2/src/shielded/mod.rs b/packages/wasm-dpp2/src/shielded/mod.rs index 886f0734bd7..08064a13792 100644 --- a/packages/wasm-dpp2/src/shielded/mod.rs +++ b/packages/wasm-dpp2/src/shielded/mod.rs @@ -2,6 +2,7 @@ pub mod address_witness; pub mod identity_create_from_shielded_pool_transition; pub mod orchard_action; pub mod shield_from_asset_lock_transition; +pub mod shield_from_identity_transition; pub mod shield_transition; pub mod shielded_transfer_transition; pub mod shielded_withdrawal_transition; @@ -11,6 +12,7 @@ pub use address_witness::{AddressWitnessWasm, input_witnesses_from_js_options}; pub use identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransitionWasm; pub use orchard_action::{SerializedOrchardActionWasm, actions_from_js_options}; pub use shield_from_asset_lock_transition::ShieldFromAssetLockTransitionWasm; +pub use shield_from_identity_transition::ShieldFromIdentityTransitionWasm; pub use shield_transition::ShieldTransitionWasm; pub use shielded_transfer_transition::ShieldedTransferTransitionWasm; pub use shielded_withdrawal_transition::ShieldedWithdrawalTransitionWasm; diff --git a/packages/wasm-dpp2/src/shielded/shield_from_identity_transition.rs b/packages/wasm-dpp2/src/shielded/shield_from_identity_transition.rs new file mode 100644 index 00000000000..4ec39b1eef5 --- /dev/null +++ b/packages/wasm-dpp2/src/shielded/shield_from_identity_transition.rs @@ -0,0 +1,339 @@ +use crate::error::{WasmDppError, WasmDppResult}; +use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; +use crate::shielded::orchard_action::{SerializedOrchardActionWasm, actions_from_js_options}; +use crate::state_transitions::StateTransitionWasm; +use crate::utils::try_vec_to_fixed_bytes; +use crate::utils::{try_from_options_optional_with, try_to_u16, try_to_u32, try_to_u64}; +use crate::{impl_wasm_conversions_inner, impl_wasm_type_info}; +use dpp::platform_value::BinaryData; +use dpp::platform_value::string_encoding::Encoding::{Base64, Hex}; +use dpp::platform_value::string_encoding::{decode, encode}; +use dpp::prelude::UserFeeIncrease; +use dpp::serialization::{PlatformDeserializable, PlatformSerializable}; +use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; +use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; +use dpp::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; +use dpp::state_transition::{ + StateTransition, StateTransitionHasUserFeeIncrease, StateTransitionIdentitySigned, + StateTransitionLike, StateTransitionSingleSigned, +}; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(typescript_custom_section)] +const TS_TYPES: &str = r#" +/** + * Options for constructing a ShieldFromIdentityTransition (identity balance to shielded pool). + * The bundle is an outputs-only Orchard bundle; the transition is identity-signed with a + * TRANSFER key after construction. + */ +export interface ShieldFromIdentityTransitionOptions { + identityId: IdentifierLike; + amount: bigint; + actions: SerializedOrchardAction[]; + anchor: Uint8Array; + proof: Uint8Array; + bindingSignature: Uint8Array; + nonce: bigint; + userFeeIncrease?: number; +} + +/** + * ShieldFromIdentityTransition serialized as a plain object. + */ +export interface ShieldFromIdentityTransitionObject { + $formatVersion: string; + identityId: Uint8Array; + amount: bigint; + actions: SerializedOrchardActionObject[]; + anchor: Uint8Array; + proof: Uint8Array; + bindingSignature: Uint8Array; + nonce: bigint; + userFeeIncrease: number; + signaturePublicKeyId: number; + signature: Uint8Array; +} + +/** + * ShieldFromIdentityTransition serialized as JSON (human-readable). + */ +export interface ShieldFromIdentityTransitionJSON { + $formatVersion: string; + identityId: string; + amount: number | string; + actions: SerializedOrchardActionJSON[]; + anchor: string; + proof: string; + bindingSignature: string; + nonce: number | string; + userFeeIncrease: number; + signaturePublicKeyId: number; + signature: string; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "ShieldFromIdentityTransitionOptions")] + pub type ShieldFromIdentityTransitionOptionsJs; + + #[wasm_bindgen(typescript_type = "ShieldFromIdentityTransitionObject")] + pub type ShieldFromIdentityTransitionObjectJs; + + #[wasm_bindgen(typescript_type = "ShieldFromIdentityTransitionJSON")] + pub type ShieldFromIdentityTransitionJSONJs; +} + +/// Non-WASM-instance fields extracted from the constructor options via serde. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ShieldFromIdentityTransitionSimpleFields { + amount: u64, + anchor: Vec, + proof: Vec, + binding_signature: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(transparent)] +#[wasm_bindgen(js_name = ShieldFromIdentityTransition)] +pub struct ShieldFromIdentityTransitionWasm(ShieldFromIdentityTransition); + +impl From for ShieldFromIdentityTransitionWasm { + fn from(v: ShieldFromIdentityTransition) -> Self { + ShieldFromIdentityTransitionWasm(v) + } +} + +impl From for ShieldFromIdentityTransition { + fn from(v: ShieldFromIdentityTransitionWasm) -> Self { + v.0 + } +} + +#[wasm_bindgen(js_class = ShieldFromIdentityTransition)] +impl ShieldFromIdentityTransitionWasm { + #[wasm_bindgen(constructor)] + pub fn new( + options: ShieldFromIdentityTransitionOptionsJs, + ) -> WasmDppResult { + let js_opts: &JsValue = options.as_ref(); + + let identity_id: IdentifierWasm = crate::utils::try_from_options(&options, "identityId")?; + let actions = actions_from_js_options(js_opts, "actions")?; + let nonce: u64 = + crate::utils::try_from_options_with(js_opts, "nonce", |v| try_to_u64(v, "nonce"))?; + let user_fee_increase: UserFeeIncrease = + try_from_options_optional_with(js_opts, "userFeeIncrease", |v| { + try_to_u16(v, "userFeeIncrease") + })? + .unwrap_or(0); + + let fields: ShieldFromIdentityTransitionSimpleFields = + serde_wasm_bindgen::from_value(options.into()) + .map_err(|e| WasmDppError::invalid_argument(e.to_string()))?; + + let anchor: [u8; 32] = try_vec_to_fixed_bytes(fields.anchor, "anchor")?; + let binding_signature: [u8; 64] = + try_vec_to_fixed_bytes(fields.binding_signature, "bindingSignature")?; + + Ok(ShieldFromIdentityTransitionWasm( + ShieldFromIdentityTransition::V0(ShieldFromIdentityTransitionV0 { + identity_id: identity_id.into(), + amount: fields.amount, + actions: actions.into_iter().map(Into::into).collect(), + anchor, + proof: fields.proof, + binding_signature, + nonce, + user_fee_increase, + signature_public_key_id: 0, + signature: Default::default(), + }), + )) + } + + /// The identity whose balance funds the shield. + #[wasm_bindgen(getter = "identityId")] + pub fn identity_id(&self) -> IdentifierWasm { + self.0.identity_id().into() + } + + #[wasm_bindgen(setter = "identityId")] + pub fn set_identity_id(&mut self, identity_id: IdentifierLikeJs) -> WasmDppResult<()> { + self.0.set_identity_id(identity_id.try_into()?); + Ok(()) + } + + /// Credits leaving the identity balance and entering the pool. + #[wasm_bindgen(getter = "amount")] + pub fn amount(&self) -> u64 { + self.0.amount() + } + + /// Returns the serialized Orchard actions. + #[wasm_bindgen(getter = "actions")] + pub fn actions(&self) -> Vec { + self.0 + .actions() + .iter() + .cloned() + .map(SerializedOrchardActionWasm::from) + .collect() + } + + /// Returns the anchor (32-byte Merkle root). + #[wasm_bindgen(getter = "anchor")] + pub fn anchor(&self) -> Vec { + self.0.anchor().to_vec() + } + + /// Returns the Halo2 proof bytes. + #[wasm_bindgen(getter = "proof")] + pub fn proof(&self) -> Vec { + self.0.proof().to_vec() + } + + /// Returns the RedPallas binding signature (64 bytes). + #[wasm_bindgen(getter = "bindingSignature")] + pub fn binding_signature(&self) -> Vec { + self.0.binding_signature().to_vec() + } + + #[wasm_bindgen(getter = "nonce")] + pub fn nonce(&self) -> u64 { + self.0.nonce() + } + + #[wasm_bindgen(setter = "nonce")] + pub fn set_nonce(&mut self, nonce: &js_sys::BigInt) -> WasmDppResult<()> { + self.0.set_nonce(try_to_u64(nonce, "nonce")?); + Ok(()) + } + + #[wasm_bindgen(getter = "userFeeIncrease")] + pub fn user_fee_increase(&self) -> u16 { + self.0.user_fee_increase() + } + + #[wasm_bindgen(setter = "userFeeIncrease")] + pub fn set_user_fee_increase(&mut self, amount: &js_sys::Number) -> WasmDppResult<()> { + self.0 + .set_user_fee_increase(try_to_u16(amount, "userFeeIncrease")?); + Ok(()) + } + + #[wasm_bindgen(getter = "signature")] + pub fn signature(&self) -> Vec { + self.0.signature().to_vec() + } + + #[wasm_bindgen(setter = "signature")] + pub fn set_signature(&mut self, signature: Vec) { + self.0.set_signature_bytes(signature) + } + + #[wasm_bindgen(getter = "signaturePublicKeyId")] + pub fn signature_public_key_id(&self) -> u32 { + self.0.signature_public_key_id() + } + + #[wasm_bindgen(setter = "signaturePublicKeyId")] + pub fn set_signature_public_key_id( + &mut self, + #[wasm_bindgen(js_name = "publicKeyId")] public_key_id: &js_sys::Number, + ) -> WasmDppResult<()> { + self.0 + .set_signature_public_key_id(try_to_u32(public_key_id, "signaturePublicKeyId")?); + Ok(()) + } + + #[wasm_bindgen(js_name = getModifiedDataIds)] + pub fn modified_data_ids(&self) -> Vec { + self.0 + .modified_data_ids() + .into_iter() + .map(IdentifierWasm::from) + .collect() + } + + #[wasm_bindgen(js_name = toBytes)] + pub fn to_bytes(&self) -> WasmDppResult> { + Ok(PlatformSerializable::serialize_to_bytes( + &StateTransition::ShieldFromIdentity(self.0.clone()), + )?) + } + + #[wasm_bindgen(js_name = "toHex")] + pub fn to_hex(&self) -> WasmDppResult { + Ok(encode(self.to_bytes()?.as_slice(), Hex)) + } + + #[wasm_bindgen(js_name = "toBase64")] + pub fn to_base64(&self) -> WasmDppResult { + Ok(encode(self.to_bytes()?.as_slice(), Base64)) + } + + #[wasm_bindgen(js_name = fromBytes)] + pub fn from_bytes(bytes: Vec) -> WasmDppResult { + let st = StateTransition::deserialize_from_bytes(&bytes)?; + match st { + StateTransition::ShieldFromIdentity(inner) => Ok(inner.into()), + _ => Err(WasmDppError::invalid_argument( + "Invalid state transition type: expected ShieldFromIdentity", + )), + } + } + + #[wasm_bindgen(js_name = "fromHex")] + pub fn from_hex(hex: String) -> WasmDppResult { + let bytes = + decode(hex.as_str(), Hex).map_err(|e| WasmDppError::serialization(e.to_string()))?; + ShieldFromIdentityTransitionWasm::from_bytes(bytes) + } + + #[wasm_bindgen(js_name = "fromBase64")] + pub fn from_base64(base64: String) -> WasmDppResult { + let bytes = decode(base64.as_str(), Base64) + .map_err(|e| WasmDppError::serialization(e.to_string()))?; + ShieldFromIdentityTransitionWasm::from_bytes(bytes) + } + + #[wasm_bindgen(js_name = toStateTransition)] + pub fn to_state_transition(&self) -> StateTransitionWasm { + StateTransition::ShieldFromIdentity(self.0.clone()).into() + } + + #[wasm_bindgen(js_name = "fromStateTransition")] + pub fn from_state_transition( + st: &StateTransitionWasm, + ) -> WasmDppResult { + let rs_st: StateTransition = st.clone().into(); + match rs_st { + StateTransition::ShieldFromIdentity(inner) => Ok(inner.into()), + _ => Err(WasmDppError::invalid_argument( + "Invalid state transition type: expected ShieldFromIdentity", + )), + } + } +} + +impl ShieldFromIdentityTransitionWasm { + pub fn set_signature_binary_data(&mut self, data: BinaryData) { + self.0.set_signature(data) + } +} + +impl_wasm_conversions_inner!( + ShieldFromIdentityTransitionWasm, + ShieldFromIdentityTransition, + ShieldFromIdentityTransition, + ShieldFromIdentityTransitionObjectJs, + ShieldFromIdentityTransitionJSONJs +); + +impl_wasm_type_info!( + ShieldFromIdentityTransitionWasm, + ShieldFromIdentityTransition +); diff --git a/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs b/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs index cd90cdc68b4..186e7a98411 100644 --- a/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs +++ b/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs @@ -37,6 +37,7 @@ use dpp::state_transition::identity_topup_transition::accessors::IdentityTopUpTr use dpp::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; use dpp::state_transition::masternode_vote_transition::accessors::MasternodeVoteTransitionAccessorsV0; +use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; use dpp::state_transition::{ StateTransition, StateTransitionIdentitySigned, StateTransitionSigningOptions, }; @@ -314,6 +315,7 @@ impl StateTransitionWasm { ShieldFromAssetLock(_) => 18, ShieldedWithdrawal(_) => 19, IdentityCreateFromShieldedPool(_) => 20, + ShieldFromIdentity(_) => 21, } } @@ -393,6 +395,7 @@ impl StateTransitionWasm { IdentityCreditTransfer(_) => None, MasternodeVote(_) => None, IdentityCreditTransferToAddresses(_) + | ShieldFromIdentity(_) | IdentityCreateFromAddresses(_) | IdentityTopUpFromAddresses(_) | AddressFundsTransfer(_) @@ -421,6 +424,7 @@ impl StateTransitionWasm { IdentityCreditTransfer(credit_transfer) => Some(credit_transfer.nonce()), MasternodeVote(mn_vote) => Some(mn_vote.nonce()), IdentityCreditTransferToAddresses(ct) => Some(ct.nonce()), + ShieldFromIdentity(st) => Some(st.nonce()), IdentityCreateFromAddresses(_) => None, IdentityTopUpFromAddresses(_) => None, AddressFundsTransfer(_) @@ -552,6 +556,10 @@ impl StateTransitionWasm { ct.set_identity_id(owner_id); self.0 = IdentityCreditTransferToAddresses(ct); } + ShieldFromIdentity(mut st) => { + st.set_identity_id(owner_id); + self.0 = ShieldFromIdentity(st); + } IdentityCreateFromAddresses(_) => { return Err(WasmDppError::invalid_argument( "Cannot set owner for identity create transition", @@ -638,6 +646,7 @@ impl StateTransitionWasm { )); } IdentityCreditTransferToAddresses(_) + | ShieldFromIdentity(_) | IdentityCreateFromAddresses(_) | IdentityTopUpFromAddresses(_) | AddressFundsTransfer(_) @@ -728,6 +737,10 @@ impl StateTransitionWasm { transfer.set_nonce(nonce); transfer.into() } + ShieldFromIdentity(mut st) => { + st.set_nonce(nonce); + st.into() + } IdentityCreateFromAddresses(_) => { return Err(WasmDppError::invalid_argument( "Cannot set identity nonce for Identity Create From Addresses", diff --git a/packages/wasm-dpp2/tests/unit/ShieldFromIdentityTransition.spec.ts b/packages/wasm-dpp2/tests/unit/ShieldFromIdentityTransition.spec.ts new file mode 100644 index 00000000000..13e38fbcf83 --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/ShieldFromIdentityTransition.spec.ts @@ -0,0 +1,116 @@ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; +import { + fakeOrchardAction, + ZERO_ANCHOR, + ZERO_BINDING_SIG, + ZERO_PROOF, +} from './helpers/shielded.ts'; + +before(async () => { + await initWasm(); +}); + +describe('ShieldFromIdentityTransition', () => { + const identityId = '11111111111111111111111111111111'; + + function createTransition() { + return new wasm.ShieldFromIdentityTransition({ + identityId, + amount: BigInt(50_000), + actions: [fakeOrchardAction()], + anchor: ZERO_ANCHOR, + proof: ZERO_PROOF, + bindingSignature: ZERO_BINDING_SIG, + nonce: BigInt(1), + }); + } + + describe('constructor()', () => { + it('should construct with required fields', () => { + const t = createTransition(); + expect(t).to.be.an.instanceof(wasm.ShieldFromIdentityTransition); + }); + + it('should accept an Identifier instance and a user fee increase', () => { + const t = new wasm.ShieldFromIdentityTransition({ + identityId: new wasm.Identifier(identityId), + amount: BigInt(1), + actions: [fakeOrchardAction()], + anchor: ZERO_ANCHOR, + proof: ZERO_PROOF, + bindingSignature: ZERO_BINDING_SIG, + nonce: BigInt(7), + userFeeIncrease: 50, + }); + expect(t.userFeeIncrease).to.equal(50); + expect(t.nonce).to.equal(BigInt(7)); + }); + + it('should reject anchor of wrong length', () => { + expect(() => new wasm.ShieldFromIdentityTransition({ + identityId, + amount: BigInt(1), + actions: [fakeOrchardAction()], + anchor: new Uint8Array(31), + proof: ZERO_PROOF, + bindingSignature: ZERO_BINDING_SIG, + nonce: BigInt(1), + })).to.throw(); + }); + }); + + describe('getters / setters', () => { + it('returns identityId, amount, actions, nonce and identity-signature fields', () => { + const t = createTransition(); + expect(t.identityId).to.be.an.instanceof(wasm.Identifier); + expect(t.identityId.toString()).to.equal(identityId); + expect(t.amount).to.equal(BigInt(50_000)); + expect(t.actions[0]).to.be.an.instanceof(wasm.SerializedOrchardAction); + expect(t.nonce).to.equal(BigInt(1)); + expect(t.signaturePublicKeyId).to.equal(0); + expect(t.signature).to.be.an.instanceof(Uint8Array); + }); + + it('supports setting signature, signaturePublicKeyId, nonce and userFeeIncrease', () => { + const t = createTransition(); + t.signature = new Uint8Array(65).fill(7); + t.signaturePublicKeyId = 2; + t.nonce = BigInt(9); + t.userFeeIncrease = 3; + expect(t.signature.length).to.equal(65); + expect(t.signaturePublicKeyId).to.equal(2); + expect(t.nonce).to.equal(BigInt(9)); + expect(t.userFeeIncrease).to.equal(3); + }); + }); + + describe('toBytes() / fromBytes()', () => { + it('round-trips via bytes', () => { + const t = createTransition(); + const bytes = t.toBytes(); + const restored = wasm.ShieldFromIdentityTransition.fromBytes(bytes); + expect(Buffer.from(restored.toBytes())).to.deep.equal(Buffer.from(bytes)); + }); + + it('round-trips via base64 and hex', () => { + const t = createTransition(); + const bytes = t.toBytes(); + const fromBase64 = wasm.ShieldFromIdentityTransition.fromBase64(t.toBase64()); + const fromHex = wasm.ShieldFromIdentityTransition.fromHex(t.toHex()); + expect(Buffer.from(fromBase64.toBytes())).to.deep.equal(Buffer.from(bytes)); + expect(Buffer.from(fromHex.toBytes())).to.deep.equal(Buffer.from(bytes)); + }); + }); + + describe('toStateTransition()', () => { + it('converts to the umbrella wrapper with type 21 and back', () => { + const t = createTransition(); + const st = t.toStateTransition(); + expect(st).to.exist(); + expect(st.actionTypeNumber).to.equal(21); + const restored = wasm.ShieldFromIdentityTransition.fromStateTransition(st); + expect(Buffer.from(restored.toBytes())).to.deep.equal(Buffer.from(t.toBytes())); + }); + }); +}); From 9376d880e40d0888662d88e246d43c09aa50eaea Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 12 Sep 2026 18:33:02 +0700 Subject: [PATCH 02/17] feat(sdk): Swift and Kotlin wrappers for ShieldFromIdentity Bridges the platform_wallet_manager_shielded_shield_from_identity FFI export (type 21, identity balance to shielded pool) to both mobile SDKs, keeping all business logic in Rust. Swift: PlatformWalletManager.shieldedShieldFromIdentity(walletId:shieldedAccount: identityId:amount:identitySigner:) next to shieldedShield, same handle, buffer, and error-mapping patterns; ShieldedFeeKind.shieldFromIdentity = 3; activity kind tag 8 labelled in the example app's activity and storage views. Kotlin: JNI export FundingNative.shieldedShieldFromIdentity, the matching external declaration, PlatformWalletManager.shieldedShieldFromIdentity signing with the manager's Keystore signer handle, ShieldedProver.FeeKind.ShieldFromIdentity(3), and the activity tag 8 label in the example app. Also documents estimator fee kind 3 on the Rust FFI. Verified: cargo check for rs-unified-sdk-jni and platform-wallet-ffi, gradle :sdk:compileDebugKotlin :app:compileDebugKotlin, and build_ios.sh --target sim (FFI slice, xcframework, and SwiftExampleApp with warnings as errors). Co-Authored-By: Claude Fable 5.1 --- .../ui/shielded/ShieldedActivityScreen.kt | 1 + .../dashsdk/ffi/FundingNative.kt | 28 +++++- .../dashsdk/funding/ShieldedProver.kt | 11 +++ .../entities/ShieldedActivityEntity.kt | 8 +- .../dashsdk/wallet/PlatformWalletManager.kt | 46 +++++++++ .../src/shielded_send.rs | 5 +- packages/rs-unified-sdk-jni/src/funding.rs | 85 ++++++++++++++++- .../Models/PersistentShieldedActivity.swift | 8 +- .../PlatformWalletManagerShieldedSync.swift | 93 ++++++++++++++++++- .../Core/Views/ShieldedActivityView.swift | 12 ++- .../Views/StorageRecordDetailViews.swift | 1 + 11 files changed, 282 insertions(+), 16 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedActivityScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedActivityScreen.kt index 648ce4c360c..81fc6f7223b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedActivityScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedActivityScreen.kt @@ -122,6 +122,7 @@ private fun kindLabel(kindTag: Int): String = when (kindTag) { 4 -> "Unshielded" 5 -> "Withdrawn" 6 -> "Identity Created" + 8 -> "Shielded from Identity" else -> "Shielded Spend" } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index c1f9a6803d5..95c9b42630c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -25,7 +25,8 @@ internal object FundingNative { /** * The flat shielded fee in credits for a transition of [kind] - * (0 = ShieldedTransfer/Shield, 1 = Unshield, 2 = ShieldedWithdrawal) + * (0 = ShieldedTransfer/Shield, 1 = Unshield, 2 = ShieldedWithdrawal, + * 3 = ShieldFromIdentity: the compute-only floor, no storage term) * 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. @@ -139,6 +140,31 @@ internal object FundingNative { signerAddressHandle: Long, ) + /** + * Shield from a Platform IDENTITY's balance, Type 21 (bridges + * `platform_wallet_manager_shielded_shield_from_identity`). Sibling of + * [shieldedShield] with the identity: not the transparent + * Platform-Payment addresses: as the funding side: [amount] credits move + * straight out of [identityId]'s balance into this wallet's own bound + * shielded pool ([shieldedAccount]), and the identity is debited [amount] + * plus the metered fee plus the shielded compute fee. The identity must + * be managed by this wallet. Self-shield only (Rust always targets the + * account's own default Orchard address), so there is no recipient. + * [signerIdentityHandle] is the Keystore identity signer (the manager's + * `signerHandle`): the same handle credit transfers use, since the + * transition is authorized by the identity's TRANSFER key. Blocks for the + * ~30s Halo 2 proof; returns the identity's proven post-debit credit + * balance (0 when the result proof carried none). + */ + external fun shieldedShieldFromIdentity( + managerHandle: Long, + walletId: ByteArray, + shieldedAccount: Int, + identityId: ByteArray, + amount: Long, + signerIdentityHandle: Long, + ): Long + /** * Create an identity funded from the shielded pool, Type 20 (bridges * `platform_wallet_manager_shielded_identity_create_from_pool`). Spends a diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt index b0c1fba1304..7ff669b90e5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt @@ -34,6 +34,17 @@ object ShieldedProver { /** ShieldedWithdrawal (base + the flat Core withdrawal-document cost). */ Withdrawal(2), + + /** + * ShieldFromIdentity (Type 21): the shielded COMPUTE fee floor + * (`compute_shielded_verification_fee`: proof verification + per-action + * processing). Unlike the pool-paid kinds above it carries no storage + * term: the identity-funded shield meters its writes through GroveDB + * against the identity balance, so only the compute portion is flat. + * Backs + * [org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedShieldFromIdentity]. + */ + ShieldFromIdentity(3), } /** Kick the ~30s Halo 2 proving-key build onto a background thread. Idempotent. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/ShieldedActivityEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/ShieldedActivityEntity.kt index 01db54dbcef..4bf956d872b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/ShieldedActivityEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/ShieldedActivityEntity.kt @@ -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. + * 7 ShieldedSpend, 8 ShieldFromIdentity. */ val kindTag: Int, /** 0 In, 1 Out, 2 Self. */ @@ -46,7 +46,11 @@ data class ShieldedActivityEntity( val hasBlockHeight: Boolean, /** Record time in Unix millis; Swift `UInt64` → [Long]. */ val createdAtMs: Long, - /** Created identity id (32 bytes) when kindTag == 6; empty otherwise. */ + /** + * 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. + */ val identityId: ByteArray = ByteArray(0), /** Counterparty bytes (43B Orchard / 21B PlatformAddress / Core script). */ val counterparty: ByteArray = ByteArray(0), diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index c4e859f3060..fca07578a76 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -1632,6 +1632,52 @@ class PlatformWalletManager( } } + /** + * Shield from a Platform IDENTITY's balance (Type 21). Sibling of + * [shieldedShield] with the identity: not the transparent + * Platform-Payment addresses: as the funding side: [amount] credits move + * straight out of [identityId]'s balance into this wallet's own bound + * shielded pool ([shieldedAccount]), and the identity is debited [amount] + * plus the metered fee plus the shielded compute fee + * ([ShieldedProver.FeeKind.ShieldFromIdentity]). The identity must be + * managed by this wallet. Signed by the Keystore identity signer + * ([signerHandle]) with the identity's TRANSFER key: the same handle + * [org.dashfoundation.dashsdk.credits.IdentityCredits.transferToAddresses] + * threads through. Self-shield only (Rust always targets this wallet's own + * default Orchard address, so there is no recipient parameter). Blocks for + * the ~30s Halo 2 proof; the note arrives on the next shielded sync pass. + * + * @param walletId the 32-byte wallet id. + * @param identityId the 32-byte funding identity id. + * @param amount credits to shield (1 DASH = 1e11). + * @return the identity's proven post-debit credit balance, or 0 when the + * result proof carried none (the transition still succeeded). + */ + suspend fun shieldedShieldFromIdentity( + walletId: ByteArray, + identityId: ByteArray, + amount: Long, + shieldedAccount: Int = 0, + ): Long = teardownGate.op { + require(identityId.size == 32) { + "identityId must be exactly 32 bytes, got ${identityId.size}" + } + require(amount > 0) { "amount must be positive, got $amount" } + require(shieldedAccount >= 0) { + "shieldedAccount must be non-negative, got $shieldedAccount" + } + mapNativeErrors { + FundingNative.shieldedShieldFromIdentity( + managerHandle, + walletId, + shieldedAccount, + identityId, + amount, + signerHandle, + ) + } + } + /** * Create an identity funded from the shielded pool (Type 20) — port of * Swift's `shieldedIdentityCreateFromPool`. Spends a note of the fixed diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 52f1bbee64c..4b5b4f29ab8 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -187,7 +187,10 @@ fn shielded_fee_formula( /// - `1` → Unshield (`compute_shielded_unshield_fee` — base + the flat /// `AddBalanceToAddress` output-write cost), /// - `2` → ShieldedWithdrawal (`compute_shielded_withdrawal_fee` — base + -/// the flat Core withdrawal-document cost). +/// the flat Core withdrawal-document cost), +/// - `3` → ShieldFromIdentity (`compute_shielded_verification_fee`: the +/// compute-only floor; the note and identity writes are metered at +/// execution and charged to the identity on top of it). /// /// `num_actions` is the Orchard action count of the bundle the host will /// build (a single-note spend with change is 2 actions). The fee is diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 1a53abf9652..ef8af079a26 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -88,7 +88,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_proverI } /// The flat shielded fee in credits for a transition of the given `kind` -/// (`0` = ShieldedTransfer/Shield, `1` = Unshield, `2` = ShieldedWithdrawal) +/// (`0` = ShieldedTransfer/Shield, `1` = Unshield, `2` = ShieldedWithdrawal, +/// `3` = ShieldFromIdentity: the compute-only floor, no storage component) /// and Orchard action `count` (a single-note spend with change is 2 /// actions), computed at `managerHandle`'s network-tracked platform /// version — the same version the shielded builders carve fees with. No @@ -338,7 +339,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde core_signer_handle: jlong, ) { guard(&mut env, (), |env| { - // Reject sign errors at the boundary — negatives would otherwise + // Reject sign errors at the boundary: negatives would otherwise // bit-cast to huge unsigned values (and a clamped 0 amount would // build a meaningless 0-duff asset lock). if amount_duffs <= 0 { @@ -573,7 +574,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde progress_bridge: JObject, ) { guard(&mut env, (), |env| { - // Reject sign errors at the boundary — negatives would otherwise + // Reject sign errors at the boundary: negatives would otherwise // bit-cast to huge unsigned values. A target of 0 is legal (the // Rust side treats an already-met target as a no-op success). if account < 0 { @@ -679,7 +680,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde signer_address_handle: jlong, ) { guard(&mut env, (), |env| { - // Reject sign errors at the boundary — negatives would otherwise + // Reject sign errors at the boundary: negatives would otherwise // bit-cast to huge unsigned values (never clamp). if amount <= 0 { throw_sdk_exception(env, 1, "amount must be positive"); @@ -714,6 +715,80 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde }) } +/// Shield from a Platform IDENTITY's balance, Type 21 (bridges +/// `platform_wallet_manager_shielded_shield_from_identity`). +/// +/// Sibling of [`Java_..._shieldedShield`] with the identity: not the +/// transparent Platform-Payment addresses: as the funding side: `amount` +/// credits move straight out of `identity_id`'s balance into this wallet's +/// own bound shielded pool (`shielded_account`), and the identity is debited +/// `amount` + the metered fee + the shielded compute fee. The identity must +/// be managed by this wallet. Self-shield only (Rust always targets the +/// account's own default Orchard address), so there is no recipient +/// parameter. +/// +/// `signer_identity_handle` is the Keystore identity signer +/// (`mgr.signerHandle`, a `*const SignerHandle` / `VTableSigner` callback +/// variant): the SAME handle credit transfers sign with, since the +/// transition is authorized by the identity's TRANSFER key. The caller +/// retains ownership. +/// +/// Blocks for the ~30s Halo 2 proof; returns the identity's proven +/// post-debit credit balance (0 when the result proof carried none: the +/// transition still succeeded), mirroring +/// [`Java_..._transferCreditsToAddresses`]. The note itself arrives on the +/// next shielded sync pass. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shieldedShieldFromIdentity( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + shielded_account: jint, + identity_id: JByteArray, + amount: jlong, + signer_identity_handle: jlong, +) -> jlong { + guard(&mut env, 0i64, |env| { + // Reject sign errors at the boundary: negatives would otherwise + // bit-cast to huge unsigned values (never clamp). + if amount <= 0 { + throw_sdk_exception(env, 1, "amount must be positive"); + return 0; + } + if shielded_account < 0 { + throw_sdk_exception(env, 1, "shieldedAccount must be non-negative"); + return 0; + } + if signer_identity_handle == 0 { + throw_sdk_exception(env, 1, "signerIdentityHandle must be non-null"); + return 0; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return 0; + }; + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return 0; + }; + let mut out_balance: u64 = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_shielded_shield_from_identity( + manager_handle as Handle, + wid.as_ptr(), + shielded_account as u32, + id.as_ptr(), + amount as u64, + signer_identity_handle as *const SignerHandle, + &mut out_balance as *mut u64, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + out_balance as i64 + }) +} + /// Create an identity funded from the shielded pool, Type 20 (bridges /// `platform_wallet_manager_shielded_identity_create_from_pool`). /// @@ -874,7 +949,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde memo_text: JString, ) { guard(&mut env, (), |env| { - // Reject sign errors at the boundary — negatives would otherwise + // Reject sign errors at the boundary: negatives would otherwise // bit-cast to huge unsigned values (never clamp). if amount <= 0 { throw_sdk_exception(env, 1, "amount must be positive"); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift index a1b8e9b8acf..823d8008458 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift @@ -46,7 +46,8 @@ public final class PersistentShieldedActivity { /// Kind discriminant (`ShieldedActivityKind::tag`): 0 Shield, /// 1 ShieldFromAssetLock, 2 Received, 3 Sent, 4 Unshield, - /// 5 Withdrawal, 6 IdentityCreate, 7 ShieldedSpend. + /// 5 Withdrawal, 6 IdentityCreate, 7 ShieldedSpend, + /// 8 ShieldFromIdentity. public var kindTag: Int /// Direction: 0 In, 1 Out, 2 Self. public var direction: Int @@ -85,8 +86,9 @@ public final class PersistentShieldedActivity { public var minNotePosition: UInt64 = 0 public var hasMinNotePosition: Bool = false - /// Created identity id (32 bytes) when `kindTag == 6` - /// (IdentityCreate); empty otherwise. + /// Identity id (32 bytes) when the kind carries one: the created + /// identity for `kindTag == 6` (IdentityCreate), the debited identity + /// for `kindTag == 8` (ShieldFromIdentity). Empty otherwise. public var identityId: Data /// Counterparty bytes (43B Orchard / 21B PlatformAddress / Core /// script) when present; empty otherwise. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index ed60ab38dfd..9fd3c71eb17 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -468,6 +468,11 @@ extension PlatformWalletManager { case unshield = 1 /// ShieldedWithdrawal (`compute_shielded_withdrawal_fee`). case withdrawal = 2 + /// ShieldFromIdentity (`compute_shielded_verification_fee`): the + /// compute-only floor (Halo 2 verification + per-action + /// processing) this transition adds on top of its GroveDB-metered + /// storage, so storage is never double-counted. + case shieldFromIdentity = 3 } /// Consensus-pinned flat shielded fee (in credits) for a pool-paid @@ -744,6 +749,92 @@ extension PlatformWalletManager { }.value } + /// Identity → Shielded. The Type 21 `ShieldFromIdentity` transition: + /// credits leave `identityId`'s Platform identity balance and enter the + /// bound shielded sub-wallet's pool directly: no transparent Platform + /// address and no payment account in between. The new note lands on + /// `shieldedAccount`'s default Orchard address. + /// + /// `identityId` is a 32-byte identity id managed by `walletId`'s wallet. + /// `identitySigner` is the host-side `KeychainSigner` whose `.handle` + /// signs the transition with the identity's TRANSFER key: the same + /// signer the identity credit-transfer path uses. Borrowed for the + /// duration of the call. + /// + /// The identity is debited `amount` plus the metered fee plus the + /// shielded compute fee (`estimateShieldedFee(kind: .shieldFromIdentity)`). + /// Returns the identity's proven post-debit balance, or `0` when the + /// execution result carried no balance proof. + /// + /// Heavy CPU work (Halo 2 proof + the identity signature) runs on a + /// detached task so the caller's actor isn't blocked. + /// + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the + /// broadcast was accepted but its execution result couldn't be + /// confirmed: the shield may already be on chain, so the caller must + /// NOT retry (a retry would rebuild the bundle and could double-shield; + /// the next sync reconciles the outcome). Like every shield it spends + /// no notes, so nothing is reserved wallet-side. + @discardableResult + public func shieldedShieldFromIdentity( + walletId: Data, + shieldedAccount: UInt32 = 0, + identityId: Data, + amount: UInt64, + identitySigner: KeychainSigner + ) async throws -> UInt64 { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be exactly 32 bytes" + ) + } + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "identityId must be exactly 32 bytes" + ) + } + + let handle = self.handle + let signerHandle = identitySigner.handle + + return try await Task.detached(priority: .userInitiated) { () -> UInt64 in + // Rust writes the proven post-debit identity balance here on + // success; it leaves the slot untouched on every error path, so + // the initializer is the value a failed call keeps (and a call + // that throws never returns it). + var newBalance: UInt64 = 0 + // Guaranteed signer keepalive across the whole FFI call : + // same rationale as `shieldedShield`. + try withExtendedLifetime(identitySigner) { + try walletId.withUnsafeBytes { widRaw in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try identityId.withUnsafeBytes { idRaw in + guard let idPtr = idRaw.baseAddress? + .assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter( + "identityId baseAddress is nil" + ) + } + try platform_wallet_manager_shielded_shield_from_identity( + handle, widPtr, shieldedAccount, idPtr, amount, + signerHandle, &newBalance + ).check() + } + } + } + return newBalance + }.value + } + /// Platform → EXTERNAL Shielded. The Type 15 shield with the note /// assigned to `recipientRaw43` (a third-party raw 43-byte Orchard /// payment address — same shape [`shieldedTransfer`] takes) instead @@ -798,7 +889,7 @@ extension PlatformWalletManager { let signerHandle = addressSigner.handle try await Task.detached(priority: .userInitiated) { - // Guaranteed signer keepalive across the whole FFI call — + // Guaranteed signer keepalive across the whole FFI call : // same rationale as `shieldedShield`. try withExtendedLifetime(addressSigner) { try walletId.withUnsafeBytes { widRaw in diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift index 5b41da8ef2c..887ea555a07 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift @@ -24,6 +24,8 @@ enum ShieldedActivityKindDisplay { case 4: return "Unshielded" case 5: return "Withdrawn" case 6: return "Identity Created" + case 8: return "Shielded from Identity" + // 7 (ShieldedSpend) and any tag this build doesn't know yet. default: return "Shielded Spend" } } @@ -31,7 +33,9 @@ enum ShieldedActivityKindDisplay { /// SF Symbol per kind. static func icon(_ tag: Int) -> String { switch tag { - case 0, 1: return "lock.fill" // Shield / ShieldFromAssetLock + // Shield / ShieldFromAssetLock / ShieldFromIdentity: all three + // are value entering the pool. + case 0, 1, 8: return "lock.fill" case 2: return "arrow.down.circle.fill" // Received case 3: return "arrow.up.circle.fill" // Sent case 4: return "lock.open.fill" // Unshield @@ -278,8 +282,10 @@ struct ShieldedActivityDetailView: View { } } - if entry.kindTag == 6, entry.identityId.count == 32 { - Section("Created Identity") { + // Both identity-bearing kinds carry `identityId`: 6 is the + // identity that was created, 8 the identity that was debited. + if entry.kindTag == 6 || entry.kindTag == 8, entry.identityId.count == 32 { + Section(entry.kindTag == 6 ? "Created Identity" : "Source Identity") { let idHex = entry.identityId.map { String(format: "%02x", $0) }.joined() Text(idHex) .font(.caption.monospaced()) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index d5394d9436a..ae9fbd4d0f2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -2437,6 +2437,7 @@ struct ShieldedActivityStorageDetailView: View { case 5: name = "Withdrawal" case 6: name = "IdentityCreate" case 7: name = "ShieldedSpend" + case 8: name = "ShieldFromIdentity" default: return "Unknown(\(tag))" } return "\(name) (\(tag))" From 164d5d3ff145f12776b71a7d59ddff376159f2f2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 12 Sep 2026 19:19:24 +0700 Subject: [PATCH 03/17] fix(platform): address review on ShieldFromIdentity (clippy, key lookup, pool-read fee) - clippy under -D warnings: drop an unused import in the dpp builder test and import the ShieldFromIdentity variant in the wasm-dpp2 base file - restrict the automatic TRANSFER key lookup to SecurityLevel::CRITICAL, the only level the transition's signing policy accepts, so a non-critical transfer key is skipped instead of failing in sign_external - meter the pool total-balance read: the transformer now folds the GroveDB read cost into the execution context as a precalculated operation, threading block_info and execution_context through, so the identity-paid event charges it - wasm-dpp2 verify_public_key: add the ShieldFromIdentity arm so the JS preflight applies the same purpose, security-level, and enabled checks as native signing - book: the entry-transition section now names all three entry transitions (heading, anchors, and the later fee-flow paragraph) - Kotlin KDoc: cite the Swift counterpart and fix two sentences Co-Authored-By: Claude Fable 5.1 --- book/src/fees/shielded-fees.md | 14 ++++++------ .../dashsdk/ffi/FundingNative.kt | 4 ++-- .../dashsdk/wallet/PlatformWalletManager.kt | 10 +++++---- .../shielded/builder/shield_from_identity.rs | 2 +- .../v0/v0_methods.rs | 5 ++++- .../shield_from_identity/mod.rs | 12 +++++++--- .../transform_into_action/v0/mod.rs | 22 ++++++++++++++++++- packages/rs-unified-sdk-jni/src/funding.rs | 4 ++-- .../base/state_transition.rs | 13 ++++++++++- 9 files changed, 64 insertions(+), 22 deletions(-) diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index 60fe01b84bf..267bc1a008d 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -37,13 +37,13 @@ The fee is derived differently depending on the shielded transition type: | Transition | Fee Formula | Explanation | |---|---|---| -| **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). | +| **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-shieldfromassetlock-and-shieldfromidentity). | | **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) + unshield_address_storage_fee` | `value_balance` (the transition's `unshielding_amount`) is the **gross** amount leaving the pool. The output address receives `unshielding_amount − fee`; validation requires `unshielding_amount ≥ fee`. Unshield also writes the net to the output platform address (`AddBalanceToAddress`), a real storage write priced on top of the base shielded minimum (`unshield_address_storage_fee = 222 × per_byte_rate`, ≈6.08M credits, flat regardless of action count — 222 bytes is the *storage* portion of the ≈6.24M metered address write) so the address write is covered and the proof fee isn't diverted to pay for it. See [Per-Action Storage Fee](#3-per-action-storage-fee). | | **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). | +| **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-and-shieldfromassetlock). | +| **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). | For `ShieldedTransfer`, the client constructs the bundle so that `total_spent − total_output = desired_fee`. The Orchard circuit proves that value is conserved @@ -51,7 +51,7 @@ total_output = desired_fee`. The Orchard circuit proves that value is conserved commits to the `value_balance`. Mutating `value_balance` after signing will cause the binding signature to fail verification. -## Entry-Transition Fees (Shield and ShieldFromAssetLock) +## Entry-Transition Fees (Shield, ShieldFromAssetLock, and ShieldFromIdentity) The *entry* transitions — `Shield` (transparent → shielded), `ShieldFromAssetLock` (Core asset lock → shielded), and, from protocol version 14, `ShieldFromIdentity` @@ -308,7 +308,7 @@ pub struct DriveAbciValidationConstants { 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)). +[Entry-Transition Fees](#entry-transition-fees-shield-shieldfromassetlock-and-shieldfromidentity)). The storage component is not a separate constant — it is derived at runtime from `fee_version.storage.storage_disk_usage_credit_per_byte` and @@ -358,7 +358,7 @@ 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 +The three entry transitions (`Shield`, `ShieldFromAssetLock`, `ShieldFromIdentity`) do not decrement the pool (they add to it), so their fees are booked from the funding side instead: ``` @@ -371,7 +371,7 @@ the standard `PaidFromAddressInputs` event (deducted == booked, no override): me 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). +fee pools); see [Entry-Transition Fees](#entry-transition-fees-shield-shieldfromassetlock-and-shieldfromidentity). ## Cryptographic Binding diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index 95c9b42630c..eb2dbb6fb07 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -143,8 +143,8 @@ internal object FundingNative { /** * Shield from a Platform IDENTITY's balance, Type 21 (bridges * `platform_wallet_manager_shielded_shield_from_identity`). Sibling of - * [shieldedShield] with the identity: not the transparent - * Platform-Payment addresses: as the funding side: [amount] credits move + * [shieldedShield] with the identity, rather than the transparent + * Platform-Payment addresses, as the funding side: [amount] credits move * straight out of [identityId]'s balance into this wallet's own bound * shielded pool ([shieldedAccount]), and the identity is debited [amount] * plus the metered fee plus the shielded compute fee. The identity must diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index fca07578a76..1605e92641c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -1634,16 +1634,18 @@ class PlatformWalletManager( /** * Shield from a Platform IDENTITY's balance (Type 21). Sibling of - * [shieldedShield] with the identity: not the transparent - * Platform-Payment addresses: as the funding side: [amount] credits move + * [shieldedShield] with the identity, rather than the transparent + * Platform-Payment addresses, as the funding side: [amount] credits move * straight out of [identityId]'s balance into this wallet's own bound * shielded pool ([shieldedAccount]), and the identity is debited [amount] * plus the metered fee plus the shielded compute fee * ([ShieldedProver.FeeKind.ShieldFromIdentity]). The identity must be * managed by this wallet. Signed by the Keystore identity signer - * ([signerHandle]) with the identity's TRANSFER key: the same handle + * ([signerHandle]) with the identity's TRANSFER key, the same handle * [org.dashfoundation.dashsdk.credits.IdentityCredits.transferToAddresses] - * threads through. Self-shield only (Rust always targets this wallet's own + * threads through. Swift counterpart: + * `PlatformWalletManager.shieldedShieldFromIdentity` in + * packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift. Self-shield only (Rust always targets this wallet's own * default Orchard address, so there is no recipient parameter). Blocks for * the ~30s Halo 2 proof; the note arrives on the next shielded sync pass. * diff --git a/packages/rs-dpp/src/shielded/builder/shield_from_identity.rs b/packages/rs-dpp/src/shielded/builder/shield_from_identity.rs index 29486d10518..36d6e510b42 100644 --- a/packages/rs-dpp/src/shielded/builder/shield_from_identity.rs +++ b/packages/rs-dpp/src/shielded/builder/shield_from_identity.rs @@ -61,7 +61,7 @@ pub async fn build_shield_from_identity_transition< mod tests { use super::*; use crate::address_funds::AddressWitness; - use crate::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + use crate::identity::accessors::IdentityGettersV0; use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use crate::identity::{KeyType, Purpose, SecurityLevel}; use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver}; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/v0_methods.rs index 7db26902742..257ff3bfcbc 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/v0/v0_methods.rs @@ -56,10 +56,13 @@ impl ShieldFromIdentityTransitionMethodsV0 for ShieldFromIdentityTransitionV0 { ); } } + // Only a CRITICAL transfer key can sign this transition (see + // `identity_signed.rs`), so restrict the automatic lookup to keys + // `sign_external` will accept instead of failing on the first match. None => identity .get_first_public_key_matching( Purpose::TRANSFER, - SecurityLevel::full_range().into(), + [SecurityLevel::CRITICAL].into(), KeyType::all_key_types().into(), true, ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs index 54e3c139ecf..2167e58c1cb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs @@ -27,12 +27,12 @@ impl StateTransitionActionTransformer for ShieldFromIdentityTransition { fn transform_into_action( &self, platform: &PlatformRef, - _block_info: &BlockInfo, + block_info: &BlockInfo, _remaining_address_input_balances: &Option< BTreeMap, >, _validation_mode: ValidationMode, - _execution_context: &mut StateTransitionExecutionContext, + execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { let platform_version = platform.state.current_platform_version()?; @@ -44,7 +44,13 @@ impl StateTransitionActionTransformer for ShieldFromIdentityTransition { .shield_from_identity_state_transition .transform_into_action { - 0 => self.transform_into_action_v0(platform.drive, tx, platform_version), + 0 => self.transform_into_action_v0( + platform.drive, + tx, + block_info, + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "shield from identity transition: transform_into_action".to_string(), known_versions: vec![0], diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs index 028f2ab294b..9432ea137b3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs @@ -1,5 +1,10 @@ use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; use crate::execution::validation::state_transition::state_transitions::shielded_common::read_pool_total_balance; +use dpp::block::block_info::BlockInfo; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; use dpp::version::PlatformVersion; @@ -14,6 +19,8 @@ pub(in crate::execution::validation::state_transition::state_transitions::shield &self, drive: &Drive, transaction: TransactionArg, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result, Error>; } @@ -24,17 +31,30 @@ impl ShieldFromIdentityStateTransitionTransformIntoActionValidationV0 /// The identity signature, nonce, balance floor, structure, and Orchard proof /// have all been checked by the processor before this point. The identity is /// debited exactly `amount`, so unlike `Shield` there is no per-input slack to - /// reallocate; only the pool total is read so the action can bump it. + /// reallocate; only the pool total is read so the action can bump it, and that + /// read is metered into the execution context so the identity pays for it. fn transform_into_action_v0( &self, drive: &Drive, transaction: TransactionArg, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result, Error> { let mut drive_operations = vec![]; let current_total_balance = read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; + let pool_read_fee = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(pool_read_fee)); + let result = ShieldFromIdentityTransitionAction::try_from_transition(self, current_total_balance); diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index ef8af079a26..cbb5470b7e0 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -718,8 +718,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde /// Shield from a Platform IDENTITY's balance, Type 21 (bridges /// `platform_wallet_manager_shielded_shield_from_identity`). /// -/// Sibling of [`Java_..._shieldedShield`] with the identity: not the -/// transparent Platform-Payment addresses: as the funding side: `amount` +/// Sibling of [`Java_..._shieldedShield`] with the identity, rather than the +/// transparent Platform-Payment addresses, as the funding side: `amount` /// credits move straight out of `identity_id`'s balance into this wallet's /// own bound shielded pool (`shielded_account`), and the identity is debited /// `amount` + the metered fee + the shielded compute fee. The identity must diff --git a/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs b/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs index 186e7a98411..4f613c16796 100644 --- a/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs +++ b/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs @@ -18,7 +18,7 @@ use dpp::prelude::{IdentityNonce, UserFeeIncrease}; use dpp::serialization::{PlatformDeserializable, PlatformSerializable, Signable}; use dpp::state_transition::StateTransition::{ Batch, DataContractCreate, DataContractUpdate, IdentityCreditTransfer, - IdentityCreditWithdrawal, IdentityUpdate, MasternodeVote, + IdentityCreditWithdrawal, IdentityUpdate, MasternodeVote, ShieldFromIdentity, }; use dpp::state_transition::batch_transition::BatchTransition; use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; @@ -216,6 +216,17 @@ impl StateTransitionWasm { st.verify_public_key_is_enabled(&public_key.clone().into())?; } + ShieldFromIdentity(st) => { + st.verify_public_key_level_and_purpose( + &public_key.clone().into(), + StateTransitionSigningOptions { + allow_signing_with_any_security_level, + allow_signing_with_any_purpose, + }, + )?; + + st.verify_public_key_is_enabled(&public_key.clone().into())?; + } _ => {} } From 9b85aab2f04aa4f136e1a541c6941d25f0c019e5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 12 Sep 2026 19:58:49 +0700 Subject: [PATCH 04/17] feat(platform)!: add IdentityTopUpFromShieldedPool state transition (shielded pool to identity) Adds state transition type 22, `IdentityTopUpFromShieldedPool`, which spends shielded-pool notes to top up an EXISTING Platform identity's balance. It is the reverse of ShieldFromIdentity (type 21) and the identity-output sibling of Unshield: spend mechanics, nullifier replay protection, pool-paid flat fee, and the sighash binding all follow Unshield, with the identity replacing the transparent address as the output. Consensus: - no platform signature: the Orchard proof authorizes the spend, and the target identity id plus the gross amount are committed into the Orchard binding sighash (new identity_top_up_from_shielded_extra_sighash_data helper), so a valid bundle cannot be re-pointed at another identity - activates at protocol version 14 via IDENTITY_TOP_UP_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION; older validation tables carry inactive rows - fee = compute_shielded_identity_top_up_fee(num_actions), the base shielded minimum plus a flat identity-balance write component priced like Unshield's address write; validated by the shielded minimum-fee gate and carved from the value balance through the existing PaidFromShieldedPool event - stateful checks: pool notes floor, anchor, unspent nullifiers, pool balance, and the identity must already exist (IdentityNotFoundError otherwise) - ops: InsertNullifiers, AddToIdentityBalance(amount - fee), InsertNote per action, UpdateTotalBalance(pool - amount); no system-credit adjustment - proof: strict merged nullifiers plus full identity, returning the existing VerifiedIdentityWithShieldedNullifiers result Clients: dpp builder, rs-sdk IdentityTopUpFromShieldedPool trait, wallet operation and PlatformWallet wrapper with a new IdentityTopUp activity kind (tag 9), wallet FFI platform_wallet_manager_shielded_identity_top_up_from_pool and estimator fee kind 4, wasm-dpp2 wrapper and spec, legacy wasm-dpp arm, Swift shieldedIdentityTopUpFromPool and Kotlin JNI/SDK wrappers, book row. Tests: dpp structure, serialization, JSON and value round trips, builder; drive converter tests; drive-abci tests with real Halo2 spend proofs covering structure and fee-floor rejections, invalid proof, mutated amount, bundle re-pointed at another identity, unknown identity, pre-14 rejection, a full block run asserting credit conservation and the exact identity credit, and a strict prove/verify round trip. Co-Authored-By: Claude Fable 5.1 --- book/src/fees/shielded-fees.md | 1 + .../ui/shielded/ShieldedActivityScreen.kt | 1 + .../dashsdk/ffi/FundingNative.kt | 23 +- .../dashsdk/funding/ShieldedProver.kt | 8 + .../entities/ShieldedActivityEntity.kt | 5 +- .../dashsdk/wallet/PlatformWalletManager.kt | 40 ++ .../identity_top_up_from_shielded_pool.rs | 126 +++++ packages/rs-dpp/src/shielded/builder/mod.rs | 2 + .../compute_minimum_shielded_fee/mod.rs | 17 + .../compute_minimum_shielded_fee/v0/mod.rs | 32 +- packages/rs-dpp/src/shielded/mod.rs | 12 +- packages/rs-dpp/src/shielded/sighash.rs | 38 ++ packages/rs-dpp/src/state_transition/mod.rs | 47 +- .../state_transition_types.rs | 9 +- .../accessors/mod.rs | 85 +++ .../accessors/v0/mod.rs | 44 ++ .../methods/mod.rs | 54 ++ .../methods/v0/mod.rs | 32 ++ .../mod.rs | 173 ++++++ ...ate_transition_estimated_fee_validation.rs | 16 + .../state_transition_like.rs | 36 ++ .../state_transition_validation.rs | 17 + .../v0/mod.rs | 88 +++ .../v0/state_transition_like.rs | 39 ++ .../v0/state_transition_validation.rs | 159 ++++++ .../v0/types.rs | 16 + .../v0/v0_methods.rs | 35 ++ .../v0/version.rs | 9 + .../version.rs | 11 + .../state_transitions/shielded/mod.rs | 1 + .../execution/types/execution_event/mod.rs | 14 + .../traits/address_balances_and_nonces.rs | 2 + .../processor/traits/address_witnesses.rs | 2 + .../traits/addresses_minimum_balance.rs | 2 + .../processor/traits/basic_structure.rs | 30 ++ .../processor/traits/identity_balance.rs | 1 + .../traits/identity_based_signature.rs | 3 + .../processor/traits/identity_nonces.rs | 2 + .../processor/traits/is_allowed.rs | 22 +- .../processor/traits/shielded_proof.rs | 46 +- .../processor/traits/state.rs | 6 + .../identity_top_up_from_shielded_pool/mod.rs | 54 ++ .../tests.rs | 507 ++++++++++++++++++ .../transform_into_action/mod.rs | 1 + .../transform_into_action/v0/mod.rs | 111 ++++ .../state_transition/state_transitions/mod.rs | 2 + .../state_transition/transformer/mod.rs | 5 + .../verify_state_transitions.rs | 3 +- .../prove/prove_state_transition/v0/mod.rs | 25 + .../action_convert_to_operations/mod.rs | 3 + ...ty_top_up_from_shielded_pool_transition.rs | 158 ++++++ .../shielded/mod.rs | 1 + .../src/state_transition_action/mod.rs | 6 + .../identity_top_up_from_shielded_pool/mod.rs | 56 ++ .../transformer.rs | 25 + .../v0/mod.rs | 22 + .../v0/transformer.rs | 26 + .../state_transition_action/shielded/mod.rs | 2 + .../v0/mod.rs | 183 ++++++- .../mod.rs | 1 + .../v1.rs | 5 + .../v2.rs | 5 + .../v3.rs | 5 + .../drive_abci_validation_versions/mod.rs | 2 + .../drive_abci_validation_versions/v1.rs | 9 + .../drive_abci_validation_versions/v10.rs | 9 + .../drive_abci_validation_versions/v2.rs | 9 + .../drive_abci_validation_versions/v3.rs | 9 + .../drive_abci_validation_versions/v4.rs | 9 + .../drive_abci_validation_versions/v5.rs | 9 + .../drive_abci_validation_versions/v6.rs | 9 + .../drive_abci_validation_versions/v7.rs | 9 + .../drive_abci_validation_versions/v8.rs | 9 + .../drive_abci_validation_versions/v9.rs | 9 + .../mod.rs | 1 + .../v1.rs | 1 + .../v2.rs | 1 + .../v3.rs | 1 + .../v4.rs | 1 + .../feature_initial_protocol_versions.rs | 3 + .../rs-platform-version/src/version/v14.rs | 9 + .../rs-platform-wallet-ffi/src/persistence.rs | 6 + .../src/shielded_send.rs | 71 ++- .../src/wallet/platform_wallet.rs | 31 ++ .../src/wallet/shielded/activity.rs | 9 + .../src/wallet/shielded/note_selection.rs | 9 +- .../src/wallet/shielded/operations.rs | 146 ++++- packages/rs-sdk/src/platform/transition.rs | 2 + .../identity_top_up_from_shielded_pool.rs | 84 +++ packages/rs-unified-sdk-jni/src/funding.rs | 56 +- .../Models/PersistentShieldedActivity.swift | 5 +- .../PlatformWalletManagerShieldedSync.swift | 82 ++- .../Core/Views/ShieldedActivityView.swift | 21 +- .../Views/StorageRecordDetailViews.swift | 1 + .../state_transition_factory.rs | 3 +- packages/wasm-dpp2/src/lib.rs | 6 +- ...ty_top_up_from_shielded_pool_transition.rs | 255 +++++++++ packages/wasm-dpp2/src/shielded/mod.rs | 2 + .../base/state_transition.rs | 16 +- ...ityTopUpFromShieldedPoolTransition.spec.ts | 74 +++ 100 files changed, 3458 insertions(+), 42 deletions(-) create mode 100644 packages/rs-dpp/src/shielded/builder/identity_top_up_from_shielded_pool.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/accessors/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/accessors/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/methods/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/methods/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_estimated_fee_validation.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_like.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_validation.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/state_transition_like.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/state_transition_validation.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/types.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/v0_methods.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/version.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/version.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/transform_into_action/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/transform_into_action/v0/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_top_up_from_shielded_pool_transition.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/transformer.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/v0/mod.rs create mode 100644 packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/v0/transformer.rs create mode 100644 packages/rs-sdk/src/platform/transition/identity_top_up_from_shielded_pool.rs create mode 100644 packages/wasm-dpp2/src/shielded/identity_top_up_from_shielded_pool_transition.rs create mode 100644 packages/wasm-dpp2/tests/unit/IdentityTopUpFromShieldedPoolTransition.spec.ts diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index 267bc1a008d..3389a797abf 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -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 priced like `Unshield`'s address write (`identity_balance_storage_fee = 222 x per_byte_rate`). The target identity and gross amount are bound into the Orchard sighash; the identity must already exist; no system-credit adjustment. | For `ShieldedTransfer`, the client constructs the bundle so that `total_spent − total_output = desired_fee`. The Orchard circuit proves that value is conserved diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedActivityScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedActivityScreen.kt index 81fc6f7223b..09311af0bd5 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedActivityScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/shielded/ShieldedActivityScreen.kt @@ -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" } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index eb2dbb6fb07..f8671071dc7 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -26,7 +26,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. @@ -229,6 +231,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 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt index 7ff669b90e5..74b4561ebed 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt @@ -45,6 +45,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), } /** Kick the ~30s Halo 2 proving-key build onto a background thread. Idempotent. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/ShieldedActivityEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/ShieldedActivityEntity.kt index 4bf956d872b..80cfb822674 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/ShieldedActivityEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/ShieldedActivityEntity.kt @@ -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. */ @@ -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). */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 1605e92641c..a3a8df258b0 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -1893,6 +1893,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:)` diff --git a/packages/rs-dpp/src/shielded/builder/identity_top_up_from_shielded_pool.rs b/packages/rs-dpp/src/shielded/builder/identity_top_up_from_shielded_pool.rs new file mode 100644 index 00000000000..e3b587b52c0 --- /dev/null +++ b/packages/rs-dpp/src/shielded/builder/identity_top_up_from_shielded_pool.rs @@ -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( + spends: Vec, + 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}" + ); + } +} diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index c423d8d1d72..04a4bc2f2bc 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -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; @@ -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; diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs index a3a7bbab57c..b8c8a9cb40a 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs @@ -5,6 +5,7 @@ use crate::ProtocolError; use platform_version::version::PlatformVersion; use v0::compute_minimum_shielded_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; @@ -83,6 +84,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 { + 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. /// diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs index 8066bca099a..7d007825771 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs @@ -1,6 +1,7 @@ use crate::fee::Credits; use crate::shielded::{ - SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES, SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES, + SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES, SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES, + SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES, }; use crate::ProtocolError; use platform_version::version::PlatformVersion; @@ -222,6 +223,35 @@ pub fn compute_shielded_unshield_fee_v0( /// /// All arithmetic is checked: an overflow (only reachable via pathological fee constants or key /// counts) surfaces as `ProtocolError::Overflow` instead of silently wrapping. +/// Flat fee for `IdentityTopUpFromShieldedPool`: the base shielded minimum plus the flat +/// identity-balance write component, mirroring `compute_shielded_unshield_fee_v0`'s address +/// write component (the top-up writes one balance element, priced like an address write). +pub fn compute_shielded_identity_top_up_fee_v0( + num_actions: usize, + platform_version: &PlatformVersion, +) -> Result { + let storage = &platform_version.fee_version.storage; + + let base_fee = compute_minimum_shielded_fee_v0(num_actions, platform_version)?; + + let per_byte_rate = storage + .storage_disk_usage_credit_per_byte + .checked_add(storage.storage_processing_credit_per_byte) + .ok_or(ProtocolError::Overflow( + "shielded storage per-byte rate overflow", + ))?; + let identity_balance_storage_fee = SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES + .checked_mul(per_byte_rate) + .ok_or(ProtocolError::Overflow( + "shielded identity top up balance storage fee overflow", + ))?; + base_fee + .checked_add(identity_balance_storage_fee) + .ok_or(ProtocolError::Overflow( + "shielded identity top up fee overflow", + )) +} + pub fn compute_shielded_identity_create_fee_v0( num_actions: usize, num_keys: usize, diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index edf4a0a7de2..8c95a7ff75b 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -15,8 +15,8 @@ use serde::{Deserialize, Serialize}; // module and the function share a name but live in different namespaces). pub use compute_minimum_shielded_fee::{ compute_minimum_shielded_fee, compute_shielded_identity_create_fee, - compute_shielded_unshield_fee, compute_shielded_verification_fee, - compute_shielded_withdrawal_fee, + compute_shielded_identity_top_up_fee, compute_shielded_unshield_fee, + compute_shielded_verification_fee, compute_shielded_withdrawal_fee, }; // Re-exported so the public paths stay `dpp::shielded::` after moving the sighash preimage @@ -24,7 +24,9 @@ pub use compute_minimum_shielded_fee::{ // re-exported (callers use the wrappers; byte-layout tests use the `_v0` impls). pub use sighash::{ compute_platform_sighash, identity_create_from_shielded_extra_sighash_data, - identity_create_from_shielded_extra_sighash_data_v0, shielded_withdrawal_extra_sighash_data, + identity_create_from_shielded_extra_sighash_data_v0, + identity_top_up_from_shielded_extra_sighash_data, + identity_top_up_from_shielded_extra_sighash_data_v0, shielded_withdrawal_extra_sighash_data, shielded_withdrawal_extra_sighash_data_v0, unshield_extra_sighash_data, unshield_extra_sighash_data_v0, }; @@ -76,6 +78,10 @@ pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100; /// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`]. pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; +/// Flat storage component charged by `IdentityTopUpFromShieldedPool` for the identity balance +/// write, priced like the `Unshield` address write so the pool-paid flat fee covers it. +pub const SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES: u64 = 222; + /// Common Orchard bundle parameters shared across all shielded transition types. /// /// Groups the fields that every shielded transition carries identically: diff --git a/packages/rs-dpp/src/shielded/sighash.rs b/packages/rs-dpp/src/shielded/sighash.rs index 5856a9e5b33..fc8f4ea056d 100644 --- a/packages/rs-dpp/src/shielded/sighash.rs +++ b/packages/rs-dpp/src/shielded/sighash.rs @@ -138,6 +138,44 @@ pub fn unshield_extra_sighash_data_v0(output_address: &[u8], unshielding_amount: data } +/// Builds the transparent `extra_data` bound into an `IdentityTopUpFromShieldedPool`'s platform +/// sighash, with the byte layout `identity_id (32) || top_up_amount (u64 LE)`. +/// +/// Like `Unshield`, the transition carries no platform signature, so the state-determining +/// transparent fields (which identity is credited, and the gross amount leaving the pool) must be +/// committed into the Orchard binding sighash; otherwise a relayer could take a valid spend bundle +/// and re-point it at a different identity. The client builder and the consensus verifier both +/// call this single function. +pub fn identity_top_up_from_shielded_extra_sighash_data( + identity_id: &[u8; 32], + top_up_amount: u64, + platform_version: &PlatformVersion, +) -> Result, ProtocolError> { + match platform_version.dpp.methods.shielded_extra_sighash_data { + 0 => Ok(identity_top_up_from_shielded_extra_sighash_data_v0( + identity_id, + top_up_amount, + )), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "identity_top_up_from_shielded_extra_sighash_data".to_string(), + known_versions: vec![0], + received: version, + }), + } +} + +/// v0 byte layout of [`identity_top_up_from_shielded_extra_sighash_data`]. Frozen: never mutate; +/// a layout change requires a new `_v1` + version bump. +pub fn identity_top_up_from_shielded_extra_sighash_data_v0( + identity_id: &[u8; 32], + top_up_amount: u64, +) -> Vec { + let mut data = Vec::with_capacity(32 + 8); + data.extend_from_slice(identity_id); + data.extend_from_slice(&top_up_amount.to_le_bytes()); + data +} + /// Builds the transparent `extra_data` bound into an `IdentityCreateFromShieldedPool`'s platform /// sighash, with the byte layout /// `identity_id (32) || denomination (u64 LE) diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index f6a60e2e97d..4ee39893cda 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -125,6 +125,9 @@ use crate::state_transition::identity_credit_transfer_transition::{ use crate::state_transition::identity_credit_withdrawal_transition::{ IdentityCreditWithdrawalTransition, IdentityCreditWithdrawalTransitionSignable, }; +use crate::state_transition::identity_top_up_from_shielded_pool_transition::{ + IdentityTopUpFromShieldedPoolTransition, IdentityTopUpFromShieldedPoolTransitionSignable, +}; use crate::state_transition::identity_topup_from_addresses_transition::{ IdentityTopUpFromAddressesTransition, IdentityTopUpFromAddressesTransitionSignable, }; @@ -181,6 +184,7 @@ macro_rules! call_method { StateTransition::Shield(st) => st.$method($args), StateTransition::ShieldedTransfer(st) => st.$method($args), StateTransition::Unshield(st) => st.$method($args), + StateTransition::IdentityTopUpFromShieldedPool(st) => st.$method($args), StateTransition::ShieldFromAssetLock(st) => st.$method($args), StateTransition::ShieldedWithdrawal(st) => st.$method($args), StateTransition::IdentityCreateFromShieldedPool(st) => st.$method($args), @@ -207,6 +211,7 @@ macro_rules! call_method { StateTransition::Shield(st) => st.$method(), StateTransition::ShieldedTransfer(st) => st.$method(), StateTransition::Unshield(st) => st.$method(), + StateTransition::IdentityTopUpFromShieldedPool(st) => st.$method(), StateTransition::ShieldFromAssetLock(st) => st.$method(), StateTransition::ShieldedWithdrawal(st) => st.$method(), StateTransition::IdentityCreateFromShieldedPool(st) => st.$method(), @@ -236,6 +241,7 @@ macro_rules! call_getter_method_identity_signed { StateTransition::Shield(_) => None, StateTransition::ShieldedTransfer(_) => None, StateTransition::Unshield(_) => None, + StateTransition::IdentityTopUpFromShieldedPool(_) => None, StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, StateTransition::IdentityCreateFromShieldedPool(_) => None, @@ -262,6 +268,7 @@ macro_rules! call_getter_method_identity_signed { StateTransition::Shield(_) => None, StateTransition::ShieldedTransfer(_) => None, StateTransition::Unshield(_) => None, + StateTransition::IdentityTopUpFromShieldedPool(_) => None, StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, StateTransition::IdentityCreateFromShieldedPool(_) => None, @@ -291,6 +298,7 @@ macro_rules! call_method_identity_signed { StateTransition::Shield(_) => {} StateTransition::ShieldedTransfer(_) => {} StateTransition::Unshield(_) => {} + StateTransition::IdentityTopUpFromShieldedPool(_) => {} StateTransition::ShieldFromAssetLock(_) => {} StateTransition::ShieldedWithdrawal(_) => {} StateTransition::IdentityCreateFromShieldedPool(_) => {} @@ -317,6 +325,7 @@ macro_rules! call_method_identity_signed { StateTransition::Shield(_) => {} StateTransition::ShieldedTransfer(_) => {} StateTransition::Unshield(_) => {} + StateTransition::IdentityTopUpFromShieldedPool(_) => {} StateTransition::ShieldFromAssetLock(_) => {} StateTransition::ShieldedWithdrawal(_) => {} StateTransition::IdentityCreateFromShieldedPool(_) => {} @@ -367,6 +376,9 @@ macro_rules! call_errorable_method_identity_signed { StateTransition::Unshield(_) => Err(ProtocolError::CorruptedCodeExecution( "unshield transition can not be called for identity signing".to_string(), )), + StateTransition::IdentityTopUpFromShieldedPool(_) => Err(ProtocolError::CorruptedCodeExecution( + "identity top up from shielded pool transition can not be called for identity signing".to_string(), + )), StateTransition::ShieldFromAssetLock(_) => Err(ProtocolError::CorruptedCodeExecution( "shield from asset lock transition can not be called for identity signing".to_string(), )), @@ -419,6 +431,9 @@ macro_rules! call_errorable_method_identity_signed { StateTransition::Unshield(_) => Err(ProtocolError::CorruptedCodeExecution( "unshield transition can not be called for identity signing".to_string(), )), + StateTransition::IdentityTopUpFromShieldedPool(_) => Err(ProtocolError::CorruptedCodeExecution( + "identity top up from shielded pool transition can not be called for identity signing".to_string(), + )), StateTransition::ShieldFromAssetLock(_) => Err(ProtocolError::CorruptedCodeExecution( "shield from asset lock transition can not be called for identity signing".to_string(), )), @@ -493,6 +508,7 @@ pub enum StateTransition { ShieldedWithdrawal(ShieldedWithdrawalTransition), IdentityCreateFromShieldedPool(IdentityCreateFromShieldedPoolTransition), ShieldFromIdentity(ShieldFromIdentityTransition), + IdentityTopUpFromShieldedPool(IdentityTopUpFromShieldedPoolTransition), } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] @@ -782,6 +798,15 @@ mod json_convertible_tests { ); } + #[test] + fn umbrella_identity_top_up_from_shielded_pool() { + let inner = crate::state_transition::identity_top_up_from_shielded_pool_transition::json_convertible_tests::fixture(); + assert_umbrella_round_trip( + StateTransition::IdentityTopUpFromShieldedPool(inner), + "identityTopUpFromShieldedPool", + ); + } + #[test] fn umbrella_shield_from_identity() { let inner = crate::state_transition::shield_from_identity_transition::json_convertible_tests::fixture(); @@ -886,7 +911,8 @@ impl StateTransition { | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) => 12..=LATEST_VERSION, - StateTransition::ShieldFromIdentity(_) => 14..=LATEST_VERSION, + StateTransition::ShieldFromIdentity(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) => 14..=LATEST_VERSION, } } @@ -901,6 +927,7 @@ impl StateTransition { | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) ) } @@ -1006,6 +1033,7 @@ impl StateTransition { Self::Shield(_) => "Shield".to_string(), Self::ShieldedTransfer(_) => "ShieldedTransfer".to_string(), Self::Unshield(_) => "Unshield".to_string(), + Self::IdentityTopUpFromShieldedPool(_) => "IdentityTopUpFromShieldedPool".to_string(), Self::ShieldFromAssetLock(_) => "ShieldFromAssetLock".to_string(), Self::ShieldedWithdrawal(_) => "ShieldedWithdrawal".to_string(), Self::IdentityCreateFromShieldedPool(_) => "IdentityCreateFromShieldedPool".to_string(), @@ -1034,6 +1062,7 @@ impl StateTransition { StateTransition::Shield(_) => None, StateTransition::ShieldedTransfer(_) => None, StateTransition::Unshield(_) => None, + StateTransition::IdentityTopUpFromShieldedPool(_) => None, StateTransition::ShieldFromAssetLock(st) => Some(st.signature()), StateTransition::ShieldedWithdrawal(_) => None, StateTransition::IdentityCreateFromShieldedPool(_) => None, @@ -1051,6 +1080,7 @@ impl StateTransition { StateTransition::Shield(st) => st.inputs().len() as u16, StateTransition::ShieldedTransfer(_) => 0, StateTransition::Unshield(_) => 0, + StateTransition::IdentityTopUpFromShieldedPool(_) => 0, StateTransition::ShieldFromAssetLock(_) => 0, StateTransition::ShieldedWithdrawal(_) => 0, StateTransition::IdentityCreateFromShieldedPool(_) => 0, @@ -1081,6 +1111,7 @@ impl StateTransition { StateTransition::MasternodeVote(_) => 0, StateTransition::ShieldedTransfer(_) => 0, StateTransition::Unshield(_) => 0, + StateTransition::IdentityTopUpFromShieldedPool(_) => 0, StateTransition::ShieldedWithdrawal(_) => 0, StateTransition::IdentityCreateFromShieldedPool(_) => 0, StateTransition::ShieldFromIdentity(st) => st.user_fee_increase(), @@ -1149,6 +1180,7 @@ impl StateTransition { StateTransition::Shield(_) => None, StateTransition::ShieldedTransfer(_) => None, StateTransition::Unshield(_) => None, + StateTransition::IdentityTopUpFromShieldedPool(_) => None, StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, StateTransition::IdentityCreateFromShieldedPool(_) => None, @@ -1177,6 +1209,7 @@ impl StateTransition { StateTransition::Shield(st) => Some(st.inputs()), StateTransition::ShieldedTransfer(_) => None, StateTransition::Unshield(_) => None, + StateTransition::IdentityTopUpFromShieldedPool(_) => None, StateTransition::ShieldFromAssetLock(_) => None, StateTransition::ShieldedWithdrawal(_) => None, StateTransition::IdentityCreateFromShieldedPool(_) => None, @@ -1244,7 +1277,8 @@ impl StateTransition { | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) - | StateTransition::IdentityCreateFromShieldedPool(_) => false, + | StateTransition::IdentityCreateFromShieldedPool(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) => false, StateTransition::ShieldFromIdentity(st) => { st.set_signature(signature); true @@ -1300,6 +1334,7 @@ impl StateTransition { StateTransition::MasternodeVote(_) => {} StateTransition::ShieldedTransfer(_) => {} StateTransition::Unshield(_) => {} + StateTransition::IdentityTopUpFromShieldedPool(_) => {} StateTransition::ShieldedWithdrawal(_) => {} StateTransition::IdentityCreateFromShieldedPool(_) => {} StateTransition::ShieldFromIdentity(st) => st.set_user_fee_increase(user_fee_increase), @@ -1466,6 +1501,11 @@ impl StateTransition { "unshield transition can not be called for identity signing".to_string(), )) } + StateTransition::IdentityTopUpFromShieldedPool(_) => { + return Err(ProtocolError::CorruptedCodeExecution( + "identity top up from shielded pool transition can not be called for identity signing".to_string(), + )) + } StateTransition::ShieldFromAssetLock(_) => { return Err(ProtocolError::CorruptedCodeExecution( "shield from asset lock transition can not be called for identity signing" @@ -1988,6 +2028,9 @@ impl StateTransitionStructureValidation for StateTransition { StateTransition::Unshield(transition) => { transition.validate_structure(platform_version) } + StateTransition::IdentityTopUpFromShieldedPool(transition) => { + transition.validate_structure(platform_version) + } StateTransition::ShieldFromAssetLock(transition) => { transition.validate_structure(platform_version) } diff --git a/packages/rs-dpp/src/state_transition/state_transition_types.rs b/packages/rs-dpp/src/state_transition/state_transition_types.rs index d066feb38ed..647ad3a0e90 100644 --- a/packages/rs-dpp/src/state_transition/state_transition_types.rs +++ b/packages/rs-dpp/src/state_transition/state_transition_types.rs @@ -42,6 +42,7 @@ pub enum StateTransitionType { ShieldedWithdrawal = 19, IdentityCreateFromShieldedPool = 20, ShieldFromIdentity = 21, + IdentityTopUpFromShieldedPool = 22, } impl std::fmt::Display for StateTransitionType { @@ -128,6 +129,10 @@ mod tests { StateTransitionType::ShieldFromIdentity, "ShieldFromIdentity", ), + ( + StateTransitionType::IdentityTopUpFromShieldedPool, + "IdentityTopUpFromShieldedPool", + ), ]; for (variant, expected) in cases { assert_eq!( @@ -164,6 +169,7 @@ mod tests { (19, StateTransitionType::ShieldedWithdrawal), (20, StateTransitionType::IdentityCreateFromShieldedPool), (21, StateTransitionType::ShieldFromIdentity), + (22, StateTransitionType::IdentityTopUpFromShieldedPool), ]; for (val, expected) in pairs { let result = StateTransitionType::try_from(val).unwrap(); @@ -173,7 +179,7 @@ mod tests { #[test] fn test_try_from_u8_invalid() { - assert!(StateTransitionType::try_from(22u8).is_err()); + assert!(StateTransitionType::try_from(23u8).is_err()); assert!(StateTransitionType::try_from(255u8).is_err()); } @@ -202,6 +208,7 @@ mod tests { StateTransitionType::ShieldedWithdrawal, StateTransitionType::IdentityCreateFromShieldedPool, StateTransitionType::ShieldFromIdentity, + StateTransitionType::IdentityTopUpFromShieldedPool, ]; for variant in all_variants { let val: u8 = variant.into(); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/accessors/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/accessors/mod.rs new file mode 100644 index 00000000000..d093baeeb54 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/accessors/mod.rs @@ -0,0 +1,85 @@ +mod v0; + +pub use v0::*; + +use crate::shielded::SerializedAction; +use crate::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; +use platform_value::Identifier; + +impl IdentityTopUpFromShieldedPoolTransitionAccessorsV0 + for IdentityTopUpFromShieldedPoolTransition +{ + fn identity_id(&self) -> Identifier { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => v0.identity_id, + } + } + + fn set_identity_id(&mut self, identity_id: Identifier) { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => v0.identity_id = identity_id, + } + } + + fn actions(&self) -> &[SerializedAction] { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => &v0.actions, + } + } + + fn set_actions(&mut self, actions: Vec) { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => v0.actions = actions, + } + } + + fn top_up_amount(&self) -> u64 { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => v0.top_up_amount, + } + } + + fn set_top_up_amount(&mut self, top_up_amount: u64) { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => v0.top_up_amount = top_up_amount, + } + } + + fn anchor(&self) -> [u8; 32] { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => v0.anchor, + } + } + + fn set_anchor(&mut self, anchor: [u8; 32]) { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => v0.anchor = anchor, + } + } + + fn proof(&self) -> &[u8] { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => &v0.proof, + } + } + + fn set_proof(&mut self, proof: Vec) { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => v0.proof = proof, + } + } + + fn binding_signature(&self) -> [u8; 64] { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => v0.binding_signature, + } + } + + fn set_binding_signature(&mut self, binding_signature: [u8; 64]) { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => { + v0.binding_signature = binding_signature + } + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/accessors/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/accessors/v0/mod.rs new file mode 100644 index 00000000000..f8ab58ccb91 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/accessors/v0/mod.rs @@ -0,0 +1,44 @@ +use crate::shielded::SerializedAction; +use platform_value::Identifier; + +/// Accessors for the fields of an `IdentityTopUpFromShieldedPoolTransition`. +pub trait IdentityTopUpFromShieldedPoolTransitionAccessorsV0 { + /// The identity whose balance receives the top-up. + fn identity_id(&self) -> Identifier; + /// Set the target identity. + fn set_identity_id(&mut self, identity_id: Identifier); + + /// Get the serialized Orchard actions (spend/output pairs). + fn actions(&self) -> &[SerializedAction]; + /// Replace the serialized Orchard actions. + fn set_actions(&mut self, actions: Vec); + + /// Gross credits leaving the pool (the bundle's value balance). The identity + /// receives this minus the flat shielded top-up fee. + fn top_up_amount(&self) -> u64; + /// Set the gross top-up amount. + fn set_top_up_amount(&mut self, top_up_amount: u64); + + /// Get the Orchard anchor (Sinsemilla root of the note commitment tree). + fn anchor(&self) -> [u8; 32]; + /// Set the Orchard anchor. + fn set_anchor(&mut self, anchor: [u8; 32]); + + /// Get the Halo2 proof bytes. + fn proof(&self) -> &[u8]; + /// Set the Halo2 proof bytes. + fn set_proof(&mut self, proof: Vec); + + /// Get the RedPallas binding signature. + fn binding_signature(&self) -> [u8; 64]; + /// Set the RedPallas binding signature. + fn set_binding_signature(&mut self, binding_signature: [u8; 64]); + + /// Extract nullifier bytes from each action. + fn nullifiers>(&self) -> Vec { + self.actions() + .iter() + .map(|a| T::from(a.nullifier)) + .collect() + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/methods/mod.rs new file mode 100644 index 00000000000..2169893546b --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/methods/mod.rs @@ -0,0 +1,54 @@ +mod v0; + +pub use v0::*; + +#[cfg(feature = "state-transition-signing")] +use crate::shielded::SerializedAction; +use crate::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; +#[cfg(feature = "state-transition-signing")] +use crate::{ + state_transition::{ + identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0, + StateTransition, + }, + ProtocolError, +}; +#[cfg(feature = "state-transition-signing")] +use platform_value::Identifier; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +impl IdentityTopUpFromShieldedPoolTransitionMethodsV0 for IdentityTopUpFromShieldedPoolTransition { + #[cfg(feature = "state-transition-signing")] + fn try_from_bundle( + identity_id: Identifier, + actions: Vec, + top_up_amount: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .state_transition_serialization_versions + .identity_top_up_from_shielded_pool_state_transition + .default_current_version + { + 0 => IdentityTopUpFromShieldedPoolTransitionV0::try_from_bundle( + identity_id, + actions, + top_up_amount, + anchor, + proof, + binding_signature, + platform_version, + ), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "IdentityTopUpFromShieldedPoolTransition::try_from_bundle".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/methods/v0/mod.rs new file mode 100644 index 00000000000..65eb07d7a34 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/methods/v0/mod.rs @@ -0,0 +1,32 @@ +#[cfg(feature = "state-transition-signing")] +use crate::shielded::SerializedAction; +use crate::state_transition::StateTransitionType; +#[cfg(feature = "state-transition-signing")] +use crate::{state_transition::StateTransition, ProtocolError}; +#[cfg(feature = "state-transition-signing")] +use platform_value::Identifier; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +pub trait IdentityTopUpFromShieldedPoolTransitionMethodsV0 { + /// Build the transition from an already proven and signed Orchard spend bundle. + /// The bundle's binding signature must have been computed over the platform + /// sighash that binds `identity_id` and `top_up_amount` (see + /// `identity_top_up_from_shielded_extra_sighash_data`). + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + fn try_from_bundle( + identity_id: Identifier, + actions: Vec, + top_up_amount: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + platform_version: &PlatformVersion, + ) -> Result; + + /// Get State Transition Type + fn get_type() -> StateTransitionType { + StateTransitionType::IdentityTopUpFromShieldedPool + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/mod.rs new file mode 100644 index 00000000000..a15eed53493 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/mod.rs @@ -0,0 +1,173 @@ +pub mod accessors; +pub mod methods; +mod state_transition_estimated_fee_validation; +mod state_transition_like; +mod state_transition_validation; +pub mod v0; +mod version; + +use crate::state_transition::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0; +use crate::state_transition::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0Signable; +use crate::state_transition::StateTransitionFieldTypes; + +pub type IdentityTopUpFromShieldedPoolTransitionLatest = IdentityTopUpFromShieldedPoolTransitionV0; + +use crate::identity::state_transition::OptionallyAssetLockProved; +#[cfg(feature = "json-conversion")] +use crate::serialization::JsonConvertible; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use derive_more::From; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize, PlatformSignable}; +use platform_versioning::PlatformVersioned; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +/// Spends shielded-pool notes to top up an EXISTING Platform identity's balance. +/// +/// The spend side is exactly `Unshield` (Orchard spend bundle, nullifiers, anchor, +/// pool-paid flat fee); the output side credits the identity instead of a platform +/// address. Like `Unshield` there is no platform signature: authorization is the +/// Orchard proof, and the target identity and gross amount are bound into the +/// Orchard sighash so a relayer cannot redirect the top-up. +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformDeserialize, + PlatformSerialize, + PlatformSignable, + PlatformVersioned, + From, + PartialEq, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +#[cfg_attr( + all(feature = "json-conversion", feature = "serde-conversion"), + derive(JsonConvertible) +)] +#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] +#[platform_serialize(unversioned)] //versioned directly, no need to use platform_version +#[platform_version_path_bounds( + "dpp.state_transition_serialization_versions.identity_top_up_from_shielded_pool_state_transition" +)] +pub enum IdentityTopUpFromShieldedPoolTransition { + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(IdentityTopUpFromShieldedPoolTransitionV0), +} + +impl OptionallyAssetLockProved for IdentityTopUpFromShieldedPoolTransition {} + +impl StateTransitionFieldTypes for IdentityTopUpFromShieldedPoolTransition { + fn signature_property_paths() -> Vec<&'static str> { + vec![] + } + + fn identifiers_property_paths() -> Vec<&'static str> { + vec![] + } + + fn binary_property_paths() -> Vec<&'static str> { + vec![] + } +} + +#[cfg(all( + test, + feature = "json-conversion", + feature = "value-conversion", + feature = "serde-conversion" +))] +pub(crate) mod json_convertible_tests { + use super::*; + use crate::shielded::SerializedAction; + use platform_value::{platform_value, Bytes32, Identifier}; + use serde_json::json; + + fn fixture_action() -> SerializedAction { + SerializedAction { + nullifier: [0x11; 32], + rk: [0x22; 32], + cmx: [0x33; 32], + encrypted_note: vec![0x44; 216], + cv_net: [0x55; 32], + spend_auth_sig: [0x66; 64], + } + } + + pub(crate) fn fixture() -> IdentityTopUpFromShieldedPoolTransition { + IdentityTopUpFromShieldedPoolTransition::V0(IdentityTopUpFromShieldedPoolTransitionV0 { + identity_id: Identifier::new([0xaa; 32]), + actions: vec![fixture_action()], + top_up_amount: 250_000, + anchor: [0x77; 32], + proof: vec![0x88; 192], + binding_signature: [0x99; 64], + }) + } + + #[test] + fn json_round_trip_with_full_wire_shape() { + use crate::serialization::JsonConvertible; + let original = fixture(); + let json = original.to_json().expect("to_json"); + assert_eq!( + json, + json!({ + "$formatVersion": "0", + "identityId": "CVDFLCAjXhVWiPXH9nTCTpCgVzmDVoiPzNJYuccr1dqB", + "actions": [{ + "nullifier": "ERERERERERERERERERERERERERERERERERERERERERE=", + "rk": "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=", + "cmx": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "encryptedNote": "RERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERE", + "cvNet": "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=", + "spendAuthSig": "ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZg==", + }], + "topUpAmount": 250_000, + "anchor": "d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3c=", + "proof": "iIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI", + "bindingSignature": "mZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmQ==", + }) + ); + let recovered = + IdentityTopUpFromShieldedPoolTransition::from_json(json).expect("from_json"); + assert_eq!(original, recovered); + } + + #[test] + fn value_round_trip_with_full_wire_shape() { + use crate::serialization::ValueConvertible; + let original = fixture(); + let value = original.to_object().expect("to_object"); + assert_eq!( + value, + platform_value!({ + "$formatVersion": "0", + "identityId": Identifier::new([0xaa; 32]), + "actions": [{ + "nullifier": Bytes32::new([0x11; 32]), + "rk": Bytes32::new([0x22; 32]), + "cmx": Bytes32::new([0x33; 32]), + "encryptedNote": platform_value::Value::Bytes(vec![0x44; 216]), + "cvNet": Bytes32::new([0x55; 32]), + "spendAuthSig": platform_value::Value::Bytes(vec![0x66; 64]), + }], + "topUpAmount": 250_000u64, + "anchor": Bytes32::new([0x77; 32]), + "proof": platform_value::Value::Bytes(vec![0x88; 192]), + "bindingSignature": platform_value::Value::Bytes(vec![0x99; 64]), + }) + ); + let recovered = + IdentityTopUpFromShieldedPoolTransition::from_object(value).expect("from_object"); + assert_eq!(original, recovered); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_estimated_fee_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_estimated_fee_validation.rs new file mode 100644 index 00000000000..f2cc05f18ef --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_estimated_fee_validation.rs @@ -0,0 +1,16 @@ +use crate::fee::Credits; +use crate::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; +use crate::state_transition::StateTransitionEstimatedFeeValidation; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +impl StateTransitionEstimatedFeeValidation for IdentityTopUpFromShieldedPoolTransition { + /// Pool-paid: the fee is carved from the bundle's value balance and enforced by + /// the shielded minimum-fee validation, not by an identity balance floor. + fn calculate_min_required_fee( + &self, + _platform_version: &PlatformVersion, + ) -> Result { + Ok(0) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_like.rs new file mode 100644 index 00000000000..8a83d187ce1 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_like.rs @@ -0,0 +1,36 @@ +use crate::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; +use crate::state_transition::{StateTransitionLike, StateTransitionType}; +use crate::version::FeatureVersion; +use platform_value::Identifier; + +impl StateTransitionLike for IdentityTopUpFromShieldedPoolTransition { + fn modified_data_ids(&self) -> Vec { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(transition) => { + transition.modified_data_ids() + } + } + } + + fn state_transition_protocol_version(&self) -> FeatureVersion { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(_) => 0, + } + } + + fn state_transition_type(&self) -> StateTransitionType { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(transition) => { + transition.state_transition_type() + } + } + } + + fn unique_identifiers(&self) -> Vec { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(transition) => { + transition.unique_identifiers() + } + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_validation.rs new file mode 100644 index 00000000000..3825a2c5cac --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/state_transition_validation.rs @@ -0,0 +1,17 @@ +use crate::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; +use crate::state_transition::StateTransitionStructureValidation; +use crate::validation::SimpleConsensusValidationResult; +use platform_version::version::PlatformVersion; + +impl StateTransitionStructureValidation for IdentityTopUpFromShieldedPoolTransition { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => { + v0.validate_structure(platform_version) + } + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/mod.rs new file mode 100644 index 00000000000..9e3c39115b8 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/mod.rs @@ -0,0 +1,88 @@ +mod state_transition_like; +mod state_transition_validation; +mod types; +pub(super) mod v0_methods; +mod version; + +use crate::prelude::Identifier; +use crate::shielded::SerializedAction; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize, PlatformSignable}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +/// Shielded pool to an existing identity's balance, version 0. +/// +/// No platform signature: the Orchard proof and spend-auth signatures authorize +/// the spend, and `identity_id` plus `top_up_amount` are committed into the +/// Orchard binding sighash so the transition cannot be re-pointed. +#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)] +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformSerialize, + PlatformDeserialize, + PlatformSignable, + PartialEq, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +#[platform_serialize(unversioned)] +pub struct IdentityTopUpFromShieldedPoolTransitionV0 { + /// The existing identity whose balance receives the top-up + pub identity_id: Identifier, + /// Orchard actions (spend-output pairs) + pub actions: Vec, + /// Gross credits leaving the pool (the bundle's value balance). The identity + /// is credited `top_up_amount - fee`. + pub top_up_amount: u64, + /// Sinsemilla root of the note commitment tree (Orchard Anchor) + pub anchor: [u8; 32], + /// Halo2 proof bytes + pub proof: Vec, + /// RedPallas binding signature + pub binding_signature: [u8; 64], +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::serialization::{PlatformDeserializable, PlatformSerializable}; + use std::fmt::Debug; + + fn test_round_trip( + transition: T, + ) where + ::Error: std::fmt::Debug, + { + let serialized = T::serialize_to_bytes(&transition).expect("expected to serialize"); + let deserialized = + T::deserialize_from_bytes(serialized.as_slice()).expect("expected to deserialize"); + assert_eq!(transition, deserialized); + } + + #[test] + fn test_identity_top_up_from_shielded_pool_transition_v0_serialization_round_trip() { + test_round_trip(IdentityTopUpFromShieldedPoolTransitionV0 { + identity_id: Identifier::from([7u8; 32]), + actions: vec![SerializedAction { + nullifier: [1u8; 32], + rk: [2u8; 32], + cmx: [3u8; 32], + encrypted_note: vec![4u8; 216], + cv_net: [5u8; 32], + spend_auth_sig: [6u8; 64], + }], + top_up_amount: 1000u64, + anchor: [7u8; 32], + proof: vec![8u8; 100], + binding_signature: [9u8; 64], + }); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/state_transition_like.rs new file mode 100644 index 00000000000..c35734406de --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/state_transition_like.rs @@ -0,0 +1,39 @@ +use crate::state_transition::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0; +use crate::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; +use crate::{ + prelude::Identifier, + state_transition::{StateTransitionLike, StateTransitionType}, +}; + +use crate::state_transition::StateTransition; +use crate::version::FeatureVersion; + +impl From for StateTransition { + fn from(value: IdentityTopUpFromShieldedPoolTransitionV0) -> Self { + let transition: IdentityTopUpFromShieldedPoolTransition = value.into(); + transition.into() + } +} + +impl StateTransitionLike for IdentityTopUpFromShieldedPoolTransitionV0 { + fn state_transition_protocol_version(&self) -> FeatureVersion { + 0 + } + + fn state_transition_type(&self) -> StateTransitionType { + StateTransitionType::IdentityTopUpFromShieldedPool + } + + fn modified_data_ids(&self) -> Vec { + vec![self.identity_id] + } + + /// Unique by spent nullifier, exactly as `Unshield`: two transitions spending the + /// same note can never both be valid. + fn unique_identifiers(&self) -> Vec { + self.actions + .iter() + .map(|action| hex::encode(action.nullifier)) + .collect() + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/state_transition_validation.rs new file mode 100644 index 00000000000..d42f1ef2596 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/state_transition_validation.rs @@ -0,0 +1,159 @@ +use crate::consensus::basic::state_transition::ShieldedInvalidValueBalanceError; +use crate::consensus::basic::BasicError; +use crate::state_transition::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0; +use crate::state_transition::state_transitions::shielded::common_validation::{ + validate_actions_count, validate_anchor_not_zero, validate_encrypted_note_sizes, + validate_proof_not_empty, +}; +use crate::state_transition::StateTransitionStructureValidation; +use crate::validation::SimpleConsensusValidationResult; +use platform_version::version::PlatformVersion; + +impl StateTransitionStructureValidation for IdentityTopUpFromShieldedPoolTransitionV0 { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + let result = validate_actions_count( + &self.actions, + platform_version + .system_limits + .max_shielded_transition_actions, + ); + if !result.is_valid() { + return result; + } + + let result = validate_encrypted_note_sizes(&self.actions); + if !result.is_valid() { + return result; + } + + if self.top_up_amount == 0 { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "identity top up amount must be greater than zero".to_string(), + ), + ) + .into(), + ); + } + + if self.top_up_amount > i64::MAX as u64 { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "identity top up amount exceeds maximum allowed value".to_string(), + ), + ) + .into(), + ); + } + + let result = validate_proof_not_empty(&self.proof); + if !result.is_valid() { + return result; + } + + validate_anchor_not_zero(&self.anchor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consensus::ConsensusError; + use crate::shielded::SerializedAction; + use assert_matches::assert_matches; + use platform_value::Identifier; + + fn action() -> SerializedAction { + SerializedAction { + nullifier: [1u8; 32], + rk: [2u8; 32], + cmx: [3u8; 32], + encrypted_note: vec![4u8; 216], + cv_net: [5u8; 32], + spend_auth_sig: [6u8; 64], + } + } + + fn valid() -> IdentityTopUpFromShieldedPoolTransitionV0 { + IdentityTopUpFromShieldedPoolTransitionV0 { + identity_id: Identifier::from([1u8; 32]), + actions: vec![action()], + top_up_amount: 1_000, + anchor: [7u8; 32], + proof: vec![8u8; 100], + binding_signature: [9u8; 64], + } + } + + #[test] + fn should_accept_a_well_formed_transition() { + let result = valid().validate_structure(PlatformVersion::latest()); + assert!(result.is_valid(), "{:?}", result.errors); + } + + #[test] + fn should_reject_no_actions() { + let mut t = valid(); + t.actions = vec![]; + let result = t.validate_structure(PlatformVersion::latest()); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedNoActionsError(_) + )] + ); + } + + #[test] + fn should_reject_zero_amount() { + let mut t = valid(); + t.top_up_amount = 0; + let result = t.validate_structure(PlatformVersion::latest()); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedInvalidValueBalanceError(_) + )] + ); + } + + #[test] + fn should_reject_amount_above_i64_max() { + let mut t = valid(); + t.top_up_amount = i64::MAX as u64 + 1; + let result = t.validate_structure(PlatformVersion::latest()); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedInvalidValueBalanceError(_) + )] + ); + } + + #[test] + fn should_reject_empty_proof_and_zero_anchor() { + let mut t = valid(); + t.proof = vec![]; + let result = t.validate_structure(PlatformVersion::latest()); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedEmptyProofError(_) + )] + ); + let mut t = valid(); + t.anchor = [0u8; 32]; + let result = t.validate_structure(PlatformVersion::latest()); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedZeroAnchorError(_) + )] + ); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/types.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/types.rs new file mode 100644 index 00000000000..cb947b45e39 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/types.rs @@ -0,0 +1,16 @@ +use crate::state_transition::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0; +use crate::state_transition::StateTransitionFieldTypes; + +impl StateTransitionFieldTypes for IdentityTopUpFromShieldedPoolTransitionV0 { + fn signature_property_paths() -> Vec<&'static str> { + vec![] + } + + fn identifiers_property_paths() -> Vec<&'static str> { + vec![] + } + + fn binary_property_paths() -> Vec<&'static str> { + vec![] + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/v0_methods.rs new file mode 100644 index 00000000000..b89ea6e48ab --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/v0_methods.rs @@ -0,0 +1,35 @@ +#[cfg(feature = "state-transition-signing")] +use crate::shielded::SerializedAction; +use crate::state_transition::identity_top_up_from_shielded_pool_transition::methods::IdentityTopUpFromShieldedPoolTransitionMethodsV0; +use crate::state_transition::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0; +#[cfg(feature = "state-transition-signing")] +use crate::{state_transition::StateTransition, ProtocolError}; +#[cfg(feature = "state-transition-signing")] +use platform_value::Identifier; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +impl IdentityTopUpFromShieldedPoolTransitionMethodsV0 + for IdentityTopUpFromShieldedPoolTransitionV0 +{ + #[cfg(feature = "state-transition-signing")] + fn try_from_bundle( + identity_id: Identifier, + actions: Vec, + top_up_amount: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + _platform_version: &PlatformVersion, + ) -> Result { + let transition = IdentityTopUpFromShieldedPoolTransitionV0 { + identity_id, + actions, + top_up_amount, + anchor, + proof, + binding_signature, + }; + Ok(transition.into()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/version.rs new file mode 100644 index 00000000000..19172b18777 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/v0/version.rs @@ -0,0 +1,9 @@ +use crate::state_transition::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0; +use crate::state_transition::FeatureVersioned; +use crate::version::FeatureVersion; + +impl FeatureVersioned for IdentityTopUpFromShieldedPoolTransitionV0 { + fn feature_version(&self) -> FeatureVersion { + 0 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/version.rs new file mode 100644 index 00000000000..5c217017770 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_top_up_from_shielded_pool_transition/version.rs @@ -0,0 +1,11 @@ +use crate::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; +use crate::state_transition::FeatureVersioned; +use crate::version::FeatureVersion; + +impl FeatureVersioned for IdentityTopUpFromShieldedPoolTransition { + fn feature_version(&self) -> FeatureVersion { + match self { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => v0.feature_version(), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs index e3c0f15409f..1f73ff3f01f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/mod.rs @@ -1,5 +1,6 @@ pub mod common_validation; pub mod identity_create_from_shielded_pool_transition; +pub mod identity_top_up_from_shielded_pool_transition; pub mod shield_from_asset_lock_transition; pub mod shield_from_identity_transition; pub mod shield_transition; diff --git a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs index 82db6ad1069..86e0015c68f 100644 --- a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs +++ b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs @@ -552,6 +552,20 @@ impl ExecutionEvent<'_> { user_fee_increase, }) } + StateTransitionAction::IdentityTopUpFromShieldedPoolAction(ref top_up_action) => { + // Pool-paid exactly like Unshield: the flat fee is carved from the value + // balance and routed to the fee pools; the identity receives the rest. + // There is no transparent address output to track. + let fee_amount = top_up_action.fee_amount(); + let operations = + action.into_high_level_drive_operations(epoch, platform_version)?; + Ok(ExecutionEvent::PaidFromShieldedPool { + operations, + fees_to_add_to_pool: fee_amount, + added_to_balance_outputs: None, + chargeable_failure: false, + }) + } StateTransitionAction::ShieldFromIdentityAction(ref shield_action) => { // Identity-paid, exactly the IdentityCreditTransferToAddresses model, plus the // shielded COMPUTE fee (proof verification + per-action processing) that GroveDB diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs index f16d15a46bb..041ba235693 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs @@ -180,6 +180,7 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) @@ -250,6 +251,7 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs index 104498eb133..79d9cbc4991 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs @@ -82,6 +82,7 @@ impl StateTransitionAddressWitnessValidationV0 for StateTransition { | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) @@ -201,6 +202,7 @@ impl StateTransitionHasAddressWitnessValidationV0 for StateTransition { | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs index 82ce2363453..571fdca9020 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs @@ -74,6 +74,7 @@ impl StateTransitionAddressesMinimumBalanceValidationV0 for StateTransition { | StateTransition::MasternodeVote(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) @@ -108,6 +109,7 @@ impl StateTransitionAddressesMinimumBalanceValidationV0 for StateTransition { | StateTransition::AddressFundingFromAssetLock(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs index e36b10ee0bf..1cebd5eb19e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs @@ -353,6 +353,28 @@ impl StateTransitionBasicStructureValidationV0 for StateTransition { })), } } + StateTransition::IdentityTopUpFromShieldedPool(st) => { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .identity_top_up_from_shielded_pool_state_transition + .basic_structure + { + Some(0) => Ok(st.validate_structure(platform_version)), + Some(version) => { + Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "identity top up from shielded pool transition: validate_basic_structure".to_string(), + known_versions: vec![0], + received: version, + })) + } + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "identity top up from shielded pool transition: validate_basic_structure".to_string(), + known_versions: vec![0], + })), + } + } StateTransition::ShieldFromAssetLock(st) => { match platform_version .drive_abci @@ -500,6 +522,13 @@ impl StateTransitionBasicStructureValidationV0 for StateTransition { .unshield_state_transition .basic_structure .is_some(), + StateTransition::IdentityTopUpFromShieldedPool(_) => platform_version + .drive_abci + .validation_and_processing + .state_transitions + .identity_top_up_from_shielded_pool_state_transition + .basic_structure + .is_some(), StateTransition::ShieldFromAssetLock(_) => platform_version .drive_abci .validation_and_processing @@ -882,6 +911,7 @@ mod tests { | StateTransition::AddressCreditWithdrawal(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) => false, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs index bff62207ce9..9e88e8540e1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs @@ -82,6 +82,7 @@ impl StateTransitionIdentityBalanceValidationV0 for StateTransition { | StateTransition::AddressCreditWithdrawal(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index 33c535af4ea..354f3bd71eb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -134,6 +134,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::AddressCreditWithdrawal(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) @@ -177,6 +178,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::AddressCreditWithdrawal(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) @@ -207,6 +209,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::IdentityTopUp(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs index bce9200b7cf..1975991755c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs @@ -124,6 +124,7 @@ impl StateTransitionIdentityNonceValidationV0 for StateTransition { | StateTransition::IdentityTopUp(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) @@ -179,6 +180,7 @@ impl StateTransitionHasIdentityNonceValidationV0 for StateTransition { | StateTransition::AddressCreditWithdrawal(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs index 2d0b8728709..eecad794a7c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs @@ -6,8 +6,9 @@ use dpp::consensus::basic::state_transition::StateTransitionNotActiveError; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::StateTransition; use dpp::version::feature_initial_protocol_versions::{ - ADDRESS_FUNDS_INITIAL_PROTOCOL_VERSION, SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, - SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION, + ADDRESS_FUNDS_INITIAL_PROTOCOL_VERSION, + IDENTITY_TOP_UP_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION, }; use dpp::version::PlatformVersion; @@ -35,6 +36,7 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { | StateTransition::AddressCreditWithdrawal(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) @@ -96,6 +98,22 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { ])) } } + StateTransition::IdentityTopUpFromShieldedPool(_) => { + if platform_version.protocol_version + >= IDENTITY_TOP_UP_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION + { + Ok(ConsensusValidationResult::new()) + } else { + Ok(ConsensusValidationResult::new_with_errors(vec![ + StateTransitionNotActiveError::new( + self.state_transition_type().to_string(), + platform_version.protocol_version, + IDENTITY_TOP_UP_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + ) + .into(), + ])) + } + } StateTransition::ShieldFromIdentity(_) => { if platform_version.protocol_version >= SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index 50772e6ffe6..253ff5dac2d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -14,6 +14,7 @@ use dpp::serialization::{PlatformMessageSignable, Signable}; use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use dpp::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; use dpp::state_transition::StateTransition; use dpp::validation::SimpleConsensusValidationResult; @@ -58,6 +59,7 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { self, StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) @@ -85,6 +87,9 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { v0.actions.len() } }, + StateTransition::IdentityTopUpFromShieldedPool(st) => match st { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => v0.actions.len(), + }, StateTransition::ShieldedWithdrawal(st) => match st { dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition::V0(v0) => { v0.actions.len() @@ -108,6 +113,7 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { matches!( self, StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) @@ -170,7 +176,10 @@ enum ShieldedMinFeeKind { /// the same constants the non-shielded `IdentityCreate` predictor uses, which grows with the key /// count). Carries `num_keys` because the fee scales with it, unlike the other (fixed) /// per-transition components. - IdentityCreate { num_keys: usize }, + IdentityCreate { + num_keys: usize, + }, + IdentityTopUp, } impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { @@ -225,6 +234,16 @@ impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { v0, ) => (v0.unshielding_amount as i64, v0.actions.len(), 0, u64::MAX, false, ShieldedMinFeeKind::Unshield), }, + StateTransition::IdentityTopUpFromShieldedPool(st) => match st { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => ( + v0.top_up_amount as i64, + v0.actions.len(), + 0, + u64::MAX, + false, + ShieldedMinFeeKind::IdentityTopUp, + ), + }, // ShieldedWithdrawal: the net (`unshielding_amount - min_fee`) becomes a // Core `TxOut`, so it must fall within the same // `[min_withdrawal_amount, max_withdrawal_amount]` range the transparent @@ -327,6 +346,12 @@ impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { platform_version, )? } + ShieldedMinFeeKind::IdentityTopUp => { + dpp::shielded::compute_shielded_identity_top_up_fee( + num_actions, + platform_version, + )? + } }; if (validated_amount as u64) < minimum_shielded_fee { @@ -517,6 +542,25 @@ impl StateTransitionShieldedProofValidationV0 for StateTransition { ) } }, + StateTransition::IdentityTopUpFromShieldedPool(st) => match st { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => { + let extra_sighash_data = + dpp::shielded::identity_top_up_from_shielded_extra_sighash_data( + &v0.identity_id.to_buffer(), + v0.top_up_amount, + platform_version, + )?; + reconstruct_and_verify_bundle( + &v0.actions, + FLAGS_SPENDS_AND_OUTPUTS, + v0.top_up_amount as i64, + &v0.anchor, + v0.proof.as_slice(), + &v0.binding_signature, + &extra_sighash_data, + ) + } + }, StateTransition::ShieldedWithdrawal(st) => match st { dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition::V0(v0) => { let extra_sighash_data = diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index a9f9c12ae19..a2590685711 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -203,6 +203,11 @@ impl StateTransitionStateValidation for StateTransition { StateTransition::Unshield(_) => Err(Error::Execution( ExecutionError::CorruptedCodeExecution("unshield should not have state validation"), )), + StateTransition::IdentityTopUpFromShieldedPool(_) => { + Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "identity top up from shielded pool should not have state validation", + ))) + } StateTransition::ShieldFromAssetLock(_) => { Err(Error::Execution(ExecutionError::CorruptedCodeExecution( "shield from asset lock should not have state validation", @@ -280,6 +285,7 @@ impl StateTransitionStateValidation for StateTransition { | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) => false, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/mod.rs new file mode 100644 index 00000000000..d7a05fc122c --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/mod.rs @@ -0,0 +1,54 @@ +mod transform_into_action; + +#[cfg(test)] +mod tests; + +use dpp::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; +use dpp::validation::ConsensusValidationResult; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::StateTransitionAction; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::validation::state_transition::identity_top_up_from_shielded_pool::transform_into_action::v0::IdentityTopUpFromShieldedPoolStateTransitionTransformIntoActionValidationV0; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::rpc::core::CoreRPCLike; + +/// A trait to transform into an action for the identity top up from shielded pool transition +pub trait StateTransitionIdentityTopUpFromShieldedPoolTransitionActionTransformer { + /// Transform into an action for the identity top up from shielded pool transition + fn transform_into_action_for_identity_top_up_from_shielded_pool_transition( + &self, + platform: &PlatformRef, + tx: TransactionArg, + ) -> Result, Error>; +} + +impl StateTransitionIdentityTopUpFromShieldedPoolTransitionActionTransformer + for IdentityTopUpFromShieldedPoolTransition +{ + fn transform_into_action_for_identity_top_up_from_shielded_pool_transition( + &self, + platform: &PlatformRef, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .identity_top_up_from_shielded_pool_state_transition + .transform_into_action + { + 0 => self.transform_into_action_v0(platform.drive, tx, platform_version), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "identity top up from shielded pool transition: transform_into_action" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs new file mode 100644 index 00000000000..f7990bd20bf --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs @@ -0,0 +1,507 @@ +#[cfg(test)] +#[allow(clippy::module_inception)] +mod tests { + use crate::config::{PlatformConfig, PlatformTestConfig}; + use crate::execution::validation::state_transition::state_transitions::shielded_common::compute_platform_sighash; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + create_dummy_serialized_action, get_proving_key, insert_anchor_into_state, + insert_dummy_encrypted_notes, process_transition, serialize_authorized_bundle_i64, + set_pool_total_balance, setup_platform, + }; + use crate::execution::validation::state_transition::state_transitions::tests::process_state_transitions; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use assert_matches::assert_matches; + use dpp::block::block_info::BlockInfo; + use dpp::consensus::basic::BasicError; + use dpp::consensus::signature::SignatureError; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::dash_to_credits; + use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + use dpp::identity::Identity; + use dpp::platform_value::Identifier; + use dpp::serialization::PlatformSerializable; + use dpp::shielded::SerializedAction; + use dpp::state_transition::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0; + use dpp::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use dpp::state_transition::StateTransition; + use drive::drive::Drive; + use grovedb_commitment_tree::{ + Builder, BundleType, ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, + FullViewingKey, Note, NoteValue, Position, RandomSeed, Retention, Rho, Scope, + SpendAuthorizingKey, SpendingKey, + }; + use platform_version::version::PlatformVersion; + use rand::rngs::StdRng; + use rand::SeedableRng; + + const NOTE_VALUE: u64 = 500_000_000; + const CHANGE_VALUE: u64 = 5_000; + /// value_balance of the fixture bundle: one spent note minus one change output. + const GROSS_AMOUNT: u64 = NOTE_VALUE - CHANGE_VALUE; + + fn create_transition( + identity_id: Identifier, + actions: Vec, + top_up_amount: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + ) -> StateTransition { + IdentityTopUpFromShieldedPoolTransition::V0(IdentityTopUpFromShieldedPoolTransitionV0 { + identity_id, + actions, + top_up_amount, + anchor, + proof, + binding_signature, + }) + .into() + } + + fn dummy_transition(identity_id: Identifier, top_up_amount: u64) -> StateTransition { + create_transition( + identity_id, + vec![create_dummy_serialized_action()], + top_up_amount, + [42u8; 32], + vec![7u8; 100], + [0u8; 64], + ) + } + + /// Builds and proves a real spend bundle (one 500M note in, one 5_000 change note + /// out) whose binding signature commits to `identity_id` and `top_up_amount`. + fn build_valid_bundle( + identity_id: &Identifier, + top_up_amount: u64, + ) -> (Vec, i64, [u8; 32], Vec, [u8; 64]) { + let mut rng = StdRng::seed_from_u64(0); + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(NOTE_VALUE), rho, rseed).unwrap(); + + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output( + None, + recipient, + NoteValue::from_raw(CHANGE_VALUE), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + + let extra_sighash_data = dpp::shielded::identity_top_up_from_shielded_extra_sighash_data_v0( + &identity_id.to_buffer(), + top_up_amount, + ); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data); + + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + serialize_authorized_bundle_i64(&bundle) + } + + /// Adds an identity holding `balance` and books it into system credits so the + /// fixture starts balanced. + fn add_identity(platform: &TempPlatform, seed: u64, balance: u64) -> Identity { + let platform_version = PlatformVersion::latest(); + let mut identity = + Identity::random_identity(2, Some(seed), platform_version).expect("identity"); + identity.set_balance(balance); + platform + .drive + .add_to_system_credits(balance, None, platform_version) + .expect("system credits"); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("should add identity"); + identity + } + + fn identity_balance(platform: &TempPlatform, id: Identifier) -> u64 { + platform + .drive + .fetch_identity_balance(id.to_buffer(), None, PlatformVersion::latest()) + .expect("fetch") + .expect("identity should exist") + } + + /// Seeds the pool state the spend needs: enough notes for the minimum-notes floor, + /// the bundle's anchor, and a pool total balance (`set_pool_total_balance` books it + /// into system credits, so the block-level conservation check holds). + fn seed_pool(platform: &TempPlatform, anchor: &[u8; 32], pool_total: u64) { + insert_dummy_encrypted_notes(platform, 250); + insert_anchor_into_state(platform, anchor); + set_pool_total_balance(platform, pool_total); + } + + fn top_up_fee(num_actions: usize) -> u64 { + dpp::shielded::compute_shielded_identity_top_up_fee(num_actions, PlatformVersion::latest()) + .expect("fee") + } + + // ========================================== + // Rejections + // ========================================== + + #[test] + fn test_zero_amount_is_rejected_by_structure_validation() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let result = process_transition( + &platform, + dummy_transition(Identifier::from([1u8; 32]), 0), + platform_version, + ); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + #[test] + fn test_amount_below_the_flat_fee_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let fee = top_up_fee(1); + let result = process_transition( + &platform, + dummy_transition(Identifier::from([1u8; 32]), fee - 1), + platform_version, + ); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InsufficientShieldedFeeError(_)) + )] + ); + } + + #[test] + fn test_invalid_orchard_proof_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let result = process_transition( + &platform, + dummy_transition(Identifier::from([1u8; 32]), dash_to_credits!(0.1)), + platform_version, + ); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + #[test] + fn test_unknown_identity_is_rejected_with_a_valid_proof() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let identity_id = Identifier::from([9u8; 32]); + let (actions, value_balance, anchor, proof, binding_sig) = + build_valid_bundle(&identity_id, GROSS_AMOUNT); + assert_eq!(value_balance as u64, GROSS_AMOUNT); + seed_pool(&platform, &anchor, NOTE_VALUE); + + let st = create_transition( + identity_id, + actions, + GROSS_AMOUNT, + anchor, + proof, + binding_sig, + ); + let result = process_transition(&platform, st, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::SignatureError(SignatureError::IdentityNotFoundError(_)) + )] + ); + } + + #[test] + fn test_valid_bundle_with_mutated_amount_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let identity = add_identity(&platform, 1, dash_to_credits!(0.1)); + let (actions, _, anchor, proof, binding_sig) = + build_valid_bundle(&identity.id(), GROSS_AMOUNT); + seed_pool(&platform, &anchor, NOTE_VALUE * 2); + + let st = create_transition( + identity.id(), + actions, + GROSS_AMOUNT + 1_000, + anchor, + proof, + binding_sig, + ); + let result = process_transition(&platform, st, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + #[test] + fn test_valid_bundle_repointed_at_another_identity_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let signed_for = add_identity(&platform, 2, dash_to_credits!(0.1)); + let other = add_identity(&platform, 3, dash_to_credits!(0.1)); + let (actions, _, anchor, proof, binding_sig) = + build_valid_bundle(&signed_for.id(), GROSS_AMOUNT); + seed_pool(&platform, &anchor, NOTE_VALUE); + + // The bundle's sighash commits to `signed_for`; crediting `other` must fail. + let st = create_transition( + other.id(), + actions, + GROSS_AMOUNT, + anchor, + proof, + binding_sig, + ); + let result = process_transition(&platform, st, platform_version); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + assert_eq!( + identity_balance(&platform, other.id()), + dash_to_credits!(0.1) + ); + } + + #[test] + fn test_rejected_before_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let platform_config = PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }; + let platform = TestPlatformBuilder::new() + .with_config(platform_config) + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + let result = process_transition( + &platform, + dummy_transition(Identifier::from([1u8; 32]), dash_to_credits!(0.1)), + platform_version, + ); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::InternalError(message)] + if message.contains("IdentityTopUpFromShieldedPool") && message.contains("not active") + ); + } + + // ========================================== + // Success, fees, conservation, proof + // ========================================== + + #[test] + fn test_valid_top_up_credits_identity_and_conserves_supply() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let initial_balance = dash_to_credits!(0.1); + let identity = add_identity(&platform, 4, initial_balance); + let (actions, value_balance, anchor, proof, binding_sig) = + build_valid_bundle(&identity.id(), GROSS_AMOUNT); + assert_eq!(value_balance as u64, GROSS_AMOUNT); + let num_actions = actions.len(); + seed_pool(&platform, &anchor, NOTE_VALUE); + + let credits_before = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("total credits before"); + assert!( + credits_before.ok().expect("no overflow"), + "fixture must start balanced" + ); + + let st = create_transition( + identity.id(), + actions, + GROSS_AMOUNT, + anchor, + proof, + binding_sig, + ); + + { + let guard_bytes = st.serialize_to_bytes().expect("serialize for guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_bytes, + "identity top up from shielded pool", + ); + } + + let platform_state = platform.state.load(); + let (fee_results, _) = + process_state_transitions(&platform, &[st], BlockInfo::default(), &platform_state); + let fee = top_up_fee(num_actions); + assert_eq!( + fee_results[0].total_base_fee(), + fee, + "the flat pool-paid fee must be booked exactly" + ); + + let credits_after = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("total credits after"); + assert!( + credits_after.ok().expect("no overflow"), + "credits must stay balanced: {credits_after}" + ); + assert_eq!( + credits_after.total_credits_in_platform, credits_before.total_credits_in_platform, + "pool to identity must not mint or burn system credits" + ); + assert_eq!( + credits_before.total_in_shielded_balances - credits_after.total_in_shielded_balances, + GROSS_AMOUNT as i64, + "pool must lose exactly the gross amount" + ); + assert_eq!( + identity_balance(&platform, identity.id()), + initial_balance + GROSS_AMOUNT - fee, + "identity must gain the gross amount minus the flat fee" + ); + } + + #[test] + fn test_prove_and_verify_returns_identity_and_spent_nullifiers() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let identity = add_identity(&platform, 5, dash_to_credits!(0.1)); + let (actions, _, anchor, proof, binding_sig) = + build_valid_bundle(&identity.id(), GROSS_AMOUNT); + let expected_nullifiers: Vec> = + actions.iter().map(|a| a.nullifier.to_vec()).collect(); + seed_pool(&platform, &anchor, NOTE_VALUE); + + let st = create_transition( + identity.id(), + actions, + GROSS_AMOUNT, + anchor, + proof, + binding_sig, + ); + let transition_bytes = st.serialize_to_bytes().expect("serialize"); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commit"); + + let proof_bytes = platform + .drive + .prove_state_transition(&st, None, platform_version) + .expect("prove") + .into_data() + .expect("proof data"); + + let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &proof_bytes, + &|_| Ok(None), + platform_version, + ) + .expect("verify"); + assert_ne!(root_hash, [0u8; 32]); + + let result = outcome.into_result(); + let StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers(proven, statuses) = + result + else { + panic!("expected VerifiedIdentityWithShieldedNullifiers, got {result:?}"); + }; + assert_eq!(proven.id(), identity.id()); + assert_eq!(proven.balance(), identity_balance(&platform, identity.id())); + assert_eq!( + statuses + .iter() + .map(|(nf, _)| nf.clone()) + .collect::>(), + expected_nullifiers + ); + assert!( + statuses.iter().all(|(_, spent)| *spent), + "all nullifiers must be spent" + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/transform_into_action/mod.rs new file mode 100644 index 00000000000..9a1925de7fc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/transform_into_action/mod.rs @@ -0,0 +1 @@ +pub(crate) mod v0; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/transform_into_action/v0/mod.rs new file mode 100644 index 00000000000..d73d96eb433 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/transform_into_action/v0/mod.rs @@ -0,0 +1,111 @@ +use crate::error::Error; +use crate::execution::validation::state_transition::state_transitions::shielded_common::{ + read_pool_total_balance, validate_anchor_exists, validate_minimum_pool_notes, + validate_nullifiers, +}; +use dpp::consensus::signature::IdentityNotFoundError; +use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; +use dpp::consensus::state::state_error::StateError; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::identity_top_up_from_shielded_pool::IdentityTopUpFromShieldedPoolTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_top_up_from_shielded_pool) trait IdentityTopUpFromShieldedPoolStateTransitionTransformIntoActionValidationV0 +{ + fn transform_into_action_v0( + &self, + drive: &Drive, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityTopUpFromShieldedPoolStateTransitionTransformIntoActionValidationV0 + for IdentityTopUpFromShieldedPoolTransition +{ + /// The Orchard proof and the flat pool-paid fee floor have already been checked by + /// the processor. Stateful checks here mirror `Unshield` (pool notes floor, anchor, + /// unspent nullifiers, pool balance) plus one addition: the credited identity must + /// already exist. A top-up to an unknown identity is refused rather than creating + /// an identity with no keys. + fn transform_into_action_v0( + &self, + drive: &Drive, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let IdentityTopUpFromShieldedPoolTransition::V0(v0) = self; + + let anchor: [u8; 32] = v0.anchor; + let nullifiers: Vec<[u8; 32]> = v0.actions.iter().map(|a| a.nullifier).collect(); + + let mut drive_operations = vec![]; + let current_total_balance = + read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; + + if let Some(consensus_error) = validate_minimum_pool_notes( + drive, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + if let Some(consensus_error) = validate_anchor_exists( + drive, + &anchor, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + if let Some(consensus_error) = validate_nullifiers( + drive, + &nullifiers, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + if current_total_balance < v0.top_up_amount { + return Ok(ConsensusValidationResult::new_with_error( + StateError::InvalidShieldedProofError(InvalidShieldedProofError::new(format!( + "shielded pool has insufficient balance: pool has {} but identity top up requires {}", + current_total_balance, v0.top_up_amount + ))) + .into(), + )); + } + + if drive + .fetch_identity_balance(v0.identity_id.to_buffer(), transaction, platform_version)? + .is_none() + { + return Ok(ConsensusValidationResult::new_with_error( + IdentityNotFoundError::new(v0.identity_id).into(), + )); + } + + let fee_amount = dpp::shielded::compute_shielded_identity_top_up_fee( + v0.actions.len(), + platform_version, + )?; + + let result = IdentityTopUpFromShieldedPoolTransitionAction::try_from_transition( + self, + current_total_balance, + fee_amount, + ); + + Ok(result.map(|action| action.into())) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 2b3ae20ecb9..bc7356f1a3e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -46,6 +46,8 @@ mod identity_top_up_from_addresses; /// Module for identity-create-from-shielded-pool transition validation pub mod identity_create_from_shielded_pool; +/// Identity top up from shielded pool (pool to an existing identity) +pub mod identity_top_up_from_shielded_pool; /// Module for shield transition validation pub mod shield; /// Module for shield from asset lock transition validation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs index 878c813c0a0..6427c0ca3ab 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs @@ -8,6 +8,7 @@ use crate::execution::validation::state_transition::identity_create::StateTransi use crate::execution::validation::state_transition::identity_create_from_addresses::StateTransitionActionTransformerForIdentityCreateFromAddressesTransitionV0; use crate::execution::validation::state_transition::identity_create_from_shielded_pool::StateTransitionIdentityCreateFromShieldedPoolTransitionActionTransformer; use crate::execution::validation::state_transition::identity_top_up::StateTransitionIdentityTopUpTransitionActionTransformer; +use crate::execution::validation::state_transition::identity_top_up_from_shielded_pool::StateTransitionIdentityTopUpFromShieldedPoolTransitionActionTransformer; use crate::execution::validation::state_transition::shield::StateTransitionShieldTransitionActionTransformer; use crate::execution::validation::state_transition::shield_from_asset_lock::StateTransitionShieldFromAssetLockTransitionActionTransformer; use crate::execution::validation::state_transition::shielded_transfer::StateTransitionShieldedTransferTransitionActionTransformer; @@ -264,6 +265,10 @@ impl StateTransitionActionTransformer for StateTransition { StateTransition::Unshield(st) => { st.transform_into_action_for_unshield_transition(platform, tx) } + StateTransition::IdentityTopUpFromShieldedPool(st) => st + .transform_into_action_for_identity_top_up_from_shielded_pool_transition( + platform, tx, + ), StateTransition::ShieldFromAssetLock(st) => { let signable_bytes = self.signable_bytes()?; st.transform_into_action_for_shield_from_asset_lock_transition( diff --git a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs index 5400ba13743..c45020d0a2b 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs @@ -1313,7 +1313,8 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( | StateTransitionAction::ShieldFromAssetLockAction(_) | StateTransitionAction::ShieldedWithdrawalAction(_) | StateTransitionAction::IdentityCreateFromShieldedPoolAction(_) - | StateTransitionAction::ShieldFromIdentityAction(_) => { + | StateTransitionAction::ShieldFromIdentityAction(_) + | StateTransitionAction::IdentityTopUpFromShieldedPoolAction(_) => { // The strategy harness does not generate shielded transitions (no shielded // `OperationType`), so their proof-verification roundtrip isn't exercised here. // IdentityCreateFromShieldedPool's strict prove/verify is covered by the unit diff --git a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs index 27e03387c5f..69e468b3ad7 100644 --- a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs +++ b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs @@ -507,6 +507,31 @@ impl Drive { &platform_version.drive.grove_version, )? } + StateTransition::IdentityTopUpFromShieldedPool(st) => { + use crate::drive::shielded::paths::shielded_credit_pool_nullifiers_path_vec; + use dpp::state_transition::identity_top_up_from_shielded_pool_transition::accessors::IdentityTopUpFromShieldedPoolTransitionAccessorsV0; + + // Spent nullifiers AND the credited identity in one STRICT merged proof, + // exactly the IdentityCreateFromShieldedPool shape. + let nullifier_keys: Vec> = st.nullifiers(); + let mut nf_query = grovedb::Query::new(); + nf_query.insert_keys(nullifier_keys); + let nullifier_pq = PathQuery::new( + shielded_credit_pool_nullifiers_path_vec(), + grovedb::SizedQuery::new(nf_query, None, None), + ); + + let mut identity_pq = Drive::full_identity_query( + &st.identity_id().to_buffer(), + &platform_version.drive.grove_version, + )?; + identity_pq.query.limit = None; + + PathQuery::merge( + vec![&nullifier_pq, &identity_pq], + &platform_version.drive.grove_version, + )? + } StateTransition::ShieldFromIdentity(st) => { // The identity's post-debit balance; the shielded note is not proven // (the client learns it through shielded sync, as after `Shield`). diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs index 39b92801898..a3a676e7851 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs @@ -113,6 +113,9 @@ impl DriveHighLevelOperationConverter for StateTransitionAction { StateTransitionAction::ShieldFromIdentityAction(action) => { action.into_high_level_drive_operations(epoch, platform_version) } + StateTransitionAction::IdentityTopUpFromShieldedPoolAction(action) => { + action.into_high_level_drive_operations(epoch, platform_version) + } StateTransitionAction::ShieldedTransferAction(shielded_transfer_action) => { shielded_transfer_action.into_high_level_drive_operations(epoch, platform_version) } diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_top_up_from_shielded_pool_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_top_up_from_shielded_pool_transition.rs new file mode 100644 index 00000000000..dd163922bff --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/identity_top_up_from_shielded_pool_transition.rs @@ -0,0 +1,158 @@ +use super::{insert_notes, insert_nullifiers, update_balance}; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; +use crate::state_transition_action::shielded::identity_top_up_from_shielded_pool::IdentityTopUpFromShieldedPoolTransitionAction; +use crate::util::batch::DriveOperation::IdentityOperation; +use crate::util::batch::{DriveOperation, IdentityOperationType}; +use dpp::block::epoch::Epoch; +use dpp::version::PlatformVersion; + +impl DriveHighLevelOperationConverter for IdentityTopUpFromShieldedPoolTransitionAction { + fn into_high_level_drive_operations<'a>( + self, + _epoch: &Epoch, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .identity_top_up_from_shielded_pool_transition + { + 0 => match self { + IdentityTopUpFromShieldedPoolTransitionAction::V0(v0) => { + let mut ops: Vec> = Vec::new(); + + insert_nullifiers(&mut ops, &v0.notes); + + // The pool and the identity balance are both right-hand-side terms of + // the block conservation equation: the net amount moves between them + // and the fee moves to the fee pools; no system-credit adjustment. + let net_identity_amount = + v0.amount.checked_sub(v0.fee_amount).ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState( + "identity top up fee exceeds top up amount".to_string(), + )) + })?; + if net_identity_amount > 0 { + ops.push(IdentityOperation(IdentityOperationType::AddToIdentityBalance { + identity_id: v0.identity_id.to_buffer(), + added_balance: net_identity_amount, + })); + } + + insert_notes(&mut ops, &v0.notes); + + let new_total_balance = v0 + .current_total_balance + .checked_sub(v0.amount) + .ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState( + "shielded pool total balance underflow when subtracting identity top up amount" + .to_string(), + )) + })?; + update_balance(&mut ops, new_total_balance); + + Ok(ops) + } + }, + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: + "IdentityTopUpFromShieldedPoolTransitionAction::into_high_level_drive_operations" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state_transition_action::shielded::identity_top_up_from_shielded_pool::v0::IdentityTopUpFromShieldedPoolTransitionActionV0; + use crate::state_transition_action::shielded::ShieldedActionNote; + use crate::util::batch::drive_op_batch::{ShieldedPoolOperationType, SystemOperationType}; + use dpp::block::epoch::Epoch; + use dpp::platform_value::Identifier; + use dpp::version::PlatformVersion; + + fn make_note() -> ShieldedActionNote { + ShieldedActionNote { + nullifier: [0x11; 32], + cmx: [0x22; 32], + cv_net: [0x33; 32], + encrypted_note: vec![1, 2, 3], + } + } + + fn make_action(amount: u64, fee: u64) -> IdentityTopUpFromShieldedPoolTransitionAction { + IdentityTopUpFromShieldedPoolTransitionAction::V0( + IdentityTopUpFromShieldedPoolTransitionActionV0 { + identity_id: Identifier::from([0xAA; 32]), + amount, + notes: vec![make_note(), make_note()], + anchor: [9; 32], + fee_amount: fee, + current_total_balance: 10_000, + }, + ) + } + + fn ops(action: IdentityTopUpFromShieldedPoolTransitionAction) -> Vec> { + action + .into_high_level_drive_operations(&Epoch::new(0).unwrap(), PlatformVersion::latest()) + .expect("expected operations") + } + + #[test] + fn test_nullifiers_identity_credit_notes_pool_total() { + let ops = ops(make_action(3_000, 200)); + // InsertNullifiers + AddToIdentityBalance + 2 InsertNote + UpdateTotalBalance + assert_eq!(ops.len(), 5); + assert!(matches!( + &ops[0], + DriveOperation::ShieldedPoolOperation( + ShieldedPoolOperationType::InsertNullifiers { .. } + ) + )); + assert!(matches!( + &ops[1], + IdentityOperation(IdentityOperationType::AddToIdentityBalance { + added_balance: 2_800, + .. + }) + )); + assert!(matches!( + ops.last().unwrap(), + DriveOperation::ShieldedPoolOperation(ShieldedPoolOperationType::UpdateTotalBalance { + new_total_balance: 7_000 + }) + )); + } + + #[test] + fn test_no_system_credit_adjustment_is_emitted() { + let ops = ops(make_action(3_000, 200)); + assert!(!ops.iter().any(|op| matches!( + op, + DriveOperation::SystemOperation(SystemOperationType::AddToSystemCredits { .. }) + | DriveOperation::SystemOperation( + SystemOperationType::RemoveFromSystemCredits { .. } + ) + ))); + } + + #[test] + fn test_fee_above_amount_is_an_error() { + let result = make_action(100, 200) + .into_high_level_drive_operations(&Epoch::new(0).unwrap(), PlatformVersion::latest()); + assert!(matches!( + result, + Err(Error::Drive(DriveError::CorruptedDriveState(_))) + )); + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs index 7ac1560e78c..ed4eae5143a 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/mod.rs @@ -1,4 +1,5 @@ mod identity_create_from_shielded_pool_transition; +mod identity_top_up_from_shielded_pool_transition; mod shield_from_asset_lock_transition; mod shield_from_identity_transition; mod shield_transition; diff --git a/packages/rs-drive/src/state_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/mod.rs index eb28b0542aa..ee88bc1cf2e 100644 --- a/packages/rs-drive/src/state_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/mod.rs @@ -30,6 +30,7 @@ use crate::state_transition_action::identity::identity_topup_from_addresses::Ide use crate::state_transition_action::identity::identity_update::IdentityUpdateTransitionAction; use crate::state_transition_action::identity::masternode_vote::MasternodeVoteTransitionAction; use crate::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; +use crate::state_transition_action::shielded::identity_top_up_from_shielded_pool::IdentityTopUpFromShieldedPoolTransitionAction; use crate::state_transition_action::shielded::shield::ShieldTransitionAction; use crate::state_transition_action::shielded::shield_from_asset_lock::ShieldFromAssetLockTransitionAction; use crate::state_transition_action::shielded::shield_from_identity::ShieldFromIdentityTransitionAction; @@ -113,6 +114,8 @@ pub enum StateTransitionAction { IdentityCreateFromShieldedPoolAction(IdentityCreateFromShieldedPoolTransitionAction), /// identity balance to shielded pool ShieldFromIdentityAction(ShieldFromIdentityTransitionAction), + /// shielded pool to an existing identity's balance + IdentityTopUpFromShieldedPoolAction(IdentityTopUpFromShieldedPoolTransitionAction), } impl StateTransitionAction { @@ -175,6 +178,9 @@ impl StateTransitionAction { UserFeeIncrease::default() // 0 (fee is locked by Orchard binding signature) } StateTransitionAction::ShieldFromIdentityAction(action) => action.user_fee_increase(), + StateTransitionAction::IdentityTopUpFromShieldedPoolAction(_) => { + UserFeeIncrease::default() // 0 (fee is locked by Orchard binding signature) + } } } } diff --git a/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/mod.rs new file mode 100644 index 00000000000..c3b3b98c079 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/mod.rs @@ -0,0 +1,56 @@ +/// transformer +pub mod transformer; +/// v0 +pub mod v0; + +use crate::state_transition_action::shielded::identity_top_up_from_shielded_pool::v0::IdentityTopUpFromShieldedPoolTransitionActionV0; +use crate::state_transition_action::shielded::ShieldedActionNote; +use derive_more::From; +use dpp::fee::Credits; +use dpp::platform_value::Identifier; + +/// Identity top up from shielded pool transition action +#[derive(Debug, Clone, From)] +pub enum IdentityTopUpFromShieldedPoolTransitionAction { + /// v0 + V0(IdentityTopUpFromShieldedPoolTransitionActionV0), +} + +impl IdentityTopUpFromShieldedPoolTransitionAction { + /// The identity whose balance is credited + pub fn identity_id(&self) -> Identifier { + match self { + IdentityTopUpFromShieldedPoolTransitionAction::V0(t) => t.identity_id, + } + } + /// Gross amount leaving the pool + pub fn amount(&self) -> Credits { + match self { + IdentityTopUpFromShieldedPoolTransitionAction::V0(t) => t.amount, + } + } + /// Notes (spent nullifiers plus change outputs) + pub fn notes(&self) -> &[ShieldedActionNote] { + match self { + IdentityTopUpFromShieldedPoolTransitionAction::V0(t) => &t.notes, + } + } + /// The anchor the spend was proven against + pub fn anchor(&self) -> &[u8; 32] { + match self { + IdentityTopUpFromShieldedPoolTransitionAction::V0(t) => &t.anchor, + } + } + /// Flat fee routed to the fee pools + pub fn fee_amount(&self) -> Credits { + match self { + IdentityTopUpFromShieldedPoolTransitionAction::V0(t) => t.fee_amount, + } + } + /// Pool total balance read at transform time + pub fn current_total_balance(&self) -> Credits { + match self { + IdentityTopUpFromShieldedPoolTransitionAction::V0(t) => t.current_total_balance, + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/transformer.rs new file mode 100644 index 00000000000..85496396ca9 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/transformer.rs @@ -0,0 +1,25 @@ +use crate::state_transition_action::shielded::identity_top_up_from_shielded_pool::v0::IdentityTopUpFromShieldedPoolTransitionActionV0; +use crate::state_transition_action::shielded::identity_top_up_from_shielded_pool::IdentityTopUpFromShieldedPoolTransitionAction; +use dpp::fee::Credits; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; + +impl IdentityTopUpFromShieldedPoolTransitionAction { + /// Transforms the state transition into an action + pub fn try_from_transition( + value: &IdentityTopUpFromShieldedPoolTransition, + current_total_balance: Credits, + fee_amount: Credits, + ) -> ConsensusValidationResult { + match value { + IdentityTopUpFromShieldedPoolTransition::V0(v0) => { + IdentityTopUpFromShieldedPoolTransitionActionV0::try_from_transition( + v0, + current_total_balance, + fee_amount, + ) + .map(|action| action.into()) + } + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/v0/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/v0/mod.rs new file mode 100644 index 00000000000..b83cee98b7c --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/v0/mod.rs @@ -0,0 +1,22 @@ +mod transformer; + +use crate::state_transition_action::shielded::ShieldedActionNote; +use dpp::fee::Credits; +use dpp::platform_value::Identifier; + +/// Shielded pool to an existing identity's balance, version 0. +#[derive(Debug, Clone)] +pub struct IdentityTopUpFromShieldedPoolTransitionActionV0 { + /// The identity whose balance is credited + pub identity_id: Identifier, + /// Gross amount leaving the pool; the identity receives `amount - fee_amount` + pub amount: Credits, + /// Notes built 1:1 from the on-wire Orchard actions + pub notes: Vec, + /// The anchor the spend was proven against + pub anchor: [u8; 32], + /// Flat fee routed to the fee pools + pub fee_amount: Credits, + /// Pool total balance read at transform time + pub current_total_balance: Credits, +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/v0/transformer.rs new file mode 100644 index 00000000000..abe7449e020 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/shielded/identity_top_up_from_shielded_pool/v0/transformer.rs @@ -0,0 +1,26 @@ +use crate::state_transition_action::shielded::identity_top_up_from_shielded_pool::v0::IdentityTopUpFromShieldedPoolTransitionActionV0; +use crate::state_transition_action::shielded::ShieldedActionNote; +use dpp::fee::Credits; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::state_transitions::shielded::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0; + +impl IdentityTopUpFromShieldedPoolTransitionActionV0 { + /// Builds the v0 action from the v0 transition, the pool total, and the flat fee + pub fn try_from_transition( + value: &IdentityTopUpFromShieldedPoolTransitionV0, + current_total_balance: Credits, + fee_amount: Credits, + ) -> ConsensusValidationResult { + let notes: Vec = + value.actions.iter().map(ShieldedActionNote::from).collect(); + + ConsensusValidationResult::new_with_data(IdentityTopUpFromShieldedPoolTransitionActionV0 { + identity_id: value.identity_id, + amount: value.top_up_amount, + notes, + anchor: value.anchor, + fee_amount, + current_total_balance, + }) + } +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/mod.rs index 9057ce00962..203c388ae64 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/mod.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/mod.rs @@ -1,5 +1,7 @@ /// IdentityCreateFromShieldedPool transition action pub mod identity_create_from_shielded_pool; +/// IdentityTopUpFromShieldedPool transition action +pub mod identity_top_up_from_shielded_pool; /// Shield transition action pub mod shield; /// Shield from asset lock transition action diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 5216e16cf27..639da37e4b3 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -1891,7 +1891,7 @@ impl Drive { use std::collections::{BTreeMap, BTreeSet}; // Recompute the id from the actions (the canonical value) instead of trusting the - // wire field, and reject a tampered transition whose wire id doesn't match — so a + // wire field, and reject a tampered transition whose wire id doesn't match: so a // client verifying a proof cannot be fed a transition that reuses these nullifiers // while pointing `identity_id` at a different identity. (Consensus enforces the same // equality in `validate_structure`; this independently re-checks it here so the @@ -1929,7 +1929,7 @@ impl Drive { // STRICT verification via `verify_query` (succinctness on). Unlike the other // shielded merged queries (which target only explicit keys and go through // `verify_merged_query_strict`), this one embeds `full_identity_query`, whose - // all-keys sub-query is an unbounded RangeFull — and + // all-keys sub-query is an unbounded RangeFull: and // `verify_query_with_absence_proof` enumerates the query's terminal keys, which // is impossible for unbounded ranges ("terminal keys are not supported with // unbounded ranges"). Absence synthesis isn't needed here anyway: every queried @@ -1944,7 +1944,7 @@ impl Drive { &platform_version.drive.grove_version, )?; - // Partition the proved key/values by PATH (NOT key length — nullifier keys and the + // Partition the proved key/values by PATH (NOT key length: nullifier keys and the // identity id are both 32 bytes): nullifier-tree entries vs the identity subtrees // (balance / revision / keys). Reconstruct the identity exactly as // `verify_full_identity_by_identity_id_v0` does. @@ -2080,6 +2080,180 @@ impl Drive { VerifiedIdentityWithShieldedNullifiers(identity, statuses), )) } + StateTransition::IdentityTopUpFromShieldedPool(st) => { + use crate::drive::balances::balance_path; + use crate::drive::identity::IdentityRootStructure::IdentityTreeRevision; + use crate::drive::identity::{identity_key_tree_path, identity_path}; + use crate::drive::shielded::paths::shielded_credit_pool_nullifiers_path_vec; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{IdentityPublicKey, IdentityV0, KeyID}; + use dpp::prelude::Revision; + use dpp::serialization::PlatformDeserializable; + use dpp::state_transition::identity_top_up_from_shielded_pool_transition::accessors::IdentityTopUpFromShieldedPoolTransitionAccessorsV0; + use dpp::state_transition::proof_result::StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers; + use std::collections::{BTreeMap, BTreeSet}; + + // The credited identity is the transition's declared `identity_id`; consensus + // binds it into the Orchard sighash, so a proof for these nullifiers can only + // have been produced for a transition crediting this identity. + let identity_id = st.identity_id().to_buffer(); + let nullifier_keys: Vec> = st.nullifiers(); + + // Rebuild the BYTE-IDENTICAL merged query the prove side built: the nullifier + // sub-query over the nullifier tree + the full-identity sub-query, each with its + // limit cleared (PathQuery::merge rejects limited sub-queries). + let mut nf_query = grovedb::Query::new(); + nf_query.insert_keys(nullifier_keys.clone()); + let nullifier_pq = grovedb::PathQuery::new( + shielded_credit_pool_nullifiers_path_vec(), + grovedb::SizedQuery::new(nf_query, None, None), + ); + + let mut identity_pq = Drive::full_identity_query( + &identity_id, + &platform_version.drive.grove_version, + )?; + identity_pq.query.limit = None; + + let merged_pq = grovedb::PathQuery::merge( + vec![&nullifier_pq, &identity_pq], + &platform_version.drive.grove_version, + )?; + + // STRICT verification via `verify_query` (succinctness on). Unlike the other + // shielded merged queries (which target only explicit keys and go through + // `verify_merged_query_strict`), this one embeds `full_identity_query`, whose + // all-keys sub-query is an unbounded RangeFull: and + // `verify_query_with_absence_proof` enumerates the query's terminal keys, which + // is impossible for unbounded ranges ("terminal keys are not supported with + // unbounded ranges"). Absence synthesis isn't needed here anyway: every queried + // element (the spent nullifiers and the credited identity) must be PRESENT, so + // presence is checked directly against the result set below. The succinctness + // check still rejects proofs padded with branches beyond {nullifiers, identity} + // (the strict-from-day-one guarantee of #3812), and the limit stays None exactly + // as the prove side built it, so no layer's result loop can break early. + let (root_hash, proved_key_values) = grovedb::GroveDb::verify_query( + proof, + &merged_pq, + &platform_version.drive.grove_version, + )?; + + // Partition the proved key/values by PATH (NOT key length: nullifier keys and the + // identity id are both 32 bytes): nullifier-tree entries vs the identity subtrees + // (balance / revision / keys). Reconstruct the identity exactly as + // `verify_full_identity_by_identity_id_v0` does. + let nullifier_path = shielded_credit_pool_nullifiers_path_vec(); + let balance_path = balance_path(); + let identity_path = identity_path(identity_id.as_slice()); + let identity_keys_path = identity_key_tree_path(identity_id.as_slice()); + + let mut spent_nullifiers = BTreeSet::>::new(); + let mut balance: Option = None; + let mut revision: Option = None; + let mut keys = BTreeMap::::new(); + + for (path, key, maybe_element) in proved_key_values { + if path == nullifier_path { + if !nullifier_keys.contains(&key) { + return Err(Error::Proof(ProofError::CorruptedProof( + "identity top up from shielded pool proof contains a nullifier \ + entry that was not requested" + .to_string(), + ))); + } + if maybe_element.is_some() { + spent_nullifiers.insert(key); + } + } else if path == balance_path && key == identity_id { + let element = maybe_element.ok_or_else(|| { + Error::Proof(ProofError::IncompleteProof( + "balance wasn't provided for the topped-up identity", + )) + })?; + let signed_balance = element.as_sum_item_value().map_err(Error::from)?; + if signed_balance < 0 { + return Err(Error::Proof(ProofError::Overflow( + "balance can't be negative", + ))); + } + balance = Some(signed_balance as Credits); + } else if path == identity_path && key == vec![IdentityTreeRevision as u8] { + let element = maybe_element.ok_or_else(|| { + Error::Proof(ProofError::IncompleteProof( + "revision wasn't provided for the topped-up identity", + )) + })?; + let item_bytes = element.into_item_bytes().map_err(Error::from)?; + revision = Some(Revision::from_be_bytes(item_bytes.try_into().map_err( + |_| { + Error::Proof(ProofError::IncorrectValueSize( + "revision should be 8 bytes", + )) + }, + )?)); + } else if path == identity_keys_path { + let element = maybe_element.ok_or_else(|| { + Error::Proof(ProofError::CorruptedProof( + "received an absence proof for a key but didn't request one" + .to_string(), + )) + })?; + let item_bytes = element.into_item_bytes().map_err(Error::from)?; + let public_key = IdentityPublicKey::deserialize_from_bytes(&item_bytes)?; + keys.insert(public_key.id(), public_key); + } else { + return Err(Error::Proof(ProofError::TooManyElements( + "identity top up from shielded pool proof contains an element outside \ + the nullifier tree and the topped-up identity", + ))); + } + } + + // Without absence synthesis an unspent nullifier yields no result entry (or a + // bare absence entry), so each expected nullifier's spend status is its + // membership in the proved-present set. + let statuses: Vec<(Vec, bool)> = nullifier_keys + .iter() + .map(|nf| (nf.clone(), spent_nullifiers.contains(nf))) + .collect(); + + // Every funding nullifier must be present (spent) in the post-execution state. + for (nf, is_spent) in &statuses { + if !is_spent { + return Err(Error::Proof(ProofError::IncorrectProof(format!( + "nullifier {} was not found as spent in the identity-top-up-from-shielded-pool proof", + hex::encode(nf) + )))); + } + } + + // The credited identity MUST be fully present (it existed before the top-up). + let (balance, revision) = match (balance, revision, keys.is_empty()) { + (Some(balance), Some(revision), false) => (balance, revision), + _ => { + return Err(Error::Proof(ProofError::IncompleteProof( + "identity top up from shielded pool was executed but the topped-up identity is absent or incomplete in the proof", + ))) + } + }; + + // The balance is deliberately NOT checked against `top_up_amount`: the proof is a + // post-execution snapshot of an identity that already held credits, so the + // pre-top-up balance and the flat fee are not recoverable here. (`top_up_amount` + // is bound into the Orchard `extra_sighash_data` at consensus.) + let identity: dpp::prelude::Identity = IdentityV0 { + id: Identifier::from(identity_id), + public_keys: keys, + balance, + revision, + } + .into(); + + Ok(( + root_hash, + VerifiedIdentityWithShieldedNullifiers(identity, statuses), + )) + } StateTransition::ShieldFromIdentity(st) => { use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; // snapshot of the identity's balance at the proof's block @@ -2242,6 +2416,9 @@ impl Drive { // Only the identity's post-debit balance is proven; the shielded note // and the requested amount are not bound. StateTransition::ShieldFromIdentity(_) => false, + // Spent nullifiers plus the credited identity do not bind the gross + // amount or the fee split. + StateTransition::IdentityTopUpFromShieldedPool(_) => false, }; Ok(binds) diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs index 35d25ede04a..8e5996f3ba2 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs @@ -41,6 +41,7 @@ pub struct DPPStateTransitionSerializationVersions { pub shielded_withdrawal_state_transition: FeatureVersionBounds, pub identity_create_from_shielded_pool_state_transition: FeatureVersionBounds, pub shield_from_identity_state_transition: FeatureVersionBounds, + pub identity_top_up_from_shielded_pool_state_transition: FeatureVersionBounds, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs index ba8921e677f..e6d53d23513 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs @@ -169,4 +169,9 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V1: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + identity_top_up_from_shielded_pool_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs index 946eb7ae48e..c2f5c219fa2 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs @@ -169,4 +169,9 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V2: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + identity_top_up_from_shielded_pool_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs index bfaa8a83320..d1e59b2dae3 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs @@ -181,4 +181,9 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V3: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + identity_top_up_from_shielded_pool_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index 86849403440..6617537c899 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -149,6 +149,8 @@ pub struct DriveAbciStateTransitionValidationVersions { pub identity_create_from_shielded_pool_state_transition: DriveAbciStateTransitionValidationVersion, pub shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion, + pub identity_top_up_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs index c84b90599ca..3a23c94c751 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs @@ -265,6 +265,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_top_up_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 0, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index d0417048f8a..81479fb65aa 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -323,6 +323,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_top_up_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs index 8f2b966167c..c395ded0e36 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs @@ -265,6 +265,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_top_up_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 0, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs index c829d0fe04f..41d57f57298 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs @@ -265,6 +265,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_top_up_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 0, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs index 19fbd08ff67..528f2d3594c 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs @@ -268,6 +268,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_top_up_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, // <---- changed this has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs index c50bcd2be88..4547fed686b 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs @@ -269,6 +269,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_top_up_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs index a6fa2d367c2..f0e7276bc20 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs @@ -272,6 +272,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_top_up_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs index f09e54dbff3..67017f621d1 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs @@ -266,6 +266,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_top_up_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs index 0a61d8cffa3..01f465dac4c 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs @@ -320,6 +320,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_top_up_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs index eedfa5cfed3..df2c16c4f4b 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs @@ -316,6 +316,15 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V9: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + identity_top_up_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, }, has_nonce_validation: 1, has_address_witness_validation: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs index 0fb1f79b263..ed0b0326f34 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs @@ -70,6 +70,7 @@ pub struct DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions pub shielded_withdrawal_transition: FeatureVersion, pub identity_create_from_shielded_pool_transition: FeatureVersion, pub shield_from_identity_transition: FeatureVersion, + pub identity_top_up_from_shielded_pool_transition: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs index c4186666224..7955a70b8cc 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs @@ -58,6 +58,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V1: DriveStateTransitionMethodV shielded_withdrawal_transition: 0, identity_create_from_shielded_pool_transition: 0, shield_from_identity_transition: 0, + identity_top_up_from_shielded_pool_transition: 0, }, document_from_action: DriveDocumentFromActionVersions { document_from_create_transition_action: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs index a5c8e26d2e5..8a34eebaf0d 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs @@ -59,6 +59,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V2: DriveStateTransitionMethodV shielded_withdrawal_transition: 0, identity_create_from_shielded_pool_transition: 0, shield_from_identity_transition: 0, + identity_top_up_from_shielded_pool_transition: 0, }, document_from_action: DriveDocumentFromActionVersions { document_from_create_transition_action: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs index 3e153bebef4..78288f4334d 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs @@ -63,6 +63,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V3: DriveStateTransitionMethodV shielded_withdrawal_transition: 0, identity_create_from_shielded_pool_transition: 0, shield_from_identity_transition: 0, + identity_top_up_from_shielded_pool_transition: 0, }, document_from_action: DriveDocumentFromActionVersions { document_from_create_transition_action: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs index 5b00fcb6a4b..c60cb68582a 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs @@ -63,6 +63,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4: DriveStateTransitionMethodV shielded_withdrawal_transition: 0, identity_create_from_shielded_pool_transition: 0, shield_from_identity_transition: 0, + identity_top_up_from_shielded_pool_transition: 0, }, document_from_action: DriveDocumentFromActionVersions { document_from_create_transition_action: 1, // changed diff --git a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs index 8f4d7b79f8c..2db21c82456 100644 --- a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs +++ b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs @@ -4,3 +4,6 @@ pub const ADDRESS_FUNDS_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 11; pub const SHIELDED_POOL_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 12; /// `ShieldFromIdentity` (identity balance to shielded pool) activates with protocol version 14. pub const SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; +/// `IdentityTopUpFromShieldedPool` (shielded pool to an existing identity's balance) activates with +/// protocol version 14. +pub const IDENTITY_TOP_UP_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 02348a38758..31a78bd7b8d 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -214,6 +214,15 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// pool side is an outputs-only Orchard bundle like `Shield`, and the fee /// is metered plus the shielded compute fee, paid from the identity. /// +/// * `IdentityTopUpFromShieldedPool` (state transition type 22) activates at the +/// same gate (`IDENTITY_TOP_UP_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION = 14`, +/// `DRIVE_ABCI_VALIDATION_VERSIONS_V10` row). It spends shielded notes like +/// `Unshield` and credits an EXISTING identity's balance instead of a platform +/// address: pool-paid flat fee (`compute_shielded_identity_top_up_fee`), no +/// platform signature, the target identity and gross amount bound into the +/// Orchard sighash, and no system-credit adjustment (pool and identity balances +/// are both conservation-equation terms). +/// /// The wire surface changes only additively: `GetDocumentsRequestV1` /// already carries `selects` / `group_by` / `order_by` / `limit` / /// `offset`; the ranked response is an additive `ResultData.ranked` diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 981c10da7d1..04bd47b4b73 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -2882,6 +2882,9 @@ impl PlatformWalletPersistence for FFIPersister { } | platform_wallet::wallet::shielded::ShieldedActivityKind::ShieldFromIdentity { identity_id, + } + | platform_wallet::wallet::shielded::ShieldedActivityKind::IdentityTopUp { + identity_id, } => (*identity_id, 1u8), _ => ([0u8; 32], 0u8), }; @@ -3396,6 +3399,9 @@ impl PlatformWalletPersistence for FFIPersister { 8 => ShieldedActivityKind::ShieldFromIdentity { identity_id: ffi.identity_id, }, + 9 => ShieldedActivityKind::IdentityTopUp { + identity_id: ffi.identity_id, + }, // 7 and any unknown tag fall back to the // residual — a forward-compat tag we don't yet // model still loads as an opaque spend rather diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 4b5b4f29ab8..0227eb30850 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -45,7 +45,8 @@ use std::os::raw::c_char; use dashcore::hashes::Hash; use dpp::address_funds::{OrchardAddress, PlatformAddress}; use dpp::shielded::{ - compute_minimum_shielded_fee, compute_shielded_unshield_fee, compute_shielded_verification_fee, + compute_minimum_shielded_fee, compute_shielded_identity_top_up_fee, + compute_shielded_unshield_fee, compute_shielded_verification_fee, compute_shielded_withdrawal_fee, ShieldedMemo, }; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; @@ -173,6 +174,7 @@ fn shielded_fee_formula( 1 => Some(compute_shielded_unshield_fee), 2 => Some(compute_shielded_withdrawal_fee), 3 => Some(compute_shielded_verification_fee), + 4 => Some(compute_shielded_identity_top_up_fee), _ => None, } } @@ -190,7 +192,9 @@ fn shielded_fee_formula( /// the flat Core withdrawal-document cost), /// - `3` → ShieldFromIdentity (`compute_shielded_verification_fee`: the /// compute-only floor; the note and identity writes are metered at -/// execution and charged to the identity on top of it). +/// execution and charged to the identity on top of it), +/// - `4` → IdentityTopUpFromShieldedPool (`compute_shielded_identity_top_up_fee`: +/// base + the flat identity-balance write cost, carved from the value balance). /// /// `num_actions` is the Orchard action count of the bundle the host will /// build (a single-note spend with change is 2 actions). The fee is @@ -222,7 +226,7 @@ pub unsafe extern "C" fn platform_wallet_shielded_estimate_fee( let Some(formula) = shielded_fee_formula(kind) else { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("unknown shielded fee kind {kind} (expected 0/1/2/3)"), + format!("unknown shielded fee kind {kind} (expected 0/1/2/3/4)"), ); }; let Some(platform_version) = @@ -1240,6 +1244,67 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield_from_identity( } } +/// Top up an existing Platform identity's balance from the wallet's shielded +/// notes: the Type 22 `IdentityTopUpFromShieldedPool` transition. The identity +/// receives `amount`; the flat pool-paid fee (`platform_wallet_shielded_estimate_fee` +/// kind 4) is spent from the notes on top. The identity only has to exist on +/// Platform; it does not have to be managed by this wallet. +/// +/// `identity_id` is the 32-byte identity id. `mnemonic_resolver_handle` +/// supplies the transient spend authority exactly as for +/// `platform_wallet_manager_shielded_unshield`. +/// +/// # Safety +/// - `wallet_id_bytes` and `identity_id` must each point to 32 readable bytes. +/// - `mnemonic_resolver_handle` must be a valid, non-destroyed handle. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_top_up_from_pool( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + identity_id: *const u8, + amount: u64, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(identity_id); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let identity_id = unwrap_result_or_return!(read_identifier(identity_id)); + + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, + Err(result) => return result, + }; + + let seed = match crate::identity_keys_from_mnemonic::resolve_seed_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + ) { + Ok(seed) => seed, + Err(result) => return result, + }; + + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + let r = wallet + .shielded_identity_top_up_from_pool( + &coordinator, + seed.as_ref(), + account, + &identity_id, + amount, + &prover, + ) + .await; + poke_sync_on_unconfirmed(&r, handle); + r + }); + map_spend_result(result, "shielded identity top up from pool") +} + /// Shield: spend credits from a Platform Payment account into a /// THIRD-PARTY shielded pool — the Type 15 shield with the note /// assigned to `recipient_raw_43` (the recipient's raw 43-byte diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 1de721ca7eb..7a2a5537564 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1428,6 +1428,37 @@ impl PlatformWallet { .await } + /// Top up an existing Platform identity's balance from `account`'s + /// shielded notes (`IdentityTopUpFromShieldedPool`, type 22). The identity + /// need not belong to this wallet: it only has to exist on Platform. The + /// identity receives `amount`; the flat pool-paid fee comes out of the + /// spent notes on top. `seed` supplies the transient spend authority (see + /// [`shielded_transfer_to`](Self::shielded_transfer_to)). + #[cfg(feature = "shielded")] + pub async fn shielded_identity_top_up_from_pool( + &self, + coordinator: &Arc, + seed: &[u8], + account: u32, + identity_id: &Identifier, + amount: u64, + prover: P, + ) -> Result<(), PlatformWalletError> { + let keyset = self.derive_spend_keyset(seed, account).await?; + super::shielded::operations::identity_top_up_from_pool( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + &keyset, + account, + *identity_id, + amount, + &prover, + ) + .await + } + /// Withdraw from `account`'s notes to a Core L1 address /// (Base58Check string). `core_fee_per_byte` is the L1 fee /// rate (duffs/byte). `seed` supplies the transient spend diff --git a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs index dd1d5f08c74..b9b3b08f882 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs @@ -105,6 +105,11 @@ pub enum ShieldedActivityKind { /// The funding identity's id (32 bytes). identity_id: [u8; 32], }, + /// Type 22: shielded pool → an existing Platform identity's balance. + IdentityTopUp { + /// The credited identity's id (32 bytes). + identity_id: [u8; 32], + }, /// Own spend whose outputs are all self-change and which no /// correlation arm could refine. The honest residual on the restore /// path. Carries `fee: None` because the exact fee is underivable @@ -128,6 +133,7 @@ impl ShieldedActivityKind { ShieldedActivityKind::IdentityCreate { .. } => 6, ShieldedActivityKind::ShieldedSpend => 7, ShieldedActivityKind::ShieldFromIdentity { .. } => 8, + ShieldedActivityKind::IdentityTopUp { .. } => 9, } } } @@ -1187,6 +1193,9 @@ mod tests { ShieldedActivityKind::ShieldFromIdentity { identity_id: [0u8; 32], }, + ShieldedActivityKind::IdentityTopUp { + identity_id: [0u8; 32], + }, ]; let tags: Set = kinds.iter().map(|k| k.tag()).collect(); assert_eq!(tags.len(), kinds.len(), "every kind tag must be distinct"); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs index b4e0b7ea81f..c4b52e5a52b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs @@ -9,7 +9,8 @@ use crate::error::PlatformWalletError; use dpp::fee::Credits; use dpp::shielded::{ compute_minimum_shielded_fee, compute_shielded_identity_create_fee, - compute_shielded_unshield_fee, compute_shielded_withdrawal_fee, + compute_shielded_identity_top_up_fee, compute_shielded_unshield_fee, + compute_shielded_withdrawal_fee, }; use dpp::version::PlatformVersion; use dpp::ProtocolError; @@ -46,6 +47,9 @@ pub enum ShieldedFeeKind { /// Number of public keys in the new identity (the fee scales per key). num_keys: usize, }, + /// `compute_shielded_identity_top_up_fee`: IdentityTopUpFromShieldedPool (base plus the flat + /// identity-balance write cost, the Unshield model with an identity as the output). + IdentityTopUp, } impl ShieldedFeeKind { @@ -66,6 +70,9 @@ impl ShieldedFeeKind { ShieldedFeeKind::IdentityCreate { num_keys } => { compute_shielded_identity_create_fee(num_actions, num_keys, platform_version) } + ShieldedFeeKind::IdentityTopUp => { + compute_shielded_identity_top_up_fee(num_actions, platform_version) + } } } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 0754bde09eb..58b4ceb781c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -51,7 +51,8 @@ use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::prelude::Identifier; use dpp::shielded::builder::{ - build_identity_create_from_shielded_pool_transition, build_shield_from_identity_transition, + build_identity_create_from_shielded_pool_transition, + build_identity_top_up_from_shielded_pool_transition, build_shield_from_identity_transition, build_shield_transition, build_shielded_transfer_transition, build_shielded_withdrawal_transition, build_unshield_transition, OrchardProver, SpendableNote, }; @@ -265,6 +266,7 @@ fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] use dpp::state_transition::shielded_transfer_transition::accessors::ShieldedTransferTransitionAccessorsV0; use dpp::state_transition::shielded_withdrawal_transition::accessors::ShieldedWithdrawalTransitionAccessorsV0; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; + use dpp::state_transition::identity_top_up_from_shielded_pool_transition::accessors::IdentityTopUpFromShieldedPoolTransitionAccessorsV0; use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; use dpp::state_transition::unshield_transition::accessors::UnshieldTransitionAccessorsV0; @@ -276,6 +278,7 @@ fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] StateTransition::ShieldedWithdrawal(t) => t.actions(), StateTransition::IdentityCreateFromShieldedPool(t) => t.actions(), StateTransition::ShieldFromIdentity(t) => t.actions(), + StateTransition::IdentityTopUpFromShieldedPool(t) => t.actions(), _ => &[], } } @@ -980,6 +983,147 @@ pub async fn shield_from_identity_to< Ok(new_balance) } +// ------------------------------------------------------------------------- +// IdentityTopUpFromShieldedPool: shielded pool -> existing identity (Type 22) +// ------------------------------------------------------------------------- + +/// Top up an existing Platform identity's balance from `account`'s shielded +/// notes. Mirrors [`unshield`]: notes are reserved for `amount + fee` under +/// [`ShieldedFeeKind::IdentityTopUp`], the spend bundle binds the identity and +/// gross amount into its sighash, and the broadcast is redrive-safe. The +/// identity receives `amount` (the fee is carved from the value balance). +/// Waits for proven execution before marking notes spent. +#[allow(clippy::too_many_arguments)] +pub async fn identity_top_up_from_pool( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + identity_id: Identifier, + amount: u64, + prover: &P, +) -> Result<(), PlatformWalletError> { + let views = keys.viewing_keys(); + let change_addr = default_orchard_address(&views)?; + let id = SubwalletId::new(wallet_id, account); + + let (selected_notes, total_input, exact_fee) = + reserve_unspent_notes(sdk, store, id, amount, 2, ShieldedFeeKind::IdentityTopUp).await?; + + info!( + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), + total_input, + identity = %identity_id, + "IdentityTopUpFromShieldedPool" + ); + + let mut pending_entry = None; + let result = async { + let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; + let anchor_bytes = anchor.to_bytes(); + + let (state_transition, fee_used) = build_identity_top_up_from_shielded_pool_transition( + spends, + identity_id, + amount, + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, + prover, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + debug_assert_eq!( + fee_used, exact_fee, + "builder fee must match the reserved identity top up fee" + ); + + pending_entry = record_pending_activity( + store, + persister, + wallet_id, + id, + &views, + LiveEntryParams { + kind: ShieldedActivityKind::IdentityTopUp { + identity_id: identity_id.to_buffer(), + }, + direction: ShieldedDirection::Out, + amount, + fee: Some(fee_used), + counterparty: Some(identity_id.to_vec()), + memo: None, + actions: shielded_actions(&state_transition), + spent_notes: &selected_notes, + }, + ) + .await; + arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; + + trace!("IdentityTopUpFromShieldedPool: state transition built, broadcasting..."); + broadcast_shielded_spend_with_redrive( + sdk, + store, + id, + &pending_entry, + anchor_bytes, + &selected_notes, + &state_transition, + "identity top up from shielded pool", + ) + .await + } + .await; + + match result { + Ok(()) => { + record_activity_status( + store, + persister, + wallet_id, + id, + &pending_entry, + ShieldedActivityStatus::Confirmed, + None, + ) + .await; + if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await + { + warn!( + account, + error = %e, + "IdentityTopUpFromShieldedPool broadcast succeeded but local spent-state \ + update failed; will heal on next sync" + ); + } + info!(account, credits = amount, identity = %identity_id, "IdentityTopUpFromShieldedPool broadcast succeeded"); + Ok(()) + } + Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => Err(e), + Err(e) => { + record_activity_status( + store, + persister, + wallet_id, + id, + &pending_entry, + ShieldedActivityStatus::Failed, + None, + ) + .await; + cancel_pending(store, id, &selected_notes).await; + Err(e) + } + } +} + // ------------------------------------------------------------------------- // ShieldFromAssetLock: Core L1 asset lock -> shielded pool (Type 18) // (orchestrated entry point lives in `wallet/shielded/fund_from_asset_lock.rs`) diff --git a/packages/rs-sdk/src/platform/transition.rs b/packages/rs-sdk/src/platform/transition.rs index 74a823667d2..d4c3e491cd4 100644 --- a/packages/rs-sdk/src/platform/transition.rs +++ b/packages/rs-sdk/src/platform/transition.rs @@ -10,6 +10,8 @@ pub(crate) mod broadcast_identity; pub mod broadcast_request; #[cfg(feature = "shielded")] pub mod identity_create_from_shielded_pool; +/// Shielded pool to an existing identity's balance. +pub mod identity_top_up_from_shielded_pool; pub mod masternode_vote_keys; pub mod purchase_document; pub mod put_contract; diff --git a/packages/rs-sdk/src/platform/transition/identity_top_up_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/identity_top_up_from_shielded_pool.rs new file mode 100644 index 00000000000..7271935e306 --- /dev/null +++ b/packages/rs-sdk/src/platform/transition/identity_top_up_from_shielded_pool.rs @@ -0,0 +1,84 @@ +//! Top up an existing identity's balance from the shielded pool. + +use super::broadcast::BroadcastStateTransition; +use super::put_settings::PutSettings; +use super::validation::ensure_valid_state_transition_structure; +use crate::{Error, Sdk}; +use dpp::platform_value::Identifier; +use dpp::shielded::OrchardBundleParams; +use dpp::state_transition::identity_top_up_from_shielded_pool_transition::methods::IdentityTopUpFromShieldedPoolTransitionMethodsV0; +use dpp::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; +use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::StateTransition; + +/// Spend shielded notes to top up an existing identity's balance (type 21 sibling of +/// `Unshield` with the identity as the output). The bundle must be an already proven +/// Orchard spend whose binding signature commits to `identity_id` and `top_up_amount` +/// (see `dpp::shielded::builder::build_identity_top_up_from_shielded_pool_transition`). +#[async_trait::async_trait] +pub trait IdentityTopUpFromShieldedPool { + /// Build and structure-check the transition without broadcasting it. + fn identity_top_up_from_shielded_pool_transition( + &self, + identity_id: Identifier, + top_up_amount: u64, + bundle: OrchardBundleParams, + ) -> Result; + + /// Build, broadcast, and wait for proven execution. Returns the + /// `VerifiedIdentityWithShieldedNullifiers` proof result (the credited identity and + /// the spent nullifiers), so a wallet only marks notes spent once the top-up is + /// cryptographically proven included. + async fn identity_top_up_from_shielded_pool( + &self, + identity_id: Identifier, + top_up_amount: u64, + bundle: OrchardBundleParams, + settings: Option, + ) -> Result; +} + +#[async_trait::async_trait] +impl IdentityTopUpFromShieldedPool for Sdk { + fn identity_top_up_from_shielded_pool_transition( + &self, + identity_id: Identifier, + top_up_amount: u64, + bundle: OrchardBundleParams, + ) -> Result { + let OrchardBundleParams { + actions, + anchor, + proof, + binding_signature, + } = bundle; + + let state_transition = IdentityTopUpFromShieldedPoolTransition::try_from_bundle( + identity_id, + actions, + top_up_amount, + anchor, + proof, + binding_signature, + self.version(), + )?; + ensure_valid_state_transition_structure(&state_transition, self.version())?; + + Ok(state_transition) + } + + async fn identity_top_up_from_shielded_pool( + &self, + identity_id: Identifier, + top_up_amount: u64, + bundle: OrchardBundleParams, + settings: Option, + ) -> Result { + let state_transition = + self.identity_top_up_from_shielded_pool_transition(identity_id, top_up_amount, bundle)?; + let proof_result = state_transition + .broadcast_and_wait_for_affected_state::(self, settings) + .await?; + Ok(proof_result) + } +} diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index cbb5470b7e0..29790d1f0d7 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -89,8 +89,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_proverI /// The flat shielded fee in credits for a transition of the given `kind` /// (`0` = ShieldedTransfer/Shield, `1` = Unshield, `2` = ShieldedWithdrawal, -/// `3` = ShieldFromIdentity: the compute-only floor, no storage component) -/// and Orchard action `count` (a single-note spend with change is 2 +/// `3` = ShieldFromIdentity: the compute-only floor, no storage component, +/// `4` = IdentityTopUpFromShieldedPool: base + the flat identity-balance +/// write cost) and Orchard action `count` (a single-note spend with change is 2 /// actions), computed at `managerHandle`'s network-tracked platform /// version — the same version the shielded builders carve fees with. No /// network round-trip. Throws on an unknown kind, an invalid manager @@ -1037,6 +1038,57 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde }) } +/// Shielded to existing-identity top-up (Type 22), bridging +/// `platform_wallet_manager_shielded_identity_top_up_from_pool`. +/// +/// Mirrors Swift's `PlatformWalletManager.shieldedIdentityTopUpFromPool` +/// (`PlatformWalletManagerShieldedSync.swift`): spends notes from +/// `account` on `walletId` and credits `amount` to the EXISTING identity +/// `identity_id` (32 bytes). The identity only has to exist on Platform; +/// it does not have to be one this wallet manages. The flat pool-paid fee +/// (`estimateShieldedFee` kind 4) is spent from the notes on top of +/// `amount`. `resolver_handle` supplies the transient spend authority +/// exactly as for [`Java_..._shieldedUnshield`]. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shieldedIdentityTopUpFromPool( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + resolver_handle: jlong, + account: jint, + identity_id: JByteArray, + amount: jlong, +) { + guard(&mut env, (), |env| { + if amount <= 0 { + throw_sdk_exception(env, 1, "amount must be positive"); + return; + } + if account < 0 { + throw_sdk_exception(env, 1, "account must be non-negative"); + return; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return; + }; + let Some(ident) = read_id32(env, &identity_id, "identityId") else { + return; + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_shielded_identity_top_up_from_pool( + manager_handle as Handle, + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + account as u32, + ident.as_ptr(), + amount as u64, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + /// Shielded → Core L1 withdrawal (Type 19) — bridges /// `platform_wallet_manager_shielded_withdraw`. /// diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift index 823d8008458..d00a7f1e0dc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift @@ -47,7 +47,7 @@ public final class PersistentShieldedActivity { /// Kind discriminant (`ShieldedActivityKind::tag`): 0 Shield, /// 1 ShieldFromAssetLock, 2 Received, 3 Sent, 4 Unshield, /// 5 Withdrawal, 6 IdentityCreate, 7 ShieldedSpend, - /// 8 ShieldFromIdentity. + /// 8 ShieldFromIdentity, 9 IdentityTopUp (from the pool). public var kindTag: Int /// Direction: 0 In, 1 Out, 2 Self. public var direction: Int @@ -88,7 +88,8 @@ public final class PersistentShieldedActivity { /// Identity id (32 bytes) when the kind carries one: the created /// identity for `kindTag == 6` (IdentityCreate), the debited identity - /// for `kindTag == 8` (ShieldFromIdentity). Empty otherwise. + /// for `kindTag == 8` (ShieldFromIdentity), the credited identity for + /// `kindTag == 9` (IdentityTopUp from the pool). Empty otherwise. public var identityId: Data /// Counterparty bytes (43B Orchard / 21B PlatformAddress / Core /// script) when present; empty otherwise. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 9fd3c71eb17..2b8e6b5e5b2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -473,6 +473,11 @@ extension PlatformWalletManager { /// processing) this transition adds on top of its GroveDB-metered /// storage, so storage is never double-counted. case shieldFromIdentity = 3 + /// IdentityTopUpFromShieldedPool + /// (`compute_shielded_identity_top_up_fee`): the base flat fee plus + /// the flat identity-balance write cost, carved from the value + /// balance so the pool pays it. + case identityTopUpFromPool = 4 } /// Consensus-pinned flat shielded fee (in credits) for a pool-paid @@ -969,7 +974,7 @@ extension PlatformWalletManager { let handle = self.handle try await Task.detached(priority: .userInitiated) { - // Guaranteed resolver keepalive across the FFI call — + // Guaranteed resolver keepalive across the FFI call : // same rationale as `shieldedTransfer`. try withExtendedLifetime(resolver) { try walletId.withUnsafeBytes { widRaw in @@ -987,6 +992,79 @@ extension PlatformWalletManager { }.value } + /// Shielded → an EXISTING identity's balance. The Type 22 + /// `IdentityTopUpFromShieldedPool` transition: notes from + /// `walletId`'s shielded balance on `account` are spent so that + /// `identityId` receives `amount` credits. The flat pool-paid fee + /// (`estimateShieldedFee(kind: .identityTopUpFromPool)`) is spent + /// from the notes on top of `amount`. + /// + /// The identity only has to exist on Platform; it does NOT have to + /// be managed by this wallet, and no identity-side signer is + /// involved: the only spend authority is the Orchard one `resolver` + /// supplies per operation (see [`shieldedTransfer`]), exactly as for + /// [`shieldedUnshield`]. + /// + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the + /// broadcast was accepted but its execution result couldn't be + /// confirmed: the spend may already be on chain, so the caller must + /// NOT retry (the spent notes stay reserved Rust-side; the next + /// shielded sync reconciles them). + public func shieldedIdentityTopUpFromPool( + walletId: Data, + resolver: MnemonicResolver, + account: UInt32 = 0, + identityId: Data, + amount: UInt64 + ) async throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be exactly 32 bytes" + ) + } + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "identityId must be exactly 32 bytes" + ) + } + guard let resolverHandle = resolver.handle else { + throw PlatformWalletError.invalidParameter( + "MnemonicResolver has no handle" + ) + } + + let handle = self.handle + try await Task.detached(priority: .userInitiated) { + // Guaranteed resolver keepalive across the FFI call : + // same rationale as `shieldedTransfer`. + try withExtendedLifetime(resolver) { + try walletId.withUnsafeBytes { widRaw in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try identityId.withUnsafeBytes { idRaw in + guard let idPtr = idRaw.baseAddress? + .assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter( + "identityId baseAddress is nil" + ) + } + try platform_wallet_manager_shielded_identity_top_up_from_pool( + handle, widPtr, resolverHandle, account, idPtr, amount + ).check() + } + } + } + }.value + } + /// Shielded → Core L1 withdraw. Spends notes from `walletId`'s /// shielded balance and creates an L1 withdrawal to /// `toCoreAddress` (Base58Check string). `coreFeePerByte` is @@ -1026,7 +1104,7 @@ extension PlatformWalletManager { let handle = self.handle try await Task.detached(priority: .userInitiated) { - // Guaranteed resolver keepalive across the FFI call — + // Guaranteed resolver keepalive across the FFI call : // same rationale as `shieldedTransfer`. try withExtendedLifetime(resolver) { try walletId.withUnsafeBytes { widRaw in diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift index 887ea555a07..2f2fb11379a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/ShieldedActivityView.swift @@ -25,6 +25,7 @@ enum ShieldedActivityKindDisplay { case 5: return "Withdrawn" case 6: return "Identity Created" case 8: return "Shielded from Identity" + case 9: return "Identity Top-Up from Pool" // 7 (ShieldedSpend) and any tag this build doesn't know yet. default: return "Shielded Spend" } @@ -40,7 +41,9 @@ enum ShieldedActivityKindDisplay { case 3: return "arrow.up.circle.fill" // Sent case 4: return "lock.open.fill" // Unshield case 5: return "arrow.up.right.circle.fill" // Withdrawal - case 6: return "person.crop.circle.badge.plus" // IdentityCreate + // IdentityCreate / IdentityTopUp: both send pool value out to a + // Platform identity's balance. + case 6, 9: return "person.crop.circle.badge.plus" default: return "questionmark.circle.fill" // ShieldedSpend } } @@ -282,10 +285,18 @@ struct ShieldedActivityDetailView: View { } } - // Both identity-bearing kinds carry `identityId`: 6 is the - // identity that was created, 8 the identity that was debited. - if entry.kindTag == 6 || entry.kindTag == 8, entry.identityId.count == 32 { - Section(entry.kindTag == 6 ? "Created Identity" : "Source Identity") { + // Every identity-bearing kind carries `identityId`: 6 is the + // identity that was created, 8 the identity that was debited, + // 9 the identity the pool topped up. + if entry.kindTag == 6 || entry.kindTag == 8 || entry.kindTag == 9, + entry.identityId.count == 32 { + // An expression, not a `switch` statement: a bare + // statement here would be parsed as a view-producing + // branch by the enclosing ViewBuilder. + let identitySectionTitle = entry.kindTag == 6 + ? "Created Identity" + : (entry.kindTag == 9 ? "Topped-Up Identity" : "Source Identity") + Section(identitySectionTitle) { let idHex = entry.identityId.map { String(format: "%02x", $0) }.joined() Text(idHex) .font(.caption.monospaced()) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index ae9fbd4d0f2..e70f8ce4abe 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -2438,6 +2438,7 @@ struct ShieldedActivityStorageDetailView: View { case 6: name = "IdentityCreate" case 7: name = "ShieldedSpend" case 8: name = "ShieldFromIdentity" + case 9: name = "IdentityTopUpFromPool" default: return "Unknown(\(tag))" } return "\(name) (\(tag))" diff --git a/packages/wasm-dpp/src/state_transition/state_transition_factory.rs b/packages/wasm-dpp/src/state_transition/state_transition_factory.rs index c2d316cdca4..dc43e1b04da 100644 --- a/packages/wasm-dpp/src/state_transition/state_transition_factory.rs +++ b/packages/wasm-dpp/src/state_transition/state_transition_factory.rs @@ -85,7 +85,8 @@ impl StateTransitionFactoryWasm { | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) - | StateTransition::ShieldFromIdentity(_) => Err(JsValue::from_str( + | StateTransition::ShieldFromIdentity(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) => Err(JsValue::from_str( "shielded transitions are not yet supported in wasm-dpp StateTransitionFactory", )), }, diff --git a/packages/wasm-dpp2/src/lib.rs b/packages/wasm-dpp2/src/lib.rs index 3270d7dbd23..8b637e98720 100644 --- a/packages/wasm-dpp2/src/lib.rs +++ b/packages/wasm-dpp2/src/lib.rs @@ -70,9 +70,9 @@ pub use platform_address::{ outputs_to_optional_btree_map, }; pub use shielded::{ - AddressWitnessWasm, SerializedOrchardActionWasm, ShieldFromAssetLockTransitionWasm, - ShieldFromIdentityTransitionWasm, ShieldTransitionWasm, ShieldedTransferTransitionWasm, - ShieldedWithdrawalTransitionWasm, UnshieldTransitionWasm, + AddressWitnessWasm, IdentityTopUpFromShieldedPoolTransitionWasm, SerializedOrchardActionWasm, + ShieldFromAssetLockTransitionWasm, ShieldFromIdentityTransitionWasm, ShieldTransitionWasm, + ShieldedTransferTransitionWasm, ShieldedWithdrawalTransitionWasm, UnshieldTransitionWasm, }; pub use state_transitions::base::{GroupStateTransitionInfoWasm, StateTransitionWasm}; pub use state_transitions::proof_result::{StateTransitionProofResultTypeJs, convert_proof_result}; diff --git a/packages/wasm-dpp2/src/shielded/identity_top_up_from_shielded_pool_transition.rs b/packages/wasm-dpp2/src/shielded/identity_top_up_from_shielded_pool_transition.rs new file mode 100644 index 00000000000..79b5fbbdaa8 --- /dev/null +++ b/packages/wasm-dpp2/src/shielded/identity_top_up_from_shielded_pool_transition.rs @@ -0,0 +1,255 @@ +use crate::error::{WasmDppError, WasmDppResult}; +use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; +use crate::shielded::orchard_action::{SerializedOrchardActionWasm, actions_from_js_options}; +use crate::state_transitions::StateTransitionWasm; +use crate::utils::try_vec_to_fixed_bytes; +use crate::{impl_wasm_conversions_inner, impl_wasm_type_info}; +use dpp::platform_value::string_encoding::Encoding::{Base64, Hex}; +use dpp::platform_value::string_encoding::{decode, encode}; +use dpp::serialization::{PlatformDeserializable, PlatformSerializable}; +use dpp::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; +use dpp::state_transition::identity_top_up_from_shielded_pool_transition::accessors::IdentityTopUpFromShieldedPoolTransitionAccessorsV0; +use dpp::state_transition::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0; +use dpp::state_transition::{StateTransition, StateTransitionLike}; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(typescript_custom_section)] +const TS_TYPES: &str = r#" +/** + * Options for constructing an IdentityTopUpFromShieldedPoolTransition (shielded pool to an + * existing identity's balance). The bundle is an Orchard spend whose binding signature commits + * to identityId and topUpAmount; there is no platform signature. + */ +export interface IdentityTopUpFromShieldedPoolTransitionOptions { + identityId: IdentifierLike; + actions: SerializedOrchardAction[]; + topUpAmount: bigint; + anchor: Uint8Array; + proof: Uint8Array; + bindingSignature: Uint8Array; +} + +export interface IdentityTopUpFromShieldedPoolTransitionObject { + $formatVersion: string; + identityId: Uint8Array; + actions: SerializedOrchardActionObject[]; + topUpAmount: bigint; + anchor: Uint8Array; + proof: Uint8Array; + bindingSignature: Uint8Array; +} + +export interface IdentityTopUpFromShieldedPoolTransitionJSON { + $formatVersion: string; + identityId: string; + actions: SerializedOrchardActionJSON[]; + topUpAmount: number | string; + anchor: string; + proof: string; + bindingSignature: string; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "IdentityTopUpFromShieldedPoolTransitionOptions")] + pub type IdentityTopUpFromShieldedPoolTransitionOptionsJs; + + #[wasm_bindgen(typescript_type = "IdentityTopUpFromShieldedPoolTransitionObject")] + pub type IdentityTopUpFromShieldedPoolTransitionObjectJs; + + #[wasm_bindgen(typescript_type = "IdentityTopUpFromShieldedPoolTransitionJSON")] + pub type IdentityTopUpFromShieldedPoolTransitionJSONJs; +} + +/// Non-WASM-instance fields extracted from the constructor options via serde. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct IdentityTopUpFromShieldedPoolTransitionSimpleFields { + top_up_amount: u64, + anchor: Vec, + proof: Vec, + binding_signature: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(transparent)] +#[wasm_bindgen(js_name = IdentityTopUpFromShieldedPoolTransition)] +pub struct IdentityTopUpFromShieldedPoolTransitionWasm(IdentityTopUpFromShieldedPoolTransition); + +impl From for IdentityTopUpFromShieldedPoolTransitionWasm { + fn from(v: IdentityTopUpFromShieldedPoolTransition) -> Self { + IdentityTopUpFromShieldedPoolTransitionWasm(v) + } +} + +impl From for IdentityTopUpFromShieldedPoolTransition { + fn from(v: IdentityTopUpFromShieldedPoolTransitionWasm) -> Self { + v.0 + } +} + +#[wasm_bindgen(js_class = IdentityTopUpFromShieldedPoolTransition)] +impl IdentityTopUpFromShieldedPoolTransitionWasm { + #[wasm_bindgen(constructor)] + pub fn new( + options: IdentityTopUpFromShieldedPoolTransitionOptionsJs, + ) -> WasmDppResult { + let js_opts: &JsValue = options.as_ref(); + + let identity_id: IdentifierWasm = crate::utils::try_from_options(&options, "identityId")?; + let actions = actions_from_js_options(js_opts, "actions")?; + + let fields: IdentityTopUpFromShieldedPoolTransitionSimpleFields = + serde_wasm_bindgen::from_value(options.into()) + .map_err(|e| WasmDppError::invalid_argument(e.to_string()))?; + + let anchor: [u8; 32] = try_vec_to_fixed_bytes(fields.anchor, "anchor")?; + let binding_signature: [u8; 64] = + try_vec_to_fixed_bytes(fields.binding_signature, "bindingSignature")?; + + Ok(IdentityTopUpFromShieldedPoolTransitionWasm( + IdentityTopUpFromShieldedPoolTransition::V0( + IdentityTopUpFromShieldedPoolTransitionV0 { + identity_id: identity_id.into(), + actions: actions.into_iter().map(Into::into).collect(), + top_up_amount: fields.top_up_amount, + anchor, + proof: fields.proof, + binding_signature, + }, + ), + )) + } + + /// The identity whose balance receives the top-up. + #[wasm_bindgen(getter = "identityId")] + pub fn identity_id(&self) -> IdentifierWasm { + self.0.identity_id().into() + } + + #[wasm_bindgen(setter = "identityId")] + pub fn set_identity_id(&mut self, identity_id: IdentifierLikeJs) -> WasmDppResult<()> { + self.0.set_identity_id(identity_id.try_into()?); + Ok(()) + } + + /// Returns the serialized Orchard actions. + #[wasm_bindgen(getter = "actions")] + pub fn actions(&self) -> Vec { + self.0 + .actions() + .iter() + .cloned() + .map(SerializedOrchardActionWasm::from) + .collect() + } + + /// Gross credits leaving the pool; the identity receives this minus the flat fee. + #[wasm_bindgen(getter = "topUpAmount")] + pub fn top_up_amount(&self) -> u64 { + self.0.top_up_amount() + } + + #[wasm_bindgen(getter = "anchor")] + pub fn anchor(&self) -> Vec { + self.0.anchor().to_vec() + } + + #[wasm_bindgen(getter = "proof")] + pub fn proof(&self) -> Vec { + self.0.proof().to_vec() + } + + #[wasm_bindgen(getter = "bindingSignature")] + pub fn binding_signature(&self) -> Vec { + self.0.binding_signature().to_vec() + } + + #[wasm_bindgen(js_name = getModifiedDataIds)] + pub fn modified_data_ids(&self) -> Vec { + self.0 + .modified_data_ids() + .into_iter() + .map(IdentifierWasm::from) + .collect() + } + + #[wasm_bindgen(js_name = toBytes)] + pub fn to_bytes(&self) -> WasmDppResult> { + Ok(PlatformSerializable::serialize_to_bytes( + &StateTransition::IdentityTopUpFromShieldedPool(self.0.clone()), + )?) + } + + #[wasm_bindgen(js_name = "toHex")] + pub fn to_hex(&self) -> WasmDppResult { + Ok(encode(self.to_bytes()?.as_slice(), Hex)) + } + + #[wasm_bindgen(js_name = "toBase64")] + pub fn to_base64(&self) -> WasmDppResult { + Ok(encode(self.to_bytes()?.as_slice(), Base64)) + } + + #[wasm_bindgen(js_name = fromBytes)] + pub fn from_bytes( + bytes: Vec, + ) -> WasmDppResult { + let st = StateTransition::deserialize_from_bytes(&bytes)?; + match st { + StateTransition::IdentityTopUpFromShieldedPool(inner) => Ok(inner.into()), + _ => Err(WasmDppError::invalid_argument( + "Invalid state transition type: expected IdentityTopUpFromShieldedPool", + )), + } + } + + #[wasm_bindgen(js_name = "fromHex")] + pub fn from_hex(hex: String) -> WasmDppResult { + let bytes = + decode(hex.as_str(), Hex).map_err(|e| WasmDppError::serialization(e.to_string()))?; + IdentityTopUpFromShieldedPoolTransitionWasm::from_bytes(bytes) + } + + #[wasm_bindgen(js_name = "fromBase64")] + pub fn from_base64( + base64: String, + ) -> WasmDppResult { + let bytes = decode(base64.as_str(), Base64) + .map_err(|e| WasmDppError::serialization(e.to_string()))?; + IdentityTopUpFromShieldedPoolTransitionWasm::from_bytes(bytes) + } + + #[wasm_bindgen(js_name = toStateTransition)] + pub fn to_state_transition(&self) -> StateTransitionWasm { + StateTransition::IdentityTopUpFromShieldedPool(self.0.clone()).into() + } + + #[wasm_bindgen(js_name = "fromStateTransition")] + pub fn from_state_transition( + st: &StateTransitionWasm, + ) -> WasmDppResult { + let rs_st: StateTransition = st.clone().into(); + match rs_st { + StateTransition::IdentityTopUpFromShieldedPool(inner) => Ok(inner.into()), + _ => Err(WasmDppError::invalid_argument( + "Invalid state transition type: expected IdentityTopUpFromShieldedPool", + )), + } + } +} + +impl_wasm_conversions_inner!( + IdentityTopUpFromShieldedPoolTransitionWasm, + IdentityTopUpFromShieldedPoolTransition, + IdentityTopUpFromShieldedPoolTransition, + IdentityTopUpFromShieldedPoolTransitionObjectJs, + IdentityTopUpFromShieldedPoolTransitionJSONJs +); + +impl_wasm_type_info!( + IdentityTopUpFromShieldedPoolTransitionWasm, + IdentityTopUpFromShieldedPoolTransition +); diff --git a/packages/wasm-dpp2/src/shielded/mod.rs b/packages/wasm-dpp2/src/shielded/mod.rs index 08064a13792..657b3b47b34 100644 --- a/packages/wasm-dpp2/src/shielded/mod.rs +++ b/packages/wasm-dpp2/src/shielded/mod.rs @@ -1,5 +1,6 @@ pub mod address_witness; pub mod identity_create_from_shielded_pool_transition; +pub mod identity_top_up_from_shielded_pool_transition; pub mod orchard_action; pub mod shield_from_asset_lock_transition; pub mod shield_from_identity_transition; @@ -10,6 +11,7 @@ pub mod unshield_transition; pub use address_witness::{AddressWitnessWasm, input_witnesses_from_js_options}; pub use identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransitionWasm; +pub use identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransitionWasm; pub use orchard_action::{SerializedOrchardActionWasm, actions_from_js_options}; pub use shield_from_asset_lock_transition::ShieldFromAssetLockTransitionWasm; pub use shield_from_identity_transition::ShieldFromIdentityTransitionWasm; diff --git a/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs b/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs index 4f613c16796..d99ab4b6686 100644 --- a/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs +++ b/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs @@ -327,6 +327,7 @@ impl StateTransitionWasm { ShieldedWithdrawal(_) => 19, IdentityCreateFromShieldedPool(_) => 20, ShieldFromIdentity(_) => 21, + IdentityTopUpFromShieldedPool(_) => 22, } } @@ -417,7 +418,8 @@ impl StateTransitionWasm { | Unshield(_) | ShieldFromAssetLock(_) | ShieldedWithdrawal(_) - | IdentityCreateFromShieldedPool(_) => None, + | IdentityCreateFromShieldedPool(_) + | IdentityTopUpFromShieldedPool(_) => None, } } @@ -446,7 +448,8 @@ impl StateTransitionWasm { | Unshield(_) | ShieldFromAssetLock(_) | ShieldedWithdrawal(_) - | IdentityCreateFromShieldedPool(_) => None, + | IdentityCreateFromShieldedPool(_) + | IdentityTopUpFromShieldedPool(_) => None, } } @@ -593,7 +596,8 @@ impl StateTransitionWasm { | Unshield(_) | ShieldFromAssetLock(_) | ShieldedWithdrawal(_) - | IdentityCreateFromShieldedPool(_) => { + | IdentityCreateFromShieldedPool(_) + | IdentityTopUpFromShieldedPool(_) => { return Err(WasmDppError::invalid_argument( "Cannot set owner for shielded transition", )); @@ -672,7 +676,8 @@ impl StateTransitionWasm { | Unshield(_) | ShieldFromAssetLock(_) | ShieldedWithdrawal(_) - | IdentityCreateFromShieldedPool(_) => { + | IdentityCreateFromShieldedPool(_) + | IdentityTopUpFromShieldedPool(_) => { return Err(WasmDppError::invalid_argument( "Cannot set identity contract nonce for shielded transition", )); @@ -774,7 +779,8 @@ impl StateTransitionWasm { | Unshield(_) | ShieldFromAssetLock(_) | ShieldedWithdrawal(_) - | IdentityCreateFromShieldedPool(_) => { + | IdentityCreateFromShieldedPool(_) + | IdentityTopUpFromShieldedPool(_) => { return Err(WasmDppError::invalid_argument( "Cannot set identity nonce for shielded transition", )); diff --git a/packages/wasm-dpp2/tests/unit/IdentityTopUpFromShieldedPoolTransition.spec.ts b/packages/wasm-dpp2/tests/unit/IdentityTopUpFromShieldedPoolTransition.spec.ts new file mode 100644 index 00000000000..04ab32a9e8b --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/IdentityTopUpFromShieldedPoolTransition.spec.ts @@ -0,0 +1,74 @@ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; +import { + fakeOrchardAction, + ZERO_ANCHOR, + ZERO_BINDING_SIG, + ZERO_PROOF, +} from './helpers/shielded.ts'; + +before(async () => { + await initWasm(); +}); + +describe('IdentityTopUpFromShieldedPoolTransition', () => { + const identityId = '11111111111111111111111111111111'; + + function createTransition() { + return new wasm.IdentityTopUpFromShieldedPoolTransition({ + identityId, + actions: [fakeOrchardAction()], + topUpAmount: BigInt(50_000), + anchor: ZERO_ANCHOR, + proof: ZERO_PROOF, + bindingSignature: ZERO_BINDING_SIG, + }); + } + + describe('constructor()', () => { + it('should construct with required fields', () => { + expect(createTransition()).to.be.an.instanceof(wasm.IdentityTopUpFromShieldedPoolTransition); + }); + + it('should reject anchor of wrong length', () => { + expect(() => new wasm.IdentityTopUpFromShieldedPoolTransition({ + identityId, + actions: [fakeOrchardAction()], + topUpAmount: BigInt(1), + anchor: new Uint8Array(31), + proof: ZERO_PROOF, + bindingSignature: ZERO_BINDING_SIG, + })).to.throw(); + }); + }); + + describe('getters', () => { + it('returns identityId, actions and topUpAmount', () => { + const t = createTransition(); + expect(t.identityId).to.be.an.instanceof(wasm.Identifier); + expect(t.identityId.toString()).to.equal(identityId); + expect(t.actions[0]).to.be.an.instanceof(wasm.SerializedOrchardAction); + expect(t.topUpAmount).to.equal(BigInt(50_000)); + }); + }); + + describe('toBytes() / fromBytes()', () => { + it('round-trips via bytes, base64 and hex', () => { + const t = createTransition(); + const bytes = t.toBytes(); + expect(Buffer.from(wasm.IdentityTopUpFromShieldedPoolTransition.fromBytes(bytes).toBytes())).to.deep.equal(Buffer.from(bytes)); + expect(Buffer.from(wasm.IdentityTopUpFromShieldedPoolTransition.fromBase64(t.toBase64()).toBytes())).to.deep.equal(Buffer.from(bytes)); + expect(Buffer.from(wasm.IdentityTopUpFromShieldedPoolTransition.fromHex(t.toHex()).toBytes())).to.deep.equal(Buffer.from(bytes)); + }); + }); + + describe('toStateTransition()', () => { + it('converts to the umbrella wrapper with type 22 and back', () => { + const t = createTransition(); + const st = t.toStateTransition(); + expect(st.actionTypeNumber).to.equal(22); + const restored = wasm.IdentityTopUpFromShieldedPoolTransition.fromStateTransition(st); + expect(Buffer.from(restored.toBytes())).to.deep.equal(Buffer.from(t.toBytes())); + }); + }); +}); From d4ac5a7414733b992a8442f14533416597994831 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 12 Sep 2026 22:00:08 +0700 Subject: [PATCH 05/17] style(wasm-dpp2): keep IdentityTopUpFromShieldedPool spec lines under the lint limit The wasm-dpp2 lint job fails on a 141-character line in the new spec (max-len 140, stylistic max-len 120). Alias the wrapper class inside the round-trip test so all three assertions stay under 120 characters. Co-Authored-By: Claude Fable 5.1 --- .../unit/IdentityTopUpFromShieldedPoolTransition.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/wasm-dpp2/tests/unit/IdentityTopUpFromShieldedPoolTransition.spec.ts b/packages/wasm-dpp2/tests/unit/IdentityTopUpFromShieldedPoolTransition.spec.ts index 04ab32a9e8b..47788fdfaab 100644 --- a/packages/wasm-dpp2/tests/unit/IdentityTopUpFromShieldedPoolTransition.spec.ts +++ b/packages/wasm-dpp2/tests/unit/IdentityTopUpFromShieldedPoolTransition.spec.ts @@ -54,11 +54,12 @@ describe('IdentityTopUpFromShieldedPoolTransition', () => { describe('toBytes() / fromBytes()', () => { it('round-trips via bytes, base64 and hex', () => { + const T = wasm.IdentityTopUpFromShieldedPoolTransition; const t = createTransition(); const bytes = t.toBytes(); - expect(Buffer.from(wasm.IdentityTopUpFromShieldedPoolTransition.fromBytes(bytes).toBytes())).to.deep.equal(Buffer.from(bytes)); - expect(Buffer.from(wasm.IdentityTopUpFromShieldedPoolTransition.fromBase64(t.toBase64()).toBytes())).to.deep.equal(Buffer.from(bytes)); - expect(Buffer.from(wasm.IdentityTopUpFromShieldedPoolTransition.fromHex(t.toHex()).toBytes())).to.deep.equal(Buffer.from(bytes)); + expect(Buffer.from(T.fromBytes(bytes).toBytes())).to.deep.equal(Buffer.from(bytes)); + expect(Buffer.from(T.fromBase64(t.toBase64()).toBytes())).to.deep.equal(Buffer.from(bytes)); + expect(Buffer.from(T.fromHex(t.toHex()).toBytes())).to.deep.equal(Buffer.from(bytes)); }); }); From 20c903ac4d80dd7ed86be5bbfd9b1f68c6410b39 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 12 Sep 2026 22:54:37 +0700 Subject: [PATCH 06/17] fix(platform-wallet): persist balance, drop floor fee, keep key-unavailable code on ShieldFromIdentity Addresses the Codex review on the wallet integration of ShieldFromIdentity: - PlatformWallet::shielded_shield_from_identity now applies the proven post-debit balance to the managed identity and persists its snapshot, the same pattern transfer_credits_to_addresses_with_external_signer follows. A proof without a balance (None) leaves the identity untouched. - The activity row no longer stores the shielded compute fee floor as the exact fee. The metered part is only known at execution and never returned, and ShieldedActivityEntry::fee is exact (the FFI reports has_fee), so the row now records None until a source of the exact fee exists. - A SigningKeyUnavailable completion from the identity signer is preserved under PlatformWalletError::Sdk via preserve_signer_key_unavailable_or instead of being stringified into ShieldedBuildError, and map_spend_result restores code 31 (ErrorSigningKeyUnavailable) for that shape so hosts keep their key-repair routing. Unit tests cover both layers, including the mid-string marker that must not be promoted. Co-Authored-By: Claude Fable 5.1 --- .../src/shielded_send.rs | 68 +++++++++++++- .../src/wallet/platform_wallet.rs | 29 +++++- .../src/wallet/shielded/operations.rs | 89 ++++++++++++++++--- 3 files changed, 172 insertions(+), 14 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 4b5b4f29ab8..a91c3635fa2 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -49,10 +49,14 @@ use dpp::shielded::{ compute_shielded_withdrawal_fee, ShieldedMemo, }; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dpp::ProtocolError; use platform_wallet::wallet::asset_lock::AssetLockFunding; use platform_wallet::wallet::shielded::CachedOrchardProver; use platform_wallet::PlatformWalletError; -use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; +use rs_sdk_ffi::{ + MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner, + DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX, +}; use crate::check_ptr; use crate::core_wallet_types::OutPointFFI; @@ -637,6 +641,16 @@ fn map_spend_result( format!("{operation} failed: {e}"), ) } + // A structured key-unavailable signer completion that the wallet layer + // preserved verbatim under `Sdk` (`preserve_signer_key_unavailable_or`, + // reached by the identity-signed shield from identity). Restore code 31 + // here as the blanket `From` conversion does, so hosts route to key + // repair instead of reading a generic wallet failure. Structural + // position-0 check only, never a substring sniff of the rendering. + Err(e) if is_preserved_signer_key_unavailable(&e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable, + format!("{operation} failed: {e}"), + ), Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{operation} failed: {e}"), @@ -644,6 +658,17 @@ fn map_spend_result( } } +/// True when `error` is a key-unavailable signer completion the wallet layer +/// preserved under `Sdk`: the reserved marker at position 0 of a +/// `ProtocolError::Generic` payload (never a substring of the rendering). +fn is_preserved_signer_key_unavailable(error: &PlatformWalletError) -> bool { + matches!( + error, + PlatformWalletError::Sdk(dash_sdk::Error::Protocol(ProtocolError::Generic(s))) + if s.starts_with(DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX) + ) +} + /// Render a caught panic payload as a human-readable string. fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { if let Some(s) = payload.downcast_ref::<&'static str>() { @@ -1177,7 +1202,13 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( /// key (the same handle credit transfers use). The caller retains ownership. /// /// `out_new_balance`, when non-null, receives the identity's proven -/// post-debit balance (0 when the result proof carried none). +/// post-debit balance (0 when the result proof carried none). The wallet's +/// managed identity is updated and persisted with that balance before this +/// returns. +/// +/// A signer that reports the TRANSFER key unavailable surfaces as code 31 +/// (`ErrorSigningKeyUnavailable`), the same key-repair signal the other +/// identity-signed operations use. /// /// # Safety /// - `wallet_id_bytes` must point to 32 readable bytes. @@ -2435,6 +2466,39 @@ mod tests { ); } + /// A key-unavailable signer completion preserved under `Sdk` by the wallet + /// layer must keep code 31 through `map_spend_result` (the shield from + /// identity path) instead of flattening to `ErrorWalletOperation`; a + /// generic protocol error that only mentions the marker mid-string must + /// NOT be promoted. + #[test] + fn map_spend_result_preserves_signing_key_unavailable_code() { + let unavailable: Result<(), PlatformWalletError> = Err(PlatformWalletError::Sdk( + dash_sdk::Error::Protocol(ProtocolError::Generic(format!( + "{DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX}transfer key missing" + ))), + )); + let result = map_spend_result(unavailable, "shielded shield from identity"); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + ); + assert!( + message_of(&result).contains("transfer key missing"), + "the signer's rendering must survive into the message" + ); + + let foreign: Result<(), PlatformWalletError> = Err(PlatformWalletError::Sdk( + dash_sdk::Error::Protocol(ProtocolError::Generic(format!( + "signer said: {DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX}mid-string" + ))), + )); + assert_eq!( + map_spend_result(foreign, "shielded shield from identity").code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + } + /// A shield (Type 15) definitively rejected on an address-nonce race must /// map to the dedicated `ErrorAddressNonceMismatch` — NOT regress to the /// generic `ErrorWalletOperation` — so hosts keep the safe-to-retry signal. diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 1de721ca7eb..be90b10e58d 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -35,6 +35,7 @@ use crate::error::PlatformWalletError; use dash_sdk::platform::transition::put_settings::PutSettings; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; +use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::prelude::Identifier; @@ -1971,7 +1972,7 @@ impl PlatformWallet { })? .clone() }; - super::shielded::operations::shield_from_identity_to( + let new_balance = super::shielded::operations::shield_from_identity_to( &self.sdk, coordinator.store(), Some(&self.persister), @@ -1985,7 +1986,31 @@ impl PlatformWallet { signer, &prover, ) - .await + .await?; + + // The operation only received a clone of the identity, so the managed + // identity still carries the pre-debit balance. Apply the proven + // post-debit balance and persist the snapshot (the pattern + // `transfer_credits_to_addresses_with_external_signer` follows); + // `None` means the proof carried no balance, so nothing is overwritten. + if let Some(balance) = new_balance { + let mut wm = self.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity_mut(identity_id)); + if let Some(managed) = managed { + managed.identity.set_balance(balance); + if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { + tracing::error!( + identity = %identity_id, + error = %e, + "Failed to persist identity balance update after shield from identity" + ); + } + } + } + + Ok(new_balance) } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 0754bde09eb..6f282ef29b7 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -30,7 +30,7 @@ use super::note_selection::{ use super::store::{PendingRedrive, ShieldedNote, ShieldedStore, SubwalletId}; use crate::broadcast_outcome::{broadcast_definitely_failed, carries_consensus_rejection}; use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; -use crate::error::PlatformWalletError; +use crate::error::{preserve_signer_key_unavailable_or, PlatformWalletError}; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; @@ -61,6 +61,7 @@ use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::state_transition::StateTransition; use dpp::version::PlatformVersion; use dpp::withdrawal::Pooling; +use dpp::ProtocolError; use grovedb_commitment_tree::{Anchor, PaymentAddress}; use tokio::sync::RwLock; use tracing::{debug, info, trace, warn}; @@ -804,8 +805,15 @@ pub async fn shield_to, P: Orchar /// plus the shielded compute fee from the identity balance. `nonce` is fetched /// from Platform here, so the caller only supplies the identity. /// +/// The activity row records no fee: the exact fee is metered at execution and +/// never returned to the wallet, and `ShieldedActivityEntry::fee` is an exact +/// value (the FFI reports it as such), so the consensus floor must not stand in +/// for it. +/// /// Returns the identity's proven post-debit balance when the result proof carried -/// it (`None` when the wait confirmed execution without a balance). +/// it (`None` when the wait confirmed execution without a balance). The caller +/// owns the managed identity and persists that balance +/// (`PlatformWallet::shielded_shield_from_identity`). #[allow(clippy::too_many_arguments)] pub async fn shield_from_identity_to< S: ShieldedStore, @@ -843,12 +851,6 @@ pub async fn shield_from_identity_to< other => other, }; - // The metered part of the fee is only known at execution; the shielded - // compute fee is the consensus floor and what the activity row records. - let fee_floor = - dpp::shielded::compute_shielded_verification_fee(SHIELD_NUM_ACTIONS, sdk.version()) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - let nonce = sdk .get_identity_nonce(identity_id, true, None) .await @@ -878,7 +880,7 @@ pub async fn shield_from_identity_to< sdk.version(), ) .await - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + .map_err(map_shield_from_identity_build_error)?; trace!("ShieldFromIdentity: state transition built, broadcasting..."); @@ -892,7 +894,8 @@ pub async fn shield_from_identity_to< kind, direction, amount, - fee: Some(fee_floor), + // Exact fee unknown (metered at execution); see the function docs. + fee: None, counterparty: external_counterparty, memo: non_zero_memo(&memo), actions: shielded_actions(&state_transition), @@ -980,6 +983,72 @@ pub async fn shield_from_identity_to< Ok(new_balance) } +/// Map a `ShieldFromIdentity` builder failure. The builder signs with the +/// identity signer, so a structured key-unavailable completion (the reserved +/// marker at position 0 of a `ProtocolError::Generic` payload) can surface +/// here; it is preserved verbatim under [`PlatformWalletError::Sdk`] so the +/// FFI boundary restores code 31 (`ErrorSigningKeyUnavailable`) instead of +/// flattening it into the generic build error. Every other failure keeps the +/// protocol error's own rendering as a `ShieldedBuildError`. +fn map_shield_from_identity_build_error(error: ProtocolError) -> PlatformWalletError { + preserve_signer_key_unavailable_or(dash_sdk::Error::Protocol(error), |e| match e { + dash_sdk::Error::Protocol(protocol_error) => { + PlatformWalletError::ShieldedBuildError(protocol_error.to_string()) + } + other => PlatformWalletError::ShieldedBuildError(other.to_string()), + }) +} + +#[cfg(test)] +mod shield_from_identity_build_error_tests { + use super::*; + use crate::error::SIGNER_KEY_UNAVAILABLE_PREFIX; + + /// A key-unavailable signer completion keeps its structured shape so the + /// FFI can restore code 31; the marker must sit at position 0. + #[test] + fn preserves_signer_key_unavailable_completion() { + let error = ProtocolError::Generic(format!( + "{SIGNER_KEY_UNAVAILABLE_PREFIX}transfer key not in keychain" + )); + let mapped = map_shield_from_identity_build_error(error); + assert!( + matches!( + &mapped, + PlatformWalletError::Sdk(dash_sdk::Error::Protocol(ProtocolError::Generic(s))) + if s.starts_with(SIGNER_KEY_UNAVAILABLE_PREFIX) + ), + "expected the signer completion preserved under Sdk, got {mapped:?}" + ); + } + + /// Any other builder failure is a build error carrying the protocol + /// error's own rendering (the SDK wrapper's "Protocol error:" prefix is + /// not added), including a generic error that merely mentions the marker + /// mid-string. + #[test] + fn stringifies_other_builder_failures() { + let mapped = map_shield_from_identity_build_error(ProtocolError::Generic( + "amount must be > 0".to_string(), + )); + assert!( + matches!( + &mapped, + PlatformWalletError::ShieldedBuildError(s) if s == "Generic Error: amount must be > 0" + ), + "got {mapped:?}" + ); + + let mid_string = map_shield_from_identity_build_error(ProtocolError::Generic(format!( + "signer failed: {SIGNER_KEY_UNAVAILABLE_PREFIX}not at position 0" + ))); + assert!( + matches!(mid_string, PlatformWalletError::ShieldedBuildError(_)), + "a mid-string marker must not be promoted, got {mid_string:?}" + ); + } +} + // ------------------------------------------------------------------------- // ShieldFromAssetLock: Core L1 asset lock -> shielded pool (Type 18) // (orchestrated entry point lives in `wallet/shielded/fund_from_asset_lock.rs`) From c98944df1abaf710fa99972be72e51b838d85f12 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 12 Sep 2026 23:10:51 +0700 Subject: [PATCH 07/17] fix(platform-wallet): confirm ShieldFromIdentity only on the identity's own balance proof wait_for_affected_state converts the proof generically, so the wallet accepted any successful result (or a VerifiedPartialIdentity for a different identity, or one without a balance) as confirmation of the shield. Mirror the SDK's ShieldFromIdentity check: only a VerifiedPartialIdentity whose id is the funding identity and which carries a balance confirms the activity; every other result leaves the row pending and reports ShieldedSpendUnconfirmed. The balance is therefore always present on success, so the operation, the PlatformWallet entry points, and the FFI now return Credits instead of Option; the FFI signature is unchanged and the Swift, Kotlin, and JNI docs no longer describe a zero-balance success. Co-Authored-By: Claude Fable 5.1 --- .../dashsdk/ffi/FundingNative.kt | 2 +- .../dashsdk/wallet/PlatformWalletManager.kt | 5 ++- .../src/shielded_send.rs | 9 ++-- .../src/wallet/platform_wallet.rs | 19 ++++---- .../src/wallet/shielded/operations.rs | 43 +++++++++++++++---- packages/rs-unified-sdk-jni/src/funding.rs | 7 ++- .../PlatformWalletManagerShieldedSync.swift | 5 ++- 7 files changed, 59 insertions(+), 31 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index eb2dbb6fb07..61d9caf9e9e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -154,7 +154,7 @@ internal object FundingNative { * `signerHandle`): the same handle credit transfers use, since the * transition is authorized by the identity's TRANSFER key. Blocks for the * ~30s Halo 2 proof; returns the identity's proven post-debit credit - * balance (0 when the result proof carried none). + * balance (the wallet only confirms on the identity's own balance proof). */ external fun shieldedShieldFromIdentity( managerHandle: Long, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 1605e92641c..cdadf81efb5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -1652,8 +1652,9 @@ class PlatformWalletManager( * @param walletId the 32-byte wallet id. * @param identityId the 32-byte funding identity id. * @param amount credits to shield (1 DASH = 1e11). - * @return the identity's proven post-debit credit balance, or 0 when the - * result proof carried none (the transition still succeeded). + * @return the identity's proven post-debit credit balance; the wallet only + * confirms on the identity's own balance proof, any other result throws + * the shielded-spend-unconfirmed error (do not resubmit). */ suspend fun shieldedShieldFromIdentity( walletId: ByteArray, diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index a91c3635fa2..d47b655f7f8 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1202,9 +1202,10 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( /// key (the same handle credit transfers use). The caller retains ownership. /// /// `out_new_balance`, when non-null, receives the identity's proven -/// post-debit balance (0 when the result proof carried none). The wallet's -/// managed identity is updated and persisted with that balance before this -/// returns. +/// post-debit balance; the wallet's managed identity is updated and persisted +/// with it before this returns. A result proof that is not this identity's +/// balance proof is reported as `ErrorShieldedSpendUnconfirmed` (the +/// transition may have executed; do not resubmit, the next sync reconciles). /// /// A signer that reports the TRANSFER key unavailable surfaces as code 31 /// (`ErrorSigningKeyUnavailable`), the same key-repair signal the other @@ -1263,7 +1264,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield_from_identity( match result { Ok(new_balance) => { if !out_new_balance.is_null() { - *out_new_balance = new_balance.unwrap_or(0); + *out_new_balance = new_balance; } PlatformWalletFFIResult::ok() } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index be90b10e58d..ecbd0d40a2c 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1847,8 +1847,10 @@ impl PlatformWallet { /// (typically the same `Signer` used for credit transfers). /// /// The identity is debited `amount` plus the metered fee plus the shielded - /// compute fee. Returns the proven post-debit balance when the result proof - /// carried one. + /// compute fee. Returns the proven post-debit balance, which is also applied + /// to the managed identity and persisted. A result proof that is not this + /// identity's balance proof reports the spend as unconfirmed + /// ([`PlatformWalletError::ShieldedSpendUnconfirmed`]). #[cfg(feature = "shielded")] pub async fn shielded_shield_from_identity( &self, @@ -1858,7 +1860,7 @@ impl PlatformWallet { amount: u64, signer: &S, prover: P, - ) -> Result, PlatformWalletError> + ) -> Result where S: dpp::identity::signer::Signer + Send + Sync, P: dpp::shielded::builder::OrchardProver, @@ -1893,7 +1895,7 @@ impl PlatformWallet { memo: [u8; 36], signer: &S, prover: P, - ) -> Result, PlatformWalletError> + ) -> Result where S: dpp::identity::signer::Signer + Send + Sync, P: dpp::shielded::builder::OrchardProver, @@ -1931,7 +1933,7 @@ impl PlatformWallet { memo: [u8; 36], signer: &S, prover: P, - ) -> Result, PlatformWalletError> + ) -> Result where S: dpp::identity::signer::Signer + Send + Sync, P: dpp::shielded::builder::OrchardProver, @@ -1991,15 +1993,14 @@ impl PlatformWallet { // The operation only received a clone of the identity, so the managed // identity still carries the pre-debit balance. Apply the proven // post-debit balance and persist the snapshot (the pattern - // `transfer_credits_to_addresses_with_external_signer` follows); - // `None` means the proof carried no balance, so nothing is overwritten. - if let Some(balance) = new_balance { + // `transfer_credits_to_addresses_with_external_signer` follows). + { let mut wm = self.wallet_manager.write().await; let managed = wm .get_wallet_info_mut(&self.wallet_id) .and_then(|info| info.identity_manager.managed_identity_mut(identity_id)); if let Some(managed) = managed { - managed.identity.set_balance(balance); + managed.identity.set_balance(new_balance); if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { tracing::error!( identity = %identity_id, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 6f282ef29b7..7c2901aa647 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -810,9 +810,13 @@ pub async fn shield_to, P: Orchar /// value (the FFI reports it as such), so the consensus floor must not stand in /// for it. /// -/// Returns the identity's proven post-debit balance when the result proof carried -/// it (`None` when the wait confirmed execution without a balance). The caller -/// owns the managed identity and persists that balance +/// Returns the identity's proven post-debit balance. The activity row is only +/// confirmed by the identity's own `VerifiedPartialIdentity` proof carrying a +/// balance (the SDK's `ShieldFromIdentity` check); a proof for another identity, +/// one without a balance, or any other result leaves the row pending and +/// reports [`PlatformWalletError::ShieldedSpendUnconfirmed`], since the wallet +/// cannot tell from it whether the transition executed. The caller owns the +/// managed identity and persists the balance /// (`PlatformWallet::shielded_shield_from_identity`). #[allow(clippy::too_many_arguments)] pub async fn shield_from_identity_to< @@ -832,7 +836,7 @@ pub async fn shield_from_identity_to< memo: [u8; 36], signer: &Sig, prover: &P, -) -> Result, PlatformWalletError> { +) -> Result { let ShieldRecipient { address: recipient_addr, counterparty: external_counterparty, @@ -936,12 +940,27 @@ pub async fn shield_from_identity_to< // The proof authenticates the identity's post-debit balance (an affected-state // snapshot, not execution evidence); a consensus rejection surfaces as an error. - let new_balance = match state_transition + // `wait_for_affected_state` only converts the proof generically, so the variant + // and the identity are enforced here: only this identity's balance proof may + // confirm the activity. + let proof_outcome: Result = match state_transition .wait_for_affected_state::(sdk, None) .await { - Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) => partial.balance, - Ok(_) => None, + Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) + if partial.id == identity_id => + { + partial + .balance + .ok_or_else(|| "the identity proof did not include the updated balance".to_string()) + } + Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) => Err(format!( + "the proof returned identity {} but {} initiated the shield", + partial.id, identity_id + )), + Ok(other) => Err(format!( + "an identity balance proof was expected, received {other:?}" + )), Err(wait_err) => { if carries_consensus_rejection(&wait_err) { record_activity_status( @@ -956,15 +975,21 @@ pub async fn shield_from_identity_to< .await; return Err(enrich(&wait_err)); } + Err(wait_err.to_string()) + } + }; + let new_balance = match proof_outcome { + Ok(balance) => balance, + Err(reason) => { warn!( account, - error = %wait_err, + %reason, "ShieldFromIdentity broadcast accepted but result confirmation failed; \ leaving the activity row pending" ); return Err(PlatformWalletError::ShieldedSpendUnconfirmed { operation: "shield from identity", - reason: wait_err.to_string(), + reason, }); } }; diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index cbb5470b7e0..c57f9b576d0 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -734,10 +734,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde /// retains ownership. /// /// Blocks for the ~30s Halo 2 proof; returns the identity's proven -/// post-debit credit balance (0 when the result proof carried none: the -/// transition still succeeded), mirroring -/// [`Java_..._transferCreditsToAddresses`]. The note itself arrives on the -/// next shielded sync pass. +/// post-debit credit balance (the wallet only confirms on the identity's own +/// balance proof), mirroring [`Java_..._transferCreditsToAddresses`]. The +/// note itself arrives on the next shielded sync pass. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shieldedShieldFromIdentity( mut env: JNIEnv, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 9fd3c71eb17..ecaa23a9936 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -763,8 +763,9 @@ extension PlatformWalletManager { /// /// The identity is debited `amount` plus the metered fee plus the /// shielded compute fee (`estimateShieldedFee(kind: .shieldFromIdentity)`). - /// Returns the identity's proven post-debit balance, or `0` when the - /// execution result carried no balance proof. + /// Returns the identity's proven post-debit balance; the wallet only + /// confirms on the identity's own balance proof, and any other result + /// throws `.shieldedSpendUnconfirmed` (do not resubmit). /// /// Heavy CPU work (Halo 2 proof + the identity signature) runs on a /// detached task so the caller's actor isn't blocked. From bb6836efc40bf0269d6ff161654941917d2dfdbd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 13 Sep 2026 05:18:15 +0700 Subject: [PATCH 08/17] fix(platform)!: paid penalty and complete-fee floor for ShieldFromIdentity proofs, nonce-reconciled wallet failures Addresses the Codex security rereview of ShieldFromIdentity. Consensus (drive-abci, drive, dpp): - A failed Orchard proof is no longer a free rejection that leaves the identity nonce and balance reusable. Like ShieldFromAssetLock, the proof is now verified inside the transition's own transform, and a failure returns a BumpIdentityNonceAction with the consensus error: the transition executes as a paid failure that consumes the nonce and charges the versioned shielded_proof_verification_failure penalty on top of the processing metered so far. CheckTx passes its proof-admission budget into the same transform and still rejects on failure without charging. - The stateless admission floor is raised from the compute fee to a conservative complete fee: compute_shielded_identity_balance_write_fee (compute_minimum_shielded_fee plus a flat 222-byte identity-write allowance at the storage rate), so an identity that could not pay the complete fee is refused before proof verification. The FFI estimator kind 3 reports the same floor. Wallet: - Unauthenticated failure verdicts (a rejected broadcast, a consensus error in the result wait) are reconciled against the identity's proven nonce before the activity row is marked Failed. Only a proof that shows the transition's nonce was never merged allows Failed; otherwise the row stays pending and the caller gets ShieldedSpendUnconfirmed, since an identity shield has no input nullifier and a rebuilt retry would debit the identity again under a fresh nonce. - The balance proof binds neither the transition nor the note, so it no longer confirms the activity row. The row stays pending until the shielded scan observes the note commitments on-chain, like the ambiguous post-broadcast paths. Tests: paid-penalty and admission-floor tests in drive-abci (nonce consumed, exact penalty debited, pool untouched; nothing charged below the floor), the floor formula in dpp, and the nonce-mask reconciliation predicate in the wallet. Co-Authored-By: Claude Fable 5.1 --- book/src/fees/shielded-fees.md | 26 +- .../dashsdk/funding/ShieldedProver.kt | 11 +- .../compute_minimum_shielded_fee/mod.rs | 31 +++ .../compute_minimum_shielded_fee/v0/mod.rs | 67 ++++- packages/rs-dpp/src/shielded/mod.rs | 20 +- ...ate_transition_estimated_fee_validation.rs | 15 +- .../check_tx_verification/v0/mod.rs | 12 + .../processor/traits/shielded_proof.rs | 26 +- .../shield_from_identity/mod.rs | 53 +++- .../shield_from_identity/tests.rs | 153 ++++++++++- .../transform_into_action/v0/mod.rs | 81 +++++- .../bump_identity_nonce_action/transformer.rs | 13 + .../v0/transformer.rs | 16 ++ .../src/shielded_send.rs | 19 +- .../src/wallet/platform_wallet.rs | 5 +- .../src/wallet/shielded/operations.rs | 244 +++++++++++++++--- .../PlatformWalletManagerShieldedSync.swift | 9 +- 17 files changed, 692 insertions(+), 109 deletions(-) diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index 267bc1a008d..b56fd189239 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -108,12 +108,26 @@ identity_balance_after = identity_balance_before - amount - fee ``` `user_fee_increase` applies to the metered processing portion. The stateless -floor requires `identity_balance >= amount + shielded_verification_fee`; the -authoritative gate is the identity-paid fee validation of the execution event -(`Paid`), which rejects with `IdentityInsufficientBalanceError`. The identity -balance and the pool total are both terms of the block conservation equation, so -the converter emits no `AddToSystemCredits` (the same rule `Unshield` and -`IdentityCreateFromShieldedPool` follow). +floor requires `identity_balance >= amount + compute_shielded_identity_balance_write_fee`, +the conservative complete fee (`compute_minimum_shielded_fee` plus a flat +`SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES` identity-write allowance at the +storage rate), so an identity that could not pay the complete fee is refused before +the Orchard proof is verified. The authoritative gate is the identity-paid fee +validation of the execution event (`Paid`), which rejects with +`IdentityInsufficientBalanceError`. The identity balance and the pool total are +both terms of the block conservation equation, so the converter emits no +`AddToSystemCredits` (the same rule `Unshield` and `IdentityCreateFromShieldedPool` +follow). + +A failed Orchard proof is a **paid failure**, not a free rejection. Like +`ShieldFromAssetLock`, the proof is verified inside the transition's own transform +rather than in the shared stateless proof step: on failure the transition executes +as a `BumpIdentityNonceAction`, consuming the identity nonce and charging the +identity the versioned `shielded_proof_verification_failure` penalty on top of the +processing metered so far. This closes the path where a funded identity could +resubmit invalid proofs indefinitely with its nonce and balance left untouched. +CheckTx never charges; it admits the verification under its node-local proof +budget only after the cheap checks passed, and rejects on failure. ### ShieldFromAssetLock diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt index 7ff669b90e5..16d4db26f40 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/funding/ShieldedProver.kt @@ -36,12 +36,11 @@ object ShieldedProver { Withdrawal(2), /** - * ShieldFromIdentity (Type 21): the shielded COMPUTE fee floor - * (`compute_shielded_verification_fee`: proof verification + per-action - * processing). Unlike the pool-paid kinds above it carries no storage - * term: the identity-funded shield meters its writes through GroveDB - * against the identity balance, so only the compute portion is flat. - * Backs + * ShieldFromIdentity (Type 21): the conservative complete-fee floor + * (`compute_shielded_identity_balance_write_fee`: compute + note storage + * allowance + identity write allowance) consensus requires the identity + * to hold on top of the amount. The exact fee is metered through GroveDB + * against the identity balance at execution. Backs * [org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedShieldFromIdentity]. */ ShieldFromIdentity(3), diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs index a3a7bbab57c..5b594d8c6d9 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs @@ -4,6 +4,7 @@ use crate::fee::Credits; use crate::ProtocolError; 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_unshield_fee_v0; use v0::compute_shielded_verification_fee_v0; @@ -118,6 +119,36 @@ pub fn compute_shielded_unshield_fee( } } +/// Computes the conservative **admission floor** (in credits) of a shielded transition that also +/// writes an identity balance (`ShieldFromIdentity`): [`compute_minimum_shielded_fee`] plus a flat +/// identity-balance-write allowance (`SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES` effective +/// bytes at the per-byte storage rate). +/// +/// The transition's authoritative fee is metered at execution; this floor stands in for it where +/// state is not yet available, so that an identity that could not pay the complete fee is refused +/// before the expensive Orchard proof verification runs. It is also the client-side estimate of +/// the total fee. +/// +/// Dispatches on the SAME version key (`dpp.methods.compute_minimum_shielded_fee`) as +/// [`compute_minimum_shielded_fee`] so the formulas evolve together across protocol versions. +/// +/// # Parameters +/// - `num_actions`: number of Orchard actions in the bundle +/// - `platform_version`: protocol version (determines the formula version and fee constants) +pub fn compute_shielded_identity_balance_write_fee( + num_actions: usize, + platform_version: &PlatformVersion, +) -> Result { + match platform_version.dpp.methods.compute_minimum_shielded_fee { + 0 => compute_shielded_identity_balance_write_fee_v0(num_actions, platform_version), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "compute_shielded_identity_balance_write_fee".to_string(), + known_versions: vec![0], + received: version, + }), + } +} + /// Computes the **compute-only** shielded fee (in credits): the ZK-compute portion (Halo 2 proof /// verification + per-action spend-auth/nullifier processing) that GroveDB metering cannot see. /// diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs index 8066bca099a..b627e905324 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs @@ -1,6 +1,7 @@ use crate::fee::Credits; use crate::shielded::{ - SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES, SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES, + SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES, SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES, + SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES, }; use crate::ProtocolError; use platform_version::version::PlatformVersion; @@ -191,6 +192,46 @@ pub fn compute_shielded_unshield_fee_v0( .ok_or(ProtocolError::Overflow("shielded unshield fee overflow")) } +/// v0 of the `ShieldFromIdentity` **admission floor**: +/// +/// `floor = compute_minimum_shielded_fee_v0(num_actions) +/// + SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES × (disk + processing) credits/byte` +/// +/// [`compute_minimum_shielded_fee_v0`] plus one flat storage component for the identity balance +/// write, the same shape as [`compute_shielded_unshield_fee_v0`]'s address-write component. The +/// transition's real fee is metered at execution (note inserts plus the identity balance and +/// nonce updates); this floor is the conservative stand-in the stateless balance pre-check uses +/// so that a short identity is refused before the Orchard proof is verified, and the client-side +/// estimate of the total fee. +/// +/// All arithmetic is checked: an overflow (only reachable via pathological fee constants) +/// surfaces as `ProtocolError::Overflow` instead of silently wrapping. +pub fn compute_shielded_identity_balance_write_fee_v0( + num_actions: usize, + platform_version: &PlatformVersion, +) -> Result { + let storage = &platform_version.fee_version.storage; + + let base_fee = compute_minimum_shielded_fee_v0(num_actions, platform_version)?; + + let per_byte_rate = storage + .storage_disk_usage_credit_per_byte + .checked_add(storage.storage_processing_credit_per_byte) + .ok_or(ProtocolError::Overflow( + "shielded storage per-byte rate overflow", + ))?; + let identity_write_fee = SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES + .checked_mul(per_byte_rate) + .ok_or(ProtocolError::Overflow( + "shielded identity balance write fee overflow", + ))?; + base_fee + .checked_add(identity_write_fee) + .ok_or(ProtocolError::Overflow( + "shielded identity balance write floor overflow", + )) +} + /// v0 of the shielded **identity-create** fee formula: /// /// `identity_create_fee = compute_minimum_shielded_fee_v0(num_actions) @@ -259,6 +300,30 @@ mod tests { /// version's own constant tables, and must decompose as /// `compute_fee + num_actions × storage_allowance` — the component split the pool-paid /// booking and the fee-floor tests rely on. + /// The `ShieldFromIdentity` admission floor is the minimum fee plus the flat + /// identity-write allowance at the storage rate, and strictly above the compute fee. + #[test] + fn compute_shielded_identity_balance_write_fee_v0_adds_identity_write_allowance() { + let platform_version = PlatformVersion::latest(); + let storage = &platform_version.fee_version.storage; + let per_byte_rate = + storage.storage_disk_usage_credit_per_byte + storage.storage_processing_credit_per_byte; + for num_actions in [1usize, 2, 5] { + let floor = + compute_shielded_identity_balance_write_fee_v0(num_actions, platform_version) + .expect("floor"); + let minimum = + compute_minimum_shielded_fee_v0(num_actions, platform_version).expect("minimum"); + let compute = compute_shielded_verification_fee_v0(num_actions, platform_version) + .expect("compute"); + assert_eq!( + floor, + minimum + SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES * per_byte_rate + ); + assert!(floor > compute); + } + } + #[test] fn compute_minimum_shielded_fee_v0_equals_historical_formula() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index edf4a0a7de2..86a741f2031 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -14,9 +14,9 @@ use serde::{Deserialize, Serialize}; // Re-exported so the public path stays `dpp::shielded::compute_minimum_shielded_fee` (the // module and the function share a name but live in different namespaces). pub use compute_minimum_shielded_fee::{ - compute_minimum_shielded_fee, compute_shielded_identity_create_fee, - compute_shielded_unshield_fee, compute_shielded_verification_fee, - compute_shielded_withdrawal_fee, + compute_minimum_shielded_fee, compute_shielded_identity_balance_write_fee, + compute_shielded_identity_create_fee, compute_shielded_unshield_fee, + compute_shielded_verification_fee, compute_shielded_withdrawal_fee, }; // Re-exported so the public paths stay `dpp::shielded::` after moving the sighash preimage @@ -76,6 +76,20 @@ pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100; /// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`]. pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; +/// Flat storage allowance (in effective bytes) for the identity balance write that +/// `ShieldFromIdentity` performs on top of its per-action note writes. +/// +/// The transition's real fee is metered (note inserts plus the identity balance and nonce +/// updates) and only known at execution, so its stateless admission floor needs a conservative +/// stand-in for that metered part: [`compute_minimum_shielded_fee`] (compute plus the per-action +/// note storage allowance) plus this identity-write allowance, priced at the same per-byte storage +/// rate. The figure mirrors `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES`: a balance-tree write of the +/// same shape as the transparent address write, ≈222 effective bytes. Admission below +/// `amount + floor` is refused BEFORE the Orchard proof is verified, so a short identity can +/// never occupy a proof-verification slot. See +/// [`compute_minimum_shielded_fee::compute_shielded_identity_balance_write_fee`]. +pub const SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES: u64 = 222; + /// Common Orchard bundle parameters shared across all shielded transition types. /// /// Groups the fields that every shielded transition carries identically: diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs index c1181ac0122..8ebba0e2676 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_identity_transition/state_transition_estimated_fee_validation.rs @@ -1,6 +1,6 @@ use crate::consensus::state::identity::IdentityInsufficientBalanceError; use crate::fee::Credits; -use crate::shielded::compute_shielded_verification_fee; +use crate::shielded::compute_shielded_identity_balance_write_fee; use crate::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; use crate::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; use crate::state_transition::{ @@ -11,15 +11,18 @@ use crate::ProtocolError; use platform_version::version::PlatformVersion; impl StateTransitionEstimatedFeeValidation for ShieldFromIdentityTransition { - /// The stateless floor is the shielded compute fee only (proof verification plus - /// per-action processing), exactly as for `Shield`. Storage and processing of the - /// note and identity writes are metered by GroveDB, so the authoritative funding - /// gate is the identity-paid fee validation of the execution event. + /// The stateless floor is the conservative complete fee: the shielded compute fee, + /// the per-action note storage allowance, and the flat identity balance write + /// allowance (`compute_shielded_identity_balance_write_fee`). Storage and processing + /// of the note and identity writes are metered by GroveDB, so the authoritative + /// funding gate is still the identity-paid fee validation of the execution event; + /// this floor exists so an identity that could not pay the complete fee is refused + /// before the expensive Orchard proof verification runs. fn calculate_min_required_fee( &self, platform_version: &PlatformVersion, ) -> Result { - compute_shielded_verification_fee(self.actions().len(), platform_version) + compute_shielded_identity_balance_write_fee(self.actions().len(), platform_version) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs index 2711d97287c..3fb832af71d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs @@ -2,6 +2,7 @@ use crate::error::Error; use crate::execution::types::execution_event::ExecutionEvent; use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; use crate::execution::validation::state_transition::shield_from_asset_lock::StateTransitionShieldFromAssetLockTransitionActionTransformer; +use crate::execution::validation::state_transition::shield_from_identity::StateTransitionShieldFromIdentityTransitionActionTransformer; use crate::platform_types::platform::PlatformRef; use crate::platform_types::check_tx_proof_verifier::CheckTxProofVerifier; use crate::platform_types::platform_state::PlatformStateV0Methods; @@ -54,6 +55,17 @@ fn transform_into_action_for_check_tx( Some(proof_verifier), None, ), + // Same shape as ShieldFromAssetLock: the proof is verified inside the transform so + // block processing can turn a failure into a paid penalty; CheckTx passes its + // admission budget and rejects on failure. + StateTransition::ShieldFromIdentity(transition) => transition + .transform_into_action_for_shield_from_identity_transition( + platform, + platform.state.last_block_info(), + execution_context, + Some(proof_verifier), + None, + ), _ => state_transition.transform_into_action( platform, platform.state.last_block_info(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index 50772e6ffe6..419fa282f94 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -54,6 +54,10 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { // is done inside transform_into_action because a failed proof must penalize // the asset lock (via PartiallyUseAssetLockAction). Moving it here would let // attackers spam bad proofs without burning their asset lock. + // ShieldFromIdentity is excluded for the same reason: its transform verifies + // the proof and turns a failure into a paid, nonce-consuming penalty on the + // funding identity (BumpIdentityNonceAction), so a rejected proof never leaves + // the identity's nonce and balance reusable for free. matches!( self, StateTransition::Shield(_) @@ -61,7 +65,6 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) - | StateTransition::ShieldFromIdentity(_) ) } @@ -470,22 +473,6 @@ impl StateTransitionShieldedProofValidationV0 for StateTransition { ) } }, - StateTransition::ShieldFromIdentity(st) => match st { - ShieldFromIdentityTransition::V0(v0) => { - // Outputs-only bundle entering the pool, exactly like `Shield`. The - // identity ECDSA signature already binds every bundle field to the - // identity and nonce, so no extra sighash data is needed. - reconstruct_and_verify_bundle( - &v0.actions, - FLAGS_OUTPUTS_ONLY, - -(v0.amount as i64), - &v0.anchor, - v0.proof.as_slice(), - &v0.binding_signature, - &[], - ) - } - }, StateTransition::ShieldedTransfer(st) => match st { dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition::V0(v0) => { reconstruct_and_verify_bundle( @@ -570,8 +557,9 @@ impl StateTransitionShieldedProofValidationV0 for StateTransition { ) } }, - // ShieldFromAssetLock retains proof verification in transform_into_action - // (penalty comes from the asset lock, which is safe) + // ShieldFromAssetLock and ShieldFromIdentity retain proof verification in + // transform_into_action (their penalties come from the asset lock and the + // funding identity respectively, which is safe) _ => return Ok(SimpleConsensusValidationResult::new()), }; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs index 2167e58c1cb..5aaf0cd472f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/mod.rs @@ -19,20 +19,37 @@ use crate::execution::types::state_transition_execution_context::StateTransition use crate::execution::validation::state_transition::shield_from_identity::transform_into_action::v0::ShieldFromIdentityStateTransitionTransformIntoActionValidationV0; use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; use crate::execution::validation::state_transition::ValidationMode; +use crate::platform_types::check_tx_proof_verifier::CheckTxProofVerifier; use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::rpc::core::CoreRPCLike; -impl StateTransitionActionTransformer for ShieldFromIdentityTransition { - fn transform_into_action( +/// Transform a `ShieldFromIdentity` transition into its action, verifying the Orchard proof +/// inside the transform (like `ShieldFromAssetLock`) so a failed proof becomes a paid, +/// nonce-consuming penalty on the funding identity instead of a free rejection. +/// +/// `check_tx_proof_verifier` is the node-local CheckTx admission budget: CheckTx passes +/// `Some` so the expensive verification runs only after the cheap current-state checks +/// passed and only under a permit; proposal and block processing pass `None`. +pub(in crate::execution) trait StateTransitionShieldFromIdentityTransitionActionTransformer { + /// Transform into an action for the shield from identity transition + fn transform_into_action_for_shield_from_identity_transition( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + check_tx_proof_verifier: Option<&CheckTxProofVerifier>, + tx: TransactionArg, + ) -> Result, Error>; +} + +impl StateTransitionShieldFromIdentityTransitionActionTransformer for ShieldFromIdentityTransition { + fn transform_into_action_for_shield_from_identity_transition( &self, platform: &PlatformRef, block_info: &BlockInfo, - _remaining_address_input_balances: &Option< - BTreeMap, - >, - _validation_mode: ValidationMode, execution_context: &mut StateTransitionExecutionContext, + check_tx_proof_verifier: Option<&CheckTxProofVerifier>, tx: TransactionArg, ) -> Result, Error> { let platform_version = platform.state.current_platform_version()?; @@ -49,6 +66,7 @@ impl StateTransitionActionTransformer for ShieldFromIdentityTransition { tx, block_info, execution_context, + check_tx_proof_verifier, platform_version, ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { @@ -59,3 +77,26 @@ impl StateTransitionActionTransformer for ShieldFromIdentityTransition { } } } + +impl StateTransitionActionTransformer for ShieldFromIdentityTransition { + /// Proposal and block processing entry point: no CheckTx proof budget applies. + fn transform_into_action( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + _remaining_address_input_balances: &Option< + BTreeMap, + >, + _validation_mode: ValidationMode, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + self.transform_into_action_for_shield_from_identity_transition( + platform, + block_info, + execution_context, + None, + tx, + ) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs index f5cfb2e69a0..fd2b654ef7e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/tests.rs @@ -205,6 +205,53 @@ mod tests { } } + /// `process_transition` with the transaction COMMITTED, so the paid-failure + /// effects (nonce bump, fee debit) can be read back from state. + fn process_transition_and_commit( + platform: &TempPlatform, + transition: StateTransition, + platform_version: &PlatformVersion, + ) -> crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult + { + let transition_bytes = transition + .serialize_to_bytes() + .expect("should serialize transition"); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + result + } + + fn identity_nonce(platform: &TempPlatform, identity: &Identity) -> u64 { + platform + .drive + .fetch_identity_nonce( + identity.id().to_buffer(), + true, + None, + PlatformVersion::latest(), + ) + .expect("fetch nonce") + .unwrap_or_default() + } + fn identity_balance(platform: &TempPlatform, identity: &Identity) -> u64 { platform .drive @@ -316,6 +363,54 @@ mod tests { ); } + /// The stateless floor is the conservative complete fee (compute + note storage + /// allowance + identity write allowance), not the compute fee alone: an identity that + /// covers `amount + compute fee` but not the floor is refused before the (dummy) + /// proof is ever verified, and pays nothing. + #[tokio::test] + async fn test_balance_below_admission_floor_is_rejected_before_proof_verification() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut rng = StdRng::seed_from_u64(31); + let amount = 1_000u64; + let num_actions = dummy_bundle().actions.len(); + let compute_fee = + dpp::shielded::compute_shielded_verification_fee(num_actions, platform_version) + .expect("compute fee"); + let floor = dpp::shielded::compute_shielded_identity_balance_write_fee( + num_actions, + platform_version, + ) + .expect("floor"); + assert!(floor > compute_fee, "the floor must exceed the compute fee"); + // Enough for amount + compute fee, short of amount + floor. + let balance = amount + compute_fee + 1; + let (identity, signer) = + create_identity_with_transfer_key([31u8; 32], balance, &mut rng, platform_version); + add_identity_to_drive(&mut platform, &identity); + + let st = create_signed_transition( + &identity, + &signer, + dummy_bundle(), + amount, + 1, + 0, + platform_version, + ) + .await; + let result = process_transition_and_commit(&platform, st, platform_version); + + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::IdentityInsufficientBalanceError(_)) + )] + ); + assert_eq!(identity_balance(&platform, &identity), balance); + assert_eq!(identity_nonce(&platform, &identity), 0); + } + #[tokio::test] async fn test_invalid_identity_signature_is_rejected() { let platform_version = PlatformVersion::latest(); @@ -362,13 +457,18 @@ mod tests { } #[tokio::test] - async fn test_invalid_orchard_proof_is_rejected() { + /// An invalid Orchard proof is a PAID failure, not a free rejection: the identity + /// nonce is consumed and the identity is charged at least the versioned + /// `shielded_proof_verification_failure` penalty, so a funded identity cannot + /// resubmit invalid proofs at no cost. The shielded pool is untouched. + async fn test_invalid_orchard_proof_is_a_paid_penalty() { let platform_version = PlatformVersion::latest(); let mut platform = setup_platform(); let mut rng = StdRng::seed_from_u64(5); + let initial_balance = dash_to_credits!(1.0); let (identity, signer) = create_identity_with_transfer_key( [5u8; 32], - dash_to_credits!(1.0), + initial_balance, &mut rng, platform_version, ); @@ -384,14 +484,40 @@ mod tests { platform_version, ) .await; - let result = process_transition(&platform, st, platform_version); - - assert_matches!( - result.execution_results().as_slice(), - [StateTransitionExecutionResult::UnpaidConsensusError( - ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) - )] + let result = process_transition_and_commit(&platform, st, platform_version); + + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + let charged = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + actual_fees, + .. + }] => actual_fees.total_base_fee(), + other => panic!("expected a paid invalid-proof failure, got {other:?}"), + }; + assert!( + charged >= penalty, + "the charged fee ({charged}) must cover the proof-failure penalty ({penalty})" + ); + assert_eq!( + identity_balance(&platform, &identity), + initial_balance - charged, + "the identity must be debited exactly the charged penalty (no shield amount)" ); + assert_eq!( + identity_nonce(&platform, &identity), + 1, + "the failed transition must consume the identity nonce" + ); + let pool_total = platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], platform_version) + .expect("fetch pool total"); + assert_eq!(pool_total, 0, "a failed proof must not credit the pool"); } #[tokio::test] @@ -423,11 +549,14 @@ mod tests { .await; let result = process_transition(&platform, st, platform_version); + // A bundle that verifies against another amount is an invalid proof for this + // transition and pays the same penalty as a garbage proof. assert_matches!( result.execution_results().as_slice(), - [StateTransitionExecutionResult::UnpaidConsensusError( - ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) - )] + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] ); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs index 9432ea137b3..91e873e20e7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_identity/transform_into_action/v0/mod.rs @@ -1,16 +1,23 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; -use crate::execution::validation::state_transition::state_transitions::shielded_common::read_pool_total_balance; +use crate::execution::validation::state_transition::state_transitions::shielded_common::{ + read_pool_total_balance, reconstruct_and_verify_bundle, FLAGS_OUTPUTS_ONLY, +}; +use crate::platform_types::check_tx_proof_verifier::CheckTxProofVerifier; use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::state_error::StateError; +use dpp::fee::fee_result::FeeResult; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; use dpp::version::PlatformVersion; use drive::drive::Drive; use drive::grovedb::TransactionArg; use drive::state_transition_action::shielded::shield_from_identity::ShieldFromIdentityTransitionAction; +use drive::state_transition_action::system::bump_identity_nonce_action::BumpIdentityNonceAction; use drive::state_transition_action::StateTransitionAction; pub(in crate::execution::validation::state_transition::state_transitions::shield_from_identity) trait ShieldFromIdentityStateTransitionTransformIntoActionValidationV0 @@ -21,6 +28,7 @@ pub(in crate::execution::validation::state_transition::state_transitions::shield transaction: TransactionArg, block_info: &BlockInfo, execution_context: &mut StateTransitionExecutionContext, + check_tx_proof_verifier: Option<&CheckTxProofVerifier>, platform_version: &PlatformVersion, ) -> Result, Error>; } @@ -28,17 +36,29 @@ pub(in crate::execution::validation::state_transition::state_transitions::shield impl ShieldFromIdentityStateTransitionTransformIntoActionValidationV0 for ShieldFromIdentityTransition { - /// The identity signature, nonce, balance floor, structure, and Orchard proof - /// have all been checked by the processor before this point. The identity is - /// debited exactly `amount`, so unlike `Shield` there is no per-input slack to - /// reallocate; only the pool total is read so the action can bump it, and that - /// read is metered into the execution context so the identity pays for it. + /// The identity signature, nonce, balance floor, and structure have all been checked + /// by the processor before this point. The identity is debited exactly `amount`, so + /// unlike `Shield` there is no per-input slack to reallocate; the pool total is read + /// so the action can bump it, and that read is metered into the execution context so + /// the identity pays for it. + /// + /// The Orchard proof is verified HERE rather than in the shared stateless proof step + /// (like `ShieldFromAssetLock`), because a failed proof must not be a free rejection: + /// the transition is identity-signed, so a rejection that left the nonce and balance + /// untouched would let a funded identity submit invalid proofs indefinitely at no + /// cost. A failed proof therefore returns a `BumpIdentityNonceAction` with the + /// consensus error, which executes as a paid failure: the identity nonce is consumed + /// and the identity is charged the versioned `shielded_proof_verification_failure` + /// penalty on top of the processing metered so far. CheckTx never charges; it only + /// admits the verification under `check_tx_proof_verifier`'s permit after the cheap + /// checks passed, and rejects the transition on failure. fn transform_into_action_v0( &self, drive: &Drive, transaction: TransactionArg, block_info: &BlockInfo, execution_context: &mut StateTransitionExecutionContext, + check_tx_proof_verifier: Option<&CheckTxProofVerifier>, platform_version: &PlatformVersion, ) -> Result, Error> { let mut drive_operations = vec![]; @@ -55,6 +75,55 @@ impl ShieldFromIdentityStateTransitionTransformIntoActionValidationV0 )?; execution_context.add_operation(ValidationOperation::PrecalculatedOperation(pool_read_fee)); + let ShieldFromIdentityTransition::V0(v0) = self; + + // CheckTx admits the expensive proof only after the signature, nonce, balance + // floor, and pool read have passed, and only under its node-local budget. + // Proposal and block processing pass `None`. + let _check_tx_permit = match check_tx_proof_verifier { + Some(verifier) => Some(verifier.try_acquire(v0.actions.len()).ok_or( + Error::Execution(ExecutionError::CheckTxProofVerificationBusy), + )?), + None => None, + }; + + // Outputs-only bundle entering the pool, exactly like `Shield`. The identity ECDSA + // signature already binds every bundle field to the identity and nonce, so no extra + // sighash data is needed. + if let Err(e) = reconstruct_and_verify_bundle( + &v0.actions, + FLAGS_OUTPUTS_ONLY, + -(v0.amount as i64), + &v0.anchor, + v0.proof.as_slice(), + &v0.binding_signature, + &[], + ) { + // Paid penalty: the same versioned amount `ShieldFromAssetLock` burns from its + // asset lock, charged here as a processing operation of the identity-paid + // failure (the balance pre-check guarantees the identity can cover it). + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation( + FeeResult { + processing_fee: penalty, + ..Default::default() + }, + )); + + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_shield_from_identity_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + vec![StateError::InvalidShieldedProofError(e).into()], + )); + } + let result = ShieldFromIdentityTransitionAction::try_from_transition(self, current_total_balance); diff --git a/packages/rs-drive/src/state_transition_action/system/bump_identity_nonce_action/transformer.rs b/packages/rs-drive/src/state_transition_action/system/bump_identity_nonce_action/transformer.rs index d344f4b289c..b9084b27e09 100644 --- a/packages/rs-drive/src/state_transition_action/system/bump_identity_nonce_action/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/system/bump_identity_nonce_action/transformer.rs @@ -9,8 +9,21 @@ use dpp::state_transition::data_contract_create_transition::DataContractCreateTr use dpp::state_transition::identity_credit_transfer_transition::IdentityCreditTransferTransition; use dpp::state_transition::identity_credit_withdrawal_transition::IdentityCreditWithdrawalTransition; use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; +use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; impl BumpIdentityNonceAction { + /// from borrowed shield from identity transition (the paid penalty for a failed + /// Orchard proof: the identity nonce is consumed and the fees are charged) + pub fn from_borrowed_shield_from_identity_transition( + value: &ShieldFromIdentityTransition, + ) -> Self { + match value { + ShieldFromIdentityTransition::V0(v0) => { + BumpIdentityNonceActionV0::from_borrowed_shield_from_identity(v0).into() + } + } + } + /// from identity update pub fn from_identity_update_transition(value: IdentityUpdateTransition) -> Self { match value { diff --git a/packages/rs-drive/src/state_transition_action/system/bump_identity_nonce_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/system/bump_identity_nonce_action/v0/transformer.rs index fdb1e8c6573..0d7c4412684 100644 --- a/packages/rs-drive/src/state_transition_action/system/bump_identity_nonce_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/system/bump_identity_nonce_action/v0/transformer.rs @@ -9,9 +9,25 @@ use dpp::state_transition::identity_credit_transfer_transition::v0::IdentityCred use dpp::state_transition::identity_credit_withdrawal_transition::accessors::IdentityCreditWithdrawalTransitionAccessorsV0; use dpp::state_transition::identity_credit_withdrawal_transition::IdentityCreditWithdrawalTransition; use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; +use dpp::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; use dpp::state_transition::StateTransitionHasUserFeeIncrease; impl BumpIdentityNonceActionV0 { + /// from borrowed shield from identity + pub fn from_borrowed_shield_from_identity(value: &ShieldFromIdentityTransitionV0) -> Self { + let ShieldFromIdentityTransitionV0 { + identity_id, + nonce, + user_fee_increase, + .. + } = value; + BumpIdentityNonceActionV0 { + identity_id: *identity_id, + identity_nonce: *nonce, + user_fee_increase: *user_fee_increase, + } + } + /// from identity update pub fn from_identity_update(value: IdentityUpdateTransitionV0) -> Self { let IdentityUpdateTransitionV0 { diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index d47b655f7f8..b4a7fde8221 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -45,8 +45,8 @@ use std::os::raw::c_char; use dashcore::hashes::Hash; use dpp::address_funds::{OrchardAddress, PlatformAddress}; use dpp::shielded::{ - compute_minimum_shielded_fee, compute_shielded_unshield_fee, compute_shielded_verification_fee, - compute_shielded_withdrawal_fee, ShieldedMemo, + compute_minimum_shielded_fee, compute_shielded_identity_balance_write_fee, + compute_shielded_unshield_fee, compute_shielded_withdrawal_fee, ShieldedMemo, }; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::ProtocolError; @@ -176,7 +176,7 @@ fn shielded_fee_formula( 0 => Some(compute_minimum_shielded_fee), 1 => Some(compute_shielded_unshield_fee), 2 => Some(compute_shielded_withdrawal_fee), - 3 => Some(compute_shielded_verification_fee), + 3 => Some(compute_shielded_identity_balance_write_fee), _ => None, } } @@ -192,9 +192,10 @@ fn shielded_fee_formula( /// `AddBalanceToAddress` output-write cost), /// - `2` → ShieldedWithdrawal (`compute_shielded_withdrawal_fee` — base + /// the flat Core withdrawal-document cost), -/// - `3` → ShieldFromIdentity (`compute_shielded_verification_fee`: the -/// compute-only floor; the note and identity writes are metered at -/// execution and charged to the identity on top of it). +/// - `3` → ShieldFromIdentity (`compute_shielded_identity_balance_write_fee`: +/// the conservative complete-fee floor, compute + note storage allowance + +/// identity write allowance, that consensus requires the identity to hold on +/// top of the amount; the exact fee is metered at execution). /// /// `num_actions` is the Orchard action count of the bundle the host will /// build (a single-note spend with change is 2 actions). The fee is @@ -1203,8 +1204,10 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( /// /// `out_new_balance`, when non-null, receives the identity's proven /// post-debit balance; the wallet's managed identity is updated and persisted -/// with it before this returns. A result proof that is not this identity's -/// balance proof is reported as `ErrorShieldedSpendUnconfirmed` (the +/// with it before this returns. The activity row stays pending until the +/// shielded scan observes the note on-chain. A result proof that is not this +/// identity's balance proof, or a failure verdict the proven identity nonce +/// cannot rule out, is reported as `ErrorShieldedSpendUnconfirmed` (the /// transition may have executed; do not resubmit, the next sync reconciles). /// /// A signer that reports the TRANSFER key unavailable surfaces as code 31 diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index ecbd0d40a2c..1b015130807 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1850,7 +1850,10 @@ impl PlatformWallet { /// compute fee. Returns the proven post-debit balance, which is also applied /// to the managed identity and persisted. A result proof that is not this /// identity's balance proof reports the spend as unconfirmed - /// ([`PlatformWalletError::ShieldedSpendUnconfirmed`]). + /// ([`PlatformWalletError::ShieldedSpendUnconfirmed`]), as does any failure + /// verdict the proven identity nonce cannot rule out (do not rebuild on it). + /// The activity row stays pending until the shielded scan observes the note + /// on-chain; the balance proof alone does not confirm it. #[cfg(feature = "shielded")] pub async fn shielded_shield_from_identity( &self, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 7c2901aa647..475fa21785e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -40,16 +40,22 @@ use std::sync::Arc; use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::transition::identity_create_from_shielded_pool::IdentityCreateFromShieldedPool; +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::IdentityNonceFetcher; use dpp::address_funds::{ AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, OrchardAddress, PlatformAddress, }; use dpp::fee::Credits; use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dpp::identity::core_script::CoreScript; +use dpp::identity::identity_nonce::{ + IDENTITY_NONCE_VALUE_FILTER, IDENTITY_NONCE_VALUE_FILTER_MAX_BYTES, + MAX_MISSING_IDENTITY_REVISIONS, MISSING_IDENTITY_REVISIONS_FILTER, +}; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; -use dpp::prelude::Identifier; +use dpp::prelude::{Identifier, IdentityNonce}; use dpp::shielded::builder::{ build_identity_create_from_shielded_pool_transition, build_shield_from_identity_transition, build_shield_transition, build_shielded_transfer_transition, @@ -810,13 +816,25 @@ pub async fn shield_to, P: Orchar /// value (the FFI reports it as such), so the consensus floor must not stand in /// for it. /// -/// Returns the identity's proven post-debit balance. The activity row is only -/// confirmed by the identity's own `VerifiedPartialIdentity` proof carrying a -/// balance (the SDK's `ShieldFromIdentity` check); a proof for another identity, -/// one without a balance, or any other result leaves the row pending and -/// reports [`PlatformWalletError::ShieldedSpendUnconfirmed`], since the wallet -/// cannot tell from it whether the transition executed. The caller owns the -/// managed identity and persists the balance +/// Failure verdicts are reconciled before the row is marked `Failed`. An identity +/// shield has no input nullifier, so a rebuilt retry would debit the identity +/// AGAIN under a fresh nonce; a DAPI that relayed the transition and then +/// returned a rejection could therefore turn one shield into two. Every +/// "definitive" failure (a rejected broadcast, a consensus error in the result +/// wait) is checked against the identity's PROVEN nonce: only when the proof +/// shows `nonce` was never merged is the row marked `Failed` and the error +/// returned; otherwise the row stays pending and the caller receives +/// [`PlatformWalletError::ShieldedSpendUnconfirmed`] and must not rebuild. +/// +/// Returns the identity's proven post-debit balance, which requires the +/// identity's own `VerifiedPartialIdentity` proof carrying a balance (the SDK's +/// `ShieldFromIdentity` check); a proof for another identity, one without a +/// balance, or any other result reports `ShieldedSpendUnconfirmed`. That balance +/// proof is an affected-state snapshot: it binds neither this transition nor the +/// created note, so it does NOT confirm the activity row. The row stays `Pending` +/// until the shielded scan observes the note commitments on-chain (the +/// coordinator's confirmation pass), exactly like the ambiguous post-broadcast +/// paths. The caller owns the managed identity and persists the balance /// (`PlatformWallet::shielded_shield_from_identity`). #[allow(clippy::too_many_arguments)] pub async fn shield_from_identity_to< @@ -916,17 +934,21 @@ pub async fn shield_from_identity_to< match state_transition.broadcast(sdk, None).await { Ok(()) => {} Err(e) if broadcast_definitely_failed(&e) => { - record_activity_status( + // Unauthenticated verdict: settle it against the proven nonce. + return Err(settle_identity_shield_failure( + sdk, store, persister, wallet_id, id, &pending_entry, - ShieldedActivityStatus::Failed, - None, + identity_id, + nonce, + account, + e, + &enrich, ) - .await; - return Err(enrich(&e)); + .await); } Err(e) => { warn!( @@ -941,8 +963,8 @@ pub async fn shield_from_identity_to< // The proof authenticates the identity's post-debit balance (an affected-state // snapshot, not execution evidence); a consensus rejection surfaces as an error. // `wait_for_affected_state` only converts the proof generically, so the variant - // and the identity are enforced here: only this identity's balance proof may - // confirm the activity. + // and the identity are enforced here: only this identity's balance proof is + // accepted as the post-debit balance. let proof_outcome: Result = match state_transition .wait_for_affected_state::(sdk, None) .await @@ -963,17 +985,22 @@ pub async fn shield_from_identity_to< )), Err(wait_err) => { if carries_consensus_rejection(&wait_err) { - record_activity_status( + // The wait-side error envelope is unauthenticated too: settle it + // against the proven nonce before recording a failure. + return Err(settle_identity_shield_failure( + sdk, store, persister, wallet_id, id, &pending_entry, - ShieldedActivityStatus::Failed, - None, + identity_id, + nonce, + account, + wait_err, + &enrich, ) - .await; - return Err(enrich(&wait_err)); + .await); } Err(wait_err.to_string()) } @@ -994,20 +1021,175 @@ pub async fn shield_from_identity_to< } }; - record_activity_status( - store, - persister, - wallet_id, - id, - &pending_entry, - ShieldedActivityStatus::Confirmed, - None, - ) - .await; - info!(account, credits = amount, identity = %identity_id, "ShieldFromIdentity broadcast succeeded"); + // Not confirmed here: the balance proof does not bind this transition or its + // note, so the row stays `Pending` until the scan's confirmation pass observes + // the note commitments on-chain (see the function docs). + info!( + account, + credits = amount, + identity = %identity_id, + "ShieldFromIdentity admitted with a proven post-debit balance; the activity row \ + awaits on-chain confirmation by the shielded scan" + ); Ok(new_balance) } +/// Whether a proven identity nonce word shows that `used` was never merged into the +/// identity's nonce history, so a transition carrying `used` cannot have executed. +/// +/// Platform stores the identity nonce as the highest merged value (the low +/// `IDENTITY_NONCE_VALUE_FILTER` bits) plus a bitmask of the up to +/// `MAX_MISSING_IDENTITY_REVISIONS` values below it that were skipped (bit +/// `position - 1 + IDENTITY_NONCE_VALUE_FILTER_MAX_BYTES` set means `tip - position` +/// is still missing). `used` is proven unmerged when it lies above the tip, or when it +/// lies within the window below the tip and its missing bit is set. A value older than +/// the window can no longer be told apart from a merged one, so it is treated as +/// possibly used. +pub(crate) fn identity_nonce_proves_unused(stored: IdentityNonce, used: IdentityNonce) -> bool { + let tip = stored & IDENTITY_NONCE_VALUE_FILTER; + if used > tip { + return true; + } + if used == tip { + return false; + } + let position = tip - used; + if position > MAX_MISSING_IDENTITY_REVISIONS { + return false; + } + let missing = stored & MISSING_IDENTITY_REVISIONS_FILTER; + missing & (1u64 << (position - 1 + IDENTITY_NONCE_VALUE_FILTER_MAX_BYTES)) != 0 +} + +/// Settle an UNAUTHENTICATED failure verdict for an identity shield (a rejected +/// broadcast or a consensus error in the result wait) against the identity's proven +/// nonce. The identity nonce is fetched with a proof: when it shows `nonce` was never +/// merged, the transition cannot have executed, the activity row is marked `Failed`, +/// and the verdict is returned through `enrich`. Otherwise the transition may have +/// executed despite the verdict (a relaying DAPI can forge one after the debit +/// committed), so the row stays pending and the caller receives +/// [`PlatformWalletError::ShieldedSpendUnconfirmed`]: it must NOT rebuild, because a +/// fresh nonce would debit the identity again. +#[allow(clippy::too_many_arguments)] +async fn settle_identity_shield_failure< + S: ShieldedStore, + F: Fn(&dash_sdk::Error) -> PlatformWalletError + Sync, +>( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + pending_entry: &Option, + identity_id: Identifier, + nonce: IdentityNonce, + account: u32, + verdict: dash_sdk::Error, + enrich: &F, +) -> PlatformWalletError { + let proven_nonce = IdentityNonceFetcher::fetch(sdk, identity_id) + .await + .map(|fetched| fetched.map(|fetcher| fetcher.0).unwrap_or_default()); + match proven_nonce { + Ok(stored) if identity_nonce_proves_unused(stored, nonce) => { + record_activity_status( + store, + persister, + wallet_id, + id, + pending_entry, + ShieldedActivityStatus::Failed, + None, + ) + .await; + enrich(&verdict) + } + Ok(stored) => { + warn!( + account, + identity = %identity_id, + nonce, + proven_nonce = stored, + verdict = %verdict, + "ShieldFromIdentity reported failed, but the proven identity nonce shows the \ + transition's nonce as merged; leaving the activity row pending" + ); + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "shield from identity", + reason: format!( + "{verdict}; the identity nonce {nonce} is already merged on Platform, so \ + the transition may have executed (do not resubmit)" + ), + } + } + Err(fetch_err) => { + warn!( + account, + identity = %identity_id, + nonce, + verdict = %verdict, + error = %fetch_err, + "ShieldFromIdentity reported failed, but the identity nonce could not be \ + verified; leaving the activity row pending" + ); + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "shield from identity", + reason: format!( + "{verdict}; the identity nonce could not be verified ({fetch_err}), so the \ + transition may have executed (do not resubmit)" + ), + } + } + } +} + +#[cfg(test)] +mod identity_nonce_proves_unused_tests { + use super::*; + + fn missing_bit(position: u64) -> u64 { + 1u64 << (position - 1 + IDENTITY_NONCE_VALUE_FILTER_MAX_BYTES) + } + + /// Above the tip: never merged. At the tip: merged. + #[test] + fn tip_comparisons() { + assert!( + identity_nonce_proves_unused(0, 1), + "fresh identity, nonce 1 unused" + ); + assert!(identity_nonce_proves_unused(5, 6)); + assert!(!identity_nonce_proves_unused(5, 5)); + } + + /// Below the tip within the window: unused only when its missing bit is set. + #[test] + fn window_below_tip_reads_the_missing_mask() { + let tip = 10u64; + // nonce 8 is two below the tip: position 2. + assert!(!identity_nonce_proves_unused(tip, 8), "no mask: merged"); + assert!(identity_nonce_proves_unused(tip | missing_bit(2), 8)); + assert!( + !identity_nonce_proves_unused(tip | missing_bit(3), 8), + "a different position's bit must not count" + ); + } + + /// Older than the window: cannot be told apart from merged, so possibly used. + #[test] + fn older_than_window_is_possibly_used() { + let tip = 100u64; + assert!(!identity_nonce_proves_unused( + tip, + tip - MAX_MISSING_IDENTITY_REVISIONS - 1 + )); + assert!(identity_nonce_proves_unused( + tip | missing_bit(MAX_MISSING_IDENTITY_REVISIONS), + tip - MAX_MISSING_IDENTITY_REVISIONS + )); + } +} + /// Map a `ShieldFromIdentity` builder failure. The builder signs with the /// identity signer, so a structured key-unavailable completion (the reserved /// marker at position 0 of a `ProtocolError::Generic` payload) can surface diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index ecaa23a9936..630573214b0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -468,10 +468,11 @@ extension PlatformWalletManager { case unshield = 1 /// ShieldedWithdrawal (`compute_shielded_withdrawal_fee`). case withdrawal = 2 - /// ShieldFromIdentity (`compute_shielded_verification_fee`): the - /// compute-only floor (Halo 2 verification + per-action - /// processing) this transition adds on top of its GroveDB-metered - /// storage, so storage is never double-counted. + /// ShieldFromIdentity (`compute_shielded_identity_balance_write_fee`): + /// the conservative complete-fee floor (compute + note storage + /// allowance + identity write allowance) consensus requires the + /// identity to hold on top of the amount; the exact fee is metered + /// at execution. case shieldFromIdentity = 3 } From 7a3b657bfbb4a93a741de820a8f536c53e0256f7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 13 Sep 2026 06:07:29 +0700 Subject: [PATCH 09/17] fix(dpp): calibrate the ShieldFromIdentity identity-write allowance to its measured cost SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES was copied from the Unshield address-write constant (222) rather than measured, and its docs described a new-address-style storage write. The identity-side operations of a ShieldFromIdentity (UpdateIdentityNonce and RemoveFromIdentityBalance) replace existing elements and add no storage bytes: their GroveDB-metered cost is 424,400 credits of processing, 15.5 effective bytes at the 27,400 credits/byte storage rate. The constant is now 16, with the same small round-up the other calibrated shielded constants use, and the docs say what it covers. The 2-action floor becomes 114,578,400 credits against a measured real fee of 106,059,160, still conservative. Co-Authored-By: Claude Fable 5.1 --- book/src/fees/shielded-fees.md | 9 +++--- .../compute_minimum_shielded_fee/mod.rs | 7 +++-- .../compute_minimum_shielded_fee/v0/mod.rs | 13 +++++---- packages/rs-dpp/src/shielded/mod.rs | 28 +++++++++++-------- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index b56fd189239..e4add21f937 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -109,10 +109,11 @@ identity_balance_after = identity_balance_before - amount - fee `user_fee_increase` applies to the metered processing portion. The stateless floor requires `identity_balance >= amount + compute_shielded_identity_balance_write_fee`, -the conservative complete fee (`compute_minimum_shielded_fee` plus a flat -`SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES` identity-write allowance at the -storage rate), so an identity that could not pay the complete fee is refused before -the Orchard proof is verified. The authoritative gate is the identity-paid fee +the conservative complete fee (`compute_minimum_shielded_fee` plus the calibrated +`SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES` identity-write component at the +storage rate: 16 effective bytes, the measured 424,400 credits of processing for the +nonce and balance replacements, which add no storage), so an identity that could not +pay the complete fee is refused before the Orchard proof is verified. The authoritative gate is the identity-paid fee validation of the execution event (`Paid`), which rejects with `IdentityInsufficientBalanceError`. The identity balance and the pool total are both terms of the block conservation equation, so the converter emits no diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs index 5b594d8c6d9..ce5ea923e35 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs @@ -120,9 +120,10 @@ pub fn compute_shielded_unshield_fee( } /// Computes the conservative **admission floor** (in credits) of a shielded transition that also -/// writes an identity balance (`ShieldFromIdentity`): [`compute_minimum_shielded_fee`] plus a flat -/// identity-balance-write allowance (`SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES` effective -/// bytes at the per-byte storage rate). +/// writes an identity balance (`ShieldFromIdentity`): [`compute_minimum_shielded_fee`] plus the +/// calibrated identity-write component (`SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES` effective +/// bytes at the per-byte storage rate: the measured processing cost of the nonce and balance +/// replacements, which add no storage bytes). /// /// The transition's authoritative fee is metered at execution; this floor stands in for it where /// state is not yet available, so that an identity that could not pay the complete fee is refused diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs index b627e905324..b3d96dc56e6 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs @@ -197,12 +197,13 @@ pub fn compute_shielded_unshield_fee_v0( /// `floor = compute_minimum_shielded_fee_v0(num_actions) /// + SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES × (disk + processing) credits/byte` /// -/// [`compute_minimum_shielded_fee_v0`] plus one flat storage component for the identity balance -/// write, the same shape as [`compute_shielded_unshield_fee_v0`]'s address-write component. The -/// transition's real fee is metered at execution (note inserts plus the identity balance and -/// nonce updates); this floor is the conservative stand-in the stateless balance pre-check uses -/// so that a short identity is refused before the Orchard proof is verified, and the client-side -/// estimate of the total fee. +/// [`compute_minimum_shielded_fee_v0`] plus one flat component for the identity-side writes +/// (nonce and balance replacements), built the same way as +/// [`compute_shielded_unshield_fee_v0`]'s address-write component but calibrated to those +/// replacements' measured processing cost (they add no storage). The transition's real fee is +/// metered at execution; this floor is the conservative stand-in the stateless balance pre-check +/// uses so that a short identity is refused before the Orchard proof is verified, and the +/// client-side estimate of the total fee. /// /// All arithmetic is checked: an overflow (only reachable via pathological fee constants) /// surfaces as `ProtocolError::Overflow` instead of silently wrapping. diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index 86a741f2031..f996e338e37 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -76,19 +76,25 @@ pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100; /// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`]. pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; -/// Flat storage allowance (in effective bytes) for the identity balance write that -/// `ShieldFromIdentity` performs on top of its per-action note writes. +/// Calibrated effective-byte cost of the identity-side writes a `ShieldFromIdentity` performs on +/// top of its per-action note inserts: the `UpdateIdentityNonce` and `RemoveFromIdentityBalance` +/// operations. /// -/// The transition's real fee is metered (note inserts plus the identity balance and nonce -/// updates) and only known at execution, so its stateless admission floor needs a conservative -/// stand-in for that metered part: [`compute_minimum_shielded_fee`] (compute plus the per-action -/// note storage allowance) plus this identity-write allowance, priced at the same per-byte storage -/// rate. The figure mirrors `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES`: a balance-tree write of the -/// same shape as the transparent address write, ≈222 effective bytes. Admission below -/// `amount + floor` is refused BEFORE the Orchard proof is verified, so a short identity can -/// never occupy a proof-verification slot. See +/// The transition's real fee is metered and only known at execution, so its stateless admission +/// floor needs a conservative stand-in for the metered part: [`compute_minimum_shielded_fee`] +/// (compute plus the per-action note allowance, which covers the note inserts with headroom) +/// plus this identity-write component, priced at the same per-byte storage rate so it tracks the +/// rate as it evolves. Admission below `amount + floor` is refused BEFORE the Orchard proof is +/// verified, so a short identity never occupies a proof-verification slot. +/// +/// Unlike the `Unshield` address write, both identity operations REPLACE existing elements +/// (the identity's nonce and its balance-tree entry), so they add no storage bytes at all: the +/// GroveDB-metered cost of the pair is 424,400 credits of processing, which is 15.5 effective +/// bytes at the 27,400 credits/byte storage rate. 16 covers it with the usual small round-up. +/// (The pool-total update is not priced separately, exactly as for the other pool-paid +/// transitions.) See /// [`compute_minimum_shielded_fee::compute_shielded_identity_balance_write_fee`]. -pub const SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES: u64 = 222; +pub const SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES: u64 = 16; /// Common Orchard bundle parameters shared across all shielded transition types. /// From 5b361b3f1c6ed6a1a46c78f2a978eec045024ffe Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 13 Sep 2026 06:15:54 +0700 Subject: [PATCH 10/17] fix(dpp): calibrate the IdentityTopUpFromShieldedPool identity-write component to its measured cost SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES was copied from the Unshield address-write constant (222) rather than measured. The top-up's only identity-side operation, AddToIdentityBalance, replaces the existing identity's balance element and adds no storage bytes: its GroveDB-metered cost is 175,280 credits of processing, 6.4 effective bytes at the 27,400 credits/byte storage rate. The constant is now 7, with the same small round-up the other calibrated shielded constants use, so the pool-paid flat fee stops overcharging about 5.9M credits per top-up. The 2-action flat fee becomes 114,331,800 credits. Co-Authored-By: Claude Fable 5.1 --- book/src/fees/shielded-fees.md | 2 +- .../compute_minimum_shielded_fee/v0/mod.rs | 6 ++++-- packages/rs-dpp/src/shielded/mod.rs | 15 ++++++++++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index ff34beff38a..4169af13842 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -44,7 +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 priced like `Unshield`'s address write (`identity_balance_storage_fee = 222 x per_byte_rate`). The target identity and gross amount are bound into the Orchard sighash; the identity must already exist; no system-credit adjustment. | +| **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 replaces the existing identity's balance element, 175,280 credits of processing and no storage, so `identity_balance_storage_fee = 7 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. | For `ShieldedTransfer`, the client constructs the bundle so that `total_spent − total_output = desired_fee`. The Orchard circuit proves that value is conserved diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs index a36c2cb73fe..0bcc807035c 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs @@ -265,8 +265,10 @@ pub fn compute_shielded_identity_balance_write_fee_v0( /// All arithmetic is checked: an overflow (only reachable via pathological fee constants or key /// counts) surfaces as `ProtocolError::Overflow` instead of silently wrapping. /// Flat fee for `IdentityTopUpFromShieldedPool`: the base shielded minimum plus the flat -/// identity-balance write component, mirroring `compute_shielded_unshield_fee_v0`'s address -/// write component (the top-up writes one balance element, priced like an address write). +/// identity-balance write component, built like `compute_shielded_unshield_fee_v0`'s address +/// write component but calibrated to the measured processing cost of replacing the existing +/// identity's balance element (it adds no storage; see +/// `SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES`). pub fn compute_shielded_identity_top_up_fee_v0( num_actions: usize, platform_version: &PlatformVersion, diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index bbbfda1ef42..a7a65a5206e 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -79,9 +79,18 @@ pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100; /// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`]. pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; -/// Flat storage component charged by `IdentityTopUpFromShieldedPool` for the identity balance -/// write, priced like the `Unshield` address write so the pool-paid flat fee covers it. -pub const SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES: u64 = 222; +/// Calibrated effective-byte cost of the identity-side write an `IdentityTopUpFromShieldedPool` +/// performs on top of its per-action nullifier and note writes: the single +/// `AddToIdentityBalance` operation, charged as a flat component of the pool-paid fee (built like +/// `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES`, priced at the same per-byte storage rate). +/// +/// Unlike the `Unshield` address write, the top-up REPLACES the existing identity's balance-tree +/// entry (the identity must already exist), so it adds no storage bytes: its GroveDB-metered cost +/// is 175,280 credits of processing, 6.4 effective bytes at the 27,400 credits/byte storage rate. +/// 7 covers it with the usual small round-up. (The pool-total update is not priced separately, +/// exactly as for the other pool-paid transitions.) See +/// [`compute_minimum_shielded_fee::compute_shielded_identity_top_up_fee`]. +pub const SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES: u64 = 7; /// Calibrated effective-byte cost of the identity-side writes a `ShieldFromIdentity` performs on /// top of its per-action note inserts: the `UpdateIdentityNonce` and `RemoveFromIdentityBalance` From ead503c187cfe907271ceeced38f2a71d20c1540 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 13 Sep 2026 07:30:12 +0700 Subject: [PATCH 11/17] fix(dpp): size the ShieldFromIdentity identity-write component as flat replace-only tree work The identity nonce and balance writes add no storage but rewrite their elements and every Merk node on the path to the root: 563 and 320 replaced bytes, 466,760 credits of processing at protocol version 14 (424,400 when batched). Like every other flat shielded component, that variable tree work is folded into one flat effective-byte figure at the storage rate instead of being modelled per replaced byte. 466,760 credits is 17.0 effective bytes; the constant is 20, leaving headroom for the path growing by about a node per doubling of the identity count. The docs no longer describe the writes as storage bytes. Co-Authored-By: Claude Fable 5.1 --- book/src/fees/shielded-fees.md | 10 ++++--- .../compute_minimum_shielded_fee/mod.rs | 6 ++-- .../compute_minimum_shielded_fee/v0/mod.rs | 13 +++++---- packages/rs-dpp/src/shielded/mod.rs | 29 +++++++++++-------- 4 files changed, 33 insertions(+), 25 deletions(-) diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index e4add21f937..fe01d767c90 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -109,11 +109,13 @@ identity_balance_after = identity_balance_before - amount - fee `user_fee_increase` applies to the metered processing portion. The stateless floor requires `identity_balance >= amount + compute_shielded_identity_balance_write_fee`, -the conservative complete fee (`compute_minimum_shielded_fee` plus the calibrated +the conservative complete fee (`compute_minimum_shielded_fee` plus the flat `SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES` identity-write component at the -storage rate: 16 effective bytes, the measured 424,400 credits of processing for the -nonce and balance replacements, which add no storage), so an identity that could not -pay the complete fee is refused before the Orchard proof is verified. The authoritative gate is the identity-paid fee +storage rate: 20 effective bytes covering the nonce and balance rewrites, which add no +storage but replace 883 bytes of Merk path for a measured 466,760 credits of +processing, folded into one flat figure with headroom like the other shielded +components), so an identity that could not pay the complete fee is refused before the +Orchard proof is verified. The authoritative gate is the identity-paid fee validation of the execution event (`Paid`), which rejects with `IdentityInsufficientBalanceError`. The identity balance and the pool total are both terms of the block conservation equation, so the converter emits no diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs index ce5ea923e35..3918cdb9505 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/mod.rs @@ -121,9 +121,9 @@ pub fn compute_shielded_unshield_fee( /// Computes the conservative **admission floor** (in credits) of a shielded transition that also /// writes an identity balance (`ShieldFromIdentity`): [`compute_minimum_shielded_fee`] plus the -/// calibrated identity-write component (`SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES` effective -/// bytes at the per-byte storage rate: the measured processing cost of the nonce and balance -/// replacements, which add no storage bytes). +/// flat identity-write component (`SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES` effective bytes +/// at the per-byte storage rate: the nonce and balance rewrites' replace-only tree work, folded +/// into one flat figure like the other shielded components). /// /// The transition's authoritative fee is metered at execution; this floor stands in for it where /// state is not yet available, so that an identity that could not pay the complete fee is refused diff --git a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs index b3d96dc56e6..c85a4c1f87b 100644 --- a/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs +++ b/packages/rs-dpp/src/shielded/compute_minimum_shielded_fee/v0/mod.rs @@ -198,12 +198,13 @@ pub fn compute_shielded_unshield_fee_v0( /// + SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES × (disk + processing) credits/byte` /// /// [`compute_minimum_shielded_fee_v0`] plus one flat component for the identity-side writes -/// (nonce and balance replacements), built the same way as -/// [`compute_shielded_unshield_fee_v0`]'s address-write component but calibrated to those -/// replacements' measured processing cost (they add no storage). The transition's real fee is -/// metered at execution; this floor is the conservative stand-in the stateless balance pre-check -/// uses so that a short identity is refused before the Orchard proof is verified, and the -/// client-side estimate of the total fee. +/// (the nonce and balance rewrites), built the same way as +/// [`compute_shielded_unshield_fee_v0`]'s address-write component: the writes' replace-only tree +/// work (they add no storage) folded into a flat effective-byte figure with headroom, see +/// `SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES`. The transition's real fee is metered at +/// execution; this floor is the conservative stand-in the stateless balance pre-check uses so +/// that a short identity is refused before the Orchard proof is verified, and the client-side +/// estimate of the total fee. /// /// All arithmetic is checked: an overflow (only reachable via pathological fee constants) /// surfaces as `ProtocolError::Overflow` instead of silently wrapping. diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index f996e338e37..de004bb826a 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -76,25 +76,30 @@ pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100; /// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`]. pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; -/// Calibrated effective-byte cost of the identity-side writes a `ShieldFromIdentity` performs on -/// top of its per-action note inserts: the `UpdateIdentityNonce` and `RemoveFromIdentityBalance` -/// operations. +/// Flat component (in effective bytes at the per-byte storage rate) for the identity-side writes a +/// `ShieldFromIdentity` performs on top of its per-action note inserts: the `UpdateIdentityNonce` +/// and `RemoveFromIdentityBalance` operations. /// /// The transition's real fee is metered and only known at execution, so its stateless admission /// floor needs a conservative stand-in for the metered part: [`compute_minimum_shielded_fee`] /// (compute plus the per-action note allowance, which covers the note inserts with headroom) -/// plus this identity-write component, priced at the same per-byte storage rate so it tracks the -/// rate as it evolves. Admission below `amount + floor` is refused BEFORE the Orchard proof is +/// plus this component. Admission below `amount + floor` is refused BEFORE the Orchard proof is /// verified, so a short identity never occupies a proof-verification slot. /// -/// Unlike the `Unshield` address write, both identity operations REPLACE existing elements -/// (the identity's nonce and its balance-tree entry), so they add no storage bytes at all: the -/// GroveDB-metered cost of the pair is 424,400 credits of processing, which is 15.5 effective -/// bytes at the 27,400 credits/byte storage rate. 16 covers it with the usual small round-up. -/// (The pool-total update is not priced separately, exactly as for the other pool-paid -/// transitions.) See +/// What the two writes do: the identity already exists, so neither adds storage. Each rewrites +/// its element and every Merk node on the path to the root (replaced bytes, charged at the +/// per-byte processing rate), loads the path, seeks, and rehashes the nodes. Measured at protocol +/// version 14: the nonce update replaces 563 bytes and the balance debit 320 bytes (883 in +/// total) for 466,760 credits of processing applied one at a time, 424,400 when batched together +/// (shared path work). Like every other flat shielded component (`shielded_storage_bytes_per_action`, +/// `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES`), that variable tree work is folded into ONE flat +/// effective-byte figure priced at the full storage rate so it tracks the rate as it evolves, +/// rather than modelled per replaced byte: 466,760 credits is 17.0 effective bytes at 27,400 +/// credits/byte, and 20 leaves headroom for the path growing by about a node (roughly 0.7 +/// effective bytes) each time the identity count doubles. The pool-total update is not priced +/// separately, exactly as for the other pool-paid transitions. See /// [`compute_minimum_shielded_fee::compute_shielded_identity_balance_write_fee`]. -pub const SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES: u64 = 16; +pub const SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES: u64 = 20; /// Common Orchard bundle parameters shared across all shielded transition types. /// From 424d14ca2ce5a6841c84ee963687b478bf20c929 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 13 Sep 2026 07:32:48 +0700 Subject: [PATCH 12/17] fix(dpp): resolve merge markers left in shielded/mod.rs The previous merge commit was pushed with an unresolved conflict block in the shielded constants (both branches rewrote adjacent constant docs). This restores the file: the ShieldFromIdentity component from the base branch (20) followed by the top-up component (8), no markers. Co-Authored-By: Claude Fable 5.1 --- packages/rs-dpp/src/shielded/mod.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index 148053f43ea..9a16c30c054 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -25,9 +25,7 @@ pub use compute_minimum_shielded_fee::{ // re-exported (callers use the wrappers; byte-layout tests use the `_v0` impls). pub use sighash::{ compute_platform_sighash, identity_create_from_shielded_extra_sighash_data, - identity_create_from_shielded_extra_sighash_data_v0, - identity_top_up_from_shielded_extra_sighash_data, - identity_top_up_from_shielded_extra_sighash_data_v0, shielded_withdrawal_extra_sighash_data, + identity_create_from_shielded_extra_sighash_data_v0, shielded_withdrawal_extra_sighash_data, shielded_withdrawal_extra_sighash_data_v0, unshield_extra_sighash_data, unshield_extra_sighash_data_v0, }; @@ -79,7 +77,6 @@ pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100; /// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`]. pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; -<<<<<<< HEAD /// Flat component (in effective bytes at the per-byte storage rate) for the identity-side write an /// `IdentityTopUpFromShieldedPool` performs on top of its per-action nullifier and note writes: /// the single `AddToIdentityBalance` operation, charged as part of the pool-paid flat fee (built @@ -98,14 +95,9 @@ pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222; /// [`compute_minimum_shielded_fee::compute_shielded_identity_top_up_fee`]. pub const SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES: u64 = 8; -/// Calibrated effective-byte cost of the identity-side writes a `ShieldFromIdentity` performs on -/// top of its per-action note inserts: the `UpdateIdentityNonce` and `RemoveFromIdentityBalance` -/// operations. -======= /// Flat component (in effective bytes at the per-byte storage rate) for the identity-side writes a /// `ShieldFromIdentity` performs on top of its per-action note inserts: the `UpdateIdentityNonce` /// and `RemoveFromIdentityBalance` operations. ->>>>>>> claude/identity-shielded-pool-transition-847713 /// /// The transition's real fee is metered and only known at execution, so its stateless admission /// floor needs a conservative stand-in for the metered part: [`compute_minimum_shielded_fee`] From 6ab057e411b18384b2fc9c12f6292c6ba6fa8896 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 13 Sep 2026 07:36:28 +0700 Subject: [PATCH 13/17] fix(dpp): restore the top-up sighash exports dropped by the marker repair The previous repair rebuilt shielded/mod.rs from the base branch and lost this branch's `identity_top_up_from_shielded_extra_sighash_data` re-exports. The file now matches the pre-merge top-up version except for the two recalibrated constants (8 and 20); dpp, drive-abci, wallet, and FFI build and their shielded tests pass. Co-Authored-By: Claude Fable 5.1 --- packages/rs-dpp/src/shielded/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index 9a16c30c054..f0b553f9aee 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -25,7 +25,9 @@ pub use compute_minimum_shielded_fee::{ // re-exported (callers use the wrappers; byte-layout tests use the `_v0` impls). pub use sighash::{ compute_platform_sighash, identity_create_from_shielded_extra_sighash_data, - identity_create_from_shielded_extra_sighash_data_v0, shielded_withdrawal_extra_sighash_data, + identity_create_from_shielded_extra_sighash_data_v0, + identity_top_up_from_shielded_extra_sighash_data, + identity_top_up_from_shielded_extra_sighash_data_v0, shielded_withdrawal_extra_sighash_data, shielded_withdrawal_extra_sighash_data_v0, unshield_extra_sighash_data, unshield_extra_sighash_data_v0, }; From 9f0d97e590ebe58ff2db4f203aba48df646c1122 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 13 Sep 2026 16:56:51 +0700 Subject: [PATCH 14/17] fix(platform): classify top-up proofs as execution-proving and persist the proven balance Addresses the review of IdentityTopUpFromShieldedPool: - The wallet's shared spend path uses the strict result wait, which rejects snapshot outcomes, while the verifier classified type 22 as a snapshot; every successful top-up therefore surfaced as ShieldedSpendUnconfirmed with its notes left pending. Type 22 is now proof-binding like the other nullifier-spend families: its spent nullifiers identify this exact transition and, unlike identity-create, no fallback action spends them on a failed top-up. The drive-abci prove/verify test asserts the executed outcome. - The shared spend helper returns the proven result, the operation extracts the credited identity's proof-attested balance, and the wallet applies and persists it for a managed identity (adding the requested amount locally would be wrong when the fee or a negative balance absorbs part of the top-up). The FFI signature is unchanged. - The top-up FFI export now runs under catch_spend_panic like the shield-to-recipient export, so a proving panic cannot unwind across the C ABI. - The durable-recovery classifier recognises type 22 as a nullifier-funded spend, so a damaged ancillary field on its redrive row no longer fails store rehydration. - The wasm wrapper's identityId setter is removed: the field is committed by the Orchard binding signature and the wrapper cannot re-sign. - The fee guide lists the top-up in the pool-paid fee-flow and flat-component sections. Co-Authored-By: Claude Fable 5.1 --- book/src/fees/shielded-fees.md | 24 +++++++--- .../tests.rs | 10 ++++- .../v0/mod.rs | 8 ++-- .../src/shielded_send.rs | 34 +++++++++++++- .../src/wallet/platform_wallet.rs | 29 ++++++++++-- .../src/wallet/shielded/file_store.rs | 3 ++ .../src/wallet/shielded/operations.rs | 45 +++++++++++++++---- ...ty_top_up_from_shielded_pool_transition.rs | 12 ++--- 8 files changed, 135 insertions(+), 30 deletions(-) diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index d170b0c9707..b9df1216525 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -258,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, @@ -273,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 @@ -345,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 @@ -366,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). diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs index f7990bd20bf..cb1340c71b8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs @@ -26,7 +26,9 @@ mod tests { use dpp::shielded::SerializedAction; use dpp::state_transition::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0; use dpp::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; - use dpp::state_transition::proof_result::StateTransitionProofResult; + use dpp::state_transition::proof_result::{ + StateTransitionProofOutcome, StateTransitionProofResult, + }; use dpp::state_transition::StateTransition; use drive::drive::Drive; use grovedb_commitment_tree::{ @@ -483,6 +485,12 @@ mod tests { ) .expect("verify"); assert_ne!(root_hash, [0u8; 32]); + // The spent nullifiers identify this exact transition, so the strict SDK + // wait (`wait_for_response`) must see an executed outcome, not a snapshot. + assert!( + matches!(outcome, StateTransitionProofOutcome::ExecutionProved(_)), + "a top-up proof must be classified as execution-proving, got {outcome:?}" + ); let result = outcome.into_result(); let StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers(proven, statuses) = diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 639da37e4b3..b3409ce3fd3 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -2416,9 +2416,11 @@ impl Drive { // Only the identity's post-debit balance is proven; the shielded note // and the requested amount are not bound. StateTransition::ShieldFromIdentity(_) => false, - // Spent nullifiers plus the credited identity do not bind the gross - // amount or the fee split. - StateTransition::IdentityTopUpFromShieldedPool(_) => false, + // Nullifier-spend proof like Unshield: the spent nullifiers bind the + // exact Orchard actions of this transition (unlike identity-create + // there is no fallback action that spends them on a failed top-up), + // and the credited identity is proven alongside. + StateTransition::IdentityTopUpFromShieldedPool(_) => true, }; Ok(binds) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index ce1215878f9..e5c086a1cca 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1306,6 +1306,36 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_top_up_from_p account: u32, identity_id: *const u8, amount: u64, +) -> PlatformWalletFFIResult { + // The whole body runs under `catch_unwind`, exactly like the shield-to-recipient + // export: `block_on_worker` re-panics on a panicking proving task, and a panic + // must not reach this `extern "C"` frame, where it would abort the host process. + // Notes may already be reserved and the transition may already be broadcast when + // the panic strikes, so the ambiguous spend-unconfirmed contract applies. + catch_spend_panic("shielded identity top up from pool", || { + shielded_identity_top_up_from_pool_inner( + handle, + wallet_id_bytes, + mnemonic_resolver_handle, + account, + identity_id, + amount, + ) + }) +} + +/// Body of [`platform_wallet_manager_shielded_identity_top_up_from_pool`], as an ordinary +/// Rust function so a panic unwinds into [`catch_spend_panic`] instead of across the C ABI. +/// +/// # Safety +/// Identical contract to the export that calls it. +unsafe fn shielded_identity_top_up_from_pool_inner( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + identity_id: *const u8, + amount: u64, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); check_ptr!(mnemonic_resolver_handle); @@ -1341,7 +1371,9 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_top_up_from_p ) .await; poke_sync_on_unconfirmed(&r, handle); - r + // The proof-attested balance is applied to a managed identity inside the + // wallet; this export reports only the outcome. + r.map(|_| ()) }); map_spend_result(result, "shielded identity top up from pool") } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 53fe0d7729b..2b7c33e9f10 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1445,9 +1445,9 @@ impl PlatformWallet { identity_id: &Identifier, amount: u64, prover: P, - ) -> Result<(), PlatformWalletError> { + ) -> Result, PlatformWalletError> { let keyset = self.derive_spend_keyset(seed, account).await?; - super::shielded::operations::identity_top_up_from_pool( + let proven_balance = super::shielded::operations::identity_top_up_from_pool( &self.sdk, coordinator.store(), Some(&self.persister), @@ -1458,7 +1458,30 @@ impl PlatformWallet { amount, &prover, ) - .await + .await?; + + // The target may be one of this wallet's identities. Apply the proof-attested + // balance rather than adding `amount` locally: the fee is carved from the + // gross amount and a negative-credit identity absorbs part of a top-up, so + // only the proven value is right. A foreign identity is simply not managed. + if let Some(balance) = proven_balance { + let mut wm = self.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity_mut(identity_id)); + if let Some(managed) = managed { + managed.identity.set_balance(balance); + if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { + tracing::error!( + identity = %identity_id, + error = %e, + "Failed to persist identity balance update after shielded top-up" + ); + } + } + } + + Ok(proven_balance) } /// Withdraw from `account`'s notes to a Core L1 address diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 8cffb03416d..728a26d5987 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -362,6 +362,9 @@ impl FileBackedShieldedStore { StateTransition::IdentityCreateFromShieldedPool(_) => { Some("identity create from shielded pool") } + StateTransition::IdentityTopUpFromShieldedPool(_) => { + Some("identity top up from shielded pool") + } _ => None, } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index b18859d57ab..0e9c1e2cfd0 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1345,6 +1345,11 @@ mod shield_from_identity_build_error_tests { /// gross amount into its sighash, and the broadcast is redrive-safe. The /// identity receives `amount` (the fee is carved from the value balance). /// Waits for proven execution before marking notes spent. +/// +/// Returns the identity's proof-attested post-top-up balance when the result +/// proof carried this identity (`None` only if the proven result had an +/// unexpected shape, which is logged). The caller persists it for a managed +/// identity (`PlatformWallet::shielded_identity_top_up_from_pool`). #[allow(clippy::too_many_arguments)] pub async fn identity_top_up_from_pool( sdk: &Arc, @@ -1356,7 +1361,7 @@ pub async fn identity_top_up_from_pool( identity_id: Identifier, amount: u64, prover: &P, -) -> Result<(), PlatformWalletError> { +) -> Result, PlatformWalletError> { let views = keys.viewing_keys(); let change_addr = default_orchard_address(&views)?; let id = SubwalletId::new(wallet_id, account); @@ -1435,7 +1440,7 @@ pub async fn identity_top_up_from_pool( .await; match result { - Ok(()) => { + Ok(proof) => { record_activity_status( store, persister, @@ -1456,7 +1461,26 @@ pub async fn identity_top_up_from_pool( ); } info!(account, credits = amount, identity = %identity_id, "IdentityTopUpFromShieldedPool broadcast succeeded"); - Ok(()) + // The strict wait only succeeds on an execution-proving result; for a + // top-up that is the spent nullifiers plus the credited identity. + let proven_balance = match proof { + StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers(proven, _) + if proven.id() == identity_id => + { + Some(proven.balance()) + } + other => { + warn!( + account, + identity = %identity_id, + result = ?other, + "IdentityTopUpFromShieldedPool proof did not carry the credited \ + identity; managed balance left for the next identity refresh" + ); + None + } + }; + Ok(proven_balance) } Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => Err(e), Err(e) => { @@ -1601,7 +1625,7 @@ pub async fn unshield( .await; match result { - Ok(()) => { + Ok(_) => { record_activity_status( store, persister, @@ -1779,7 +1803,7 @@ pub async fn transfer( .await; match result { - Ok(()) => { + Ok(_) => { record_activity_status( store, persister, @@ -1947,7 +1971,7 @@ pub async fn withdraw( .await; match result { - Ok(()) => { + Ok(_) => { record_activity_status( store, persister, @@ -2854,7 +2878,7 @@ async fn broadcast_shielded_spend_with_redrive( notes: &[ShieldedNote], state_transition: &StateTransition, operation: &'static str, -) -> Result<(), PlatformWalletError> { +) -> Result { let result = broadcast_shielded_spend(sdk, state_transition, operation).await; if matches!( &result, @@ -3250,7 +3274,7 @@ async fn broadcast_shielded_spend( sdk: &Arc, state_transition: &StateTransition, operation: &'static str, -) -> Result<(), PlatformWalletError> { +) -> Result { match state_transition.broadcast(sdk, None).await { Ok(()) => {} Err(e) if broadcast_definitely_failed(&e) => { @@ -3267,10 +3291,13 @@ async fn broadcast_shielded_spend( } } + // Strict wait: every nullifier-spend family (transfer, unshield, withdrawal, + // identity top-up) is proof-binding, so a snapshot outcome is an error here. + // The proven result is returned so callers that credit a known identity can + // apply its proof-attested balance. state_transition .wait_for_response::(sdk, None) .await - .map(|_| ()) .map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) } diff --git a/packages/wasm-dpp2/src/shielded/identity_top_up_from_shielded_pool_transition.rs b/packages/wasm-dpp2/src/shielded/identity_top_up_from_shielded_pool_transition.rs index 79b5fbbdaa8..acdc9c4b811 100644 --- a/packages/wasm-dpp2/src/shielded/identity_top_up_from_shielded_pool_transition.rs +++ b/packages/wasm-dpp2/src/shielded/identity_top_up_from_shielded_pool_transition.rs @@ -1,5 +1,5 @@ use crate::error::{WasmDppError, WasmDppResult}; -use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; +use crate::identifier::IdentifierWasm; use crate::shielded::orchard_action::{SerializedOrchardActionWasm, actions_from_js_options}; use crate::state_transitions::StateTransitionWasm; use crate::utils::try_vec_to_fixed_bytes; @@ -123,18 +123,14 @@ impl IdentityTopUpFromShieldedPoolTransitionWasm { )) } - /// The identity whose balance receives the top-up. + /// The identity whose balance receives the top-up. Read-only: the identity id + /// and the gross amount are committed into the Orchard binding signature, so a + /// wrapper that changed them would produce a transition consensus must reject. #[wasm_bindgen(getter = "identityId")] pub fn identity_id(&self) -> IdentifierWasm { self.0.identity_id().into() } - #[wasm_bindgen(setter = "identityId")] - pub fn set_identity_id(&mut self, identity_id: IdentifierLikeJs) -> WasmDppResult<()> { - self.0.set_identity_id(identity_id.try_into()?); - Ok(()) - } - /// Returns the serialized Orchard actions. #[wasm_bindgen(getter = "actions")] pub fn actions(&self) -> Vec { From 69d8939925fa73b6ffe33e2f59a1aa168f33136b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 13 Sep 2026 17:18:44 +0700 Subject: [PATCH 15/17] fix(platform-wallet): hold the shield guard across a shielded identity top-up The top-up applies the identity's proof-attested absolute balance to a managed identity. Two top-ups, or a top-up and a shield-from-identity debit, whose result waits complete out of execution order would let the older balance overwrite the newer one. The top-up now holds the wallet's single-flight shield guard across build, broadcast, wait and reconcile, the same guard the identity debit already holds, so those writes land in execution order. Co-Authored-By: Claude Fable 5.1 --- .../src/wallet/platform_wallet.rs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 2b7c33e9f10..b95f1771808 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -351,12 +351,15 @@ pub struct PlatformWallet { pub(crate) shielded_keys: Arc>>>, /// Per-wallet single-flight guard for shield-class operations - /// (Type 15). Two concurrent `shield` calls on one wallet would - /// each fetch the same address nonce and build with `nonce + 1`, so - /// the second to reach drive-abci is rejected as a replay after a - /// ~30 s proof. Holding this across fetch → build → broadcast - /// serializes the double-tap / retry-while-proving case. `Arc` so - /// cloned wallet handles share the one lock. + /// (Type 15, and the identity-side Types 21 and 22). Two concurrent + /// `shield` calls on one wallet would each fetch the same address + /// nonce and build with `nonce + 1`, so the second to reach + /// drive-abci is rejected as a replay after a ~30 s proof. Holding + /// this across fetch → build → broadcast serializes the double-tap / + /// retry-while-proving case. The identity-side operations also apply + /// the proof-attested absolute identity balance, so holding it across + /// their waits keeps those writes in execution order. `Arc` so cloned + /// wallet handles share the one lock. #[cfg(feature = "shielded")] pub(crate) shield_guard: Arc>, /// Set once this wallet has been removed from the manager, to stop @@ -1446,6 +1449,14 @@ impl PlatformWallet { amount: u64, prover: P, ) -> Result, PlatformWalletError> { + // Single-flight with the other shield-class operations. The proof + // result carries the identity's absolute post-execution balance, so + // two top-ups (or a top-up and a shield-from-identity debit) whose + // waits completed out of execution order would let the older balance + // overwrite the newer one. Held across build -> broadcast -> wait -> + // reconcile. + let _shield_guard = self.shield_guard.lock().await; + let keyset = self.derive_spend_keyset(seed, account).await?; let proven_balance = super::shielded::operations::identity_top_up_from_pool( &self.sdk, From ad57dd66c159f8099f8fc49e078d760e8b7c117f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 13 Sep 2026 17:31:02 +0700 Subject: [PATCH 16/17] fix(platform): classify top-up proofs as affected state and wait accordingly A spent nullifier is stored as an empty item shared by every spend family, so its presence cannot tell one top-up apart from a competing spend of the same notes that credits another identity, and the credited identity's balance is a snapshot at the proof's block. Classifying type 22 as execution-proving let a proof for a top-up that never executed verify as ExecutionProved once a competing spend consumed its nullifiers. The verifier now returns AffectedState for the family, and a drive-abci test reproduces the competing spend: the losing transition's proof verifies, is classified as a snapshot, and shows its identity never credited. The wallet's shared spend helper takes a wait mode: the proof-binding families keep the strict wait, and the top-up uses the affected-state wait like the shield, shield-from-identity and identity-create paths, preserving the staged broadcast, consensus-rejection classification and redrive arming. The snapshot still proves the reserved notes are consumed and authenticates the credited identity's balance, which the wallet applies as the balance at that block. Co-Authored-By: Claude Fable 5.1 --- .../tests.rs | 128 +++++++++++++++++- .../v0/mod.rs | 11 +- .../src/wallet/shielded/operations.rs | 59 ++++++-- 3 files changed, 178 insertions(+), 20 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs index cb1340c71b8..56d6ca61c76 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs @@ -485,11 +485,13 @@ mod tests { ) .expect("verify"); assert_ne!(root_hash, [0u8; 32]); - // The spent nullifiers identify this exact transition, so the strict SDK - // wait (`wait_for_response`) must see an executed outcome, not a snapshot. + // Spent nullifiers and the identity's balance are a snapshot at the + // proof's block: they cannot tell this top-up apart from a competing + // spend of the same notes (see the competing-spend test below), so the + // outcome is affected state and wallets use the affected-state wait. assert!( - matches!(outcome, StateTransitionProofOutcome::ExecutionProved(_)), - "a top-up proof must be classified as execution-proving, got {outcome:?}" + matches!(outcome, StateTransitionProofOutcome::AffectedState(_)), + "a top-up proof must be classified as affected state, got {outcome:?}" ); let result = outcome.into_result(); @@ -512,4 +514,122 @@ mod tests { "all nullifiers must be spent" ); } + + #[test] + fn test_competing_spend_of_the_same_notes_is_not_proven_as_this_top_up() { + // Two top-ups authorized over the same note share its nullifier but + // credit different identities. Once one executes, a proof for the other + // still verifies (its nullifiers are spent and its identity exists), so + // the outcome must stay a snapshot: execution evidence here would tell + // a wallet its top-up landed when it did not. + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let initial_balance = dash_to_credits!(0.1); + let loser = add_identity(&platform, 6, initial_balance); + let winner = add_identity(&platform, 7, initial_balance); + let (loser_actions, _, anchor, loser_proof, loser_sig) = + build_valid_bundle(&loser.id(), GROSS_AMOUNT); + let (winner_actions, _, winner_anchor, winner_proof, winner_sig) = + build_valid_bundle(&winner.id(), GROSS_AMOUNT); + assert_eq!(anchor, winner_anchor); + assert_eq!( + loser_actions + .iter() + .map(|a| a.nullifier) + .collect::>(), + winner_actions + .iter() + .map(|a| a.nullifier) + .collect::>(), + "both bundles must spend the same note" + ); + let num_actions = winner_actions.len(); + seed_pool(&platform, &anchor, NOTE_VALUE); + + let winning = create_transition( + winner.id(), + winner_actions, + GROSS_AMOUNT, + anchor, + winner_proof, + winner_sig, + ); + let losing = create_transition( + loser.id(), + loser_actions, + GROSS_AMOUNT, + anchor, + loser_proof, + loser_sig, + ); + + // Only the winner executes. + let transition_bytes = winning.serialize_to_bytes().expect("serialize"); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("process"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commit"); + assert_eq!( + identity_balance(&platform, winner.id()), + initial_balance + GROSS_AMOUNT - top_up_fee(num_actions) + ); + assert_eq!(identity_balance(&platform, loser.id()), initial_balance); + + // The loser's proof verifies: its nullifiers are spent (by the winner) + // and its identity exists. That is exactly why it cannot bind execution. + let proof_bytes = platform + .drive + .prove_state_transition(&losing, None, platform_version) + .expect("prove") + .into_data() + .expect("proof data"); + let (_, outcome) = Drive::verify_state_transition_was_executed_with_proof( + &losing, + &BlockInfo::default(), + &proof_bytes, + &|_| Ok(None), + platform_version, + ) + .expect("the losing top-up's proof verifies as a snapshot"); + assert!( + matches!(outcome, StateTransitionProofOutcome::AffectedState(_)), + "a proof that cannot tell competing spends apart must not claim execution, got {outcome:?}" + ); + let result = outcome.into_result(); + let StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers(proven, statuses) = + result + else { + panic!("expected VerifiedIdentityWithShieldedNullifiers, got {result:?}"); + }; + assert_eq!(proven.id(), loser.id()); + assert_eq!( + proven.balance(), + initial_balance, + "the snapshot shows the losing identity was never credited" + ); + assert!( + statuses.iter().all(|(_, spent)| *spent), + "the shared nullifiers are spent by the winner" + ); + } } diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index b3409ce3fd3..d11e6cdc939 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -2416,11 +2416,12 @@ impl Drive { // Only the identity's post-debit balance is proven; the shielded note // and the requested amount are not bound. StateTransition::ShieldFromIdentity(_) => false, - // Nullifier-spend proof like Unshield: the spent nullifiers bind the - // exact Orchard actions of this transition (unlike identity-create - // there is no fallback action that spends them on a failed top-up), - // and the credited identity is proven alongside. - StateTransition::IdentityTopUpFromShieldedPool(_) => true, + // A spent nullifier is stored as an empty item shared by every + // spend family, so its presence cannot tell this top-up apart from + // a competing spend of the same notes that credits another + // identity, and the credited identity's balance is a snapshot at + // the proof's block. + StateTransition::IdentityTopUpFromShieldedPool(_) => false, }; Ok(binds) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 0e9c1e2cfd0..2f08f709e35 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1425,6 +1425,12 @@ pub async fn identity_top_up_from_pool( arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; trace!("IdentityTopUpFromShieldedPool: state transition built, broadcasting..."); + // Type 22 is classified affected state: its nullifiers are shared with any + // competing spend of the same notes and the identity's balance is a + // snapshot, so the strict wait would reject every success. The snapshot + // still proves the reserved notes are consumed and authenticates the + // credited identity's balance; the shield, shield-from-identity and + // identity-create paths accept the same class of outcome. broadcast_shielded_spend_with_redrive( sdk, store, @@ -1434,6 +1440,7 @@ pub async fn identity_top_up_from_pool( &selected_notes, &state_transition, "identity top up from shielded pool", + SpendResultWait::AffectedState, ) .await } @@ -1461,8 +1468,10 @@ pub async fn identity_top_up_from_pool( ); } info!(account, credits = amount, identity = %identity_id, "IdentityTopUpFromShieldedPool broadcast succeeded"); - // The strict wait only succeeds on an execution-proving result; for a - // top-up that is the spent nullifiers plus the credited identity. + // The affected-state wait returns the spent nullifiers plus the + // credited identity as a snapshot at the proof's block. The notes are + // spent either way; the identity's proven balance is the balance at + // that block, never derived from the requested amount. let proven_balance = match proof { StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers(proven, _) if proven.id() == identity_id => @@ -1619,6 +1628,7 @@ pub async fn unshield( &selected_notes, &state_transition, "unshield", + SpendResultWait::ExecutionProved, ) .await } @@ -1797,6 +1807,7 @@ pub async fn transfer( &selected_notes, &state_transition, "transfer", + SpendResultWait::ExecutionProved, ) .await } @@ -1965,6 +1976,7 @@ pub async fn withdraw( &selected_notes, &state_transition, "withdraw", + SpendResultWait::ExecutionProved, ) .await } @@ -2878,8 +2890,9 @@ async fn broadcast_shielded_spend_with_redrive( notes: &[ShieldedNote], state_transition: &StateTransition, operation: &'static str, + wait: SpendResultWait, ) -> Result { - let result = broadcast_shielded_spend(sdk, state_transition, operation).await; + let result = broadcast_shielded_spend(sdk, state_transition, operation, wait).await; if matches!( &result, Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) @@ -3270,10 +3283,25 @@ pub(super) async fn redrive_pending_spends( /// cheaply queryable as an identity row, so ambiguity is surfaced /// directly and reconciled by the next nullifier sync. The proven /// result is discarded; only the confirmation matters. +/// How [`broadcast_shielded_spend`] waits for an already-broadcast spend's +/// result, mirroring the verifier's classification of the family. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SpendResultWait { + /// The family is classified execution-proving (transfer, unshield, + /// withdrawal): a snapshot outcome is an error. + ExecutionProved, + /// The proof only authenticates the affected keys' state at the proof's + /// block (identity top-up: the spent nullifiers are shared with any + /// competing spend of the same notes and the credited identity's balance + /// is a snapshot). Accepted as that snapshot. + AffectedState, +} + async fn broadcast_shielded_spend( sdk: &Arc, state_transition: &StateTransition, operation: &'static str, + wait: SpendResultWait, ) -> Result { match state_transition.broadcast(sdk, None).await { Ok(()) => {} @@ -3291,14 +3319,23 @@ async fn broadcast_shielded_spend( } } - // Strict wait: every nullifier-spend family (transfer, unshield, withdrawal, - // identity top-up) is proof-binding, so a snapshot outcome is an error here. - // The proven result is returned so callers that credit a known identity can - // apply its proof-attested balance. - state_transition - .wait_for_response::(sdk, None) - .await - .map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) + // The verifier's classification of the family decides the wait: a + // proof-binding family rejects a snapshot outcome, an affected-state family + // accepts it. Either way the proven result is returned so callers that + // credit a known identity can apply its proof-attested balance. + let waited = match wait { + SpendResultWait::ExecutionProved => { + state_transition + .wait_for_response::(sdk, None) + .await + } + SpendResultWait::AffectedState => { + state_transition + .wait_for_affected_state::(sdk, None) + .await + } + }; + waited.map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) } /// Classify a `wait_for_response` failure for an already-broadcast From c1c67a7bbc05394e04ac63d4aa0ff5c338b13829 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 13 Sep 2026 18:56:47 +0700 Subject: [PATCH 17/17] docs(platform-wallet): record why a shielded identity top-up confirms on spent nullifiers The affected-state snapshot cannot distinguish this top-up from a competing spend of the same notes, but only this wallet's spending key can author one and the notes were reserved locally, so the row is confirmed once the nullifiers are proven consumed, matching the transfer, unshield and withdrawal paths. A transition-bound receipt in the protocol is the planned follow-up; immediate confirmation was chosen over a pending row for that edge case. Co-Authored-By: Claude Fable 5.1 --- .../src/wallet/shielded/operations.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 2f08f709e35..5775f8eb62b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1472,6 +1472,17 @@ pub async fn identity_top_up_from_pool( // credited identity as a snapshot at the proof's block. The notes are // spent either way; the identity's proven balance is the balance at // that block, never derived from the requested amount. + // + // The row is confirmed here on purpose. The snapshot cannot tell this + // top-up apart from a competing spend of the same notes, but only this + // wallet's spending key can author one and the notes were reserved + // locally, so spent nullifiers are treated as this transition's + // execution, the same policy the transfer, unshield and withdrawal + // paths apply. Until the protocol stores a transition-bound receipt + // (a follow-up that would make every nullifier-spend family + // execution-proving), a second device on the same seed can show a + // confirmed row for a top-up its sibling displaced; immediate + // confirmation was chosen over a pending row for that edge case. let proven_balance = match proof { StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers(proven, _) if proven.id() == identity_id =>