From 6229d41500a5b148517e0142fa62c26a0cca952b Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Mon, 13 Jul 2026 15:03:18 -0300 Subject: [PATCH 1/2] fix(payment): skip the Solana balance check when no Tatum API key is configured --- .../shared/util/blockchain-client.ts | 5 ++ .../blockchain/solana/solana-client.ts | 5 ++ .../__tests__/payment-balance.service.spec.ts | 86 +++++++++++++++++++ .../services/payment-balance.service.ts | 2 +- 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/subdomains/core/payment-link/services/__tests__/payment-balance.service.spec.ts diff --git a/src/integration/blockchain/shared/util/blockchain-client.ts b/src/integration/blockchain/shared/util/blockchain-client.ts index b0809a4834..fd63f3117d 100644 --- a/src/integration/blockchain/shared/util/blockchain-client.ts +++ b/src/integration/blockchain/shared/util/blockchain-client.ts @@ -20,6 +20,11 @@ export interface CoinOnly { } export abstract class BlockchainClient { + // clients whose provider needs optional config (e.g. an API key) override this + get isConfigured(): boolean { + return true; + } + abstract get walletAddress(): string; abstract getNativeCoinBalance(): Promise; abstract getNativeCoinBalanceForAddress(address: string): Promise; diff --git a/src/integration/blockchain/solana/solana-client.ts b/src/integration/blockchain/solana/solana-client.ts index 482166bc1d..c7f256fd20 100644 --- a/src/integration/blockchain/solana/solana-client.ts +++ b/src/integration/blockchain/solana/solana-client.ts @@ -49,6 +49,11 @@ export class SolanaClient extends BlockchainClient { return this.wallet.address; } + // without a Tatum API key the gateway serves the anonymous tier, which rejects getBalance + get isConfigured(): boolean { + return !!Config.blockchain.solana.solanaApiKey; + } + async getBlockHeight(): Promise { return this.connection.getBlockHeight(); } diff --git a/src/subdomains/core/payment-link/services/__tests__/payment-balance.service.spec.ts b/src/subdomains/core/payment-link/services/__tests__/payment-balance.service.spec.ts new file mode 100644 index 0000000000..30830c439d --- /dev/null +++ b/src/subdomains/core/payment-link/services/__tests__/payment-balance.service.spec.ts @@ -0,0 +1,86 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BitcoinFeeService } from 'src/integration/blockchain/bitcoin/services/bitcoin-fee.service'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { PaymentBalanceService } from '../payment-balance.service'; + +describe('PaymentBalanceService', () => { + let service: PaymentBalanceService; + + let assetService: AssetService; + let blockchainRegistryService: BlockchainRegistryService; + let bitcoinFeeService: BitcoinFeeService; + + beforeEach(async () => { + assetService = createMock(); + blockchainRegistryService = createMock(); + bitcoinFeeService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PaymentBalanceService, + { provide: AssetService, useValue: assetService }, + { provide: BlockchainRegistryService, useValue: blockchainRegistryService }, + { provide: BitcoinFeeService, useValue: bitcoinFeeService }, + TestUtil.provideConfig(), + ], + }).compile(); + + service = module.get(PaymentBalanceService); + }); + + afterEach(() => jest.restoreAllMocks()); + + const solanaCoin = () => + createCustomAsset({ + id: 1, + blockchain: Blockchain.SOLANA, + type: AssetType.COIN, + paymentEnabled: true, + chainId: 'So11111111111111111111111111111111111111112', + }); + + describe('#getPaymentBalances', () => { + it('skips an unconfigured client with a single warning and no error', async () => { + const warnSpy = jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation(() => undefined); + const errorSpy = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + const getBalance = jest.fn(); + jest + .spyOn(blockchainRegistryService, 'getClient') + .mockReturnValue({ isConfigured: false, getNativeCoinBalanceForAddress: getBalance } as any); + + const first = await service.getPaymentBalances([solanaCoin()], true); + const second = await service.getPaymentBalances([solanaCoin()], true); + + expect(first.size).toBe(0); + expect(second.size).toBe(0); + expect(getBalance).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Solana')); + }); + + it('fetches balances from a configured client', async () => { + jest.spyOn(blockchainRegistryService, 'getClient').mockReturnValue({ + isConfigured: true, + getNativeCoinBalanceForAddress: jest.fn().mockResolvedValue(1.5), + getTokenBalances: jest.fn().mockResolvedValue([]), + } as any); + service['solanaDepositAddress'] = 'DwS2fTestDepositAddress'; + + const balances = await service.getPaymentBalances([solanaCoin()], true); + + expect(balances.get(1)).toEqual({ + owner: 'DwS2fTestDepositAddress', + contractAddress: 'So11111111111111111111111111111111111111112', + balance: 1.5, + }); + }); + }); +}); diff --git a/src/subdomains/core/payment-link/services/payment-balance.service.ts b/src/subdomains/core/payment-link/services/payment-balance.service.ts index 186bf3cf23..7d1aec0b67 100644 --- a/src/subdomains/core/payment-link/services/payment-balance.service.ts +++ b/src/subdomains/core/payment-link/services/payment-balance.service.ts @@ -79,7 +79,7 @@ export class PaymentBalanceService implements OnModuleInit { await Promise.all( groupedAssets.map(async ([chain, assets]) => { const client = this.blockchainRegistryService.getClient(chain); - if (!client) { + if (!client || !client.isConfigured) { if (!this.unavailableWarningsLogged.has(chain)) { this.logger.warn(`Blockchain client not configured for ${chain} - skipping payment balance`); this.unavailableWarningsLogged.add(chain); From 25efafad86efc8f312ac8e988709979ec10d09b6 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Mon, 13 Jul 2026 15:30:24 -0300 Subject: [PATCH 2/2] test(payment): assert a failing configured client still surfaces an error --- .../__tests__/payment-balance.service.spec.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/subdomains/core/payment-link/services/__tests__/payment-balance.service.spec.ts b/src/subdomains/core/payment-link/services/__tests__/payment-balance.service.spec.ts index 30830c439d..657fff1f6c 100644 --- a/src/subdomains/core/payment-link/services/__tests__/payment-balance.service.spec.ts +++ b/src/subdomains/core/payment-link/services/__tests__/payment-balance.service.spec.ts @@ -72,15 +72,34 @@ describe('PaymentBalanceService', () => { getNativeCoinBalanceForAddress: jest.fn().mockResolvedValue(1.5), getTokenBalances: jest.fn().mockResolvedValue([]), } as any); - service['solanaDepositAddress'] = 'DwS2fTestDepositAddress'; + service['solanaDepositAddress'] = 'SolTestDepositAddress'; const balances = await service.getPaymentBalances([solanaCoin()], true); expect(balances.get(1)).toEqual({ - owner: 'DwS2fTestDepositAddress', + owner: 'SolTestDepositAddress', contractAddress: 'So11111111111111111111111111111111111111112', balance: 1.5, }); }); + + it('keeps a failing configured client visible as an error', async () => { + const warnSpy = jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation(() => undefined); + const errorSpy = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + jest.spyOn(blockchainRegistryService, 'getClient').mockReturnValue({ + isConfigured: true, + getNativeCoinBalanceForAddress: jest.fn().mockRejectedValue(new Error('getBalance rejected')), + } as any); + service['solanaDepositAddress'] = 'SolTestDepositAddress'; + + await expect(service.getPaymentBalances([solanaCoin()], false)).rejects.toThrow('getBalance rejected'); + + const balances = await service.getPaymentBalances([solanaCoin()], true); + + expect(balances.size).toBe(0); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Solana'), expect.any(Error)); + }); }); });