Skip to content

feat(wallet): always emit ChainLockProcessed on chainlock advance#769

Open
shumkov wants to merge 6 commits into
v0.42-devfrom
feat/key-wallet-chain-lock-applied-event
Open

feat(wallet): always emit ChainLockProcessed on chainlock advance#769
shumkov wants to merge 6 commits into
v0.42-devfrom
feat/key-wallet-chain-lock-applied-event

Conversation

@shumkov
Copy link
Copy Markdown
Collaborator

@shumkov shumkov commented May 15, 2026

Summary

Make wallet chainlock events atomic and emit one on every metadata advance, even when no transaction record was promoted.

Concretely:

  • Rename WalletEvent::TransactionsChainlockedWalletEvent::ChainLockProcessed.
  • Rename its per_account field → locked_transactions (and the underlying ApplyChainLockOutcome.per_account in key-wallet, so outcome and event share one name).
  • Fire one ChainLockProcessed per wallet whenever the wallet's last_applied_chain_lock advances — promotions ride along in locked_transactions, which is empty when the chainlock advanced the boundary without flipping any record.

Before this PR, the event only fired when at least one record flipped InBlockInChainLockedBlock. A chainlock that landed on a quiescent wallet (no InBlock records at heights <= cl_height) advanced the in-memory metadata.last_applied_chain_lock but produced no event, so durable consumers had no signal to flush the new height to disk.

Why this matters downstream

dashpay/platform's rs-platform-wallet ships a fast-path on app restart: if a persisted asset-lock transaction is still InBlock and the persisted last_applied_chain_lock has a height >= record.height, build a ChainAssetLockProof directly from the persisted chainlock signature instead of waiting for SPV to re-deliver one.

That fast-path is reliable only if the persisted chainlock is current. Pre-this-PR, sessions that received chainlocks without promoting records left the persister stale, so the fast-path missed on cold restart. See dashpay/platform#3634 for context.

Design choice

An earlier iteration added a second variant (ChainLockApplied) alongside the existing TransactionsChainlocked, with an explicit before-after ordering between the two. @xdustinface pushed back: keep the chainlock fan-out atomic and reuse one event. This PR now reflects that — a single ChainLockProcessed per advance, with locked_transactions possibly empty.

Changes

key-wallet

  • ApplyChainLockOutcome retains both metadata_advanced: bool and locked_transactions: BTreeMap<AccountType, Vec<Txid>> (renamed from per_account). WalletInfoInterface::apply_chain_lock still returns it; the default impl returns ApplyChainLockOutcome::default(); ManagedWalletInfo populates both fields.

key-wallet-manager

  • WalletEvent::TransactionsChainlockedWalletEvent::ChainLockProcessed { wallet_id, chain_lock, locked_transactions }.
  • process_block::apply_chain_lock emits one event per wallet gated on outcome.metadata_advanced; replays at the same height are silent.
  • Display + wallet_id() arms updated; trait doc on apply_chain_lock rewritten.

dash-spv-ffi

  • OnWalletTransactionsChainlockedCallbackOnWalletChainLockProcessedCallback (same signature: wallet_id, height, hash, signature, finalized array, count, user_data).
  • FFIWalletEventCallbacks.on_transactions_chainlockedon_chain_lock_processed. The struct shrinks by one fn pointer (the abandoned on_chain_lock_applied slot from the earlier two-event design is removed).
  • Dispatcher matches the new event; CLI demo printer renamed (on_wallet_chain_lock_processed) and emits ‟[Wallet] ChainLock processed”.

Backwards compatibility

Breaking at the public surface — variant renamed, field renamed, callback type & field renamed. This is acceptable here because v0.42 is unreleased; downstream consumers regenerated against this branch pick up the rename via cbindgen and the compiler.

Test plan

  • cargo check --workspace --all-targets --all-features
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo fmt --check
  • cargo test -p key-wallet --lib (476 passed)
  • cargo test -p key-wallet-manager --lib (45 passed — chainlock event tests updated to the merged shape, including the empty-locked_transactions case for advances that promote nothing)
  • cargo test -p dash-spv-ffi --lib (46 passed — two new dispatch tests pin every wired field plus the empty-promotion path)

🤖 Generated with Claude Code

