Skip to content

feat(platform-wallet): unban a PoSe-banned masternode — ProUpServTx orchestration, FFI, Swift - #4507

Open
QuantumExplorer wants to merge 3 commits into
v4.2-devfrom
feat/masternode-update-service
Open

feat(platform-wallet): unban a PoSe-banned masternode — ProUpServTx orchestration, FFI, Swift#4507
QuantumExplorer wants to merge 3 commits into
v4.2-devfrom
feat/masternode-update-service

Conversation

@QuantumExplorer

Copy link
Copy Markdown
Member

What

Adds the full below-app stack for unbanning a PoSe-banned masternode/evonode from the mobile wallets: a ProUpServTx orchestrator in platform-wallet, two additive FFI entry points mirroring the withdraw pair, and swift-sdk wrappers. Second of the three-PR sequence (rust-dashcore#991 → this → the wallet UI).

How

Pin bump (commit 1): rust-dashcore moves 3d13d9834db5c367 — the existing chore/sync-fixes-without-swept lineage plus a cherry-pick of the merged dashpay/rust-dashcore#991 payload-finalization seam (pushed as chore/sync-fixes-payload-seam). Staying off dev head because its TransactionsSwept breaking changes are not absorbed here yet.

Orchestrator (commit 2): execute_masternode_update_service re-asserts the node's current service values from the live DML entry (revive-only by design) and rides the seam: selection reserves the funding inputs → inputs_hash → operator BLS payload_sig (basic scheme over base_payload_hash, modern serialization — the same convention verify_message_digest checks real mainnet signatures with) → input ECDSA signing (sighashes cover the finished payload) → broadcast via CoreWallet::finalize_transaction + broadcast_finalized_transaction (release on Rejected, keep reserved on MaybeSent).

Guards, before network work wherever possible:

  • the operator secret must match the DML entry's operator key under either BLS serialization (mirrors verify_masternode_key);
  • an evonode payload requires the caller-supplied platform P2P port (the SML doesn't carry it) plus the entry's node id / HTTP port, with mn_type set explicitly so the serializer can't silently drop the triplet;
  • the operator-payout rule: operatorReward is read from the fetched ProRegTx; reward 0 ⇒ always the empty script and an address is refused (consensus forbids one anyway); non-zero ⇒ the address must be supplied explicitly — the payload replaces the payout script on-chain, so an unban can never silently clear an operator payout.

New PlatformWalletError::MasternodeListUnavailable maps to the existing FFI code 46. No new error codes.

FFI + Swift (commit 3): platform_wallet_manager_masternode_update_service (wallet-owned; operator key derived at the record's operator_key_index with the three-phase seed resolution from platform_wallet_provider_key_at_index) and platform_wallet_manager_tracked_masternode_update_service (host-vaulted key text via parse_secret_for_role). Both fund the fee from the wallet through the mnemonic resolver. out_txid zero-initialised on every path, written on definitive success; ambiguous broadcasts return the existing ErrorTransactionBroadcastUnconfirmed (never retry). Swift wrappers follow the masternodeWithdraw marshalling; both needed Swift error cases already exist.

Tests / verification

  • 9 orchestrator tests, incl. an end-to-end funded build against a recording broadcaster with the BLS signature verified over base_payload_hash, and the IPv4-mapped LE encoding pinned against dashcore's known testnet ProUpServTx vector.
  • FFI: invalid-handle + null-pointer tests with out-param-zeroing assertions, mirroring the withdraw pair's contract.
  • cargo fmt, workspace clippy -D warnings, cargo check --workspace --all-features (which compiles rs-unified-sdk-jni against the new externs — additive only, nothing existing changed), and the three wallet-crate test suites: platform-wallet 937 passed with the one pre-existing v4.2-dev failure (regression_reports_max_from_usable_suffix_not_total_account_balance, fixture invalidated by feat(dpp)!: rebalance the shielded fee constants for protocol 14 #4467 — unrelated, this diff doesn't touch shielded selection); platform-wallet-ffi and platform-wallet-storage fully green.
  • build_ios.sh --target sim succeeds end to end including the SwiftExampleApp link, so the new Swift wrappers compile against the regenerated headers.

🤖 Generated with Claude Code

QuantumExplorer and others added 3 commits August 28, 2026 13:14
Pin lineage chore/sync-fixes-without-swept + a cherry-pick of
dashpay/rust-dashcore#991 (merged to dev as 5f2de2e0), pushed as
chore/sync-fixes-payload-seam. The seam adds
TransactionBuilder::set_payload_finalizer, needed to build ProUpServTx:
its inputs_hash + operator-BLS payload_sig are only knowable after input
selection and must land before input signing. Staying off dev head
because dev's TransactionsSwept changes are not absorbed here yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed masternode

execute_masternode_update_service builds, operator-BLS-signs, funds,
input-signs, and broadcasts the provider update service transaction that
revives a PoSe-banned masternode or evonode, re-asserting its current
service values from the live masternode-list entry (revive-only by
design).

The build rides key-wallet's new payload-finalization seam: selection
reserves the funding inputs, the finalizer writes inputs_hash and the
operator's basic-scheme BLS signature over base_payload_hash (the same
convention verify_message_digest checks real mainnet signatures with),
and only then are inputs ECDSA-signed, since their sighashes cover the
finished payload.

Guards, all before network work where possible: the operator secret must
match the list entry's operator key under either serialization; an
evonode payload requires the caller-supplied platform P2P port (the list
does not carry it) and the entry's node id + HTTP port; and the operator
payout script follows the owner-decided rule — operatorReward 0 (read
from the fetched ProRegTx) always sends the empty script and forbids an
address, non-zero requires the address explicitly, so an unban can never
silently clear an operator payout on-chain.

New PlatformWalletError::MasternodeListUnavailable mirrors the locator's
list-unavailable outcome for FFI mapping. Nine tests, including an
end-to-end funded build against a recording broadcaster with the BLS
signature verified over base_payload_hash, and the IPv4-mapped LE
encoding pinned against dashcore's known testnet ProUpServTx vector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wrappers

Two additive entry points mirroring the withdraw pair, both funding the
L1 fee from the wallet through the mnemonic resolver:

- platform_wallet_manager_masternode_update_service — wallet-owned
  nodes; derives the operator key at the record's operator_key_index
  with the same three-phase seed resolution as
  platform_wallet_provider_key_at_index (resolver never under a wallet
  guard).
- platform_wallet_manager_tracked_masternode_update_service — tracked
  nodes; parses the host-vaulted operator key text through
  parse_secret_for_role like the verify-key path.

out_txid (32 wire-order bytes) is zero-initialised on every path and
written on definitive success; an ambiguous broadcast returns the
existing ErrorTransactionBroadcastUnconfirmed (never retry — inputs stay
reserved). PlatformWalletError::MasternodeListUnavailable maps to the
existing code 46. No new error codes; the tracked_masternode helpers
are promoted to pub(crate) instead of copied.

Swift: masternodeUpdateService / trackedMasternodeUpdateService follow
the masternodeWithdraw resolver marshalling, returning the txid Data.
Both existing Swift error cases (transactionBroadcastUnconfirmed,
masternodeListUnavailable) already cover the new outcomes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added this to the v4.2.0 milestone Aug 28, 2026
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 31 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2349789e-ad83-47f4-9c3d-3389f825b220

📥 Commits

Reviewing files that changed from the base of the PR and between 6a34ba2 and 9c7d370.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • Cargo.toml
  • packages/rs-platform-wallet-ffi/src/error.rs
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/rs-platform-wallet-ffi/src/masternode_update_service.rs
  • packages/rs-platform-wallet-ffi/src/tracked_masternode.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/masternode/mod.rs
  • packages/rs-platform-wallet/src/masternode/update_service.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Opus deferred (commit 9c7d370)
Canonical validated blockers: 2

@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.38%. Comparing base (6a34ba2) to head (9c7d370).

Additional details and impacted files
@@             Coverage Diff              @@
##           v4.2-dev    #4507      +/-   ##
============================================
+ Coverage     82.66%   83.38%   +0.71%     
============================================
  Files          2744     2773      +29     
  Lines        370075   373250    +3175     
============================================
+ Hits         305916   311223    +5307     
+ Misses        64159    62027    -2132     
Components Coverage Δ
dpp 82.75% <ø> (+0.68%) ⬆️
drive 82.23% <ø> (+0.88%) ⬆️
drive-abci 86.88% <ø> (+1.08%) ⬆️
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.92% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 48.41% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@thepastaclaw thepastaclaw left a comment

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.

Preliminary review — Codex only

The payload finalization and signing order is sound, but two in-scope blockers remain: the payout guard trusts an unbound DAPI transaction, and v3 extended service entries are silently reduced to a v2 payload that downgrades and discards service information. The new FFI functions also violate their zero-on-error output contract and make avoidable non-zeroizing copies of the operator secret.
Source: reviewer backend gpt-5.6-sol (general, security-auditor, rust-quality, and ffi-engineer); final verifier backend gpt-5.6-sol. Orchestration only, not reviewer evidence: openclaw-agent/cliproxy/gpt-5.6-sol.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking | 🟡 2 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/masternode/update_service.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/masternode/update_service.rs:138-145: Verify the fetched ProRegTx matches the requested proTxHash
  `Sdk::get_transaction` only decodes the bytes returned by DAPI; it does not verify that the decoded transaction has the requested txid. This code therefore trusts an unauthenticated response's `operator_reward`. A faulty or malicious endpoint can return an unrelated zero-reward ProRegTx for a target whose real reward is non-zero, causing `resolve_operator_payout_script` to accept an empty script. The resulting valid ProUpServTx targets the real masternode and clears its operator payout, defeating this PR's explicit payout-protection guarantee. Compare the decoded txid with the SPV-authenticated DML `pro_tx_hash` before reading the payload, and test a mismatched response.
- [BLOCKING] packages/rs-platform-wallet/src/masternode/update_service.rs:222-229: Reject or preserve v3 extended masternode service information
  `MasternodeListSummary::from_entry` reduces both legacy and v3 `MasternodeNetInfo::Extended` entries to `primary_service_address()`, so this code cannot tell that an IPv4/IPv6 primary came from an extended map containing additional Core P2P, Platform P2P/HTTPS, domain, or fallback endpoints. It then always creates `ProviderUpdateServicePayload::CURRENT_VERSION`, which the pinned rust-dashcore defines as v2. After the v24 deployment, Dash Core accepts a v2 service update for a v3 state, sets the state version to the transaction version, and replaces the complete `netInfo` map with the supplied legacy address. The purported revive-only operation therefore downgrades a v3 entry and discards live endpoints instead of reasserting them, potentially taking services offline. Preserve the entry version and full extended network information and emit a v3 payload, or reject extended entries before funding until rust-dashcore supports that payload; add coverage for an extended entry with a routable primary address.

In `packages/rs-platform-wallet-ffi/src/masternode_update_service.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/masternode_update_service.rs:137-143: Do not copy the operator secret through an unzeroized array
  `private` is a `Zeroizing<Vec<u8>>`, but `try_into()` copies it into the plain `bytes: [u8; 32]`. Because the array is `Copy`, passing it to `Zeroizing::new` leaves the named stack value outside zeroizing storage until the function returns. Copy directly into a zeroizing destination instead. The tracked-key arm at line 301 should likewise move the existing `Zeroizing<[u8; 32]>` out of `LocatorSecret::Bls` directly rather than dereferencing it into `Zeroizing::new(*secret)`.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/masternode_update_service.rs:226-230: Zero the txid output before other pointer checks
  `check_ptr!` returns immediately, so a valid `out_txid` remains unchanged whenever an earlier required input is null. This contradicts the PR's stated contract that `out_txid` is zero-initialized on every path and lets C-family callers observe stale transaction bytes after an error. Validate `out_txid` first, clear it, and only then validate the input pointers. Apply the same ordering to the tracked-masternode extern at lines 286-291, and initialize the null-pointer tests with a non-zero sentinel.

Comment on lines +138 to +145
match fetched.transaction.special_transaction_payload {
Some(TransactionPayload::ProviderRegistrationPayloadType(registration)) => {
Ok(registration.operator_reward)
}
_ => Err(PlatformWalletError::InvalidParameter(format!(
"transaction {display} is not a provider registration transaction"
))),
}

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.

🔴 Blocking: Verify the fetched ProRegTx matches the requested proTxHash

Sdk::get_transaction only decodes the bytes returned by DAPI; it does not verify that the decoded transaction has the requested txid. This code therefore trusts an unauthenticated response's operator_reward. A faulty or malicious endpoint can return an unrelated zero-reward ProRegTx for a target whose real reward is non-zero, causing resolve_operator_payout_script to accept an empty script. The resulting valid ProUpServTx targets the real masternode and clears its operator payout, defeating this PR's explicit payout-protection guarantee. Compare the decoded txid with the SPV-authenticated DML pro_tx_hash before reading the payload, and test a mismatched response.

Suggested change
match fetched.transaction.special_transaction_payload {
Some(TransactionPayload::ProviderRegistrationPayloadType(registration)) => {
Ok(registration.operator_reward)
}
_ => Err(PlatformWalletError::InvalidParameter(format!(
"transaction {display} is not a provider registration transaction"
))),
}
let fetched_txid = fetched.transaction.txid();
let expected_txid = Txid::from_byte_array(*pro_tx_hash);
if fetched_txid != expected_txid {
return Err(PlatformWalletError::InvalidIdentityData(format!(
"DAPI returned transaction {fetched_txid} for requested registration transaction \
{display}"
)));
}
match fetched.transaction.special_transaction_payload {
Some(TransactionPayload::ProviderRegistrationPayloadType(registration)) => {
Ok(registration.operator_reward)
}
_ => Err(PlatformWalletError::InvalidParameter(format!(
"transaction {display} is not a provider registration transaction"
))),
}

source: ['codex']

Comment on lines +222 to +229
let service = entry.service_address.ok_or_else(|| {
PlatformWalletError::InvalidParameter(
"the masternode's service address is not a plain IP:port entry, so it cannot be \
re-asserted from the masternode list"
.to_string(),
)
})?;
let (ip_address, port) = service_payload_fields(service);

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.

🔴 Blocking: Reject or preserve v3 extended masternode service information

MasternodeListSummary::from_entry reduces both legacy and v3 MasternodeNetInfo::Extended entries to primary_service_address(), so this code cannot tell that an IPv4/IPv6 primary came from an extended map containing additional Core P2P, Platform P2P/HTTPS, domain, or fallback endpoints. It then always creates ProviderUpdateServicePayload::CURRENT_VERSION, which the pinned rust-dashcore defines as v2. After the v24 deployment, Dash Core accepts a v2 service update for a v3 state, sets the state version to the transaction version, and replaces the complete netInfo map with the supplied legacy address. The purported revive-only operation therefore downgrades a v3 entry and discards live endpoints instead of reasserting them, potentially taking services offline. Preserve the entry version and full extended network information and emit a v3 payload, or reject extended entries before funding until rust-dashcore supports that payload; add coverage for an extended entry with a routable primary address.

source: ['codex']

Comment on lines +137 to +143
let bytes: [u8; 32] = private.as_slice().try_into().map_err(|_| {
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
"the derived operator private key is not 32 bytes",
)
})?;
Ok(Zeroizing::new(bytes))

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: Do not copy the operator secret through an unzeroized array

private is a Zeroizing<Vec<u8>>, but try_into() copies it into the plain bytes: [u8; 32]. Because the array is Copy, passing it to Zeroizing::new leaves the named stack value outside zeroizing storage until the function returns. Copy directly into a zeroizing destination instead. The tracked-key arm at line 301 should likewise move the existing Zeroizing<[u8; 32]> out of LocatorSecret::Bls directly rather than dereferencing it into Zeroizing::new(*secret).

Suggested change
let bytes: [u8; 32] = private.as_slice().try_into().map_err(|_| {
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
"the derived operator private key is not 32 bytes",
)
})?;
Ok(Zeroizing::new(bytes))
if private.len() != 32 {
return Err(PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
"the derived operator private key is not 32 bytes",
));
}
let mut bytes = Zeroizing::new([0u8; 32]);
bytes.copy_from_slice(private.as_slice());
Ok(bytes)

source: ['codex']

Comment on lines +226 to +230
check_ptr!(wallet_id);
check_ptr!(pro_tx_hash);
check_ptr!(mnemonic_resolver_handle);
check_ptr!(out_txid);
*out_txid = [0u8; 32];

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: Zero the txid output before other pointer checks

check_ptr! returns immediately, so a valid out_txid remains unchanged whenever an earlier required input is null. This contradicts the PR's stated contract that out_txid is zero-initialized on every path and lets C-family callers observe stale transaction bytes after an error. Validate out_txid first, clear it, and only then validate the input pointers. Apply the same ordering to the tracked-masternode extern at lines 286-291, and initialize the null-pointer tests with a non-zero sentinel.

Suggested change
check_ptr!(wallet_id);
check_ptr!(pro_tx_hash);
check_ptr!(mnemonic_resolver_handle);
check_ptr!(out_txid);
*out_txid = [0u8; 32];
check_ptr!(out_txid);
*out_txid = [0u8; 32];
check_ptr!(wallet_id);
check_ptr!(pro_tx_hash);
check_ptr!(mnemonic_resolver_handle);

source: ['codex']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants