Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4538fc4
feat(platform-wallet): let a Core build fund from only the inputs it …
jeanpierreroma Aug 31, 2026
d207874
feat(platform-wallet): let a Core build fund from only the inputs it …
jeanpierreroma Sep 1, 2026
28044ee
feat(platform-wallet): report the balance a pooled build can actually…
jeanpierreroma Sep 1, 2026
96c8be1
refactor(platform-wallet): drive both funding paths from one account …
jeanpierreroma Sep 3, 2026
c002d33
Merge branch 'v4.2-dev' into feat/pooled-spendable-balance
jeanpierreroma Sep 3, 2026
bb3ea98
test(platform-wallet): pin pooled_spendable_balance to the funding set
jeanpierreroma Sep 3, 2026
dd92e1b
feat(platform-wallet): price the fee into a pooled send-max figure
jeanpierreroma Sep 3, 2026
6444e67
style: rustfmt the new pooled-max-sendable FFI entry point
jeanpierreroma Sep 3, 2026
f95e401
fix(platform-wallet): cap pooled_max_sendable at the standard input l…
jeanpierreroma Sep 3, 2026
2abc906
fix(platform-wallet-ffi): look the pooled-balance handle up in the co…
jeanpierreroma Sep 7, 2026
e9c69dd
fix(platform-wallet): make the pooled send-max figure safe to act on
jeanpierreroma Sep 9, 2026
8a857a8
Merge remote-tracking branch 'origin/v4.2-dev' into feat/pooled-spend…
jeanpierreroma Sep 9, 2026
6ca60d2
Merge branch 'v4.2-dev' into feat/pooled-spendable-balance
romchornyi Sep 10, 2026
61daa8d
fix(platform-wallet): price the pooled maximum the way coin selection…
jeanpierreroma Sep 10, 2026
8a3ecc7
Merge branch 'v4.2-dev' into feat/pooled-spendable-balance
romchornyi Sep 10, 2026
8b6131f
Merge remote-tracking branch 'origin/v4.2-dev' into feat/pooled-spend…
jeanpierreroma Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ members = [
]

[workspace.dependencies]
dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" }
dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" }
dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" }
key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" }
key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" }
key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" }
dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" }
dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" }

tokio-metrics = "0.5"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ use std::str::FromStr;
pub struct FFITransactionBuilder {
inner: *mut c_void,
network: FFINetwork,
/// Set by `core_wallet_tx_builder_use_only_added_inputs`. key-wallet takes
/// this per funding call, which the finalizers make internally, so the
/// intent has to be carried here and read when they run.
reservation_only: bool,
}