shumkov and others added 2 commits May 15, 2026 18:43
Replace the BTreeMap return of `WalletInfoInterface::apply_chain_lock`
with an explicit `ApplyChainLockOutcome { per_account, metadata_advanced }`
so the wallet-manager-level emitter can fire one event per effect:
`TransactionsChainlocked` when records were promoted, and a separate
event whenever `last_applied_chain_lock` advanced (added in the next
commit).

The two effects fire independently — a quiescent wallet that sees a
chainlock above its history still advances the finality boundary even
though no record is promoted. Durable consumers that persist
`last_applied_chain_lock` (e.g. the platform-wallet bridge that uses
it to build a `ChainAssetLockProof` for `InBlock` asset-lock TXs on
restart) need a signal on every boundary advance, not just on the
promotion path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ance

Add a new `WalletEvent::ChainLockApplied { wallet_id, chain_lock }`
variant that fires whenever a wallet's `last_applied_chain_lock`
metadata advances forward (or moves from `None` to `Some`), and route
it through the FFI dispatcher.

`apply_chain_lock` now reads `ApplyChainLockOutcome` from the wallet
info and emits up to two events per wallet, in this order:

1. `ChainLockApplied` when the metadata advanced, so persisters that
   need to mirror `last_applied_chain_lock` to durable storage have a
   single hook regardless of whether any record was promoted. This is
   the gap the platform-wallet bridge had to live with: a chainlock
   that advanced the boundary without promoting anything was
   completely invisible, and the persisted `last_applied_chain_lock`
   went stale, which broke restart-time `ChainAssetLockProof`
   construction for `InBlock` asset-lock TXs.
2. `TransactionsChainlocked` when at least one record was promoted
   from `InBlock` to `InChainLockedBlock` (unchanged contract).

Ordering matters: `ChainLockApplied` fires FIRST so a persister
listening to both events can write durable metadata before the
promotion record.

FFI: adds `OnWalletChainLockAppliedCallback` and an
`on_chain_lock_applied` field appended to `FFIWalletEventCallbacks`
(before `user_data`) so existing offsets stay stable for C consumers.

Event-tests updated to reflect the new two-event contract on chainlock
promotion, the metadata-only advance path, and the higher-replay path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 15, 2026

📝 Walkthrough

Walkthrough

This PR introduces a new wallet event ChainLockApplied and restructures chainlock application to emit two independent events: one for metadata advancement and one for transaction promotion. A new ApplyChainLockOutcome struct splits these effects. The change propagates from core event definitions through wallet implementation, manager orchestration, FFI bindings, and comprehensive test coverage.

Changes

ChainLock Event Separation

Layer / File(s) Summary
Event contract and outcome type
key-wallet-manager/src/events.rs, key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs
WalletEvent::ChainLockApplied variant carries wallet_id and full ChainLock. New ApplyChainLockOutcome struct separates per-account promotions from a metadata_advanced flag. Event helpers (wallet_id(), Display) and docs updated to reflect independent event semantics.
Wallet interface and implementation
key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs
WalletInfoInterface::apply_chain_lock returns ApplyChainLockOutcome instead of promotions map. ManagedWalletInfo implementation computes both per_account finalized txids and whether metadata advanced, returning both in the outcome.
Manager event emission and docs
key-wallet-manager/src/process_block.rs, key-wallet-manager/src/wallet_interface.rs
WalletManager::apply_chain_lock emits ChainLockApplied when metadata advances and TransactionsChainlocked only when promotions exist, allowing independent firing. Method documentation updated to describe two-event model and ordering.
FFI callback wiring
dash-spv-ffi/src/callbacks.rs, dash-spv-ffi/src/bin/ffi_cli.rs
New OnWalletChainLockAppliedCallback C ABI type exposing wallet_id (C string), chainlock height/hash/signature (byte pointers), and user_data. FFIWalletEventCallbacks::dispatch handles the new event variant. CLI initialization wires the callback field.
Test coverage
key-wallet-manager/src/event_tests.rs, key-wallet/src/tests/keep_finalized_transactions_tests.rs
Event tests verify ChainLockApplied emission alongside promotions, without promotions, and idempotency across repeated/higher locks. Wallet tests capture and assert ApplyChainLockOutcome fields for promotion and boundary advancement scenarios.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Suggested reviewers

  • xdustinface

