Skip to content

test(platform-wallet): e2e framework + full test suite — triage pins, Found-*/PA-* guards, fail-closed persist, Stage-2 merge#3549

Draft
Claudius-Maginificent wants to merge 444 commits into
v3.1-devfrom
feat/rs-platform-wallet-e2e
Draft

test(platform-wallet): e2e framework + full test suite — triage pins, Found-*/PA-* guards, fail-closed persist, Stage-2 merge#3549
Claudius-Maginificent wants to merge 444 commits into
v3.1-devfrom
feat/rs-platform-wallet-e2e

Conversation

@Claudius-Maginificent

@Claudius-Maginificent Claudius-Maginificent commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Blocking dependency — rust-dashcore #808 (Core 23 platform addresses)

This branch temporarily pins all dashpay/rust-dashcore workspace deps to rust-dashcore#808 (fix/rpc-json-core23-platform-addresses, rev 7f1b46b9) instead of branch = "dev" — see the # TEMPORARY marker in Cargo.toml.

  • Problem: Core 23 moved an Evo masternode's platform ports into a nested addresses object; the top-level platformP2PPort/platformHTTPPort are gone. Without backfill, new_validator_if_masternode_in_state sees empty ports and silently drops the evonode from the validator set after a Core 23 upgrade.
  • Fix carried here: fix(ci): fix release workflow syntax error #808 adds the backfill + breaking field/accessor renames (platform_p2p_portlegacy_platform_p2p_port; new platform_p2p_address() / platform_http_address() accessors). This branch migrates drive-abci's DMNState / DMNStateDiff conversions to the accessors and adds the validator_built_from_core23_addresses_entry regression test. Core-22 nodes resolve byte-identical ports via the legacy fallback → no consensus split on existing data.
  • Merge gate: do not merge until fix(ci): fix release workflow syntax error #808 lands in rust-dashcore dev and the pin is reverted to branch = "dev". Until then drive-abci does not compile against dev (the addresses field is absent there).

Why this PR exists

The rs-platform-wallet crate lacked an end-to-end test framework and suite capable of running against a live Dash Platform testnet. This PR delivers that framework — including bank-wallet funding mechanics, orphan-wallet cleanup, a multi-phase fund planner, proof-verified balance checks, and a full corpus of Found-/PA-/AL-/CR-/TK-* regression guards — plus the correctness fixes surfaced by running it against paloma (the shared devnet).


What was done

