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
66 changes: 65 additions & 1 deletion packages/rs-platform-wallet-ffi/src/spv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use std::ffi::CStr;
use std::os::raw::c_char;

use dashcore::sml::llmq_type::LlmqDevnetParams;
use platform_wallet::spv::{ClientConfig, ProgressPercentage, SyncProgress, SyncState};

use crate::error::*;
Expand Down Expand Up @@ -197,6 +198,17 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_tip_unix_seconds(
/// Hardcoded to `true` here; if a future caller has a real reason to
/// run SPV without masternode sync, it can construct the wallet
/// manager without the asset-lock path instead.
///
/// Devnet support:
/// - `devnet_name` must be set (non-null) when `network == Devnet` and
/// must be null on every other network. When set, the SPV user agent
/// is rewritten to embed the `devnet.devnet-<name>` substring Dash
/// Core peers gate inbound devnet handshakes on (matches the format
/// the `dash-spv` binary uses).
/// - `llmq_devnet_size` / `llmq_devnet_threshold` mirror Dash Core's
/// `-llmqdevnetparams=<size>:<threshold>`. Both zero means "no
/// override". Setting one without the other, or setting them on a
/// non-devnet network, is rejected.
#[no_mangle]
#[allow(clippy::field_reassign_with_default)]
pub unsafe extern "C" fn platform_wallet_manager_spv_start(
Expand All @@ -208,17 +220,63 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_start(
peer_count: usize,
restrict_to_configured_peers: bool,
start_from_height: u32,
devnet_name: *const c_char,
llmq_devnet_size: u32,
llmq_devnet_threshold: u32,
) -> PlatformWalletFFIResult {
check_ptr!(data_dir);
let data_dir_str = unwrap_result_or_return!(CStr::from_ptr(data_dir).to_str()).to_string();
let user_agent_str = if user_agent.is_null() {
let mut user_agent_str = if user_agent.is_null() {
None
} else {
Some(unwrap_result_or_return!(CStr::from_ptr(user_agent).to_str()).to_string())
};

let net: crate::types::Network = network.into();

let devnet_name_str = if devnet_name.is_null() {
None
} else {
Some(unwrap_result_or_return!(CStr::from_ptr(devnet_name).to_str()).to_string())
};

if net == crate::types::Network::Devnet && devnet_name_str.is_none() {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"devnet_name is required when network=Devnet",
);
}
Comment on lines +237 to +248
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.

🟡 Suggestion: Empty devnet_name passes the required-on-devnet check

The check on line 243 only rejects NULL. A caller passing an empty (but non-null) C string for devnet_name while network == Devnet satisfies validation, and the user agent is then rebuilt as /{base}(devnet.devnet-)/. That trailing devnet.devnet- substring will not match any real devnet's gating prefix, so the SPV client returns ok() but silently fails the handshake — precisely the failure mode this PR exists to prevent. Because the Swift wrapper takes devnetName: String? (not a validated enum), the FFI is the right place to enforce non-empty.

Suggested change
let devnet_name_str = if devnet_name.is_null() {
None
} else {
Some(unwrap_result_or_return!(CStr::from_ptr(devnet_name).to_str()).to_string())
};
if net == crate::types::Network::Devnet && devnet_name_str.is_none() {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"devnet_name is required when network=Devnet",
);
}
let devnet_name_str = if devnet_name.is_null() {
None
} else {
Some(unwrap_result_or_return!(CStr::from_ptr(devnet_name).to_str()).to_string())
};
if net == crate::types::Network::Devnet
&& devnet_name_str.as_deref().map_or(true, str::is_empty)
{
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"devnet_name is required (non-empty) when network=Devnet",
);
}
if net != crate::types::Network::Devnet && devnet_name_str.is_some() {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"devnet_name is only valid on devnet",
);
}

source: ['claude', 'codex']

if net != crate::types::Network::Devnet && devnet_name_str.is_some() {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"devnet_name is only valid on devnet",
);
}
if (llmq_devnet_size > 0) ^ (llmq_devnet_threshold > 0) {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"llmq_devnet_size and llmq_devnet_threshold must both be set or both zero",
);
}
if llmq_devnet_size > 0 && net != crate::types::Network::Devnet {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"llmq_devnet_params is only valid on devnet",
);
}
Comment on lines +255 to +266
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.

💬 Nitpick: No threshold <= size check at the FFI boundary

The XOR check ensures both LLMQ params are zero or both nonzero, but accepts size=3, threshold=10. Downstream ClientConfig::validate() is expected to catch it, but enforcing at the FFI surface produces a direct ErrorInvalidParameter instead of a less direct downstream failure. Low priority — confirm whether spawn_in_background actually invokes validate() before applying the config; if it does, this is purely cosmetic.

Suggested change
if (llmq_devnet_size > 0) ^ (llmq_devnet_threshold > 0) {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"llmq_devnet_size and llmq_devnet_threshold must both be set or both zero",
);
}
if llmq_devnet_size > 0 && net != crate::types::Network::Devnet {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"llmq_devnet_params is only valid on devnet",
);
}
if (llmq_devnet_size > 0) ^ (llmq_devnet_threshold > 0) {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"llmq_devnet_size and llmq_devnet_threshold must both be set or both zero",
);
}
if llmq_devnet_threshold > llmq_devnet_size {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"llmq_devnet_threshold must be <= llmq_devnet_size",
);
}
if llmq_devnet_size > 0 && net != crate::types::Network::Devnet {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"llmq_devnet_params is only valid on devnet",
);
}

