Skip to content

Commit 8c7ec00

Browse files
lklimekclaude
andauthored
fix(rs-platform-wallet/e2e): bank.fund_address pays fee from input [QA-001b] (#3579)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 74b1ed7 commit 8c7ec00

3 files changed

Lines changed: 78 additions & 35 deletions

File tree

packages/rs-platform-wallet/tests/e2e/cases/transfer.rs

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,16 @@ use crate::framework::prelude::*;
3333
// the empirical chain-time ceiling sidesteps the bug until #3040
3434
// lands at the dpp layer.
3535

36-
/// Gross credits the bank submits when funding `addr_1`. The bank
37-
/// uses `[ReduceOutput(0)]`, so addr_1 actually receives
38-
/// `FUNDING_CREDITS − bank_fee`. Sized well above the chain-time
39-
/// fee (~15M empirically) so addr_1 retains enough headroom to
40-
/// fund the test's own self-transfer (see #3040 comment above).
36+
/// Credits the bank delivers to `addr_1`. The bank uses
37+
/// `[DeductFromInput(0)]`, so addr_1 receives this exact amount;
38+
/// the bank's fee is absorbed by the bank's own input. Sized well
39+
/// above the chain-time fee (~15M empirically) so addr_1 has
40+
/// enough headroom for the self-transfer (see #3040 comment above).
4141
const FUNDING_CREDITS: u64 = 100_000_000;
4242

43-
/// Lower bound on what addr_1 must receive after the bank's fee
44-
/// deduction before the test proceeds. Pinned well below the raw
45-
/// gross so the wait isn't sensitive to fee fluctuations across
46-
/// protocol versions.
43+
/// Safety floor for the addr_1 wait. Under `[DeductFromInput(0)]`
44+
/// addr_1 receives FUNDING_CREDITS exactly; the floor is kept as a
45+
/// guard against an empty/stale observation slipping through.
4746
const FUNDING_FLOOR: u64 = 70_000_000;
4847

4948
/// Gross credits the test wallet submits in its self-transfer to
@@ -82,14 +81,19 @@ async fn transfer_between_two_platform_addresses() {
8281
.await
8382
.expect("derive addr_1");
8483

84+
// Snapshot bank balance before funding so we can derive the fee
85+
// the bank's input actually paid (invisible to the test wallet).
86+
let bank_pre = s.ctx.bank().total_credits().await;
87+
8588
s.ctx
8689
.bank()
8790
.fund_address(&addr_1, FUNDING_CREDITS)
8891
.await
8992
.expect("bank.fund_address");
9093

91-
// Bank uses `[ReduceOutput(0)]`, so addr_1 receives
92-
// `FUNDING_CREDITS − bank_fee`. Wait on the post-fee floor.
94+
// Bank uses `[DeductFromInput(0)]`: addr_1 receives FUNDING_CREDITS
95+
// exactly. Wait on the safety floor; the exact-amount assertion
96+
// follows after the test wallet syncs.
9397
wait_for_balance(&s.test_wallet, &addr_1, FUNDING_FLOOR, STEP_TIMEOUT)
9498
.await
9599
.expect("addr_1 funding never observed");
@@ -116,31 +120,38 @@ async fn transfer_between_two_platform_addresses() {
116120
.await
117121
.expect("addr_2 transfer never observed");
118122

119-
// Re-sync so the cached view reflects post-transfer state across
120-
// BOTH addresses, then derive bank- and transfer-fee shares from
121-
// observed balances.
123+
// Re-sync test wallet so the cached view reflects post-transfer
124+
// state across BOTH addresses.
122125
s.test_wallet
123126
.sync_balances()
124127
.await
125128
.expect("post-transfer sync");
126129
let balances = s.test_wallet.balances().await;
127130
let received = balances.get(&addr_2).copied().unwrap_or(0);
128131
let remaining = balances.get(&addr_1).copied().unwrap_or(0);
129-
let observed_total = received.saturating_add(remaining);
130-
// Bank's `ReduceOutput(0)` charged its fee against addr_1's
131-
// funding output: the wallet's total post-transfer is
132-
// `FUNDING_CREDITS − bank_fee − transfer_fee`. Each fee is the
133-
// amount each ReduceOutput step trimmed off its respective
134-
// output; together they equal `FUNDING_CREDITS − observed_total`.
135-
let total_fees = FUNDING_CREDITS.saturating_sub(observed_total);
136132
// The transfer fee is the share TRANSFER_CREDITS lost while
137-
// crossing addr_1 -> addr_2.
133+
// crossing addr_1 -> addr_2 via `[ReduceOutput(0)]`.
138134
let transfer_fee = TRANSFER_CREDITS.saturating_sub(received);
139-
let bank_fee = total_fees.saturating_sub(transfer_fee);
135+
136+
// Resync the bank to get its post-funding balance, then derive
137+
// the fee the bank's input absorbed under `[DeductFromInput(0)]`.
138+
s.ctx
139+
.bank()
140+
.sync_balances()
141+
.await
142+
.expect("bank post-funding sync");
143+
let bank_post = s.ctx.bank().total_credits().await;
144+
// bank_pre - bank_post = FUNDING_CREDITS + bank_fee
145+
let bank_fee = bank_pre
146+
.saturating_sub(bank_post)
147+
.saturating_sub(FUNDING_CREDITS);
148+
140149
tracing::info!(
141150
target: "platform_wallet::e2e::cases::transfer",
142151
?addr_1,
143152
?addr_2,
153+
bank_pre,
154+
bank_post,
144155
funded = FUNDING_CREDITS,
145156
received,
146157
remaining,
@@ -149,14 +160,25 @@ async fn transfer_between_two_platform_addresses() {
149160
"post-transfer balance snapshot"
150161
);
151162

152-
assert!(
153-
received >= TRANSFER_FLOOR,
154-
"addr_2 must hold at least TRANSFER_FLOOR ({TRANSFER_FLOOR}); observed {received}"
163+
// Under [ReduceOutput(0)], the protocol deducts the transfer fee
164+
// from output[0] — addr_2's received amount — not from addr_1's
165+
// residual. So addr_1 retains FUNDING_CREDITS - TRANSFER_CREDITS
166+
// and addr_2 receives TRANSFER_CREDITS - transfer_fee.
167+
assert_eq!(
168+
remaining,
169+
FUNDING_CREDITS - TRANSFER_CREDITS,
170+
"addr_1 must retain FUNDING_CREDITS - TRANSFER_CREDITS \
171+
(transfer_fee is deducted from addr_2's amount, not addr_1's residual). \
172+
observed remaining={remaining} expected={}",
173+
FUNDING_CREDITS - TRANSFER_CREDITS,
155174
);
156-
assert!(
157-
received < TRANSFER_CREDITS,
158-
"addr_2 must hold less than TRANSFER_CREDITS ({TRANSFER_CREDITS}) \
159-
after `ReduceOutput(0)` fee deduction; observed {received}"
175+
assert_eq!(
176+
received,
177+
TRANSFER_CREDITS - transfer_fee,
178+
"addr_2 must receive TRANSFER_CREDITS minus the transfer fee \
179+
(ReduceOutput(0) deducts fee from the transferred amount). \
180+
observed received={received} expected={}",
181+
TRANSFER_CREDITS - transfer_fee,
160182
);
161183
assert!(
162184
transfer_fee > 0,
@@ -168,7 +190,8 @@ async fn transfer_between_two_platform_addresses() {
168190
);
169191
assert!(
170192
bank_fee > 0,
171-
"bank funding must charge a non-zero fee (observed_total={observed_total})"
193+
"bank funding must charge a non-zero fee to its own input \
194+
(bank_pre={bank_pre} bank_post={bank_post} funded={FUNDING_CREDITS})"
172195
);
173196

174197
s.teardown().await.expect("teardown");

packages/rs-platform-wallet/tests/e2e/framework/bank.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ use tokio::sync::Mutex as AsyncMutex;
2525
use simple_signer::signer::SimpleSigner;
2626

2727
use super::config::Config;
28-
use super::wallet_factory::{
29-
default_fee_strategy, DEFAULT_ACCOUNT_INDEX_PUB, DEFAULT_KEY_CLASS_PUB,
30-
};
28+
use super::wallet_factory::{bank_fee_strategy, DEFAULT_ACCOUNT_INDEX_PUB, DEFAULT_KEY_CLASS_PUB};
3129
use super::{make_platform_signer, FrameworkError, FrameworkResult};
3230

3331
/// In-process funding mutex — serialises concurrent
@@ -153,6 +151,13 @@ impl BankWallet {
153151
/// Fund `target` with `credits` from the bank's primary
154152
/// account.
155153
///
154+
/// Recipients receive the **exact** `credits` amount; the fee
155+
/// is deducted from the bank's input via
156+
/// [`bank_fee_strategy`]. The bank therefore consumes
157+
/// `credits + fee` from its own platform-addresses pool —
158+
/// verify the bank balance is sufficiently above
159+
/// `min_bank_credits` before calling.
160+
///
156161
/// Submits the transfer immediately and returns the resulting
157162
/// [`PlatformAddressChangeSet`]. Does NOT wait for the chain to
158163
/// observe the credit — callers follow up with
@@ -173,7 +178,7 @@ impl BankWallet {
173178
DEFAULT_ACCOUNT_INDEX_PUB,
174179
InputSelection::Auto,
175180
outputs,
176-
default_fee_strategy(),
181+
bank_fee_strategy(),
177182
Some(PlatformVersion::latest()),
178183
&self.signer,
179184
)

packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,21 @@ pub(crate) fn default_fee_strategy() -> AddressFundsFeeStrategy {
418418
vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]
419419
}
420420

421+
/// Bank-funding fee strategy: deduct fee from input #0 so the
422+
/// recipient receives the **exact** requested amount.
423+
///
424+
/// Used by [`super::bank::BankWallet::fund_address`] so
425+
/// downstream calls — e.g. `register_identity_from_addresses(
426+
/// {addr: N}, ...)` — don't have to compensate for fee
427+
/// deduction at the recipient.
428+
///
429+
/// Tests that need the alternative `ReduceOutput(0)` semantics
430+
/// (e.g. PA-002b verifying `Σ outputs + fee == input balance`)
431+
/// should call [`default_fee_strategy`] explicitly.
432+
pub(crate) fn bank_fee_strategy() -> AddressFundsFeeStrategy {
433+
vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]
434+
}
435+
421436
/// Rebalance an explicit-input map so its sum equals `Σ outputs`.
422437
///
423438
/// `AddressFundsTransferTransition` validation rejects with

0 commit comments

Comments
 (0)