Skip to content

Commit

Permalink
Merge pull request #1552 from input-output-hk/feat/lw-11912-blockfros…
Browse files Browse the repository at this point in the history
…t-tx-submission-errors

fix: map Blockfrost ValueNotConserved and OutsideOfValidityInterval errors
  • Loading branch information
mkazlauskas authored Jan 6, 2025
2 parents babafa3 + 68fb8c8 commit 80205ff
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,76 @@
import { BlockfrostClient, BlockfrostProvider } from '../blockfrost';
import { Logger } from 'ts-log';
import { SubmitTxArgs, TxSubmitProvider } from '@cardano-sdk/core';
import {
ProviderError,
SubmitTxArgs,
TxSubmissionError,
TxSubmissionErrorCode,
TxSubmitProvider,
ValueNotConservedData
} from '@cardano-sdk/core';

type BlockfrostTxSubmissionErrorMessage = {
contents: {
contents: {
contents: {
error: [string];
};
};
};
};

const tryParseBlockfrostTxSubmissionErrorMessage = (
errorMessage: string
): BlockfrostTxSubmissionErrorMessage | null => {
try {
const error = JSON.parse(errorMessage);
if (typeof error === 'object' && Array.isArray(error?.contents?.contents?.contents?.error)) {
return error;
}
} catch {
return null;
}
return null;
};

/**
* @returns TxSubmissionError if sucessfully mapped, otherwise `null`
*/
const tryMapTxBlockfrostSubmissionError = (error: ProviderError): TxSubmissionError | null => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detail = JSON.parse(error.detail as any);
if (typeof detail?.message === 'string') {
const blockfrostTxSubmissionErrorMessage = tryParseBlockfrostTxSubmissionErrorMessage(detail.message);
if (!blockfrostTxSubmissionErrorMessage) {
return null;
}
const message = blockfrostTxSubmissionErrorMessage.contents.contents.contents.error[0];
if (message.includes('OutsideValidityIntervalUTxO')) {
// error also contains information about validity interval and actual slots,
// but we're currently not using this info
return new TxSubmissionError(TxSubmissionErrorCode.OutsideOfValidityInterval, null, message);
}
// eslint-disable-next-line wrap-regex
const valueNotConservedMatch = /ValueNotConservedUTxO.+Coin (\d+).+Coin (\d+)/.exec(message);
if (valueNotConservedMatch) {
const consumed = BigInt(valueNotConservedMatch[1]);
const produced = BigInt(valueNotConservedMatch[2]);
const valueNotConservedData: ValueNotConservedData = {
// error also contains information about consumed and produced native assets
// but we're currently not using this info
consumed: { coins: consumed },
produced: { coins: produced }
};
return new TxSubmissionError(TxSubmissionErrorCode.ValueNotConserved, valueNotConservedData, message);
}
}
} catch {
return null;
}

return null;
};

