Skip to content

multi: harden directed send and add receiver notification - #225

Merged
Roasbeef merged 4 commits into
mainfrom
send-in-round-followup
Apr 9, 2026
Merged

multi: harden directed send and add receiver notification#225
Roasbeef merged 4 commits into
mainfrom
send-in-round-followup

Conversation

@ellemouton

Copy link
Copy Markdown
Member

Summary

Follow-up to #176. Addresses security hardening, ownership model
improvements, and receiver-side VTXO materialization for in-round
directed sends.

Closes #223.

Changes

1. Hardening (74fc7c7)

  • Validate recipient amounts against 21M BTC cap with overflow-safe addition
  • Replace releaseAndFail with defer + committed pattern so panics don't leak PendingForfeitState VTXOs
  • Use context.WithoutCancel for deferred forfeit release (survives client disconnection)
  • Cap recipients at 256 in RPC validation

2. Replace IsOwner with OwnedScriptChecker (e4f5aa9)

  • Add OwnedScriptChecker interface to round FSM's ClientEnvironment
  • VTXO ownership is now data-driven: checked against owned_receive_scripts DB store
  • Remove IsOwner from VTXOIntent and VTXORequest
  • Add OwnedScriptRegistrar interface on round actor — registers pkScripts for boarding, refresh, and change VTXOs at intent time
  • Wire adapters into daemon using OORArtifactPersistenceStore
  • Self-send works correctly (both recipient + change VTXOs persisted)

3. Extend IncomingVTXOEvent proto (1d2ead4)

  • Add pk_script, value_sat, round_id, batch_expiry_height, relative_expiry fields
  • For VTXO_CREATED events from confirmed rounds, carries enough data for the client to materialize without a follow-up indexer query

4. Receiver-side VTXO materialization (074ce93)

  • New IncomingVTXOHandler actor processes IncomingVTXOEvent push notifications
  • When server publishes a VTXO_CREATED event for a round leaf matching a registered receive script:
    1. Looks up pkScript in owned_receive_scripts
    2. Derives tapscript from owner key + operator key
    3. Builds vtxo.Descriptor from event metadata
    4. Persists via VTXOPersistenceStore
    5. Notifies VTXO manager via VTXOsMaterializedNotification
  • Envelope route registered for (arkrpc.ArkService, IncomingVTXO)
  • Same receive-script mechanism as OOR — recipient calls NewOORReceiveScript once and receives VTXOs from both OOR and in-round sends

Testing

  • All existing tests pass with race detector
  • Server-side itest (companion PR): TestDirectedSendIntegration verifies bob receives his VTXO via ListVTXOs, TestDirectedSendSelfSend verifies both VTXOs

Companion PR

Server-side itest + VTXOEventPublisher: lightninglabs/darepo#205

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the security, robustness, and client-side experience of directed sends within the system. It introduces a more secure and data-driven approach to VTXO ownership, moving away from a simple boolean flag to a persistent script-based check. Furthermore, it streamlines the process of VTXO materialization for recipients by providing comprehensive event notifications, reducing the need for additional queries and improving overall efficiency and reliability. The changes also include important hardening measures to prevent overflow issues and ensure proper resource cleanup.

Highlights

  • Security Hardening: Implemented validation for recipient amounts against the 21M BTC cap with overflow-safe addition, replaced releaseAndFail with a defer + committed pattern to prevent VTXO leaks on panics, and used context.WithoutCancel for deferred forfeit release to ensure it survives client disconnections. Recipient count in RPC validation is now capped at 256.
  • Ownership Model Improvement: Replaced the IsOwner flag with a data-driven OwnedScriptChecker interface, which checks VTXO ownership against a owned_receive_scripts database store. The VTXOIntent and VTXORequest structs no longer contain the IsOwner field. A new OwnedScriptRegistrar interface was added to register pkScripts for boarding, refresh, and change VTXOs at intent time.
  • Enhanced IncomingVTXOEvent Proto: Extended the IncomingVTXOEvent protobuf message with pk_script, value_sat, round_id, batch_expiry_height, and relative_expiry fields. This provides sufficient data for clients to materialize VTXOs from VTXO_CREATED events without requiring a follow-up indexer query.
  • Receiver-Side VTXO Materialization: Introduced a new IncomingVTXOHandler actor to process IncomingVTXOEvent push notifications. When the server publishes a VTXO_CREATED event matching a registered receive script, the handler derives the tapscript, builds a vtxo.Descriptor, persists it via VTXOPersistenceStore, and notifies the VTXO manager. An envelope route for (arkrpc.ArkService, IncomingVTXO) was registered to facilitate this.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new IncomingVTXOEvent protobuf message with extended fields and a corresponding IncomingVTXOHandler to process these events, materialize VTXOs, and persist them. It also replaces the IsOwner flag with OwnedScriptChecker and OwnedScriptRegistrar interfaces for managing script ownership. The SendVTXO RPC now includes validation for recipient count and amounts, and dry-run error handling in wallet/wallet.go has been updated. Review comments highlight several areas in the IncomingVTXOHandler where critical failures (e.g., VTXO persistence, script store initialization, tapscript derivation, or data inconsistencies) might be silently ignored, suggesting that these should either return errors or be logged with a more severe level to ensure data integrity and prevent silent failures.