Poem

🐰 Chain locks split their ways with grace,
One for metadata's forward pace,
One for transactions finally clear—
Two events now, no interference near!
The FFI rings with callbacks new,
Tests confirm what both events do. 🔗

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title mentions 'ChainLockProcessed' but the actual implementation introduces 'ChainLockApplied' event; the title is misleading about the event name. Update the title to use 'ChainLockApplied' instead of 'ChainLockProcessed' to accurately reflect the event name introduced in the changeset.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/key-wallet-chain-lock-applied-event

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
dash-spv-ffi/src/callbacks.rs (1)

1203-1218: ⚡ Quick win

Add a direct unit test for ChainLockApplied callback dispatch.

This new dispatch branch should have an explicit callback assertion (wallet id + height/hash/signature wiring) to prevent silent FFI regressions.

As per coding guidelines, "Write unit tests for new functionality".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv-ffi/src/callbacks.rs` around lines 1203 - 1218, Add a unit test that
exercises the WalletEvent::ChainLockApplied branch and asserts the FFI callback
receives the exact wallet id, block_height, block_hash and signature values:
create a test that sets up a struct with on_chain_lock_applied pointing to a
mock/tracing callback (capturing the C string pointer, height, hash bytes, sig
bytes and user_data), construct a WalletEvent::ChainLockApplied with a known
wallet_id and a ChainLock containing a known block_height, a 32-byte block_hash
and a 96-byte signature, invoke the same event dispatcher that contains the
match on WalletEvent::ChainLockApplied (the code that references
self.on_chain_lock_applied), and assert the captured values match the originals
(decode the C string pointer back to bytes/string and compare raw arrays for
hash/signature and the height and wallet_id), failing the test if any value is
miswired to catch regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dash-spv-ffi/src/callbacks.rs`:
- Around line 886-889: The new field on_chain_lock_applied was inserted before
user_data which changes the ABI layout despite the comment promise; move
on_chain_lock_applied to after user_data to preserve the previous ABI offset for
user_data, update the struct in callbacks.rs accordingly (referencing the field
names on_chain_lock_applied and user_data), and ensure the dispatch path in
dispatch() correctly invokes the callback for WalletEvent::ChainLockApplied;
also add a unit test mirroring the existing test_blocks_needed_dispatch_* tests
to cover ChainLockApplied dispatch so behavior is verified.

---

Nitpick comments:
In `@dash-spv-ffi/src/callbacks.rs`:
- Around line 1203-1218: Add a unit test that exercises the
WalletEvent::ChainLockApplied branch and asserts the FFI callback receives the
exact wallet id, block_height, block_hash and signature values: create a test
that sets up a struct with on_chain_lock_applied pointing to a mock/tracing
callback (capturing the C string pointer, height, hash bytes, sig bytes and
user_data), construct a WalletEvent::ChainLockApplied with a known wallet_id and
a ChainLock containing a known block_height, a 32-byte block_hash and a 96-byte
signature, invoke the same event dispatcher that contains the match on
WalletEvent::ChainLockApplied (the code that references
self.on_chain_lock_applied), and assert the captured values match the originals
(decode the C string pointer back to bytes/string and compare raw arrays for
hash/signature and the height and wallet_id), failing the test if any value is
miswired to catch regressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b06f31c1-731f-482c-b22a-d7be36ebcfd6

📥 Commits

Reviewing files that changed from the base of the PR and between bbf0a9c and de28922.

📒 Files selected for processing (8)
  • dash-spv-ffi/src/bin/ffi_cli.rs
  • dash-spv-ffi/src/callbacks.rs
  • key-wallet-manager/src/event_tests.rs
  • key-wallet-manager/src/events.rs
  • key-wallet-manager/src/process_block.rs
  • key-wallet-manager/src/wallet_interface.rs
  • key-wallet/src/tests/keep_finalized_transactions_tests.rs
  • key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs

Comment thread dash-spv-ffi/src/callbacks.rs Outdated
@shumkov shumkov changed the title feat(key-wallet, key-wallet-manager): emit ChainLockApplied event whenever metadata advances feat(wallet): emit ChainLockApplied event whenever metadata advances May 15, 2026
shumkov and others added 2 commits May 15, 2026 19:10
…ied dispatch

