Skip to content

Commit ef654ca

Browse files
feat!: added change address resolver to the round robin input selector
BREAKING CHANGES: - RoundRobinRandomImprove now takes as a dependency a ChangeAddressResolver instance.
1 parent 46029ae commit ef654ca

22 files changed

+1343
-30
lines changed

Diff for: packages/input-selection/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
},
5454
"dependencies": {
5555
"@cardano-sdk/core": "workspace:~",
56+
"@cardano-sdk/key-management": "workspace:~",
5657
"@cardano-sdk/util": "workspace:~",
5758
"bignumber.js": "^9.1.1",
5859
"lodash": "^4.17.21",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Cardano } from '@cardano-sdk/core';
2+
import { Selection } from '../';
3+
4+
/**
5+
* Resolves the addresses to be used for change outputs.
6+
*
7+
* The resolver takes the selection results from the input selection algorithm and
8+
* updates its change outputs to use the resolved addresses.
9+
*/
10+
export interface ChangeAddressResolver {
11+
/**
12+
* Resolves the change addresses for the change outputs.
13+
*
14+
* @param selection The inputs selection result.
15+
* @returns The updated change outputs.
16+
*/
17+
resolve(selection: Selection): Promise<Array<Cardano.TxOut>>;
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Cardano } from '@cardano-sdk/core';
2+
import { ChangeAddressResolver, Selection } from '../';
3+
import { GroupedAddress } from '@cardano-sdk/key-management';
4+
import { InvalidStateError } from '@cardano-sdk/util';
5+
6+
export type GetAddresses = () => Promise<GroupedAddress[]>;
7+
8+
/**
9+
* Default change address resolver.
10+
*/
11+
export class StaticChangeAddressResolver implements ChangeAddressResolver {
12+
readonly #getAddresses: GetAddresses;
13+
14+
/**
15+
* Initializes a new instance of the StaticChangeAddressResolver.
16+
*
17+
* @param getAddresses A promise that will be resolved with the list of known addresses.
18+
*/
19+
constructor(getAddresses: GetAddresses) {
20+
this.#getAddresses = getAddresses;
21+
}
22+
23+
/**
24+
* Always resolves to the same address.
25+
*/
26+
async resolve(selection: Selection): Promise<Array<Cardano.TxOut>> {
27+
const groupedAddresses = await this.#getAddresses();
28+
29+
if (groupedAddresses.length === 0) throw new InvalidStateError('The wallet has no known addresses.');
30+
31+
const address = groupedAddresses[0].address;
32+
33+
return selection.change.map((txOut) => ({ ...txOut, address }));
34+
}
35+
}

Diff for: packages/input-selection/src/ChangeAddress/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './ChangeAddressResolver';
2+
export * from './StaticChangeAddressResolver';

Diff for: packages/input-selection/src/RoundRobinRandomImprove/index.ts

+22-15
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { Cardano, cmlUtil } from '@cardano-sdk/core';
2+
import { ChangeAddressResolver } from '../ChangeAddress';
23
import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError';
34
import { InputSelectionParameters, InputSelector, SelectionResult } from '../types';
4-
import { assertIsBalanceSufficient, preProcessArgs, toValues } from '../util';
5+
import { assertIsBalanceSufficient, preProcessArgs, stubMaxSizeAddress, toValues } from '../util';
56
import { computeChangeAndAdjustForFee } from './change';
67
import { roundRobinSelection } from './roundRobin';
78

89
interface RoundRobinRandomImproveOptions {
9-
getChangeAddress: () => Promise<Cardano.PaymentAddress>;
10+
changeAddressResolver: ChangeAddressResolver;
1011
random?: typeof Math.random;
1112
}
1213

