Skip to main content

dpp/identity/state_transition/asset_lock_proof/
mod.rs

1use std::convert::{TryFrom, TryInto};
2
3use dashcore::{OutPoint, Transaction};
4
5use serde::{Deserialize, Deserializer, Serialize};
6
7use bincode::{Decode, DecodeUntrusted, Encode};
8
9pub use instant::*;
10use platform_serialization::PLATFORM_BINCODE_CONFIG;
11use platform_value::Value;
12#[cfg(feature = "validation")]
13use platform_version::version::PlatformVersion;
14use serde::de::Error;
15
16use crate::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof;
17use crate::prelude::Identifier;
18#[cfg(feature = "json-conversion")]
19use crate::serialization::JsonConvertible;
20#[cfg(feature = "value-conversion")]
21use crate::serialization::ValueConvertible;
22#[cfg(feature = "validation")]
23use crate::validation::SimpleConsensusValidationResult;
24use crate::{ProtocolError, SerdeParsingError};
25
26pub mod chain;
27pub mod instant;
28pub mod validate_asset_lock_transaction_structure;
29
30// TODO: Serialization with bincode
31// TODO: Consider use Box for InstantAssetLockProof
32//
33// Wire-shape note: this is an *internally-tagged* enum (`#[serde(tag = "$type")]`
34// with no `content`). serde's internal tagging works on newtype variants whose
35// inner is a struct — both `InstantAssetLockProof` and `ChainAssetLockProof`
36// qualify — so the inner struct's fields are flattened next to the `type`
37// discriminator: `{"$type": "instant", "instantLock": ..., "transaction": ...,
38// "outputIndex": ...}`. This matches the convention applied to other tagged
39// unions exposed to JS (see `AddressWitness`, `AddressFundsFeeStrategyStep`).
40// Bincode `Encode`/`Decode` derives are independent of serde, so consensus
41// binary format is unaffected.
42#[derive(Clone, Debug, Eq, PartialEq, Serialize, Encode, Decode, DecodeUntrusted)]
43#[serde(tag = "$type", rename_all = "camelCase")]
44#[allow(clippy::large_enum_variant)]
45pub enum AssetLockProof {
46    Instant(#[bincode(with_serde)] InstantAssetLockProof),
47    Chain(#[bincode(with_serde)] ChainAssetLockProof),
48}
49
50/// Wire-shape Deserialize uses the same internal-tag layout the Serialize derive
51/// produces, but routes the instant variant through `RawInstantLockProof` so the
52/// dashcore `InstantLock` can be reconstructed from its raw bytes form.
53#[derive(Deserialize)]
54#[serde(tag = "$type", rename_all = "camelCase")]
55enum RawAssetLockProof {
56    Instant(RawInstantLockProof),
57    Chain(ChainAssetLockProof),
58}
59
60impl TryFrom<RawAssetLockProof> for AssetLockProof {
61    type Error = ProtocolError;
62
63    fn try_from(value: RawAssetLockProof) -> Result<Self, Self::Error> {
64        match value {
65            RawAssetLockProof::Instant(raw_instant_lock) => {
66                let instant_lock = raw_instant_lock.try_into()?;
67
68                Ok(AssetLockProof::Instant(instant_lock))
69            }
70            RawAssetLockProof::Chain(chain) => Ok(AssetLockProof::Chain(chain)),
71        }
72    }
73}
74
75impl<'de> Deserialize<'de> for AssetLockProof {
76    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
77    where
78        D: Deserializer<'de>,
79    {
80        let raw = RawAssetLockProof::deserialize(deserializer)?;
81        raw.try_into().map_err(|e: ProtocolError| {
82            D::Error::custom(format!(
83                "expected to be able to deserialize asset lock proof: {}",
84                e
85            ))
86        })
87    }
88}
89
90impl Default for AssetLockProof {
91    fn default() -> Self {
92        Self::Instant(InstantAssetLockProof::default())
93    }
94}
95
96#[cfg(feature = "json-conversion")]
97impl JsonConvertible for AssetLockProof {}
98
99#[cfg(feature = "value-conversion")]
100impl ValueConvertible for AssetLockProof {}
101
102impl AsRef<AssetLockProof> for AssetLockProof {
103    fn as_ref(&self) -> &AssetLockProof {
104        self
105    }
106}
107
108#[cfg(all(
109    test,
110    feature = "json-conversion",
111    feature = "value-conversion",
112    feature = "serde-conversion"
113))]
114mod json_convertible_tests {
115    use super::*;
116    use dashcore::OutPoint;
117    use platform_value::platform_value;
118    use serde_json::json;
119    use std::str::FromStr;
120
121    /// Non-default variant (`Chain` with non-zero core height + a real
122    /// outpoint) so the wire-shape assertion catches silent variant flip /
123    /// inner-zero on round-trip — the previous fixture used `Default::default`
124    /// (`Instant` zero proof).
125    fn fixture() -> AssetLockProof {
126        let out_point = OutPoint::from_str(
127            "0000000000000000000000000000000000000000000000000000000000000001:1",
128        )
129        .expect("outpoint");
130        AssetLockProof::Chain(ChainAssetLockProof {
131            core_chain_locked_height: 12_345,
132            out_point,
133        })
134    }
135
136    #[test]
137    fn json_round_trip_with_full_wire_shape() {
138        use crate::serialization::JsonConvertible;
139        let original = fixture();
140        let json = original.to_json().expect("to_json");
141        // `AssetLockProof` is internally tagged (`#[serde(tag = "$type")]`), so
142        // the inner `ChainAssetLockProof`'s fields are flattened next to the
143        // discriminator. Surprising shape: `OutPoint` has a *string-form*
144        // Serialize impl ("<txid>:<vout>") in dashcore which JSON consumes
145        // as-is — so on the JSON wire, `outPoint` is a single string. The
146        // platform_value layer goes through a different path (see the
147        // value-side test below) and produces a typed Map with `Bytes32` txid
148        // and `U32` vout. `coreChainLockedHeight` is `u32`; JSON erases the
149        // size — see the value-path assertion.
150        assert_eq!(
151            json,
152            json!({
153                "$type": "chain",
154                "coreChainLockedHeight": 12_345,
155                "outPoint": "0000000000000000000000000000000000000000000000000000000000000001:1",
156            })
157        );
158        let recovered = AssetLockProof::from_json(json).expect("from_json");
159        assert_eq!(original, recovered);
160    }
161
162    #[test]
163    fn value_round_trip_with_full_wire_shape() {
164        use crate::serialization::ValueConvertible;
165        let original = fixture();
166        let value = original.to_object().expect("to_object");
167        // platform_value path: `OutPoint` serializes via its derived structural
168        // impl producing a Map { txid: Bytes32, vout: U32 } (NOT the string form
169        // produced on the JSON side). `coreChainLockedHeight` is `u32` so
170        // `12_345u32` locks in `Value::U32`.
171        let mut txid_bytes = [0u8; 32];
172        txid_bytes[0] = 1;
173        assert_eq!(
174            value,
175            platform_value!({
176                "$type": "chain",
177                "coreChainLockedHeight": 12_345u32,
178                "outPoint": {
179                    "txid": platform_value::Value::Bytes32(txid_bytes),
180                    "vout": 1u32,
181                },
182            })
183        );
184        let recovered = AssetLockProof::from_object(value).expect("from_object");
185        assert_eq!(original, recovered);
186    }
187}
188pub enum AssetLockProofType {
189    Instant = 0,
190    Chain = 1,
191}
192
193impl TryFrom<u8> for AssetLockProofType {
194    type Error = SerdeParsingError;
195
196    fn try_from(value: u8) -> Result<Self, Self::Error> {
197        match value {
198            0 => Ok(Self::Instant),
199            1 => Ok(Self::Chain),
200            _ => Err(SerdeParsingError::new("Unexpected asset lock proof type")),
201        }
202    }
203}
204
205impl TryFrom<u64> for AssetLockProofType {
206    type Error = SerdeParsingError;
207
208    fn try_from(value: u64) -> Result<Self, Self::Error> {
209        match value {
210            0 => Ok(Self::Instant),
211            1 => Ok(Self::Chain),
212            _ => Err(SerdeParsingError::new("Unexpected asset lock proof type")),
213        }
214    }
215}
216
217// TODO: Versioning
218impl AssetLockProof {
219    /// The bytes this proof takes inside a serialized state transition: its
220    /// bincode encoding under [`PLATFORM_BINCODE_CONFIG`], the configuration
221    /// `#[platform_serialize]` uses for `StateTransition`. A transition encodes the
222    /// proof in place as one of its fields, so two transitions that differ
223    /// only in their proof differ in length by exactly the difference of these
224    /// values.
225    pub fn serialized_len_in_state_transition(&self) -> Result<usize, ProtocolError> {
226        let mut writer = bincode::enc::write::SizeWriter::default();
227        bincode::encode_into_writer(self, &mut writer, PLATFORM_BINCODE_CONFIG)
228            .map_err(|e| ProtocolError::EncodingError(e.to_string()))?;
229        Ok(writer.bytes_written)
230    }
231
232    pub fn type_from_raw_value(value: &Value) -> Option<AssetLockProofType> {
233        let proof_type_res = value.get_integer::<u8>("type");
234
235        match proof_type_res {
236            Ok(proof_type_int) => {
237                let proof_type = AssetLockProofType::try_from(proof_type_int);
238                proof_type.ok()
239            }
240            Err(_) => None,
241        }
242    }
243
244    pub fn create_identifier(&self) -> Result<Identifier, ProtocolError> {
245        match self {
246            AssetLockProof::Instant(instant_proof) => instant_proof.create_identifier(),
247            AssetLockProof::Chain(chain_proof) => Ok(chain_proof.create_identifier()),
248        }
249    }
250
251    pub fn output_index(&self) -> u32 {
252        match self {
253            AssetLockProof::Instant(proof) => proof.output_index(),
254            AssetLockProof::Chain(proof) => proof.out_point.vout,
255        }
256    }
257
258    pub fn out_point(&self) -> Option<OutPoint> {
259        match self {
260            AssetLockProof::Instant(proof) => proof.out_point(),
261            AssetLockProof::Chain(proof) => Some(proof.out_point),
262        }
263    }
264
265    pub fn transaction(&self) -> Option<&Transaction> {
266        match self {
267            AssetLockProof::Instant(is_lock) => Some(is_lock.transaction()),
268            AssetLockProof::Chain(_chain_lock) => None,
269        }
270    }
271
272    /// Validate the structure of the asset lock proof
273    #[cfg(feature = "validation")]
274    pub fn validate_structure(
275        &self,
276        platform_version: &PlatformVersion,
277    ) -> Result<SimpleConsensusValidationResult, ProtocolError> {
278        match self {
279            AssetLockProof::Instant(proof) => proof.validate_structure(platform_version),
280            AssetLockProof::Chain(_) => Ok(SimpleConsensusValidationResult::default()),
281        }
282    }
283}
284
285// Canonical `TryFrom<Value> for AssetLockProof` is provided via the
286// `Deserialize` impl above (which routes through `RawAssetLockProof` for
287// the instant-lock raw-bytes shape) and `platform_value::from_value`. The
288// previous hack here accepted legacy integer-tagged
289// (`{type: 0|1, ...fields}`) and externally-tagged
290// (`{Instant: {...}}`) shapes — both predated the
291// `#[serde(tag = "$type")]` Critical-2 fix. Audit (Phase D step 6)
292// confirmed all currently-flowing values are canonical-tagged
293// (string `type`), so the hacks were dead.
294
295impl TryFrom<&Value> for AssetLockProof {
296    type Error = ProtocolError;
297
298    fn try_from(value: &Value) -> Result<Self, Self::Error> {
299        platform_value::from_value(value.clone()).map_err(ProtocolError::ValueError)
300    }
301}
302
303impl TryFrom<Value> for AssetLockProof {
304    type Error = ProtocolError;
305
306    fn try_from(value: Value) -> Result<Self, Self::Error> {
307        platform_value::from_value(value).map_err(ProtocolError::ValueError)
308    }
309}
310
311// `TryInto<Value>` impls (and the inherent `to_raw_object` that mirrored
312// them) used to live here, producing *untagged* `Value` (drops the variant
313// tag entirely). They were structurally asymmetric with the canonical
314// Deserialize, which expects the `type: "instant" | "chain"` discriminator
315// to route through `RawAssetLockProof`. Confirmed zero production callers,
316// so deleted in Phase D step 6. Use canonical `ValueConvertible::to_object`
317// — it produces the correctly-tagged shape that `Deserialize` accepts on
318// the way back.
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof;
324    use dashcore::{OutPoint, Txid};
325    use std::str::FromStr;
326
327    /// `serialized_len_in_state_transition` measures the proof as a serialized
328    /// transition carries it: two identity top-ups that differ only in their
329    /// proof differ in serialized length by exactly the difference of the two
330    /// measured lengths, for InstantSend and ChainLock proofs alike.
331    #[cfg(feature = "state-transitions")]
332    #[test]
333    fn should_measure_the_proof_as_a_serialized_transition_carries_it() {
334        use crate::serialization::PlatformSerializable;
335        use crate::state_transition::identity_topup_transition::v0::IdentityTopUpTransitionV0;
336        use crate::state_transition::identity_topup_transition::IdentityTopUpTransition;
337        use crate::state_transition::StateTransition;
338        use dashcore::{InstantLock, ScriptBuf, TxIn};
339
340        let input: Vec<TxIn> = (0..5u8)
341            .map(|i| TxIn {
342                previous_output: OutPoint::from([i; 36]),
343                script_sig: ScriptBuf::from_bytes(vec![0u8; 107]),
344                sequence: u32::MAX,
345                witness: Default::default(),
346            })
347            .collect();
348        let instant = AssetLockProof::Instant(InstantAssetLockProof::new(
349            InstantLock {
350                inputs: input.iter().map(|i| i.previous_output).collect(),
351                ..InstantLock::default()
352            },
353            Transaction {
354                version: 3,
355                lock_time: 0,
356                input,
357                output: Vec::new(),
358                special_transaction_payload: None,
359            },
360            0,
361        ));
362        let chain = AssetLockProof::Chain(ChainAssetLockProof {
363            core_chain_locked_height: u32::MAX,
364            out_point: OutPoint::from([7u8; 36]),
365        });
366        let transition_len = |proof: &AssetLockProof| {
367            StateTransition::IdentityTopUp(IdentityTopUpTransition::V0(IdentityTopUpTransitionV0 {
368                asset_lock_proof: proof.clone(),
369                identity_id: Identifier::default(),
370                user_fee_increase: 0,
371                signature: Default::default(),
372            }))
373            .serialize_to_bytes()
374            .expect("serialize")
375            .len()
376        };
377
378        let instant_len = instant.serialized_len_in_state_transition().unwrap();
379        let chain_len = chain.serialized_len_in_state_transition().unwrap();
380        assert!(instant_len > chain_len);
381        assert_eq!(
382            transition_len(&instant) - transition_len(&chain),
383            instant_len - chain_len
384        );
385    }
386
387    /// JSON wire shape is internally tagged: `{type, ...flattened inner fields}`,
388    /// no `data` wrapper. This guards against accidental reintroduction of the
389    /// old adjacent-tagged `{type, data: {...}}` shape and against the divergence
390    /// from the `AddressWitness` / `AddressFundsFeeStrategyStep` precedent.
391    #[test]
392    fn chain_variant_serializes_with_internal_tag() {
393        let txid =
394            Txid::from_str("e8b43025641eea4fd21190f01bd870ef90f1a8b199d8fc3376c5b62c0b1a179d")
395                .unwrap();
396        let proof = AssetLockProof::Chain(ChainAssetLockProof {
397            core_chain_locked_height: 11,
398            out_point: OutPoint { txid, vout: 1 },
399        });
400
401        let json = serde_json::to_value(&proof).expect("serialize");
402
403        assert_eq!(json["$type"], "chain");
404        assert_eq!(json["coreChainLockedHeight"], 11);
405        assert!(
406            json.get("data").is_none(),
407            "should not have a `data` wrapper, got: {}",
408            json
409        );
410
411        // Round-trip
412        let restored: AssetLockProof = serde_json::from_value(json).expect("deserialize");
413        assert_eq!(proof, restored);
414    }
415
416    mod asset_lock_proof_type_try_from {
417        use super::*;
418
419        #[test]
420        fn u8_instant_type() {
421            let proof_type = AssetLockProofType::try_from(0u8).expect("should parse type 0");
422            assert!(matches!(proof_type, AssetLockProofType::Instant));
423        }
424
425        #[test]
426        fn u8_chain_type() {
427            let proof_type = AssetLockProofType::try_from(1u8).expect("should parse type 1");
428            assert!(matches!(proof_type, AssetLockProofType::Chain));
429        }
430
431        #[test]
432        fn u8_invalid_type() {
433            let result = AssetLockProofType::try_from(2u8);
434            assert!(result.is_err());
435        }
436
437        #[test]
438        fn u8_max_invalid_type() {
439            let result = AssetLockProofType::try_from(255u8);
440            assert!(result.is_err());
441        }
442
443        #[test]
444        fn u64_instant_type() {
445            let proof_type = AssetLockProofType::try_from(0u64).expect("should parse type 0");
446            assert!(matches!(proof_type, AssetLockProofType::Instant));
447        }
448
449        #[test]
450        fn u64_chain_type() {
451            let proof_type = AssetLockProofType::try_from(1u64).expect("should parse type 1");
452            assert!(matches!(proof_type, AssetLockProofType::Chain));
453        }
454
455        #[test]
456        fn u64_invalid_type() {
457            let result = AssetLockProofType::try_from(2u64);
458            assert!(result.is_err());
459        }
460
461        #[test]
462        fn u64_large_invalid_type() {
463            let result = AssetLockProofType::try_from(u64::MAX);
464            assert!(result.is_err());
465        }
466    }
467
468    mod chain_asset_lock_proof {
469        use super::*;
470
471        fn make_chain_proof() -> ChainAssetLockProof {
472            ChainAssetLockProof::new(100, [0xAB; 36])
473        }
474
475        #[test]
476        fn chain_proof_construction() {
477            let proof = ChainAssetLockProof::new(42, [0x01; 36]);
478            assert_eq!(proof.core_chain_locked_height, 42);
479        }
480
481        #[test]
482        fn chain_proof_create_identifier_deterministic() {
483            let proof = make_chain_proof();
484            let id1 = proof.create_identifier();
485            let id2 = proof.create_identifier();
486            assert_eq!(id1, id2);
487        }
488
489        #[test]
490        fn different_outpoints_produce_different_identifiers() {
491            let proof_a = ChainAssetLockProof::new(100, [0xAA; 36]);
492            let proof_b = ChainAssetLockProof::new(100, [0xBB; 36]);
493            assert_ne!(proof_a.create_identifier(), proof_b.create_identifier());
494        }
495
496        #[test]
497        fn chain_proof_equality() {
498            let a = ChainAssetLockProof::new(10, [0x01; 36]);
499            let b = ChainAssetLockProof::new(10, [0x01; 36]);
500            assert_eq!(a, b);
501        }
502
503        #[test]
504        fn chain_proof_inequality_height() {
505            let a = ChainAssetLockProof::new(10, [0x01; 36]);
506            let b = ChainAssetLockProof::new(20, [0x01; 36]);
507            assert_ne!(a, b);
508        }
509    }
510
511    mod asset_lock_proof_methods {
512        use super::*;
513
514        fn make_chain_lock_proof() -> AssetLockProof {
515            let chain_proof = ChainAssetLockProof::new(50, [0xCC; 36]);
516            AssetLockProof::Chain(chain_proof)
517        }
518
519        #[test]
520        fn default_is_instant() {
521            let proof = AssetLockProof::default();
522            assert!(matches!(proof, AssetLockProof::Instant(_)));
523        }
524
525        #[test]
526        fn as_ref_returns_self() {
527            let proof = make_chain_lock_proof();
528            let reference: &AssetLockProof = proof.as_ref();
529            assert_eq!(&proof, reference);
530        }
531
532        #[test]
533        fn chain_proof_output_index() {
534            let mut out_point_bytes = [0u8; 36];
535            // Set vout (last 4 bytes in little-endian) to 3
536            out_point_bytes[32] = 3;
537            let chain_proof = ChainAssetLockProof::new(50, out_point_bytes);
538            let proof = AssetLockProof::Chain(chain_proof);
539            assert_eq!(proof.output_index(), 3);
540        }
541
542        #[test]
543        fn chain_proof_out_point_is_some() {
544            let proof = make_chain_lock_proof();
545            assert!(proof.out_point().is_some());
546        }
547
548        #[test]
549        fn chain_proof_transaction_is_none() {
550            let proof = make_chain_lock_proof();
551            assert!(proof.transaction().is_none());
552        }
553
554        #[test]
555        fn chain_proof_to_object_canonical() {
556            // After Phase D step 6, `to_raw_object` (which produced an
557            // untagged Value) was deleted. Canonical
558            // `ValueConvertible::to_object` produces the correctly-tagged
559            // shape that round-trips through `Deserialize`.
560            use crate::serialization::ValueConvertible;
561            let proof = make_chain_lock_proof();
562            let result = proof.to_object();
563            assert!(result.is_ok());
564        }
565
566        #[test]
567        fn chain_proof_create_identifier() {
568            let proof = make_chain_lock_proof();
569            let id = proof.create_identifier();
570            assert!(id.is_ok());
571        }
572    }
573
574    mod try_from_value {
575        use super::*;
576
577        #[test]
578        fn chain_proof_value_round_trip() {
579            // Canonical `ValueConvertible::to_object` produces a tagged
580            // Value (`{type: "chain", coreChainLockedHeight: ..., outPoint: ...}`)
581            // that round-trips through the manual `Deserialize` (which routes
582            // via `RawAssetLockProof`).
583            use crate::serialization::ValueConvertible;
584            let chain_proof = ChainAssetLockProof::new(100, [0x42; 36]);
585            let proof = AssetLockProof::Chain(chain_proof);
586
587            let value = proof.to_object().expect("to_object");
588            // The canonical `to_object` produces `type: "chain"` in the
589            // wire shape. `type_from_raw_value` expects an integer-typed
590            // tag (legacy shape), so it returns None on canonical output —
591            // confirm via the serde Map directly instead.
592            let map = value.to_map_ref().expect("map");
593            assert_eq!(
594                map.iter()
595                    .find_map(|(k, v)| (k.as_text() == Some("$type")).then(|| v.as_text())),
596                Some(Some("chain"))
597            );
598
599            let recovered =
600                AssetLockProof::from_object(value).expect("from_object should round-trip");
601            assert_eq!(proof, recovered);
602        }
603
604        #[test]
605        fn type_from_raw_value_returns_none_for_missing_type() {
606            let value = Value::Map(vec![]);
607            let result = AssetLockProof::type_from_raw_value(&value);
608            assert!(result.is_none());
609        }
610
611        #[test]
612        fn try_from_empty_map_fails() {
613            let value = Value::Map(vec![]);
614            let result = AssetLockProof::try_from(&value);
615            assert!(result.is_err());
616        }
617
618        #[test]
619        fn try_from_value_with_unknown_key_fails() {
620            let value = Value::Map(vec![(
621                Value::Text("Unknown".to_string()),
622                Value::Map(vec![]),
623            )]);
624            let result = AssetLockProof::try_from(&value);
625            assert!(result.is_err());
626        }
627    }
628
629    // The `try_into_value` module previously exercised the now-deleted
630    // `TryInto<Value>` impls (which produced untagged `Value`). Canonical
631    // `ValueConvertible::to_object` is exercised in `try_from_value` above.
632}