Skip to content

feat: watcher emits persistence-ready watch-only activities#109

Merged
coreyphillips merged 6 commits into
masterfrom
feat/watch-only-activity
Jun 24, 2026
Merged

feat: watcher emits persistence-ready watch-only activities#109
coreyphillips merged 6 commits into
masterfrom
feat/watch-only-activity

Conversation

@coreyphillips

@coreyphillips coreyphillips commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Subtask 2 of #107, reworked per @ovitrif's review. The onchain xpub watcher now emits persistence-ready Core activity data that Bitkit stores through the existing activity APIs — rather than the app hand-reconstructing activities per-platform (and drifting).

What it does

Watcher builds the payload (where the data still exists). WatcherEvent::TransactionsChanged now carries:

  • activities: Vec<Activity>
  • transaction_details: Vec<TransactionDetails>

…instead of Vec<HistoryTransaction>. They're built inside the watcher using the BDK Wallet, raw txs (list_transactions(true)), confirmation time, fee/vsize, and is_mine ownership — the decode path is shared with get_transaction_detail via the extracted map_bdk_tx_to_detail.

Wallet boundary = the watched address type. WatcherParams gains wallet_id. One activity per tx, scoped to that wallet; the same txid under two address types becomes two wallet-scoped activities (no cross-address-type merge).

Persistence-compatible semantics, from the watched wallet's perspective:

case value fee address
Received received 0 (sender paid it) owned output
Sent sent - received - fee fee destination (non-owned) output
SelfTransfer 0 fee owned output

Self-transfer is value = 0, fee = fee so Bitkit's value + fee total isn't doubled. Unconfirmed txs get a positive (now) timestamp so rows are DB-valid and sort to the top while pending.

Upsert hardening so watcher refreshes don't clobber user/first-seen state: contact is preserved when the incoming value is None (COALESCE), and an unconfirmed tx keeps its existing timestamp until it confirms. (seen_at and tags were already preserved.)

Also: HistoryTransaction keeps the new fee_rate field for get_transaction_history consumers; both that path and the watcher now use list_transactions(true). The earlier standalone watch_only_activity_from_history mapper is removed.

Tests

cargo test --lib modules::onchain:: (55) and modules::activity:: (167) pass. New coverage:

  • watch_only_activity_from_detail: received-drops-sender-fee, sent value/fee split, self-transfer zero-value, unconfirmed now timestamp, matching TransactionDetails.
  • test_upsert_preserves_contact_and_unconfirmed_timestamp.

Swift + Python bindings regenerated (iOS xcframework / Android Kotlin / dylib rebuilt at release via build.sh, per repo convention).

🤖 Generated with Claude Code

…pper

Subtask 2 of #107. Lets core own the History->Activity mapping that iOS and
Android would otherwise hand-reconstruct (and drift on).

Part A — root-cause fix for fee rate: HistoryTransaction now carries an
Option<f64> fee_rate (sat/vB), computed in map_bdk_tx_to_history from the
fee and the raw tx vsize. This flows into WatcherEvent::TransactionsChanged,
so the watcher no longer forces callers to hardcode feeRate: 1.

Part B — watch_only_activity_from_history(wallet_id, txs) -> Vec<Activity>:
a pure mapper (no DB writes). Direction -> PaymentType (SelfTransfer -> Sent),
deterministic id = tx_id so re-emitted lists upsert in place, address left
empty for watch-only.

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

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 55ee1fc3f6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/lib.rs Outdated
Comment thread src/modules/onchain/implementation.rs
Aligns the mapper with how bitkit-ios and bitkit-android actually build
onchain activities:

- id = tx_id (confirmed: iOS CoreService and Android HwWalletRepo both key
  onchain activities on the txid).
- Merge HistoryTransaction rows by txid before classifying. A hardware
  device's accounts/script-types are watched separately, so the same tx can
  appear once per account (an internal send+receive). Summing received/sent
  and classifying the whole tx yields one activity instead of duplicates that
  would collapse under id = tx_id. Mirrors Android's toMergedActivities().

Direction/amount come from core's classify_tx (the canonical classifier),
fee/fee_rate are taken from the account that paid (received-only rows carry
none), and first-seen txid order is preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coreyphillips

Copy link
Copy Markdown
Collaborator Author

