Skip to content

Commit de42903

Browse files
fix(platform-wallet): reserve the contact address across the send
The send now claims the contact address as Reserved, kept apart from the Used flag that records an observed payment: - Reserve under the manager lock (available, no resident record paying it, funding reservation fresh), then store and flush before the broadcast. - Sent (or possibly sent): Reserved -> Used. Provably not sent (store failure, rejection, stale at dispatch): release the reservation only. A payment the wallet observed in flight already promoted it to Used, so the release cannot erase it, even after a ChainLock evicted the record. The settled pool is always stored and flushed. - The FFI row is a reuse guard: Reserved now flattens to is_used = true, so a crash mid-send cannot free the address on the Swift host; the release stores false, which the host applies as written. SQLite keeps the reservation reversibly (reserved_at); restoring it on load is #4188. Tests: durable reservation before the broadcast (flush + Reserved in the stored pool), an observation during the send surviving a rejection and reaching disk, SQLite release/promote with a reload through load_used_addresses, the flipped FFI round trip, and FFI boundary tests for the prepared-payment endpoints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 62c37e8 commit de42903

6 files changed

Lines changed: 643 additions & 145 deletions

File tree

‎packages/rs-platform-wallet-ffi/src/core_address_types.rs‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,14 @@ pub struct CoreAddressEntryFFI {
7676
pub pool_type_tag: u8,
7777
/// Derivation index within this pool.
7878
pub address_index: u32,
79-
/// Whether funds had been seen at this address at emit time, i.e.
80-
/// `AddressInfo.state == AddressState::Used`.
79+
/// Whether the address must not be handed out again: funds were seen
80+
/// at it (`AddressState::Used`), or it is reserved
81+
/// (`AddressState::Reserved`).
8182
///
82-
/// This schema has no slot for a reservation, so an
83-
/// `AddressState::Reserved` entry necessarily flattens to `false`
84-
/// and reloads as `Available`. Nothing in platform reserves
85-
/// addresses today, so that lossy case is unreachable; adding a
86-
/// reserving caller requires a schema decision here first (see
83+
/// This schema has no slot for a reservation, and the row is a reuse
84+
/// guard, so a reservation is stored as `true` and reloads as `Used`.
85+
/// Releasing it stores `false` again. The one reserving caller is the
86+
/// two-step DashPay contact payment (see
8787
/// `persistence::build_core_address_entry_ffi`).
8888
pub is_used: bool,
8989
/// Cached balance in duffs from `AddressInfo.balance`.

‎packages/rs-platform-wallet-ffi/src/dashpay.rs‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1656,6 +1656,109 @@ mod tests {
16561656
);
16571657
}
16581658

