test(platform-wallet): e2e framework + full test suite — triage pins, Found-*/PA-* guards, fail-closed persist, Stage-2 merge#3549
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAn end-to-end testing framework for Changes
Sequence Diagram(s)sequenceDiagram
participant Test as E2E Test
participant Harness as E2eContext Harness
participant Registry as Wallet Registry
participant Bank as BankWallet
participant TWallet as TestWallet
participant Manager as PlatformWalletManager
participant SDK as SDK/PlatformWallet
participant Cleanup as Cleanup
Test->>Harness: init() first call
Harness->>Registry: open(test_wallets.json)
Harness->>Cleanup: sweep_orphans()
Cleanup->>Registry: list_orphans()
Cleanup->>Manager: create from orphan seed
Cleanup->>SDK: sync & drain to bank
Cleanup->>Registry: remove_orphan_entry
Harness->>Bank: load from mnemonic
Harness->>Bank: sync_balances()
Harness->>Bank: fund_address(test_addr1, credits)
Harness->>SDK: transfer via bank wallet
Test->>Test: setup() generates seed
Test->>Manager: create TestWallet
Test->>TWallet: create(seed)
Test->>TWallet: next_unused_address() → addr2
Test->>Bank: fund_address(addr2, TRANSFER_CREDITS)
Test->>SDK: transfer via bank
Test->>TWallet: wait_for_balance(addr2, expected)
TWallet->>SDK: sync_balances()
Test->>SDK: transfer(addr2 → addr1, TRANSFER_CREDITS)
SDK->>SDK: execute, compute fee
Test->>TWallet: verify balances & fee
Test->>Test: teardown()
Test->>Cleanup: teardown_one(test_wallet)
Cleanup->>TWallet: drain all addresses to bank
Cleanup->>Registry: remove_entry
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Pull request overview
Adds an end-to-end (wallet → SDK → broadcast) integration test harness to rs-platform-wallet and introduces the first live test case (address-funds transfer), alongside a production fix to InputSelection::Auto input selection so generated transitions satisfy protocol structure rules.
Changes:
- Added a reusable E2E framework under
packages/rs-platform-wallet/tests/e2e/(workdir slot locking, bank wallet, persistent registry, cleanup/sweep, wait hub, signer, SDK wiring). - Added the first E2E test case: transferring credits between two platform-payment addresses in a test wallet (ignored by default).
- Fixed
auto_select_inputsin production code to avoid selecting full balances as “input credits”, and added unit tests for the selection logic.
Reviewed changes
Copilot reviewed 21 out of 22 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs | Fixes auto input selection; adds pure helper + unit tests for selection behavior. |
| packages/rs-platform-wallet/tests/e2e.rs | Adds the integration test crate root and module wiring for the e2e suite. |
| packages/rs-platform-wallet/tests/e2e/README.md | Operator/setup documentation for running live e2e tests. |
| packages/rs-platform-wallet/tests/e2e/cases/mod.rs | Declares e2e test modules. |
| packages/rs-platform-wallet/tests/e2e/cases/transfer.rs | First e2e test exercising funding + self-transfer + teardown. |
| packages/rs-platform-wallet/tests/e2e/framework/mod.rs | Framework public surface (setup, errors, prelude) and module layout. |
| packages/rs-platform-wallet/tests/e2e/framework/harness.rs | E2eContext singleton init: config, workdir locking, SDK, manager, bank, registry, startup sweep. |
| packages/rs-platform-wallet/tests/e2e/framework/config.rs | Env/.env configuration loader for the harness. |
| packages/rs-platform-wallet/tests/e2e/framework/sdk.rs | Constructs dash_sdk::Sdk with TrustedHttpContextProvider and DAPI address resolution. |
| packages/rs-platform-wallet/tests/e2e/framework/workdir.rs | Cross-process workdir slot selection via flock. |
| packages/rs-platform-wallet/tests/e2e/framework/panic_hook.rs | Installs panic hook to cancel background work on panic. |
| packages/rs-platform-wallet/tests/e2e/framework/wait_hub.rs | Notify-based hub bridging wallet/SPV/platform events to async waiters. |
| packages/rs-platform-wallet/tests/e2e/framework/wait.rs | Async waiting helpers (event-driven balance wait + generic polling). |
| packages/rs-platform-wallet/tests/e2e/framework/signer.rs | Seed-backed Signer<PlatformAddress> with eager DIP-17 key cache. |
| packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs | Test wallet factory + SetupGuard (panic-safe registry-backed lifecycle). |
| packages/rs-platform-wallet/tests/e2e/framework/registry.rs | JSON-backed persistent registry for panic-safe orphan recovery. |
| packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs | Startup sweep + per-test teardown draining funds back to bank. |
| packages/rs-platform-wallet/tests/e2e/framework/bank.rs | Loads and syncs a pre-funded bank wallet; serialized funding API. |
| packages/rs-platform-wallet/tests/e2e/framework/context_provider.rs | Retained (disabled) SPV-backed SDK context provider module for future re-enable. |
| packages/rs-platform-wallet/tests/e2e/framework/spv.rs | Retained (disabled) SPV startup/readiness helpers for future re-enable. |
| packages/rs-platform-wallet/Cargo.toml | Adds dev-dependencies needed by the e2e harness. |
| Cargo.lock | Locks new/updated dependencies for the added test tooling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@coderabbitai review all |
|
🧠 Learnings used✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 9
♻️ Duplicate comments (1)
packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs (1)
57-75:⚠️ Potential issue | 🟠 Major | ⚡ Quick winKeep sub-threshold wallets recoverable.
If
0 < total <= SWEEP_DUST_THRESHOLD, both cleanup paths skipsweep_platform_addressesand still delete the registry entry. That permanently abandons the remaining credits and will slowly drain the shared bank across repeated runs. Either sweep every positive balance withReduceOutput(0)or only remove the entry once the wallet is actually empty.Also applies to: 109-121, 145-163
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs` around lines 57 - 75, The cleanup currently deletes registry entries even when 0 < total <= SWEEP_DUST_THRESHOLD, abandoning recoverable credits; update the logic in the sweep_one match branches (the block that calls registry.remove and registry.set_status) to: if the wallet balance is > 0 but <= SWEEP_DUST_THRESHOLD, call sweep_platform_addresses with ReduceOutput(0) (or otherwise perform a full sweep for any positive balance) and only call registry.remove when the wallet is actually empty; ensure failed-path still sets EntryStatus::Failed when sweep fails and that successful-path only increments swept and removes the registry entry when the post-sweep balance is zero (reference symbols: sweep_one, sweep_platform_addresses, SWEEP_DUST_THRESHOLD, ReduceOutput(0), registry.remove, registry.set_status, EntryStatus::Failed).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/rs-platform-wallet/tests/e2e/cases/transfer.rs`:
- Line 31: Rename the test function transfer_between_two_platform_addresses to
follow the convention by renaming it to
should_transfer_between_two_platform_addresses; update the async fn declaration
(and any internal references or usages of
transfer_between_two_platform_addresses) to the new name so the test name begins
with "should" while keeping the function body and attributes unchanged.
- Around line 51-79: This test performs real network calls via
s.ctx.bank().fund_address and s.test_wallet.transfer / wait_for_balance; change
it to comply with the "no network in unit/integration tests" rule by either (A)
moving this file/case to an e2e-only suite (so it runs under an e2e test runner)
or (B) refactoring to inject mocked implementations for the bank client and
wallet observer used by wait_for_balance and transfer (replace s.ctx.bank() and
any network-dependent wait_for_balance calls with test doubles that simulate
funding/transfer and observable balance updates); update references to
next_unused_address, transfer, and wait_for_balance to use the mocks or the
e2e-only harness accordingly.
In `@packages/rs-platform-wallet/tests/e2e/framework/config.rs`:
- Around line 34-50: Config currently derives Debug and will print sensitive
bank_mnemonic; replace the automatic derive with a manual impl Debug for Config
that omits or redacts bank_mnemonic (e.g., display "REDACTED" or hide its value)
and prints the other fields normally; implement Debug in the same module
referencing the struct name Config and its fields (bank_mnemonic, network,
dapi_addresses, min_bank_credits, workdir_base, trusted_context_url) so future
secret fields can also be redacted consistently.
In `@packages/rs-platform-wallet/tests/e2e/framework/registry.rs`:
- Around line 225-259: Rename the three test functions to follow the "should …"
naming convention: change missing_file_opens_empty to a descriptive name like
should_open_empty_if_file_missing, change insert_remove_round_trip_persists to
should_persist_insert_remove_round_trip, and change
corrupt_file_falls_back_to_empty to should_fall_back_to_empty_on_corrupt_file;
update the fn identifiers in
packages/rs-platform-wallet/tests/e2e/framework/registry.rs (the tests currently
named missing_file_opens_empty, insert_remove_round_trip_persists,
corrupt_file_falls_back_to_empty) and run cargo test to ensure no references
break.
In `@packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs`:
- Around line 291-293: Rename the test function
default_spec_matches_pinned_constants to follow the repository "should …"
convention (e.g., should_default_spec_match_pinned_constants or
should_match_pinned_constants_by_default) so the test name starts with "should";
update the function declaration fn default_spec_matches_pinned_constants() to
the new name and keep the body (including PlatformPaymentAccountSpec::default())
unchanged so references and assertions remain valid.
In `@packages/rs-platform-wallet/tests/e2e/framework/workdir.rs`:
- Line 92: Rename the test function
first_call_takes_slot_zero_second_falls_through to follow the required "should
..." convention (for example
should_first_call_take_slot_zero_and_second_fall_through); update the function
identifier wherever referenced (the test declaration itself and any uses in
attributes or calls) so the Rust test name begins with "should_" and keep the
original behavior and test annotation (e.g., #[test]) unchanged.
- Around line 50-61: The current error handling in the lock acquisition loop
treats every Err(err) as a busy slot; update the branch in the function that
opens/locks `lock_file` (the block that logs "workdir slot busy, trying next")
to inspect the IO error kind: if the error indicates contention (e.g.,
would-block / ErrorKind::WouldBlock or the platform-specific WouldBlock
equivalent), keep the existing tracing::debug and continue; for any other errors
(permission, other IO), log an error and propagate/return the error instead of
retrying so real failures aren’t swallowed.
In `@packages/rs-platform-wallet/tests/e2e/README.md`:
- Around line 99-106: The fenced code blocks in the e2e README (the blocks
starting with the "Bank wallet under-funded." message and the "SetupGuard
dropped without explicit teardown — wallet <id>" message) lack language tags,
causing MD040 lint failures; update those fenced blocks to include a language
specifier (e.g., change ``` to ```text) for both occurrences (the block
containing "Bank wallet under-funded." and the later block containing
"SetupGuard dropped without explicit teardown") so the markdown linter accepts
them.
- Around line 233-235: Update the stale troubleshooting example to match the
current error shape emitted by the pick_available_workdir routine: replace the
quoted `No available workdir slots (tried 0..10)` text with the actual error
text produced by pick_available_workdir (copy exact current message/format), and
note that this occurs when all 10 workdir slots are locked so operators search
logs for the correct string; reference pick_available_workdir in the note so
maintainers can locate the implementation for future changes.
---
Duplicate comments:
In `@packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs`:
- Around line 57-75: The cleanup currently deletes registry entries even when 0
< total <= SWEEP_DUST_THRESHOLD, abandoning recoverable credits; update the
logic in the sweep_one match branches (the block that calls registry.remove and
registry.set_status) to: if the wallet balance is > 0 but <=
SWEEP_DUST_THRESHOLD, call sweep_platform_addresses with ReduceOutput(0) (or
otherwise perform a full sweep for any positive balance) and only call
registry.remove when the wallet is actually empty; ensure failed-path still sets
EntryStatus::Failed when sweep fails and that successful-path only increments
swept and removes the registry entry when the post-sweep balance is zero
(reference symbols: sweep_one, sweep_platform_addresses, SWEEP_DUST_THRESHOLD,
ReduceOutput(0), registry.remove, registry.set_status, EntryStatus::Failed).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0379415c-b6af-4b82-b05c-635af13cb042
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (23)
packages/rs-platform-wallet/Cargo.tomlpackages/rs-platform-wallet/tests/.env.examplepackages/rs-platform-wallet/tests/e2e.rspackages/rs-platform-wallet/tests/e2e/README.mdpackages/rs-platform-wallet/tests/e2e/cases/mod.rspackages/rs-platform-wallet/tests/e2e/cases/transfer.rspackages/rs-platform-wallet/tests/e2e/framework/bank.rspackages/rs-platform-wallet/tests/e2e/framework/cleanup.rspackages/rs-platform-wallet/tests/e2e/framework/config.rspackages/rs-platform-wallet/tests/e2e/framework/context_provider.rspackages/rs-platform-wallet/tests/e2e/framework/harness.rspackages/rs-platform-wallet/tests/e2e/framework/mod.rspackages/rs-platform-wallet/tests/e2e/framework/registry.rspackages/rs-platform-wallet/tests/e2e/framework/sdk.rspackages/rs-platform-wallet/tests/e2e/framework/spv.rspackages/rs-platform-wallet/tests/e2e/framework/wait.rspackages/rs-platform-wallet/tests/e2e/framework/wait_hub.rspackages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rspackages/rs-platform-wallet/tests/e2e/framework/workdir.rspackages/rs-sdk/src/platform/transition.rspackages/rs-sdk/src/platform/transition/address_inputs.rspackages/simple-signer/Cargo.tomlpackages/simple-signer/src/signer.rs
|
✅ Review complete (commit 921833f) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 24 changed files in this pull request and generated 9 comments.
Comments suppressed due to low confidence (2)
packages/rs-sdk/src/platform/transition/address_inputs.rs:39
- Now that this helper is public,
nonce + 1can overflow whennonce == u32::MAX, which will panic in debug builds and wrap in release builds. Consider usingchecked_add(1)and returning an error (or otherwise handling the overflow) so callers can't accidentally produce an invalid/wrapping nonce.
pub fn nonce_inc(
data: BTreeMap<PlatformAddress, (AddressNonce, Credits)>,
) -> BTreeMap<PlatformAddress, (AddressNonce, Credits)> {
data.into_iter()
.map(|(address, (nonce, credits))| (address, (nonce + 1, credits)))
.collect()
packages/rs-sdk/src/platform/transition/address_inputs.rs:18
fetch_inputs_with_nonceis now public but has no doc comment explaining (1) that it performs existence/balance checks and (2) that callers typically need to applynonce_incbefore building a transfer (astransfer_address_fundsdoes). Please document the intended call pattern (or provide a single public helper that returns the incremented nonces) to reduce misuse from external callers.
pub async fn fetch_inputs_with_nonce(
sdk: &Sdk,
amounts: &BTreeMap<PlatformAddress, Credits>,
) -> Result<BTreeMap<PlatformAddress, (AddressNonce, Credits)>, Error> {
if amounts.is_empty() {
return Err(Error::from(TransitionNoInputsError::new()));
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
PR adds a substantial e2e framework for rs-platform-wallet. Four blocking issues in the cleanup/teardown lifecycle: the live test no longer carries #[ignore] (so plain cargo test fails without the bank mnemonic), the sweep helper doesn't filter sub-min_input_amount inputs (DPP rejects them), SWEEP_DUST_THRESHOLD (5M) sits below the protocol's min transfer fee (6.5M) leaving an unsweepable balance band, and positive sub-threshold balances are silently dropped from the registry. Several supporting suggestions and nitpicks around dead/misnamed API and error-context loss. Overflow: 3 valid findings dropped to fit the 10-comment budget.
Reviewed commit: ae98ccf
🔴 4 blocking | 🟡 4 suggestion(s) | 💬 2 nitpick(s)
1 additional finding
🟡 suggestion: `fetch_inputs_with_nonce` / `nonce_inc` promoted to `pub` with no caller outside rs-sdk
packages/rs-sdk/src/platform/transition/address_inputs.rs (lines 12-40)
Both functions (and the address_inputs module itself) were widened from pub(crate) to pub. A repo-wide grep finds no caller outside crate::platform::transition::* — the e2e framework in rs-platform-wallet does not import them, and rs-platform-wallet production code doesn't either. The PR description frames this as future-friendliness for the e2e framework, but that framework never lands the call. Promoting low-level internals to the SDK's public API surface without a concrete consumer is a maintenance hazard: once pub, the signatures become a stability commitment, and nonce_inc in particular is footgun-prone outside the strict fetch→increment→sign→broadcast flow. Revert to pub(crate) (or pub(super)) and widen in the same PR as the first external caller.
🤖 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/tests/e2e/cases/transfer.rs`:
- [BLOCKING] lines 30-31: Live e2e test runs by default; `cargo test` hard-fails without operator env
`transfer_between_two_platform_addresses` is no longer `#[ignore]`d (the doc comment on lines 4-7 makes this explicit). `setup()` calls `Config::from_env()` which errors if `PLATFORM_WALLET_E2E_BANK_MNEMONIC` is unset, and the test escalates that to a panic via `.expect("e2e setup failed")`. Consequence: a stock `cargo test -p platform-wallet` (or workspace-wide invocation) becomes a hard failure for any contributor or CI job without a funded testnet bank wallet. Workflow-level gating is a coordination requirement, not a guarantee. The DET precedent the framework cites keeps live-network tests behind `#[ignore]` for exactly this reason. Re-add `#[ignore]` and run live with `cargo test -- --ignored`.
In `packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs`:
- [BLOCKING] lines 202-211: Sweep helper doesn't filter sub-`min_input_amount` balances; DPP rejects the transition
`sweep_platform_addresses` filters inputs by `*b > 0` only. The address-funds-transfer state-transition validation (`packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funds_transfer_transition/v0/state_transition_validation.rs:157-163`) rejects any input below `platform_version.dpp.state_transitions.address_funds.min_input_amount`. So as soon as one tracked address holds a sub-minimum balance, every sweep attempt for that wallet — both `teardown_one` and the orphan `sweep_one` — submits an invalid transition and the entry stays stuck. Mirror the production auto-selector and drop inputs below `min_input_amount` from the explicit map.
- [BLOCKING] lines 26-30: `SWEEP_DUST_THRESHOLD` (5M) is below the protocol's minimum transfer fee (6.5M)
Sweep eligibility is `total > 5_000_000`, but the minimum fee for a 1-input/1-output address transfer is `address_funds_transfer_input_cost (500_000) + address_funds_transfer_output_cost (6_000_000) = 6_500_000` credits (`packages/rs-platform-version/src/version/fee/state_transition_min_fees/v1.rs:14-15`). For balances in `(5_000_000, 6_500_000)`, both `teardown_one` and `sweep_one` will attempt a `ReduceOutput(0)` sweep that cannot cover its own fee, so those wallets get retried forever (with the registry entry repeatedly marked `Failed`) until someone tops them up manually. Raise the threshold above the protocol minimum (and ideally derive it from the platform-version constants so it stays in sync).
- [BLOCKING] lines 109-162: Positive sub-threshold balances are dropped from the registry without sweeping
When `total <= SWEEP_DUST_THRESHOLD`, `teardown_one` (lines 147-162) skips `sweep_platform_addresses` and unconditionally calls `registry.remove(...)`; the orphan path does the same indirectly — `sweep_one` returns `Ok(())` after logging "below sweep threshold; skipping" (lines 109-117), and `sweep_orphans` then removes the registry entry (lines 58-66). Any wallet that still holds a positive balance under the threshold is therefore forgotten rather than retried or aggregated, permanently stranding real testnet credits and contradicting the README's recovery guarantees. Either keep the entry tagged `Failed` so a future operator can audit, or only drop entries whose `total == 0`.
In `packages/simple-signer/src/signer.rs`:
- [SUGGESTION] lines 197-241: `from_seed_for_identity` is misleadingly named, half-functional, and unused
The new (feature-gated) constructor derives DIP-9 identity-authentication ECDSA secp256k1 keys but inserts them into `address_private_keys: BTreeMap<[u8; 20], [u8; 32]>` — the map consumed by `Signer<PlatformAddress>::sign` (line 339, keyed on the 20-byte address hash). The `Signer<IdentityPublicKey>` view that the function name implies (line 245) only consults `private_keys` / `private_keys_in_creation`, both of which remain empty after this constructor runs. The doc comment hand-waves this with "callers must additionally register `IdentityPublicKey` records" — but if the caller has to do that themselves the constructor isn't actually "for identity." A repo-wide grep confirms zero callers. Either (a) populate `private_keys` inside the constructor so identity signing works out of the box, (b) drop it until a real consumer exists, or (c) rename to reflect what it actually populates (e.g. `derive_identity_path_into_address_keys`).
In `packages/rs-platform-wallet/tests/e2e/framework/sdk.rs`:
- [SUGGESTION] lines 39-46: `FrameworkError::NotImplemented` used as a generic runtime-error wrapper, dropping the underlying error
`SdkBuilder::build()` failure here is a real runtime error, not an unimplemented-feature path, but it's mapped to `FrameworkError::NotImplemented("sdk::build_sdk — SdkBuilder::build failed (see logs)")`. The actual error `e` is only emitted via a side-effect `tracing::error!` and then discarded. Callers that pattern-match on the `Result` (or render it for CI failure summaries) see only the `&'static str`. The same pattern recurs at lines 76-84, 99-107, 117-125, and `framework/spv.rs:125-148, 215-236`. The `FrameworkError` enum already has `Wallet(String)`, `Bank(String)`, `Config(String)` for this purpose — add `Sdk(String)` / `Spv(String)` variants and propagate `e.to_string()` through the `Result`.
In `packages/rs-sdk/src/platform/transition/address_inputs.rs`:
- [SUGGESTION] lines 12-40: `fetch_inputs_with_nonce` / `nonce_inc` promoted to `pub` with no caller outside rs-sdk
Both functions (and the `address_inputs` module itself) were widened from `pub(crate)` to `pub`. A repo-wide grep finds no caller outside `crate::platform::transition::*` — the e2e framework in rs-platform-wallet does not import them, and rs-platform-wallet production code doesn't either. The PR description frames this as future-friendliness for the e2e framework, but that framework never lands the call. Promoting low-level internals to the SDK's public API surface without a concrete consumer is a maintenance hazard: once `pub`, the signatures become a stability commitment, and `nonce_inc` in particular is footgun-prone outside the strict fetch→increment→sign→broadcast flow. Revert to `pub(crate)` (or `pub(super)`) and widen in the same PR as the first external caller.
In `packages/rs-platform-wallet/tests/e2e/framework/spv.rs`:
- [SUGGESTION] lines 205-208: Retained SPV path bypasses the slot-locked workdir
`E2eContext::build` acquires a unique slot via `pick_available_workdir` and stores it in `workdir`, but `build_client_config` derives its storage path from `config.workdir_base`. If the commented-out SPV block in `harness.rs` is re-enabled (Task #15), every concurrent process will share `<base>/spv-data` instead of using the locked slot directory, defeating the cross-process isolation mechanism and creating avoidable RocksDB/SPV state contention. Because the SPV module is intentionally kept compilable for re-enablement, fix this now — pass the slot workdir into `build_client_config` so the path tracks the lock.
| let inputs: BTreeMap<PlatformAddress, Credits> = wallet | ||
| .platform() | ||
| .addresses_with_balances() | ||
| .await | ||
| .into_iter() | ||
| .filter(|(_, b)| *b > 0) | ||
| .collect(); | ||
| if inputs.is_empty() { | ||
| return Ok(()); | ||
| } |
There was a problem hiding this comment.
🔴 Blocking: Sweep helper doesn't filter sub-min_input_amount balances; DPP rejects the transition
sweep_platform_addresses filters inputs by *b > 0 only. The address-funds-transfer state-transition validation (packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funds_transfer_transition/v0/state_transition_validation.rs:157-163) rejects any input below platform_version.dpp.state_transitions.address_funds.min_input_amount. So as soon as one tracked address holds a sub-minimum balance, every sweep attempt for that wallet — both teardown_one and the orphan sweep_one — submits an invalid transition and the entry stays stuck. Mirror the production auto-selector and drop inputs below min_input_amount from the explicit map.
source: ['codex']
🤖 Fix this 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/tests/e2e/framework/cleanup.rs`:
- [BLOCKING] lines 202-211: Sweep helper doesn't filter sub-`min_input_amount` balances; DPP rejects the transition
`sweep_platform_addresses` filters inputs by `*b > 0` only. The address-funds-transfer state-transition validation (`packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funds_transfer_transition/v0/state_transition_validation.rs:157-163`) rejects any input below `platform_version.dpp.state_transitions.address_funds.min_input_amount`. So as soon as one tracked address holds a sub-minimum balance, every sweep attempt for that wallet — both `teardown_one` and the orphan `sweep_one` — submits an invalid transition and the entry stays stuck. Mirror the production auto-selector and drop inputs below `min_input_amount` from the explicit map.
| /// Minimum sweep amount: skip wallets whose total balance is below | ||
| /// this. Acts as the dust gate so sweeps don't churn the chain for | ||
| /// negligible recoveries; the fee is absorbed from the output via | ||
| /// `ReduceOutput(0)` so no fee-headroom margin is needed here. | ||
| const SWEEP_DUST_THRESHOLD: Credits = 5_000_000; |
There was a problem hiding this comment.
🔴 Blocking: SWEEP_DUST_THRESHOLD (5M) is below the protocol's minimum transfer fee (6.5M)
Sweep eligibility is total > 5_000_000, but the minimum fee for a 1-input/1-output address transfer is address_funds_transfer_input_cost (500_000) + address_funds_transfer_output_cost (6_000_000) = 6_500_000 credits (packages/rs-platform-version/src/version/fee/state_transition_min_fees/v1.rs:14-15). For balances in (5_000_000, 6_500_000), both teardown_one and sweep_one will attempt a ReduceOutput(0) sweep that cannot cover its own fee, so those wallets get retried forever (with the registry entry repeatedly marked Failed) until someone tops them up manually. Raise the threshold above the protocol minimum (and ideally derive it from the platform-version constants so it stays in sync).
source: ['codex']
🤖 Fix this 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/tests/e2e/framework/cleanup.rs`:
- [BLOCKING] lines 26-30: `SWEEP_DUST_THRESHOLD` (5M) is below the protocol's minimum transfer fee (6.5M)
Sweep eligibility is `total > 5_000_000`, but the minimum fee for a 1-input/1-output address transfer is `address_funds_transfer_input_cost (500_000) + address_funds_transfer_output_cost (6_000_000) = 6_500_000` credits (`packages/rs-platform-version/src/version/fee/state_transition_min_fees/v1.rs:14-15`). For balances in `(5_000_000, 6_500_000)`, both `teardown_one` and `sweep_one` will attempt a `ReduceOutput(0)` sweep that cannot cover its own fee, so those wallets get retried forever (with the registry entry repeatedly marked `Failed`) until someone tops them up manually. Raise the threshold above the protocol minimum (and ideally derive it from the platform-version constants so it stays in sync).
| if total > SWEEP_DUST_THRESHOLD { | ||
| sweep_platform_addresses(&wallet, &signer, bank.primary_receive_address()).await?; | ||
| } else { | ||
| tracing::debug!( | ||
| wallet_id = %hex::encode(hash), | ||
| total, | ||
| "orphan platform total below sweep threshold; skipping" | ||
| ); | ||
| } | ||
| sweep_identities(&wallet).await?; | ||
| sweep_core_addresses(&wallet).await?; | ||
| sweep_unused_core_asset_locks(&wallet).await?; | ||
| sweep_shielded(&wallet).await?; | ||
|
|
||
| // Best-effort manager unregister so SPV stops tracking the | ||
| // wallet's addresses on subsequent passes. | ||
| if let Err(err) = manager.remove_wallet(hash).await { | ||
| tracing::warn!( | ||
| target: "platform_wallet::e2e::cleanup", | ||
| wallet_id = %hex::encode(hash), | ||
| error = %err, | ||
| "manager unregister failed after sweep; wallet remains tracked" | ||
| ); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Per-test teardown: drain back to bank, drop the registry entry, | ||
| /// and unregister from the manager. Best-effort — failures retain | ||
| /// the entry so the next startup's [`sweep_orphans`] retries. | ||
| pub async fn teardown_one( | ||
| manager: &Arc<PlatformWalletManager<NoPlatformPersistence>>, | ||
| bank: &BankWallet, | ||
| registry: &PersistentTestWalletRegistry, | ||
| test_wallet: &TestWallet, | ||
| ) -> FrameworkResult<()> { | ||
| test_wallet.sync_balances().await?; | ||
| let total = test_wallet.total_credits().await; | ||
| if total > SWEEP_DUST_THRESHOLD { | ||
| sweep_platform_addresses( | ||
| test_wallet.platform_wallet(), | ||
| test_wallet.address_signer(), | ||
| bank.primary_receive_address(), | ||
| ) | ||
| .await?; | ||
| } | ||
| sweep_identities(test_wallet.platform_wallet()).await?; | ||
| sweep_core_addresses(test_wallet.platform_wallet()).await?; | ||
| sweep_unused_core_asset_locks(test_wallet.platform_wallet()).await?; | ||
| sweep_shielded(test_wallet.platform_wallet()).await?; | ||
|
|
||
| // Drop the registry entry first so an unregister failure | ||
| // doesn't leak it; the wallet has no balance left to recover. | ||
| registry.remove(&test_wallet.id())?; |
There was a problem hiding this comment.
🔴 Blocking: Positive sub-threshold balances are dropped from the registry without sweeping
When total <= SWEEP_DUST_THRESHOLD, teardown_one (lines 147-162) skips sweep_platform_addresses and unconditionally calls registry.remove(...); the orphan path does the same indirectly — sweep_one returns Ok(()) after logging "below sweep threshold; skipping" (lines 109-117), and sweep_orphans then removes the registry entry (lines 58-66). Any wallet that still holds a positive balance under the threshold is therefore forgotten rather than retried or aggregated, permanently stranding real testnet credits and contradicting the README's recovery guarantees. Either keep the entry tagged Failed so a future operator can audit, or only drop entries whose total == 0.
source: ['claude', 'codex']
🤖 Fix this 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/tests/e2e/framework/cleanup.rs`:
- [BLOCKING] lines 109-162: Positive sub-threshold balances are dropped from the registry without sweeping
When `total <= SWEEP_DUST_THRESHOLD`, `teardown_one` (lines 147-162) skips `sweep_platform_addresses` and unconditionally calls `registry.remove(...)`; the orphan path does the same indirectly — `sweep_one` returns `Ok(())` after logging "below sweep threshold; skipping" (lines 109-117), and `sweep_orphans` then removes the registry entry (lines 58-66). Any wallet that still holds a positive balance under the threshold is therefore forgotten rather than retried or aggregated, permanently stranding real testnet credits and contradicting the README's recovery guarantees. Either keep the entry tagged `Failed` so a future operator can audit, or only drop entries whose `total == 0`.
| /// Build a [`SimpleSigner`] populated with the DIP-9 identity-authentication | ||
| /// (ECDSA) gap window for `identity_index`. The returned signer holds raw | ||
| /// secp256k1 secrets keyed on `(pubkey-hash, secret)` via | ||
| /// [`Self::address_private_keys`] — callers that need a `Signer<IdentityPublicKey>` | ||
| /// view must additionally register `IdentityPublicKey` records via | ||
| /// [`Self::add_identity_public_key`] using the matching pubkey bytes. | ||
| #[cfg(feature = "derive")] | ||
| pub fn from_seed_for_identity( | ||
| seed: &[u8; 64], | ||
| network: key_wallet::Network, | ||
| identity_index: u32, | ||
| gap_limit: u32, | ||
| ) -> Result<Self, SimpleSignerError> { | ||
| use key_wallet::bip32::KeyDerivationType; | ||
| use key_wallet::wallet::root_extended_keys::RootExtendedPrivKey; | ||
| use key_wallet::DerivationPath; | ||
|
|
||
| let root_priv = RootExtendedPrivKey::new_master(seed) | ||
| .map_err(|err| SimpleSignerError::InvalidSeed(err.to_string()))?; | ||
| let root_xpriv = root_priv.to_extended_priv_key(network); | ||
|
|
||
| let secp = Secp256k1::new(); | ||
| let mut signer = Self::default(); | ||
| for key_index in 0..gap_limit { | ||
| let leaf_path = DerivationPath::identity_authentication_path( | ||
| network, | ||
| KeyDerivationType::ECDSA, | ||
| identity_index, | ||
| key_index, | ||
| ); | ||
| let xpriv = root_xpriv.derive_priv(&secp, &leaf_path).map_err(|err| { | ||
| SimpleSignerError::DerivePriv { | ||
| index: key_index, | ||
| message: err.to_string(), | ||
| } | ||
| })?; | ||
| let secret: SecretKey = xpriv.private_key; | ||
| let pubkey: PublicKey = PublicKey::from_secret_key(&secp, &secret); | ||
| let pkh = ripemd160_sha256(&pubkey.serialize()); | ||
| signer | ||
| .address_private_keys | ||
| .insert(pkh, secret.secret_bytes()); | ||
| } | ||
| Ok(signer) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: from_seed_for_identity is misleadingly named, half-functional, and unused
The new (feature-gated) constructor derives DIP-9 identity-authentication ECDSA secp256k1 keys but inserts them into address_private_keys: BTreeMap<[u8; 20], [u8; 32]> — the map consumed by Signer<PlatformAddress>::sign (line 339, keyed on the 20-byte address hash). The Signer<IdentityPublicKey> view that the function name implies (line 245) only consults private_keys / private_keys_in_creation, both of which remain empty after this constructor runs. The doc comment hand-waves this with "callers must additionally register IdentityPublicKey records" — but if the caller has to do that themselves the constructor isn't actually "for identity." A repo-wide grep confirms zero callers. Either (a) populate private_keys inside the constructor so identity signing works out of the box, (b) drop it until a real consumer exists, or (c) rename to reflect what it actually populates (e.g. derive_identity_path_into_address_keys).
source: ['claude', 'codex']
🤖 Fix this 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/simple-signer/src/signer.rs`:
- [SUGGESTION] lines 197-241: `from_seed_for_identity` is misleadingly named, half-functional, and unused
The new (feature-gated) constructor derives DIP-9 identity-authentication ECDSA secp256k1 keys but inserts them into `address_private_keys: BTreeMap<[u8; 20], [u8; 32]>` — the map consumed by `Signer<PlatformAddress>::sign` (line 339, keyed on the 20-byte address hash). The `Signer<IdentityPublicKey>` view that the function name implies (line 245) only consults `private_keys` / `private_keys_in_creation`, both of which remain empty after this constructor runs. The doc comment hand-waves this with "callers must additionally register `IdentityPublicKey` records" — but if the caller has to do that themselves the constructor isn't actually "for identity." A repo-wide grep confirms zero callers. Either (a) populate `private_keys` inside the constructor so identity signing works out of the box, (b) drop it until a real consumer exists, or (c) rename to reflect what it actually populates (e.g. `derive_identity_path_into_address_keys`).
| /// Framework-wide shutdown signal for background tasks. Not | ||
| /// tripped by individual test panics — a single failing test | ||
| /// must not cancel SPV / wait helpers for sibling tests. | ||
| pub cancel_token: CancellationToken, | ||
| /// Installed as the harness's `PlatformEventHandler`; test | ||
| /// wallets clone the `Arc` so `wait_for_balance` wakes on real | ||
| /// events instead of fixed polling. | ||
| pub wait_hub: Arc<WaitEventHub>, | ||
| } | ||
|
|
||
| impl E2eContext { | ||
| /// Lazily build (or reuse) the process-shared context. | ||
| /// Concurrent callers serialise inside `OnceCell` — exactly one | ||
| /// build runs. | ||
| pub async fn init() -> FrameworkResult<&'static Self> { | ||
| CTX.get_or_try_init(Self::build).await | ||
| } | ||
|
|
||
| pub fn sdk(&self) -> &Arc<dash_sdk::Sdk> { | ||
| &self.sdk | ||
| } | ||
|
|
||
| pub fn manager(&self) -> &Arc<PlatformWalletManager<NoPlatformPersistence>> { | ||
| &self.manager | ||
| } | ||
|
|
||
| /// Pre-funded bank wallet — the funding source for tests. | ||
| pub fn bank(&self) -> &BankWallet { | ||
| &self.bank | ||
| } | ||
|
|
||
| /// Persistent test-wallet registry — every `setup` registers, | ||
| /// every `teardown` removes its entry. | ||
| pub fn registry(&self) -> &PersistentTestWalletRegistry { | ||
| &self.registry | ||
| } | ||
|
|
||
| /// `None` while the SPV-based context provider is deferred | ||
| /// (Task #15). | ||
| pub fn spv(&self) -> Option<&Arc<SpvRuntime>> { | ||
| self.spv_runtime.as_ref() | ||
| } | ||
|
|
||
| /// Framework-shutdown signal; background helpers can `select!` | ||
| /// on it for graceful shutdown. | ||
| pub fn cancel_token(&self) -> &CancellationToken { | ||
| &self.cancel_token | ||
| } | ||
|
|
||
| pub fn wait_hub(&self) -> &Arc<WaitEventHub> { | ||
| &self.wait_hub | ||
| } | ||
|
|
||
| async fn build() -> FrameworkResult<E2eContext> { | ||
| let config = Config::from_env()?; | ||
|
|
||
| let (workdir, workdir_lock) = workdir::pick_available_workdir(&config.workdir_base)?; | ||
|
|
||
| let cancel_token = CancellationToken::new(); |
There was a problem hiding this comment.
💬 Nitpick: cancel_token is constructed and exposed but never observed
E2eContext::cancel_token is created at line 109, exposed via the cancel_token() accessor at line 96, and the doc comments promise it backs "graceful shutdown" of background helpers. In practice no code in the framework or test cases ever (a) cancel()s it, or (b) select!s on it — wait_for_balance, the deferred SPV blocks, and the test bodies all ignore it. The token is dead state with a forward-looking accessor that tempts misuse. Either drop the field until shutdown wiring lands (Task #15) or add a tokio::select! arm in wait_for_balance so the documented behavior actually fires.
source: ['claude']
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] | ||
| pub enum EntryStatus { | ||
| #[default] | ||
| Active, | ||
| Sweeping, | ||
| Failed, | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: EntryStatus::Sweeping is defined but never set anywhere
The doc comment promises Sweeping is "set transiently so a second process knows the wallet is already being handled." The only set_status call in the codebase is cleanup::sweep_orphans setting EntryStatus::Failed after a failed sweep — no code path ever transitions an entry to Sweeping. Either wire set_status(.., Sweeping) at the start of cleanup::sweep_one (and clear it on success/failure) so the doc claim becomes true, or drop the variant and update the doc.
source: ['claude']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Two blocking issues remain: the live testnet e2e test still runs in the default cargo test path (no #[ignore]), and the cleanup sweep's per-input filter (*b > 0) admits sub-min_input_amount dust into the explicit input map, which DPP rejects with InputBelowMinimumError — making mixed-balance wallets perpetually un-sweepable. The remaining items are correctness/quality suggestions: a fee-floor mismatch in the sweep gate, error-context loss via FrameworkError::NotImplemented, dead-but-public cancel_token, the misnamed SimpleSigner::from_seed_for_identity, premature pub widening of SDK internals, and the SPV path bypassing the slot-locked workdir. Several single-source security findings were dropped as not meeting the bar.
Reviewed commit: 5515ba9
🔴 2 blocking | 🟡 5 suggestion(s) | 💬 3 nitpick(s)
1 additional finding
🟡 suggestion: `fetch_inputs_with_nonce` / `nonce_inc` promoted to `pub` with no caller outside rs-sdk
packages/rs-sdk/src/platform/transition/address_inputs.rs (lines 12-40)
pub mod address_inputs; at transition.rs:3 and pub fn fetch_inputs_with_nonce / pub fn nonce_inc widen these from pub(crate) to pub. A repo-wide grep finds callers only inside crate::platform::transition::* (address_credit_withdrawal.rs, top_up_identity_from_addresses.rs, shield.rs, transfer_address_funds.rs, put_identity.rs); the e2e framework in rs-platform-wallet does not import them, and rs-platform-wallet production code doesn't either. Once pub, the signatures become a stability commitment — nonce_inc in particular is footgun-prone outside the strict fetch→increment→sign→broadcast flow (it does not protect against double-spending the same nonce in concurrent calls). Revert to pub(crate) (or pub(super)) and widen alongside the first external caller.
🤖 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/tests/e2e/cases/transfer.rs`:
- [BLOCKING] lines 62-63: Live testnet e2e test runs by default; `cargo test` hard-fails without operator env
`transfer_between_two_platform_addresses` has no `#[ignore]` and the module docs explicitly say it "Runs by default". `setup()` calls `Config::from_env()`, which returns `FrameworkError::Bank` when `PLATFORM_WALLET_E2E_BANK_MNEMONIC` is unset (`framework/config.rs`); the test escalates that to a panic via `.expect("e2e setup failed")`. Consequence: a stock `cargo test -p platform-wallet` (or workspace-wide invocation) becomes a hard failure for any contributor or CI job without a funded testnet bank wallet, live DAPI access, and the operator `.env`. The crate's own `tests/spv_sync.rs` follows the standard convention of gating live-network tests behind `#[ignore]`. Re-add the gate so default runs stay green and live coverage is opt-in.
In `packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs`:
- [BLOCKING] lines 217-226: Sweep input filter is `b > 0`; sub-`min_input_amount` inputs make mixed wallets permanently un-sweepable
The new total-balance gate at lines 114 and 155 uses `min_input_amount(version)` (good), but the per-input filter inside `sweep_platform_addresses` is still `filter(|(_, b)| *b > 0)`. DPP enforces `min_input_amount` per individual input (`packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funds_transfer_transition/v0/state_transition_validation.rs:157-167` — the loop returns `InputBelowMinimumError` for any amount below the threshold), not on the sum. So a wallet with addr_A=50M and addr_B=50K passes the total gate (50.05M >> 100K) but the broadcast fails with `InputBelowMinimumError`. `teardown_one` returns the error and `sweep_orphans` marks the entry `EntryStatus::Failed` and retries on every startup — it can never succeed without manual intervention. Mirror the production auto-selector and drop sub-`min_input_amount` inputs from the explicit map (the unsweepable dust on those addresses is the same loss already accepted by the wallet-level skip path).
- [SUGGESTION] lines 111-169: Sweep gate is keyed to `min_input_amount` (100K), not the minimum transfer fee (~6.5M)
Both `sweep_one` (line 114) and `teardown_one` (line 155) treat `min_input_amount` as the sweep gate. On current platform versions that value is `100_000`, but the static 1-input/1-output address-transfer fee floor is already `address_funds_transfer_input_cost + address_funds_transfer_output_cost = 6_500_000` (`packages/rs-platform-version/src/version/fee/state_transition_min_fees/v1.rs:14-15`), and this PR's own transfer test commentary notes real chain-time fees closer to ~15M while platform bug #3040 is open (`tests/e2e/cases/transfer.rs:24-33`). So wallets with totals in `[100k, 6.5M)` go down the sweep path even though every `ReduceOutput(0)` attempt will fail (output goes negative or below `min_output_amount`), leaving the orphan permanently in `Failed`. Gate on a fee-aware floor (e.g. the static min-fee plus a safety margin) instead of just the per-input minimum.
In `packages/simple-signer/src/signer.rs`:
- [SUGGESTION] lines 197-241: `from_seed_for_identity` is misleadingly named, half-functional, and unused
The (feature-gated) constructor derives DIP-9 identity-authentication ECDSA secp256k1 keys but inserts them into `address_private_keys: BTreeMap<[u8; 20], [u8; 32]>` — the map consumed by `Signer<PlatformAddress>::sign` (line 339, keyed on the 20-byte address hash). The `Signer<IdentityPublicKey>` impl that the function name implies (line 245) only consults `private_keys` / `private_keys_in_creation`, both of which remain empty after this constructor runs. The doc comment hand-waves this with "callers must additionally register `IdentityPublicKey` records via `add_identity_public_key`" — but if the caller has to do that themselves the constructor isn't actually "for identity." A repo-wide grep confirms zero callers outside this file. Either populate `private_keys` inside the constructor so identity signing works out of the box, drop it until a real consumer exists, or rename to reflect what it actually populates (e.g. `derive_identity_path_into_address_keys`). Beyond the API-quality issue, the dual-keystore reachability (same secret reachable via both signer pathways) is the kind of cross-purpose-key footgun worth eliminating before any production caller arrives.
In `packages/rs-platform-wallet/tests/e2e/framework/sdk.rs`:
- [SUGGESTION] lines 32-41: `FrameworkError::NotImplemented` used as a generic runtime-error wrapper, dropping the underlying error
`SdkBuilder::build()` failure is a real runtime error, not an unimplemented-feature path, but it's mapped to `FrameworkError::NotImplemented("sdk::build_sdk — SdkBuilder::build failed (see logs)")`. The actual error `e` is only emitted via a side-effect `tracing::error!` and then discarded — callers that pattern-match on the `Result` (or render it for CI failure summaries) see only the static `&str`. The same pattern recurs at lines 68-77, 100-103, 113-122 here and at `framework/spv.rs:223-226, 241-244`. The `FrameworkError` enum already has `Wallet(String)`, `Bank(String)`, `Config(String)` variants for this purpose — add `Sdk(String)` / `Spv(String)` variants and propagate `e.to_string()` so CI logs and downstream callers actually receive the underlying message.
In `packages/rs-sdk/src/platform/transition/address_inputs.rs`:
- [SUGGESTION] lines 12-40: `fetch_inputs_with_nonce` / `nonce_inc` promoted to `pub` with no caller outside rs-sdk
`pub mod address_inputs;` at `transition.rs:3` and `pub fn fetch_inputs_with_nonce` / `pub fn nonce_inc` widen these from `pub(crate)` to `pub`. A repo-wide grep finds callers only inside `crate::platform::transition::*` (`address_credit_withdrawal.rs`, `top_up_identity_from_addresses.rs`, `shield.rs`, `transfer_address_funds.rs`, `put_identity.rs`); the e2e framework in rs-platform-wallet does not import them, and rs-platform-wallet production code doesn't either. Once `pub`, the signatures become a stability commitment — `nonce_inc` in particular is footgun-prone outside the strict fetch→increment→sign→broadcast flow (it does not protect against double-spending the same nonce in concurrent calls). Revert to `pub(crate)` (or `pub(super)`) and widen alongside the first external caller.
In `packages/rs-platform-wallet/tests/e2e/framework/spv.rs`:
- [SUGGESTION] lines 210-247: Retained SPV path bypasses the slot-locked workdir
`E2eContext::build` acquires a unique slot via `pick_available_workdir` and stores it in `workdir`, but `build_client_config` derives its storage path from `config.workdir_base.join("spv-data")` (line 216). When the commented-out SPV block in `harness.rs:131-147` is re-enabled (Task #15), every concurrent process will share `<base>/spv-data` instead of using the locked slot directory, defeating the cross-process isolation mechanism and creating avoidable RocksDB/SPV state contention. Because the SPV module is intentionally kept compilable for re-enablement, fix it now — pass the slot workdir into `build_client_config` so SPV storage tracks the lock.
| let inputs: BTreeMap<PlatformAddress, Credits> = wallet | ||
| .platform() | ||
| .addresses_with_balances() | ||
| .await | ||
| .into_iter() | ||
| .filter(|(_, b)| *b > 0) | ||
| .collect(); | ||
| if inputs.is_empty() { | ||
| return Ok(()); | ||
| } |
There was a problem hiding this comment.
🔴 Blocking: Sweep input filter is b > 0; sub-min_input_amount inputs make mixed wallets permanently un-sweepable
The new total-balance gate at lines 114 and 155 uses min_input_amount(version) (good), but the per-input filter inside sweep_platform_addresses is still filter(|(_, b)| *b > 0). DPP enforces min_input_amount per individual input (packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funds_transfer_transition/v0/state_transition_validation.rs:157-167 — the loop returns InputBelowMinimumError for any amount below the threshold), not on the sum. So a wallet with addr_A=50M and addr_B=50K passes the total gate (50.05M >> 100K) but the broadcast fails with InputBelowMinimumError. teardown_one returns the error and sweep_orphans marks the entry EntryStatus::Failed and retries on every startup — it can never succeed without manual intervention. Mirror the production auto-selector and drop sub-min_input_amount inputs from the explicit map (the unsweepable dust on those addresses is the same loss already accepted by the wallet-level skip path).
💡 Suggested change
| let inputs: BTreeMap<PlatformAddress, Credits> = wallet | |
| .platform() | |
| .addresses_with_balances() | |
| .await | |
| .into_iter() | |
| .filter(|(_, b)| *b > 0) | |
| .collect(); | |
| if inputs.is_empty() { | |
| return Ok(()); | |
| } | |
| let dust_gate = min_input_amount(PlatformVersion::latest()); | |
| let inputs: BTreeMap<PlatformAddress, Credits> = wallet | |
| .platform() | |
| .addresses_with_balances() | |
| .await | |
| .into_iter() | |
| .filter(|(_, b)| *b >= dust_gate) | |
| .collect(); | |
| if inputs.is_empty() { | |
| return Ok(()); | |
| } |
source: ['claude', 'codex']
🤖 Fix this 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/tests/e2e/framework/cleanup.rs`:
- [BLOCKING] lines 217-226: Sweep input filter is `b > 0`; sub-`min_input_amount` inputs make mixed wallets permanently un-sweepable
The new total-balance gate at lines 114 and 155 uses `min_input_amount(version)` (good), but the per-input filter inside `sweep_platform_addresses` is still `filter(|(_, b)| *b > 0)`. DPP enforces `min_input_amount` per individual input (`packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funds_transfer_transition/v0/state_transition_validation.rs:157-167` — the loop returns `InputBelowMinimumError` for any amount below the threshold), not on the sum. So a wallet with addr_A=50M and addr_B=50K passes the total gate (50.05M >> 100K) but the broadcast fails with `InputBelowMinimumError`. `teardown_one` returns the error and `sweep_orphans` marks the entry `EntryStatus::Failed` and retries on every startup — it can never succeed without manual intervention. Mirror the production auto-selector and drop sub-`min_input_amount` inputs from the explicit map (the unsweepable dust on those addresses is the same loss already accepted by the wallet-level skip path).
| let platform_version = PlatformVersion::latest(); | ||
| let dust_gate = min_input_amount(platform_version); | ||
| let total = wallet.platform().total_credits().await; | ||
| if total >= dust_gate { | ||
| sweep_platform_addresses(&wallet, &signer, bank.primary_receive_address()).await?; | ||
| } else { | ||
| tracing::debug!( | ||
| wallet_id = %hex::encode(hash), | ||
| total, | ||
| min_input = dust_gate, | ||
| "orphan platform total below protocol min_input_amount; skipping" | ||
| ); | ||
| } | ||
| sweep_identities(&wallet).await?; | ||
| sweep_core_addresses(&wallet).await?; | ||
| sweep_unused_core_asset_locks(&wallet).await?; | ||
| sweep_shielded(&wallet).await?; | ||
|
|
||
| // Best-effort manager unregister so SPV stops tracking the | ||
| // wallet's addresses on subsequent passes. | ||
| if let Err(err) = manager.remove_wallet(hash).await { | ||
| tracing::warn!( | ||
| target: "platform_wallet::e2e::cleanup", | ||
| wallet_id = %hex::encode(hash), | ||
| error = %err, | ||
| "manager unregister failed after sweep; wallet remains tracked" | ||
| ); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Per-test teardown: drain back to bank, drop the registry entry, | ||
| /// and unregister from the manager. Best-effort — failures retain | ||
| /// the entry so the next startup's [`sweep_orphans`] retries. | ||
| pub async fn teardown_one( | ||
| manager: &Arc<PlatformWalletManager<NoPlatformPersistence>>, | ||
| bank: &BankWallet, | ||
| registry: &PersistentTestWalletRegistry, | ||
| test_wallet: &TestWallet, | ||
| ) -> FrameworkResult<()> { | ||
| test_wallet.sync_balances().await?; | ||
| let platform_version = PlatformVersion::latest(); | ||
| let dust_gate = min_input_amount(platform_version); | ||
| let total = test_wallet.total_credits().await; | ||
| if total >= dust_gate { | ||
| sweep_platform_addresses( | ||
| test_wallet.platform_wallet(), | ||
| test_wallet.address_signer(), | ||
| bank.primary_receive_address(), | ||
| ) | ||
| .await?; | ||
| } else { | ||
| tracing::debug!( | ||
| wallet_id = %hex::encode(test_wallet.id()), | ||
| total, | ||
| min_input = dust_gate, | ||
| "test wallet total below protocol min_input_amount; skipping platform sweep" | ||
| ); | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Sweep gate is keyed to min_input_amount (100K), not the minimum transfer fee (~6.5M)
Both sweep_one (line 114) and teardown_one (line 155) treat min_input_amount as the sweep gate. On current platform versions that value is 100_000, but the static 1-input/1-output address-transfer fee floor is already address_funds_transfer_input_cost + address_funds_transfer_output_cost = 6_500_000 (packages/rs-platform-version/src/version/fee/state_transition_min_fees/v1.rs:14-15), and this PR's own transfer test commentary notes real chain-time fees closer to ~15M while platform bug #3040 is open (tests/e2e/cases/transfer.rs:24-33). So wallets with totals in [100k, 6.5M) go down the sweep path even though every ReduceOutput(0) attempt will fail (output goes negative or below min_output_amount), leaving the orphan permanently in Failed. Gate on a fee-aware floor (e.g. the static min-fee plus a safety margin) instead of just the per-input minimum.
source: ['codex']
🤖 Fix this 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/tests/e2e/framework/cleanup.rs`:
- [SUGGESTION] lines 111-169: Sweep gate is keyed to `min_input_amount` (100K), not the minimum transfer fee (~6.5M)
Both `sweep_one` (line 114) and `teardown_one` (line 155) treat `min_input_amount` as the sweep gate. On current platform versions that value is `100_000`, but the static 1-input/1-output address-transfer fee floor is already `address_funds_transfer_input_cost + address_funds_transfer_output_cost = 6_500_000` (`packages/rs-platform-version/src/version/fee/state_transition_min_fees/v1.rs:14-15`), and this PR's own transfer test commentary notes real chain-time fees closer to ~15M while platform bug #3040 is open (`tests/e2e/cases/transfer.rs:24-33`). So wallets with totals in `[100k, 6.5M)` go down the sweep path even though every `ReduceOutput(0)` attempt will fail (output goes negative or below `min_output_amount`), leaving the orphan permanently in `Failed`. Gate on a fee-aware floor (e.g. the static min-fee plus a safety margin) instead of just the per-input minimum.
| /// Build a [`SimpleSigner`] populated with the DIP-9 identity-authentication | ||
| /// (ECDSA) gap window for `identity_index`. The returned signer holds raw | ||
| /// secp256k1 secrets keyed on `(pubkey-hash, secret)` via | ||
| /// [`Self::address_private_keys`] — callers that need a `Signer<IdentityPublicKey>` | ||
| /// view must additionally register `IdentityPublicKey` records via | ||
| /// [`Self::add_identity_public_key`] using the matching pubkey bytes. | ||
| #[cfg(feature = "derive")] | ||
| pub fn from_seed_for_identity( | ||
| seed: &[u8; 64], | ||
| network: key_wallet::Network, | ||
| identity_index: u32, | ||
| gap_limit: u32, | ||
| ) -> Result<Self, SimpleSignerError> { | ||
| use key_wallet::bip32::KeyDerivationType; | ||
| use key_wallet::wallet::root_extended_keys::RootExtendedPrivKey; | ||
| use key_wallet::DerivationPath; | ||
|
|
||
| let root_priv = RootExtendedPrivKey::new_master(seed) | ||
| .map_err(|err| SimpleSignerError::InvalidSeed(err.to_string()))?; | ||
| let root_xpriv = root_priv.to_extended_priv_key(network); | ||
|
|
||
| let secp = Secp256k1::new(); | ||
| let mut signer = Self::default(); | ||
| for key_index in 0..gap_limit { | ||
| let leaf_path = DerivationPath::identity_authentication_path( | ||
| network, | ||
| KeyDerivationType::ECDSA, | ||
| identity_index, | ||
| key_index, | ||
| ); | ||
| let xpriv = root_xpriv.derive_priv(&secp, &leaf_path).map_err(|err| { | ||
| SimpleSignerError::DerivePriv { | ||
| index: key_index, | ||
| message: err.to_string(), | ||
| } | ||
| })?; | ||
| let secret: SecretKey = xpriv.private_key; | ||
| let pubkey: PublicKey = PublicKey::from_secret_key(&secp, &secret); | ||
| let pkh = ripemd160_sha256(&pubkey.serialize()); | ||
| signer | ||
| .address_private_keys | ||
| .insert(pkh, secret.secret_bytes()); | ||
| } | ||
| Ok(signer) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: from_seed_for_identity is misleadingly named, half-functional, and unused
The (feature-gated) constructor derives DIP-9 identity-authentication ECDSA secp256k1 keys but inserts them into address_private_keys: BTreeMap<[u8; 20], [u8; 32]> — the map consumed by Signer<PlatformAddress>::sign (line 339, keyed on the 20-byte address hash). The Signer<IdentityPublicKey> impl that the function name implies (line 245) only consults private_keys / private_keys_in_creation, both of which remain empty after this constructor runs. The doc comment hand-waves this with "callers must additionally register IdentityPublicKey records via add_identity_public_key" — but if the caller has to do that themselves the constructor isn't actually "for identity." A repo-wide grep confirms zero callers outside this file. Either populate private_keys inside the constructor so identity signing works out of the box, drop it until a real consumer exists, or rename to reflect what it actually populates (e.g. derive_identity_path_into_address_keys). Beyond the API-quality issue, the dual-keystore reachability (same secret reachable via both signer pathways) is the kind of cross-purpose-key footgun worth eliminating before any production caller arrives.
source: ['claude', 'codex']
🤖 Fix this 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/simple-signer/src/signer.rs`:
- [SUGGESTION] lines 197-241: `from_seed_for_identity` is misleadingly named, half-functional, and unused
The (feature-gated) constructor derives DIP-9 identity-authentication ECDSA secp256k1 keys but inserts them into `address_private_keys: BTreeMap<[u8; 20], [u8; 32]>` — the map consumed by `Signer<PlatformAddress>::sign` (line 339, keyed on the 20-byte address hash). The `Signer<IdentityPublicKey>` impl that the function name implies (line 245) only consults `private_keys` / `private_keys_in_creation`, both of which remain empty after this constructor runs. The doc comment hand-waves this with "callers must additionally register `IdentityPublicKey` records via `add_identity_public_key`" — but if the caller has to do that themselves the constructor isn't actually "for identity." A repo-wide grep confirms zero callers outside this file. Either populate `private_keys` inside the constructor so identity signing works out of the box, drop it until a real consumer exists, or rename to reflect what it actually populates (e.g. `derive_identity_path_into_address_keys`). Beyond the API-quality issue, the dual-keystore reachability (same secret reachable via both signer pathways) is the kind of cross-purpose-key footgun worth eliminating before any production caller arrives.
| let signer = make_platform_signer(&seed_bytes, network)?; | ||
|
|
||
| let platform_version = PlatformVersion::latest(); | ||
| let dust_gate = min_input_amount(platform_version); | ||
| let total = wallet.platform().total_credits().await; | ||
| if total >= dust_gate { | ||
| sweep_platform_addresses(&wallet, &signer, bank.primary_receive_address()).await?; | ||
| } else { | ||
| tracing::debug!( | ||
| wallet_id = %hex::encode(hash), | ||
| total, | ||
| min_input = dust_gate, | ||
| "orphan platform total below protocol min_input_amount; skipping" | ||
| ); | ||
| } | ||
| sweep_identities(&wallet).await?; | ||
| sweep_core_addresses(&wallet).await?; | ||
| sweep_unused_core_asset_locks(&wallet).await?; | ||
| sweep_shielded(&wallet).await?; | ||
|
|
||
| // Best-effort manager unregister so SPV stops tracking the | ||
| // wallet's addresses on subsequent passes. | ||
| if let Err(err) = manager.remove_wallet(hash).await { | ||
| tracing::warn!( | ||
| target: "platform_wallet::e2e::cleanup", | ||
| wallet_id = %hex::encode(hash), | ||
| error = %err, | ||
| "manager unregister failed after sweep; wallet remains tracked" | ||
| ); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Per-test teardown: drain back to bank, drop the registry entry, | ||
| /// and unregister from the manager. Best-effort — failures retain | ||
| /// the entry so the next startup's [`sweep_orphans`] retries. | ||
| pub async fn teardown_one( | ||
| manager: &Arc<PlatformWalletManager<NoPlatformPersistence>>, | ||
| bank: &BankWallet, | ||
| registry: &PersistentTestWalletRegistry, | ||
| test_wallet: &TestWallet, | ||
| ) -> FrameworkResult<()> { | ||
| test_wallet.sync_balances().await?; | ||
| let platform_version = PlatformVersion::latest(); | ||
| let dust_gate = min_input_amount(platform_version); | ||
| let total = test_wallet.total_credits().await; | ||
| if total >= dust_gate { | ||
| sweep_platform_addresses( | ||
| test_wallet.platform_wallet(), | ||
| test_wallet.address_signer(), | ||
| bank.primary_receive_address(), | ||
| ) | ||
| .await?; | ||
| } else { | ||
| tracing::debug!( | ||
| wallet_id = %hex::encode(test_wallet.id()), | ||
| total, | ||
| min_input = dust_gate, | ||
| "test wallet total below protocol min_input_amount; skipping platform sweep" | ||
| ); | ||
| } | ||
| sweep_identities(test_wallet.platform_wallet()).await?; | ||
| sweep_core_addresses(test_wallet.platform_wallet()).await?; | ||
| sweep_unused_core_asset_locks(test_wallet.platform_wallet()).await?; | ||
| sweep_shielded(test_wallet.platform_wallet()).await?; | ||
|
|
||
| // Drop the registry entry first so an unregister failure | ||
| // doesn't leak it; the wallet has no balance left to recover. | ||
| registry.remove(&test_wallet.id())?; | ||
| if let Err(err) = manager.remove_wallet(&test_wallet.id()).await { | ||
| tracing::warn!( | ||
| target: "platform_wallet::e2e::cleanup", | ||
| wallet_id = %hex::encode(test_wallet.id()), | ||
| error = %err, | ||
| "manager unregister failed after teardown; wallet remains tracked" | ||
| ); | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Sub-min_input_amount balances are silently dropped from the registry
When total < dust_gate, both sweep_one (lines 113-123) and teardown_one (lines 155-169) skip the sweep — sweep_orphans then treats the Ok(()) as a successful recovery and removes the entry (line 62), and teardown_one unconditionally calls registry.remove(...) (line 177). Because dust_gate is PlatformVersion::min_input_amount (currently 100K), the funds in the dropped band are protocol-unsweepable so removing the entry is defensible — but small refund / fee-dust residues then silently disappear from the registry with no audit trail. Consider keeping the entry tagged EntryStatus::Failed with a one-line note like "balance below min_input_amount" so an operator can see what was abandoned, rather than removing it.
source: ['claude', 'codex']
| /// Framework-wide shutdown signal for background tasks. Not | ||
| /// tripped by individual test panics — a single failing test | ||
| /// must not cancel SPV / wait helpers for sibling tests. | ||
| pub cancel_token: CancellationToken, | ||
| /// Installed as the harness's `PlatformEventHandler`; test | ||
| /// wallets clone the `Arc` so `wait_for_balance` wakes on real | ||
| /// events instead of fixed polling. | ||
| pub wait_hub: Arc<WaitEventHub>, | ||
| } | ||
|
|
||
| impl E2eContext { | ||
| /// Lazily build (or reuse) the process-shared context. | ||
| /// Concurrent callers serialise inside `OnceCell` — exactly one | ||
| /// build runs. | ||
| pub async fn init() -> FrameworkResult<&'static Self> { | ||
| CTX.get_or_try_init(Self::build).await | ||
| } | ||
|
|
||
| pub fn sdk(&self) -> &Arc<dash_sdk::Sdk> { | ||
| &self.sdk | ||
| } | ||
|
|
||
| pub fn manager(&self) -> &Arc<PlatformWalletManager<NoPlatformPersistence>> { | ||
| &self.manager | ||
| } | ||
|
|
||
| /// Pre-funded bank wallet — the funding source for tests. | ||
| pub fn bank(&self) -> &BankWallet { | ||
| &self.bank | ||
| } | ||
|
|
||
| /// Persistent test-wallet registry — every `setup` registers, | ||
| /// every `teardown` removes its entry. | ||
| pub fn registry(&self) -> &PersistentTestWalletRegistry { | ||
| &self.registry | ||
| } | ||
|
|
||
| /// `None` while the SPV-based context provider is deferred | ||
| /// (Task #15). | ||
| pub fn spv(&self) -> Option<&Arc<SpvRuntime>> { | ||
| self.spv_runtime.as_ref() | ||
| } | ||
|
|
||
| /// Framework-shutdown signal; background helpers can `select!` | ||
| /// on it for graceful shutdown. | ||
| pub fn cancel_token(&self) -> &CancellationToken { | ||
| &self.cancel_token | ||
| } | ||
|
|
||
| pub fn wait_hub(&self) -> &Arc<WaitEventHub> { | ||
| &self.wait_hub | ||
| } | ||
|
|
||
| async fn build() -> FrameworkResult<E2eContext> { | ||
| let config = Config::from_env()?; | ||
|
|
||
| let (workdir, workdir_lock) = workdir::pick_available_workdir(&config.workdir_base)?; | ||
|
|
||
| let cancel_token = CancellationToken::new(); |
There was a problem hiding this comment.
💬 Nitpick: cancel_token is constructed and exposed but never observed
E2eContext::cancel_token is created at line 109, exposed via the cancel_token() accessor at line 96, and the doc comments promise it backs "graceful shutdown" of background helpers. In practice no code in the framework or test cases ever (a) cancel()s it, or (b) select!s on it — wait_for_balance, the deferred SPV blocks, and the test bodies all ignore it. The token is dead state with a forward-looking accessor that tempts misuse. Either drop the field until shutdown wiring lands (Task #15) or add a tokio::select! arm in wait_for_balance so the documented behavior actually fires.
source: ['claude']
| /// Insert (or overwrite) an entry, persisting before returning. | ||
| /// Last-write-wins on duplicate: failing the insert would risk | ||
| /// leaking the new entry, while a sweep can still recover. | ||
| pub fn insert(&self, hash: WalletSeedHash, entry: RegistryEntry) -> FrameworkResult<()> { | ||
| let snapshot = { | ||
| let mut guard = self.state.lock(); | ||
| guard.insert(hash, entry); | ||
| guard.clone() | ||
| }; | ||
| atomic_write_json(&self.path, &snapshot) | ||
| } | ||
|
|
||
| /// Remove an entry. Missing-key is OK — teardown is best-effort. | ||
| pub fn remove(&self, hash: &WalletSeedHash) -> FrameworkResult<()> { | ||
| let snapshot = { | ||
| let mut guard = self.state.lock(); | ||
| guard.remove(hash); | ||
| guard.clone() | ||
| }; | ||
| atomic_write_json(&self.path, &snapshot) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Test-wallet seeds persisted hex-plaintext to JSON without restrictive file mode
atomic_write_json writes the registry — which contains hex-encoded 64-byte BIP-39 seeds in RegistryEntry::seed_hex — via tempfile::NamedTempFile then persist, with no chmod/0600 step. Default file mode honors umask, so on a multi-user host with a permissive umask another local user could read in-flight test seeds from <workdir>/test_wallets.json. Risk is bounded: seeds are OsRng-generated, ephemeral, scoped to one test run, used only on testnet, and never the bank mnemonic; the workdir defaults to $TMPDIR/dash-platform-wallet-e2e which is typically user-private. Defense-in-depth: set mode 0600 on the temp file before persist, or document that the workdir must be on a user-private mount.
source: ['claude']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Test-only PR adding an e2e harness for rs-platform-wallet plus a small production surface (auto_select_inputs fix, simple-signer derive feature, two pub-visibility bumps). One blocking issue: the live testnet e2e test had its #[ignore] removed but the CI workflow runs platform-wallet --all-features with no env wiring or filter, so it will panic in every CI run. Several smaller architecture / robustness concerns in the framework and unused public-API surface.
Reviewed commit: aad27c5
🔴 1 blocking | 🟡 5 suggestion(s) | 💬 3 nitpick(s)
1 additional finding
💬 nitpick: Inconsistent invariant guarding: debug_assert + runtime check here, debug_assert only in sibling helper
packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs (lines 343-360)
select_inputs_deduct_from_input is private and its only caller (auto_select_inputs) has already pattern-matched the strategy before dispatching here. The function still re-checks the same invariant twice — a debug_assert! (343-350) followed by a runtime if !matches!(...) (351-360) returning an error string referencing an internal function name. The companion select_inputs_reduce_output (570-574) keeps only the debug_assert!. Pick one pattern for private invariant guards and apply it consistently.
🤖 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/tests/e2e/cases/transfer.rs`:
- [BLOCKING] lines 62-63: Live testnet e2e test will panic in CI: #[ignore] removed but workflow runs platform-wallet --all-features with no env wiring or test filter
`transfer_between_two_platform_addresses` is no longer `#[ignore]`. `.github/workflows/tests-rs-workspace.yml` (lines 144-171 and 308-335) runs `cargo nextest --package platform-wallet --all-features --locked` with only an `-E 'not test(~shield)'` filter — no env wiring for `PLATFORM_WALLET_E2E_BANK_MNEMONIC`, no exclusion of the `e2e` test binary, and no `offline-testing`-style feature gate on platform-wallet. Without the env var, `Config::from_env()` returns `FrameworkError::Bank("PLATFORM_WALLET_E2E_BANK_MNEMONIC not set ...")`, `setup().await.expect("e2e setup failed")` panics, and CI fails on every run. Either restore `#[ignore]` until the workflow is updated, or land the workflow change (filter + env wiring) in this PR.
In `packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs`:
- [SUGGESTION] lines 217-223: sweep_platform_addresses includes dust inputs the protocol will reject
`sweep_platform_addresses` collects every address with `balance > 0` and feeds the full map into `InputSelection::Explicit`. The DPP `address_funds_transfer_transition/v0/state_transition_validation.rs:159` rejects any input `< min_input_amount`. The wallet-level gates at lines 113-114 and 154-155 only check `total >= min_input_amount`, not per-address balance, so a wallet with one spendable address plus any sub-minimum dust address (easy to produce once `ReduceOutput(0)` leaves remainders or future tests do partial spends) will fail teardown forever, leaving the registry entry behind. The current single test happens to leave both addresses well above min, but the framework is meant to generalize. Filter individual balances against `min_input_amount` instead of relying on the total gate.
In `packages/rs-sdk/src/platform/transition.rs`:
- [SUGGESTION] line 3: address_inputs promoted to pub with no external consumer
`address_inputs` (and `fetch_inputs_with_nonce` / `nonce_inc`) flipped from `pub(crate)` to `pub`, but every caller in the workspace is still inside rs-sdk itself (`transfer_address_funds.rs`, `address_credit_withdrawal.rs`, `top_up_identity_from_addresses.rs`, `put_identity.rs`, `shield.rs`). The e2e framework added in this PR does not call either function. The signatures expose internal SDK types (`dpp::AddressNonce`, `drive_proof_verifier::types::AddressInfos`, `BTreeMap<PlatformAddress, ...>`) and once `pub`, downgrading is a breaking change. Either land a justified external consumer alongside the visibility bump or keep these `pub(crate)`.
In `packages/simple-signer/src/signer.rs`:
- [SUGGESTION] lines 197-241: from_seed_for_identity is unused in this PR and has a misleading contract
`from_seed_for_identity` populates `self.address_private_keys` (keyed on pubkey-hash, used by `Signer<PlatformAddress>` at lines 379-385) but does not populate `self.private_keys: BTreeMap<IdentityPublicKey, [u8; 32]>`. Per the impl at lines 247-258, `Signer<IdentityPublicKey>::sign` reads from `private_keys` only, so the returned signer cannot satisfy that trait despite the function name. The doc-comment honestly admits callers must additionally call `add_identity_public_key`, but the e2e framework only uses `from_seed_for_platform_address_account`; nothing in the PR consumes `from_seed_for_identity`. Either drop it until a real consumer lands or rename to reflect that it populates the address-signing path (e.g. `from_seed_for_identity_authentication_addresses`) so future callers don't expect a turnkey `Signer<IdentityPublicKey>`.
In `packages/rs-platform-wallet/tests/e2e/framework/sdk.rs`:
- [SUGGESTION] lines 35-38: FrameworkError::NotImplemented misused as a generic error envelope; underlying cause is dropped
`SdkBuilder::build` (and several sibling sites in sdk.rs and spv.rs) wrap a real runtime failure in `FrameworkError::NotImplemented`, whose `Display` reads "e2e framework not yet implemented: ...". The actual error is logged at error-level then discarded. Operators reading test output will see a misleading "not implemented" message when SDK construction in fact failed at runtime, and downstream `Result` matching cannot recover the cause. Add a dedicated `Sdk(String)` (and `Spv(String)`) variant or carry the source via `#[source] Box<dyn Error + Send + Sync>` so the chain survives.
In `packages/rs-platform-wallet/tests/e2e/framework/registry.rs`:
- [SUGGESTION] lines 103-132: Registry mutates in-memory state before the JSON write succeeds
`insert`, `remove`, and `set_status` all lock, mutate `self.state`, clone the snapshot, drop the lock, and only then call `atomic_write_json`. If the write fails, the method returns `Err` but the in-memory map has already changed. That violates the module's own "persist before returning" contract: an `insert` failure leaves an in-memory orphan with no disk record (next-run sweep won't see it), and a `remove` failure forgets the entry in memory while the disk entry persists. Build the snapshot first, persist it, then swap it into `self.state` only after the write succeeds.
| let inputs: BTreeMap<PlatformAddress, Credits> = wallet | ||
| .platform() | ||
| .addresses_with_balances() | ||
| .await | ||
| .into_iter() | ||
| .filter(|(_, b)| *b > 0) | ||
| .collect(); |
There was a problem hiding this comment.
🟡 Suggestion: sweep_platform_addresses includes dust inputs the protocol will reject
sweep_platform_addresses collects every address with balance > 0 and feeds the full map into InputSelection::Explicit. The DPP address_funds_transfer_transition/v0/state_transition_validation.rs:159 rejects any input < min_input_amount. The wallet-level gates at lines 113-114 and 154-155 only check total >= min_input_amount, not per-address balance, so a wallet with one spendable address plus any sub-minimum dust address (easy to produce once ReduceOutput(0) leaves remainders or future tests do partial spends) will fail teardown forever, leaving the registry entry behind. The current single test happens to leave both addresses well above min, but the framework is meant to generalize. Filter individual balances against min_input_amount instead of relying on the total gate.
💡 Suggested change
| let inputs: BTreeMap<PlatformAddress, Credits> = wallet | |
| .platform() | |
| .addresses_with_balances() | |
| .await | |
| .into_iter() | |
| .filter(|(_, b)| *b > 0) | |
| .collect(); | |
| let min_input = PlatformVersion::latest() | |
| .dpp | |
| .state_transitions | |
| .address_funds | |
| .min_input_amount; | |
| let inputs: BTreeMap<PlatformAddress, Credits> = wallet | |
| .platform() | |
| .addresses_with_balances() | |
| .await | |
| .into_iter() | |
| .filter(|(_, b)| *b >= min_input) | |
| .collect(); |
source: ['codex']
🤖 Fix this 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/tests/e2e/framework/cleanup.rs`:
- [SUGGESTION] lines 217-223: sweep_platform_addresses includes dust inputs the protocol will reject
`sweep_platform_addresses` collects every address with `balance > 0` and feeds the full map into `InputSelection::Explicit`. The DPP `address_funds_transfer_transition/v0/state_transition_validation.rs:159` rejects any input `< min_input_amount`. The wallet-level gates at lines 113-114 and 154-155 only check `total >= min_input_amount`, not per-address balance, so a wallet with one spendable address plus any sub-minimum dust address (easy to produce once `ReduceOutput(0)` leaves remainders or future tests do partial spends) will fail teardown forever, leaving the registry entry behind. The current single test happens to leave both addresses well above min, but the framework is meant to generalize. Filter individual balances against `min_input_amount` instead of relying on the total gate.
| //! State transitions used to put changed objects to the Dash Platform. | ||
| pub mod address_credit_withdrawal; | ||
| pub(crate) mod address_inputs; | ||
| pub mod address_inputs; |
There was a problem hiding this comment.
🟡 Suggestion: address_inputs promoted to pub with no external consumer
address_inputs (and fetch_inputs_with_nonce / nonce_inc) flipped from pub(crate) to pub, but every caller in the workspace is still inside rs-sdk itself (transfer_address_funds.rs, address_credit_withdrawal.rs, top_up_identity_from_addresses.rs, put_identity.rs, shield.rs). The e2e framework added in this PR does not call either function. The signatures expose internal SDK types (dpp::AddressNonce, drive_proof_verifier::types::AddressInfos, BTreeMap<PlatformAddress, ...>) and once pub, downgrading is a breaking change. Either land a justified external consumer alongside the visibility bump or keep these pub(crate).
💡 Suggested change
| pub mod address_inputs; | |
| pub(crate) mod address_inputs; |
source: ['claude']
🤖 Fix this 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-sdk/src/platform/transition.rs`:
- [SUGGESTION] line 3: address_inputs promoted to pub with no external consumer
`address_inputs` (and `fetch_inputs_with_nonce` / `nonce_inc`) flipped from `pub(crate)` to `pub`, but every caller in the workspace is still inside rs-sdk itself (`transfer_address_funds.rs`, `address_credit_withdrawal.rs`, `top_up_identity_from_addresses.rs`, `put_identity.rs`, `shield.rs`). The e2e framework added in this PR does not call either function. The signatures expose internal SDK types (`dpp::AddressNonce`, `drive_proof_verifier::types::AddressInfos`, `BTreeMap<PlatformAddress, ...>`) and once `pub`, downgrading is a breaking change. Either land a justified external consumer alongside the visibility bump or keep these `pub(crate)`.
| /// Build a [`SimpleSigner`] populated with the DIP-9 identity-authentication | ||
| /// (ECDSA) gap window for `identity_index`. The returned signer holds raw | ||
| /// secp256k1 secrets keyed on `(pubkey-hash, secret)` via | ||
| /// [`Self::address_private_keys`] — callers that need a `Signer<IdentityPublicKey>` | ||
| /// view must additionally register `IdentityPublicKey` records via | ||
| /// [`Self::add_identity_public_key`] using the matching pubkey bytes. | ||
| #[cfg(feature = "derive")] | ||
| pub fn from_seed_for_identity( | ||
| seed: &[u8; 64], | ||
| network: key_wallet::Network, | ||
| identity_index: u32, | ||
| gap_limit: u32, | ||
| ) -> Result<Self, SimpleSignerError> { | ||
| use key_wallet::bip32::KeyDerivationType; | ||
| use key_wallet::wallet::root_extended_keys::RootExtendedPrivKey; | ||
| use key_wallet::DerivationPath; | ||
|
|
||
| let root_priv = RootExtendedPrivKey::new_master(seed) | ||
| .map_err(|err| SimpleSignerError::InvalidSeed(err.to_string()))?; | ||
| let root_xpriv = root_priv.to_extended_priv_key(network); | ||
|
|
||
| let secp = Secp256k1::new(); | ||
| let mut signer = Self::default(); | ||
| for key_index in 0..gap_limit { | ||
| let leaf_path = DerivationPath::identity_authentication_path( | ||
| network, | ||
| KeyDerivationType::ECDSA, | ||
| identity_index, | ||
| key_index, | ||
| ); | ||
| let xpriv = root_xpriv.derive_priv(&secp, &leaf_path).map_err(|err| { | ||
| SimpleSignerError::DerivePriv { | ||
| index: key_index, | ||
| message: err.to_string(), | ||
| } | ||
| })?; | ||
| let secret: SecretKey = xpriv.private_key; | ||
| let pubkey: PublicKey = PublicKey::from_secret_key(&secp, &secret); | ||
| let pkh = ripemd160_sha256(&pubkey.serialize()); | ||
| signer | ||
| .address_private_keys | ||
| .insert(pkh, secret.secret_bytes()); | ||
| } | ||
| Ok(signer) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: from_seed_for_identity is unused in this PR and has a misleading contract
from_seed_for_identity populates self.address_private_keys (keyed on pubkey-hash, used by Signer<PlatformAddress> at lines 379-385) but does not populate self.private_keys: BTreeMap<IdentityPublicKey, [u8; 32]>. Per the impl at lines 247-258, Signer<IdentityPublicKey>::sign reads from private_keys only, so the returned signer cannot satisfy that trait despite the function name. The doc-comment honestly admits callers must additionally call add_identity_public_key, but the e2e framework only uses from_seed_for_platform_address_account; nothing in the PR consumes from_seed_for_identity. Either drop it until a real consumer lands or rename to reflect that it populates the address-signing path (e.g. from_seed_for_identity_authentication_addresses) so future callers don't expect a turnkey Signer<IdentityPublicKey>.
source: ['claude']
🤖 Fix this 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/simple-signer/src/signer.rs`:
- [SUGGESTION] lines 197-241: from_seed_for_identity is unused in this PR and has a misleading contract
`from_seed_for_identity` populates `self.address_private_keys` (keyed on pubkey-hash, used by `Signer<PlatformAddress>` at lines 379-385) but does not populate `self.private_keys: BTreeMap<IdentityPublicKey, [u8; 32]>`. Per the impl at lines 247-258, `Signer<IdentityPublicKey>::sign` reads from `private_keys` only, so the returned signer cannot satisfy that trait despite the function name. The doc-comment honestly admits callers must additionally call `add_identity_public_key`, but the e2e framework only uses `from_seed_for_platform_address_account`; nothing in the PR consumes `from_seed_for_identity`. Either drop it until a real consumer lands or rename to reflect that it populates the address-signing path (e.g. `from_seed_for_identity_authentication_addresses`) so future callers don't expect a turnkey `Signer<IdentityPublicKey>`.
| const DEFAULT_ACCOUNT_INDEX: u32 = 0; | ||
| const DEFAULT_KEY_CLASS: u32 = 0; |
There was a problem hiding this comment.
💬 Nitpick: DEFAULT_ACCOUNT_INDEX/DEFAULT_KEY_CLASS in mod.rs duplicate wallet_factory's pinned spec without sharing the drift guard
wallet_factory.rs pins DEFAULT_PLATFORM_PAYMENT_ACCOUNT_SPEC from PlatformPaymentAccountSpec::default() and exports DEFAULT_ACCOUNT_INDEX_PUB / DEFAULT_KEY_CLASS_PUB with a drift test. mod.rs:40-41 declares its own DEFAULT_ACCOUNT_INDEX = 0; DEFAULT_KEY_CLASS = 0; and feeds them into make_platform_signer. If PlatformPaymentAccountSpec::default() ever drifts, TestWallet::create (uses WalletAccountCreationOptions::Default) would track the new value while make_platform_signer would still derive 0/0 keys — signer/wallet drift without firing the existing test. Re-export from wallet_factory so there's one source of truth.
source: ['claude']
| fn atomic_write_json( | ||
| path: &Path, | ||
| state: &HashMap<WalletSeedHash, RegistryEntry>, | ||
| ) -> FrameworkResult<()> { | ||
| use std::io::Write; | ||
|
|
||
| let on_disk = encode_keys(state); | ||
| let bytes = serde_json::to_vec_pretty(&on_disk).map_err(|err| { | ||
| FrameworkError::Io(format!("serialising registry to {}: {err}", path.display())) | ||
| })?; | ||
| let parent = path.parent().ok_or_else(|| { | ||
| FrameworkError::Io(format!( | ||
| "registry path {} has no parent directory", | ||
| path.display() | ||
| )) | ||
| })?; | ||
| fs::create_dir_all(parent) | ||
| .map_err(|err| FrameworkError::Io(format!("creating {}: {err}", parent.display())))?; | ||
|
|
||
| // Same-filesystem temp file is required for atomic rename; | ||
| // `persist` (not `persist_noclobber`) overwrites cross-platform. | ||
| let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(|err| { | ||
| FrameworkError::Io(format!("creating temp file in {}: {err}", parent.display())) | ||
| })?; | ||
| tmp.write_all(&bytes).map_err(|err| { | ||
| FrameworkError::Io(format!("writing temp file {}: {err}", tmp.path().display())) | ||
| })?; | ||
| tmp.as_file_mut().flush().map_err(|err| { | ||
| FrameworkError::Io(format!( | ||
| "flushing temp file {}: {err}", | ||
| tmp.path().display() | ||
| )) | ||
| })?; | ||
| tmp.persist(path).map_err(|err| { | ||
| FrameworkError::Io(format!("persisting temp file -> {}: {err}", path.display())) | ||
| })?; | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Test wallet seeds persisted to default-permissioned JSON under shared TMPDIR
atomic_write_json writes the registry (containing 64-byte hex seeds for every fresh test wallet) under ${TMPDIR}/dash-platform-wallet-e2e/... with default umask permissions. On Linux/macOS that's typically /tmp and world-readable. On a multi-user runner or shared dev host, a co-located unprivileged user could read seeds between setup and teardown and drain the testnet credits. Impact is bounded (testnet credits, narrow window, operators self-select), but defense-in-depth is cheap: chmod the registry file to 0600 and the slot dir to 0700, or default the workdir base to ${HOME}/.cache/dash-platform-wallet-e2e.
source: ['claude']
… bleed (S-1) + regression guard #559 root cause: the orphan/teardown sweep's Platform-address leg signed with the static 0..DIP17_GAP_LIMIT make_platform_signer that B-2 (#557) fixed only on BankWallet::load. A panicked al_001 strands ~1.3 DASH on the test wallet; next-run startup sweep_orphans can't sign drifted-index (>=20) funded addresses -> sweep Failed -> funds never return -> permanent one-way bank bleed. Pre-existing, TEST-HARNESS-ONLY (cleanup.rs under tests/, zero src/ refs), not Stage-2-introduced. S-1: new shared derive_sweep_pool_signer (exact mirror of #557's BankWallet::derive_pool_signer) — post-sync_balances, build the Platform sweep signer over the synced funded pool 0..=max(synced,highest_generated) +DIP17_GAP_LIMIT via the existing additive SimpleSigner::from_seed_for_platform_addresses; make_platform_signer kept as the no-pool fallback. Wired into sweep_one (orphan) and teardown_one (was test_wallet.address_signer(), also static-window). Margin code-derived & bounded: the sweep is InputSelection::Explicit + ReduceOutput(0) -> sdk.transfer_address_funds directly, no auto_select_inputs, no change branch, zero run-time change addresses (Explicit-path analog of #557's Auto finding). No production/<S: Signer>/API change. Regression guard: static_gap_window_signer_cannot_sign_drifted_index — non-funded deterministic unit test asserting the static 0..20 signer has NO key for a DIP-17 index-25 address while the pool signer does. Fails if any fixed-window signer returns to a sweep/funding path. S-2 (bank self-consolidation): STOPPED, concrete shared-bank hazard surfaced — the bank is cross-process-shared (bank.rs header: only the workdir is per-process; mnemonic/UTXO set shared); no cross-process interlock exists (registry.rs:30-33 deliberately rejected one). A startup self-consolidation debiting the bank's own UTXOs would race concurrent processes' fund_address input selection -> double-spend/strand. S-1 alone stops the bleed; S-2 options surfaced in /tmp/bilby-s1s2-560.md for a design decision. Not shipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full e2e re-run at
|
| Total | PASS | RED-by-design | RED — new finding | RED — environmental | SKIPPED |
|---|---|---|---|---|---|
| 109 | 70 | 2 | 1 | 36 | 0 |
Zero green-paint: tests that prove a bug stay RED.
PASS highlights (70)
- Regression guards held:
found_017(registration-persist-error) ✅,found_017_register_wallet_store_ok_persists✅,found_024(V27-007 transfer-foreign pollution) ✅. - Cross-runner:
cr_001(SPV MN-list),cr_003(asset-lock funded registration),cr_004(legacy BIP-32 UTXO post-spend) ✅. - Identity:
id_001,id_002,id_005,id_007,id_sweep_recovers_identity_credits✅. - Platform-address:
pa_001,pa_001b::{a,b},pa_009::{a,b}✅. - Framework unit tests (~62) ✅.
RED-by-design (2 — correctly RED, pinning known unfixed bugs)
found_021_instant_lock_dropped_on_context_promotion—transaction_record.rs:182-184unconditionalself.context = contextsilently drops InstantLock on InBlock promotion.found_022_asset_lock_builder_consumes_change_index_on_failure—set_fundingcallsnext_change_address(..., add_to_state=true)BEFOREbuild_signedruns; failed builds permanently advance BIP-44-account-0monitor_revision.
Both pure unit tests, deterministic, full diagnostic printed.
RED — new finding (1, NOT a Stage-2 regression)
ID-002b — AssetLockFundingType::IdentityTopUp entries absent from tracked_asset_locks after a successful top-up.
- The test is
#[ignore]-gated onPLATFORM_WALLET_E2E_BANK_CORE_GATE(Layer-1 funding prereq) and only ran because this invocation passed--include-ignored— i.e. it never executed in prior runs. It is not a Stage-2 regression; it is the e2e framework now able to surface a production bug that the previously-blocked guard was always going to find. - The top-up itself landed on-chain (observed identity balance
100,091,903,040≥ expected50,100,000,000); the asset-lock build pipeline (shared with CR-003) is healthy; only the per-funding-type tracking is broken. - Blast radius: any caller enumerating
tracked_asset_locksto find top-up entries (UI, recovery) silently misses them. - Filed as a follow-up production fix; not a blocker for this PR (which delivers the framework that surfaced it).
RED — environmental (36)
Cause: testnet DAPI envoy rate-limit storm starting at T+91s (≈ 2026-05-20T07:15:14Z). Error class: ResourceExhausted / x-envoy-ratelimited: true escalating to NoAvailableAddresses as all endpoints (68.67.122.{5,21,24}:1443) were banned by the client. All 14 threads hit the same window — failure cluster is dense, not spread.
Wiped: all P0 token tests (tk_001..tk_014), several PA-* (pa_003..pa_009::c, pa_3040_bug_pin), dpns_001, id_003, and al_001 — the Found-008 discriminator never reached its decision point (failed at setup register_identity_from_addresses before the concurrent build step).
Note on Found-008: this run yields no fresh verdict on #3641 — the discriminator did not execute. The earlier 4× corroboration (#3634 proof.rs code evidence + healthy-wakeup traces + 3 prior ENVIRONMENTAL (NOT Found-008) discriminator emissions + persistor cleared) remains canonical. A clean al_001 GREEN under a low-load testnet window is still the documented honest follow-up.
Spec mismatches (3, advisory)
- SM-001 — ID-002b: spec says
blocked — #[ignore]d on bank Core funding prereq; with the gate met under--include-ignoredthe test ran and surfaced the new finding above. Spec status will update to reflect "exercises a production bug" once the fix lands. - SM-002 — PA-005b: subcases A/B/C all passed their assertions; only
teardown().await.expect(...)failed under the DAPI storm, propagating an environmental error as a test failure. Harness fragility, not a code defect — candidate for log-and-continue on the teardown sweep. - SM-003 — AL-001: did not reach its in-process discriminator; spec entry ("active Found-008 regression guard") still correct, just not exercised this run.
Verdict
The PR — e2e framework + full test suite — performs exactly as designed: 70 green, 2 RED-by-design holding their pins, regression guards (Found-017, Found-024, CR-, ID-, PA-*) all green. The single new RED (ID-002b) is the framework correctly surfacing a separate production bug, not a defect of this PR. The 36 environmentals are a transient testnet DAPI rate-limit storm.
Honest follow-ups (separately tracked, NOT release gates for this PR):
- Production fix for
IdentityTopUptracked_asset_locksregistration (ID-002b finding). - A cosmetically-clean re-run on a low-load testnet window to exercise TK-* / al_001 discriminator / DPNS-001 etc.
🤖 Co-authored by Claudius the Magnificent AI Agent
…l-version builder Adds SdkBuilder::with_initial_version() for auto-detect SDKs that must talk to a network whose protocol version is older than the binary's PlatformVersion::latest() (e.g. v3.0 testnet from a v3.1+ SDK). Unlike with_version(), this leaves auto-detect active so the existing fetch_max ratchet can still pick up newer network versions. Adds V0/V1 dispatch to the DocumentQuery -> GetDocumentsRequest encoder, driven by the platform_version's drive_abci.query.document_query.default_current_version feature version. V0 ships the legacy CBOR-encoded where/order_by shape; v1-only fields (selects/group_by/having/count_star projections) are rejected with Error::Config at request build time rather than silently emitting a malformed V0 request the server would round-trip-and-reject. The SDK trampolines (Fetch::fetch_with_metadata_and_proof, FetchMany::fetch_many_with_metadata_and_proof) populate the new DocumentQuery.protocol_version_override field from sdk.protocol_version_number() before transport. Dispatch is a single TypeId comparison via std::any::Any; no-op for every non-document request type. Adds a 'static bound to the Fetch::Request / FetchMany::Request associated types (all existing proto-generated request types satisfy it). Fixes the misleading 'could not decode data contracts query' error text emitted by the documents-query decoder when the V1 oneof tag is absent (e.g. when a v3.1 SDK sends V1 to a v3.0 server that only knows V0). The data-contracts handler still uses its own correct string. Tests cover V0/V1 wire-shape parity, dispatch by SDK version, v1-only feature rejection on V0, and with_initial_version atomic seeding semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… to exercise auto-detect upward ratchet Calls SdkBuilder::with_initial_version(PV_10) in framework/sdk.rs::build_sdk so every funded e2e run seeds the per-instance protocol_version atomic deliberately BELOW the expected Dash Platform v3.0 testnet PV (= PV_11) and then exercises the upward ratchet path landed in #3483: maybe_update_protocol_version's fetch_max should bump the atomic to the network's reported version on the first proof-verified response. A clean run therefore proves two things at once: - v3.0 wire compatibility holds — the SDK starts at PV_10 (whose DRIVE_ABCI_QUERY_VERSIONS_V0 binding pins document_query.{max,default} to 0), so the just-landed V0/V1 dispatch picks V0 wire bytes which v3.0 HPMNs deserialize. No more 'could not decode' decode storm. - The auto-detect upward ratchet is doing its job — the atomic ends up >= the network's reported PV after the first response metadata round-trip. If it didn't ratchet, subsequent versioned-feature surfaces would silently regress. Hardcoded as a live-network regression probe; once the upward-ratchet shape stops needing live verification this can be replaced with PlatformVersion::get(11) (or whatever PV the shared testnet rolls to). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sion_override Replaces the smuggling-via-DocumentQuery-field mechanism added in 8d5de89 with a direct &Sdk argument on Query::query(). The GetDocumentsRequest V0/V1 encoder now reads sdk.version() at the call site, eliminating: - DocumentQuery::protocol_version_override field - #[cfg_attr(feature = "mocks", serde(skip))] workaround - apply_sdk_protocol_version helper + + 'static trait bounds - TypeId::downcast_mut hack in Fetch / FetchMany trampolines Same observable behaviour; cleaner trait shape; PV is now a first-class concern in the Query trait for future versioned request types.
… + adopt TryFromPlatformVersioned PV_V1..V10 were wired to DRIVE_ABCI_QUERY_VERSIONS_V1, causing the SDK to emit V1 getDocuments wire when seeded with an older PV. Testnet v3.0 HPMNs (PV_11) reject this. Sibling fix to 2b8eae0 which re-pinned PV_11. Also collapses encode_get_documents_request freestanding helper into a TryFromPlatformVersioned impl, removing one indirection layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-platform-wallet-e2e # Conflicts: # packages/rs-drive/benches/document_count_worst_case.rs
…transport Commit 34e0395 added TryFromPlatformVersioned<DocumentQuery> with V0/V1 dispatch, but DocumentQuery::execute_transport still went through the ambient TryFrom<DocumentQuery> trap that hardcoded PlatformVersion::latest() (= PV_12 = V1 wire). The runtime PV from sdk.version() never reached the encoder, so v3.0 testnet (PV_11, V0 wire) still received V1 bytes and rejected with the "could not decode" storm (165 occurrences in last funded e2e run). This commit: - Adds a DocumentQuery.wire_protocol_version pin and reads it in execute_transport via TryFromPlatformVersioned (falls back to PlatformVersion::latest() with a debug trace when unset, so a direct TransportRequest caller is loud not silent). - Sets the pin from the Query<T> for T blanket impl in platform/query.rs via a runtime Any-downcast on the cloned request (the blanket is the only Query<DocumentQuery> path the Fetch / FetchMany trampolines reach, since Fetch::Request for Document is DocumentQuery, not GetDocumentsRequest). Lower-blast-radius than removing the blanket and re-impling for ~50 proto types. - Deletes the ambient TryFrom<DocumentQuery> for GetDocumentsRequest impl (silent PlatformVersion::latest() default was the trap). - Adds tracing::debug! at the encoder dispatch site and reworks the PV ratchet info! to use a stable target/from/to shape (closes Marvin's QA-004 observability gap). - #[serde(skip)] on wire_protocol_version keeps existing mock vectors hash-stable: the pin is a transport-side dispatch input, not part of the query's identity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the architectural smell flagged after 8c0d614 and prevents recurrence for the 58 other tracked query operations that may grow versioned wire in the future. Removes the `Any::downcast_mut::<DocumentQuery>()` runtime type-erasure from the blanket `Query<T> for T` impl and the `wire_protocol_version: Option<u32>` field from `DocumentQuery`. The PV-aware encoder now runs inside `Query::query(&self, sdk)` where `&Sdk` is in hand — extending V0/V1 dispatch to any future versioned request type is a 5-line `impl Query<NewWireType> for NewRichType` away. # What changed * `Fetch::Query` (new associated type): the user-facing query that callers hand to the SDK and that `FromProof` binds to. For non-versioned operations `type Query = Self::Request` (one extra line per impl); for documents and the six aggregate views (`DocumentCount`/`Sum`/`Average`/`SplitCounts`/ `SplitSums`/`SplitAverages`) `type Query = DocumentQuery` (the rich form with data contract context) and `type Request = GetDocumentsRequest` (the wire). * `FromProof<Self::Query>` (was `FromProof<Self::Request>`): the proof verifier surface keeps binding to the rich form unchanged — zero changes to the eight `FromProof<DocumentQuery>` impls in `packages/rs-sdk/src/platform/documents/`. * `Sdk::parse_proof_with_metadata_and_proof` (renamed parameter source): takes `method_name: &'static str` as an explicit argument instead of reading it from `O::Request: TransportRequest`, since the rich query is no longer required to implement `TransportRequest`. Trampoline call sites pass `wire.method_name()` explicitly. * `DocumentQuery: TransportRequest` impl removed. Only `GetDocumentsRequest` implements `TransportRequest` now. Direct callers that constructed a `DocumentQuery` and pushed it through `rs-dapi-client` no longer compile — they should call `Document::fetch(...)` or `DocumentQuery::try_into_request_for_version(pv)` instead. * `'static` bound dropped from the `Query<T> for T` blanket (was only required by the deleted `Any::downcast`). * Mock infrastructure: `MockDashPlatformSdk::expect[_many]` now keys the `from_proof_expectations` cache on the rich `Self::Query` (preserving the protocol-version-agnostic mock key property) while keying the DAPI executor mock on the wire `Self::Request` (where the proto bytes actually flow). The internal `expect` / `remove` helpers take both args explicitly. # Mock vector regeneration Existing checked-in vectors under `packages/rs-sdk/tests/vectors/document_*/` were captured with filenames `msg_DocumentQuery_<hash>.json`. After this refactor the DAPI executor dumps the wire `GetDocumentsRequest` instead, so the filenames become `msg_GetDocumentsRequest_<hash>.json` with hashes computed from proto bytes (PV-coupled). Affected tests in `packages/rs-sdk/tests/fetch/document.rs` are gated with `#[ignore = "vectors require regeneration after Fetch::Query/Fetch::Request split (γ refactor); see commit body"]`: * `document_read` * `document_read_no_document` * `document_list_drive_query` * `document_list_document_query` * `document_list_bug_value_text_decode_base58_PLAN_653` To regenerate, run the live-testnet path that produces vectors (`yarn start && yarn test:sdk` or per-test `cargo test ... -- --ignored` with `DUMP_DIR` set per the sdk dump conventions). After regen, the old `msg_DocumentQuery_*.json` files can be deleted. The remaining document-related tests use `expect_fetch` programmatically and register expectations at test runtime — those continue to pass without any vector changes (`tests/fetch/document_count.rs`, `tests/fetch/mock_fetch.rs`, `tests/fetch/mock_fetch_many.rs::test_mock_document_fetch_many`). # Public API breaks (acceptable on v3.1-dev) * `<Document as Fetch>::Request` is now `GetDocumentsRequest`, not `DocumentQuery`. Code that named this explicitly breaks. * `DocumentQuery` no longer implements `TransportRequest`. Callers using `DocumentQuery` directly with `rs-dapi-client::DapiRequest` break. * `DocumentQuery::wire_protocol_version` public field is removed. * `Query::query` keeps the `(prove: bool, sdk: &Sdk)` signature for this commit (the `prove` parameter collapse is the planned second commit per the 3-commit Phase B plan). # Verification `cargo check --workspace --exclude wasm-sdk --exclude wasm-dpp --exclude rs-sdk-ffi` clean. `cargo check -p wasm-sdk` clean. `cargo test -p dash-sdk --features mocks,offline-testing --lib` → 138 passed, 0 failed, 6 ignored. `cargo test -p dash-sdk --features mocks,offline-testing --tests` → 127 passed, 0 failed, 8 ignored (the +4 ignores are the document tests gated above; one was pre-existing). `cargo test -p drive-abci --lib query` → 585 passed, 0 failed, 1 ignored. `cargo test -p platform-version` → 5 passed, 0 failed. `cargo test -p platform-wallet --no-run` clean. `cargo fmt --all` applied; `cargo clippy -p dash-sdk --features mocks,offline-testing --tests -- -D warnings` clean. `rg 'Any::downcast' packages/rs-sdk/src` returns nothing. `rg wire_protocol_version packages --include='*.rs'` returns nothing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tonic merge Drops the QA-002 hardening of `update_sync_state` (per-field `max()` over the previous watermark and the incoming sync result) and the four unit tests that pinned it. Restores the original unconditional overwrite of all three watermark fields. Rationale: the active call path (`PlatformAddressWallet::sync_balances`) holds the unified provider's write lock across the SDK sync and the `update_sync_state` call, so two same-provider syncs serialise — no observable rollback race reachable through this path (@QuantumExplorer in #3647). Independently, per-field `max()` on `last_known_recent_block` violates the SDK's paired-cursor sentinel-zero contract documented in `rs-sdk/.../address_sync/mod.rs:431-435,472`: preserving a non-zero `last_known_recent_block` while `sync_height` advances on a result that itself carried zero recent_block produces an inconsistent cursor pair and risks replaying non-idempotent recent-tree AddToCredits (@thepastaclaw in #3647). The "defensive hardening" introduced a real invariant violation while not fixing a reachable bug. The `pub last_known_recent_block()` provider accessor and the wallet's `sync_watermark()` helper (added separately by #3563) are left in place; they are independent of the merge contract being reverted here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Reverted the TL;DR — the hardening was both unmotivated and unsafe:
The The standalone PR #3647 that proposed the same per-field-max change has been closed; the 🤖 Co-authored by Claudius the Magnificent AI Agent |
…) into feat/rs-platform-wallet-e2e Brings v3.1-dev's QuerySettings encoder API and the auto-select-inputs stack into the e2e branch. Conflict resolutions favored v3.1-dev's canonical versions except where the e2e stack carried a more complete fix (Found-017 fail-closed registration persist). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Point the e2e operator template at the `porter` devnet, sourced from the porter Ansible inventory: - network = devnet - DAPI seeds = the 11 porter HP-masternode public IPs on port 1443 - trusted-context URL = https://quorums.devnet.porter.networks.dash.org (convention-derived; flagged with a TODO since the inventory names no quorums host) - SPV P2P port = 19799 (devnet has no built-in default in the harness) The harness already supports devnet end-to-end via env vars — explicit PLATFORM_WALLET_E2E_DAPI_ADDRESSES bypass the testnet/mainnet seed builders, dashcore::Network parses "devnet", and the trusted-context provider / SPV peer-seeder honour the URL and P2P-port overrides. No framework code change was needed. The previous testnet template is preserved verbatim at tests/.env.example.testnet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v3.1-dev merge changed three lib APIs the e2e test tree still called the old way, breaking compilation of the whole e2e binary: - `transfer` / `transfer_with_change_address` now take `IndexMap<PlatformAddress, Credits>` (insertion-ordered public outputs map). Callers convert their `BTreeMap` at the boundary via `.into_iter().collect()` — BTreeMap's sorted iteration keeps the resulting order deterministic. - `address_inputs::nonce_inc` is now crate-private and `fetch_inputs_with_nonce` is re-exported at `dash_sdk::platform::transition`. The byte-capture helpers import the public re-export and apply their own +1 increment (a 4-line `bump_input_nonces`), matching the documented "or apply their own checked increment" contract. - `PlatformWalletError::OnlyOutputAddressesFunded` gained `sub_min_count` / `sub_min_aggregate` dust breadcrumbs. The classification unit test fills both with 0. Test-only changes; no lib code touched. `cargo check -p platform-wallet --tests --features shielded` and `cargo test --test e2e --features shielded -- --list` (110 tests) now succeed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…[ignore] Replace the per-test #[ignore] gating of the live e2e suite with a cargo feature gate: - New `e2e` feature pulls in `shielded` so an e2e run also exercises the shielded-pool cases. - New `[[test]] name = "e2e" required-features = ["e2e"]` keeps the network-dependent harness out of a stock `cargo test` build entirely — it compiles and runs only under `--features e2e`. Workspace CI stays green with nothing to skip, no operator env required. - Removed all 38 `#[ignore = "..."]` attributes from the 38 gated cases under tests/e2e/cases/ (single- and multi-line forms). Test logic untouched; the `#[tokio_shared_rt::test]` attributes remain. - Refreshed stale doc-comment prose that described the old #[ignore] gate to describe the feature gate (present-state). - README run commands now use `--features e2e` in place of `--include-ignored` / `--ignored`. Verified: `cargo test --test e2e --features e2e -- --list` enumerates 110 tests, 0 ignored; `cargo test -p platform-wallet` (no feature) does not build the e2e binary; `cargo fmt --check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e for e2e feature gate Two present-state references described the retired #[ignore]/--ignored run mechanism; point them at the `e2e` cargo feature instead: - ID-007 spec body gating clause. - Section 7 "Known Issues" preamble run-behaviour bullets. Dated/historical ledger entries that mention #[ignore] (changelog notes, status lines, reproduction notes) are intentionally left untouched — rewriting them would falsify the change-history record. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… status line
Swap the now-retired `cargo test -- --ignored` run instruction for the
`e2e` feature-gated invocation. The dated status descriptor ("PASS in
v47") is preserved verbatim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…A-001) The porter devnet publishes no quorums HTTP host (quorums.devnet.porter.networks.dash.org is NXDOMAIN), so TrustedHttpContextProvider's construction-time DNS check killed setup() before any wallet work — all 110 e2e tests died at init. The 11 porter HP DAPI nodes (:1443) are reachable; only the quorums host is missing. Reuse the already-present (but dormant) SpvContextProvider — quorum keys come from the local SPV runtime's masternode list, no hosted quorums service needed. Add a CompositeContextProvider that routes quorum lookups to SPV while keeping data-contract / token-config lookups on the TrustedHttpContextProvider's known-contracts cache, so the harness keeps its add_known_contract surface (QA-900) intact. Selection via PLATFORM_WALLET_E2E_CONTEXT_PROVIDER=spv|http with a sensible auto-default: mainnet/testnet -> http (built-in endpoints); devnet/local -> http when a trusted URL is set, else spv. The HTTP path is untouched for testnet. In spv mode the trusted provider is built as a cache-only store anchored at a reachable DAPI host (never fetched), and the SDK's active provider is swapped to the composite after the SPV mn-list syncs (ArcSwap-backed set_context_provider). A loud warn guards the disable_spv + spv misconfiguration (no quorum source). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ME fixes QA-004: add cases/print_bank_address_offline.rs — derives the bank's Platform (L2, DIP-17 idx 0) and Core (L1, BIP-44 ext idx 0) receive addresses via offline key derivation, no SDK / SPV / network. Lets operators get the fund-me addresses on a fresh network even when the context host is unreachable. Mirrors framework::bank's exact derivation. Task #3 (.env.example): document PLATFORM_WALLET_E2E_CONTEXT_PROVIDER=spv as the porter default; demote the bogus TODO(porter) quorums host to an optional, commented-out http-mode-only override. QA-003 (README): reconcile the funding section with reality. Platform funding is primary, but Core duffs (direct top-up or Platform->Core refill) ARE needed for the Core / asset-lock cases (CR-003, ID-002b, AL-001); init gates on the bank's Core balance (BANK_CORE_GATE). Add the new CONTEXT_PROVIDER var to the env table and reference the offline util. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bootstrap Replace the ad-hoc setup-phase floor checks (drain → assert_floor → refill → assert_core) with one cost-ordered planner over four account types: PLATFORM (hub), IDENTITY, SHIELDED, CORE. bank_plan.rs owns the decision (pure, deterministic plan()): measure deficits, pick the cheapest edge to close each — fast L2 (drain/top-up) < shield (E4) < one-time Core→Platform asset-lock (E5) ≪ Platform→Core withdrawal (E1, last resort, real Core deficit only). execute() dispatches each Move to the bank_rebalance primitives (mechanism layer, reused). Execution order is hub-first per the design: drain identity → E5 bootstrap if Platform short → fan out top-up/shield → Core withdrawal last. E5 (the crux of "fund only Core, framework handles the rest") is now wired: the harness SeedBackedCoreSigner drives create_funded_asset_lock_proof, materialises the credit-output private key from the seed at the returned derivation path (test-only — production keeps keys in the signer), and feeds fund_from_asset_lock. Requires SPV for the ChainLocked proof, so it errors clearly under DISABLE_SPV=1. assert_all_floors subsumes the prior assert_floor (Platform panic) + assert_core_funded_for_one_pass (Core error), preserving both shapes, and adds soft identity/shielded WARNs. Insufficient total funds fail the whole run with one operator-actionable error: per-type have/need/short + both fixed top-up addresses. Idempotent: a re-run at min is a no-op. New knobs (behaviour-preserving defaults): MIN_IDENTITY_CREDITS (30M), MIN_SHIELDED_CREDITS (500M, NON-ZERO/on by default per user decision — WARN+skip when the prover/coordinator is unconfigured rather than hang). MIN_BANK_CREDITS is the PLATFORM floor; token floor stays a soft warn. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README: new "Smart fund planner" section (account-type model, cost-ordered edges, Core-only bootstrap with the DISABLE_SPV caveat, insufficient-funds fail-the-run behaviour). Add MIN_IDENTITY_CREDITS / MIN_SHIELDED_CREDITS to the env table; clarify MIN_BANK_CREDITS is the PLATFORM floor. .env.example: document the two new knobs, including that shielded is non-zero/on by default (set to 0 to opt out of the prover warm-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
The rs-platform-wallet end-to-end test framework + full test suite: the e2e harness, triage-pin cases and Found-/PA-/AL-/CR- regression guards, the QA-001/PA-005 work, and the campaign's correctness fixes. As part of it, this branch also carries the Stage-2 merge of
#3554(auto-select-inputs — which brings v3.1-dev #3634 identity registration with asset-lock proofs + #3633 getDocuments v1), the fail-closed persist policy, the e2e bank-harness signer fixes (B-2/S-1), and the rs-sdkGetDocumentsV0/V1 versioned-encoder fix + transport wiring +Fetch::Querytrait refactor that unblocks the e2e suite against Dash Platform v3.0 testnet (the current shared testnet release). The Stage-2 merge is one component, not the whole of this PR.What landed
#3549 ← #3554, the PR's actual stacked base) — delivers feat: identity registration with asset-lock proofs #3634/Found-008 + the v3.1-dev advance onto the e2e branch.PlatformWalletError::PersistedAfterOnChainSuccessenforced at the 5 post-on-chain-success persistence sites (registration,top_up_from_addresses,transfer,transfer_to_addresses,withdrawal) — roll back in-memory state + propagate a typed, non-conflatable error instead of log-and-continue.LockNotifyHandler::notify_waiters()drops lock events arriving inwait_for_proof's check/await gap (concurrent asset-lock builds stall on FinalityTimeout) #3641 — confirmed NOT regressed by Stage-2, corroborated four independent ways: feat: identity registration with asset-lock proofs #3634proof.rswaiter-side pre-arm code evidence; prior healthy-wakeup traces; an in-processlist_tracked_locks()discriminator that explicitly classified failuresENVIRONMENTAL (NOT Found-008)across 3 independent testnet windows; and persistor/fail-closed proven not implicated.found_008retired (F-A) — was a misconceived pin (exercised correcttokio::Notifyno-permit semantics, neverwait_for_proof); AL-001 is the genuine Found-008 guard.wallet_lifecycle.rs); pin accounting reconciled.SimpleSigner::from_seed_for_platform_addresses; existing constructor byte-stable). Harness robustness; not a production behavior change.GetDocumentsV0/V1 versioned encoder + transport wiring +Fetch::Querytrait refactor. Three landed pieces (now backported tov3.1-devas sibling PR fix(rs-sdk,drive-abci): SDK emits incompatible getDocuments wire against pre-v3.1 networks #3699):2b8eae05+ced1eb5f, etc.) — changesQuery::query()to take&Sdkso theDocumentQuery → GetDocumentsRequestencoder can readsdk.version()and dispatch onplatform_version.drive_abci.query.document_query.default_current_version: V0 wire (CBORwhere/order_by, plainuint32 limit) for v3.0-class networks; V1 wire (structuredWhereClause/OrderClause,optional uint32 limit,selects/group_by/having/offset) for v3.1+. V1-only features (group_by,having, count/sum/avg projections) reject at request-build time withError::Configrather than emitting a malformed V0 request the server round-trips and rejects. AddsSdkBuilder::with_initial_version(&PlatformVersion)— additive builder method that seeds the protocol-version atomic without disabling auto-detect, so an SDK can start at an older PV and the existing upward-onlymaybe_update_protocol_version(fetch_max) ratchets up once the network's actual version arrives in response metadata. AddsDRIVE_ABCI_QUERY_VERSIONS_V0(verbatim fork of_V1exceptdocument_query.{min,max,default}=0,0,0) and re-pinsPROTOCOL_VERSION_1..10(incl. PV_11 — the in-tree const for Dash Platform v3.0.0) to this new const. The root cause: feat(platform): getDocuments v1 — SQL-shaped select + count surface #3633 (90441b90fc, "feat(platform): getDocuments v1") retroactively bumped_V1'sdocument_querybounds from V0 → V1 without forking a_V0module, so PV_11 silently started reporting V1 semantics even thoughv3.0.0shipped V0 wire on the server. The fix restores PV_11's actual v3.0 semantics; PROTOCOL_VERSION_12 is untouched and keeps V1 doc-query. Also fixes the misleading "could not decode data contracts query" error string the documents handler emitted when V1's oneof tag was absent.8c0d6142ad, "fix(rs-sdk): connect PV-aware encoder to liveDocumentQuery::execute_transport") — the encoder from (a) was reachable viaQuery::query()but not via the live transport path.DocumentQuery::execute_transportwas still using the SDK-lessTryFrom<DocumentQuery> for GetDocumentsRequestimpl, which falls back toPlatformVersion::latest()— meaning live wallets against v3.0 testnet still emitted V1 wire despite the encoder existing. This commit threadssdk.version()throughexecute_transport. Adds a fallback trace at the call site.fedfce8396, "refactor(rs-sdk): splitFetch::Query(rich) fromFetch::Request(wire)") — cleanup of (b). Introduces an explicit associated typeFetch::Queryfor the rich, SDK-aware query type (e.g.DocumentQuery) distinct fromFetch::Request(the wire-level transport request, e.g.GetDocumentsRequest). Replaces the ad-hocAny-downcast inQuery::query()with a proper trait-level PV-aware override (impl Query<GetDocumentsRequest> for DocumentQuery). Removes the now-redundantTransportRequest for DocumentQueryimpl and thewire_protocol_versionfield. Same observable behaviour as (b) — cleaner shape.QueryItem::AggregateSumOnRangedrive bench arm (via merge).Test plan
cargo check -p platform-wallet -p simple-signer→ clean;cargo test -p platform-wallet --no-run→ clean (all e2e bins link).cargo test -p dash-sdk --features mocks,offline-testing --lib→ 133 passed.cargo test -p dash-sdk --features mocks,offline-testing --tests→ 127 passed (incl. V0/V1 wire-shape + dispatch_by_sdk_pv).cargo test -p drive-abci --lib query→ 585 passed.cargo test -p platform-version→ 5 passed.bank.fund_addressall succeed), the discriminator validated in production, Found-008 confirmed not-regressed.NoAvailableAddressescascades.🤖 Co-authored by Claudius the Magnificent AI Agent