Skip to main content

zcash_client_backend/data_api/
locking.rs

1//! # Output locking
2//!
3//! This module is the single home for the wallet's advisory output-locking
4//! vocabulary: the identity of a lock holder ([`LockOwner`]), the error surface
5//! of lock acquisition ([`LockError`]), the storage contract through which
6//! wallet backends persist lock state ([`OutputLockStore`]), the request
7//! through which proposal-creation functions acquire locks ([`LockRequest`]
8//! and [`unlock_proposal_inputs`]), and the policy types through which input
9//! selection interacts with lock state ([`LockedInputPolicy`] and
10//! [`LockFilter`]).
11//!
12//! ## Semantics
13//!
14//! All lock state is a pair of columns on each received output: a
15//! `lock_expiry_height` and a `lock_owner`. Every behavior is derived from the
16//! following invariants, stated here once:
17//!
18//! * **Locked**: an output is *locked* while `lock_expiry_height >=
19//!   target_height`, where the target height is the height at which a new
20//!   transaction would be mined (chain tip + 1). Balance computations tally an
21//!   output as locked under exactly this condition.
22//! * **Eligible for selection**: input selection eligibility is the exact
23//!   complement of the locked condition, owner-scoped under a
24//!   [`LockedInputPolicy`]: an output is eligible when it carries no lock, when
25//!   its lock has expired (`lock_expiry_height < target_height`), or when its
26//!   `lock_owner` is one of the owners the policy admits. An output locked by
27//!   any other owner is never selected.
28//! * **Acquisition**: a lock may be acquired ([`OutputLockStore::lock_outputs`])
29//!   when the output is unlocked, when its existing lock has expired as of the
30//!   chain tip, or when the existing lock is held by the *same* owner (an
31//!   idempotent re-acquire/extend, so a flow that crashed after locking may
32//!   safely retry under its original owner token). Acquisition fails only on
33//!   an active foreign lock. There is no stealing: because "expired as of the
34//!   chain tip" (`h <= chain_tip`) is exactly "not locked for selection"
35//!   (`h < target_height`), a foreign lock is replaceable exactly when the
36//!   output has already become selectable again.
37//! * **Advisory**: locks are advisory in selection. An owner-scoped policy
38//!   override *spends through* a lock during selection; it never releases the
39//!   lock.
40//! * **Release**: there are exactly four release paths: owner-scoped unlock
41//!   ([`OutputLockStore::unlock_output`], [`unlock_proposal_inputs`]),
42//!   owner-agnostic clearing ([`OutputLockStore::clear_locked_outputs`]),
43//!   unlock-on-store (implementations of
44//!   [`WalletWrite::store_transactions_to_be_sent`] unlock outputs recorded as
45//!   spent, the spend records having taken over double-selection protection),
46//!   and expiry (the passage of the chain tip beyond `lock_expiry_height`).
47//! * **Expiry re-opens the race**: if an operation outlasts its lock window,
48//!   the lock expires and a concurrent proposal may select and spend the same
49//!   outputs. Lock windows must be chosen conservatively with respect to the
50//!   worst-case time between proposal creation and transaction storage.
51//!
52//! ## Integration map
53//!
54//! The locking feature touches the codebase at the following points, by role:
55//!
56//! * **Schema**: the `note_locking` migration in `zcash_client_sqlite`
57//!   (`wallet::init::migrations::note_locking`) adds the `lock_expiry_height`
58//!   and `lock_owner` columns to the four received-output tables; the table
59//!   definitions in `zcash_client_sqlite::wallet::db` carry them forward.
60//! * **Acquisition**: the five proposal-creation functions
61//!   ([`propose_transfer`], [`propose_standard_transfer_to_address`],
62//!   [`propose_send_max_transfer`], [`propose_shielding`], and
63//!   [`propose_shielding_coinbase`]) accept an optional [`LockRequest`] and
64//!   lock every selected input via [`OutputLockStore::lock_outputs`].
65//! * **Selection**: the seven `zcash_client_sqlite` leaf queries that select
66//!   spendable outputs (`get_spendable_note`, `select_unspent_notes`,
67//!   `select_spendable_notes_matching_value`, and `unspent_notes_meta` in
68//!   `wallet::common`; `get_spendable_transparent_outputs`,
69//!   `get_spendable_transparent_outputs_for_addresses`, and
70//!   `select_spendable_transparent_outputs` in `wallet::transparent`) embed
71//!   the eligibility fragment parameterized by a [`LockFilter`].
72//! * **Balance**: four classification sites tally locked value separately:
73//!   the shielded-note branch of `get_wallet_summary` and the three
74//!   transparent tallies (`get_transparent_balances` and the two branches of
75//!   `add_transparent_account_balances`, including the coinbase arm) in
76//!   `zcash_client_sqlite`.
77//! * **Release**: the four release paths listed under Semantics above.
78//! * **Proposal decoding**: proposal decoding in [`crate::proto`] reads wallet
79//!   contents with [`LockFilter::Unfiltered`], since a persisted proposal must
80//!   be reconstructible regardless of current lock state.
81//! * **Consumers**: `zcash_client_sqlite`'s
82//!   `PoolMigrations::migration_lock_owners` exposes the owners of a pool
83//!   migration's in-flight locks so callers can construct owner-scoped
84//!   policies; Zallet consumes the locking API for its transaction flows.
85//!
86//! [`OutputLockStore::lock_outputs`]: crate::data_api::OutputLockStore::lock_outputs
87//! [`OutputLockStore::unlock_output`]: crate::data_api::OutputLockStore::unlock_output
88//! [`OutputLockStore::clear_locked_outputs`]: crate::data_api::OutputLockStore::clear_locked_outputs
89//! [`WalletWrite::store_transactions_to_be_sent`]: crate::data_api::WalletWrite::store_transactions_to_be_sent
90//! [`propose_transfer`]: crate::data_api::wallet::propose_transfer
91//! [`propose_standard_transfer_to_address`]: crate::data_api::wallet::propose_standard_transfer_to_address
92//! [`propose_send_max_transfer`]: crate::data_api::wallet::propose_send_max_transfer
93//! [`propose_shielding`]: crate::data_api::wallet::propose_shielding
94//! [`propose_shielding_coinbase`]: crate::data_api::wallet::propose_shielding_coinbase
95
96use std::collections::BTreeSet;
97use std::error;
98use std::fmt::{self, Debug, Display};
99use std::hash::Hash;
100
101#[cfg(feature = "test-dependencies")]
102use ambassador::delegatable_trait;
103
104use zcash_primitives::transaction::TxId;
105use zcash_protocol::{PoolType, consensus::BlockHeight};
106
107use crate::{
108    data_api::{WalletRead, WalletWrite, error::Error, wallet::input_selection::NonEmptyBTreeSet},
109    proposal::{Proposal, ProposalError},
110    wallet::OutputRef,
111};
112
113/// An opaque token identifying the holder of an output lock.
114///
115/// A caller that locks outputs (directly via [`OutputLockStore::lock_outputs`], or through a
116/// proposal-creation function's lock request) supplies an owner token and must retain it: the
117/// token is what authorizes releasing the locks ([`OutputLockStore::unlock_output`],
118/// [`unlock_proposal_inputs`]) and what makes re-locking idempotent (an owner may re-acquire or
119/// extend its own active lock, for example when retrying a flow after a crash, while a different
120/// owner's lock attempt fails until the lock expires).
121///
122/// The token is not a cryptographic secret: everything that can reach the wallet database can
123/// read it. It exists to prevent *accidental* cross-flow interference between concurrent
124/// in-process operations, not to protect against an adversary with database access.
125///
126/// [`OutputLockStore::lock_outputs`]: crate::data_api::OutputLockStore::lock_outputs
127/// [`OutputLockStore::unlock_output`]: crate::data_api::OutputLockStore::unlock_output
128/// [`unlock_proposal_inputs`]: crate::data_api::wallet::unlock_proposal_inputs
129#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
130pub struct LockOwner([u8; 32]);
131
132impl LockOwner {
133    /// Constructs a `LockOwner` from the given bytes.
134    ///
135    /// Callers that persist their own operation state may derive a stable token from it; all
136    /// others should prefer [`LockOwner::random`].
137    pub const fn new(bytes: [u8; 32]) -> Self {
138        Self(bytes)
139    }
140
141    /// Generates a fresh random `LockOwner`.
142    pub fn random<R>(rng: &mut R) -> Self
143    where
144        R: rand_core::Rng,
145    {
146        let mut bytes = [0u8; 32];
147        rng.fill_bytes(&mut bytes);
148        Self(bytes)
149    }
150
151    /// Returns the byte representation of this token.
152    pub fn as_bytes(&self) -> &[u8; 32] {
153        &self.0
154    }
155}
156
157/// A transaction id may serve as a lock owner.
158///
159/// This is the right owner choice for flows that hold a durable transaction identity while
160/// their locks are alive; most notably a persisted PCZT, whose v5 txid is fixed once its
161/// effecting data is final. Deriving the owner from the txid lets such a flow re-derive its
162/// token after a restart and release (or re-acquire) exactly its own locks.
163///
164/// It is NOT a suitable owner for proposal-time locking in general: at proposal creation no
165/// transaction exists yet, a multi-step proposal builds several transactions, and a
166/// transaction rebuilt after a crash generally has a different txid, which would defeat the
167/// idempotent same-owner re-lock. Flows without a durable transaction identity should use
168/// [`LockOwner::random`] and retain the token.
169impl From<TxId> for LockOwner {
170    fn from(txid: TxId) -> Self {
171        Self(txid.into())
172    }
173}
174
175/// Errors that occur when attempting to lock an output.
176#[derive(Clone, Debug, PartialEq, Eq)]
177#[non_exhaustive]
178pub enum LockError<S> {
179    /// Wrapper for storage errors.
180    Storage(S),
181    /// The wrapped output reference was not found, or the output it refers to was already locked.
182    LockFailure(OutputRef),
183}
184
185impl<S> Display for LockError<S>
186where
187    S: Display,
188{
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        match self {
191            LockError::Storage(e) => write!(f, "Note locking failed: {e}"),
192            LockError::LockFailure(output) => {
193                write!(
194                    f,
195                    "Lock conflict or missing output for reference {output:?}"
196                )
197            }
198        }
199    }
200}
201
202impl<S> error::Error for LockError<S>
203where
204    S: error::Error + 'static,
205{
206    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
207        match self {
208            LockError::Storage(e) => Some(e),
209            LockError::LockFailure(_) => None,
210        }
211    }
212}
213
214/// The subset of wallet-storage operations that manage output locks.
215///
216/// [`WalletWrite`] requires this trait with its [`Self::AccountId`] and [`Self::Error`] types
217/// tied to the corresponding [`WalletRead`] types, so every writable wallet backend is an
218/// `OutputLockStore`. The separate trait exists so that lock storage can be implemented,
219/// delegated, and reasoned about independently of the full wallet-mutation surface.
220///
221/// [`WalletRead`]: crate::data_api::WalletRead
222#[cfg_attr(feature = "test-dependencies", delegatable_trait)]
223pub trait OutputLockStore {
224    /// The type of errors produced by the lock store.
225    type Error: Debug;
226
227    /// The type of the account identifier.
228    type AccountId: Copy + Debug + Eq + Hash;
229
230    /// Locks the specified outputs on behalf of `owner` so that, by default, they are not
231    /// selected for spending at any height less than or equal to the given height.
232    ///
233    /// Locks are advisory. Input selection excludes locked outputs by default, but a caller may
234    /// deliberately draw on them by supplying an owner-scoped
235    /// [`LockedInputPolicy`](crate::data_api::wallet::input_selection::LockedInputPolicy) (via
236    /// [`SpendPolicy::with_locked_input_policy`](crate::data_api::wallet::input_selection::SpendPolicy::with_locked_input_policy)),
237    /// scoped to the lock owners it names; doing so spends through the lock during selection but
238    /// never releases it. A locked output can be released only via [`Self::unlock_output`] (by its
239    /// owner) or [`Self::clear_locked_outputs`].
240    ///
241    /// Returns the number of row updates performed on success (equal to the number of provided
242    /// references; a duplicated reference is counted per occurrence), or a [`LockError`] on
243    /// failure, wrapping either an error from the underlying storage backend or the first output
244    /// that could not be locked.
245    ///
246    /// A lock may be acquired when the output holds no lock, when its existing lock has expired
247    /// as of the chain tip, or when its existing lock is held by the *same* `owner`; in the
248    /// latter case the lock's expiry height is updated. Same-owner re-locking makes the
249    /// operation idempotent, so a caller that crashes between locking and persisting its
250    /// proposal can safely retry the flow under the same [`LockOwner`]. Acquisition fails only
251    /// when an unexpired lock is held by a different owner.
252    ///
253    /// Implementations of this method must either succeed completely, successfully locking each
254    /// provided output on success, or fail completely leaving all lock state unmodified if any
255    /// of the outputs is actively locked by a different owner.
256    ///
257    /// This is the mechanism by which overlapping proposals for the same account avoid selecting
258    /// the same inputs by default. Because note selection and locking cannot be performed as a
259    /// single atomic step above the storage layer, two callers may independently select an
260    /// overlapping set of outputs before either locks them (a time-of-check/time-of-use race); the
261    /// conflict is resolved here, at the storage layer, where the second caller's `lock_outputs`
262    /// fails with [`LockError::LockFailure`] naming the already-locked output. Callers that lock
263    /// via a proposal-creation function surface this as
264    /// [`ProposalError::InputsLocked`](crate::proposal::ProposalError::InputsLocked). The
265    /// losing caller has not partially locked anything and should treat the failure as "the
266    /// account is busy" and retry.
267    fn lock_outputs(
268        &mut self,
269        outputs: &[OutputRef],
270        owner: LockOwner,
271        lock_expiry_height: BlockHeight,
272    ) -> Result<usize, LockError<Self::Error>>;
273
274    /// Unlocks the specified output if it is locked by the given `owner`, making it once again
275    /// available for spending and balance computations.
276    ///
277    /// Returns `true` if a lock held by `owner` (whether or not it had already expired) was
278    /// removed from the output, and `false` otherwise: in particular, a lock held by a
279    /// different owner is left in place, so one flow cannot accidentally release another's
280    /// locks.
281    fn unlock_output(&mut self, output: &OutputRef, owner: LockOwner) -> Result<bool, Self::Error>;
282
283    /// Unlocks every currently-locked output belonging to the specified account, regardless of
284    /// lock expiry height.
285    ///
286    /// This is intended as a recovery mechanism for callers that have lost track of their
287    /// in-flight proposals or PCZTs (for example, because the application was terminated by the
288    /// operating system before the corresponding transactions could be built). By clearing all
289    /// locks for the account, the caller declares that it has no pending proposals holding those
290    /// outputs.
291    ///
292    /// # Warning
293    ///
294    /// This releases every lock for the account regardless of its owner, including locks held
295    /// by proposals that are still legitimately in flight; those proposals' inputs immediately
296    /// become selectable by new proposals, re-creating the conflict that locking exists to
297    /// prevent. Only call this when no in-flight proposal or PCZT for the account remains.
298    ///
299    /// Returns the number of outputs that were unlocked.
300    fn clear_locked_outputs(&mut self, account: Self::AccountId) -> Result<usize, Self::Error>;
301
302    /// Returns the set of currently locked outputs for the given account.
303    ///
304    /// Locked outputs are excluded from note selection, and are tallied separately in balance
305    /// computations. A wallet needs this to show the user which funds are reserved by an in-flight
306    /// proposal, so it is part of the ordinary interface rather than a testing affordance.
307    fn get_locked_outputs(&self, account: Self::AccountId) -> Result<Vec<OutputRef>, Self::Error>;
308}
309
310/// Returns the [`OutputRef`] identifying each output that the given proposal consumes as an
311/// input.
312///
313/// Each note or UTXO selected for spending is an *input* to the proposal's transaction, but is at
314/// the same time an *output* of the earlier transaction that created it; an [`OutputRef`] names it
315/// by that creating transaction's id, which is the stable identity the lock tables are keyed on.
316fn proposal_input_refs<FeeRuleT, NoteRef>(
317    proposal: &Proposal<FeeRuleT, NoteRef>,
318) -> Vec<OutputRef> {
319    proposal
320        .steps()
321        .iter()
322        .flat_map(|step| {
323            step.shielded_inputs()
324                .into_iter()
325                .flat_map(|shielded_inputs| {
326                    shielded_inputs.notes().iter().map(|note| {
327                        OutputRef::new(
328                            *note.txid(),
329                            PoolType::Shielded(note.note().pool()),
330                            u32::from(note.output_index()),
331                        )
332                    })
333                })
334                .chain(step.transparent_inputs().iter().map(|utxo| {
335                    let outpoint = utxo.outpoint();
336                    OutputRef::new(
337                        TxId::from_bytes(*outpoint.hash()),
338                        PoolType::TRANSPARENT,
339                        outpoint.n(),
340                    )
341                }))
342        })
343        .collect()
344}
345
346/// A request to lock the inputs selected by a proposal, made when calling one of the
347/// proposal-creation functions ([`propose_transfer`] and friends).
348///
349/// The caller supplies the [`LockOwner`] under which the locks are taken and must retain it: the
350/// owner token is what authorizes releasing the locks with [`unlock_proposal_inputs`], and what
351/// allows the same flow to re-lock its own inputs when retrying after a crash.
352///
353/// [`propose_transfer`]: crate::data_api::wallet::propose_transfer
354#[derive(Clone, Copy, Debug, PartialEq, Eq)]
355pub struct LockRequest {
356    owner: LockOwner,
357    for_blocks: u32,
358}
359
360impl LockRequest {
361    /// Constructs a request to lock the proposal's inputs on behalf of `owner` until
362    /// `for_blocks` blocks past the proposal's target height.
363    ///
364    /// Choose `for_blocks` conservatively with respect to the worst-case time between proposal
365    /// creation and transaction storage: once the lock expires, a concurrent proposal may
366    /// select the same inputs.
367    pub fn new(owner: LockOwner, for_blocks: u32) -> Self {
368        Self { owner, for_blocks }
369    }
370
371    /// Returns the owner under which the locks will be taken.
372    pub fn owner(&self) -> LockOwner {
373        self.owner
374    }
375
376    /// Returns the number of blocks past the proposal's target height at which the locks will
377    /// expire.
378    pub fn for_blocks(&self) -> u32 {
379        self.for_blocks
380    }
381}
382
383/// Locks all inputs selected by the given proposal, preventing them from being
384/// selected by subsequent proposals. The lock expires at the given height.
385#[allow(clippy::type_complexity)]
386pub(crate) fn lock_proposal_inputs<DbT, FeeRuleT, NoteRef, TE, SE, FE, CE>(
387    wallet_db: &mut DbT,
388    proposal: &Proposal<FeeRuleT, NoteRef>,
389    owner: LockOwner,
390    lock_expiry_height: BlockHeight,
391) -> Result<(), Error<<DbT as WalletRead>::Error, TE, SE, FE, CE, NoteRef>>
392where
393    DbT: WalletWrite,
394{
395    match wallet_db.lock_outputs(&proposal_input_refs(proposal), owner, lock_expiry_height) {
396        Ok(_) => Ok(()),
397        Err(LockError::LockFailure(out_ref)) => {
398            Err(Error::Proposal(ProposalError::InputsLocked(out_ref)))
399        }
400        Err(LockError::Storage(e)) => Err(Error::DataSource(e)),
401    }
402}
403
404/// Unlocks all inputs selected by the given proposal, reversing the locks acquired when the
405/// proposal was created with a [`LockRequest`] under the same `owner`.
406///
407/// This is useful when a proposal is rejected or abandoned after its inputs were locked, so that
408/// the outputs become available for selection and balance computation once again. Because
409/// unlocking is scoped to `owner`, inputs that are not locked, or whose locks are held by a
410/// different owner (for example a concurrently-created proposal), are left unchanged.
411pub fn unlock_proposal_inputs<DbT, FeeRuleT, NoteRef>(
412    wallet_db: &mut DbT,
413    proposal: &Proposal<FeeRuleT, NoteRef>,
414    owner: LockOwner,
415) -> Result<(), <DbT as WalletRead>::Error>
416where
417    DbT: WalletWrite,
418{
419    for output_ref in proposal_input_refs(proposal) {
420        wallet_db.unlock_output(&output_ref, owner)?;
421    }
422    Ok(())
423}
424
425/// Governs whether input selection may draw on locked outputs, and with what preference.
426///
427/// Locks are advisory. The default, [`Self::Exclude`], never selects a locked output. The
428/// overriding variants each carry the set of lock owners whose locks may be drawn upon; a locked
429/// output whose owner is not in that set is never selected, regardless of variant. This keeps an
430/// override scoped to a known reason (e.g. the wallet's own pool-migration PCZTs) and leaves every
431/// other flow's locks intact. Overriding here only *spends through* a lock during selection; it
432/// never releases the lock.
433#[derive(Clone, Debug, PartialEq, Eq, Default)]
434pub enum LockedInputPolicy {
435    /// Never select locked outputs.
436    #[default]
437    Exclude,
438    /// Prefer unlocked outputs; draw on outputs locked by one of these owners only as needed to
439    /// reach the target value.
440    PreferUnlocked(NonEmptyBTreeSet<LockOwner>),
441    /// Prefer outputs locked by one of these owners; draw on unlocked outputs only as needed to
442    /// reach the target value.
443    PreferLocked(NonEmptyBTreeSet<LockOwner>),
444}
445
446impl LockedInputPolicy {
447    /// The set of lock owners whose locked outputs this policy admits (empty for `Exclude`).
448    pub fn overridable_owners(&self) -> &BTreeSet<LockOwner> {
449        static EMPTY: BTreeSet<LockOwner> = BTreeSet::new();
450        match self {
451            LockedInputPolicy::Exclude => &EMPTY,
452            LockedInputPolicy::PreferUnlocked(o) | LockedInputPolicy::PreferLocked(o) => o.as_set(),
453        }
454    }
455
456    /// Whether locked (overridable) outputs are preferred ahead of unlocked ones.
457    pub fn prefers_locked(&self) -> bool {
458        matches!(self, LockedInputPolicy::PreferLocked(_))
459    }
460
461    /// Whether any locked outputs may be selected at all.
462    pub fn admits_locked(&self) -> bool {
463        !matches!(self, LockedInputPolicy::Exclude)
464    }
465}
466
467/// How a query filters candidate outputs by lock state.
468///
469/// Input selection for a proposal passes [`Self::Policy`], carrying the caller's owner-scoped
470/// [`LockedInputPolicy`]. Retrieval/decoding paths that must expose wallet contents regardless of
471/// locks (proposal decoding, low-level and test accessors) pass [`Self::Unfiltered`]. Keeping the
472/// two separate means a `SpendPolicy` can only ever request an owner-scoped override, never an
473/// unscoped "ignore all locks".
474#[derive(Clone, Copy, Debug, PartialEq, Eq)]
475pub enum LockFilter<'a> {
476    /// Apply the given owner-scoped selection policy.
477    Policy(&'a LockedInputPolicy),
478    /// Ignore lock state entirely; every matching output is eligible.
479    Unfiltered,
480}
481
482#[cfg(test)]
483mod tests {
484    use std::collections::BTreeSet;
485
486    use proptest::prelude::*;
487
488    use super::{LockOwner, LockedInputPolicy};
489    use crate::data_api::wallet::input_selection::NonEmptyBTreeSet;
490
491    // Each variant's accessors agree with the meaning of the variant: `Exclude` admits no
492    // owners at all, while `PreferUnlocked`/`PreferLocked` admit exactly the given owners and
493    // differ only in whether locked outputs are preferred.
494    #[test]
495    fn locked_input_policy_accessors() {
496        let owner = LockOwner::new([7u8; 32]);
497        let set = BTreeSet::from([owner]);
498        let owners = NonEmptyBTreeSet::from_set(set.clone()).unwrap();
499        assert_eq!(LockedInputPolicy::default(), LockedInputPolicy::Exclude);
500        assert!(LockedInputPolicy::Exclude.overridable_owners().is_empty());
501        assert!(!LockedInputPolicy::Exclude.admits_locked());
502        let pu = LockedInputPolicy::PreferUnlocked(owners.clone());
503        assert!(pu.admits_locked() && !pu.prefers_locked());
504        assert_eq!(pu.overridable_owners(), &set);
505        let pl = LockedInputPolicy::PreferLocked(owners.clone());
506        assert!(pl.admits_locked() && pl.prefers_locked());
507        assert_eq!(pl.overridable_owners(), &set);
508    }
509
510    proptest! {
511        /// A txid-derived [`LockOwner`] preserves the txid bytes, so two owners
512        /// derived from distinct transactions are distinct (a persisted PCZT re-derives
513        /// exactly its own token after a restart).
514        #[test]
515        fn lock_owner_from_txid_preserves_bytes(txid in any::<[u8; 32]>()) {
516            let owner = LockOwner::from(super::TxId::from_bytes(txid));
517            prop_assert_eq!(*owner.as_bytes(), txid);
518        }
519    }
520}