diff --git a/daemonrpc/daemon.pb.go b/daemonrpc/daemon.pb.go index 5c9cc233d..07bb8f533 100644 --- a/daemonrpc/daemon.pb.go +++ b/daemonrpc/daemon.pb.go @@ -1427,8 +1427,14 @@ type SendVTXOResponse struct { // total_amount_sat is the total amount being sent (sum of // recipients). TotalAmountSat int64 `protobuf:"varint,3,opt,name=total_amount_sat,json=totalAmountSat,proto3" json:"total_amount_sat,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // change_amount_sat is the change returned to the sender. Zero if + // the selected VTXOs exactly covered the total. + ChangeAmountSat int64 `protobuf:"varint,4,opt,name=change_amount_sat,json=changeAmountSat,proto3" json:"change_amount_sat,omitempty"` + // selected_count is the number of VTXOs selected as inputs for + // this send. + SelectedCount int32 `protobuf:"varint,5,opt,name=selected_count,json=selectedCount,proto3" json:"selected_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendVTXOResponse) Reset() { @@ -1482,6 +1488,20 @@ func (x *SendVTXOResponse) GetTotalAmountSat() int64 { return 0 } +func (x *SendVTXOResponse) GetChangeAmountSat() int64 { + if x != nil { + return x.ChangeAmountSat + } + return 0 +} + +func (x *SendVTXOResponse) GetSelectedCount() int32 { + if x != nil { + return x.SelectedCount + } + return 0 +} + type SendOORRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // recipient is the output to create via the out-of-round transfer. @@ -2292,11 +2312,13 @@ const file_daemon_proto_rawDesc = "" + "\n" + "recipients\x18\x01 \x03(\v2\x11.daemonrpc.OutputR\n" + "recipients\x12\x17\n" + - "\adry_run\x18\x02 \x01(\bR\x06dryRun\"o\n" + + "\adry_run\x18\x02 \x01(\bR\x06dryRun\"\xc2\x01\n" + "\x10SendVTXOResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status\x12\x19\n" + "\bround_id\x18\x02 \x01(\tR\aroundId\x12(\n" + - "\x10total_amount_sat\x18\x03 \x01(\x03R\x0etotalAmountSat\"Z\n" + + "\x10total_amount_sat\x18\x03 \x01(\x03R\x0etotalAmountSat\x12*\n" + + "\x11change_amount_sat\x18\x04 \x01(\x03R\x0fchangeAmountSat\x12%\n" + + "\x0eselected_count\x18\x05 \x01(\x05R\rselectedCount\"Z\n" + "\x0eSendOORRequest\x12/\n" + "\trecipient\x18\x01 \x01(\v2\x11.daemonrpc.OutputR\trecipient\x12\x17\n" + "\adry_run\x18\x02 \x01(\bR\x06dryRun\"H\n" + diff --git a/daemonrpc/daemon.proto b/daemonrpc/daemon.proto index 7811bf2a8..c979a3168 100644 --- a/daemonrpc/daemon.proto +++ b/daemonrpc/daemon.proto @@ -350,6 +350,14 @@ message SendVTXOResponse { // total_amount_sat is the total amount being sent (sum of // recipients). int64 total_amount_sat = 3; + + // change_amount_sat is the change returned to the sender. Zero if + // the selected VTXOs exactly covered the total. + int64 change_amount_sat = 4; + + // selected_count is the number of VTXOs selected as inputs for + // this send. + int32 selected_count = 5; } message SendOORRequest { diff --git a/darepod/AGENTS.md b/darepod/AGENTS.md index 45d351e7d..260b31ab2 100644 --- a/darepod/AGENTS.md +++ b/darepod/AGENTS.md @@ -9,7 +9,7 @@ gRPC API. ## Key Types - `Server` — Main daemon owning wallet, DB, chainsource actor, gRPC server, and ActorSystem. -- `RPCServer` — Implements the gRPC `DaemonService` API (Board, ListRounds, WatchRounds, NewOORReceiveScript, etc.). Includes test hooks for mailbox edge factory and round registration. +- `RPCServer` — Implements the gRPC `DaemonService` API (Board, ListRounds, WatchRounds, NewOORReceiveScript, SendVTXO, etc.). Includes test hooks for mailbox edge factory and round registration. - `Config` — Daemon configuration (data dir, network, RPC host, wallet type, etc.). Includes `MailboxEdgeFactory` hook for test harness transport interception. - `TriggerRoundRegistration` — Test-hook method that injects a round registration event into the round actor (in `server_round_testhook.go`). - `WalletState` — Enum (None/Locked/Ready) for wallet lifecycle. @@ -17,6 +17,8 @@ gRPC API. - `NewOwnedReceiveScriptSigner` — Indexer signer that resolves the wallet key for any persisted owned receive script, then delegates signing to the backend-specific signer. - `EnsureDefaultOORReceiveScript` / `CreateOORReceiveScript` — Receive-key lifecycle: derive, register with indexer (proof-of-control), persist ownership record. - `ResolveIncomingMetadataFromIndexer` — Resolves authoritative VTXO lineage metadata from the indexer's `ListVTXOsByScripts` response for incoming materialization. +- `SendVTXO` — RPC handler for in-round directed sends. Validates recipients, resolves destinations via `resolveRecipientOutput`, and delegates to the wallet actor. +- `resolveRecipientOutput` — Extracts pkScript and client pubkey from an `Output` proto oneof (pubkey or address). Enforces taproot-only for directed sends. ## Relationships diff --git a/darepod/CLAUDE.md b/darepod/CLAUDE.md index 45d351e7d..260b31ab2 100644 --- a/darepod/CLAUDE.md +++ b/darepod/CLAUDE.md @@ -9,7 +9,7 @@ gRPC API. ## Key Types - `Server` — Main daemon owning wallet, DB, chainsource actor, gRPC server, and ActorSystem. -- `RPCServer` — Implements the gRPC `DaemonService` API (Board, ListRounds, WatchRounds, NewOORReceiveScript, etc.). Includes test hooks for mailbox edge factory and round registration. +- `RPCServer` — Implements the gRPC `DaemonService` API (Board, ListRounds, WatchRounds, NewOORReceiveScript, SendVTXO, etc.). Includes test hooks for mailbox edge factory and round registration. - `Config` — Daemon configuration (data dir, network, RPC host, wallet type, etc.). Includes `MailboxEdgeFactory` hook for test harness transport interception. - `TriggerRoundRegistration` — Test-hook method that injects a round registration event into the round actor (in `server_round_testhook.go`). - `WalletState` — Enum (None/Locked/Ready) for wallet lifecycle. @@ -17,6 +17,8 @@ gRPC API. - `NewOwnedReceiveScriptSigner` — Indexer signer that resolves the wallet key for any persisted owned receive script, then delegates signing to the backend-specific signer. - `EnsureDefaultOORReceiveScript` / `CreateOORReceiveScript` — Receive-key lifecycle: derive, register with indexer (proof-of-control), persist ownership record. - `ResolveIncomingMetadataFromIndexer` — Resolves authoritative VTXO lineage metadata from the indexer's `ListVTXOsByScripts` response for incoming materialization. +- `SendVTXO` — RPC handler for in-round directed sends. Validates recipients, resolves destinations via `resolveRecipientOutput`, and delegates to the wallet actor. +- `resolveRecipientOutput` — Extracts pkScript and client pubkey from an `Output` proto oneof (pubkey or address). Enforces taproot-only for directed sends. ## Relationships diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index 05555420d..db30be95f 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -622,9 +623,10 @@ func (r *RPCServer) Board(ctx context.Context, }, nil } -// SendVTXO initiates an in-round transfer by submitting a refresh -// request with specific recipient outputs to the round coordinator. -// The transfer completes asynchronously when the next round commits. +// SendVTXO initiates an in-round directed transfer by forfeiting +// existing VTXOs and creating new recipient VTXOs in the same round. +// Coin selection, reservation, and round registration are handled +// atomically by the wallet actor. func (r *RPCServer) SendVTXO(ctx context.Context, req *daemonrpc.SendVTXORequest) ( *daemonrpc.SendVTXOResponse, error) { @@ -638,43 +640,103 @@ func (r *RPCServer) SendVTXO(ctx context.Context, "at least one recipient is required") } - // Validate recipients and compute total amount. + // Resolve each recipient's pkScript and client pubkey from + // the proto Output destination. + recipients := make( + []wallet.SendRecipient, 0, len(req.Recipients), + ) var totalAmount int64 + for i, out := range req.Recipients { if out.GetDestination() == nil { return nil, status.Errorf( codes.InvalidArgument, "recipient %d: destination is "+ - "required", i) + "required", i, + ) } if out.AmountSat <= 0 { return nil, status.Errorf( codes.InvalidArgument, "recipient %d: amount must be "+ - "positive", i) + "positive", i, + ) + } + + pkScript, clientKey, err := r.resolveRecipientOutput( + out, + ) + if err != nil { + return nil, status.Errorf( + codes.InvalidArgument, + "recipient %d: %v", i, err, + ) } + recipients = append(recipients, wallet.SendRecipient{ + PkScript: pkScript, + Amount: btcutil.Amount(out.AmountSat), + ClientKey: clientKey, + }) + totalAmount += out.AmountSat } - // For dry_run, validate inputs and return a preview. - if req.DryRun { - return &daemonrpc.SendVTXOResponse{ - Status: "preview", - TotalAmountSat: totalAmount, - }, nil + // Fetch operator terms for fee, dust limit, exit delay, and + // operator key. + terms, err := r.server.fetchOperatorTerms(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, + "unable to fetch operator terms: %v", err) + } + + if !r.server.walletRef.IsSome() { + return nil, status.Errorf(codes.Internal, + "wallet actor not initialized") + } + + wRef := r.server.walletRef.UnsafeFromSome() + + sendReq := &wallet.SendVTXOsRequest{ + Recipients: recipients, + OperatorFee: terms.MinOperatorFee, + DustLimit: terms.DustLimit, + OperatorKey: terms.PubKey, + VTXOExitDelay: terms.VTXOExitDelay, + DryRun: req.DryRun, + } + + future := wRef.Ask(ctx, sendReq) + result := future.Await(ctx) + + resp, err := result.Unpack() + if err != nil { + return nil, status.Errorf(codes.Internal, + "send failed: %v", err) } - // TODO(roasbeef): In-round directed sends are not yet - // implemented. The wallet actor's RefreshVTXOsRequest only - // supports self-refresh (sending back to self), not directed - // transfers to external recipients. Once the round protocol - // supports recipient outputs, this handler should build a - // proper send request with the validated recipients. - return nil, status.Errorf(codes.Unimplemented, - "in-round directed sends are not yet implemented; "+ - "use SendOOR for out-of-round transfers") + sendResp, ok := resp.(*wallet.SendVTXOsResponse) + if !ok { + return nil, status.Errorf(codes.Internal, + "unexpected response type: %T", resp) + } + + r.server.log.InfoS(ctx, "SendVTXO completed", + slog.String("status", sendResp.Status), + slog.Int("selected_count", + sendResp.SelectedCount), + slog.Int64("total_selected", + int64(sendResp.TotalSelected)), + slog.Int64("change", + int64(sendResp.ChangeAmount))) + + return &daemonrpc.SendVTXOResponse{ + Status: sendResp.Status, + TotalAmountSat: totalAmount, + ChangeAmountSat: int64(sendResp.ChangeAmount), + SelectedCount: int32(sendResp.SelectedCount), + }, nil } // SendOOR initiates an out-of-round transfer directly between the @@ -870,6 +932,98 @@ func (r *RPCServer) unlockVTXOs(ctx context.Context, }) } +// resolveRecipientOutput extracts both the pkScript and the client +// public key from an Output proto. The client key is required for +// constructing VTXO descriptors in directed sends. Only the pubkey and +// taproot address destination types are supported — raw pk_script does +// not carry the public key needed for MuSig2. +func (r *RPCServer) resolveRecipientOutput( + out *daemonrpc.Output) ([]byte, *btcec.PublicKey, error) { + + switch d := out.Destination.(type) { + case *daemonrpc.Output_Pubkey: + if len(d.Pubkey) != schnorr.PubKeyBytesLen { + return nil, nil, fmt.Errorf( + "pubkey must be %d bytes, got %d", + schnorr.PubKeyBytesLen, + len(d.Pubkey), + ) + } + + clientKey, err := schnorr.ParsePubKey(d.Pubkey) + if err != nil { + return nil, nil, fmt.Errorf( + "invalid pubkey: %w", err, + ) + } + + // Derive the BIP-86 taproot pkScript from the + // x-only pubkey. + addr, err := btcutil.NewAddressTaproot( + d.Pubkey, r.server.chainParams, + ) + if err != nil { + return nil, nil, fmt.Errorf( + "derive taproot address: %w", err, + ) + } + + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return nil, nil, fmt.Errorf( + "derive pkScript: %w", err, + ) + } + + return pkScript, clientKey, nil + + case *daemonrpc.Output_Address: + addr, err := btcutil.DecodeAddress( + d.Address, r.server.chainParams, + ) + if err != nil { + return nil, nil, fmt.Errorf( + "invalid address: %w", err, + ) + } + + // Only taproot addresses carry the x-only pubkey + // needed for VTXO construction. + tapAddr, ok := addr.(*btcutil.AddressTaproot) + if !ok { + return nil, nil, fmt.Errorf( + "directed sends require a taproot "+ + "address, got %T", addr, + ) + } + + clientKey, err := schnorr.ParsePubKey( + tapAddr.ScriptAddress(), + ) + if err != nil { + return nil, nil, fmt.Errorf( + "extract pubkey from address: %w", + err, + ) + } + + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return nil, nil, fmt.Errorf( + "derive pkScript: %w", err, + ) + } + + return pkScript, clientKey, nil + + default: + return nil, nil, fmt.Errorf( + "directed sends require pubkey or taproot "+ + "address destination, got %T", d, + ) + } +} + // resolveOutputPkScript derives a pkScript from the Output's // destination oneof. It supports address, raw pubkey, and raw // pkScript destinations. For pubkey destinations, operator terms diff --git a/darepod/rpc_server_test.go b/darepod/rpc_server_test.go new file mode 100644 index 000000000..e01edede5 --- /dev/null +++ b/darepod/rpc_server_test.go @@ -0,0 +1,145 @@ +package darepod + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/darepo-client/daemonrpc" + "github.com/stretchr/testify/require" +) + +// newTestRPCServer creates a minimal RPCServer with chain params set +// for regtest. Only resolveRecipientOutput is usable. +func newTestRPCServer() *RPCServer { + return &RPCServer{ + server: &Server{ + chainParams: &chaincfg.RegressionNetParams, + }, + } +} + +// TestResolveRecipientOutputPubkey verifies that a raw x-only pubkey +// destination correctly yields both a taproot pkScript and the parsed +// public key. +func TestResolveRecipientOutputPubkey(t *testing.T) { + t.Parallel() + + r := newTestRPCServer() + + _, pub := btcec.PrivKeyFromBytes( + []byte("test-key-data-for-resolve-output"), + ) + xOnly := pub.SerializeCompressed()[1:] + + out := &daemonrpc.Output{ + Destination: &daemonrpc.Output_Pubkey{ + Pubkey: xOnly, + }, + AmountSat: 50_000, + } + + pkScript, clientKey, err := r.resolveRecipientOutput(out) + require.NoError(t, err) + require.NotEmpty(t, pkScript) + require.NotNil(t, clientKey) + + // The pkScript should be a valid P2TR output. + require.Len(t, pkScript, 34) + require.Equal(t, byte(0x51), pkScript[0]) // OP_1 + require.Equal(t, byte(0x20), pkScript[1]) // push 32 + + // The client key should match the input pubkey. + require.True(t, clientKey.IsEqual(pub)) +} + +// TestResolveRecipientOutputAddress verifies that a taproot address +// destination extracts the correct pkScript and client key. +func TestResolveRecipientOutputAddress(t *testing.T) { + t.Parallel() + + r := newTestRPCServer() + + _, pub := btcec.PrivKeyFromBytes( + []byte("test-key-data-for-resolve-addr."), + ) + xOnly := pub.SerializeCompressed()[1:] + + addr, err := btcutil.NewAddressTaproot( + xOnly, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + out := &daemonrpc.Output{ + Destination: &daemonrpc.Output_Address{ + Address: addr.EncodeAddress(), + }, + AmountSat: 100_000, + } + + pkScript, clientKey, err := r.resolveRecipientOutput(out) + require.NoError(t, err) + require.NotEmpty(t, pkScript) + + // The taproot witness program IS the x-only pubkey, so the + // extracted key matches the original (not tweaked). + require.Equal(t, xOnly, clientKey.SerializeCompressed()[1:]) +} + +// TestResolveRecipientOutputPkScriptRejected verifies that raw +// pk_script destinations are rejected for directed sends. +func TestResolveRecipientOutputPkScriptRejected(t *testing.T) { + t.Parallel() + + r := newTestRPCServer() + + out := &daemonrpc.Output{ + Destination: &daemonrpc.Output_PkScript{ + PkScript: []byte{0x51, 0x20, 0x01}, + }, + AmountSat: 50_000, + } + + _, _, err := r.resolveRecipientOutput(out) + require.Error(t, err) + require.Contains(t, err.Error(), "directed sends require") +} + +// TestResolveRecipientOutputNonTaprootRejected verifies that +// non-taproot addresses are rejected for directed sends. +func TestResolveRecipientOutputNonTaprootRejected(t *testing.T) { + t.Parallel() + + r := newTestRPCServer() + + out := &daemonrpc.Output{ + Destination: &daemonrpc.Output_Address{ + Address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080", + }, + AmountSat: 50_000, + } + + _, _, err := r.resolveRecipientOutput(out) + require.Error(t, err) + require.Contains(t, err.Error(), "taproot address") +} + +// TestResolveRecipientOutputInvalidPubkey verifies that a malformed +// pubkey is rejected. +func TestResolveRecipientOutputInvalidPubkey(t *testing.T) { + t.Parallel() + + r := newTestRPCServer() + + out := &daemonrpc.Output{ + Destination: &daemonrpc.Output_Pubkey{ + Pubkey: []byte{0x01, 0x02, 0x03}, + }, + AmountSat: 50_000, + } + + _, _, err := r.resolveRecipientOutput(out) + require.Error(t, err) + require.Contains(t, err.Error(), "32 bytes") +} diff --git a/lib/actormsg/AGENTS.md b/lib/actormsg/AGENTS.md index 4f8dbdfd8..bacb56da8 100644 --- a/lib/actormsg/AGENTS.md +++ b/lib/actormsg/AGENTS.md @@ -13,6 +13,7 @@ package boundaries. Lives in `lib/` to break import cycles between `vtxo`, - `VTXOManagerMsg` / `VTXOManagerResp` — Marker interfaces for VTXO manager messages and responses. - `VTXOActorMsg` / `VTXOActorResp` — Marker interfaces for per-VTXO actor messages and responses. - `SelectAndReserveSpendRequest` / `SelectAndReserveSpendResponse` — Ask-message to select and lock VTXOs for OOR spend. +- `SelectAndReserveForfeitRequest` / `SelectAndReserveForfeitResponse` — Ask-message to atomically select and reserve VTXOs for cooperative forfeit (directed sends). Combines coin selection and PendingForfeit reservation in one step to close a race window. - `ReserveForfeitRequest` / `ReleaseForfeitRequest` — Forfeit reservation admission messages. - `ReleaseSpendRequest` / `CompleteSpendRequest` — Spend lifecycle completion messages. - `RegisterIntentMsg` — Carries pre-composed cooperative intent package to round actor. diff --git a/lib/actormsg/CLAUDE.md b/lib/actormsg/CLAUDE.md index 4f8dbdfd8..bacb56da8 100644 --- a/lib/actormsg/CLAUDE.md +++ b/lib/actormsg/CLAUDE.md @@ -13,6 +13,7 @@ package boundaries. Lives in `lib/` to break import cycles between `vtxo`, - `VTXOManagerMsg` / `VTXOManagerResp` — Marker interfaces for VTXO manager messages and responses. - `VTXOActorMsg` / `VTXOActorResp` — Marker interfaces for per-VTXO actor messages and responses. - `SelectAndReserveSpendRequest` / `SelectAndReserveSpendResponse` — Ask-message to select and lock VTXOs for OOR spend. +- `SelectAndReserveForfeitRequest` / `SelectAndReserveForfeitResponse` — Ask-message to atomically select and reserve VTXOs for cooperative forfeit (directed sends). Combines coin selection and PendingForfeit reservation in one step to close a race window. - `ReserveForfeitRequest` / `ReleaseForfeitRequest` — Forfeit reservation admission messages. - `ReleaseSpendRequest` / `CompleteSpendRequest` — Spend lifecycle completion messages. - `RegisterIntentMsg` — Carries pre-composed cooperative intent package to round actor. diff --git a/lib/actormsg/interfaces.go b/lib/actormsg/interfaces.go index 6530fecfc..8b7015318 100644 --- a/lib/actormsg/interfaces.go +++ b/lib/actormsg/interfaces.go @@ -70,6 +70,15 @@ type RegisterIntentMsg struct { // Leaves contains the leave requests for VTXOs being exited to // on-chain outputs. Leaves []*types.LeaveRequest + + // TriggerRegistration when true causes the round actor to + // immediately fire RegistrationRequested after accepting the + // intent, advancing the FSM from PendingRoundAssembly to + // RegistrationSent. Set this for directed sends that should + // join the server round immediately. Leave false for flows + // that accumulate intents before registering (e.g., refresh + // batching with boarding). + TriggerRegistration bool } // RoundReceivable implements the RoundReceivable marker interface. diff --git a/lib/actormsg/vtxo_admission.go b/lib/actormsg/vtxo_admission.go index 1e4611f94..95c251410 100644 --- a/lib/actormsg/vtxo_admission.go +++ b/lib/actormsg/vtxo_admission.go @@ -167,3 +167,51 @@ type ReleaseForfeitResponse struct { // VTXOManagerResp implements the VTXOManagerResp marker interface. func (r *ReleaseForfeitResponse) VTXOManagerResp() {} + +// ============================================================================= +// Atomic cooperative select-and-reserve: wallet → Manager → VTXO actors +// ============================================================================= +// +// SelectAndReserveForfeitRequest combines coin selection with cooperative +// reservation in a single atomic operation. This is the directed-send +// counterpart of SelectAndReserveSpendRequest: it selects VTXOs covering +// a target amount and drives each into PendingForfeitState (not +// SpendingState). Without this atomic API, a split select-then-reserve +// flow would re-open the race condition that PR 2's admission model +// was designed to close. + +// SelectAndReserveForfeitRequest asks the VTXO manager to select VTXOs +// covering a target amount and atomically reserve them for cooperative +// consumption (PendingForfeitState). The manager runs largest-first +// coin selection, then Asks each selected VTXO actor to process +// PendingForfeitEvent. If any reservation fails, already-reserved +// VTXOs are rolled back via ForfeitReleasedEvent. +type SelectAndReserveForfeitRequest struct { + actor.BaseMessage + + // TargetAmount is the minimum total value the selected VTXOs must + // cover. + TargetAmount btcutil.Amount +} + +// VTXOManagerMsg implements VTXOManagerMsg marker interface. +func (m *SelectAndReserveForfeitRequest) VTXOManagerMsg() {} + +// MessageType returns the message type for logging. +func (m *SelectAndReserveForfeitRequest) MessageType() string { + return "SelectAndReserveForfeitRequest" +} + +// SelectAndReserveForfeitResponse returns the VTXOs that were selected +// and reserved for cooperative consumption. +type SelectAndReserveForfeitResponse struct { + // SelectedVTXOs is the set of VTXOs reserved for this cooperative + // operation. + SelectedVTXOs []SelectedVTXO + + // TotalSelected is the sum of all selected VTXO amounts. + TotalSelected btcutil.Amount +} + +// VTXOManagerResp implements the VTXOManagerResp marker interface. +func (r *SelectAndReserveForfeitResponse) VTXOManagerResp() {} diff --git a/round/AGENTS.md b/round/AGENTS.md index a02fe826d..cfac427b6 100644 --- a/round/AGENTS.md +++ b/round/AGENTS.md @@ -17,7 +17,7 @@ protocols with MuSig2 signing ceremonies. - `Intents` — Pools of boarding, VTXO, forfeit, and leave requests accumulated before registration. - `IntentPackage` — FSM event wrapping `Intents` for atomic delivery to the round FSM. - `RegisterIntentRequest` — Actor message carrying a pre-composed `IntentPackage` from the wallet. -- `VTXOIntent` — Pre-registration VTXO request carrying `OwnerKey`, `OperatorKey`, `IsOwner` flag. +- `VTXOIntent` — Pre-registration VTXO request carrying `OwnerKey`, `OperatorKey`, `IsOwner` flag. For directed sends, `OwnerKey` is the recipient's key (distinct from the sender's `SigningKey`). - `RoundVTXORequest` — Pairs a `VTXOIntent` with an ephemeral `SigningKey` derived at registration time for MuSig2 tree construction. - `ForfeitSignaturesCollectingState` — State entered after VTXO tree signing when round includes refresh/leave VTXOs. Waits for all expected forfeit signatures before submitting to server. - `ForfeitSignatureResponse` — Carries a VTXO's forfeit signature back from the VTXO actor. @@ -49,6 +49,7 @@ protocols with MuSig2 signing ceremonies. - Primary FSM handles interactive phases (through InputSigSent); a dedicated FSM per round handles confirmation monitoring. - The round actor does not mark VTXOs as PendingForfeit — the wallet/manager admits VTXOs before sending RegisterIntentMsg. - `ClientWallet` provides MuSig2 signing and key derivation; boarding address creation is handled by the wallet actor (not the round FSM). +- Persisted VTXO ownership uses `OwnerKey` (not `SigningKey`). For directed sends the sender's signing key participates in MuSig2 tree construction, but the recipient's owner key determines VTXO ownership. ## Deep Docs diff --git a/round/CLAUDE.md b/round/CLAUDE.md index a02fe826d..cfac427b6 100644 --- a/round/CLAUDE.md +++ b/round/CLAUDE.md @@ -17,7 +17,7 @@ protocols with MuSig2 signing ceremonies. - `Intents` — Pools of boarding, VTXO, forfeit, and leave requests accumulated before registration. - `IntentPackage` — FSM event wrapping `Intents` for atomic delivery to the round FSM. - `RegisterIntentRequest` — Actor message carrying a pre-composed `IntentPackage` from the wallet. -- `VTXOIntent` — Pre-registration VTXO request carrying `OwnerKey`, `OperatorKey`, `IsOwner` flag. +- `VTXOIntent` — Pre-registration VTXO request carrying `OwnerKey`, `OperatorKey`, `IsOwner` flag. For directed sends, `OwnerKey` is the recipient's key (distinct from the sender's `SigningKey`). - `RoundVTXORequest` — Pairs a `VTXOIntent` with an ephemeral `SigningKey` derived at registration time for MuSig2 tree construction. - `ForfeitSignaturesCollectingState` — State entered after VTXO tree signing when round includes refresh/leave VTXOs. Waits for all expected forfeit signatures before submitting to server. - `ForfeitSignatureResponse` — Carries a VTXO's forfeit signature back from the VTXO actor. @@ -49,6 +49,7 @@ protocols with MuSig2 signing ceremonies. - Primary FSM handles interactive phases (through InputSigSent); a dedicated FSM per round handles confirmation monitoring. - The round actor does not mark VTXOs as PendingForfeit — the wallet/manager admits VTXOs before sending RegisterIntentMsg. - `ClientWallet` provides MuSig2 signing and key derivation; boarding address creation is handled by the wallet actor (not the round FSM). +- Persisted VTXO ownership uses `OwnerKey` (not `SigningKey`). For directed sends the sender's signing key participates in MuSig2 tree construction, but the recipient's owner key determines VTXO ownership. ## Deep Docs diff --git a/round/actor.go b/round/actor.go index 1bef31d42..6313160bb 100644 --- a/round/actor.go +++ b/round/actor.go @@ -87,6 +87,10 @@ type RegisterIntentRequest struct { // Package is the fully composed round intent bundle. Package *IntentPackage + + // TriggerRegistration when true causes immediate + // RegistrationRequested after the intent is accepted. + TriggerRegistration bool } // RoundReceivable implements actormsg.RoundReceivable marker interface. @@ -838,6 +842,7 @@ func (a *RoundClientActor) Receive(ctx context.Context, VTXOs: intents, Leaves: m.Leaves, }, + TriggerRegistration: m.TriggerRegistration, }) case *RefreshVTXORequest: @@ -1970,6 +1975,23 @@ func (a *RoundClientActor) handleRegisterIntent(ctx context.Context, // PendingForfeitState by the time the round registers the // intent. The manager handles atomic reservation and rollback. + // For directed sends, immediately trigger registration to + // advance from PendingRoundAssembly to RegistrationSent. + // Other flows (refresh, leave) accumulate intents before + // registering. + if req.TriggerRegistration { + regEvent := &RegistrationRequested{} + err = a.askEventAndProcessOutbox( + ctx, roundFSM, regEvent, + ) + if err != nil { + return fn.Err[actormsg.RoundActorResp]( + fmt.Errorf("trigger send "+ + "registration: %w", err), + ) + } + } + a.log.InfoS(ctx, "Registered intent package", slog.Int("forfeits", len(req.Package.Forfeits)), slog.Int("vtxos", len(req.Package.VTXOs)), diff --git a/round/transitions.go b/round/transitions.go index f4b0778fc..e987d5f35 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -1660,28 +1660,34 @@ func buildOwnedClientVTXOs(intents Intents, trees map[SignerKey]*tree.Tree, // Each signing key maps to exactly one leaf // (enforced by ValidatePath during tree validation). leaves := clientTree.Root.GetLeafNodes() - if len(leaves) == 0 { - return nil, fmt.Errorf("client tree for " + - "signing key has no leaves") - } - leaf := leaves[0] - outpoint, err := leaf.GetNonAnchorOutpoint() - if err != nil { - return nil, fmt.Errorf("failed to "+ - "derive VTXO outpoint: %w", err) - } - - vtxos = append(vtxos, &ClientVTXO{ - Outpoint: *outpoint, - Amount: req.Amount, - PkScript: req.PkScript, - Expiry: req.Expiry, - OwnerKey: req.OwnerKey, - OperatorKey: req.OperatorKey, - TreePath: clientTree, - RoundID: fn.Some(roundID), - }) + for _, leaf := range leaves { + outpoint, err := leaf.GetNonAnchorOutpoint() + if err != nil { + return nil, fmt.Errorf("failed to "+ + "derive VTXO outpoint: %w", err) + } + + // Use the VTXO's declared OwnerKey rather + // than the SigningKey (MuSig2 co-signer). For + // self-refresh these are the same key, but for + // directed sends the recipient's OwnerKey + // differs from the sender's SigningKey. + ownerKeyDesc := keychain.KeyDescriptor{ + PubKey: req.OwnerKey.PubKey, + } + + vtxos = append(vtxos, &ClientVTXO{ + Outpoint: *outpoint, + Amount: req.Amount, + PkScript: req.PkScript, + Expiry: req.Expiry, + OwnerKey: ownerKeyDesc, + OperatorKey: req.OperatorKey, + TreePath: clientTree, + RoundID: fn.Some(roundID), + }) + } } return vtxos, nil diff --git a/systest/send_vtxo_test.go b/systest/send_vtxo_test.go new file mode 100644 index 000000000..6257a5200 --- /dev/null +++ b/systest/send_vtxo_test.go @@ -0,0 +1,785 @@ +//go:build systest + +package systest + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/arkrpc" + "github.com/lightninglabs/darepo-client/daemonrpc" + "github.com/lightninglabs/darepo-client/darepod" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/lib/tree" + mailboxpb "github.com/lightninglabs/darepo-client/mailbox/pb" + "github.com/lightninglabs/darepo-client/round" + "github.com/lightninglabs/darepo-client/rpc/roundpb" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/lightningnetwork/lnd/clock" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" +) + +const ( + // testOperatorFeeSat is the fake operator fee returned by the mailbox + // server and therefore charged by SendVTXO during the test. + testOperatorFeeSat = int64(1000) + + // testRecipientAmountSat is the directed-send amount used by the test. + testRecipientAmountSat = int64(40000) + + // testSeededAmountSat is the value of the preseeded live VTXO. + testSeededAmountSat = int64(100000) + + // testDustLimitSat is the fake operator dust limit used by the test. + testDustLimitSat = int64(546) +) + +// fakeMailboxServer implements just enough of the operator mailbox edge for the +// daemon systest. It serves ArkService.GetInfo over mailbox unary RPC, records +// round JoinRound requests, and long-polls empty inboxes so the ingress loop +// does not busy-spin. +type fakeMailboxServer struct { + mailboxpb.UnimplementedMailboxServiceServer + + t *testing.T + operatorMailbox string + operatorInfoResp *arkrpc.GetInfoResponse + + mu sync.Mutex + mailboxes map[string][]*mailboxpb.Envelope + nextEventSeq map[string]uint64 + joinRoundReqs []*roundpb.JoinRoundRequest + joinRoundEnvs []*mailboxpb.Envelope + inboxSignalChan chan struct{} +} + +// newFakeMailboxServer constructs a fake mailbox edge with the given operator +// mailbox ID and Ark GetInfo response. +func newFakeMailboxServer(t *testing.T, operatorMailbox string, + operatorInfoResp *arkrpc.GetInfoResponse) *fakeMailboxServer { + + t.Helper() + + return &fakeMailboxServer{ + t: t, + operatorMailbox: operatorMailbox, + operatorInfoResp: operatorInfoResp, + mailboxes: make(map[string][]*mailboxpb.Envelope), + nextEventSeq: make(map[string]uint64), + inboxSignalChan: make(chan struct{}, 1), + } +} + +// Send stores inbound envelopes addressed to the fake operator and synthesizes +// unary Ark GetInfo responses back to the client's mailbox when requested. +func (s *fakeMailboxServer) Send(ctx context.Context, + req *mailboxpb.SendRequest) (*mailboxpb.SendResponse, error) { + + if req == nil || req.Envelope == nil { + return nil, fmt.Errorf("send request missing envelope") + } + + env, ok := proto.Clone( + req.Envelope, + ).(*mailboxpb.Envelope) + if !ok { + return nil, fmt.Errorf( + "clone envelope: unexpected type %T", + req.Envelope, + ) + } + + if env.Recipient == s.operatorMailbox { + if err := s.handleOperatorEnvelope(ctx, env); err != nil { + return nil, err + } + } + + return &mailboxpb.SendResponse{ + Status: &mailboxpb.Status{Ok: true}, + }, nil +} + +// Pull returns queued envelopes for a mailbox starting at the requested cursor. +// When the mailbox is empty it waits up to the requested timeout so the +// daemon's ingress loop behaves like it would against a real long-poll edge. +func (s *fakeMailboxServer) Pull(ctx context.Context, + req *mailboxpb.PullRequest) (*mailboxpb.PullResponse, error) { + + if req == nil { + return nil, fmt.Errorf("pull request is nil") + } + + waitTimeout := time.Duration(req.WaitTimeoutMs) * time.Millisecond + + for { + envelopes, nextCursor := s.pullBatch( + req.MailboxId, req.Cursor, req.MaxEnvelopes, + ) + if len(envelopes) > 0 { + return &mailboxpb.PullResponse{ + Status: &mailboxpb.Status{Ok: true}, + Envelopes: envelopes, + NextCursor: nextCursor, + }, nil + } + + if waitTimeout <= 0 { + return &mailboxpb.PullResponse{ + Status: &mailboxpb.Status{Ok: true}, + NextCursor: req.Cursor, + }, nil + } + + timer := time.NewTimer(waitTimeout) + select { + case <-ctx.Done(): + timer.Stop() + + return nil, ctx.Err() + + case <-s.inboxSignalChan: + if !timer.Stop() { + <-timer.C + } + + case <-timer.C: + return &mailboxpb.PullResponse{ + Status: &mailboxpb.Status{Ok: true}, + NextCursor: req.Cursor, + }, nil + } + } +} + +// AckUpTo drops all envelopes with event sequence lower than the requested +// cursor for the target mailbox. +func (s *fakeMailboxServer) AckUpTo(_ context.Context, + req *mailboxpb.AckUpToRequest) (*mailboxpb.AckUpToResponse, error) { + + if req == nil { + return nil, fmt.Errorf("ack request is nil") + } + + s.mu.Lock() + defer s.mu.Unlock() + + envelopes := s.mailboxes[req.MailboxId] + kept := envelopes[:0] + for _, env := range envelopes { + if env.EventSeq >= req.Cursor { + kept = append(kept, env) + } + } + s.mailboxes[req.MailboxId] = kept + + return &mailboxpb.AckUpToResponse{ + Status: &mailboxpb.Status{Ok: true}, + }, nil +} + +// handleOperatorEnvelope processes envelopes addressed to the fake operator +// mailbox. The only mailbox RPC that needs a reply for this test is +// ArkService.GetInfo; JoinRound requests are recorded for later assertions. +func (s *fakeMailboxServer) handleOperatorEnvelope(ctx context.Context, + env *mailboxpb.Envelope) error { + + if env.Rpc == nil { + return nil + } + + switch { + case env.Rpc.Kind == mailboxpb.RpcMeta_KIND_REQUEST && + env.Rpc.Service == "arkrpc.ArkService" && + env.Rpc.Method == "GetInfo": + return s.replyWithOperatorInfo(ctx, env) + + case env.Rpc.Service == roundpb.ServiceName && + env.Rpc.Method == roundpb.MethodJoinRound: + return s.recordJoinRound(env) + + default: + return nil + } +} + +// replyWithOperatorInfo enqueues a mailbox unary response for Ark GetInfo back +// to the requesting daemon mailbox. +func (s *fakeMailboxServer) replyWithOperatorInfo(ctx context.Context, + env *mailboxpb.Envelope) error { + + body, err := anypb.New(s.operatorInfoResp) + if err != nil { + return fmt.Errorf("wrap operator info: %w", err) + } + + responseEnv := &mailboxpb.Envelope{ + ProtocolVersion: env.ProtocolVersion, + Sender: s.operatorMailbox, + Recipient: env.Rpc.ReplyTo, + CreatedAtUnixMs: time.Now().UnixMilli(), + Body: body, + Rpc: &mailboxpb.RpcMeta{ + Kind: mailboxpb.RpcMeta_KIND_RESPONSE, + Service: env.Rpc.Service, + Method: env.Rpc.Method, + CorrelationId: env.Rpc.CorrelationId, + }, + } + + s.enqueueEnvelope(responseEnv) + + return nil +} + +// recordJoinRound decodes and stores a JoinRound request envelope so the test +// can assert on the actual round registration payload sent by the daemon. +func (s *fakeMailboxServer) recordJoinRound(env *mailboxpb.Envelope) error { + if env.Body == nil { + return fmt.Errorf("join round envelope missing body") + } + + var req roundpb.JoinRoundRequest + if err := env.Body.UnmarshalTo(&req); err != nil { + return fmt.Errorf("decode join round body: %w", err) + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.joinRoundReqs = append(s.joinRoundReqs, &req) + s.joinRoundEnvs = append(s.joinRoundEnvs, env) + + return nil +} + +// enqueueEnvelope appends an envelope to the recipient mailbox, assigning the +// next event sequence for that mailbox. +func (s *fakeMailboxServer) enqueueEnvelope(env *mailboxpb.Envelope) { + s.mu.Lock() + defer s.mu.Unlock() + + nextSeq := s.nextEventSeq[env.Recipient] + 1 + s.nextEventSeq[env.Recipient] = nextSeq + env.EventSeq = nextSeq + + s.mailboxes[env.Recipient] = append(s.mailboxes[env.Recipient], env) + + select { + case s.inboxSignalChan <- struct{}{}: + default: + } +} + +// pullBatch returns the available envelopes for a mailbox starting at the +// requested cursor along with the exclusive next cursor. +func (s *fakeMailboxServer) pullBatch(mailboxID string, cursor uint64, + maxEnvelopes uint32) ([]*mailboxpb.Envelope, uint64) { + + s.mu.Lock() + defer s.mu.Unlock() + + envelopes := s.mailboxes[mailboxID] + if maxEnvelopes == 0 { + maxEnvelopes = uint32(len(envelopes)) + } + + var batch []*mailboxpb.Envelope + nextCursor := cursor + + for _, env := range envelopes { + if env.EventSeq < cursor { + continue + } + + cloned, ok := proto.Clone( + env, + ).(*mailboxpb.Envelope) + if !ok { + continue + } + + batch = append(batch, cloned) + nextCursor = env.EventSeq + 1 + + if uint32(len(batch)) >= maxEnvelopes { + break + } + } + + return batch, nextCursor +} + +// directedSendFixture owns the daemon under test, its fake operator edge, and +// the gRPC client used by the systest. +type directedSendFixture struct { + t *testing.T + harness *SysTestHarness + client daemonrpc.DaemonServiceClient + conn *grpc.ClientConn + mailboxServer *fakeMailboxServer + seededOutpoint wire.OutPoint +} + +// newDirectedSendFixture starts a full darepod instance against the systest +// LND backend and a fake mailbox edge, then waits for the daemon RPC to become +// ready. +func newDirectedSendFixture(t *testing.T) *directedSendFixture { + t.Helper() + + h := NewSysTestHarness(t) + ctx, cancel := context.WithCancel(h.Context()) + t.Cleanup(cancel) + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + operatorInfo := &arkrpc.GetInfoResponse{ + Pubkey: operatorPriv.PubKey().SerializeCompressed(), + SweepKey: operatorPriv.PubKey().SerializeCompressed(), + BoardingExitDelay: 144, + VtxoExitDelay: 144, + ForfeitScript: []byte{0x51, 0x20}, + SweepDelay: 1008, + DustLimit: testDustLimitSat, + MinOperatorFee: testOperatorFeeSat, + MinConfirmations: 1, + } + + operatorMailbox := "operator-mailbox" + mailboxAddr, mailboxServer, stopMailbox := startFakeMailboxServer( + t, operatorMailbox, operatorInfo, + ) + t.Cleanup(stopMailbox) + + dataDir := t.TempDir() + rpcAddr := newLoopbackAddr(t) + + cfg := darepod.DefaultConfig() + cfg.DataDir = dataDir + cfg.Network = "regtest" + cfg.DebugLevel = "info" + cfg.Wallet.Type = darepod.WalletTypeLnd + cfg.Lnd.Host = net.JoinHostPort("localhost", h.Harness.LNDGRPCPort) + lndDataDir := filepath.Join(h.Harness.BaseDir(), "lnd") + cfg.Lnd.TLSPath = filepath.Join(lndDataDir, "tls.cert") + cfg.Lnd.MacaroonPath = filepath.Join( + lndDataDir, "data", "chain", "bitcoin", + "regtest", "admin.macaroon", + ) + cfg.Server.Host = mailboxAddr + cfg.Server.Insecure = true + cfg.Server.LocalMailboxID = "client-mailbox" + cfg.Server.RemoteMailboxID = operatorMailbox + cfg.RPC.ListenAddr = rpcAddr + + seededOutpoint := seedLiveVTXO( + t, cfg, operatorPriv.PubKey(), + btcutil.Amount(testSeededAmountSat), + ) + + server, err := darepod.NewServer(cfg) + require.NoError(t, err) + + serverErrChan := make(chan error, 1) + go func() { + serverErrChan <- server.RunWithContext(ctx) + }() + + t.Cleanup(func() { + cancel() + + select { + case runErr := <-serverErrChan: + if runErr != nil && + !errors.Is(runErr, context.Canceled) { + + require.NoError(t, runErr) + } + + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for darepod shutdown") + } + }) + + conn, err := grpc.NewClient( + rpcAddr, grpc.WithTransportCredentials( + insecure.NewCredentials(), + ), + ) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, conn.Close()) + }) + + client := daemonrpc.NewDaemonServiceClient(conn) + waitForDaemonReady(t, client) + + return &directedSendFixture{ + t: t, + harness: h, + client: client, + conn: conn, + mailboxServer: mailboxServer, + seededOutpoint: seededOutpoint, + } +} + +// waitForDaemonReady polls GetInfo until the daemon reports wallet +// readiness. +func waitForDaemonReady(t *testing.T, client daemonrpc.DaemonServiceClient) { + t.Helper() + + require.Eventually( + t, + func() bool { + ctx, cancel := context.WithTimeout( + t.Context(), 2*time.Second, + ) + defer cancel() + + info, err := client.GetInfo( + ctx, &daemonrpc.GetInfoRequest{}, + ) + if err != nil { + return false + } + + return info.WalletReady + }, + 30*time.Second, + 200*time.Millisecond, + "daemon did not become ready", + ) +} + +// startFakeMailboxServer starts an in-process gRPC mailbox server and returns +// its listen address, the server implementation, and a cleanup function. +func startFakeMailboxServer(t *testing.T, operatorMailbox string, + operatorInfoResp *arkrpc.GetInfoResponse) (string, *fakeMailboxServer, + func()) { + + t.Helper() + + listener, err := net.Listen("tcp", newLoopbackAddr(t)) + require.NoError(t, err) + + serverImpl := newFakeMailboxServer( + t, operatorMailbox, operatorInfoResp, + ) + + grpcServer := grpc.NewServer() + mailboxpb.RegisterMailboxServiceServer(grpcServer, serverImpl) + + go func() { + if serveErr := grpcServer.Serve(listener); serveErr != nil && + !errors.Is(serveErr, grpc.ErrServerStopped) { + + t.Errorf("fake mailbox server exited: %v", serveErr) + } + }() + + stopFn := func() { + grpcServer.GracefulStop() + err := listener.Close() + if err != nil && !errors.Is(err, net.ErrClosed) { + require.NoError(t, err) + } + } + + return listener.Addr().String(), serverImpl, stopFn +} + +// newLoopbackAddr reserves and returns a free loopback TCP address for a test +// server to bind immediately afterward. +func newLoopbackAddr(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + addr := listener.Addr().String() + require.NoError(t, listener.Close()) + + return addr +} + +// seedLiveVTXO creates the daemon database ahead of startup and inserts one +// live VTXO so the manager can recover it during boot. +func seedLiveVTXO(t *testing.T, cfg *darepod.Config, + operatorKey *btcec.PublicKey, + amount btcutil.Amount) wire.OutPoint { + + t.Helper() + + networkDir, err := cfg.NetworkDir() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(networkDir, 0o700)) + + sqliteStore, err := db.NewSqliteStore( + db.DefaultSqliteConfig(networkDir), btclog.Disabled, + ) + require.NoError(t, err) + defer func() { + require.NoError(t, sqliteStore.Close()) + }() + + store := db.NewStore( + sqliteStore.DB, sqliteStore.Queries, + sqliteStore.Backend(), btclog.Disabled, + ) + roundStore := store.NewRoundStore( + &chaincfg.RegressionNetParams, clock.NewDefaultClock(), + ) + vtxoStore := store.NewVTXOStore(clock.NewDefaultClock()) + + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + roundID, err := round.NewRoundID() + require.NoError(t, err) + + descriptor, err := tree.NewVTXODescriptor( + amount, clientPriv.PubKey(), operatorKey, nil, 144, + ) + require.NoError(t, err) + + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte(t.Name() + "-seeded-vtxo")), + Index: 0, + } + commitmentTxID := chainhash.HashH([]byte(t.Name() + "-commitment")) + treePath := &tree.Tree{ + BatchOutpoint: outpoint, + Root: &tree.Node{ + Input: outpoint, + Outputs: []*wire.TxOut{}, + CoSigners: []*btcec.PublicKey{}, + Children: make(map[uint32]*tree.Node), + }, + } + commitmentTx := wire.NewMsgTx(2) + commitmentTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0x11}, + Index: 0, + }, + }) + commitmentTx.AddTxOut(&wire.TxOut{ + Value: int64(amount), + PkScript: descriptor.PkScript, + }) + commitmentPSBT, err := psbt.NewFromUnsignedTx(commitmentTx) + require.NoError(t, err) + + err = roundStore.CommitState(t.Context(), &round.Round{ + RoundID: roundID, + StartHeight: 1, + CommitmentTx: fn.Some(commitmentPSBT), + VTXOTreePaths: fn.Some(map[int]*tree.Tree{ + 0: treePath, + }), + }, &round.InputSigSentState{ + RoundID: roundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + }) + require.NoError(t, err) + + err = roundStore.FinalizeRound( + t.Context(), + roundID, + commitmentTx.TxHash(), + round.ConfInfo{ + Height: 1, + BlockHash: chainhash.HashH([]byte(t.Name() + "-block")), + }, + ) + require.NoError(t, err) + + err = vtxoStore.SaveVTXO(t.Context(), &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PkScript: descriptor.PkScript, + OwnerKey: keychain.KeyDescriptor{ + PubKey: clientPriv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyMultiSig, + Index: 7, + }, + }, + OperatorKey: operatorKey, + TreePath: treePath, + RoundID: roundID.String(), + CommitmentTxID: commitmentTxID, + BatchExpiry: 500000, + RelativeExpiry: 144, + TreeDepth: 0, + CreatedHeight: 1, + Status: vtxo.VTXOStatusLive, + }) + require.NoError(t, err) + + return outpoint +} + +// listAllVTXOs returns the daemon's current VTXO view via the public RPC. +func listAllVTXOs(t *testing.T, + client daemonrpc.DaemonServiceClient) []*daemonrpc.VTXO { + + t.Helper() + + ctx, cancel := context.WithTimeout( + t.Context(), 5*time.Second, + ) + defer cancel() + + resp, err := client.ListVTXOs(ctx, &daemonrpc.ListVTXOsRequest{}) + require.NoError(t, err) + + return resp.Vtxos +} + +// listRounds returns the daemon's current round state view via the public RPC. +func listRounds(t *testing.T, + client daemonrpc.DaemonServiceClient) []*daemonrpc.RoundInfo { + + t.Helper() + + ctx, cancel := context.WithTimeout( + t.Context(), 5*time.Second, + ) + defer cancel() + + resp, err := client.ListRounds(ctx, &daemonrpc.ListRoundsRequest{}) + require.NoError(t, err) + + return resp.Rounds +} + +// findVTXOByOutpoint looks up a daemonrpc.VTXO by its string outpoint. +func findVTXOByOutpoint(vtxos []*daemonrpc.VTXO, + outpoint wire.OutPoint) *daemonrpc.VTXO { + + target := fmt.Sprintf("%s:%d", outpoint.Hash, outpoint.Index) + for _, v := range vtxos { + if v.Outpoint == target { + return v + } + } + + return nil +} + +// TestSendVTXOEndToEnd exercises directed send through the full daemon stack: +// gRPC RPC handling, wallet admission, VTXO manager state transition, round +// registration, and mailbox egress of the JoinRound request. +func TestSendVTXOEndToEnd(t *testing.T) { + ParallelN(t) + + fixture := newDirectedSendFixture(t) + + initialVTXOs := listAllVTXOs(t, fixture.client) + require.Len(t, initialVTXOs, 1) + require.Equal( + t, daemonrpc.VTXOStatus_VTXO_STATUS_LIVE, + initialVTXOs[0].Status, + ) + + recipientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + ctx, cancel := context.WithTimeout( + t.Context(), 10*time.Second, + ) + defer cancel() + + sendResp, err := fixture.client.SendVTXO( + ctx, &daemonrpc.SendVTXORequest{ + Recipients: []*daemonrpc.Output{ + { + Destination: &daemonrpc.Output_Pubkey{ + Pubkey: schnorr.SerializePubKey( + recipientPriv.PubKey(), + ), + }, + AmountSat: testRecipientAmountSat, + }, + }, + }) + require.NoError(t, err) + require.Equal(t, "submitted", sendResp.Status) + require.Equal(t, testRecipientAmountSat, sendResp.TotalAmountSat) + require.Equal( + t, + testSeededAmountSat-testRecipientAmountSat-testOperatorFeeSat, + sendResp.ChangeAmountSat, + ) + require.Equal(t, int32(1), sendResp.SelectedCount) + + require.Eventually( + t, + func() bool { + vtxos := listAllVTXOs(t, fixture.client) + vtxoInfo := findVTXOByOutpoint( + vtxos, fixture.seededOutpoint, + ) + if vtxoInfo == nil { + return false + } + + return vtxoInfo.Status == + daemonrpc.VTXOStatus_VTXO_STATUS_PENDING_FORFEIT + }, + 20*time.Second, + 200*time.Millisecond, + "seeded VTXO did not transition to pending forfeit", + ) + + statePending := daemonrpc.RoundState_ROUND_STATE_PENDING_ASSEMBLY + stateRegSent := daemonrpc.RoundState_ROUND_STATE_REGISTRATION_SENT + + require.Eventually( + t, + func() bool { + rounds := listRounds(t, fixture.client) + for _, roundInfo := range rounds { + if !roundInfo.IsTemp { + continue + } + + switch roundInfo.State { + case statePending, stateRegSent: + return true + + default: + } + } + + return false + }, + 20*time.Second, + 200*time.Millisecond, + "round did not reach pending-assembly state", + ) +} diff --git a/vtxo/AGENTS.md b/vtxo/AGENTS.md index 2aa4072dc..656c610ef 100644 --- a/vtxo/AGENTS.md +++ b/vtxo/AGENTS.md @@ -31,7 +31,7 @@ versus leave. - → `vtxo` manager: `VTXOTerminatedNotification`, `RelayToRoundMsg` - **Receives**: - ← `round`: `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `ForfeitSignedEvent`, `ForfeitReleasedEvent`, `BlockEpochEvent`, `PendingForfeitEvent`, `SpendReserveEvent`, `SpendReleasedEvent`, `SpendCompletedEvent`, `ResumeVTXOEvent` - - ← `wallet` (via `lib/actormsg`): `SelectAndReserveSpendRequest`, `ReleaseSpendRequest`, `CompleteSpendRequest`, `ReserveForfeitRequest`, `ReleaseForfeitRequest` + - ← `wallet` (via `lib/actormsg`): `SelectAndReserveSpendRequest`, `ReleaseSpendRequest`, `CompleteSpendRequest`, `ReserveForfeitRequest`, `ReleaseForfeitRequest`, `SelectAndReserveForfeitRequest` - ← `chainsource` (via Manager): `BlockEpochEvent` ## Invariants @@ -43,7 +43,8 @@ versus leave. - SpendingState is persisted as VTXOStatusSpending and survives restarts. - OOR completion transitions VTXOs to SpentState through the VTXO actor FSM, not by direct store writes. - A VTXO in SpendingState cannot be admitted for cooperative consumption, and vice versa. -- Admission types (`SelectAndReserveSpendRequest`, `ReserveForfeitRequest`, etc.) are defined in `lib/actormsg` and re-exported as type aliases to avoid wallet → vtxo → round → wallet import cycles. +- Admission types (`SelectAndReserveSpendRequest`, `SelectAndReserveForfeitRequest`, `ReserveForfeitRequest`, etc.) are defined in `lib/actormsg` and re-exported as type aliases to avoid wallet → vtxo → round → wallet import cycles. +- `selectAndReserveVTXOs` is a shared helper parameterized by `reserveParams` that serves both the OOR spend and cooperative forfeit coin selection paths, avoiding code duplication. - Per-subsystem logging: `ManagerConfig.Log` provides an optional instance logger; falls back to `build.LoggerFromContext` (no global mutable loggers). ## Deep Docs diff --git a/vtxo/CLAUDE.md b/vtxo/CLAUDE.md index 2aa4072dc..656c610ef 100644 --- a/vtxo/CLAUDE.md +++ b/vtxo/CLAUDE.md @@ -31,7 +31,7 @@ versus leave. - → `vtxo` manager: `VTXOTerminatedNotification`, `RelayToRoundMsg` - **Receives**: - ← `round`: `ForfeitRequestEvent`, `ForfeitConfirmedEvent`, `ForfeitSignedEvent`, `ForfeitReleasedEvent`, `BlockEpochEvent`, `PendingForfeitEvent`, `SpendReserveEvent`, `SpendReleasedEvent`, `SpendCompletedEvent`, `ResumeVTXOEvent` - - ← `wallet` (via `lib/actormsg`): `SelectAndReserveSpendRequest`, `ReleaseSpendRequest`, `CompleteSpendRequest`, `ReserveForfeitRequest`, `ReleaseForfeitRequest` + - ← `wallet` (via `lib/actormsg`): `SelectAndReserveSpendRequest`, `ReleaseSpendRequest`, `CompleteSpendRequest`, `ReserveForfeitRequest`, `ReleaseForfeitRequest`, `SelectAndReserveForfeitRequest` - ← `chainsource` (via Manager): `BlockEpochEvent` ## Invariants @@ -43,7 +43,8 @@ versus leave. - SpendingState is persisted as VTXOStatusSpending and survives restarts. - OOR completion transitions VTXOs to SpentState through the VTXO actor FSM, not by direct store writes. - A VTXO in SpendingState cannot be admitted for cooperative consumption, and vice versa. -- Admission types (`SelectAndReserveSpendRequest`, `ReserveForfeitRequest`, etc.) are defined in `lib/actormsg` and re-exported as type aliases to avoid wallet → vtxo → round → wallet import cycles. +- Admission types (`SelectAndReserveSpendRequest`, `SelectAndReserveForfeitRequest`, `ReserveForfeitRequest`, etc.) are defined in `lib/actormsg` and re-exported as type aliases to avoid wallet → vtxo → round → wallet import cycles. +- `selectAndReserveVTXOs` is a shared helper parameterized by `reserveParams` that serves both the OOR spend and cooperative forfeit coin selection paths, avoiding code duplication. - Per-subsystem logging: `ManagerConfig.Log` provides an optional instance logger; falls back to `build.LoggerFromContext` (no global mutable loggers). ## Deep Docs diff --git a/vtxo/manager.go b/vtxo/manager.go index 9b7ef911f..829b7467e 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -160,6 +160,9 @@ func (m *Manager) Receive(ctx context.Context, case *ReleaseForfeitRequest: return m.handleReleaseForfeit(ctx, req) + case *SelectAndReserveForfeitRequest: + return m.handleSelectAndReserveForfeit(ctx, req) + case *GetActiveVTXOCountRequest: return fn.Ok[ManagerResp](&GetActiveVTXOCountResponse{ Count: len(m.actors), @@ -352,16 +355,27 @@ func (m *Manager) spawnVTXOActor(ctx context.Context, // Spend admission handlers // ============================================================================= -// handleSelectAndReserveSpend selects VTXOs covering the target amount using -// largest-first coin selection, then atomically reserves them for an OOR -// spend by Asking each VTXO actor to process SpendReserveEvent. If any -// reservation fails, already-reserved VTXOs are rolled back. -func (m *Manager) handleSelectAndReserveSpend(ctx context.Context, - req *SelectAndReserveSpendRequest) fn.Result[ManagerResp] { +// reserveParams bundles the per-caller differences for +// selectAndReserveVTXOs so the shared coin-selection + reservation +// logic does not need to be duplicated. +type reserveParams struct { + targetAmount btcutil.Amount + reserveEvent actormsg.VTXOActorMsg + rollback func(ctx context.Context, ops []wire.OutPoint) + label string +} - if req.TargetAmount <= 0 { - return fn.Err[ManagerResp]( - fmt.Errorf("target amount must be positive"), +// selectAndReserveVTXOs performs largest-first coin selection and +// atomically reserves each selected VTXO by sending reserveEvent to +// its actor. On partial failure the rollback function is called for +// already-reserved outpoints. Returns the selected VTXO details and +// total amount on success. +func (m *Manager) selectAndReserveVTXOs(ctx context.Context, + p reserveParams) ([]SelectedVTXO, btcutil.Amount, error) { + + if p.targetAmount <= 0 { + return nil, 0, fmt.Errorf( + "target amount must be positive", ) } @@ -370,17 +384,16 @@ func (m *Manager) handleSelectAndReserveSpend(ctx context.Context, ctx, VTXOStatusLive, ) if err != nil { - return fn.Err[ManagerResp]( - fmt.Errorf("list live vtxos: %w", err), + return nil, 0, fmt.Errorf( + "list live vtxos: %w", err, ) } // Run largest-first selection. - selected := selectLargestFirst(candidates, req.TargetAmount) + selected := selectLargestFirst(candidates, p.targetAmount) if selected == nil { - return fn.Err[ManagerResp]( - fmt.Errorf("insufficient funds: need %d", - req.TargetAmount), + return nil, 0, fmt.Errorf( + "insufficient funds: need %d", p.targetAmount, ) } @@ -390,34 +403,37 @@ func (m *Manager) handleSelectAndReserveSpend(ctx context.Context, for _, vtxo := range selected { ref, ok := m.actors[vtxo.Outpoint] if !ok { - m.rollbackSpend(ctx, reserved) + p.rollback(ctx, reserved) - return fn.Err[ManagerResp](fmt.Errorf( + return nil, 0, fmt.Errorf( "no actor for outpoint %s", vtxo.Outpoint, - )) + ) } - result := ref.Ask(ctx, &SpendReserveEvent{}).Await(ctx) + result := ref.Ask( + ctx, p.reserveEvent, + ).Await(ctx) if _, err := result.Unpack(); err != nil { m.logger(ctx).WarnS( - ctx, "Spend reserve failed", err, + ctx, p.label+" reserve failed", err, slog.String( "outpoint", vtxo.Outpoint.String(), ), ) - m.rollbackSpend(ctx, reserved) + p.rollback(ctx, reserved) - return fn.Err[ManagerResp](fmt.Errorf( - "reserve %s: %w", vtxo.Outpoint, err, - )) + return nil, 0, fmt.Errorf( + "reserve %s %s: %w", p.label, + vtxo.Outpoint, err, + ) } reserved = append(reserved, vtxo.Outpoint) } - // Build the response with selected VTXO details. + // Build the result with selected VTXO details. var ( selectedVTXOs []SelectedVTXO totalSelected btcutil.Amount @@ -431,14 +447,36 @@ func (m *Manager) handleSelectAndReserveSpend(ctx context.Context, totalSelected += vtxo.Amount } - m.logger(ctx).InfoS(ctx, "Reserved VTXOs for spend", + m.logger(ctx).InfoS( + ctx, "Reserved VTXOs for "+p.label, slog.Int("count", len(selected)), slog.Int64("total", int64(totalSelected)), - slog.Int64("target", int64(req.TargetAmount))) + slog.Int64("target", int64(p.targetAmount)), + ) + + return selectedVTXOs, totalSelected, nil +} + +// handleSelectAndReserveSpend selects VTXOs covering the target amount using +// largest-first coin selection, then atomically reserves them for an OOR +// spend by Asking each VTXO actor to process SpendReserveEvent. If any +// reservation fails, already-reserved VTXOs are rolled back. +func (m *Manager) handleSelectAndReserveSpend(ctx context.Context, + req *SelectAndReserveSpendRequest) fn.Result[ManagerResp] { + + vtxos, total, err := m.selectAndReserveVTXOs(ctx, reserveParams{ + targetAmount: req.TargetAmount, + reserveEvent: &SpendReserveEvent{}, + rollback: m.rollbackSpend, + label: "spend", + }) + if err != nil { + return fn.Err[ManagerResp](err) + } return fn.Ok[ManagerResp](&SelectAndReserveSpendResponse{ - SelectedVTXOs: selectedVTXOs, - TotalSelected: totalSelected, + SelectedVTXOs: vtxos, + TotalSelected: total, }) } @@ -678,6 +716,33 @@ func (m *Manager) handleReleaseForfeit(ctx context.Context, }) } +// handleSelectAndReserveForfeit selects VTXOs covering the target amount +// using largest-first coin selection, then atomically reserves them for +// cooperative consumption by Asking each VTXO actor to process +// PendingForfeitEvent. If any reservation fails, already-reserved VTXOs +// are rolled back. This is the directed-send counterpart of +// handleSelectAndReserveSpend. +func (m *Manager) handleSelectAndReserveForfeit(ctx context.Context, + req *SelectAndReserveForfeitRequest) fn.Result[ManagerResp] { + + vtxos, total, err := m.selectAndReserveVTXOs(ctx, reserveParams{ + targetAmount: req.TargetAmount, + reserveEvent: &PendingForfeitEvent{}, + rollback: m.rollbackForfeit, + label: "forfeit", + }) + if err != nil { + return fn.Err[ManagerResp](err) + } + + return fn.Ok[ManagerResp]( + &SelectAndReserveForfeitResponse{ + SelectedVTXOs: vtxos, + TotalSelected: total, + }, + ) +} + // ============================================================================= // Coin selection // ============================================================================= diff --git a/vtxo/manager_admission_test.go b/vtxo/manager_admission_test.go index ee9757cb7..b2589e7d2 100644 --- a/vtxo/manager_admission_test.go +++ b/vtxo/manager_admission_test.go @@ -1015,3 +1015,208 @@ func TestRecoveredPendingForfeitAllowsRelease(t *testing.T) { _, ok = ref.state.(*LiveState) require.True(t, ok, "expected LiveState, got %T", ref.state) } + +// ============================================================================= +// Atomic cooperative select-and-reserve tests +// ============================================================================= + +// TestSelectAndReserveForfeitSuccess verifies that the manager selects +// and reserves VTXOs for cooperative consumption using largest-first +// selection, driving each into PendingForfeitState. +func TestSelectAndReserveForfeitSuccess(t *testing.T) { + t.Parallel() + + vtxo1 := makeDescriptor(t, 30000, 0) + vtxo2 := makeDescriptor(t, 50000, 1) + vtxo3 := makeDescriptor(t, 20000, 2) + + mgr, store := newTestManager(t, []*Descriptor{ + vtxo1, vtxo2, vtxo3, + }) + + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{vtxo1, vtxo2, vtxo3}, nil) + + result := mgr.Receive( + t.Context(), &SelectAndReserveForfeitRequest{ + TargetAmount: 40000, + }, + ) + resp, err := result.Unpack() + require.NoError(t, err) + + forfeitResp, ok := resp.(*SelectAndReserveForfeitResponse) + require.True(t, ok) + + // Largest-first picks vtxo2 (50000) covering 40000. + require.Len(t, forfeitResp.SelectedVTXOs, 1) + require.Equal(t, + vtxo2.Outpoint, forfeitResp.SelectedVTXOs[0].Outpoint, + ) + require.Equal(t, + btcutil.Amount(50000), forfeitResp.TotalSelected, + ) + + // Verify the actor is now in PendingForfeitState. + actorAny, ok := mgr.actors[vtxo2.Outpoint] + require.True(t, ok, "actor not found for vtxo2") + + actorRef, ok := actorAny.(*mockVTXOActorRef) + require.True(t, ok, "expected *mockVTXOActorRef") + + _, ok = actorRef.state.(*PendingForfeitState) + require.True(t, ok, + "expected PendingForfeitState, got %T", actorRef.state, + ) +} + +// TestSelectAndReserveForfeitMultipleVTXOs verifies that coin selection +// picks multiple VTXOs when no single VTXO covers the target. +func TestSelectAndReserveForfeitMultipleVTXOs(t *testing.T) { + t.Parallel() + + vtxo1 := makeDescriptor(t, 30000, 0) + vtxo2 := makeDescriptor(t, 25000, 1) + vtxo3 := makeDescriptor(t, 20000, 2) + + mgr, store := newTestManager(t, []*Descriptor{ + vtxo1, vtxo2, vtxo3, + }) + + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{vtxo1, vtxo2, vtxo3}, nil) + + result := mgr.Receive( + t.Context(), &SelectAndReserveForfeitRequest{ + TargetAmount: 50000, + }, + ) + resp, err := result.Unpack() + require.NoError(t, err) + + forfeitResp, ok := resp.(*SelectAndReserveForfeitResponse) + require.True(t, ok, "expected *SelectAndReserveForfeitResponse") + + // Largest-first: vtxo1 (30000) + vtxo2 (25000) = 55000. + require.Len(t, forfeitResp.SelectedVTXOs, 2) + require.Equal(t, + btcutil.Amount(55000), forfeitResp.TotalSelected, + ) +} + +// TestSelectAndReserveForfeitInsufficientFunds verifies that selection +// fails when live candidates cannot cover the target. +func TestSelectAndReserveForfeitInsufficientFunds(t *testing.T) { + t.Parallel() + + vtxo1 := makeDescriptor(t, 10000, 0) + + mgr, store := newTestManager(t, []*Descriptor{vtxo1}) + + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{vtxo1}, nil) + + result := mgr.Receive( + t.Context(), &SelectAndReserveForfeitRequest{ + TargetAmount: 50000, + }, + ) + _, err := result.Unpack() + require.Error(t, err) + require.Contains(t, err.Error(), "insufficient funds") +} + +// TestSelectAndReserveForfeitSkipsNonLive verifies that VTXOs already +// in SpendingState or PendingForfeitState are excluded from candidates +// because only Live VTXOs are returned by ListVTXOsByStatus. +func TestSelectAndReserveForfeitSkipsNonLive(t *testing.T) { + t.Parallel() + + vtxo1 := makeDescriptor(t, 50000, 0) + vtxo2 := makeDescriptor(t, 30000, 1) + + mgr, store := newTestManager(t, []*Descriptor{ + vtxo1, vtxo2, + }) + + // Put vtxo1 into SpendingState so it won't be listed as Live. + mgr.actors[vtxo1.Outpoint] = newMockVTXOActorRef( + vtxo1.Outpoint.String(), + &SpendingState{VTXO: vtxo1}, + ) + + // Store only returns vtxo2 as Live. + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{vtxo2}, nil) + + result := mgr.Receive( + t.Context(), &SelectAndReserveForfeitRequest{ + TargetAmount: 25000, + }, + ) + resp, err := result.Unpack() + require.NoError(t, err) + + forfeitResp, ok := resp.(*SelectAndReserveForfeitResponse) + require.True(t, ok, "expected *SelectAndReserveForfeitResponse") + + // Only vtxo2 was available and selected. + require.Len(t, forfeitResp.SelectedVTXOs, 1) + require.Equal(t, + vtxo2.Outpoint, + forfeitResp.SelectedVTXOs[0].Outpoint, + ) +} + +// TestSelectAndReserveForfeitPartialRollback verifies that if one VTXO +// rejects PendingForfeitEvent, previously reserved VTXOs are rolled +// back via ForfeitReleasedEvent. +func TestSelectAndReserveForfeitPartialRollback(t *testing.T) { + t.Parallel() + + vtxo1 := makeDescriptor(t, 30000, 0) + vtxo2 := makeDescriptor(t, 25000, 1) + + mgr, store := newTestManager(t, []*Descriptor{ + vtxo1, vtxo2, + }) + + // Put vtxo2 (second in sort order) into SpendingState so it + // will reject PendingForfeitEvent during reservation. + mgr.actors[vtxo2.Outpoint] = newMockVTXOActorRef( + vtxo2.Outpoint.String(), + &SpendingState{VTXO: vtxo2}, + ) + + // Store returns both as Live (stale view). + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{vtxo1, vtxo2}, nil) + + // Target requires both VTXOs. + result := mgr.Receive( + t.Context(), &SelectAndReserveForfeitRequest{ + TargetAmount: 50000, + }, + ) + _, err := result.Unpack() + require.Error(t, err) + require.Contains(t, err.Error(), "reserve forfeit") + + // Verify vtxo1 was rolled back to LiveState. + actorAny, ok := mgr.actors[vtxo1.Outpoint] + require.True(t, ok, "actor not found for vtxo1") + + actorRef, ok := actorAny.(*mockVTXOActorRef) + require.True(t, ok, "expected *mockVTXOActorRef") + + _, ok = actorRef.state.(*LiveState) + require.True(t, ok, + "expected LiveState after rollback, got %T", + actorRef.state, + ) +} diff --git a/vtxo/messages.go b/vtxo/messages.go index 6077784b1..8c26d81fb 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -157,3 +157,11 @@ type ReleaseForfeitRequest = actormsg.ReleaseForfeitRequest // ReleaseForfeitResponse is an alias for the canonical type in actormsg. type ReleaseForfeitResponse = actormsg.ReleaseForfeitResponse + +// SelectAndReserveForfeitRequest is an alias for the canonical type in +// actormsg. +type SelectAndReserveForfeitRequest = actormsg.SelectAndReserveForfeitRequest + +// SelectAndReserveForfeitResponse is an alias for the canonical type in +// actormsg. +type SelectAndReserveForfeitResponse = actormsg.SelectAndReserveForfeitResponse diff --git a/wallet/AGENTS.md b/wallet/AGENTS.md index e4ad39665..f1a13b352 100644 --- a/wallet/AGENTS.md +++ b/wallet/AGENTS.md @@ -6,7 +6,7 @@ Manages on-chain boarding addresses (2-of-2 multisig with operator + CSV timeout), monitors for confirmed boarding UTXOs, composes cooperative intent packages, and gates round registration through the VTXO manager admission APIs. The wallet actor owns VTXO selection and locking for -refresh, leave, and OOR spend flows. +refresh, leave, OOR spend, and directed send flows. ## Key Types @@ -25,6 +25,8 @@ refresh, leave, and OOR spend flows. - `LeaveVTXOsRequest` — Ask-request to select VTXOs for cooperative leave. - `CompleteSpendVTXOsRequest` — Tell-message to finalize spend and release locks. - `UnlockVTXOsRequest` — Tell-message to release locked VTXOs on failure. +- `SendRecipient` — Describes a single directed send destination (pkscript, amount, recipient client key). +- `SendVTXOsRequest` / `SendVTXOsResponse` — Ask-request for in-round directed sends. Atomically selects and reserves VTXOs via `SelectAndReserveForfeitRequest`, builds forfeit + recipient VTXO intents, and registers with the round actor. Supports dry-run mode for previewing coin selection without committing. ## Relationships @@ -33,11 +35,11 @@ refresh, leave, and OOR spend flows. - **Sends**: - → `round` (via registered notifier): `BoardingUtxoConfirmedEvent` - → `round` (via `lib/actormsg`): `TriggerBoardMsg` (VTXO amounts for boarding), `RegisterIntentMsg` (pre-composed cooperative intents with forfeits + VTXOs/leaves) - - → `vtxo` manager (via `lib/actormsg`): `SelectAndReserveSpendRequest`, `ReleaseSpendRequest`, `CompleteSpendRequest`, `ReserveForfeitRequest`, `ReleaseForfeitRequest` + - → `vtxo` manager (via `lib/actormsg`): `SelectAndReserveSpendRequest`, `ReleaseSpendRequest`, `CompleteSpendRequest`, `ReserveForfeitRequest`, `ReleaseForfeitRequest`, `SelectAndReserveForfeitRequest` - **Receives**: - ← `chainsource`: `BlockEpochNotification` (triggers UTXO polling) - ← `round`: `RegisterConfirmationNotifierRequest`, `UnregisterConfirmationNotifierRequest` - - ← API: `CreateBoardingAddressRequest`, `GetActiveBoardingAddressesRequest`, `GetBoardingBalanceRequest`, `RefreshVTXOsRequest`, `SelectAndLockVTXOsRequest`, `LeaveVTXOsRequest`, `BoardRequest`, `CompleteSpendVTXOsRequest`, `UnlockVTXOsRequest` + - ← API: `CreateBoardingAddressRequest`, `GetActiveBoardingAddressesRequest`, `GetBoardingBalanceRequest`, `RefreshVTXOsRequest`, `SelectAndLockVTXOsRequest`, `LeaveVTXOsRequest`, `BoardRequest`, `CompleteSpendVTXOsRequest`, `UnlockVTXOsRequest`, `SendVTXOsRequest` ## Invariants @@ -46,6 +48,7 @@ refresh, leave, and OOR spend flows. - Notifier registration captures `minConf` parameter per actor; different actors can require different confirmation depths. - Cooperative admission (refresh/leave) must reserve forfeit inputs through the VTXO manager before sending `RegisterIntentMsg` to the round actor. - If round registration fails after successful admission, the wallet releases the forfeit reservation so VTXOs return to LiveState. +- Directed sends use `SelectAndReserveForfeitRequest` (cooperative forfeit path) rather than the OOR spend path. The wallet builds recipient VTXOs with the recipient's key as `OwnerKey` and derives a separate ephemeral `SigningKey` for MuSig2 tree construction. - `VTXOReader` / `VTXODescriptor` / `SelectedVTXO` break the vtxo → round → wallet import cycle by providing wallet-level types that don't reference `vtxo.Descriptor` directly. - Per-subsystem logging via `build.LoggerFromContext` (no global mutable loggers). diff --git a/wallet/CLAUDE.md b/wallet/CLAUDE.md index e4ad39665..f1a13b352 100644 --- a/wallet/CLAUDE.md +++ b/wallet/CLAUDE.md @@ -6,7 +6,7 @@ Manages on-chain boarding addresses (2-of-2 multisig with operator + CSV timeout), monitors for confirmed boarding UTXOs, composes cooperative intent packages, and gates round registration through the VTXO manager admission APIs. The wallet actor owns VTXO selection and locking for -refresh, leave, and OOR spend flows. +refresh, leave, OOR spend, and directed send flows. ## Key Types @@ -25,6 +25,8 @@ refresh, leave, and OOR spend flows. - `LeaveVTXOsRequest` — Ask-request to select VTXOs for cooperative leave. - `CompleteSpendVTXOsRequest` — Tell-message to finalize spend and release locks. - `UnlockVTXOsRequest` — Tell-message to release locked VTXOs on failure. +- `SendRecipient` — Describes a single directed send destination (pkscript, amount, recipient client key). +- `SendVTXOsRequest` / `SendVTXOsResponse` — Ask-request for in-round directed sends. Atomically selects and reserves VTXOs via `SelectAndReserveForfeitRequest`, builds forfeit + recipient VTXO intents, and registers with the round actor. Supports dry-run mode for previewing coin selection without committing. ## Relationships @@ -33,11 +35,11 @@ refresh, leave, and OOR spend flows. - **Sends**: - → `round` (via registered notifier): `BoardingUtxoConfirmedEvent` - → `round` (via `lib/actormsg`): `TriggerBoardMsg` (VTXO amounts for boarding), `RegisterIntentMsg` (pre-composed cooperative intents with forfeits + VTXOs/leaves) - - → `vtxo` manager (via `lib/actormsg`): `SelectAndReserveSpendRequest`, `ReleaseSpendRequest`, `CompleteSpendRequest`, `ReserveForfeitRequest`, `ReleaseForfeitRequest` + - → `vtxo` manager (via `lib/actormsg`): `SelectAndReserveSpendRequest`, `ReleaseSpendRequest`, `CompleteSpendRequest`, `ReserveForfeitRequest`, `ReleaseForfeitRequest`, `SelectAndReserveForfeitRequest` - **Receives**: - ← `chainsource`: `BlockEpochNotification` (triggers UTXO polling) - ← `round`: `RegisterConfirmationNotifierRequest`, `UnregisterConfirmationNotifierRequest` - - ← API: `CreateBoardingAddressRequest`, `GetActiveBoardingAddressesRequest`, `GetBoardingBalanceRequest`, `RefreshVTXOsRequest`, `SelectAndLockVTXOsRequest`, `LeaveVTXOsRequest`, `BoardRequest`, `CompleteSpendVTXOsRequest`, `UnlockVTXOsRequest` + - ← API: `CreateBoardingAddressRequest`, `GetActiveBoardingAddressesRequest`, `GetBoardingBalanceRequest`, `RefreshVTXOsRequest`, `SelectAndLockVTXOsRequest`, `LeaveVTXOsRequest`, `BoardRequest`, `CompleteSpendVTXOsRequest`, `UnlockVTXOsRequest`, `SendVTXOsRequest` ## Invariants @@ -46,6 +48,7 @@ refresh, leave, and OOR spend flows. - Notifier registration captures `minConf` parameter per actor; different actors can require different confirmation depths. - Cooperative admission (refresh/leave) must reserve forfeit inputs through the VTXO manager before sending `RegisterIntentMsg` to the round actor. - If round registration fails after successful admission, the wallet releases the forfeit reservation so VTXOs return to LiveState. +- Directed sends use `SelectAndReserveForfeitRequest` (cooperative forfeit path) rather than the OOR spend path. The wallet builds recipient VTXOs with the recipient's key as `OwnerKey` and derives a separate ephemeral `SigningKey` for MuSig2 tree construction. - `VTXOReader` / `VTXODescriptor` / `SelectedVTXO` break the vtxo → round → wallet import cycle by providing wallet-level types that don't reference `vtxo.Descriptor` directly. - Per-subsystem logging via `build.LoggerFromContext` (no global mutable loggers). diff --git a/wallet/messages.go b/wallet/messages.go index 19d8eca87..321e25b96 100644 --- a/wallet/messages.go +++ b/wallet/messages.go @@ -584,3 +584,88 @@ func (m *LeaveVTXOsResponse) MessageType() string { // walletRespSealed implements the sealed WalletResp interface. func (m *LeaveVTXOsResponse) walletRespSealed() {} + +// SendRecipient describes a single recipient for an in-round directed +// send. The PkScript is the fully resolved VTXO output script. +type SendRecipient struct { + // PkScript is the recipient's VTXO output script. For pubkey + // destinations this is derived from the recipient's key, the + // operator's key, and the VTXO exit delay via + // tree.NewVTXODescriptor. For pk_script destinations the caller + // provides the raw script directly. + PkScript []byte + + // Amount is the value to send to this recipient in satoshis. + Amount btcutil.Amount + + // ClientKey is the recipient's public key for the collaborative + // spend path. Nil for pk_script destinations where the key is + // embedded in the script but not provided separately. + ClientKey *btcec.PublicKey +} + +// SendVTXOsRequest asks the wallet to execute an in-round directed +// send. The wallet atomically selects and reserves VTXOs for +// cooperative consumption, builds the IntentPackage (forfeits + +// recipient VTXOs + change), and registers it with the round actor. +type SendVTXOsRequest struct { + actor.BaseMessage + + // Recipients is the list of send destinations with resolved + // pkScripts and amounts. + Recipients []SendRecipient + + // OperatorFee is the fee deducted from the total to pay the + // operator. + OperatorFee btcutil.Amount + + // DustLimit is the minimum viable VTXO output amount. Change + // below this threshold causes the send to be rejected. + DustLimit btcutil.Amount + + // OperatorKey is the operator's public key for constructing + // new VTXO descriptors (change output). + OperatorKey *btcec.PublicKey + + // VTXOExitDelay is the CSV delay for the unilateral exit path + // of new VTXOs. + VTXOExitDelay uint32 + + // DryRun when true validates coin selection and immediately + // releases the reservation without registering with the round. + DryRun bool +} + +// MessageType returns the message type identifier for logging. +func (m *SendVTXOsRequest) MessageType() string { + return "SendVTXOsRequest" +} + +// walletMsgSealed implements the sealed WalletMsg interface. +func (m *SendVTXOsRequest) walletMsgSealed() {} + +// SendVTXOsResponse contains the result of a directed send request. +type SendVTXOsResponse struct { + actor.BaseMessage + + // Status is "submitted" for real sends or "preview" for dry-run. + Status string + + // SelectedCount is the number of VTXOs selected as inputs. + SelectedCount int + + // TotalSelected is the sum of selected VTXO amounts. + TotalSelected btcutil.Amount + + // ChangeAmount is the change returned to the sender. Zero if + // the selection exactly covered the total. + ChangeAmount btcutil.Amount +} + +// MessageType returns the message type identifier for logging. +func (m *SendVTXOsResponse) MessageType() string { + return "SendVTXOsResponse" +} + +// walletRespSealed implements the sealed WalletResp interface. +func (m *SendVTXOsResponse) walletRespSealed() {} diff --git a/wallet/wallet.go b/wallet/wallet.go index 00b686d4b..491b3266d 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -18,9 +18,11 @@ import ( "github.com/lightninglabs/darepo-client/chainsource" "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/lightninglabs/darepo-client/lib/tree" "github.com/lightninglabs/darepo-client/lib/types" "github.com/lightninglabs/taproot-assets/proof" fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" ) const ( @@ -320,6 +322,9 @@ func (a *Ark) Receive(ctx context.Context, case *MarkBoardingIntentsAdoptedRequest: return a.handleMarkBoardingIntentsAdopted(ctx, m) + case *SendVTXOsRequest: + return a.handleSendVTXOs(ctx, m) + default: return fn.Err[WalletResp]( fmt.Errorf("unknown message type: %T", msg)) @@ -1332,6 +1337,283 @@ func (a *Ark) handleCompleteSpendVTXOs(ctx context.Context, }) } +// handleSendVTXOs processes an in-round directed send. It atomically +// selects and reserves VTXOs for cooperative consumption, builds an +// IntentPackage with forfeits + recipient VTXOs + change, and +// registers it with the round actor. On failure, all reservations are +// released. For dry-run, the reservation is immediately released +// after validation. +func (a *Ark) handleSendVTXOs(ctx context.Context, + req *SendVTXOsRequest) fn.Result[WalletResp] { + + // Validate recipients. + if len(req.Recipients) == 0 { + return fn.Err[WalletResp]( + fmt.Errorf("no recipients provided"), + ) + } + + var totalRecipientAmount btcutil.Amount + for i, r := range req.Recipients { + if len(r.PkScript) == 0 { + return fn.Err[WalletResp](fmt.Errorf( + "recipient %d: empty pk_script", i, + )) + } + + if r.Amount <= 0 { + return fn.Err[WalletResp](fmt.Errorf( + "recipient %d: amount must be positive", + i, + )) + } + + totalRecipientAmount += r.Amount + } + + totalNeeded := totalRecipientAmount + req.OperatorFee + + a.logger(ctx).InfoS(ctx, "Processing directed send", + slog.Int("recipients", len(req.Recipients)), + slog.Int64("total_amount", + int64(totalRecipientAmount)), + slog.Int64("operator_fee", + int64(req.OperatorFee)), + slog.Bool("dry_run", req.DryRun)) + + // Atomic select-and-reserve for cooperative consumption. + resp, err := a.askManager( + ctx, + &actormsg.SelectAndReserveForfeitRequest{ + TargetAmount: totalNeeded, + }, + ) + if err != nil { + return fn.Err[WalletResp](fmt.Errorf( + "select and reserve forfeit: %w", err, + )) + } + + //nolint:forcetypeassert + mgrResp := resp.(*actormsg.SelectAndReserveForfeitResponse) + + // Collect reserved outpoints for potential rollback. + reservedOutpoints := make( + []wire.OutPoint, 0, len(mgrResp.SelectedVTXOs), + ) + for _, v := range mgrResp.SelectedVTXOs { + reservedOutpoints = append( + reservedOutpoints, v.Outpoint, + ) + } + + // releaseAndFail attempts a strict forfeit release and + // returns the primary error, augmented with release failure + // info if the release itself fails. + releaseAndFail := func(primary error) fn.Result[WalletResp] { + releaseErr := a.releaseManagerForfeitStrict( + ctx, reservedOutpoints, + ) + if releaseErr != nil { + return fn.Err[WalletResp](fmt.Errorf( + "%w; additionally, forfeit "+ + "release failed: %v", + primary, releaseErr, + )) + } + + return fn.Err[WalletResp](primary) + } + + // Compute change. + change := mgrResp.TotalSelected - totalNeeded + if change < 0 { + // Should not happen since coin selection covers the + // target, but be defensive. + return releaseAndFail(fmt.Errorf( + "selection shortfall: selected %d, need %d", + mgrResp.TotalSelected, totalNeeded, + )) + } + + if change > 0 && change <= req.DustLimit { + return releaseAndFail(fmt.Errorf( + "change %d is below dust limit %d; "+ + "adjust send amount", + change, req.DustLimit, + )) + } + + // Dry-run: validate coin selection then release immediately. + if req.DryRun { + releaseErr := a.releaseManagerForfeitStrict( + ctx, reservedOutpoints, + ) + if releaseErr != nil { + return fn.Err[WalletResp](fmt.Errorf( + "dry-run release failed, funds may "+ + "be temporarily unavailable: "+ + "%w", releaseErr, + )) + } + + return fn.Ok[WalletResp](&SendVTXOsResponse{ + Status: "preview", + SelectedCount: len(mgrResp.SelectedVTXOs), + TotalSelected: mgrResp.TotalSelected, + ChangeAmount: change, + }) + } + + // Build the intent package. + forfeits := make( + []types.ForfeitRequest, 0, + len(mgrResp.SelectedVTXOs), + ) + for _, v := range mgrResp.SelectedVTXOs { + op := v.Outpoint + forfeits = append(forfeits, types.ForfeitRequest{ + VTXOOutpoint: &op, + Amount: v.Amount, + }) + } + + // Build recipient + change VTXOs with fresh signing keys. + vtxoRequests, buildErr := a.buildSendVTXORequests( + ctx, req, change, + ) + if buildErr != nil { + return releaseAndFail(buildErr) + } + + // Register the intent with the round actor. + serviceKey := actormsg.RoundActorServiceKey() + roundRef := serviceKey.Ref(a.actorSystem) + + future := roundRef.Ask(ctx, &actormsg.RegisterIntentMsg{ + Forfeits: forfeits, + VTXOs: vtxoRequests, + TriggerRegistration: true, + }) + result := future.Await(ctx) + if result.IsErr() { + a.logger(ctx).WarnS(ctx, + "Round rejected send intent", result.Err()) + + return releaseAndFail(fmt.Errorf( + "round rejected send intent: %w", + result.Err(), + )) + } + + a.logger(ctx).InfoS(ctx, "Directed send intent registered", + slog.Int("forfeits", len(forfeits)), + slog.Int("recipient_vtxos", len(req.Recipients)), + slog.Int64("change", int64(change))) + + return fn.Ok[WalletResp](&SendVTXOsResponse{ + Status: "submitted", + SelectedCount: len(mgrResp.SelectedVTXOs), + TotalSelected: mgrResp.TotalSelected, + ChangeAmount: change, + }) +} + +// buildSendVTXORequests derives fresh signing keys and assembles +// VTXORequest entries for each recipient plus an optional change +// output. It returns the slice of requests or the first error +// encountered during key derivation or descriptor construction. +func (a *Ark) buildSendVTXORequests(ctx context.Context, + req *SendVTXOsRequest, + change btcutil.Amount) ([]types.VTXORequest, error) { + + vtxoRequests := make( + []types.VTXORequest, 0, + len(req.Recipients)+1, + ) + for i, r := range req.Recipients { + // Derive the VTXO descriptor pkScript from + // (ownerKey, operatorKey, exitDelay). Signing keys + // are NOT derived here — the round FSM derives them + // during the RegistrationSent transition per #210. + desc, descErr := tree.NewVTXODescriptor( + r.Amount, r.ClientKey, + req.OperatorKey, nil, + req.VTXOExitDelay, + ) + if descErr != nil { + return nil, fmt.Errorf( + "build recipient %d descriptor: %w", + i, descErr, + ) + } + + vtxoRequests = append(vtxoRequests, types.VTXORequest{ + Amount: r.Amount, + PkScript: desc.PkScript, + Expiry: req.VTXOExitDelay, + OwnerKey: keychain.KeyDescriptor{ + PubKey: r.ClientKey, + }, + IsOwner: false, + OperatorKey: req.OperatorKey, + }) + } + + // Add change VTXO if needed. The sender owns the change. + if change > 0 { + changeOwnerKey, keyErr := a.backend.DeriveNextKey( + ctx, types.VTXOOwnerKeyFamily, + ) + if keyErr != nil { + return nil, fmt.Errorf( + "derive change owner key: %w", + keyErr, + ) + } + + changeDesc, descErr := tree.NewVTXODescriptor( + change, changeOwnerKey.PubKey, + req.OperatorKey, nil, + req.VTXOExitDelay, + ) + if descErr != nil { + return nil, fmt.Errorf( + "build change descriptor: %w", + descErr, + ) + } + + vtxoRequests = append( + vtxoRequests, types.VTXORequest{ + Amount: change, + PkScript: changeDesc.PkScript, + Expiry: req.VTXOExitDelay, + OwnerKey: *changeOwnerKey, + IsOwner: true, + OperatorKey: req.OperatorKey, + }, + ) + } + + return vtxoRequests, nil +} + +// releaseManagerForfeitStrict releases forfeit reservations and returns +// the error rather than swallowing it. Used by dry-run where release +// failure must be surfaced to the caller. +func (a *Ark) releaseManagerForfeitStrict(ctx context.Context, + outpoints []wire.OutPoint) error { + + _, err := a.askManager( + ctx, &actormsg.ReleaseForfeitRequest{ + Outpoints: outpoints, + }, + ) + + return err +} + // buildBoardingTapscript constructs a 2-of-2 tapscript with CSV timeout for // boarding. The tapscript has two spending paths: // - Collaborative: Requires both client and operator signatures (spendable diff --git a/wallet/wallet_admission_test.go b/wallet/wallet_admission_test.go index 627d8738e..db7e4d035 100644 --- a/wallet/wallet_admission_test.go +++ b/wallet/wallet_admission_test.go @@ -5,10 +5,12 @@ import ( "fmt" "testing" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" + "github.com/btcsuite/btcwallet/waddrmgr" "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/lib/actormsg" fn "github.com/lightningnetwork/lnd/fn/v2" @@ -53,6 +55,14 @@ type mockVTXOManagerBehavior struct { // forfeitReleaseCalls tracks how many ReleaseForfeitRequest were // received. forfeitReleaseCalls int + + // selectForfeitResp is returned for + // SelectAndReserveForfeitRequest. + selectForfeitResp *actormsg.SelectAndReserveForfeitResponse + + // selectForfeitErr when set causes + // SelectAndReserveForfeitRequest to fail. + selectForfeitErr error } // Receive processes VTXO manager messages from the wallet. @@ -111,6 +121,17 @@ func (m *mockVTXOManagerBehavior) Receive(_ context.Context, m.forfeitReleaseResp, ) + case *actormsg.SelectAndReserveForfeitRequest: + if m.selectForfeitErr != nil { + return fn.Err[actormsg.VTXOManagerResp]( + m.selectForfeitErr, + ) + } + + return fn.Ok[actormsg.VTXOManagerResp]( + m.selectForfeitResp, + ) + default: return fn.Err[actormsg.VTXOManagerResp]( fmt.Errorf("unexpected message: %T", msg), @@ -333,15 +354,20 @@ type mockRoundActorBehavior struct { // registerCalls tracks how many times RegisterIntentMsg was received. registerCalls int + + // capturedIntent holds the last RegisterIntentMsg received, so + // tests can inspect the intent package contents. + capturedIntent *actormsg.RegisterIntentMsg } // Receive processes round actor messages from the wallet. func (m *mockRoundActorBehavior) Receive(_ context.Context, msg actormsg.RoundReceivable) fn.Result[actormsg.RoundActorResp] { - switch msg.(type) { + switch typedMsg := msg.(type) { case *actormsg.RegisterIntentMsg: m.registerCalls++ + m.capturedIntent = typedMsg if m.registerErr != nil { return fn.Err[actormsg.RoundActorResp]( @@ -604,3 +630,522 @@ func TestLeaveReleasesOnRoundRejection(t *testing.T) { // Manager should have received the release call. require.Equal(t, 1, mgr.forfeitReleaseCalls) } + +// ============================================================================= +// Directed send tests +// ============================================================================= + +// stubBackend is a minimal BoardingBackend for send tests. It returns +// deterministic keys from DeriveNextKey and stubs all other methods. +type stubBackend struct { + keyCounter uint32 +} + +// DeriveNextKey returns a deterministic key descriptor. +func (s *stubBackend) DeriveNextKey(_ context.Context, + family keychain.KeyFamily) (*keychain.KeyDescriptor, error) { + + s.keyCounter++ + priv, _ := btcec.NewPrivateKey() + + return &keychain.KeyDescriptor{ + PubKey: priv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: family, + Index: s.keyCounter, + }, + }, nil +} + +// ImportTaprootScript is a no-op stub. +func (s *stubBackend) ImportTaprootScript(_ context.Context, + _ *waddrmgr.Tapscript) (btcutil.Address, error) { + + return nil, fmt.Errorf("not implemented") +} + +// ListUnspent is a no-op stub. +func (s *stubBackend) ListUnspent(_ context.Context, + _ int32, _ int32) ([]*Utxo, error) { + + return nil, nil +} + +// GetTransaction is a no-op stub. +func (s *stubBackend) GetTransaction(_ context.Context, + _ chainhash.Hash) (*wire.MsgTx, *chainhash.Hash, error) { + + return nil, nil, fmt.Errorf("not implemented") +} + +// GetBlock is a no-op stub. +func (s *stubBackend) GetBlock(_ context.Context, + _ chainhash.Hash) (*wire.MsgBlock, error) { + + return nil, fmt.Errorf("not implemented") +} + +// newTestWalletForSend creates a wallet with mock VTXO manager, mock +// round actor, and a stubBackend for key derivation. This is the +// setup needed for directed send tests. +func newTestWalletForSend(t *testing.T, + mgr *mockVTXOManagerBehavior, + roundActor *mockRoundActorBehavior) *Ark { + + t.Helper() + + system := actor.NewActorSystem() + t.Cleanup(func() { + //nolint:usetesting + // t.Context() is cancelled before cleanup runs, so + // we need a fresh context for graceful shutdown. + err := system.Shutdown(context.Background()) + require.NoError(t, err) + }) + + mgrKey := actormsg.VTXOManagerServiceKey() + actor.RegisterWithSystem( + system, actormsg.VTXOManagerServiceKeyName, + mgrKey, mgr, + ) + + roundKey := actormsg.RoundActorServiceKey() + actor.RegisterWithSystem( + system, actormsg.RoundActorServiceKeyName, + roundKey, roundActor, + ) + + backend := &stubBackend{} + + return NewArk( + backend, nil, nil, nil, system, btclog.Disabled, + ) +} + +// testSendRecipient returns a SendRecipient for testing. +func testSendRecipient(amount btcutil.Amount) SendRecipient { + priv, _ := btcec.NewPrivateKey() + + return SendRecipient{ + PkScript: []byte{0x51, 0x20, 0x01}, + Amount: amount, + ClientKey: priv.PubKey(), + } +} + +// testOperatorKey returns a deterministic operator key for testing. +func testOperatorKey() *btcec.PublicKey { + priv, _ := btcec.NewPrivateKey() + + return priv.PubKey() +} + +// TestSendVTXOsSuccess verifies the happy path: select VTXOs, build +// intent with forfeits + recipient + change, register with round. +func TestSendVTXOsSuccess(t *testing.T) { + t.Parallel() + + mgr := &mockVTXOManagerBehavior{ + selectForfeitResp: &actormsg.SelectAndReserveForfeitResponse{ + SelectedVTXOs: []actormsg.SelectedVTXO{ + { + Outpoint: testOutpoint(0), + Amount: 50000, + PkScript: []byte{0x51, 0x20, 0x01}, + }, + }, + TotalSelected: 50000, + }, + forfeitReleaseResp: &actormsg.ReleaseForfeitResponse{ + ReleasedCount: 1, + }, + } + roundActor := &mockRoundActorBehavior{} + + w := newTestWalletForSend(t, mgr, roundActor) + + result := w.Receive(t.Context(), &SendVTXOsRequest{ + Recipients: []SendRecipient{testSendRecipient(40000)}, + OperatorFee: 1000, + DustLimit: 546, + OperatorKey: testOperatorKey(), + VTXOExitDelay: 144, + }) + resp, err := result.Unpack() + require.NoError(t, err) + + sendResp, ok := resp.(*SendVTXOsResponse) + require.True(t, ok) + require.Equal(t, "submitted", sendResp.Status) + require.Equal(t, 1, sendResp.SelectedCount) + require.Equal(t, btcutil.Amount(50000), sendResp.TotalSelected) + require.Equal(t, btcutil.Amount(9000), sendResp.ChangeAmount) + require.Equal(t, 1, roundActor.registerCalls) +} + +// TestSendVTXOsNoChange verifies that exact-amount sends produce no +// change output. +func TestSendVTXOsNoChange(t *testing.T) { + t.Parallel() + + mgr := &mockVTXOManagerBehavior{ + selectForfeitResp: &actormsg.SelectAndReserveForfeitResponse{ + SelectedVTXOs: []actormsg.SelectedVTXO{ + { + Outpoint: testOutpoint(0), + Amount: 41000, + PkScript: []byte{0x51, 0x20, 0x01}, + }, + }, + TotalSelected: 41000, + }, + } + roundActor := &mockRoundActorBehavior{} + + w := newTestWalletForSend(t, mgr, roundActor) + + result := w.Receive(t.Context(), &SendVTXOsRequest{ + Recipients: []SendRecipient{testSendRecipient(40000)}, + OperatorFee: 1000, + DustLimit: 546, + OperatorKey: testOperatorKey(), + VTXOExitDelay: 144, + }) + resp, err := result.Unpack() + require.NoError(t, err) + + sendResp, ok := resp.(*SendVTXOsResponse) + require.True(t, ok, "expected *SendVTXOsResponse") + require.Equal(t, btcutil.Amount(0), sendResp.ChangeAmount) + require.Equal(t, 1, roundActor.registerCalls) +} + +// TestSendVTXOsDustChange verifies that change below the dust limit +// is rejected. +func TestSendVTXOsDustChange(t *testing.T) { + t.Parallel() + + mgr := &mockVTXOManagerBehavior{ + selectForfeitResp: &actormsg.SelectAndReserveForfeitResponse{ + SelectedVTXOs: []actormsg.SelectedVTXO{ + { + Outpoint: testOutpoint(0), + Amount: 41500, + PkScript: []byte{0x51, 0x20, 0x01}, + }, + }, + TotalSelected: 41500, + }, + forfeitReleaseResp: &actormsg.ReleaseForfeitResponse{ + ReleasedCount: 1, + }, + } + roundActor := &mockRoundActorBehavior{} + + w := newTestWalletForSend(t, mgr, roundActor) + + result := w.Receive(t.Context(), &SendVTXOsRequest{ + Recipients: []SendRecipient{testSendRecipient(40000)}, + OperatorFee: 1000, + DustLimit: 546, + OperatorKey: testOperatorKey(), + VTXOExitDelay: 144, + }) + _, err := result.Unpack() + require.Error(t, err) + require.Contains(t, err.Error(), "below dust limit") + + // Round should not have been called. + require.Equal(t, 0, roundActor.registerCalls) +} + +// TestSendVTXOsDryRun verifies that dry-run validates selection then +// immediately releases the reservation. +func TestSendVTXOsDryRun(t *testing.T) { + t.Parallel() + + mgr := &mockVTXOManagerBehavior{ + selectForfeitResp: &actormsg.SelectAndReserveForfeitResponse{ + SelectedVTXOs: []actormsg.SelectedVTXO{ + { + Outpoint: testOutpoint(0), + Amount: 50000, + PkScript: []byte{0x51, 0x20, 0x01}, + }, + }, + TotalSelected: 50000, + }, + forfeitReleaseResp: &actormsg.ReleaseForfeitResponse{ + ReleasedCount: 1, + }, + } + roundActor := &mockRoundActorBehavior{} + + w := newTestWalletForSend(t, mgr, roundActor) + + result := w.Receive(t.Context(), &SendVTXOsRequest{ + Recipients: []SendRecipient{testSendRecipient(40000)}, + OperatorFee: 1000, + DustLimit: 546, + OperatorKey: testOperatorKey(), + VTXOExitDelay: 144, + DryRun: true, + }) + resp, err := result.Unpack() + require.NoError(t, err) + + sendResp, ok := resp.(*SendVTXOsResponse) + require.True(t, ok, "expected *SendVTXOsResponse") + require.Equal(t, "preview", sendResp.Status) + require.Equal(t, btcutil.Amount(9000), sendResp.ChangeAmount) + + // Round should NOT have been called. + require.Equal(t, 0, roundActor.registerCalls) + + // Release should have been called. + require.Equal(t, 1, mgr.forfeitReleaseCalls) +} + +// TestSendVTXOsDryRunReleaseFails verifies that a dry-run where +// release fails returns an explicit error about lingering +// reservations. +func TestSendVTXOsDryRunReleaseFails(t *testing.T) { + t.Parallel() + + mgr := &mockVTXOManagerBehavior{ + selectForfeitResp: &actormsg.SelectAndReserveForfeitResponse{ + SelectedVTXOs: []actormsg.SelectedVTXO{ + { + Outpoint: testOutpoint(0), + Amount: 50000, + PkScript: []byte{0x51, 0x20, 0x01}, + }, + }, + TotalSelected: 50000, + }, + forfeitReleaseErr: fmt.Errorf("release timeout"), + } + roundActor := &mockRoundActorBehavior{} + + w := newTestWalletForSend(t, mgr, roundActor) + + result := w.Receive(t.Context(), &SendVTXOsRequest{ + Recipients: []SendRecipient{testSendRecipient(40000)}, + OperatorFee: 1000, + DustLimit: 546, + OperatorKey: testOperatorKey(), + VTXOExitDelay: 144, + DryRun: true, + }) + _, err := result.Unpack() + require.Error(t, err) + require.Contains(t, err.Error(), "temporarily unavailable") +} + +// TestSendVTXOsRoundRejectsAndReleases verifies that when the round +// rejects the intent, the forfeit reservation is released. +func TestSendVTXOsRoundRejectsAndReleases(t *testing.T) { + t.Parallel() + + mgr := &mockVTXOManagerBehavior{ + selectForfeitResp: &actormsg.SelectAndReserveForfeitResponse{ + SelectedVTXOs: []actormsg.SelectedVTXO{ + { + Outpoint: testOutpoint(0), + Amount: 50000, + PkScript: []byte{0x51, 0x20, 0x01}, + }, + }, + TotalSelected: 50000, + }, + forfeitReleaseResp: &actormsg.ReleaseForfeitResponse{ + ReleasedCount: 1, + }, + } + roundActor := &mockRoundActorBehavior{ + registerErr: fmt.Errorf("round full"), + } + + w := newTestWalletForSend(t, mgr, roundActor) + + result := w.Receive(t.Context(), &SendVTXOsRequest{ + Recipients: []SendRecipient{testSendRecipient(40000)}, + OperatorFee: 1000, + DustLimit: 546, + OperatorKey: testOperatorKey(), + VTXOExitDelay: 144, + }) + _, err := result.Unpack() + require.Error(t, err) + require.Contains(t, err.Error(), "round full") + require.Equal(t, 1, mgr.forfeitReleaseCalls) +} + +// TestSendVTXOsSelectionFails verifies that the send fails gracefully +// when the manager reports insufficient funds. +func TestSendVTXOsSelectionFails(t *testing.T) { + t.Parallel() + + mgr := &mockVTXOManagerBehavior{ + selectForfeitErr: fmt.Errorf("insufficient funds"), + } + roundActor := &mockRoundActorBehavior{} + + w := newTestWalletForSend(t, mgr, roundActor) + + result := w.Receive(t.Context(), &SendVTXOsRequest{ + Recipients: []SendRecipient{testSendRecipient(100000)}, + OperatorFee: 1000, + DustLimit: 546, + OperatorKey: testOperatorKey(), + VTXOExitDelay: 144, + }) + _, err := result.Unpack() + require.Error(t, err) + require.Contains(t, err.Error(), "insufficient funds") + require.Equal(t, 0, roundActor.registerCalls) +} + +// TestSendVTXOsIntentPackageContents verifies the full intent package +// that the wallet registers with the round actor. This is the +// higher-fidelity test that proves the end-to-end flow: wallet selects +// coins via the manager, builds forfeits + recipient VTXOs + change +// VTXO, and registers the correct intent with the round. +func TestSendVTXOsIntentPackageContents(t *testing.T) { + t.Parallel() + + // Two VTXOs selected as inputs. + mgr := &mockVTXOManagerBehavior{ + selectForfeitResp: &actormsg.SelectAndReserveForfeitResponse{ + SelectedVTXOs: []actormsg.SelectedVTXO{ + { + Outpoint: testOutpoint(0), + Amount: 30000, + PkScript: []byte{0x51, 0x20, 0x01}, + }, + { + Outpoint: testOutpoint(1), + Amount: 25000, + PkScript: []byte{0x51, 0x20, 0x02}, + }, + }, + TotalSelected: 55000, + }, + } + roundActor := &mockRoundActorBehavior{} + + // Two recipients with known keys. + recipientKeyA, _ := btcec.NewPrivateKey() + recipientKeyB, _ := btcec.NewPrivateKey() + operatorKey := testOperatorKey() + + recipients := []SendRecipient{ + { + PkScript: []byte{0x51, 0x20, 0xAA}, + Amount: 20000, + ClientKey: recipientKeyA.PubKey(), + }, + { + PkScript: []byte{0x51, 0x20, 0xBB}, + Amount: 15000, + ClientKey: recipientKeyB.PubKey(), + }, + } + + w := newTestWalletForSend(t, mgr, roundActor) + + operatorFee := btcutil.Amount(1000) + result := w.Receive(t.Context(), &SendVTXOsRequest{ + Recipients: recipients, + OperatorFee: operatorFee, + DustLimit: 546, + OperatorKey: operatorKey, + VTXOExitDelay: 144, + }) + resp, err := result.Unpack() + require.NoError(t, err) + + sendResp, ok := resp.(*SendVTXOsResponse) + require.True(t, ok, "expected *SendVTXOsResponse") + require.Equal(t, "submitted", sendResp.Status) + + // Expected change: 55000 - (20000+15000) - 1000 = 19000. + expectedChange := btcutil.Amount(19000) + require.Equal(t, expectedChange, sendResp.ChangeAmount) + require.Equal(t, 2, sendResp.SelectedCount) + + // Verify the captured intent package. + require.NotNil(t, roundActor.capturedIntent) + intent := roundActor.capturedIntent + + // --- Forfeits: one per selected VTXO, correct outpoints. --- + require.Len(t, intent.Forfeits, 2) + require.Equal(t, + testOutpoint(0), *intent.Forfeits[0].VTXOOutpoint, + ) + require.Equal(t, + btcutil.Amount(30000), intent.Forfeits[0].Amount, + ) + require.Equal(t, + testOutpoint(1), *intent.Forfeits[1].VTXOOutpoint, + ) + require.Equal(t, + btcutil.Amount(25000), intent.Forfeits[1].Amount, + ) + + // --- VTXOs: 2 recipients + 1 change = 3 total. --- + require.Len(t, intent.VTXOs, 3) + + // Recipient A: OwnerKey is the recipient's key. + vtxoA := intent.VTXOs[0] + require.Equal(t, btcutil.Amount(20000), vtxoA.Amount) + // PkScript is derived from the VTXO descriptor, not the + // RPC-provided value. Verify it's a valid P2TR (34 bytes). + require.Len(t, vtxoA.PkScript, 34) + require.Equal(t, byte(0x51), vtxoA.PkScript[0]) + require.True(t, vtxoA.OwnerKey.PubKey.IsEqual( + recipientKeyA.PubKey(), + )) + require.True(t, vtxoA.OperatorKey.IsEqual(operatorKey)) + require.Equal(t, uint32(144), vtxoA.Expiry) + + // Recipient B: OwnerKey is the recipient's key. + vtxoB := intent.VTXOs[1] + require.Equal(t, btcutil.Amount(15000), vtxoB.Amount) + require.Len(t, vtxoB.PkScript, 34) + require.Equal(t, byte(0x51), vtxoB.PkScript[0]) + require.True(t, vtxoB.OwnerKey.PubKey.IsEqual( + recipientKeyB.PubKey(), + )) + + // Change VTXO: amount matches, OwnerKey is sender-derived + // (NOT a recipient key). + vtxoChange := intent.VTXOs[2] + require.Equal(t, expectedChange, vtxoChange.Amount) + require.False(t, vtxoChange.OwnerKey.PubKey.IsEqual( + recipientKeyA.PubKey(), + )) + require.False(t, vtxoChange.OwnerKey.PubKey.IsEqual( + recipientKeyB.PubKey(), + )) + require.True(t, vtxoChange.OperatorKey.IsEqual(operatorKey)) + + // Signing keys are NOT derived in the wallet — the round FSM + // derives them during RegistrationSent per #210. Verify they + // are empty. + for i, vtxo := range intent.VTXOs { + require.Nil(t, vtxo.SigningKey.PubKey, + "vtxo %d: SigningKey should be nil "+ + "(FSM derives it)", i, + ) + } + + // The change VTXO should have IsOwner=true with the VTXO + // owner key family. Recipient VTXOs have IsOwner=false. + require.True(t, vtxoChange.IsOwner, + "change VTXO should be owned") + for _, vtxo := range intent.VTXOs[:2] { + require.False(t, vtxo.IsOwner, + "recipient VTXO should not be owned") + } +}