Skip to content
115 changes: 115 additions & 0 deletions packages/rs-platform-wallet-ffi/src/shielded_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,121 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock(
PlatformWalletFFIResult::ok()
}

/// Fund the shielded pool by DRAINING the wallet's CoinJoin account
/// (`m/9'/coinType'/4'/account_index'`) into a single asset lock.
///
/// Sister to [`platform_wallet_manager_shielded_fund_from_asset_lock`],
/// with two differences:
///
/// 1. **Funding**: instead of coin-selecting an exact amount from a BIP44
/// account, every final CoinJoin UTXO is consumed and the lock value is
/// `Σ inputs − L1 fee`, computed by the builder. There is no amount
/// parameter, and the mixed coins never hop through a transparent BIP44
/// address — this is the CoinJoin → Shielded migration path.
/// 2. **No surplus output**: the single-recipient remainder flow pins the
/// consensus surplus to zero (see the resume sibling's doc), so the
/// parameter is omitted rather than plumbed.
///
/// The recipient receives `lock_value − pool_fee` credits. A stuck lock is
/// resumable via
/// [`platform_wallet_manager_shielded_resume_fund_from_asset_lock`] exactly
/// like a BIP44-funded one. The preflight rejects a drain whose balance
/// could not clear the Type 18 pool fee, so an unrecoverable dust lock is
/// never broadcast.
///
/// # Safety
/// - `wallet_id_bytes` must point to 32 readable bytes.
/// - `recipient_raw_43` must point to 43 readable bytes (raw Orchard
/// payment address: 11-byte diversifier + 32-byte pk_d).
/// - `core_signer_handle` must be a valid, non-destroyed
/// `*mut MnemonicResolverHandle` produced by
/// `dash_sdk_mnemonic_resolver_create`. The caller retains ownership.
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock_coinjoin_drain(
handle: Handle,
wallet_id_bytes: *const u8,
account_index: u32,
recipient_raw_43: *const u8,
core_signer_handle: *mut MnemonicResolverHandle,
) -> PlatformWalletFFIResult {
check_ptr!(wallet_id_bytes);
check_ptr!(recipient_raw_43);
check_ptr!(core_signer_handle);

let mut wallet_id = [0u8; 32];
std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32);

let mut recipient_bytes = [0u8; 43];
std::ptr::copy_nonoverlapping(recipient_raw_43, recipient_bytes.as_mut_ptr(), 43);
let recipient = match OrchardAddress::from_raw_bytes(&recipient_bytes) {
Ok(a) => a,
Err(e) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!("invalid Orchard recipient address: {e}"),
);
}
};

// The Type 18 live activity recorder writes to the coordinator's
// shared in-memory store, so resolve the coordinator alongside the
// wallet (same as the BIP44-funded sibling).
let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) {
Ok(p) => p,
Err(result) => return result,
};
let network = wallet.network();

// Round-trip the resolver handle through `usize` so the worker
// future's capture is `Send + 'static`.
let core_signer_addr = core_signer_handle as usize;

// Run the proof on a worker thread (8 MB stack) — see the sibling for
// why the Halo 2 synthesis cannot run on the calling thread.
let result = block_on_worker(async move {
// SAFETY: see the fn-level safety doc — the resolver handle
// is pinned alive for the duration of this FFI call.
let asset_lock_signer = unsafe {
MnemonicResolverCoreSigner::new(
core_signer_addr as *mut MnemonicResolverHandle,
wallet_id,
network,
)
};
let prover = CachedOrchardProver::new();
wallet
.shielded_fund_from_asset_lock(
&coordinator,
AssetLockFunding::DrainAccountBalance {
account:
key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingAccount::CoinJoin {
account_index,
},
},
vec![(recipient, None)],
&asset_lock_signer,
&prover,
// Single-recipient remainder flow: surplus is structurally
// zero, so no surplus output.
None,
// Single real note, no anonymity-set fillers.
0,
None,
// User-facing funding: wait for the ChainLock indefinitely —
// a broadcast asset lock is pending finality, never failed.
None,
)
.await
});
if let Err(e) = result {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("shielded coinjoin-drain fund-from-asset-lock failed: {e}"),
);
}
PlatformWalletFFIResult::ok()
Comment thread
QuantumExplorer marked this conversation as resolved.
Outdated
}

