Imported from lightninglabs/wavelength (
serverconn/AGENTS.md). Install upstream withnpx skills add lightninglabs/wavelength --skill serverconn. Copyright stays with the author.
serverconn
Purpose
Unified connector for all mailbox traffic between client and remote Ark server, combining durable egress (crash-safe events), low-latency unary RPCs, and background ingress polling with event routing.
Key Types
Runtime— Main entry point wrapping DurableActor, ServerConnectionActor, and UnaryFacade. The egress DurableActor runs on the Read/Commit (TxBehavior) path: each handler builds its envelope and callsEdge.Sendwith NO SQLite writer held, then a short lease-fenced Commit folds the ack + dedup. It runs as a competing-consumer pool ofConnectorConfig.EgressWorkersworker loops, so the round and out-of-round actors' sends proceed concurrently; the single ingress puller is separate and unaffected.ServerConnectionActor— Core behavior handling egress messages and the ingress loop. DispatchesDurableUnaryQueryvalues generically viabuildDurableUnary.ArkVersionNegotiator— Single home for Ark protocol version selection (ark_version.go).Bootstrapperforms the one bootstrapGetInfoover the operator's direct ArkService connection (ArkVersionGetInfoClient, never the mailbox edge) and returns the response + selected version; the daemon parses domain terms from the same response. The free functionValidateRefreshSelection(resp, boundVersion)enforces that a refresh-onlyGetInfokeeps the runtime bound (returns a permanent*StatusErroron drift/disable). Enabled versions are derived from the response's ACTIVEArkVersionPolicyentries.UnaryFacade— Implementsmailboxrpc.RPCClientfor generated RPC stubs (low-latency path). Bounded waits come from the caller's context plus the response registry TTL; there is no separate timeout entry point.SendRPCgates onConnectorConfig.MaxInFlightUnaryand fails a send that would exceed it withcodes.ResourceExhausted.ConnectorConfig— Wiring configuration (edge address, mailbox IDs, dispatchers, store, durable unary builder,EgressWorkers,MaxInFlightUnary).EgressWorkerssizes the egress worker pool (defaultDefaultEgressWorkers= 4);<= 1keeps the legacy single sender. TheDurableUnaryBuilderfield must be set to handleDurableUnaryQuerymessage types; otherwise those messages are rejected. TheAuthSignaturefield holds the Schnorr auth sig injected into every outbound envelope viamergeAuthHeaders(auth header always wins over caller-provided headers).PubKeyMailboxID— Derives canonical mailbox ID from a public key (hex-encoded compressed SEC). Panics on nil.MailboxAuthDigest/MailboxAuthMessage— BIP-340 tagged hash digest construction for mailbox auth signatures. Useschainhash.TaggedHashwith theMailboxAuthTagStrdomain separator oversenderCompressedPubKey || recipientMailboxID. Do not read this as preventing cross-server replay on its own — see the invariant below for which callers get that property and which do not.SignMailboxAuth/VerifyMailboxAuth/ParseMailboxPubKey— Schnorr sign/verify helpers for pubkey-derived mailbox identity.AuthHeaderKey— Envelope header key (x-mailbox-auth-sig) for the Schnorr auth signature.GenerateClientTLSCert— Creates an ephemeral P-256 mTLS client cert with the secp256k1 identity pubkey hex as Subject CN. Returns error on nil key.EventRouter— Registry mapping inboundServiceMethods to typed actor dispatch.AddRoute/NewEventRouteregister durable actor-message routes;AddEnvelopeRouteregisters raw-envelope handlers (e.g. shared RPC methods where a stale response is dropped viaErrEnvelopeHandledinstead of delivered).MailboxTLSBindDigest/Message,SignMailboxTLSBind/VerifyMailboxTLSBind,TLSBindHeaderKey— Binds the ephemeral mTLS leaf cert's SPKI to the secp256k1 identity via a BIP-340 Schnorr signature, complementingGenerateClientTLSCert(the cert alone proves nothing; this signature proves the TLS key and the identity key are held by the same party).NewAuthenticatedMailboxClient/MailboxAuthSigner—mailboxpb.MailboxServiceClientdecorator that signs and attaches thex-mailbox-auth-sigheader to everySend,Pull, andAckUpTobefore forwarding to the wrapped edge transport.MailboxAuthSignerisfunc(ctx, recipientMailboxID) (string, error)returning the hex-encoded signature;wavedsupplies a memoizing implementation. Wrapping unconditionally means the daemon works against an operator that terminates TLS at a proxy (and so never sees a client certificate) without that posture leaking into client config.AckState— Four-cursor watermark state machine (PullCursor, DispatchCommittedTo, AckTarget, AckCommittedTo).SendUnaryRequest— Durable typed unary request that becomes a real unary RPC after commit. The response arrives via KIND_RESPONSE and, if no in-memory waiter exists, falls back to durable route dispatch via the EventRouter.DurableUnaryRequestBuilder— Interface for proof-gated request-body construction. Implementations build the actual proto request (e.g., with signed proofs) at send time, not at persist time. The interface is provided viaConnectorConfig.DurableUnaryBuilder.DurableUnaryQuery— Interface implemented by transport-native durable query messages that persist raw query parameters (not a full proto). TheServerConnectionActormatches anyDurableUnaryQuerygenerically in itsReceiveloop and callsbuildDurableUnaryto construct aSendUnaryRequeston the fly, usingBuildBody,QueryCorrelationID,QueryMsgID,QueryIdempotencyKey, andServiceMethod.SendListOORRecipientEventsByScriptRequest— TLV-durable (type2003) indexer query message for phase-1 OOR receive resolution. Persists PkScript, AfterEventID, Limit, CorrelationID, MsgID, and IdempotencyKey; the proof-gated proto body is built at send time byDurableUnaryRequestBuilder.BuildListOORRecipientEventsByScriptRequest.SendListVTXOsByScriptsRequest— TLV-durable (type2004) indexer query message for phase-2 OOR metadata resolution. Persists PkScripts (count-prefixed, length-prefixed list), opaque AfterCursor, Limit, CorrelationID, MsgID, and IdempotencyKey; the proof-gated proto body is built byDurableUnaryRequestBuilder.BuildListVTXOsByScriptsRequest.CorrelationKey()onSendClientEventRequest— Forwards the innerServerMessage's per-key FIFO key. Uses a structural assertion on the inner message in the pre-Encode path; falls back to acachedCorrelationKey(populated at TLV decode) in the post-Decode path, becauseDecodereplaces the concrete inner message with arawServerMessagethat no longer implementsCorrelationKey(). This ensures the durable mailbox enqueues events into the correct per-key FIFO lane (e.g.oor/<session>,round/<id>) even after a crash-replay decode cycle.
Relationships
- Depends on:
baselib/actor(DurableActor infrastructure),mailbox/*(Envelope, RpcMeta, MailboxServiceClient),arkrpc(GetInforequest/response +ArkVersionPolicyfor version negotiation). - Depended on by:
round(outbound RPCs),oor(durable transport),waved(wiring),sdk/swaps(CompoundMailboxID,PubKeyMailboxID),swapclientserver. - Sends (egress → remote mailbox):
SendClientEventRequest(durable): wrapsJoinRoundRequest,JoinRoundAccept,JoinRoundReject,SubmitNoncesRequest,SubmitPartialSigRequest,SubmitForfeitSigRequest.JoinRoundAccept/JoinRoundRejectare the explicit responses to a server-issued seal-timeJoinRoundQuote(#270); both echo thequote_idso the server can drop stale responses after a reseal.SendRPCRequest(unary, non-durable): low-latency request-response RPCs- transport-native durable query messages for proof-gated indexer lookups
- Routes (ingress → local actors via EventRouter):
- →
round:CommitmentTxBuilt,JoinRoundQuoteReceived,NoncesAggregated,OperatorSigned,RoundJoined,BoardingFailed.JoinRoundQuoteReceivedis the seal-time fee quote (#270) routed byRoundID; the round actor buffers it viabufferPendingQuotewhen it arrives before the matchingRoundJoinedre-keys the FSM (the mailbox contract permits out-of-order delivery). - →
oor:SubmitAcceptedEvent,FinalizeAcceptedEvent,IncomingTransferEvent
- →
- Receives (from local actors for outbound delivery):
- ←
round:SendClientEventRequest(outbox messages for persistence) - ←
oor:SendSubmitPackageRequest,SendFinalizePackageRequest,SendIncomingAckRequest
- ←
Invariants
- Mailbox auth cross-server replay resistance is per-caller, not universal.
Including the recipient mailbox ID in the digest binds the signature to what
it addresses, but only
Sendgets the strong version: it passes the compoundoperator:clientrecipient, which embeds the operator's pubkey-derived ID, so aSendsignature is useless at any other operator.PullandAckUpTopass the client's own plain mailbox ID (fromConnectorConfig.LocalMailboxID), which carries no operator component — so that digest is identical at every operator, and onePull/Acksignature authorizes that client's mailbox at every operator its identity key is known to. Identity keys are a deterministic derivation, so a wallet driving two operators presents the same credential to both. Closing thePull/Ackcase means folding a server identity into the digest, which is a wire change on both sides. - Ack watermark only advances AFTER durable local dispatch commit (prevents message loss on crash).
- The ingress fold never holds the database writer across network IO.
runFoldedDispatchruns waiter-backed responses and theConnectorConfig.NonTxRoutesrequests BEFORE opening the write transaction; only durable enqueues and the cursor checkpoint go inside it. A route is hoisted only when it is listed inNonTxRoutesAND the envelope is aKIND_REQUEST, so a durable actorTellcan never escape the fold. An envelope of any other kind arriving on a marked route is skip-warned bydispatchBatchrather than dispatched, because the mux bridge ignoresenv.Rpc.Kindand would otherwise serve a sender-mislabeled envelope over the network with the writer held. Any new dispatcher that terminates inEdge.Sendrather than a durable enqueue MUST be added toNonTxRoutesat wiring time (seewaved.Server.buildRPCDispatchers), otherwise it pins the SQLite global writer lock (production opens with_txlock=immediate) or a SERIALIZABLE Postgres snapshot for the length of a round trip to the operator. - Pre-transaction dispatch happens before the commit, never after. A crash in between re-pulls the batch and redelivers, which is the at-least-once contract; committing first would advance the cursor past a request that was never answered.
- Unary RPC responses use in-memory registry first; if no waiter exists (crash replay), the ingress falls back to durable EventRouter dispatch. The ResponseRegistry returns a tri-state delivery result (waiter/buffered/dropped) so the ingress knows whether to route durably.
SendClientEventRequestauto-derivesService/MethodfromMessage.ServiceMethod()when callers leave them empty, preventing silent drops.- Idempotency keys are derived from message payload hash; same key on retry enables server deduplication.
- Egress is at-least-once: on the Read/Commit path the
Edge.Sendis not atomic with the mailbox ack (it never was, even on the old Classic path), so a crash or a lost lease between a successful send and its Commit redelivers and re-sends. The server absorbs the duplicate via the stableMsgId/IdempotencyKey. UnderEgressWorkers > 1aSendClientEventRequestcarries the inner message'sCorrelationKey, so same-session events keep per-key FIFO order across the worker pool while distinct sessions send in parallel.SendUnaryRequestandSendRPCRequestare intentionally unkeyed (theBaseMessagedefault), so distinct unary/RPC sends may reorder across workers; that is safe only because each is an independent request/response RPC matched by an explicit correlation ID, not a position in an ordered stream. Any new order-sensitive egress message MUST define aCorrelationKey, or it will silently reorder under the pool. - Every durable
Edge.SendusessendEnvelope, which preserves context values, detaches from actor-turn cancellation, and applies the 30-seconddefaultSendEventTimeout. A timeout returns before Commit, so the actor nacks and retries the unchanged envelope with its stable identifiers. This bound prevents black-holed sends from occupying all egress workers. The directUnaryFacadepath remains caller-scoped. - The mailbox protocol has no cancel envelope kind (
RpcMeta_Kindis REQUEST/RESPONSE/EVENT only), so a unary caller that gives up on its deadline cannot recall the request: the operator runs it to completion and the response arrives with no waiter, falling through to durable route dispatch.ConnectorConfig.MaxInFlightUnary(defaultDefaultMaxInFlightUnary= 256) is the only client-side bound on how much of that abandoned work one client can queue. The cap is soft: concurrent senders may overshoot it by one burst, since making it exact would mean holding the registry lock across the send. - A unary caller that re-issues must own its idempotency key for the life of
the logical request (see
mailboxrpc.Retry).SendRPCmints a fresh key only when the caller leavesRPCOptions.IdempotencyKeyempty, which is correct for a single-shot call and defeats deduplication for a retry. - Ingress loop checkpoints pull cursor and ack state; on restart, resumes from checkpoint.
- A mailbox outage warns once per episode, not once per retry.
pullPhasecarries a dedicatedpullFailCountalongside the shared backofffailCount: the first failure of an episode logsWarnSso the dependency outage still pages, and every subsequent failure logsDebugSwith aconsecutive_failuresfield. A successful pull resets the counter to zero, so a later outage warns again. The counter is pull-specific on purpose — sharing the backoff counter would let unrelated backoff activity suppress the first warning of a genuine outage. Without this, one rollout produced ~8 alerts in ~20 seconds per client, all carrying the same cause and cursor. Shutdown errors (isIngressShutdownErr) and permanent version errors (checkPermanentStatus) are classified before this counter and exit the loop instead. DurableUnaryQueryvalues are handled generically inServerConnectionActor.ReceiveviabuildDurableUnary: the query is converted to aSendUnaryRequestusing the configuredDurableUnaryRequestBuilder. Adding a new durable indexer query type requires only implementingDurableUnaryQuery— no newReceivecase is needed.DurableUnaryQueryimplementations must produce stable identity bytes inBuildBodyso thatMsgIDandIdempotencyKeyare deterministic across restarts (auto-derived viamailboxconn.StableEventMsgID/StableEventIdempotencyKeywhen the caller leaves them empty).ServerConnectionActorruns a background heartbeat goroutine (DefaultHeartbeatInterval= 30s) to keep the mailbox session alive.- Ingress handles header-only error responses (nil body) by routing them as errors rather than panicking on nil proto.
SendClientEventRequest.CorrelationKey()always returns the correct per-key FIFO lane key regardless of whether the message was constructed fresh or decoded from TLV. ThecachedCorrelationKeyfield is populated duringDecodeviatlv.TlvType8so restarts do not lose FIFO routing.- Every outbound envelope is stamped with the runtime-bound mailbox
transport and Ark protocol version pair (
stampEnvelopeVersions/versionStampingMailboxClient), overwriting any caller-provided value. Every inbound envelope is checked against the same bound pair (validateInboundEnvelope); a mismatch is always a permanent*mailboxconn.StatusError— there is no legacy zero-version fallback, since client and operator are always deployed with a negotiated version.
Deep Docs
- serverconn/README.md — Architecture, usage guide, crash recovery paths.
- docs/mailbox_architecture.md — Three-layer mailbox system.
- docs/mailbox_transport_serverconn_clientconn.md — Transport split between serverconn (client-side) and clientconn (server-side).
- docs/durable_actor_architecture.md — Durable actor internals.
- p-models/durableactor/README.md — P models of the ingress cursor fold and of the dispatch deferral/redrive against bounded in-memory mailboxes.
- ARCHITECTURE.md — System-wide package map.
Ingress evidence
See mailbox ingress safety for the occurrence-ID, 30-day receipt and bounded poison-quarantine contracts. Receipts are consumed at durable inbox insertion, not at an in-memory Tell. Quarantine uses per-lane reservations inside a global bound and is never aged out; recovery removes it only with a durable handoff.