Top-level daemon orchestrator that wires the wallet backend, mailbox transport, chain backend, database, and all domain actors into a running system with a gRPC API.
For field-level detail, use go doc github.com/lightninglabs/wavelength/waved.<Symbol>.
Server— main daemon. Owns the wallet, DB, chainsource actor, gRPC server, andActorSystem. CacheslocalMailboxID(pubkey-derived),authSigHex(Schnorr auth),clientKeyDesc(the stable daemon identity descriptor, behindclientKeyDescMu),mailboxAuthSigs(per-recipient mailbox auth signature memo, behindmailboxAuthSigsMu), and a singleclk(clock.Clock) shared by all sub-stores for deterministic time injection.RPCServer— implements the gRPCDaemonService. Most write RPCs (Board,SendVTXO,SendOOR,SweepBoardingUTXOs,SendOnChain) validate input locally thenAskthe relevant actor;GetRoundandListVTXOsmerge live actor state with persisted SQL rows, whileGetFeeHistoryandListTransactionsare pure SQL reads (rpc_fees.go).Config— daemon configuration: wallet backend selection, mailbox/chain backend wiring,OORConfig/OORLimitsConfig(receive safety caps),UnrollConfig(unilateral-exit fee-bump cadence and cap), andMaxOperatorFeeSat(the #270 seal-time fee-cap validated inConfig.Validate()).WalletState—None/Locked/Readywallet lifecycle.WalletRecoveryResult— counters returned by the in-process, post-unlock recovery hook.UnrollConfig/OORConfig— subsystem tunables; seeConfig.Validate()for the invariants each enforces.
- Depends on:
baselib/actor,btcwbackend,chainbackends,chainsource,lib/actormsg,db,ledger,round,txconfirm,unroll,vtxo,wallet,walletcore,oor,serverconn,indexer,arkrpc,lndbackend,fraud,gateway,rpc/restclient,vhtlcrecovery,vhtlcrecovery/coordinator,vhtlcrecovery/unrollpolicy. - Depended on by:
cmd/waved.
- The lnd wallet account (
lnd.account, empty = lnd'sdefault) bounds what this daemon may spend:ListWalletUnspent(fee inputs and the exit preflight),NewWalletAddress(the deposit address), and thelndUnrollWalletfund/sign/change triple all resolve it throughServer.lndWalletAccount(). Observation deliberately does not:listBackingWalletUnspentstays unfiltered becausefetchUnconfirmedBoardingBalanceandListUnconfirmedBoardingUTXOsexist to see imported boarding scripts, which live in lnd's watch-only account and belong to no wallet account. Scoping that dispatcher empties both, and does so even with no account configured, since lnd reads an empty account as every account but"default"as a real filter. validateLndAccountrefuses to start on a configured account that is missing, not taproot-scoped, or watch-only. Without it each of those fails later and worse — a missing account silently filters every UTXO away, a wrong-scoped one funds and signs but cannot derive a fresh script, and a watch-only one fails at signing after inputs are leased.Server.runregisters a deferredactorSystem.Shutdown()before the deferreddb.Close()so in-flight actor DB transactions drain before the connection pool tears down.- Wallet transitions
None → Locked → Ready(or direct toReadyif a seed is provided). Three wallet backends: LND, lightweight (lwwallet), or neutrino-backed (btcwalletviabtcwbackend). WaitForWalletServicesReadyresolves after wallet-dependent actors and mailbox ingress start, or with the first error before that boundary.DaemonReadyremains the later full-startup signal.Server.RecoverWalletStateis an in-process, post-unlock retry seam. It keeps the supplied context through the scan; idempotent writes make a partial scan safe to retry.- Mailbox IDs are derived from identity pubkeys via
serverconn.PubKeyMailboxID, not config strings. The operator's remote mailbox ID and pubkey are fetched via direct gRPC (fetchCurrentOperatorPubKey) before the mailbox runtime starts. - Every outbound mailbox edge is wrapped in
serverconn.NewAuthenticatedMailboxClient, soSend,Pull, andAckUpToall carryx-mailbox-auth-sig. The operator authorizes a mailbox RPC either from the TLS client certificate bound to the caller's mailbox ID or from that header, and an operator terminating TLS at a proxy never sees a client certificate — signing unconditionally keeps the operator's TLS posture out of client config. BothconnectOperatorClientsarms (gRPC and REST) andnewMailboxEdgewrap; the last is latent today but exists so a future caller cannot silently lose the header. - Mailbox auth signatures are memoized per recipient in
mailboxAuthSig. The digest isTaggedHash("mailbox-auth", identityPubKey || recipientMailboxID)and does not vary with the request, so one signature serves the life of the key; signing per RPC would put a wallet round trip in front of every long-pollPull. Two properties are load-bearing:- The wallet call happens with
mailboxAuthSigsMureleased.sync.Mutex.Lockis not context-aware, so holding it across the round trip would serialize the whole mailbox edge behind one signature and stall egress, ingress, heartbeat, and ack together on a wedged wallet. Two callers racing a cold recipient may both sign, which is harmless — the digest is deterministic. - The map grows with distinct recipients and is never evicted. The
operator edge alone contributes two (
Sendaddresses the compoundoperator:clientmailbox;Pull/AckUpToaddress the plain local one), andRPCServer.SignMailboxAuthadds one per-swap mailbox (client:payment_hash) per out-swap. This is growth, not a constant. The signing round trip is bounded bymailboxAuthSignTimeout(30 s) rather than inheriting the caller's context, because the ingress puller builds its context with no deadline at all.
- The wallet call happens with
- Public network endpoint defaults live in
defaultNetworkEndpoints(config.go).mainnet,testnet3, andsignetresolve to the Lightning Labs deployments;regtest/simnetkeep the historical localhost endpoints. The mainnet REST hosts are declared but not yet routable — the external NLB and dual-SAN certificates cover only the gRPC names — so they stay dark pending the ingress work tracked in lightning-infra#3749. Changing a default here changes where an existing user's daemon dials on restart; treat it as a deployment change, not a constant tweak. - All sub-stores share the single
s.clkclock assigned inNewServer; new code must not callclock.NewDefaultClock()directly, uses.clk. - Actor startup order in
startWalletDependentActors: VTXO manager, then round actor, then the unroll subsystem (initUnrollSubsystem), then the OOR actor (initOORActor). The VTXO manager is constructed with avtxo.LazyChainResolverplaceholder thatinitUnrollSubsystemfills in later; anything needing that seam must run afterinitUnrollSubsystem. initUnrollSubsystemboot ordering is policy-preserving.recoverySvc.RestoreNonTerminal(in-flight vHTLC recovery jobs, each carrying its durable exit policy) runs before the chain resolver isSet(); the force-exit admissions it drives through the VTXO manager are buffered by theLazyChainResolverand replayed to the unroll registry the instant the resolver is wired. The registry is first-writer-wins on exit policy, so the generic orphan-job scan (recoverOrphanedUnrollJobs) runs afterSet()and is itself policy-carrying: it is handed a per-outpoint exit-policy map (recoveryExitPolicies, built from the recovery store) and re-admits each orphaned recovery target under its own vHTLC exit policy rather than mislabeling it as a standard timeout.- The chain-resolver→unroll bridge (
ensureUnrollFromExpiring) maps a VTXOExpiringNotification's trigger and optional exit policy into the registry'sEnsureUnrollRequest.unrollStartTriggerconverts the string-typedactormsg.UnrollTrigger(kept string-typed to avoid avtxo → unrollimport cycle) intounroll.StartTrigger; an empty or unknown trigger admits as critical expiry. ANoneexit policy leaves the registry on its standard VTXO timeout policy. - The fraud watcher (
initFraudWatcher) is wired withVTXOManagerRef, so fraud spends drive exits through the VTXO manager — the same admission path as manual, critical-expiry, and vHTLC recovery exits — rather than talking to the unroll registry directly. - The vHTLC recovery service is wired with an
Exiter: managerExitAdmitter, aForceExitseam thatAsks the VTXO manager to force a materialized recovery target into unilateral exit. The target materializer (EnsureRecoveryTarget) persists the descriptor directly intoVTXOStatusUnilateralExit(notVTXOStatusSpending) so the exiting coin is excluded from the live/coin-selection query and cannot leak back into a cooperative round as a forfeit; the boot-time orphan scan re-admits it on restart. - Boarding-sweep transaction construction, fee estimation, spend watching,
and startup resumption live inside the wallet actor
(
wallet.Ark.handleSweepBoardingUTXOs/handleResumeBoardingSweepsinwallet/boarding_sweep_actor.goandwallet/boarding_sweep.go), not in waved.RPCServer.SweepBoardingUTXOsonly validates the request andAsks the wallet actor; waved supplies the boarding store (newBoardingStore) and the backend-specific sweep-wallet adapter (newSweepWallet, one oflndUnrollWallet/lwUnrollWallet/btcwUnrollWallet), which is structurally compatible with bothunroll.SweepWalletand the wallet actor'sSweepSigner. LeaveVTXOsfilters its targets throughadmitLeaveTargetsbefore dispatch, which runsvtxo.CheckForfeitAdmissionover each descriptor. The two selection modes fail in opposite directions on purpose: an explicitly named outpoint is refused withFailedPreconditionnaming the round that holds it, since silently dropping it would report a queued leave that never happens, while aselection=allsweep drops it (ListLiveVTXOsreturns every non-terminal VTXO, so one in-flight coin would otherwise sink the batch). Every drop is logged with its outpoint and admission error and counted intoskipped_count, soqueued_count=0over a fully committed wallet stays distinguishable from an empty one. The filter is advisory; the VTXO FSM refuses a late claim in bothPendingForfeitandForfeiting.GetExitPlanreports a round commitment as an advisory, never as a per-entry error: the entry is still priced, andCanStartis lowered withunroll.ExitRoundCommittedplus aRoundCommitmentnaming the coin. This must stay an advisory becauseUnrollshort-circuits only onVTXOStatusUnilateralExitand the FSM escalates a manual trigger from both committed states — so the exit being warned about is one the exit command performs, and it is the only recovery when the operator is unreachable and the commitment never confirms. Failing the entry would contradictUnrolland withhold the funding figures that recovery needs.RefreshVTXOsdry-run short-circuits before the wallet-ready gate (LeaveVTXOs parity) and attaches a best-effort advisory fee estimate (rpc_refresh_estimate.go): explicit outpoints are deduped and resolve viavtxoStore.GetVTXO(unknown or non-live outpoint = InvalidArgument, mirroring the --all LiveState filter), operator quotes go through theEstimateFeeproxy deduped on (amount, remaining blocks) with remaining clamped to >= 1, and the free-refresh waiver is computed locally from the cached operator terms (the operator's EstimateFee does not apply it). Estimate failures setestimate_errorand never fail the preview. The real refresh path still gates on wallet readiness.SendVTXOenforcesmaxRecipients = 256, rejects per-recipient amounts outside(0, MaxSatoshi], and uses overflow-safe summation; the wallet actor repeats these checks as defense-in-depth.SendOORwith custom inputs serializes concurrent calls on the same outpoints viareserveCustomInputs; the release function is deferred on both success and failure.SendOORmapsoor.ErrIdempotencyKeyConflicttocodes.AlreadyExistsafter releasing the freshly selected VTXO locks, so it never reports success under a caller key the deterministic session cannot retain. This includes a keyed retry over a live durable session that was originally admitted keylessly. A terminal failed outgoing row with no immutable attempt can be rebound only by rebuilding the same deterministic operation; its new key and proof still commit before transport enqueue.resolveExistingOORRecipientOutpoints(the keyed-replay path that rebuildsSendOORResponse.RecipientOutpointswithout reselecting wallet inputs) reads the immutable dispatch attempt, not the mutable session snapshot. The sender can ingest its own OOR change under the same session id without hiding the outgoing identity. The proof covers caller recipients, not the separately added wallet change output. Exact recipient reordering returns the same distinct outpoints in caller order. A changed count, amount, or script is rejected withcodes.AlreadyExists; malformed durable data iscodes.DataLoss. A legacy binding with no canonical request returns the stable session id without recipient outpoints and never sends again. If the current outgoing lifecycle is known to have failed, replay returns statusfailedwithout claiming that its recipient outpoints exist. A same-key retry that reaches the in-memory admission winner before its attempt commits returns the stable session id with no unproven outpoints; a later retry resolves them from the committed attempt.Unroll/GetUnrollStatusreturncodes.Unavailable(notInternal) when the unroll subsystem refs are not yet set, so clients can retry.Unrollmust setForceUnrollRequest.Triggerexplicitly toactormsg.UnrollTriggerManual. The zero value admits asUnrollTriggerCriticalExpiry, so omitting it records a hand-typed unroll as the expiry safety net and the job's persisted provenance names a trigger that never fired. The distinction is not cosmetic:ForfeitingStatesuppresses a critical-expiry exit once the forfeit signature has issued but still honours a manual one.NewReceiveScripttreats a non-empty idempotency key as one durable allocation. The owned-script store records the key locator, script, operator terms, absolute expiry, stable mailbox RPC key, and completion evidence before indexer registration. Pending retry reuses those artifacts; completed replay returns without the indexer while its window is active. Expired replay atomically persists a new window and remote key before re-registering the same script. Empty keys preserve fresh allocation. Concurrent callers on one key serialize onRPCServer.receiveScriptLocks, a refcounted in-process keyed lock, so two retries never drive the same mailbox correlation ID at once; the entry is dropped when its last holder or waiter leaves.NewReceiveScriptResponse.expires_at_unix_sreports the absolute indexer registration expiry on both fresh and replayed paths.SignCreditAccountAuthorization(and the internalRPCServer.SignCreditAccountAuthbehind it) signs a canonical swap credit-account request digest with the daemon identity key. It validates the digest (32 B), nonce (32 B), and account key (33 B compressed) lengths, requiresaccount_pubkeyto equal this daemon's own identity key — the daemon signs only for itself, and a mismatch isInvalidArgument, notInternal— and bounds the requested lifetime to(now, now + swaprpc.CreditAccountMaxAuthTTL]. It is granted under theswap:writemacaroon entity alongsideSignOutSwapHtlcAck.- Under
js && wasm,ensureDataDiris no longer an unconditional no-op: it callsos.MkdirAlland treats onlyENOSYS(the browser's stub fs) as "no filesystem here". Every other error is returned, so a Node host given an unwritable path fails at startup rather than at the first database open. OORLimitsConfig.MaxMailboxScriptBytesmust be at leastminOORMailboxScriptBytes = 34(P2TR script length); validated inConfig.Validate().Config.EagerRoundJoindefaults viadefaultEagerRoundJoin():falseon the standalone build,trueunder thewavewalletrpcbuild tag.registerOOREventRouteschecks for a typed*oorpb.SubmitRejectedErrorbefore the generic error path, so an OOR rejection drives anOutboxErrorEventinstead of anAdapterror that would stall the serverconn ingress cursor on the offending envelope (server.go).oorRejectRetryclassifies the event'sRetryableflag: it isfalse(terminal) for every typed reject except the two transient codes,OOR_REJECT_INPUT_NOT_SPENDABLE(the operator has not yet caught up to the input's commitment confirmation) andOOR_REJECT_USER_BALANCE(the recipient mailbox is over its balance cap), which re-drive the submit afteroorTransientRejectRetryDelay. That retry is now bounded: the FSM'sAwaitingSubmitAcceptedtransition (handleSubmitOutboxErrorinoor/transitions.go) gives up terminally once the cumulative retry window exceedsOORConfig.MaxTransientSubmitRetry(default 1h), persisting the window start (FirstRejectUnixNanos) in the outgoing snapshot (version 5) so the bound survives restarts.operatorTermsFromResponseand daemonGetInfomust preserveFreeRefreshWindowBlocksend to end.RPCServer.OperatorVTXOFloorrefreshes authenticated operator terms for each credit materialization decision and bounds that refresh withoperatorTermsRefreshTimeout(30 s). Its callers use daemon/actor lifetime contexts, so removing this local timeout can park boot reconciliation or a settled-receive actor turn forever on a stalled operator.deriveIdentityKeyEarlypublishesclientKeyDescbefore mailbox bootstrap.GetInforeuses this descriptor instead of deriving the same key for every status request because btcwallet-backed derivation opens a wallet database write transaction. Before the descriptor is available,GetInfokeeps its pre-initialization fallback. All descriptor reads and startup writes go throughloadClientKeyDescandstoreClientKeyDescbecauseGetInfois callable while startup is publishing the value.- The VTXO manager reads
FreeRefreshWindowBlocksfrom the latest cached operator terms on each expiry check. It delays automatic refresh to the window boundary only when the local dynamic critical threshold plus retry buffer remains intact. When that cached boundary fires, it fetches a freshGetInfosnapshot and rechecks the window before reserving the input.
- docs/daemon_cli_guide.md — Installation, configuration, CLI reference.
- ARCHITECTURE.md — System-wide package map.