Skip to content

Feat/lw 12753 add retry mechanism for inputselection errors #1623

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/input-selection/src/InputSelectionError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CustomError } from 'ts-custom-error';
export enum InputSelectionFailure {
/**
* Total value of the entries within the initial UTxO set (the amount of money available)
* is less than the the total value of all entries in the requested output set (the amount of money required).
* is less than the total value of all entries in the requested output set (the amount of money required).
*/
UtxoBalanceInsufficient = 'UTxO Balance Insufficient',
/**
Expand Down
77 changes: 53 additions & 24 deletions packages/tx-construction/src/tx-builder/TxBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ import {
UnwitnessedTx
} from './types';
import { GreedyTxEvaluator } from './GreedyTxEvaluator';
import {
InputSelectionError,
InputSelectionFailure,
LargeFirstSelector,
SelectionSkeleton,
StaticChangeAddressResolver
} from '@cardano-sdk/input-selection';
import { Logger } from 'ts-log';
import { OutputBuilderValidator, TxOutputBuilder } from './OutputBuilder';
import { RedeemersByType } from '../input-selection';
Expand All @@ -38,7 +45,6 @@ import {
sortRewardAccountsDelegatedFirst,
validateValidityInterval
} from './utils';
import { SelectionSkeleton } from '@cardano-sdk/input-selection';
import { contextLogger, deepEquals } from '@cardano-sdk/util';
import { createOutputValidator } from '../output-validation';
import { ensureNoDeRegistrationsWithRewardsLocked } from './ensureNoDeRegistrationsWithRewardsLocked';
Expand Down Expand Up @@ -341,6 +347,7 @@ export class GenericTxBuilder implements TxBuilder {
const isAlteringDelegation =
this.#requestedPortfolio !== undefined || this.#delegateFirstStakeCredConfig !== undefined;

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

// Resolved all unresolved inputs
Expand Down Expand Up @@ -396,30 +404,51 @@ export class GenericTxBuilder implements TxBuilder {
}
}

const { body, hash, inputSelection, redeemers } = await initializeTx(
{
auxiliaryData,
certificates: this.partialTxBody.certificates,
collateralReturn,
collaterals,
customizeCb: this.#customizeCb,
handleResolutions: this.#handleResolutions,
inputs: new Set(this.#preSelectedInputs.values()),
options: {
validityInterval: this.partialTxBody.validityInterval
},
outputs: new Set(this.partialTxBody.outputs || []),
proposalProcedures: this.partialTxBody.proposalProcedures,
redeemersByType: this.#knownRedeemers,
referenceInputs: new Set([...this.#referenceInputs.values()].map((utxo) => utxo[0])),
scriptIntegrityHash: hasPlutusScripts ? DUMMY_SCRIPT_DATA_HASH : undefined,
scriptVersions,
signingOptions: partialSigningOptions,
txEvaluator: this.#txEvaluator,
witness
const initializeTxProps = {
auxiliaryData,
certificates: this.partialTxBody.certificates,
collateralReturn,
collaterals,
customizeCb: this.#customizeCb,
handleResolutions: this.#handleResolutions,
inputs: new Set(this.#preSelectedInputs.values()),
options: {
validityInterval: this.partialTxBody.validityInterval
},
dependencies
);
outputs: new Set(this.partialTxBody.outputs || []),
proposalProcedures: this.partialTxBody.proposalProcedures,
redeemersByType: this.#knownRedeemers,
referenceInputs: new Set([...this.#referenceInputs.values()].map((utxo) => utxo[0])),
scriptIntegrityHash: hasPlutusScripts ? DUMMY_SCRIPT_DATA_HASH : undefined,
scriptVersions,
signingOptions: partialSigningOptions,
txEvaluator: this.#txEvaluator,
witness
};

let initialTxResult;

try {
initialTxResult = await initializeTx(initializeTxProps, dependencies);
} catch (error) {
// Fallback to large first if we are not using the greedy selector
if (
usingGreedySelector ||
!(error instanceof InputSelectionError) ||
error.failure === InputSelectionFailure.UtxoBalanceInsufficient
) {
throw error;
}

dependencies.inputSelector = new LargeFirstSelector({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally fallback inputSelector would be a separate dependency, but this is just a minor nit

changeAddressResolver: new StaticChangeAddressResolver(async () => ownAddresses)
});

this.#logger.warn(`Building attempt failed with ${error.failure}, retrying with large first selector`);
initialTxResult = await initializeTx(initializeTxProps, dependencies);
}

const { body, hash, inputSelection, redeemers } = initialTxResult;

witness.redeemers = redeemers;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/* eslint-disable sonarjs/no-duplicate-string */
import * as Crypto from '@cardano-sdk/crypto';
import { AddressType, Bip32Account, GroupedAddress, InMemoryKeyAgent, util } from '@cardano-sdk/key-management';
import { Cardano } from '@cardano-sdk/core';
import { GenericTxBuilder, OutputValidation, RewardAccountWithPoolId, TxBuilderProviders } from '../../src';
import {
GreedyInputSelector,
InputSelectionError,
InputSelectionFailure,
LargeFirstSelector,
roundRobinRandomImprove
} from '@cardano-sdk/input-selection';
import { dummyLogger } from 'ts-log';
import { mockTxEvaluator } from './mocks';
import { mockProviders as mocks } from '@cardano-sdk/util-dev';
import uniqBy from 'lodash/uniqBy.js';

const largeFirstSelectSpy = jest.spyOn(LargeFirstSelector.prototype, 'select');

jest.mock('@cardano-sdk/input-selection', () => {
const actual = jest.requireActual('@cardano-sdk/input-selection');
return {
...actual,
roundRobinRandomImprove: jest.fn((args) => actual.roundRobinRandomImprove(args))
};
});

const inputResolver: Cardano.InputResolver = {
resolveInput: async (txIn) =>
mocks.utxo.find(([hydratedTxIn]) => txIn.txId === hydratedTxIn.txId && txIn.index === hydratedTxIn.index)?.[1] ||
null
};

/** Utility factory for tests to create a GenericTxBuilder with mocked dependencies */
const createTxBuilder = async ({
adjustRewardAccount = (r) => r,
stakeDelegations,
numAddresses = stakeDelegations.length,
useMultiplePaymentKeys = false,
rewardAccounts,
keyAgent
}: {
adjustRewardAccount?: (rewardAccountWithPoolId: RewardAccountWithPoolId, index: number) => RewardAccountWithPoolId;
stakeDelegations: {
credentialStatus: Cardano.StakeCredentialStatus;
poolId?: Cardano.PoolId;
deposit?: Cardano.Lovelace;
}[];
numAddresses?: number;
useMultiplePaymentKeys?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rewardAccounts?: any;
keyAgent: InMemoryKeyAgent;
}) => {
let groupedAddresses = await Promise.all(
Array.from({ length: numAddresses }).map(async (_, idx) =>
keyAgent.deriveAddress({ index: 0, type: AddressType.External }, idx)
)
);

// Simulate an HD wallet where a each stake key partitions 2 payment keys (2 addresses per stake key)
if (useMultiplePaymentKeys) {
const groupedAddresses2 = await Promise.all(
stakeDelegations.map(async (_, idx) => keyAgent.deriveAddress({ index: 1, type: AddressType.External }, idx))
);
groupedAddresses = [...groupedAddresses, ...groupedAddresses2];
}

const txBuilderProviders: jest.Mocked<TxBuilderProviders> = {
addresses: {
add: jest.fn().mockImplementation((...addreses) => groupedAddresses.push(...addreses)),
get: jest.fn().mockResolvedValue(groupedAddresses)
},
genesisParameters: jest.fn().mockResolvedValue(mocks.genesisParameters),
protocolParameters: jest.fn().mockResolvedValue(mocks.protocolParameters),
rewardAccounts:
rewardAccounts ||
jest.fn().mockImplementation(() =>
Promise.resolve(
// There can be multiple addresses with the same reward account. Extract the uniq reward accounts
uniqBy(groupedAddresses, ({ rewardAccount }) => rewardAccount)
// Create mock stakeKey/delegation status for each reward account according to the requested stakeDelegations.
// This would normally be done by the wallet.delegation.rewardAccounts
.map<RewardAccountWithPoolId>(({ rewardAccount: address }, index) => {
const { credentialStatus, poolId, deposit } = stakeDelegations[index] ?? {};
return adjustRewardAccount(
{
address,
credentialStatus: credentialStatus ?? Cardano.StakeCredentialStatus.Unregistered,
dRepDelegatee: {
delegateRepresentative: {
__typename: 'AlwaysAbstain'
}
},
rewardBalance: mocks.rewardAccountBalance,
...(poolId ? { delegatee: { nextNextEpoch: { id: poolId } } } : undefined),
...(deposit && { deposit })
},
index
);
})
)
),
tip: jest.fn().mockResolvedValue(mocks.ledgerTip),
utxoAvailable: jest.fn().mockResolvedValue(mocks.utxo)
};
const outputValidator = {
validateOutput: jest.fn().mockResolvedValue({ coinMissing: 0n } as OutputValidation)
};
const asyncKeyAgent = util.createAsyncKeyAgent(keyAgent);
return {
groupedAddresses,
txBuilder: new GenericTxBuilder({
bip32Account: await Bip32Account.fromAsyncKeyAgent(asyncKeyAgent),
inputResolver,
logger: dummyLogger,
outputValidator,
txBuilderProviders,
txEvaluator: mockTxEvaluator,
witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent)
}),
txBuilderProviders,
txBuilderWithoutBip32Account: new GenericTxBuilder({
inputResolver,
logger: dummyLogger,
outputValidator,
txBuilderProviders,
txEvaluator: mockTxEvaluator,
witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent)
})
};
};

