Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 130 additions & 0 deletions packages/rs-drive/src/util/common/asset_lock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! The record Drive keeps under `SpentAssetLockTransactions` for each asset
//! lock outpoint a transition used.

use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue;
use dpp::asset_lock::StoredAssetLockInfo;

/// Reads the item Drive keeps for an asset lock outpoint: none before any
/// transition used the lock, an empty one once a transition used it up, and
/// otherwise the lock's remaining value, which `decode_remaining`
/// deserializes as far as the caller trusts the bytes.
pub fn stored_asset_lock_info<E>(
item: Option<&[u8]>,
decode_remaining: impl FnOnce(&[u8]) -> Result<AssetLockValue, E>,
) -> Result<StoredAssetLockInfo, E> {
match item {
None => Ok(StoredAssetLockInfo::NotPresent),
Some([]) => Ok(StoredAssetLockInfo::FullyConsumed),
Some(bytes) => decode_remaining(bytes).map(StoredAssetLockInfo::PartiallyConsumed),
}
}

#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
use crate::drive::RootTree;
use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure;
use dpp::platform_value::Bytes36;
use dpp::serialization::{PlatformDeserializableTrusted, PlatformDeserializableUntrusted};
use dpp::version::PlatformVersion;
use grovedb::Element;

/// Drive's writer, its consensus fetch and this reader agree on every
/// record: none, a used-up lock, and a partly used one.
#[test]
fn should_read_records_as_drive_writes_and_fetches_them() {
let platform_version = PlatformVersion::latest();
let drive_version = &platform_version.drive;
let drive = setup_drive_with_initial_state_structure(None);
Comment on lines +32 to +38

@thepastaclaw thepastaclaw Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Resolved at 2a28db5e; see the replies below.

🟡 Suggestion: Pin the extracted verifier reader at a historical protocol version

This new comparison exercises Drive's writer, consensus fetch and helper only at PlatformVersion::latest(). The extraction also changes both ShieldFromAssetLock branches in verify_state_transition_was_executed_with_proof/v0, which historical protocol versions still select; the existing proof-verification tests for those branches likewise use latest(). The delegation preserves the previous behavior, so it does not require a new generation, but the book's in-place-edit checklist calls for historical regression coverage. Add dispatcher-level coverage at a historical version such as 13 for both surplus-output paths, pinning empty/nonempty record handling, absent-record rejection and the existing non-exact untrusted decoding. Document that equivalence in the prescribed 'In-place changes to shipped generations' PR section.

source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, platform-versioning, rust-quality, security-auditor)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2072e02 (head 2a28db5).

should_read_the_shield_from_asset_lock_record_alike_from_protocol_version_13 sits in the verifier's test module. It runs both ShieldFromAssetLock surplus-output paths at protocol versions 13, 14 and the latest, proving with Drive's prover and verifying through verify_state_transition_was_executed_with_proof:

  • an empty record is FullyConsumed;
  • a nonempty one is PartiallyConsumed;
  • trailing bytes are still ignored (non-exact untrusted decoding);
  • an absent record is still refused as CorruptedProof.

Switching either arm to exact decoding makes the test fail at version 13. So does accepting an absent record as NotPresent in either arm.

I didn't extend the drive-abci round trips. A real shield always uses the lock up, so it can't reach the other three records. Instead the test writes the records into the tree directly.

The edited lines now say why they are inert at every protocol version. The PR description has the "In-place changes to shipped generations" section.


🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (re-reviewed at 2a28db5e): Your new test invokes the public verifier dispatcher for both surplus-output paths at protocol versions 13, 14 and latest, covering empty, encoded, trailing-byte and absent records. Directly inserting those records appropriately exercises cases a successful shield cannot produce, and your inline comments and PR-description section document the preserved behavior.

let left = AssetLockValue::new(2_000, vec![0x76], 1_500, vec![], platform_version)
.expect("an asset lock value");

let cases = [
(Bytes36::new([1; 36]), None),
(
Bytes36::new([2; 36]),
Some(AssetLockValue::new(2_000, vec![0x76], 0, vec![], platform_version).unwrap()),
),
(Bytes36::new([3; 36]), Some(left.clone())),
];
for (outpoint, value) in &cases {
let Some(value) = value else { continue };
let operations = drive
.add_asset_lock_outpoint_operations(
outpoint,
value.clone(),
&mut None,
platform_version,
)
.expect("the operations adding an asset lock outpoint");
drive
.apply_batch_low_level_drive_operations(
None,
None,
operations,
&mut vec![],
drive_version,
)
.expect("an asset lock outpoint added");
}