Update: aligned with bitkit-ios / bitkit-android conventions

Checked how both apps key and build onchain activities to settle the open id question:

  • id = tx_id — confirmed. iOS CoreService.createSentOnchainActivityFromSendResult uses id: txid, and Android HwWalletRepo.toOnchainActivity uses id = first.txid. The activity table's PK is (wallet_id, id), so keying on the bare txid is collision-safe across wallets. No change needed.
  • address = "" — matches Android's watch-only mapping exactly.

While there I found the apps do one more thing the 1:1 mapper didn't: they merge HistoryTransaction rows by txid (Android toMergedActivities). A hardware device has multiple accounts/script-types watched separately, so the same txid can surface once per account (e.g. an internal send→receive). With id = tx_id, two un-merged rows would collapse under upsert to last-wins and lose the combined picture. The mapper now:

  • groups rows by txid, sums received/sent, and classifies the whole tx via core's classify_tx (the canonical direction/amount logic);
  • keeps the real fee/fee_rate from the account that paid (received-only rows carry none);
  • preserves first-seen txid order; produces exactly one activity per tx.

So the mapper is now a faithful drop-in for the per-platform logic rather than just a field copy. Tests cover received / sent / self-transfer / same-txid merge / ordering / unconfirmed defaults / deterministic id.

…gen bindings

Addresses Codex review on #109:

- P2: get_transaction_history and the watcher's event builder called
  list_transactions(false), so tx.transaction was None and the new
  HistoryTransaction.fee_rate stayed None for every history/watcher consumer.
  Switch both to list_transactions(true) so vsize (hence fee_rate) is available.

- P1: the mapper's results carry address="" and timestamp=0, which violate the
  activity DB CHECK constraints (length(address) > 0, timestamp > 0). bitkit-ios
  and bitkit-android keep these as in-memory display models and never insert
  them. Reworded the doc to say so and dropped the "callers upsert_activity"
  guidance that would have produced DB errors.

- Regenerated Swift + Python bindings for the new fee_rate field and
  watch_only_activity_from_history export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ovitrif

ovitrif commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Thanks, I hope the fact that we didn't share the full context why the 2 apps differ in implementation is not going to cause a lot of trouble to get this PR in its merge-able state.

The reason Android works more in-memory is mainly because it didn't have the later #103 PR at the time of the implementation.

The scope of that task actually surfaced as an idea during the Android implementation of Trezor subtask 1:

Since iOS had the primitives in place, Joao and myself synced to make sure iOS doesn't have to carry over this requirement to refactor later, which Android's first PR (ported by iOS' PR) embedded.

After #103 was merged, I updated the Android epic and its subtask (6) for optimisation with specs that came as a result of investigation how to levarage the walletId/wallet-scoped activities.

Links:

@ovitrif

ovitrif commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

The deterministic id = tx_id choice should be confirmed against how Bitkit already keys onchain activities; easy to switch to wallet_id:tx_id if cross-wallet collisions are a concern.

TxId might be problematic for cases where we send coins from Trezor to app's onchain wallet. My understanding is that activities for such actions will both have the same txId. This is one of the reasons we had to include more discriminators in the walletId PR #103

@ovitrif ovitrif left a comment

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.

Leaving these together so the intended direction is clear. The watcher does not need to persist into the DB itself, but the watcher payload should be normal Core activity data that Bitkit can persist.

Comment thread src/lib.rs Outdated
Comment thread src/lib.rs Outdated
Comment thread src/lib.rs Outdated
Comment thread src/lib.rs Outdated
Comment thread bindings/ios/bitkitcore.swift
…view)

Reworks the watch-only flow per review: instead of a downstream display-only
mapper, the watcher itself builds Core activity data the app can persist
through the normal activity APIs.

- WatcherEvent::TransactionsChanged now carries activities: Vec<Activity> +
  transaction_details: Vec<TransactionDetails> (replacing Vec<HistoryTransaction>),
  built inside the watcher where the BDK Wallet, raw txs, and is_mine checks
  are still available.
- WatcherParams gains wallet_id; the boundary stays at the watched address type
  (no cross-address-type txid merging).
