Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
202 changes: 182 additions & 20 deletions packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ pub struct OutPointFFI {
pub vout: u32,
}

impl OutPointFFI {
/// The one authority for building this value, for callers that hold a
/// txid and an index rather than an `OutPoint` — the additive UTXO path
/// (`record_utxos_ffi`) is exactly that shape.
///
/// This value is the join key a sweep's `released_outpoints` uses to
/// find additive-path rows on the host side, so byte-order drift
/// between hand-rolled copies would silently unlink them: the release
/// would match nothing and the coin would stay spent. Both this and the
/// `From<&OutPoint>` impl below exist so no site has to spell the copy
/// out again.
pub fn new(txid: &dashcore::Txid, vout: u32) -> Self {
let mut bytes = [0u8; 32];
bytes.copy_from_slice(txid.as_ref());
Self { txid: bytes, vout }
}
}

impl From<&dashcore::OutPoint> for OutPointFFI {
/// Conversion for callers holding a whole `OutPoint`; delegates to
/// [`OutPointFFI::new`], which is where the byte copy lives.
fn from(outpoint: &dashcore::OutPoint) -> Self {
Self::new(&outpoint.txid, outpoint.vout)
}
}

/// Outpoint of a TXO that was spent, paired with the spending
/// transaction's txid. Replaces the bare `OutPointFFI` on
/// `AccountChangeSetFFI.utxos_spent` so the Swift persister can
Expand Down Expand Up @@ -237,6 +263,81 @@ pub struct WalletChangeSetFFI {
/// `proof.rs` can't fire until SPV re-applies a fresh CL).
pub last_applied_chain_lock_bytes: *mut u8,
pub last_applied_chain_lock_bytes_len: usize,
// This struct's layout is FROZEN here. It crosses the C ABI by bare
// pointer — `on_persist_wallet_changeset_fn` carries no size or version
// field — so appending anything makes the pairing of a new callback
// with an older native producer read past the end of the producer's
// allocation: the callback signature and the manager-create entry
// points are unchanged, so nothing stops that pairing, and a capability
// bit gates semantics, not memory layout — it cannot make an
// out-of-bounds read safe. The round's sweep batches, briefly appended
// here, now travel through the size-tagged
// `PersistenceCallbacksExtension` sweep callback instead (see
// `persistence.rs`), whose declared `struct_size` is exactly the proof
// of presence this struct cannot give. New per-round payloads must take
// that same route.
}

/// One sweep: the transactions it removed, the transaction that beat them,
/// and the coins its removal actually freed.
///
/// Delivered through `PersistenceCallbacksExtension`'s
/// `on_persist_wallet_changeset_sweeps_fn` — deliberately NOT a field on
/// [`WalletChangeSetFFI`], whose bare-pointer ABI cannot prove to a newer
/// consumer that an older producer allocated the field (see the layout note
/// there). The batches arrive in the order the wallet emitted them, and the
/// only subtractive part of a persistence round rides here: each entry
/// describes the wallet as that sweep saw it, and a later entry can keep a
/// coin spent that an earlier one freed. **A persister must apply them in
/// sequence** — folding them together lets the first answer outlive the
/// last one that is actually true. Ignoring them leaves dead rows that are
/// handed back at the next load and re-create a balance the wallet has
/// already corrected.
#[repr(C)]
/// # Null at count 0
///
/// `txids` and `released_outpoints` are BOTH null when their count is zero —
/// a batch can carry an empty release set, and (defensively) an empty txid
/// list. A consumer must check each pointer before forming a slice from it:
/// `slice::from_raw_parts(null, 0)` is undefined behaviour in Rust, not a
/// harmless empty slice, and a naive host binding would dereference null.
pub struct SweepBatchFFI {
/// Removed transactions, raw 32-byte txids. Delete these rows and every
/// UTXO they created.
pub txids: *const [u8; 32],
pub txids_count: usize,
/// The transaction whose arrival settled the inputs. Final, and not
/// necessarily wallet-relevant — it can pay entirely to outside
/// addresses and never reach this store at all, which is why what it
/// took cannot be worked out by looking it up.
pub superseded_by: [u8; 32],
/// Of the inputs the removed transactions claimed, the ones that came
/// free. Everything else they claimed was taken by `superseded_by` and
/// stays spent — a persister holds every input of what it deletes, so
/// this is the only thing telling it which to hand back.
pub released_outpoints: *const OutPointFFI,
pub released_outpoints_count: usize,
/// Whether `winner_mined_height` is meaningful. `false` means the sweep
/// was triggered by an InstantSend-locked winner still waiting to be
/// mined (upstream's only other trigger — an unlocked mempool arrival
/// never sweeps), and the winner has NO finality horizon: a persister
/// must still create a durable placeholder for a held-but-unfunded
/// input — under DIP-10 the lock alone settles it, and the placeholder
/// is the only claim that survives a restart — but must leave it
/// UNSTAMPED and never collect an unstamped placeholder (the winner has
/// no mining deadline, so no watermark proves its funding output
/// delivered-or-never; only funding materialisation, a later
/// block-context re-stamp, or a release resolves it). Re-pointing an
/// existing placeholder on such a sweep must keep (not clear) any
/// stamp it already carries.
pub has_winner_mined_height: bool,
/// Mined height of `superseded_by` when `has_winner_mined_height` —
/// the winner's own block, carried from the sweep event because the
/// winner may never appear anywhere else in this wallet's stream. A
/// persister stamps it onto the placeholder it writes for a
/// held-but-unfunded input, and collects that placeholder exactly when
/// `min(chainlock_height, synced_height)` reaches the stamp.
pub winner_mined_height: u32,
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -520,6 +621,82 @@ impl WalletChangeSetFFI {
}
}