1314
export const roundRobinRandomImprove = ({
14-
getChangeAddress,
15+
changeAddressResolver,
1516
random = Math.random
1617
}: RoundRobinRandomImproveOptions): InputSelector => ({
1718
select: async ({
@@ -20,7 +21,7 @@ export const roundRobinRandomImprove = ({
2021
constraints: { computeMinimumCost, computeSelectionLimit, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit },
2122
implicitValue: partialImplicitValue = {}
2223
}: InputSelectionParameters): Promise<SelectionResult> => {
23-
const changeAddress = await getChangeAddress();
24+
const changeAddress = stubMaxSizeAddress;
2425
const { utxo, outputs, uniqueTxAssetIDs, implicitValue } = preProcessArgs(
2526
utxoSet,
2627
outputSet,
@@ -63,23 +64,29 @@ export const roundRobinRandomImprove = ({
6364
});
6465

6566
const inputs = new Set(result.inputs);
66-
const change = result.change.map((value) => ({
67-
address: changeAddress,
68-
value
69-
}));
7067

71-
if (result.inputs.length > (await computeSelectionLimit({ change, fee: result.fee, inputs, outputs: outputSet }))) {
68+
const selection = {
69+
change: result.change.map((value) => ({
70+
address: changeAddress,
71+
value
72+
})),
73+
fee: result.fee,
74+
inputs,
75+
outputs: outputSet
76+
};
77+
78+
selection.change = await changeAddressResolver.resolve(selection);
79+
80+
if (
81+
result.inputs.length >
82+
(await computeSelectionLimit({ change: selection.change, fee: selection.fee, inputs, outputs: outputSet }))
83+
) {
7284
throw new InputSelectionError(InputSelectionFailure.MaximumInputCountExceeded);
7385
}
7486

7587
return {
7688
remainingUTxO: new Set(result.remainingUTxO),
77-
selection: {
78-
change,
79-
fee: result.fee,
80-
inputs,
81-
outputs: outputSet
82-
}
89+
selection
8390
};
8491
}
8592
});

Diff for: packages/input-selection/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './RoundRobinRandomImprove';
22
export * from './GreedySelection';
33
export * from './types';
44
export * from './InputSelectionError';
5+
export * from './ChangeAddress';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { AddressType, GroupedAddress, KeyRole } from '@cardano-sdk/key-management';
2+
import { Cardano } from '@cardano-sdk/core';
3+
import { InvalidStateError } from '@cardano-sdk/util';
4+
import { StaticChangeAddressResolver } from '../../src';
5+
6+
export const knownAddresses = () =>
7+
Promise.resolve([
8+
{
9+
accountIndex: 0,
10+
address: 'testAddress' as Cardano.PaymentAddress,
11+
index: 0,
12+
networkId: Cardano.NetworkId.Testnet,
13+
rewardAccount: '' as Cardano.RewardAccount,
14+
stakeKeyDerivationPath: { index: 0, role: KeyRole.Stake },
15+
type: AddressType.External
16+
}
17+
]);
18+
19+
const emptyKnownAddresses = () => Promise.resolve(new Array<GroupedAddress>());
20+
21+
describe('StaticChangeAddressResolver', () => {
22+
it('always resolves to the first address in the knownAddresses', async () => {
23+
const changeAddressResolver = new StaticChangeAddressResolver(knownAddresses);
24+
25+
const selection = {
26+
change: [
27+
{
28+
address: '_' as Cardano.PaymentAddress,
29+
value: { coins: 10n }
30+
},
31+
{
32+
address: '_' as Cardano.PaymentAddress,
33+
value: { coins: 20n }
34+
},
35+
{
36+
address: '_' as Cardano.PaymentAddress,
37+
value: { coins: 30n }
38+
}
39+
],
40+
fee: 0n,
41+
inputs: new Set<Cardano.Utxo>(),
42+
outputs: new Set<Cardano.TxOut>()
43+
};
44+
45+
const updatedChange = await changeAddressResolver.resolve(selection);
46+
expect(updatedChange).toEqual([
47+
{ address: 'testAddress', value: { coins: 10n } },
48+
{ address: 'testAddress', value: { coins: 20n } },
49+
{ address: 'testAddress', value: { coins: 30n } }
50+
]);
51+
});
52+
53+
it('throws InvalidStateError if the there are no known addresses', async () => {
54+
const changeAddressResolver = new StaticChangeAddressResolver(emptyKnownAddresses);
55+
56+
const selection = {
57+
change: [
58+
{
59+
address: '_' as Cardano.PaymentAddress,
60+
value: { coins: 0n }
61+
}
62+
],
63+
fee: 0n,
64+
inputs: new Set<Cardano.Utxo>(),
65+
outputs: new Set<Cardano.TxOut>()
66+
};
67+
68+
await expect(changeAddressResolver.resolve(selection)).rejects.toThrow(
69+
new InvalidStateError('The wallet has no known addresses.')
70+
);
71+
});
72+
});

Diff for: packages/input-selection/test/InputSelectionPropertyTesting.test.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AssetId, TxTestUtil } from '@cardano-sdk/util-dev';
22
import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core';
3-
import { GreedyInputSelector, InputSelectionError, InputSelector } from '../src';
3+
import { ChangeAddressResolver, GreedyInputSelector, InputSelectionError, InputSelector, Selection } from '../src';
44
import { InputSelectionFailure } from '../src/InputSelectionError';
55
import {
66
SelectionConstraints,
@@ -17,9 +17,18 @@ import fc from 'fast-check';
1717
const changeAddress =
1818
'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9' as Cardano.PaymentAddress;
1919

20+
class MockChangeAddressResolver implements ChangeAddressResolver {
21+
async resolve(selection: Selection) {
22+
return selection.change.map((txOut) => {
23+
txOut.address = changeAddress;
24+
return txOut;
25+
});
26+
}
27+
}
28+
2029
const createRoundRobinRandomImprove = () =>
2130
roundRobinRandomImprove({
22-
getChangeAddress: async () => changeAddress
31+
changeAddressResolver: new MockChangeAddressResolver()
2332
});
2433

2534
const createGreedySelector = () =>

Diff for: packages/input-selection/test/RoundRobinRandomImprove.test.ts

+5-6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
1-
import { Cardano } from '@cardano-sdk/core';
2-
import { SelectionConstraints } from './util';
1+
import { MockChangeAddressResolver, SelectionConstraints } from './util';
32
import { TxTestUtil } from '@cardano-sdk/util-dev';
43
import { roundRobinRandomImprove } from '../src/RoundRobinRandomImprove';
54

6-
const changeAddress =
7-
'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9' as Cardano.PaymentAddress;
8-
95
describe('RoundRobinRandomImprove', () => {
106
it('Recomputes fee after selecting an extra utxo due to change not meeting minimumCoinQuantity', async () => {
117
const utxo = new Set([
@@ -27,7 +23,10 @@ describe('RoundRobinRandomImprove', () => {
2723
*/
2824
const random = jest.fn().mockReturnValue(0).mockReturnValueOnce(0).mockReturnValueOnce(0.99);
2925

30-
const results = await roundRobinRandomImprove({ getChangeAddress: async () => changeAddress, random }).select({
26+
const results = await roundRobinRandomImprove({
27+
changeAddressResolver: new MockChangeAddressResolver(),
28+
random
29+
}).select({
3130
constraints: SelectionConstraints.mockConstraintsToConstraints({
3231
...SelectionConstraints.MOCK_NO_CONSTRAINTS,
3332
minimumCoinQuantity: 900_000n,

Diff for: packages/input-selection/test/util/index.ts

+11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Cardano } from '@cardano-sdk/core';
2+
import { ChangeAddressResolver, Selection } from '../../src';
23

34
export * from './properties';
45
export * from './tests';
@@ -7,3 +8,13 @@ export * as SelectionConstraints from './selectionConstraints';
78
export const asAssetId = (x: string): Cardano.AssetId => x as unknown as Cardano.AssetId;
89
export const asPaymentAddress = (x: string): Cardano.PaymentAddress => x as unknown as Cardano.PaymentAddress;
910
export const asTokenMap = (elements: Iterable<[Cardano.AssetId, bigint]>) => new Map<Cardano.AssetId, bigint>(elements);
11+
12+
export class MockChangeAddressResolver implements ChangeAddressResolver {
13+
async resolve(selection: Selection) {
14+
return selection.change.map((txOut) => ({
15+
...txOut,
16+
address:
17+
'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9' as Cardano.PaymentAddress
18+
}));
19+
}
20+
}

Diff for: packages/tx-construction/src/tx-builder/initializeTx.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { roundRobinRandomImprove } from '@cardano-sdk/input-selection';
1+
import { StaticChangeAddressResolver, roundRobinRandomImprove } from '@cardano-sdk/input-selection';
22

33
import { Cardano } from '@cardano-sdk/core';
44
import { InitializeTxProps, InitializeTxResult } from '../types';
@@ -25,7 +25,7 @@ export const initializeTx = async (
2525
inputSelector =
2626
inputSelector ??
2727
roundRobinRandomImprove({
28-
getChangeAddress: async () => addresses[0].address
28+
changeAddressResolver: new StaticChangeAddressResolver(() => firstValueFrom(keyAgent.knownAddresses$))
2929
});
3030

3131
const validityInterval = ensureValidityInterval(tip.slot, genesisParameters, props.options?.validityInterval);

Diff for: packages/tx-construction/test/createTransactionInternals.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as Crypto from '@cardano-sdk/crypto';
22
import { AssetId, mockProviders } from '@cardano-sdk/util-dev';
33
import { Cardano, NetworkInfoProvider } from '@cardano-sdk/core';
44
import { CreateTxInternalsProps, createTransactionInternals } from '../src';
5+
import { MockChangeAddressResolver } from './tx-builder/mocks';
56
import { SelectionConstraints } from '../../input-selection/test/util';
67
import { SelectionSkeleton, roundRobinRandomImprove } from '@cardano-sdk/input-selection';
78

@@ -29,8 +30,7 @@ describe('createTransactionInternals', () => {
2930
props: (inputSelection: SelectionSkeleton) => Partial<CreateTxInternalsProps> = () => ({})
3031
) => {
3132
const result = await roundRobinRandomImprove({
32-
getChangeAddress: async () =>
33-
'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9' as Cardano.PaymentAddress
33+
changeAddressResolver: new MockChangeAddressResolver()
3434
}).select({
3535
constraints: SelectionConstraints.NO_CONSTRAINTS,
3636
outputs: new Set(outputs),

Diff for: packages/tx-construction/test/tx-builder/mocks.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Cardano } from '@cardano-sdk/core';
2+
import { ChangeAddressResolver, Selection } from '@cardano-sdk/input-selection';
3+
4+
export class MockChangeAddressResolver implements ChangeAddressResolver {
5+
async resolve(selection: Selection) {
6+
return selection.change.map((txOut) => ({
7+
...txOut,
8+
address:
9+
'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9' as Cardano.PaymentAddress
10+
}));
11+
}
12+
}

Diff for: packages/util/src/errors.ts

+17
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,20 @@ export class InvalidArgumentError extends CustomError {
101101
super(`Invalid argument '${argName}': ${message}`);
102102
}
103103
}
104+
105+
/**
106+
* The error that is thrown when a method call is invalid for the object's current state.
107+
*
108+
* This error can be used in cases when the failure to invoke a method is caused by reasons
109+
* other than invalid arguments.
110+
*/
111+
export class InvalidStateError extends CustomError {
112+
/**
113+
* Initializes a new instance of the InvalidStateError class.
114+
*
115+
* @param message The error message.
116+
*/
117+
public constructor(message: string) {
118+
super(`Invalid state': ${message}`);
119+
}
120+
}

Diff for: packages/wallet/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"@cardano-sdk/util": "workspace:~",
7474
"@cardano-sdk/util-rxjs": "workspace:~",
7575
"backoff-rxjs": "^7.0.0",
76+
"bignumber.js": "^9.1.1",
7677
"delay": "^5.0.0",
7778
"emittery": "^0.10.0",
7879
"lodash": "^4.17.21",

0 commit comments

Comments
 (0)