Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ impl PerAccountPlatformAddressState {
self.addresses.insert(address_index, address);
self.found.insert(address, funds);
}

/// Read-only view of the persisted `(address, funds)` entries.
///
/// Used by `PlatformAddressWallet::initialize_from_persisted` to
/// push the persisted balances onto each `ManagedPlatformAccount`
/// before the provider takes over.
pub(crate) fn found(&self) -> &BTreeMap<PlatformP2PKHAddress, AddressFunds> {
&self.found
}
}

/// Per-wallet account map — keys are DIP-17 account indexes (hardened
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use dpp::version::PlatformVersion;
use dpp::version::LATEST_PLATFORM_VERSION;
use key_wallet::PlatformP2PKHAddress;

use crate::changeset::Merge;
use crate::wallet::PlatformAddressWallet;
use crate::{PlatformAddressChangeSet, PlatformWalletError};
use dash_sdk::platform::transition::transfer_address_funds::TransferAddressFunds;
Expand Down Expand Up @@ -136,6 +137,26 @@ impl PlatformAddressWallet {
}
}
}
drop(wm);

// Mirror `sync.rs`: push the post-broadcast balances through
// the persister so any external store stays in sync with the
// in-memory account state we just updated above. Without
// this, persisted rows for these addresses stay frozen at
// pre-send values until the next BLAST sync, and
// `initialize_from_persisted` on the next process start would
// seed `account.address_credit_balance` from those stale rows
// — leaving `auto_select_inputs` to declare an input balance
// the protocol then rejects.
//
// Log-on-error rather than propagate: the on-chain transition
// already succeeded, and a persistence hiccup shouldn't mask
// that. A subsequent sync reconciles.
if !cs.is_empty() {
if let Err(e) = self.persister.store(cs.clone().into()) {
tracing::error!("Failed to persist transfer changeset: {}", e);
}
}
Comment on lines 137 to +159

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Transfer persists proof entries for non-owned recipient addresses as sender-account rows

Sdk::transfer_address_funds returns AddressInfos for inputs ∪ outputs (see expected_addresses = inputs.keys().chain(outputs.keys()).copied().collect() in rs-sdk/src/platform/transition/transfer_address_funds.rs:94 and the equality enforcement in collect_address_infos_from_proof). The loop in transfer.rs:103-137 then iterates every returned address, calls set_address_credit_balance on the sender account, and — when the address is not found in that account's HD pool — falls back to address_index = 0 (line 129) and pushes the entry into cs. The new self.persister.store(cs.clone().into()) on line 156 makes that durable. For a normal external send, the recipient's proved post-transfer balance is now persisted as if it lived in the sender's account at index 0. If the recipient hash happens to exist in another account on the same wallet, the persisted row's (wallet_id, account_index, address_index) coordinates are silently rewritten to the sender's. Filter the loop to only addresses owned by account_index (i.e. only push when the find_map actually finds an address_index), or only when addr came from the original inputs set.

source: ['codex-general']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- [BLOCKING] lines 103-159: Transfer persists proof entries for non-owned recipient addresses as sender-account rows
  `Sdk::transfer_address_funds` returns `AddressInfos` for `inputs ∪ outputs` (see `expected_addresses = inputs.keys().chain(outputs.keys()).copied().collect()` in `rs-sdk/src/platform/transition/transfer_address_funds.rs:94` and the equality enforcement in `collect_address_infos_from_proof`). The loop in `transfer.rs:103-137` then iterates every returned address, calls `set_address_credit_balance` on the *sender* account, and — when the address is not found in that account's HD pool — falls back to `address_index = 0` (line 129) and pushes the entry into `cs`. The new `self.persister.store(cs.clone().into())` on line 156 makes that durable. For a normal external send, the recipient's proved post-transfer balance is now persisted as if it lived in the sender's account at index 0. If the recipient hash happens to exist in another account on the same wallet, the persisted row's `(wallet_id, account_index, address_index)` coordinates are silently rewritten to the sender's. Filter the loop to only addresses owned by `account_index` (i.e. only push when the `find_map` actually finds an `address_index`), or only when `addr` came from the original `inputs` set.


Ok(cs)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,47 @@ impl PlatformAddressWallet {
/// [`PlatformPaymentAddressProvider::from_persisted`] so xpubs,
/// `found`, and `absent` are restored verbatim while `addresses`
/// and `pending` are rebuilt from the live `AddressPool`.
///
/// Also pushes each persisted balance back onto the matching
/// `ManagedPlatformAccount` via `set_address_credit_balance` so
/// the transfer/withdrawal `auto_select_inputs` paths see a
/// non-zero balance immediately after restore — without this,
/// they'd report "available 0 credits" until a fresh BLAST sync
/// round fired `on_address_found` for every known address.
/// Mirrors the `set_address_credit_balance(.., None)` shape in
/// [apply.rs](crate::wallet::apply): `None` for the key-source
/// argument because the gap-limit pool is already restored from
/// `account_state.addresses` inside `from_persisted`.
pub async fn initialize_from_persisted(
&self,
persisted: crate::PlatformAddressSyncStartState,
) -> Result<(), PlatformWalletError> {
// Hydrate `account.address_credit_balance` BEFORE constructing
// the provider. `from_persisted` holds a read lock on
// `wallet_manager` for its duration, and Tokio's `RwLock` has
// no read→write upgrade — doing the write-lock dance first
// keeps both paths simple and avoids exposing a new public
// accessor on the provider.
{
let mut wm = self.wallet_manager.write().await;
if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) {
for (account_index, account_state) in &persisted.per_account {
if let Some(account) = info
.core_wallet
.platform_payment_managed_account_at_index_mut(*account_index)
{
for (p2pkh, funds) in account_state.found() {
account.set_address_credit_balance(
*p2pkh,
funds.balance,
None,
);
}
}
}
}
}

let mut per_wallet = std::collections::BTreeMap::new();
per_wallet.insert(self.wallet_id, persisted.per_account);
let provider = PlatformPaymentAddressProvider::from_persisted(
Expand Down
Loading
Loading