-
Notifications
You must be signed in to change notification settings - Fork 53
test(platform-wallet): implement PA P0 — multi-output, partial-fund, sweep-back #3571
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Claudius-Maginificent
wants to merge
1
commit into
fix/rs-platform-wallet-arithmetic-and-sync-hardening
from
feat/rs-platform-wallet-e2e-cases-pa
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,29 @@ | ||
| //! End-to-end test cases. Each submodule hosts | ||
| //! `#[tokio_shared_rt::test(shared)]` entries that share the | ||
| //! process-wide [`super::framework::E2eContext`]. | ||
| //! | ||
| //! P0 platform-address (PA) cases land here first; the remaining | ||
| //! TEST_SPEC.md priorities (P1, P2, ID-, DP-, DPNS-, TK-, …) follow | ||
| //! in subsequent PRs. | ||
|
|
||
| pub mod transfer; | ||
| pub mod pa_001_multi_output; | ||
| pub mod pa_001b_change_address_branch; | ||
| pub mod pa_001c_zero_credit_output; | ||
| pub mod pa_002_partial_fund; | ||
| pub mod pa_002b_zero_change; | ||
| pub mod pa_003_fee_scaling; | ||
| pub mod pa_004_sweep_back; | ||
| pub mod pa_004b_sweep_dust_boundary; | ||
| pub mod pa_004c_sweep_zero_balance; | ||
| pub mod pa_005_address_rotation; | ||
| pub mod pa_005b_gap_limit_triplet; | ||
| pub mod pa_006_replay_safety; | ||
| pub mod pa_006b_concurrent_broadcast; | ||
| pub mod pa_007_sync_watermark; | ||
| pub mod pa_007b_concurrent_sync; | ||
| pub mod pa_008_concurrent_funding; | ||
| pub mod pa_008b_cross_wallet_funding; | ||
| pub mod pa_008c_funding_mutex_observable; | ||
| pub mod pa_009_min_input_amount; | ||
| pub mod pa_010_bank_starvation; | ||
| pub mod pa_3040_bug_pin; |
254 changes: 254 additions & 0 deletions
254
packages/rs-platform-wallet/tests/e2e/cases/pa_001_multi_output.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,254 @@ | ||
| //! PA-001 — Multi-output platform-address transfer (one tx, N outputs). | ||
| //! Spec: `tests/e2e/TEST_SPEC.md` §3 "Platform Addresses (PA)" → PA-001. | ||
| //! Priority: P0. | ||
| //! | ||
| //! Bank funds `addr_1`. The wallet derives a pair of fresh receive | ||
| //! addresses (`addr_2`, `addr_3`) — note that `next_unused_address` | ||
| //! parks the cursor until each derived address is *observed used* | ||
| //! (PA-005 invariant), so `addr_3` is only distinct from `addr_2` | ||
| //! after a small "prep" transfer marks `addr_2` used. The PA-001 | ||
| //! transfer itself then sends `OUTPUT_A_CREDITS` and | ||
| //! `OUTPUT_B_CREDITS` to {`addr_2`, `addr_3`} in a single transition. | ||
| //! | ||
| //! Under the default `[ReduceOutput(0)]` strategy the **lex-smallest** | ||
| //! output absorbs the chain-time fee — assertions pin the lex-larger | ||
| //! output's gross arrival exactly, and bound the lex-smaller's | ||
| //! gross-minus-fee value. The `Σ inputs == Σ outputs` invariant is | ||
| //! checked against `addr_1`'s residual change. | ||
| //! | ||
| //! Why bumped output amounts: see PA-002's `#3040` note. For 1in/2out | ||
| //! the empirical chain-time fee is larger (~20M) than 1in/1out, so | ||
| //! `OUTPUT_A_CREDITS` (the lex-smallest output's gross) sits well | ||
| //! above that ceiling. | ||
|
|
||
| use std::collections::BTreeMap; | ||
| use std::time::Duration; | ||
|
|
||
| use crate::framework::prelude::*; | ||
|
|
||
| /// Gross credits the bank submits when funding `addr_1`. Bank uses | ||
| /// `[ReduceOutput(0)]`; addr_1 receives `FUNDING_CREDITS − bank_fee`. | ||
| /// Sized to cover (a) the prep transfer that marks `addr_2` used, | ||
| /// (b) the multi-output transfer's gross sum | ||
| /// (`OUTPUT_A_CREDITS + OUTPUT_B_CREDITS`), and (c) chain-time fees on | ||
| /// every transition the harness drives. | ||
| const FUNDING_CREDITS: u64 = 250_000_000; | ||
|
|
||
| /// Lower bound on what addr_1 must receive after the bank's fee | ||
| /// deduction before the test proceeds. | ||
| const FUNDING_FLOOR: u64 = 200_000_000; | ||
|
|
||
| /// Marker transfer to advance the receive-address cursor past | ||
| /// `addr_2`. Sized above the empirical 1in/1out chain-time fee | ||
| /// (~15M, see #3040) so `addr_2` lands with a non-zero post-fee | ||
| /// balance and `wait_for_balance(addr_2, …)` can observe it. | ||
| const PREP_CREDITS: u64 = 30_000_000; | ||
|
|
||
| /// Lower bound on `addr_2`'s balance after the prep transfer settles | ||
| /// (gross PREP minus 1in/1out chain-time fee). | ||
| const PREP_FLOOR: u64 = 1_000_000; | ||
|
|
||
| /// Gross credits sent to the lex-smallest of the two destination | ||
| /// addresses. `[ReduceOutput(0)]` charges the chain-time fee against | ||
| /// this output, so its on-chain delta is `OUTPUT_A_CREDITS − fee`. | ||
| /// Sized well above the empirical 1in/2out fee (~20M) to dodge #3040. | ||
| const OUTPUT_A_CREDITS: u64 = 50_000_000; | ||
|
|
||
| /// Gross credits sent to the lex-larger of the two destination | ||
| /// addresses. This output is **not** reduced by the chain-time fee; | ||
| /// its on-chain delta must equal this gross value exactly. | ||
| const OUTPUT_B_CREDITS: u64 = 60_000_000; | ||
|
|
||
| /// Lower bound on the lex-smaller output's post-fee delta. | ||
| const OUTPUT_A_FLOOR: u64 = 1_000_000; | ||
|
|
||
| /// Upper bound on the chain-time fee for a 1in/2out transition. The | ||
| /// empirical fee at the time PA-001 was written sits around ~20M | ||
| /// credits (per platform #3040's static-vs-chain-time gap analysis); | ||
| /// pinning the assertion here at 30M leaves room for protocol-version | ||
| /// drift while still surfacing a fee-explosion regression. A failure | ||
| /// of this bound means either (a) the protocol's fee schedule shifted | ||
| /// significantly, in which case update this constant deliberately, or | ||
| /// (b) a wallet-side or dpp-side regression is over-charging — which | ||
| /// is precisely what a tight bound is meant to catch. | ||
| const MULTI_FEE_CEILING: u64 = 30_000_000; | ||
|
|
||
| /// Per-step deadline for balance observations. | ||
| const STEP_TIMEOUT: Duration = Duration::from_secs(60); | ||
|
|
||
| #[tokio_shared_rt::test(shared)] | ||
| async fn pa_001_multi_output_transfer() { | ||
| let _ = tracing_subscriber::fmt() | ||
| .with_env_filter( | ||
| tracing_subscriber::EnvFilter::try_from_default_env() | ||
| .unwrap_or_else(|_| "info,platform_wallet=debug".into()), | ||
| ) | ||
| .with_test_writer() | ||
| .try_init(); | ||
|
|
||
| let s = setup().await.expect("e2e setup failed"); | ||
|
|
||
| // ---- Setup: derive 3 distinct addresses, only addr_1 funded ---- | ||
|
|
||
| let addr_1 = s | ||
| .test_wallet | ||
| .next_unused_address() | ||
| .await | ||
| .expect("derive addr_1"); | ||
| s.ctx | ||
| .bank() | ||
| .fund_address(&addr_1, FUNDING_CREDITS) | ||
| .await | ||
| .expect("bank.fund_address"); | ||
| wait_for_balance(&s.test_wallet, &addr_1, FUNDING_FLOOR, STEP_TIMEOUT) | ||
| .await | ||
| .expect("addr_1 funding never observed"); | ||
|
|
||
| let addr_2 = s | ||
| .test_wallet | ||
| .next_unused_address() | ||
| .await | ||
| .expect("derive addr_2"); | ||
| assert_ne!(addr_1, addr_2, "addr_2 must differ from addr_1"); | ||
|
|
||
| // Prep transfer to mark `addr_2` observed-used so the cursor | ||
| // advances. `addr_2` absorbs the chain-time fee (it's the sole | ||
| // output). Without this step `next_unused_address` would park | ||
| // and return `addr_2` again — see PA-005. | ||
| let prep_outputs: BTreeMap<_, _> = std::iter::once((addr_2, PREP_CREDITS)).collect(); | ||
| s.test_wallet | ||
| .transfer(prep_outputs) | ||
| .await | ||
| .expect("prep transfer to addr_2"); | ||
| wait_for_balance(&s.test_wallet, &addr_2, PREP_FLOOR, STEP_TIMEOUT) | ||
| .await | ||
| .expect("addr_2 prep transfer never observed"); | ||
|
|
||
| let addr_3 = s | ||
| .test_wallet | ||
| .next_unused_address() | ||
| .await | ||
| .expect("derive addr_3"); | ||
| assert_ne!(addr_1, addr_3, "addr_3 must differ from addr_1"); | ||
| assert_ne!(addr_2, addr_3, "addr_3 must differ from addr_2"); | ||
|
|
||
| // ---- The PA-001 transfer: one transition, two outputs ---- | ||
|
|
||
| // Capture the pre-multi balance snapshot so we can compute deltas | ||
| // (addr_2 already holds the prep remainder). | ||
| s.test_wallet.sync_balances().await.expect("pre-multi sync"); | ||
| let pre_balances = s.test_wallet.balances().await; | ||
| let addr_1_pre = pre_balances.get(&addr_1).copied().unwrap_or(0); | ||
| let addr_2_pre = pre_balances.get(&addr_2).copied().unwrap_or(0); | ||
| let addr_3_pre = pre_balances.get(&addr_3).copied().unwrap_or(0); | ||
|
|
||
| // Route the smaller output (OUTPUT_A) to whichever destination | ||
| // sorts first lexicographically — that's the one ReduceOutput(0) | ||
| // charges the fee against. | ||
| let (lex_lo, lex_hi) = if addr_2 < addr_3 { | ||
| (addr_2, addr_3) | ||
| } else { | ||
| (addr_3, addr_2) | ||
| }; | ||
| let multi_outputs: BTreeMap<_, _> = [(lex_lo, OUTPUT_A_CREDITS), (lex_hi, OUTPUT_B_CREDITS)] | ||
| .into_iter() | ||
| .collect(); | ||
| s.test_wallet | ||
| .transfer(multi_outputs) | ||
| .await | ||
| .expect("multi-output self-transfer"); | ||
|
|
||
| // Wait for both destinations. The lex-larger output arrives at | ||
| // exactly its gross amount (no fee deduction); the lex-smaller | ||
| // arrives at gross-minus-fee. Compute the per-address delta | ||
| // expectation against the pre-multi snapshot. | ||
| let lex_hi_pre = if lex_hi == addr_2 { | ||
| addr_2_pre | ||
| } else { | ||
| addr_3_pre | ||
| }; | ||
| let lex_lo_pre = if lex_lo == addr_2 { | ||
| addr_2_pre | ||
| } else { | ||
| addr_3_pre | ||
| }; | ||
| wait_for_balance( | ||
| &s.test_wallet, | ||
| &lex_hi, | ||
| lex_hi_pre.saturating_add(OUTPUT_B_CREDITS), | ||
| STEP_TIMEOUT, | ||
| ) | ||
| .await | ||
| .expect("lex_hi never observed"); | ||
| wait_for_balance( | ||
| &s.test_wallet, | ||
| &lex_lo, | ||
| lex_lo_pre.saturating_add(OUTPUT_A_FLOOR), | ||
| STEP_TIMEOUT, | ||
| ) | ||
| .await | ||
| .expect("lex_lo never observed"); | ||
|
|
||
| s.test_wallet | ||
| .sync_balances() | ||
| .await | ||
| .expect("post-multi sync"); | ||
| let post_balances = s.test_wallet.balances().await; | ||
| let addr_1_post = post_balances.get(&addr_1).copied().unwrap_or(0); | ||
| let lex_lo_post = post_balances.get(&lex_lo).copied().unwrap_or(0); | ||
| let lex_hi_post = post_balances.get(&lex_hi).copied().unwrap_or(0); | ||
|
|
||
| let lo_delta = lex_lo_post.saturating_sub(lex_lo_pre); | ||
| let hi_delta = lex_hi_post.saturating_sub(lex_hi_pre); | ||
| let multi_fee = OUTPUT_A_CREDITS.saturating_sub(lo_delta); | ||
| let addr_1_drain = addr_1_pre.saturating_sub(addr_1_post); | ||
|
|
||
| tracing::info!( | ||
| target: "platform_wallet::e2e::cases::pa_001", | ||
| ?addr_1, | ||
| ?lex_lo, | ||
| ?lex_hi, | ||
| addr_1_pre, | ||
| addr_1_post, | ||
| lo_delta, | ||
| hi_delta, | ||
| multi_fee, | ||
| "post-multi-output balance snapshot" | ||
| ); | ||
|
|
||
| // PA-001 contract: lex-larger output arrives at gross exactly | ||
| // (ReduceOutput(0) only deducts from output[0]). | ||
| assert_eq!( | ||
| hi_delta, OUTPUT_B_CREDITS, | ||
| "lex-larger output must arrive at gross amount exactly \ | ||
| (lex-smaller absorbs fee under [ReduceOutput(0)])" | ||
| ); | ||
| // Lex-smaller output absorbed the chain-time fee. | ||
| assert!( | ||
| (OUTPUT_A_FLOOR..OUTPUT_A_CREDITS).contains(&lo_delta), | ||
| "lex-smaller output delta must be gross-minus-fee in \ | ||
| [{OUTPUT_A_FLOOR}, {OUTPUT_A_CREDITS}); observed {lo_delta}" | ||
| ); | ||
| assert!( | ||
| multi_fee > 0, | ||
| "multi-output transfer must charge a non-zero fee" | ||
| ); | ||
| assert!( | ||
| multi_fee < MULTI_FEE_CEILING, | ||
| "multi-output fee {multi_fee} exceeds the regression-guard ceiling \ | ||
| {MULTI_FEE_CEILING} — either the protocol fee schedule shifted \ | ||
| (update MULTI_FEE_CEILING deliberately) or a fee-explosion \ | ||
| regression has landed on either the wallet or dpp side" | ||
| ); | ||
| // Σ inputs == Σ outputs (gross): addr_1 was drained by exactly | ||
| // the gross output total. The actual fee left output[0]'s | ||
| // amount, not addr_1's contribution. | ||
| let gross_outputs = OUTPUT_A_CREDITS.saturating_add(OUTPUT_B_CREDITS); | ||
| assert_eq!( | ||
| addr_1_drain, gross_outputs, | ||
| "addr_1 drain must equal `Σ outputs` (gross) — Σ inputs == Σ outputs \ | ||
| invariant; expected {gross_outputs}, observed {addr_1_drain}" | ||
| ); | ||
|
|
||
| s.teardown().await.expect("teardown"); | ||
| } | ||
58 changes: 58 additions & 0 deletions
58
packages/rs-platform-wallet/tests/e2e/cases/pa_001b_change_address_branch.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| //! PA-001b — Transfer with `output_change_address: None` vs `Some(addr)`. | ||
| //! Spec: `tests/e2e/TEST_SPEC.md` §3 "Platform Addresses (PA)" → PA-001b. | ||
| //! Priority: P2. | ||
| //! | ||
| //! ## Status | ||
| //! | ||
| //! `BLOCKED — feature missing in production.` See spec status field | ||
| //! and Found-019 (sibling Found-bug pin documenting the spec drift). | ||
| //! | ||
| //! The spec describes driving `PlatformAddressWallet::transfer` with | ||
| //! an `output_change_address: Option<PlatformAddress>` parameter that | ||
| //! does not exist in the production signature | ||
| //! (`packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs:31`): | ||
| //! | ||
| //! ```rust,ignore | ||
| //! pub async fn transfer<S: Signer<PlatformAddress> + Send + Sync>( | ||
| //! &self, | ||
| //! account_index: u32, | ||
| //! input_selection: InputSelection, | ||
| //! outputs: BTreeMap<PlatformAddress, Credits>, | ||
| //! fee_strategy: AddressFundsFeeStrategy, | ||
| //! platform_version: Option<&PlatformVersion>, | ||
| //! address_signer: &S, | ||
| //! ) -> Result<PlatformAddressChangeSet, PlatformWalletError> | ||
| //! ``` | ||
| //! | ||
| //! Under the current shape, "change" semantics are implicit: | ||
| //! | ||
| //! - `InputSelection::Auto`: the auto-selector consumes input balance | ||
| //! to cover `Σ outputs` exactly under the post-fix `Σ inputs == | ||
| //! Σ outputs` invariant. There is no separate "change output", so | ||
| //! no `output_change_address` to route — residual stays on the | ||
| //! selected input addresses. | ||
| //! - `InputSelection::Explicit(map)`: the caller declares the | ||
| //! consumed amount per input directly. Any residual stays on the | ||
| //! input. | ||
| //! | ||
| //! PA-001b is therefore not a missing TEST — it's a missing FEATURE. | ||
| //! Surfaced as a Found-bug pin in the spec; this stub stays | ||
| //! `#[ignore]`'d until either the production API gains an explicit | ||
| //! change-address parameter or the spec entry is removed. | ||
|
|
||
| #[tokio_shared_rt::test(shared)] | ||
| #[ignore = "BLOCKED — feature missing in production: \ | ||
| PlatformAddressWallet::transfer has no output_change_address \ | ||
| parameter. See TEST_SPEC.md PA-001b status field and the \ | ||
| Found-NNN entry for the spec/impl drift."] | ||
| async fn pa_001b_change_address_branch() { | ||
| panic!( | ||
| "PA-001b is BLOCKED on a missing production API. \ | ||
| The spec describes an `output_change_address: Option<PlatformAddress>` \ | ||
| parameter on `PlatformAddressWallet::transfer` that does not exist in \ | ||
| `packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs:31`. \ | ||
| See TEST_SPEC.md → PA-001b → **Status** and the corresponding \ | ||
| Found-NNN entry. This `#[ignore]` is intentional; remove it only \ | ||
| once the production API gains the parameter." | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💬 Nitpick: Pervasive
saturating_sub/saturating_addin test deltas masks arithmetic regressionsThe new test files (
pa_001,pa_002,pa_002b,pa_006,pa_004) compute pre/post deltas withsaturating_suband sums withsaturating_add. Production code wants overflow-tolerant arithmetic; tests want the opposite — an off-by-one that flipsaddr_1_post > addr_1_preshould panic, not silently produce 0 and let downstream assertions accept it. Use checked operators or plain-/+so regressions in either direction surface as a loud failure.source: ['claude']