Skip to content

Commit 8548d1b

Browse files
Merge pull request #1623 from input-output-hk/feat/lw-12753-add-retry-mechanism-for-inputselection-errors
Feat/lw 12753 add retry mechanism for inputselection errors
2 parents 07ac410 + 0500286 commit 8548d1b

File tree

3 files changed

+301
-25
lines changed

3 files changed

+301
-25
lines changed

packages/input-selection/src/InputSelectionError.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CustomError } from 'ts-custom-error';
33
export enum InputSelectionFailure {
44
/**
55
* Total value of the entries within the initial UTxO set (the amount of money available)
6-
* is less than the the total value of all entries in the requested output set (the amount of money required).
6+
* is less than the total value of all entries in the requested output set (the amount of money required).
77
*/
88
UtxoBalanceInsufficient = 'UTxO Balance Insufficient',
99
/**

packages/tx-construction/src/tx-builder/TxBuilder.ts

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ import {
2626
UnwitnessedTx
2727
} from './types';
2828
import { GreedyTxEvaluator } from './GreedyTxEvaluator';
29+
import {
30+
InputSelectionError,
31+
InputSelectionFailure,
32+
LargeFirstSelector,
33+
SelectionSkeleton,
34+
StaticChangeAddressResolver
35+
} from '@cardano-sdk/input-selection';
2936
import { Logger } from 'ts-log';
3037
import { OutputBuilderValidator, TxOutputBuilder } from './OutputBuilder';
3138
import { RedeemersByType } from '../input-selection';
@@ -38,7 +45,6 @@ import {
3845
sortRewardAccountsDelegatedFirst,
3946
validateValidityInterval
4047
} from './utils';
41-
import { SelectionSkeleton } from '@cardano-sdk/input-selection';
4248
import { contextLogger, deepEquals } from '@cardano-sdk/util';
4349
import { createOutputValidator } from '../output-validation';
4450
import { ensureNoDeRegistrationsWithRewardsLocked } from './ensureNoDeRegistrationsWithRewardsLocked';
@@ -341,6 +347,7 @@ export class GenericTxBuilder implements TxBuilder {
341347
const isAlteringDelegation =
342348
this.#requestedPortfolio !== undefined || this.#delegateFirstStakeCredConfig !== undefined;
343349

350+
let usingGreedySelector = false;
344351
if (
345352
this.#dependencies.bip32Account &&
346353
isAlteringDelegation &&
@@ -361,6 +368,7 @@ export class GenericTxBuilder implements TxBuilder {
361368
// Distributing balance according to weights is necessary when there are multiple reward accounts
362369
// and delegating, to make sure utxos are part of the correct addresses (the ones being delegated)
363370
dependencies.inputSelector = createGreedyInputSelector(rewardAccountsWithWeights, ownAddresses);
371+
usingGreedySelector = true;
364372
}
365373

366374
// Resolved all unresolved inputs
@@ -396,30 +404,51 @@ export class GenericTxBuilder implements TxBuilder {
396404
}
397405
}
398406

399-
const { body, hash, inputSelection, redeemers } = await initializeTx(
400-
{
401-
auxiliaryData,
402-
certificates: this.partialTxBody.certificates,
403-
collateralReturn,
404-
collaterals,
405-
customizeCb: this.#customizeCb,
406-
handleResolutions: this.#handleResolutions,
407-
inputs: new Set(this.#preSelectedInputs.values()),
408-
options: {
409-
validityInterval: this.partialTxBody.validityInterval
410-
},
411-
outputs: new Set(this.partialTxBody.outputs || []),
412-
proposalProcedures: this.partialTxBody.proposalProcedures,
413-
redeemersByType: this.#knownRedeemers,
414-
referenceInputs: new Set([...this.#referenceInputs.values()].map((utxo) => utxo[0])),
415-
scriptIntegrityHash: hasPlutusScripts ? DUMMY_SCRIPT_DATA_HASH : undefined,
416-
scriptVersions,
417-
signingOptions: partialSigningOptions,
418-
txEvaluator: this.#txEvaluator,
419-
witness
407+
const initializeTxProps = {
408+
auxiliaryData,
409+
certificates: this.partialTxBody.certificates,
410+
collateralReturn,
411+
collaterals,
412+
customizeCb: this.#customizeCb,
413+
handleResolutions: this.#handleResolutions,
414+
inputs: new Set(this.#preSelectedInputs.values()),
415+
options: {
416+
validityInterval: this.partialTxBody.validityInterval
420417
},
421-
dependencies
422-
);
418+
outputs: new Set(this.partialTxBody.outputs || []),
419+
proposalProcedures: this.partialTxBody.proposalProcedures,
420+
redeemersByType: this.#knownRedeemers,
421+
referenceInputs: new Set([...this.#referenceInputs.values()].map((utxo) => utxo[0])),
422+
scriptIntegrityHash: hasPlutusScripts ? DUMMY_SCRIPT_DATA_HASH : undefined,
423+
scriptVersions,
424+
signingOptions: partialSigningOptions,
425+
txEvaluator: this.#txEvaluator,
426+
witness
427+
};
428+
429+
let initialTxResult;
430+
431+
try {
432+
initialTxResult = await initializeTx(initializeTxProps, dependencies);
433+
} catch (error) {
434+
// Fallback to large first if we are not using the greedy selector
435+
if (
436+
usingGreedySelector ||
437+
!(error instanceof InputSelectionError) ||
438+
error.failure === InputSelectionFailure.UtxoBalanceInsufficient
439+
) {
440+
throw error;
441+
}
442+
443+
dependencies.inputSelector = new LargeFirstSelector({
444+
changeAddressResolver: new StaticChangeAddressResolver(async () => ownAddresses)
445+
});
446+
447+
this.#logger.warn(`Building attempt failed with ${error.failure}, retrying with large first selector`);
448+
initialTxResult = await initializeTx(initializeTxProps, dependencies);
449+
}
450+
451+
const { body, hash, inputSelection, redeemers } = initialTxResult;
423452

424453
witness.redeemers = redeemers;
425454

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/* eslint-disable sonarjs/no-duplicate-string */
2+
import * as Crypto from '@cardano-sdk/crypto';
3+
import { AddressType, Bip32Account, GroupedAddress, InMemoryKeyAgent, util } from '@cardano-sdk/key-management';
4+
import { Cardano } from '@cardano-sdk/core';
5+
import { GenericTxBuilder, OutputValidation, RewardAccountWithPoolId, TxBuilderProviders } from '../../src';
6+
import {
7+
GreedyInputSelector,
8+
InputSelectionError,
9+
InputSelectionFailure,
10+
LargeFirstSelector,
11+
roundRobinRandomImprove
12+
} from '@cardano-sdk/input-selection';
13+
import { dummyLogger } from 'ts-log';
14+
import { mockTxEvaluator } from './mocks';
15+
import { mockProviders as mocks } from '@cardano-sdk/util-dev';
16+
import uniqBy from 'lodash/uniqBy.js';
17+
18+
const largeFirstSelectSpy = jest.spyOn(LargeFirstSelector.prototype, 'select');
19+
20+
jest.mock('@cardano-sdk/input-selection', () => {
21+
const actual = jest.requireActual('@cardano-sdk/input-selection');
22+
return {
23+
...actual,
24+
roundRobinRandomImprove: jest.fn((args) => actual.roundRobinRandomImprove(args))
25+
};
26+
});
27+
28+
const inputResolver: Cardano.InputResolver = {
29+
resolveInput: async (txIn) =>
30+
mocks.utxo.find(([hydratedTxIn]) => txIn.txId === hydratedTxIn.txId && txIn.index === hydratedTxIn.index)?.[1] ||
31+
null
32+
};
33+
34+
/** Utility factory for tests to create a GenericTxBuilder with mocked dependencies */
35+
const createTxBuilder = async ({
36+
adjustRewardAccount = (r) => r,
37+
stakeDelegations,
38+
numAddresses = stakeDelegations.length,
39+
useMultiplePaymentKeys = false,
40+
rewardAccounts,
41+
keyAgent
42+
}: {
43+
adjustRewardAccount?: (rewardAccountWithPoolId: RewardAccountWithPoolId, index: number) => RewardAccountWithPoolId;
44+
stakeDelegations: {
45+
credentialStatus: Cardano.StakeCredentialStatus;
46+
poolId?: Cardano.PoolId;
47+
deposit?: Cardano.Lovelace;
48+
}[];
49+
numAddresses?: number;
50+
useMultiplePaymentKeys?: boolean;
51+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
52+
rewardAccounts?: any;
53+
keyAgent: InMemoryKeyAgent;
54+
}) => {
55+
let groupedAddresses = await Promise.all(
56+
Array.from({ length: numAddresses }).map(async (_, idx) =>
57+
keyAgent.deriveAddress({ index: 0, type: AddressType.External }, idx)
58+
)
59+
);
60+
61+
// Simulate an HD wallet where a each stake key partitions 2 payment keys (2 addresses per stake key)
62+
if (useMultiplePaymentKeys) {
63+
const groupedAddresses2 = await Promise.all(
64+
stakeDelegations.map(async (_, idx) => keyAgent.deriveAddress({ index: 1, type: AddressType.External }, idx))
65+
);
66+
groupedAddresses = [...groupedAddresses, ...groupedAddresses2];
67+
}
68+
69+
const txBuilderProviders: jest.Mocked<TxBuilderProviders> = {
70+
addresses: {
71+
add: jest.fn().mockImplementation((...addreses) => groupedAddresses.push(...addreses)),
72+
get: jest.fn().mockResolvedValue(groupedAddresses)
73+
},
74+
genesisParameters: jest.fn().mockResolvedValue(mocks.genesisParameters),
75+
protocolParameters: jest.fn().mockResolvedValue(mocks.protocolParameters),
76+
rewardAccounts:
77+
rewardAccounts ||
78+
jest.fn().mockImplementation(() =>
79+
Promise.resolve(
80+
// There can be multiple addresses with the same reward account. Extract the uniq reward accounts
81+
uniqBy(groupedAddresses, ({ rewardAccount }) => rewardAccount)
82+
// Create mock stakeKey/delegation status for each reward account according to the requested stakeDelegations.
83+
// This would normally be done by the wallet.delegation.rewardAccounts
84+
.map<RewardAccountWithPoolId>(({ rewardAccount: address }, index) => {
85+
const { credentialStatus, poolId, deposit } = stakeDelegations[index] ?? {};
86+
return adjustRewardAccount(
87+
{
88+
address,
89+
credentialStatus: credentialStatus ?? Cardano.StakeCredentialStatus.Unregistered,
90+
dRepDelegatee: {
91+
delegateRepresentative: {
92+
__typename: 'AlwaysAbstain'
93+
}
94+
},
95+
rewardBalance: mocks.rewardAccountBalance,
96+
...(poolId ? { delegatee: { nextNextEpoch: { id: poolId } } } : undefined),
97+
...(deposit && { deposit })
98+
},
99+
index
100+
);
101+
})
102+
)
103+
),
104+
tip: jest.fn().mockResolvedValue(mocks.ledgerTip),
105+
utxoAvailable: jest.fn().mockResolvedValue(mocks.utxo)
106+
};
107+
const outputValidator = {
108+
validateOutput: jest.fn().mockResolvedValue({ coinMissing: 0n } as OutputValidation)
109+
};
110+
const asyncKeyAgent = util.createAsyncKeyAgent(keyAgent);
111+
return {
112+
groupedAddresses,
113+
txBuilder: new GenericTxBuilder({
114+
bip32Account: await Bip32Account.fromAsyncKeyAgent(asyncKeyAgent),
115+
inputResolver,
116+
logger: dummyLogger,
117+
outputValidator,
118+
txBuilderProviders,
119+
txEvaluator: mockTxEvaluator,
120+
witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent)
121+
}),
122+
txBuilderProviders,
123+
txBuilderWithoutBip32Account: new GenericTxBuilder({
124+
inputResolver,
125+
logger: dummyLogger,
126+
outputValidator,
127+
txBuilderProviders,
128+
txEvaluator: mockTxEvaluator,
129+
witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent)
130+
})
131+
};
132+
};
133+
134+
describe('TxBuilder/inputSelectorFallback', () => {
135+
let txBuilder: GenericTxBuilder;
136+
let keyAgent: InMemoryKeyAgent;
137+
let groupedAddresses: GroupedAddress[];
138+
139+
beforeEach(async () => {
140+
keyAgent = await InMemoryKeyAgent.fromBip39MnemonicWords(
141+
{
142+
chainId: Cardano.ChainIds.Preprod,
143+
getPassphrase: async () => Buffer.from('passphrase'),
144+
mnemonicWords: util.generateMnemonicWords()
145+
},
146+
{ bip32Ed25519: await Crypto.SodiumBip32Ed25519.create(), logger: dummyLogger }
147+
);
148+
149+
const txBuilderFactory = await createTxBuilder({
150+
keyAgent,
151+
stakeDelegations: [{ credentialStatus: Cardano.StakeCredentialStatus.Unregistered }]
152+
});
153+
txBuilder = txBuilderFactory.txBuilder;
154+
groupedAddresses = txBuilderFactory.groupedAddresses;
155+
});
156+
157+
afterEach(() => jest.clearAllMocks());
158+
159+
it('uses random improve by default', async () => {
160+
const tx = await txBuilder.addOutput(mocks.utxo[0][1]).build().inspect();
161+
162+
expect(tx.inputSelection.inputs.size).toBeGreaterThan(0);
163+
expect(tx.inputSelection.outputs.size).toBe(1);
164+
expect(tx.inputSelection.change.length).toBeGreaterThan(0);
165+
expect(roundRobinRandomImprove).toHaveBeenCalled();
166+
expect(largeFirstSelectSpy).not.toHaveBeenCalled();
167+
});
168+
169+
const fallbackFailures = [
170+
InputSelectionFailure.MaximumInputCountExceeded,
171+
InputSelectionFailure.UtxoFullyDepleted,
172+
InputSelectionFailure.UtxoNotFragmentedEnough
173+
] as const;
174+
175+
it.each(fallbackFailures)('falls back to large first when random improve throws', async (failure) => {
176+
(roundRobinRandomImprove as jest.Mock).mockImplementationOnce(() => {
177+
throw new InputSelectionError(failure);
178+
});
179+
180+
const tx = await txBuilder.addOutput(mocks.utxo[0][1]).build().inspect();
181+
182+
expect(tx.inputSelection.inputs.size).toBeGreaterThan(0);
183+
expect(tx.inputSelection.outputs.size).toBe(1);
184+
expect(tx.inputSelection.change.length).toBeGreaterThan(0);
185+
186+
expect(roundRobinRandomImprove).toHaveBeenCalled();
187+
expect(largeFirstSelectSpy).toHaveBeenCalled();
188+
});
189+
190+
it.each(fallbackFailures)('only retries once with large first when random improve throws %s', async (failure) => {
191+
(roundRobinRandomImprove as jest.Mock).mockImplementationOnce(() => {
192+
throw new InputSelectionError(failure);
193+
});
194+
195+
largeFirstSelectSpy.mockImplementationOnce(async () => {
196+
throw new InputSelectionError(failure);
197+
});
198+
199+
await expect(txBuilder.addOutput(mocks.utxo[0][1]).build().inspect()).rejects.toThrow(failure);
200+
expect(roundRobinRandomImprove).toHaveBeenCalledTimes(1);
201+
expect(largeFirstSelectSpy).toHaveBeenCalledTimes(1);
202+
});
203+
204+
it('does not fallback to large first when random improve throws UtxoBalanceInsufficient input selection error', async () => {
205+
(roundRobinRandomImprove as jest.Mock).mockImplementationOnce(() => {
206+
throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient);
207+
});
208+
209+
await expect(txBuilder.addOutput(mocks.utxo[0][1]).build().inspect()).rejects.toThrow('UTxO Balance Insufficient');
210+
expect(roundRobinRandomImprove).toHaveBeenCalled();
211+
expect(largeFirstSelectSpy).not.toHaveBeenCalled();
212+
});
213+
214+
it('does not fallback to large first when using greedy input selector', async () => {
215+
const poolIds: Cardano.PoolId[] = [
216+
Cardano.PoolId('pool1zuevzm3xlrhmwjw87ec38mzs02tlkwec9wxpgafcaykmwg7efhh'),
217+
Cardano.PoolId('pool1t9xlrjyk76c96jltaspgwcnulq6pdkmhnge8xgza8ku7qvpsy9r')
218+
];
219+
220+
jest.spyOn(GreedyInputSelector.prototype, 'select').mockImplementationOnce(async () => {
221+
throw new InputSelectionError(InputSelectionFailure.MaximumInputCountExceeded);
222+
});
223+
224+
const output = { address: groupedAddresses[0].address, value: { coins: 10n } };
225+
await expect(
226+
txBuilder
227+
.delegatePortfolio({
228+
name: 'Tests Portfolio',
229+
pools: [
230+
{
231+
id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])),
232+
weight: 1
233+
},
234+
{
235+
id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[1])),
236+
weight: 2
237+
}
238+
]
239+
})
240+
.addOutput(txBuilder.buildOutput(output).toTxOut())
241+
.build()
242+
.inspect()
243+
).rejects.toThrow('Maximum Input Count Exceeded');
244+
expect(roundRobinRandomImprove).not.toHaveBeenCalled();
245+
expect(largeFirstSelectSpy).not.toHaveBeenCalled();
246+
});
247+
});

0 commit comments

Comments
 (0)