- Correct, persistence-compatible semantics from the watched wallet's view:
  * Received: real owned address, value = received, fee/fee_rate = 0 (the fee was
    the sender's, even when BDK attaches one to the row).
  * Sent: destination address, value = sent - received - fee, fee = fee
    (Bitkit renders value + fee).
  * SelfTransfer: value = 0, fee = fee (so value + fee isn't doubled).
  * Positive timestamp (now) for unconfirmed txs so rows are DB-valid.
- Extracted map_bdk_tx_to_detail (shared with get_transaction_detail) and added
  watch_only_activity_from_detail; removed the old standalone mapper.
- Harden upsert: preserve user-set contact (COALESCE) and keep the existing
  timestamp while a tx is still unconfirmed, so watcher refreshes don't churn
  user/first-seen state.
- Regenerated Swift + Python bindings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coreyphillips coreyphillips changed the title feat: surface fee_rate on HistoryTransaction + watch-only Activity mapper feat: watcher emits persistence-ready watch-only activities Jun 24, 2026
@coreyphillips

Copy link
Copy Markdown
Collaborator Author

@ovitrif thanks for the thorough review — this was exactly the right call. Pushed a redesign in 540f894 that moves activity-building into the watcher and emits persistence-ready activities + transaction_details, with per-address-type wallet_id scoping (no merge), correct value/fee semantics (self-transfer = value 0 + fee; receive-only drops the sender's fee), real decoded addresses, positive unconfirmed timestamps, and upsert hardening so refreshes preserve contact + first-seen timestamp. Replied inline on each point. Note this makes WatcherEvent / WatcherParams a breaking change vs the previous shape.

@ben-kaufman ben-kaufman left a comment

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.

Review comments from local pass.

Comment thread src/modules/activity/implementation.rs Outdated
Comment thread src/modules/activity/implementation.rs Outdated
Comment thread src/modules/onchain/implementation.rs Outdated
Addresses @ben-kaufman review:

- update_onchain_activity_by_id is a literal replacement again, so update_activity
  callers can still clear contact and move an unconfirmed tx's timestamp.
- Field preservation now lives in the upsert path: upsert_onchain_activities keeps
  the existing contact when the incoming one is None (COALESCE) and keeps the
  existing timestamp while a tx is unconfirmed (snapping to the block time on
  confirmation). Singular upsert_activity routes through the batch upsert so both
  entry points behave the same.
- TransactionDetails.amount_sats is now fee-excluded (net + the wallet's own fee),
  matching its documented contract and the activity value; receive-only rows are
  unaffected since their fee is 0.

Tests: pure-replacement update, batch + singular upsert preservation, amount_sats
for received/sent. Regenerated all platform bindings + artifacts via build.sh all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coreyphillips

Copy link
Copy Markdown
Collaborator Author

@ovitrif on the tx_id point (a Trezor send to the app's own onchain wallet producing the same txid in both): the redesign keys activities on the composite primary key (wallet_id, id) with id = tx_id, and each watcher is scoped to a specific address-type wallet_id. So the Trezor-side activity (wallet_id = trezor:...) and the app-wallet-side activity (its own wallet_id) are distinct rows even with the same txid, no collision. That matches the wallet-scoping direction from #103. Thanks also for the context on why Android went in-memory (pre-#103) and the epic/subtask links, that lines up with this approach.

@ben-kaufman ben-kaufman left a comment

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.

Full review looks good. I rechecked the watcher/activity flow, generated surfaces, and the resolved threads.

…ivity

# Conflicts:
#	Package.swift
#	bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so
#	bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so
#	bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so
#	bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so
#	bindings/ios/BitkitCore.xcframework.zip
#	bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/libbitkitcore.a
#	bindings/ios/BitkitCore.xcframework/ios-arm64/libbitkitcore.a
#	bindings/python/bitkitcore/libbitkitcore.dylib
#	src/modules/activity/tests.rs
#	src/modules/activity/types.rs
#	src/modules/onchain/implementation.rs
#	src/modules/onchain/listener.rs
#	src/modules/onchain/tests.rs
@coreyphillips coreyphillips merged commit c098b41 into master Jun 24, 2026
@coreyphillips coreyphillips deleted the feat/watch-only-activity branch June 24, 2026 16:31
@ovitrif

ovitrif commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

LGTM, reviewed after merge. Thanks everyone for the help 🙏🏻

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.

3 participants