feat(platform-wallet): e2e test spec and harness extensions#3563
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
08bbd42 to
3e00df4
Compare
Spec document enumerating prioritized test cases for the e2e harness introduced in PR #3549. No implementation; pure documentation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dentity count - PlatformAddressChangeSet::fee_paid() — direct read of fee paid - PlatformAddressWallet::sync_watermark() — monotonic block watermark - IdentityManager::identity_count() / identity_ids() Supports e2e test assertions noted in TEST_SPEC.md §4 gaps 1-3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…m_addresses - SeedBackedIdentitySigner: Signer<IdentityPublicKey> impl using DIP-9 - derive_identity_key: top-level helper for placeholder identity construction - TestWallet::register_identity_from_addresses: end-to-end registration helper - wait_for_identity_balance / wait_for_dpns_name_visible Implements TEST_SPEC.md §4 Wave A. Unblocks ID-*, DPNS-*, DP-*, TK-*, CT-001. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Returns a guard with N freshly-registered identities on the same test wallet. Builds on register_identity_from_addresses (Wave A). Implements TEST_SPEC.md §4 Wave B. Unblocks ID-003, DP-002, DP-003. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- TestWallet::transfer_with_inputs (PA-002 negative variant) - TestWallet::transfer_capturing_st_bytes (PA-006) - TestRegistry::get_status (PA-004) Implements TEST_SPEC.md §4 Wave F. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cret Pure rustfmt-driven line break-up after the Wave A SeedBackedIdentitySigner refactor that lands the composition wrapper over SimpleSigner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… in transfer_capturing_st_bytes
Replace the hand-rolled AddressInfo::fetch_many + manual nonce bump in
transfer_capturing_st_bytes with the now-public
dash_sdk::platform::transition::address_inputs::{fetch_inputs_with_nonce,
nonce_inc} helpers (promoted to pub in PR #3549's dedup base).
Closes the wallet-side half of PR #3549 dedup §3.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r rebase Bilby B's r-aA6u rename in the parent PR (#3549) propagated the public constant to `DEFAULT_ACCOUNT_INDEX_PUB`. The cases-stack added a `transfer_with_inputs` call referencing the pre-rename name; rebasing onto the updated parent leaves it dangling. One-token rename to match.
3e00df4 to
491f65c
Compare
…-agent-a808ae1575a310154 # Conflicts: # packages/rs-platform-wallet/tests/e2e/framework/signer.rs # packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs
…o feat/rs-platform-wallet-e2e-cases
Review GateCommit:
|
There was a problem hiding this comment.
Pull request overview
This PR extends rs-platform-wallet’s end-to-end (e2e) testing effort by adding a written test-case specification and expanding the e2e harness with identity-related tooling and a few small, test-driven wallet API additions to enable upcoming follow-up test PRs.
Changes:
- Added a comprehensive e2e test specification document (
TEST_SPEC.md) enumerating prioritized test cases and required harness capabilities. - Extended the e2e harness with an identity signer, identity registration helpers, new wait utilities, and multi-identity setup support.
- Introduced small wallet/public-API additions to support e2e assertions (transfer fee accessor plumbing via changesets, sync watermark getter, identity manager convenience accessor, and a provider watermark getter).
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs | Adds transfer helpers, identity registration helper, and a RegisteredIdentity bundle used by future e2e cases |
| packages/rs-platform-wallet/tests/e2e/framework/wait.rs | Adds polling waiters for identity balance visibility and DPNS name propagation |
| packages/rs-platform-wallet/tests/e2e/framework/signer.rs | New seed-backed Signer<IdentityPublicKey> and identity key derivation helper for harness usage |
| packages/rs-platform-wallet/tests/e2e/framework/registry.rs | Adds a single-entry status accessor for registry lifecycle assertions |
| packages/rs-platform-wallet/tests/e2e/framework/mod.rs | Adds setup_with_n_identities and guard type for multi-identity test setup |
| packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md | New written e2e test plan/spec with prioritized cases and harness roadmap |
| packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs | Adds sync_watermark() accessor for incremental-sync assertions |
| packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs | Computes and attaches an estimated fee into PlatformAddressChangeSet on transfer |
| packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs | Exposes provider watermark getter used by wallet-level sync_watermark() |
| packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs | Adds identity_ids() convenience accessor |
| packages/rs-platform-wallet/src/changeset/changeset.rs | Extends PlatformAddressChangeSet with a fee field and fee_paid() accessor; updates merge/is_empty logic |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…e and priority tags Adds 27 test cases from Marvin's edge-case audit (boundary, empty-input, concurrency, malformed-on-disk, async-cancellation, network-failure, large-input, unicode). Introduces P0/P1/P2 priority tags; primary happy-path cases (P0) are listed first within each section, P1 core variants next, P2 edge cases last. Renumbers within each prefix for density. See /tmp/test-spec-edge-case-audit.md for the per-finding rationale. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Walks `packages/rs-platform-wallet/src/` looking for contract violations, boundary mishandles, concurrency races, error swallowing, trust mismatches analogous to platform #3040, and panic-vs-typed-error paths. Each suspected bug becomes a Found-NNN test case in TEST_SPEC.md §3, P2 priority, with the proof-shape assertion and the expected-vs-actual contract. Doc only. No code changes; no renumbering of existing cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…r double-hashes ECDSA_HASH160 `SeedBackedIdentitySigner::lookup_identity_secret` and `can_sign_with` unconditionally compute `ripemd160_sha256(key.data())` for the cache probe. The cache itself is populated by `SimpleSigner::from_seed_for_identity` keyed by `ripemd160_sha256(serialize(secp256k1_pubkey))`. For `KeyType::ECDSA_SECP256K1` keys this matches (raw 33-byte pubkey hashed once). For `KeyType::ECDSA_HASH160` keys — whose `data` is already the 20-byte `ripemd160_sha256(pubkey)` per the DPP `KeyType` contract — the lookup hashes the already-hashed value, producing `ripemd160_sha256(ripemd160_sha256(pubkey))` and missing every cached secret. Both match arms at signer.rs:90 and signer.rs:116 admit `ECDSA_HASH160`, so the type signature lies: the impl claims support for a key type it can never resolve. The bug is currently dormant because `derive_identity_key` hard-codes `ECDSA_SECP256K1`, but any caller that constructs an `ECDSA_HASH160`-typed `IdentityPublicKey` (e.g. a chain identity registered by another wallet) will fail every sign call with a `not in pre-derived gap window` error on a key that demonstrably IS in the gap window. Documents the contract and the proof-shape unit test under § Found-bug pins. Spec-only — no `.rs` changes, no fix in this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verified all findings against the worktree at HEAD 895fa93. Two blocking issues are real and dovetail: setup_with_n_identities() advertises safe teardown but sweep_identities() is a no-op so identity credits leak while the registry entry is removed, and the same setup helper returns a wallet whose platform-address cache is stale because register_from_addresses explicitly skips the cache update (confirmed by an in-source TODO). Suggestion-tier findings cluster around the new fee_paid() API: its doc promises strategy-adjusted accounting but the implementation just stores estimate_min_fee, which the same crate elsewhere flags (issue #3040) as a known under-estimate of real chain-time fees — and the harness's default strategy is exactly the case that under-estimates the most. Two comment-correctness issues round out the review.
Reviewed commit: 895fa93
🔴 2 blocking | 🟡 3 suggestion(s) | 💬 2 nitpick(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet/tests/e2e/framework/mod.rs`:
- [BLOCKING] lines 220-223: `setup_with_n_identities()` leaks identity credits because `sweep_identities()` is still a no-op
`MultiIdentitySetupGuard::teardown()` (mod.rs:236-238) forwards to `SetupGuard::teardown()`, whose docstring at mod.rs:220-223 advertises identity-side cleanup as implicit because "funds drain back to the bank during the wallet sweep". They don't: `cleanup.rs:170` calls `sweep_identities()` which is `Ok(())` (cleanup.rs:261-263, with a TODO blocking on a `Signer<IdentityPublicKey>` and CreditTransfer wiring). Meanwhile in the new flow, every funded address is consumed by `register_identity_from_addresses()`, so the credits sit on the identities, not on platform addresses, and the platform-address sweep at line 159 cannot recover them. Then `cleanup.rs:177` removes the registry breadcrumb, so the next startup sweep can no longer recover them either. Net effect: every test using this helper permanently burns `n * funding_per` bank credits while reporting a clean teardown. Fix: either implement `sweep_identities()` before merging this helper, or change the multi-identity guard's docstring + teardown to surface the leak (e.g. fail-loud teardown unless identity sweep lands), so tests don't silently drain the testnet bank.
- [BLOCKING] lines 193-215: `setup_with_n_identities()` returns a wallet with stale platform-address balances and nonces
After registration, `IdentityWallet::register_from_addresses()` explicitly does NOT push the returned address infos into the platform-address cache — see the TODO at `src/wallet/identity/network/register_from_addresses.rs:139-143` ("For now the next BLAST sync round refreshes them"). `setup_with_n_identities()` registers `n` identities sequentially and returns immediately, so the `TestWallet` it returns thinks each funding address still holds its pre-registration balance with the pre-registration nonce. Any consumer that calls `balances()`, `total_credits()`, or attempts an address-funded transfer before triggering a sync will read stale state and likely select already-spent inputs, producing spurious `InsufficientFunds` / nonce-mismatch failures from the very tests this helper is meant to enable. A setup helper should leave the wallet in a consistent post-setup state — invoke `sync_balances()` on each consumed address (or push the returned `address_infos` into the cache directly) before returning the guard.
In `packages/rs-platform-wallet/src/changeset/changeset.rs`:
- [SUGGESTION] lines 591-606: `fee_paid()` doc promises a `fee_strategy` adjustment the implementation doesn't perform — and underestimates real chain-time fees
The doc on `fee_paid()` claims the value is computed via `estimate_min_fee` "then adjusted by the configured `fee_strategy` so the returned number reflects the portion of the fee charged to the fee-bearing input's remaining balance." The producer at `transfer.rs:118-119` does no such adjustment — it stores the raw `estimate_min_fee(input_count, output_count, version)`. Worse, the in-tree comment at `transfer.rs:685-698` (cross-referencing platform issue #3040) acknowledges that `estimate_min_fee` returns only the static `state_transition_min_fees` floor, which the comment notes can be ~6.5M for 1in/1out while the real chain-time fee is ~14.94M. The harness's own `default_fee_strategy()` is `ReduceOutput(0)` (`wallet_factory.rs:399-401`) — exactly the case the bug report calls out. Any test that uses `cs.fee_paid()` to compute a post-transfer balance will be off by a meaningful and non-deterministic amount, producing flaky equality assertions on real testnets. Recommended fix: tighten the doc to say "lower-bound static estimate from `estimate_min_fee`, NOT the actual on-chain fee — see platform issue #3040" and consider renaming the accessor to `estimated_min_fee` (or returning `Option<Credits>`) until #3040 is resolved, so consumers cannot mistake the static floor for ground truth.
In `packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- [SUGGESTION] lines 113-119: Comment claims `DeductFromInput(_)` is the canonical strategy, but the crate's default is `ReduceOutput(0)`
The new comment immediately above the `fee_paid` computation says: "With `DeductFromInput(_)` (the canonical strategy used everywhere in this crate) the entire fee is charged to the targeted input's remaining balance, so this value matches the on-chain debit." This is wrong on two counts. (1) `default_fee_strategy()` in `tests/e2e/framework/wallet_factory.rs:399-401` is `vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]`, and the cleanup sweeps in `cleanup.rs:231` also use `ReduceOutput(0)` — there is no `DeductFromInput` use in this crate. (2) Under `ReduceOutput(0)`, the fee is taken from output 0's amount, not from any input's remaining balance, so the "matches the on-chain debit" claim doesn't hold for the strategy actually in use. The total `estimate_min_fee` value is the same regardless of strategy, so the field semantics are unaffected — but the justification is misleading and will confuse anyone trying to reconcile `fee_paid` against per-input deltas.
In `packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs`:
- [SUGGESTION] lines 237-245: `transfer_capturing_st_bytes` doc misattributes byte differences to nonce divergence
The doc says: "The captured bytes are taken from a sibling build (separate nonce fetch, separate signing pass) — they are NOT byte-equal to the broadcast transition, because the production path bumps nonces independently." The sibling build runs `fetch_inputs_with_nonce` + `nonce_inc` BEFORE `transfer_with_inputs` is called; `transfer_with_inputs` then does its own fetch of on-chain state and bumps the nonce identically. Because the sibling transition is never broadcast, on-chain state is unchanged between the two fetches, so both transitions end up with the *same* address nonces. The bytes diverge only because of ECDSA signature non-determinism (assuming no RFC 6979). This matters for PA-006: with identical nonces but different signatures the second submission is rejected by nonce-duplicate detection, not by content-hash duplicate detection. Future test authors writing assertions around this helper will be misled by the current explanation.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verified all findings against worktree HEAD 511d28c. Two blocking issues persist around setup_with_n_identities(): it forwards teardown to a no-op sweep_identities() (leaking identity credits while removing the registry breadcrumb), and it returns a wallet with stale platform-address cache because register_from_addresses deliberately skips the cache update. The new SeedBackedIdentitySigner ECDSA_HASH160 double-hash bug flagged by Codex is real but dormant on this PR's reachable paths and the author already pinned it as Found-019 in TEST_SPEC.md — kept as suggestion for visibility on the follow-up test wiring. Remaining items are doc/comment correctness on the new fee_paid API and a wait-helper timeout overshoot.
Reviewed commit: 511d28c
🔴 2 blocking | 🟡 4 suggestion(s) | 💬 2 nitpick(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet/tests/e2e/framework/mod.rs`:
- [BLOCKING] lines 220-238: `setup_with_n_identities()` leaks identity credits because `sweep_identities()` is still a no-op
`MultiIdentitySetupGuard::teardown()` (mod.rs:236-238) just forwards to `SetupGuard::teardown()`, whose docstring at mod.rs:220-223 advertises identity-side cleanup as implicit because "funds drain back to the bank during the wallet sweep". They don't: `cleanup.rs:170` calls `sweep_identities()` which is `Ok(())` (cleanup.rs:261-263, with a TODO blocking on `Signer<IdentityPublicKey>` and CreditTransfer wiring). In the new flow every funded address is consumed by `register_identity_from_addresses()`, so credits sit on identities, not on platform addresses — the platform-address sweep at line 159 cannot recover them. Then `cleanup.rs:177` removes the registry breadcrumb, so the next startup sweep also loses the only handle to the wallet. Net effect: every test using this helper permanently burns `n * funding_per` bank credits while reporting a clean teardown. Fix: implement `sweep_identities()` before merging this helper, or change the multi-identity guard's docstring + teardown to surface the leak (fail-loud teardown until identity sweep lands) so tests don't silently drain the testnet bank.
- [BLOCKING] lines 193-215: `setup_with_n_identities()` returns a wallet with stale platform-address balances and nonces
`IdentityWallet::register_from_addresses()` explicitly does NOT push the SDK-returned address infos into the platform-address cache — see the TODO at `src/wallet/identity/network/register_from_addresses.rs:139-143` ("For now the next BLAST sync round refreshes them"). `setup_with_n_identities()` registers `n` identities sequentially and returns immediately, so the `TestWallet` it returns thinks each funding address still holds its pre-registration balance with the pre-registration nonce. Any consumer that calls `balances()`, `total_credits()`, or attempts another address-funded transfer before triggering a sync will read stale state and likely select already-spent inputs, producing spurious `InsufficientFunds` / nonce-mismatch failures from the very tests this helper is meant to enable. A setup helper should leave the wallet in a consistent post-setup state — invoke `sync_balances()` on each consumed address (or push the returned `address_infos` into the cache directly) before returning the guard.
In `packages/rs-platform-wallet/src/changeset/changeset.rs`:
- [SUGGESTION] lines 591-606: `fee_paid()` doc promises a `fee_strategy` adjustment the implementation never performs — and underestimates real chain-time fees
The doc on `fee_paid()` claims the value is computed via `estimate_min_fee` "then adjusted by the configured `fee_strategy` so the returned number reflects the portion of the fee charged to the fee-bearing input's remaining balance." The producer at `transfer.rs:118-119` does no such adjustment — it stores the raw `estimate_min_fee(input_count, output_count, version)`. Worse, the in-tree comment at `transfer.rs:685-698` (cross-referencing platform issue #3040) acknowledges that `estimate_min_fee` returns only the static `state_transition_min_fees` floor; for 1in/1out that floor is ~6.5M while real chain-time fees are ~14.94M. The harness's own `default_fee_strategy()` is `ReduceOutput(0)` (`wallet_factory.rs:399-401`) — exactly the case the bug report calls out. Tests that use `cs.fee_paid()` to compute post-transfer balances will be off by a meaningful and non-deterministic amount, producing flaky equality assertions. Fix: tighten the doc to say "lower-bound static estimate from `estimate_min_fee`, NOT the actual on-chain fee — see platform issue #3040" and consider renaming the accessor to `estimated_min_fee` (or returning `Option<Credits>`) until #3040 is resolved.
In `packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- [SUGGESTION] lines 113-119: Comment claims `DeductFromInput(_)` is the canonical strategy, but the crate's default is `ReduceOutput(0)`
The new comment immediately above the `fee_paid` computation says: "With `DeductFromInput(_)` (the canonical strategy used everywhere in this crate) the entire fee is charged to the targeted input's remaining balance, so this value matches the on-chain debit." This is wrong on two counts. (1) `default_fee_strategy()` in `tests/e2e/framework/wallet_factory.rs:399-401` is `vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]`, and the cleanup sweeps in `cleanup.rs:231` also use `ReduceOutput(0)` — there is no `DeductFromInput` use in this crate. (2) Under `ReduceOutput(0)`, the fee is taken from output 0's amount, not from any input's remaining balance, so the "matches the on-chain debit" claim doesn't hold for the strategy actually in use. The total `estimate_min_fee` value is the same regardless of strategy, so field semantics are unaffected — but the justification will mislead anyone reconciling `fee_paid` against per-input deltas.
In `packages/rs-platform-wallet/tests/e2e/framework/signer.rs`:
- [SUGGESTION] lines 114-143: `SeedBackedIdentitySigner` double-hashes `ECDSA_HASH160` lookup keys
Both `can_sign_with()` (lines 114-122) and `lookup_identity_secret()` (lines 128-143) compute `ripemd160_sha256(key.data())` before probing `inner.address_private_keys`. That lookup is correct for `ECDSA_SECP256K1` because the cache is keyed by `hash160(pubkey)`. It is wrong for `ECDSA_HASH160` where `key.data()` is already the 20-byte hash — hashing it again yields `hash160(hash160(pubkey))`, which never matches the stored `hash160(pubkey)`. Result: any `ECDSA_HASH160` key reports `can_sign_with == false` and `sign()` fails with the misleading "not in pre-derived gap window" error even when the secret was pre-derived. Currently dormant — only `ECDSA_SECP256K1` keys are derived on reachable paths in this PR — and already documented as Found-019 in `tests/e2e/TEST_SPEC.md`. Worth fixing alongside the test pin so the test author isn't reasoning about a moving target. Fix: branch on `key.key_type()` so `ECDSA_HASH160` uses `key.data().as_slice()` directly as the lookup fingerprint.
In `packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs`:
- [SUGGESTION] lines 237-245: `transfer_capturing_st_bytes` doc misattributes byte differences to nonce divergence
The doc says: "The captured bytes are taken from a sibling build (separate nonce fetch, separate signing pass) — they are NOT byte-equal to the broadcast transition, because the production path bumps nonces independently." In practice the sibling build runs `fetch_inputs_with_nonce` + `nonce_inc` first against unchanged on-chain state; `transfer_with_inputs` then does its own fetch and bumps the nonce identically. Both transitions end up with the *same* address nonces — the bytes diverge only because of ECDSA signature non-determinism (no RFC 6979 in the signing path). This matters for PA-006: with identical nonces but different signatures, the second submission is rejected by nonce-duplicate detection, not by content-hash duplicate detection. The current explanation will mislead future test authors writing assertions around this helper, and risks landing a green PA-006 test that doesn't actually exercise pure-bytes-replay protection. Fix the doc to attribute the divergence to signature non-determinism, and either capture the bytes from the production submission (so re-broadcast shares both nonces and signature) or assert the rejection reason is duplicate-content rather than nonce-replay.
…docs [CMT-001] The previous docstring claimed identity funds drained back to the bank during the wallet sweep — they don't. `sweep_identities()` is an intentional no-op stub tracked by the `#identity-sweep` TODO and will be implemented in a follow-up PR (alongside the sibling `sweep_core_addresses`, `sweep_unused_core_asset_locks`, `sweep_shielded` no-ops). Tighten the guard docstring to point at the TODO instead of pretending cleanup is automatic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…p [CMT-002] `setup_with_n_identities` was returning the test wallet with stale platform-address state — `register_from_addresses` consumes the funding addresses but doesn't update the cached `(balance, nonce)` pair (by design, per the cache TODO in `register_from_addresses.rs`). A consumer calling `balances()` or driving an auto-selected transfer right after setup would read pre-registration balances and pick already-spent inputs. Issue one `sync_balances()` at the end of the helper. One round-trip refreshes every consumed address's balance and nonce together, so the wallet handed to the test body matches reality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nest doc [CMT-003] The old `fee_paid()` accessor and its docstring promised a value adjusted by `fee_strategy` "so the returned number reflects the portion of the fee charged to the fee-bearing input's remaining balance". The implementation just returned the raw `estimate_min_fee(input_count, output_count, version)` result — unadjusted — which models only the static `state_transition_min_fees` floor (~6.5M for 1in/1out vs ~14.94M observed real chain-time fee). Rename the accessor to `estimated_min_fee()`, rewrite the docstring to spell out the static-floor semantics, and link platform issue #3040 (open ticket for chain-time-accurate `estimate_min_fee`). This helper has zero external call sites in the workspace today (verified with grep), so the rename is safe to do now while the API surface is small. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…CMT-004] The comment above `estimate_min_fee` claimed `DeductFromInput(_)` was "the canonical strategy used everywhere in this crate" and that the captured value "matches the on-chain debit". Both halves were wrong: `wallet_factory.rs` defaults to `[ReduceOutput(0)]`, the cleanup path sweeps with `[ReduceOutput(0)]`, and the value is the static estimate-min-fee floor — not the chain-time debit (see #3040). Reword the comment to describe what the captured value actually is, list both supported strategies, and point at `PlatformAddressChangeSet::estimated_min_fee` for the full caveat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er lookup [CMT-005] `SeedBackedIdentitySigner` populates `inner.address_private_keys` keyed by `ripemd160_sha256(compressed_pubkey)`. Both `can_sign_with` and `lookup_identity_secret` were hashing `key.data().as_slice()` unconditionally, which is correct for `ECDSA_SECP256K1` (where `data` is the raw pubkey) but **wrong** for `ECDSA_HASH160` (where `data` is already the 20-byte hash). The double-hash produced `hash160(hash160(pubkey))` and never matched the cache — so any `ECDSA_HASH160`-typed key silently failed every probe (Found-019). Extract the lookup-key derivation into `identity_key_lookup`, which hashes only for `ECDSA_SECP256K1` and uses `key.data()` directly for `ECDSA_HASH160`. `lookup_identity_secret` now also returns a clear "unsupported key type" error rather than tripping the gap-window miss for unsupported types. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…m semantics [CMT-006] The merge comment opened with "last-non-zero-wins" and immediately described sum semantics — internally contradictory and at odds with the `saturating_add` implementation (which sums, never replaces). Reword to describe the actual append-sum behaviour: sync-only `fee == 0` merges are a no-op, transfer + transfer sums the fees. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`wait_for_identity_balance` and `wait_for_dpns_name_visible` slept unconditionally for `BACKSTOP_WAKE_INTERVAL` (2s) after the deadline check. A caller passing `timeout < 2s` would have its timeout error returned almost a full backstop late. Mirror the `wait_for_balance` pattern: compute `remaining = deadline.saturating_duration_since(now)`, fail fast if it's zero, otherwise sleep `min(remaining, BACKSTOP_WAKE_INTERVAL)`. Low-impact today (every harness caller passes >= 30s), but a free correctness upgrade for any future tighter timeout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
No agent findings survived verification. Both candidate Codex findings flagged behaviors that are already explicitly documented and justified in the surrounding doc comments — captured-bytes semantics for PA-006 and deferred identity-credit cleanup with a tracked follow-up. No blocking issues remain.
Reviewed commit: 696ae3d
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
PR is a clean test-harness extension with no blocking issues. The most material concern is the new PlatformAddressChangeSet::fee/estimated_min_fee surface, where the public field name, the accessor's per-transfer doc, and the merge's batch-summing semantics are mutually inconsistent and form a real footgun. Several smaller doc/API-shape suggestions in the e2e harness round out the review.
Reviewed commit: d18fd4d
🟡 8 suggestion(s) | 💬 2 nitpick(s)
1 additional finding
🟡 suggestion: `cs.fee` is unconditionally written even when the wallet-manager update branch falls through
packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs (lines 122-181)
fee_paid is written into cs.fee (line 137) before the wallet_manager.write().await block. If wm.get_wallet_info_mut(&self.wallet_id) returns None (line 140), the function falls through without populating cs.addresses — but the returned changeset is non-empty (fee != 0 ⇒ is_empty() is false) and carries no address entries. Persistence consumers that key on addresses will see a stray fee with no corresponding address mutation.
The on-chain transition has already broadcast in this case; silently dropping the address-side update is also wrong. Probably theoretical (the wallet_id is supplied by self, so the wallet is virtually always in the manager), but the fall-through case currently yields a malformed changeset rather than an error or a warn! log. Consider logging or returning an error if the manager doesn't know the wallet at this point.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet/src/changeset/changeset.rs`:
- [SUGGESTION] lines 585-644: `fee` field name, `estimated_min_fee()` accessor doc, and `merge()` semantics are mutually inconsistent
Three layers disagree on what this number means:
1. Field (line 589): `pub fee: Credits` — public, named `fee`, suggesting "the fee for this transfer".
2. Accessor doc (lines 593–614): explicitly warns this is a `state_transition_min_fees` lower-bound floor only — NOT the on-chain fee, NOT adjusted by `fee_strategy`, ~6.5M static vs ~14.94M observed for 1in/1out. Phrased per-transfer ("the transfer that produced this changeset", singular).
3. Merge (lines 639–644): `self.fee = self.fee.saturating_add(other.fee)` with a comment that the intent is "total fee paid across operations in this batch".
A caller reading only the field name reasonably writes a balance-delta assertion against `cs.fee`, expecting the actual debit. A caller reading the accessor doc reasonably treats `estimated_min_fee()` as a single-transfer estimate. Either of those callers, if a `merge()` runs in between, gets a value that means neither — it is the sum of static lower-bound estimates across N transfers in the batch.
Fix one of:
- Rename the field to match the accessor (`pub estimated_min_fee: Credits`) and rewrite the merge comment + accessor doc to consistently describe "sum of static lower-bound fee estimates across all transfers contributing to this changeset".
- Make the field `pub(crate)` so all reads go through the accessor, and clarify in the accessor doc that the value is per-batch (post-merge) rather than per-transfer.
In `packages/rs-platform-wallet/tests/e2e/framework/signer.rs`:
- [SUGGESTION] lines 176-212: `derive_identity_key` accepts `purpose`/`security_level` but always derives from the AUTHENTICATION sub-tree
The helper takes `purpose: Purpose` and `security_level: SecurityLevel` and stamps them onto the returned `IdentityPublicKeyV0` (lines 203–204), but the underlying derivation is unconditionally `derive_ecdsa_identity_auth_keypair_from_master(...)` — i.e. the DIP-9 AUTHENTICATION sub-tree at `m/9'/coin'/5'/0'/ECDSA'/identity'/key'`. Passing `Purpose::ENCRYPTION`, `Purpose::TRANSFER`, or `Purpose::VOTING` yields an `IdentityPublicKey` whose `purpose` field claims one thing while its bytes were derived from a different path entirely.
`SeedBackedIdentitySigner` only pre-derives AUTHENTICATION-path secrets via `SimpleSigner::from_seed_for_identity`, so signing with such a misclassified key happens to work — masking the inconsistency rather than surfacing it. As a `pub` test helper this is a real footgun for anyone writing future test cases that exercise non-AUTHENTICATION purposes.
Either (a) drop the `purpose`/`security_level` parameters, hardcode `Purpose::AUTHENTICATION`, and infer security level from `key_index` (matching the MASTER/HIGH convention used at the call site at wallet_factory.rs:324–339), or (b) reject (return `Err`) when called with a non-AUTHENTICATION purpose so the inconsistency fails loudly at construction.
In `packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs`:
- [SUGGESTION] lines 373-386: `funding / 2` is both too loose and silently coupled to a `funding ≥ 2 × register_fee` precondition
After `register_from_addresses`, the helper waits for the on-chain identity balance to reach only `funding / 2`. Two problems:
1. The actual post-fee balance is already returned in `registered.balance()` — this is proof-verified by the SDK call. Waiting for `funding / 2` instead of `registered.balance()` weakens the setup invariant: a fee regression or partial-credit bug that still leaves the identity above 50% of the requested funding would silently pass setup and surface as a confusing assertion failure later in the test body.
2. The threshold is only correct under the unstated precondition `funding ≥ 2 × register_fee`. For the canonical 30M test funding it's safe, but tests that pass smaller `funding` values (e.g. probing edge cases near `min_input_amount`) could see registration fees exceed `funding/2` and time out even on a normal registration.
Use `registered.balance()` directly as the wait target — it's already an exact, proven value — and the implicit precondition disappears.
- [SUGGESTION] lines 237-245: Doc rationale for `transfer_capturing_st_bytes` rejection mechanism is wrong
The doc claims captured bytes are NOT byte-equal to the broadcast transition because "the production path bumps nonces independently." That isn't what happens. Both code paths run sequentially with no broadcast in between (line 260: capture build calls `fetch_inputs_with_nonce` + `nonce_inc`; line 280: production `transfer_with_inputs` re-fetches nonces from chain). The capture build never broadcasts, so the on-chain nonce stays at N for both reads — both bump to N+1, both use the same inputs, outputs, fee strategy, and signer; ECDSA signing is deterministic via `core_signer`. The two transitions are very likely byte-equal, or at minimum share nonces.
PA-006's replay rejection still works, but for a different reason: the broadcast in step 2 advances the chain nonce to N+1, and the captured bytes (which use nonce N+1) are then rejected on replay because that nonce has already been consumed. Suggested replacement docstring: "Both builds use the same fetched + bumped nonce; the captured transition is rejected on replay because the production submission has already consumed that nonce on-chain."
- [SUGGESTION] lines 246-282: `transfer_capturing_st_bytes` hard-codes `default_fee_strategy()` for both builds
Both the captured sibling build (line 268) and the in-call `transfer_with_inputs` (line 280) use `default_fee_strategy()` (`[ReduceOutput(0)]`). This is fine for PA-006 as written, but anchors the helper to one strategy — replay-safety / serialization tests for `[DeductFromInput(0)]` would need a parallel helper. Threading a `fee_strategy: AddressFundsFeeStrategy` parameter through to both call sites (with a private `transfer_with_inputs_and_strategy` variant) would let the harness cover both shipped strategies symmetrically.
In `packages/rs-platform-wallet/tests/e2e/framework/mod.rs`:
- [SUGGESTION] lines 226-250: `setup_with_n_identities` amplifies the documented identity-credit leak by `n`
Teardown forwards to `SetupGuard::teardown` which calls `cleanup::teardown_one`, whose `sweep_identities` is currently a no-op (cleanup.rs:261–263, gated on the `#identity-sweep` TODO). The PR's own doc on `MultiIdentitySetupGuard` (lines 230–234) acknowledges this and points at the same TODO. So every identity created by `setup_with_n_identities` keeps its post-registration credit balance until the follow-up sweep lands — and `n`-identity setup multiplies the per-test leak by `n`.
This is not a regression introduced by this PR (the same leak applies to the existing single-identity helper), and the docstring is honest about it. The reason to flag is sizing: any test that uses `setup_with_n_identities(n, funding_per)` permanently consumes `n × (funding_per − register_fee)` credits from the bank wallet per run, scaling worse than the single-identity case. Either (a) prioritize the `#identity-sweep` follow-up before adopting this helper widely, or (b) gate the helper on a CI-only env var until the sweep lands so accidental local runs don't quietly drain the bank.
In `packages/rs-platform-wallet/tests/e2e/framework/wait.rs`:
- [SUGGESTION] lines 143-242: Wait helpers downgrade every SDK error to a generic timeout
Both `wait_for_identity_balance` (lines 143–188) and `wait_for_dpns_name_visible` (lines 200–242) treat every `Err` from the SDK as transient: log at `debug`, sleep, retry. Proof-verification failures, malformed queries, and other deterministic regressions get hidden behind the eventual timeout error and lose the concrete error type — the harness reports "timed out after 30s" rather than the specific verification or transport failure.
Retrying `Ok(None)` is correct (the identity / name simply isn't visible yet). Narrow transport-flake retries are also fine. But blanket-retrying every `Err` makes the harness much worse at distinguishing real failures from chain latency. Consider classifying errors and only retrying transport / not-found shapes; surface deterministic failures (proof verification, malformed request) immediately.
In `packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs`:
- [SUGGESTION] lines 122-181: `cs.fee` is unconditionally written even when the wallet-manager update branch falls through
`fee_paid` is written into `cs.fee` (line 137) before the `wallet_manager.write().await` block. If `wm.get_wallet_info_mut(&self.wallet_id)` returns `None` (line 140), the function falls through without populating `cs.addresses` — but the returned changeset is non-empty (`fee != 0` ⇒ `is_empty()` is false) and carries no address entries. Persistence consumers that key on `addresses` will see a stray fee with no corresponding address mutation.
The on-chain transition has already broadcast in this case; silently dropping the address-side update is also wrong. Probably theoretical (the `wallet_id` is supplied by `self`, so the wallet is virtually always in the manager), but the fall-through case currently yields a malformed changeset rather than an error or a `warn!` log. Consider logging or returning an error if the manager doesn't know the wallet at this point.
| @@ -606,13 +636,20 @@ impl Merge for PlatformAddressChangeSet { | |||
| .map_or(r, |existing| existing.max(r)), | |||
| ); | |||
| } | |||
| // Fee: append-sum via `saturating_add`. Sync-only merges | |||
| // (`fee == 0`) are a no-op so a transfer's recorded fee | |||
| // survives untouched; merging two transfer changesets sums | |||
| // the per-operation fees so the merged total reflects the | |||
| // "total fee paid across operations in this batch" intent. | |||
| self.fee = self.fee.saturating_add(other.fee); | |||
There was a problem hiding this comment.
🟡 Suggestion: fee field name, estimated_min_fee() accessor doc, and merge() semantics are mutually inconsistent
Three layers disagree on what this number means:
- Field (line 589):
pub fee: Credits— public, namedfee, suggesting "the fee for this transfer". - Accessor doc (lines 593–614): explicitly warns this is a
state_transition_min_feeslower-bound floor only — NOT the on-chain fee, NOT adjusted byfee_strategy, ~6.5M static vs ~14.94M observed for 1in/1out. Phrased per-transfer ("the transfer that produced this changeset", singular). - Merge (lines 639–644):
self.fee = self.fee.saturating_add(other.fee)with a comment that the intent is "total fee paid across operations in this batch".
A caller reading only the field name reasonably writes a balance-delta assertion against cs.fee, expecting the actual debit. A caller reading the accessor doc reasonably treats estimated_min_fee() as a single-transfer estimate. Either of those callers, if a merge() runs in between, gets a value that means neither — it is the sum of static lower-bound estimates across N transfers in the batch.
Fix one of:
- Rename the field to match the accessor (
pub estimated_min_fee: Credits) and rewrite the merge comment + accessor doc to consistently describe "sum of static lower-bound fee estimates across all transfers contributing to this changeset". - Make the field
pub(crate)so all reads go through the accessor, and clarify in the accessor doc that the value is per-batch (post-merge) rather than per-transfer.
source: ['claude-rust-quality', 'codex-general']
🤖 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/src/changeset/changeset.rs`:
- [SUGGESTION] lines 585-644: `fee` field name, `estimated_min_fee()` accessor doc, and `merge()` semantics are mutually inconsistent
Three layers disagree on what this number means:
1. Field (line 589): `pub fee: Credits` — public, named `fee`, suggesting "the fee for this transfer".
2. Accessor doc (lines 593–614): explicitly warns this is a `state_transition_min_fees` lower-bound floor only — NOT the on-chain fee, NOT adjusted by `fee_strategy`, ~6.5M static vs ~14.94M observed for 1in/1out. Phrased per-transfer ("the transfer that produced this changeset", singular).
3. Merge (lines 639–644): `self.fee = self.fee.saturating_add(other.fee)` with a comment that the intent is "total fee paid across operations in this batch".
A caller reading only the field name reasonably writes a balance-delta assertion against `cs.fee`, expecting the actual debit. A caller reading the accessor doc reasonably treats `estimated_min_fee()` as a single-transfer estimate. Either of those callers, if a `merge()` runs in between, gets a value that means neither — it is the sum of static lower-bound estimates across N transfers in the batch.
Fix one of:
- Rename the field to match the accessor (`pub estimated_min_fee: Credits`) and rewrite the merge comment + accessor doc to consistently describe "sum of static lower-bound fee estimates across all transfers contributing to this changeset".
- Make the field `pub(crate)` so all reads go through the accessor, and clarify in the accessor doc that the value is per-batch (post-merge) rather than per-transfer.
| pub fn derive_identity_key( | ||
| seed: &[u8; 64], | ||
| network: Network, | ||
| identity_index: u32, | ||
| key_index: u32, | ||
| purpose: Purpose, | ||
| security_level: SecurityLevel, | ||
| ) -> FrameworkResult<IdentityPublicKey> { | ||
| use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; | ||
| use key_wallet::wallet::root_extended_keys::RootExtendedPrivKey; | ||
| use platform_wallet::wallet::identity::network::derive_ecdsa_identity_auth_keypair_from_master; | ||
|
|
||
| let root_priv = RootExtendedPrivKey::new_master(seed).map_err(|err| { | ||
| FrameworkError::Wallet(format!( | ||
| "derive_identity_key: invalid seed for root xpriv: {err}" | ||
| )) | ||
| })?; | ||
| let master = root_priv.to_extended_priv_key(network); | ||
| let derived = | ||
| derive_ecdsa_identity_auth_keypair_from_master(&master, network, identity_index, key_index) | ||
| .map_err(|err| { | ||
| FrameworkError::Wallet(format!( | ||
| "derive_identity_key: derive ({identity_index}, {key_index}): {err}" | ||
| )) | ||
| })?; | ||
| let v0 = IdentityPublicKeyV0 { | ||
| id: key_index as KeyID, | ||
| purpose, | ||
| security_level, | ||
| contract_bounds: None, | ||
| key_type: KeyType::ECDSA_SECP256K1, | ||
| read_only: false, | ||
| data: BinaryData::new(derived.public_key.to_vec()), | ||
| disabled_at: None, | ||
| }; | ||
| Ok(IdentityPublicKey::V0(v0)) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: derive_identity_key accepts purpose/security_level but always derives from the AUTHENTICATION sub-tree
The helper takes purpose: Purpose and security_level: SecurityLevel and stamps them onto the returned IdentityPublicKeyV0 (lines 203–204), but the underlying derivation is unconditionally derive_ecdsa_identity_auth_keypair_from_master(...) — i.e. the DIP-9 AUTHENTICATION sub-tree at m/9'/coin'/5'/0'/ECDSA'/identity'/key'. Passing Purpose::ENCRYPTION, Purpose::TRANSFER, or Purpose::VOTING yields an IdentityPublicKey whose purpose field claims one thing while its bytes were derived from a different path entirely.
SeedBackedIdentitySigner only pre-derives AUTHENTICATION-path secrets via SimpleSigner::from_seed_for_identity, so signing with such a misclassified key happens to work — masking the inconsistency rather than surfacing it. As a pub test helper this is a real footgun for anyone writing future test cases that exercise non-AUTHENTICATION purposes.
Either (a) drop the purpose/security_level parameters, hardcode Purpose::AUTHENTICATION, and infer security level from key_index (matching the MASTER/HIGH convention used at the call site at wallet_factory.rs:324–339), or (b) reject (return Err) when called with a non-AUTHENTICATION purpose so the inconsistency fails loudly at construction.
source: ['claude-rust-quality']
🤖 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/signer.rs`:
- [SUGGESTION] lines 176-212: `derive_identity_key` accepts `purpose`/`security_level` but always derives from the AUTHENTICATION sub-tree
The helper takes `purpose: Purpose` and `security_level: SecurityLevel` and stamps them onto the returned `IdentityPublicKeyV0` (lines 203–204), but the underlying derivation is unconditionally `derive_ecdsa_identity_auth_keypair_from_master(...)` — i.e. the DIP-9 AUTHENTICATION sub-tree at `m/9'/coin'/5'/0'/ECDSA'/identity'/key'`. Passing `Purpose::ENCRYPTION`, `Purpose::TRANSFER`, or `Purpose::VOTING` yields an `IdentityPublicKey` whose `purpose` field claims one thing while its bytes were derived from a different path entirely.
`SeedBackedIdentitySigner` only pre-derives AUTHENTICATION-path secrets via `SimpleSigner::from_seed_for_identity`, so signing with such a misclassified key happens to work — masking the inconsistency rather than surfacing it. As a `pub` test helper this is a real footgun for anyone writing future test cases that exercise non-AUTHENTICATION purposes.
Either (a) drop the `purpose`/`security_level` parameters, hardcode `Purpose::AUTHENTICATION`, and infer security level from `key_index` (matching the MASTER/HIGH convention used at the call site at wallet_factory.rs:324–339), or (b) reject (return `Err`) when called with a non-AUTHENTICATION purpose so the inconsistency fails loudly at construction.
| // The balance check uses a post-fee threshold of `funding / | ||
| // 2` — registration fees on testnet are well below half the | ||
| // funding amount, so this gives us a deterministic "the | ||
| // identity exists and has been credited" assertion without | ||
| // hard-coding a specific fee number that a protocol bump | ||
| // could invalidate. | ||
| wait_for_identity_balance( | ||
| self.wallet.sdk(), | ||
| registered.id(), | ||
| funding / 2, | ||
| DEFAULT_IDENTITY_VISIBILITY_TIMEOUT, | ||
| ) | ||
| .await?; | ||
|
|
There was a problem hiding this comment.
🟡 Suggestion: funding / 2 is both too loose and silently coupled to a funding ≥ 2 × register_fee precondition
After register_from_addresses, the helper waits for the on-chain identity balance to reach only funding / 2. Two problems:
- The actual post-fee balance is already returned in
registered.balance()— this is proof-verified by the SDK call. Waiting forfunding / 2instead ofregistered.balance()weakens the setup invariant: a fee regression or partial-credit bug that still leaves the identity above 50% of the requested funding would silently pass setup and surface as a confusing assertion failure later in the test body. - The threshold is only correct under the unstated precondition
funding ≥ 2 × register_fee. For the canonical 30M test funding it's safe, but tests that pass smallerfundingvalues (e.g. probing edge cases nearmin_input_amount) could see registration fees exceedfunding/2and time out even on a normal registration.
Use registered.balance() directly as the wait target — it's already an exact, proven value — and the implicit precondition disappears.
💡 Suggested change
| // The balance check uses a post-fee threshold of `funding / | |
| // 2` — registration fees on testnet are well below half the | |
| // funding amount, so this gives us a deterministic "the | |
| // identity exists and has been credited" assertion without | |
| // hard-coding a specific fee number that a protocol bump | |
| // could invalidate. | |
| wait_for_identity_balance( | |
| self.wallet.sdk(), | |
| registered.id(), | |
| funding / 2, | |
| DEFAULT_IDENTITY_VISIBILITY_TIMEOUT, | |
| ) | |
| .await?; | |
| wait_for_identity_balance( | |
| self.wallet.sdk(), | |
| registered.id(), | |
| registered.balance(), | |
| DEFAULT_IDENTITY_VISIBILITY_TIMEOUT, | |
| ) | |
| .await?; |
source: ['claude-general', 'codex-rust-quality']
🤖 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/wallet_factory.rs`:
- [SUGGESTION] lines 373-386: `funding / 2` is both too loose and silently coupled to a `funding ≥ 2 × register_fee` precondition
After `register_from_addresses`, the helper waits for the on-chain identity balance to reach only `funding / 2`. Two problems:
1. The actual post-fee balance is already returned in `registered.balance()` — this is proof-verified by the SDK call. Waiting for `funding / 2` instead of `registered.balance()` weakens the setup invariant: a fee regression or partial-credit bug that still leaves the identity above 50% of the requested funding would silently pass setup and surface as a confusing assertion failure later in the test body.
2. The threshold is only correct under the unstated precondition `funding ≥ 2 × register_fee`. For the canonical 30M test funding it's safe, but tests that pass smaller `funding` values (e.g. probing edge cases near `min_input_amount`) could see registration fees exceed `funding/2` and time out even on a normal registration.
Use `registered.balance()` directly as the wait target — it's already an exact, proven value — and the implicit precondition disappears.
| /// Used by replay-safety tests (PA-006): re-submit the captured | ||
| /// bytes via `sdk.broadcast_state_transition` and assert the | ||
| /// platform rejects the duplicate. The captured bytes are taken | ||
| /// from a sibling build (separate nonce fetch, separate signing | ||
| /// pass) — they are NOT byte-equal to the broadcast transition, | ||
| /// because the production path bumps nonces independently. For | ||
| /// PA-006 that's the right shape: the test confirms the second | ||
| /// submission is rejected regardless of the nonce relationship | ||
| /// between the two builds. |
There was a problem hiding this comment.
🟡 Suggestion: Doc rationale for transfer_capturing_st_bytes rejection mechanism is wrong
The doc claims captured bytes are NOT byte-equal to the broadcast transition because "the production path bumps nonces independently." That isn't what happens. Both code paths run sequentially with no broadcast in between (line 260: capture build calls fetch_inputs_with_nonce + nonce_inc; line 280: production transfer_with_inputs re-fetches nonces from chain). The capture build never broadcasts, so the on-chain nonce stays at N for both reads — both bump to N+1, both use the same inputs, outputs, fee strategy, and signer; ECDSA signing is deterministic via core_signer. The two transitions are very likely byte-equal, or at minimum share nonces.
PA-006's replay rejection still works, but for a different reason: the broadcast in step 2 advances the chain nonce to N+1, and the captured bytes (which use nonce N+1) are then rejected on replay because that nonce has already been consumed. Suggested replacement docstring: "Both builds use the same fetched + bumped nonce; the captured transition is rejected on replay because the production submission has already consumed that nonce on-chain."
source: ['claude-general']
🤖 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/wallet_factory.rs`:
- [SUGGESTION] lines 237-245: Doc rationale for `transfer_capturing_st_bytes` rejection mechanism is wrong
The doc claims captured bytes are NOT byte-equal to the broadcast transition because "the production path bumps nonces independently." That isn't what happens. Both code paths run sequentially with no broadcast in between (line 260: capture build calls `fetch_inputs_with_nonce` + `nonce_inc`; line 280: production `transfer_with_inputs` re-fetches nonces from chain). The capture build never broadcasts, so the on-chain nonce stays at N for both reads — both bump to N+1, both use the same inputs, outputs, fee strategy, and signer; ECDSA signing is deterministic via `core_signer`. The two transitions are very likely byte-equal, or at minimum share nonces.
PA-006's replay rejection still works, but for a different reason: the broadcast in step 2 advances the chain nonce to N+1, and the captured bytes (which use nonce N+1) are then rejected on replay because that nonce has already been consumed. Suggested replacement docstring: "Both builds use the same fetched + bumped nonce; the captured transition is rejected on replay because the production submission has already consumed that nonce on-chain."
| /// Guard returned by [`setup_with_n_identities`]. Wraps the base | ||
| /// [`SetupGuard`] plus the freshly-registered identities. | ||
| /// | ||
| /// Calling [`MultiIdentitySetupGuard::teardown`] consumes the guard | ||
| /// and forwards to the inner [`SetupGuard::teardown`], which sweeps | ||
| /// platform-address balances. Identity-credit cleanup is deferred to | ||
| /// a follow-up PR — see the `#identity-sweep` TODO in | ||
| /// [`cleanup::sweep_identities`]. Until then, every identity | ||
| /// registered here keeps its post-registration credit balance. | ||
| pub struct MultiIdentitySetupGuard { | ||
| /// Inner single-wallet guard. Holds the [`E2eContext`] and the | ||
| /// shared [`wallet_factory::TestWallet`] every identity is | ||
| /// derived from. | ||
| pub base: SetupGuard, | ||
| /// Identities registered during setup, ordered by DIP-9 index | ||
| /// `0..n`. | ||
| pub identities: Vec<wallet_factory::RegisteredIdentity>, | ||
| } | ||
|
|
||
| impl MultiIdentitySetupGuard { | ||
| /// Forward to the inner [`SetupGuard::teardown`]. | ||
| pub async fn teardown(self) -> FrameworkResult<()> { | ||
| self.base.teardown().await | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: setup_with_n_identities amplifies the documented identity-credit leak by n
Teardown forwards to SetupGuard::teardown which calls cleanup::teardown_one, whose sweep_identities is currently a no-op (cleanup.rs:261–263, gated on the #identity-sweep TODO). The PR's own doc on MultiIdentitySetupGuard (lines 230–234) acknowledges this and points at the same TODO. So every identity created by setup_with_n_identities keeps its post-registration credit balance until the follow-up sweep lands — and n-identity setup multiplies the per-test leak by n.
This is not a regression introduced by this PR (the same leak applies to the existing single-identity helper), and the docstring is honest about it. The reason to flag is sizing: any test that uses setup_with_n_identities(n, funding_per) permanently consumes n × (funding_per − register_fee) credits from the bank wallet per run, scaling worse than the single-identity case. Either (a) prioritize the #identity-sweep follow-up before adopting this helper widely, or (b) gate the helper on a CI-only env var until the sweep lands so accidental local runs don't quietly drain the bank.
source: ['codex-general']
🤖 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/mod.rs`:
- [SUGGESTION] lines 226-250: `setup_with_n_identities` amplifies the documented identity-credit leak by `n`
Teardown forwards to `SetupGuard::teardown` which calls `cleanup::teardown_one`, whose `sweep_identities` is currently a no-op (cleanup.rs:261–263, gated on the `#identity-sweep` TODO). The PR's own doc on `MultiIdentitySetupGuard` (lines 230–234) acknowledges this and points at the same TODO. So every identity created by `setup_with_n_identities` keeps its post-registration credit balance until the follow-up sweep lands — and `n`-identity setup multiplies the per-test leak by `n`.
This is not a regression introduced by this PR (the same leak applies to the existing single-identity helper), and the docstring is honest about it. The reason to flag is sizing: any test that uses `setup_with_n_identities(n, funding_per)` permanently consumes `n × (funding_per − register_fee)` credits from the bank wallet per run, scaling worse than the single-identity case. Either (a) prioritize the `#identity-sweep` follow-up before adopting this helper widely, or (b) gate the helper on a CI-only env var until the sweep lands so accidental local runs don't quietly drain the bank.
| match Identity::fetch(sdk, identity_id).await { | ||
| Ok(Some(identity)) => { | ||
| let balance = identity.balance(); | ||
| if balance >= expected { | ||
| tracing::info!( | ||
| target: "platform_wallet::e2e::wait", | ||
| ?identity_id, | ||
| observed = balance, | ||
| expected, | ||
| elapsed = ?start.elapsed(), | ||
| "identity balance reached target" | ||
| ); | ||
| return Ok(balance); | ||
| } | ||
| tracing::debug!( | ||
| target: "platform_wallet::e2e::wait", | ||
| ?identity_id, | ||
| current = balance, | ||
| expected, | ||
| "identity balance below target" | ||
| ); | ||
| } | ||
| Ok(None) => tracing::debug!( | ||
| target: "platform_wallet::e2e::wait", | ||
| ?identity_id, | ||
| "identity not yet visible on chain" | ||
| ), | ||
| Err(err) => tracing::debug!( | ||
| target: "platform_wallet::e2e::wait", | ||
| error = %err, | ||
| "fetch::<Identity> failed during wait_for_identity_balance" | ||
| ), | ||
| } | ||
|
|
||
| let remaining = deadline.saturating_duration_since(Instant::now()); | ||
| if remaining.is_zero() { | ||
| return Err(FrameworkError::Cleanup(format!( | ||
| "wait_for_identity_balance timed out after {timeout:?} \ | ||
| (identity_id={identity_id:?} expected={expected})" | ||
| ))); | ||
| } | ||
| // Cap the sleep against the remaining budget so a sub-2s | ||
| // `timeout` doesn't overshoot by up to `BACKSTOP_WAKE_INTERVAL`. | ||
| tokio::time::sleep(std::cmp::min(remaining, BACKSTOP_WAKE_INTERVAL)).await; | ||
| } | ||
| } | ||
|
|
||
| /// Wait for a DPNS `<name>.dash` registration to become visible to | ||
| /// resolvers. | ||
| /// | ||
| /// Polls [`Sdk::resolve_dpns_name`] every [`BACKSTOP_WAKE_INTERVAL`] | ||
| /// until it returns `Some(..)` or the timeout elapses. Returns the | ||
| /// resolved owning identity id on success. Test authors typically | ||
| /// pair this with the wallet's `register_name_with_external_signer` | ||
| /// call so the assertion side of the test waits on observable | ||
| /// propagation, not just on the state-transition's broadcast | ||
| /// acknowledgement. | ||
| pub async fn wait_for_dpns_name_visible( | ||
| sdk: &Sdk, | ||
| name: &str, | ||
| timeout: Duration, | ||
| ) -> FrameworkResult<Identifier> { | ||
| let start = Instant::now(); | ||
| let deadline = Instant::now() + timeout; | ||
|
|
||
| loop { | ||
| match sdk.resolve_dpns_name(name).await { | ||
| Ok(Some(id)) => { | ||
| tracing::info!( | ||
| target: "platform_wallet::e2e::wait", | ||
| name, | ||
| elapsed = ?start.elapsed(), | ||
| "DPNS name visible" | ||
| ); | ||
| return Ok(id); | ||
| } | ||
| Ok(None) => tracing::debug!( | ||
| target: "platform_wallet::e2e::wait", | ||
| name, | ||
| "DPNS name not yet visible" | ||
| ), | ||
| Err(err) => tracing::debug!( | ||
| target: "platform_wallet::e2e::wait", | ||
| name, | ||
| error = %err, | ||
| "DPNS resolve failed during wait_for_dpns_name_visible" | ||
| ), | ||
| } | ||
|
|
||
| let remaining = deadline.saturating_duration_since(Instant::now()); | ||
| if remaining.is_zero() { | ||
| return Err(FrameworkError::Cleanup(format!( | ||
| "wait_for_dpns_name_visible timed out after {timeout:?} (name={name:?})" | ||
| ))); | ||
| } | ||
| // Cap the sleep against the remaining budget so a sub-2s | ||
| // `timeout` doesn't overshoot by up to `BACKSTOP_WAKE_INTERVAL`. | ||
| tokio::time::sleep(std::cmp::min(remaining, BACKSTOP_WAKE_INTERVAL)).await; | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Wait helpers downgrade every SDK error to a generic timeout
Both wait_for_identity_balance (lines 143–188) and wait_for_dpns_name_visible (lines 200–242) treat every Err from the SDK as transient: log at debug, sleep, retry. Proof-verification failures, malformed queries, and other deterministic regressions get hidden behind the eventual timeout error and lose the concrete error type — the harness reports "timed out after 30s" rather than the specific verification or transport failure.
Retrying Ok(None) is correct (the identity / name simply isn't visible yet). Narrow transport-flake retries are also fine. But blanket-retrying every Err makes the harness much worse at distinguishing real failures from chain latency. Consider classifying errors and only retrying transport / not-found shapes; surface deterministic failures (proof verification, malformed request) immediately.
source: ['codex-rust-quality']
🤖 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/wait.rs`:
- [SUGGESTION] lines 143-242: Wait helpers downgrade every SDK error to a generic timeout
Both `wait_for_identity_balance` (lines 143–188) and `wait_for_dpns_name_visible` (lines 200–242) treat every `Err` from the SDK as transient: log at `debug`, sleep, retry. Proof-verification failures, malformed queries, and other deterministic regressions get hidden behind the eventual timeout error and lose the concrete error type — the harness reports "timed out after 30s" rather than the specific verification or transport failure.
Retrying `Ok(None)` is correct (the identity / name simply isn't visible yet). Narrow transport-flake retries are also fine. But blanket-retrying every `Err` makes the harness much worse at distinguishing real failures from chain latency. Consider classifying errors and only retrying transport / not-found shapes; surface deterministic failures (proof verification, malformed request) immediately.
…de restored state [CMT-002] Reviewer noted the previous wording suggested watermarks were session-local; in fact apply_sync_state restores a persisted watermark. Update the doc to acknowledge both sources of a non-None return value. Addresses Copilot review feedback on PR #3563. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s docstring [CMT-003] The previous docstring claimed the function picks a fresh receive address and verifies funding — neither is true. The function takes a caller-provided funding_address parameter and performs no balance check. Reword step 1 to reflect actual behavior and warn about the deferred-error case for under-funded addresses. Addresses Copilot review feedback on PR #3563. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e-divergence rationale [CMT-004/005] Previous docstring attributed byte differences between captured and broadcast transitions to nonce divergence — incorrect. Both share the same address nonces (the sibling capture never broadcasts, so chain state is unchanged between fetches). The actual cause is ECDSA signature non-determinism. Update the rationale and add guidance for PA-006-style replay tests: assert on nonce-duplicate rejection, or capture bytes from the production submission directly. Addresses thepastaclaw review feedback on PR #3563 (two duplicate threads collapsed into one fix). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…apturing_st_bytes [QA-003/004] The capture helper previously forwarded the caller-supplied `inputs` map verbatim. Tests that pass each address's full balance as the input amount produced transitions where Σ inputs ≫ Σ outputs and the protocol rejected with InputOutputBalanceMismatchError before the replay-detection logic the test is actually exercising could ever fire. `AddressFundsTransferTransition` validation enforces literal Σ inputs == Σ outputs on the encoded transition; the chain-time fee comes from output 0 at execution under [ReduceOutput(0)] but the payload itself must balance pre-fee. The new `balance_explicit_inputs` helper rebalances the caller-supplied input map to match Σ outputs exactly: a single input gets the full output sum (the PA-006 / PA-006b shape), multi-input maps split proportionally with rounding remainder absorbed by the lex-smallest entry. Each share is held at or above min_input_amount so the per-input minimum check downstream still passes. The helper still bypasses auto_select_inputs intentionally (replay tests want byte-equal captured/broadcast transitions), but now produces a protocol-valid transition. Surfaced by pa_006_replay_safety and pa_006b_concurrent_broadcast in PR #3571's e2e suite (Marvin QA-003 / QA-004). Note: the brief proposed `Σ inputs == Σ outputs + estimated_min_fee`. The protocol's basic validation enforces Σ inputs == Σ outputs literally (see `v0/state_transition_validation.rs:184-221`); the fee strategy is applied at execution time. The fix uses the literal-equality formulation to match what the validator actually checks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mic sweeps [QA-005/006] sweep_platform_addresses previously bundled every address with balance > 0 into the explicit-input set. Tests that deliberately distribute funds across multiple addresses (pa_004b_sweep_dust_boundary, pa_009_min_input_amount) leave sub-floor balances on some addresses (change outputs, in-test self-transfers, etc.); the protocol then rejected the entire sweep with InputBelowMinimumError before any address was drained, and the test's post-trim assertion saw ~497B credits stranded. Two changes, factored through a new pure `build_sweep_plan` helper: - Filter to balance ≥ min_input_amount. Sub-floor entries are reported as `skipped_dust` and logged at warn level — they fall under the protocol's dust floor and sweeping them is impossible by definition. - Skip the broadcast when Σ recoverable ≤ estimated fee for the selected (N_inputs, 1) shape. Sweeping when the fee exceeds the haul wastes more than it recovers. The single-address case is the degenerate path of the multi-input flow; it falls through naturally without special casing. Surfaced by pa_004b_sweep_dust_boundary and pa_009_min_input_amount in PR #3571's e2e suite (Marvin QA-005 / QA-006). Note: the brief described the previous behaviour as "iterates only addr_1". The actual code already iterated every address with balance > 0 — the missing piece was the `min_input_amount` filter. The fix matches the brief's intent (sweep all recoverable addresses) using the correct diagnosis. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue being fixed or feature implemented
Stacks on top of #3549 to deliver the next layer of the
rs-platform-wallete2e test suite: a written test specification, plus the harness extensions that subsequent test-implementation PRs will consume.No test cases are added in this PR — the existing
cases/transfer.rsis unchanged. The cases themselves come in follow-up PRs.What was done?
Documentation
tests/e2e/TEST_SPEC.md— 31 prioritized test cases across Platform Addresses (8), Identity (6), Tokens (4), Core/SPV (3), Contracts (3), DPNS (2), Dashpay (3), Contested (2). Each case has scenario steps, assertions, harness extensions required, DET parallels, complexity estimate.Wallet public API additions (small, surgical)
PlatformAddressChangeSet::fee_paid()— direct fee accessor populated intransfer()viaAddressFundsTransferTransition::estimate_min_fee. Exact for the canonicalDeductFromInput(_)strategy.PlatformAddressWallet::sync_watermark() -> Option<u64>— monotonic block watermark for sync assertions.IdentityManager::identity_count()/identity_ids()— convenience accessors.PlatformPaymentAddressProvider::last_known_recent_blockgetter — required plumbing forsync_watermark.Harness — Wave A: identity signer + setup helpers
SeedBackedIdentitySignerimplementingSigner<IdentityPublicKey>(DIP-9 derivation).derive_identity_key(seed, network, identity_index, key_index, purpose, security_level) -> IdentityPublicKey— placeholder identity construction helper.TestWallet::register_identity_from_addresses(funding) -> RegisteredIdentity— end-to-end registration helper.wait_for_identity_balanceandwait_for_dpns_name_visibleinframework/wait.rs.Harness — Wave B: multi-identity setup
setup_with_n_identities(n) -> MultiIdentitySetupGuard— fundsndistinct addresses on one test wallet and registers an identity from each. Wrapper around the existingSetupGuard(chosen over field extension becauseSetupGuard.teardown_calledispub(crate)).Harness — Wave F: test-only utilities
TestWallet::transfer_with_inputs(PA-002 negative variant — bypassesauto_select_inputs).TestWallet::transfer_capturing_st_bytes(PA-006 — returns serialized state-transition bytes alongside the changeset).PersistentTestWalletRegistry::get_status(PA-004 — single-entry status read).Out of scope (per spec §4)
How Has This Been Tested?
cargo check -p platform-wallet --tests✓cargo clippy -p platform-wallet --tests --all-features -- -D warnings✓cargo fmt --all -- --check✓PLATFORM_WALLET_E2E_BANK_MNEMONICand a funded testnet wallet; documented intests/e2e/README.md.Breaking Changes
None. All additions are net-new public API or framework-only code.
Checklist:
Stacks on #3549. Merge order: #3549 → this PR → follow-up test-case PRs.
🤖 Generated with Claude Code