Skip to content

Commit ec75d2a

Browse files
pragmaximclaude
andcommitted
feat(wallet-core): track and declare private-pending nonces (slice + delivery + prune)
Second of three stacked changes for the blockbook privatePending hint (trezor/blockbook#1639). Adds the durable model and the delivery/prune machinery; no producer writes yet, so the slice stays empty and this is still a functional no-op end to end. Why a dedicated slice: the private/relay bit is transient (computed only at broadcast in getMevProtectedTxData) and lives on neither the fake nor the real tx object, and blockbook overwrites the fake pending tx with its real (unmarked) tx mid-flight - so a tx-object marker cannot survive and a tx-list-derived set cannot tell private from public. A small Redux slice keyed by (accountKey, nonce) is the sole authoritative source; nonce is invariant across the fake->real swap. - new suite-common/wallet-core/src/privatePending slice (Redux only for now; IndexedDB persistence deferred - Redux already survives WS reconnect, only an app restart mid-tx regresses, matching today's heuristic fallback); - DELIVERY: attach the hint (and confirmedNonce:true) to the UNCONDITIONAL basic getAccountInfo call in fetchAndUpdateAccountThunk, gated on there being an in-flight private nonce. It must ride the basic call, not the gated details:'txs' one, because a blockbook-invisible private tx does not make the account "outdated" so the txs call is skipped for exactly the flows that need the hint; - PRUNE (STOP): drop entries with nonce < confirmedNonce (mined) or past a TTL backstop, on every basic refresh - instance-agnostic, so it self-heals across load-balanced blockbook replicas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 888f31e commit ec75d2a

5 files changed

Lines changed: 180 additions & 0 deletions

File tree

packages/suite/src/reducers/wallet/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type FiatRatesState,
1111
type FormDraftState,
1212
type PhishingState,
13+
type PrivatePendingState,
1314
type SendState,
1415
type StablecoinYieldState,
1516
type StakeState,
@@ -27,6 +28,7 @@ import {
2728
prepareStakeReducer,
2829
prepareTransactionsReducer,
2930
prepareWalletSettingsReducer,
31+
privatePendingReducer,
3032
stablecoinYieldReducer,
3133
tronStakeReducer,
3234
} from '@suite-common/wallet-core';
@@ -67,6 +69,7 @@ export type WalletState = {
6769
accountsRefreshTime: AccountsRefreshTimeState;
6870
selectedAccount: SelectedAccountStatus;
6971
fees: FeesState;
72+
privatePending: PrivatePendingState;
7073
blockchain: BlockchainNetworks;
7174
explorer: ExplorerConfig;
7275
trading: TradingState;
@@ -94,6 +97,7 @@ export const walletReducers: Reducer<
9497
accountsRefreshTime: accountsRefreshTimeReducer,
9598
selectedAccount: selectedAccountReducer,
9699
fees: feesReducer,
100+
privatePending: privatePendingReducer,
97101
blockchain: blockchainReducer,
98102
explorer: explorerReducer,
99103
trading: tradingReducer,

suite-common/wallet-core/src/accounts/accountsThunks.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
import { accountRefreshed } from './accountsRefreshTimeReducer';
3131
import { selectAccountByKey } from './accountsSelectors';
3232
import { selectBlockchainHeightBySymbol, selectGapLimit } from '../blockchain/blockchainReducer';
33+
import { privatePendingActions } from '../privatePending/privatePendingActions';
34+
import { selectAccountPrivatePendingHint } from '../privatePending/privatePendingReducer';
3335
import { selectBitcoinAmountUnit } from '../settings/walletSettingsReducer';
3436
import { transactionsActions } from '../transactions/transactionsActions';
3537
import { selectTransactions } from '../transactions/transactionsSelectors';
@@ -132,6 +134,19 @@ export const fetchAndUpdateAccountThunk = createThunk(
132134
? selectGapLimit(getState(), account.symbol)
133135
: undefined;
134136

137+
// Declare the wallet's in-flight PRIVATE (relay) txs on the UNCONDITIONAL basic refresh so
138+
// blockbook routes the pending nonce deterministically even for txs its node cannot see - and
139+
// even after reconnecting to a different (load-balanced) instance, since this call fires on
140+
// every connect / periodic sync / block. It rides the basic (not the gated details:'txs')
141+
// call because a blockbook-invisible private tx does not make the account "outdated", so the
142+
// txs call may be skipped. selectAccountPrivatePendingHint is undefined when nothing is in
143+
// flight, so the hint (and the extra confirmedNonce round-trip below) are sent only when
144+
// relevant - which is also the over-declaration guard. See trezor/blockbook#1639.
145+
const privatePendingHint =
146+
account.networkType === 'ethereum'
147+
? selectAccountPrivatePendingHint(getState(), account.key)
148+
: undefined;
149+
135150
const basic = await TrezorConnect.getAccountInfo({
136151
coin: account.symbol,
137152
identity: tryGetAccountIdentity(account),
@@ -141,10 +156,30 @@ export const fetchAndUpdateAccountThunk = createThunk(
141156
tokenAccountsPubKeys,
142157
protocols: account.networkType === 'ethereum' ? ['erc4626'] : undefined,
143158
gap,
159+
// confirmedNonce is the trustworthy mined nonce used to prune settled private nonces
160+
// below; request it only when a private tx is in flight, to avoid the extra backend call.
161+
confirmedNonce: privatePendingHint ? true : undefined,
162+
privatePending: privatePendingHint,
144163
});
145164

146165
if (!basic.success) return;
147166

167+
// Prune the declared private nonces against the mined nonce on every basic refresh, BEFORE
168+
// the not-outdated early-return below, so a settled or relay-dropped private nonce stops
169+
// being declared. confirmedNonce is instance-agnostic, so this self-heals after reconnecting
170+
// to a different blockbook instance.
171+
if (privatePendingHint) {
172+
const confirmedNonce = Number.parseInt(basic.payload.misc?.confirmedNonce ?? '', 10);
173+
if (!Number.isNaN(confirmedNonce)) {
174+
dispatch(
175+
privatePendingActions.privatePendingPruned({
176+
accountKey: account.key,
177+
confirmedNonce,
178+
}),
179+
);
180+
}
181+
}
182+
148183
const accountOutdated = isAccountOutdated(account, basic.payload);
149184
const accountTransactions = selectTransactions(getState());
150185
const accountTxs = getAccountTransactions(account.key, accountTransactions);

suite-common/wallet-core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export * from './fees/feesReducer';
3737
export * from './fees/feesThunks';
3838
export * from './fees/feesUtils';
3939
export * from './fees/hooks/useRefetchFees';
40+
export * from './privatePending/privatePendingActions';
41+
export * from './privatePending/privatePendingReducer';
4042
export * from './fiat-rates/fiatRatesMiddleware';
4143
export * from './fiat-rates/fiatRatesReducer';
4244
export * from './fiat-rates/fiatRatesSelectors';
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createAction } from '@reduxjs/toolkit';
2+
3+
import { type AccountKey } from '@suite-common/wallet-types';
4+
5+
export const PRIVATE_PENDING_MODULE_PREFIX = '@common/wallet-core/privatePending';
6+
7+
// Record a genuinely-private (MEV-protected / relay-routed) in-flight tx at broadcast time. Upsert by
8+
// nonce so a speed-up/cancel at the same nonce rewrites the txid rather than orphaning the entry.
9+
const privatePendingAdded = createAction(
10+
`${PRIVATE_PENDING_MODULE_PREFIX}/added`,
11+
(payload: { accountKey: AccountKey; nonce: number; txid: string }) => ({
12+
payload: { ...payload, submittedAt: Date.now() },
13+
}),
14+
);
15+
16+
// STOP condition: drop entries for one account that are now mined (nonce < confirmedNonce) or older
17+
// than the TTL backstop. Dispatched on every basic getAccountInfo response so it self-heals across
18+
// load-balanced blockbook instances (confirmedNonce is instance-agnostic). now is stamped here to
19+
// keep the reducer pure.
20+
const privatePendingPruned = createAction(
21+
`${PRIVATE_PENDING_MODULE_PREFIX}/pruned`,
22+
(payload: { accountKey: AccountKey; confirmedNonce: number }) => ({
23+
payload: { ...payload, now: Date.now() },
24+
}),
25+
);
26+
27+
const privatePendingAccountRemoved = createAction(
28+
`${PRIVATE_PENDING_MODULE_PREFIX}/accountRemoved`,
29+
(payload: { accountKey: AccountKey }) => ({ payload }),
30+
);
31+
32+
export const privatePendingActions = {
33+
privatePendingAdded,
34+
privatePendingPruned,
35+
privatePendingAccountRemoved,
36+
};
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { createReducer } from '@reduxjs/toolkit';
2+
3+
import { type AccountKey } from '@suite-common/wallet-types';
4+
5+
import { privatePendingActions } from './privatePendingActions';
6+
7+
// A private in-flight tx must never be declared forever: if the relay drops it and it never mines,
8+
// confirmedNonce never advances past its nonce, so this TTL is the backstop that stops declaring a
9+
// nonce that will never confirm (bounds the trezor/blockbook#1629 over-declaration window). Kept a
10+
// little above the send flow's fake-pending-tx TTL (~15 min) so a slow relay inclusion is not
11+
// dropped prematurely (which would re-open under-declaration).
12+
export const PRIVATE_PENDING_TTL_MS = 20 * 60 * 1000;
13+
14+
export interface PrivatePendingEntry {
15+
nonce: number;
16+
txid: string;
17+
submittedAt: number;
18+
}
19+
20+
// Keyed by account.key (a template-literal type, hence the string index). Only genuinely-private
21+
// (MEV-protected) in-flight txs are recorded here; this slice is the sole source of truth for the
22+
// privatePending hint - deliberately NOT derived from the tx list, which cannot represent the
23+
// private/public bit and misses the staking / walletconnect / claim flows that leave no tx-list
24+
// artifact.
25+
export type PrivatePendingState = {
26+
[accountKey: string]: PrivatePendingEntry[];
27+
};
28+
29+
export type PrivatePendingRootState = {
30+
wallet: { privatePending: PrivatePendingState };
31+
};
32+
33+
export const privatePendingInitialState: PrivatePendingState = {};
34+
35+
export const privatePendingReducer = createReducer(privatePendingInitialState, builder => {
36+
builder
37+
.addCase(privatePendingActions.privatePendingAdded, (state, { payload }) => {
38+
const { accountKey, nonce, txid, submittedAt } = payload;
39+
if (!state[accountKey]) {
40+
state[accountKey] = [];
41+
}
42+
const existing = state[accountKey].find(entry => entry.nonce === nonce);
43+
if (existing) {
44+
existing.txid = txid;
45+
existing.submittedAt = submittedAt;
46+
} else {
47+
state[accountKey].push({ nonce, txid, submittedAt });
48+
}
49+
})
50+
.addCase(privatePendingActions.privatePendingPruned, (state, { payload }) => {
51+
const { accountKey, confirmedNonce, now } = payload;
52+
const entries = state[accountKey];
53+
if (!entries) return;
54+
const kept = entries.filter(
55+
entry =>
56+
entry.nonce >= confirmedNonce &&
57+
now - entry.submittedAt < PRIVATE_PENDING_TTL_MS,
58+
);
59+
if (kept.length > 0) {
60+
state[accountKey] = kept;
61+
} else {
62+
delete state[accountKey];
63+
}
64+
})
65+
.addCase(privatePendingActions.privatePendingAccountRemoved, (state, { payload }) => {
66+
delete state[payload.accountKey];
67+
});
68+
});
69+
70+
const selectAccountPrivatePendingEntries = (
71+
state: PrivatePendingRootState,
72+
accountKey: AccountKey,
73+
// Optional chaining tolerates a partial store that omits this slice (e.g. hook unit tests with a
74+
// hand-built mock state); the real store always initializes it via combineReducers.
75+
): PrivatePendingEntry[] | undefined => state.wallet.privatePending?.[accountKey];
76+
77+
// The unpruned private nonces for this account, ascending. The routing/floor source for both hint
78+
// sites. Deliberately independent of the tx list so the no-fake-tx flows stay covered.
79+
export const selectAccountPrivatePendingNonces = (
80+
state: PrivatePendingRootState,
81+
accountKey: AccountKey,
82+
): number[] => {
83+
const entries = selectAccountPrivatePendingEntries(state, accountKey);
84+
85+
return entries ? entries.map(entry => entry.nonce).sort((a, b) => a - b) : [];
86+
};
87+
88+
// The getAccountInfo hint object, or undefined when nothing is in flight (a safe no-op, like
89+
// confirmedNonce's default). Returning undefined for the empty case is also what gates the extra
90+
// confirmedNonce round-trip and the relay routing off for accounts with no private tx in flight.
91+
export const selectAccountPrivatePendingHint = (
92+
state: PrivatePendingRootState,
93+
accountKey: AccountKey,
94+
): { nonces: number[]; txids: string[] } | undefined => {
95+
const entries = selectAccountPrivatePendingEntries(state, accountKey);
96+
if (!entries || entries.length === 0) return undefined;
97+
const sorted = [...entries].sort((a, b) => a.nonce - b.nonce);
98+
99+
return {
100+
nonces: sorted.map(entry => entry.nonce),
101+
txids: sorted.map(entry => entry.txid),
102+
};
103+
};

0 commit comments

Comments
 (0)