Skip to content
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
7 changes: 7 additions & 0 deletions .changeset/defi-error-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@ton/walletkit': patch
'@ton/appkit': patch
'@ton/appkit-react': patch
---

DeFi operations (swap, staking, crypto-onramp, gasless) now always throw a typed `DefiError` on failure — a specific subclass (`SwapError`, `StakingError`, `CryptoOnrampError`, `GaslessError`) when available, otherwise a `DefiError` with the new `DefiErrorCode.Unknown` code (the original error is preserved in `details`). Failures can be reliably caught and branched on by `code`.
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { mapDefiError } from '../../../../../utils/map-defi-error';

/**
* Map a thrown crypto-onramp error to an i18n key. Tries crypto-onramp-specific codes first,
* falls back to the shared {@link mapDefiError} for base DeFi codes, and finally to a generic
* `cryptoOnramp.genericError`.
* falls back to the shared {@link mapDefiError} for base DeFi codes, and finally to the shared
* `defi.genericError`.
*/
export const mapCryptoOnrampError = (error: unknown): string => {
if (error instanceof CryptoOnrampError) {
Expand All @@ -40,8 +40,10 @@ export const mapCryptoOnrampError = (error: unknown): string => {
return 'cryptoOnramp.quoteError';
case CryptoOnrampErrorCode.ProviderError:
return 'cryptoOnramp.providerError';
case CryptoOnrampErrorCode.DepositFailed:
return 'cryptoOnramp.depositFailed';
}
}

return mapDefiError(error) ?? 'cryptoOnramp.genericError';
return mapDefiError(error) ?? 'defi.genericError';
};
3 changes: 2 additions & 1 deletion packages/appkit-react/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default {
noDefaultProvider: 'No provider configured',
invalidProvider: 'Invalid provider',
invalidParams: 'Invalid parameters',
genericError: 'Something went wrong',
},

// Swap
Expand Down Expand Up @@ -137,9 +138,9 @@ export default {
selectMethod: 'Select payment method',
searchMethod: 'Search',
quoteError: 'Failed to get a quote',
depositFailed: 'Failed to create deposit',
tooManyDecimals: 'Too many decimals',
providerError: 'Provider error',
genericError: 'Something went wrong',
addressTab: 'Address',
memoTab: 'Memo',
youGet: 'You get',
Expand Down
2 changes: 2 additions & 0 deletions packages/appkit-react/src/utils/map-defi-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export const mapDefiError = (error: unknown): string | null => {
return 'defi.invalidProvider';
case DefiErrorCode.InvalidParams:
return 'defi.invalidParams';
case DefiErrorCode.Unknown:
return 'defi.genericError';
default:
return null;
}
Expand Down
15 changes: 5 additions & 10 deletions packages/walletkit/src/defi/DefiManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import type { DefiProvider } from '../api/interfaces';
import { resolveProvider } from '../types';
import type { ProviderInput } from '../types';
import type { ProviderFactoryContext } from '../types/factory';
import type { DefiError } from './errors';
import { DefiErrorCode } from './errors';
import { DefiError, DefiErrorCode } from './errors';
import type { SharedKitEvents } from '../types/emitter';
import type { EventEmitter } from '../core/EventEmitter';

Expand All @@ -24,7 +23,6 @@ export abstract class DefiManager<

protected providers: T[] = [];
protected defaultProviderId?: string;
protected abstract createError(message: string, code: string, details?: unknown): DefiError;
protected eventEmitter: EventEmitter<E>;

constructor(createFactoryContext: () => ProviderFactoryContext<E>) {
Expand All @@ -44,7 +42,7 @@ export abstract class DefiManager<
const providerId = provider.providerId;

if (!providerId) {
throw this.createError('Provider must have a providerId', DefiErrorCode.InvalidProvider);
throw new DefiError('Provider must have a providerId', DefiErrorCode.InvalidProvider);
}

const oldProvider = this.providers.find((p) => p.providerId === providerId);
Expand Down Expand Up @@ -84,7 +82,7 @@ export abstract class DefiManager<
const provider = this.providers.find((p) => p.providerId === providerId);

if (!provider) {
throw this.createError(`Provider '${providerId}' not found`, DefiErrorCode.ProviderNotFound, {
throw new DefiError(`Provider '${providerId}' not found`, DefiErrorCode.ProviderNotFound, {
provider: providerId,
registered: this.providers.map((p) => p.providerId),
});
Expand All @@ -104,15 +102,12 @@ export abstract class DefiManager<
const providerName = providerId || this.defaultProviderId;

if (!providerName) {
throw this.createError(
'No default provider set. Register a provider first.',
DefiErrorCode.NoDefaultProvider,
);
throw new DefiError('No default provider set. Register a provider first.', DefiErrorCode.NoDefaultProvider);
}

const provider = this.providers.find((p) => p.providerId === providerName);
if (!provider) {
throw this.createError(`Provider '${providerName}' not found`, DefiErrorCode.ProviderNotFound, {
throw new DefiError(`Provider '${providerName}' not found`, DefiErrorCode.ProviderNotFound, {
provider: providerName,
registered: this.providers.map((p) => p.providerId),
});
Expand Down
17 changes: 6 additions & 11 deletions packages/walletkit/src/defi/crypto-onramp/CryptoOnrampManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ import type {
CryptoOnrampStatusParams,
CryptoOnrampSupportedCurrencies,
} from '../../api/models';
import type { CryptoOnrampErrorCode } from './errors';
import { CryptoOnrampError } from './errors';
import { toDefiError } from '../errors';
import { globalLogger } from '../../core/Logger';
import { DefiManager } from '../DefiManager';

Expand All @@ -43,7 +42,7 @@ export class CryptoOnrampManager extends DefiManager<CryptoOnrampProviderInterfa
return this.getProvider(selectedProviderId).getMetadata();
} catch (error) {
log.error('Failed to get crypto onramp metadata', { error });
throw error;
throw toDefiError(error, 'Failed to get crypto onramp metadata');
}
}

Expand Down Expand Up @@ -79,7 +78,7 @@ export class CryptoOnrampManager extends DefiManager<CryptoOnrampProviderInterfa
return quote;
} catch (error) {
log.error('Failed to get crypto onramp quote', { error, params });
throw error;
throw toDefiError(error, 'Failed to get crypto onramp quote');
}
}

Expand Down Expand Up @@ -113,7 +112,7 @@ export class CryptoOnrampManager extends DefiManager<CryptoOnrampProviderInterfa
return deposit;
} catch (error) {
log.error('Failed to create crypto onramp deposit', { error, params });
throw error;
throw toDefiError(error, 'Failed to create crypto onramp deposit');
}
}

Expand Down Expand Up @@ -141,7 +140,7 @@ export class CryptoOnrampManager extends DefiManager<CryptoOnrampProviderInterfa
return status;
} catch (error) {
log.error('Failed to get crypto onramp deposit status', { error, params });
throw error;
throw toDefiError(error, 'Failed to get crypto onramp deposit status');
}
}

