From 5223cca341bf8f3660b4919f0a6af7681a73fcc8 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Thu, 19 Jun 2025 22:22:00 -0700 Subject: [PATCH] Implement blockchain service --- .../__tests__/blockchainService.test.ts | 63 ++++++++++++ .../src/services/blockchainService.ts | 99 +++++++++++++++---- 2 files changed, 141 insertions(+), 21 deletions(-) create mode 100644 packages/ordinals-plus-api/src/services/__tests__/blockchainService.test.ts diff --git a/packages/ordinals-plus-api/src/services/__tests__/blockchainService.test.ts b/packages/ordinals-plus-api/src/services/__tests__/blockchainService.test.ts new file mode 100644 index 0000000..899bae9 --- /dev/null +++ b/packages/ordinals-plus-api/src/services/__tests__/blockchainService.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { getTransactionStatus, broadcastTransaction } from '../blockchainService'; + +let fetchSpy: any; + +beforeEach(() => { + // default network for tests + process.env.BITCOIN_NETWORK = 'testnet'; + fetchSpy = spyOn(globalThis, 'fetch'); +}); + +afterEach(() => { + fetchSpy.mockRestore(); +}); + +describe('getTransactionStatus', () => { + it('returns confirmed status when API confirms', async () => { + const txid = 'abc'; + fetchSpy.mockResolvedValue(new Response(JSON.stringify({ confirmed: true, block_height: 100 }), { status: 200 })); + + const result = await getTransactionStatus(txid); + expect(result).toEqual({ status: 'confirmed', blockHeight: 100 }); + expect(fetchSpy).toHaveBeenCalled(); + }); + + it('returns pending status when API not confirmed', async () => { + const txid = 'def'; + fetchSpy.mockResolvedValue(new Response(JSON.stringify({ confirmed: false }), { status: 200 })); + + const result = await getTransactionStatus(txid); + expect(result).toEqual({ status: 'pending' }); + }); + + it('returns not_found when API responds 404', async () => { + const txid = 'ghi'; + fetchSpy.mockResolvedValue(new Response('', { status: 404 })); + + const result = await getTransactionStatus(txid); + expect(result).toEqual({ status: 'not_found' }); + }); + + it('throws on network error', async () => { + const txid = 'err'; + fetchSpy.mockRejectedValue(new Error('network fail')); + await expect(getTransactionStatus(txid)).rejects.toThrow('Failed to fetch transaction status'); + }); +}); + +describe('broadcastTransaction', () => { + it('returns txid when broadcast succeeds', async () => { + const txHex = 'deadbeef'; + fetchSpy.mockResolvedValue(new Response('txid123', { status: 200 })); + const txid = await broadcastTransaction(txHex); + expect(txid).toBe('txid123'); + expect(fetchSpy).toHaveBeenCalled(); + }); + + it('throws when broadcast fails', async () => { + const txHex = 'deadbeef'; + fetchSpy.mockResolvedValue(new Response('error', { status: 500 })); + await expect(broadcastTransaction(txHex)).rejects.toThrow('Failed to broadcast transaction'); + }); +}); diff --git a/packages/ordinals-plus-api/src/services/blockchainService.ts b/packages/ordinals-plus-api/src/services/blockchainService.ts index 522273e..29d25da 100644 --- a/packages/ordinals-plus-api/src/services/blockchainService.ts +++ b/packages/ordinals-plus-api/src/services/blockchainService.ts @@ -1,24 +1,81 @@ -// Placeholder for blockchain interaction service - -import type { TransactionStatusResponse } from '../types'; - -export async function getTransactionStatus(txid: string): Promise { - // TODO: Implement transaction status check (e.g., call node RPC or block explorer API) - console.warn(`getTransactionStatus: Placeholder implementation for ${txid}`); - // Simulate different statuses based on txid for testing - if (txid.endsWith('pending')) { - return { status: 'pending' }; - } else if (txid.endsWith('confirmed')) { - return { status: 'confirmed', blockHeight: 800000, inscriptionId: `i${txid.substring(0, 10)}0` }; - } else if (txid.endsWith('failed')) { - return { status: 'failed' }; +import type { TransactionStatusResponse, NetworkType } from '../types'; +import fetchClient from '../utils/fetchUtils'; + +// Environment controlled network selection +const DEFAULT_NETWORK: NetworkType = + (process.env.BITCOIN_NETWORK as NetworkType) || 'mainnet'; + +const MEMPOOL_MAINNET_API_URL = + process.env.MEMPOOL_MAINNET_API_URL || 'https://mempool.space/api'; +const MEMPOOL_TESTNET_API_URL = + process.env.MEMPOOL_TESTNET_API_URL || 'https://mempool.space/testnet/api'; +const MEMPOOL_SIGNET_API_URL = + process.env.MEMPOOL_SIGNET_API_URL || 'https://mempool.space/signet/api'; + +const getMempoolApiUrl = (network: NetworkType): string => { + switch (network) { + case 'testnet': + return MEMPOOL_TESTNET_API_URL; + case 'signet': + return MEMPOOL_SIGNET_API_URL; + case 'mainnet': + default: + return MEMPOOL_MAINNET_API_URL; + } +}; + +export async function getTransactionStatus( + txid: string, + network: NetworkType = DEFAULT_NETWORK +): Promise { + const statusUrl = `${getMempoolApiUrl(network)}/tx/${txid}/status`; + try { + const response = await fetchClient.get(statusUrl); + + if (response.status === 404) { + return { status: 'not_found' }; + } + + const data = response.data as { + confirmed: boolean; + block_height?: number; + }; + + if (data.confirmed) { + return { status: 'confirmed', blockHeight: data.block_height }; } - return { status: 'not_found' }; + + return { status: 'pending' }; + } catch (error) { + console.error( + `[blockchainService] Failed to fetch transaction status from ${statusUrl}:`, + error + ); + throw new Error('Failed to fetch transaction status'); + } } -export async function broadcastTransaction(signedTxHex: string): Promise { - // TODO: Implement transaction broadcasting (e.g., via node RPC or API) - console.warn('broadcastTransaction: Placeholder implementation'); - // Return a dummy txid - return `tx_${Date.now()}_broadcasted`; -} \ No newline at end of file +export async function broadcastTransaction( + signedTxHex: string, + network: NetworkType = DEFAULT_NETWORK +): Promise { + const broadcastUrl = `${getMempoolApiUrl(network)}/tx`; + + try { + const response = await fetchClient.post(broadcastUrl, signedTxHex, { + headers: { 'Content-Type': 'text/plain' }, + responseType: 'text' + }); + + if (response.status >= 400 || typeof response.data !== 'string') { + throw new Error( + `Broadcast failed with status ${response.status}: ${response.data}` + ); + } + + return response.data.trim(); + } catch (error) { + console.error('[blockchainService] Transaction broadcast failed:', error); + throw new Error('Failed to broadcast transaction'); + } +}