diff --git a/src/contract/read.ts b/src/contract/read.ts index fc1118b..329ba84 100644 --- a/src/contract/read.ts +++ b/src/contract/read.ts @@ -14,9 +14,9 @@ export async function readContractState( const rpcUrl = SOROBAN_RPC_URLS[client.network]; const server = new rpc.Server(rpcUrl); const contract = new Contract(client.contractId); - const _operation = contract.call(method, ...(args as any[])); + contract.call(method, ...(args as any[])); const result = await server.simulateTransaction({ toEnvelope: () => ({ toXDR: () => '' }) as FakeEnvelope, - } as rpc.Api.Transaction); + } as any); return result; } diff --git a/src/contract/simulate.ts b/src/contract/simulate.ts index c963f62..dcc3b50 100644 --- a/src/contract/simulate.ts +++ b/src/contract/simulate.ts @@ -22,7 +22,7 @@ export async function simulateContractCall( try { const result = await server.simulateTransaction({ toEnvelope: () => ({ toXDR: () => xdr }) as FakeEnvelope, - } as rpc.Api.Transaction); + } as any); if (rpc.Api.isSimulationError(result)) { return { success: false, cost: { cpuInsns: '0', memBytes: '0' }, error: result.error }; } diff --git a/src/escrow/dispute.ts b/src/escrow/dispute.ts index 3ecc88d..a70644a 100644 --- a/src/escrow/dispute.ts +++ b/src/escrow/dispute.ts @@ -4,6 +4,7 @@ import { DisputeParams, SDKResult } from '../types/index'; import { TrustFlowError } from '../errors'; import { buildDisputeArgs } from '../contract/build'; import { createApiHttpClient, toApiErrorMessage } from '../utils/http'; +import { logger } from '../utils/logger'; /** * Raises a dispute directly against the TrustFlow contract. @@ -67,6 +68,7 @@ export class DisputeClient { const data = response.data; return { ok: true, data: { disputeId: data.id } }; } catch (e) { + logger.error('Failed to raise dispute', e); return { ok: false, error: toApiErrorMessage(e) }; } } @@ -81,6 +83,7 @@ export class DisputeClient { const response = await this.http.get(`/disputes/${escrowId}`); return { ok: true, data: response.data }; } catch (e) { + logger.error(`Failed to get dispute for escrow ${escrowId}`, e); return { ok: false, error: toApiErrorMessage(e) }; } } diff --git a/src/escrow/monitor.ts b/src/escrow/monitor.ts index ba1ee66..e8fb161 100644 --- a/src/escrow/monitor.ts +++ b/src/escrow/monitor.ts @@ -1,4 +1,5 @@ import { TrustFlowEvent, EventHandler } from '../types/events'; +import { logger } from '../utils/logger'; export class EscrowMonitor { private handlers = new Map>(); @@ -24,7 +25,7 @@ export class EscrowMonitor { const handlers = this.handlers.get(event.type) ?? new Set(); const wildcards = this.handlers.get('*') ?? new Set(); [...handlers, ...wildcards].forEach((h) => { - Promise.resolve(h(event)).catch(console.error); + Promise.resolve(h(event)).catch((err) => logger.error(String(err))); }); } }, intervalMs); diff --git a/tests/contract.test.ts b/tests/contract.test.ts new file mode 100644 index 0000000..415ae71 --- /dev/null +++ b/tests/contract.test.ts @@ -0,0 +1,182 @@ +import { rpc, Contract } from '@stellar/stellar-sdk'; +import { buildCreateEscrowArgs, buildReleaseArgs, buildDisputeArgs } from '../src/contract/build'; +import { invokeContract } from '../src/contract/invoke'; +import { readContractState } from '../src/contract/read'; +import { simulateContractCall } from '../src/contract/simulate'; +import type { TrustFlowClient } from '../src/client'; +import { TrustFlowError } from '../src/errors'; + +jest.mock('@stellar/stellar-sdk', () => { + const original = jest.requireActual('@stellar/stellar-sdk'); + return { + ...original, + rpc: { + ...original.rpc, + Server: jest.fn(), + Api: { + ...original.rpc.Api, + isSimulationError: jest.fn(), + }, + assembleTransaction: jest.fn(), + }, + Contract: jest.fn(), + Address: jest.fn().mockImplementation(() => ({ + toScVal: jest.fn().mockReturnValue('mock_scval') + })), + TransactionBuilder: jest.fn().mockImplementation(() => ({ + addOperation: jest.fn().mockReturnThis(), + setTimeout: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue('mock_tx'), + })), + BASE_FEE: '100', + }; +}); + +describe('contract module', () => { + const mockClient = { + network: 'testnet', + contractId: 'C...', + getNetworkPassphrase: jest.fn().mockReturnValue('Test SDF Network ; September 2015'), + } as unknown as TrustFlowClient; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('build.ts', () => { + it('buildCreateEscrowArgs returns valid arguments', () => { + const args = buildCreateEscrowArgs({ + sender: 'GBM...', + recipient: 'GBA...', + amountStroops: 1000n, + durationBlocks: 100, + }); + expect(args.length).toBe(4); + }); + + it('buildReleaseArgs returns valid arguments', () => { + const args = buildReleaseArgs('escrow1', 'GBM...'); + expect(args.length).toBe(2); + }); + + it('buildDisputeArgs returns valid arguments', () => { + const args = buildDisputeArgs('escrow1', 'fraud'); + expect(args.length).toBe(2); + }); + }); + + describe('invoke.ts', () => { + it('returns error if simulation fails', async () => { + (rpc.Api.isSimulationError as unknown as jest.Mock).mockReturnValue(true); + const mockServer = { + getAccount: jest.fn().mockResolvedValue({ accountId: () => 'GBM...', sequenceNumber: () => '1' }), + simulateTransaction: jest.fn().mockResolvedValue({ error: 'sim error' }), + }; + (rpc.Server as jest.Mock).mockImplementation(() => mockServer); + + const mockContract = { + call: jest.fn().mockReturnValue({}), + }; + (Contract as jest.Mock).mockImplementation(() => mockContract); + + const result = await invokeContract(mockClient, 'release', [], 'GBM...'); + expect(result.success).toBe(false); + }); + + it('returns success if simulation succeeds and no signAndSubmit provided', async () => { + (rpc.Api.isSimulationError as unknown as jest.Mock).mockReturnValue(false); + const mockServer = { + getAccount: jest.fn().mockResolvedValue({ accountId: () => 'GBM...', sequenceNumber: () => '1' }), + simulateTransaction: jest.fn().mockResolvedValue({ result: { retval: 'value' } }), + }; + (rpc.Server as jest.Mock).mockImplementation(() => mockServer); + + const mockContract = { + call: jest.fn().mockReturnValue({}), + }; + (Contract as jest.Mock).mockImplementation(() => mockContract); + + const result = await invokeContract(mockClient, 'release', [], 'GBM...'); + expect(result.success).toBe(true); + // @ts-ignore + expect(result.returnValue).toBe('value'); + }); + + it('signs and submits transaction if signAndSubmit is provided', async () => { + (rpc.Api.isSimulationError as unknown as jest.Mock).mockReturnValue(false); + const mockServer = { + getAccount: jest.fn().mockResolvedValue({ accountId: () => 'GBM...', sequenceNumber: () => '1' }), + simulateTransaction: jest.fn().mockResolvedValue({ result: { retval: 'value' } }), + }; + (rpc.Server as jest.Mock).mockImplementation(() => mockServer); + + const mockContract = { + call: jest.fn().mockReturnValue({}), + }; + (Contract as jest.Mock).mockImplementation(() => mockContract); + + (rpc.assembleTransaction as jest.Mock).mockReturnValue({ + build: jest.fn().mockReturnValue({ toXDR: () => 'xdr_string' }), + }); + + const signAndSubmit = jest.fn().mockResolvedValue('tx_hash'); + + const result = await invokeContract(mockClient, 'release', [], 'GBM...', signAndSubmit); + expect(result.success).toBe(true); + // @ts-ignore + expect(result.txHash).toBe('tx_hash'); + expect(signAndSubmit).toHaveBeenCalledWith('xdr_string'); + }); + }); + + describe('read.ts', () => { + it('readContractState calls simulateTransaction', async () => { + const mockServer = { + simulateTransaction: jest.fn().mockResolvedValue('read_result'), + }; + (rpc.Server as jest.Mock).mockImplementation(() => mockServer); + + const mockContract = { + call: jest.fn().mockReturnValue({}), + }; + (Contract as jest.Mock).mockImplementation(() => mockContract); + + const result = await readContractState(mockClient, 'get_state'); + expect(result).toBe('read_result'); + }); + }); + + describe('simulate.ts', () => { + it('returns failure object on simulation error', async () => { + (rpc.Api.isSimulationError as unknown as jest.Mock).mockReturnValue(true); + const mockServer = { + simulateTransaction: jest.fn().mockResolvedValue({ error: 'failed' }), + }; + (rpc.Server as jest.Mock).mockImplementation(() => mockServer); + + const result = await simulateContractCall(mockClient, 'xdr_string'); + expect(result.success).toBe(false); + expect(result.error).toBe('failed'); + }); + + it('returns success on valid simulation', async () => { + (rpc.Api.isSimulationError as unknown as jest.Mock).mockReturnValue(false); + const mockServer = { + simulateTransaction: jest.fn().mockResolvedValue({}), + }; + (rpc.Server as jest.Mock).mockImplementation(() => mockServer); + + const result = await simulateContractCall(mockClient, 'xdr_string'); + expect(result.success).toBe(true); + }); + + it('throws TrustFlowError on internal exception', async () => { + const mockServer = { + simulateTransaction: jest.fn().mockRejectedValue(new Error('Network error')), + }; + (rpc.Server as jest.Mock).mockImplementation(() => mockServer); + + await expect(simulateContractCall(mockClient, 'xdr_string')).rejects.toThrow(TrustFlowError); + }); + }); +}); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 16c945b..9719114 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -9,151 +9,6 @@ describe('format', () => { it('truncates long address', () => { expect(truncateAddress('GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ')).toContain('...'); }); }); -describe('SimpleCache', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - it('stores and retrieves values', () => { - const cache = new SimpleCache(1000); - cache.set('key', 42); - expect(cache.get('key')).toBe(42); - }); - - it('uses default TTL when no custom TTL is provided', () => { - const cache = new SimpleCache(1000); - cache.set('key', 42); - - jest.advanceTimersByTime(500); - expect(cache.get('key')).toBe(42); - - jest.advanceTimersByTime(600); - expect(cache.get('key')).toBeUndefined(); - }); - - it('uses custom TTL when provided', () => { - const cache = new SimpleCache(1000); - cache.set('key', 42, 500); - - jest.advanceTimersByTime(400); - expect(cache.get('key')).toBe(42); - - jest.advanceTimersByTime(150); - expect(cache.get('key')).toBeUndefined(); - }); - - it('lazily evicts expired entries on get', () => { - const cache = new SimpleCache(1000); - cache.set('key', 42); - expect(cache.size()).toBe(1); - - jest.advanceTimersByTime(1100); - expect(cache.get('key')).toBeUndefined(); - expect(cache.size()).toBe(0); - }); - - it('deletes keys explicitly', () => { - const cache = new SimpleCache(1000); - cache.set('key1', 1); - cache.set('key2', 2); - expect(cache.size()).toBe(2); - - cache.delete('key1'); - expect(cache.get('key1')).toBeUndefined(); - expect(cache.get('key2')).toBe(2); - expect(cache.size()).toBe(1); - }); - - it('clears all entries', () => { - const cache = new SimpleCache(1000); - cache.set('key1', 1); - cache.set('key2', 2); - cache.set('key3', 3); - expect(cache.size()).toBe(3); - - cache.clear(); - expect(cache.size()).toBe(0); - expect(cache.get('key1')).toBeUndefined(); - expect(cache.get('key2')).toBeUndefined(); - expect(cache.get('key3')).toBeUndefined(); - }); - - it('returns size correctly', () => { - const cache = new SimpleCache(1000); - expect(cache.size()).toBe(0); - - cache.set('a', 1); - expect(cache.size()).toBe(1); - - cache.set('b', 2); - cache.set('c', 3); - expect(cache.size()).toBe(3); - - cache.delete('b'); - expect(cache.size()).toBe(2); - }); - - it('handles multiple keys with different expiry times', () => { - const cache = new SimpleCache(1000); - cache.set('fast', 1, 100); - cache.set('medium', 2, 500); - cache.set('slow', 3, 1000); - - jest.advanceTimersByTime(150); - expect(cache.get('fast')).toBeUndefined(); - expect(cache.get('medium')).toBe(2); - expect(cache.get('slow')).toBe(3); - - jest.advanceTimersByTime(400); - expect(cache.get('medium')).toBeUndefined(); - expect(cache.get('slow')).toBe(3); - - jest.advanceTimersByTime(550); - expect(cache.get('slow')).toBeUndefined(); - }); - - it('allows re-setting expired keys', () => { - const cache = new SimpleCache(1000); - cache.set('key', 1); - - jest.advanceTimersByTime(1100); - expect(cache.get('key')).toBeUndefined(); - - cache.set('key', 2, 500); - expect(cache.get('key')).toBe(2); - - jest.advanceTimersByTime(300); - expect(cache.get('key')).toBe(2); - - jest.advanceTimersByTime(250); - expect(cache.get('key')).toBeUndefined(); - }); - - it('handles generic types correctly', () => { - const stringCache = new SimpleCache(1000); - stringCache.set('greeting', 'hello'); - expect(stringCache.get('greeting')).toBe('hello'); - - const objectCache = new SimpleCache(1000); - const obj = { id: 42, name: 'test' }; - objectCache.set('data', obj); - expect(objectCache.get('data')).toEqual(obj); - }); - - it('expires at exact boundary', () => { - const cache = new SimpleCache(1000); - cache.set('key', 42); - - // At exactly the expiry time (1000ms), should be expired since Date.now() > entry.expiresAt - jest.advanceTimersByTime(1000); - expect(cache.get('key')).toBeUndefined(); - }); -}); describe('retry', () => { it('resolves on first success', async () => { diff --git a/tests/wallet.test.ts b/tests/wallet.test.ts new file mode 100644 index 0000000..9368d71 --- /dev/null +++ b/tests/wallet.test.ts @@ -0,0 +1,120 @@ +import { connectWallet, disconnectWallet } from '../src/wallet/connect'; +import { getFreighter, isFreighterInstalled } from '../src/wallet/freighter'; +import { getAlbedo } from '../src/wallet/albedo'; +import { TrustFlowError } from '../src/errors'; + +describe('wallet module', () => { + const originalWindow = global.window; + + beforeEach(() => { + // Reset global window before each test + global.window = undefined as any; + }); + + afterAll(() => { + global.window = originalWindow; + }); + + describe('when window is undefined (Node environment)', () => { + it('getFreighter returns null', () => { + expect(getFreighter()).toBeNull(); + }); + + it('isFreighterInstalled returns false', async () => { + await expect(isFreighterInstalled()).resolves.toBe(false); + }); + + it('getAlbedo returns null', () => { + expect(getAlbedo()).toBeNull(); + }); + }); + + describe('when window is defined but wallets are not installed', () => { + beforeEach(() => { + global.window = {} as any; + }); + + it('getFreighter returns null', () => { + expect(getFreighter()).toBeNull(); + }); + + it('isFreighterInstalled returns false', async () => { + await expect(isFreighterInstalled()).resolves.toBe(false); + }); + + it('getAlbedo returns null', () => { + expect(getAlbedo()).toBeNull(); + }); + }); + + describe('when wallets are installed', () => { + const mockFreighter = { + getPublicKey: jest.fn().mockResolvedValue('GBM...'), + getNetwork: jest.fn().mockResolvedValue('TESTNET'), + }; + const mockAlbedo = { + publicKey: jest.fn().mockResolvedValue({ pubkey: 'GBA...' }), + }; + + beforeEach(() => { + global.window = { + freighter: mockFreighter, + albedo: mockAlbedo, + } as any; + }); + + it('getFreighter returns the freighter instance wrapper', () => { + const freighterWrapper = getFreighter(); + expect(freighterWrapper).toBeDefined(); + expect(freighterWrapper?.isAvailable()).toBe(true); + }); + + it('isFreighterInstalled returns true', async () => { + await expect(isFreighterInstalled()).resolves.toBe(true); + }); + + it('getAlbedo returns the albedo instance', () => { + expect(getAlbedo()).toBe(mockAlbedo); + }); + }); + + describe('connectWallet', () => { + beforeEach(() => { + global.window = undefined as any; + }); + + it('throws UNAUTHORIZED if freighter is not installed', async () => { + await expect(connectWallet('freighter')).rejects.toMatchObject( + new TrustFlowError('Freighter not installed', 'UNAUTHORIZED') + ); + }); + + it('throws UNAUTHORIZED if wallet type is unsupported', async () => { + // @ts-expect-error Testing invalid wallet type + await expect(connectWallet('unsupported')).rejects.toMatchObject( + new TrustFlowError('Wallet type unsupported not supported', 'UNAUTHORIZED') + ); + }); + + it('connects to freighter successfully when installed', async () => { + const mockFreighter = { + getPublicKey: jest.fn().mockResolvedValue('GBM...'), + getNetwork: jest.fn().mockResolvedValue('TESTNET'), + }; + global.window = { freighter: mockFreighter } as any; + + const connection = await connectWallet('freighter'); + expect(connection).toEqual({ + type: 'freighter', + publicKey: 'GBM...', + network: 'TESTNET', + }); + }); + }); + + describe('disconnectWallet', () => { + it('is a no-op that resolves successfully', async () => { + await expect(disconnectWallet()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 6d26e59..ac0200e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2022", "module": "CommonJS", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "declaration": true, "outDir": "dist", "declarationMap": true,