Expand All @@ -157,11 +156,7 @@ export class CryptoOnrampManager extends DefiManager<CryptoOnrampProviderInterfa
return await this.getProvider(selectedProviderId).getSupportedCurrencies();
} catch (error) {
log.error('Failed to discover crypto onramp supported currencies', { error });
throw error;
throw toDefiError(error, 'Failed to discover crypto onramp supported currencies');
}
}

protected createError(message: string, code: CryptoOnrampErrorCode, details?: unknown): CryptoOnrampError {
return new CryptoOnrampError(message, code, details);
}
}
1 change: 1 addition & 0 deletions packages/walletkit/src/defi/crypto-onramp/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DefiError } from '../errors';
export enum CryptoOnrampErrorCode {
ProviderError = 'PROVIDER_ERROR',
QuoteFailed = 'QUOTE_FAILED',
DepositFailed = 'DEPOSIT_FAILED',
RefundAddressRequired = 'REFUND_ADDRESS_REQUIRED',
InvalidRefundAddress = 'INVALID_REFUND_ADDRESS',
ReversedAmountNotSupported = 'REVERSED_AMOUNT_NOT_SUPPORTED',
Expand Down
11 changes: 11 additions & 0 deletions packages/walletkit/src/defi/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export enum DefiErrorCode {
UnsupportedNetwork = 'UNSUPPORTED_NETWORK',
InvalidParams = 'INVALID_PARAMS',
InvalidProvider = 'INVALID_PROVIDER',
Unknown = 'UNKNOWN',
}

export class DefiError extends Error {
Expand All @@ -26,3 +27,13 @@ export class DefiError extends Error {
this.details = details;
}
}

/**
* Guarantees a typed error: returns `error` unchanged when it already is a {@link DefiError}
* (including subclasses like `SwapError`, `StakingError`, etc.), otherwise wraps it in a
* `DefiError` with the {@link DefiErrorCode.Unknown} code. Use in manager catch blocks so the
* public API always throws a `DefiError`.
*/
export function toDefiError(error: unknown, message: string): DefiError {
return error instanceof DefiError ? error : new DefiError(message, DefiErrorCode.Unknown, error);
}
5 changes: 3 additions & 2 deletions packages/walletkit/src/defi/gasless/GaslessManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Network } from '../../api/models';
import { EventEmitter } from '../../core/EventEmitter';
import type { ProviderFactoryContext } from '../../types/factory';
import { GaslessError, GaslessErrorCode } from './errors';
import { DefiError } from '../errors';
import { GaslessManager } from './GaslessManager';