/// Backing storage for one [`SweepBatchFFI`]'s nested buffers. The C struct
/// borrows into it, so the caller keeps this alive for the callback window —
/// the same `(entries, storage)` discipline
/// `build_address_pools_for_callback` uses, rather than `Box::into_raw` +
/// a paired free: nothing outlives the call, so nothing needs a free path.
pub(crate) struct SweepBatchStorage {
txids: Vec<[u8; 32]>,
released: Vec<OutPointFFI>,
}

/// Build the C mirrors of a changeset's sweep batches for the extension
/// sweep callback (`on_persist_wallet_changeset_sweeps_fn`), preserving the
/// wallet's emission order — the one property a persister cannot recover on
/// its own, since a later batch can keep a coin spent that an earlier one
/// freed. Sweeps travel wallet-scoped, not per account: the upstream events
/// are wallet-scoped, and the persister deletes by txid — the row it
/// deletes carries its own account link.
pub(crate) fn build_sweep_batches_for_callback(
cs: &platform_wallet::changeset::CoreChangeSet,
) -> (Vec<SweepBatchFFI>, Vec<SweepBatchStorage>) {
let storage: Vec<SweepBatchStorage> = cs
.sweeps
.iter()
.map(|batch| SweepBatchStorage {
txids: batch
.txids
.iter()
.map(|txid| {
let mut raw = [0u8; 32];
raw.copy_from_slice(txid.as_ref());
raw
})
.collect(),
released: batch
.released_outpoints
.iter()
.map(OutPointFFI::from)
.collect(),
})
.collect();

let batches: Vec<SweepBatchFFI> = cs
.sweeps
.iter()
.zip(storage.iter())
.map(|(batch, backing)| {
let mut superseded_by = [0u8; 32];
superseded_by.copy_from_slice(batch.superseded_by.as_ref());
SweepBatchFFI {
// `*const`, built straight from `as_ptr()`: the storage is
// borrowed immutably here, and `Vec::as_ptr` does not permit
// writes through the pointer or anything derived from it.
// Casting to `*mut` would advertise a C ABI that a callback
// could take literally, breaking Rust's aliasing rules.
txids: if backing.txids.is_empty() {
std::ptr::null()
} else {
backing.txids.as_ptr()
},
txids_count: backing.txids.len(),
superseded_by,
released_outpoints: if backing.released.is_empty() {
std::ptr::null()
} else {
backing.released.as_ptr()
},
released_outpoints_count: backing.released.len(),
has_winner_mined_height: batch.winner_mined_height.is_some(),
winner_mined_height: batch.winner_mined_height.unwrap_or(0),
}
})
.collect();

(batches, storage)
}

/// Returns the account "index" the FFI surfaces in `account_index`.
///
/// For variants with a natural index field (`Standard`, `CoinJoin`,
Expand Down Expand Up @@ -892,13 +1069,10 @@ fn record_new_utxos_ffi(
let script_bytes = txout.script_pubkey.as_bytes().to_vec();
let script_len = script_bytes.len();
let script_ptr = vec_to_ptr_u8(script_bytes, script_len);
let mut txid = [0u8; 32];
txid.copy_from_slice(rec.txid.as_ref());
Some(UtxoEntryFFI {
outpoint: OutPointFFI {
txid,
vout: d.index,
},
// Through the shared authority: this is the row a sweep's
// release later joins against by outpoint.
outpoint: OutPointFFI::new(&rec.txid, d.index),
amount: txout.value,
address: address.into_raw(),
script_pubkey: script_ptr,
Expand Down Expand Up @@ -926,13 +1100,8 @@ fn record_spent_outpoints_ffi(
.iter()
.filter_map(|d| {
let input = rec.transaction.input.get(d.index as usize)?;
let mut txid = [0u8; 32];
txid.copy_from_slice(input.previous_output.txid.as_ref());
Some(SpentOutPointFFI {
outpoint: OutPointFFI {
txid,
vout: input.previous_output.vout,
},
outpoint: OutPointFFI::from(&input.previous_output),
spending_txid,
})
})
Expand Down Expand Up @@ -1309,14 +1478,7 @@ fn tx_record_to_ffi(
tr.transaction
.input
.iter()
.map(|input| {
let mut prev_txid = [0u8; 32];
prev_txid.copy_from_slice(input.previous_output.txid.as_ref());
OutPointFFI {
txid: prev_txid,
vout: input.previous_output.vout,
}
})
.map(|input| OutPointFFI::from(&input.previous_output))
.collect()
};
let input_outpoints_count = input_outpoints_vec.len();
Expand Down
12 changes: 4 additions & 8 deletions packages/rs-platform-wallet-ffi/src/invitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,15 +208,11 @@ pub unsafe extern "C" fn platform_wallet_create_invitation(
let result = unwrap_option_or_return!(option);
let invitation = unwrap_result_or_return!(result);

// Marshal the funding outpoint out. `Txid: AsRef<[u8]>`, matching the
// conversion convention used across this crate's changeset FFI.
let mut txid = [0u8; 32];
txid.copy_from_slice(invitation.out_point.txid.as_ref());
// Marshal the funding outpoint out through the crate's one conversion
// authority (`From<&OutPoint> for OutPointFFI`) — this value joins the
// same outpoint-keyed rows the sweep releases match on.
unsafe {
*out_outpoint = OutPointFFI {
txid,
vout: invitation.out_point.vout,
};
*out_outpoint = OutPointFFI::from(&invitation.out_point);
}

// The URI is a secret (embeds the voucher key). Do NOT log it — the error
Expand Down
Loading
Loading