export class BlockfrostTxSubmitProvider extends BlockfrostProvider implements TxSubmitProvider {
constructor(client: BlockfrostClient, logger: Logger) {
Expand All @@ -9,10 +79,20 @@ export class BlockfrostTxSubmitProvider extends BlockfrostProvider implements Tx

async submitTx({ signedTransaction }: SubmitTxArgs): Promise<void> {
// @ todo handle context and resolutions
await this.request<string>('tx/submit', {
body: Buffer.from(signedTransaction, 'hex'),
headers: { 'Content-Type': 'application/cbor' },
method: 'POST'
});
try {
await this.request<string>('tx/submit', {
body: Buffer.from(signedTransaction, 'hex'),
headers: { 'Content-Type': 'application/cbor' },
method: 'POST'
});
} catch (error) {
if (error instanceof ProviderError) {
const submissionError = tryMapTxBlockfrostSubmissionError(error);
if (submissionError) {
throw new ProviderError(error.reason, submissionError, error.detail);
}
}
throw error;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ export type BlockfrostClientDependencies = {
rateLimiter: RateLimiter;
};

const tryReadResponseText = async (response: Response): Promise<string | undefined> => {
try {
return response.text();
} catch {
return undefined;
}
};

export class BlockfrostError extends CustomError {
constructor(public status?: number, public body?: string, public innerError?: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -71,12 +79,7 @@ export class BlockfrostClient {
throw new BlockfrostError(response.status, 'Failed to parse json');
}
}
try {
const responseBody = await response.text();
throw new BlockfrostError(response.status, responseBody);
} catch {
throw new BlockfrostError(response.status);
}
throw new BlockfrostError(response.status, await tryReadResponseText(response));
}),
catchError((err) => {
if (err instanceof BlockfrostError) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export abstract class BlockfrostProvider implements Provider {
return error;
}
if (error instanceof BlockfrostError) {
return new ProviderError(toProviderFailure(error.status), error);
return new ProviderError(toProviderFailure(error.status), error, error.body);
}
return new ProviderError(ProviderFailure.Unknown, error);
}
Expand Down
32 changes: 26 additions & 6 deletions packages/e2e/src/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ import { Logger } from 'ts-log';
import { NoCache, NodeTxSubmitProvider } from '@cardano-sdk/cardano-services';
import { OgmiosObservableCardanoNode } from '@cardano-sdk/ogmios';
import { TrezorKeyAgent } from '@cardano-sdk/hardware-trezor';
import { createStubStakePoolProvider } from '@cardano-sdk/util-dev';
import { createStubHandleProvider, createStubStakePoolProvider } from '@cardano-sdk/util-dev';
import { filter, firstValueFrom, of } from 'rxjs';
import { getEnv, walletVariables } from './environment';
import DeviceConnection from '@cardano-foundation/ledgerjs-hw-app-cardano';
Expand Down Expand Up @@ -179,7 +179,10 @@ chainHistoryProviderFactory.register(BLOCKFROST_PROVIDER, async (params: any, lo
return new Promise(async (resolve) => {
resolve(
new BlockfrostChainHistoryProvider(
new BlockfrostClient({ baseUrl: params.baseUrl }, { rateLimiter: { schedule: (task) => task() } }),
new BlockfrostClient(
{ apiVersion: params.apiVersion, baseUrl: params.baseUrl, projectId: params.projectId },
{ rateLimiter: { schedule: (task) => task() } }
),
await networkInfoProviderFactory.create('blockfrost', params, logger),
logger
)
Expand Down Expand Up @@ -225,7 +228,10 @@ networkInfoProviderFactory.register(BLOCKFROST_PROVIDER, async (params: any, log
return new Promise(async (resolve) => {
resolve(
new BlockfrostNetworkInfoProvider(
new BlockfrostClient({ baseUrl: params.baseUrl }, { rateLimiter: { schedule: (task) => task() } }),
new BlockfrostClient(
{ apiVersion: params.apiVersion, baseUrl: params.baseUrl, projectId: params.projectId },
{ rateLimiter: { schedule: (task) => task() } }
),
logger
)
);
Expand All @@ -246,7 +252,10 @@ rewardsProviderFactory.register(BLOCKFROST_PROVIDER, async (params: any, logger)
return new Promise(async (resolve) => {
resolve(
new BlockfrostRewardsProvider(
new BlockfrostClient({ baseUrl: params.baseUrl }, { rateLimiter: { schedule: (task) => task() } }),
new BlockfrostClient(
{ apiVersion: params.apiVersion, baseUrl: params.baseUrl, projectId: params.projectId },
{ rateLimiter: { schedule: (task) => task() } }
),
logger
)
);
Expand Down Expand Up @@ -293,7 +302,10 @@ txSubmitProviderFactory.register(BLOCKFROST_PROVIDER, async (params: any, logger
return new Promise(async (resolve) => {
resolve(
new BlockfrostTxSubmitProvider(
new BlockfrostClient({ baseUrl: params.baseUrl }, { rateLimiter: { schedule: (task) => task() } }),
new BlockfrostClient(
{ apiVersion: params.apiVersion, baseUrl: params.baseUrl, projectId: params.projectId },
{ rateLimiter: { schedule: (task) => task() } }
),
logger
)
);
Expand All @@ -319,7 +331,10 @@ utxoProviderFactory.register(BLOCKFROST_PROVIDER, async (params: any, logger) =>
return new Promise(async (resolve) => {
resolve(
new BlockfrostUtxoProvider(
new BlockfrostClient({ baseUrl: params.baseUrl }, { rateLimiter: { schedule: (task) => task() } }),
new BlockfrostClient(
{ apiVersion: params.apiVersion, baseUrl: params.baseUrl, projectId: params.projectId },
{ rateLimiter: { schedule: (task) => task() } }
),
logger
)
);
Expand All @@ -334,6 +349,11 @@ handleProviderFactory.register(HTTP_PROVIDER, async (params: any, logger: Logger
});
});

handleProviderFactory.register(
STUB_PROVIDER,
async (): Promise<HandleProvider> => Promise.resolve(createStubHandleProvider())
);

// Stake Pool providers
stakePoolProviderFactory.register(
STUB_PROVIDER,
Expand Down
126 changes: 126 additions & 0 deletions packages/e2e/test/providers/TxSubmitProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { BaseWallet } from '@cardano-sdk/wallet';
import { Cardano, ProviderFailure, TxSubmissionErrorCode, TxSubmitProvider } from '@cardano-sdk/core';
import { GenericTxBuilder, TxBuilderDependencies } from '@cardano-sdk/tx-construction';
import { firstValueFrom } from 'rxjs';
import { getEnv, getWallet, walletVariables } from '../../src';
import { logger } from '@cardano-sdk/util-dev';

describe('TxSubmitProvider', () => {
let wallet: BaseWallet;
let txSubmitProvider: TxSubmitProvider;
let ownAddress: Cardano.PaymentAddress;
let walletTxBuilderDependencies: TxBuilderDependencies;

beforeAll(async () => {
const env = getEnv(walletVariables);
({
wallet,
providers: { txSubmitProvider }
} = await getWallet({ env, idx: 0, logger, name: 'Test Wallet' }));
const addresses = await firstValueFrom(wallet.addresses$);
ownAddress = addresses[0].address;
walletTxBuilderDependencies = wallet.getTxBuilderDependencies();
});

it('maps ProviderError{TxSubmissionError{OutsideOfValidityInterval}}', async () => {
const actualTip = await firstValueFrom(wallet.tip$);
const txBuilder = new GenericTxBuilder({
...walletTxBuilderDependencies,
txBuilderProviders: {
...walletTxBuilderDependencies.txBuilderProviders,
tip: async () => ({
...actualTip,
// GenericTxBuilder fails to build a tx with expired validity interval.
// we need to trick it to believe the it's within validity interval
// in order to test submission error
slot: Cardano.Slot(actualTip.slot - 10)
})
}
});
const tx = await txBuilder
.addOutput({ address: ownAddress, value: { coins: 2_000_000n } })
.setValidityInterval({ invalidHereafter: Cardano.Slot(actualTip.slot - 1) })
.build()
.sign();

await expect(txSubmitProvider.submitTx({ signedTransaction: tx.cbor })).rejects.toThrow(
expect.objectContaining({
innerError: expect.objectContaining({
code: TxSubmissionErrorCode.OutsideOfValidityInterval
}),
reason: ProviderFailure.BadRequest
})
);
});

it('maps ProviderError{TxSubmissionError{ValueNotConserved}}', async () => {
const txBuilder = new GenericTxBuilder({
...walletTxBuilderDependencies,
inputSelector: {
select: async ({ utxo, outputs }) => ({
remainingUTxO: new Set([...utxo].slice(1)),
selection: {
change: [] as Cardano.TxOut[],
fee: 2_000_000n,
inputs: new Set([[...utxo][0]]),
outputs
}
})
}
});
const tx = await txBuilder
.addOutput({ address: ownAddress, value: { coins: 2_000_000n } })
.build()
.sign();

await expect(txSubmitProvider.submitTx({ signedTransaction: tx.cbor })).rejects.toThrow(
expect.objectContaining({
innerError: expect.objectContaining({
code: TxSubmissionErrorCode.ValueNotConserved,
data: expect.objectContaining({
consumed: expect.objectContaining({ coins: expect.any(BigInt) }),
produced: expect.objectContaining({ coins: expect.any(BigInt) })
})
}),
reason: ProviderFailure.BadRequest
})
);
});

// this mapping is not implemented yet due to
// https://input-output-rnd.slack.com/archives/C06J663L2A2/p1735920667624239
it.skip('maps ProviderError{TxSubmissionError{IncompleteWithdrawals}}', async () => {
const rewardAccounts = await firstValueFrom(wallet.delegation.rewardAccounts$);
if (rewardAccounts.some((acc) => !acc.dRepDelegatee || !acc.rewardBalance)) {
return logger.warn(
'Skipping IncompleteWithdrawals error test because there are either no rewards, or not delegated to drep'
);
}
const txBuilder = new GenericTxBuilder({
...walletTxBuilderDependencies,
txBuilderProviders: {
...walletTxBuilderDependencies.txBuilderProviders,
rewardAccounts: async () => {
const accounts = await walletTxBuilderDependencies.txBuilderProviders.rewardAccounts();
return accounts.map((account) => ({
...account,
rewardBalance: account.rewardBalance - 1n
}));
}
}
});
const tx = await txBuilder
.addOutput({ address: ownAddress, value: { coins: 2_000_000n } })
.build()
.sign();

await expect(txSubmitProvider.submitTx({ signedTransaction: tx.cbor })).rejects.toThrow(
expect.objectContaining({
innerError: expect.objectContaining({
code: TxSubmissionErrorCode.IncompleteWithdrawals
}),
reason: ProviderFailure.BadRequest
})
);
});
});
21 changes: 21 additions & 0 deletions packages/util-dev/src/createStubHandleProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Cardano, HandleProvider } from '@cardano-sdk/core';
import delay from 'delay';

/**
* @returns provider that fails to resolve all handles
*/
export const createStubHandleProvider = (delayMs?: number): HandleProvider => ({
getPolicyIds: async () => {
if (delayMs) await delay(delayMs);
// Kora labs testnet policy id
return [Cardano.PolicyId('8d18d786e92776c824607fd8e193ec535c79dc61ea2405ddf3b09fe3')];
},
healthCheck: async () => {
if (delayMs) await delay(delayMs);
return { ok: true };
},
resolveHandles: async ({ handles }) => {
if (delayMs) await delay(delayMs);
return handles.map(() => null);
}
});
1 change: 1 addition & 0 deletions packages/util-dev/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './chainSync';
export * from './TestLogger';
export * from './util';
export * from './createStubStakePoolProvider';
export * from './createStubHandleProvider';
export * from './testScheduler';
export * from './createStubUtxoProvider';
export * from './createStubObservable';
Expand Down

0 comments on commit 80205ff

Please sign in to comment.