Comment thread darepod/incoming_vtxo_handler.go Outdated
Comment on lines +144 to +153
if h.cfg.VTXOStore != nil {
saveErr := h.cfg.VTXOStore.SaveVTXO(
lookupCtx, desc,
)
if saveErr != nil {
h.log.Warnf("Failed to save incoming "+
"VTXO %s: %v", outpoint, saveErr)

return fn.Ok[IncomingVTXOResp](nil)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Similar to previous points, if h.cfg.VTXOStore.SaveVTXO fails, a warning is logged, and the function returns fn.Ok[IncomingVTXOResp](nil). This means that if the VTXO cannot be persisted, it will be silently dropped from the client's state. This is a critical operation, and its failure should likely result in an error being returned to the caller, or at least a more severe log level, to ensure data integrity.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

~cc: This is intentional best-effort design. The IncomingVTXOHandler processes server push notifications — returning an error here would only surface to the actor framework, not to any user-facing RPC. The handler will receive the event again on next connection/restart. A warning log is sufficient for operator visibility.

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.

I'd say this more signals a database error or schema inconsistency which we should surface.

Comment thread darepod/incoming_vtxo_handler.go Outdated
Comment on lines +94 to +96
if h.cfg.ScriptStore == nil {
return fn.Ok[IncomingVTXOResp](nil)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The ScriptStore is checked for nil and if it is, the function returns fn.Ok[IncomingVTXOResp](nil). This means that if the ScriptStore is not initialized, the VTXO will not be materialized, but no error will be reported. While this might be intended for optionality, it could lead to silent failures if the store is expected to be present. Consider logging a warning or returning an error if ScriptStore is a mandatory dependency for VTXO materialization.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

~cc: This nil check is for optionality during startup — the handler can be registered before all stores are wired. It's a defensive guard, not a normal code path. Adding a warning here would be noisy since it fires for every event during early init.

Comment thread darepod/incoming_vtxo_handler.go Outdated
Comment on lines +103 to +105
if err != nil {
// Not our script — ignore.
return fn.Ok[IncomingVTXOResp](nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The comment "Not our script — ignore." indicates that if LookupOwnedReceiveScript returns an error (meaning the script is not found), the function silently returns fn.Ok[IncomingVTXOResp](nil). This is a valid design choice for filtering, but it's important to ensure that this behavior is explicitly desired and doesn't mask potential misconfigurations or unexpected errors from the ScriptStore itself. If LookupOwnedReceiveScript can return errors for reasons other than "not found," those might be silently ignored.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

~cc: Agreed this is a design choice. In practice, LookupOwnedReceiveScript returns a 'not found' error for scripts we don't own — which is the common case since the server pushes events for ALL round leaves, not just ours. A follow-up could distinguish 'not found' from unexpected DB errors, but the current behavior is correct for the happy path.

Comment thread darepod/incoming_vtxo_handler.go Outdated
Comment on lines +108 to +110
if rec.ClientKey.PubKey == nil {
return fn.Ok[IncomingVTXOResp](nil)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If rec.ClientKey.PubKey is nil, the function returns fn.Ok[IncomingVTXOResp](nil). This implies that a record might exist for the pkScript, but it's considered invalid if the client's public key is missing. This could indicate an incomplete or corrupted record in the ScriptStore. It might be beneficial to log a warning here to aid in debugging such data inconsistencies.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

~cc: Good callout. A nil PubKey here would indicate a corrupted DB record. Adding a warning log for this case is reasonable — will note as a follow-up improvement.

Comment on lines +119 to +124
if err != nil {
h.log.Warnf("Failed to derive tapscript for "+
"incoming VTXO %s: %v", outpoint, err)

return fn.Ok[IncomingVTXOResp](nil)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If scripts.VTXOTapScript fails, a warning is logged, and the function returns fn.Ok[IncomingVTXOResp](nil). This means that if the tapscript cannot be derived, the VTXO will not be materialized, but the process will continue without an explicit error. Depending on the criticality of VTXO materialization, this might warrant returning an error to the caller or escalating the issue, rather than just logging a warning and proceeding.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

~cc: This is already logging a warning with the outpoint and error details. The handler is best-effort — the VTXO event will be re-delivered on reconnect. Returning an error to the actor framework wouldn't add recovery capability here.

Comment thread wallet/wallet.go
Comment on lines +1418 to +1431
// 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
}

return fn.Err[WalletResp](primary)
}
releaseCtx := context.WithoutCancel(ctx)
_ = a.releaseManagerForfeitStrict(
releaseCtx, reservedOutpoints,
)
}()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The defer statement with context.WithoutCancel(ctx) for releasing reserved VTXOs is a significant improvement. It ensures that cleanup occurs even if the client disconnects or the function panics, making the system more robust. This pattern correctly addresses the need for durable cleanup actions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

~cc: Agreed — this pattern ensures VTXOs are always released even on context cancellation or panic.

@ellemouton
ellemouton force-pushed the send-in-round-followup branch from 074ce93 to 359ef64 Compare March 26, 2026 10:02
@ellemouton
ellemouton requested a review from bhandras March 26, 2026 10:13
@ellemouton
ellemouton force-pushed the send-in-round-followup branch from 359ef64 to 0e1db22 Compare March 26, 2026 10:18
@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Mar 26, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 3m 57s —— View job


Code Review

  • Gather context
  • Read changed files
  • Analyze code
  • Post review

Overall this is a well-structured follow-up with clear separation of concerns across the four commits. The defer + committed pattern for forfeit release, the ownership model migration, and the receiver-side materialization all look correct at the high level. A few things worth discussing:


1. Logging style violations — incoming_vtxo_handler.go

The entire handler uses printf-style logging (Infof, Warnf) while the rest of darepod uses structured logging (InfoS, WarnS with slog.XX KV pairs). Per the style guide this is required. Four call sites need updating:

// darepod/incoming_vtxo_handler.go:90
h.log.Infof("Received IncomingVTXOEvent: outpoint=%s value=%d round=%s", ...)

// Should be:
h.log.InfoS(ctx, "Received IncomingVTXOEvent",
    btclog.Fmt("outpoint", outpoint),
    slog.Uint64("value_sat", evt.ValueSat),
    slog.String("round_id", evt.RoundId))

Note that ctx is available in Receive — the structured helpers accept it. Fix this →


2. LookupOwnedReceiveScript swallows all errors — incoming_vtxo_handler.go:103-106

rec, err := h.cfg.ScriptStore.LookupOwnedReceiveScript(lookupCtx, pkScript)
if err != nil {
    // Not our script — ignore.
    return fn.Ok[IncomingVTXOResp](nil)
}

The comment "Not our script — ignore" is correct for ErrNotFound but misleading for DB connectivity failures, schema errors, etc. A transient DB hiccup would silently drop a VTXO event. Since this handler is best-effort (events re-deliver on reconnect), dropping is acceptable — but a WarnS on unexpected errors would help operators distinguish "foreign VTXO" from "DB unavailable during round confirmation."

ellemouton already acknowledged this as a follow-up item in the Gemini thread. Leaving it open here as a tracking note.


3. Dry-run forfeit release error now silent — wallet/wallet.go

The old code:

releaseErr := a.releaseManagerForfeitStrict(ctx, reservedOutpoints)
if releaseErr != nil {
    return fn.Err[WalletResp](fmt.Errorf(
       ,
        releaseErr,
    ))
}

The new code via the deferred closure:

_ = a.releaseManagerForfeitStrict(releaseCtx, reservedOutpoints)

For the dry-run path, a release failure is now silently swallowed. The old behaviour explicitly surfaced this to callers so they knew funds might be temporarily locked. Low-risk since a failed dry-run release is self-correcting, but the diagnostic value is lost. Fix this →


4. buildOwnedClientVTXOs: leaves[0] without len==1 assertion — round/transitions.go:1666-1671

The old code iterated all leaves; the new code takes only leaves[0], which is correct given the one-leaf-per-signing-key invariant. The len == 0 guard is good. However, if a future bug somehow produces multiple leaves for one signing key, the extra leaves would be silently ignored (wrong balance). A debug-level check:

if len(leaves) != 1 {
    return nil, fmt.Errorf("expected exactly 1 leaf for " +
        "signing key, got %d", len(leaves))
}

...would make the invariant explicit and catch regressions early.


5. handleRegisterIntent KeyLocator zero-value heuristic — round/actor.go:1985-2005

if vtxo.OwnerKey.KeyLocator == (keychain.KeyLocator{}) {
    continue
}

This uses a zero-value KeyLocator (Family=0, Index=0) as a sentinel for "not locally derived." A fresh wallet's very first derived key would have exactly this locator and would be skipped. Is index 0 in family 0 ever legitimately used as an owner key here, or is there a reserved family / offset that prevents this? Worth a comment explaining why the zero locator is safe to skip.


6. ValueSat uint64 → btcutil.Amount (int64) cast — incoming_vtxo_handler.go:131

Amount: btcutil.Amount(evt.ValueSat),

evt.ValueSat is a server-provided uint64. Values > math.MaxInt64 silently wrap to a negative amount that would be stored in the VTXO. Since amounts are validated at send time (21M BTC cap), this can't happen via normal flows — but a malicious or buggy server could push an oversized value. A guard like:

if evt.ValueSat > uint64(btcutil.MaxSatoshi) {
    h.log.WarnS(...)
    return fn.Ok[IncomingVTXOResp](nil)
}

...would make the handler robust against malformed server events.


7. commitTxID redundancy — incoming_vtxo_handler.go:126-127

var commitTxID chainhash.Hash
copy(commitTxID[:], outpoint.Hash[:])

This sets commitTxID to the same value as outpoint.Hash. For a VTXO in a round commitment tree this is semantically correct (the VTXO outpoint txid is the commitment tx). The redundancy is harmless, but a short comment explaining why these are equal would help readers not familiar with Ark's commitment structure.


Minor nits

  • darepod/owned_script_checker.go:54-56: returning fmt.Errorf("store is nil") when the adapter was constructed with a nil store is defensive programming, but the constructor always sets a non-nil store when s.db != nil. If nil-store adapters can't be constructed in practice, the guard is dead code; if they can, it should panic rather than returning a transient error (since it indicates a wiring bug, not a runtime condition).

  • darepod/server.go (initRoundActor): A new OORArtifactPersistenceStore is created here independently of the one in initOORActor. Both point at the same underlying DB, so there's no data divergence, but it's worth ensuring no per-instance state creates subtle differences. A comment noting this intentional duplication would help future readers.


Summary

The four commits are cohesive and the approach is sound. The logging style issue in incoming_vtxo_handler.go should be fixed before merge (it's a clear style-guide violation). The other items are follow-up candidates or nits. The defer + committed pattern is a real improvement; the OwnedScriptChecker migration cleanly removes a boolean ownership flag in favour of data-driven lookup.

@ellemouton
ellemouton force-pushed the send-in-round-followup branch from 0e1db22 to 0beffff Compare March 26, 2026 12:16
@ellemouton

Copy link
Copy Markdown
Member Author

~cc: Addressed the review feedback:

Fixed in this push:

  1. Logging style — converted all Infof/Warnf calls in incoming_vtxo_handler.go to structured InfoS/WarnS with slog KV pairs
  2. ValueSat overflow guard ([test, ignore] harness: basic infrastructure test environment #6) — added uint64(math.MaxInt64) and MaxSatoshi check before casting to btcutil.Amount
  3. leaves[0] invariant (multi: stage boarding actor client  #4) — changed len == 0 guard to len != 1 assertion with descriptive error
  4. Dry-run release error logging (chainbackend+chainsource: add chainsource actor and backend impl #3) — deferred closure now logs WarnS on release failure instead of silently discarding
  5. commitTxID comment (multi: clean up unnecessary stuff and use instance logging in favor of global logger #7) — added comment explaining why outpoint hash == commitment tx ID
  6. KeyLocator zero-value comment (harness: basic infrastructure test environment #5) — documented why Family=0/Index=0 is safe to skip (VTXOOwnerKeyFamily starts at 44)
  7. nil PubKey warning (multI: add initial repo scaffolding #2 partial) — added WarnS log for corrupted DB records with nil client pubkey

Acknowledged as follow-ups:

  • LookupOwnedReceiveScript distinguishing "not found" from DB errors (multI: add initial repo scaffolding #2)
  • OORArtifactPersistenceStore duplication comment (minor nit)
  • Dead nil-store guard in owned_script_checker.go (minor nit)

@ellemouton
ellemouton requested a review from Roasbeef March 26, 2026 18:19
Comment thread wallet/wallet.go
return
}

releaseCtx := context.WithoutCancel(ctx)

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.

👍


var _ round.OwnedScriptChecker = (*ownedScriptCheckerAdapter)(nil)

func (a *ownedScriptCheckerAdapter) IsOwnedScript(ctx context.Context,

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.

nit: missing godoc.

// (e.g., during round confirmation in a shutting-down FSM).
lookupCtx := context.WithoutCancel(ctx)

_, err := a.store.LookupOwnedReceiveScript(lookupCtx, pkScript)

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.

I don't think this is correct as is, we should instead surface the error and a bool flag if it returns the receive script?


var _ round.OwnedScriptRegistrar = (*ownedScriptRegistrarAdapter)(nil)

func (a *ownedScriptRegistrarAdapter) RegisterOwnedScript(

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.

nit: missing godoc

Comment thread darepod/server.go Outdated
oorStore := db.NewStore(
s.db.DB, s.db.Queries, s.db.Backend(),
s.log,
).NewOORArtifactStore(clock.NewDefaultClock())

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.

nit: perhaps we could store the clock in the server and inject it on creation of the server?

Comment thread darepod/incoming_vtxo_handler.go Outdated
if evt == nil ||
evt.Type != arkrpc.VTXOEventType_VTXO_EVENT_TYPE_CREATED {

return fn.Ok[IncomingVTXOResp](nil)

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.

IIUC this always need to be VTXOEventType_VTXO_EVENT_TYPE_CREATED right? Maybe we could add some debug logging in case something else is coming through.

Comment thread darepod/incoming_vtxo_handler.go Outdated
}

// Look up the pkScript in owned receive scripts.
lookupCtx := context.WithoutCancel(ctx)

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.

Do we strictly need withoutcancel here?

rec, err := h.cfg.ScriptStore.LookupOwnedReceiveScript(
lookupCtx, pkScript,
)
if err != nil {

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.

Perhaps we could specifically only skip not-found errors (or even just log) otherwise surface the error.

Comment thread darepod/incoming_vtxo_handler.go Outdated
Comment on lines +144 to +153
if h.cfg.VTXOStore != nil {
saveErr := h.cfg.VTXOStore.SaveVTXO(
lookupCtx, desc,
)
if saveErr != nil {
h.log.Warnf("Failed to save incoming "+
"VTXO %s: %v", outpoint, saveErr)

return fn.Ok[IncomingVTXOResp](nil)
}

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.

I'd say this more signals a database error or schema inconsistency which we should surface.

Comment thread darepod/rpc_server.go Outdated
}

if out.AmountSat <= 0 {
const maxAmountSat int64 = 21_000_000 * 1e8

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.

nit: btcutil.MaxSatoshi

Comment thread darepod/incoming_vtxo_handler.go Outdated
OperatorKey: operatorKey,
TapScript: tapscript,
RoundID: evt.RoundId,
CommitmentTxID: commitTxID,

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.

codex: outpoint.Hash here is the leaf txid, not the round commitment txid. GetNonAnchorOutpoint derives the outpoint from the leaf transaction, while the rest of the client uses CommitmentTxID for commitment-lineage metadata. Storing the leaf txid under CommitmentTxID will leave these descriptors with inconsistent ancestry information.

Comment thread darepod/incoming_vtxo_handler.go Outdated
TapScript: tapscript,
RoundID: evt.RoundId,
CommitmentTxID: commitTxID,
BatchExpiry: evt.BatchExpiryHeight,

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.

codex: This assumes evt.BatchExpiryHeight is already an absolute height, but the companion server PR currently populates it from Terms.SweepDelay when publishing the event. The rest of the client treats BatchExpiry as an absolute block height, so a received VTXO will look immediately critical/expired on any chain height greater than the sweep delay. Can we either send confirmation_height + sweep_delay from the server or recompute it here?

rec, err := h.cfg.ScriptStore.LookupOwnedReceiveScript(
lookupCtx, pkScript,
)
if err != nil {

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.

codex: Returning Ok on store/materialization failures makes this mailbox event effectively lossy. IncomingVTXOEvent is delivered via Tell, so once we ack it there is no retry path here; a transient DB issue during lookup/save would silently drop the receive. Could we distinguish not found from real store errors and surface the latter instead of swallowing them?

@litbot-9000

Copy link
Copy Markdown
Collaborator

@ellemouton, remember to re-request review from reviewers when ready

Comment thread darepod/rpc_server.go
return nil, err
}

const maxRecipients = 256

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.

Should make an issue to tune this param later.

Comment thread wallet/wallet.go Outdated
}

if r.Amount <= 0 {
const maxAmount btcutil.Amount = 21_000_000 * 1e8

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.

Can use btcutil.MaxSatoshi here again.

Comment thread round/transitions.go
"1 leaf for signing key, got %d",
len(leaves))
}
leaf := leaves[0]

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.

We now assume a single leaf here?

Comment thread arkrpc/indexer.proto

// pk_script is the VTXO output script. Present for CREATED events
// so the client can match against registered receive scripts.
bytes pk_script = 5;

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.

Perhaps we should use an enum/oneof here to add more structure to the proto? So we can more easily distinguiish if this is a new VTXO from an in round send, or an oor.

Comment thread darepod/incoming_vtxo_handler.go Outdated
// IncomingVTXOHandler materializes VTXOs from IncomingVTXOEvent
// notifications pushed by the server's indexer after round
// confirmation.
type IncomingVTXOHandler struct {

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.

Let's move this to a new package? Similar to the other actor sub-ssytems, then we can write some dierct unit tests against it.

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.

Can go in the vtxo package perhaps

Comment thread darepod/server.go
// 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(

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.

👍

Comment thread darepod/server.go
Service: arkServiceName,
Method: MethodIncomingVTXO,
NewEvent: func() proto.Message {
return &arkrpc.IncomingVTXOEvent{}

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.

Ah so we added this, but it wasn't actuall used for the oor flow?

- 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)
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
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.
@Roasbeef
Roasbeef force-pushed the send-in-round-followup branch from 0beffff to 6a38835 Compare April 9, 2026 00:39
@Roasbeef

Roasbeef commented Apr 9, 2026

Copy link
Copy Markdown
Member

Review comment resolutions

Pushed a force-update with all fixups squashed into their target commits. Here's what changed:

Commit 1: multi: harden directed send validation and cleanup

Commit 2: multi: replace IsOwner flag with OwnedScriptChecker

  • Added godocs to ownedScriptCheckerAdapter.IsOwnedScript and ownedScriptRegistrarAdapter.RegisterOwnedScript (@bhandras)
  • Changed IsOwnedScript return type from bool to fn.Result[bool] — distinguishes not-found from real store errors (@bhandras)
  • Updated ownedScriptCheckerAdapter to return fn.Err on real DB errors, fn.Ok(false) on sql.ErrNoRows
  • Escalated RegisterOwnedScript failure from warn log to error return in round/actor.go (@bhandras)
  • Stored clock.Clock in Server struct, replaced all 8 clock.NewDefaultClock() calls with s.clk (@bhandras)
  • Expanded single-leaf-per-signer comment in transitions.go to explain the invariant (@Roasbeef)

Commit 3: arkrpc: add pkScript, value, and round metadata to IncomingVTXOEvent

  • Added VTXOOrigin enum (IN_ROUND / OOR) to distinguish VTXO creation paths (@Roasbeef)
  • Added commitment_txid field for round commitment tx (distinct from leaf txid in outpoint) (@bhandras/codex)
  • Clarified batch_expiry_height proto comment: server MUST send absolute height (confirmation_height + sweep_delay) (@bhandras/codex)

Commit 4: darepod: add IncomingVTXOEvent handler for round VTXO receipt

  • Moved handler to vtxo package with OwnedScriptLookup and VTXOSaver interfaces for testability (@Roasbeef)
  • Added ownedScriptLookupAdapter in darepod to bridge db.OORArtifactPersistenceStorevtxo.OwnedScriptLookup
  • Added 4 unit tests: owned script, unowned script, non-CREATED event, nil event
  • Added field-level godocs to IncomingVTXOHandlerConfig (@bhandras)
  • Added debug logging for non-CREATED event types, warning for nil outpoint/pkScript (@bhandras)
  • Removed unnecessary context.WithoutCancel (@bhandras)
  • Distinguished sql.ErrNoRows (not-found) from real store errors — returns fn.Err on real failures (@bhandras/codex)
  • Changed SaveVTXO failure from warn+Ok to fn.Err return (@bhandras)
  • Used commitment_txid from event instead of leaf txid for CommitmentTxID field (@bhandras/codex)

Not changed (by design)

  • server.go:1190 route registration: confirmed this is intentionally a new route for in-round sends, separate from OOR event routes (@Roasbeef)

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.
@Roasbeef
Roasbeef force-pushed the send-in-round-followup branch from 6a38835 to 6c2fefa Compare April 9, 2026 01:25

@Roasbeef Roasbeef left a comment

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.

LGTM 🛩️

@Roasbeef
Roasbeef merged commit 216573d into main Apr 9, 2026
17 checks passed
Roasbeef added a commit that referenced this pull request Apr 10, 2026
Three landed PRs since the last broad gardening sweep touch subsystems
whose CLAUDE.md/AGENTS.md did not yet reflect the new shape: PR #225
(send-in-round-followup), PR #238 (fee-estimator fix, no doc impact),
and PR #239 (partial-unroll-harness-accessor). The most significant
structural changes are the introduction of a data-driven pkScript
ownership path and a dedicated actor that materializes round-produced
VTXOs from indexer push notifications.

The round FSM no longer relies on a per-intent IsOwner flag. Instead,
buildOwnedClientVTXOs resolves ownership at round confirmation time by
asking a round.OwnedScriptChecker whether each VTXO's pkScript is
recognized by the local wallet, and round.OwnedScriptRegistrar persists
locally-owned scripts at intent-build time and inside
handleRegisterIntent for incoming intents whose owner key has a
non-zero KeyLocator. The darepod package exposes both interfaces as
thin adapters over the OOR owned-receive-scripts store, which means the
same table now backs three abstractions (OwnedScriptChecker,
OwnedScriptRegistrar, and vtxo.OwnedScriptLookup). The per-package docs
pick up these new interfaces, the adapter types, and the invariants
they enforce; ARCHITECTURE.md gains a new "Data-Driven Script
Ownership" pattern section so agents encountering the code can follow
the flow without re-deriving it from commits.

The vtxo package now hosts an IncomingVTXOHandler actor that decodes
arkrpc.IncomingVTXOEvent push notifications, materializes the VTXO
descriptor via lib/scripts.VTXOTapScript, persists it, and notifies the
VTXO manager via VTXOsMaterializedNotification. darepod registers the
actor under vtxo.IncomingVTXOServiceKey and wires a new
MethodIncomingVTXO route into the EventRouter. The vtxo CLAUDE.md now
documents the handler's inputs, validation rules (only VTXO_CREATED
events, bounds-checked values, nil-safe pkScripts), and the fact that
CommitmentTxID comes from the event rather than the leaf txid. The
darepod doc picks up the route registration and the service-key
bootstrap; ARCHITECTURE.md mentions the new route under the
RPC-over-Mailbox pattern.

Smaller updates round out the sweep. wallet/CLAUDE.md describes the
hardened handleSendVTXOs path: recipient amounts are now bounded by
MaxSatoshi, the running total uses overflow-safe accumulation, and the
old releaseAndFail helper is replaced by a deferred release that uses
context.WithoutCancel so cleanup survives caller disconnect. The
darepod doc records the matching RPC-side validation cap
(maxRecipients = 256) and notes that s.clk is now a cached clock
instance so sub-stores share a single Clock for deterministic tests,
plus the new GetStoredVTXO harness accessor used by partial unroll
itests. Round, darepod, vtxo, and wallet docs all cross-reference the
new ownership interfaces where relevant so navigating the per-package
graph reaches the same explanation from any entry point.

make doc-check is clean after these changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add receiver-side notification for in-round (InRon) VTXO sends

5 participants