/// Owned signed-transaction bytes handed across the C ABI as the `out_tx`
Expand Down Expand Up @@ -142,11 +146,13 @@ pub unsafe extern "C" fn core_wallet_tx_builder_finalize(

let signer =
MnemonicResolverCoreSigner::new(core_signer_handle, wallet.wallet_id(), wallet.network());
let finalized = runtime().block_on(wallet.core().finalize_transaction(
let reservation_only = (*builder).reservation_only;
let finalized = runtime().block_on(wallet.core().finalize_transaction_with_options(
inner,
account_type.funding_sources(),
account_index,
&signer,
reservation_only,
));
let finalized = unwrap_result_or_return!(finalized);

Expand Down Expand Up @@ -451,7 +457,11 @@ pub unsafe extern "C" fn core_wallet_tx_builder_new(
network: FFINetwork,
) -> *mut FFITransactionBuilder {
let inner = Box::into_raw(Box::new(TransactionBuilder::new())) as *mut c_void;
Box::into_raw(Box::new(FFITransactionBuilder { inner, network }))
Box::into_raw(Box::new(FFITransactionBuilder {
inner,
network,
reservation_only: false,
}))
}

/// # Safety
Expand Down Expand Up @@ -616,6 +626,59 @@ pub unsafe extern "C" fn core_wallet_tx_builder_set_fee_rate(
PlatformWalletFFIResult::ok()
}

/// Fund the build from the inputs `core_wallet_tx_builder_add_inputs_from_outpoints`
/// supplied, and nothing else.
///
/// Without this, the wallet-aware finalizers offer every unreserved UTXO of the
/// funding account alongside the seeded ones, so seeding a subset does not
/// restrict what gets selected. A caller draining an account in batches that
/// each stay under the standard-transaction input limit needs this, or every
/// batch sees the whole account and fails with a too-many-inputs error.
///
/// The balance a build funded by `account_type` could actually select from — the
Comment thread
romchornyi marked this conversation as resolved.
/// same accounts `core_wallet_tx_builder_finalize` would fund from, counting
/// only UTXOs coin selection accepts.
///
/// Gate amount entry on this rather than on `core_wallet_get_balance`, which
/// sums every funding account the wallet has — CoinJoin included — and so
/// reports money a build then refuses.
///
/// Reservations are not subtracted; see `CoreWallet::pooled_spendable_balance`.
///
/// # Safety
/// `out_balance` must be a valid, writable pointer.
#[no_mangle]
pub unsafe extern "C" fn core_wallet_pooled_spendable_balance(
wallet: Handle,
account_type: CoreAccountTypeFFI,
account_index: u32,
out_balance: *mut u64,
) -> PlatformWalletFFIResult {
check_ptr!(out_balance);
*out_balance = 0;

let wallet = unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet, |w| w.clone()));
let balance = unwrap_result_or_return!(runtime().block_on(
wallet
.core()
.pooled_spendable_balance(account_type.funding_sources(), account_index)
));

*out_balance = balance;
PlatformWalletFFIResult::ok()
}

/// # Safety
/// `builder` must be a valid, non-destroyed pointer.
#[no_mangle]
pub unsafe extern "C" fn core_wallet_tx_builder_use_only_added_inputs(
builder: *mut FFITransactionBuilder,
) -> PlatformWalletFFIResult {
check_ptr!(builder);
(*builder).reservation_only = true;
PlatformWalletFFIResult::ok()
}

/// # Safety
/// `builder` must be a valid, non-destroyed pointer.
#[no_mangle]
Expand Down
82 changes: 81 additions & 1 deletion packages/rs-platform-wallet/src/wallet/core/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,82 @@ pub(crate) fn resolve_source_accounts(
impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
/// Consume a configured builder, atomically fund and reserve its selected
Comment thread
romchornyi marked this conversation as resolved.
Outdated
/// inputs, then sign without holding the wallet-manager lock.
/// The balance a pooled build could actually select from — the same
/// accounts [`Self::finalize_transaction`] funds from, counting only UTXOs
/// coin selection would accept.
///
/// Hosts gate their amount entry on this. The wallet-level balance is a
/// strict superset: it sums every funding account, CoinJoin included, and
/// never consults a reservation set, so gating on it offers money the build
/// then refuses — the shortfall surfacing as
/// [`CorePooledInsufficientFunds`](PlatformWalletError::CorePooledInsufficientFunds)
/// after the user has already committed to an amount.
///
/// Missing sources are skipped, as in a pooled build: a wallet without a
/// BIP32 account or without DashPay contacts still has a spendable balance.
///
/// Reservations are NOT subtracted: key-wallet keeps each account's
/// `ReservationSet` private, so reading it needs an accessor there and a pin
/// bump. The figure is therefore optimistic by whatever another in-flight
/// build currently holds — transient by construction, since a reservation is
/// released when its spend is processed, on a definitive broadcast
/// rejection, at the TTL, or on restart. The account-set mismatch this fixes
/// is permanent, and was the whole of the shortfall in the report that
/// prompted it (support ticket 32081: 0.0054 DASH offered as spendable
/// against a 94 DASH balance, all of it CoinJoin).
pub async fn pooled_spendable_balance(
Comment thread
romchornyi marked this conversation as resolved.
&self,
sources: &[AccountTypePreference],
source_index: u32,
) -> Result<u64, PlatformWalletError> {
let mut manager = self.wallet_manager.write().await;
Comment thread
romchornyi marked this conversation as resolved.
Outdated
let (_wallet, info) = manager
.get_wallet_and_info_mut(&self.wallet_id)
.ok_or_else(|| PlatformWalletError::WalletNotFound("wallet not found".into()))?;
let height = info.core_wallet.last_processed_height();

let mut seen: HashSet<AccountType> = HashSet::new();
let mut total: u64 = 0;
for &preference in sources {
Comment thread
romchornyi marked this conversation as resolved.
Outdated
for at in resolve_source_accounts(&info.core_wallet.accounts, preference, source_index)
Comment thread
romchornyi marked this conversation as resolved.
Outdated
{
if !seen.insert(at) {
continue;
}
let Some(managed) = info.core_wallet.accounts.funds_account_mut(&at) else {
Comment thread
romchornyi marked this conversation as resolved.
Outdated
continue;
};
total += managed
Comment thread
romchornyi marked this conversation as resolved.
Outdated
.spendable_utxos(height)
.iter()
.map(|utxo| utxo.value())
.sum::<u64>();
}
}
Ok(total)
}

pub async fn finalize_transaction<S: TransactionSigner + ?Sized + Sync>(
&self,
builder: TransactionBuilder,
sources: &[AccountTypePreference],
source_index: u32,
signer: &S,
) -> Result<SignedCoreTransaction, PlatformWalletError> {
self.finalize_transaction_with_options(builder, sources, source_index, signer, false)
.await
}

/// `reservation_only` funds through
/// [`TransactionBuilder::add_funding_reservation_only`]: the sources take on
/// their reservation bookkeeping but offer no candidates, so the build spends
/// only the inputs already seeded on the builder.
///
/// A caller draining an account in batches under the standard-transaction
/// input limit needs it — ordinary funding offers the whole account on top of
/// the batch, so every batch trips the cap and an account above it can never
/// be drained.
pub async fn finalize_transaction_with_options<S: TransactionSigner + ?Sized + Sync>(
&self,
builder: TransactionBuilder,
// The funding sources to POOL, in order — the first supplies the
Expand All @@ -311,6 +386,7 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
sources: &[AccountTypePreference],
source_index: u32,
signer: &S,
reservation_only: bool,
) -> Result<SignedCoreTransaction, PlatformWalletError> {
let primary = *sources.first().ok_or_else(|| {
PlatformWalletError::TransactionBuild("no funding sources named".into())
Expand Down Expand Up @@ -375,7 +451,11 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
paths.insert(utxo.address.clone(), path);
}
}
builder = builder.add_funding(managed, account);
builder = if reservation_only {
builder.add_funding_reservation_only(managed, account)
} else {
builder.add_funding(managed, account)
};
offered_accounts.push(at);
}
// A strict single-source SET selector (a DashPay preference
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,19 @@ public final class CoreTransactionBuilder {
return self
}

/// Fund the build from the inputs `addInputs` supplied, and nothing else.
///
/// Without this, `finalizeAtomic` adds every unreserved UTXO of the funding
/// account to the candidate pool, so seeding a subset does not restrict what
/// gets selected. A caller draining an account in batches that each stay
/// under the standard-transaction input limit needs this, or every batch
/// sees the whole account and fails with a too-many-inputs error.
@discardableResult
public func useOnlyAddedInputs() throws -> CoreTransactionBuilder {
try core_wallet_tx_builder_use_only_added_inputs(handle).check()
return self
}

@discardableResult
public func setCurrentHeight(_ height: UInt32) throws -> CoreTransactionBuilder {
try core_wallet_tx_builder_set_current_height(handle, height).check()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,27 @@ public class ManagedCoreWallet {
)
}

/// The balance a build funded by `accountType` could actually select from —
/// the same accounts `finalizeAtomic` funds from, counting only UTXOs coin
/// selection accepts.
///
/// Gate amount entry on this, not on ``balance()``: that sums every funding
/// account the wallet has, CoinJoin included, so a wallet holding mixed
/// coins is offered money the build then refuses.
///
/// Reservations are not subtracted — an in-flight build's inputs still
/// count here. That is transient; the account-set difference is not.
public func pooledSpendableBalance(
accountType: CoreTransactionBuilder.AccountType = .allSpendable,
accountIndex: UInt32 = 0
) throws -> UInt64 {
var balance: UInt64 = 0
try core_wallet_pooled_spendable_balance(
handle, accountType.ffi, accountIndex, &balance
).check()
return balance
}

/// Get the network this wallet operates on.
public func network() throws -> Network {
var ffiNetwork = FFINetwork(0)
Expand Down
Loading