From cac51fa3d7ac22657b372462f16b59c3e8028898 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 15 May 2026 12:51:00 +0200 Subject: [PATCH] fix(rs-platform-wallet): local-ledger ownership guard (V27-007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK returns post-transition state for every address touched by a transfer transition — both inputs and outputs. When the source wallet sends credits to a foreign address (e.g. bank's primary receive address), the response includes the bank's post-credit balance. Without an ownership check, `transfer` wrote that foreign balance into the source wallet's local ledger via `set_address_credit_balance`, corrupting `total_credits()`. Symptom chain (PA-004b / PA-009c teardown, QA-010): 1. Test wallet trims residual credits to bank's primary address. 2. `transfer` writes bank's balance into the source wallet's ledger. 3. `total_credits()` is inflated; dust-gate passes; sweep begins. 4. Sweep selects bank's address (now in the source ledger), tries to sign, fails ("No private key for address P2pkh(...)"). 5. Failure cascade in teardown. Fix: guard `set_address_credit_balance` with `contains_platform_address` in `transfer`, mirroring the identical guard already present in `fund_from_asset_lock`. The recipient wallet's syncer observes inbound credits on its own addresses; the source wallet must not. Also applied the same guard defensively to `withdrawal` (no foreign platform output addresses appear there today, but consistency with the local-ledger ownership invariant is cleaner than a latent risk). Carved out of dashpay/platform#3549 (source commit 16636f01c0) as a standalone production fix on v3.1-dev. No e2e tests, no dependency bumps included. References: V27-007, QA-010 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/platform_addresses/transfer.rs | 20 +++++++++++++++++++ .../wallet/platform_addresses/withdrawal.rs | 7 +++++++ 2 files changed, 27 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs index 3140ac4586f..945d938f360 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs @@ -29,6 +29,17 @@ impl PlatformAddressWallet { /// material — callers supply a seed-backed, hardware, or /// FFI-trampoline signer per their environment (iOS routes through /// `KeychainSigner` via `VTableSigner`). + /// + /// # Local-ledger ownership invariant (V27-007 / QA-010) + /// + /// The SDK returns post-transition states for **all** addresses touched by + /// the transition, including foreign output addresses the caller does not + /// own. Only addresses that belong to this wallet's account are written into + /// the local ledger; foreign addresses are silently skipped. The recipient + /// wallet's syncer is responsible for observing inbound credits on its own + /// addresses. Violating this invariant causes the source wallet's + /// `total_credits()` to absorb the recipient's balance, which corrupts + /// dust-gate checks and sweep address selection in teardown. pub async fn transfer + Send + Sync>( &self, account_index: u32, @@ -105,6 +116,15 @@ impl PlatformAddressWallet { continue; }; let p2pkh = PlatformP2PKHAddress::new(*hash); + // V27-007 / QA-010: skip foreign output addresses. + // The SDK returns post-transition state for every address in + // the transition (inputs + outputs). Output addresses may + // belong to a different wallet; writing their balances here + // would pollute this wallet's local ledger and corrupt + // `total_credits()`. See the method-level doc comment. + if !account.contains_platform_address(&p2pkh) { + continue; + } let funds = match maybe_info { Some(ai) => dash_sdk::platform::address_sync::AddressFunds { balance: ai.balance, diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs index 61695829700..6c5bc759ab7 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -128,6 +128,13 @@ impl PlatformAddressWallet { continue; }; let p2pkh = PlatformP2PKHAddress::new(*hash); + // Defense-in-depth (V27-007 / QA-010): only update owned + // addresses. Withdrawals send no platform output addresses, + // so this guard is never expected to fire, but keeps the + // local-ledger ownership invariant consistent with `transfer`. + if !account.contains_platform_address(&p2pkh) { + continue; + } let funds = match maybe_info { Some(ai) => dash_sdk::platform::address_sync::AddressFunds { balance: ai.balance,