CodeRabbit caught that the new `on_chain_lock_applied` field was inserted
before `user_data` in `FFIWalletEventCallbacks`, which shifted
`user_data`'s byte offset and contradicted the layout-stability claim in
the doc comment — C-side consumers hand-allocating this struct from older
headers (i.e. without regenerating via cbindgen) rely on `user_data`
staying where it was. Move the new field strictly to the end so every
prior field — including `user_data` — keeps its previous offset, and
rewrite the field's doc comment to reflect the new placement and the
actual ABI guarantee.

Also adds the `ChainLockApplied` dispatch unit test CodeRabbit suggested
as a nice-to-have, sitting alongside the existing dispatch tests at the
bottom of `callbacks.rs`. The test registers an `extern "C"` callback,
fires a `WalletEvent::ChainLockApplied` carrying a synthetic
`ChainLock::dummy(777)`, and asserts the callback received `cl_height ==
777` — pinning the dispatch path against silent regressions.

Refs: #769 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…xture

Appending `on_chain_lock_applied` to `FFIWalletEventCallbacks` broke the
integration-test fixture in `dashd_sync/callbacks.rs`, which constructs
the struct with all fields named explicitly (no `..Default::default()`
spread). CI under `cargo test -p dash-spv-ffi --all-features` failed
with `error[E0063]: missing field on_chain_lock_applied`, which
cascaded into every job that builds the FFI test suite (ffi matrix,
pre-commit, address-sanitizer).

The new wallet-event tests don't need this callback, so wire it as
`None` — same shape as the freshly-added `on_transactions_chainlocked`
slot right above it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

❌ Patch coverage is 90.47619% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.68%. Comparing base (bbf0a9c) to head (73fb21c).

Files with missing lines Patch % Lines
key-wallet-manager/src/events.rs 0.00% 6 Missing ⚠️
dash-spv-ffi/src/bin/ffi_cli.rs 0.00% 2 Missing ⚠️
...allet/managed_wallet_info/wallet_info_interface.rs 77.77% 2 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##           v0.42-dev     #769      +/-   ##
=============================================
+ Coverage      72.62%   72.68%   +0.05%     
=============================================
  Files            320      320              
  Lines          70254    70333      +79     
=============================================
+ Hits           51022    51119      +97     
+ Misses         19232    19214      -18     
Flag Coverage Δ
core 76.30% <ø> (ø)
ffi 49.15% <97.56%> (+0.74%) ⬆️
rpc 20.00% <ø> (ø)
spv 89.90% <ø> (-0.07%) ⬇️
wallet 71.27% <65.21%> (-0.01%) ⬇️
Files with missing lines Coverage Δ
dash-spv-ffi/src/callbacks.rs 83.39% <100.00%> (+9.24%) ⬆️
key-wallet-manager/src/process_block.rs 92.80% <100.00%> (-0.04%) ⬇️
key-wallet-manager/src/wallet_interface.rs 15.00% <ø> (ø)
.../src/managed_account/managed_core_funds_account.rs 76.72% <100.00%> (ø)
dash-spv-ffi/src/bin/ffi_cli.rs 0.00% <0.00%> (ø)
...allet/managed_wallet_info/wallet_info_interface.rs 83.41% <77.77%> (+0.26%) ⬆️
key-wallet-manager/src/events.rs 68.13% <0.00%> (-0.68%) ⬇️

... and 2 files with indirect coverage changes

Copy link
Copy Markdown
Collaborator

@xdustinface xdustinface left a comment

Choose a reason for hiding this comment

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

I think we should keep it atomic here too and just modify and reuse TransactionsChainlocked chainlock event. Like:

  • Rename TransactionsChainlocked -> ChainLockProcessed
  • Rename per_account -> locked_transactions
  • Just always emit when metadata_advanced with empty transactoins if none were updated.

shumkov and others added 2 commits May 15, 2026 19:33
Two `assert_eq!` calls added earlier on this branch (commits 3a6e452 /
de28922) exceed the workspace rustfmt width and now fail the
`cargo fmt` pre-commit hook on macOS/Ubuntu/Windows. Straight
`cargo fmt --all` output — no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cessed