/// Resume a shielded fund-from-asset-lock by outpoint.
///
/// Sister to [`platform_wallet_manager_shielded_fund_from_asset_lock`]:
Expand Down
151 changes: 133 additions & 18 deletions packages/rs-platform-wallet/src/wallet/asset_lock/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ use super::tracked::{AssetLockStatus, TrackedAssetLock};
// Asset lock transaction building
// ---------------------------------------------------------------------------

/// Amount semantics of a funded asset-lock build.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetLockBuildAmount {
/// Lock exactly this many duffs; funding UTXOs are coin-selected and
/// change returns to the funding account.
Exact(u64),
/// Drain the funding account: every final UTXO is consumed and the
/// lock value is `Σ inputs − fee`, computed by the key-wallet builder
/// (see `build_asset_lock_with_signer`'s drain mode). Required for
/// CoinJoin funding, whose accounts have no change semantics.
DrainAll,
}

impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
/// Build an asset lock transaction using the key-wallet builder.
///
Expand All @@ -39,6 +52,10 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
/// `DerivationPath` is what the caller hands back to the same
/// `signer` when the credit output is later consumed on Platform.
///
/// Exact-amount BIP44 form — the historical entry point; the
/// funding-parameterized form is
/// [`Self::build_asset_lock_transaction_with_funding`].
///
/// # Arguments
///
/// * `amount_duffs` — Amount to lock in duffs.
Expand All @@ -61,7 +78,39 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
identity_index: u32,
signer: &S,
) -> Result<(Transaction, DerivationPath), PlatformWalletError> {
if amount_duffs == 0 {
self.build_asset_lock_transaction_with_funding(
AssetLockBuildAmount::Exact(amount_duffs),
AssetLockFundingAccount::Bip44 {
account_index,
},
funding_type,
identity_index,
signer,
)
.await
}

/// Funding-parameterized form of [`Self::build_asset_lock_transaction`]:
/// `funding_account` picks the account family supplying (and signing)
/// the funding UTXOs, and `amount` picks exact-amount vs whole-balance
/// drain semantics (see [`AssetLockBuildAmount`]). CoinJoin funding is
/// drain-only — the key-wallet builder rejects a non-drain CoinJoin
/// build.
pub async fn build_asset_lock_transaction_with_funding<S: ExtendedPubKeySigner>(
&self,
amount: AssetLockBuildAmount,
funding_account: AssetLockFundingAccount,
funding_type: AssetLockFundingType,
identity_index: u32,
signer: &S,
) -> Result<(Transaction, DerivationPath), PlatformWalletError> {
let (amount_duffs, drain) = match amount {
AssetLockBuildAmount::Exact(v) => (v, false),
// The credit-output value is a placeholder — the key-wallet
// drain build rewrites it to Σ inputs − fee.
AssetLockBuildAmount::DrainAll => (0, true),
};
if amount_duffs == 0 && !drain {
return Err(PlatformWalletError::AssetLockTransaction(
"Amount must be greater than zero".to_string(),
));
Expand Down Expand Up @@ -106,18 +155,17 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
identity_index,
};

// 3. Delegate to the key-wallet signer-driven builder. Platform
// asset locks fund from the standard BIP44 account and never
// drain; upstream only supports non-drain funding for BIP44
// (CoinJoin funding is drain-only).
// 3. Delegate to the key-wallet signer-driven builder with the
// caller's funding account + drain semantics (the key-wallet side
// enforces that CoinJoin funding is drain-only).
let result = info
.core_wallet
.build_asset_lock_with_signer(
wallet,
AssetLockFundingAccount::Bip44 { account_index },
funding_account,
vec![funding],
DEFAULT_FEE_PER_KB,
false,
drain,
signer,
)
.await
Expand Down Expand Up @@ -579,17 +627,40 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
identity_index: u32,
signer: &S,
) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> {
let (path, out_point) = self
.broadcast_funded_asset_lock(
amount_duffs,
self.create_funded_asset_lock_proof_with_funding(
AssetLockBuildAmount::Exact(amount_duffs),
AssetLockFundingAccount::Bip44 {
account_index,
},
funding_type,
identity_index,
signer,
)
.await
}

/// Funding-parameterized form of [`Self::create_funded_asset_lock_proof`]
/// — same build → broadcast → proof pipeline with the account family and
/// amount semantics of [`Self::build_asset_lock_transaction_with_funding`].
pub async fn create_funded_asset_lock_proof_with_funding<S: ExtendedPubKeySigner>(
&self,
amount: AssetLockBuildAmount,
funding_account: AssetLockFundingAccount,
funding_type: AssetLockFundingType,
identity_index: u32,
signer: &S,
) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> {
let (path, out_point) = self
.broadcast_funded_asset_lock_with_funding(
amount,
funding_account,
funding_type,
identity_index,
signer,
)
.await?;
let proof = self
.wait_for_funded_asset_lock_proof(&out_point, account_index)
.wait_for_funded_asset_lock_proof(&out_point, funding_account.account_index())
.await?;
Ok((proof, path, out_point))
}
Expand All @@ -609,6 +680,27 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
funding_type: AssetLockFundingType,
identity_index: u32,
signer: &S,
) -> Result<(DerivationPath, OutPoint), PlatformWalletError> {
self.broadcast_funded_asset_lock_with_funding(
AssetLockBuildAmount::Exact(amount_duffs),
AssetLockFundingAccount::Bip44 {
account_index,
},
funding_type,
identity_index,
signer,
)
.await
}

/// Funding-parameterized form of [`Self::broadcast_funded_asset_lock`].
pub(crate) async fn broadcast_funded_asset_lock_with_funding<S: ExtendedPubKeySigner>(
&self,
amount: AssetLockBuildAmount,
funding_account: AssetLockFundingAccount,
funding_type: AssetLockFundingType,
identity_index: u32,
signer: &S,
) -> Result<(DerivationPath, OutPoint), PlatformWalletError> {
// Serialize build→persist so a concurrent build cannot interleave its
// pool snapshot with ours. The snapshot is collected from live wallet
Expand Down Expand Up @@ -641,9 +733,9 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {

// 1. Build the asset lock transaction.
let (tx, path) = self
.build_asset_lock_transaction(
amount_duffs,
account_index,
.build_asset_lock_transaction_with_funding(
amount,
funding_account,
funding_type,
identity_index,
signer,
Expand All @@ -653,6 +745,17 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
let txid = tx.txid();
let out_point = OutPoint::new(txid, 0);

// The tracked/logged amount is read back from the built payload —
// for `Exact` it equals the requested value; for `DrainAll` the
// builder computed it (Σ inputs − fee) and this is the only place
// it is known.
let locked_amount_duffs: u64 = match &tx.special_transaction_payload {
Some(
dashcore::blockdata::transaction::special_transaction::TransactionPayload::AssetLockPayloadType(p),
) => p.credit_outputs.iter().map(|o| o.value).sum(),
_ => 0,
};

// Persist the funding account's address pool now that the build marked
// its index used. These asset-lock accounts fund OP_RETURN-payload
// credit outputs that never appear as on-chain UTXOs, so SPV can never
Expand Down Expand Up @@ -693,10 +796,10 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
.track_asset_lock(TrackedAssetLock {
out_point,
transaction: tx.clone(),
account_index,
account_index: funding_account.account_index(),
funding_type,
identity_index,
amount: amount_duffs,
amount: locked_amount_duffs,
status: AssetLockStatus::Built,
proof: None,
})
Expand Down Expand Up @@ -730,11 +833,23 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
let removed_built_row = cs_untrack.removed.contains(&out_point);
self.queue_asset_lock_changeset(cs_untrack);
if removed_built_row {
let reserved_account = match funding_account {
AssetLockFundingAccount::Bip44 {
account_index,
} => crate::wallet::reservations::ReservedFundingAccount::Standard(
key_wallet::account::account_type::StandardAccountType::BIP44Account,
account_index,
),
AssetLockFundingAccount::CoinJoin {
account_index,
} => crate::wallet::reservations::ReservedFundingAccount::CoinJoin(
account_index,
),
};
crate::wallet::reservations::release_reservation_after_rejected_broadcast(
&self.wallet_manager,
&self.wallet_id,
key_wallet::account::account_type::StandardAccountType::BIP44Account,
account_index,
reserved_account,
&tx,
)
.await;
Expand Down
Loading
Loading