diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index fe01d767c90..b9df1216525 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 as a flat component built like `Unshield`'s address write but calibrated to its measured cost: the top-up rewrites the existing identity's balance element and its Merk path (320 replaced bytes, 175,320 credits of processing, no storage), folded into one flat figure with headroom like the other shielded components, so `identity_balance_storage_fee = 8 x per_byte_rate` (`SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES`). The target identity and gross amount are bound into the Orchard sighash; the identity must already exist; no system-credit adjustment. | For `ShieldedTransfer`, the client constructs the bundle so that `total_spent − total_output = desired_fee`. The Orchard circuit proves that value is conserved @@ -257,8 +258,8 @@ Note: The Orchard protocol requires a minimum of 2 actions per bundle for privac action). Bundles with 1 action are structurally invalid. The totals above are the **base** `compute_minimum_shielded_fee` and apply directly to -`ShieldedTransfer`. The two pool-paid transitions that write one extra per-transition output add a -flat storage component on top of this base: +`ShieldedTransfer`. The three pool-paid transitions that write one extra per-transition output +add a flat component on top of this base: - **`Unshield` adds the output-address write cost**: a flat `unshield_address_storage_fee = 222 × per_byte_rate = 222 × 27,400 = 6,082,800` credits, @@ -272,6 +273,12 @@ flat storage component on top of this base: `161,097,600 + 112,340,000 = 273,437,600` credits (and likewise `+112,340,000` at every action count). See the [Fee Extraction](#fee-extraction-by-transition-type) ShieldedWithdrawal row for why this component exists. +- **`IdentityTopUpFromShieldedPool` adds the identity balance write cost**: a flat + `identity_balance_storage_fee = 8 × per_byte_rate = 8 × 27,400 = 219,200` credits, + independent of action count, so the top-up fee at any action count is the base plus + `219,200`. See the [Fee Extraction](#fee-extraction-by-transition-type) IdentityTopUpFromShieldedPool + row for why this component is so much smaller than the address write: it rewrites an existing + balance element instead of storing a new entry. ## Where Fee Validation Runs @@ -344,9 +351,10 @@ shielded pool's total balance is decremented and the fee is booked via the `PaidFromShieldedPool` execution event: ``` -ShieldedTransfer: pool_balance -= fee_amount // fee == value_balance -Unshield: pool_balance -= unshielding_amount // gross -ShieldedWithdrawal: pool_balance -= unshielding_amount // gross +ShieldedTransfer: pool_balance -= fee_amount // fee == value_balance +Unshield: pool_balance -= unshielding_amount // gross +ShieldedWithdrawal: pool_balance -= unshielding_amount // gross +IdentityTopUpFromShieldedPool: pool_balance -= top_up_amount // gross ``` For `Unshield` and `ShieldedWithdrawal`, `unshielding_amount` is the **gross** amount @@ -365,6 +373,13 @@ extra write, the booking split (storage routed to the storage pool, the remainde the proposer) covers that write instead of zeroing the proposer's processing reward to cover it. +For `IdentityTopUpFromShieldedPool`, `top_up_amount` is likewise the gross amount leaving the +pool: `top_up_amount − fee_amount` is added to the existing identity's balance and +`fee_amount` (`compute_shielded_identity_top_up_fee`, the base fee plus the flat identity +balance write component) is booked as the transition fee; validation guarantees +`top_up_amount ≥ fee_amount`. The identity balance and the pool total are both terms of the +block conservation equation, so no system-credit adjustment is emitted. + For `ShieldedTransfer`, the pool decreases by exactly the fee (the sender's notes are spent and the recipient's notes are created, but the pool's aggregate balance only drops by the fee). 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 42b1cc7e04f..cd53f9a43f5 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 366813908e8..fb82e03ccb3 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 @@ -39,7 +39,9 @@ internal object FundingNative { /** * The flat shielded fee in credits for a transition of [kind] * (0 = ShieldedTransfer/Shield, 1 = Unshield, 2 = ShieldedWithdrawal, - * 3 = ShieldFromIdentity: the compute-only floor, no storage term) + * 3 = ShieldFromIdentity: the compute-only floor, no storage term, + * 4 = IdentityTopUpFromShieldedPool: base plus the flat + * identity-balance write cost) * and Orchard action count [numActions], computed at [managerHandle]'s * network-tracked platform version. No network round-trip; throws on * an unknown kind, an invalid manager handle, or overflow. @@ -242,6 +244,25 @@ internal object FundingNative { amount: Long, ) + /** + * Shielded to existing-identity top-up, Type 22 (bridges + * `platform_wallet_manager_shielded_identity_top_up_from_pool`). + * [identityId] is the 32-byte id of an EXISTING Platform identity (it + * need not be one this wallet manages); [amount] is the credits the + * identity receives, and the flat pool-paid fee ([estimateShieldedFee] + * kind 4) is spent from the notes on top of it. [resolverHandle] + * supplies the transient spend authority exactly as for + * [shieldedUnshield]. + */ + external fun shieldedIdentityTopUpFromPool( + managerHandle: Long, + walletId: ByteArray, + resolverHandle: Long, + account: Int, + identityId: ByteArray, + amount: Long, + ) + /** * Shielded → Core L1 withdrawal, Type 19 (bridges * `platform_wallet_manager_shielded_withdraw`). [toCoreAddress] is a 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 16d4db26f40..ae6f7c8aef0 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 @@ -44,6 +44,14 @@ object ShieldedProver { * [org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedShieldFromIdentity]. */ ShieldFromIdentity(3), + + /** + * IdentityTopUpFromShieldedPool (Type 22): base plus the flat + * identity-balance write cost, carved from the value balance like + * the other pool-paid kinds. Backs + * [org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedIdentityTopUpFromPool]. + */ + IdentityTopUpFromPool(4), } /** 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 9b60a2a31e3..9fa598533f4 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 0bce9493bf7..7ef17c2d23e 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 @@ -1931,6 +1931,46 @@ class PlatformWalletManager( } } + /** + * Shielded to existing-identity top-up (Type 22), a port of Swift's + * `PlatformWalletManager.shieldedIdentityTopUpFromPool` + * (`PlatformWalletManagerShieldedSync.swift`). Spends notes from + * [account] on [walletId] and credits [identityId]'s Platform balance. + * + * The identity only has to exist on Platform; it does not have to be + * one this wallet manages. The flat pool-paid fee + * ([ShieldedProver.FeeKind.IdentityTopUpFromPool], i.e. + * [estimateShieldedFee] kind 4) is spent from the notes on top of + * [amount]. + * + * @param walletId the 32-byte wallet id. + * @param identityId the 32-byte id of the identity being topped up. + * @param amount credits the identity receives (1 DASH = 1e11 credits). + * @param account the ZIP-32 shielded account to spend from (usually 0). + */ + suspend fun shieldedIdentityTopUpFromPool( + walletId: ByteArray, + identityId: ByteArray, + amount: Long, + account: Int = 0, + ): Unit = teardownGate.op { + require(amount > 0) { "amount must be positive, got $amount" } + require(account >= 0) { "account must be non-negative, got $account" } + require(identityId.size == 32) { + "identityId must be exactly 32 bytes, got ${identityId.size}" + } + mapNativeErrors { + FundingNative.shieldedIdentityTopUpFromPool( + managerHandle, + walletId, + mnemonicResolver.nativeHandle, + account, + identityId, + amount, + ) + } + } + /** * Shielded → Core L1 withdrawal (Type 19) — port of Swift's * `PlatformWalletManager.shieldedWithdraw(walletId:account:toCoreAddress:amount:coreFeePerByte:)` 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 3918cdb9505..614dd9e5c40 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 @@ -6,6 +6,7 @@ use platform_version::version::PlatformVersion; use v0::compute_minimum_shielded_fee_v0; use v0::compute_shielded_identity_balance_write_fee_v0; use v0::compute_shielded_identity_create_fee_v0; +use v0::compute_shielded_identity_top_up_fee_v0; use v0::compute_shielded_unshield_fee_v0; use v0::compute_shielded_verification_fee_v0; use v0::compute_shielded_withdrawal_fee_v0; @@ -84,6 +85,22 @@ pub fn compute_shielded_withdrawal_fee( } } +/// Computes the flat fee for `IdentityTopUpFromShieldedPool` (base minimum plus the flat +/// identity-balance write component). +pub fn compute_shielded_identity_top_up_fee( + num_actions: usize, + platform_version: &PlatformVersion, +) -> Result { + 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 c85a4c1f87b..3f23ef06605 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,7 +1,7 @@ use crate::fee::Credits; use crate::shielded::{ - SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES, SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES, - SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES, + SHIELDED_IDENTITY_BALANCE_WRITE_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; @@ -265,6 +265,37 @@ 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, built like `compute_shielded_unshield_fee_v0`'s address +/// write component: the balance rewrite's replace-only tree work (it adds no storage) folded +/// into a flat effective-byte figure with headroom, see +/// `SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES`. +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 de004bb826a..f0b553f9aee 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -15,8 +15,9 @@ 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_balance_write_fee, - compute_shielded_identity_create_fee, compute_shielded_unshield_fee, - compute_shielded_verification_fee, compute_shielded_withdrawal_fee, + compute_shielded_identity_create_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 +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, }; @@ -76,6 +79,24 @@ 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 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 +/// like `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES`). +/// +/// What the write does: the identity must already exist, so it adds no storage. It rewrites the +/// balance 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: 320 replaced bytes, 886 loaded bytes, 12 seeks and 14 hash calls for 175,320 +/// credits of processing. Like every other flat shielded component, 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: 175,320 credits is 6.4 effective +/// bytes at 27,400 credits/byte, and 8 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_top_up_fee`]. +pub const SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES: u64 = 8; + /// 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. 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 1c9bc66c13e..ae2700273f5 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; @@ -68,6 +69,7 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { self, StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) @@ -94,6 +96,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() @@ -126,6 +131,7 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { matches!( self, StateTransition::ShieldedTransfer(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) | StateTransition::IdentityCreateFromShieldedPool(_) @@ -188,7 +194,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 { @@ -243,6 +252,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 @@ -345,6 +364,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 { @@ -530,6 +555,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..56d6ca61c76 --- /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,635 @@ +#[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::{ + StateTransitionProofOutcome, 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]); + // 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::AffectedState(_)), + "a top-up proof must be classified as affected state, got {outcome:?}" + ); + + 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" + ); + } + + #[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-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..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 @@ -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,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, + // 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-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 534e0d386a2..535c577e7a1 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -2884,6 +2884,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), }; @@ -3398,6 +3401,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 9f6ff3d05b5..e5c086a1cca 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -46,7 +46,8 @@ use dashcore::hashes::Hash; use dpp::address_funds::{OrchardAddress, PlatformAddress}; use dpp::shielded::{ compute_minimum_shielded_fee, compute_shielded_identity_balance_write_fee, - compute_shielded_unshield_fee, compute_shielded_withdrawal_fee, ShieldedMemo, + compute_shielded_identity_top_up_fee, compute_shielded_unshield_fee, + compute_shielded_withdrawal_fee, ShieldedMemo, }; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::ProtocolError; @@ -177,6 +178,7 @@ fn shielded_fee_formula( 1 => Some(compute_shielded_unshield_fee), 2 => Some(compute_shielded_withdrawal_fee), 3 => Some(compute_shielded_identity_balance_write_fee), + 4 => Some(compute_shielded_identity_top_up_fee), _ => None, } } @@ -195,7 +197,9 @@ fn shielded_fee_formula( /// - `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). +/// top of the amount; the exact fee is metered at execution), +/// - `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 @@ -227,7 +231,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) = @@ -1281,6 +1285,99 @@ 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 { + // 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); + 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); + // 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") +} + /// 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 ceea7176538..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 @@ -1430,6 +1433,68 @@ 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> { + // 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, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + &keyset, + account, + *identity_id, + amount, + &prover, + ) + .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 /// (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 2494b25dbe3..7fb79b2b365 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs @@ -108,6 +108,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 @@ -131,6 +136,7 @@ impl ShieldedActivityKind { ShieldedActivityKind::IdentityCreate { .. } => 6, ShieldedActivityKind::ShieldedSpend => 7, ShieldedActivityKind::ShieldFromIdentity { .. } => 8, + ShieldedActivityKind::IdentityTopUp { .. } => 9, } } } @@ -1190,6 +1196,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/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/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 f8c7ab4c01c..5775f8eb62b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -57,7 +57,8 @@ use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::prelude::{Identifier, IdentityNonce}; 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, }; @@ -272,6 +273,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; @@ -283,6 +285,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(), _ => &[], } } @@ -1332,6 +1335,191 @@ mod shield_from_identity_build_error_tests { } } +// ------------------------------------------------------------------------- +// 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. +/// +/// 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, + 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..."); + // 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, + id, + &pending_entry, + anchor_bytes, + &selected_notes, + &state_transition, + "identity top up from shielded pool", + SpendResultWait::AffectedState, + ) + .await + } + .await; + + match result { + Ok(proof) => { + 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"); + // 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. + // + // 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 => + { + 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) => { + 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`) @@ -1451,13 +1639,14 @@ pub async fn unshield( &selected_notes, &state_transition, "unshield", + SpendResultWait::ExecutionProved, ) .await } .await; match result { - Ok(()) => { + Ok(_) => { record_activity_status( store, persister, @@ -1629,13 +1818,14 @@ pub async fn transfer( &selected_notes, &state_transition, "transfer", + SpendResultWait::ExecutionProved, ) .await } .await; match result { - Ok(()) => { + Ok(_) => { record_activity_status( store, persister, @@ -1797,13 +1987,14 @@ pub async fn withdraw( &selected_notes, &state_transition, "withdraw", + SpendResultWait::ExecutionProved, ) .await } .await; match result { - Ok(()) => { + Ok(_) => { record_activity_status( store, persister, @@ -2710,8 +2901,9 @@ async fn broadcast_shielded_spend_with_redrive( notes: &[ShieldedNote], state_transition: &StateTransition, operation: &'static str, -) -> Result<(), PlatformWalletError> { - let result = broadcast_shielded_spend(sdk, state_transition, operation).await; + wait: SpendResultWait, +) -> Result { + let result = broadcast_shielded_spend(sdk, state_transition, operation, wait).await; if matches!( &result, Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) @@ -3102,11 +3294,26 @@ 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, -) -> Result<(), PlatformWalletError> { + wait: SpendResultWait, +) -> Result { match state_transition.broadcast(sdk, None).await { Ok(()) => {} Err(e) if broadcast_definitely_failed(&e) => { @@ -3123,11 +3330,23 @@ async fn broadcast_shielded_spend( } } - state_transition - .wait_for_response::(sdk, None) - .await - .map(|_| ()) - .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 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 ce26aed2996..67bfc71e13f 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 @@ -1144,6 +1145,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 76bed59d654..39ae9835138 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 278e6211fe1..429abcbee76 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -474,6 +474,11 @@ extension PlatformWalletManager { /// identity to hold on top of the amount; the exact fee is metered /// at execution. 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 @@ -974,7 +979,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 @@ -992,6 +997,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 @@ -1031,7 +1109,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 a7a71acf766..e2903a3b784 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 } } @@ -287,10 +290,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..acdc9c4b811 --- /dev/null +++ b/packages/wasm-dpp2/src/shielded/identity_top_up_from_shielded_pool_transition.rs @@ -0,0 +1,251 @@ +use crate::error::{WasmDppError, WasmDppResult}; +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; +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. 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() + } + + /// 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..47788fdfaab --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/IdentityTopUpFromShieldedPoolTransition.spec.ts @@ -0,0 +1,75 @@ +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 = wasm.IdentityTopUpFromShieldedPoolTransition; + const t = createTransition(); + const bytes = t.toBytes(); + 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)); + }); + }); + + 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())); + }); + }); +});