Skip to content

Commit 7539b97

Browse files
Merge pull request #1469 from input-output-hk/fix/dynamic-change-address-resolver-should-always-pick-lower-derivation-index
fix(wallet): dynamicChangeResolver gives preference to lower derivation indices
2 parents 7c2d694 + 4fa3bd5 commit 7539b97

File tree

6 files changed

+177
-10
lines changed

6 files changed

+177
-10
lines changed

packages/wallet/src/services/AddressTracker.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from 'rxjs';
1818
import { WalletStores } from '../persistence';
1919
import { groupedAddressesEquals } from './util';
20+
import { sortAddresses } from './util/sortAddresses';
2021

2122
export type AddressTrackerDependencies = {
2223
store: WalletStores['addresses'];
@@ -86,7 +87,7 @@ export const createAddressTracker = ({ addressDiscovery$, store, logger }: Addre
8687
take(1)
8788
);
8889
},
89-
addresses$,
90+
addresses$: addresses$.pipe(map(sortAddresses)),
9091
shutdown: newAddresses$.complete.bind(newAddresses$)
9192
};
9293
};

packages/wallet/src/services/ChangeAddress/DynamicChangeAddressResolver.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { GroupedAddress } from '@cardano-sdk/key-management';
77
import { InvalidStateError } from '@cardano-sdk/util';
88
import { Logger } from 'ts-log';
99
import { Observable, firstValueFrom } from 'rxjs';
10+
import { sortAddresses } from '../util/sortAddresses';
1011
import isEqual from 'lodash/isEqual.js';
1112
import uniq from 'lodash/uniq.js';
1213

