diff --git a/packages/cardano-services-client/src/TxSubmitProvider/BlockfrostTxSubmitProvider.ts b/packages/cardano-services-client/src/TxSubmitProvider/BlockfrostTxSubmitProvider.ts index 4f0a2a6a1e3..8af0ec3eb9b 100644 --- a/packages/cardano-services-client/src/TxSubmitProvider/BlockfrostTxSubmitProvider.ts +++ b/packages/cardano-services-client/src/TxSubmitProvider/BlockfrostTxSubmitProvider.ts @@ -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) { @@ -9,10 +79,20 @@ export class BlockfrostTxSubmitProvider extends BlockfrostProvider implements Tx async submitTx({ signedTransaction }: SubmitTxArgs): Promise { // @ todo handle context and resolutions - await this.request('tx/submit', { - body: Buffer.from(signedTransaction, 'hex'), - headers: { 'Content-Type': 'application/cbor' }, - method: 'POST' - }); + try { + await this.request('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; + } } } diff --git a/packages/cardano-services-client/src/blockfrost/BlockfrostClient.ts b/packages/cardano-services-client/src/blockfrost/BlockfrostClient.ts index dd0aa48f349..bd139edec13 100644 --- a/packages/cardano-services-client/src/blockfrost/BlockfrostClient.ts +++ b/packages/cardano-services-client/src/blockfrost/BlockfrostClient.ts @@ -26,6 +26,14 @@ export type BlockfrostClientDependencies = { rateLimiter: RateLimiter; }; +const tryReadResponseText = async (response: Response): Promise => { + 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 @@ -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) { diff --git a/packages/cardano-services-client/src/blockfrost/BlockfrostProvider.ts b/packages/cardano-services-client/src/blockfrost/BlockfrostProvider.ts index f9d4ae11d49..b85e6d172c9 100644 --- a/packages/cardano-services-client/src/blockfrost/BlockfrostProvider.ts +++ b/packages/cardano-services-client/src/blockfrost/BlockfrostProvider.ts @@ -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); } diff --git a/packages/e2e/src/factories.ts b/packages/e2e/src/factories.ts index 51e4aa917c8..adb7c12992f 100644 --- a/packages/e2e/src/factories.ts +++ b/packages/e2e/src/factories.ts @@ -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'; @@ -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 ) @@ -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 ) ); @@ -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 ) ); @@ -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 ) ); @@ -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 ) ); @@ -334,6 +349,11 @@ handleProviderFactory.register(HTTP_PROVIDER, async (params: any, logger: Logger }); }); +handleProviderFactory.register( + STUB_PROVIDER, + async (): Promise => Promise.resolve(createStubHandleProvider()) +); + // Stake Pool providers stakePoolProviderFactory.register( STUB_PROVIDER, diff --git a/packages/e2e/test/providers/TxSubmitProvider.test.ts b/packages/e2e/test/providers/TxSubmitProvider.test.ts new file mode 100644 index 00000000000..bad0a12655c --- /dev/null +++ b/packages/e2e/test/providers/TxSubmitProvider.test.ts @@ -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 + }) + ); + }); +}); diff --git a/packages/util-dev/src/createStubHandleProvider.ts b/packages/util-dev/src/createStubHandleProvider.ts new file mode 100644 index 00000000000..fa4b4ddf2a1 --- /dev/null +++ b/packages/util-dev/src/createStubHandleProvider.ts @@ -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); + } +}); diff --git a/packages/util-dev/src/index.ts b/packages/util-dev/src/index.ts index 3bfa3bcdaf6..7d3a41b18c6 100644 --- a/packages/util-dev/src/index.ts +++ b/packages/util-dev/src/index.ts @@ -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';