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..657fff1f6c --- /dev/null +++ b/src/subdomains/core/payment-link/services/__tests__/payment-balance.service.spec.ts @@ -0,0 +1,105 @@ +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'] = 'SolTestDepositAddress'; + + const balances = await service.getPaymentBalances([solanaCoin()], true); + + expect(balances.get(1)).toEqual({ + 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)); + }); + }); +}); 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);