Foundation & Stage-2 merge

  • Stage-2 merge (#3549 ← #3554) — delivers feat: identity registration with asset-lock proofs #3634/Found-008 + the v3.1-dev advance onto the e2e branch.
  • Fail-closed persist policy (E-A): new additive PlatformWalletError::PersistedAfterOnChainSuccess enforced at the 5 post-on-chain-success persistence sites — roll back in-memory state + propagate a typed, non-conflatable error instead of log-and-continue.
  • Found-008 / Found-008: LockNotifyHandler::notify_waiters() drops lock events arriving in wait_for_proof's check/await gap (concurrent asset-lock builds stall on FinalityTimeout) #3641 — confirmed NOT regressed by Stage-2, corroborated four independent ways.
  • found_008 retired (F-A) — was a misconceived pin; AL-001 is the genuine Found-008 guard.
  • Found-017 guard activated — registration-persist-error regression guard now runs.
  • AL-001 reclassified — stale RED-by-design → active Found-008 regression guard with environmental-vs-regression discriminator.
  • B-2 — e2e bank Platform signer derived from the synced funded pool.
  • S-1 — e2e cleanup/orphan-sweep Platform signer static-window fund-bleed fixed.
  • rs-sdk GetDocuments V0/V1 versioned encoder + transport wiring + Fetch::Query trait refactor (three landed pieces, backported to v3.1-dev as fix(rs-sdk,drive-abci): SDK emits incompatible getDocuments wire against pre-v3.1 networks #3699):
    • (a) PV-aware encoder: V0 wire for v3.0-class networks, V1 for v3.1+; SdkBuilder::with_initial_version additive helper.
    • (b) Wiring fix: threads sdk.version() through execute_transport so live wallets against v3.0 testnet emit the correct wire format.
    • (c) Trait split: explicit Fetch::Query (rich) vs Fetch::Request (wire) associated types; removes ad-hoc Any-downcast.

Harness robustness (recent, validated on paloma)

  • Orphan-sweep idempotencysweep_orphans now handles WalletAlreadyExists gracefully: when a wallet was re-registered by SPV persistent state across a process restart, the sweep retrieves the existing handle via get_wallet() instead of failing. Prevents a WalletAlreadyExists cascade from leaving orphan funds stranded. Validated: 0 occurrences across 19 paloma tests (runs 4–6).

  • Funding minimums raised (config.rs):

    • EXPECTED_TOKEN_SUITE_FLOOR: 50B → 88.8B credits — reflects observed per-suite consumption on paloma; prevents mid-run exhaustion of TK token tests.
    • DEFAULT_MIN_BANK_CREDITS: 500M → 200B credits — guarantees the bank always meets the 88.8B floor so TK token tests run instead of silently passing while skipping all assertions (QA-012 false-green trap).
  • bug(dapi): stale nonce/balance read after confirmed broadcast due to DAPI replica lag #3611 stale-balance gate fix (bank.rs, bank_plan.rs, harness.rs):
    sync_balances(None) at startup can land on a lagging DAPI replica and return 0 credits for the bank's Platform address even when the real balance is ~225B. Previously the code treated harness_credits=0 as authoritative, which: (a) set bank_floor_satisfied=false → token tests silently skipped; (b) caused the fund planner to see a false 200B deficit and attempt a spurious Core→Platform asset-lock; (c) caused assert_floor() to panic. Validated on paloma run-4: harness_credits=0, independent_credits=225358877701.

    Fix: BankWallet::effective_platform_credits() = max(wallet_cache, adopted_platform_floor). When the independent proof-verified AddressInfo::fetch shows significantly more credits than the wallet cache (positive drift > 100M credits), the harness retries sync_and_refresh_floor() up to 3× (2 s between tries). If still diverged, accept_independent_platform_balance() adopts the DAPI-verified reading as the authoritative floor for all three call sites — floor gate, fund planner, and assert_floor().

  • rs-dapi-client — transient gRPC errors: 5 s flat cooldown instead of 60 s×exp ban (address_list.rs, dapi_client.rs, lib.rs, transport.rs, transport/grpc.rs, executor.rs):
    Transient, client-fixable gRPC codes were subject to the same 60 s × exp(ban_count) ban used for genuine node failures. For ResourceExhausted, this caused a cascade: one rate-limited node gets expelled → remaining nodes absorb its load → they hit their limit → they get banned → NoAvailableAddressesToRetry.

    New CanRetry::is_transient_error() trait method returns true for four client-fixable codes:

    • ResourceExhausted — per-node rate limit (HTTP 429). The node is healthy; banning it cascades load.
    • DeadlineExceeded — client-side timeout under load. Slow ≠ dead.
    • Aborted — MVCC transaction conflict. State-machine level; another node wouldn't resolve it.
    • Cancelled — client-side cancel. No node fault.

    These receive a 5 s flat rate_limit_cooldown() (ban_count unchanged) instead of the exponential ban. Genuine server-side codes (Unavailable, Internal, DataLoss, Unimplemented, Unknown) continue to receive the full exponential ban. ban_info().banned fixed to match get_live_address() filter semantics. 8 integration tests added.

    Caveat: this fix eliminates the rate-limit cascade for well-provisioned environments. It does not resolve sustained over-capacity rate-limiting where the server per-IP limit (e.g. 150 req/min/node on paloma) is structurally too low for the test thread count — that requires the operator to raise requestsPerUnit. In that over-capacity regime, the old aggressive ban actually shed load better by rotating traffic away from throttled nodes (eval: run-3 old: 130/40 vs run-5/6 new: 89/93). The cooldown is the principled default for correctly-provisioned environments.


Testing

  • 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.
  • cargo test -p rs-dapi-client → 116 passed (incl. 8 new transient-cooldown integration tests).
  • AL-001 exercised across multiple funded testnet runs: B-2 confirmed fixed, Found-008 confirmed not-regressed.
  • Orphan-sweep idempotency, rate-limit fix, and bug(dapi): stale nonce/balance read after confirmed broadcast due to DAPI replica lag #3611 balance fix all validated on paloma runs 4–6: 0 WalletAlreadyExists, 0 rate-limit cascade events, bug(dapi): stale nonce/balance read after confirmed broadcast due to DAPI replica lag #3611 recovery confirmed.

Known limitation — paloma per-IP Envoy rate limit:
A fully-green full-suite run on paloma is currently blocked by the devnet's per-IP rate limit (150 req/min/node). Cascade confirmed at 2, 3, and 4 --test-threads (run-6 at 2 threads, no concurrent test: 1530 NoAvailableAddresses). This is a server-side infrastructure constraint — the operator must raise requestsPerUnit on the paloma Envoy config. Client-side fixes (orphan sweep, balance cache, rate-limit cooldown) are validated; remaining suite failures are rate-limit infra + 2 known client-side product bugs (id_002b external-signable regression, pa_007 watermark).


Breaking changes

None. All changes are additive (new trait methods with default impls, new struct fields with defaults, new public constants). The total_credits() API is unchanged; effective_platform_credits() is a new parallel method.


Checklist

  • Code compiles (cargo check clean)
  • Clippy clean (-D warnings)
  • cargo fmt applied
  • Tests added for new behaviour (rate-limit cooldown integration tests)
  • No secrets committed (BANK_MNEMONIC read from env, not hardcoded)
  • Incidental fmt drift reverted (operations.rs, sh_018 not included)

🤖 Co-authored by Claudius the Magnificent AI Agent

@github-actions github-actions Bot added this to the v3.1.0 milestone Apr 27, 2026
@coderabbitai

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

An end-to-end testing framework for rs-platform-wallet is added with shared process context, bank wallet funding, persistent wallet registry, cleanup/orphan sweeping, and complete test infrastructure including configuration management, SDK setup, event notification, and example test cases demonstrating transfers between platform addresses. Related SDK modules are also exposed for external use, and seed-based signer constructors are introduced.

Changes

Cohort / File(s) Summary
E2E Framework Infrastructure
packages/rs-platform-wallet/tests/e2e/framework/config.rs, workdir.rs, registry.rs
Environment variable loading with .env file support, workdir slot reservation with exclusive file locking, and persistent JSON-backed wallet seed registry with atomic writes and corruption recovery.
E2E Harness & Context
packages/rs-platform-wallet/tests/e2e/framework/harness.rs, mod.rs
Process-shared E2eContext initialization via OnceCell, SDK/wallet manager/bank/registry/event-hub injection, setup utilities for seed generation and test wallet creation with registry entry insertion.
Bank Wallet & Funding
packages/rs-platform-wallet/tests/e2e/framework/bank.rs
BIP-39 mnemonic-backed bank wallet with minimum-credit validation, DIP-17 address derivation, and concurrent-safe fund transfers using global async mutex to prevent nonce races.
Cleanup & Lifecycle
packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs, wallet_factory.rs
Orphan wallet sweeping at startup with fund drainage and registry status updates, per-test teardown with unregistration, plus test wallet factory with address selection, balance sync, and cleanup guards.
SDK & Event Infrastructure
packages/rs-platform-wallet/tests/e2e/framework/sdk.rs, wait_hub.rs, context_provider.rs
SDK construction with TrustedHttpContextProvider, testnet DAPI endpoint defaults, WaitEventHub for event-driven async test waits, and SpvContextProvider implementing platform SDK context bridge.
Wait & Polling Utilities
packages/rs-platform-wallet/tests/e2e/framework/wait.rs, spv.rs
Generic polling loop with timeout, event-driven balance-change waiter with sync/notification integration, SPV client startup and masternode-list sync readiness checker with progress logging.
E2E Test Cases & Entry Points
packages/rs-platform-wallet/tests/e2e.rs, cases/mod.rs, cases/transfer.rs, README.md, .env.example
Integration test root module, test case organization, fund-and-transfer example test verifying fee deduction, framework documentation with operator setup requirements and architecture overview, and environment configuration template.
Cargo Dependencies
packages/rs-platform-wallet/Cargo.toml
Dev-dependency additions: tokio-shared-rt, tempfile, dotenv, bip39, fs2, parking_lot, simple-signer, SDK context provider, plus tokio-util rt feature.
SDK Public API Exposure
packages/rs-sdk/src/platform/transition.rs, transition/address_inputs.rs
Module visibility change from crate-restricted to public; functions fetch_inputs_with_nonce and nonce_inc exposed for external callers.
Signer Feature & Constructors
packages/simple-signer/Cargo.toml, signer.rs
New derive feature pulling key-wallet and thiserror dependencies; two seed-based constructors for platform-address and identity signers with BIP-32 derivation and error mapping.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes


🐰 Hopping through the test warren,
New E2E frames make wallets soar-in',
Bank funds and registry keep,
While cleanup sweeps run deep,
SDK paths now public—hooray, no more hide-n'! 🌙✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title comprehensively captures the main additions: e2e framework, full test suite, and notes quality gates (triage pins, guards, fail-closed persist, Stage-2 merge).
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rs-platform-wallet-e2e

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

❤️ Share

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

@lklimek lklimek changed the title feat(rs-platform-wallet): integration test framework + first transfer test test(platform-wallet): integration test framework + first transfer test Apr 27, 2026
@lklimek lklimek requested a review from Copilot April 27, 2026 14:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_inputs in 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.

Comment thread packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/README.md Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/README.md Outdated
Comment thread packages/rs-platform-wallet/tests/e2e.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/registry.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/registry.rs Outdated
@lklimek lklimek changed the base branch from v3.1-dev to fix/rs-platform-wallet-auto-select-inputs April 28, 2026 07:17
Comment thread packages/rs-platform-wallet/tests/e2e/cases/transfer.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/cases/transfer.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/cases/transfer.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/cases/transfer.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/bank.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/panic_hook.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/sdk.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/wait.rs
Comment thread packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs
Comment thread packages/rs-platform-wallet/tests/e2e/framework/signer.rs Outdated
@lklimek

lklimek commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai review all

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@lklimek Sure! I'll kick off a full review of all the changes in this PR.


🧠 Learnings used
Learnt from: QuantumExplorer
Repo: dashpay/platform PR: 3564
File: packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs:300-317
Timestamp: 2026-04-29T20:49:09.968Z
Learning: In `packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs`, the `platform_wallet.identity().sync().await` call inside `register_wallet` is intentionally blocking (not detached to a background task). This is a UX design choice: for recovery flows, identity data must be hydrated before the wallet detail view renders to avoid showing an empty identity list that fills in seconds later. The sync is bounded by Platform query timeouts (~10s worst case) and failures are non-fatal (caught + logged with `tracing::warn`). Do not flag this as a blocking-call issue.

Learnt from: QuantumExplorer
Repo: dashpay/platform PR: 3564
File: packages/rs-platform-wallet/src/changeset/core_bridge.rs:105-109
Timestamp: 2026-04-29T20:49:08.152Z
Learning: In `packages/rs-platform-wallet/src/changeset/core_bridge.rs`, the `RecvError::Lagged` arm in `spawn_wallet_event_adapter` intentionally only logs a warning and continues. A full wallet reconciliation/resync cannot be triggered here because the upstream `key_wallet_manager`/`dashcore` crate does not yet expose a rescan entry point. This is known technical debt to revisit when rust-dashcore adds a rescan API.
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

♻️ Duplicate comments (1)
packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs (1)

57-75: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep sub-threshold wallets recoverable.

If 0 < total <= SWEEP_DUST_THRESHOLD, both cleanup paths skip sweep_platform_addresses and 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 with ReduceOutput(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

📥 Commits

Reviewing files that changed from the base of the PR and between 74b81d1 and ae98ccf.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (23)
  • packages/rs-platform-wallet/Cargo.toml
  • packages/rs-platform-wallet/tests/.env.example
  • packages/rs-platform-wallet/tests/e2e.rs
  • packages/rs-platform-wallet/tests/e2e/README.md
  • packages/rs-platform-wallet/tests/e2e/cases/mod.rs
  • packages/rs-platform-wallet/tests/e2e/cases/transfer.rs
  • packages/rs-platform-wallet/tests/e2e/framework/bank.rs
  • packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs
  • packages/rs-platform-wallet/tests/e2e/framework/config.rs
  • packages/rs-platform-wallet/tests/e2e/framework/context_provider.rs
  • packages/rs-platform-wallet/tests/e2e/framework/harness.rs
  • packages/rs-platform-wallet/tests/e2e/framework/mod.rs
  • packages/rs-platform-wallet/tests/e2e/framework/registry.rs
  • packages/rs-platform-wallet/tests/e2e/framework/sdk.rs
  • packages/rs-platform-wallet/tests/e2e/framework/spv.rs
  • packages/rs-platform-wallet/tests/e2e/framework/wait.rs
  • packages/rs-platform-wallet/tests/e2e/framework/wait_hub.rs
  • packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs
  • packages/rs-platform-wallet/tests/e2e/framework/workdir.rs
  • packages/rs-sdk/src/platform/transition.rs
  • packages/rs-sdk/src/platform/transition/address_inputs.rs
  • packages/simple-signer/Cargo.toml
  • packages/simple-signer/src/signer.rs

Comment thread packages/rs-platform-wallet/tests/e2e/cases/transfer.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/cases/pa_002_partial_fund.rs
Comment thread packages/rs-platform-wallet/tests/e2e/framework/config.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/registry.rs
Comment thread packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs
Comment thread packages/rs-platform-wallet/tests/e2e/framework/workdir.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/workdir.rs
Comment thread packages/rs-platform-wallet/tests/e2e/README.md Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/README.md Outdated
@lklimek lklimek requested a review from Copilot April 30, 2026 06:19
@lklimek lklimek marked this pull request as ready for review April 30, 2026 06:19
@thepastaclaw

thepastaclaw commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 921833f)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + 1 can overflow when nonce == u32::MAX, which will panic in debug builds and wrap in release builds. Consider using checked_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_nonce is now public but has no doc comment explaining (1) that it performs existence/balance checks and (2) that callers typically need to apply nonce_inc before building a transfer (as transfer_address_funds does). 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.

Comment thread packages/rs-platform-wallet/tests/e2e/cases/transfer.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/README.md Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/registry.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/cases/transfer.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/README.md Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/README.md Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/cleanup.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/README.md Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/README.md Outdated

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/rs-platform-wallet/tests/e2e/cases/transfer.rs Outdated
Comment on lines +202 to +211
let inputs: BTreeMap<PlatformAddress, Credits> = wallet
.platform()
.addresses_with_balances()
.await
.into_iter()
.filter(|(_, b)| *b > 0)
.collect();
if inputs.is_empty() {
return Ok(());
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: 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.

Comment on lines +26 to +30
/// 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: 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).

Comment on lines +109 to +162
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())?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: 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`.

Comment on lines +197 to +241
/// 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)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: 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`).

Comment thread packages/rs-platform-wallet/tests/e2e/framework/sdk.rs Outdated
Comment thread packages/rs-platform-wallet/tests/e2e/framework/spv.rs Outdated
Comment on lines +51 to +109
/// 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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: 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']

Comment on lines +31 to +37
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum EntryStatus {
#[default]
Active,
Sweeping,
Failed,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: 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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/rs-platform-wallet/tests/e2e/cases/transfer.rs Outdated
Comment on lines +217 to +226
let inputs: BTreeMap<PlatformAddress, Credits> = wallet
.platform()
.addresses_with_balances()
.await
.into_iter()
.filter(|(_, b)| *b > 0)
.collect();
if inputs.is_empty() {
return Ok(());
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: 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
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).

Comment on lines +111 to +169
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"
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: 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.

Comment on lines +197 to +241
/// 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)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: 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.

Comment thread packages/rs-platform-wallet/tests/e2e/framework/sdk.rs
Comment thread packages/rs-platform-wallet/tests/e2e/framework/spv.rs
Comment on lines +109 to +187
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(())
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: 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']

Comment on lines +51 to +109
/// 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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: 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']

Comment on lines +100 to +120
/// 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)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: 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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/rs-platform-wallet/tests/e2e/cases/transfer.rs Outdated
Comment on lines +217 to +223
let inputs: BTreeMap<PlatformAddress, Credits> = wallet
.platform()
.addresses_with_balances()
.await
.into_iter()
.filter(|(_, b)| *b > 0)
.collect();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: 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
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: 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
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)`.

Comment on lines +197 to +241
/// 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)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: 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>`.

Comment thread packages/rs-platform-wallet/tests/e2e/framework/sdk.rs
Comment thread packages/rs-platform-wallet/tests/e2e/framework/registry.rs
Comment on lines +40 to +41
const DEFAULT_ACCOUNT_INDEX: u32 = 0;
const DEFAULT_KEY_CLASS: u32 = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: 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']

Comment on lines +152 to +189
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(())
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: 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']

lklimek and others added 3 commits June 10, 2026 16:55
…) shielded identity-create e2e

