Uh oh!
There was an error while loading. Please reload this page.
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
3e00df4CompareSpec 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
491f65cCompare…-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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…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