@@ -315,7 +316,8 @@ export class DynamicChangeAddressResolver implements ChangeAddressResolver {
315316
async resolve(selection: Selection): Promise<Array<Cardano.TxOut>> {
316317
const delegationDistribution = [...(await firstValueFrom(this.#delegationDistribution)).values()];
317318
let portfolio = await this.#getDelegationPortfolio();
318-
const addresses = await firstValueFrom(this.#addresses$);
319+
const addresses = sortAddresses(await firstValueFrom(this.#addresses$));
320+
319321
let updatedChange = [...selection.change];
320322

321323
if (addresses.length === 0) throw new InvalidStateError('The wallet has no known addresses.');
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { GroupedAddress } from '@cardano-sdk/key-management';
2+
3+
/**
4+
* Sorts an array of addresses by their primary index and, if available, by the
5+
* index of their stakeKeyDerivationPath.
6+
*
7+
* @param addresses - The array of addresses to sort.
8+
* @returns A new sorted array of addresses.
9+
*/
10+
export const sortAddresses = (addresses: GroupedAddress[]): GroupedAddress[] =>
11+
[...addresses].sort((a, b) => {
12+
if (a.index !== b.index) {
13+
return a.index - b.index;
14+
}
15+
16+
if (a.stakeKeyDerivationPath && b.stakeKeyDerivationPath) {
17+
return a.stakeKeyDerivationPath.index - b.stakeKeyDerivationPath.index;
18+
}
19+
20+
if (a.stakeKeyDerivationPath && !b.stakeKeyDerivationPath) {
21+
return -1;
22+
}
23+
24+
if (!a.stakeKeyDerivationPath && b.stakeKeyDerivationPath) {
25+
return 1;
26+
}
27+
28+
return 0;
29+
});

packages/wallet/test/services/AddressTracker.test.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,44 @@
11
import { AddressTracker, createAddressTracker } from '../../src';
2+
import { AddressType, GroupedAddress, KeyRole } from '@cardano-sdk/key-management';
23
import { Cardano } from '@cardano-sdk/core';
34
import { EMPTY, firstValueFrom, of } from 'rxjs';
4-
import { GroupedAddress } from '@cardano-sdk/key-management';
55
import { WalletStores } from '../../src/persistence';
6+
import { address_0_0, address_0_5, address_5_0, rewardAccount_0, rewardAccount_5 } from './ChangeAddress/testData';
67
import { createTestScheduler, logger } from '@cardano-sdk/util-dev';
8+
import { sortAddresses } from '../../src/services/util/sortAddresses';
79

810
describe('AddressTracker', () => {
911
let store: jest.Mocked<WalletStores['addresses']>;
1012
let addressTracker: AddressTracker;
11-
const discoveredAddresses = [{ address: 'addr1' as Cardano.PaymentAddress } as GroupedAddress];
13+
const discoveredAddresses = [
14+
{
15+
accountIndex: 0,
16+
address: address_5_0,
17+
index: 5,
18+
networkId: Cardano.NetworkId.Testnet,
19+
rewardAccount: rewardAccount_0,
20+
stakeKeyDerivationPath: { index: 0, role: KeyRole.Stake },
21+
type: AddressType.External
22+
},
23+
{
24+
accountIndex: 0,
25+
address: address_0_5,
26+
index: 0,
27+
networkId: Cardano.NetworkId.Testnet,
28+
rewardAccount: rewardAccount_5,
29+
stakeKeyDerivationPath: { index: 5, role: KeyRole.Stake },
30+
type: AddressType.External
31+
},
32+
{
33+
accountIndex: 0,
34+
address: address_0_0,
35+
index: 0,
36+
networkId: Cardano.NetworkId.Testnet,
37+
rewardAccount: rewardAccount_0,
38+
stakeKeyDerivationPath: { index: 0, role: KeyRole.Stake },
39+
type: AddressType.External
40+
}
41+
];
1242

1343
beforeEach(() => {
1444
store = {
@@ -19,6 +49,8 @@ describe('AddressTracker', () => {
1949
};
2050
});
2151

52+
const sortedDiscoveredAddresses = sortAddresses(discoveredAddresses);
53+
2254
afterEach(() => addressTracker.shutdown());
2355

2456
describe('load', () => {
@@ -31,7 +63,7 @@ describe('AddressTracker', () => {
3163
logger,
3264
store
3365
});
34-
expectObservable(addressTracker.addresses$).toBe('a', { a: discoveredAddresses });
66+
expectObservable(addressTracker.addresses$).toBe('a', { a: sortedDiscoveredAddresses });
3567
expectSubscriptions(addressDiscovery$.subscriptions).toBe('^');
3668
flush();
3769
expect(store.get).toBeCalledTimes(1);
@@ -70,12 +102,12 @@ describe('AddressTracker', () => {
70102
logger,
71103
store
72104
});
73-
await expect(firstValueFrom(addressTracker.addresses$)).resolves.toEqual(discoveredAddresses);
105+
await expect(firstValueFrom(addressTracker.addresses$)).resolves.toEqual(sortedDiscoveredAddresses);
74106
const newAddress = { address: 'addr2' as Cardano.PaymentAddress } as GroupedAddress;
75107
const combinedAddresses = [...discoveredAddresses, newAddress];
76108

77109
await expect(firstValueFrom(addressTracker.addAddresses([newAddress]))).resolves.toEqual(combinedAddresses);
78-
await expect(firstValueFrom(addressTracker.addresses$)).resolves.toEqual(combinedAddresses);
110+
await expect(firstValueFrom(addressTracker.addresses$)).resolves.toEqual(sortAddresses(combinedAddresses));
79111
expect(store.set).toBeCalledTimes(2);
80112
expect(store.set).toBeCalledWith(combinedAddresses);
81113
});

packages/wallet/test/services/ChangeAddress/DynamicChangeAddressResolver.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {
2525
rewardAccount_0,
2626
rewardAccount_1,
2727
rewardAccount_2,
28-
rewardAccount_3
28+
rewardAccount_3,
29+
unorderedKnownAddresses$
2930
} from './testData';
3031
import { logger } from '@cardano-sdk/util-dev';
3132

@@ -154,7 +155,7 @@ describe('DynamicChangeAddressResolver', () => {
154155

155156
it('adds all change outputs at payment_stake address 0 if the wallet is currently not delegating to any pool', async () => {
156157
const changeAddressResolver = new DynamicChangeAddressResolver(
157-
knownAddresses$,
158+
unorderedKnownAddresses$,
158159
createMockDelegateTracker(new Map<Cardano.PoolId, DelegatedStake>([])).distribution$,
159160
getNullDelegationPortfolio,
160161
logger
@@ -191,7 +192,7 @@ describe('DynamicChangeAddressResolver', () => {
191192

192193
it('distributes change equally between the currently delegated addresses if no portfolio is given, ', async () => {
193194
const changeAddressResolver = new DynamicChangeAddressResolver(
194-
knownAddresses$,
195+
unorderedKnownAddresses$,
195196
createMockDelegateTracker(
196197
new Map<Cardano.PoolId, DelegatedStake>([
197198
[

packages/wallet/test/services/ChangeAddress/testData.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,108 @@ export const knownAddresses$ = new BehaviorSubject<GroupedAddress[]>([
212212
}
213213
]);
214214

215+
export const unorderedKnownAddresses$ = new BehaviorSubject<GroupedAddress[]>([
216+
{
217+
accountIndex: 0,
218+
address: address_5_0,
219+
index: 5,
220+
networkId: Cardano.NetworkId.Testnet,
221+
rewardAccount: rewardAccount_0,
222+
stakeKeyDerivationPath: { index: 0, role: KeyRole.Stake },
223+
type: AddressType.External
224+
},
225+
{
226+
accountIndex: 0,
227+
address: address_1_0,
228+
index: 1,
229+
networkId: Cardano.NetworkId.Testnet,
230+
rewardAccount: rewardAccount_0,
231+
stakeKeyDerivationPath: { index: 0, role: KeyRole.Stake },
232+
type: AddressType.External
233+
},
234+
{
235+
accountIndex: 0,
236+
address: address_0_3,
237+
index: 0,
238+
networkId: Cardano.NetworkId.Testnet,
239+
rewardAccount: rewardAccount_3,
240+
stakeKeyDerivationPath: { index: 3, role: KeyRole.Stake },
241+
type: AddressType.External
242+
},
243+
{
244+
accountIndex: 0,
245+
address: address_0_1,
246+
index: 0,
247+
networkId: Cardano.NetworkId.Testnet,
248+
rewardAccount: rewardAccount_1,
249+
stakeKeyDerivationPath: { index: 1, role: KeyRole.Stake },
250+
type: AddressType.External
251+
},
252+
{
253+
accountIndex: 0,
254+
address: address_0_2,
255+
index: 0,
256+
networkId: Cardano.NetworkId.Testnet,
257+
rewardAccount: rewardAccount_2,
258+
stakeKeyDerivationPath: { index: 2, role: KeyRole.Stake },
259+
type: AddressType.External
260+
},
261+
{
262+
accountIndex: 0,
263+
address: address_2_0,
264+
index: 2,
265+
networkId: Cardano.NetworkId.Testnet,
266+
rewardAccount: rewardAccount_0,
267+
stakeKeyDerivationPath: { index: 0, role: KeyRole.Stake },
268+
type: AddressType.External
269+
},
270+
{
271+
accountIndex: 0,
272+
address: address_0_4,
273+
index: 0,
274+
networkId: Cardano.NetworkId.Testnet,
275+
rewardAccount: rewardAccount_4,
276+
stakeKeyDerivationPath: { index: 4, role: KeyRole.Stake },
277+
type: AddressType.External
278+
},
279+
{
280+
accountIndex: 0,
281+
address: address_3_0,
282+
index: 3,
283+
networkId: Cardano.NetworkId.Testnet,
284+
rewardAccount: rewardAccount_0,
285+
stakeKeyDerivationPath: { index: 0, role: KeyRole.Stake },
286+
type: AddressType.External
287+
},
288+
{
289+
accountIndex: 0,
290+
address: address_0_5,
291+
index: 0,
292+
networkId: Cardano.NetworkId.Testnet,
293+
rewardAccount: rewardAccount_5,
294+
stakeKeyDerivationPath: { index: 5, role: KeyRole.Stake },
295+
type: AddressType.External
296+
},
297+
{
298+
accountIndex: 0,
299+
address: address_0_0,
300+
index: 0,
301+
networkId: Cardano.NetworkId.Testnet,
302+
rewardAccount: rewardAccount_0,
303+
stakeKeyDerivationPath: { index: 0, role: KeyRole.Stake },
304+
type: AddressType.External
305+
},
306+
{
307+
accountIndex: 0,
308+
address: address_4_0,
309+
index: 4,
310+
networkId: Cardano.NetworkId.Testnet,
311+
rewardAccount: rewardAccount_0,
312+
stakeKeyDerivationPath: { index: 0, role: KeyRole.Stake },
313+
type: AddressType.External
314+
}
315+
]);
316+
215317
export const emptyKnownAddresses$ = new BehaviorSubject<GroupedAddress[]>([]);
216318

217319
export const createMockDelegateTracker = (delegatedStake: Map<Cardano.PoolId, DelegatedStake>) => ({

0 commit comments

Comments
 (0)