Stamp raw virtual outputs with the correct per-contract tapscripts (forfeit, intent, tap tree).
Resolves each vtxo's script to its owning contract via the contract
repository and attaches the matching tapscripts. Throws when any vtxo
references a script with no registered contract — callers are expected
to register the contract before asking for annotation. This is the
single shared path that replaces scattered extendVirtualCoin* calls
in wallet/handler code, and keeps the wallet from silently stamping the
default tapscript onto a non-default vtxo.
Optionaltapscripts: ContractTapscriptCacheThrow unless every one of vtxos still has an annotatable contract.
Spending does not re-derive tapscripts — it uses the ones stored on the coin — so a contract that stopped being annotatable (handler no longer registered, or params its handler now rejects) still builds and submits a transaction fine, and only fails afterwards, in the bookkeeping that re-annotates the inputs. Calling this before submitting turns that into a refusal to spend, naming the contract, rather than a broadcast whose local state could not be recorded.
Throw when one of vtxos belongs to a contract that provably cannot be
spent right now, asking each owning handler's
ContractHandler.assertSpendableNow.
The complement of isContractGenericallySpendable, which keeps escrow out of generic selection and leaves explicit-input APIs open on purpose. This does not close that door — it makes walking through it too early report itself locally, naming the timelock, instead of coming back as a protocol-level rejection after the round trip.
Handlers answer only where they are certain, so contracts with no opinion (which is all of them but VHTLC today) pass through untouched and cost nothing — not even a chain-tip read.
Optional so that adding it is not a breaking change for an embedder with
its own IContractManager. An implementation that omits it simply offers
no opinion, which is the same answer every non-VHTLC contract gives.
OptionalwalletDescriptor: () => Promise<string | undefined>Create and register a new contract.
Contract parameters
The created contract
createContract's step order, fetch batched: persist all, hydrate once,
then watch. Hydrating first keeps it equivalent — the watcher seeds from
the repository, so an unhydrated row reads as all-new. A part-way failure
still watches what it wrote; a retry reports those rows as existing.
Delete a contract, dropping the row along with the watch. To stop
watching a finished contract without losing its history, use
setContractWatchState("retained").
Contract script
Dispose of the ContractManager and release all resources.
Stops the watcher, clears callbacks, and marks the manager as uninitialized.
Implements the disposable pattern for cleanup.
Get every currently valid spending path for a contract.
No blockHeight: this enumerates paths "regardless of current
spendability", so no handler evaluates a timelock here and the tip would
be fetched only to be discarded — leaving a purely local answer waiting
on the network for nothing.
Options for getting spending paths
Get contracts with optional filters.
Optionalfilter: ContractFilterOptional filter criteria
Filtered contracts TODO: filter spent/unspent
List contracts and their current virtual outputs.
If no filter is provided, returns all contracts with their virtual outputs.
Optionalfilter: ContractFilterOptionalpageSize: numberGet currently spendable paths for a contract.
Options for getting spendable paths
Latest provider-sync health. See ContractSyncState. Degradation is
recorded by initialize, getContractsWithVtxos, and
createContract; it flips back to online on the next successful
sync. Purely a freshness signal — not a source of truth for wallet data.
Check if currently watching.
Register a callback for contract events.
The manager automatically watches after initialize(). This method
allows registering callbacks to receive events.
Event callback
Unsubscribe function to remove this callback
Reconcile specific outpoints with the indexer's authoritative state and upsert the result into the wallet repository.
The cursor-derived delta sync filters by created_at, so a VTXO that
was created before the cursor but spent recently won't surface in a
standard refreshVtxos() call. This method is the surgical recovery
path for that case: when something hands us a stale outpoint (e.g. the
server returns VTXO_ALREADY_SPENT with a vtxo_outpoint in its
error metadata), call this to pull the latest state and unblock the
caller — no full re-scan, no cursor change.
Outpoints not owned by any tracked contract are silently dropped.
Force refresh virtual outputs from the indexer.
Without options, re-fetches the watcher's watched set and advances the global cursor. Each option narrows or widens that scope and may hold the cursor back — see RefreshVtxosOptions.
Optionalopts: RefreshVtxosOptionsExplicit, gap-limit contract discovery (see IContractManager.scanContracts).
Each hit is routed through persistAndWatchContract — the same
dedupe + watcher-register path as createContract minus the
per-contract indexer round-trip. The caller (Wallet.restore) follows
up with a single bulk refreshVtxos({ includeInactive: true }), so a
scan that finds N contracts costs one batched indexer call instead of
N + 1.
Safety-critical invariants (spec §2.C / §4):
opts.materialize(i) throwing is structural/fatal: it is NOT
wrapped — it propagates and aborts the scan.handlerErrors and makes its
index indeterminate: it does NOT advance the gap counter, and the
scan stops verifying there, reporting truncatedAt. Only an index
every handler answered for can be a confirmed miss.persistAndWatchContract rejecting is operational/fatal and
propagates (only the handler calls are guarded).Discoverable.discoverRange).discoverables order to preserve
the first-wins collision tie-break.batchSize at a time, but each window is CAPPED to
gapLimit - unused indices — the most a serial scan could still reach
before the gap window is guaranteed to close. So every index probed in
a window is one a one-index-at-a-time scan would also reach: nothing is
over-scanned, nothing is discarded, and materialize/discovery are
invoked on exactly the same index set. The window's hits are still
processed strictly in ascending index order, so the discovered set,
persisted rows, highestConfirmedUsedIndex, and handlerErrors are
byte-for-byte identical to the serial path — only the wall-clock
differs. Truncation is the one exception: a window's concurrent probes
can surface hits above the failed index that a serial scan would never
have reached, so a truncated batched scan discovers a superset — never
a subset — of the serial one.subscribeForScripts instead of N
growing ones (see ContractWatcher.withCoalescedSubscription).Set a contract's state. Retiring (inactive) keeps it watched;
see ContractState. To stop watching while keeping the row,
use setContractWatchState.
Which of vtxos their owning handler refuses right now, keyed by outpoint
(txid:vout) and valued with the handler's own explanation.
The predicate form of assertSpendableNow, for callers that must DROP a refused input rather than fail the batch holding it. Keyed per outpoint, not per script: a relative (CSV) timelock is measured from each coin's own confirmation, so two coins on one contract can disagree.
Optional for the same reason assertSpendableNow is.
OptionalwalletDescriptor: () => Promise<string | undefined>Update a contract's params. This method preserves existing params by merging the provided values.
Contract script
The new values to merge with existing params
StaticcreateStatic factory method for creating a new ContractManager. Initialize the manager by loading persisted contracts and starting to watch.
After initialization, the manager automatically watches every persisted
contract. Use onContractEvent() to register event callbacks.
ContractManagerConfig
Central manager for contract lifecycle and operations.
Responsibilities:
Notes:
onContractEvent()is just for subscribing).Example