Skip to content

Commit a56d066

Browse files
Merge pull request #1530 from input-output-hk/feat/lw-11842-performance-improvements
Feat/lw 11842 performance improvements
2 parents edb73ad + 2306f10 commit a56d066

File tree

7 files changed

+563
-85
lines changed

7 files changed

+563
-85
lines changed

packages/util-dev/src/mockProviders/mockChainHistoryProvider.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as AssetId from '../assetId';
22
import * as Crypto from '@cardano-sdk/crypto';
3-
import { Cardano, Paginated } from '@cardano-sdk/core';
3+
import { Cardano, Paginated, TransactionsByAddressesArgs } from '@cardano-sdk/core';
44
import { currentEpoch, handleAssetId, ledgerTip, stakeCredential } from './mockData';
55
import { somePartialStakePools } from '../createStubStakePoolProvider';
66
import delay from 'delay';
@@ -219,10 +219,20 @@ export const mockChainHistoryProvider2 = (delayMs: number) => {
219219
const delayedJestFn = <T>(resolvedValue: T) =>
220220
jest.fn().mockImplementationOnce(() => delay(delayMs).then(() => resolvedValue));
221221

222+
const blockRangeTransactions = (blockRangeStart: number): Paginated<Cardano.HydratedTx> => {
223+
const pageResults = queryTransactionsResult2.pageResults.filter(
224+
(res) => res.blockHeader.blockNo >= blockRangeStart
225+
);
226+
227+
return { pageResults, totalResultCount: pageResults.length };
228+
};
229+
222230
return {
223231
blocksByHashes: delayedJestFn(blocksByHashes),
224232
healthCheck: delayedJestFn({ ok: true }),
225-
transactionsByAddresses: delayedJestFn(queryTransactionsResult2),
233+
transactionsByAddresses: jest.fn(({ blockRange }: TransactionsByAddressesArgs) =>
234+
delay(delayMs).then(() => blockRangeTransactions(blockRange?.lowerBound || 0))
235+
),
226236
transactionsByHashes: delayedJestFn(queryTransactionsResult2)
227237
};
228238
};

packages/wallet/src/Wallets/BaseWallet.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ import {
5151
createUtxoTracker,
5252
createWalletUtil,
5353
currentEpochTracker,
54-
distinctBlock,
5554
distinctEraSummaries
5655
} from '../services';
5756
import { AddressType, Bip32Account, GroupedAddress, WitnessedTx, Witnesser, util } from '@cardano-sdk/key-management';
@@ -397,7 +396,6 @@ export class BaseWallet implements ObservableWallet {
397396
store: stores.tip,
398397
syncStatus: this.syncStatus
399398
});
400-
const tipBlockHeight$ = distinctBlock(this.tip$);
401399

