Time Lock Vault
Wallet-facing half of the time lock vault: a non-interactive contract that locks coins to an address until a committed unlock value — a block height or, at/above the BIP-65 500,000,000 split, a Unix timestamp — is reached, after which only the holder of the committed private key can spend them.
One contract instance is a factory of vaults rather than a single vault. Each value of index (a BIP-44 leaf index) produces a distinct vault with its own address, and nextDestination advances index to the next slot, using the unlocking height/timestamp of the current destination.
To change the unlock height/timestamp, call buildDestination (height) or buildDestinationAtTime (epoch time) (note time is currently not supported by Otoplo).
The owner key for the vault at the current index is derived at the BIP-44 path: m / 44' / coinType' / contractIndex' / 0 / index with coinType taken from account.addressDerivationCoin (29223 for Nexa) and contractIndex defaulting to 1 (Otoplo convention). A single account cannot have multiple contracts that use the same contractIndex.
Responsibilities:
buildDestination / nextDestination: derive vault destinations.
save / load: persist
(unlockValue, index)per(name, index)slot in the wallet DB. Derived state (address, secret, destination) is recomputed from those inputs on load.markInterestingSpendable: claim UTXOs whose address matches the current vault, tagging them with contractId for later lookup.
balance: surface native + per-group-token balances across every vault UTXO this contract has tagged, with confirmed / unconfirmed split.
filterInputs: select spendable inputs from this contract, layering ownership and CLTV-maturity gates on top of the wallet's selector.
send: build, fund, and sign a withdrawal. Sets
tx.lockTimeto unlockValue for CHECKLOCKTIMEVERIFY, thennSequence = 0xFFFFFFFEon every input (a final-sequence input would make CLTV a no-op per BIP-65), then Schnorr-signs each input.
Non-interactive: makeFormationInvitation / acceptFormationInvitation throw.
Supports native Nexa, group tokens, and group authority batons through txCompleter flags in send. The default filterInputs selection is native-Nexa only; callers wanting tokens pass a token-aware filter.
See docs/time-lock-vault.md for the architectural overview, lifecycle diagram, and compatibility invariants.
Constructors
Properties
The DB key where the contract's current state is saved.
The vault destination for the current index and unlockValue, or null when no vault has been built. Set by buildDestination and load.
BIP-44 leaf index of the vault this contract currently points at. Starts at 0. Moved by nextDestination (advance by one), the buildDestination index overload (jump to a chosen leaf), and load (restore from a saved record). Persisted by save.
This is the contract type as specified in contractTypes. Contracts are deserialized according to this type so if a new version of the contract has an incompatible deserialization it should use a different contract type via the convention contractName_major.minor.release.bugfix, e.g. multisig_1.2.3.4
Unlock value of the current vault — a block height or, at/above the BIP-65 500,000,000 split, a Unix timestamp — or null when no vault has been built. Read through from destination, which is the single source of truth; the contract keeps no separate copy.
Functions
For interactive contracts, join based on the provided invitation
Total balance held across EVERY vault this factory has tagged, by group id — all of them share one contractId. For the current vault's own holdings use currentVaultBalance. "Can I spend this right now?" is a separate question answered by filterInputs / markInterestingSpendable, not by balance.
Build a TimeLockVaultDestination for this contract's current index at the supplied blockHeight and cache it as destination.
Jump the cursor to index and buildDestination there, keeping the LOCKTIME_THRESHOLD ceiling — use this to create a vault at a chosen leaf. Moving the cursor always rebuilds destination in the same step, so the two cannot drift apart.
Build a TimeLockVaultDestination for this contract's current index that unlocks at a wall-clock time — epochSeconds, Unix time — instead of a block height, and cache it as destination.
Jump the cursor to index and buildDestinationAtTime there, keeping its timestamp-range checks — the epoch-seconds counterpart of the buildDestination index overload. See buildDestinationAtTime for the semantics and the Otoplo compatibility caveat.
Default chainTime: the median of the last MTP_BLOCK_SPAN block header times ending at the wallet's synced height, per BIP-113.
Balance of ungrouped Nexa held in this contract.
Balance of the CURRENT vault only — UTXOs at destination's address — as opposed to balance, which spans every vault this factory has ever tagged (all vaults share one contractId). The text UI shows this figure next to the current vault's address so a re-pointed factory cannot display a sibling vault's coins under the current vault's identity. Empty-seeded map when no vault has been built.
Selects UTXOs from this vault that can be used as inputs in a new transaction, with four gates layered on top of the wallet's general selector (CommonWallet.filterInputs):
Contract UTXO (unspent transaction output) iterator. Visits each unspent transaction output. Return true ("I found it") from your callback to stop the iteration early, false ("not what I wanted") to continue with the next UTXO.
Detect transactions that deposit into a vault for this contract's current index — at any unlockValue.
Whether the vault committing unlock is spendable against the current chain, or null when the wallet is too disconnected to prove it either way.
isVaultMature against a caller-held chainTimeSnapshot, so a single operation on a timestamp vault — a send refusal and its reason, or a status line's verdict and countdown — reads the clock once and stays self-consistent even if a block lands mid-way. (The height branch ignores the snapshot and reads the live synced height; its messages are best-effort against a moving tip.)
Every vault of this factory the wallet can currently see: the one the contract points at (even when unfunded), every destination this factory has installed into the wallet's receiving map (so vaults that were built but never funded are not lost — matched by TimeLockVaultDestination.contractName), plus one entry per sibling vault still holding unspent coins. Siblings are read off the tagged UTXOs themselves — markInterestingSpendable stores each vault's own TimeLockVaultDestination as the UTXO's backing destination, so its (hdIndex, unlockValue, address) survive both factory re-points and wallet reloads.
Reads the record save wrote, overwrites index, and rebuilds destination (which carries the unlock value).
For interactive contracts, make an invitation for others to join
Claims a UTXO whose address matches any vault of this factory.
Creates another destination with the same unlock semantics (same time or block height) as the current one. Call buildDestination to get a destination that unlocks at a different time.
Re-derive the vault at an already-committed (unlockValue, leaf) parsed off an on-chain script, WITHOUT the LOCKTIME_THRESHOLD ceiling that gates new-vault creation.
Writes the current vault's unlock value, leaf index, and destination under contractDbKey. All values are read from destination.
Build, fund, and sign a transaction that spends from the vault at the contract's current (index, unlockValue).
Return an object of the expected Wallet Contract Interface
Balance of the single vault at vaultAddress — the per-vault slice of balance. All vaults of this factory share one contractId, so the address is what tells them apart; the text UI uses this to give each vault's status line its own figure. See balance for the confirmed/unconfirmed classification rules.