first commit of refactor
This commit is contained in:
61
backend/protocols/ethereum/EthereumObserver.js
Normal file
61
backend/protocols/ethereum/EthereumObserver.js
Normal file
@@ -0,0 +1,61 @@
|
||||
|
||||
/**
|
||||
* @typedef {import('../../domain/NormalizedTx.js').NormalizedTx} NormalizedTx
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} EthereumObserver
|
||||
* @property {() => Promise<number>} getLatestBlockNumber - Get the most recent block number on the chain
|
||||
* @property {(blockNumber: number) => Promise<NormalizedTx[]>} getBlockTransactions - Get all transactions in a specific block
|
||||
* @property {(address: string) => Promise<string>} getBalance - Get the current balance of an address (in Wei as string)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates that an object implements the EthereumObserver interface
|
||||
* @param {any} obj - Object to validate
|
||||
* @returns {boolean} True if object implements the interface
|
||||
*/
|
||||
export function isEthereumObserver(obj) {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof obj.getLatestBlockNumber === 'function' &&
|
||||
typeof obj.getBlockTransactions === 'function' &&
|
||||
typeof obj.getBalance === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that an object implements the EthereumObserver interface
|
||||
* Throws an error if validation fails
|
||||
* @param {any} obj - Object to validate
|
||||
* @param {string} [name='object'] - Name for error messages
|
||||
* @throws {Error} If object doesn't implement the interface
|
||||
*/
|
||||
export function assertEthereumObserver(obj, name = 'object') {
|
||||
if (!isEthereumObserver(obj)) {
|
||||
throw new Error(
|
||||
`${name} must implement EthereumObserver interface ` +
|
||||
`(getLatestBlockNumber, getBlockTransactions, getBalance)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage
|
||||
*
|
||||
* import { assertEthereumObserver } from './protocols/ethereum/EthereumObserver.js';
|
||||
*
|
||||
* export function createObserverService(ethereumObserver) {
|
||||
* assertEthereumObserver(ethereumObserver, 'ethereumObserver');
|
||||
* // ... use ethereumObserver safely
|
||||
* }
|
||||
*/
|
||||
|
||||
export default {
|
||||
isEthereumObserver,
|
||||
assertEthereumObserver,
|
||||
};
|
||||
|
||||
159
backend/protocols/ethereum/JsonRpcEthereum.js
Normal file
159
backend/protocols/ethereum/JsonRpcEthereum.js
Normal file
@@ -0,0 +1,159 @@
|
||||
|
||||
import { assertEthereumObserver } from './EthereumObserver.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('../../domain/NormalizedTx.js').NormalizedTx} NormalizedTx
|
||||
*/
|
||||
|
||||
const RPC_TIMEOUT_MS = 30000; // 30 seconds
|
||||
|
||||
/**
|
||||
* JsonRpcEthereum - Concrete implementation of EthereumObserver using JSON-RPC
|
||||
* @implements {EthereumObserver}
|
||||
*/
|
||||
export class JsonRpcEthereum {
|
||||
/**
|
||||
* @param {string} rpcUrl - Ethereum JSON-RPC endpoint URL
|
||||
*/
|
||||
constructor(rpcUrl) {
|
||||
if (!rpcUrl || typeof rpcUrl !== 'string') {
|
||||
throw new Error('JsonRpcEthereum requires a valid RPC URL');
|
||||
}
|
||||
this.rpcUrl = rpcUrl;
|
||||
}
|
||||
/**
|
||||
* Make a JSON-RPC call to the Ethereum node
|
||||
* @private
|
||||
* @param {string} method - RPC method name (e.g., 'eth_blockNumber')
|
||||
* @param {any[]} params - Method parameters
|
||||
* @returns {Promise<any>} RPC result
|
||||
* @throws {Error} If RPC call fails or returns an error
|
||||
*/
|
||||
async _callRpc(method, params = []) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(this.rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now(),
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${response.status}: ${response.statusText} for ${method}`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (payload.error) {
|
||||
throw new Error(
|
||||
`RPC Error [${method}]: ${payload.error.message} (code: ${payload.error.code})`
|
||||
);
|
||||
}
|
||||
|
||||
return payload.result;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error(`RPC timeout after ${RPC_TIMEOUT_MS}ms for ${method}`);
|
||||
}
|
||||
|
||||
throw new Error(`RPC call failed [${method}]: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest block number on the chain
|
||||
* @returns {Promise<number>} Current block number
|
||||
*/
|
||||
async getLatestBlockNumber() {
|
||||
const hexBlock = await this._callRpc('eth_blockNumber');
|
||||
return parseInt(hexBlock, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transactions in a specific block
|
||||
* @param {number} blockNumber - Block number to fetch
|
||||
* @returns {Promise<NormalizedTx[]>} Array of normalized transactions
|
||||
*/
|
||||
async getBlockTransactions(blockNumber) {
|
||||
const hexBlockNumber = '0x' + blockNumber.toString(16);
|
||||
|
||||
const block = await this._callRpc('eth_getBlockByNumber', [
|
||||
hexBlockNumber,
|
||||
true, // true = return full transaction objects, not just hashes
|
||||
]);
|
||||
|
||||
if (!block || !block.transactions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blockTimestamp = parseInt(block.timestamp, 16);
|
||||
|
||||
return block.transactions.map((tx) => ({
|
||||
hash: tx.hash,
|
||||
from: tx.from?.toLowerCase() || '',
|
||||
to: tx.to?.toLowerCase() || null, // null for contract creation
|
||||
value: this._hexToDecimalString(tx.value),
|
||||
blockNumber: parseInt(tx.blockNumber, 16),
|
||||
blockTimestamp: blockTimestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current balance of an address
|
||||
* @param {string} address - Ethereum address
|
||||
* @returns {Promise<string>} Balance in Wei as decimal string
|
||||
*/
|
||||
async getBalance(address) {
|
||||
const balanceHex = await this._callRpc('eth_getBalance', [
|
||||
address,
|
||||
'latest',
|
||||
]);
|
||||
|
||||
return this._hexToDecimalString(balanceHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to decimal string (for large numbers)
|
||||
* @private
|
||||
* @param {string} hexValue - Hex value (with or without 0x prefix)
|
||||
* @returns {string}
|
||||
*/
|
||||
_hexToDecimalString(hexValue) {
|
||||
if (!hexValue || hexValue === '0x' || hexValue === '0x0') {
|
||||
return '0';
|
||||
}
|
||||
|
||||
try {
|
||||
return BigInt(hexValue).toString();
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid hex value: ${hexValue}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} rpcUrl - Ethereum JSON-RPC endpoint URL
|
||||
* @returns {JsonRpcEthereum}
|
||||
*/
|
||||
export function createJsonRpcEthereum(rpcUrl) {
|
||||
const instance = new JsonRpcEthereum(rpcUrl);
|
||||
|
||||
// Validate that it implements the interface
|
||||
assertEthereumObserver(instance, 'JsonRpcEthereum');
|
||||
|
||||
return instance;
|
||||
}
|
||||
Reference in New Issue
Block a user