Skip to content
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

fix: map Blockfrost ValueNotConserved and OutsideOfValidityInterval errors #1552

Merged
merged 5 commits into from
Jan 6, 2025
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
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
Loading