TimeLockVault

class TimeLockVault(val name: String, val account: Bip44Wallet, var contractIndex: Long = 1, val chain: ChainSelector = account.chainSelector) : WalletContract

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.lockTime to unlockValue for CHECKLOCKTIMEVERIFY, then nSequence = 0xFFFFFFFE on 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

Link copied to clipboard
constructor(name: String, account: Bip44Wallet, contractIndex: Long = 1, chain: ChainSelector = account.chainSelector)

Types

Link copied to clipboard
object Companion

Properties

Link copied to clipboard
open override val account: Bip44Wallet
Link copied to clipboard
open override val cfi: Nothing? = null

If an invitation has been created, save it here

Link copied to clipboard
Link copied to clipboard
var chainTime: () -> Long

The chain time a timestamp vault's maturity is judged against, in epoch seconds; non-positive when the wallet cannot prove any chain time.

Link copied to clipboard

The DB key where the contract's current state is saved.

Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
open override var destination: PayDestination?

The vault destination for the current index and unlockValue, or null when no vault has been built. Set by buildDestination and load.

Link copied to clipboard
var index: Int

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.

Link copied to clipboard
open override val name: String

Name the contract something. You can only have 1 contract of a particular name in your Wallet. Used as the contractId by default

Link copied to clipboard
Link copied to clipboard
open override val type: String

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

Link copied to clipboard

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.

Link copied to clipboard
open val wallet: Wallet?

Functions

Link copied to clipboard

For interactive contracts, join based on the provided invitation

Link copied to clipboard
open override fun balance(): Map<GroupId, Balance>

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.

Link copied to clipboard

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.

Link copied to clipboard

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.

Link copied to clipboard

Default chainTime: the median of the last MTP_BLOCK_SPAN block header times ending at the wallet's synced height, per BIP-113.

Link copied to clipboard
open override fun close()

End any ongoing monitoring, etc. that this contract is doing

Link copied to clipboard
open fun coinBalance(): Balance?

Balance of ungrouped Nexa held in this contract.

Link copied to clipboard

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.

Link copied to clipboard
open override fun filterInputs(minAmt: Long, minConfirms: Int = 0, filter: (Spendable) -> Long? = null): MutableList<Spendable>

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):

Link copied to clipboard
open override fun forEachUtxo(doit: (Spendable) -> Boolean)

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.

Link copied to clipboard
open override fun interestingTx(tx: TransactionHistory): Boolean?

Detect transactions that deposit into a vault for this contract's current index — at any unlockValue.

Link copied to clipboard
fun isVaultMature(unlock: Long): Boolean?

Whether the vault committing unlock is spendable against the current chain, or null when the wallet is too disconnected to prove it either way.

fun isVaultMature(unlock: Long, chainTimeSnapshot: Long): Boolean?

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.)

Link copied to clipboard

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.

Link copied to clipboard
open override fun load(db: WalletDatabase)

Reads the record save wrote, overwrites index, and rebuilds destination (which carries the unlock value).

Link copied to clipboard

For interactive contracts, make an invitation for others to join

Link copied to clipboard

Claims a UTXO whose address matches any vault of this factory.

Link copied to clipboard
open override fun nextDestination(): Boolean

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.

Link copied to clipboard

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.

Link copied to clipboard
open override fun save(db: WalletDatabase)

Writes the current vault's unlock value, leaf index, and destination under contractDbKey. All values are read from destination.

Link copied to clipboard
open override fun send(vararg dests: iTxOutput, minConfirms: Int = 0, title: String? = null, data: ByteArray? = null): iTransaction

Build, fund, and sign a transaction that spends from the vault at the contract's current (index, unlockValue).

open fun send(amount: Long, address: String, minConfirms: Int = 0, title: String? = null, data: ByteArray? = null): iTransaction
open fun send(amount: Long, address: PayAddress, minConfirms: Int = 0, title: String? = null, data: ByteArray? = null): iTransaction
Link copied to clipboard
open override fun ui(c: KClass<*>): Any?

Get a UI for this wallet contract. Currently you can only ask for a TextUI

Link copied to clipboard
inline fun <T> WalletContract.ui(): T?

Return an object of the expected Wallet Contract Interface

Link copied to clipboard
fun vaultBalance(vaultAddress: PayAddress): Map<GroupId, Balance>

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.