Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions daemonrpc/daemon.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions daemonrpc/daemon.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion darepod/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ 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.
- `serverDurableUnaryBuilder` — Implements `serverconn.DurableUnaryRequestBuilder` by delegating to the indexer client with proof-of-control credentials.
- `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

Expand Down
4 changes: 3 additions & 1 deletion darepod/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ 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.
- `serverDurableUnaryBuilder` — Implements `serverconn.DurableUnaryRequestBuilder` by delegating to the indexer client with proof-of-control credentials.
- `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

Expand Down
196 changes: 175 additions & 21 deletions darepod/rpc_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand All @@ -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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since I had the scope of this PR, we should also have the client validate that the exit delay is actually saying otherwise if it's zero, then that means that there's no actual safety for the user.

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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading