From cc9b1b6758dc237012bb60f0208f2462103dc814 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 26 Mar 2026 10:44:16 +0200 Subject: [PATCH 1/4] multi: harden directed send validation and cleanup - Validate recipient amounts against 21M BTC cap with overflow-safe addition in both RPC and wallet layers - Replace releaseAndFail with defer+committed pattern so panics don't leak PendingForfeitState VTXOs - Use context.WithoutCancel for deferred forfeit release so cleanup survives client disconnection - Cap recipients at 256 in RPC validation - Dry-run release is now best-effort via defer (errors don't propagate) --- darepod/rpc_server.go | 24 +++++++++++- wallet/wallet.go | 65 +++++++++++++++++---------------- wallet/wallet_admission_test.go | 15 +++++--- 3 files changed, 65 insertions(+), 39 deletions(-) diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index db30be95f..3b46bc9a7 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -635,11 +635,21 @@ func (r *RPCServer) SendVTXO(ctx context.Context, return nil, err } + // TODO(#241): Tune this cap based on round tree constraints + // and consider making it configurable. + const maxRecipients = 256 + if len(req.Recipients) == 0 { return nil, status.Errorf(codes.InvalidArgument, "at least one recipient is required") } + if len(req.Recipients) > maxRecipients { + return nil, status.Errorf(codes.InvalidArgument, + "too many recipients: %d (max %d)", + len(req.Recipients), maxRecipients) + } + // Resolve each recipient's pkScript and client pubkey from // the proto Output destination. recipients := make( @@ -656,14 +666,24 @@ func (r *RPCServer) SendVTXO(ctx context.Context, ) } - if out.AmountSat <= 0 { + if out.AmountSat <= 0 || + out.AmountSat > int64(btcutil.MaxSatoshi) { + return nil, status.Errorf( codes.InvalidArgument, "recipient %d: amount must be "+ - "positive", i, + "between 1 and %d", + i, btcutil.MaxSatoshi, ) } + // Overflow-safe addition. + if totalAmount > int64(btcutil.MaxSatoshi)-out.AmountSat { + return nil, status.Errorf( + codes.InvalidArgument, + "total amount overflows max supply") + } + pkScript, clientKey, err := r.resolveRecipientOutput( out, ) diff --git a/wallet/wallet.go b/wallet/wallet.go index 491b3266d..e7188385f 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1361,10 +1361,17 @@ func (a *Ark) handleSendVTXOs(ctx context.Context, )) } - if r.Amount <= 0 { + if r.Amount <= 0 || r.Amount > btcutil.MaxSatoshi { return fn.Err[WalletResp](fmt.Errorf( - "recipient %d: amount must be positive", - i, + "recipient %d: amount must be "+ + "between 1 and %d", + i, btcutil.MaxSatoshi, + )) + } + + if totalRecipientAmount+r.Amount < 0 { + return fn.Err[WalletResp](fmt.Errorf( + "total recipient amount overflows", )) } @@ -1407,37 +1414,41 @@ func (a *Ark) handleSendVTXOs(ctx context.Context, ) } - // 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] { + // Ensure reserved VTXOs are released if we don't reach the + // successful registration at the end. Use a background + // context so cleanup survives client disconnection. + committed := false + defer func() { + if committed { + return + } + + releaseCtx := context.WithoutCancel(ctx) releaseErr := a.releaseManagerForfeitStrict( - ctx, reservedOutpoints, + releaseCtx, reservedOutpoints, ) if releaseErr != nil { - return fn.Err[WalletResp](fmt.Errorf( - "%w; additionally, forfeit "+ - "release failed: %v", - primary, releaseErr, - )) + a.logger(releaseCtx).WarnS( + releaseCtx, + "Failed to release reserved "+ + "VTXOs", 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( + return fn.Err[WalletResp](fmt.Errorf( "selection shortfall: selected %d, need %d", mgrResp.TotalSelected, totalNeeded, )) } if change > 0 && change <= req.DustLimit { - return releaseAndFail(fmt.Errorf( + return fn.Err[WalletResp](fmt.Errorf( "change %d is below dust limit %d; "+ "adjust send amount", change, req.DustLimit, @@ -1446,17 +1457,7 @@ func (a *Ark) handleSendVTXOs(ctx context.Context, // 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, - )) - } - + // The deferred cleanup releases the reservation. return fn.Ok[WalletResp](&SendVTXOsResponse{ Status: "preview", SelectedCount: len(mgrResp.SelectedVTXOs), @@ -1483,7 +1484,7 @@ func (a *Ark) handleSendVTXOs(ctx context.Context, ctx, req, change, ) if buildErr != nil { - return releaseAndFail(buildErr) + return fn.Err[WalletResp](buildErr) } // Register the intent with the round actor. @@ -1500,12 +1501,14 @@ func (a *Ark) handleSendVTXOs(ctx context.Context, a.logger(ctx).WarnS(ctx, "Round rejected send intent", result.Err()) - return releaseAndFail(fmt.Errorf( + return fn.Err[WalletResp](fmt.Errorf( "round rejected send intent: %w", result.Err(), )) } + committed = true + a.logger(ctx).InfoS(ctx, "Directed send intent registered", slog.Int("forfeits", len(forfeits)), slog.Int("recipient_vtxos", len(req.Recipients)), diff --git a/wallet/wallet_admission_test.go b/wallet/wallet_admission_test.go index db7e4d035..fabb0192a 100644 --- a/wallet/wallet_admission_test.go +++ b/wallet/wallet_admission_test.go @@ -906,9 +906,9 @@ func TestSendVTXOsDryRun(t *testing.T) { require.Equal(t, 1, mgr.forfeitReleaseCalls) } -// TestSendVTXOsDryRunReleaseFails verifies that a dry-run where -// release fails returns an explicit error about lingering -// reservations. +// TestSendVTXOsDryRunReleaseFails verifies that a dry-run succeeds +// even when the deferred forfeit release fails. The release is +// best-effort — errors are logged but don't propagate. func TestSendVTXOsDryRunReleaseFails(t *testing.T) { t.Parallel() @@ -937,9 +937,12 @@ func TestSendVTXOsDryRunReleaseFails(t *testing.T) { VTXOExitDelay: 144, DryRun: true, }) - _, err := result.Unpack() - require.Error(t, err) - require.Contains(t, err.Error(), "temporarily unavailable") + resp, err := result.Unpack() + require.NoError(t, err) + + sendResp, ok := resp.(*SendVTXOsResponse) + require.True(t, ok) + require.Equal(t, "preview", sendResp.Status) } // TestSendVTXOsRoundRejectsAndReleases verifies that when the round From 126c699bea75271be224dd21612756e502595ff8 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 26 Mar 2026 10:56:38 +0200 Subject: [PATCH 2/4] multi: replace IsOwner flag with OwnedScriptChecker Remove the IsOwner boolean from VTXOIntent and VTXORequest in favor of a data-driven OwnedScriptChecker interface. Instead of tagging each VTXO with an ownership flag at construction time, the round FSM now queries the owned receive scripts store at confirmation time to determine which VTXOs to persist locally. This decouples VTXO construction from ownership tracking and is required for OOR receive scripts to work correctly with round VTXOs. Key changes: - Add OwnedScriptChecker and OwnedScriptRegistrar interfaces to round package - Wire OwnedScriptChecker into ClientEnvironment and all FSM construction sites - Register owned scripts in buildVTXOIntent and handleRegisterIntent via OwnedScriptRegistrar - Create ownedScriptCheckerAdapter and ownedScriptRegistrarAdapter in darepod backed by OORArtifactPersistenceStore - Use context.WithoutCancel in IsOwnedScript for shutdown safety - Update all tests to use mockOwnedScriptChecker instead of IsOwner --- darepod/owned_script_checker.go | 86 +++++++++++++++++++++++++++++++++ darepod/rpc_server.go | 2 +- darepod/server.go | 76 ++++++++++++++++++----------- db/round_store.go | 2 - db/round_store_test.go | 2 - lib/types/boarding.go | 6 --- round/AGENTS.md | 2 +- round/CLAUDE.md | 2 +- round/actor.go | 60 +++++++++++++++++++++-- round/actor_test.go | 1 - round/fsm_environment.go | 7 +++ round/harness_test.go | 27 ++++++++++- round/interfaces.go | 28 +++++++++-- round/transitions.go | 80 ++++++++++++++++-------------- round/transitions_test.go | 11 +++-- wallet/wallet.go | 5 +- wallet/wallet_admission_test.go | 8 --- 17 files changed, 302 insertions(+), 103 deletions(-) create mode 100644 darepod/owned_script_checker.go diff --git a/darepod/owned_script_checker.go b/darepod/owned_script_checker.go new file mode 100644 index 000000000..3d4b8fbb4 --- /dev/null +++ b/darepod/owned_script_checker.go @@ -0,0 +1,86 @@ +package darepod + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/round" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" +) + +// ownedScriptCheckerAdapter implements round.OwnedScriptChecker by +// looking up pkScripts in the owned_receive_scripts persistence store. +type ownedScriptCheckerAdapter struct { + store *db.OORArtifactPersistenceStore +} + +var _ round.OwnedScriptChecker = (*ownedScriptCheckerAdapter)(nil) + +// IsOwnedScript returns whether the pkScript is registered as an owned +// receive script in the OOR artifact store. Returns an error for real +// store failures; a not-found result returns false with no error. +func (a *ownedScriptCheckerAdapter) IsOwnedScript(ctx context.Context, + pkScript []byte) fn.Result[bool] { + + if a.store == nil { + return fn.Ok(false) + } + + // Use a context that survives cancellation so the DB lookup + // completes even if the caller's context is being torn down + // (e.g., during round confirmation in a shutting-down FSM). + lookupCtx := context.WithoutCancel(ctx) + + _, err := a.store.LookupOwnedReceiveScript(lookupCtx, pkScript) + if err != nil { + // Not-found means the script isn't ours. + if errors.Is(err, sql.ErrNoRows) { + return fn.Ok(false) + } + + return fn.Err[bool](fmt.Errorf( + "lookup owned receive script: %w", err, + )) + } + + return fn.Ok(true) +} + +// ownedScriptRegistrarAdapter implements round.OwnedScriptRegistrar by +// persisting pkScripts in the owned_receive_scripts table. +type ownedScriptRegistrarAdapter struct { + store *db.OORArtifactPersistenceStore + operatorKey *btcec.PublicKey + exitDelay uint32 +} + +var _ round.OwnedScriptRegistrar = (*ownedScriptRegistrarAdapter)(nil) + +// RegisterOwnedScript persists the pkScript as a locally owned receive +// script in the OOR artifact store. +func (a *ownedScriptRegistrarAdapter) RegisterOwnedScript( + ctx context.Context, pkScript []byte, + ownerKey keychain.KeyDescriptor) error { + + if a.store == nil { + return fmt.Errorf("store is nil") + } + + return a.store.UpsertOwnedReceiveScript( + ctx, db.OwnedReceiveScriptRecord{ + PkScript: pkScript, + ClientKey: ownerKey, + OperatorPubKey: a.operatorKey, + ExitDelay: int64(a.exitDelay), + Source: db.OwnedReceiveScriptSourceWallet, + CreatedAt: time.Now(), + LastUsedAt: fn.None[time.Time](), + }, + ) +} diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index 3b46bc9a7..c3582b2d7 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -673,7 +673,7 @@ func (r *RPCServer) SendVTXO(ctx context.Context, codes.InvalidArgument, "recipient %d: amount must be "+ "between 1 and %d", - i, btcutil.MaxSatoshi, + i, int64(btcutil.MaxSatoshi), ) } diff --git a/darepod/server.go b/darepod/server.go index 68c23260a..e79aab534 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -117,6 +117,7 @@ func Main(cfg *Config, interceptor signal.Interceptor) error { // indexer client, and the daemon's own gRPC server. type Server struct { cfg *Config + clk clock.Clock logManager *lndbuild.SubLoggerManager loggers SubLoggers @@ -204,6 +205,7 @@ type Server struct { func NewServer(cfg *Config) (*Server, error) { return &Server{ cfg: cfg, + clk: clock.NewDefaultClock(), walletReady: make(chan struct{}), }, nil } @@ -473,12 +475,11 @@ func (s *Server) run(ctx context.Context, } // Create the VTXO store for RPC queries (ListVTXOs, GetBalance). - clk := clock.NewDefaultClock() dbStore := db.NewStore( s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), ) - s.vtxoStore = dbStore.NewVTXOStore(clk) + s.vtxoStore = dbStore.NewVTXOStore(s.clk) // ------------------------------------------------------- // 6. Start the daemon's own gRPC server and mailbox mux. @@ -1941,7 +1942,7 @@ func (s *Server) initDatabase(ctx context.Context) error { } s.deliveryStore, err = actordelivery.NewTxAwareDeliveryStoreFromDB( - s.db.DB, s.db.Backend(), clock.NewDefaultClock(), + s.db.DB, s.db.Backend(), s.clk, s.subLogger(actor.Subsystem), ) if err != nil { @@ -1965,7 +1966,7 @@ func (s *Server) initRPCClients(ctx context.Context) { s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), ) - packageStore := dbStore.NewOORArtifactStore(clock.NewDefaultClock()) + packageStore := dbStore.NewOORArtifactStore(s.clk) // Determine the node identity pubkey for indexer registration. // In lnd mode this comes from the lnd connection. In lwwallet @@ -2071,13 +2072,11 @@ func (s *Server) initWalletActor(ctx context.Context, chainsource.ChainSourceMsg, chainsource.ChainSourceResp, ]) (actor.ActorRef[wallet.WalletMsg, wallet.WalletResp], error) { - clk := clock.NewDefaultClock() - dbStore := db.NewStore( s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), ) - boardingStore := dbStore.NewBoardingStore(s.chainParams, clk) + boardingStore := dbStore.NewBoardingStore(s.chainParams, s.clk) // Select the boarding backend based on wallet type. var boardingBackend wallet.BoardingBackend @@ -2186,13 +2185,11 @@ func (s *Server) initRoundActor(ctx context.Context, clientWallet = s.btcwWallet.UnsafeFromSome() } - clk := clock.NewDefaultClock() - dbStore := db.NewStore( s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), ) - roundStore := dbStore.NewRoundStore(s.chainParams, clk) + roundStore := dbStore.NewRoundStore(s.chainParams, s.clk) s.roundStore = roundStore // Fetch the operator's terms from the server. These include @@ -2214,21 +2211,44 @@ func (s *Server) initRoundActor(ctx context.Context, // is generous for regtest/testnet usage. const defaultMaxOperatorFee = btcutil.Amount(1_000_000) + // Build the owned-script checker from the OOR artifact store. + // This allows the round FSM to determine which VTXOs belong + // to the local wallet by looking up registered receive scripts. + var scriptChecker round.OwnedScriptChecker + var scriptRegistrar round.OwnedScriptRegistrar + if s.db != nil { + oorStore := db.NewStore( + s.db.DB, s.db.Queries, s.db.Backend(), + s.log, + ).NewOORArtifactStore(s.clk) + + scriptChecker = &ownedScriptCheckerAdapter{ + store: oorStore, + } + scriptRegistrar = &ownedScriptRegistrarAdapter{ + store: oorStore, + operatorKey: operatorTerms.PubKey, + exitDelay: operatorTerms.VTXOExitDelay, + } + } + roundCfg := &round.RoundClientConfig{ - Name: "round-client", - Logger: s.subLogger(round.Subsystem), - Wallet: clientWallet, - RoundStore: roundStore, - VTXOStore: roundStore, - OperatorTerms: operatorTerms, - ServerConn: s.runtime.TellRef(), - ChainSource: chainSourceRef, - WalletActor: walletRef, - ChainParams: s.chainParams, - ActorSystem: s.actorSystem, - TimeoutActor: timeoutRef, - MaxOperatorFee: defaultMaxOperatorFee, - VTXOManager: vtxoManager, + Name: "round-client", + Logger: s.subLogger(round.Subsystem), + Wallet: clientWallet, + RoundStore: roundStore, + VTXOStore: roundStore, + OperatorTerms: operatorTerms, + ServerConn: s.runtime.TellRef(), + ChainSource: chainSourceRef, + WalletActor: walletRef, + ChainParams: s.chainParams, + ActorSystem: s.actorSystem, + TimeoutActor: timeoutRef, + MaxOperatorFee: defaultMaxOperatorFee, + VTXOManager: vtxoManager, + OwnedScriptChecker: scriptChecker, + OwnedScriptRegistrar: scriptRegistrar, ForfeitCollectionTimeout: s.cfg. ForfeitCollectionTimeout, } @@ -2288,12 +2308,11 @@ func (s *Server) initVTXOManager(ctx context.Context, vtxoWallet = s.btcwWallet.UnsafeFromSome() } - clk := clock.NewDefaultClock() dbStore := db.NewStore( s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), ) - vtxoStore := dbStore.NewVTXOStore(clk) + vtxoStore := dbStore.NewVTXOStore(s.clk) manager := vtxo.NewManager(&vtxo.ManagerConfig{ Store: vtxoStore, @@ -2342,7 +2361,6 @@ func (s *Server) initVTXOManager(ctx context.Context, func (s *Server) initOORActor(ctx context.Context, vtxoManagerRef actor.TellOnlyRef[vtxo.ManagerMsg]) error { - clk := clock.NewDefaultClock() dbStore := db.NewStore( s.db.DB, s.db.Queries, s.db.Backend(), s.subLogger(db.Subsystem), @@ -2368,8 +2386,8 @@ func (s *Server) initOORActor(ctx context.Context, oorSigner = s.btcwWallet.UnsafeFromSome() } - vtxoStore := dbStore.NewVTXOStore(clk) - packageStore := dbStore.NewOORArtifactStore(clk) + vtxoStore := dbStore.NewVTXOStore(s.clk) + packageStore := dbStore.NewOORArtifactStore(s.clk) // Create the timeout actor for scheduling retry timers. When a // retry timer fires, the callback ref transforms the expiry into diff --git a/db/round_store.go b/db/round_store.go index 4a2458a31..15db09f10 100644 --- a/db/round_store.go +++ b/db/round_store.go @@ -1102,7 +1102,6 @@ func vtxoRequestToRoundParams(roundID string, requestIndex int, ClientPubkey: clientPubkey, ClientKeyFamily: int32(req.OwnerKey.KeyLocator.Family), ClientKeyIndex: int32(req.OwnerKey.KeyLocator.Index), - OwnsClientKey: req.IsOwner, OperatorPubkey: operatorPubkey, SigningKeyFamily: int32(req.SigningKey.KeyLocator.Family), SigningKeyIndex: int32(req.SigningKey.KeyLocator.Index), @@ -1151,7 +1150,6 @@ func dbVtxoRequestRowToRoundVTXORequest( Index: uint32(t.ClientKeyIndex), }, }, - IsOwner: t.OwnsClientKey, OperatorKey: operatorKey, }, SigningKey: keychain.KeyDescriptor{ diff --git a/db/round_store_test.go b/db/round_store_test.go index f66a80e5a..346e33470 100644 --- a/db/round_store_test.go +++ b/db/round_store_test.go @@ -614,7 +614,6 @@ func createBoardingIntentFixture( Index: uint32(idx * 10), }, }, - IsOwner: true, OperatorKey: operatorPubKey, }, SigningKey: signingKey, @@ -767,7 +766,6 @@ func TestRoundStoreDecoupledVTXOStorage(t *testing.T) { Index: uint32(i), }, }, - IsOwner: true, OperatorKey: operatorKey.PubKey(), }, SigningKey: signingKey, diff --git a/lib/types/boarding.go b/lib/types/boarding.go index 215cfb76c..5c676223f 100644 --- a/lib/types/boarding.go +++ b/lib/types/boarding.go @@ -130,12 +130,6 @@ type VTXORequest struct { // preserved locally when the client owns the resulting VTXO. OwnerKey keychain.KeyDescriptor - // IsOwner reports whether this client controls the VTXO owner - // key and should persist the created VTXO locally once the - // round confirms. This is local-only metadata and is not sent - // on the wire. - IsOwner bool - // OperatorKey is the public key of the operator used in the // construction of the collaborative spend path of the VTXO. OperatorKey *btcec.PublicKey diff --git a/round/AGENTS.md b/round/AGENTS.md index cfac427b6..5073dc8a5 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. For directed sends, `OwnerKey` is the recipient's key (distinct from the sender's `SigningKey`). +- `VTXOIntent` — Pre-registration VTXO request carrying `OwnerKey`, `OperatorKey`. For directed sends, `OwnerKey` is the recipient's key (distinct from the sender's `SigningKey`). Ownership is determined at confirmation time via `OwnedScriptChecker`. - `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. diff --git a/round/CLAUDE.md b/round/CLAUDE.md index cfac427b6..5073dc8a5 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. For directed sends, `OwnerKey` is the recipient's key (distinct from the sender's `SigningKey`). +- `VTXOIntent` — Pre-registration VTXO request carrying `OwnerKey`, `OperatorKey`. For directed sends, `OwnerKey` is the recipient's key (distinct from the sender's `SigningKey`). Ownership is determined at confirmation time via `OwnedScriptChecker`. - `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. diff --git a/round/actor.go b/round/actor.go index 6313160bb..064aab034 100644 --- a/round/actor.go +++ b/round/actor.go @@ -113,7 +113,6 @@ func (a *RoundClientActor) buildVTXOIntentFromRefresh( PkScript: req.PkScript, Expiry: req.Expiry, OwnerKey: req.NewVTXOKey, - IsOwner: true, OperatorKey: req.OperatorKey, }, nil } @@ -267,6 +266,17 @@ type RoundClientConfig struct { // forfeit signatures after entering ForfeitSignaturesCollectingState. // If zero, a conservative default is used. ForfeitCollectionTimeout time.Duration + + // OwnedScriptChecker determines whether a VTXO pkScript belongs + // to the local wallet. When nil, all VTXOs pass the ownership + // check (backward-compatible default for tests). + OwnedScriptChecker OwnedScriptChecker + + // OwnedScriptRegistrar registers pkScripts as locally owned. + // Called when building VTXO intents so the checker can + // recognize them at confirmation time. When nil, registration + // is skipped (tests). + OwnedScriptRegistrar OwnedScriptRegistrar } // NewRoundClientActor creates a new client actor with the provided @@ -295,6 +305,7 @@ func NewRoundClientActor(cfg *RoundClientConfig) fn.Result[*RoundClientActor] { MaxOperatorFee: cfg.MaxOperatorFee, Log: actorLog, DisableJoinRequestAuth: cfg.DisableJoinRequestAuth, + OwnedScriptChecker: cfg.OwnedScriptChecker, } if err := ValidateDelayParameters( @@ -387,6 +398,7 @@ func (a *RoundClientActor) createRoundFSMFromDB(ctx context.Context, DisableJoinRequestAuth: a.cfg.DisableJoinRequestAuth, ForfeitCollectionTimeout: a. env.ForfeitCollectionTimeout, + OwnedScriptChecker: a.cfg.OwnedScriptChecker, } fsmCfg := ClientStateMachineCfg{ Logger: fsmLogger, @@ -446,6 +458,7 @@ func (a *RoundClientActor) createNewRound(ctx context.Context) (*RoundFSM, error DisableJoinRequestAuth: a.cfg.DisableJoinRequestAuth, ForfeitCollectionTimeout: a. env.ForfeitCollectionTimeout, + OwnedScriptChecker: a.cfg.OwnedScriptChecker, } fsmCfg := ClientStateMachineCfg{ Logger: fsmLogger, @@ -1080,7 +1093,6 @@ func vtxoRequestToIntent(req types.VTXORequest) VTXOIntent { PkScript: req.PkScript, Expiry: req.Expiry, OwnerKey: req.OwnerKey, - IsOwner: req.IsOwner, OperatorKey: req.OperatorKey, } } @@ -1113,12 +1125,24 @@ func (a *RoundClientActor) buildVTXOIntent(ctx context.Context, ) } + // Register the pkScript as locally owned so the + // OwnedScriptChecker recognizes it at confirmation time. + if a.cfg.OwnedScriptRegistrar != nil { + regErr := a.cfg.OwnedScriptRegistrar.RegisterOwnedScript( + ctx, desc.PkScript, *ownerKeyDesc, + ) + if regErr != nil { + return nil, fmt.Errorf( + "register owned script: %w", regErr, + ) + } + } + return &VTXOIntent{ Amount: amount, PkScript: desc.PkScript, Expiry: expiry, OwnerKey: *ownerKeyDesc, - IsOwner: true, OperatorKey: operatorKey, }, nil } @@ -1961,6 +1985,36 @@ func (a *RoundClientActor) handleRegisterIntent(ctx context.Context, } } + // Register locally-owned VTXO pkScripts from the intent so + // the OwnedScriptChecker recognizes them at confirmation + // time. VTXOs with a populated OwnerKey.KeyLocator are + // locally derived (e.g. change outputs in directed sends). + // A zero-value KeyLocator (Family=0, Index=0) signals a + // remote recipient whose key was not derived by this + // wallet — Family 0 is not used for VTXO owner keys + // (VTXOOwnerKeyFamily starts at 44). + if a.cfg.OwnedScriptRegistrar != nil { + for _, vtxo := range req.Package.VTXOs { + if vtxo.OwnerKey.PubKey == nil { + continue + } + + if vtxo.OwnerKey.KeyLocator == (keychain.KeyLocator{}) { //nolint:ll + continue + } + + regErr := a.cfg.OwnedScriptRegistrar.RegisterOwnedScript( //nolint:ll + ctx, vtxo.PkScript, vtxo.OwnerKey, + ) + if regErr != nil { + return fn.Err[actormsg.RoundActorResp]( + fmt.Errorf("register owned "+ + "script: %w", regErr), + ) + } + } + } + // Feed the pre-composed package to the FSM. err := a.askEventAndProcessOutbox(ctx, roundFSM, req.Package) if err != nil { diff --git a/round/actor_test.go b/round/actor_test.go index d192bb6aa..c3541f4d0 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -222,7 +222,6 @@ func TestActorStart(t *testing.T) { require.Equal(t, expectedDesc.PkScript, req.PkScript) require.Equal(t, h.operatorTerms.VTXOExitDelay, req.Expiry) require.True(t, req.OwnerKey.PubKey.IsEqual(ownerKey.PubKey)) - require.True(t, req.IsOwner) require.True(t, req.OperatorKey.IsEqual(h.operatorPubKey)) // SigningKey is not on VTXORequest — it lives on diff --git a/round/fsm_environment.go b/round/fsm_environment.go index a32745ba7..4bad6918d 100644 --- a/round/fsm_environment.go +++ b/round/fsm_environment.go @@ -62,6 +62,13 @@ type ClientEnvironment struct { // ForfeitCollectionTimeout is the timeout used while waiting for // forfeit signatures from VTXO actors. ForfeitCollectionTimeout time.Duration + + // OwnedScriptChecker determines whether a pkScript belongs to + // the local wallet. Used by buildOwnedClientVTXOs to filter + // VTXOs that should be persisted locally. This replaces the + // IsOwner flag with a data-driven ownership check backed by + // the owned receive scripts store. + OwnedScriptChecker OwnedScriptChecker } // Name returns the unique identifier for this FSM instance. diff --git a/round/harness_test.go b/round/harness_test.go index b4f9f2b16..aa42fec65 100644 --- a/round/harness_test.go +++ b/round/harness_test.go @@ -22,6 +22,7 @@ import ( "github.com/lightninglabs/darepo-client/lib/tree" "github.com/lightninglabs/darepo-client/lib/types" "github.com/lightninglabs/darepo-client/wallet" + fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/mock" @@ -145,6 +146,31 @@ func (m *MockVTXOStore) MarkVTXOSpent(ctx context.Context, // Compile-time check that MockVTXOStore implements VTXOStore. var _ VTXOStore = (*MockVTXOStore)(nil) +// mockOwnedScriptChecker is an OwnedScriptChecker backed by a set of +// known owned pkScripts. +type mockOwnedScriptChecker struct { + owned map[string]bool +} + +func newMockOwnedScriptChecker( + ownedScripts ...[]byte) *mockOwnedScriptChecker { + + m := &mockOwnedScriptChecker{owned: make(map[string]bool)} + for _, s := range ownedScripts { + m.owned[string(s)] = true + } + + return m +} + +func (m *mockOwnedScriptChecker) IsOwnedScript(_ context.Context, + pkScript []byte) fn.Result[bool] { + + return fn.Ok(m.owned[string(pkScript)]) +} + +var _ OwnedScriptChecker = (*mockOwnedScriptChecker)(nil) + // MockClientWallet implements ClientWallet (input.MuSig2Signer + input.Signer) // using mock.Mock for testing. type MockClientWallet struct { @@ -562,7 +588,6 @@ func (h *boardingTestHarness) newTestVTXORequestForIntent( PkScript: pkScript, Expiry: testExitDelay, OwnerKey: clientKey, - IsOwner: true, OperatorKey: h.operatorPubKey, } } diff --git a/round/interfaces.go b/round/interfaces.go index c41f28b9a..bfb3229d9 100644 --- a/round/interfaces.go +++ b/round/interfaces.go @@ -232,10 +232,6 @@ type VTXOIntent struct { // OwnerKey is the owner's key descriptor for the VTXO. OwnerKey keychain.KeyDescriptor - // IsOwner reports whether this client controls the VTXO owner - // key and should persist the VTXO locally once confirmed. - IsOwner bool - // OperatorKey is the operator's public key for collaborative // spends. OperatorKey *btcec.PublicKey @@ -260,7 +256,6 @@ func (r RoundVTXORequest) ToVTXORequest() types.VTXORequest { PkScript: r.PkScript, Expiry: r.Expiry, OwnerKey: r.OwnerKey, - IsOwner: r.IsOwner, OperatorKey: r.OperatorKey, SigningKey: r.SigningKey, } @@ -446,6 +441,29 @@ type ClientVTXO struct { RoundID fn.Option[RoundID] } +// OwnedScriptChecker determines whether a pkScript belongs to the local +// wallet. Implementations typically check against the persisted set of +// registered receive scripts (the same store used for OOR receives). +type OwnedScriptChecker interface { + // IsOwnedScript returns whether the pkScript is registered as + // an owned receive script in the local wallet. Returns an + // error if the store lookup fails for reasons other than the + // script not being found. + IsOwnedScript(ctx context.Context, + pkScript []byte) fn.Result[bool] +} + +// OwnedScriptRegistrar registers a pkScript as locally owned. The +// round actor calls this when building VTXO intents (boarding, +// refresh, change) so the OwnedScriptChecker can recognize them +// when the round confirms. +type OwnedScriptRegistrar interface { + // RegisterOwnedScript persists a pkScript + owner key as + // locally owned. + RegisterOwnedScript(ctx context.Context, pkScript []byte, + ownerKey keychain.KeyDescriptor) error +} + // VTXOStore defines the storage interface for off-chain balance management. // VTXOs (Virtual Transaction Outputs) are created when rounds complete // successfully and represent the client's off-chain balance. diff --git a/round/transitions.go b/round/transitions.go index e987d5f35..3e65107f0 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -1638,16 +1638,27 @@ func (s *PartialSigsSentState) transitionToForfeitCollection( } // buildOwnedClientVTXOs constructs locally owned ClientVTXO instances from -// the intents and client trees. Requests for foreign-owned VTXOs are skipped: -// the client still co-signs their tree path, but it must not persist them as -// spendable local balance. -func buildOwnedClientVTXOs(intents Intents, trees map[SignerKey]*tree.Tree, +// the intents and client trees. VTXOs whose pkScript is not recognized by +// the OwnedScriptChecker are skipped: the client still co-signs their tree +// path, but it must not persist them as spendable local balance. +func buildOwnedClientVTXOs(ctx context.Context, checker OwnedScriptChecker, + intents Intents, trees map[SignerKey]*tree.Tree, roundID RoundID) ([]*ClientVTXO, error) { vtxos := make([]*ClientVTXO, 0) for _, req := range intents.VTXOs { - if !req.IsOwner { - continue + if checker != nil { + owned, err := checker.IsOwnedScript( + ctx, req.PkScript, + ).Unpack() + if err != nil { + return nil, fmt.Errorf("check owned "+ + "script: %w", err) + } + + if !owned { + continue + } } signerKey := NewSignerKey(req.SigningKey.PubKey) @@ -1657,37 +1668,35 @@ func buildOwnedClientVTXOs(intents Intents, trees map[SignerKey]*tree.Tree, "for signing key") } - // Each signing key maps to exactly one leaf - // (enforced by ValidatePath during tree validation). + // Each signing key maps to exactly one leaf in the + // commitment tree. The server assigns one sub-tree + // per signing key, and each sub-tree contains a + // single VTXO output leaf (plus an anchor). This is + // enforced by ValidatePath during tree validation. leaves := clientTree.Root.GetLeafNodes() - - 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), - }) + if len(leaves) != 1 { + return nil, fmt.Errorf("expected exactly "+ + "1 leaf for signing key, got %d", + len(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), + }) } return vtxos, nil @@ -1720,6 +1729,7 @@ func (s *InputSigSentState) ProcessEvent( slog.Int("confirmations", int(evt.Confirmations))) vtxos, err := buildOwnedClientVTXOs( + ctx, env.OwnedScriptChecker, s.Intents, s.ClientTrees, s.RoundID, ) if err != nil { diff --git a/round/transitions_test.go b/round/transitions_test.go index 2385ec095..ca2a4493c 100644 --- a/round/transitions_test.go +++ b/round/transitions_test.go @@ -12,6 +12,7 @@ import ( "github.com/lightninglabs/darepo-client/lib/scripts" "github.com/lightninglabs/darepo-client/lib/tree" "github.com/lightninglabs/darepo-client/lib/types" + "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -1520,10 +1521,12 @@ func TestInputSigSentState(t *testing.T) { []BoardingIntent{intent}, ) - // Mark the VTXO request as not locally owned. The - // client co-signed the tree path but does not own the - // resulting VTXO, so it must not be persisted. - state.Intents.VTXOs[0].IsOwner = false + // Simulate a foreign-owned VTXO: clear the owner key's + // key locator family so it doesn't match the local + // VTXO owner key family, and install a checker that + // also rejects the pkScript. + state.Intents.VTXOs[0].OwnerKey.KeyLocator = keychain.KeyLocator{} + h.env.OwnedScriptChecker = newMockOwnedScriptChecker() h.withState(state) diff --git a/wallet/wallet.go b/wallet/wallet.go index e7188385f..88593f918 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -785,7 +785,6 @@ func (a *Ark) handleRefreshVTXOs(ctx context.Context, PkScript: vtxo.PkScript, Expiry: vtxo.Expiry, OwnerKey: vtxo.OwnerKey, - IsOwner: true, OperatorKey: vtxo.OperatorKey, }) } @@ -1365,7 +1364,7 @@ func (a *Ark) handleSendVTXOs(ctx context.Context, return fn.Err[WalletResp](fmt.Errorf( "recipient %d: amount must be "+ "between 1 and %d", - i, btcutil.MaxSatoshi, + i, int64(btcutil.MaxSatoshi), )) } @@ -1558,7 +1557,6 @@ func (a *Ark) buildSendVTXORequests(ctx context.Context, OwnerKey: keychain.KeyDescriptor{ PubKey: r.ClientKey, }, - IsOwner: false, OperatorKey: req.OperatorKey, }) } @@ -1593,7 +1591,6 @@ func (a *Ark) buildSendVTXORequests(ctx context.Context, PkScript: changeDesc.PkScript, Expiry: req.VTXOExitDelay, OwnerKey: *changeOwnerKey, - IsOwner: true, OperatorKey: req.OperatorKey, }, ) diff --git a/wallet/wallet_admission_test.go b/wallet/wallet_admission_test.go index fabb0192a..92aad7e4d 100644 --- a/wallet/wallet_admission_test.go +++ b/wallet/wallet_admission_test.go @@ -1143,12 +1143,4 @@ func TestSendVTXOsIntentPackageContents(t *testing.T) { ) } - // 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") - } } From 8861e56a077084dae7f19b15fc69122592727e26 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 26 Mar 2026 11:20:56 +0200 Subject: [PATCH 3/4] arkrpc: add pkScript, value, and round metadata to IncomingVTXOEvent Extend IncomingVTXOEvent with pk_script, value_sat, round_id, batch_expiry_height, and relative_expiry fields. For VTXO_CREATED events from confirmed rounds, these carry enough data for the client to materialize the VTXO without a follow-up indexer query. --- arkrpc/indexer.pb.go | 309 ++++++++++++++++++++++++++++++------------- arkrpc/indexer.proto | 42 +++++- 2 files changed, 259 insertions(+), 92 deletions(-) diff --git a/arkrpc/indexer.pb.go b/arkrpc/indexer.pb.go index ba0d37969..053ad6964 100644 --- a/arkrpc/indexer.pb.go +++ b/arkrpc/indexer.pb.go @@ -155,6 +155,62 @@ func (VTXOEventType) EnumDescriptor() ([]byte, []int) { return file_indexer_proto_rawDescGZIP(), []int{1} } +// VTXOOrigin distinguishes how a VTXO was created so the receiving +// client can apply the correct materialization logic. +type VTXOOrigin int32 + +const ( + // VTXO_ORIGIN_UNSPECIFIED is the default zero value. + VTXOOrigin_VTXO_ORIGIN_UNSPECIFIED VTXOOrigin = 0 + // VTXO_ORIGIN_IN_ROUND indicates the VTXO was created as part of + // a confirmed round (directed in-round send). + VTXOOrigin_VTXO_ORIGIN_IN_ROUND VTXOOrigin = 1 + // VTXO_ORIGIN_OOR indicates the VTXO was created via an + // out-of-round transfer. + VTXOOrigin_VTXO_ORIGIN_OOR VTXOOrigin = 2 +) + +// Enum value maps for VTXOOrigin. +var ( + VTXOOrigin_name = map[int32]string{ + 0: "VTXO_ORIGIN_UNSPECIFIED", + 1: "VTXO_ORIGIN_IN_ROUND", + 2: "VTXO_ORIGIN_OOR", + } + VTXOOrigin_value = map[string]int32{ + "VTXO_ORIGIN_UNSPECIFIED": 0, + "VTXO_ORIGIN_IN_ROUND": 1, + "VTXO_ORIGIN_OOR": 2, + } +) + +func (x VTXOOrigin) Enum() *VTXOOrigin { + p := new(VTXOOrigin) + *p = x + return p +} + +func (x VTXOOrigin) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (VTXOOrigin) Descriptor() protoreflect.EnumDescriptor { + return file_indexer_proto_enumTypes[2].Descriptor() +} + +func (VTXOOrigin) Type() protoreflect.EnumType { + return &file_indexer_proto_enumTypes[2] +} + +func (x VTXOOrigin) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use VTXOOrigin.Descriptor instead. +func (VTXOOrigin) EnumDescriptor() ([]byte, []int) { + return file_indexer_proto_rawDescGZIP(), []int{2} +} + type RegisterReceiveScriptRequest struct { state protoimpl.MessageState `protogen:"open.v1"` PkScript []byte `protobuf:"bytes,1,opt,name=pk_script,json=pkScript,proto3" json:"pk_script,omitempty"` @@ -2188,14 +2244,31 @@ func (x *ListVTXOEventsByScriptsResponse) GetNextCursor() uint64 { return 0 } -// IncomingVTXOEvent is delivered as a mailbox EVENT envelope. It is a durable -// hint that a wallet should reconcile state by polling event feeds. +// IncomingVTXOEvent is delivered as a mailbox EVENT envelope. For +// VTXO_CREATED events from confirmed rounds, the pk_script and +// value_sat fields carry enough data for the client to materialize +// the VTXO without a follow-up indexer query. type IncomingVTXOEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - EventId uint64 `protobuf:"varint,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` - Type VTXOEventType `protobuf:"varint,2,opt,name=type,proto3,enum=arkrpc.VTXOEventType" json:"type,omitempty"` - Outpoint *OutPoint `protobuf:"bytes,3,opt,name=outpoint,proto3" json:"outpoint,omitempty"` - Status VTXOStatus `protobuf:"varint,4,opt,name=status,proto3,enum=arkrpc.VTXOStatus" json:"status,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + EventId uint64 `protobuf:"varint,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + Type VTXOEventType `protobuf:"varint,2,opt,name=type,proto3,enum=arkrpc.VTXOEventType" json:"type,omitempty"` + Outpoint *OutPoint `protobuf:"bytes,3,opt,name=outpoint,proto3" json:"outpoint,omitempty"` + Status VTXOStatus `protobuf:"varint,4,opt,name=status,proto3,enum=arkrpc.VTXOStatus" json:"status,omitempty"` + // pk_script is the VTXO output script. Present for CREATED events + // so the client can match against registered receive scripts. + PkScript []byte `protobuf:"bytes,5,opt,name=pk_script,json=pkScript,proto3" json:"pk_script,omitempty"` + // value_sat is the VTXO amount in satoshis. + ValueSat uint64 `protobuf:"varint,6,opt,name=value_sat,json=valueSat,proto3" json:"value_sat,omitempty"` + // round_id identifies the round that created this VTXO. + RoundId string `protobuf:"bytes,7,opt,name=round_id,json=roundId,proto3" json:"round_id,omitempty"` + // batch_expiry_height is the absolute height at which the batch + // expires. + BatchExpiryHeight int32 `protobuf:"varint,8,opt,name=batch_expiry_height,json=batchExpiryHeight,proto3" json:"batch_expiry_height,omitempty"` + // relative_expiry is the CSV delay for the unilateral exit path. + RelativeExpiry uint32 `protobuf:"varint,9,opt,name=relative_expiry,json=relativeExpiry,proto3" json:"relative_expiry,omitempty"` + // origin indicates how the VTXO was created (in-round send vs + // out-of-round transfer). + Origin VTXOOrigin `protobuf:"varint,10,opt,name=origin,proto3,enum=arkrpc.VTXOOrigin" json:"origin,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2258,6 +2331,48 @@ func (x *IncomingVTXOEvent) GetStatus() VTXOStatus { return VTXOStatus_VTXO_STATUS_UNSPECIFIED } +func (x *IncomingVTXOEvent) GetPkScript() []byte { + if x != nil { + return x.PkScript + } + return nil +} + +func (x *IncomingVTXOEvent) GetValueSat() uint64 { + if x != nil { + return x.ValueSat + } + return 0 +} + +func (x *IncomingVTXOEvent) GetRoundId() string { + if x != nil { + return x.RoundId + } + return "" +} + +func (x *IncomingVTXOEvent) GetBatchExpiryHeight() int32 { + if x != nil { + return x.BatchExpiryHeight + } + return 0 +} + +func (x *IncomingVTXOEvent) GetRelativeExpiry() uint32 { + if x != nil { + return x.RelativeExpiry + } + return 0 +} + +func (x *IncomingVTXOEvent) GetOrigin() VTXOOrigin { + if x != nil { + return x.Origin + } + return VTXOOrigin_VTXO_ORIGIN_UNSPECIFIED +} + var File_indexer_proto protoreflect.FileDescriptor const file_indexer_proto_rawDesc = "" + @@ -2410,12 +2525,19 @@ const file_indexer_proto_rawDesc = "" + "\x1fListVTXOEventsByScriptsResponse\x12)\n" + "\x06events\x18\x01 \x03(\v2\x11.arkrpc.VTXOEventR\x06events\x12\x1f\n" + "\vnext_cursor\x18\x02 \x01(\x04R\n" + - "nextCursor\"\xb3\x01\n" + + "nextCursor\"\x8d\x03\n" + "\x11IncomingVTXOEvent\x12\x19\n" + "\bevent_id\x18\x01 \x01(\x04R\aeventId\x12)\n" + "\x04type\x18\x02 \x01(\x0e2\x15.arkrpc.VTXOEventTypeR\x04type\x12,\n" + "\boutpoint\x18\x03 \x01(\v2\x10.arkrpc.OutPointR\boutpoint\x12*\n" + - "\x06status\x18\x04 \x01(\x0e2\x12.arkrpc.VTXOStatusR\x06status*\x84\x02\n" + + "\x06status\x18\x04 \x01(\x0e2\x12.arkrpc.VTXOStatusR\x06status\x12\x1b\n" + + "\tpk_script\x18\x05 \x01(\fR\bpkScript\x12\x1b\n" + + "\tvalue_sat\x18\x06 \x01(\x04R\bvalueSat\x12\x19\n" + + "\bround_id\x18\a \x01(\tR\aroundId\x12.\n" + + "\x13batch_expiry_height\x18\b \x01(\x05R\x11batchExpiryHeight\x12'\n" + + "\x0frelative_expiry\x18\t \x01(\rR\x0erelativeExpiry\x12*\n" + + "\x06origin\x18\n" + + " \x01(\x0e2\x12.arkrpc.VTXOOriginR\x06origin*\x84\x02\n" + "\n" + "VTXOStatus\x12\x1b\n" + "\x17VTXO_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + @@ -2431,7 +2553,12 @@ const file_indexer_proto_rawDesc = "" + "\x1bVTXO_EVENT_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17VTXO_EVENT_TYPE_CREATED\x10\x01\x12\"\n" + "\x1eVTXO_EVENT_TYPE_STATUS_CHANGED\x10\x02\x12\x1e\n" + - "\x1aVTXO_EVENT_TYPE_TERMINATED\x10\x032\xef\x05\n" + + "\x1aVTXO_EVENT_TYPE_TERMINATED\x10\x03*X\n" + + "\n" + + "VTXOOrigin\x12\x1b\n" + + "\x17VTXO_ORIGIN_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14VTXO_ORIGIN_IN_ROUND\x10\x01\x12\x13\n" + + "\x0fVTXO_ORIGIN_OOR\x10\x022\xef\x05\n" + "\x0eIndexerService\x12d\n" + "\x15RegisterReceiveScript\x12$.arkrpc.RegisterReceiveScriptRequest\x1a%.arkrpc.RegisterReceiveScriptResponse\x12a\n" + "\x14ListMyReceiveScripts\x12#.arkrpc.ListMyReceiveScriptsRequest\x1a$.arkrpc.ListMyReceiveScriptsResponse\x12j\n" + @@ -2453,97 +2580,99 @@ func file_indexer_proto_rawDescGZIP() []byte { return file_indexer_proto_rawDescData } -var file_indexer_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_indexer_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_indexer_proto_msgTypes = make([]protoimpl.MessageInfo, 30) var file_indexer_proto_goTypes = []any{ (VTXOStatus)(0), // 0: arkrpc.VTXOStatus (VTXOEventType)(0), // 1: arkrpc.VTXOEventType - (*RegisterReceiveScriptRequest)(nil), // 2: arkrpc.RegisterReceiveScriptRequest - (*TaprootSchnorrProof)(nil), // 3: arkrpc.TaprootSchnorrProof - (*BIP322Proof)(nil), // 4: arkrpc.BIP322Proof - (*RegisterReceiveScriptResponse)(nil), // 5: arkrpc.RegisterReceiveScriptResponse - (*UnregisterReceiveScriptRequest)(nil), // 6: arkrpc.UnregisterReceiveScriptRequest - (*ListMyReceiveScriptsRequest)(nil), // 7: arkrpc.ListMyReceiveScriptsRequest - (*ListMyReceiveScriptsResponse)(nil), // 8: arkrpc.ListMyReceiveScriptsResponse - (*RegisteredReceiveScript)(nil), // 9: arkrpc.RegisteredReceiveScript - (*UnregisterReceiveScriptResponse)(nil), // 10: arkrpc.UnregisterReceiveScriptResponse - (*ListOORRecipientEventsByScriptRequest)(nil), // 11: arkrpc.ListOORRecipientEventsByScriptRequest - (*ListOORRecipientEventsByScriptResponse)(nil), // 12: arkrpc.ListOORRecipientEventsByScriptResponse - (*OORRecipientEvent)(nil), // 13: arkrpc.OORRecipientEvent - (*IncomingOOREvent)(nil), // 14: arkrpc.IncomingOOREvent - (*OutPoint)(nil), // 15: arkrpc.OutPoint - (*TxOut)(nil), // 16: arkrpc.TxOut - (*TreePathNode)(nil), // 17: arkrpc.TreePathNode - (*TreePath)(nil), // 18: arkrpc.TreePath - (*ScriptScope)(nil), // 19: arkrpc.ScriptScope - (*VTXO)(nil), // 20: arkrpc.VTXO - (*ListVTXOsByScriptsRequest)(nil), // 21: arkrpc.ListVTXOsByScriptsRequest - (*ListVTXOsByScriptsResponse)(nil), // 22: arkrpc.ListVTXOsByScriptsResponse - (*TreeNode)(nil), // 23: arkrpc.TreeNode - (*TreeEdge)(nil), // 24: arkrpc.TreeEdge - (*GetSubtreeByScriptsRequest)(nil), // 25: arkrpc.GetSubtreeByScriptsRequest - (*GetSubtreeByScriptsResponse)(nil), // 26: arkrpc.GetSubtreeByScriptsResponse - (*VTXOEvent)(nil), // 27: arkrpc.VTXOEvent - (*ListVTXOEventsByScriptsRequest)(nil), // 28: arkrpc.ListVTXOEventsByScriptsRequest - (*ListVTXOEventsByScriptsResponse)(nil), // 29: arkrpc.ListVTXOEventsByScriptsResponse - (*IncomingVTXOEvent)(nil), // 30: arkrpc.IncomingVTXOEvent - nil, // 31: arkrpc.TreePathNode.ChildrenEntry + (VTXOOrigin)(0), // 2: arkrpc.VTXOOrigin + (*RegisterReceiveScriptRequest)(nil), // 3: arkrpc.RegisterReceiveScriptRequest + (*TaprootSchnorrProof)(nil), // 4: arkrpc.TaprootSchnorrProof + (*BIP322Proof)(nil), // 5: arkrpc.BIP322Proof + (*RegisterReceiveScriptResponse)(nil), // 6: arkrpc.RegisterReceiveScriptResponse + (*UnregisterReceiveScriptRequest)(nil), // 7: arkrpc.UnregisterReceiveScriptRequest + (*ListMyReceiveScriptsRequest)(nil), // 8: arkrpc.ListMyReceiveScriptsRequest + (*ListMyReceiveScriptsResponse)(nil), // 9: arkrpc.ListMyReceiveScriptsResponse + (*RegisteredReceiveScript)(nil), // 10: arkrpc.RegisteredReceiveScript + (*UnregisterReceiveScriptResponse)(nil), // 11: arkrpc.UnregisterReceiveScriptResponse + (*ListOORRecipientEventsByScriptRequest)(nil), // 12: arkrpc.ListOORRecipientEventsByScriptRequest + (*ListOORRecipientEventsByScriptResponse)(nil), // 13: arkrpc.ListOORRecipientEventsByScriptResponse + (*OORRecipientEvent)(nil), // 14: arkrpc.OORRecipientEvent + (*IncomingOOREvent)(nil), // 15: arkrpc.IncomingOOREvent + (*OutPoint)(nil), // 16: arkrpc.OutPoint + (*TxOut)(nil), // 17: arkrpc.TxOut + (*TreePathNode)(nil), // 18: arkrpc.TreePathNode + (*TreePath)(nil), // 19: arkrpc.TreePath + (*ScriptScope)(nil), // 20: arkrpc.ScriptScope + (*VTXO)(nil), // 21: arkrpc.VTXO + (*ListVTXOsByScriptsRequest)(nil), // 22: arkrpc.ListVTXOsByScriptsRequest + (*ListVTXOsByScriptsResponse)(nil), // 23: arkrpc.ListVTXOsByScriptsResponse + (*TreeNode)(nil), // 24: arkrpc.TreeNode + (*TreeEdge)(nil), // 25: arkrpc.TreeEdge + (*GetSubtreeByScriptsRequest)(nil), // 26: arkrpc.GetSubtreeByScriptsRequest + (*GetSubtreeByScriptsResponse)(nil), // 27: arkrpc.GetSubtreeByScriptsResponse + (*VTXOEvent)(nil), // 28: arkrpc.VTXOEvent + (*ListVTXOEventsByScriptsRequest)(nil), // 29: arkrpc.ListVTXOEventsByScriptsRequest + (*ListVTXOEventsByScriptsResponse)(nil), // 30: arkrpc.ListVTXOEventsByScriptsResponse + (*IncomingVTXOEvent)(nil), // 31: arkrpc.IncomingVTXOEvent + nil, // 32: arkrpc.TreePathNode.ChildrenEntry } var file_indexer_proto_depIdxs = []int32{ - 3, // 0: arkrpc.RegisterReceiveScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof - 4, // 1: arkrpc.RegisterReceiveScriptRequest.bip322:type_name -> arkrpc.BIP322Proof - 3, // 2: arkrpc.UnregisterReceiveScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof - 4, // 3: arkrpc.UnregisterReceiveScriptRequest.bip322:type_name -> arkrpc.BIP322Proof - 9, // 4: arkrpc.ListMyReceiveScriptsResponse.scripts:type_name -> arkrpc.RegisteredReceiveScript - 3, // 5: arkrpc.ListOORRecipientEventsByScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof - 4, // 6: arkrpc.ListOORRecipientEventsByScriptRequest.bip322:type_name -> arkrpc.BIP322Proof - 13, // 7: arkrpc.ListOORRecipientEventsByScriptResponse.events:type_name -> arkrpc.OORRecipientEvent - 15, // 8: arkrpc.TreePathNode.input:type_name -> arkrpc.OutPoint - 16, // 9: arkrpc.TreePathNode.outputs:type_name -> arkrpc.TxOut - 31, // 10: arkrpc.TreePathNode.children:type_name -> arkrpc.TreePathNode.ChildrenEntry - 17, // 11: arkrpc.TreePath.nodes:type_name -> arkrpc.TreePathNode - 15, // 12: arkrpc.TreePath.batch_outpoint:type_name -> arkrpc.OutPoint - 16, // 13: arkrpc.TreePath.batch_output:type_name -> arkrpc.TxOut - 3, // 14: arkrpc.ScriptScope.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof - 4, // 15: arkrpc.ScriptScope.bip322:type_name -> arkrpc.BIP322Proof - 15, // 16: arkrpc.VTXO.outpoint:type_name -> arkrpc.OutPoint + 4, // 0: arkrpc.RegisterReceiveScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof + 5, // 1: arkrpc.RegisterReceiveScriptRequest.bip322:type_name -> arkrpc.BIP322Proof + 4, // 2: arkrpc.UnregisterReceiveScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof + 5, // 3: arkrpc.UnregisterReceiveScriptRequest.bip322:type_name -> arkrpc.BIP322Proof + 10, // 4: arkrpc.ListMyReceiveScriptsResponse.scripts:type_name -> arkrpc.RegisteredReceiveScript + 4, // 5: arkrpc.ListOORRecipientEventsByScriptRequest.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof + 5, // 6: arkrpc.ListOORRecipientEventsByScriptRequest.bip322:type_name -> arkrpc.BIP322Proof + 14, // 7: arkrpc.ListOORRecipientEventsByScriptResponse.events:type_name -> arkrpc.OORRecipientEvent + 16, // 8: arkrpc.TreePathNode.input:type_name -> arkrpc.OutPoint + 17, // 9: arkrpc.TreePathNode.outputs:type_name -> arkrpc.TxOut + 32, // 10: arkrpc.TreePathNode.children:type_name -> arkrpc.TreePathNode.ChildrenEntry + 18, // 11: arkrpc.TreePath.nodes:type_name -> arkrpc.TreePathNode + 16, // 12: arkrpc.TreePath.batch_outpoint:type_name -> arkrpc.OutPoint + 17, // 13: arkrpc.TreePath.batch_output:type_name -> arkrpc.TxOut + 4, // 14: arkrpc.ScriptScope.taproot_schnorr:type_name -> arkrpc.TaprootSchnorrProof + 5, // 15: arkrpc.ScriptScope.bip322:type_name -> arkrpc.BIP322Proof + 16, // 16: arkrpc.VTXO.outpoint:type_name -> arkrpc.OutPoint 0, // 17: arkrpc.VTXO.status:type_name -> arkrpc.VTXOStatus - 18, // 18: arkrpc.VTXO.tree_path:type_name -> arkrpc.TreePath - 19, // 19: arkrpc.ListVTXOsByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope + 19, // 18: arkrpc.VTXO.tree_path:type_name -> arkrpc.TreePath + 20, // 19: arkrpc.ListVTXOsByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope 0, // 20: arkrpc.ListVTXOsByScriptsRequest.status_filter:type_name -> arkrpc.VTXOStatus - 20, // 21: arkrpc.ListVTXOsByScriptsResponse.vtxos:type_name -> arkrpc.VTXO - 15, // 22: arkrpc.TreeNode.input:type_name -> arkrpc.OutPoint - 19, // 23: arkrpc.GetSubtreeByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope - 20, // 24: arkrpc.GetSubtreeByScriptsResponse.vtxos:type_name -> arkrpc.VTXO - 23, // 25: arkrpc.GetSubtreeByScriptsResponse.nodes:type_name -> arkrpc.TreeNode - 24, // 26: arkrpc.GetSubtreeByScriptsResponse.edges:type_name -> arkrpc.TreeEdge + 21, // 21: arkrpc.ListVTXOsByScriptsResponse.vtxos:type_name -> arkrpc.VTXO + 16, // 22: arkrpc.TreeNode.input:type_name -> arkrpc.OutPoint + 20, // 23: arkrpc.GetSubtreeByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope + 21, // 24: arkrpc.GetSubtreeByScriptsResponse.vtxos:type_name -> arkrpc.VTXO + 24, // 25: arkrpc.GetSubtreeByScriptsResponse.nodes:type_name -> arkrpc.TreeNode + 25, // 26: arkrpc.GetSubtreeByScriptsResponse.edges:type_name -> arkrpc.TreeEdge 1, // 27: arkrpc.VTXOEvent.type:type_name -> arkrpc.VTXOEventType - 15, // 28: arkrpc.VTXOEvent.outpoint:type_name -> arkrpc.OutPoint + 16, // 28: arkrpc.VTXOEvent.outpoint:type_name -> arkrpc.OutPoint 0, // 29: arkrpc.VTXOEvent.status:type_name -> arkrpc.VTXOStatus - 19, // 30: arkrpc.ListVTXOEventsByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope - 27, // 31: arkrpc.ListVTXOEventsByScriptsResponse.events:type_name -> arkrpc.VTXOEvent + 20, // 30: arkrpc.ListVTXOEventsByScriptsRequest.scripts:type_name -> arkrpc.ScriptScope + 28, // 31: arkrpc.ListVTXOEventsByScriptsResponse.events:type_name -> arkrpc.VTXOEvent 1, // 32: arkrpc.IncomingVTXOEvent.type:type_name -> arkrpc.VTXOEventType - 15, // 33: arkrpc.IncomingVTXOEvent.outpoint:type_name -> arkrpc.OutPoint + 16, // 33: arkrpc.IncomingVTXOEvent.outpoint:type_name -> arkrpc.OutPoint 0, // 34: arkrpc.IncomingVTXOEvent.status:type_name -> arkrpc.VTXOStatus - 2, // 35: arkrpc.IndexerService.RegisterReceiveScript:input_type -> arkrpc.RegisterReceiveScriptRequest - 7, // 36: arkrpc.IndexerService.ListMyReceiveScripts:input_type -> arkrpc.ListMyReceiveScriptsRequest - 6, // 37: arkrpc.IndexerService.UnregisterReceiveScript:input_type -> arkrpc.UnregisterReceiveScriptRequest - 11, // 38: arkrpc.IndexerService.ListOORRecipientEventsByScript:input_type -> arkrpc.ListOORRecipientEventsByScriptRequest - 21, // 39: arkrpc.IndexerService.ListVTXOsByScripts:input_type -> arkrpc.ListVTXOsByScriptsRequest - 25, // 40: arkrpc.IndexerService.GetSubtreeByScripts:input_type -> arkrpc.GetSubtreeByScriptsRequest - 28, // 41: arkrpc.IndexerService.ListVTXOEventsByScripts:input_type -> arkrpc.ListVTXOEventsByScriptsRequest - 5, // 42: arkrpc.IndexerService.RegisterReceiveScript:output_type -> arkrpc.RegisterReceiveScriptResponse - 8, // 43: arkrpc.IndexerService.ListMyReceiveScripts:output_type -> arkrpc.ListMyReceiveScriptsResponse - 10, // 44: arkrpc.IndexerService.UnregisterReceiveScript:output_type -> arkrpc.UnregisterReceiveScriptResponse - 12, // 45: arkrpc.IndexerService.ListOORRecipientEventsByScript:output_type -> arkrpc.ListOORRecipientEventsByScriptResponse - 22, // 46: arkrpc.IndexerService.ListVTXOsByScripts:output_type -> arkrpc.ListVTXOsByScriptsResponse - 26, // 47: arkrpc.IndexerService.GetSubtreeByScripts:output_type -> arkrpc.GetSubtreeByScriptsResponse - 29, // 48: arkrpc.IndexerService.ListVTXOEventsByScripts:output_type -> arkrpc.ListVTXOEventsByScriptsResponse - 42, // [42:49] is the sub-list for method output_type - 35, // [35:42] is the sub-list for method input_type - 35, // [35:35] is the sub-list for extension type_name - 35, // [35:35] is the sub-list for extension extendee - 0, // [0:35] is the sub-list for field type_name + 2, // 35: arkrpc.IncomingVTXOEvent.origin:type_name -> arkrpc.VTXOOrigin + 3, // 36: arkrpc.IndexerService.RegisterReceiveScript:input_type -> arkrpc.RegisterReceiveScriptRequest + 8, // 37: arkrpc.IndexerService.ListMyReceiveScripts:input_type -> arkrpc.ListMyReceiveScriptsRequest + 7, // 38: arkrpc.IndexerService.UnregisterReceiveScript:input_type -> arkrpc.UnregisterReceiveScriptRequest + 12, // 39: arkrpc.IndexerService.ListOORRecipientEventsByScript:input_type -> arkrpc.ListOORRecipientEventsByScriptRequest + 22, // 40: arkrpc.IndexerService.ListVTXOsByScripts:input_type -> arkrpc.ListVTXOsByScriptsRequest + 26, // 41: arkrpc.IndexerService.GetSubtreeByScripts:input_type -> arkrpc.GetSubtreeByScriptsRequest + 29, // 42: arkrpc.IndexerService.ListVTXOEventsByScripts:input_type -> arkrpc.ListVTXOEventsByScriptsRequest + 6, // 43: arkrpc.IndexerService.RegisterReceiveScript:output_type -> arkrpc.RegisterReceiveScriptResponse + 9, // 44: arkrpc.IndexerService.ListMyReceiveScripts:output_type -> arkrpc.ListMyReceiveScriptsResponse + 11, // 45: arkrpc.IndexerService.UnregisterReceiveScript:output_type -> arkrpc.UnregisterReceiveScriptResponse + 13, // 46: arkrpc.IndexerService.ListOORRecipientEventsByScript:output_type -> arkrpc.ListOORRecipientEventsByScriptResponse + 23, // 47: arkrpc.IndexerService.ListVTXOsByScripts:output_type -> arkrpc.ListVTXOsByScriptsResponse + 27, // 48: arkrpc.IndexerService.GetSubtreeByScripts:output_type -> arkrpc.GetSubtreeByScriptsResponse + 30, // 49: arkrpc.IndexerService.ListVTXOEventsByScripts:output_type -> arkrpc.ListVTXOEventsByScriptsResponse + 43, // [43:50] is the sub-list for method output_type + 36, // [36:43] is the sub-list for method input_type + 36, // [36:36] is the sub-list for extension type_name + 36, // [36:36] is the sub-list for extension extendee + 0, // [0:36] is the sub-list for field type_name } func init() { file_indexer_proto_init() } @@ -2572,7 +2701,7 @@ func file_indexer_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_indexer_proto_rawDesc), len(file_indexer_proto_rawDesc)), - NumEnums: 2, + NumEnums: 3, NumMessages: 30, NumExtensions: 0, NumServices: 1, diff --git a/arkrpc/indexer.proto b/arkrpc/indexer.proto index 3d8ce01e6..46bcee5e7 100644 --- a/arkrpc/indexer.proto +++ b/arkrpc/indexer.proto @@ -472,11 +472,49 @@ message ListVTXOEventsByScriptsResponse { uint64 next_cursor = 2; } -// IncomingVTXOEvent is delivered as a mailbox EVENT envelope. It is a durable -// hint that a wallet should reconcile state by polling event feeds. +// VTXOOrigin distinguishes how a VTXO was created so the receiving +// client can apply the correct materialization logic. +enum VTXOOrigin { + // VTXO_ORIGIN_UNSPECIFIED is the default zero value. + VTXO_ORIGIN_UNSPECIFIED = 0; + + // VTXO_ORIGIN_IN_ROUND indicates the VTXO was created as part of + // a confirmed round (directed in-round send). + VTXO_ORIGIN_IN_ROUND = 1; + + // VTXO_ORIGIN_OOR indicates the VTXO was created via an + // out-of-round transfer. + VTXO_ORIGIN_OOR = 2; +} + +// IncomingVTXOEvent is delivered as a mailbox EVENT envelope. For +// VTXO_CREATED events from confirmed rounds, the pk_script and +// value_sat fields carry enough data for the client to materialize +// the VTXO without a follow-up indexer query. message IncomingVTXOEvent { uint64 event_id = 1; VTXOEventType type = 2; OutPoint outpoint = 3; VTXOStatus status = 4; + + // pk_script is the VTXO output script. Present for CREATED events + // so the client can match against registered receive scripts. + bytes pk_script = 5; + + // value_sat is the VTXO amount in satoshis. + uint64 value_sat = 6; + + // round_id identifies the round that created this VTXO. + string round_id = 7; + + // batch_expiry_height is the absolute height at which the batch + // expires. + int32 batch_expiry_height = 8; + + // relative_expiry is the CSV delay for the unilateral exit path. + uint32 relative_expiry = 9; + + // origin indicates how the VTXO was created (in-round send vs + // out-of-round transfer). + VTXOOrigin origin = 10; } From 6c2fefa899f107b5dcabd4a93a576ba2964ab902 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 26 Mar 2026 11:30:32 +0200 Subject: [PATCH 4/4] darepod: add IncomingVTXOEvent handler for round VTXO receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a lightweight actor that handles IncomingVTXOEvent push notifications from the server's indexer. When the server publishes a VTXO_CREATED event for a confirmed round leaf matching a registered receive script, the handler: 1. Looks up the pkScript in owned_receive_scripts 2. Derives the tapscript from the owner key + operator key 3. Builds a vtxo.Descriptor from the event's metadata 4. Persists the VTXO via VTXOPersistenceStore 5. Notifies the VTXO manager via VTXOsMaterializedNotification This enables in-round VTXO receipt via the same receive-script mechanism as OOR — the recipient calls NewOORReceiveScript once and receives VTXOs from both OOR and in-round sends. The event route is registered for (arkrpc.ArkService, IncomingVTXO) alongside the existing IncomingOOR route. --- .gitignore | 1 + arkrpc/indexer.pb.go | 28 +++- arkrpc/indexer.proto | 11 +- darepod/incoming_vtxo_handler.go | 35 ++++ darepod/server.go | 63 ++++++++ db/vtxo_store.go | 5 +- round/transitions_test.go | 3 +- vtxo/incoming_handler.go | 270 +++++++++++++++++++++++++++++++ vtxo/incoming_handler_test.go | 188 +++++++++++++++++++++ wallet/wallet_admission_test.go | 1 - 10 files changed, 592 insertions(+), 13 deletions(-) create mode 100644 darepod/incoming_vtxo_handler.go create mode 100644 vtxo/incoming_handler.go create mode 100644 vtxo/incoming_handler_test.go diff --git a/.gitignore b/.gitignore index 53489bdf6..df3166191 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ DS_Store go.work.sum /tools/custom-gcl +.reviews/ diff --git a/arkrpc/indexer.pb.go b/arkrpc/indexer.pb.go index 053ad6964..406526a10 100644 --- a/arkrpc/indexer.pb.go +++ b/arkrpc/indexer.pb.go @@ -2261,16 +2261,22 @@ type IncomingVTXOEvent struct { ValueSat uint64 `protobuf:"varint,6,opt,name=value_sat,json=valueSat,proto3" json:"value_sat,omitempty"` // round_id identifies the round that created this VTXO. RoundId string `protobuf:"bytes,7,opt,name=round_id,json=roundId,proto3" json:"round_id,omitempty"` - // batch_expiry_height is the absolute height at which the batch - // expires. + // batch_expiry_height is the absolute block height at which the + // batch sweep path becomes spendable. The server MUST compute + // this as confirmation_height + sweep_delay before publishing + // the event. BatchExpiryHeight int32 `protobuf:"varint,8,opt,name=batch_expiry_height,json=batchExpiryHeight,proto3" json:"batch_expiry_height,omitempty"` // relative_expiry is the CSV delay for the unilateral exit path. RelativeExpiry uint32 `protobuf:"varint,9,opt,name=relative_expiry,json=relativeExpiry,proto3" json:"relative_expiry,omitempty"` // origin indicates how the VTXO was created (in-round send vs // out-of-round transfer). - Origin VTXOOrigin `protobuf:"varint,10,opt,name=origin,proto3,enum=arkrpc.VTXOOrigin" json:"origin,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Origin VTXOOrigin `protobuf:"varint,10,opt,name=origin,proto3,enum=arkrpc.VTXOOrigin" json:"origin,omitempty"` + // commitment_txid is the transaction ID of the round's + // commitment transaction. This is distinct from the leaf txid + // carried in the outpoint field. + CommitmentTxid []byte `protobuf:"bytes,11,opt,name=commitment_txid,json=commitmentTxid,proto3" json:"commitment_txid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *IncomingVTXOEvent) Reset() { @@ -2373,6 +2379,13 @@ func (x *IncomingVTXOEvent) GetOrigin() VTXOOrigin { return VTXOOrigin_VTXO_ORIGIN_UNSPECIFIED } +func (x *IncomingVTXOEvent) GetCommitmentTxid() []byte { + if x != nil { + return x.CommitmentTxid + } + return nil +} + var File_indexer_proto protoreflect.FileDescriptor const file_indexer_proto_rawDesc = "" + @@ -2525,7 +2538,7 @@ const file_indexer_proto_rawDesc = "" + "\x1fListVTXOEventsByScriptsResponse\x12)\n" + "\x06events\x18\x01 \x03(\v2\x11.arkrpc.VTXOEventR\x06events\x12\x1f\n" + "\vnext_cursor\x18\x02 \x01(\x04R\n" + - "nextCursor\"\x8d\x03\n" + + "nextCursor\"\xb6\x03\n" + "\x11IncomingVTXOEvent\x12\x19\n" + "\bevent_id\x18\x01 \x01(\x04R\aeventId\x12)\n" + "\x04type\x18\x02 \x01(\x0e2\x15.arkrpc.VTXOEventTypeR\x04type\x12,\n" + @@ -2537,7 +2550,8 @@ const file_indexer_proto_rawDesc = "" + "\x13batch_expiry_height\x18\b \x01(\x05R\x11batchExpiryHeight\x12'\n" + "\x0frelative_expiry\x18\t \x01(\rR\x0erelativeExpiry\x12*\n" + "\x06origin\x18\n" + - " \x01(\x0e2\x12.arkrpc.VTXOOriginR\x06origin*\x84\x02\n" + + " \x01(\x0e2\x12.arkrpc.VTXOOriginR\x06origin\x12'\n" + + "\x0fcommitment_txid\x18\v \x01(\fR\x0ecommitmentTxid*\x84\x02\n" + "\n" + "VTXOStatus\x12\x1b\n" + "\x17VTXO_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + diff --git a/arkrpc/indexer.proto b/arkrpc/indexer.proto index 46bcee5e7..0e93405e3 100644 --- a/arkrpc/indexer.proto +++ b/arkrpc/indexer.proto @@ -507,8 +507,10 @@ message IncomingVTXOEvent { // round_id identifies the round that created this VTXO. string round_id = 7; - // batch_expiry_height is the absolute height at which the batch - // expires. + // batch_expiry_height is the absolute block height at which the + // batch sweep path becomes spendable. The server MUST compute + // this as confirmation_height + sweep_delay before publishing + // the event. int32 batch_expiry_height = 8; // relative_expiry is the CSV delay for the unilateral exit path. @@ -517,4 +519,9 @@ message IncomingVTXOEvent { // origin indicates how the VTXO was created (in-round send vs // out-of-round transfer). VTXOOrigin origin = 10; + + // commitment_txid is the transaction ID of the round's + // commitment transaction. This is distinct from the leaf txid + // carried in the outpoint field. + bytes commitment_txid = 11; } diff --git a/darepod/incoming_vtxo_handler.go b/darepod/incoming_vtxo_handler.go new file mode 100644 index 000000000..b4faf3d19 --- /dev/null +++ b/darepod/incoming_vtxo_handler.go @@ -0,0 +1,35 @@ +package darepod + +import ( + "context" + + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/vtxo" +) + +// ownedScriptLookupAdapter wraps db.OORArtifactPersistenceStore to +// satisfy the vtxo.OwnedScriptLookup interface. It converts the +// db-specific record type to the vtxo-level OwnedReceiveScript. +type ownedScriptLookupAdapter struct { + store *db.OORArtifactPersistenceStore +} + +// LookupOwnedReceiveScript delegates to the underlying store and +// converts the result to a vtxo.OwnedReceiveScript. +func (a *ownedScriptLookupAdapter) LookupOwnedReceiveScript( + ctx context.Context, + pkScript []byte) (*vtxo.OwnedReceiveScript, error) { + + rec, err := a.store.LookupOwnedReceiveScript(ctx, pkScript) + if err != nil { + return nil, err + } + + return &vtxo.OwnedReceiveScript{ + ClientKey: rec.ClientKey, + OperatorPubKey: rec.OperatorPubKey, + ExitDelay: rec.ExitDelay, + }, nil +} + +var _ vtxo.OwnedScriptLookup = (*ownedScriptLookupAdapter)(nil) diff --git a/darepod/server.go b/darepod/server.go index e79aab534..8a83c46b2 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -99,6 +99,10 @@ const ( // MethodIncomingOOR is the routing method name for incoming // OOR transfer notifications pushed by the server indexer. MethodIncomingOOR = "IncomingOOR" + + // MethodIncomingVTXO is the routing method name for incoming + // VTXO lifecycle events pushed by the server indexer. + MethodIncomingVTXO = "IncomingVTXO" ) // Main is the true entry point for the daemon. It is called after CLI flag @@ -1451,10 +1455,47 @@ func (s *Server) buildEventRoutes() *serverconn.EventRouter { s.registerOOREventRoutes(router) s.registerRoundEventRoutes(router) + s.registerIncomingVTXOEventRoute(router) return router } +// registerIncomingVTXOEventRoute registers the IncomingVTXO push event +// route. When the server publishes a VTXO_CREATED event for a round +// leaf matching a registered receive script, this route dispatches it +// to the incoming VTXO handler actor for materialization. +func (s *Server) registerIncomingVTXOEventRoute( + router *serverconn.EventRouter) { + + vtxoKey := vtxo.IncomingVTXOServiceKey() + + serverconn.AddRoute(router, serverconn.EventRouteConfig[ + vtxo.IncomingVTXOMsg, vtxo.IncomingVTXOResp, + ]{ + Service: arkServiceName, + Method: MethodIncomingVTXO, + NewEvent: func() proto.Message { + return &arkrpc.IncomingVTXOEvent{} + }, + Key: vtxoKey, + Adapt: func(p proto.Message) ( + vtxo.IncomingVTXOMsg, error) { + + evt, ok := p.(*arkrpc.IncomingVTXOEvent) + if !ok { + return vtxo.IncomingVTXOMsg{}, + fmt.Errorf( + "expected "+ + "IncomingVTXOEvent"+ + ", got %T", p, + ) + } + + return vtxo.IncomingVTXOMsg{Event: evt}, nil + }, + }) +} + // registerOOREventRoutes registers OOR mailbox service event routes with the // EventRouter. When the server pushes SubmitPackage or FinalizePackage // response events, the router decodes the oorpb proto, adapts it into a @@ -2485,6 +2526,28 @@ func (s *Server) initOORActor(ctx context.Context, s.log.InfoS(ctx, "OOR client actor started") + // Register the incoming VTXO handler actor. This handles + // IncomingVTXOEvent push notifications from the indexer and + // materializes VTXOs for registered receive scripts. + incomingVTXOStore := dbStore.NewVTXOStore(s.clk) + incomingHandler := vtxo.NewIncomingVTXOHandler( + vtxo.IncomingVTXOHandlerConfig{ + Log: fn.Some(s.subLogger(Subsystem)), + ScriptStore: &ownedScriptLookupAdapter{ + store: packageStore, + }, + VTXOStore: incomingVTXOStore, + VTXOManager: vtxoManagerRef, + }, + ) + incomingKey := vtxo.IncomingVTXOServiceKey() + actor.RegisterWithSystem( + s.actorSystem, "incoming-vtxo-handler", + incomingKey, incomingHandler, + ) + + s.log.InfoS(ctx, "Incoming VTXO handler started") + return nil } diff --git a/db/vtxo_store.go b/db/vtxo_store.go index 9661f8c51..7d694574c 100644 --- a/db/vtxo_store.go +++ b/db/vtxo_store.go @@ -366,8 +366,9 @@ func (s *VTXOPersistenceStore) descriptorToInsertParams( desc *vtxo.Descriptor, ) (InsertVTXOParams, error) { - // Serialize tree path. - var treePathBytes []byte + // Serialize tree path. Use empty blob if no path is available + // (e.g., incoming VTXOs from round notifications). + treePathBytes := []byte{} if desc.TreePath != nil { data, err := SerializeTree(desc.TreePath) if err != nil { diff --git a/round/transitions_test.go b/round/transitions_test.go index ca2a4493c..08f6d09aa 100644 --- a/round/transitions_test.go +++ b/round/transitions_test.go @@ -1525,7 +1525,8 @@ func TestInputSigSentState(t *testing.T) { // key locator family so it doesn't match the local // VTXO owner key family, and install a checker that // also rejects the pkScript. - state.Intents.VTXOs[0].OwnerKey.KeyLocator = keychain.KeyLocator{} + state.Intents.VTXOs[0].OwnerKey.KeyLocator = + keychain.KeyLocator{} h.env.OwnedScriptChecker = newMockOwnedScriptChecker() h.withState(state) diff --git a/vtxo/incoming_handler.go b/vtxo/incoming_handler.go new file mode 100644 index 000000000..45d3b547a --- /dev/null +++ b/vtxo/incoming_handler.go @@ -0,0 +1,270 @@ +package vtxo + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "math" + + "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/lightninglabs/darepo-client/arkrpc" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/lib/scripts" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" +) + +// OwnedReceiveScript holds the metadata returned when looking up a +// pkScript in the owned receive scripts store. +type OwnedReceiveScript struct { + // ClientKey is the local wallet key descriptor for this script. + ClientKey keychain.KeyDescriptor + + // OperatorPubKey is the operator pubkey used for this script. + OperatorPubKey *btcec.PublicKey + + // ExitDelay is the relative CSV delay for the exit path. + ExitDelay int64 +} + +// OwnedScriptLookup provides read access to the owned receive scripts +// store. Implementations return sql.ErrNoRows when the script is not +// found. +type OwnedScriptLookup interface { + // LookupOwnedReceiveScript returns the metadata for a + // registered owned receive script, or sql.ErrNoRows if the + // script is not tracked. + LookupOwnedReceiveScript(ctx context.Context, + pkScript []byte) (*OwnedReceiveScript, error) +} + +// VTXOSaver persists materialized VTXO descriptors. +type VTXOSaver interface { + // SaveVTXO persists the given VTXO descriptor. + SaveVTXO(ctx context.Context, desc *Descriptor) error +} + +// IncomingVTXOMsg wraps an IncomingVTXOEvent for the handler actor. +type IncomingVTXOMsg struct { + actor.BaseMessage + Event *arkrpc.IncomingVTXOEvent +} + +// MessageType returns a human-readable message identifier. +func (m IncomingVTXOMsg) MessageType() string { + return fmt.Sprintf("IncomingVTXOMsg(event_id=%d)", + m.Event.GetEventId()) +} + +// IncomingVTXOResp is the handler's response type. +type IncomingVTXOResp = any + +const incomingVTXOServiceKeyName = "incoming-vtxo-handler" + +// IncomingVTXOServiceKey returns the well-known service key for the +// incoming VTXO handler actor. +func IncomingVTXOServiceKey() actor.ServiceKey[ + IncomingVTXOMsg, IncomingVTXOResp] { + + return actor.NewServiceKey[IncomingVTXOMsg, IncomingVTXOResp]( + incomingVTXOServiceKeyName, + ) +} + +// IncomingVTXOHandlerConfig holds the handler's dependencies. +type IncomingVTXOHandlerConfig struct { + // Log is the optional logger for the handler. + Log fn.Option[btclog.Logger] + + // ScriptStore is the persistence store used to look up owned + // receive scripts by pkScript. + ScriptStore OwnedScriptLookup + + // VTXOStore is the persistence store used to save materialized + // VTXO descriptors. + VTXOStore VTXOSaver + + // VTXOManager is a tell-only reference to the VTXO manager + // actor, used to notify it of newly materialized VTXOs. + VTXOManager actor.TellOnlyRef[ManagerMsg] +} + +// IncomingVTXOHandler materializes VTXOs from IncomingVTXOEvent +// notifications pushed by the server's indexer after round +// confirmation. +type IncomingVTXOHandler struct { + cfg IncomingVTXOHandlerConfig + log btclog.Logger +} + +// NewIncomingVTXOHandler creates a new handler. +func NewIncomingVTXOHandler( + cfg IncomingVTXOHandlerConfig) *IncomingVTXOHandler { + + return &IncomingVTXOHandler{ + cfg: cfg, + log: cfg.Log.UnwrapOr(btclog.Disabled), + } +} + +// Receive processes IncomingVTXOEvent messages. +func (h *IncomingVTXOHandler) Receive(ctx context.Context, + msg IncomingVTXOMsg) fn.Result[IncomingVTXOResp] { + + evt := msg.Event + if evt == nil { + return fn.Ok[IncomingVTXOResp](nil) + } + + // We only handle VTXO_CREATED events. Log unexpected types + // so we notice if the server starts sending new event kinds. + if evt.Type != arkrpc.VTXOEventType_VTXO_EVENT_TYPE_CREATED { + h.log.DebugS(ctx, "Ignoring non-CREATED VTXO event", + slog.Int("type", int(evt.Type))) + + return fn.Ok[IncomingVTXOResp](nil) + } + + op := evt.GetOutpoint() + if op == nil || len(op.Txid) != 32 { + h.log.WarnS(ctx, "IncomingVTXOEvent has invalid "+ + "or missing outpoint", nil) + + return fn.Ok[IncomingVTXOResp](nil) + } + + pkScript := evt.GetPkScript() + if len(pkScript) == 0 { + h.log.WarnS(ctx, "IncomingVTXOEvent has empty "+ + "pkScript", nil) + + return fn.Ok[IncomingVTXOResp](nil) + } + + var outpoint wire.OutPoint + copy(outpoint.Hash[:], op.Txid) + outpoint.Index = op.Vout + + h.log.InfoS(ctx, "Received IncomingVTXOEvent", + slog.String("outpoint", outpoint.String()), + slog.Uint64("value_sat", evt.ValueSat), + slog.String("round_id", evt.RoundId)) + + if h.cfg.ScriptStore == nil { + return fn.Ok[IncomingVTXOResp](nil) + } + + // Look up the pkScript in owned receive scripts. + rec, err := h.cfg.ScriptStore.LookupOwnedReceiveScript( + ctx, pkScript, + ) + if err != nil { + // Not-found means the script isn't ours — ignore. + // Any other error is a real store failure that + // should be surfaced. + if errors.Is(err, sql.ErrNoRows) { + return fn.Ok[IncomingVTXOResp](nil) + } + + return fn.Err[IncomingVTXOResp](fmt.Errorf( + "lookup owned receive script: %w", err, + )) + } + + if rec.ClientKey.PubKey == nil { + h.log.WarnS(ctx, "Owned receive script has nil "+ + "client pubkey", nil, + slog.String("outpoint", outpoint.String())) + + return fn.Ok[IncomingVTXOResp](nil) + } + + // Reject server-provided values that would overflow int64 + // when cast to btcutil.Amount. + if evt.ValueSat > uint64(math.MaxInt64) || + evt.ValueSat > uint64(btcutil.MaxSatoshi) { + + h.log.WarnS(ctx, "Incoming VTXO value exceeds "+ + "maximum", nil, + slog.String("outpoint", outpoint.String()), + slog.Uint64("value_sat", evt.ValueSat)) + + return fn.Ok[IncomingVTXOResp](nil) + } + + // Build the tapscript for the descriptor. + operatorKey := rec.OperatorPubKey + exitDelay := uint32(rec.ExitDelay) + + tapscript, err := scripts.VTXOTapScript( + rec.ClientKey.PubKey, operatorKey, exitDelay, + ) + if err != nil { + h.log.WarnS(ctx, "Failed to derive tapscript "+ + "for incoming VTXO", err, + slog.String("outpoint", outpoint.String())) + + return fn.Ok[IncomingVTXOResp](nil) + } + + // Use the commitment tx ID from the event, which references + // the round's commitment transaction. This is distinct from + // the leaf txid in the outpoint. + var commitTxID chainhash.Hash + if len(evt.CommitmentTxid) == chainhash.HashSize { + copy(commitTxID[:], evt.CommitmentTxid) + } + + desc := &Descriptor{ + Outpoint: outpoint, + Amount: btcutil.Amount(evt.ValueSat), + PkScript: pkScript, + OwnerKey: rec.ClientKey, + OperatorKey: operatorKey, + TapScript: tapscript, + RoundID: evt.RoundId, + CommitmentTxID: commitTxID, + BatchExpiry: evt.BatchExpiryHeight, + RelativeExpiry: evt.RelativeExpiry, + Status: VTXOStatusLive, + } + + // Persist the VTXO. A save failure signals a database or + // schema inconsistency that must be surfaced. + if h.cfg.VTXOStore != nil { + saveErr := h.cfg.VTXOStore.SaveVTXO(ctx, desc) + if saveErr != nil { + return fn.Err[IncomingVTXOResp](fmt.Errorf( + "save incoming VTXO %s: %w", + outpoint.String(), saveErr, + )) + } + } + + // Notify the VTXO manager to spawn an actor. + if h.cfg.VTXOManager != nil { + tellErr := h.cfg.VTXOManager.Tell( + ctx, + &VTXOsMaterializedNotification{ + VTXOs: []*Descriptor{desc}, + }, + ) + if tellErr != nil { + h.log.WarnS(ctx, "Failed to notify "+ + "VTXO manager", tellErr) + } + } + + h.log.InfoS(ctx, "Materialized incoming VTXO", + slog.String("outpoint", outpoint.String()), + slog.Int64("amount", int64(desc.Amount)), + slog.String("round_id", evt.RoundId)) + + return fn.Ok[IncomingVTXOResp](nil) +} diff --git a/vtxo/incoming_handler_test.go b/vtxo/incoming_handler_test.go new file mode 100644 index 000000000..6b77fddf3 --- /dev/null +++ b/vtxo/incoming_handler_test.go @@ -0,0 +1,188 @@ +package vtxo + +import ( + "context" + "database/sql" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/darepo-client/arkrpc" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// mockScriptLookup implements OwnedScriptLookup for testing. +type mockScriptLookup struct { + scripts map[string]*OwnedReceiveScript +} + +func (m *mockScriptLookup) LookupOwnedReceiveScript( + _ context.Context, + pkScript []byte) (*OwnedReceiveScript, error) { + + rec, ok := m.scripts[string(pkScript)] + if !ok { + return nil, sql.ErrNoRows + } + + return rec, nil +} + +// mockVTXOSaver implements VTXOSaver for testing. +type mockVTXOSaver struct { + saved []*Descriptor +} + +func (m *mockVTXOSaver) SaveVTXO( + _ context.Context, desc *Descriptor) error { + + m.saved = append(m.saved, desc) + + return nil +} + +// newTestEvent creates an IncomingVTXOEvent with the given parameters. +func newTestEvent(txid chainhash.Hash, vout uint32, + pkScript []byte, valueSat uint64, + roundID string) *arkrpc.IncomingVTXOEvent { + + return &arkrpc.IncomingVTXOEvent{ + EventId: 1, + Type: arkrpc.VTXOEventType_VTXO_EVENT_TYPE_CREATED, + Outpoint: &arkrpc.OutPoint{ + Txid: txid[:], + Vout: vout, + }, + PkScript: pkScript, + ValueSat: valueSat, + RoundId: roundID, + BatchExpiryHeight: 800_000, + RelativeExpiry: 144, + CommitmentTxid: txid[:], + } +} + +// TestIncomingVTXOHandlerOwnedScript verifies that a VTXO_CREATED +// event for an owned script results in a persisted VTXO and +// manager notification. +func TestIncomingVTXOHandlerOwnedScript(t *testing.T) { + t.Parallel() + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pkScript := []byte{0x51, 0x20, 0xaa, 0xbb} + + lookup := &mockScriptLookup{ + scripts: map[string]*OwnedReceiveScript{ + string(pkScript): { + ClientKey: keychain.KeyDescriptor{ + PubKey: privKey.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: 44, + Index: 0, + }, + }, + OperatorPubKey: operatorPriv.PubKey(), + ExitDelay: 144, + }, + }, + } + saver := &mockVTXOSaver{} + + handler := NewIncomingVTXOHandler(IncomingVTXOHandlerConfig{ + ScriptStore: lookup, + VTXOStore: saver, + }) + + var txid chainhash.Hash + txid[0] = 0x01 + + evt := newTestEvent(txid, 0, pkScript, 50_000, "round-1") + msg := IncomingVTXOMsg{Event: evt} + + result := handler.Receive(t.Context(), msg) + _, resultErr := result.Unpack() + require.NoError(t, resultErr) + + require.Len(t, saver.saved, 1) + + desc := saver.saved[0] + require.Equal(t, txid, desc.Outpoint.Hash) + require.Equal(t, uint32(0), desc.Outpoint.Index) + require.Equal(t, int64(50_000), int64(desc.Amount)) + require.Equal(t, pkScript, desc.PkScript) + require.Equal(t, "round-1", desc.RoundID) + require.Equal(t, VTXOStatusLive, desc.Status) +} + +// TestIncomingVTXOHandlerUnownedScript verifies that a VTXO_CREATED +// event for an unowned script is silently ignored. +func TestIncomingVTXOHandlerUnownedScript(t *testing.T) { + t.Parallel() + + lookup := &mockScriptLookup{ + scripts: map[string]*OwnedReceiveScript{}, + } + saver := &mockVTXOSaver{} + + handler := NewIncomingVTXOHandler(IncomingVTXOHandlerConfig{ + ScriptStore: lookup, + VTXOStore: saver, + }) + + var txid chainhash.Hash + txid[0] = 0x02 + + evt := newTestEvent( + txid, 0, []byte{0x51, 0x20, 0xff}, 10_000, "round-2", + ) + msg := IncomingVTXOMsg{Event: evt} + + result := handler.Receive(t.Context(), msg) + _, resultErr := result.Unpack() + require.NoError(t, resultErr) + + require.Empty(t, saver.saved) +} + +// TestIncomingVTXOHandlerNonCreatedEvent verifies that non-CREATED +// event types are ignored. +func TestIncomingVTXOHandlerNonCreatedEvent(t *testing.T) { + t.Parallel() + + saver := &mockVTXOSaver{} + handler := NewIncomingVTXOHandler(IncomingVTXOHandlerConfig{ + VTXOStore: saver, + }) + + evt := &arkrpc.IncomingVTXOEvent{ + EventId: 2, + Type: arkrpc.VTXOEventType_VTXO_EVENT_TYPE_STATUS_CHANGED, + } + msg := IncomingVTXOMsg{Event: evt} + + result := handler.Receive(t.Context(), msg) + _, resultErr := result.Unpack() + require.NoError(t, resultErr) + + require.Empty(t, saver.saved) +} + +// TestIncomingVTXOHandlerNilEvent verifies that a nil event is +// handled gracefully. +func TestIncomingVTXOHandlerNilEvent(t *testing.T) { + t.Parallel() + + handler := NewIncomingVTXOHandler(IncomingVTXOHandlerConfig{}) + + msg := IncomingVTXOMsg{Event: nil} + + result := handler.Receive(t.Context(), msg) + _, resultErr := result.Unpack() + require.NoError(t, resultErr) +} diff --git a/wallet/wallet_admission_test.go b/wallet/wallet_admission_test.go index 92aad7e4d..9c9cefa26 100644 --- a/wallet/wallet_admission_test.go +++ b/wallet/wallet_admission_test.go @@ -1142,5 +1142,4 @@ func TestSendVTXOsIntentPackageContents(t *testing.T) { "(FSM derives it)", i, ) } - }