Adds the e2e case for the one shielded transition the suite never
exercised: create a brand-new Platform identity funded directly from the
shielded pool (state-transition type 20).

Flow mirrors sh_002: fund a fresh address with DENOMINATION + fee
headroom, chain-confirm, bind shielded account 0, shield into the pool,
then drive `PlatformWallet::shielded_identity_create_from_pool` with a
MASTER/HIGH/TRANSFER/CRITICAL key set built via the id_* helpers
(`derive_identity_key` + `SeedBackedIdentitySigner`), converting each key
to its `IdentityPublicKeyInCreation` form via the same `(&key).into()` the
FFI create path uses.

DENOMINATION is read at runtime from the protocol's versioned
exit-denomination set (`event_constants.shielded_identity_create_denominations`,
smallest member) — never hardcoded.

Authoritative verdict = A1 Ok(non-nil Identifier) ∧ A2 proof-verified
`Identity::fetch == Some` ∧ A3 on-chain key set matches the submitted set ∧
A4 pool dropped by exactly DENOMINATION (fee carved from it, headroom
re-enters as change). Secondary (logged, non-fatal): A5 identity balance
bounded in (0, DENOMINATION] with a TODO(#3040-fee) until the create fee is
a stable queryable constant; A6 a second create against the spent pool
fails (no-replay). SKIP via the central bank-floor gate emits the loud
`E2E-SKIP` marker — never a silent green when the devnet bank can't fund.

Compile + link only (paloma devnet down): `cargo check --tests
--features e2e` GREEN, `cargo test --no-run --features e2e` links the e2e
binary, clippy clean (no new warnings from this file).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (QA-001)

The logged-only A5 fee predictor hardcoded `num_actions = 2`, duplicating
the builder's `spends.len().max(2)` action-padding floor
(identity_create_from_shielded_pool.rs). Correct today, but a silent drift
if the padding floor ever changes. Derive it from the consumed spend-note
count (one shield → one note) through the same `.max(2)` floor, with a
comment noting the mirror. Logged-only path — no correctness impact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…concile

The merge-reconcile commit (47bced3) threaded extra args (`memo`,
`vec![event_handler]`) onto three call sites that then exceeded rustfmt's
100-col limit. Apply `cargo fmt` to wrap them; pure formatting, no
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Claudius-Maginificent

Copy link
Copy Markdown
Collaborator Author

Update — v3.1-dev merge + SH-036 type-20 shielded identity-create e2e (e6d0eba9ea)

Branch brought up to date with v3.1-dev and a new shielded e2e case added. Pushed 9033daa6d9..e6d0eba9ea (4 commits on top of the merged base).

What this push adds

  • Base mergeorigin/v3.1-dev merged into the branch (9208b2ad5f), plus a reconcile fix (47bced3f28) for two merge-induced semantic conflicts the feature-gated build surfaced: the new sender_ovk arg on the shield-from-asset-lock seam, and a Vec<event_handler> signature change in the manager test helper. These produced no textual conflict and were invisible to a plain cargo check — only cargo check --tests --features e2e caught them.
  • SH-036 — IdentityCreateFromShieldedPool (state-transition type 20), happy path (954398268d). New case tests/e2e/cases/sh_036_identity_create_from_shielded_pool.rs (+ mod.rs registration after sh_035, no renumbering; documented in TEST_SPEC.md §3). Exercises PlatformWallet::shielded_identity_create_from_pool(...). Authoritative assertions (all hard-fail):
    • A1 — API returns a non-nil Ok(Identifier).
    • A2 — proof-verified Identity::fetch(sdk, id) == Some (routes through drive_proof_verifier FromProof) — the real verdict on applied on-chain state, not a "broadcast didn't error" check.
    • A3 — fetched identity public keys match the submitted set.
    • A4 — shielded pool decreases by exactly the denomination (the builder computes change_amount = total_spent − denomination, so the net delta is fee-independent). The denomination is read at runtime from the versioned shielded_identity_create_denominations set — never hardcoded.
    • Secondary A5 (identity balance bounded (0, DENOMINATION] with a TODO(#3040-fee) marker, not exact equality — rs-dpp/rs-drive-abci fee estimates diverge per AddressFundsTransferTransition::calculate_min_required_fee is too low #3040) and A6 (replay guard) are logged, non-fatal.
    • Skips loudly via the greppable E2E-SKIP marker (no silent green, no network hang) when the bank floor is unmet.
  • QA-001 polish (5cc2d4d9c6) — A5's fee predictor derives num_actions from the spends count (mirrors the builder's spends.len().max(2) floor) instead of a bare literal, so it can't silently drift.
  • fmt cleanup (e6d0eba9ea) — wrapped 3 over-length lines left by the merge reconcile; package is cargo fmt --check clean.

Verification

  • cargo check --tests --features e2e -p platform-wallet → exit 0
  • cargo test --no-run --features e2e -p platform-wallet → exit 0 (tests/e2e.rs links)
  • cargo clippy -p platform-wallet --tests --features e2e → exit 0, zero new warnings attributable to SH-036
  • cargo fmt -p platform-wallet --check → clean

Not done, by design

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 7 commits June 11, 2026 11:12
…e-only)

A testnet wallet with CoinJoin funds doesn't fully sync at the default
gap limit — funds parked beyond CoinJoin index 30 stay invisible. This
adds a testnet e2e reproduction plus the one gated production hook it
needs, no fix.

SpvRuntime::set_terminal_height(Option<u32>): None keeps the production
sync-to-tip behaviour unchanged; Some(h) makes run() race the sync loop
against a watcher that stops the client once the confirmed filter height
(FiltersProgress::committed_height — the height up to which filter
batches are committed to the wallet) reaches h. Lets the e2e test sync a
fixed historical window without chasing the live tip.

found_coinjoin_gap_limit_sync: restores the same mnemonic into two
managers (same seed+network = same wallet id, can't coexist in one) and
syncs each in its own capped pass to height 1491827 (last testnet block
of Sun 2026-06-07 UTC). Wallet A uses Default (CoinJoin gap 30); Wallet B
uses AllAccounts incl. CoinJoin account 0 plus a 200-deep pre-derivation
across every funding keychain (incl. m/9'/1'/4'), all generated BEFORE
sync so the bloom filter watches them. Asserts balance_B > balance_A.

Live run: balance_A=1,275,304,861 vs balance_B=1,722,309,331, delta
447,004,470 duffs hidden by the default config — Wallet A's CoinJoin
account reads confirmed=0 while Wallet B finds the funds at CoinJoin
External index 1727, far past the default gap window.

The reproduction bypasses the bank/funding harness path entirely:
builds SDK (new_testnet) + manager + SPV directly, no bank mnemonic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the CoinJoin gap-limit reproduction with a diagnostic that walks
Wallet B's synced used-index set per funding keychain and prints, for
each: the sorted USED indices, the leading run (0 → first used), every
non-zero unused run between consecutive used indices (flagging any
>= 30), and a summary (count, span, max inter-used run, runs >= 30).

The CoinJoin External pass corrects the initial hypothesis. The used
indices are effectively CONTIGUOUS 0→1727 (max inter-used run = 12,
ZERO runs >= 30), yet the default-configured Wallet A stalls at
highest_used=59 with confirmed=0. So the defeat is NOT an inter-address
gap >= 30 — it is the shallow initial pre-derivation window (gap_limit
30) plus the SPV historical scan failing to advance the watched window
across a deep contiguous CoinJoin run. The report quantifies this: of
1638 used CoinJoin indices, 1578 sit above Wallet A's index-59 ceiling
and were invisible to the default scan.

coinjoin_external_highest_used reads Wallet A's CoinJoin External
highest_used so the report can contrast the two wallets directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…backups

Marvin's code-level RCA overturned the gap-limit framing. The committed
test doc no longer enshrines the wrong "default gap limit 30 too small /
>30 gap canyon" story. The accurate root cause:

- CoinJoin usage is effectively CONTIGUOUS in address index (largest
  unused run = 12, no run >= 30), dense to index 1727. Gap=30 is ample.
- The defect is dash-spv historical discovery being forward-only within
  the active batch window (~MAX_LOOKAHEAD_BATCHES=3 × BATCH_PROCESSING_SIZE
  =5000 ≈ 15000 blocks). Addresses derived mid-scan (maintain_gap_limit)
  are only re-matched against still-active batches; committed batches are
  evicted and never revisited. Discovery depends on the INDEX axis (to
  learn index N+gap, first match the block that used N) while the rescan
  runs on the HEIGHT/batch axis — orthogonal. CoinJoin first-use blocks
  are scattered across years, so discovery snaps shut once the next
  index's using-block leaves the live window. Wallet A advances one gap
  step (highest_used=59) then stalls, missing 1578/1638 used indices.
- The delta is NON-DETERMINISTIC (447M/739M/2022M across runs) due to a
  race between gap-limit derivation latency and batch commit/eviction, so
  the test asserts only qualitative B>A, never an exact amount.
- Reliable workaround: pre-derive CoinJoin addresses beyond the highest
  used index (~1727, e.g. 2500) before sync. The fixed 200-deep
  pre-derivation is only a probabilistic mitigation (hence B's variance).

No assertion or logic changes — doc/comment text only.

Adds packages/rs-platform-wallet/tests/.gitignore so operator-local env
files and their per-network backups (.env.paloma.bak etc.) can never be
committed; .env.paloma.bak is now ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tall

Adds a ground-truth index↔height analysis + discovery simulations for the
CoinJoin forward-only-discovery bug, and corrects the root-cause model to
match the empirical stall (index 59) exactly.

New ignored diagnostic `found_coinjoin_gap_limit_sync_height_analysis`:
syncs a wallet that pre-derives CoinJoin External to depth 2500 (past the
highest used index) so every used index is watched from genesis, extracts
h(i) = first funding height per used index from the retained tx history
(keep-finalized-transactions), and prints the (i, h) tables plus an
inversion analysis and two simulations.

Key correction (Marvin's RCA, validated against live data): the defeat is
NOT a >=30 index gap and NOT a height-axis inversion across years — the
CoinJoin funding is dense and near-monotonic in height (block 1415403
first-uses indices 0..=51, block 1415404 uses 52..=139, ...). The defect
is BLOCK-ATOMIC forward-only matching with zero backward re-scan: a block
is matched once against the filter active when scanned; the gap window
extends only AFTER the block, and newly-derived addresses are checked
only against later blocks. So a 0..30 watch matches 0..29 in block
1415403 (→ watch 0..59), then 52..59 in block 1415404 → highest_used=59,
the exact empirical stall. Indices 30..51 (used only in the committed
block) are never recovered.

sim_windowed is rewritten block-atomic; a unit test reproduces the 59
stall from the block shape. Live results: SIM WINDOWED stalls at 59
(38/1758); SIM FULL-RESCAN recovers 1728/1758 up to 1727, stopping only
at a genuine 39-index gap in the post-cutoff overshoot tail; minimum
initial pre-derivation depth to reach the full range under the real model
= 1771. Module docs updated to the block-atomic mechanism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chanism

Doc/comment-only — no assertion or simulation logic changed. Retracts the
"forward-only ~15,000-block window / first-use blocks scattered across
history" framing (commit 5199f30) now that the reconciliation pinned the
definitive mechanism.

Final root cause: a matched block is APPLIED to a wallet exactly once,
against only the addresses generated at that instant.
- At apply, check_transaction_for_match recognises only already-generated
  addresses (key-wallet account_checker.rs:651-654), so outputs paying
  idx 30..51 in the SAME block 1415403 are invisible; only 0..29 are
  marked used, then maintain_gap_limit derives 30..59.
- On batch commit, rescan_batch DOES re-match the block's own filters
  against the new scripts (dash-spv manager.rs:479) and 30..51 genuinely
  match — but the per-(wallet, BLOCK) BlockMatchTracker
  (manager.rs:667-668 -> block_match_tracker.rs:78-82) returns
  AlreadyProcessed (recorded done at sync_manager.rs:178), so the block is
  skipped and never re-applied. The gate is per-(wallet, block), not
  per-(wallet, address) — that is the bug.
- Funding is dense in BOTH index and height (block 1415403 funds 0..51,
  1415404 funds 52..139; no index↔height inversion below 1767), so the
  ceiling lifts exactly one gap step per dense block → deterministic stall
  at highest_used = 59 (= 29 initial watch + 30 gap).

Documents the fix direction (track processed SCRIPTS per block so a
new-script residual re-queues it, or re-test the block's own outputs
against newly-derived addresses to a fixpoint before record_processed)
and keeps the practical notes (non-deterministic headline delta; reliable
recovery = pre-derive CoinJoin to >= 1771 before sync). The block-atomic
simulation and its unit tests are unchanged and still reproduce 59.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stall

Adds an ignored gap-limit sweep that makes the block-atomic single-apply
diagnosis numerically falsifiable against the real testnet chain, and
records the result: the diagnosis SURVIVES, the naive gap-size theory is
refuted.

found_coinjoin_gap_limit_sweep_f1 rebuilds the wallet's CoinJoin account-0
External pool at a chosen gap g (initial watch window exactly 0..g-1 —
production hardcodes 30 at managed_account_collection.rs:595, so the test
reaches the knob via the pool's public fields), syncs the real chain to
the cutoff, and reports actual CoinJoin External highest_used. Gap from
F1_COINJOIN_GAP (default 30).

The block-atomic model predicts a specific stall per g from the dense
per-block funding (block 1415403 funds idx 0..51, 1415404 funds 52..139);
the naive "any gap > max-unused-run (12) finds everything" predicts the
full range (~1799) for all g >= 13. Live testnet results (cap 1491827,
birth 0):

  g=13: model 12   | actual 12   | naive 1799  -> model, naive refuted ~150x
  g=27: model 53   | actual 53   | naive 1799  -> model, naive refuted ~34x
  g=52: model 1799 | actual 1799 | naive 1799  -> model (upward jump 59->1799
        because block 1415403's full span 0..51 fits the initial window)
  g=30: model 59   | actual 59 (prior anchor)

Three-for-three on the model's exact, non-trivial predictions (12 / 53 /
1799). F1 tests block-atomic BEHAVIOR vs gap-size; it does not pin the
exact gate (that is F2 instrumentation / F3 apply-the-fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sses

Adds a validator/v0 test that deserializes a Core-23 Evo masternode entry
(platform ports only in the nested `addresses` object, no top-level
platformP2PPort/platformHTTPPort) and asserts new_validator_if_masternode_in_state
yields a ValidatorV0 with platform_p2p_port 36656 / platform_http_port 443,
instead of dropping the node.

Handles the new DMNState::addresses field in the MasternodeStateV0 <-> DMNState
conversions and in masternode-identity test fixtures: the backfilled ports are
authoritative, so the raw addresses object is not carried into platform state.

Note: exercising the real fix requires rust-dashcore with the Core-23
addresses backfill (branch fix/rpc-json-core23-platform-addresses). The
temporary local [patch] wiring this in is intentionally left uncommitted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shumkov added a commit that referenced this pull request Jun 12, 2026
…hout order-by

Two devnet-UAT fixes on the rs-sdk side:

- contact_request_queries: add explicit `ORDER BY $createdAt` to both
  fetch_received/fetch_sent queries. Drive answers a bare
  secondary-index equality (toUserId / $ownerId) with a verified
  proof of ABSENCE even when matching documents exist — isolated
  live against devnet with a host-side probe (equality-only: 0 docs;
  with order-by: found). The order-by binds the query to the
  (field, $createdAt) index so results return. Worth a platform
  issue: drive should reject the under-specified query instead of
  proving absence.

- rs-sdk-ffi: 8MB tokio worker stacks. GroveDB document-query proof
  verification (verify_layer_proof_v1) recurses deep enough to
  overflow the platform-default stack (SIGBUS on the stack guard,
  observed on-device).

No test: requires a live drive node answering proofs; pinned by the
on-device UAT flow (docs/dashpay/SPEC.md Part 7 e2e plan covers it
once PR #3549 lands).
lklimek and others added 3 commits June 15, 2026 10:53
Pins all 8 dashpay/rust-dashcore workspace dependencies from branch="dev"
to rev=7f1b46b9c7b264cb9887725da7e3567c204141a3 (dashpay/rust-dashcore#808,
branch fix/rpc-json-core23-platform-addresses). Revert to branch="dev"
once #808 merges to dev.

PR #808 renames DMNState fields to support Core 23's nested platform
addresses object, with backwards-compatible fallback to legacy top-level
port fields. Changes in this repo:

- DMNState::platform_p2p_port → legacy_platform_p2p_port (deprecated)
- DMNState::platform_http_port → legacy_platform_http_port (deprecated)
- New DMNState::addresses: Option<MasternodeAddresses> field
- New DMNState::platform_p2p_address() / platform_http_address() accessors

Fixes applied:

masternode/v0/mod.rs:
  From<DMNState> → MasternodeStateV0: use platform_p2p_address() /
  platform_http_address() accessors (prefer Core 23 nested addresses,
  fall back to legacy ports automatically).
  From<MasternodeStateV0> → DMNState: populate legacy_platform_p2p_port
  / legacy_platform_http_port, set addresses: None (ports already resolved).

validator/v0/mod.rs:
  new_validator_if_masternode_in_state: replaced direct field destructuring
  with accessor methods platform_p2p_address() / platform_http_address().
  Existing Core-23 regression test validator_built_from_core23_addresses_entry
  preserved and confirmed passing.

update_state_masternode_list/v0/mod.rs:
  update_masternode_in_validator_sets: DMNStateDiff port update uses
  platform_p2p_address() / platform_http_address() with legacy fallback.
  p2p_changed guard now covers both nested addresses and legacy fields.

Test fixture files (create_operator/owner/voter_identity, get_operator_identifier,
update_operator_identity, state_transitions/mod.rs):
  DMNState struct literals updated to use legacy_platform_p2p_port /
  legacy_platform_http_port (addresses: None was already present on this
  branch). Test modules annotated with #[allow(deprecated)].

CONSENSUS SAFETY: the resolved platform p2p/http ports are byte-identical
to before for all Core 22 nodes (legacy fallback path). Core 23 nodes that
previously had no ports (and were dropped from validator sets) now correctly
surface their ports from the nested addresses object, which is the intended fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve 4 conflicts: Cargo #808 pin preserved (+iOS profiles), runtime.rs terminal_height combined with task-abort stop(), shielded operations 2-phase broadcast with our helpers restored, strategy_tests DMNState rename. Adapt e2e shielded call sites to upstream's 6-arg shielded_shield_from_* signature so the e2e suite compiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…uction path

Migrate sh_018 to PlatformWallet::shielded_fund_from_asset_lock (FromWalletBalance), mirroring the FFI/seed_pool production callers; realign the assertion to production's self-derived lock_value - pool_fee. Document sh_035's raw-proof seam as adversarial-only (replay probe Drive's single-use check) and steer production to the orchestrated API via rustdoc on shielded_shield_from_asset_lock. Validated live against paloma devnet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
lklimek and others added 8 commits June 18, 2026 10:52
…onflict logic

PR #3585 (generic reservation subsystem) was closed/abandoned. PR #3549 has
its own OutpointReservations subsystem; the ConcurrentSpendConflict variant
and two unreachable defense-in-depth broadcast checks were leftover 3585
fragments. Removed them; OutpointReservations/NoSpendableInputs path retained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…wallet-e2e

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	packages/rs-platform-wallet/src/spv/runtime.rs
…e2e validation

Formats the reconciled spawn_in_background/spawn_run_loop block from the
origin/v3.1-dev base-merge, and records a TODO in tests/e2e.rs noting the
post-merge compile gate + e2e suite could not run (host disk 100% full —
`ld` SIGBUS mid-link, an out-of-disk failure, not a code issue).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…egister_wallet downgrade

Post-merge e2e validation found that v3.1-dev's new
`register_wallet -> downgrade_to_external_signable()` (wallet_lifecycle.rs:244)
strips the private key from every managed wallet, so the e2e bank's hardened
DIP-17/BIP-44 address derivation (bank.rs derive_*_at_index via the managed
wallet's derive_public_key) fails with "External signable wallet has no private
key", breaking every bank-funded case at setup. Adds TODOs at the two bank
helpers + tests/e2e.rs documenting root cause and the seed-based fix direction.
No production code changed; documentation/markers only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… to file

Full-suite runs take 30+ minutes. A recent run lost all --nocapture output
to terminal scrollback; and the suite is substantially faster run in parallel.

Add a "Recommended invocation" subsection in "## Running tests" covering:
- Why --test-threads=1 is wrong (harness is designed for parallelism via
  FUNDING_MUTEX + per-test fresh wallets); recommend explicit --test-threads=14
  or libtest's default, and clarify the distinction between --test-threads
  (libtest concurrency) and worker_threads=12 (tokio runtime per test).
- Pipe stdout+stderr through tee so results are greppable after a long run.
  Canonical form and a grep example included.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ation

A --test-threads=14 full run was OOM-killed on a 19 GiB box — each parallel
test case can drive a concurrent SPV sync and the combined RSS exhausted
available memory before any result was written.

Update the "Recommended invocation" section:
- Move logfile guidance first (it is the primary safeguard regardless of N)
- Change the example to --test-threads=4 as the memory-safe default
- Add a RAM/thread-count guidance table (4 @ ~16-19 GiB, 8 @ ~32 GiB,
  12-14 @ 64 GiB+) so operators tune to their box rather than cargo-culting 14
- Retain the --test-threads vs worker_threads distinction note

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reconciles the e2e bank with v3.1-dev's external-signable wallet model.
register_wallet now downgrades every managed wallet to external-signable
(no private key), so the bank's hardened DIP-17 / BIP-44 address derivation
(derive_platform_address_at_index / derive_core_receive_address_at_index)
could no longer derive from the live managed wallet — it failed with
"External signable wallet has no private key", breaking bank setup and every
bank-funded e2e case.

Both helpers now rebuild a fresh signable key_wallet::Wallet from the bank's
retained seed (new signable_bank_wallet helper) and derive from that. Same
seed -> same root key -> identical addresses (the same derivation surface
print_bank_address_offline uses to print the operator's funding addresses),
with the private key available for the hardened steps. Test-harness only; no
production code changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d + env caveat

Updates the tests/e2e.rs note now that the v3.1-dev external-signable bank
regression is fixed (seed-based derivation) and verified: the "External
signable wallet has no private key" error is gone from every test path and the
bank funds asset locks again. Records the residual environmental caveat
(bank-funded cases still hit testnet asset-lock finality-proof timeouts / Core
depletion on the current degraded testnet) and the slot-lock serialization that
makes ~4 test threads sufficient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Claudius-Maginificent

Copy link
Copy Markdown
Collaborator Author

Update: merged v3.1-dev, fixed a merge-induced regression, re-validated

Base mergeorigin/v3.1-dev merged into this branch (a03f7f73f). 3 real conflicts resolved; the # TEMPORARY rust-dashcore #808 pin was preserved (kept 7f1b46b9, also kept v3.1-dev's new tokio-metrics). The spv/runtime.rs spawn/run reconcile (v3.1-dev's loop-only run() + spawn_run_loop() vs this branch's cancel-token spawn_in_background) keeps both methods. Compile gate cargo check --tests --features e2e -p platform-walletpass.

Regression found & fixed — v3.1-dev's register_wallet() now calls downgrade_to_external_signable() (wallet_lifecycle.rs), making managed wallets watch-only. The e2e bank derived its funded Platform/Core addresses via hardened key derivation on the managed wallet (bank.rs), which then failed External signable wallet has no private key → every bank-funded case died at setup. This auto-merged with no textual conflict (silent semantic conflict, invisible to bare cargo check). Fixed in 4f43ceb82 by deriving the bank addresses from the retained seed_bytes via key_wallet::Wallet::from_seed_bytes (test-harness only; no production change).

Verified post-fix (independently): External signable hard-panic count = 0; print_bank_address_offline resolves the bank address; regression guards found_017_* / found_024 and framework/sim unit tests green. The remaining bank-funded reds (cr_001, cr_004, id_001, id_002, id_002b) are environmental — 4 share a single stuck testnet asset-lock finality proof, 1 is bank Core depletion — i.e. the same class as the previously-recorded environmental reds, not a code regression. A full green run is gated on testnet health.

Docs — README now recommends running multi-threaded + capturing output to a logfile, with a RAM/thread-count caveat (--test-threads tuned to memory; 14 OOM'd a 19 GiB box).

Still blocked for merge to v3.1-dev: rust-dashcore #808 must land in dev and the # TEMPORARY Cargo.toml pin reverted to branch = "dev" before this PR can merge. Unchanged by this update.

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 4 commits June 19, 2026 18:33
…readyExists

When the SPV runtime restores its persistent state across a process
restart (a crashed run 1 → fresh run 2), wallets from the prior run are
already registered in the PlatformWalletManager before sweep_orphans
runs. The previous code called create_wallet_from_seed_bytes and bailed
on WalletAlreadyExists, leaving every orphan unswept and their registry
entries permanently stuck as Failed — causing the 288× WalletAlreadyExists
errors that polluted run 2 (QA-T11).

Fix: on WalletAlreadyExists, fall back to manager.get_wallet(hash) and
continue the sweep with the existing handle. The sweep body is unchanged
so funds are still drained back to the bank before the wallet is removed
from the manager and the registry entry dropped. Self-heals on the next
run without manual workdir cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ce gate

## Funding minimums (config.rs, bank.rs, harness.rs, sh_036)

* `EXPECTED_TOKEN_SUITE_FLOOR`: 50B → 88.8B credits — reflects observed
  per-suite consumption on paloma, preventing mid-run exhaustion.
* `DEFAULT_MIN_BANK_CREDITS`: 500M → 200B credits — guarantees the bank
  always meets the 88.8B token-suite floor so TK token tests RUN rather
  than silently passing while skipping all assertions (QA-012 false-green
  trap).  String references in bank.rs and sh_036 updated to match.

## #3611: stale wallet-cache poisons funding gate on lagging DAPI replica

**Root cause.** `sync_balances(None)` at startup can land on a lagging
DAPI replica and return 0 credits for the bank's Platform address even
though the real balance is ~225B.  The wallet cache then holds 0, which
propagated to three call sites:
  1. `bank_floor_satisfied = false` → token tests silently skip.
  2. `snapshot_balances().platform = 0` → fund planner sees a false 200B
     deficit and attempts a spurious Core→Platform asset-lock (E5).
  3. `assert_floor()` panics on the stale 0.

The independent `AddressInfo::fetch` (proof-verified, wired for QA-V26-005)
already had the real balance — it was being compared and logged as MISMATCH
but the code discarded it and kept harness_credits=0 as authoritative.

Validated on paloma run-4: `harness_credits=0`, `independent_credits=225358877701`.

**Fix.**

`bank.rs` — new `adopted_platform_floor: Credits` field + supporting API:
  * `effective_platform_credits()` → `max(wallet_cache, adopted_platform_floor)`.
    Under normal operation identical to `total_credits()`.
  * `accept_independent_platform_balance(credits)` — sets the floor and
    recomputes `bank_floor_satisfied`.  Called by harness on persistent drift.
  * `sync_and_refresh_floor()` and `assert_floor()` both switched from
    `total_credits()` to `effective_platform_credits()` so a subsequent
    lagging-replica sync cannot clobber an already-adopted balance.
  * `BALANCE_SYNC_RETRIES = 3`, `BALANCE_SYNC_RETRY_SLEEP = 2 s` (public).

`bank_plan.rs` — `snapshot_balances()`: `total_credits()` →
`effective_platform_credits()`.  Fund planner sees the real balance; plan
becomes a no-op when the bank is already funded.

`harness.rs` — cross-check block extended with recovery logic:
  * Positive drift (independent >> harness): retry `sync_and_refresh_floor()`
    up to 3× with 2 s sleep; log each attempt.
  * If converged after retry: continue normally (no adoption needed).
  * If still diverged: call `accept_independent_platform_balance(independent_credits)`.
  * Negative drift (harness >> independent): separate WARN, no adoption
    (harness overestimate is safe).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nt gRPC errors

Transient, client-fixable gRPC codes (ResourceExhausted / DeadlineExceeded /
Aborted / Cancelled) were subject to the same 60 s × exp(ban_count) exponential
ban used for genuine node health failures.  For ResourceExhausted specifically,
this caused a ban-cascade: one rate-limited node gets expelled → remaining nodes
absorb its load → they also hit their rate limit → they get banned → cascade to
NoAvailableAddressesToRetry.

## Changes

`CanRetry` trait (lib.rs) — new `is_transient_error()` default method.
  Documented per-code rationale (see trait doc table).

`tonic::Status` impl (transport/grpc.rs) — returns `true` for the four
client-fixable codes:
  * `ResourceExhausted` — per-node rate limit (HTTP 429 / Envoy).  The node
    is healthy; banning it shifts load to remaining nodes → cascade.
  * `DeadlineExceeded` — client-side 10 s timeout under load.  A slow node
    ≠ a dead node.
  * `Aborted` — MVCC transaction conflict.  State-machine level; another node
    wouldn't resolve it faster.
  * `Cancelled` — request cancelled by the client.  No node fault.

`TransportError`, `DapiClientError`, `ExecutionError` (transport.rs,
dapi_client.rs, executor.rs) — delegate `is_transient_error()` up the chain.

`AddressList` / `AddressStatus` (address_list.rs) — new
`rate_limit_cooldown()` method: sets `banned_until = now + 5 s` WITHOUT
incrementing `ban_count`, so the exponential ladder is never triggered for
transient conditions.  Fixed `ban_info().banned` flag to match
`get_live_address()` filter semantics (`banned_until >= now` only, without
requiring `ban_count > 0`).

`update_address_ban_status()` (dapi_client.rs) — branches on
`is_transient_error()`: transient → `rate_limit_cooldown(5 s)`;
genuine server-side failure → `ban_with_reason()` (unchanged exponential).

Genuine server-side codes (`Unavailable`, `Internal`, `DataLoss`,
`Unimplemented`, `Unknown`) continue to receive the full exponential ban
so truly unhealthy nodes are correctly routed around.

Tests (tests/rate_limit_cooldown.rs) — 8 integration tests verifying:
  * All four transient codes propagate `is_transient_error()` through the
    chain and trigger cooldown with `ban_count == 0`.
  * `Unavailable` still triggers full ban with `ban_count == 1`.
  * Repeated transient errors do not escalate the exponential ladder.

## Validation & caveat

Validated on paloma (runs 4–6): 0 rate-limit events, 0 NoAvailableAddresses,
0 WalletAlreadyExists across 19 tests when applied in isolation.

NOTE: this fix does NOT resolve sustained over-capacity rate-limiting where
the server per-IP limit (e.g. 150 req/min/node) is structurally too low for
the test thread count.  For that case the operator must raise requestsPerUnit
on the paloma node.  Notably, in the over-capacity regime the old aggressive
ban appeared to perform BETTER (run-3 old: 130/40 pass vs run-5/6 new: 89/93
pass) because banning a throttled node shed load from it; the 5 s cooldown
lets tests continue to hammer it.  Recorded here so future tuners understand
the tradeoff when capacity is the constraint rather than client behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cleanup A refactoring or something that tidies up the project. Client Only

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

6 participants