const TEST_ADDRESS = 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs';
Expand Down Expand Up @@ -109,11 +110,11 @@ describe('GaslessManager.setDefaultProvider', () => {
expect(manager.getProvider('first')).toBe(first);
});

it('throws GaslessError when the providerId is not registered', () => {
it('throws DefiError when the providerId is not registered', () => {
const { manager } = makeManager();
manager.registerProvider(makeProvider('first'));

expect(() => manager.setDefaultProvider('missing')).toThrow(GaslessError);
expect(() => manager.setDefaultProvider('missing')).toThrow(DefiError);
});
Comment on lines +113 to 118

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert the specific ProviderNotFound behavior, not just the error class.

Line [117] only checks DefiError, which can hide regressions in error-code semantics for this path.

Suggested test tightening
-import { DefiError } from '../errors';
+import { DefiError, DefiErrorCode } from '../errors';
@@
-        expect(() => manager.setDefaultProvider('missing')).toThrow(DefiError);
+        expect(() => manager.setDefaultProvider('missing')).toThrowError(
+            expect.objectContaining({ name: 'DefiError', code: DefiErrorCode.ProviderNotFound }),
+        );

As per coding guidelines, "Test behaviors, not implementation details. Always update existing tests related to your changes."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('throws DefiError when the providerId is not registered', () => {
const { manager } = makeManager();
manager.registerProvider(makeProvider('first'));
expect(() => manager.setDefaultProvider('missing')).toThrow(GaslessError);
expect(() => manager.setDefaultProvider('missing')).toThrow(DefiError);
});
it('throws DefiError when the providerId is not registered', () => {
const { manager } = makeManager();
manager.registerProvider(makeProvider('first'));
expect(() => manager.setDefaultProvider('missing')).toThrowError(
expect.objectContaining({ name: 'DefiError', code: DefiErrorCode.ProviderNotFound }),
);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/walletkit/src/defi/gasless/GaslessManager.spec.ts` around lines 113
- 118, The test for the setDefaultProvider method only verifies that a DefiError
is thrown, but does not assert the specific error code. Update the expect
statement to check for both the error class and the specific ProviderNotFound
error code or error message to ensure the correct error semantics are
maintained. Use a more specific assertion pattern (such as checking the error
code property or error message) alongside the DefiError class check in the
setDefaultProvider test.

Source: Coding guidelines

});

Expand Down
15 changes: 5 additions & 10 deletions packages/walletkit/src/defi/gasless/GaslessManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ import type {
import { globalLogger } from '../../core/Logger';
import type { ProviderFactoryContext } from '../../types/factory';
import { DefiManager } from '../DefiManager';
import type { GaslessErrorCode } from './errors';
import { GaslessError } from './errors';
import { toDefiError } from '../errors';

const log = globalLogger.createChild('GaslessManager');

Expand All @@ -46,7 +45,7 @@ export class GaslessManager extends DefiManager<GaslessProviderInterface> implem
return await this.getProvider(selectedProviderId).getMetadata();
} catch (error) {
log.error('Failed to get gasless provider metadata', { error });
throw error;
throw toDefiError(error, 'Failed to get gasless provider metadata');
}
}

Expand All @@ -67,7 +66,7 @@ export class GaslessManager extends DefiManager<GaslessProviderInterface> implem
return await provider.getConfig(targetNetwork);
} catch (error) {
log.error('Failed to get gasless config', { error });
throw error;
throw toDefiError(error, 'Failed to get gasless config');
}
}

Expand All @@ -87,7 +86,7 @@ export class GaslessManager extends DefiManager<GaslessProviderInterface> implem
return await this.getProvider(providerId ?? this.defaultProviderId).getQuote(params);
} catch (error) {
log.error('Failed to quote gasless transaction', { error, params });
throw error;
throw toDefiError(error, 'Failed to quote gasless transaction');
}
}

Expand All @@ -104,11 +103,7 @@ export class GaslessManager extends DefiManager<GaslessProviderInterface> implem
return await this.getProvider(providerId ?? this.defaultProviderId).sendTransaction(params);
} catch (error) {
log.error('Failed to send gasless transaction', { error });
throw error;
throw toDefiError(error, 'Failed to send gasless transaction');
}
}

protected createError(message: string, code: string, details?: unknown): GaslessError {
return new GaslessError(message, code as GaslessErrorCode, details);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type { TonApiGaslessChainConfig, TonApiGaslessProviderConfig } from './mo
import type { TonApiGaslessConfig } from './types/config';
import type { TonApiGaslessEstimateResponse } from './types/estimate';
import type { TonApiGaslessSendResponse } from './types/send';
import { DefiErrorCode, DefiError } from '../../errors';

const log = globalLogger.createChild('TonApiGaslessProvider');

Expand Down Expand Up @@ -135,8 +136,9 @@ export class TonApiGaslessProvider extends GaslessProvider {
}

if (Object.keys(chainConfig).length === 0) {
throw new Error(
throw new DefiError(
'createTonApiGaslessProvider: no eligible networks (configure at least one network in the kit, or pass `chains` matching a configured network)',
DefiErrorCode.InvalidParams,
);
}

Expand Down
20 changes: 6 additions & 14 deletions packages/walletkit/src/defi/staking/StakingManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import type {
StakingQuote,
} from '../../api/models';
import type { StakingAPI, StakingProviderInterface } from '../../api/interfaces';
import { StakingError, StakingErrorCode } from './errors';
import { globalLogger } from '../../core/Logger';
import { DefiManager } from '../DefiManager';
import { toDefiError } from '../errors';
import type { ProviderFactoryContext } from '../../types/factory';

const log = globalLogger.createChild('StakingManager');
Expand Down Expand Up @@ -47,7 +47,7 @@ export class StakingManager extends DefiManager<StakingProviderInterface> implem
return quote;
} catch (error) {
log.error('Failed to get staking quote', { error, params });
throw error;
throw toDefiError(error, 'Failed to get staking quote');
}
}

Expand All @@ -62,7 +62,7 @@ export class StakingManager extends DefiManager<StakingProviderInterface> implem
return await this.getProvider(providerId).buildStakeTransaction(params);
} catch (error) {
log.error('Failed to build staking transaction', { error, params });
throw error;
throw toDefiError(error, 'Failed to build staking transaction');
}
}

Expand All @@ -87,7 +87,7 @@ export class StakingManager extends DefiManager<StakingProviderInterface> implem
return await this.getProvider(providerId).getStakedBalance(userAddress, network);
} catch (error) {
log.error('Failed to get staking balance', { error, userAddress, network });
throw error;
throw toDefiError(error, 'Failed to get staking balance');
}
}

Expand All @@ -106,7 +106,7 @@ export class StakingManager extends DefiManager<StakingProviderInterface> implem
return await this.getProvider(providerId).getStakingProviderInfo(network);
} catch (error) {
log.error('Failed to get staking info', { error, network });
throw error;
throw toDefiError(error, 'Failed to get staking info');
}
}

Expand All @@ -125,15 +125,7 @@ export class StakingManager extends DefiManager<StakingProviderInterface> implem
return this.getProvider(providerId).getStakingProviderMetadata(network);
} catch (error) {
log.error('Failed to get staking metadata', { error, network });
throw error;
throw toDefiError(error, 'Failed to get staking metadata');
}
}

protected createError(message: string, code: string, details?: unknown): StakingError {
const errorCode = Object.values(StakingErrorCode).includes(code as StakingErrorCode)
? (code as StakingErrorCode)
: StakingErrorCode.InvalidParams;
log.error(message, { code, details });
return new StakingError(message, errorCode, details);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
import { globalLogger } from '../../../core/Logger';
import { StakingProvider } from '../StakingProvider';
import { StakingError, StakingErrorCode } from '../errors';
import { DefiError, DefiErrorCode } from '../../errors';
import type { TonStakersChainConfig, TonStakersProviderConfig } from './models/TonStakersProviderConfig';
import type { ProviderFactoryContext } from '../../../types/factory';
import type { NetworkManager } from '../../../core/NetworkManager';
Expand Down Expand Up @@ -109,8 +110,9 @@ export class TonStakersStakingProvider extends StakingProvider {
}

if (Object.keys(chainConfig).length === 0) {
throw new Error(
throw new DefiError(
'createTonstakersProvider: no eligible networks (add mainnet/testnet or pass metadata.contractAddress in overrides)',
DefiErrorCode.InvalidParams,
);
}

Expand Down Expand Up @@ -485,7 +487,7 @@ export class TonStakersStakingProvider extends StakingProvider {
const poolInfo = await client.getJson<{ pool: { apy: number } }>(`/v2/staking/pool/${address}`);

if (!poolInfo?.pool?.apy) {
throw new Error('Invalid APY data from TonAPI');
throw new StakingError('Invalid APY data from TonAPI', StakingErrorCode.InvalidParams);
}

return Number(poolInfo.pool.apy);
Expand Down
Loading
Loading