diff --git a/Cargo.lock b/Cargo.lock index 1aa5f89fb..a353129fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1717,7 +1717,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "dash-platform-macros", "futures-core", @@ -1785,7 +1785,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "dpp", "drive", @@ -1874,7 +1874,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "heck", "quote", @@ -1884,7 +1884,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "arc-swap", "async-trait", @@ -2035,7 +2035,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "platform-value", "platform-version", @@ -2046,7 +2046,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2299,7 +2299,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "platform-value", "platform-version", @@ -2310,7 +2310,7 @@ dependencies = [ [[package]] name = "dpp" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "anyhow", "async-trait", @@ -2358,7 +2358,7 @@ dependencies = [ [[package]] name = "drive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2383,7 +2383,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -2965,7 +2965,7 @@ dependencies = [ [[package]] name = "feature-flags-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "platform-value", "platform-version", @@ -4013,7 +4013,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.61.2", ] [[package]] @@ -4428,7 +4428,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "platform-value", "platform-version", @@ -4620,7 +4620,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "platform-value", "platform-version", @@ -5678,7 +5678,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "bincode 2.0.1", "platform-version", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "proc-macro2", "quote", @@ -5698,7 +5698,7 @@ dependencies = [ [[package]] name = "platform-value" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -5718,7 +5718,7 @@ dependencies = [ [[package]] name = "platform-version" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "bincode 2.0.1", "grovedb-version", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "proc-macro2", "quote", @@ -5900,7 +5900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools 0.10.5", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -5921,7 +5921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.116", @@ -6438,7 +6438,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "backon", "chrono", @@ -7578,7 +7578,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "platform-value", "platform-version", @@ -8326,7 +8326,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "platform-value", "platform-version", @@ -9720,7 +9720,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "3.1.0-dev.1" -source = "git+https://github.com/dashpay/platform?rev=b445b6f0e0bd486357f86100ba8b7fddab283bd7#b445b6f0e0bd486357f86100ba8b7fddab283bd7" +source = "git+https://github.com/dashpay/platform?rev=0fa82e6652097d17a700d8bcc006d6b2aa922c6e#0fa82e6652097d17a700d8bcc006d6b2aa922c6e" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index f71c926af..1ed4b5f8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } eframe = { version = "0.33.3", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "b445b6f0e0bd486357f86100ba8b7fddab283bd7", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "0fa82e6652097d17a700d8bcc006d6b2aa922c6e", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", diff --git a/docs/ai-design/2026-02-24-platform-sync-simplification/manual-test-scenarios.md b/docs/ai-design/2026-02-24-platform-sync-simplification/manual-test-scenarios.md new file mode 100644 index 000000000..2d970c676 --- /dev/null +++ b/docs/ai-design/2026-02-24-platform-sync-simplification/manual-test-scenarios.md @@ -0,0 +1,293 @@ +# Manual Test Scenarios: Platform Sync Simplification + +**PR:** #635 (`zk-extract/platform-sync-simplification`) +**Date:** 2026-02-24 +**Scope:** Removal of `PlatformSyncMode` enum (Auto/ForceFull/TerminalOnly), replacement of +full/terminal sync logic with SDK-managed incremental sync, simplified `RefreshMode` dropdown, +removal of `last_full_sync_balance` and `last_terminal_block` from persistence. + +--- + +## Preconditions (all scenarios) + +- Dash Evo Tool built from the `zk-extract/platform-sync-simplification` branch. +- Dash Core node running and reachable (or Regtest local node for Regtest scenarios). +- At least one wallet loaded with a known mnemonic / seed phrase. +- Platform (DAPI) endpoints reachable for the selected network. +- Developer mode enabled (Settings > Developer Mode) for scenarios that reference the + refresh mode dropdown. + +--- + +## Scenario 1: Fresh Wallet Sync (No Prior Sync State) + +**Goal:** Verify that a brand-new wallet with no database sync state performs a complete +Platform address balance sync successfully. + +### Steps + +1. Create or import a new wallet that has never been synced. +2. Ensure the wallet is unlocked (seed phrase entered / wallet open). +3. On the Wallets screen, click the Refresh button (default mode "Core + Platform"). +4. Wait for the sync to complete. + +### Expected Results + +- A loading/spinner indicator appears during sync. +- After completion, Platform address balances are displayed (may be zero for a truly new wallet). +- No errors or warnings appear in the UI. +- In the application logs (`RUST_LOG=info`), you see: + - `Platform address sync start` (no "mode" field -- the old `mode: Auto/ForceFull/TerminalOnly` + log line should be absent). + - `Sync complete: duration=..., found=..., absent=..., checkpoint=..., new_sync_height=..., new_sync_timestamp=...` + - `Platform address sync complete: total_duration=..., addresses_with_balance=...` +- The database `wallet` table is updated with non-zero `last_platform_full_sync` and + `last_platform_sync_checkpoint` values for the wallet's seed hash. + +--- + +## Scenario 2: Incremental Sync (Wallet Already Has Balances) + +**Goal:** Verify that a wallet that was previously synced performs an incremental sync +(using `last_ts`) and picks up balance changes since the last sync. + +### Steps + +1. Start with a wallet that was already synced at least once (Scenario 1 completed). +2. From another tool or wallet, send a small amount of Dash credits to one of the + wallet's Platform addresses (e.g., via asset lock or transfer). +3. Return to Dash Evo Tool. Click Refresh ("Core + Platform"). +4. Wait for sync to complete. + +### Expected Results + +- The balance of the funded Platform address increases by the expected amount. +- Sync is noticeably faster than a full sync (the SDK receives `last_ts` > 0 and + performs incremental catch-up rather than querying all addresses from scratch). +- Logs show `new_sync_height` and `new_sync_timestamp` are updated to newer values + than the previous sync. +- No double-counting of balances (the old `apply_recent_balance_changes` two-pass + approach is gone; the SDK handles deltas internally). + +--- + +## Scenario 3: Refresh Mode Dropdown (Developer Mode) + +**Goal:** Verify that the simplified refresh mode dropdown in developer mode offers +exactly three options and each triggers the correct behavior. + +### Preconditions + +- Developer mode is enabled. + +### Steps + +1. On the Wallets screen, locate the refresh dropdown next to the Refresh button. +2. Click the dropdown and observe the available options. +3. Select **"Core + Platform"** and click Refresh. Observe behavior. +4. Select **"Core Only"** and click Refresh. Observe behavior. +5. Select **"Platform Only"** and click Refresh. Observe behavior. + +### Expected Results + +- **Step 2:** Exactly three options are listed: + - "Core + Platform" (default) + - "Core Only" + - "Platform Only" + - The old options ("All (Auto)", "Platform (Full)", "Platform (Terminal)", + "Core + Platform (Full)", "Core + Platform (Terminal)") must NOT appear. +- **Step 3 ("Core + Platform"):** Both Core wallet UTXOs/transactions and Platform + address balances are refreshed. The loading indicator covers both operations. +- **Step 4 ("Core Only"):** Only Core wallet data refreshes. Platform address + balances remain unchanged (no Platform network calls in logs). +- **Step 5 ("Platform Only"):** Only Platform address balances refresh. Core wallet + balance and transaction list remain unchanged. + +--- + +## Scenario 4: Platform Address Balance Updates After Funding + +**Goal:** Verify balances update correctly after funding a Platform address via +asset lock or wallet UTXOs. + +### Steps + +1. Open a wallet with Core balance available. +2. Initiate "Fund Platform Address" (either from asset lock or from wallet UTXOs). +3. Enter an amount and confirm the transaction. +4. Wait for the operation to complete. + +### Expected Results + +- After funding completes, an automatic Platform balance refresh is triggered + (the code calls `fetch_platform_address_balances(seed_hash)` without any mode argument). +- The funded Platform address shows the increased balance. +- The Core wallet balance decreases by the funded amount plus fees. +- No errors appear in the UI or logs. + +--- + +## Scenario 5: Platform Address Balance Updates After Withdrawal + +**Goal:** Verify balances update correctly after withdrawing from a Platform address. + +### Steps + +1. Open a wallet that has Platform address balance > 0. +2. Initiate "Withdraw from Platform Address." +3. Enter an amount and confirm. +4. Wait for the operation to complete. + +### Expected Results + +- After withdrawal completes, an automatic Platform balance refresh is triggered. +- The Platform address balance decreases by the withdrawn amount. +- No errors appear in the UI or logs. + +--- + +## Scenario 6: Wallet Lock/Unlock Behavior During Sync + +**Goal:** Verify that sync correctly reports an error when the wallet is locked, +and succeeds after unlocking. + +### Steps + +1. Lock the wallet (close it / remove seed phrase from memory). +2. Click Refresh ("Core + Platform" or "Platform Only"). +3. Observe the error. +4. Unlock the wallet (enter seed phrase / passphrase). +5. Click Refresh again. + +### Expected Results + +- **Step 3:** An error message appears: "Wallet is locked. Please unlock it first to refresh." +- **Step 5:** Sync completes successfully; Platform address balances are displayed. + +--- + +## Scenario 7: Network Switching (Mainnet / Testnet / Regtest) + +**Goal:** Verify that sync state is per-network and switching networks does not +corrupt balances. + +### Steps + +1. Connect to Testnet. Load a wallet and perform a full refresh. Note the balances. +2. Switch to Mainnet (or another network). Perform a refresh. +3. Switch back to Testnet. + +### Expected Results + +- **Step 1:** Testnet balances are displayed and persisted. +- **Step 2:** Mainnet balances are fetched independently; Testnet balances are not + displayed or overwritten. +- **Step 3:** Testnet balances are restored from the database and match the values + from Step 1 (unless on-chain state changed). +- The `platform_address_balances` table uses the `network` column to isolate data. + +--- + +## Scenario 8: Regtest with Empty Balance Tree + +**Goal:** Verify graceful handling when the Platform balance tree is empty (common +on fresh Regtest/devnet with no funded addresses). + +### Preconditions + +- Regtest or local devnet with no Platform balances funded yet. + +### Steps + +1. Connect to Regtest. +2. Load a wallet. +3. Click Refresh ("Core + Platform"). + +### Expected Results + +- No crash or unhandled error. +- The sync completes with zero balances displayed. +- Logs show: `Platform address balance tree is empty. Returning empty sync result.` +- The `ban_failed_address: false` config is applied for Regtest (verified in logs + or by the fact that DAPI addresses are not banned after the empty-tree response). + +--- + +## Scenario 9: No DAPI Endpoints Available + +**Goal:** Verify graceful error handling when Platform is unreachable. + +### Steps + +1. Disconnect from the network or configure invalid DAPI endpoints. +2. Click Refresh ("Core + Platform" or "Platform Only"). + +### Expected Results + +- An error message is displayed to the user (e.g., "Failed to sync Platform addresses: ..."). +- The application does not crash or freeze. +- Core-only refresh still works if selected separately ("Core Only" mode). +- Previously stored Platform balances remain visible from the database cache. + +--- + +## Scenario 10: Database Migration / Upgrade from Previous Version + +**Goal:** Verify that upgrading from a version with the old 3-tuple sync state +(`last_full_sync`, `checkpoint`, `last_terminal_block`) to the new 2-tuple +(`last_sync_timestamp`, `last_sync_height`) does not cause data loss or crashes. + +### Steps + +1. Run the previous version of the app (before this PR) and perform at least one + Platform sync to populate the old database columns. +2. Upgrade to the build from this PR branch. +3. Launch the application. +4. Navigate to the Wallets screen. Observe that existing wallet data loads. +5. Perform a refresh. + +### Expected Results + +- The application starts without database errors. +- The old `last_terminal_block` column, if still present, is simply ignored + (the new code reads only 2 columns from the query). +- Existing platform address balances load correctly from the database + (the `last_full_sync_balance` column is no longer read but its presence + does not cause errors). +- The first sync after upgrade behaves like a fresh sync (since the old + checkpoint semantics differ from the new `sync_height`), and subsequent + syncs are incremental. + +--- + +## Scenario 11: Pending Platform Balance Refresh After Transfer + +**Goal:** Verify that the pending refresh mechanism (triggered after credit +transfers between Platform addresses) works correctly with the simplified API. + +### Steps + +1. Open a wallet with at least two Platform addresses, both with balances. +2. Initiate a Platform credit transfer from one address to another. +3. Wait for the transfer to complete. + +### Expected Results + +- After the transfer completes, a pending platform balance refresh is automatically + queued (`pending_platform_balance_refresh` fires). +- Both addresses update: sender balance decreases, receiver balance increases. +- The refresh uses `FetchPlatformAddressBalances { seed_hash }` with no mode + parameter (verified in logs -- no "sync_mode" field). + +--- + +## Edge Cases Checklist + +| Edge Case | Expected Behavior | +|---|---| +| Wallet with 0 Platform addresses derived | Sync completes with no found addresses; no error | +| Very large number of Platform addresses (>100) | Sync completes; gap limit logic extends as needed | +| Sync interrupted mid-way (app closed) | Next launch performs a clean sync from stored state | +| Two wallets synced concurrently | Each wallet syncs independently by seed hash | +| `last_sync_timestamp` is 0 in DB | SDK receives `None` for `last_ts`, performs full sync | +| `last_sync_timestamp` > 0 in DB | SDK receives `Some(ts)`, performs incremental sync | diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index e88d3d138..3795b9dfd 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -46,18 +46,14 @@ fn networks_address_compatible(a: &Network, b: &Network) -> bool { ) } -use crate::backend_task::wallet::PlatformSyncMode; - #[derive(Debug, Clone)] pub enum CoreTask { #[allow(dead_code)] // May be used for getting single chain lock GetBestChainLock, GetBestChainLocks, - /// Refresh wallet info from Core. The optional PlatformSyncMode controls whether - /// and how to sync Platform address balances: - /// - None: Skip Platform sync entirely (Core only) - /// - Some(mode): Sync Platform with the specified mode - RefreshWalletInfo(Arc>, Option), + /// Refresh wallet info from Core. The bool controls whether to also sync + /// Platform address balances (true = sync Platform, false = Core only). + RefreshWalletInfo(Arc>, bool), RefreshSingleKeyWalletInfo(Arc>), StartDashQT(Network, PathBuf, bool), CreateRegistrationAssetLock(Arc>, Credits, u32), // wallet, amount in credits, identity index @@ -209,7 +205,7 @@ impl AppContext { local_chainlock, ))) } - CoreTask::RefreshWalletInfo(wallet, platform_sync_mode) => { + CoreTask::RefreshWalletInfo(wallet, sync_platform) => { // Get wallet seed hash for Platform balance refresh let seed_hash = { let wallet_guard = wallet.read().map_err(|e| e.to_string())?; @@ -229,12 +225,9 @@ impl AppContext { .map_err(|e| format!("Error refreshing wallet: {}", e))?; } - // Also refresh Platform address balances if a sync mode is specified - let warning = if let Some(sync_mode) = platform_sync_mode { - match self - .fetch_platform_address_balances(seed_hash, sync_mode) - .await - { + // Also refresh Platform address balances if requested + let warning = if sync_platform { + match self.fetch_platform_address_balances(seed_hash).await { Ok(_) => None, Err(e) => { tracing::warn!("Failed to fetch Platform address balances: {}", e); diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index e572fe1f9..873a6b398 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -50,7 +50,7 @@ impl AppContext { // Scope the read guard so it's dropped before the async DAPI call below let private_key = { - let wallet = wallet.read().unwrap(); + let wallet = wallet.read().map_err(|e| e.to_string())?; wallet_id = wallet.seed_hash(); wallet .private_key_for_address(&address, self.network)? @@ -91,7 +91,8 @@ impl AppContext { } RegisterIdentityFundingMethod::FundWithWallet(amount, identity_index) => { // Scope the write lock to avoid holding it across an await. - let (asset_lock_transaction, asset_lock_proof_private_key, _, _used_utxos) = { + // UTXOs are selected but NOT removed yet — removal happens after broadcast. + let (asset_lock_transaction, asset_lock_proof_private_key, _, used_utxos) = { let mut wallet = wallet.write().map_err(|e| e.to_string())?; wallet_id = wallet.seed_hash(); match wallet.registration_asset_lock_transaction( @@ -119,28 +120,15 @@ impl AppContext { } }; - let tx_id = asset_lock_transaction.txid(); - - { - let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); - proofs.insert(tx_id, None); - } - - self.broadcast_raw_transaction(&asset_lock_transaction) - .await?; - - // Store the asset lock transaction in the database immediately after sending. - // This ensures it's tracked even if the proof times out or identity creation fails. - // SPV will update the instant_lock_data when it detects the transaction. - self.db - .store_asset_lock_transaction( + let tx_id = self + .broadcast_and_commit_asset_lock( &asset_lock_transaction, amount, - None, // No islock yet - SPV will update this &wallet_id, - self.network, + &wallet, + &used_utxos, ) - .map_err(|e| format!("Failed to store asset lock transaction: {}", e))?; + .await?; let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; @@ -198,7 +186,7 @@ impl AppContext { ) => { // Scope the write lock to avoid holding it across an await. let (asset_lock_transaction, asset_lock_proof_private_key) = { - let mut wallet = wallet.write().unwrap(); + let mut wallet = wallet.write().map_err(|e| e.to_string())?; wallet_id = wallet.seed_hash(); wallet.registration_asset_lock_transaction_for_utxo( self, @@ -210,42 +198,17 @@ impl AppContext { )? }; - let tx_id = asset_lock_transaction.txid(); + let used_utxos = BTreeMap::from([(utxo, (tx_out.clone(), input_address.clone()))]); - { - let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); - proofs.insert(tx_id, None); - } - - self.broadcast_raw_transaction(&asset_lock_transaction) - .await?; - - // Store the asset lock transaction in the database immediately after sending. - // This ensures it's tracked even if the proof times out or identity creation fails. - // SPV will update the instant_lock_data when it detects the transaction. - self.db - .store_asset_lock_transaction( + let tx_id = self + .broadcast_and_commit_asset_lock( &asset_lock_transaction, tx_out.value, - None, // No islock yet - SPV will update this &wallet_id, - self.network, + &wallet, + &used_utxos, ) - .map_err(|e| format!("Failed to store asset lock transaction: {}", e))?; - - // TODO: UTXO removal timing issue - see comment above for FundWithWallet case. - { - let mut wallet = wallet.write().unwrap(); - wallet.utxos.retain(|_, utxo_map| { - utxo_map.retain(|outpoint, _| outpoint != &utxo); - !utxo_map.is_empty() - }); - self.db - .drop_utxo(&utxo, &self.network.to_string()) - .map_err(|e| e.to_string())?; - - wallet.recalculate_address_balance(&input_address, self)?; - } + .await?; let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; @@ -290,7 +253,7 @@ impl AppContext { .map_err(|e| format!("Failed to create identity: {}", e))?, }; - let wallet_seed_hash = { wallet.read().unwrap().seed_hash() }; + let wallet_seed_hash = { wallet.read().map_err(|e| e.to_string())?.seed_hash() }; let mut qualified_identity = QualifiedIdentity { identity: identity.clone(), associated_voter_identity: None, @@ -301,7 +264,7 @@ impl AppContext { private_keys: keys.to_key_storage(wallet_seed_hash), dpns_names: vec![], associated_wallets: BTreeMap::from([( - wallet.read().unwrap().seed_hash(), + wallet.read().map_err(|e| e.to_string())?.seed_hash(), wallet.clone(), )]), wallet_index: Some(wallet_identity_index), @@ -325,7 +288,7 @@ impl AppContext { .map_err(|e| e.to_string())?; { - let mut wallet = wallet.write().unwrap(); + let mut wallet = wallet.write().map_err(|e| e.to_string())?; wallet .unused_asset_locks .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); @@ -474,7 +437,7 @@ impl AppContext { ) .map_err(|e| e.to_string())?; { - let mut wallet = wallet.write().unwrap(); + let mut wallet = wallet.write().map_err(|e| e.to_string())?; wallet .unused_asset_locks .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); @@ -634,7 +597,7 @@ impl AppContext { ) .map_err(|e| format!("Failed to create identity: {}", e))?; - let wallet_seed_hash_actual = { wallet.read().unwrap().seed_hash() }; + let wallet_seed_hash_actual = { wallet.read().map_err(|e| e.to_string())?.seed_hash() }; let mut qualified_identity = QualifiedIdentity { identity: identity.clone(), associated_voter_identity: None, @@ -678,7 +641,7 @@ impl AppContext { .map_err(|e| e.to_string())?; { - let mut wallet_guard = wallet.write().unwrap(); + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; wallet_guard .identities .insert(wallet_identity_index, qualified_identity.identity.clone()); diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 277ffee33..fee1e4411 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -15,6 +15,7 @@ use dash_sdk::dpp::state_transition::identity_topup_transition::IdentityTopUpTra use dash_sdk::dpp::state_transition::identity_topup_transition::methods::IdentityTopUpTransitionMethodsV0; use dash_sdk::platform::Fetch; use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; +use std::collections::BTreeMap; impl AppContext { pub(super) async fn top_up_identity( @@ -44,7 +45,7 @@ impl AppContext { // Scope the read guard so it's dropped before the async DAPI call below let private_key = { - let wallet = wallet.read().unwrap(); + let wallet = wallet.read().map_err(|e| e.to_string())?; wallet .private_key_for_address(&address, self.network)? .ok_or("Asset Lock not valid for wallet")? @@ -92,11 +93,12 @@ impl AppContext { top_up_index, ) => { // Scope the write lock to avoid holding it across an await. + // UTXOs are selected but NOT removed yet — removal happens after broadcast. let ( asset_lock_transaction, asset_lock_proof_private_key, _, - _used_utxos, + used_utxos, wallet_seed_hash, ) = { let mut wallet = wallet.write().map_err(|e| e.to_string())?; @@ -135,28 +137,15 @@ impl AppContext { ) }; - let tx_id = asset_lock_transaction.txid(); - - { - let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); - proofs.insert(tx_id, None); - } - - self.broadcast_raw_transaction(&asset_lock_transaction) - .await?; - - // Store the asset lock transaction in the database immediately after sending. - // This ensures it's tracked even if the proof times out or top-up fails. - // SPV will update the instant_lock_data when it detects the transaction. - self.db - .store_asset_lock_transaction( + let tx_id = self + .broadcast_and_commit_asset_lock( &asset_lock_transaction, amount, - None, // No islock yet - SPV will update this &wallet_seed_hash, - self.network, + &wallet, + &used_utxos, ) - .map_err(|e| format!("Failed to store asset lock transaction: {}", e))?; + .await?; let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; @@ -176,7 +165,7 @@ impl AppContext { ) => { // Scope the write lock to avoid holding it across an await. let (asset_lock_transaction, asset_lock_proof_private_key, wallet_seed_hash) = { - let mut wallet = wallet.write().unwrap(); + let mut wallet = wallet.write().map_err(|e| e.to_string())?; let seed_hash = wallet.seed_hash(); let tx_result = wallet.top_up_asset_lock_transaction_for_utxo( self, @@ -190,47 +179,18 @@ impl AppContext { (tx_result.0, tx_result.1, seed_hash) }; - let tx_id = asset_lock_transaction.txid(); - // todo: maybe one day we will want to use platform again, but for right now we use - // the local core as it is more stable - // let asset_lock_proof = self - // .broadcast_and_retrieve_asset_lock(&asset_lock_transaction, &change_address) - // .await - // .map_err(|e| e.to_string())?; + let used_utxos = + BTreeMap::from([(utxo, (tx_out.clone(), input_address.clone()))]); - { - let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); - proofs.insert(tx_id, None); - } - - self.broadcast_raw_transaction(&asset_lock_transaction) - .await?; - - // Store the asset lock transaction in the database immediately after sending. - // This ensures it's tracked even if the proof times out or top-up fails. - // SPV will update the instant_lock_data when it detects the transaction. - self.db - .store_asset_lock_transaction( + let tx_id = self + .broadcast_and_commit_asset_lock( &asset_lock_transaction, tx_out.value, - None, // No islock yet - SPV will update this &wallet_seed_hash, - self.network, + &wallet, + &used_utxos, ) - .map_err(|e| format!("Failed to store asset lock transaction: {}", e))?; - - { - let mut wallet = wallet.write().unwrap(); - wallet.utxos.retain(|_, utxo_map| { - utxo_map.retain(|outpoint, _| outpoint != &utxo); - !utxo_map.is_empty() - }); - self.db - .drop_utxo(&utxo, &self.network.to_string()) - .map_err(|e| e.to_string())?; - - wallet.recalculate_address_balance(&input_address, self)?; - } + .await?; let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; @@ -463,7 +423,7 @@ impl AppContext { .map_err(|e| e.to_string())?; { - let mut wallet = wallet.write().unwrap(); + let mut wallet = wallet.write().map_err(|e| e.to_string())?; wallet .unused_asset_locks .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 5786b1dca..695a5f45d 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -363,12 +363,8 @@ impl AppContext { WalletTask::GenerateReceiveAddress { seed_hash } => { self.generate_receive_address(seed_hash).await } - WalletTask::FetchPlatformAddressBalances { - seed_hash, - sync_mode, - } => { - self.fetch_platform_address_balances(seed_hash, sync_mode) - .await + WalletTask::FetchPlatformAddressBalances { seed_hash } => { + self.fetch_platform_address_balances(seed_hash).await } WalletTask::TransferPlatformCredits { seed_hash, diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index 737129fd3..219752505 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -1,28 +1,22 @@ use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::wallet::PlatformSyncMode; use crate::context::AppContext; use crate::model::wallet::{ - DerivationPathHelpers, DerivationPathReference, DerivationPathType, Wallet, - WalletAddressProvider, WalletSeedHash, + DerivationPathHelpers, DerivationPathReference, DerivationPathType, WalletAddressProvider, + WalletSeedHash, }; use dash_sdk::RequestSettings; -use dash_sdk::Sdk; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::platform::address_sync::AddressSyncConfig; use dash_sdk::platform::address_sync::AddressSyncResult; -use std::sync::{Arc, RwLock}; +use std::sync::Arc; impl AppContext { pub(crate) async fn fetch_platform_address_balances( self: &Arc, seed_hash: WalletSeedHash, - sync_mode: PlatformSyncMode, ) -> Result { - // 6 days and 20 hours in seconds (to be safe before 7 days) - const FULL_SYNC_INTERVAL_SECS: u64 = 6 * 24 * 60 * 60 + 20 * 60 * 60; // 590400 seconds - - tracing::info!("Platform address sync start (mode: {:?})", sync_mode); + tracing::info!("Platform address sync start"); let start_time = std::time::Instant::now(); let wallet_arc = { @@ -33,41 +27,15 @@ impl AppContext { .ok_or_else(|| "Wallet not found".to_string())? }; - // Check last full sync time and terminal block from database - let (last_full_sync, stored_checkpoint, last_terminal_block) = self - .db - .get_platform_sync_info(&seed_hash) - .unwrap_or((0, 0, 0)); - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - - // Determine if we need a full sync based on mode - let needs_full_sync = match sync_mode { - PlatformSyncMode::ForceFull => true, - PlatformSyncMode::TerminalOnly => { - if stored_checkpoint == 0 { - return Err( - "Terminal-only sync requested but no checkpoint exists. Run a full sync first." - .to_string(), - ); - } - false - } - PlatformSyncMode::Auto => { - last_full_sync == 0 - || stored_checkpoint == 0 - || now.saturating_sub(last_full_sync) >= FULL_SYNC_INTERVAL_SECS - } - }; + // Get last sync timestamp from database + let (last_sync_timestamp, last_sync_height) = + self.db.get_platform_sync_info(&seed_hash).unwrap_or((0, 0)); // Create provider (requires wallet to be open for address derivation) let mut provider = { let wallet = wallet_arc.read().map_err(|e| e.to_string())?; match WalletAddressProvider::new(&wallet, self.network) { - Ok(provider) => provider, + Ok(provider) => provider.with_stored_state(&wallet, self.network, last_sync_height), Err(_) if !wallet.is_open() => { return Err("Wallet is locked. Please unlock it first to refresh.".to_string()); } @@ -75,189 +43,80 @@ impl AppContext { } }; - // Sync using SDK's privacy-preserving method + // Sync using SDK's privacy-preserving method (handles both full and incremental) let sdk = self.sdk.load().as_ref().clone(); - let (_checkpoint_height, highest_block_processed) = if needs_full_sync { - tracing::info!( - "Performing full platform address sync (last sync: {} seconds ago)", - now.saturating_sub(last_full_sync) - ); - - // trunk state query is failing if tree is empty with internal error - // this happens when we don't have any balances yet - // this case most often happens for local network - // so we do not ban addresses in case of failure - // and return empty `AddressSyncResult` - let config = if sdk.network == Network::Regtest { - Some(AddressSyncConfig { - request_settings: RequestSettings { - ban_failed_address: Some(false), - ..Default::default() - }, + let config = if sdk.network == Network::Regtest { + Some(AddressSyncConfig { + request_settings: RequestSettings { + ban_failed_address: Some(false), ..Default::default() - }) - } else { - None - }; - - // Perform the base sync - let base_start = std::time::Instant::now(); - let result = match sdk - .sync_address_balances(&mut provider, config.clone()) - .await - { - Ok(res) => res, - Err(e) if e.to_string().contains("empty tree") => { - tracing::debug!( - "Platform address balance tree is empty. Returning empty sync result." - ); - AddressSyncResult::default() - } - Err(e) => return Err(format!("Failed to sync Platform addresses: {}", e)), - }; - let base_duration = base_start.elapsed(); - - tracing::info!( - "Base sync complete: duration={:?}, found={}, absent={}, checkpoint={}", - base_duration, - result.found.len(), - result.absent.len(), - result.checkpoint_height - ); - - // Apply terminal updates and capture the highest block processed - let terminal_start_height = result.checkpoint_height.max(last_terminal_block); - let terminal_sync_start = std::time::Instant::now(); - let highest_block_processed = self - .apply_recent_balance_changes( - &sdk, - &wallet_arc, - &mut provider, - terminal_start_height, - ) - .await?; - let terminal_sync_duration = terminal_sync_start.elapsed(); - tracing::info!( - "Terminal balance updates complete: duration={:?}, start_height={}, end_height={}", - terminal_sync_duration, - terminal_start_height, - highest_block_processed - ); + }, + ..Default::default() + }) + } else { + None + }; - tracing::info!( - "Full sync complete: duration={:?}, found={}, absent={}, highest_index={:?}, checkpoint_height={}", - start_time.elapsed(), - result.found.len(), - result.absent.len(), - result.highest_found_index, - result.checkpoint_height - ); + let last_ts = if last_sync_timestamp > 0 { + Some(last_sync_timestamp) + } else { + None + }; - // Log the found balances from provider - for (addr, funds) in provider.found_balances() { - use dash_sdk::dpp::address_funds::PlatformAddress; - let platform_addr_str = PlatformAddress::try_from(addr.clone()) - .map(|p| p.to_bech32m_string(self.network)) - .unwrap_or_else(|_| addr.to_string()); - tracing::info!( - "Sync found address: {} with balance: {}, nonce: {}", - platform_addr_str, - funds.balance, - funds.nonce + let result = match sdk + .sync_address_balances(&mut provider, config, last_ts) + .await + { + Ok(res) => res, + Err(e) if e.to_string().contains("empty tree") => { + tracing::debug!( + "Platform address balance tree is empty. Returning empty sync result." ); + AddressSyncResult::default() } + Err(e) => return Err(format!("Failed to sync Platform addresses: {}", e)), + }; - // Save the new full sync timestamp and checkpoint - if let Err(e) = - self.db - .set_platform_sync_info(&seed_hash, now, result.checkpoint_height) - { - tracing::warn!("Failed to save platform sync info: {}", e); - } - - (result.checkpoint_height, highest_block_processed) - } else { - let terminal_only_start = std::time::Instant::now(); - tracing::info!( - "Performing terminal-only platform address sync (last full sync: {} seconds ago, checkpoint={}, last_terminal_block={})", - now.saturating_sub(last_full_sync), - stored_checkpoint, - last_terminal_block - ); - - // Pre-populate provider with LAST SYNCED balances (not current balances) - // This prevents double-counting when proof-verified updates happened after last sync - let mut pre_populated_count = 0; - { - let wallet = wallet_arc.read().map_err(|e| e.to_string())?; - for (core_addr, platform_addr) in wallet.platform_addresses(self.network) { - if let Some(info) = wallet.get_platform_address_info(&core_addr) { - // Only pre-populate if we have a last_full_sync_balance - // (meaning this address was found in a previous full sync) - // This prevents double-counting AddToCredits after app restart - if let Some(full_sync_balance) = info.last_full_sync_balance { - let lookup_addr = platform_addr.to_address_with_network(self.network); - provider.update_balance(&lookup_addr, full_sync_balance); - pre_populated_count += 1; - tracing::debug!( - "Pre-populated balance for {}: {} (from last full sync)", - platform_addr.to_bech32m_string(self.network), - full_sync_balance - ); - } else { - tracing::debug!( - "Skipping pre-population for {} (no last_full_sync_balance, needs full sync)", - platform_addr.to_bech32m_string(self.network) - ); - } - } - } - } - tracing::info!( - "Terminal-only sync setup complete: duration={:?}, pre_populated={} addresses", - terminal_only_start.elapsed(), - pre_populated_count - ); + tracing::info!( + "Sync complete: duration={:?}, found={}, absent={}, highest_index={:?}, checkpoint={}, new_sync_height={}, new_sync_timestamp={}", + start_time.elapsed(), + result.found.len(), + result.absent.len(), + result.highest_found_index, + result.checkpoint_height, + result.new_sync_height, + result.new_sync_timestamp, + ); - // For terminal-only sync, fetch recent balance changes - // Use the higher of checkpoint_height or last_terminal_block to avoid - // re-applying changes we've already processed. - let terminal_start_height = stored_checkpoint.max(last_terminal_block); - let terminal_sync_start = std::time::Instant::now(); - let highest_block_processed = self - .apply_recent_balance_changes( - &sdk, - &wallet_arc, - &mut provider, - terminal_start_height, - ) - .await?; - let terminal_sync_duration = terminal_sync_start.elapsed(); + // Log the found balances from provider + for (addr, funds) in provider.found_balances() { + use dash_sdk::dpp::address_funds::PlatformAddress; + let platform_addr_str = PlatformAddress::try_from(addr.clone()) + .map(|p| p.to_bech32m_string(self.network)) + .unwrap_or_else(|_| addr.to_string()); tracing::info!( - "Terminal balance updates complete: duration={:?}, start_height={}, end_height={}", - terminal_sync_duration, - terminal_start_height, - highest_block_processed + "Sync found address: {} with balance: {}, nonce: {}", + platform_addr_str, + funds.balance, + funds.nonce ); + } - (stored_checkpoint, highest_block_processed) - }; - - // Save the highest block we've processed to avoid re-applying the same changes - if highest_block_processed > last_terminal_block - && let Err(e) = self - .db - .set_last_terminal_block(&seed_hash, highest_block_processed) - { - tracing::warn!("Failed to save last terminal block: {}", e); + // Persist sync state + if let Err(e) = self.db.set_platform_sync_info( + &seed_hash, + result.new_sync_timestamp, + result.new_sync_height, + ) { + tracing::warn!("Failed to save platform sync info: {}", e); } // Apply results to wallet and persist let balances = { let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; - // Update wallet with synced balances (also updates last_full_sync_balance for next sync) + // Update wallet with synced balances provider.apply_results_to_wallet(&mut wallet); // Persist addresses and balances to database @@ -282,21 +141,18 @@ impl AppContext { } // Persist balance to platform_address_balances table - // Use the nonce from AddressFunds which comes directly from SDK sync - // This is a sync operation, so update last_full_sync_balance if let Err(e) = self.db.set_platform_address_info( &seed_hash, address, funds.balance, funds.nonce, &self.network, - true, // Sync operation - update last_full_sync_balance ) { tracing::warn!("Failed to persist Platform address info: {}", e); } } - // Return balances for result (use nonce from AddressFunds) + // Return balances for result provider .found_balances() .iter() @@ -305,11 +161,9 @@ impl AppContext { }; let addresses_with_balance = provider.found_balances().len(); - let total_duration = start_time.elapsed(); tracing::info!( - "Platform address sync complete: total_duration={:?}, mode={:?}, addresses_with_balance={}", - total_duration, - sync_mode, + "Platform address sync complete: total_duration={:?}, addresses_with_balance={}", + start_time.elapsed(), addresses_with_balance ); @@ -318,241 +172,4 @@ impl AppContext { balances, }) } - - /// Apply recent balance changes (terminal updates) to catch changes after a starting block. - /// - /// The trunk/branch sync provides balances as of a checkpoint (every ~10 minutes). - /// This function fetches balance changes since the starting block to provide - /// more up-to-date balances. - /// - /// Two queries are performed in sequence: - /// 1. RecentCompactedAddressBalanceChanges - merged changes for ranges of blocks - /// 2. RecentAddressBalanceChanges - individual per-block changes for most recent blocks - /// - /// Returns the highest block height processed, or an error if network requests failed. - async fn apply_recent_balance_changes( - &self, - sdk: &Sdk, - wallet_arc: &Arc>, - provider: &mut WalletAddressProvider, - start_height: u64, - ) -> Result { - use dash_sdk::dpp::address_funds::PlatformAddress; - use dash_sdk::dpp::balances::credits::{BlockAwareCreditOperation, CreditOperation}; - use dash_sdk::platform::{ - Fetch, RecentAddressBalanceChangesQuery, RecentCompactedAddressBalanceChangesQuery, - }; - use dash_sdk::query_types::{ - RecentAddressBalanceChanges, RecentCompactedAddressBalanceChanges, - }; - - // The trunk/branch sync provides balances as of the checkpoint height. - // We query for compacted changes starting from that start height, - // then query recent non-compacted changes starting from where compacted ends. - - // Query from start_height + 1 because start_height was already processed - // in the previous sync (last_terminal_block is the highest block we've seen) - let query_from_height = start_height.saturating_add(1); - - tracing::debug!( - "Fetching terminal balance updates from height {} (start_height={})", - query_from_height, - start_height - ); - - // Get the wallet's platform addresses to filter relevant changes - let wallet_platform_addresses: std::collections::HashSet = { - let wallet = match wallet_arc.read() { - Ok(w) => w, - Err(e) => return Err(format!("Failed to read wallet: {}", e)), - }; - wallet - .platform_addresses(self.network) - .into_iter() - .map(|(_, platform_addr)| platform_addr) - .collect() - }; - - let mut updates_applied = 0; - let mut highest_block_seen = start_height; - - // Step 1: Fetch compacted balance changes (merged changes for ranges of blocks) - // Start from query_from_height (start_height + 1) to get changes since the last sync - let compacted_fetch_start = std::time::Instant::now(); - let compacted_query = RecentCompactedAddressBalanceChangesQuery::new(query_from_height); - let compacted_result = tokio::time::timeout( - std::time::Duration::from_secs(30), - RecentCompactedAddressBalanceChanges::fetch(sdk, compacted_query), - ) - .await; - let compacted_duration = compacted_fetch_start.elapsed(); - tracing::info!( - "Compacted balance changes fetch: duration={:?}, from_height={}", - compacted_duration, - query_from_height - ); - let compacted_result = match compacted_result { - Ok(result) => result, - Err(_) => { - return Err("Compacted balance changes fetch timed out after 30s".to_string()); - } - }; - let compacted_changes = match compacted_result { - Ok(Some(changes)) => Some(changes), - Ok(None) => None, - Err(e) => { - return Err(format!("Failed to fetch compacted balance changes: {}", e)); - } - }; - if let Some(compacted_changes) = compacted_changes { - for block_changes in compacted_changes.into_inner() { - // Track the highest block height we've processed - if block_changes.end_block_height > highest_block_seen { - highest_block_seen = block_changes.end_block_height; - } - - for (platform_addr, credit_op) in block_changes.changes { - if wallet_platform_addresses.contains(&platform_addr) { - let core_addr = platform_addr.to_address_with_network(self.network); - let current_balance = provider - .found_balances() - .get(&core_addr) - .map(|funds| funds.balance) - .unwrap_or(0); - - let new_balance = match credit_op { - BlockAwareCreditOperation::SetCredits(credits) => { - tracing::debug!( - "Compacted SetCredits: {} = {}", - platform_addr.to_bech32m_string(self.network), - credits - ); - credits - } - BlockAwareCreditOperation::AddToCreditsOperations(operations) => { - // Only apply credits from blocks at or after our query height - // (since we query from start_height + 1, all results should be valid) - let total_to_add: u64 = operations - .iter() - .filter(|(height, _)| **height >= query_from_height) - .map(|(_, credits)| *credits) - .sum(); - tracing::debug!( - "Compacted AddToCredits: {} current={} + add={} = {}", - platform_addr.to_bech32m_string(self.network), - current_balance, - total_to_add, - current_balance.saturating_add(total_to_add) - ); - current_balance.saturating_add(total_to_add) - } - }; - - if new_balance != current_balance { - provider.update_balance(&core_addr, new_balance); - let addr_str = platform_addr.to_bech32m_string(self.network); - tracing::info!( - "Compacted update: {} balance {} -> {}", - addr_str, - current_balance, - new_balance - ); - updates_applied += 1; - } - } - } - } - } - - // Step 2: Fetch non-compacted balance changes (individual per-block changes) - // Use the highest block height from compacted changes + 1 as the start - let recent_fetch_start = std::time::Instant::now(); - let recent_query = RecentAddressBalanceChangesQuery::new(highest_block_seen + 1); - let recent_result = tokio::time::timeout( - std::time::Duration::from_secs(30), - RecentAddressBalanceChanges::fetch(sdk, recent_query), - ) - .await; - let recent_duration = recent_fetch_start.elapsed(); - tracing::info!( - "Recent balance changes fetch: duration={:?}, from_height={}", - recent_duration, - highest_block_seen + 1 - ); - let recent_result = match recent_result { - Ok(result) => result, - Err(_) => { - return Err("Recent balance changes fetch timed out after 30s".to_string()); - } - }; - let recent_changes = match recent_result { - Ok(Some(changes)) => Some(changes), - Ok(None) => None, - Err(e) => { - return Err(format!("Failed to fetch recent balance changes: {}", e)); - } - }; - if let Some(recent_changes) = recent_changes { - for block_changes in recent_changes.into_inner() { - // Track the block height from non-compacted changes - if block_changes.block_height > highest_block_seen { - highest_block_seen = block_changes.block_height; - } - - for (platform_addr, credit_op) in block_changes.changes { - if wallet_platform_addresses.contains(&platform_addr) { - let core_addr = platform_addr.to_address_with_network(self.network); - let current_balance = provider - .found_balances() - .get(&core_addr) - .map(|funds| funds.balance) - .unwrap_or(0); - - let new_balance = match credit_op { - CreditOperation::SetCredits(credits) => { - tracing::debug!( - "Recent SetCredits: {} = {}", - platform_addr.to_bech32m_string(self.network), - credits - ); - credits - } - CreditOperation::AddToCredits(credits) => { - tracing::debug!( - "Recent AddToCredits: {} current={} + add={} = {}", - platform_addr.to_bech32m_string(self.network), - current_balance, - credits, - current_balance.saturating_add(credits) - ); - current_balance.saturating_add(credits) - } - }; - - if new_balance != current_balance { - provider.update_balance(&core_addr, new_balance); - let addr_str = platform_addr.to_bech32m_string(self.network); - tracing::info!( - "Recent update: {} balance {} -> {}", - addr_str, - current_balance, - new_balance - ); - updates_applied += 1; - } - } - } - } - } - - if updates_applied > 0 { - tracing::info!( - "Applied {} terminal balance updates from recent blocks (up to block {})", - updates_applied, - highest_block_seen - ); - } - - Ok(highest_block_seen) - } } diff --git a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs index f189b7fc2..2b9ca1891 100644 --- a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs +++ b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs @@ -1,5 +1,4 @@ use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::wallet::PlatformSyncMode; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; @@ -137,8 +136,7 @@ impl AppContext { } // Trigger a balance refresh - self.fetch_platform_address_balances(seed_hash, PlatformSyncMode::Auto) - .await?; + self.fetch_platform_address_balances(seed_hash).await?; Ok(BackendTaskSuccessResult::PlatformAddressFunded { seed_hash }) } diff --git a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs index 44396380d..229e87083 100644 --- a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs +++ b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs @@ -1,10 +1,8 @@ use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::wallet::PlatformSyncMode; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; -use dash_sdk::dpp::dashcore::hashes::Hash; use std::sync::Arc; impl AppContext { @@ -39,8 +37,8 @@ impl AppContext { (asset_lock_amount, false) }; - // Step 1: Create the asset lock transaction - let (asset_lock_transaction, asset_lock_private_key, _asset_lock_address, _used_utxos) = { + // Step 1: Create the asset lock transaction (UTXOs are selected but NOT yet removed) + let (asset_lock_transaction, asset_lock_private_key, _asset_lock_address, used_utxos) = { let wallet_arc = { let wallets = self.wallets.read().unwrap(); wallets @@ -77,45 +75,24 @@ impl AppContext { } }; - let tx_id = asset_lock_transaction.txid(); - - // Step 2: Register this transaction as waiting for finality - { - let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); - proofs.insert(tx_id, None); - } - - // Step 3: Store the asset lock transaction in the database *before* - // broadcast. The SPV finality listener retrieves the transaction from - // the DB to process InstantLock/ChainLock events — if the store happened - // after broadcast, a fast InstantSend could arrive before the DB row - // exists, causing the finality proof to be missed. - self.db - .store_asset_lock_transaction( + // Step 2–4: Store → broadcast → remove UTXOs (atomic pattern). + let wallet_arc = { + let wallets = self.wallets.read().map_err(|e| e.to_string())?; + wallets + .get(&seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + + let tx_id = self + .broadcast_and_commit_asset_lock( &asset_lock_transaction, asset_lock_amount, - None, // No islock yet — SPV/ZMQ will update this &seed_hash, - self.network, + &wallet_arc, + &used_utxos, ) - .map_err(|e| format!("Failed to store asset lock transaction: {}", e))?; - - // Step 4: Broadcast the transaction (mode-aware: RPC or SPV). - // On failure, clean up the finality tracking entry and pre-stored DB row. - // TODO: The broadcast may have reached the network before our connection - // dropped. Deleting the DB row could lose a valid tx. Consider adding a - // status column to asset_lock_transaction and marking it as "broadcast_failed" - // instead, so recovery logic can re-check and re-broadcast if needed. - if let Err(e) = self - .broadcast_raw_transaction(&asset_lock_transaction) - .await - { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.try_lock() { - proofs.remove(&tx_id); - } - let _ = self.db.delete_asset_lock_transaction(tx_id.as_byte_array()); - return Err(format!("Failed to broadcast asset lock transaction: {}", e)); - } + .await?; // Step 5: Wait for asset lock proof (InstantLock or ChainLock) via shared helper. // On timeout the helper cleans up the finality tracking entry. @@ -239,9 +216,8 @@ impl AppContext { .await .map_err(|e| format!("Failed to fund platform address: {}", e))?; - // Step 8: Refresh platform address balances - self.fetch_platform_address_balances(seed_hash, PlatformSyncMode::Auto) - .await?; + // Step 9: Refresh platform address balances + self.fetch_platform_address_balances(seed_hash).await?; Ok(BackendTaskSuccessResult::PlatformAddressFunded { seed_hash }) } diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs index 413dea742..83a227fd7 100644 --- a/src/backend_task/wallet/mod.rs +++ b/src/backend_task/wallet/mod.rs @@ -13,18 +13,6 @@ use dash_sdk::dpp::identity::core_script::CoreScript; use dash_sdk::dpp::prelude::AssetLockProof; use std::collections::BTreeMap; -/// Controls how Platform address balance sync is performed -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PlatformSyncMode { - /// Automatically decide based on time since last full sync - #[default] - Auto, - /// Force a full sync (queries all addresses) - ForceFull, - /// Only do terminal sync using stored checkpoint (fails if no checkpoint exists) - TerminalOnly, -} - #[derive(Debug, Clone, PartialEq)] pub enum WalletTask { GenerateReceiveAddress { @@ -33,7 +21,6 @@ pub enum WalletTask { /// Fetch Platform address balances and nonces from Platform for a wallet FetchPlatformAddressBalances { seed_hash: WalletSeedHash, - sync_mode: PlatformSyncMode, }, /// Transfer credits between Platform addresses TransferPlatformCredits { diff --git a/src/backend_task/wallet/withdraw_from_platform_address.rs b/src/backend_task/wallet/withdraw_from_platform_address.rs index f717943e4..ef49b2880 100644 --- a/src/backend_task/wallet/withdraw_from_platform_address.rs +++ b/src/backend_task/wallet/withdraw_from_platform_address.rs @@ -1,5 +1,4 @@ use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::wallet::PlatformSyncMode; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; @@ -57,8 +56,7 @@ impl AppContext { .map_err(|e| format!("Failed to withdraw from Platform address: {}", e))?; // Trigger a balance refresh - self.fetch_platform_address_balances(seed_hash, PlatformSyncMode::Auto) - .await?; + self.fetch_platform_address_balances(seed_hash).await?; Ok(BackendTaskSuccessResult::PlatformAddressWithdrawal { seed_hash }) } diff --git a/src/context/transaction_processing.rs b/src/context/transaction_processing.rs index f89cbfca6..59b055082 100644 --- a/src/context/transaction_processing.rs +++ b/src/context/transaction_processing.rs @@ -1,4 +1,5 @@ use super::AppContext; +use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::spv::CoreBackendMode; use dash_sdk::Sdk; use dash_sdk::dashcore_rpc::RpcApi; @@ -9,7 +10,8 @@ use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLoc use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dash_sdk::dpp::prelude::{AssetLockProof, CoreBlockHeight}; use rusqlite::Result; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, RwLock}; impl AppContext { /// Broadcast a raw transaction via Core RPC or SPV depending on backend mode. @@ -74,6 +76,82 @@ impl AppContext { } } + /// Broadcast an asset lock transaction with the store-before-broadcast + /// safety pattern, removing spent UTXOs only after a successful broadcast. + /// + /// Performs the following steps in order: + /// 1. Register the transaction for finality tracking. + /// 2. Store the asset lock in the DB **before** broadcast — prevents an SPV + /// InstantSend race where the finality proof arrives before the DB row. + /// 3. Broadcast the transaction. On failure: clean up the finality tracker + /// and pre-stored DB row; UTXOs are **not** removed. + /// 4. On success: remove spent UTXOs from the wallet and DB. + /// + /// # UTXO selection race window + /// + /// Callers select UTXOs while holding the wallet write lock, then drop it + /// before calling this method (which re-acquires the lock in step 4). During + /// the gap — covering steps 1–3 and the network broadcast — another task + /// could theoretically select the same UTXOs via `select_unspent_utxos_for`, + /// leading to a double-spend attempt on-chain (which Core would reject). + /// + /// We cannot hold the `std::sync::RwLock` write guard across the async + /// broadcast because the guard is `!Send` and tokio tasks require `Send` + /// futures. Fixing this properly requires either migrating to + /// `tokio::sync::RwLock` (large refactor) or adding a UTXO reservation + /// mechanism. In practice the risk is negligible: users would have to + /// trigger two fund operations on the same wallet near-simultaneously. + /// + /// Returns the [`Txid`] of the broadcast transaction. + pub(crate) async fn broadcast_and_commit_asset_lock( + &self, + asset_lock_transaction: &Transaction, + amount: u64, + wallet_seed_hash: &WalletSeedHash, + wallet: &Arc>, + used_utxos: &BTreeMap, + ) -> std::result::Result { + let tx_id = asset_lock_transaction.txid(); + + // Step 1: Register for finality tracking. + { + let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); + proofs.insert(tx_id, None); + } + + // Step 2: Store the asset lock transaction in the database *before* broadcast. + // The SPV finality listener retrieves the transaction from the DB to + // process InstantLock/ChainLock events — if the store happened after + // broadcast, a fast InstantSend could arrive before the DB row exists. + self.db + .store_asset_lock_transaction( + asset_lock_transaction, + amount, + None, // No islock yet — SPV will update this + wallet_seed_hash, + self.network, + ) + .map_err(|e| format!("Failed to store asset lock transaction: {}", e))?; + + // Step 3: Broadcast. On failure, clean up DB row and finality tracker. + // UTXOs are NOT removed on failure, preserving wallet balance. + if let Err(e) = self.broadcast_raw_transaction(asset_lock_transaction).await { + if let Ok(mut proofs) = self.transactions_waiting_for_finality.try_lock() { + proofs.remove(&tx_id); + } + let _ = self.db.delete_asset_lock_transaction(tx_id.as_byte_array()); + return Err(format!("Failed to broadcast asset lock transaction: {}", e)); + } + + // Step 4: Broadcast succeeded — commit UTXO removal now. + { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + wallet_guard.remove_selected_utxos(used_utxos, &self.db, self.network)?; + } + + Ok(tx_id) + } + pub(crate) fn received_transaction_finality( &self, tx: &Transaction, diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index fa5c15b2c..f75428f1a 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -244,15 +244,13 @@ impl AppContext { // Update in-memory wallet state wallet.set_platform_address_info(core_addr.clone(), info.balance, info.nonce); - // Update database (not a sync operation - preserve last_full_sync_balance - // so the next terminal sync can correctly apply any pending AddToCredits) + // Update database if let Err(e) = self.db.set_platform_address_info( &seed_hash, &core_addr, info.balance, info.nonce, &self.network, - false, // Not a sync operation ) { tracing::warn!("Failed to store Platform address info in database: {}", e); } diff --git a/src/database/wallet.rs b/src/database/wallet.rs index c03cb08c3..bf8f040eb 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -929,28 +929,21 @@ impl Database { ); // Load platform address info for each wallet (using existing connection to avoid deadlock) let mut platform_stmt = conn.prepare( - "SELECT seed_hash, address, balance, nonce, last_full_sync_balance FROM platform_address_balances WHERE network = ?", + "SELECT seed_hash, address, balance, nonce FROM platform_address_balances WHERE network = ?", )?; let platform_rows = platform_stmt.query_map([network_str.clone()], |row| { let seed_hash: Vec = row.get(0)?; let address_str: String = row.get(1)?; let balance: i64 = row.get(2)?; let nonce: i64 = row.get(3)?; - let last_full_sync_balance: Option = row.get(4)?; let seed_hash_array: [u8; 32] = seed_hash.try_into().map_err(|_| { rusqlite::Error::InvalidParameterName("Seed hash should be 32 bytes".to_string()) })?; - Ok(( - seed_hash_array, - address_str, - balance as u64, - nonce as u32, - last_full_sync_balance.map(|b| b as u64), - )) + Ok((seed_hash_array, address_str, balance as u64, nonce as u32)) })?; for row in platform_rows { - if let Ok((seed_hash, address_str, balance, nonce, last_full_sync_balance)) = row + if let Ok((seed_hash, address_str, balance, nonce)) = row && let Some(wallet) = wallets_map.get_mut(&seed_hash) && let Ok(address) = Address::::from_str(&address_str) { @@ -966,13 +959,7 @@ impl Database { wallet.platform_address_info.insert( canonical_address, - crate::model::wallet::PlatformAddressInfo { - balance, - nonce, - // Use the stored last_full_sync_balance from the database - // This is the balance from the last FULL sync checkpoint, not including terminal updates - last_full_sync_balance, - }, + crate::model::wallet::PlatformAddressInfo { balance, nonce }, ); } } @@ -982,11 +969,6 @@ impl Database { } /// Store or update Platform address balance and nonce. - /// - /// When `is_sync_operation` is true, also updates `last_full_sync_balance` to the current - /// balance. This should be true for sync operations (full or terminal) and false for - /// internal updates (e.g., after a transfer completes), so that subsequent terminal syncs - /// can correctly apply any pending AddToCredits. pub fn set_platform_address_info( &self, seed_hash: &[u8; 32], @@ -994,7 +976,6 @@ impl Database { balance: u64, nonce: u32, network: &Network, - is_sync_operation: bool, ) -> rusqlite::Result<()> { let network_str = network.to_string(); let canonical_address = Wallet::canonical_address(address, *network); @@ -1004,49 +985,23 @@ impl Database { .unwrap_or_default() .as_secs() as i64; - if is_sync_operation { - // Sync operation: update both balance and last_full_sync_balance - // last_full_sync_balance becomes the baseline for pre-population in the next sync - self.execute( - "INSERT INTO platform_address_balances - (seed_hash, address, balance, nonce, network, updated_at, last_full_sync_balance) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(seed_hash, address, network) DO UPDATE SET - balance = excluded.balance, - nonce = excluded.nonce, - updated_at = excluded.updated_at, - last_full_sync_balance = excluded.last_full_sync_balance", - params![ - seed_hash, - address_str, - balance as i64, - nonce as i64, - network_str, - updated_at, - balance as i64 - ], - )?; - } else { - // Internal update (e.g., after transfer): update balance but preserve last_full_sync_balance - // This ensures the next terminal sync correctly applies any pending AddToCredits - self.execute( - "INSERT INTO platform_address_balances - (seed_hash, address, balance, nonce, network, updated_at, last_full_sync_balance) - VALUES (?, ?, ?, ?, ?, ?, NULL) - ON CONFLICT(seed_hash, address, network) DO UPDATE SET - balance = excluded.balance, - nonce = excluded.nonce, - updated_at = excluded.updated_at", - params![ - seed_hash, - address_str, - balance as i64, - nonce as i64, - network_str, - updated_at - ], - )?; - } + self.execute( + "INSERT INTO platform_address_balances + (seed_hash, address, balance, nonce, network, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(seed_hash, address, network) DO UPDATE SET + balance = excluded.balance, + nonce = excluded.nonce, + updated_at = excluded.updated_at", + params![ + seed_hash, + address_str, + balance as i64, + nonce as i64, + network_str, + updated_at + ], + )?; Ok(()) } @@ -1167,49 +1122,36 @@ impl Database { Ok(deleted) } - /// Get the last platform full sync timestamp, checkpoint height, and last terminal block for a wallet - /// Returns (last_sync_timestamp, checkpoint_height, last_terminal_block) or (0, 0, 0) if not set - pub fn get_platform_sync_info( - &self, - seed_hash: &[u8; 32], - ) -> rusqlite::Result<(u64, u64, u64)> { + /// Get the last platform sync timestamp and sync height for a wallet. + /// Returns (last_sync_timestamp, last_sync_height) or (0, 0) if not set. + pub fn get_platform_sync_info(&self, seed_hash: &[u8; 32]) -> rusqlite::Result<(u64, u64)> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT last_platform_full_sync, last_platform_sync_checkpoint, COALESCE(last_terminal_block, 0) FROM wallet WHERE seed_hash = ?", + "SELECT last_platform_full_sync, last_platform_sync_checkpoint FROM wallet WHERE seed_hash = ?", params![seed_hash], |row| { let last_sync: i64 = row.get(0)?; - let checkpoint: i64 = row.get(1)?; - let last_terminal: i64 = row.get(2)?; - Ok((last_sync as u64, checkpoint as u64, last_terminal as u64)) + let sync_height: i64 = row.get(1)?; + Ok((last_sync as u64, sync_height as u64)) }, ) } - /// Set the last platform full sync timestamp and checkpoint height for a wallet - /// Also resets last_terminal_block to 0 since a new full sync was performed + /// Set the platform sync timestamp and sync height for a wallet. + /// + /// Note: The `sync_height` value (SDK's `new_sync_height`) is stored in the + /// `last_platform_sync_checkpoint` SQL column. The column was not renamed to + /// avoid an extra DB migration, but it now represents a block height rather + /// than the old checkpoint concept. pub fn set_platform_sync_info( &self, seed_hash: &[u8; 32], last_sync_timestamp: u64, - checkpoint_height: u64, - ) -> rusqlite::Result<()> { - self.execute( - "UPDATE wallet SET last_platform_full_sync = ?, last_platform_sync_checkpoint = ?, last_terminal_block = 0 WHERE seed_hash = ?", - params![last_sync_timestamp as i64, checkpoint_height as i64, seed_hash], - )?; - Ok(()) - } - - /// Update the last terminal block height after processing terminal balance updates - pub fn set_last_terminal_block( - &self, - seed_hash: &[u8; 32], - last_terminal_block: u64, + sync_height: u64, ) -> rusqlite::Result<()> { self.execute( - "UPDATE wallet SET last_terminal_block = ? WHERE seed_hash = ?", - params![last_terminal_block as i64, seed_hash], + "UPDATE wallet SET last_platform_full_sync = ?, last_platform_sync_checkpoint = ? WHERE seed_hash = ?", + params![last_sync_timestamp as i64, sync_height as i64, seed_hash], )?; Ok(()) } @@ -1482,7 +1424,7 @@ mod tests { assert!(info.is_none()); // Set platform address info - db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network, true) + db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network) .expect("Failed to set platform address info"); // Retrieve it @@ -1495,7 +1437,7 @@ mod tests { assert_eq!(info.1, 5); // nonce // Update it - db.set_platform_address_info(&seed_hash, &address, 20_000_000, 10, &network, true) + db.set_platform_address_info(&seed_hash, &address, 20_000_000, 10, &network) .expect("Failed to update platform address info"); let info = db @@ -1593,7 +1535,7 @@ mod tests { // Add a single valid platform address using the helper function let address = create_test_address(network); - db.set_platform_address_info(&seed_hash, &address, 5_000_000, 3, &network, true) + db.set_platform_address_info(&seed_hash, &address, 5_000_000, 3, &network) .expect("Failed to set platform address info"); // Get all addresses @@ -1631,7 +1573,7 @@ mod tests { } // Set platform address info - db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network, true) + db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network) .expect("Failed to set platform address info"); // Verify it exists @@ -1674,12 +1616,11 @@ mod tests { } // Initial sync info should be zeros - let (last_sync, checkpoint, last_terminal) = db + let (last_sync, sync_height) = db .get_platform_sync_info(&seed_hash) .expect("Failed to get platform sync info"); assert_eq!(last_sync, 0); - assert_eq!(checkpoint, 0); - assert_eq!(last_terminal, 0); + assert_eq!(sync_height, 0); // Set sync info let timestamp = 1700000000u64; @@ -1687,21 +1628,11 @@ mod tests { db.set_platform_sync_info(&seed_hash, timestamp, height) .expect("Failed to set platform sync info"); - let (last_sync, checkpoint, last_terminal) = db + let (last_sync, sync_height) = db .get_platform_sync_info(&seed_hash) .expect("Failed to get platform sync info"); assert_eq!(last_sync, timestamp); - assert_eq!(checkpoint, height); - assert_eq!(last_terminal, 0); // Reset to 0 by set_platform_sync_info - - // Set last terminal block - db.set_last_terminal_block(&seed_hash, 100500) - .expect("Failed to set last terminal block"); - - let (_, _, last_terminal) = db - .get_platform_sync_info(&seed_hash) - .expect("Failed to get platform sync info"); - assert_eq!(last_terminal, 100500); + assert_eq!(sync_height, height); } #[test] diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs index d50ff8dc8..f9de609d2 100644 --- a/src/model/wallet/asset_lock_transaction.rs +++ b/src/model/wallet/asset_lock_transaction.rs @@ -437,9 +437,9 @@ impl Wallet { Ok::<(), String>(()) })?; - // Transaction is fully built and signed; commit the UTXO removals now. - self.remove_selected_utxos(&utxos, &app_context.db, network)?; - + // Transaction is fully built and signed. UTXOs are returned to the caller + // so they can be removed explicitly after successful broadcast. This avoids + // permanently losing UTXO tracking if broadcast fails. Ok((tx, private_key, change_address, utxos)) } diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index ed6892cb3..79ddaaadb 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -299,10 +299,6 @@ impl PartialEq for WalletArcRef { pub struct PlatformAddressInfo { pub balance: Credits, pub nonce: AddressNonce, - /// Balance recorded at the last sync checkpoint. Updated by `set_platform_address_info_from_sync` - /// during both full and terminal syncs; preserved by `set_platform_address_info` during internal - /// updates (e.g., after transfers) to avoid double-counting AddToCredits on subsequent syncs. - pub last_full_sync_balance: Option, } #[derive(Debug, Clone, PartialEq)] @@ -1955,97 +1951,42 @@ impl Wallet { None } - /// Update Platform address info (balance and nonce) + /// Update Platform address info (balance and nonce). /// - /// This method handles the case where the same platform address may be represented - /// by different Address objects. It normalizes by comparing PlatformAddress bytes - /// and removes any duplicate entries before inserting. + /// Handles canonical address deduplication: if the same platform address is + /// stored under a different `Address` key, the duplicate is removed first. pub fn set_platform_address_info( &mut self, address: Address, balance: Credits, nonce: AddressNonce, ) { - // Convert the incoming address to PlatformAddress for canonical comparison - let (keys_to_remove, last_full_sync_balance) = - if let Ok(platform_addr) = PlatformAddress::try_from(address.clone()) { - let canonical_bytes = platform_addr.to_bytes(); - - // First, find last_full_sync_balance from any canonical-equivalent entry - // (must be done BEFORE removing duplicates) - let last_full_sync_balance = - self.platform_address_info - .iter() - .find_map(|(existing_addr, info)| { - if let Ok(existing_platform) = - PlatformAddress::try_from(existing_addr.clone()) - && existing_platform.to_bytes() == canonical_bytes - { - return info.last_full_sync_balance; - } - None - }); - - // Find duplicate entries to remove (same platform address, different key) - let keys_to_remove: Vec
= self - .platform_address_info - .keys() - .filter(|existing_addr| { - if let Ok(existing_platform) = - PlatformAddress::try_from((*existing_addr).clone()) - { - existing_platform.to_bytes() == canonical_bytes - && *existing_addr != &address - } else { - false - } - }) - .cloned() - .collect(); - - (keys_to_remove, last_full_sync_balance) - } else { - // Fallback: try direct lookup if canonical conversion fails - let last_full_sync_balance = self - .platform_address_info - .get(&address) - .and_then(|info| info.last_full_sync_balance); - (vec![], last_full_sync_balance) - }; + // Remove duplicate entries for the same canonical platform address + if let Ok(platform_addr) = PlatformAddress::try_from(address.clone()) { + let canonical_bytes = platform_addr.to_bytes(); + let keys_to_remove: Vec
= self + .platform_address_info + .keys() + .filter(|existing_addr| { + if let Ok(existing_platform) = + PlatformAddress::try_from((*existing_addr).clone()) + { + existing_platform.to_bytes() == canonical_bytes + && *existing_addr != &address + } else { + false + } + }) + .cloned() + .collect(); - // Remove duplicate entries - for key in keys_to_remove { - self.platform_address_info.remove(&key); + for key in keys_to_remove { + self.platform_address_info.remove(&key); + } } - self.platform_address_info.insert( - address, - PlatformAddressInfo { - balance, - nonce, - last_full_sync_balance, - }, - ); - } - - /// Set platform address info from a sync operation. - /// Always updates `last_full_sync_balance` to the current balance, as this becomes - /// the baseline for pre-population in the next terminal sync. - pub fn set_platform_address_info_from_sync( - &mut self, - address: Address, - balance: Credits, - nonce: AddressNonce, - ) { - self.platform_address_info.insert( - address, - PlatformAddressInfo { - balance, - nonce, - // Always update to current balance - this is the baseline for next sync - last_full_sync_balance: Some(balance), - }, - ); + self.platform_address_info + .insert(address, PlatformAddressInfo { balance, nonce }); } /// Get the private key for a Platform address @@ -2182,7 +2123,7 @@ const DEFAULT_GAP_LIMIT: AddressIndex = 20; /// # Usage /// ```ignore /// let mut provider = WalletAddressProvider::new(&wallet, network)?; -/// let result = sdk.sync_address_balances(&mut provider, None).await?; +/// let result = sdk.sync_address_balances(&mut provider, None, None).await?; /// provider.apply_results_to_wallet(&mut wallet); /// ``` pub struct WalletAddressProvider { @@ -2204,6 +2145,10 @@ pub struct WalletAddressProvider { highest_found: Option, /// Results: address -> balance for addresses found with balance found_balances: BTreeMap, + /// Known balances from previous sync for incremental catch-up + stored_balances: Vec<(AddressIndex, AddressKey, AddressFunds)>, + /// Last sync height from previous sync for incremental catch-up + stored_sync_height: u64, } impl WalletAddressProvider { @@ -2239,6 +2184,8 @@ impl WalletAddressProvider { resolved: BTreeSet::new(), highest_found: None, found_balances: BTreeMap::new(), + stored_balances: Vec::new(), + stored_sync_height: 0, }; // Bootstrap initial addresses (0 to gap_limit - 1) @@ -2315,12 +2262,8 @@ impl WalletAddressProvider { for (address, funds) in &self.found_balances { let canonical_address = Wallet::canonical_address(address, self.network); - // Update wallet with synced balance (also updates last_full_sync_balance for next sync) - wallet.set_platform_address_info_from_sync( - canonical_address.clone(), - funds.balance, - funds.nonce, - ); + // Update wallet with synced balances + wallet.set_platform_address_info(canonical_address.clone(), funds.balance, funds.nonce); // Also register in known_addresses and watched_addresses if not already present if !wallet.known_addresses.contains_key(&canonical_address) @@ -2349,6 +2292,41 @@ impl WalletAddressProvider { } } + /// Populate stored balances and sync height from a wallet's known state. + /// + /// Call this after construction to enable incremental catch-up. + /// The SDK uses `current_balances()` as the baseline and `last_sync_height()` + /// as the starting block for applying delta operations. + pub fn with_stored_state( + mut self, + wallet: &Wallet, + network: Network, + last_sync_height: u64, + ) -> Self { + self.stored_sync_height = last_sync_height; + + // Populate stored_balances from wallet's known platform addresses + for (core_addr, info) in &wallet.platform_address_info { + // Find the matching pending address to get the index and key + for (index, (key, pending_addr)) in &self.pending { + let canonical = Wallet::canonical_address(pending_addr, network); + if &canonical == core_addr { + self.stored_balances.push(( + *index, + key.clone(), + AddressFunds { + balance: info.balance, + nonce: info.nonce, + }, + )); + break; + } + } + } + + self + } + /// Derive a Platform address at the given index. fn derive_address_at_index( &self, @@ -2469,6 +2447,14 @@ impl AddressProvider for WalletAddressProvider { fn highest_found_index(&self) -> Option { self.highest_found } + + fn current_balances(&self) -> Vec<(AddressIndex, AddressKey, AddressFunds)> { + self.stored_balances.clone() + } + + fn last_sync_height(&self) -> u64 { + self.stored_sync_height + } } #[cfg(test)] @@ -2845,7 +2831,6 @@ mod tests { PlatformAddressInfo { balance: 1_000_000, nonce: 0, - last_full_sync_balance: None, }, ); wallet.platform_address_info.insert( @@ -2853,7 +2838,6 @@ mod tests { PlatformAddressInfo { balance: 2_000_000, nonce: 1, - last_full_sync_balance: None, }, ); @@ -2861,33 +2845,17 @@ mod tests { } #[test] - fn test_set_platform_address_info_from_sync() { - let mut wallet = test_wallet(); - let addr = test_address(1); - - wallet.set_platform_address_info_from_sync(addr.clone(), 500_000, 3); - - let info = wallet.platform_address_info.get(&addr).unwrap(); - assert_eq!(info.balance, 500_000); - assert_eq!(info.nonce, 3); - assert_eq!(info.last_full_sync_balance, Some(500_000)); - } - - #[test] - fn test_set_platform_address_info_preserves_sync_balance() { + fn test_set_platform_address_info_update() { let mut wallet = test_wallet(); let addr = test_address(1); - // First set via sync (establishes last_full_sync_balance) - wallet.set_platform_address_info_from_sync(addr.clone(), 500_000, 3); + wallet.set_platform_address_info(addr.clone(), 500_000, 3); - // Then update via non-sync (should preserve last_full_sync_balance) wallet.set_platform_address_info(addr.clone(), 600_000, 4); let info = wallet.platform_address_info.get(&addr).unwrap(); assert_eq!(info.balance, 600_000); assert_eq!(info.nonce, 4); - assert_eq!(info.last_full_sync_balance, Some(500_000)); } #[test] @@ -2900,7 +2868,6 @@ mod tests { PlatformAddressInfo { balance: 100_000, nonce: 1, - last_full_sync_balance: None, }, ); diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 92e4d8fa8..d6443b1ba 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -41,30 +41,21 @@ use dialogs::{ /// Refresh mode for dev mode dropdown - controls what gets refreshed #[derive(Clone, Copy, PartialEq, Eq, Default)] enum RefreshMode { - /// Current behavior: Core wallet + Platform (auto decides full vs terminal) + /// Core wallet + Platform address sync #[default] All, /// Only refresh Core wallet balances CoreOnly, - /// Only Platform sync - force full sync - PlatformFull, - /// Only Platform sync - terminal only - PlatformTerminal, - /// Core wallet + Platform full sync - CoreAndPlatformFull, - /// Core wallet + Platform terminal sync - CoreAndPlatformTerminal, + /// Only Platform address sync + PlatformOnly, } impl RefreshMode { fn label(&self) -> &'static str { match self { - RefreshMode::All => "All (Auto)", + RefreshMode::All => "Core + Platform", RefreshMode::CoreOnly => "Core Only", - RefreshMode::PlatformFull => "Platform (Full)", - RefreshMode::PlatformTerminal => "Platform (Terminal)", - RefreshMode::CoreAndPlatformFull => "Core + Platform (Full)", - RefreshMode::CoreAndPlatformTerminal => "Core + Platform (Terminal)", + RefreshMode::PlatformOnly => "Platform Only", } } @@ -72,10 +63,7 @@ impl RefreshMode { &[ RefreshMode::All, RefreshMode::CoreOnly, - RefreshMode::PlatformFull, - RefreshMode::PlatformTerminal, - RefreshMode::CoreAndPlatformFull, - RefreshMode::CoreAndPlatformTerminal, + RefreshMode::PlatformOnly, ] } } @@ -180,7 +168,6 @@ impl WalletsBalancesScreen { .as_ref() .and_then(|w| w.read().ok().map(|g| g.seed_hash())) .and_then(|hash| app_context.db.get_platform_sync_info(&hash).ok()) - .map(|(ts, checkpoint, _terminal)| (ts, checkpoint)) .filter(|(ts, _)| *ts > 0); Self { @@ -245,7 +232,6 @@ impl WalletsBalancesScreen { .db .get_platform_sync_info(seed_hash) .ok() - .map(|(ts, checkpoint, _terminal)| (ts, checkpoint)) .filter(|(ts, _)| *ts > 0); } @@ -1486,8 +1472,6 @@ impl WalletsBalancesScreen { wallet_arc: &Arc>, mode: RefreshMode, ) -> AppAction { - use crate::backend_task::wallet::PlatformSyncMode; - let seed_hash = wallet_arc .read() .ok() @@ -1496,51 +1480,27 @@ impl WalletsBalancesScreen { match mode { RefreshMode::All => { - // Default behavior: Core + Platform (Auto) + // Core + Platform AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( wallet_arc.clone(), - Some(PlatformSyncMode::Auto), + true, ))) } RefreshMode::CoreOnly => { // Core only, no Platform sync AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( wallet_arc.clone(), - None, + false, ))) } - RefreshMode::PlatformFull => { - // Platform only with forced full sync - AppAction::BackendTask(BackendTask::WalletTask( - crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { - seed_hash, - sync_mode: PlatformSyncMode::ForceFull, - }, - )) - } - RefreshMode::PlatformTerminal => { - // Platform only with terminal sync + RefreshMode::PlatformOnly => { + // Platform only AppAction::BackendTask(BackendTask::WalletTask( crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { seed_hash, - sync_mode: PlatformSyncMode::TerminalOnly, }, )) } - RefreshMode::CoreAndPlatformFull => { - // Core + Platform with forced full sync - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( - wallet_arc.clone(), - Some(PlatformSyncMode::ForceFull), - ))) - } - RefreshMode::CoreAndPlatformTerminal => { - // Core + Platform with terminal sync - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( - wallet_arc.clone(), - Some(PlatformSyncMode::TerminalOnly), - ))) - } } } } @@ -1550,17 +1510,15 @@ impl ScreenLike for WalletsBalancesScreen { self.check_message_expiration(); // Check for pending platform balance refresh (triggered after transfers) - let pending_refresh_action = - if let Some(seed_hash) = self.pending_platform_balance_refresh.take() { - AppAction::BackendTask(BackendTask::WalletTask( - crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { - seed_hash, - sync_mode: crate::backend_task::wallet::PlatformSyncMode::Auto, - }, - )) - } else { - AppAction::None - }; + let pending_refresh_action = if let Some(seed_hash) = + self.pending_platform_balance_refresh.take() + { + AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { seed_hash }, + )) + } else { + AppAction::None + }; let mut right_buttons = vec![ (