diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b3df8df29..ad59aaf2b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -50,6 +50,7 @@ package may import from a higher layer. | [`db`](db/) | SQLite/PostgreSQL persistence: boarding, rounds, VTXOs, OOR artifacts, fee ledger | | [`mailbox`](mailbox/) | Mailbox protocol primitives across three sub-packages (pb, rpc, conn) | | [`serverconn`](serverconn/) | Unified server connector: durable egress, ingress polling, unary RPC facade | +| [`serverconn/mailboxpull`](serverconn/mailboxpull/) | Shared exponential-backoff retry primitives for mailbox pull loops (used by serverconn ingress and SDK swap consumers) | ### Layer 3: Application & Orchestration @@ -77,6 +78,8 @@ package may import from a higher layer. | Package | Purpose | |---------|---------| +| [`p-models`](p-models/) | Executable P formal models and Go conformance bridge for distributed-systems properties (durable mailbox, Read/Commit fence) | +| [`p-models/durableactor/bridge`](p-models/durableactor/bridge/) | Go conformance harness: replays P model mailbox traces against the real `db/actordelivery` store | | [`harness`](harness/) | Docker-based Bitcoin/LND integration test environment | | [`systest`](systest/) | System-level end-to-end tests | | [`internal/actortest`](internal/actortest/) | Durable actor integration tests with real DB backends | diff --git a/baselib/actor/AGENTS.md b/baselib/actor/AGENTS.md index f8add5d33..8724e4c60 100644 --- a/baselib/actor/AGENTS.md +++ b/baselib/actor/AGENTS.md @@ -28,7 +28,8 @@ crash-safe at-least-once delivery with exactly-once deduplication. - `MessageCodec` — TLV-based codec for message serialization/deserialization. - `DeliveryStore` / `TxAwareDeliveryStore` — Interfaces for durable mailbox persistence (enqueue, claim, ack, dead-letter). - `DurableActor` — Actor variant with crash-safe mailbox backed by SQL persistence. Provides `Wait(ctx)` to block until the actor stops and `StopAndWait(ctx)` to request a graceful shutdown and then wait. -- `DurableActorConfig[M, R]` — Configuration struct for `DurableActor`: behavior, store, codec, clock, DLO, WaitGroup, `TellRetryPolicy`, lease/heartbeat/poll durations, max attempts, cleanup timeout, and deduplication TTL. +- `DurableActorConfig[M, R]` — Configuration struct for `DurableActor`: behavior, store, codec, clock, DLO, WaitGroup, `TellRetryPolicy`, lease/heartbeat/poll durations, max attempts, cleanup timeout, deduplication TTL, and `NumWorkers`. +- `DurableActorConfig.NumWorkers` — How many concurrent worker loops drain the actor's single mailbox. Default and any value `<= 1` is one worker (strictly-sequential processing). A value `> 1` turns the actor into a competing-consumer pool: that many goroutines each lease distinct messages via `LeaseNextMailboxMessage`, so independent messages run in parallel while per-correlation-key FIFO still keeps same-key messages ordered. Only for behaviors whose handlers are concurrency-safe and hold no writer across their side effects (e.g. the serverconn egress sender on the Read/Commit path). `NewDurableActor` **fails closed** with `ErrConcurrentClassicBehavior` when `NumWorkers > 1` is paired with a classic (`Left`) `ActorBehavior`, since the classic path wraps the whole `Receive` in one write transaction and assumes sequential delivery; pools are only valid on the Read/Commit (`TxBehavior`) path. The test-only `DurableActorConfig.AllowConcurrentClassicBehavior()` escape hatch bypasses the guard for the egress benchmark that measures the forbidden config; production code must never call it. - `DefaultDurableActorConfig[M, R]()` — Constructor returning a `DurableActorConfig` with safe defaults (30s lease, 10 max attempts, 1s poll fallback, DefaultTellRetryPolicy). - `TellRetryPolicy` — Function type `func(attempts int, lastErr error) (bool, time.Duration)` determining retry behavior for failed Tell messages. Return `(false, _)` to dead-letter immediately. - `DefaultTellRetryPolicy` — Exponential backoff policy: up to 5 attempts, starting at 1s, capped at 60s. @@ -60,7 +61,7 @@ crash-safe at-least-once delivery with exactly-once deduplication. ## Invariants -- Messages are processed sequentially per actor — no concurrent `Receive` calls. +- Messages are processed sequentially per actor by default (one worker, no concurrent `Receive` calls). Opting into `DurableActorConfig.NumWorkers > 1` relaxes this: that many worker loops drain the one mailbox concurrently, so `Receive` may run in parallel across distinct messages. The competing-consumer lease guarantees each message is still processed by exactly one worker, and per-correlation-key FIFO holds across workers; only behaviors with concurrency-safe handlers should set it. The combination is structurally restricted to the Read/Commit path: `NewDurableActor` rejects `NumWorkers > 1` on a classic `ActorBehavior` with `ErrConcurrentClassicBehavior` so a stateful, sequentially-assumed actor can never be silently fanned out. - `Tell` with a `DurableActor` persists the message before returning (crash-safe enqueue). - Outbox messages are dispatched only after state is persisted (outbox pattern). - `ServiceKey` lookup via `Receptionist` is type-safe: mismatched types return `ErrServiceKeyTypeMismatch`. diff --git a/db/AGENTS.md b/db/AGENTS.md index 28275a5ff..da4d2b24b 100644 --- a/db/AGENTS.md +++ b/db/AGENTS.md @@ -48,8 +48,12 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/db.`. -- `LedgerActor` — durable actor processing accounting messages. Caches - the resolved `clock.Clock` at construction so handlers stamp - `CreatedAt` without re-optioning the field. +- `LedgerActor` — durable actor processing accounting messages. Runs on the + durable Read/Commit (`TxBehavior`) path: each handler books its ledger legs + inside one short, lease-fenced Commit transaction rather than holding a + writer tx across the whole `Receive`. The `bindStores` factory injects a + `ledgerTx` (typed store pair) bound to each Commit transaction. Caches the + resolved `clock.Clock` at construction so handlers stamp `CreatedAt` without + re-optioning the field. - `ActorConfig` — logger, delivery store, ledger store, UTXO audit store, actor ID, optional `Clock` (`fn.Option[clock.Clock]`); None falls back to `clock.NewDefaultClock()`. diff --git a/ledger/CLAUDE.md b/ledger/CLAUDE.md index a92dfe2ca..303569e6a 100644 --- a/ledger/CLAUDE.md +++ b/ledger/CLAUDE.md @@ -35,9 +35,13 @@ For per-flow walkthroughs see For field-level detail, use `go doc github.com/lightninglabs/darepo-client/ledger.`. -- `LedgerActor` — durable actor processing accounting messages. Caches - the resolved `clock.Clock` at construction so handlers stamp - `CreatedAt` without re-optioning the field. +- `LedgerActor` — durable actor processing accounting messages. Runs on the + durable Read/Commit (`TxBehavior`) path: each handler books its ledger legs + inside one short, lease-fenced Commit transaction rather than holding a + writer tx across the whole `Receive`. The `bindStores` factory injects a + `ledgerTx` (typed store pair) bound to each Commit transaction. Caches the + resolved `clock.Clock` at construction so handlers stamp `CreatedAt` without + re-optioning the field. - `ActorConfig` — logger, delivery store, ledger store, UTXO audit store, actor ID, optional `Clock` (`fn.Option[clock.Clock]`); None falls back to `clock.NewDefaultClock()`. diff --git a/oor/AGENTS.md b/oor/AGENTS.md index cb8c2383c..adb3d1da5 100644 --- a/oor/AGENTS.md +++ b/oor/AGENTS.md @@ -29,6 +29,12 @@ State transitions and validation rules live under [Invariants](#invariants). materialized — lets daemon subsystems arm work without depending on `oor`), `SigningEffect` (route signing through a separate actor), `Limits *ReceiveLimits` (defaults via `DefaultReceiveLimits`). +- `ReservationStore` — Minimal persistence contract for durable spending + reservations. `UpsertReservation(ctx, outpoint, ownerKind, ownerID)` is + called once a new outgoing OOR session is checkpointed, so the startup VTXO + sweep can tell in-flight spends from orphaned ones. + `ReservationOwnerKindOOROutgoing = 0` is the owner-kind value recorded for + outgoing OOR sessions. - `OORClientActor` — durable actor wrapping per-session state machines. Handles outgoing and incoming flows via three-phase async resolution; emits `VTXOSentMsg` / `VTXOReceivedMsg` to ledger at the two state @@ -130,6 +136,11 @@ State transitions and validation rules live under [Invariants](#invariants). `completed`, `failed`). - `IncomingSnapshot`, `IncomingPhase` (`resolve_pending`, `materialize_pending`, `ack_pending`, `completed`, `failed`). + `IncomingSnapshot.MetadataAttempts uint32` — persisted retry count for + authoritative metadata resolution (phase-2 indexer query). Drives bounded + exponential backoff and terminal give-up in `handleReceiveOutboxError` + across restarts so a session whose VTXO never lands in the indexer stops + re-querying forever. Serialized as TLV record 19. - `TransferInputSnapshot` — portable encoding of client-side signing context required to finalize checkpoint PSBTs after restart. - `IncomingVTXOMetadata` — lineage metadata for incoming OOR VTXOs diff --git a/oor/CLAUDE.md b/oor/CLAUDE.md index cb8c2383c..adb3d1da5 100644 --- a/oor/CLAUDE.md +++ b/oor/CLAUDE.md @@ -29,6 +29,12 @@ State transitions and validation rules live under [Invariants](#invariants). materialized — lets daemon subsystems arm work without depending on `oor`), `SigningEffect` (route signing through a separate actor), `Limits *ReceiveLimits` (defaults via `DefaultReceiveLimits`). +- `ReservationStore` — Minimal persistence contract for durable spending + reservations. `UpsertReservation(ctx, outpoint, ownerKind, ownerID)` is + called once a new outgoing OOR session is checkpointed, so the startup VTXO + sweep can tell in-flight spends from orphaned ones. + `ReservationOwnerKindOOROutgoing = 0` is the owner-kind value recorded for + outgoing OOR sessions. - `OORClientActor` — durable actor wrapping per-session state machines. Handles outgoing and incoming flows via three-phase async resolution; emits `VTXOSentMsg` / `VTXOReceivedMsg` to ledger at the two state @@ -130,6 +136,11 @@ State transitions and validation rules live under [Invariants](#invariants). `completed`, `failed`). - `IncomingSnapshot`, `IncomingPhase` (`resolve_pending`, `materialize_pending`, `ack_pending`, `completed`, `failed`). + `IncomingSnapshot.MetadataAttempts uint32` — persisted retry count for + authoritative metadata resolution (phase-2 indexer query). Drives bounded + exponential backoff and terminal give-up in `handleReceiveOutboxError` + across restarts so a session whose VTXO never lands in the indexer stops + re-querying forever. Serialized as TLV record 19. - `TransferInputSnapshot` — portable encoding of client-side signing context required to finalize checkpoint PSBTs after restart. - `IncomingVTXOMetadata` — lineage metadata for incoming OOR VTXOs diff --git a/p-models/durableactor/AGENTS.md b/p-models/durableactor/AGENTS.md index 986c3dc3d..66a587b63 100644 --- a/p-models/durableactor/AGENTS.md +++ b/p-models/durableactor/AGENTS.md @@ -2,8 +2,9 @@ This package models the durable actor mailbox from distributed-systems first principles: durable enqueue, lease ownership, retry scheduling, ack/nack token -validation, dead-letter/removal, idempotent delivery identity, and -per-correlation-key FIFO. +validation, dead-letter/removal, idempotent delivery identity, +per-correlation-key FIFO, and the Read/Commit consume step (lease-fenced +exactly-once effect application under lease-expiry-during-IO). ## Files @@ -22,7 +23,12 @@ per-correlation-key FIFO. | `./p-models/scripts/check.sh` | Full default check: P model plus Go bridge | | `p compile -pp p-models/durableactor/infra.pproj` | Compile this model | | `p check PGenerated/PChecker/net8.0/MailboxInfraModels.dll --testcase tcMailboxCorrelationKeyFIFO` | Run green durable mailbox tests | +| `p check PGenerated/PChecker/net8.0/MailboxInfraModels.dll --testcase tcMailboxReadCommitFence` | Run the green Read/Commit exactly-once-effect test | | `p check PGenerated/PChecker/net8.0/MailboxInfraModels.dll --testcase tcMailboxLegacyReorderCounterexample` | Demonstrate the old ordering bug | +| `p check PGenerated/PChecker/net8.0/MailboxInfraModels.dll --testcase tcMailboxUnfencedCommitCounterexample` | Demonstrate the unfenced-commit double-apply bug | +| `p check PGenerated/PChecker/net8.0/MailboxInfraModels.dll --testcase tcMailboxStageCommitExactlyOnce` | Run the green Stage-then-Commit replay-safety test | +| `p check PGenerated/PChecker/net8.0/MailboxInfraModels.dll --testcase tcMailboxStagedDoubleBroadcastCounterexample` | Demonstrate the unstable-broadcast double-broadcast bug | +| `p check PGenerated/PChecker/net8.0/MailboxInfraModels.dll --testcase tcMailboxStaleStageRegressesCounterexample` | Demonstrate the unfenced-stage checkpoint regression bug | | `go test ./p-models/durableactor/bridge` | Replay traces against Go | ## Modeling Guidance diff --git a/p-models/durableactor/bridge/AGENTS.md b/p-models/durableactor/bridge/AGENTS.md new file mode 100644 index 000000000..c0e708e5a --- /dev/null +++ b/p-models/durableactor/bridge/AGENTS.md @@ -0,0 +1,55 @@ +# p-models/durableactor/bridge + +## Purpose + +Go conformance harness that replays P model mailbox traces against the real +`db/actordelivery` SQLite store. Keeps the formal P model abstraction tied to +the SQL claim implementation: every P scenario in `mailbox_fifo_test.p` +that produces a trace is replayed here using production store code, so a +divergence between the model and the implementation fails the Go test rather +than the P checker. + +## Key Types + +- `MailboxTrace` — A named sequence of mailbox operations loaded from a JSON + trace file (`trace_id`, `description`, `events`). +- `MailboxTraceEvent` — One store operation in a trace: `op` (enqueue/lease/ + nack/ack/commit/dead_letter/expire_leases), plus op-specific fields for id, + mailbox_id, lease_token, expected outcome, etc. `ExpectDuplicate` asserts + idempotent no-op enqueue semantics. `ExpectProcessed` verifies the dedup + mark after a fenced commit. +- `ParseMailboxTrace(path)` — Parses one trace file from disk. +- `ParseMailboxTraceDir(dir)` — Parses all `*.json` trace files in a directory, + sorted by `TraceID`. +- `ReplayMailboxTrace(t, trace)` — Replays a trace against a fresh SQLite + `actordelivery` store in a temp dir. The `commit` op models the Read/Commit + fenced-ack pattern exactly: it runs `AckMessage` + `MarkProcessed` inside one + writer transaction, rolling back with `actor.ErrLeaseLost` when the ack row + count is zero. + +## Relationships + +- **Depends on**: `db/actordelivery` (real store under test), `baselib/actor` + (store interfaces, `ErrLeaseLost`), `db/sqlc` (backend type constants). +- **Depended on by**: nothing (test-only package, invoked via + `go test ./p-models/durableactor/bridge`). + +## Invariants + +- Every trace op that can fail hard uses `t.Fatal`; partial replays are not + allowed to proceed silently. +- The `commit` op is the sole site where `ExecTx`/`AckMessage`/`MarkProcessed` + are combined — it deliberately mirrors `execCore.commit` in `baselib/actor` + so the P model's commit-fence scenario stays tied to the real SQL path. +- Duplicate enqueue ops (`ExpectDuplicate: true`) must complete without error; + a future rejection would fail here explicitly rather than at a later lease + step. + +## Deep Docs + +- [p-models/durableactor/CLAUDE.md](../CLAUDE.md) — P model structure, trace + layout, and check commands. +- [p-models/CLAUDE.md](../../CLAUDE.md) — Top-level p-models layout and + orchestration. +- [docs/durable_actor_architecture.md](../../../docs/durable_actor_architecture.md) + — Durable actor internals. diff --git a/p-models/durableactor/bridge/CLAUDE.md b/p-models/durableactor/bridge/CLAUDE.md new file mode 100644 index 000000000..c0e708e5a --- /dev/null +++ b/p-models/durableactor/bridge/CLAUDE.md @@ -0,0 +1,55 @@ +# p-models/durableactor/bridge + +## Purpose + +Go conformance harness that replays P model mailbox traces against the real +`db/actordelivery` SQLite store. Keeps the formal P model abstraction tied to +the SQL claim implementation: every P scenario in `mailbox_fifo_test.p` +that produces a trace is replayed here using production store code, so a +divergence between the model and the implementation fails the Go test rather +than the P checker. + +## Key Types + +- `MailboxTrace` — A named sequence of mailbox operations loaded from a JSON + trace file (`trace_id`, `description`, `events`). +- `MailboxTraceEvent` — One store operation in a trace: `op` (enqueue/lease/ + nack/ack/commit/dead_letter/expire_leases), plus op-specific fields for id, + mailbox_id, lease_token, expected outcome, etc. `ExpectDuplicate` asserts + idempotent no-op enqueue semantics. `ExpectProcessed` verifies the dedup + mark after a fenced commit. +- `ParseMailboxTrace(path)` — Parses one trace file from disk. +- `ParseMailboxTraceDir(dir)` — Parses all `*.json` trace files in a directory, + sorted by `TraceID`. +- `ReplayMailboxTrace(t, trace)` — Replays a trace against a fresh SQLite + `actordelivery` store in a temp dir. The `commit` op models the Read/Commit + fenced-ack pattern exactly: it runs `AckMessage` + `MarkProcessed` inside one + writer transaction, rolling back with `actor.ErrLeaseLost` when the ack row + count is zero. + +## Relationships + +- **Depends on**: `db/actordelivery` (real store under test), `baselib/actor` + (store interfaces, `ErrLeaseLost`), `db/sqlc` (backend type constants). +- **Depended on by**: nothing (test-only package, invoked via + `go test ./p-models/durableactor/bridge`). + +## Invariants + +- Every trace op that can fail hard uses `t.Fatal`; partial replays are not + allowed to proceed silently. +- The `commit` op is the sole site where `ExecTx`/`AckMessage`/`MarkProcessed` + are combined — it deliberately mirrors `execCore.commit` in `baselib/actor` + so the P model's commit-fence scenario stays tied to the real SQL path. +- Duplicate enqueue ops (`ExpectDuplicate: true`) must complete without error; + a future rejection would fail here explicitly rather than at a later lease + step. + +## Deep Docs + +- [p-models/durableactor/CLAUDE.md](../CLAUDE.md) — P model structure, trace + layout, and check commands. +- [p-models/CLAUDE.md](../../CLAUDE.md) — Top-level p-models layout and + orchestration. +- [docs/durable_actor_architecture.md](../../../docs/durable_actor_architecture.md) + — Durable actor internals. diff --git a/round/AGENTS.md b/round/AGENTS.md index f1c5d32b9..d055c8b8f 100644 --- a/round/AGENTS.md +++ b/round/AGENTS.md @@ -109,6 +109,11 @@ state transitions and validation rules live under [Invariants](#invariants). - `RoundClientConfig.LedgerSink` — optional `fn.Option[ledger.Sink]` plumbed onto the round actor; `emitVTXOsReceived` and `emitRoundFee` fire-and-forget messages when `fn.Some`. +- `RoundClientConfig.RegistrationTimeout` — max wall-clock duration to wait in + `IntentSentState` for the server's `RoundJoined` admission watermark. Zero + selects `defaultRegistrationTimeout` (60 s); negative disables the timeout + (round waits indefinitely). Bounds how long forfeit-reserved inputs sit + stranded when the server never responds (darepo-client#653). - `computeClientOperatorFee(intents, ownedVTXOs) int64` — Σ(boarding inputs) + Σ(forfeited VTXOs) − Σ(owned output VTXOs) − Σ(cooperative leave outputs), clamped to zero. Carried on diff --git a/round/CLAUDE.md b/round/CLAUDE.md index f1c5d32b9..d055c8b8f 100644 --- a/round/CLAUDE.md +++ b/round/CLAUDE.md @@ -109,6 +109,11 @@ state transitions and validation rules live under [Invariants](#invariants). - `RoundClientConfig.LedgerSink` — optional `fn.Option[ledger.Sink]` plumbed onto the round actor; `emitVTXOsReceived` and `emitRoundFee` fire-and-forget messages when `fn.Some`. +- `RoundClientConfig.RegistrationTimeout` — max wall-clock duration to wait in + `IntentSentState` for the server's `RoundJoined` admission watermark. Zero + selects `defaultRegistrationTimeout` (60 s); negative disables the timeout + (round waits indefinitely). Bounds how long forfeit-reserved inputs sit + stranded when the server never responds (darepo-client#653). - `computeClientOperatorFee(intents, ownedVTXOs) int64` — Σ(boarding inputs) + Σ(forfeited VTXOs) − Σ(owned output VTXOs) − Σ(cooperative leave outputs), clamped to zero. Carried on diff --git a/scripts/check-sample-darepod-conf/AGENTS.md b/scripts/check-sample-darepod-conf/AGENTS.md new file mode 100644 index 000000000..2acbcc4ff --- /dev/null +++ b/scripts/check-sample-darepod-conf/AGENTS.md @@ -0,0 +1,26 @@ +# scripts/check-sample-darepod-conf + +## Purpose + +CI validation tool that verifies `sample-darepod.conf` documents every +config option exposed by the daemon. It runs `darepod --help` to collect +the canonical flag list, then checks that each non-skipped flag appears at +least once in the sample conf file. Fails with a diff-style report when a +flag is undocumented. + +## Relationships + +- **Depends on**: `darepod` (for the flag surface via `--help`). +- **Depended on by**: Makefile / CI (invoked by `make doc-check` or equivalent + sample-conf check target). + +## Invariants + +- Flags in `skippedFlags` (`configfile`, `help`, `version`) are excluded from + the check — they are tool flags with no meaningful conf-file representation. +- The tool reads `defaultConfFile` (`sample-darepod.conf`) from the repo root + and `mainFile` (`cmd/darepod/main.go`) to locate the daemon entry point. + +## Deep Docs + +- [scripts/CLAUDE.md](../CLAUDE.md) — Parent scripts package overview. diff --git a/scripts/check-sample-darepod-conf/CLAUDE.md b/scripts/check-sample-darepod-conf/CLAUDE.md new file mode 100644 index 000000000..2acbcc4ff --- /dev/null +++ b/scripts/check-sample-darepod-conf/CLAUDE.md @@ -0,0 +1,26 @@ +# scripts/check-sample-darepod-conf + +## Purpose + +CI validation tool that verifies `sample-darepod.conf` documents every +config option exposed by the daemon. It runs `darepod --help` to collect +the canonical flag list, then checks that each non-skipped flag appears at +least once in the sample conf file. Fails with a diff-style report when a +flag is undocumented. + +## Relationships + +- **Depends on**: `darepod` (for the flag surface via `--help`). +- **Depended on by**: Makefile / CI (invoked by `make doc-check` or equivalent + sample-conf check target). + +## Invariants + +- Flags in `skippedFlags` (`configfile`, `help`, `version`) are excluded from + the check — they are tool flags with no meaningful conf-file representation. +- The tool reads `defaultConfFile` (`sample-darepod.conf`) from the repo root + and `mainFile` (`cmd/darepod/main.go`) to locate the daemon entry point. + +## Deep Docs + +- [scripts/CLAUDE.md](../CLAUDE.md) — Parent scripts package overview. diff --git a/serverconn/AGENTS.md b/serverconn/AGENTS.md index 54fc545e8..365942130 100644 --- a/serverconn/AGENTS.md +++ b/serverconn/AGENTS.md @@ -8,10 +8,10 @@ background ingress polling with event routing. ## Key Types -- `Runtime` — Main entry point wrapping DurableActor, ServerConnectionActor, and UnaryFacade. +- `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 calls `Edge.Send` with NO SQLite writer held, then a short lease-fenced Commit folds the ack + dedup. It runs as a competing-consumer pool of `ConnectorConfig.EgressWorkers` worker 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. Dispatches `DurableUnaryQuery` values generically via `buildDurableUnary`. - `UnaryFacade` — Implements `mailboxrpc.RPCClient` for generated RPC stubs (low-latency path). Also provides `AwaitRPCTimeout` for bounded waits. -- `ConnectorConfig` — Wiring configuration (edge address, mailbox IDs, dispatchers, store, durable unary builder). The `DurableUnaryBuilder` field must be set to handle `DurableUnaryQuery` message types; otherwise those messages are rejected. The `AuthSignature` field holds the Schnorr auth sig injected into every outbound envelope via `mergeAuthHeaders` (auth header always wins over caller-provided headers). +- `ConnectorConfig` — Wiring configuration (edge address, mailbox IDs, dispatchers, store, durable unary builder, `EgressWorkers`). `EgressWorkers` sizes the egress worker pool (default `DefaultEgressWorkers` = 4); `<= 1` keeps the legacy single sender. The `DurableUnaryBuilder` field must be set to handle `DurableUnaryQuery` message types; otherwise those messages are rejected. The `AuthSignature` field holds the Schnorr auth sig injected into every outbound envelope via `mergeAuthHeaders` (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. Uses `chainhash.TaggedHash` with the `MailboxAuthTagStr` domain separator. - `SignMailboxAuth` / `VerifyMailboxAuth` / `ParseMailboxPubKey` — Schnorr sign/verify helpers for pubkey-derived mailbox identity. @@ -53,6 +53,7 @@ background ingress polling with event routing. - 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. - `SendClientEventRequest` auto-derives `Service`/`Method` from `Message.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.Send` is 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 stable `MsgId`/`IdempotencyKey`. Under `EgressWorkers > 1` a `SendClientEventRequest` carries the inner message's `CorrelationKey`, so same-session events keep per-key FIFO order across the worker pool while distinct sessions send in parallel. `SendUnaryRequest` and `SendRPCRequest` are intentionally **unkeyed** (the `BaseMessage` default), 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 a `CorrelationKey`, or it will silently reorder under the pool. - Ingress loop checkpoints pull cursor and ack state; on restart, resumes from checkpoint. - `DurableUnaryQuery` values are handled generically in `ServerConnectionActor.Receive` via `buildDurableUnary`: the query is converted to a `SendUnaryRequest` using the configured `DurableUnaryRequestBuilder`. Adding a new durable indexer query type requires only implementing `DurableUnaryQuery` — no new `Receive` case is needed. - `DurableUnaryQuery` implementations must produce stable identity bytes in `BuildBody` so that `MsgID` and `IdempotencyKey` are deterministic across restarts (auto-derived via `mailboxconn.StableEventMsgID` / `StableEventIdempotencyKey` when the caller leaves them empty). diff --git a/serverconn/mailboxpull/AGENTS.md b/serverconn/mailboxpull/AGENTS.md new file mode 100644 index 000000000..c1ba7d679 --- /dev/null +++ b/serverconn/mailboxpull/AGENTS.md @@ -0,0 +1,56 @@ +# serverconn/mailboxpull + +## Purpose + +Shared exponential-backoff retry primitives for mailbox pull loops. Both +the persistent identity-mailbox ingress loop in `serverconn` and per-swap +event consumers in the SDK need the same retry shape; this package factors +that shape out so reliability semantics stay uniform across both consumers. + +## Key Types + +- `BackoffConfig` — Controls exponential backoff with jitter. `BaseDelay` + (non-positive → 200 ms default) and `MaxDelay` (non-positive → 30 s default). + The zero value selects package defaults so callers that do not care about + tuning can pass `BackoffConfig{}`. +- `DefaultBackoffConfig()` — Returns the production defaults (200 ms base, + 30 s cap) matching `serverconn.DefaultConnectorConfig` so daemon and SDK + pull loops back off identically when the shared mailbox endpoint is flapping. +- `RetryDelay(cfg, attempt) time.Duration` — Exponential backoff with jitter: + `min(base × 2^(attempt-1), max) × U[0.5, 1.0)`. Non-cryptographic randomness + (security-insensitive timing). +- `Sleep(ctx, cfg, *attempt)` — Increments the caller-owned attempt counter and + sleeps for the next backoff interval, respecting context cancellation. Sharing + a single counter across pull cycles grows the delay on successive failures + and resets it implicitly when the caller resets the counter on success. +- `PullWithRetry(ctx, edge, req, cfg, log)` — Calls `edge.Pull`, retrying + transport errors with exponential backoff until success or ctx cancellation. + Returns ctx error (not the transport error) on cancellation so callers + distinguish "caller gave up" from "endpoint is flapping". Status-level + failures (`resp.Status.Ok == false`) are returned as-is; only transport + errors trigger retry. + +## Relationships + +- **Depends on**: `mailbox/pb` (`MailboxServiceClient`, `PullRequest`, + `PullResponse`). +- **Depended on by**: `serverconn` (identity-mailbox ingress loop), `sdk/swaps` + (per-swap event consumer pull loops). + +## Invariants + +- Backoff parameters default to the same values as + `serverconn.DefaultConnectorConfig` so daemon and SDK pull loops back off + identically under flapping mailbox endpoints. +- Context cancellation surfaces as the context error, not the underlying + transport error — callers test `errors.Is(err, context.Canceled)` rather + than inspecting gRPC status codes. +- The attempt counter is caller-owned so a single counter can span multiple + consecutive pull cycles (successive failures grow the delay; a successful + cycle allows the caller to reset it). + +## Deep Docs + +- [serverconn/CLAUDE.md](../CLAUDE.md) — Parent package: identity-mailbox + ingress loop that uses this package. +- [ARCHITECTURE.md](../../ARCHITECTURE.md) — System-wide package map. diff --git a/serverconn/mailboxpull/CLAUDE.md b/serverconn/mailboxpull/CLAUDE.md new file mode 100644 index 000000000..c1ba7d679 --- /dev/null +++ b/serverconn/mailboxpull/CLAUDE.md @@ -0,0 +1,56 @@ +# serverconn/mailboxpull + +## Purpose + +Shared exponential-backoff retry primitives for mailbox pull loops. Both +the persistent identity-mailbox ingress loop in `serverconn` and per-swap +event consumers in the SDK need the same retry shape; this package factors +that shape out so reliability semantics stay uniform across both consumers. + +## Key Types + +- `BackoffConfig` — Controls exponential backoff with jitter. `BaseDelay` + (non-positive → 200 ms default) and `MaxDelay` (non-positive → 30 s default). + The zero value selects package defaults so callers that do not care about + tuning can pass `BackoffConfig{}`. +- `DefaultBackoffConfig()` — Returns the production defaults (200 ms base, + 30 s cap) matching `serverconn.DefaultConnectorConfig` so daemon and SDK + pull loops back off identically when the shared mailbox endpoint is flapping. +- `RetryDelay(cfg, attempt) time.Duration` — Exponential backoff with jitter: + `min(base × 2^(attempt-1), max) × U[0.5, 1.0)`. Non-cryptographic randomness + (security-insensitive timing). +- `Sleep(ctx, cfg, *attempt)` — Increments the caller-owned attempt counter and + sleeps for the next backoff interval, respecting context cancellation. Sharing + a single counter across pull cycles grows the delay on successive failures + and resets it implicitly when the caller resets the counter on success. +- `PullWithRetry(ctx, edge, req, cfg, log)` — Calls `edge.Pull`, retrying + transport errors with exponential backoff until success or ctx cancellation. + Returns ctx error (not the transport error) on cancellation so callers + distinguish "caller gave up" from "endpoint is flapping". Status-level + failures (`resp.Status.Ok == false`) are returned as-is; only transport + errors trigger retry. + +## Relationships + +- **Depends on**: `mailbox/pb` (`MailboxServiceClient`, `PullRequest`, + `PullResponse`). +- **Depended on by**: `serverconn` (identity-mailbox ingress loop), `sdk/swaps` + (per-swap event consumer pull loops). + +## Invariants + +- Backoff parameters default to the same values as + `serverconn.DefaultConnectorConfig` so daemon and SDK pull loops back off + identically under flapping mailbox endpoints. +- Context cancellation surfaces as the context error, not the underlying + transport error — callers test `errors.Is(err, context.Canceled)` rather + than inspecting gRPC status codes. +- The attempt counter is caller-owned so a single counter can span multiple + consecutive pull cycles (successive failures grow the delay; a successful + cycle allows the caller to reset it). + +## Deep Docs + +- [serverconn/CLAUDE.md](../CLAUDE.md) — Parent package: identity-mailbox + ingress loop that uses this package. +- [ARCHITECTURE.md](../../ARCHITECTURE.md) — System-wide package map. diff --git a/txconfirm/AGENTS.md b/txconfirm/AGENTS.md index cd4845f2f..6af44f7e9 100644 --- a/txconfirm/AGENTS.md +++ b/txconfirm/AGENTS.md @@ -46,9 +46,15 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/txcon - `TxConfirmed` / `TxFailed` — terminal `Notification` types delivered to each subscriber. - `TxState` — `New`, `Broadcasting`, `AwaitingConfirmation`, - `FeeBumping`, `Confirmed`, `Failed`. + `FeeBumping`, `Confirmed`, `Failed`. `Broadcasting` covers BOTH the + initial attempt and the "reached no mempool, retrying" case; + `AwaitingConfirmation` is reported only once the tx (or a redundant + parent) is actually in a mempool. - Sentinels: `ErrNonTRUCParent`, `ErrCPFPFeeInputUnavailable`, `ErrEnsureParamsMismatch`, `ErrFeeInputProducesDust`. +- `Config.BroadcastFailureAlertThreshold` — consecutive no-mempool + failures before the operator escalation fires (default 3). Time to + first alert ≈ threshold × `FeeBumpIntervalBlocks` blocks. ## Relationships @@ -73,6 +79,19 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/txcon ## Invariants +- **Never give up on a no-mempool tx**: a tx whose broadcast reached no + mempool stays in `Broadcasting` and is re-attempted every + `FeeBumpIntervalBlocks`, never transitioning to terminal `Failed`. This + covers `ErrCPFPFeeInputUnavailable` and transient package-relay + rejections (min-relay-fee on the zero-fee anchor parent, mempool-full, + fee input spent mid-submit) — the conditions CPFP retry exists to + overcome. Only a structurally permanent error + (`isPermanentBroadcastError`, currently `ErrNonTRUCParent`) fails + terminally; `ErrParentAlreadyBroadcast` advances to + `AwaitingConfirmation` (a live parent exists on another path). Rationale: + a fraud-response checkpoint must land before the counterparty's + CSV-timeout path, so the actor escalates to operators rather than + silently aborting. - **Strict dedup check**: two `EnsureConfirmedReq` for the same txid must agree on `TargetConfs` and `ConfirmationPkScript`; mismatches return `ErrEnsureParamsMismatch` rather than silently reusing the diff --git a/unroll/AGENTS.md b/unroll/AGENTS.md index 953bb5b77..18b7dc0ef 100644 --- a/unroll/AGENTS.md +++ b/unroll/AGENTS.md @@ -17,17 +17,24 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/unrol ### Per-target actor - `VTXOUnrollActor` — one durable actor per target outpoint, wrapping - `baselib/actor.DurableActor[Msg, Resp]`. Owns the FSM session, - proof, planner, and cached sweep transaction for this VTXO. + `baselib/actor.DurableActor[Msg, Resp]`. Owns the FSM session, proof, + planner, and cached sweep transaction for this VTXO. Runs on the durable + Read/Commit (`TxBehavior`) path: each checkpoint write is a short, + lock-releasing Stage ahead of the `txconfirm` IO, and the message is + consumed in a single lease-fenced Commit so the SQLite writer is never held + across a cross-actor Ask. - `Config` — per-actor wiring. Notable: `TargetOutpoint`, `ActorID`, `DeliveryStore`, `ProofAssembler`, `VTXOStore`, `TxConfirmRef`, `ChainSource`, `Wallet` (`SweepWallet`), `MaxSweepFeeRateSatPerVByte`, `FraudCheckpointSafetyMargin int32` (overrides the fraud-triggered unroll backstop margin in blocks; zero falls back to the default), `RegistryRef`. -- `behavior` — actor behavior. Holds `b.sweepTx` (restored from - checkpoint on boot) so retries and replays converge on a single - sweep txid / pkScript under `txconfirm`'s txid-keyed dedup. +- `behavior` — actor behavior implementing `actor.TxBehavior[Msg, Resp, + unrollTx]`. Holds `b.sweepTx` (restored from checkpoint on boot) so + retries and replays converge on a single sweep txid / pkScript under + `txconfirm`'s txid-keyed dedup. The `dispatch` method runs the full + FSM pipeline including Stage writes; `Receive` owns the single + lease-fenced Commit. - `Msg` / `Resp` / `Event` / `OutboxEvent` — sealed durable-mailbox, response, FSM event, and FSM outbox interfaces. - Mailbox messages: `StartUnrollRequest`, `ResumeUnrollRequest`, @@ -78,9 +85,15 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/unrol `validateExitPolicyIdentity` checks consistency at admit time. Dedup runs against `r.active`, `r.pending`, AND `Store.GetRecord` so a repeat after termination returns `Created=false` with the historical - `ActorID`, never clobbering the sweep txid / failure reason. Any - existing unroll job for the same target must carry the same - `(ExitPolicyKind, ExitPolicyRef)`; mismatches fail closed. + `ActorID`, never clobbering the sweep txid / failure reason. The one + exception is a **recoverable** terminal failure + (`RecoverableFailure`): the prior exit failed cleanly with no on-chain + footprint and the VTXO was rolled back to live (darepo-client#602), so + a fresh `EnsureUnrollRequest` re-admits (spawns a new child, + overwriting the stale record) instead of deduping — otherwise a + recovered VTXO could never be unrolled again. Any existing unroll job + for the same target must carry the same `(ExitPolicyKind, + ExitPolicyRef)`; mismatches fail closed. - `ExitSpendPolicyResolver` — interface for looking up the final spend policy by `(ExitPolicyKind, ExitPolicyRef)`. Implemented by `vhtlcrecovery/unrollpolicy.ExitSpendPolicyResolver`. @@ -195,6 +208,10 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/unrol `r.active`, `r.pending`, AND `Store.GetRecord` before spawning so a repeat for an already-terminal outpoint returns the historical `ActorID` and never overwrites stored sweep txid / failure reason. + A **recoverable** terminal failure is the deliberate exception: the + VTXO was rolled back to live (darepo-client#602), so `handleEnsure` + falls through both the `r.pending` and `Store.GetRecord` arms to + re-admit a fresh exit rather than strand the recovered coin. - **Fail-closed on restore gaps.** `handleEnsure` validates restorable non-terminal records via `validateRestorableRecords` before re-admitting them; a record with an unrecognized `ExitPolicyKind` or missing ref fails diff --git a/unroll/CLAUDE.md b/unroll/CLAUDE.md index bf82beebf..18b7dc0ef 100644 --- a/unroll/CLAUDE.md +++ b/unroll/CLAUDE.md @@ -17,17 +17,24 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/unrol ### Per-target actor - `VTXOUnrollActor` — one durable actor per target outpoint, wrapping - `baselib/actor.DurableActor[Msg, Resp]`. Owns the FSM session, - proof, planner, and cached sweep transaction for this VTXO. + `baselib/actor.DurableActor[Msg, Resp]`. Owns the FSM session, proof, + planner, and cached sweep transaction for this VTXO. Runs on the durable + Read/Commit (`TxBehavior`) path: each checkpoint write is a short, + lock-releasing Stage ahead of the `txconfirm` IO, and the message is + consumed in a single lease-fenced Commit so the SQLite writer is never held + across a cross-actor Ask. - `Config` — per-actor wiring. Notable: `TargetOutpoint`, `ActorID`, `DeliveryStore`, `ProofAssembler`, `VTXOStore`, `TxConfirmRef`, `ChainSource`, `Wallet` (`SweepWallet`), `MaxSweepFeeRateSatPerVByte`, `FraudCheckpointSafetyMargin int32` (overrides the fraud-triggered unroll backstop margin in blocks; zero falls back to the default), `RegistryRef`. -- `behavior` — actor behavior. Holds `b.sweepTx` (restored from - checkpoint on boot) so retries and replays converge on a single - sweep txid / pkScript under `txconfirm`'s txid-keyed dedup. +- `behavior` — actor behavior implementing `actor.TxBehavior[Msg, Resp, + unrollTx]`. Holds `b.sweepTx` (restored from checkpoint on boot) so + retries and replays converge on a single sweep txid / pkScript under + `txconfirm`'s txid-keyed dedup. The `dispatch` method runs the full + FSM pipeline including Stage writes; `Receive` owns the single + lease-fenced Commit. - `Msg` / `Resp` / `Event` / `OutboxEvent` — sealed durable-mailbox, response, FSM event, and FSM outbox interfaces. - Mailbox messages: `StartUnrollRequest`, `ResumeUnrollRequest`, diff --git a/vtxo/AGENTS.md b/vtxo/AGENTS.md index 41080bc06..0312652ca 100644 --- a/vtxo/AGENTS.md +++ b/vtxo/AGENTS.md @@ -19,12 +19,23 @@ when the local wallet owns the receive script. - `ManagerConfig` — Configuration holding Store, Wallet, ChainSource, ActorSystem, ChainParams, ExpiryConfig, RoundActor ref, ChainResolver ref, optional `Log`, optional `LedgerSink fn.Option[ledger.Sink]`, - `ForfeitVTXOActorAskTimeout`, and `RefreshFeeQuoter`. The manager - propagates the sink into each spawned `VTXOActor` for `ExitCostMsg` - emissions. `ForfeitVTXOActorAskTimeout` (default 5 s) bounds forfeit - and refresh child asks so a blocked child actor cannot monopolize the - manager until the outer RPC deadline. Zero uses the default; negative - disables the timeout. Spend-path asks keep the caller's context. + `ForfeitVTXOActorAskTimeout`, `RefreshFeeQuoter`, `ExitOutcomeResolver`, and + `ReservationStore`. The manager propagates the sink into each spawned + `VTXOActor` for `ExitCostMsg` emissions. `ForfeitVTXOActorAskTimeout` + (default 5 s) bounds forfeit and refresh child asks so a blocked child actor + cannot monopolize the manager until the outer RPC deadline. Zero uses the + default; negative disables the timeout. Spend-path asks keep the caller's + context. `ExitOutcomeResolver` is called at startup to reconcile VTXOs still + persisted in `VTXOStatusUnilateralExit` with their terminal job outcome. + `ReservationStore` is used at startup to sweep orphaned Spending VTXOs. +- `ExitOutcomeResolution` — Terminal result for an exiting VTXO: `Outcome` + (`ExitOutcomeRecoverable` or `ExitOutcomeConfirmed`) and `Reason`. +- `ExitOutcomeResolver` — Function type + `func(ctx, wire.OutPoint) (fn.Option[ExitOutcomeResolution], error)`. + Returns `None` when the job has no terminal result yet. +- `SpendingReservationStore` — Narrow interface the VTXO manager uses for its + startup orphan sweep: `ListReservedOutpoints(ctx) ([]wire.OutPoint, error)`. + Intentionally small to avoid coupling vtxo to the concrete db type or oor. - `VTXOActorConfig.LedgerSink` — Per-VTXO actor field plumbed from the manager. The `emitExitCost` helper is wired onto the unilateral-exit transition but is currently a no-op pending chain resolver integration: the actor cannot determine the on-chain miner fee until the chain resolver reports the confirmed exit-spend transaction. The emission site exists so a single future change in the chain resolver wiring enables it without touching the FSM transition logic. - `VTXOEvent` — Inbound events (BlockEpochEvent, ForfeitRequest, ForfeitConfirmed, SpendReserveEvent, SpendCompletedEvent, etc.). - `VTXOOutMsg` — Outbound messages (ForfeitRequest, ExpiringNotify, StatusUpdate, Terminated). @@ -42,7 +53,7 @@ when the local wallet owns the receive script. ## Relationships -- **Depends on**: `baselib/protofsm` (FSM engine), `baselib/actor` (actor system), `lib/tree` (tree paths), `lib/arkscript` (taproot construction and policy helpers in `IncomingVTXOHandler`), `lib/actormsg` (admission message types), `arkrpc` (`IncomingVTXOEvent`), `chainsource` (block epochs), `ledger` (`Sink` + `ExitCostMsg` for planned exit cost emission). +- **Depends on**: `baselib/protofsm` (FSM engine), `baselib/actor` (actor system), `lib/tree` (tree paths), `lib/arkscript` (taproot construction and policy helpers in `IncomingVTXOHandler`), `lib/actormsg` (admission message types), `arkrpc` (`IncomingVTXOEvent`), `chainsource` (block epochs), `ledger` (`Sink` + `ExitCostMsg` for planned exit cost emission), `unroll` (via `ExitOutcomeResolver` callback wired by `darepod`). - **Depended on by**: `round` (triggers forfeit requests), `oor` (incoming VTXOs), `wallet` (admission gating), `db` (persistence), `darepod` (wiring, owned-script adapters, incoming event route). - **Sends**: - → `round` (via manager relay): `RelayToRoundMsg` wrapping `ForfeitSignatureSubmission` @@ -54,6 +65,7 @@ when the local wallet owns the receive script. - ← `wallet` (via `lib/actormsg`): `SelectAndReserveSpendRequest`, `ReleaseSpendRequest`, `CompleteSpendRequest`, `ReserveForfeitRequest`, `ReleaseForfeitRequest`, `SelectAndReserveForfeitRequest` - ← `chainsource` (via Manager): `BlockEpochEvent` - ← `serverconn` (via `EventRouter` route `MethodIncomingVTXO`): `IncomingVTXOMsg` (wrapping `arkrpc.IncomingVTXOEvent`), routed to `IncomingVTXOHandler` + - ← `unroll` (via `RegistryConfig.VTXOExitObserver`, forwarded by `darepod`): `ExitOutcomeNotification` — terminal exit job result forwarded to reconcile VTXO lifecycle after an unroll completes or fails cleanly ## Multi-Tree Ancestry @@ -85,6 +97,9 @@ when the local wallet owns the receive script. on the actor turn context). This prevents a slow or blocking chain resolver from stalling the VTXO actor's turn and delays the notification delivery past the FSM transition without affecting the transition outcome. +- **Startup reconcile of unilateral-exit VTXOs.** When `ManagerConfig.ExitOutcomeResolver` is set, `Start` calls `reconcileUnilateralExits` after recovering actors. For each VTXO in `VTXOStatusUnilateralExit`, it resolves the terminal outcome: `ExitOutcomeRecoverable` (no on-chain footprint) rolls the VTXO back to `LiveState` and spawns a fresh actor; `ExitOutcomeConfirmed` retires it to `SpentState`. `None` (job still running) is left untouched. +- **Startup sweep of orphaned Spending VTXOs.** When `ManagerConfig.ReservationStore` is set, `Start` calls `sweepOrphanedReservations` after all actors are recovered. A Spending VTXO with no reservation row in the durable index is provably orphaned (its spend session died before checkpointing) and is released back to `LiveState` via `SpendReleasedEvent`. The sweep aborts entirely if `ListReservedOutpoints` fails to avoid releasing VTXOs an in-flight spend still owns. +- **Atomic reservation cleanup.** `VTXOStore.UpdateVTXOStatusReleasingReservation` deletes the spending-reservation row in the same transaction as the VTXO status change when a VTXO leaves `SpendingState` (via `SpendReleasedEvent`, `SpendCompletedEvent`, or escalation to `UnilateralExitState`). This prevents the durable index from retaining stale rows that would mask a future orphan on the same outpoint. - `ForceUnrollEvent` is accepted in `LiveState`, `PendingForfeitState`, `SpendingState`, and `ForfeitingState`: each transitions to `UnilateralExitState` and emits `ExpiringNotification` + `VTXOStatusUpdate{UnilateralExit}`. It does **not** emit `VTXOTerminatedNotification` on intent — `UnilateralExitState` is **non-terminal** (darepo-client#602), so the actor stays alive to observe the exit. Truly terminal states (`Spent`, `Forfeited`, `Failed`) self-loop; the manager maps that self-loop back to `ForceUnrollResponse{Accepted: false, Reason: "already terminal"}`. A re-unroll of a VTXO already in `UnilateralExitState` self-loops with no outbox; the `Unroll` RPC short-circuits it earlier via the persisted `VTXOStatusUnilateralExit` status. - `UnilateralExitState` is **non-terminal** and observed, not fire-and-forget. The actor survives until the unroll job reports a terminal outcome via the manager's `ExitOutcomeNotification`: `ExitOutcomeRecoverable` (the unroll failed with no on-chain footprint) drives `ExitFailedEvent` → `LiveState` + `VTXOStatusUpdate{Live}`, while `ExitOutcomeConfirmed` (the exit confirmed on-chain) drives `ExitConfirmedEvent` → terminal `SpentState` + `VTXOTerminatedNotification` (the actor is reaped here, gated on a terminal on-chain event rather than the user's intent). When the actor is absent (e.g. a daemon restart, since exiting VTXOs are excluded from `ListLiveVTXOs` recovery) the manager re-materializes a live actor from the persisted descriptor (recover) or persists `VTXOStatusSpent` directly (confirm). - `Manager.handleForceUnroll` uses `Ask` (not `Tell`) so FSM errors and self-loop no-ops surface as structured `ForceUnrollResponse{Accepted, Reason}` instead of a uniform `Accepted:true` that masks work that was never scheduled. diff --git a/vtxo/CLAUDE.md b/vtxo/CLAUDE.md index 41080bc06..0312652ca 100644 --- a/vtxo/CLAUDE.md +++ b/vtxo/CLAUDE.md @@ -19,12 +19,23 @@ when the local wallet owns the receive script. - `ManagerConfig` — Configuration holding Store, Wallet, ChainSource, ActorSystem, ChainParams, ExpiryConfig, RoundActor ref, ChainResolver ref, optional `Log`, optional `LedgerSink fn.Option[ledger.Sink]`, - `ForfeitVTXOActorAskTimeout`, and `RefreshFeeQuoter`. The manager - propagates the sink into each spawned `VTXOActor` for `ExitCostMsg` - emissions. `ForfeitVTXOActorAskTimeout` (default 5 s) bounds forfeit - and refresh child asks so a blocked child actor cannot monopolize the - manager until the outer RPC deadline. Zero uses the default; negative - disables the timeout. Spend-path asks keep the caller's context. + `ForfeitVTXOActorAskTimeout`, `RefreshFeeQuoter`, `ExitOutcomeResolver`, and + `ReservationStore`. The manager propagates the sink into each spawned + `VTXOActor` for `ExitCostMsg` emissions. `ForfeitVTXOActorAskTimeout` + (default 5 s) bounds forfeit and refresh child asks so a blocked child actor + cannot monopolize the manager until the outer RPC deadline. Zero uses the + default; negative disables the timeout. Spend-path asks keep the caller's + context. `ExitOutcomeResolver` is called at startup to reconcile VTXOs still + persisted in `VTXOStatusUnilateralExit` with their terminal job outcome. + `ReservationStore` is used at startup to sweep orphaned Spending VTXOs. +- `ExitOutcomeResolution` — Terminal result for an exiting VTXO: `Outcome` + (`ExitOutcomeRecoverable` or `ExitOutcomeConfirmed`) and `Reason`. +- `ExitOutcomeResolver` — Function type + `func(ctx, wire.OutPoint) (fn.Option[ExitOutcomeResolution], error)`. + Returns `None` when the job has no terminal result yet. +- `SpendingReservationStore` — Narrow interface the VTXO manager uses for its + startup orphan sweep: `ListReservedOutpoints(ctx) ([]wire.OutPoint, error)`. + Intentionally small to avoid coupling vtxo to the concrete db type or oor. - `VTXOActorConfig.LedgerSink` — Per-VTXO actor field plumbed from the manager. The `emitExitCost` helper is wired onto the unilateral-exit transition but is currently a no-op pending chain resolver integration: the actor cannot determine the on-chain miner fee until the chain resolver reports the confirmed exit-spend transaction. The emission site exists so a single future change in the chain resolver wiring enables it without touching the FSM transition logic. - `VTXOEvent` — Inbound events (BlockEpochEvent, ForfeitRequest, ForfeitConfirmed, SpendReserveEvent, SpendCompletedEvent, etc.). - `VTXOOutMsg` — Outbound messages (ForfeitRequest, ExpiringNotify, StatusUpdate, Terminated). @@ -42,7 +53,7 @@ when the local wallet owns the receive script. ## Relationships -- **Depends on**: `baselib/protofsm` (FSM engine), `baselib/actor` (actor system), `lib/tree` (tree paths), `lib/arkscript` (taproot construction and policy helpers in `IncomingVTXOHandler`), `lib/actormsg` (admission message types), `arkrpc` (`IncomingVTXOEvent`), `chainsource` (block epochs), `ledger` (`Sink` + `ExitCostMsg` for planned exit cost emission). +- **Depends on**: `baselib/protofsm` (FSM engine), `baselib/actor` (actor system), `lib/tree` (tree paths), `lib/arkscript` (taproot construction and policy helpers in `IncomingVTXOHandler`), `lib/actormsg` (admission message types), `arkrpc` (`IncomingVTXOEvent`), `chainsource` (block epochs), `ledger` (`Sink` + `ExitCostMsg` for planned exit cost emission), `unroll` (via `ExitOutcomeResolver` callback wired by `darepod`). - **Depended on by**: `round` (triggers forfeit requests), `oor` (incoming VTXOs), `wallet` (admission gating), `db` (persistence), `darepod` (wiring, owned-script adapters, incoming event route). - **Sends**: - → `round` (via manager relay): `RelayToRoundMsg` wrapping `ForfeitSignatureSubmission` @@ -54,6 +65,7 @@ when the local wallet owns the receive script. - ← `wallet` (via `lib/actormsg`): `SelectAndReserveSpendRequest`, `ReleaseSpendRequest`, `CompleteSpendRequest`, `ReserveForfeitRequest`, `ReleaseForfeitRequest`, `SelectAndReserveForfeitRequest` - ← `chainsource` (via Manager): `BlockEpochEvent` - ← `serverconn` (via `EventRouter` route `MethodIncomingVTXO`): `IncomingVTXOMsg` (wrapping `arkrpc.IncomingVTXOEvent`), routed to `IncomingVTXOHandler` + - ← `unroll` (via `RegistryConfig.VTXOExitObserver`, forwarded by `darepod`): `ExitOutcomeNotification` — terminal exit job result forwarded to reconcile VTXO lifecycle after an unroll completes or fails cleanly ## Multi-Tree Ancestry @@ -85,6 +97,9 @@ when the local wallet owns the receive script. on the actor turn context). This prevents a slow or blocking chain resolver from stalling the VTXO actor's turn and delays the notification delivery past the FSM transition without affecting the transition outcome. +- **Startup reconcile of unilateral-exit VTXOs.** When `ManagerConfig.ExitOutcomeResolver` is set, `Start` calls `reconcileUnilateralExits` after recovering actors. For each VTXO in `VTXOStatusUnilateralExit`, it resolves the terminal outcome: `ExitOutcomeRecoverable` (no on-chain footprint) rolls the VTXO back to `LiveState` and spawns a fresh actor; `ExitOutcomeConfirmed` retires it to `SpentState`. `None` (job still running) is left untouched. +- **Startup sweep of orphaned Spending VTXOs.** When `ManagerConfig.ReservationStore` is set, `Start` calls `sweepOrphanedReservations` after all actors are recovered. A Spending VTXO with no reservation row in the durable index is provably orphaned (its spend session died before checkpointing) and is released back to `LiveState` via `SpendReleasedEvent`. The sweep aborts entirely if `ListReservedOutpoints` fails to avoid releasing VTXOs an in-flight spend still owns. +- **Atomic reservation cleanup.** `VTXOStore.UpdateVTXOStatusReleasingReservation` deletes the spending-reservation row in the same transaction as the VTXO status change when a VTXO leaves `SpendingState` (via `SpendReleasedEvent`, `SpendCompletedEvent`, or escalation to `UnilateralExitState`). This prevents the durable index from retaining stale rows that would mask a future orphan on the same outpoint. - `ForceUnrollEvent` is accepted in `LiveState`, `PendingForfeitState`, `SpendingState`, and `ForfeitingState`: each transitions to `UnilateralExitState` and emits `ExpiringNotification` + `VTXOStatusUpdate{UnilateralExit}`. It does **not** emit `VTXOTerminatedNotification` on intent — `UnilateralExitState` is **non-terminal** (darepo-client#602), so the actor stays alive to observe the exit. Truly terminal states (`Spent`, `Forfeited`, `Failed`) self-loop; the manager maps that self-loop back to `ForceUnrollResponse{Accepted: false, Reason: "already terminal"}`. A re-unroll of a VTXO already in `UnilateralExitState` self-loops with no outbox; the `Unroll` RPC short-circuits it earlier via the persisted `VTXOStatusUnilateralExit` status. - `UnilateralExitState` is **non-terminal** and observed, not fire-and-forget. The actor survives until the unroll job reports a terminal outcome via the manager's `ExitOutcomeNotification`: `ExitOutcomeRecoverable` (the unroll failed with no on-chain footprint) drives `ExitFailedEvent` → `LiveState` + `VTXOStatusUpdate{Live}`, while `ExitOutcomeConfirmed` (the exit confirmed on-chain) drives `ExitConfirmedEvent` → terminal `SpentState` + `VTXOTerminatedNotification` (the actor is reaped here, gated on a terminal on-chain event rather than the user's intent). When the actor is absent (e.g. a daemon restart, since exiting VTXOs are excluded from `ListLiveVTXOs` recovery) the manager re-materializes a live actor from the persisted descriptor (recover) or persists `VTXOStatusSpent` directly (confirm). - `Manager.handleForceUnroll` uses `Ask` (not `Tell`) so FSM errors and self-loop no-ops surface as structured `ForceUnrollResponse{Accepted, Reason}` instead of a uniform `Accepted:true` that masks work that was never scheduled.