let path: [&[u8]; 1] = [&[RootTree::SpentAssetLockTransactions as u8]];
let expected = [
StoredAssetLockInfo::NotPresent,
StoredAssetLockInfo::FullyConsumed,
StoredAssetLockInfo::PartiallyConsumed(left),
];
for ((outpoint, _), expected) in cases.iter().zip(expected) {
let fetched = drive
.fetch_asset_lock_outpoint_info(outpoint, None, drive_version)
.expect("an asset lock record fetched");
let element = drive
.grove
.get_raw_optional(
(&path).into(),
outpoint.as_slice(),
None,
&drive_version.grove_version,
)
.unwrap()
.expect("a raw read");
let item = element
.as_ref()
.map(Element::as_item_bytes)
.transpose()
.unwrap();

assert_eq!(fetched, expected);
assert_eq!(
stored_asset_lock_info(item, AssetLockValue::deserialize_from_bytes_trusted)
.unwrap(),
expected
);
assert_eq!(
stored_asset_lock_info(
item,
AssetLockValue::deserialize_from_bytes_untrusted_exact
)
.unwrap(),
expected
);
}
}

#[test]
fn should_hand_a_nonempty_item_to_the_callers_decoder() {
let refuse = |_: &[u8]| Err::<AssetLockValue, _>("undecodable");
assert_eq!(
stored_asset_lock_info(Some(&[0xff; 3]), refuse),
Err("undecodable")
);
assert_eq!(
stored_asset_lock_info(Some(&[]), refuse),
Ok(StoredAssetLockInfo::FullyConsumed)
);
assert_eq!(
stored_asset_lock_info(None, refuse),
Ok(StoredAssetLockInfo::NotPresent)
);
}
}
1 change: 1 addition & 0 deletions packages/rs-drive/src/util/common/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod asset_lock;
pub mod decode;
pub mod encode;
Original file line number Diff line number Diff line change
Expand Up @@ -2303,8 +2303,8 @@ impl Drive {
}
StateTransition::ShieldFromAssetLock(st) => {
use crate::drive::RootTree;
use crate::util::common::asset_lock::stored_asset_lock_info;
use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue;
use dpp::asset_lock::StoredAssetLockInfo;
use dpp::identity::state_transition::AssetLockProved;
use dpp::serialization::PlatformDeserializableUntrusted;
use dpp::state_transition::proof_result::StateTransitionProofResult::{
Expand Down Expand Up @@ -2413,16 +2413,14 @@ impl Drive {
))
})?;