1659+
/// Prepare rejects each null required pointer with `ErrorNullPointer`
1660+
/// before any wallet lookup, and publishes no payment handle.
1661+
#[test]
1662+
fn prepare_dashpay_payment_null_pointers_are_rejected() {
1663+
let id = [0u8; 32];
1664+
let dummy_signer = std::ptr::dangling_mut::<MnemonicResolverHandle>();
1665+
let mut payment: Handle = 0x5EED;
1666+
let mut fee: u64 = 0;
1667+
let mut total: u64 = 0;
1668+
let cases: [(*mut MnemonicResolverHandle, *mut Handle, *mut u64, *mut u64); 4] = [
1669+
(std::ptr::null_mut(), &mut payment, &mut fee, &mut total),
1670+
(dummy_signer, std::ptr::null_mut(), &mut fee, &mut total),
1671+
(dummy_signer, &mut payment, std::ptr::null_mut(), &mut total),
1672+
(dummy_signer, &mut payment, &mut fee, std::ptr::null_mut()),
1673+
];
1674+
for (signer, out_payment, out_fee, out_total) in cases {
1675+
let r = unsafe {
1676+
platform_wallet_prepare_dashpay_payment(
1677+
1,
1678+
id.as_ptr(),
1679+
id.as_ptr(),
1680+
1_000,
1681+
std::ptr::null(),
1682+
signer,
1683+
out_payment,
1684+
out_fee,
1685+
out_total,
1686+
)
1687+
};
1688+
assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer);
1689+
}
1690+
assert_eq!(payment, 0x5EED, "no handle is written on a pointer failure");
1691+
}
1692+
1693+
/// An unknown wallet fails the prepare and leaves `*out_payment` at the
1694+
/// null handle, so a caller can never mistake a failure for a payment.
1695+
#[test]
1696+
fn prepare_dashpay_payment_unknown_wallet_publishes_no_handle() {
1697+
let id = [0u8; 32];
1698+
let dummy_signer = std::ptr::dangling_mut::<MnemonicResolverHandle>();
1699+
let mut payment: Handle = 0x5EED;
1700+
let mut fee: u64 = 7;
1701+
let mut total: u64 = 7;
1702+
let r = unsafe {
1703+
platform_wallet_prepare_dashpay_payment(
1704+
0xDEAD_BEEF,
1705+
id.as_ptr(),
1706+
id.as_ptr(),
1707+
1_000,
1708+
std::ptr::null(),
1709+
dummy_signer,
1710+
&mut payment,
1711+
&mut fee,
1712+
&mut total,
1713+
)
1714+
};
1715+
assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound);
1716+
assert_eq!(payment, NULL_HANDLE);
1717+
assert_eq!(
1718+
(fee, total),
1719+
(7, 7),
1720+
"fee and total are written only on success"
1721+
);
1722+
}
1723+
1724+
/// Send, abandon and free with a payment handle that is not (or no
1725+
/// longer) held: send and abandon report `NotFound` without touching the
1726+
/// outputs, and free stays a no-op however often it is called.
1727+
#[test]
1728+
fn prepared_payment_unknown_handle_is_consumed_nowhere() {
1729+
let mut txid = [0xAAu8; 32];
1730+
let mut fee: u64 = 7;
1731+
let r = unsafe {
1732+
platform_wallet_send_prepared_dashpay_payment(
1733+
0xDEAD_BEEF,
1734+
0xDEAD_BEEF,
1735+
&mut txid,
1736+
&mut fee,
1737+
)
1738+
};
1739+
assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound);
1740+
assert_eq!(txid, [0xAAu8; 32]);
1741+
assert_eq!(fee, 7);
1742+
1743+
// A null `out_txid` with a handle that is not held is still a lookup
1744+
// miss: nothing to abandon, nothing written.
1745+
let r = unsafe {
1746+
platform_wallet_send_prepared_dashpay_payment(
1747+
0xDEAD_BEEF,
1748+
0xDEAD_BEEF,
1749+
std::ptr::null_mut(),
1750+
std::ptr::null_mut(),
1751+
)
1752+
};
1753+
assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound);
1754+
1755+
for _ in 0..2 {
1756+
let r = platform_wallet_abandon_prepared_dashpay_payment(0xDEAD_BEEF);
1757+
assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound);
1758+
platform_wallet_prepared_dashpay_payment_free(0xDEAD_BEEF);
1759+
}
1760+
}
1761+
16591762
/// A null `out_count` is rejected with `ErrorNullPointer` (the `check_ptr!`
16601763
/// contract) before any wallet lookup.
16611764
#[test]

