From 57e3b8ca66a2541058c8567eb866a9d7325ff59b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 4 May 2026 15:42:44 +0200 Subject: [PATCH 1/3] fix(rs-platform-wallet/e2e): bank.fund_address pays fee from input, not recipient [QA-001b] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bank.fund_address previously used `[ReduceOutput(0)]`, so recipients received `funding_per - fee` instead of the requested `funding_per`. Tests calling `register_identity_from_addresses({addr: funding_per}, ...)` then failed with `AddressesNotEnoughFundsError` because the address balance was lower than the requested input amount. Switch the bank to `[DeductFromInput(0)]` so the bank's own input absorbs the fee and the recipient receives the exact requested amount. Other call sites of `default_fee_strategy()` are unaffected — they keep the `ReduceOutput(0)` semantics that PA tests rely on. Reported by Marvin (id_003 retest after QA-001 wait-threshold fix). Co-Authored-By: Claude Opus 4.6 --- .../tests/e2e/framework/bank.rs | 13 +++++++++---- .../tests/e2e/framework/wallet_factory.rs | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/bank.rs b/packages/rs-platform-wallet/tests/e2e/framework/bank.rs index 0dade6e17d9..c953e18d13d 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/bank.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/bank.rs @@ -25,9 +25,7 @@ use tokio::sync::Mutex as AsyncMutex; use simple_signer::signer::SimpleSigner; use super::config::Config; -use super::wallet_factory::{ - default_fee_strategy, DEFAULT_ACCOUNT_INDEX_PUB, DEFAULT_KEY_CLASS_PUB, -}; +use super::wallet_factory::{bank_fee_strategy, DEFAULT_ACCOUNT_INDEX_PUB, DEFAULT_KEY_CLASS_PUB}; use super::{make_platform_signer, FrameworkError, FrameworkResult}; /// In-process funding mutex — serialises concurrent @@ -153,6 +151,13 @@ impl BankWallet { /// Fund `target` with `credits` from the bank's primary /// account. /// + /// Recipients receive the **exact** `credits` amount; the fee + /// is deducted from the bank's input via + /// [`bank_fee_strategy`]. The bank therefore consumes + /// `credits + fee` from its own platform-addresses pool — + /// verify the bank balance is sufficiently above + /// `min_bank_credits` before calling. + /// /// Submits the transfer immediately and returns the resulting /// [`PlatformAddressChangeSet`]. Does NOT wait for the chain to /// observe the credit — callers follow up with @@ -173,7 +178,7 @@ impl BankWallet { DEFAULT_ACCOUNT_INDEX_PUB, InputSelection::Auto, outputs, - default_fee_strategy(), + bank_fee_strategy(), Some(PlatformVersion::latest()), &self.signer, ) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs b/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs index 9c37f3fc6cd..450d0813920 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs @@ -418,6 +418,21 @@ pub(crate) fn default_fee_strategy() -> AddressFundsFeeStrategy { vec![AddressFundsFeeStrategyStep::ReduceOutput(0)] } +/// Bank-funding fee strategy: deduct fee from input #0 so the +/// recipient receives the **exact** requested amount. +/// +/// Used by [`super::bank::BankWallet::fund_address`] so +/// downstream calls — e.g. `register_identity_from_addresses( +/// {addr: N}, ...)` — don't have to compensate for fee +/// deduction at the recipient. +/// +/// Tests that need the alternative `ReduceOutput(0)` semantics +/// (e.g. PA-002b verifying `Σ outputs + fee == input balance`) +/// should call [`default_fee_strategy`] explicitly. +pub(crate) fn bank_fee_strategy() -> AddressFundsFeeStrategy { + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)] +} + /// Rebalance an explicit-input map so its sum equals `Σ outputs`. /// /// `AddressFundsTransferTransition` validation rejects with From bc3fe6094aebdf3bc9432c6bb3579c2bb7adf134 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 4 May 2026 16:28:31 +0200 Subject: [PATCH 2/3] fix(rs-platform-wallet/e2e): adjust PA-001 assertions for DeductFromInput(0) bank semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under Option C, bank.fund_address uses [DeductFromInput(0)] so the recipient receives the exact requested amount; the bank's fee is deducted from its own input and is invisible to the test wallet. The previous arithmetic derived bank_fee from (FUNDING_CREDITS - observed_total - transfer_fee), which under the new strategy always computes 0 — making the bank_fee > 0 assertion at transfer.rs:169 fail unconditionally. Replace with a direct bank.total_credits() pre/post comparison: capture bank_pre before fund_address, resync the bank afterward, capture bank_post, then bank_fee = bank_pre - bank_post - FUNDING_CREDITS. Also adds an assert_eq! that addr_1 retains FUNDING_CREDITS minus only the self-transfer fee (not the bank fee), and updates const comments to reflect DeductFromInput(0) semantics for the bank step. Reported by Marvin (PA-001 smoke test on PR #3579). Co-Authored-By: Claude Sonnet 4.6 --- .../tests/e2e/cases/transfer.rs | 68 ++++++++++++------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/cases/transfer.rs b/packages/rs-platform-wallet/tests/e2e/cases/transfer.rs index aa5bf365b7e..1e0a2700391 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/transfer.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/transfer.rs @@ -33,17 +33,16 @@ use crate::framework::prelude::*; // the empirical chain-time ceiling sidesteps the bug until #3040 // lands at the dpp layer. -/// Gross credits the bank submits when funding `addr_1`. The bank -/// uses `[ReduceOutput(0)]`, so addr_1 actually receives -/// `FUNDING_CREDITS − bank_fee`. Sized well above the chain-time -/// fee (~15M empirically) so addr_1 retains enough headroom to -/// fund the test's own self-transfer (see #3040 comment above). +/// Credits the bank delivers to `addr_1`. The bank uses +/// `[DeductFromInput(0)]`, so addr_1 receives this exact amount; +/// the bank's fee is absorbed by the bank's own input. Sized well +/// above the chain-time fee (~15M empirically) so addr_1 has +/// enough headroom for the self-transfer (see #3040 comment above). const FUNDING_CREDITS: u64 = 100_000_000; -/// Lower bound on what addr_1 must receive after the bank's fee -/// deduction before the test proceeds. Pinned well below the raw -/// gross so the wait isn't sensitive to fee fluctuations across -/// protocol versions. +/// Safety floor for the addr_1 wait. Under `[DeductFromInput(0)]` +/// addr_1 receives FUNDING_CREDITS exactly; the floor is kept as a +/// guard against an empty/stale observation slipping through. const FUNDING_FLOOR: u64 = 70_000_000; /// Gross credits the test wallet submits in its self-transfer to @@ -82,14 +81,19 @@ async fn transfer_between_two_platform_addresses() { .await .expect("derive addr_1"); + // Snapshot bank balance before funding so we can derive the fee + // the bank's input actually paid (invisible to the test wallet). + let bank_pre = s.ctx.bank().total_credits().await; + s.ctx .bank() .fund_address(&addr_1, FUNDING_CREDITS) .await .expect("bank.fund_address"); - // Bank uses `[ReduceOutput(0)]`, so addr_1 receives - // `FUNDING_CREDITS − bank_fee`. Wait on the post-fee floor. + // Bank uses `[DeductFromInput(0)]`: addr_1 receives FUNDING_CREDITS + // exactly. Wait on the safety floor; the exact-amount assertion + // follows after the test wallet syncs. wait_for_balance(&s.test_wallet, &addr_1, FUNDING_FLOOR, STEP_TIMEOUT) .await .expect("addr_1 funding never observed"); @@ -116,9 +120,8 @@ async fn transfer_between_two_platform_addresses() { .await .expect("addr_2 transfer never observed"); - // Re-sync so the cached view reflects post-transfer state across - // BOTH addresses, then derive bank- and transfer-fee shares from - // observed balances. + // Re-sync test wallet so the cached view reflects post-transfer + // state across BOTH addresses. s.test_wallet .sync_balances() .await @@ -126,21 +129,29 @@ async fn transfer_between_two_platform_addresses() { let balances = s.test_wallet.balances().await; let received = balances.get(&addr_2).copied().unwrap_or(0); let remaining = balances.get(&addr_1).copied().unwrap_or(0); - let observed_total = received.saturating_add(remaining); - // Bank's `ReduceOutput(0)` charged its fee against addr_1's - // funding output: the wallet's total post-transfer is - // `FUNDING_CREDITS − bank_fee − transfer_fee`. Each fee is the - // amount each ReduceOutput step trimmed off its respective - // output; together they equal `FUNDING_CREDITS − observed_total`. - let total_fees = FUNDING_CREDITS.saturating_sub(observed_total); // The transfer fee is the share TRANSFER_CREDITS lost while - // crossing addr_1 -> addr_2. + // crossing addr_1 -> addr_2 via `[ReduceOutput(0)]`. let transfer_fee = TRANSFER_CREDITS.saturating_sub(received); - let bank_fee = total_fees.saturating_sub(transfer_fee); + + // Resync the bank to get its post-funding balance, then derive + // the fee the bank's input absorbed under `[DeductFromInput(0)]`. + s.ctx + .bank() + .sync_balances() + .await + .expect("bank post-funding sync"); + let bank_post = s.ctx.bank().total_credits().await; + // bank_pre - bank_post = FUNDING_CREDITS + bank_fee + let bank_fee = bank_pre + .saturating_sub(bank_post) + .saturating_sub(FUNDING_CREDITS); + tracing::info!( target: "platform_wallet::e2e::cases::transfer", ?addr_1, ?addr_2, + bank_pre, + bank_post, funded = FUNDING_CREDITS, received, remaining, @@ -149,6 +160,14 @@ async fn transfer_between_two_platform_addresses() { "post-transfer balance snapshot" ); + // Under `[DeductFromInput(0)]`, addr_1 receives the exact + // requested amount; the bank absorbs the fee. + assert_eq!( + remaining, + FUNDING_CREDITS - transfer_fee, + "addr_1 must retain FUNDING_CREDITS minus the transfer fee \ + (bank_pre={bank_pre} bank_post={bank_post} funded={FUNDING_CREDITS})" + ); assert!( received >= TRANSFER_FLOOR, "addr_2 must hold at least TRANSFER_FLOOR ({TRANSFER_FLOOR}); observed {received}" @@ -168,7 +187,8 @@ async fn transfer_between_two_platform_addresses() { ); assert!( bank_fee > 0, - "bank funding must charge a non-zero fee (observed_total={observed_total})" + "bank funding must charge a non-zero fee to its own input \ + (bank_pre={bank_pre} bank_post={bank_post} funded={FUNDING_CREDITS})" ); s.teardown().await.expect("teardown"); From e2fa9c23f4e5ac54b0d31d356f0dfc3956fe8ade Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 4 May 2026 20:57:42 +0200 Subject: [PATCH 3/3] fix(rs-platform-wallet/e2e): correct PA-001 retention assertion for [ReduceOutput(0)] semantics [QA-104] Previous fix updated bank_fee derivation (bc3fe6094a) but left the addr_1 retention assertion wrong: it asserted addr_1 == FUNDING_CREDITS - transfer_fee, but the protocol's [ReduceOutput(0)] strategy deducts the fee from output[0] (addr_2's amount), not from addr_1's residual. Correct math: - addr_1 retains FUNDING_CREDITS - TRANSFER_CREDITS (what was sent leaves). - addr_2 receives TRANSFER_CREDITS - transfer_fee (post-fee delivery). Reported by Marvin (suite-2 PA-001 rerun on PR #3579). Co-Authored-By: Claude Sonnet 4.6 --- .../tests/e2e/cases/transfer.rs | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/cases/transfer.rs b/packages/rs-platform-wallet/tests/e2e/cases/transfer.rs index 1e0a2700391..d76bfb5b208 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/transfer.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/transfer.rs @@ -160,22 +160,25 @@ async fn transfer_between_two_platform_addresses() { "post-transfer balance snapshot" ); - // Under `[DeductFromInput(0)]`, addr_1 receives the exact - // requested amount; the bank absorbs the fee. + // Under [ReduceOutput(0)], the protocol deducts the transfer fee + // from output[0] — addr_2's received amount — not from addr_1's + // residual. So addr_1 retains FUNDING_CREDITS - TRANSFER_CREDITS + // and addr_2 receives TRANSFER_CREDITS - transfer_fee. assert_eq!( remaining, - FUNDING_CREDITS - transfer_fee, - "addr_1 must retain FUNDING_CREDITS minus the transfer fee \ - (bank_pre={bank_pre} bank_post={bank_post} funded={FUNDING_CREDITS})" - ); - assert!( - received >= TRANSFER_FLOOR, - "addr_2 must hold at least TRANSFER_FLOOR ({TRANSFER_FLOOR}); observed {received}" + FUNDING_CREDITS - TRANSFER_CREDITS, + "addr_1 must retain FUNDING_CREDITS - TRANSFER_CREDITS \ + (transfer_fee is deducted from addr_2's amount, not addr_1's residual). \ + observed remaining={remaining} expected={}", + FUNDING_CREDITS - TRANSFER_CREDITS, ); - assert!( - received < TRANSFER_CREDITS, - "addr_2 must hold less than TRANSFER_CREDITS ({TRANSFER_CREDITS}) \ - after `ReduceOutput(0)` fee deduction; observed {received}" + assert_eq!( + received, + TRANSFER_CREDITS - transfer_fee, + "addr_2 must receive TRANSFER_CREDITS minus the transfer fee \ + (ReduceOutput(0) deducts fee from the transferred amount). \ + observed received={received} expected={}", + TRANSFER_CREDITS - transfer_fee, ); assert!( transfer_fee > 0,