Per @xdustinface's review on #769: keep chainlock fan-out atomic by
collapsing the two-event split (`ChainLockApplied` +
`TransactionsChainlocked`) into a single
`WalletEvent::ChainLockProcessed`, fired whenever the wallet's
`last_applied_chain_lock` advances. Net-new per-account promotions
ride along in `locked_transactions` — possibly empty when the chainlock
advanced the metadata boundary without promoting any record (durable
consumers that persist the chainlock proof still observe those
empty-promotion events).

Surface changes:

* `WalletEvent::TransactionsChainlocked` → `WalletEvent::ChainLockProcessed`
* `ApplyChainLockOutcome.per_account` → `locked_transactions` (matching
  the event field name; outcome and event speak the same vocabulary)
* `WalletEvent::ChainLockApplied` removed entirely
* `OnWalletTransactionsChainlockedCallback` →
  `OnWalletChainLockProcessedCallback`, signature unchanged
* `FFIWalletEventCallbacks.on_transactions_chainlocked` →
  `on_chain_lock_processed`; the separate `on_chain_lock_applied` slot
  is gone, so `FFIWalletEventCallbacks` shrinks by one callback pointer
  (a hard ABI break for the unreleased addition, fine since
  v0.42 hasn't shipped)
* `ffi_cli`'s `on_wallet_transactions_chainlocked` printer renamed
  `on_wallet_chain_lock_processed`; emits "[Wallet] ChainLock processed"

Emission semantics: `process_block.rs` now sends one event per wallet
per chainlock, gated on `outcome.metadata_advanced`. Replays of the
same chainlock height are silent. Tests in
`key-wallet-manager/src/event_tests.rs` and
`key-wallet/src/tests/keep_finalized_transactions_tests.rs` updated to
the merged shape, including the empty-`locked_transactions` case for
chainlocks that advance the boundary without promoting any record.

Dispatch test coverage strengthened per CodeRabbit's nitpick on
#769#discussion (1203-1218): replaces the height-only assertion with
two tests at the bottom of `dash-spv-ffi/src/callbacks.rs`:

* `test_chain_lock_processed_dispatch_round_trips_every_field` —
  registers an `extern "C"` callback, captures every wired argument
  into a typed `Captured` struct, fires a `ChainLockProcessed` with
  two distinct accounts (one txid each), and asserts wallet_id
  hex-encoding, height, 32-byte block hash, 96-byte signature, and
  `finalized_count == 2` (counts (account, txid) pairs, not accounts).
* `test_chain_lock_processed_dispatch_fires_with_empty_promotions` —
  empty `locked_transactions` must still fire the callback with
  `finalized_count == 0`; pins the contract for durable consumers that
  persist the chainlock proof even when no record was promoted.

Closes the design pushback on #769; addresses the dispatch-test
nitpick in the same review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shumkov
Copy link
Copy Markdown
Collaborator Author

shumkov commented May 15, 2026

Applied as you proposed, in 73fb21c:

  • WalletEvent::TransactionsChainlockedWalletEvent::ChainLockProcessed
  • event field per_accountlocked_transactions (and the underlying ApplyChainLockOutcome.per_account renamed too, so outcome and event use the same name)
  • WalletEvent::ChainLockApplied is gone — the manager now emits a single atomic ChainLockProcessed per wallet whenever outcome.metadata_advanced, carrying locked_transactions (possibly empty when the chainlock advanced the boundary without promoting any record). Replays at the same height are silent.

FFI side renamed in lockstep: callback is now on_chain_lock_processed (carries the same finalized / finalized_count payload as before, plus the empty-promotion case), the separate on_chain_lock_applied slot is removed. Tests in key-wallet-manager/src/event_tests.rs and key-wallet/src/tests/keep_finalized_transactions_tests.rs updated to the merged shape, including a case that pins the empty-locked_transactions event when metadata advances with no promotions.

@shumkov shumkov requested a review from xdustinface May 15, 2026 13:24
/// fire the callback (durable consumers persist the chainlock proof
/// even when no record was promoted) with `finalized_count == 0`.
#[test]
fn test_chain_lock_processed_dispatch_fires_with_empty_promotions() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This test doesn't really make sense i think? Its testing the dispatch function?

@shumkov shumkov changed the title feat(wallet): emit ChainLockApplied event whenever metadata advances feat(wallet): always emit ChainLockProcessed on chainlock advance May 15, 2026
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.

2 participants