// Inert for every protocol version: the helper reads an empty item as
// FullyConsumed and hands any other to the same untrusted decoder this
// arm called inline, so each item verifies as before.
let info = match outpoint_element {
Some(Element::Item(bytes, _)) => {
if bytes.is_empty() {
StoredAssetLockInfo::FullyConsumed
} else {
StoredAssetLockInfo::PartiallyConsumed(
AssetLockValue::deserialize_from_bytes_untrusted(&bytes)?,
)
}
}
Some(Element::Item(bytes, _)) => stored_asset_lock_info(
Some(&bytes),
AssetLockValue::deserialize_from_bytes_untrusted,
)?,
Some(_) => {
return Err(Error::Proof(ProofError::CorruptedProof(
"expected an item element for asset lock outpoint".to_string(),
Expand Down Expand Up @@ -2456,19 +2454,13 @@ impl Drive {
)));
}

// Inert for every protocol version, as in the surplus arm above.
let info = if let Some(proved) = proved_key_values.pop() {
match proved.2 {
Some(Element::Item(bytes, _)) => {
if bytes.is_empty() {
StoredAssetLockInfo::FullyConsumed
} else {
StoredAssetLockInfo::PartiallyConsumed(
AssetLockValue::deserialize_from_bytes_untrusted(
&bytes,
)?,
)
}
}
Some(Element::Item(bytes, _)) => stored_asset_lock_info(
Some(&bytes),
AssetLockValue::deserialize_from_bytes_untrusted,
)?,
Some(_) => {
return Err(Error::Proof(ProofError::CorruptedProof(
"expected an item element for asset lock outpoint"
Expand Down Expand Up @@ -6070,6 +6062,131 @@ mod tests {
);
}

// --- ShieldFromAssetLock reads the outpoint's record alike in both arms,
// at protocol version 13 (the last of verifier generation 0), 14 and the
// latest. An empty item is FullyConsumed, a nonempty one decodes untrusted
// with trailing bytes ignored, and an absent record is a corrupted proof,
// because the transition executed.
#[test]
fn should_read_the_shield_from_asset_lock_record_alike_from_protocol_version_13() {
use crate::drive::RootTree;
use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue;
use dpp::asset_lock::StoredAssetLockInfo;
use dpp::dashcore::OutPoint;
use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof;
use dpp::identity::state_transition::asset_lock_proof::AssetLockProof;
use dpp::platform_value::BinaryData;
use dpp::serialization::PlatformSerializable;
use dpp::state_transition::shield_from_asset_lock_transition::v0::ShieldFromAssetLockTransitionV0;
use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition;
use grovedb::Element;
use StateTransitionProofResult::{
VerifiedAssetLockConsumed, VerifiedAssetLockConsumedWithAddressInfos,
};

let path: [&[u8]; 1] = [&[RootTree::SpentAssetLockTransactions as u8]];
for protocol_version in [13, 14, PlatformVersion::latest().protocol_version] {
let platform_version =
PlatformVersion::get(protocol_version).expect("a known protocol version");
let drive = setup_drive_with_initial_state_structure(Some(platform_version));
let left = AssetLockValue::new(2_000, vec![0x76], 1_500, vec![], platform_version)
.expect("an asset lock value");
let encoded = left
.serialize_to_bytes()
.expect("an encoded asset lock value");
let padded = [encoded.as_slice(), &[0xab, 0xcd]].concat();
let rows = [
(1, Some(vec![]), Some(StoredAssetLockInfo::FullyConsumed)),
(
2,
Some(encoded),
Some(StoredAssetLockInfo::PartiallyConsumed(left.clone())),
),
(
3,
Some(padded),
Some(StoredAssetLockInfo::PartiallyConsumed(left)),
),
(4, None, None),
];

for (seed, item, expected) in rows {
let out_point = [seed; 36];
if let Some(item) = item {
drive
.grove_insert(
(&path).into(),
&out_point,
Element::new_item(item),
None,
None,
&mut vec![],
&platform_version.drive,
)
.expect("a record inserted");
}

for surplus_output in [None, Some(PlatformAddress::P2pkh([0x33; 20]))] {
let with_surplus = surplus_output.is_some();
let transition = StateTransition::ShieldFromAssetLock(
ShieldFromAssetLockTransition::V0(ShieldFromAssetLockTransitionV0 {
asset_lock_proof: AssetLockProof::Chain(ChainAssetLockProof {
core_chain_locked_height: 100,
out_point: OutPoint::from(out_point),
}),
actions: vec![],
value_balance: 0,
anchor: [0u8; 32],
proof: vec![],
binding_signature: [0u8; 64],
surplus_output,
signature: BinaryData::new(vec![]),
}),
);
let proof = drive
.prove_state_transition(&transition, None, platform_version)
.expect("a proof")
.into_data()
.expect("proof bytes");

let verified = Drive::verify_state_transition_was_executed_with_proof(
&transition,
&BlockInfo::default(),
&proof,
&|_| Ok(None),
platform_version,
)
.map(|(_, outcome)| outcome.into_result());

let row = format!(
"protocol version {protocol_version}, record {seed}, surplus output {with_surplus}"
);
match (&expected, verified) {
(Some(expected), Ok(VerifiedAssetLockConsumed(info))) if !with_surplus => {
assert_eq!(&info, expected, "{row}")
}
(
Some(expected),
Ok(VerifiedAssetLockConsumedWithAddressInfos(info, _)),
) if with_surplus => {
assert_eq!(&info, expected, "{row}")
}
(
None,
Err(crate::error::Error::Proof(ProofError::CorruptedProof(message))),
) => assert!(
message.contains("asset lock outpoint is absent from proof"),
"{row}: {message}"
),
(expected, verified) => {
panic!("{row}: expected {expected:?}, got {verified:?}")
}
}
}
}
}
}

// --- IdentityCreateFromAddresses: empty proof returns error.
#[test]
fn verify_identity_create_from_addresses_empty_proof_returns_error() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@
//! - [`submit_with_cl_height_retry`] — re-exported from
//! `dash_sdk::platform::transition::asset_lock_submission`: the
//! retry-on-10506 wrapper that bumps `user_fee_increase` between
//! attempts so Tenderdash's invalid-tx hash cache (24h on
//! mainnet/testnet) can't silently drop resubmits.
//! attempts so no attempt repeats bytes a node refused: Tenderdash
//! keeps refused hashes with no time limit (the mempool's 24 h
//! `ttl-duration` covers admitted transactions only) and drops
//! identical-bytes resubmits; see the SDK module's "Refused
//! transitions".
//! - [`AssetLockManager::resolve_funding_with_is_timeout_fallback`] —
//! maps an [`AssetLockFunding`] choice to a [`FundingResolution`]
//! that the caller can drive into an IS→CL retry when the IS-lock
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
//! consensus code 10506) — Platform's observed Core tip is briefly
//! behind the wallet's SPV-verified CL. Bump
//! `PutSettings::user_fee_increase` (changes ST signable bytes →
//! different ST hash → bypasses Tenderdash's 24h invalid-tx cache)
//! different ST hash → not dropped by Tenderdash's cache of refused
//! hashes, which keeps them with no time limit)
//! and resubmit the same proof. See
//! [`submit_with_cl_height_retry`].
//!
Expand Down
15 changes: 15 additions & 0 deletions packages/rs-sdk-cxx/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,20 @@ tokio = { version = "1.40", features = ["rt-multi-thread"] }
async-trait = "0.1"
zeroize = "1.8"

[dev-dependencies]
tokio = { version = "1.40", features = ["macros", "test-util"] }
# The top-up tests answer the mock SDK with Drive's own proofs, signed as a
# quorum signs them.
dash-sdk = { path = "../rs-sdk", default-features = false, features = [
"mocks",
"dpns-contract",
"dashpay-contract",
"core_key_wallet",
] }
drive = { path = "../rs-drive" }
tenderdash-abci = { git = "https://github.com/dashpay/rs-tenderdash-abci", tag = "v1.8.1", features = [
"crypto",
], default-features = false }

[build-dependencies]
cxx-build = "1.0"
Loading
Loading