source: ['claude']


// Dash Core devnet peers disconnect any inbound connection whose
// user agent doesn't contain `devnet.devnet-<name>`. Rebuild the
// user agent to embed the substring while keeping whatever base
// the caller (or our default) provided. Mirrors the dash-spv
// binary's `/rust-dash-spv:VERSION(devnet.devnet-NAME)/` format.
if let Some(name) = devnet_name_str.as_deref() {
let base = user_agent_str
.clone()
.unwrap_or_else(|| format!("platform-wallet-ffi:{}", env!("CARGO_PKG_VERSION")));
user_agent_str = Some(format!("/{base}(devnet.devnet-{name})/"));
}
Comment on lines +273 to +278
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.

💬 Nitpick: User-agent rebuild double-wraps an already-BIP14-formatted base

If the caller supplies a base already in BIP14 form (e.g. "/myapp:1.0/"), the rewrite emits "//myapp:1.0/(devnet.devnet-name)/" — extra slashes that deviate from the /rust-dash-spv:VERSION(devnet.devnet-NAME)/ format the comment claims to mirror. Dash Core's substring filter still matches, so this is cosmetic, but normalizing the base avoids surprising peer-log fingerprints.

Suggested change
if let Some(name) = devnet_name_str.as_deref() {
let base = user_agent_str
.clone()
.unwrap_or_else(|| format!("platform-wallet-ffi:{}", env!("CARGO_PKG_VERSION")));
user_agent_str = Some(format!("/{base}(devnet.devnet-{name})/"));
}
if let Some(name) = devnet_name_str.as_deref() {
let base = user_agent_str
.clone()
.unwrap_or_else(|| format!("platform-wallet-ffi:{}", env!("CARGO_PKG_VERSION")));
let base = base.trim_matches('/');
user_agent_str = Some(format!("/{base}(devnet.devnet-{name})/"));
}

source: ['claude']


let mut peer_list: Vec<String> = Vec::new();
if !peers.is_null() && peer_count > 0 {
for i in 0..peer_count {
Expand Down Expand Up @@ -256,6 +314,12 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_start(
config.peers.push(addr);
}
}
if llmq_devnet_size > 0 {
config.llmq_devnet_params = Some(LlmqDevnetParams {
size: llmq_devnet_size,
threshold: llmq_devnet_threshold,
});
}

let _guard = runtime().enter();
manager.spv_arc().spawn_in_background(config);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,21 +112,41 @@ public struct PlatformSpvStartConfig {
public var peers: [String]
public var restrictToConfiguredPeers: Bool
public var startFromHeight: UInt32
/// Devnet name (e.g. `"333"` for `devnet-333`). Required when
/// `network == .devnet`, must be `nil` on every other network.
/// The FFI rebuilds the user agent to embed
/// `devnet.devnet-<name>` so Dash Core devnet peers accept the
/// handshake.
public var devnetName: String?
/// LLMQ_DEVNET quorum size override (matches Dash Core's
/// `-llmqdevnetparams=<size>:<threshold>`). `0` means "no
/// override". Must be paired with `llmqDevnetThreshold` and only
/// valid on devnet.
public var llmqDevnetSize: UInt32
/// LLMQ_DEVNET signing threshold override. See
/// `llmqDevnetSize`.
public var llmqDevnetThreshold: UInt32

public init(
dataDir: String,
network: Network,
userAgent: String? = nil,
peers: [String] = [],
restrictToConfiguredPeers: Bool = false,
startFromHeight: UInt32 = 0
startFromHeight: UInt32 = 0,
devnetName: String? = nil,
llmqDevnetSize: UInt32 = 0,
llmqDevnetThreshold: UInt32 = 0
) {
self.dataDir = dataDir
self.network = network
self.userAgent = userAgent
self.peers = peers
self.restrictToConfiguredPeers = restrictToConfiguredPeers
self.startFromHeight = startFromHeight
self.devnetName = devnetName
self.llmqDevnetSize = llmqDevnetSize
self.llmqDevnetThreshold = llmqDevnetThreshold
}
}

Expand Down Expand Up @@ -194,6 +214,9 @@ extension PlatformWalletManager {
let userAgentPtr = config.userAgent.flatMap { strdup($0) }
defer { if let p = userAgentPtr { free(p) } }

let devnetNamePtr = config.devnetName.flatMap { strdup($0) }
defer { if let p = devnetNamePtr { free(p) } }

try peerCStrings.withUnsafeBufferPointer { peersBuf in
let peersPtr: UnsafePointer<UnsafePointer<CChar>?>? = peersBuf.baseAddress.map {
UnsafeRawPointer($0).assumingMemoryBound(to: UnsafePointer<CChar>?.self)
Expand All @@ -206,7 +229,10 @@ extension PlatformWalletManager {
peersPtr,
UInt(peerCStrings.count),
config.restrictToConfiguredPeers,
config.startFromHeight
config.startFromHeight,
devnetNamePtr,
config.llmqDevnetSize,
config.llmqDevnetThreshold
).check()
}
}
Expand Down
Loading