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}