402400
this.txSubmitProvider = new SmartTxSubmitProvider(
403401
{ retryBackoffConfig },
@@ -499,11 +497,11 @@ export class BaseWallet implements ObservableWallet {
499497

500498
this.utxo = createUtxoTracker({
501499
addresses$,
500+
history$: this.transactions.history$,
502501
logger: contextLogger(this.#logger, 'utxo'),
503502
onFatalError,
504503
retryBackoffConfig,
505504
stores,
506-
tipBlockHeight$,
507505
transactionsInFlight$: this.transactions.outgoing.inFlight$,
508506
utxoProvider: this.utxoProvider
509507
});

packages/wallet/src/services/TransactionsTracker.ts

Lines changed: 161 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,7 @@ import { distinctBlock, signedTxsEquals, transactionsEquals, txInEquals } from '
3838
import { WitnessedTx } from '@cardano-sdk/key-management';
3939
import { newAndStoredMulticast } from './util/newAndStoredMulticast';
4040
import chunk from 'lodash/chunk.js';
41-
import intersectionBy from 'lodash/intersectionBy.js';
4241
import sortBy from 'lodash/sortBy.js';
43-
import unionBy from 'lodash/unionBy.js';
4442

4543
export interface TransactionsTrackerProps {
4644
chainHistoryProvider: ChainHistoryProvider;
@@ -107,15 +105,159 @@ const allTransactionsByAddresses = async (
107105
return response;
108106
};
109107

110-
export const createAddressTransactionsProvider = ({
108+
const getLastTransactionsAtBlock = (
109+
transactions: Cardano.HydratedTx[],
110+
blockNo: Cardano.BlockNo
111+
): Cardano.HydratedTx[] => {
112+
const txsFromSameBlock = [];
113+
114+
for (let i = transactions.length - 1; i >= 0; --i) {
115+
const tx = transactions[i];
116+
if (tx.blockHeader.blockNo === blockNo) {
117+
txsFromSameBlock.push(tx);
118+
} else {
119+
break;
120+
}
121+
}
122+
123+
return txsFromSameBlock;
124+
};
125+
126+
export const revertLastBlock = (
127+
localTransactions: Cardano.HydratedTx[],
128+
blockNo: Cardano.BlockNo,
129+
rollback$: Subject<Cardano.HydratedTx>,
130+
newTransactions: Cardano.HydratedTx[],
131+
logger: Logger
132+
) => {
133+
const result = [...localTransactions];
134+
135+
while (result.length > 0) {
136+
const lastKnownTx = result[result.length - 1];
137+
138+
if (lastKnownTx.blockHeader.blockNo === blockNo) {
139+
// only emit if the tx is also not present in the new transactions to be added
140+
if (newTransactions.findIndex((tx) => tx.id === lastKnownTx.id) === -1) {
141+
logger.debug(`Transaction ${lastKnownTx.id} was rolled back`);
142+
rollback$.next(lastKnownTx);
143+
}
144+
145+
result.pop();
146+
} else {
147+
break;
148+
}
149+
}
150+
151+
return result;
152+
};
153+
154+
const findIntersectionAndUpdateTxStore = ({
111155
chainHistoryProvider,
112-
addresses$,
156+
logger,
157+
store,
113158
retryBackoffConfig,
159+
onFatalError,
114160
tipBlockHeight$,
115-
store,
116-
logger,
117-
onFatalError
118-
}: TransactionsTrackerInternalsProps): TransactionsTrackerInternals => {
161+
rollback$,
162+
localTransactions,
163+
addresses
164+
}: Pick<
165+
TransactionsTrackerInternalsProps,
166+
'chainHistoryProvider' | 'logger' | 'store' | 'retryBackoffConfig' | 'onFatalError' | 'tipBlockHeight$'
167+
> & {
168+
localTransactions: Cardano.HydratedTx[];
169+
rollback$: Subject<Cardano.HydratedTx>;
170+
addresses: Cardano.PaymentAddress[];
171+
}) =>
172+
coldObservableProvider({
173+
// Do not re-fetch transactions twice on load when tipBlockHeight$ loads from storage first
174+
// It should also help when using poor internet connection.
175+
// Caveat is that local transactions might get out of date...
176+
combinator: exhaustMap,
177+
equals: transactionsEquals,
178+
onFatalError,
179+
// eslint-disable-next-line sonarjs/cognitive-complexity
180+
provider: async () => {
181+
let rollbackOcurred = false;
182+
// eslint-disable-next-line no-constant-condition
183+
while (true) {
184+
const lastStoredTransaction: Cardano.HydratedTx | undefined = localTransactions[localTransactions.length - 1];
185+
186+
lastStoredTransaction &&
187+
logger.debug(
188+
`Last stored tx: ${lastStoredTransaction?.id} block:${lastStoredTransaction?.blockHeader.blockNo}`
189+
);
190+
191+
const lowerBound = lastStoredTransaction?.blockHeader.blockNo;
192+
const newTransactions = await allTransactionsByAddresses(chainHistoryProvider, {
193+
addresses,
194+
blockRange: { lowerBound }
195+
});
196+
197+
logger.debug(
198+
`chainHistoryProvider returned ${newTransactions.length} transactions`,
199+
lowerBound !== undefined && `since block ${lowerBound}`
200+
);
201+
202+
// Fetching transactions from scratch, nothing else to do here.
203+
if (lowerBound === undefined) {
204+
if (newTransactions.length > 0) {
205+
localTransactions = newTransactions;
206+
store.setAll(newTransactions);
207+
}
208+
209+
return newTransactions;
210+
}
211+
212+
// If no transactions found from that block range, it means the last known block has been rolled back.
213+
if (newTransactions.length === 0) {
214+
localTransactions = revertLastBlock(localTransactions, lowerBound, rollback$, newTransactions, logger);
215+
rollbackOcurred = true;
216+
217+
continue;
218+
}
219+
220+
const localTxsFromSameBlock = getLastTransactionsAtBlock(localTransactions, lowerBound);
221+
const firstSegmentOfNewTransactions = newTransactions.slice(0, localTxsFromSameBlock.length);
222+
223+
// The first segment of new transaction should match exactly (same txs and same order) our last know TXs. Otherwise
224+
// roll them back and re-apply in new order.
225+
const sameTxAndOrder = localTxsFromSameBlock.every(
226+
(tx, index) => tx.id === firstSegmentOfNewTransactions[index].id
227+
);
228+
229+
if (!sameTxAndOrder) {
230+
localTransactions = revertLastBlock(localTransactions, lowerBound, rollback$, newTransactions, logger);
231+
rollbackOcurred = true;
232+
233+
continue;
234+
}
235+
236+
// No rollbacks, if they overlap 100% do nothing, otherwise add the difference.
237+
const areTransactionsSame =
238+
newTransactions.length === localTxsFromSameBlock.length &&
239+
localTxsFromSameBlock.every((tx, index) => tx.id === newTransactions[index].id);
240+
241+
if (!areTransactionsSame) {
242+
// Skip overlapping transactions to avoid duplicates
243+
localTransactions = [...localTransactions, ...newTransactions.slice(localTxsFromSameBlock.length)];
244+
store.setAll(localTransactions);
245+
} else if (rollbackOcurred) {
246+
// This case handles rollbacks without new additions
247+
store.setAll(localTransactions);
248+
}
249+
250+
return localTransactions;
251+
}
252+
},
253+
retryBackoffConfig,
254+
trigger$: tipBlockHeight$
255+
});
256+
257+
export const createAddressTransactionsProvider = (
258+
props: TransactionsTrackerInternalsProps
259+
): TransactionsTrackerInternals => {
260+
const { addresses$, store, logger } = props;
119261
const rollback$ = new Subject<Cardano.HydratedTx>();
120262
const storedTransactions$ = store.getAll().pipe(share());
121263
return {
@@ -127,61 +269,14 @@ export const createAddressTransactionsProvider = ({
127269
)
128270
),
129271
combineLatest([addresses$, storedTransactions$.pipe(defaultIfEmpty([] as Cardano.HydratedTx[]))]).pipe(
130-
switchMap(([addresses, storedTransactions]) => {
131-
let localTransactions: Cardano.HydratedTx[] = [...storedTransactions];
132-
133-
return coldObservableProvider({
134-
// Do not re-fetch transactions twice on load when tipBlockHeight$ loads from storage first
135-
// It should also help when using poor internet connection.
136-
// Caveat is that local transactions might get out of date...
137-
combinator: exhaustMap,
138-
equals: transactionsEquals,
139-
onFatalError,
140-
provider: async () => {
141-
// eslint-disable-next-line no-constant-condition
142-
while (true) {
143-
const lastStoredTransaction: Cardano.HydratedTx | undefined =
144-
localTransactions[localTransactions.length - 1];
145-
146-
lastStoredTransaction &&
147-
logger.debug(
148-
`Last stored tx: ${lastStoredTransaction?.id} block:${lastStoredTransaction?.blockHeader.blockNo}`
149-
);
150-
151-
const lowerBound = lastStoredTransaction?.blockHeader.blockNo;
152-
const newTransactions = await allTransactionsByAddresses(chainHistoryProvider, {
153-
addresses,
154-
blockRange: { lowerBound }
155-
});
156-
157-
logger.debug(
158-
`chainHistoryProvider returned ${newTransactions.length} transactions`,
159-
lowerBound !== undefined && `since block ${lowerBound}`
160-
);
161-
const duplicateTransactions =
162-
lastStoredTransaction && intersectionBy(localTransactions, newTransactions, (tx) => tx.id);
163-
if (typeof duplicateTransactions !== 'undefined' && duplicateTransactions.length === 0) {
164-
const rollbackTransactions = localTransactions.filter(
165-
({ blockHeader: { blockNo } }) => blockNo >= lowerBound
166-
);
167-
168-
from(rollbackTransactions)
169-
.pipe(tap((tx) => logger.debug(`Transaction ${tx.id} was rolled back`)))
170-
.subscribe((v) => rollback$.next(v));
171-
172-
// Rollback by 1 block, try again in next loop iteration
173-
localTransactions = localTransactions.filter(({ blockHeader: { blockNo } }) => blockNo < lowerBound);
174-
} else {
175-
localTransactions = unionBy(localTransactions, newTransactions, (tx) => tx.id);
176-
store.setAll(localTransactions);
177-
return localTransactions;
178-
}
179-
}
180-
},
181-
retryBackoffConfig,
182-
trigger$: tipBlockHeight$
183-
});
184-
})
272+
switchMap(([addresses, storedTransactions]) =>
273+
findIntersectionAndUpdateTxStore({
274+
addresses,
275+
localTransactions: [...storedTransactions],
276+
rollback$,
277+
...props
278+
})
279+
)
185280
)
186281
)
187282
};
@@ -247,7 +342,9 @@ export const createTransactionsTracker = (
247342

248343
const transactionsSource$ = new TrackerSubject(txSource$);
249344

250-
const historicalTransactions$ = createHistoricalTransactionsTrackerSubject(transactionsSource$);
345+
const historicalTransactions$ = createHistoricalTransactionsTrackerSubject(transactionsSource$).pipe(
346+
tap((transactions) => logger.debug(`History transactions count: ${transactions?.length || 0}`))
347+
);
251348

252349
const [onChainNewTxPhase2Failed$, onChainNewTxSuccess$] = partition(
253350
newTransactions$(historicalTransactions$).pipe(share()),

packages/wallet/src/services/UtxoTracker.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { RetryBackoffConfig } from 'backoff-rxjs';
66
import { TxInFlight, UtxoTracker } from './types';
77
import { WalletStores } from '../persistence';
88
import { coldObservableProvider } from '@cardano-sdk/util-rxjs';
9+
import { sortUtxoByTxIn } from '@cardano-sdk/input-selection';
910
import chunk from 'lodash/chunk.js';
1011
import uniqWith from 'lodash/uniqWith.js';
1112

@@ -17,7 +18,7 @@ export interface UtxoTrackerProps {
1718
addresses$: Observable<Cardano.PaymentAddress[]>;
1819
stores: Pick<WalletStores, 'utxo' | 'unspendableUtxo'>;
1920
transactionsInFlight$: Observable<TxInFlight[]>;
20-
tipBlockHeight$: Observable<Cardano.BlockNo>;
21+
history$: Observable<Cardano.HydratedTx[]>;
2122
retryBackoffConfig: RetryBackoffConfig;
2223
logger: Logger;
2324
onFatalError?: (value: unknown) => void;
@@ -31,7 +32,7 @@ export interface UtxoTrackerInternals {
3132
export const createUtxoProvider = (
3233
utxoProvider: UtxoProvider,
3334
addresses$: Observable<Cardano.PaymentAddress[]>,
34-
tipBlockHeight$: Observable<Cardano.BlockNo>,
35+
history$: Observable<Cardano.HydratedTx[]>,
3536
retryBackoffConfig: RetryBackoffConfig,
3637
onFatalError?: (value: unknown) => void
3738
) =>
@@ -49,10 +50,10 @@ export const createUtxoProvider = (
4950
utxos = [...utxos, ...(await utxoProvider.utxoByAddresses({ addresses }))];
5051
}
5152

52-
return utxos;
53+
return utxos.sort(sortUtxoByTxIn);
5354
},
5455
retryBackoffConfig,
55-
trigger$: tipBlockHeight$
56+
trigger$: history$
5657
})
5758
)
5859
);
@@ -64,13 +65,13 @@ export const createUtxoTracker = (
6465
stores,
6566
transactionsInFlight$,
6667
retryBackoffConfig,
67-
tipBlockHeight$,
68+
history$,
6869
logger,
6970
onFatalError
7071
}: UtxoTrackerProps,
7172
{
7273
utxoSource$ = new PersistentCollectionTrackerSubject<Cardano.Utxo>(
73-
() => createUtxoProvider(utxoProvider, addresses$, tipBlockHeight$, retryBackoffConfig, onFatalError),
74+
() => createUtxoProvider(utxoProvider, addresses$, history$, retryBackoffConfig, onFatalError),
7475
stores.utxo
7576
),
7677
unspendableUtxoSource$ = new PersistentCollectionTrackerSubject(

0 commit comments

Comments
 (0)