diff --git a/cmd/waved/taprootassets.go b/cmd/waved/taprootassets.go index 37b76a53c..7e73b4eec 100644 --- a/cmd/waved/taprootassets.go +++ b/cmd/waved/taprootassets.go @@ -73,10 +73,17 @@ func configureTaprootAssets(cfg *waved.Config) { return nil, err } + reservationStore, err := rpcServer.OORReservationStore() + if err != nil { + closeWallet() + + return nil, err + } preparer, err := tapassets.NewPreparer( tapassets.PreparerConfig{ - Wallet: wallet, - Store: store, + Wallet: wallet, + Store: store, + ReservationStore: reservationStore, }, ) if err != nil { diff --git a/db/AGENTS.md b/db/AGENTS.md index 082fc4f07..7831323ed 100644 --- a/db/AGENTS.md +++ b/db/AGENTS.md @@ -88,9 +88,10 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/db. 0 { + if len(row.TaprootAssetRoot) != + chainhash.HashSize { + return fmt.Errorf("invalid selection "+ + "Taproot Asset root length: %d", + len(row.TaprootAssetRoot)) + } + + root := &chainhash.Hash{} + copy(root[:], row.TaprootAssetRoot) + taprootAssetRoot = root + } candidates = append(candidates, vtxo.SelectedVTXO{ Outpoint: wire.OutPoint{ Hash: outpointHash, Index: uint32(row.OutpointIndex), }, - Amount: btcutil.Amount(row.Amount), - PkScript: row.PkScript, + Amount: btcutil.Amount(row.Amount), + PkScript: row.PkScript, + TaprootAssetRoot: taprootAssetRoot, }) } diff --git a/db/vtxo_store_test.go b/db/vtxo_store_test.go index 3ed7ead6c..f9b15e387 100644 --- a/db/vtxo_store_test.go +++ b/db/vtxo_store_test.go @@ -201,9 +201,11 @@ func TestUpdateVTXOStatusReleasingReservation(t *testing.T) { ) ownerID := chainhash.Hash{0x99} + const taprootAssetPreparationOwnerKind = 1 require.NoError( t, reservationStore.UpsertReservation( - ctx, desc.Outpoint, 0, ownerID, + ctx, desc.Outpoint, taprootAssetPreparationOwnerKind, + ownerID, ), ) @@ -211,33 +213,34 @@ func TestUpdateVTXOStatusReleasingReservation(t *testing.T) { require.NoError(t, err) require.Equal(t, []wire.OutPoint{desc.Outpoint}, reserved) - // The combined update flips the status to Spent and drops the row in - // one transaction. + // A known-safe setup failure releases the VTXO to Live and drops the + // preparation-owner row in one transaction. require.NoError( t, vtxoStore.UpdateVTXOStatusReleasingReservation( - ctx, desc.Outpoint, vtxo.VTXOStatusSpent, + ctx, desc.Outpoint, vtxo.VTXOStatusLive, ), ) fetched, err := vtxoStore.GetVTXO(ctx, desc.Outpoint) require.NoError(t, err) - require.Equal(t, vtxo.VTXOStatusSpent, fetched.Status) + require.Equal(t, vtxo.VTXOStatusLive, fetched.Status) reserved, err = reservationStore.ListReservedOutpoints(ctx) require.NoError(t, err) require.Empty(t, reserved) - // Releasing a reservation for an outpoint that has none is a no-op: the - // status still updates and no error surfaces. + // Updating an outpoint whose reservation was already released is a + // no-op for the reservation table: the status still updates without an + // error. require.NoError( t, vtxoStore.UpdateVTXOStatusReleasingReservation( - ctx, desc.Outpoint, vtxo.VTXOStatusLive, + ctx, desc.Outpoint, vtxo.VTXOStatusSpent, ), ) fetched, err = vtxoStore.GetVTXO(ctx, desc.Outpoint) require.NoError(t, err) - require.Equal(t, vtxo.VTXOStatusLive, fetched.Status) + require.Equal(t, vtxo.VTXOStatusSpent, fetched.Status) } // TestVTXOPersistenceStoreSaveAndGet tests the basic save and get operations. @@ -395,6 +398,7 @@ func TestListSelectionCandidatesByStatus(t *testing.T) { require.True(t, ok) require.Equal(t, desc.Amount, candidate.Amount) require.Equal(t, desc.PkScript, candidate.PkScript) + require.Nil(t, candidate.TaprootAssetRoot) } storedAsset, err := vtxoStore.GetVTXO(ctx, descAsset.Outpoint) diff --git a/docs/taproot-assets-carrier-selection-execplan.md b/docs/taproot-assets-carrier-selection-execplan.md new file mode 100644 index 000000000..a67bb5c8e --- /dev/null +++ b/docs/taproot-assets-carrier-selection-execplan.md @@ -0,0 +1,367 @@ +# Reserve asset VTXOs while selecting ordinary Bitcoin carriers + +This ExecPlan is a living document. The sections `Progress`, `Surprises & +Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to +date as work proceeds. This document is maintained in accordance with +`PLANS.md` at the repository root. + +## Purpose / Big Picture + +After this change, a Taproot Asset out-of-round send names the locally managed +asset VTXO it must consume. Wavelength atomically reserves that VTXO and, when +the Bitcoin target requires more satoshis, selects only ordinary Bitcoin VTXOs +as additional carrier inputs. An unrelated asset VTXO can never be selected as +if it were ordinary Bitcoin balance. Before tapd is allowed to commit an asset +transition, Wavelength records a durable owner for every selected input so a +restart cannot mistakenly release inputs whose asset outcome is ambiguous. + +The behavior is observable in focused tests. A manager fixture containing one +required asset VTXO, ordinary Bitcoin VTXOs, and another unrelated asset VTXO +must always return the required asset plus only enough ordinary Bitcoin value. +RPC tests must show the explicit asset outpoint reaches the wallet selection +request, all errors before OOR actor admission unlock known-safe reservations, +and an ambiguous tapd result leaves the preparation reservations quarantined. + +This milestone intentionally retains the current one-asset-input, +one-asset-recipient transaction builder. It establishes selection and recovery +semantics independently. A later custom-transaction milestone will encode +mixed asset and Bitcoin checkpoints and asset change without changing these +wallet-selection rules. + +## Progress + +- [x] (2026-07-21 17:15Z) Audited the current wallet, VTXO manager, database, + OOR RPC, tapassets preparer, and spending-reservation ownership paths. +- [x] (2026-07-21 17:15Z) Wrote and committed this living implementation plan + on `feat/taproot-assets-carrier-selection` stacked above carrier onboarding. +- [x] (2026-07-21 17:41Z) Added required-outpoint selection through wallet and + manager messages, including ordinary-Bitcoin filtering and min-change + behavior. +- [x] (2026-07-21 17:41Z) Added an explicit asset input outpoint to the RPC + intent and routed asset sends through managed wallet selection instead of + the custom-input bypass. +- [x] (2026-07-21 17:41Z) Persisted pre-commit preparation ownership, preserved + ambiguous and post-checkpoint outcomes, and wired one shared reservation + store into the tap-sdk runtime. +- [x] (2026-07-21 17:41Z) Added manager, wallet, database, RPC, preparer, actor, + and restart tests; regenerated sqlc/protobuf output; and passed formatting, + focused tests, race tests, full unit tests, build, and changed-code lint. +- [x] (2026-07-21 17:48Z) Recorded final evidence and staged the implementation + as a separate signed milestone. + +## Surprises & Discoveries + +- Observation: the refreshed integration already stores an optional + `taproot_asset_root` and its production selection SQL excludes non-null + roots, but the lightweight Go projection does not expose the root. + Evidence: `db/sqlc/queries/vtxo.sql` filters + `taproot_asset_root IS NULL`, while `actormsg.SelectedVTXO` carries only + outpoint, amount, and pkScript. The manager therefore cannot defend itself + against a faulty alternate store or test double that returns asset rows. +- Observation: the VTXO manager reserves spend inputs asynchronously, while + the durable OOR reservation rows are currently written only after an OOR + session checkpoints. + Evidence: `vtxo.Manager.selectAndReserveVTXOs` uses detached child asks for + spend selection, and `oor.sessionBehavior.recordReservations` writes owner + kind zero during session admission. Tapd preparation occurs between these + two points. +- Observation: the existing asset RPC requires one `custom_input`, bypassing + the managed wallet selector even though asset VTXOs are persisted wallet + VTXOs. + Evidence: `waved/taprootAssetOORIntent` requires exactly one custom input and + `RPCServer.SendOOR` enters `BuildCustomTransferInputs` whenever any custom + input is supplied. +- Observation: safety changes at the first successful Taproot Asset commit, + not only when tapd returns an explicitly unknown outcome. Once the checkpoint + transition succeeds, a later Ark rejection, journal failure, or local result + validation error cannot safely release the original asset VTXO. + Evidence: the preparer now classifies every error with a checkpoint package + or active commit marker as reconciliation-required; tests cover both the + first unknown response and checkpoint-success/Ark-known-rejection sequence. +- Observation: Docker was unavailable in this worktree, so the canonical + `make sqlc` and `make rpc` wrappers could not connect to the Docker socket. + Evidence: both targets failed before generation. Generated output was instead + produced with the same pinned sqlc 1.29.0, protobuf 3.21.12, Go protobuf + 1.36.11, gRPC 1.5.1, gateway 2.29.0, and repository mailbox plugin versions; + generated headers and diffs match the canonical toolchain. + +## Decision Log + +- Decision: add `RequiredOutpoints []wire.OutPoint` to the wallet and manager + spend-selection requests rather than overloading custom inputs. + Rationale: a required outpoint is still a normal managed VTXO with the + standard actor lifecycle. Custom inputs describe non-standard policies and + deliberately bypass that lifecycle, which is the wrong trust model for an + asset-bearing wallet coin. + Date/Author: 2026-07-21 / Codex. +- Decision: preserve required-outpoint order, then append ordinary Bitcoin + selections in largest-first order. + Rationale: this makes the asset input stable at the graph boundary while + retaining the deterministic selector used by ordinary sends. + Date/Author: 2026-07-21 / Codex. +- Decision: enforce ordinary-Bitcoin eligibility in both SQL and the manager. + Rationale: the SQL filter keeps the hot path small, while the manager-side + `TaprootAssetRoot == nil` check protects alternate stores and tests from + accidentally treating asset commitments as fungible satoshi balance. + Date/Author: 2026-07-21 / Codex. +- Decision: use preparation owner kind one and the preparation request digest + as its owner ID. + Rationale: owner kind zero remains the admitted OOR session. The existing SQL + upsert lets successful actor admission atomically rebind the same outpoints + to the final session owner without another schema migration. + Date/Author: 2026-07-21 / Codex. +- Decision: keep reservations on an unknown tapd commit outcome and unlock on + every known pre-commit failure. Also quarantine every failure after the + checkpoint transition has committed, even if a later Ark rejection is known. + Rationale: releasing an input after tapd may have committed can enable a + conflicting Bitcoin spend. Only an error before the first external commit is + known to have no asset side effect and can restore wallet balance normally. + Date/Author: 2026-07-21 / Codex. + +## Outcomes & Retrospective + +The managed carrier-input substrate is complete. An asset send now names one +wallet-managed VTXO, the manager reserves it first, and optional selection can +add only ordinary Bitcoin VTXOs. Selection validates duplicates, existence, +Live status, amounts, and in-memory ownership; it preserves deterministic +required-first ordering and minimum-change behavior. The public asset RPC no +longer accepts the custom-input bypass. + +Before proof verification or the first tapd call, the tap-sdk preparer writes a +durable preparation owner derived from the request digest. The VTXO manager, +OOR registry, and compiled-in tap-sdk runtime use the same database store. A +known-safe pre-commit error drives the wallet unlock, the VTXO actor back to +Live, and atomic deletion of the preparation row. An unknown first commit or +any failure after a successful checkpoint transition returns `codes.Aborted` +with reconciliation required and leaves both the Spending state and row intact. + +Validation completed with `make fmt-changed`, focused tests across `db`, `oor`, +`tapassets`, `vtxo`, `wallet`, `waverpc`, `waved`, and `cmd/waved`, the same +packages under `go test -race`, the full `make unit` suite (including the +`baselib` submodule), `make build`, and `make lint-changed-local`. All passed. + +This branch deliberately does not yet make a mixed asset-plus-Bitcoin graph +buildable: the sealed asset preparation still requires exactly one input and +one recipient with equal BTC value. The selection API and reservation handoff +are ready for that next stacked transaction-builder branch. A live cross-daemon +Taproot Asset send remains the appropriate Lumos end-to-end acceptance test +once that builder consumes the selected carrier inputs. + +## Context and Orientation + +A virtual transaction output, or VTXO, is a spendable Bitcoin output represented +inside Wavelength. An asset-bearing VTXO commits Taproot Asset state in its +Taproot tree and also carries a satoshi value. Those satoshis are carrier value: +asset units and satoshis remain separate accounting quantities. + +`wallet/messages.go` defines the wallet actor request used by +`waved/RPCServer.SendOOR`. `wallet/wallet.go` forwards that request to +`lib/actormsg/SelectAndReserveSpendRequest`. The shared message lives in +`lib/actormsg/vtxo_admission.go` to avoid a Go import cycle. The VTXO manager in +`vtxo/manager.go` is the only admission gate: it selects candidates and moves +their per-VTXO actors from Live to Spending. + +`db/sqlc/queries/vtxo.sql` is the source of truth for the lightweight candidate +query. Generated files under `db/sqlc` must be produced with `make sqlc`, never +edited by hand. `vtxo.SelectedVTXO`, aliased from `actormsg.SelectedVTXO`, is +the lightweight projection consumed by the manager. Required outpoints are +loaded with `VTXOStore.GetVTXO` because their asset root and lifecycle status +must be checked explicitly. + +`waverpc/daemon.proto` defines the public asset intent. The new input outpoint +is a `txid:vout` string. `waved/rpc_oor_taproot_asset.go` parses it before any +selection or tapd call. `waved/rpc_server.go` passes it in +`RequiredOutpoints`, builds all selected inputs from the local descriptor +store, and uses one cleanup owner for every error between selection and OOR +actor admission. Generated protobuf files come only from `make rpc`. + +`tapassets/preparer.go` is the tap-sdk boundary. Its journal digest already +binds all Bitcoin inputs and the asset proof. Before its first tapd commit it +must call `oor.ReservationStore.UpsertReservation` for every input with owner +kind `oor.ReservationOwnerKindTaprootAssetPreparation` and the digest converted +to a `chainhash.Hash`. The database upsert later lets the admitted OOR actor +replace that preparation owner with owner kind +`oor.ReservationOwnerKindOOROutgoing`. + +## Plan of Work + +First, extend `wallet.SelectAndLockVTXOsRequest` and +`actormsg.SelectAndReserveSpendRequest` with required outpoints and forward an +owned copy through the wallet actor. Extend the lightweight selected VTXO +projection with an optional asset root. Add the root to the SQL projection +while retaining `taproot_asset_root IS NULL`, regenerate sqlc, and make test +stores preserve and filter the root. + +In `vtxo.Manager.selectAndReserveVTXOs`, reject duplicate required outpoints. +Load each required descriptor directly and require Live status, an active +actor or recoverable stored actor, and no current in-memory reservation. Remove +required ordinary outpoints from the optional candidate set and discard every +optional candidate whose asset root is non-null. If required value is below +the target, run largest-first over ordinary candidates for the shortfall with +the original minimum-change rule. If required value already exceeds the target +but leaves sub-minimum change, select enough ordinary value to raise that +residual to the minimum. Reserve the combined required-first list through the +existing rollback-safe actor loop. + +Next, add `input_vtxo_outpoint` to `TaprootAssetOORIntent` in +`waverpc/daemon.proto` and `InputVTXOOutpoint wire.OutPoint` to the SDK-neutral +intent in `oor/taproot_asset_preparer.go`. Asset requests must no longer supply +custom inputs. Parse the explicit outpoint once, route it through +`RequiredOutpoints`, and build all inputs with `BuildTransferInputs`. Preserve +custom input behavior for Bitcoin-only and non-standard-policy sends. Install a +deferred cleanup immediately after managed selection so every validation, +change-building, normalization, preparation, and actor-admission error releases +known-safe inputs. An ambiguous asset preparation error disables that cleanup +and leaves the inputs quarantined. + +Then add `ReservationStore oor.ReservationStore` to +`tapassets.PreparerConfig` and `Preparer`. After validating and loading the +durable state, upsert every request input under preparation owner kind one and +the request digest before proof verification or either tapd commit. Define a +shared SDK-neutral unknown-outcome sentinel in `oor` so waved can distinguish a +safe rejection from a quarantined result without importing tap-sdk. Reuse one +`db.SpendingReservationPersistenceStore` owned by `waved.Server` for the VTXO +manager, OOR actor, and tapassets runtime; expose the narrow store through +`RPCServer` to the compiled-in `cmd/waved` registrar. + +Finally, add focused tests. Manager tests cover required exact fit, required +plus ordinary shortfall, sub-minimum residual top-up, duplicate/missing/non-live +required inputs, an already-reserved required input, and exclusion of unrelated +asset VTXOs. Wallet tests prove field forwarding. Database tests prove the +projection excludes asset rows while preserving the nullable root contract. +RPC tests prove explicit outpoint parsing, custom-input rejection for assets, +required selection, and cleanup on each known pre-actor failure. Preparer tests +prove deterministic reservation ownership, idempotent restart, reservation +write failure before tapd, and ambiguous commit retention. + +## Concrete Steps + +Work from: + + cd /Users/dario/dev/lightninglabs/.worktrees/wavelength-carrier-funding + +After SQL and protobuf source edits, regenerate rather than editing generated +files (or use the exact pinned local tools when Docker is unavailable): + + make sqlc + make rpc + +Format changed handwritten files and run focused tests throughout: + + make fmt-changed + go test ./coinselect ./lib/actormsg ./vtxo ./wallet ./db + go test ./oor ./tapassets ./waved ./cmd/waved ./waverpc + +Before the implementation commit, run: + + make build + make lint-changed-local + make commitmsg-lint range="origin/main..HEAD" + +The plan and implementation are separate signed commits. The plan commit is: + + git commit -S -m 'docs: plan asset carrier input selection' + +The implementation commit uses a multi-package prefix because it spans the +wallet admission and tapassets boundary: + + git commit -S -m 'multi: reserve asset carrier inputs' + +## Validation and Acceptance + +The primary manager acceptance fixture has a required 600-satoshi asset VTXO, +ordinary 500- and 300-satoshi VTXOs, and an unrelated 2,000-satoshi asset VTXO. +For a 1,000-satoshi target with a valid minimum change, selection returns the +required asset and ordinary Bitcoin value; it never returns the unrelated +2,000-satoshi asset despite largest-first ordering. Every returned actor enters +Spending and a forced failure rolls every earlier actor back to Live. + +An asset RPC request carries `input_vtxo_outpoint` and no custom inputs. The +wallet actor observes that outpoint in `RequiredOutpoints`. Reusing the same +idempotency key still returns before selection. A malformed outpoint fails +before selection. A known pre-commit preparer error unlocks all selected inputs, +returns their actors to Live, and atomically deletes the preparation rows. An +unknown commit outcome returns a reconciliation-required error while the VTXOs +remain Spending. Preparer restart tests prove the same request digest +idempotently refreshes the same durable owner without another committed graph; +manager recovery tests prove reservation rows retain Spending VTXOs at startup. + +Run the commands in `Concrete Steps` and expect every package to report `ok`, +`make build` to complete, and changed-code lint to report no findings. The +future Lumos end-to-end test remains outside this branch because the current +sealed asset container still models one asset-bearing checkpoint and one asset +recipient. + +## Idempotence and Recovery + +Required selection is deterministic and does not mutate the caller's slice. +If any required point is invalid, no actor is reserved. If any actor reservation +fails, the existing rollback path releases the already accepted points. The RPC +cleanup uses a context detached from caller cancellation so a disconnected +client does not pin known-safe inputs. + +Preparation reservation upserts are idempotent on outpoint. Repeating the same +request refreshes the same owner kind and digest. Successful OOR actor admission +rebinds the rows to its session ID. Known failures invoke the wallet release; +the VTXO status transition deletes the reservation row in the same database +transaction. Unknown tapd outcomes deliberately retain both Spending status and +the preparation row. Without an upstream status-by-lock/request API, the safe +recovery action is manual reconciliation rather than a competing retry. + +SQL and protobuf generation are deterministic and safe to rerun. If generation +fails, leave source files intact, fix the tool environment, and rerun the same +target; never patch generated descriptors manually. + +## Artifacts and Notes + +The starting branch is: + + b2b3f10f tapassets: wallet fund onboarding carriers + +The existing database already provides the ownership handoff primitive: + + ON CONFLICT (outpoint_hash, outpoint_index) DO UPDATE SET + owner_kind = EXCLUDED.owner_kind, + owner_id = EXCLUDED.owner_id, + created_at = EXCLUDED.created_at; + +This means the feature needs no new migration. Only the selection projection +and generated query types change. + +## Interfaces and Dependencies + +At completion, the wallet and manager requests expose: + + RequiredOutpoints []wire.OutPoint + +The public asset intent exposes: + + string input_vtxo_outpoint + +The SDK-neutral internal intent exposes: + + InputVTXOOutpoint wire.OutPoint + +The reservation owner kinds are: + + const ReservationOwnerKindOOROutgoing = 0 + const ReservationOwnerKindTaprootAssetPreparation = 1 + +`tapassets.PreparerConfig` requires: + + ReservationStore oor.ReservationStore + +No new third-party dependency is required. The only confirmed upstream gap is +a tapd or tap-sdk query that resolves an ambiguous custom-anchor commit by its +deterministic request or lock identity. This milestone remains safe without it +by quarantining the selected inputs. + +Revision note (2026-07-21): created the plan after auditing the carrier +onboarding stack and current mainline reservation behavior. The scope stops at +managed selection and pre-commit recovery so mixed asset change remains a +separate reviewable milestone. + +Revision note (2026-07-21): completed the selection, RPC, reservation handoff, +error quarantine, runtime wiring, generated output, and layered validation. +Expanded the safety rule from explicitly unknown tapd responses to every error +after the first successful asset checkpoint transition. diff --git a/lib/actormsg/vtxo_admission.go b/lib/actormsg/vtxo_admission.go index 63cb89a31..ef5761e32 100644 --- a/lib/actormsg/vtxo_admission.go +++ b/lib/actormsg/vtxo_admission.go @@ -34,6 +34,11 @@ type SelectAndReserveSpendRequest struct { // MinChangeAmount, when positive, asks selection to avoid a // non-zero residual below this amount. Exact spends are still valid. MinChangeAmount btcutil.Amount + + // RequiredOutpoints identifies managed VTXOs that must be included in + // the selection. The manager validates and reserves these before adding + // ordinary Bitcoin VTXOs to cover any remaining target. + RequiredOutpoints []wire.OutPoint } // VTXOManagerMsg implements VTXOManagerMsg marker interface. @@ -55,6 +60,11 @@ type SelectedVTXO struct { // PkScript is the output script for this VTXO. PkScript []byte + + // TaprootAssetRoot is non-nil when this VTXO carries a Taproot Asset + // commitment. Optional coin selection must exclude such VTXOs; required + // selection may include them explicitly. + TaprootAssetRoot *chainhash.Hash } // SelectAndReserveSpendResponse returns the VTXOs that were selected and diff --git a/oor/package_persistence.go b/oor/package_persistence.go index e3cc5cdc1..8687121b2 100644 --- a/oor/package_persistence.go +++ b/oor/package_persistence.go @@ -93,20 +93,29 @@ func upsertPackage(ctx context.Context, store PackagePersistence, ) } -// ReservationOwnerKindOOROutgoing is the owner_kind value recorded for -// reservations held by an outgoing OOR session. It is the only kind for now. -const ReservationOwnerKindOOROutgoing = 0 +const ( + // ReservationOwnerKindOOROutgoing is the owner_kind value recorded for + // reservations held by an outgoing OOR session. + ReservationOwnerKindOOROutgoing = 0 + + // ReservationOwnerKindTaprootAssetPreparation records a reservation + // before the first external Taproot Asset commit. The deterministic + // request digest is the owner ID, allowing retries and restart recovery + // to upsert the same reservation. + ReservationOwnerKindTaprootAssetPreparation = 1 +) // ReservationStore is the minimal storage contract the OOR runtime needs to -// record durable spending reservations. A row is written once a new outgoing -// session is checkpointed, so a startup sweep can tell an in-flight spend from -// an orphaned one. +// record durable spending reservations. A row is written either before the +// first external Taproot Asset commit or when a new outgoing session is +// checkpointed, so a startup sweep can tell an in-flight or quarantined spend +// from an orphaned one. // // It is intentionally small and defined here so the runtime is not coupled to // a specific database package. type ReservationStore interface { // UpsertReservation records that the given outpoint is reserved by the - // owner identified by ownerKind/ownerID (the OOR session id). + // owner identified by ownerKind/ownerID. UpsertReservation(ctx context.Context, outpoint wire.OutPoint, ownerKind int, ownerID chainhash.Hash) error } diff --git a/oor/taproot_asset_preparer.go b/oor/taproot_asset_preparer.go index 446b211b2..605df34a7 100644 --- a/oor/taproot_asset_preparer.go +++ b/oor/taproot_asset_preparer.go @@ -3,14 +3,23 @@ package oor import ( "bytes" "context" + "errors" "fmt" "strings" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/wavelength/lib/arkscript" oortx "github.com/lightninglabs/wavelength/lib/tx/oor" ) +// ErrTaprootAssetCommitOutcomeUnknown reports an asset commit attempt whose +// durable outcome cannot be established. Callers must retain every input +// reservation and reconcile the tapd transition before retrying or releasing +// the inputs. +var ErrTaprootAssetCommitOutcomeUnknown = errors.New("taproot asset commit " + + "outcome is unknown") + const ( // MaxTaprootAssetRefBytes bounds the opaque tap-sdk asset identifier at // the daemon boundary. @@ -29,6 +38,10 @@ const ( // OOR caller. AssetAmount is measured in asset units and is deliberately // separate from the satoshi value of the containing Ark VTXO. type TaprootAssetOORIntent struct { + // InputVTXOOutpoint is the wallet-managed asset-bearing VTXO that must + // be selected and reserved through the normal VTXO manager path. + InputVTXOOutpoint wire.OutPoint + // AssetRef is the opaque tap-sdk asset or group identifier. AssetRef string @@ -142,6 +155,10 @@ func (r *TaprootAssetOORPrepareRequest) Validate() error { if r.Inputs[0].TaprootAssetRoot == nil { return fmt.Errorf("taproot asset OOR input root is required") } + if r.Inputs[0].VTXO.Outpoint != r.Intent.InputVTXOOutpoint { + return fmt.Errorf("taproot asset OOR input outpoint does not " + + "match the requested managed VTXO") + } if r.Recipients[0].Value != r.Inputs[0].VTXO.Amount { return fmt.Errorf("taproot asset OOR requires exact BTC value") } diff --git a/oor/taproot_asset_preparer_test.go b/oor/taproot_asset_preparer_test.go index eb8884561..51f47b71a 100644 --- a/oor/taproot_asset_preparer_test.go +++ b/oor/taproot_asset_preparer_test.go @@ -108,9 +108,10 @@ func TestTaprootAssetOORPreparationBindsRequest(t *testing.T) { Inputs: inputs, Recipients: requestRecipients, Intent: TaprootAssetOORIntent{ - AssetRef: "tapr1asset", - AssetAmount: 21, - ProofFile: []byte("confirmed-proof"), + InputVTXOOutpoint: inputs[0].VTXO.Outpoint, + AssetRef: "tapr1asset", + AssetAmount: 21, + ProofFile: []byte("confirmed-proof"), RecipientScriptKey: recipientKey.PubKey(). SerializeCompressed(), }, @@ -121,6 +122,13 @@ func TestTaprootAssetOORPreparationBindsRequest(t *testing.T) { } require.NoError(t, preparation.Validate(request)) + mismatchedInput := *request + mismatchedInput.Intent.InputVTXOOutpoint.Index++ + require.ErrorContains( + t, mismatchedInput.Validate(), + "does not match", + ) + valueChanged := *preparation valueChanged.Recipients = cloneRecipientOutputs( preparation.Recipients, diff --git a/tapassets/preparer.go b/tapassets/preparer.go index 40db8bbc3..a7cec9bd6 100644 --- a/tapassets/preparer.go +++ b/tapassets/preparer.go @@ -37,22 +37,25 @@ const ( // ErrReconciliationRequired reports a commit attempt whose durable outcome is // unknown. Retrying it could create a competing Taproot Asset transition. -var ErrReconciliationRequired = errors.New("taproot asset commit requires " + - "reconciliation") +// It aliases the OOR-layer sentinel so the RPC boundary can preserve input +// reservations without depending on this concrete adapter package. +var ErrReconciliationRequired = oor.ErrTaprootAssetCommitOutcomeUnknown // PreparerConfig contains the dependencies of the concrete tap-sdk adapter. type PreparerConfig struct { - Wallet *tapsdk.Wallet - Store Store + Wallet *tapsdk.Wallet + Store Store + ReservationStore oor.ReservationStore } // Preparer commits the checkpoint and Ark asset transitions before handing a // sealed, immutable package to Wavelength's outgoing OOR actor. type Preparer struct { - driver customAnchorDriver - inventory proofInventoryClient - store Store - mu sync.Mutex + driver customAnchorDriver + inventory proofInventoryClient + store Store + reservations oor.ReservationStore + mu sync.Mutex } type preparationState struct { @@ -72,13 +75,18 @@ func NewPreparer(cfg PreparerConfig) (*Preparer, error) { return nil, fmt.Errorf("taproot asset preparation store is " + "required") } + if cfg.ReservationStore == nil { + return nil, fmt.Errorf("taproot asset reservation store is " + + "required") + } return &Preparer{ driver: &sdkDriver{ wallet: cfg.Wallet, }, - inventory: cfg.Wallet.Client(), - store: cfg.Store, + inventory: cfg.Wallet.Client(), + store: cfg.Store, + reservations: cfg.ReservationStore, }, nil } @@ -89,7 +97,9 @@ func (p *Preparer) PrepareTaprootAssetOOR(ctx context.Context, request *oor.TaprootAssetOORPrepareRequest) ( *oor.TaprootAssetOORPreparation, error) { - if p == nil || p.driver == nil || p.inventory == nil || p.store == nil { + if p == nil || p.driver == nil || p.inventory == nil || + p.store == nil || + p.reservations == nil { return nil, fmt.Errorf("taproot asset preparer is not " + "configured") } @@ -118,10 +128,28 @@ func (p *Preparer) PrepareTaprootAssetOOR(ctx context.Context, ErrReconciliationRequired, state.Attempt, request.RequestID) } + reservationOwner := chainhash.Hash(digest) + for idx := range request.Inputs { + err := p.reservations.UpsertReservation( + ctx, request.Inputs[idx].VTXO.Outpoint, + oor.ReservationOwnerKindTaprootAssetPreparation, + reservationOwner, + ) + if err != nil { + return nil, preparationReconciliationError( + state, request.RequestID, fmt.Errorf( + "reserve Taproot Asset input %d: %w", + idx, err), + ) + } + } assetRef, err := tapsdk.ParseAssetRef(request.Intent.AssetRef) if err != nil { - return nil, fmt.Errorf("parse Taproot Asset ref: %w", err) + return nil, preparationReconciliationError( + state, request.RequestID, + fmt.Errorf("parse Taproot Asset ref: %w", err), + ) } input := &request.Inputs[0] verifier := &proofInventoryVerifier{ @@ -138,7 +166,9 @@ func (p *Preparer) PrepareTaprootAssetOOR(ctx context.Context, ctx, request.Intent.ProofFile, ) if err != nil { - return nil, err + return nil, preparationReconciliationError( + state, request.RequestID, err, + ) } if verification.PassiveAssetCount != 0 { return nil, fmt.Errorf("Taproot Asset OOR PoC "+ @@ -152,7 +182,9 @@ func (p *Preparer) PrepareTaprootAssetOOR(ctx context.Context, ctx, request, assetRef, digest, state, ) if err != nil { - return nil, err + return nil, preparationReconciliationError( + state, request.RequestID, err, + ) } ark, arkResult, recipients, err := p.prepareArk( @@ -160,7 +192,9 @@ func (p *Preparer) PrepareTaprootAssetOOR(ctx context.Context, state, ) if err != nil { - return nil, err + return nil, preparationReconciliationError( + state, request.RequestID, err, + ) } checkpointPackage := append( @@ -185,8 +219,10 @@ func (p *Preparer) PrepareTaprootAssetOOR(ctx context.Context, Recipients: recipients, } if err := prepared.Validate(request); err != nil { - return nil, fmt.Errorf("validate prepared Taproot "+ - "Asset OOR: %w", err) + return nil, preparationReconciliationError( + state, request.RequestID, fmt.Errorf("validate "+ + "prepared Taproot Asset OOR: %w", err), + ) } return prepared, nil @@ -296,9 +332,13 @@ func (p *Preparer) prepareCheckpoint(ctx context.Context, state.CheckpointPackage = append( []byte(nil), committed.packageBytes..., ) + state.Attempt = "" if err := p.storeState( ctx, request.RequestID, state, ); err != nil { + + state.Attempt = attemptCheckpoint + return nil, nil, fmt.Errorf("persist checkpoint "+ "package: %w", err) } @@ -481,9 +521,13 @@ func (p *Preparer) prepareArk(ctx context.Context, state.ArkPackage = append( []byte(nil), committed.packageBytes..., ) + state.Attempt = "" if err := p.storeState( ctx, request.RequestID, state, ); err != nil { + + state.Attempt = attemptArk + return nil, nil, nil, fmt.Errorf("persist Ark "+ "package: %w", err) } @@ -517,23 +561,48 @@ func (p *Preparer) commit(ctx context.Context, requestID string, } result, err := p.driver.Commit(ctx, request, verifier) if err != nil { - if commitOutcomeKnown(err) { - state.Attempt = "" - if storeErr := p.storeState( - ctx, requestID, state, - ); storeErr != nil { - return nil, fmt.Errorf("%v; clear commit "+ - "intent: %w", err, storeErr) - } + if !commitOutcomeKnown(err) { + return nil, fmt.Errorf("%w: %s commit for request "+ + "%q: %w", ErrReconciliationRequired, attempt, + requestID, err) + } + + state.Attempt = "" + if storeErr := p.storeState( + ctx, requestID, state, + ); storeErr != nil { + + state.Attempt = attempt + + return nil, fmt.Errorf("%w; clear commit intent: %w", + err, storeErr) } return nil, err } - state.Attempt = "" return result, nil } +// preparationReconciliationError marks failures after the first external +// transition boundary as unsafe to release. Before that boundary, callers can +// safely unlock and delete the preparation reservation on ordinary failures. +func preparationReconciliationError(state *preparationState, requestID string, + err error) error { + + if err == nil || state == nil || + errors.Is(err, ErrReconciliationRequired) { + return err + } + if state.Attempt == "" && len(state.CheckpointPackage) == 0 { + return err + } + + return fmt.Errorf("%w: request %q crossed the first Taproot Asset "+ + "commit boundary: %w", ErrReconciliationRequired, requestID, + err) +} + // loadState restores and validates the durable state for one request. func (p *Preparer) loadState(ctx context.Context, requestID string, digest tapsdk.Hash) (*preparationState, error) { diff --git a/tapassets/preparer_test.go b/tapassets/preparer_test.go index d02396446..dfd165a40 100644 --- a/tapassets/preparer_test.go +++ b/tapassets/preparer_test.go @@ -79,13 +79,14 @@ func TestPreparerRestoresCommittedPackages(t *testing.T) { driver := newFakeDriver() store, err := NewFileStore(t.TempDir()) require.NoError(t, err) - first := newTestPreparer(driver, inventory, store) + reservations := &fakeReservationStore{} + first := newTestPreparer(driver, inventory, store, reservations) prepared, err := first.PrepareTaprootAssetOOR(t.Context(), request) require.NoError(t, err) require.Len(t, driver.requests, 2) inventory.err = errors.New("tapd unavailable") - restarted := newTestPreparer(driver, inventory, store) + restarted := newTestPreparer(driver, inventory, store, reservations) restored, err := restarted.PrepareTaprootAssetOOR(t.Context(), request) require.NoError(t, err) require.Len(t, driver.requests, 2) @@ -98,6 +99,21 @@ func TestPreparerRestoresCommittedPackages(t *testing.T) { secondArk, err := psbtutil.Serialize(restored.PreparedSubmit.ArkPSBT) require.NoError(t, err) require.Equal(t, firstArk, secondArk) + + digest, err := preparationRequestDigest(request) + require.NoError(t, err) + records := reservations.records() + require.Len(t, records, 2) + for _, record := range records { + require.Equal( + t, request.Inputs[0].VTXO.Outpoint, record.outpoint, + ) + require.Equal( + t, oor.ReservationOwnerKindTaprootAssetPreparation, + record.ownerKind, + ) + require.Equal(t, chainhash.Hash(digest), record.ownerID) + } } // TestPreparerBlocksUnknownCommitRetry proves an ambiguous external commit @@ -117,6 +133,7 @@ func TestPreparerBlocksUnknownCommitRetry(t *testing.T) { _, err = preparer.PrepareTaprootAssetOOR(t.Context(), request) require.ErrorContains(t, err, "transport lost") + require.ErrorIs(t, err, ErrReconciliationRequired) require.Len(t, driver.requests, 1) driver.commitErr = nil @@ -149,6 +166,60 @@ func TestPreparerRetriesKnownCommitFailure(t *testing.T) { require.Len(t, driver.requests, 3) } +// TestPreparerQuarantinesAfterCheckpointCommit proves any later failure keeps +// the original managed VTXO quarantined once tapd has accepted the checkpoint +// transition, even when the subsequent Ark failure is known-negative. +func TestPreparerQuarantinesAfterCheckpointCommit(t *testing.T) { + t.Parallel() + + request, inventory := testPreparationRequest(t) + driver := newFakeDriver() + driver.commitErrs = []error{ + nil, errors.New("Ark transition rejected"), + } + store, err := NewFileStore(t.TempDir()) + require.NoError(t, err) + reservations := &fakeReservationStore{} + preparer := newTestPreparer( + driver, inventory, store, reservations, + ) + + _, err = preparer.PrepareTaprootAssetOOR(t.Context(), request) + require.ErrorContains(t, err, "Ark transition rejected") + require.ErrorIs(t, err, ErrReconciliationRequired) + require.Len(t, driver.requests, 2) + require.Len(t, reservations.records(), 1) + + restarted := newTestPreparer( + driver, inventory, store, reservations, + ) + _, err = restarted.PrepareTaprootAssetOOR(t.Context(), request) + require.NoError(t, err) + require.Len(t, driver.requests, 3) + require.Len(t, reservations.records(), 2) +} + +// TestPreparerFailsBeforeCommitWhenReservationFails proves the durable input +// quarantine is established before the first external tapd side effect. +func TestPreparerFailsBeforeCommitWhenReservationFails(t *testing.T) { + t.Parallel() + + request, inventory := testPreparationRequest(t) + driver := newFakeDriver() + store, err := NewFileStore(t.TempDir()) + require.NoError(t, err) + reservations := &fakeReservationStore{ + err: errors.New("reservation unavailable"), + } + preparer := newTestPreparer( + driver, inventory, store, reservations, + ) + + _, err = preparer.PrepareTaprootAssetOOR(t.Context(), request) + require.ErrorContains(t, err, "reserve Taproot Asset input 0") + require.Empty(t, driver.requests) +} + // TestPreparerRejectsIdempotencyRewrite proves the durable request digest // prevents the same idempotency key from being reused for another allocation. func TestPreparerRejectsIdempotencyRewrite(t *testing.T) { @@ -243,10 +314,11 @@ func TestProofInventoryVerifierBindsUnconfirmedAnchor(t *testing.T) { } type fakeDriver struct { - mu sync.Mutex - requests []*tapsdk.CustomAnchorRequest - results map[string]*commitResult - commitErr error + mu sync.Mutex + requests []*tapsdk.CustomAnchorRequest + results map[string]*commitResult + commitErr error + commitErrs []error } // newFakeDriver constructs a deterministic SDK commit boundary for graph @@ -264,6 +336,13 @@ func (d *fakeDriver) Commit(_ context.Context, defer d.mu.Unlock() d.requests = append(d.requests, request.Clone()) + if len(d.commitErrs) != 0 { + commitErr := d.commitErrs[0] + d.commitErrs = d.commitErrs[1:] + if commitErr != nil { + return nil, commitErr + } + } if d.commitErr != nil { return nil, d.commitErr } @@ -370,6 +449,44 @@ type fakeInventory struct { err error } +type reservationRecord struct { + outpoint wire.OutPoint + ownerKind int + ownerID chainhash.Hash +} + +type fakeReservationStore struct { + mu sync.Mutex + err error + + upserts []reservationRecord +} + +func (f *fakeReservationStore) UpsertReservation(_ context.Context, + outpoint wire.OutPoint, ownerKind int, ownerID chainhash.Hash) error { + + f.mu.Lock() + defer f.mu.Unlock() + + if f.err != nil { + return f.err + } + f.upserts = append(f.upserts, reservationRecord{ + outpoint: outpoint, + ownerKind: ownerKind, + ownerID: ownerID, + }) + + return nil +} + +func (f *fakeReservationStore) records() []reservationRecord { + f.mu.Lock() + defer f.mu.Unlock() + + return append([]reservationRecord(nil), f.upserts...) +} + // VerifyProof returns the configured tapd proof result. func (f *fakeInventory) VerifyProof(context.Context, []byte) ( *tapsdk.VerifyProofResponse, error) { @@ -404,12 +521,18 @@ func (f *fakeInventory) onlyAnchor() *tapsdk.ManagedUtxo { // newTestPreparer installs fake SDK dependencies while retaining the real // durable journal and Wavelength graph builders. func newTestPreparer(driver customAnchorDriver, inventory proofInventoryClient, - store Store) *Preparer { + store Store, reservationStores ...oor.ReservationStore) *Preparer { + + reservationStore := oor.ReservationStore(&fakeReservationStore{}) + if len(reservationStores) != 0 { + reservationStore = reservationStores[0] + } return &Preparer{ - driver: driver, - inventory: inventory, - store: store, + driver: driver, + inventory: inventory, + store: store, + reservations: reservationStore, } } @@ -499,9 +622,10 @@ func testPreparationRequest(t *testing.T) (*oor.TaprootAssetOORPrepareRequest, VTXOPolicyTemplate: recipientPolicyBytes, }}, Intent: oor.TaprootAssetOORIntent{ - AssetRef: assetRef.String(), - AssetAmount: 21, - ProofFile: []byte("confirmed-proof"), + InputVTXOOutpoint: input.VTXO.Outpoint, + AssetRef: assetRef.String(), + AssetAmount: 21, + ProofFile: []byte("confirmed-proof"), RecipientScriptKey: assetScript. PubKey(). SerializeCompressed(), diff --git a/vtxo/AGENTS.md b/vtxo/AGENTS.md index 33639af24..10000f551 100644 --- a/vtxo/AGENTS.md +++ b/vtxo/AGENTS.md @@ -142,7 +142,7 @@ when the local wallet owns the receive script. 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 (carrying the resolved `ExitPolicyKind` on the `ExitOutcomeNotification`): `ExitOutcomeRecoverable` (no on-chain footprint) rolls a standard-policy VTXO back to `LiveState` and spawns a fresh actor, but a recovery-only target (`ExitPolicyKind.Valid()`) is held in `UnilateralExitState` rather than relived; `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. +- **Startup sweep of orphaned Spending VTXOs.** When `ManagerConfig.ReservationStore` is set, `Start` calls `sweepOrphanedReservations` after all actors are recovered. A reservation row exists after a Taproot Asset preparation is quarantined or an OOR session is checkpointed. A Spending VTXO with no row is provably orphaned and is released back to `LiveState` via `SpendReleasedEvent`. The sweep aborts entirely if `ListReservedOutpoints` fails to avoid releasing VTXOs an in-flight or quarantined workflow still owns. - **Startup sweep of orphaned PendingForfeit VTXOs.** `Start` unconditionally calls `releaseOrphanedForfeits` after actor recovery. Any VTXO still in `VTXOStatusPendingForfeit` at startup is provably orphaned — forfeit signatures are submitted only on the PendingForfeit -> Forfeiting transition, so it has leaked no signature and is safe to release to `LiveState`. VTXOs already in `Forfeiting`/`Forfeited` are past the point of no return and are left untouched for chain-confirmation reconciliation. - **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` unifies every unilateral-exit trigger (manual `Unroll` RPC, fraud spend, vHTLC recovery) behind the manager's admission gate. It carries a `Trigger actormsg.UnrollTrigger` (zero value admits as critical expiry) and an `ExitPolicy fn.Option[actormsg.ExitPolicy]` (None selects the standard VTXO timeout policy); both ride through to the emitted `ExpiringNotification` so the chain-resolver bridge admits the registry job under the right `StartTrigger` and persists the correct exit-spend policy. It is accepted in `LiveState`, `PendingForfeitState`, `SpendingState`, and `ForfeitingState`: each transitions to `UnilateralExitState` and emits `ExpiringNotification` (trigger + exit policy threaded through) + `VTXOStatusUpdate{UnilateralExit}`. It does **not** emit `VTXOTerminatedNotification` on intent — `UnilateralExitState` is **non-terminal**, 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 `ForceUnrollEvent` on a VTXO already in `UnilateralExitState` is an idempotent re-admission, not a no-op: the actor stays in `UnilateralExitState`, does not re-persist the status, and **re-emits** the `ExpiringNotification` under the same trigger/policy so the chain-resolver bridge re-admits the job (the first admission's best-effort Tell can be lost to a crash before the registry writes its record; the registry dedups against a live record, so a redundant re-admit is a benign no-op). diff --git a/vtxo/CLAUDE.md b/vtxo/CLAUDE.md index f9fbef3ab..013619629 100644 --- a/vtxo/CLAUDE.md +++ b/vtxo/CLAUDE.md @@ -135,7 +135,7 @@ when the local wallet owns the receive script. 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 (carrying the resolved `ExitPolicyKind` on the `ExitOutcomeNotification`): `ExitOutcomeRecoverable` (no on-chain footprint) rolls a standard-policy VTXO back to `LiveState` and spawns a fresh actor, but a recovery-only target (`ExitPolicyKind.Valid()`) is held in `UnilateralExitState` rather than relived; `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. +- **Startup sweep of orphaned Spending VTXOs.** When `ManagerConfig.ReservationStore` is set, `Start` calls `sweepOrphanedReservations` after all actors are recovered. A reservation row exists after a Taproot Asset preparation is quarantined or an OOR session is checkpointed. A Spending VTXO with no row is provably orphaned and is released back to `LiveState` via `SpendReleasedEvent`. The sweep aborts entirely if `ListReservedOutpoints` fails to avoid releasing VTXOs an in-flight or quarantined workflow still owns. - **Startup sweep of orphaned PendingForfeit VTXOs.** `Start` unconditionally calls `releaseOrphanedForfeits` after actor recovery. Any VTXO still in `VTXOStatusPendingForfeit` at startup is provably orphaned — forfeit signatures are submitted only on the PendingForfeit -> Forfeiting transition, so it has leaked no signature and is safe to release to `LiveState`. VTXOs already in `Forfeiting`/`Forfeited` are past the point of no return and are left untouched for chain-confirmation reconciliation. - **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` unifies every unilateral-exit trigger (manual `Unroll` RPC, fraud spend, vHTLC recovery) behind the manager's admission gate. It carries a `Trigger actormsg.UnrollTrigger` (zero value admits as critical expiry) and an `ExitPolicy fn.Option[actormsg.ExitPolicy]` (None selects the standard VTXO timeout policy); both ride through to the emitted `ExpiringNotification` so the chain-resolver bridge admits the registry job under the right `StartTrigger` and persists the correct exit-spend policy. It is accepted in `LiveState`, `PendingForfeitState`, `SpendingState`, and `ForfeitingState`: each transitions to `UnilateralExitState` and emits `ExpiringNotification` (trigger + exit policy threaded through) + `VTXOStatusUpdate{UnilateralExit}`. It does **not** emit `VTXOTerminatedNotification` on intent — `UnilateralExitState` is **non-terminal**, 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 `ForceUnrollEvent` on a VTXO already in `UnilateralExitState` is an idempotent re-admission, not a no-op: the actor stays in `UnilateralExitState`, does not re-persist the status, and **re-emits** the `ExpiringNotification` under the same trigger/policy so the chain-resolver bridge re-admits the job (the first admission's best-effort Tell can be lost to a crash before the registry writes its record; the registry dedups against a live record, so a redundant re-admit is a benign no-op). diff --git a/vtxo/actor_test.go b/vtxo/actor_test.go index 1850c3703..b50a72eab 100644 --- a/vtxo/actor_test.go +++ b/vtxo/actor_test.go @@ -285,6 +285,41 @@ func TestReceiveStatusUpdateFailurePreservesStateForRetry(t *testing.T) { h.store.AssertExpectations(t) } +// TestSpendReleasedPersistsLiveAndReservationRelease proves a known-safe OOR +// setup failure leaves Spending through the actor and selects the atomic store +// path that also deletes the durable preparation reservation. +func TestSpendReleasedPersistsLiveAndReservationRelease(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + actor := &VTXOActor{ + cfg: &VTXOActorConfig{ + VTXO: vtxo, + Store: h.store, + ChainSource: noopChainSourceRef{}, + ChainParams: &chaincfg.RegressionNetParams, + }, + state: &SpendingState{ + VTXO: vtxo, + LastCheckedHeight: vtxo.CreatedHeight, + }, + env: h.env, + } + + h.store.On( + "UpdateVTXOStatusReleasingReservation", h.ctx, vtxo.Outpoint, + VTXOStatusLive, + ).Return(nil).Once() + + result := actor.Receive(h.ctx, &SpendReleasedEvent{}) + _, err := result.Unpack() + require.NoError(t, err) + _, ok := actor.state.(*LiveState) + require.True(t, ok, "expected LiveState, got %T", actor.state) + h.store.AssertExpectations(t) +} + // TestProcessOutboxForfeitRequest verifies that ForfeitRequest messages are // relayed through the manager as a RelayToRoundMsg containing a // RefreshVTXORequest with the correct fields. diff --git a/vtxo/admission_errors.go b/vtxo/admission_errors.go index 65625293a..59ec505e3 100644 --- a/vtxo/admission_errors.go +++ b/vtxo/admission_errors.go @@ -11,4 +11,8 @@ var ( // ErrVTXOLiquidityLocked means enough non-terminal liquidity exists, // but some of it is currently reserved by another in-flight operation. ErrVTXOLiquidityLocked = errors.New("vtxo liquidity temporarily locked") + + // ErrRequiredVTXOInvalid means a caller-required outpoint is + // duplicated, missing, or not in Live state. + ErrRequiredVTXOInvalid = errors.New("invalid required vtxo") ) diff --git a/vtxo/harness_test.go b/vtxo/harness_test.go index b0d869904..ec8ab2827 100644 --- a/vtxo/harness_test.go +++ b/vtxo/harness_test.go @@ -88,9 +88,10 @@ func (m *MockVTXOStore) ListSelectionCandidatesByStatus(ctx context.Context, candidates := make([]SelectedVTXO, 0, len(descs)) for _, desc := range descs { candidates = append(candidates, SelectedVTXO{ - Outpoint: desc.Outpoint, - Amount: desc.Amount, - PkScript: desc.PkScript, + Outpoint: desc.Outpoint, + Amount: desc.Amount, + PkScript: desc.PkScript, + TaprootAssetRoot: desc.TaprootAssetRoot, }) } diff --git a/vtxo/interfaces.go b/vtxo/interfaces.go index daf8640b4..816dc4662 100644 --- a/vtxo/interfaces.go +++ b/vtxo/interfaces.go @@ -517,9 +517,10 @@ type VTXOStore interface { status VTXOStatus) ([]*Descriptor, error) // ListSelectionCandidatesByStatus returns the lightweight - // (outpoint, amount, pkScript) projection coin selection runs on. - // Selection happens on every payment and needs only these fields, - // so this avoids decoding full descriptors on the hot path. + // (outpoint, amount, pkScript, optional Taproot Asset root) projection + // coin selection runs on. Selection happens on every payment and needs + // only these fields, so this avoids decoding full descriptors on the + // hot path. ListSelectionCandidatesByStatus(ctx context.Context, status VTXOStatus) ([]SelectedVTXO, error) diff --git a/vtxo/manager.go b/vtxo/manager.go index 1e76b4ad3..27921efa5 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -1239,6 +1239,7 @@ func (m *Manager) spawnVTXOActor(ctx context.Context, vtxo *Descriptor) ( type reserveParams struct { targetAmount btcutil.Amount minChangeAmount btcutil.Amount + required []wire.OutPoint reserveEvent actormsg.VTXOActorMsg rollback func(ctx context.Context, ops []wire.OutPoint) ask func(context.Context, VTXOActorRef, @@ -1267,6 +1268,20 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( return nil, 0, fmt.Errorf("target amount must be positive") } + required, requiredTotal, requiredSet, err := m.loadRequiredVTXOs( + ctx, p.required, + ) + if err != nil { + return nil, 0, err + } + selected := append([]*Descriptor(nil), required...) + selectionRequest, selectOptional := optionalSelectionRequest( + requiredTotal, p.targetAmount, p.minChangeAmount, + ) + if !selectOptional { + return m.reserveSelectedVTXOs(ctx, selected, p) + } + // List live candidates from the store via the lightweight selection // projection: selection only consumes outpoint, amount, and pkScript, // so there is no reason to decode full descriptors (taproot script @@ -1293,6 +1308,12 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( // selection paths funnel through this filter. candidates := make([]*Descriptor, 0, len(rows)) for _, row := range rows { + if row.TaprootAssetRoot != nil { + continue + } + if _, ok := requiredSet[row.Outpoint]; ok { + continue + } if m.isReserved(row.Outpoint) { continue } @@ -1313,26 +1334,34 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( res, err := coinselect.LargestFirst( candidates, func(d *Descriptor) btcutil.Amount { return d.Amount - }, coinselect.Request{ - Target: p.targetAmount, - MinChange: p.minChangeAmount, - }, + }, selectionRequest, ) switch { case errors.Is(err, coinselect.ErrChangeBelowMin): - change := res.Total - p.targetAmount + change := requiredTotal + res.Total - p.targetAmount return nil, 0, fmt.Errorf("change %d is below minimum change "+ "amount %d", change, p.minChangeAmount) case errors.Is(err, coinselect.ErrSelectionShortfall), errors.Is(err, coinselect.ErrNoCandidates): - return nil, 0, m.insufficientLiquidityError(ctx, candidates, p) + return nil, 0, m.insufficientLiquidityError( + ctx, candidates, requiredTotal, p, + ) case err != nil: return nil, 0, err } - selected := res.Selected + selected = append(selected, res.Selected...) + + return m.reserveSelectedVTXOs(ctx, selected, p) +} + +// reserveSelectedVTXOs atomically reserves an already selected required-first +// VTXO list through the caller's spend or forfeit lifecycle. +func (m *Manager) reserveSelectedVTXOs(ctx context.Context, + selected []*Descriptor, p reserveParams) ([]SelectedVTXO, + btcutil.Amount, error) { // Reserve each selected VTXO via its actor. Track successfully // reserved outpoints so we can roll back on partial failure. @@ -1410,9 +1439,10 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( ) for _, vtxo := range selected { selectedVTXOs = append(selectedVTXOs, SelectedVTXO{ - Outpoint: vtxo.Outpoint, - Amount: vtxo.Amount, - PkScript: vtxo.PkScript, + Outpoint: vtxo.Outpoint, + Amount: vtxo.Amount, + PkScript: vtxo.PkScript, + TaprootAssetRoot: vtxo.TaprootAssetRoot, }) totalSelected += vtxo.Amount } @@ -1426,13 +1456,91 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( return selectedVTXOs, totalSelected, nil } +// loadRequiredVTXOs validates and loads caller-mandated VTXOs in request +// order before optional coin selection can begin. +func (m *Manager) loadRequiredVTXOs(ctx context.Context, + outpoints []wire.OutPoint) ([]*Descriptor, btcutil.Amount, + map[wire.OutPoint]struct{}, error) { + + required := make([]*Descriptor, 0, len(outpoints)) + seen := make(map[wire.OutPoint]struct{}, len(outpoints)) + for _, outpoint := range outpoints { + if _, ok := seen[outpoint]; ok { + return nil, 0, nil, fmt.Errorf("%w: duplicate "+ + "outpoint %s", ErrRequiredVTXOInvalid, outpoint) + } + seen[outpoint] = struct{}{} + } + + var total btcutil.Amount + for _, outpoint := range outpoints { + desc, err := m.cfg.Store.GetVTXO(ctx, outpoint) + if errors.Is(err, ErrVTXONotFound) { + return nil, 0, nil, fmt.Errorf("%w: outpoint %s "+ + "not found", ErrRequiredVTXOInvalid, outpoint) + } + if err != nil { + return nil, 0, nil, fmt.Errorf("load required VTXO "+ + "%s: %w", outpoint, err) + } + if desc == nil || desc.Outpoint != outpoint { + return nil, 0, nil, fmt.Errorf("%w: descriptor "+ + "mismatch for outpoint %s", + ErrRequiredVTXOInvalid, outpoint) + } + if desc.Status != VTXOStatusLive { + return nil, 0, nil, fmt.Errorf("%w: outpoint %s has "+ + "status %s", ErrRequiredVTXOInvalid, outpoint, + desc.Status) + } + if m.isReserved(outpoint) { + return nil, 0, nil, fmt.Errorf("%w: required "+ + "outpoint %s", ErrVTXOLiquidityLocked, outpoint) + } + if desc.Amount <= 0 || total > btcutil.MaxSatoshi-desc.Amount { + return nil, 0, nil, fmt.Errorf("%w: invalid amount %d "+ + "for outpoint %s", ErrRequiredVTXOInvalid, + desc.Amount, outpoint) + } + + total += desc.Amount + required = append(required, desc) + } + + return required, total, seen, nil +} + +// optionalSelectionRequest returns the ordinary-Bitcoin selection needed +// after required value is accounted for. It preserves exact spends and raises +// a sub-minimum required residual to the requested change floor. +func optionalSelectionRequest(requiredTotal, target, + minChange btcutil.Amount) (coinselect.Request, bool) { + + if requiredTotal < target { + return coinselect.Request{ + Target: target - requiredTotal, + MinChange: minChange, + }, true + } + + change := requiredTotal - target + if change == 0 || minChange <= 0 || change >= minChange { + return coinselect.Request{}, false + } + + return coinselect.Request{ + Target: minChange - change, + }, true +} + // insufficientLiquidityError distinguishes a true spendable-funds shortfall // from liquidity that is present but unavailable because another operation has // already moved it out of LiveState. func (m *Manager) insufficientLiquidityError(ctx context.Context, - liveCandidates []*Descriptor, p reserveParams) error { + liveCandidates []*Descriptor, requiredTotal btcutil.Amount, + p reserveParams) error { - liveTotal := SumBalance(liveCandidates) + liveTotal := requiredTotal + SumBalance(liveCandidates) nonTerminal, err := m.cfg.Store.ListLiveVTXOs(ctx) if err != nil { @@ -1444,6 +1552,9 @@ func (m *Manager) insufficientLiquidityError(ctx context.Context, if desc == nil { continue } + if desc.TaprootAssetRoot != nil { + continue + } if desc.Status == VTXOStatusLive { continue @@ -1452,7 +1563,14 @@ func (m *Manager) insufficientLiquidityError(ctx context.Context, lockedTotal += desc.Amount } - if lockedTotal > 0 && liveTotal+lockedTotal >= p.targetAmount { + neededTotal := p.targetAmount + change := requiredTotal - p.targetAmount + if requiredTotal > p.targetAmount && p.minChangeAmount > 0 && + change < p.minChangeAmount { + + neededTotal += p.minChangeAmount + } + if lockedTotal > 0 && liveTotal+lockedTotal >= neededTotal { return fmt.Errorf("%w: need %d, spendable %d, locked %d", ErrVTXOLiquidityLocked, p.targetAmount, liveTotal, lockedTotal) @@ -1472,11 +1590,14 @@ func (m *Manager) handleSelectAndReserveSpend(ctx context.Context, vtxos, total, err := m.selectAndReserveVTXOs(ctx, reserveParams{ targetAmount: req.TargetAmount, minChangeAmount: req.MinChangeAmount, - reserveEvent: &SpendReserveEvent{}, - rollback: m.rollbackSpend, - ask: m.askVTXOActor, - label: "spend", - detached: true, + required: append( + []wire.OutPoint(nil), req.RequiredOutpoints..., + ), + reserveEvent: &SpendReserveEvent{}, + rollback: m.rollbackSpend, + ask: m.askVTXOActor, + label: "spend", + detached: true, }) if err != nil { return fn.Err[ManagerResp](err) @@ -1521,10 +1642,11 @@ func (m *Manager) rollbackSpend(ctx context.Context, } // sweepOrphanedReservations releases Spending VTXOs that have no live -// reservation row. A reservation row exists IFF the owning spend session was -// durably checkpointed, and a checkpointed session is always restored+resumed -// on restart; so a Spending VTXO with no row is provably orphaned (its spend -// died before checkpointing) and is safe to release back to LiveState. +// reservation row. A row exists after the owning workflow crosses its durable +// handoff boundary: either a Taproot Asset preparation is quarantined before +// its first external commit, or an OOR session is checkpointed. A Spending +// VTXO with no row is therefore provably orphaned and safe to release back to +// LiveState. // // The sweep is conservative: if the reservation list cannot be read it aborts // without releasing anything, because releasing on incomplete information could @@ -1549,8 +1671,8 @@ func (m *Manager) sweepOrphanedReservations(ctx context.Context) { // Note: do not early-return when there are no Spending VTXOs. The // reverse-direction recovery below re-drives reservation rows whose - // VTXO is still Live (the owning session checkpointed its reservation - // but the detached SpendingState write never landed before the + // VTXO is still Live (the owning workflow persisted its reservation but + // the detached SpendingState write never landed before the // shutdown). In exactly that case the spending set is empty, so a // len(spending) == 0 short-circuit here would skip recovery and leave // the live input selectable by another spend. @@ -1608,12 +1730,11 @@ func (m *Manager) sweepOrphanedReservations(ctx context.Context) { } // Reverse direction: a reservation row whose VTXO row is NOT in - // SpendingState means the owning session checkpointed but the - // detached Spending status write never landed before the shutdown. - // The session resumes on this boot and still owns the input, so - // re-mark the in-memory reservation and re-drive the reserve event - // to converge the durable status. Without this, a restarted daemon - // could select an input an in-flight session owns. + // SpendingState means the owning workflow persisted its durable handoff + // but the detached Spending status write never landed before shutdown. + // Re-mark the in-memory reservation and re-drive the reserve event to + // converge the durable status. Without this, a restarted daemon could + // select an input an in-flight or quarantined workflow owns. spendingSet := fn.NewSet[wire.OutPoint]() for _, desc := range spending { spendingSet.Add(desc.Outpoint) diff --git a/vtxo/manager_admission_test.go b/vtxo/manager_admission_test.go index 188dccb08..81d5f3b76 100644 --- a/vtxo/manager_admission_test.go +++ b/vtxo/manager_admission_test.go @@ -848,6 +848,248 @@ func TestSelectAndReserveSpendSuccess(t *testing.T) { require.True(t, ok, "expected SpendingState, got %T", ref.state) } +// TestSelectAndReserveSpendRequiredAssetWithBitcoin verifies a caller-named +// asset VTXO is selected first and only ordinary Bitcoin VTXOs may cover the +// remaining carrier target. +func TestSelectAndReserveSpendRequiredAssetWithBitcoin(t *testing.T) { + t.Parallel() + + requiredAsset := makeDescriptor(t, 600, 10) + requiredRoot := chainhash.Hash{1} + requiredAsset.TaprootAssetRoot = &requiredRoot + bitcoinLarge := makeDescriptor(t, 500, 11) + bitcoinSmall := makeDescriptor(t, 300, 12) + unrelatedAsset := makeDescriptor(t, 2_000, 13) + unrelatedRoot := chainhash.Hash{2} + unrelatedAsset.TaprootAssetRoot = &unrelatedRoot + + mgr, store := newTestManager(t, []*Descriptor{ + requiredAsset, bitcoinLarge, bitcoinSmall, unrelatedAsset, + }) + store.On( + "GetVTXO", t.Context(), requiredAsset.Outpoint, + ).Return(requiredAsset, nil).Once() + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{ + unrelatedAsset, bitcoinSmall, requiredAsset, bitcoinLarge, + }, nil).Once() + + result := mgr.Receive(t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 1_000, + MinChangeAmount: 100, + RequiredOutpoints: []wire.OutPoint{requiredAsset.Outpoint}, + }) + resp, err := result.Unpack() + require.NoError(t, err) + + spendResp, ok := resp.(*SelectAndReserveSpendResponse) + require.True(t, ok) + require.Equal(t, btcutil.Amount(1_100), spendResp.TotalSelected) + require.Len(t, spendResp.SelectedVTXOs, 2) + require.Equal( + t, requiredAsset.Outpoint, spendResp.SelectedVTXOs[0].Outpoint, + ) + require.Equal( + t, &requiredRoot, spendResp.SelectedVTXOs[0].TaprootAssetRoot, + ) + require.Equal( + t, bitcoinLarge.Outpoint, spendResp.SelectedVTXOs[1].Outpoint, + ) + require.NotEqual( + t, unrelatedAsset.Outpoint, spendResp.SelectedVTXOs[1].Outpoint, + ) +} + +// TestSelectAndReserveSpendRequiredExact verifies an exact required asset +// input reserves directly without consulting the ordinary candidate set. +func TestSelectAndReserveSpendRequiredExact(t *testing.T) { + t.Parallel() + + required := makeDescriptor(t, 1_000, 14) + root := chainhash.Hash{6} + required.TaprootAssetRoot = &root + mgr, store := newTestManager(t, []*Descriptor{required}) + store.On( + "GetVTXO", t.Context(), required.Outpoint, + ).Return(required, nil).Once() + + result := mgr.Receive(t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: required.Amount, + MinChangeAmount: 500, + RequiredOutpoints: []wire.OutPoint{required.Outpoint}, + }) + resp, err := result.Unpack() + require.NoError(t, err) + + spendResp, ok := resp.(*SelectAndReserveSpendResponse) + require.True(t, ok) + require.Equal(t, required.Amount, spendResp.TotalSelected) + require.Len(t, spendResp.SelectedVTXOs, 1) + require.Equal( + t, required.Outpoint, spendResp.SelectedVTXOs[0].Outpoint, + ) +} + +// TestSelectAndReserveSpendRequiredResidualTopUp verifies an already-covering +// required input selects ordinary Bitcoin when its residual would otherwise +// be below the minimum change amount. +func TestSelectAndReserveSpendRequiredResidualTopUp(t *testing.T) { + t.Parallel() + + required := makeDescriptor(t, 1_500, 20) + root := chainhash.Hash{3} + required.TaprootAssetRoot = &root + bitcoin := makeDescriptor(t, 500, 21) + mgr, store := newTestManager(t, []*Descriptor{required, bitcoin}) + store.On( + "GetVTXO", t.Context(), required.Outpoint, + ).Return(required, nil).Once() + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{required, bitcoin}, nil).Once() + + result := mgr.Receive(t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 600, + MinChangeAmount: 1_000, + RequiredOutpoints: []wire.OutPoint{required.Outpoint}, + }) + resp, err := result.Unpack() + require.NoError(t, err) + + spendResp, ok := resp.(*SelectAndReserveSpendResponse) + require.True(t, ok) + require.Equal(t, btcutil.Amount(2_000), spendResp.TotalSelected) + require.Len(t, spendResp.SelectedVTXOs, 2) + require.Equal(t, required.Outpoint, + spendResp.SelectedVTXOs[0].Outpoint) + require.Equal(t, bitcoin.Outpoint, + spendResp.SelectedVTXOs[1].Outpoint) +} + +// TestSelectAndReserveSpendRequiredValidation verifies malformed or +// unavailable required outpoints fail before optional VTXO reservation. +func TestSelectAndReserveSpendRequiredValidation(t *testing.T) { + t.Parallel() + + t.Run("duplicate", func(t *testing.T) { + t.Parallel() + + required := makeDescriptor(t, 1_000, 30) + mgr, _ := newTestManager(t, []*Descriptor{required}) + result := mgr.Receive( + t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 1_000, + RequiredOutpoints: []wire.OutPoint{ + required.Outpoint, required.Outpoint, + }, + }, + ) + _, err := result.Unpack() + require.ErrorIs(t, err, ErrRequiredVTXOInvalid) + require.ErrorContains(t, err, "duplicate") + }) + + t.Run("missing", func(t *testing.T) { + t.Parallel() + + required := makeDescriptor(t, 1_000, 31) + mgr, store := newTestManager(t, nil) + store.On( + "GetVTXO", t.Context(), required.Outpoint, + ).Return(nil, ErrVTXONotFound).Once() + result := mgr.Receive( + t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 1_000, + RequiredOutpoints: []wire.OutPoint{ + required.Outpoint, + }, + }, + ) + _, err := result.Unpack() + require.ErrorIs(t, err, ErrRequiredVTXOInvalid) + require.ErrorContains(t, err, "not found") + }) + + t.Run("non live", func(t *testing.T) { + t.Parallel() + + required := makeDescriptor(t, 1_000, 32) + required.Status = VTXOStatusSpending + mgr, store := newTestManager(t, nil) + store.On( + "GetVTXO", t.Context(), required.Outpoint, + ).Return(required, nil).Once() + result := mgr.Receive( + t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 1_000, + RequiredOutpoints: []wire.OutPoint{ + required.Outpoint, + }, + }, + ) + _, err := result.Unpack() + require.ErrorIs(t, err, ErrRequiredVTXOInvalid) + require.ErrorContains(t, err, "status") + }) + + t.Run("reserved", func(t *testing.T) { + t.Parallel() + + required := makeDescriptor(t, 1_000, 33) + mgr, store := newTestManager(t, []*Descriptor{required}) + store.On( + "GetVTXO", t.Context(), required.Outpoint, + ).Return(required, nil).Once() + mgr.markReserved(required.Outpoint) + result := mgr.Receive( + t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 1_000, + RequiredOutpoints: []wire.OutPoint{ + required.Outpoint, + }, + }, + ) + _, err := result.Unpack() + require.ErrorIs(t, err, ErrVTXOLiquidityLocked) + }) +} + +// TestSelectAndReserveSpendExcludesUnrequiredAssets verifies unrelated asset +// carrier value is not counted in spendable or locked Bitcoin liquidity. +func TestSelectAndReserveSpendExcludesUnrequiredAssets(t *testing.T) { + t.Parallel() + + required := makeDescriptor(t, 600, 40) + requiredRoot := chainhash.Hash{4} + required.TaprootAssetRoot = &requiredRoot + bitcoin := makeDescriptor(t, 100, 41) + unrelated := makeDescriptor(t, 5_000, 42) + unrelatedRoot := chainhash.Hash{5} + unrelated.TaprootAssetRoot = &unrelatedRoot + mgr, store := newTestManager(t, []*Descriptor{ + required, bitcoin, unrelated, + }) + store.On( + "GetVTXO", t.Context(), required.Outpoint, + ).Return(required, nil).Once() + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{unrelated, bitcoin, required}, nil).Once() + store.On("ListLiveVTXOs", t.Context()).Return( + []*Descriptor{required, bitcoin, unrelated}, nil, + ).Once() + + result := mgr.Receive(t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 1_000, + RequiredOutpoints: []wire.OutPoint{required.Outpoint}, + }) + _, err := result.Unpack() + require.ErrorIs(t, err, ErrInsufficientSpendableFunds) + require.NotErrorIs(t, err, ErrVTXOLiquidityLocked) + require.ErrorContains(t, err, "spendable 700") +} + // TestSelectAndReserveRespawnGuards verifies the self-heal path for a // live-in-DB but actorless selection candidate fails closed: a store miss // and a non-live row both surface as reservation errors instead of spawning diff --git a/wallet/messages.go b/wallet/messages.go index e5ccec9de..ba4c2aea0 100644 --- a/wallet/messages.go +++ b/wallet/messages.go @@ -561,6 +561,10 @@ type SelectAndLockVTXOsRequest struct { // MinChangeAmount, when positive, asks selection to avoid a // non-zero residual below this amount. Exact spends are still valid. MinChangeAmount btcutil.Amount + + // RequiredOutpoints identifies managed VTXOs that must be included in + // the selection before ordinary Bitcoin VTXOs cover any shortfall. + RequiredOutpoints []wire.OutPoint } // MessageType returns the message type identifier for logging and debugging. diff --git a/wallet/wallet.go b/wallet/wallet.go index c7c02b172..4b1733632 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -2651,6 +2651,9 @@ func (a *Ark) handleSelectAndLockVTXOs(ctx context.Context, ctx, &actormsg.SelectAndReserveSpendRequest{ TargetAmount: req.TargetAmount, MinChangeAmount: req.MinChangeAmount, + RequiredOutpoints: append( + []wire.OutPoint(nil), req.RequiredOutpoints..., + ), }, ) if err != nil { diff --git a/wallet/wallet_admission_test.go b/wallet/wallet_admission_test.go index 0a52435da..4af984ab4 100644 --- a/wallet/wallet_admission_test.go +++ b/wallet/wallet_admission_test.go @@ -275,6 +275,9 @@ func TestSelectAndLockVTXOs(t *testing.T) { req := &SelectAndLockVTXOsRequest{ TargetAmount: 70000, MinChangeAmount: 1000, + RequiredOutpoints: []wire.OutPoint{ + testOutpoint(9), + }, } result := w.Receive(t.Context(), req) require.True(t, result.IsOk(), "expected ok, got: %v", @@ -291,6 +294,17 @@ func TestSelectAndLockVTXOs(t *testing.T) { resp.SelectedVTXOs[0].Amount) require.Equal(t, btcutil.Amount(70000), mgr.selectReq.TargetAmount) require.Equal(t, btcutil.Amount(1000), mgr.selectReq.MinChangeAmount) + require.Equal( + t, []wire.OutPoint{testOutpoint(9)}, + mgr.selectReq.RequiredOutpoints, + ) + + // The actor boundary owns its request copy. + req.RequiredOutpoints[0] = testOutpoint(10) + require.Equal( + t, []wire.OutPoint{testOutpoint(9)}, + mgr.selectReq.RequiredOutpoints, + ) } // TestSelectAndLockVTXOsInsufficientFunds verifies that the wallet surfaces diff --git a/waved/rpc_oor_idempotency_test.go b/waved/rpc_oor_idempotency_test.go index b56ee995a..3a6d6ea02 100644 --- a/waved/rpc_oor_idempotency_test.go +++ b/waved/rpc_oor_idempotency_test.go @@ -56,6 +56,9 @@ func (w *sendOORTestWallet) Receive(_ context.Context, case *wallet.SelectAndLockVTXOsRequest: w.selects++ reqCopy := *msg + reqCopy.RequiredOutpoints = append( + []wire.OutPoint(nil), msg.RequiredOutpoints..., + ) w.selectReqs = append(w.selectReqs, &reqCopy) if len(w.selections) == 0 { @@ -136,6 +139,9 @@ func (w *sendOORTestWallet) selectionRequests() []*selectionReq { ) for _, req := range w.selectReqs { reqCopy := *req + reqCopy.RequiredOutpoints = append( + []wire.OutPoint(nil), req.RequiredOutpoints..., + ) requests = append(requests, &reqCopy) } diff --git a/waved/rpc_oor_taproot_asset.go b/waved/rpc_oor_taproot_asset.go index 2a9d6cb1c..2ca0d64c5 100644 --- a/waved/rpc_oor_taproot_asset.go +++ b/waved/rpc_oor_taproot_asset.go @@ -2,6 +2,7 @@ package waved import ( "bytes" + "errors" "strings" "github.com/lightninglabs/wavelength/oor" @@ -29,9 +30,9 @@ func taprootAssetOORIntent(req *waverpc.SendOORRequest) ( return nil, status.Errorf(codes.InvalidArgument, "Taproot "+ "Asset OOR transfers require exactly one recipient") - case len(req.GetCustomInputs()) != 1: + case len(req.GetCustomInputs()) != 0: return nil, status.Errorf(codes.InvalidArgument, "Taproot "+ - "Asset OOR transfers require exactly one custom input") + "Asset OOR transfers do not support custom inputs") case strings.TrimSpace(req.GetIdempotencyKey()) == "": return nil, status.Errorf(codes.InvalidArgument, "Taproot "+ @@ -43,10 +44,20 @@ func taprootAssetOORIntent(req *waverpc.SendOORRequest) ( "acknowledge_unconfirmed=true") } + inputOutpoint, err := parseOutpointString( + rpcIntent.GetInputVtxoOutpoint(), + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "parse "+ + "Taproot Asset input VTXO outpoint %q: %v", + rpcIntent.GetInputVtxoOutpoint(), err) + } + intent := &oor.TaprootAssetOORIntent{ - AssetRef: rpcIntent.GetAssetRef(), - AssetAmount: rpcIntent.GetAssetAmount(), - ProofFile: bytes.Clone(rpcIntent.GetInputProofFile()), + InputVTXOOutpoint: inputOutpoint, + AssetRef: rpcIntent.GetAssetRef(), + AssetAmount: rpcIntent.GetAssetAmount(), + ProofFile: bytes.Clone(rpcIntent.GetInputProofFile()), RecipientScriptKey: bytes.Clone( rpcIntent.GetRecipientScriptKey(), ), @@ -82,6 +93,10 @@ func taprootAssetOORPreparationError(err error) error { if err == nil { return nil } + if errors.Is(err, oor.ErrTaprootAssetCommitOutcomeUnknown) { + return status.Errorf(codes.Aborted, "prepare Taproot Asset "+ + "OOR requires reconciliation: %v", err) + } if status.Code(err) != codes.Unknown { return err } diff --git a/waved/rpc_oor_taproot_asset_test.go b/waved/rpc_oor_taproot_asset_test.go index 68d701d01..af2867135 100644 --- a/waved/rpc_oor_taproot_asset_test.go +++ b/waved/rpc_oor_taproot_asset_test.go @@ -2,6 +2,7 @@ package waved import ( "context" + "fmt" "sync" "testing" "time" @@ -11,9 +12,11 @@ import ( "github.com/btcsuite/btcd/chaincfg/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/wavelength/arkrpc" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/db" "github.com/lightninglabs/wavelength/lib/arkscript" oortx "github.com/lightninglabs/wavelength/lib/tx/oor" "github.com/lightninglabs/wavelength/oor" @@ -36,6 +39,20 @@ type testTaprootAssetOORPreparer struct { type assetPrepareRequest = oor.TaprootAssetOORPrepareRequest +// TestOORReservationStoreShared proves the optional Taproot Asset registrar +// receives the exact durable store cached for the manager and OOR runtime. +func TestOORReservationStoreShared(t *testing.T) { + t.Parallel() + + shared := &db.SpendingReservationPersistenceStore{} + server := &Server{reservationStore: shared} + require.Same(t, shared, server.spendingReservationStore(nil)) + + store, err := NewRPCServer(server).OORReservationStore() + require.NoError(t, err) + require.Same(t, shared, store) +} + func (p *testTaprootAssetOORPreparer) PrepareTaprootAssetOOR(_ context.Context, request *oor.TaprootAssetOORPrepareRequest) ( *oor.TaprootAssetOORPreparation, error) { @@ -117,6 +134,7 @@ type taprootAssetOORRPCFixture struct { oorActor *capturingSendOORActor request *waverpc.SendOORRequest desc *vtxo.Descriptor + wallet *sendOORTestWallet } func newTaprootAssetOORRPCFixture(t *testing.T) *taprootAssetOORRPCFixture { @@ -155,7 +173,11 @@ func newTaprootAssetOORRPCFixture(t *testing.T) *taprootAssetOORRPCFixture { require.NoError(t, system.Shutdown(shutdownCtx)) }) - testWallet := &sendOORTestWallet{} + testWallet := &sendOORTestWallet{ + selections: [][]wallet.SelectedVTXO{{ + selectedVTXOFromDescriptor(desc), + }}, + } walletKey := actor.NewServiceKey[ wallet.WalletMsg, wallet.WalletResp, ]( @@ -208,14 +230,12 @@ func newTaprootAssetOORRPCFixture(t *testing.T) *taprootAssetOORRPCFixture { exitDelay, int64(amountSat), ), }, - CustomInputs: []*waverpc.CustomOORInput{{ - Outpoint: desc.Outpoint.String(), - }}, IdempotencyKey: "taproot-asset-request-1", TaprootAsset: &waverpc.TaprootAssetOORIntent{ - AssetRef: "tapr1asset", - AssetAmount: 21, - InputProofFile: []byte("confirmed-proof"), + InputVtxoOutpoint: desc.Outpoint.String(), + AssetRef: "tapr1asset", + AssetAmount: 21, + InputProofFile: []byte("confirmed-proof"), RecipientScriptKey: assetScriptKey.PubKey(). SerializeCompressed(), AcknowledgeUnconfirmed: true, @@ -228,6 +248,7 @@ func newTaprootAssetOORRPCFixture(t *testing.T) *taprootAssetOORRPCFixture { oorActor: oorActor, request: request, desc: desc, + wallet: testWallet, } } @@ -252,6 +273,10 @@ func TestSendOORTaprootAssetPreparesBeforeActor(t *testing.T) { prepareRequest.RequestID, ) require.EqualValues(t, 21, prepareRequest.Intent.AssetAmount) + require.Equal( + t, fixture.desc.Outpoint, + prepareRequest.Intent.InputVTXOOutpoint, + ) require.Equal( t, fixture.desc.TaprootAssetRoot, prepareRequest.Inputs[0].TaprootAssetRoot, @@ -265,6 +290,13 @@ func TestSendOORTaprootAssetPreparesBeforeActor(t *testing.T) { require.NoError( t, actorRequest.Recipients[0].ValidateTaprootAssetCommitment(), ) + selectRequests := fixture.wallet.selectionRequests() + require.Len(t, selectRequests, 1) + require.Equal( + t, []wire.OutPoint{fixture.desc.Outpoint}, + selectRequests[0].RequiredOutpoints, + ) + require.Empty(t, fixture.wallet.unlockBatches()) } // TestSendOORTaprootAssetFailsClosed covers public-shape, feature-gate, BTC @@ -277,7 +309,39 @@ func TestSendOORTaprootAssetFailsClosed(t *testing.T) { mutate func(*taprootAssetOORRPCFixture) wantCode codes.Code wantContains string + wantSelect bool + wantUnlock bool }{ + { + name: "missing managed input outpoint", + mutate: func(f *taprootAssetOORRPCFixture) { + f.request.TaprootAsset.InputVtxoOutpoint = "" + }, + wantCode: codes.InvalidArgument, + wantContains: "input VTXO outpoint", + }, + { + name: "malformed managed input outpoint", + mutate: func(f *taprootAssetOORRPCFixture) { + f.request.TaprootAsset.InputVtxoOutpoint = "bad" + }, + wantCode: codes.InvalidArgument, + wantContains: "input VTXO outpoint", + }, + { + name: "custom input bypass rejected", + mutate: func(f *taprootAssetOORRPCFixture) { + customInput := &waverpc.CustomOORInput{ + Outpoint: f.desc.Outpoint.String(), + } + customInputs := []*waverpc.CustomOORInput{ + customInput, + } + f.request.CustomInputs = customInputs + }, + wantCode: codes.InvalidArgument, + wantContains: "do not support custom inputs", + }, { name: "missing acknowledgement", mutate: func(f *taprootAssetOORRPCFixture) { @@ -311,6 +375,8 @@ func TestSendOORTaprootAssetFailsClosed(t *testing.T) { }, wantCode: codes.InvalidArgument, wantContains: "requires exact BTC value", + wantSelect: true, + wantUnlock: true, }, { name: "adapter changes value", @@ -319,6 +385,8 @@ func TestSendOORTaprootAssetFailsClosed(t *testing.T) { }, wantCode: codes.Internal, wantContains: "recipient 0 value changed", + wantSelect: true, + wantUnlock: true, }, { name: "typed preparer error", @@ -329,6 +397,8 @@ func TestSendOORTaprootAssetFailsClosed(t *testing.T) { }, wantCode: codes.Unavailable, wantContains: "tapd unavailable", + wantSelect: true, + wantUnlock: true, }, } @@ -346,10 +416,46 @@ func TestSendOORTaprootAssetFailsClosed(t *testing.T) { require.Equal(t, test.wantCode, status.Code(err)) require.ErrorContains(t, err, test.wantContains) require.Empty(t, fixture.oorActor.capturedRequests()) + if test.wantSelect { + require.Equal( + t, 1, fixture.wallet.selectCount(), + ) + } else { + require.Zero(t, fixture.wallet.selectCount()) + } + if test.wantUnlock { + require.Eventually(t, func() bool { + return len( + fixture.wallet.unlockBatches(), + ) == 1 + }, time.Second, 10*time.Millisecond) + } else { + require.Empty(t, fixture.wallet.unlockBatches()) + } }) } } +// TestSendOORTaprootAssetAmbiguousCommitKeepsReservation proves an unknown +// tapd outcome is surfaced distinctly and never releases the managed VTXO for +// a competing spend. +func TestSendOORTaprootAssetAmbiguousCommitKeepsReservation(t *testing.T) { + t.Parallel() + + fixture := newTaprootAssetOORRPCFixture(t) + fixture.preparer.err = fmt.Errorf("tapd response lost: %w", + oor.ErrTaprootAssetCommitOutcomeUnknown) + + _, err := fixture.rpcServer.SendOOR(t.Context(), fixture.request) + require.Equal(t, codes.Aborted, status.Code(err)) + require.ErrorContains(t, err, "requires reconciliation") + require.Equal(t, 1, fixture.wallet.selectCount()) + require.Empty(t, fixture.oorActor.capturedRequests()) + require.Never(t, func() bool { + return len(fixture.wallet.unlockBatches()) != 0 + }, 200*time.Millisecond, 10*time.Millisecond) +} + func incrementAssetRecipientValue(preparation *oor.TaprootAssetOORPreparation) { preparation.Recipients[0].Value++ } diff --git a/waved/rpc_server.go b/waved/rpc_server.go index 86bb82610..f89d84d24 100644 --- a/waved/rpc_server.go +++ b/waved/rpc_server.go @@ -126,6 +126,19 @@ func (r *RPCServer) SubLogger(tag string) btclog.Logger { return r.server.subLogger(tag) } +// OORReservationStore returns the daemon's shared durable spending +// reservation store. Optional Taproot Asset runtimes use this narrow +// interface to quarantine managed VTXOs before the first external tapd +// commit, without gaining access to the daemon database itself. +func (r *RPCServer) OORReservationStore() (oor.ReservationStore, error) { + if r == nil || r.server == nil || r.server.reservationStore == nil { + return nil, fmt.Errorf("spending reservation store is not " + + "ready") + } + + return r.server.reservationStore, nil +} + // SignMailboxAuth returns a hex-encoded Schnorr mailbox auth signature for // the daemon identity key bound to the recipient mailbox ID. Optional // subservers use this to authenticate mailbox RPCs without learning how the @@ -198,6 +211,9 @@ func vtxoAdmissionCode(err error) codes.Code { case errors.Is(err, vtxo.ErrInsufficientSpendableFunds): return codes.ResourceExhausted + case errors.Is(err, vtxo.ErrRequiredVTXOInvalid): + return codes.InvalidArgument + default: return codes.Internal } @@ -2904,6 +2920,19 @@ func (r *RPCServer) SendVTXO(ctx context.Context, }, nil } +// sendOORRequiredOutpoints maps an optional asset intent to the wallet-managed +// VTXO that coin selection must include. Bitcoin-only sends have no required +// outpoints. +func sendOORRequiredOutpoints( + assetIntent *oor.TaprootAssetOORIntent) []wire.OutPoint { + + if assetIntent == nil { + return nil + } + + return []wire.OutPoint{assetIntent.InputVTXOOutpoint} +} + // SendOOR initiates an out-of-round transfer directly between the // client and operator, without waiting for a round. The transfer // completes asynchronously via the OOR protocol. @@ -3047,7 +3076,20 @@ func (r *RPCServer) SendOOR(ctx context.Context, req *waverpc.SendOORRequest) ( locked *wallet.SelectAndLockVTXOsResponse customInputsRelease func() releaseCustomInputs bool + releaseSelected bool ) + defer func() { + if !releaseSelected { + return + } + + unlockCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), submittedOORUnlockTimeout, + ) + defer cancel() + + r.unlockSelectedVTXOsBestEffort(unlockCtx, locked) + }() if len(req.CustomInputs) > 0 { phaseStart = time.Now() @@ -3115,10 +3157,12 @@ func (r *RPCServer) SendOOR(ctx context.Context, req *waverpc.SendOORRequest) ( // Standard path: select and lock VTXOs from wallet. phaseStart = time.Now() wRef := r.server.walletRef.UnsafeFromSome() + requiredOutpoints := sendOORRequiredOutpoints(assetIntent) selectReq := &wallet.SelectAndLockVTXOsRequest{ - TargetAmount: targetAmt, - MinChangeAmount: terms.MinVTXOAmountFloor(), + TargetAmount: targetAmt, + MinChangeAmount: terms.MinVTXOAmountFloor(), + RequiredOutpoints: requiredOutpoints, } selectFuture := wRef.Ask(ctx, selectReq) selectResult := selectFuture.Await(ctx) @@ -3135,6 +3179,7 @@ func (r *RPCServer) SendOOR(ctx context.Context, req *waverpc.SendOORRequest) ( return nil, status.Errorf(codes.Internal, "unexpected "+ "response type: %T", selectResp) } + releaseSelected = true inputSelectDuration = time.Since(phaseStart) outpoints := make( @@ -3152,8 +3197,6 @@ func (r *RPCServer) SendOOR(ctx context.Context, req *waverpc.SendOORRequest) ( ) buildInputsDuration = time.Since(phaseStart) if err != nil { - r.unlockSelectedVTXOsBestEffort(ctx, locked) - return nil, status.Errorf(codes.Internal, "build "+ "transfer inputs: %v", err) } @@ -3169,8 +3212,6 @@ func (r *RPCServer) SendOOR(ctx context.Context, req *waverpc.SendOORRequest) ( phaseStart = time.Now() inputTotal, err := sumOORInputAmounts(selectedInputs) if err != nil { - r.unlockSelectedVTXOsBestEffort(ctx, locked) - return nil, status.Errorf(codes.Internal, "sum OOR input "+ "amounts: %v", err) } @@ -3194,8 +3235,6 @@ func (r *RPCServer) SendOOR(ctx context.Context, req *waverpc.SendOORRequest) ( }, ) if err != nil { - r.unlockSelectedVTXOsBestEffort(ctx, locked) - return nil, err } changeOutputDuration = time.Since(phaseStart) @@ -3229,6 +3268,13 @@ func (r *RPCServer) SendOOR(ctx context.Context, req *waverpc.SendOORRequest) ( ctx, prepareRequest, ) if err != nil { + if errors.Is( + err, oor.ErrTaprootAssetCommitOutcomeUnknown, + ) { + + releaseSelected = false + } + return nil, taprootAssetOORPreparationError(err) } if err := preparation.Validate(prepareRequest); err != nil { @@ -3266,6 +3312,7 @@ func (r *RPCServer) SendOOR(ctx context.Context, req *waverpc.SendOORRequest) ( if err != nil { if isAwaitContextError(ctx, err) { releaseCustomInputs = false + releaseSelected = false r.cleanupSubmittedOORStart( ctx, future, locked, customInputsRelease, ) @@ -3281,8 +3328,6 @@ func (r *RPCServer) SendOOR(ctx context.Context, req *waverpc.SendOORRequest) ( // Unlock VTXOs on OOR failure so they can be // reused (only for wallet-selected inputs). - r.unlockSelectedVTXOsBestEffort(ctx, locked) - r.server.emitMetric(ctx, &metrics.OORTransferSentMsg{ Status: "failed", Duration: time.Since(startTime), @@ -3294,14 +3339,12 @@ func (r *RPCServer) SendOOR(ctx context.Context, req *waverpc.SendOORRequest) ( resp, ok := oorResp.(*oor.StartTransferResponse) if !ok { - r.unlockSelectedVTXOsBestEffort(ctx, locked) - return nil, status.Errorf(codes.Internal, "unexpected "+ "response type: %T", oorResp) } - if resp.Existing { - r.unlockSelectedVTXOsBestEffort(ctx, locked) + if !resp.Existing { + releaseSelected = false } recipientOutpoints := r.resolveOORRecipientOutpoints( diff --git a/waved/server.go b/waved/server.go index f69d58143..7c42bca7a 100644 --- a/waved/server.go +++ b/waved/server.go @@ -186,12 +186,13 @@ type Server struct { loggers SubLoggers log btclog.Logger - db *db.SqliteStore - deliveryStore actor.DeliveryStore - vtxoStore *db.VTXOPersistenceStore - activityStore *db.ActivityPersistenceStore - roundStore *db.RoundPersistenceStore - ueStore *db.UnilateralExitPersistenceStore + db *db.SqliteStore + deliveryStore actor.DeliveryStore + vtxoStore *db.VTXOPersistenceStore + reservationStore *db.SpendingReservationPersistenceStore + activityStore *db.ActivityPersistenceStore + roundStore *db.RoundPersistenceStore + ueStore *db.UnilateralExitPersistenceStore // oorSessionStore exposes the OOR session-registry control-plane rows // for direct RPC reads (idempotency pre-flight); the OOR registry @@ -1241,6 +1242,7 @@ func (s *Server) run(ctx context.Context, shutdownFn func()) error { s.subLogger(db.Subsystem), ) s.vtxoStore = dbStore.NewVTXOStore(s.clk) + s.reservationStore = dbStore.NewSpendingReservationStore(s.clk) // Build the activity-log store for the wavewalletrpc subserver. s.activityStore = dbStore.NewActivityStore(s.clk) @@ -4239,7 +4241,7 @@ func (s *Server) initVTXOManager(ctx context.Context, ) vtxoStore := dbStore.NewVTXOStore(s.clk) ueStore := dbStore.NewUnilateralExitStore(s.clk) - reservationStore := dbStore.NewSpendingReservationStore(s.clk) + reservationStore := s.spendingReservationStore(dbStore) roundActor := round.NewServiceKey().Ref(s.actorSystem) ledgerSink := ledger.NewSink(s.actorSystem) @@ -4339,6 +4341,21 @@ func resolveExitOutcome(ctx context.Context, // wiring site instead. var _ oor.ReservationStore = (*db.SpendingReservationPersistenceStore)(nil) +// spendingReservationStore returns the one reservation-store instance shared +// by the VTXO manager, OOR registry, and optional Taproot Asset preparer. The +// fallback construction keeps focused actor tests that initialize these +// components without running the full daemon startup sequence working while +// caching the result for every later consumer. +func (s *Server) spendingReservationStore( + dbStore *db.Store) *db.SpendingReservationPersistenceStore { + + if s.reservationStore == nil { + s.reservationStore = dbStore.NewSpendingReservationStore(s.clk) + } + + return s.reservationStore +} + // initOORActor creates and starts the OOR (out-of-round) client actor. // // The OOR actor manages outgoing off-chain transfers: it drives the @@ -4369,7 +4386,7 @@ func (s *Server) initOORActor(ctx context.Context, vtxoStore := dbStore.NewVTXOStore(s.clk) packageStore := dbStore.NewOORArtifactStore(s.clk) - reservationStore := dbStore.NewSpendingReservationStore(s.clk) + reservationStore := s.spendingReservationStore(dbStore) operatorTerms := s.loadOperatorTerms() if operatorTerms == nil { diff --git a/waverpc/daemon.pb.go b/waverpc/daemon.pb.go index f4cc9f016..df6a17995 100644 --- a/waverpc/daemon.pb.go +++ b/waverpc/daemon.pb.go @@ -3803,9 +3803,11 @@ type SendOORRequest struct { // of creating a duplicate transfer. IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` // taproot_asset requests the experimental proof-selected Taproot Asset - // extension. The first release requires exactly one locally stored custom - // input, one recipient, equal input/output BTC values, and a non-empty - // idempotency_key. Bitcoin-only sends leave this field unset. + // extension. The first release requires exactly one managed asset input, + // one recipient, equal input/output BTC values, and a non-empty + // idempotency_key. Asset transfers must identify the managed input through + // taproot_asset.input_vtxo_outpoint and must not use custom_inputs. + // Bitcoin-only sends leave this field unset. TaprootAsset *TaprootAssetOORIntent `protobuf:"bytes,5,opt,name=taproot_asset,json=taprootAsset,proto3" json:"taproot_asset,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -3878,7 +3880,8 @@ func (x *SendOORRequest) GetTaprootAsset() *TaprootAssetOORIntent { // TaprootAssetOORIntent selects exact asset units for the experimental OOR // custom-anchor flow. The BTC amount remains on the enclosing recipient and -// custom input; asset_amount is measured in independent Taproot Asset units. +// managed VTXO input; asset_amount is measured in independent Taproot Asset +// units. type TaprootAssetOORIntent struct { state protoimpl.MessageState `protogen:"open.v1"` // asset_ref is the opaque tap-sdk asset or group identifier. @@ -3902,8 +3905,12 @@ type TaprootAssetOORIntent struct { // persists compact unconfirmed proof paths but cannot yet publish and log // them through tapd's chain porter. AcknowledgeUnconfirmed bool `protobuf:"varint,7,opt,name=acknowledge_unconfirmed,json=acknowledgeUnconfirmed,proto3" json:"acknowledge_unconfirmed,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // input_vtxo_outpoint identifies the wallet-managed asset-bearing VTXO in + // "txid:vout" form. The wallet reserves this required input through the + // normal VTXO manager path before any Taproot Asset commit occurs. + InputVtxoOutpoint string `protobuf:"bytes,8,opt,name=input_vtxo_outpoint,json=inputVtxoOutpoint,proto3" json:"input_vtxo_outpoint,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *TaprootAssetOORIntent) Reset() { @@ -3985,6 +3992,13 @@ func (x *TaprootAssetOORIntent) GetAcknowledgeUnconfirmed() bool { return false } +func (x *TaprootAssetOORIntent) GetInputVtxoOutpoint() string { + if x != nil { + return x.InputVtxoOutpoint + } + return "" +} + // CustomOORInput specifies a VTXO to spend with an explicit semantic policy // and selected spend path. type CustomOORInput struct { @@ -10753,7 +10767,7 @@ const file_daemon_proto_rawDesc = "" + "\adry_run\x18\x02 \x01(\bR\x06dryRun\x12<\n" + "\rcustom_inputs\x18\x03 \x03(\v2\x17.waverpc.CustomOORInputR\fcustomInputs\x12'\n" + "\x0fidempotency_key\x18\x04 \x01(\tR\x0eidempotencyKey\x12C\n" + - "\rtaproot_asset\x18\x05 \x01(\v2\x1e.waverpc.TaprootAssetOORIntentR\ftaprootAsset\"\xd8\x02\n" + + "\rtaproot_asset\x18\x05 \x01(\v2\x1e.waverpc.TaprootAssetOORIntentR\ftaprootAsset\"\x88\x03\n" + "\x15TaprootAssetOORIntent\x12\x1b\n" + "\tasset_ref\x18\x01 \x01(\tR\bassetRef\x12!\n" + "\fasset_amount\x18\x02 \x01(\x04R\vassetAmount\x12(\n" + @@ -10761,7 +10775,8 @@ const file_daemon_proto_rawDesc = "" + "\x14recipient_script_key\x18\x04 \x01(\fR\x12recipientScriptKey\x122\n" + "\x15proof_courier_address\x18\x05 \x01(\tR\x13proofCourierAddress\x126\n" + "\x17proof_delivery_metadata\x18\x06 \x01(\fR\x15proofDeliveryMetadata\x127\n" + - "\x17acknowledge_unconfirmed\x18\a \x01(\bR\x16acknowledgeUnconfirmed\"\x8b\x02\n" + + "\x17acknowledge_unconfirmed\x18\a \x01(\bR\x16acknowledgeUnconfirmed\x12.\n" + + "\x13input_vtxo_outpoint\x18\b \x01(\tR\x11inputVtxoOutpoint\"\x8b\x02\n" + "\x0eCustomOORInput\x12\x1a\n" + "\boutpoint\x18\x01 \x01(\tR\boutpoint\x120\n" + "\x14vtxo_policy_template\x18\x02 \x01(\fR\x12vtxoPolicyTemplate\x12\x1d\n" + diff --git a/waverpc/daemon.proto b/waverpc/daemon.proto index 1508c032a..76f69eb23 100644 --- a/waverpc/daemon.proto +++ b/waverpc/daemon.proto @@ -1063,15 +1063,18 @@ message SendOORRequest { string idempotency_key = 4; // taproot_asset requests the experimental proof-selected Taproot Asset - // extension. The first release requires exactly one locally stored custom - // input, one recipient, equal input/output BTC values, and a non-empty - // idempotency_key. Bitcoin-only sends leave this field unset. + // extension. The first release requires exactly one managed asset input, + // one recipient, equal input/output BTC values, and a non-empty + // idempotency_key. Asset transfers must identify the managed input through + // taproot_asset.input_vtxo_outpoint and must not use custom_inputs. + // Bitcoin-only sends leave this field unset. TaprootAssetOORIntent taproot_asset = 5; } // TaprootAssetOORIntent selects exact asset units for the experimental OOR // custom-anchor flow. The BTC amount remains on the enclosing recipient and -// custom input; asset_amount is measured in independent Taproot Asset units. +// managed VTXO input; asset_amount is measured in independent Taproot Asset +// units. message TaprootAssetOORIntent { // asset_ref is the opaque tap-sdk asset or group identifier. string asset_ref = 1; @@ -1100,6 +1103,11 @@ message TaprootAssetOORIntent { // persists compact unconfirmed proof paths but cannot yet publish and log // them through tapd's chain porter. bool acknowledge_unconfirmed = 7; + + // input_vtxo_outpoint identifies the wallet-managed asset-bearing VTXO in + // "txid:vout" form. The wallet reserves this required input through the + // normal VTXO manager path before any Taproot Asset commit occurs. + string input_vtxo_outpoint = 8; } // CustomOORInput specifies a VTXO to spend with an explicit semantic policy diff --git a/waverpc/daemon_proto_test.go b/waverpc/daemon_proto_test.go index 2f653b3ea..b117233fd 100644 --- a/waverpc/daemon_proto_test.go +++ b/waverpc/daemon_proto_test.go @@ -39,6 +39,27 @@ func TestSendVTXOResponseProtoRoundTrip(t *testing.T) { require.Equal(t, original.SelectedCount, decoded.SelectedCount) } +// TestTaprootAssetInputOutpointProtoRoundTrip guards the managed VTXO selector +// that replaces the custom-input bypass for asset OOR transfers. +func TestTaprootAssetInputOutpointProtoRoundTrip(t *testing.T) { + t.Parallel() + + original := &TaprootAssetOORIntent{ + InputVtxoOutpoint: "0000000000000000000000000000000000000000" + + "000000000000000000000000:1", + } + + payload, err := proto.Marshal(original) + require.NoError(t, err) + + var decoded TaprootAssetOORIntent + require.NoError(t, proto.Unmarshal(payload, &decoded)) + require.Equal( + t, original.GetInputVtxoOutpoint(), + decoded.GetInputVtxoOutpoint(), + ) +} + // TestVTXOExpiryInfoProtoRoundTrip guards the expiry posture fields that swap // services use to decide whether a VTXO is safe to build a swap around. func TestVTXOExpiryInfoProtoRoundTrip(t *testing.T) {