‎packages/rs-platform-wallet-ffi/src/persistence.rs‎

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4452,26 +4452,15 @@ fn build_core_address_entry_ffi(
44524452
pool_type_tag,
44534453
address_index: info.index,
44544454
// `CoreAddressEntryFFI` carries a single `is_used` bool with no
4455-
// slot for a reservation, so `Reserved` can only flatten to
4456-
// `false` and would reload as `Available` — silently returning a
4457-
// handed-out address to the pool. Nothing in platform reserves
4458-
// addresses (no caller of `next_receive_address_and_reserve` /
4459-
// `next_unused_and_reserve` in the workspace), so that arm is
4460-
// unreachable today. It is spelled out rather than folded into a
4461-
// catch-all so the first reserving caller shows up as an explicit
4462-
// schema decision instead of losing state on the next reload.
4455+
// slot for a reservation. The row is a reuse guard, so `Reserved`
4456+
// flattens to `true`: a handed-out address must not be handed out
4457+
// again after a reload. Its release is a later `Available` row,
4458+
// stored as `false`, which the host applies as written. The one
4459+
// reserving caller is the two-step DashPay contact payment, which
4460+
// reserves the contact address for the length of its send.
44634461
is_used: match info.state {
4464-
AddressState::Used => true,
4462+
AddressState::Used | AddressState::Reserved { .. } => true,
44654463
AddressState::Available => false,
4466-
AddressState::Reserved { .. } => {
4467-
tracing::warn!(
4468-
index = info.index,
4469-
"persist: address pool entry is reserved, but the persisted \
4470-
address schema cannot represent a reservation; it will \
4471-
reload as available and may be handed out again"
4472-
);
4473-
false
4474-
}
44754464
},
44764465
balance: info.balance,
44774466
address_base58: address_ptr,
@@ -4569,8 +4558,8 @@ unsafe fn address_info_from_ffi(
45694558
index: entry.address_index,
45704559
path,
45714560
// The persisted entry only carries `is_used`, so state round-trips
4572-
// to `Used`/`Available`; a `Reserved` entry is not representable —
4573-
// see `build_core_address_entry_ffi`.
4561+
// to `Used`/`Available`; a `Reserved` entry is stored as used and
4562+
// reloads `Used` — see `build_core_address_entry_ffi`.
45744563
state: if entry.is_used {
45754564
AddressState::Used
45764565
} else {
@@ -9608,21 +9597,19 @@ mod tests {
96089597

96099598
/// Pin what the persisted address row does to `AddressState`.
96109599
/// `CoreAddressEntryFFI` carries a single `is_used` bool, so only
9611-
/// `Used` and `Available` survive a save/load cycle intact;
9612-
/// `Reserved { .. }` has no representation and comes back
9613-
/// `Available`, i.e. a reload silently frees a handed-out address.
9614-
/// Nothing in platform reserves addresses today, so this records the
9615-
/// boundary rather than a live bug: if a reserving caller is ever
9616-
/// added, this test is where the schema decision (a dedicated
9617-
/// reservation field on the row) has to be made.
9600+
/// `Used` and `Available` survive a save/load cycle intact.
9601+
/// `Reserved { .. }` flattens to used: the row is a reuse guard, and a
9602+
/// reload must never hand out an address that was handed out (the
9603+
/// two-step DashPay contact payment reserves its address across the
9604+
/// broadcast). A released reservation is stored as `Available`.
96189605
#[test]
96199606
fn address_state_round_trip_keeps_used_and_flattens_reserved() {
96209607
for (state, expected) in [
96219608
(AddressState::Available, AddressState::Available),
96229609
(AddressState::Used, AddressState::Used),
96239610
(
96249611
AddressState::Reserved { at: 1_700_000_000 },
9625-
AddressState::Available,
9612+
AddressState::Used,
96269613
),
96279614
] {
96289615
let mut info = typed_key_test_address_info(11, None);

‎packages/rs-platform-wallet-storage/tests/integration/sqlite_core_pool_writer.rs‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,86 @@ fn used_address_cannot_regain_reservation_from_stale_snapshot() {
351351
assert_eq!(state, (1, None));
352352
}
353353

354+
/// A reservation is a claim the backend can take back: storing the address
355+
/// available again clears it, while storing it used makes it final. This is
356+
/// what a two-step contact payment relies on to release the address of a
357+
/// send that never reached the network, which a `used` flag (monotonic, MAX)
358+
/// could not express.
359+
#[test]
360+
fn reservation_can_be_released_or_promoted_to_used() {
361+
let (persister, _tmp, _path) = fresh_persister();
362+
let w: WalletId = wid(0xB3);
363+
ensure_wallet_meta(&persister, &w);
364+
let account_type = AccountType::Standard {
365+
index: 0,
366+
standard_account_type: StandardAccountType::BIP44Account,
367+
};
368+
let mut infos = external_infos(0xB3);
369+
infos.truncate(2);
370+
let store = |infos: Vec<AddressInfo>| {
371+
persister
372+
.store(
373+
w,
374+
PlatformWalletChangeSet {
375+
account_address_pools: vec![pool_entry(
376+
account_type,
377+
AddressPoolType::External,
378+
infos,
379+
)],
380+
..Default::default()
381+
},
382+
)
383+
.unwrap();
384+
};
385+
386+
// Both reserved, as a send stores them before its broadcast.
387+
for info in &mut infos {
388+
info.state = AddressState::Reserved { at: 1_752_528_625 };
389+
}
390+
store(infos.clone());
391+
// Index 0: rejected — released. Index 1: sent — used.
392+
infos[0].state = AddressState::Available;
393+
infos[1].state = AddressState::Used;
394+
let (released, sent) = (infos[0].address.clone(), infos[1].address.clone());
395+
store(infos);
396+
397+
// What a reload restores as used — the loader `load()` rehydrates the
398+
// pool from: the sent address, never the released one.
399+
{
400+
let conn = persister.lock_conn_for_test();
401+
let reloaded: Vec<_> = core_pool::load_used_addresses(&conn, &w, Network::Testnet)
402+
.unwrap()
403+
.into_iter()
404+
.map(|(address, _)| address)
405+
.collect();
406+
assert_eq!(
407+
reloaded,
408+
vec![sent],
409+
"released {released} must reload available"
410+
);
411+
}
412+
413+
let conn = persister.lock_conn_for_test();
414+
let mut stmt = conn
415+
.prepare(
416+
"SELECT used, reserved_at FROM core_address_pool \
417+
WHERE wallet_id = ?1 ORDER BY address_index",
418+
)
419+
.unwrap();
420+
let rows = stmt
421+
.query_map(rusqlite::params![w.as_slice()], |row| {
422+
Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?))
423+
})
424+
.unwrap()
425+
.collect::<Result<Vec<_>, _>>()
426+
.unwrap();
427+
assert_eq!(
428+
rows,
429+
vec![(0, None), (1, None)],
430+
"the released address is available on disk, the sent one used"
431+
);
432+
}
433+
354434
/// UTXOs resolve to their pool-declared account during reads.
355435
#[test]
356436
fn account_index_is_resolved_from_pool() {

0 commit comments

Comments
 (0)