describe('TxBuilder/inputSelectorFallback', () => {
let txBuilder: GenericTxBuilder;
let keyAgent: InMemoryKeyAgent;
let groupedAddresses: GroupedAddress[];

beforeEach(async () => {
keyAgent = await InMemoryKeyAgent.fromBip39MnemonicWords(
{
chainId: Cardano.ChainIds.Preprod,
getPassphrase: async () => Buffer.from('passphrase'),
mnemonicWords: util.generateMnemonicWords()
},
{ bip32Ed25519: await Crypto.SodiumBip32Ed25519.create(), logger: dummyLogger }
);

const txBuilderFactory = await createTxBuilder({
keyAgent,
stakeDelegations: [{ credentialStatus: Cardano.StakeCredentialStatus.Unregistered }]
});
txBuilder = txBuilderFactory.txBuilder;
groupedAddresses = txBuilderFactory.groupedAddresses;
});

afterEach(() => jest.clearAllMocks());

it('uses random improve by default', async () => {
const tx = await txBuilder.addOutput(mocks.utxo[0][1]).build().inspect();

expect(tx.inputSelection.inputs.size).toBeGreaterThan(0);
expect(tx.inputSelection.outputs.size).toBe(1);
expect(tx.inputSelection.change.length).toBeGreaterThan(0);
expect(roundRobinRandomImprove).toHaveBeenCalled();
expect(largeFirstSelectSpy).not.toHaveBeenCalled();
});

const fallbackFailures = [
InputSelectionFailure.MaximumInputCountExceeded,
InputSelectionFailure.UtxoFullyDepleted,
InputSelectionFailure.UtxoNotFragmentedEnough
] as const;

it.each(fallbackFailures)('falls back to large first when random improve throws', async (failure) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice tests ❤️

(roundRobinRandomImprove as jest.Mock).mockImplementationOnce(() => {
throw new InputSelectionError(failure);
});

const tx = await txBuilder.addOutput(mocks.utxo[0][1]).build().inspect();

expect(tx.inputSelection.inputs.size).toBeGreaterThan(0);
expect(tx.inputSelection.outputs.size).toBe(1);
expect(tx.inputSelection.change.length).toBeGreaterThan(0);

expect(roundRobinRandomImprove).toHaveBeenCalled();
expect(largeFirstSelectSpy).toHaveBeenCalled();
});

it.each(fallbackFailures)('only retries once with large first when random improve throws %s', async (failure) => {
(roundRobinRandomImprove as jest.Mock).mockImplementationOnce(() => {
throw new InputSelectionError(failure);
});

largeFirstSelectSpy.mockImplementationOnce(async () => {
throw new InputSelectionError(failure);
});

await expect(txBuilder.addOutput(mocks.utxo[0][1]).build().inspect()).rejects.toThrow(failure);
expect(roundRobinRandomImprove).toHaveBeenCalledTimes(1);
expect(largeFirstSelectSpy).toHaveBeenCalledTimes(1);
});

it('does not fallback to large first when random improve throws UtxoBalanceInsufficient input selection error', async () => {
(roundRobinRandomImprove as jest.Mock).mockImplementationOnce(() => {
throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient);
});

await expect(txBuilder.addOutput(mocks.utxo[0][1]).build().inspect()).rejects.toThrow('UTxO Balance Insufficient');
expect(roundRobinRandomImprove).toHaveBeenCalled();
expect(largeFirstSelectSpy).not.toHaveBeenCalled();
});

it('does not fallback to large first when using greedy input selector', async () => {
const poolIds: Cardano.PoolId[] = [
Cardano.PoolId('pool1zuevzm3xlrhmwjw87ec38mzs02tlkwec9wxpgafcaykmwg7efhh'),
Cardano.PoolId('pool1t9xlrjyk76c96jltaspgwcnulq6pdkmhnge8xgza8ku7qvpsy9r')
];

jest.spyOn(GreedyInputSelector.prototype, 'select').mockImplementationOnce(async () => {
throw new InputSelectionError(InputSelectionFailure.MaximumInputCountExceeded);
});

const output = { address: groupedAddresses[0].address, value: { coins: 10n } };
await expect(
txBuilder
.delegatePortfolio({
name: 'Tests Portfolio',
pools: [
{
id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])),
weight: 1
},
{
id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[1])),
weight: 2
}
]
})
.addOutput(txBuilder.buildOutput(output).toTxOut())
.build()
.inspect()
).rejects.toThrow('Maximum Input Count Exceeded');
expect(roundRobinRandomImprove).not.toHaveBeenCalled();
expect(largeFirstSelectSpy).not.toHaveBeenCalled();
});
});
Loading