opnet
Version:
The perfect library for building Bitcoin-based applications.
828 lines (690 loc) • 30.9 kB
text/typescript
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { JSONRpcProvider } from '../src/providers/JSONRpcProvider.js';
import { networks } from '@btc-vision/bitcoin';
import { AddressTypes } from '@btc-vision/transaction';
import type { JsonRpcPayload } from '../src/providers/interfaces/JSONRpc.js';
import type { JsonRpcCallResult } from '../src/providers/interfaces/JSONRpcResult.js';
import type { MempoolInfo } from '../src/providers/interfaces/mempool/MempoolInfo.js';
import type { IMempoolTransactionData, IMempoolOPNetTransactionData } from '../src/providers/interfaces/mempool/MempoolTransactionData.js';
// ============================================================================
// Mock provider that intercepts _send
// ============================================================================
function createMockProvider(): JSONRpcProvider & {
mockSend: ReturnType<typeof vi.fn>;
} {
const provider = new JSONRpcProvider({
url: 'https://mock.opnet.org',
network: networks.regtest,
});
const mockSend = vi.fn<(payload: JsonRpcPayload | JsonRpcPayload[]) => Promise<JsonRpcCallResult>>();
// Override the internal _send
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(provider as any)._send = mockSend;
return Object.assign(provider, { mockSend });
}
// ============================================================================
// Unit Tests
// ============================================================================
describe('Mempool API - Unit Tests', () => {
let provider: ReturnType<typeof createMockProvider>;
beforeEach(() => {
provider = createMockProvider();
});
// ========================================================================
// getMempoolInfo
// ========================================================================
describe('getMempoolInfo', () => {
it('should return mempool info with count, opnetCount, and size', async () => {
const mockInfo: MempoolInfo = {
count: 42,
opnetCount: 5,
size: 123456,
};
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: mockInfo,
},
]);
const result = await provider.getMempoolInfo();
expect(result).toEqual(mockInfo);
expect(result.count).toBe(42);
expect(result.opnetCount).toBe(5);
expect(result.size).toBe(123456);
});
it('should call the correct JSON-RPC method', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: { count: 0, opnetCount: 0, size: 0 },
},
]);
await provider.getMempoolInfo();
expect(provider.mockSend).toHaveBeenCalledOnce();
const payload = provider.mockSend.mock.calls[0][0] as JsonRpcPayload;
expect(payload.method).toBe('btc_getMempoolInfo');
expect(payload.params).toEqual([]);
});
it('should throw on error response', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
error: { code: -32000, message: 'Internal error' },
},
]);
await expect(provider.getMempoolInfo()).rejects.toThrow('Error fetching mempool info');
});
it('should handle empty mempool', async () => {
const mockInfo: MempoolInfo = {
count: 0,
opnetCount: 0,
size: 0,
};
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: mockInfo,
},
]);
const result = await provider.getMempoolInfo();
expect(result.count).toBe(0);
expect(result.opnetCount).toBe(0);
expect(result.size).toBe(0);
});
it('should handle large mempool counts', async () => {
const mockInfo: MempoolInfo = {
count: 100000,
opnetCount: 50000,
size: 999999999,
};
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: mockInfo,
},
]);
const result = await provider.getMempoolInfo();
expect(result.count).toBe(100000);
expect(result.opnetCount).toBe(50000);
expect(result.size).toBe(999999999);
});
});
// ========================================================================
// getPendingTransaction
// ========================================================================
describe('getPendingTransaction', () => {
const mockTx: IMempoolOPNetTransactionData = {
id: 'abc123def456abc123def456abc123def456abc123def456abc123def456abc1',
firstSeen: '2023-11-14T22:13:20.000Z',
blockHeight: '0xcf080',
transactionType: 'Interaction',
theoreticalGasLimit: '0xf4240',
priorityFee: '0x1f4',
from: 'bcrt1psender...',
contractAddress: 'bcrt1pcontract...',
calldata: 'deadbeef',
psbt: false,
inputs: [
{
transactionId:
'def456abc123def456abc123def456abc123def456abc123def456abc123def4',
outputIndex: 0,
},
],
outputs: [
{
address: 'bcrt1qtest...',
outputIndex: 0,
value: '100000',
scriptPubKey: '0014abc123',
},
],
raw: 'deadbeef',
};
it('should return pending transaction data by hash', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: mockTx,
},
]);
const result = await provider.getPendingTransaction(mockTx.id);
expect(result).not.toBeNull();
expect(result!.id).toBe(mockTx.id);
expect(result!.transactionType).toBe('Interaction');
expect(result!.psbt).toBe(false);
expect(result!.inputs).toHaveLength(1);
expect(result!.outputs).toHaveLength(1);
});
it('should pass the hash as param', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: mockTx,
},
]);
const txHash = 'abc123def456abc123def456abc123def456abc123def456abc123def456abc1';
await provider.getPendingTransaction(txHash);
expect(provider.mockSend).toHaveBeenCalledOnce();
const payload = provider.mockSend.mock.calls[0][0] as JsonRpcPayload;
expect(payload.method).toBe('btc_getPendingTransaction');
expect(payload.params).toEqual([txHash]);
});
it('should return null when the node reports the transaction is not found', async () => {
// The node returns a JSON-RPC error (not a null result) for missing transactions.
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
error: {
code: -32000,
message: 'Pending transaction 0000000000000000000000000000000000000000000000000000000000000000 not found.',
},
},
]);
const result = await provider.getPendingTransaction(
'0000000000000000000000000000000000000000000000000000000000000000',
);
expect(result).toBeNull();
});
it('should throw on error response', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
error: { code: -32000, message: 'Internal error' },
},
]);
await expect(
provider.getPendingTransaction(
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
),
).rejects.toThrow('Error fetching pending transaction');
});
it('should throw for an invalid hash (too short)', async () => {
await expect(provider.getPendingTransaction('abc123')).rejects.toThrow(
'getPendingTransaction: expected a 64-character hex txid',
);
});
it('should throw for an empty hash', async () => {
await expect(provider.getPendingTransaction('')).rejects.toThrow(
'getPendingTransaction: expected a 64-character hex txid',
);
});
it('should throw for a hash with non-hex characters', async () => {
await expect(
provider.getPendingTransaction(
'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz',
),
).rejects.toThrow('getPendingTransaction: expected a 64-character hex txid');
});
it('should handle Generic transaction', async () => {
const nonOPNetTx: IMempoolTransactionData = {
id: mockTx.id,
firstSeen: mockTx.firstSeen,
blockHeight: mockTx.blockHeight,
transactionType: 'Generic',
psbt: mockTx.psbt,
inputs: mockTx.inputs,
outputs: mockTx.outputs,
raw: mockTx.raw,
};
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: nonOPNetTx,
},
]);
const result = await provider.getPendingTransaction(nonOPNetTx.id);
expect(result).not.toBeNull();
expect(result!.transactionType).toBe('Generic');
});
it('should handle PSBT transaction', async () => {
const psbtTx: IMempoolTransactionData = {
...mockTx,
psbt: true,
};
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: psbtTx,
},
]);
const result = await provider.getPendingTransaction(psbtTx.id);
expect(result).not.toBeNull();
expect(result!.psbt).toBe(true);
});
it('should handle transaction with multiple inputs and outputs', async () => {
const multiTx: IMempoolTransactionData = {
...mockTx,
inputs: [
{ transactionId: 'tx1', outputIndex: 0 },
{ transactionId: 'tx2', outputIndex: 1 },
{ transactionId: 'tx3', outputIndex: 2 },
],
outputs: [
{ address: 'addr1', outputIndex: 0, value: '50000', scriptPubKey: '001401' },
{ address: 'addr2', outputIndex: 1, value: '30000', scriptPubKey: '001402' },
{ address: null, outputIndex: 2, value: '0', scriptPubKey: '6a' },
],
};
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: multiTx,
},
]);
const result = await provider.getPendingTransaction(multiTx.id);
expect(result).not.toBeNull();
expect(result!.inputs).toHaveLength(3);
expect(result!.outputs).toHaveLength(3);
expect(result!.outputs[2].address).toBeNull();
});
});
// ========================================================================
// getLatestPendingTransactions
// ========================================================================
describe('getLatestPendingTransactions', () => {
const mockTx1: IMempoolOPNetTransactionData = {
id: 'a000000000000000000000000000000000000000000000000000000000000001',
firstSeen: '2023-11-14T22:13:20.000Z',
blockHeight: '0xcf080',
transactionType: 'Interaction',
theoreticalGasLimit: '0xf4240',
priorityFee: '0x1f4',
from: 'bcrt1psender...',
contractAddress: 'bcrt1pcontract...',
calldata: 'aabbccdd',
psbt: false,
inputs: [{ transactionId: 'input1', outputIndex: 0 }],
outputs: [
{ address: 'addr1', outputIndex: 0, value: '100000', scriptPubKey: '0014abc' },
],
raw: 'deadbeef01',
};
const mockTx2: IMempoolTransactionData = {
id: 'a000000000000000000000000000000000000000000000000000000000000002',
firstSeen: '2023-11-14T22:13:21.000Z',
blockHeight: '0xcf080',
transactionType: 'Generic',
psbt: false,
inputs: [{ transactionId: 'input2', outputIndex: 1 }],
outputs: [
{ address: 'addr2', outputIndex: 0, value: '200000', scriptPubKey: '0014def' },
],
raw: 'deadbeef02',
};
it('should return latest pending transactions with no filters', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: { transactions: [mockTx1, mockTx2] },
},
]);
const result = await provider.getLatestPendingTransactions();
expect(result).toHaveLength(2);
expect(result[0].id).toBe(mockTx1.id);
expect(result[1].id).toBe(mockTx2.id);
});
it('should pass correct params with no filters', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: { transactions: [] },
},
]);
await provider.getLatestPendingTransactions();
const payload = provider.mockSend.mock.calls[0][0] as JsonRpcPayload;
expect(payload.method).toBe('btc_getLatestPendingTransactions');
expect(payload.params).toEqual([null, null, null]);
});
it('should pass single address filter', async () => {
vi.spyOn(provider, 'validateAddress').mockReturnValue(AddressTypes.P2WPKH);
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: { transactions: [mockTx1] },
},
]);
await provider.getLatestPendingTransactions({ address: 'bcrt1qtest...' });
const payload = provider.mockSend.mock.calls[0][0] as JsonRpcPayload;
expect(payload.params).toEqual(['bcrt1qtest...', null, null]);
});
it('should pass limit parameter', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: { transactions: [mockTx1] },
},
]);
await provider.getLatestPendingTransactions({ limit: 10 });
const payload = provider.mockSend.mock.calls[0][0] as JsonRpcPayload;
expect(payload.params).toEqual([null, null, 10]);
});
it('should return empty array when no transactions found', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: { transactions: [] },
},
]);
const result = await provider.getLatestPendingTransactions();
expect(result).toEqual([]);
});
it('should throw on error response', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
error: { code: -32000, message: 'Internal error' },
},
]);
await expect(provider.getLatestPendingTransactions()).rejects.toThrow(
'Error fetching latest pending transactions',
);
});
it('should handle null transactions field gracefully', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: { transactions: null },
},
]);
const result = await provider.getLatestPendingTransactions();
expect(result).toEqual([]);
});
it('should handle many transactions', async () => {
const manyTxs: IMempoolTransactionData[] = Array.from({ length: 100 }, (_, i) => ({
id: `a${String(i).padStart(63, '0')}`,
firstSeen: new Date(1700000000000 + i * 1000).toISOString(),
blockHeight: '0xcf080',
transactionType: i % 2 === 0 ? 'Interaction' : 'Generic',
...(i % 2 === 0
? {
theoreticalGasLimit: '0xf4240',
priorityFee: `0x${(i * 100).toString(16)}`,
from: `bcrt1psender${i}`,
contractAddress: `bcrt1pcontract${i}`,
calldata: 'aa',
}
: {}),
psbt: false,
inputs: [{ transactionId: `input_${i}`, outputIndex: 0 }],
outputs: [
{
address: `addr_${i}`,
outputIndex: 0,
value: String(i * 1000),
scriptPubKey: '0014',
},
],
raw: `raw_${i}`,
}));
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: { transactions: manyTxs },
},
]);
const result = await provider.getLatestPendingTransactions();
expect(result).toHaveLength(100);
expect(result[0].transactionType).toBe('Interaction');
expect(result[1].transactionType).toBe('Generic');
});
it('should handle null result gracefully', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: null,
},
]);
const result = await provider.getLatestPendingTransactions();
expect(result).toEqual([]);
});
it('should throw for a malformed response shape', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: 42,
},
]);
await expect(provider.getLatestPendingTransactions()).rejects.toThrow(
'unexpected response shape',
);
});
it('should throw when transactions field is not an array', async () => {
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: { transactions: 'not-an-array' },
},
]);
await expect(provider.getLatestPendingTransactions()).rejects.toThrow(
'expected transactions to be an array',
);
});
it('should throw for an invalid address', async () => {
vi.spyOn(provider, 'validateAddress').mockReturnValue(null);
await expect(
provider.getLatestPendingTransactions({ address: 'not-a-valid-address' }),
).rejects.toThrow('getLatestPendingTransactions: invalid address');
});
it('should throw for a non-integer limit', async () => {
await expect(
provider.getLatestPendingTransactions({ limit: 3.7 }),
).rejects.toThrow('limit must be a positive integer');
});
it('should throw for a zero limit', async () => {
await expect(
provider.getLatestPendingTransactions({ limit: 0 }),
).rejects.toThrow('limit must be a positive integer');
});
it('should throw for a negative limit', async () => {
await expect(
provider.getLatestPendingTransactions({ limit: -1 }),
).rejects.toThrow('limit must be a positive integer');
});
});
// ========================================================================
// getLatestPendingTransactionsByAddresses
// ========================================================================
describe('getLatestPendingTransactionsByAddresses', () => {
const mockTx1: IMempoolOPNetTransactionData = {
id: 'a000000000000000000000000000000000000000000000000000000000000001',
firstSeen: '2023-11-14T22:13:20.000Z',
blockHeight: '0xcf080',
transactionType: 'Interaction',
theoreticalGasLimit: '0xf4240',
priorityFee: '0x1f4',
from: 'bcrt1psender...',
contractAddress: 'bcrt1pcontract...',
calldata: 'aabbccdd',
psbt: false,
inputs: [{ transactionId: 'input1', outputIndex: 0 }],
outputs: [
{ address: 'addr1', outputIndex: 0, value: '100000', scriptPubKey: '0014abc' },
],
raw: 'deadbeef01',
};
const mockTx2: IMempoolTransactionData = {
id: 'a000000000000000000000000000000000000000000000000000000000000002',
firstSeen: '2023-11-14T22:13:21.000Z',
blockHeight: '0xcf080',
transactionType: 'Generic',
psbt: false,
inputs: [{ transactionId: 'input2', outputIndex: 1 }],
outputs: [
{ address: 'addr2', outputIndex: 0, value: '200000', scriptPubKey: '0014def' },
],
raw: 'deadbeef02',
};
it('should pass addresses filter', async () => {
vi.spyOn(provider, 'validateAddress').mockReturnValue(AddressTypes.P2WPKH);
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: { transactions: [mockTx1, mockTx2] },
},
]);
await provider.getLatestPendingTransactionsByAddresses({ addresses: ['addr1', 'addr2'] });
const payload = provider.mockSend.mock.calls[0][0] as JsonRpcPayload;
expect(payload.params).toEqual([null, ['addr1', 'addr2'], null]);
});
it('should pass addresses and limit filter', async () => {
vi.spyOn(provider, 'validateAddress').mockReturnValue(AddressTypes.P2WPKH);
provider.mockSend.mockResolvedValue([
{
jsonrpc: '2.0',
id: 1,
result: { transactions: [mockTx1] },
},
]);
await provider.getLatestPendingTransactionsByAddresses({ addresses: ['addr1', 'addr2'], limit: 5 });
const payload = provider.mockSend.mock.calls[0][0] as JsonRpcPayload;
expect(payload.params).toEqual([null, ['addr1', 'addr2'], 5]);
});
it('should throw for an empty addresses array', async () => {
await expect(
provider.getLatestPendingTransactionsByAddresses({ addresses: [] }),
).rejects.toThrow('addresses array must not be empty');
});
it('should throw for an invalid address in the array', async () => {
vi.spyOn(provider, 'validateAddress').mockReturnValue(null);
await expect(
provider.getLatestPendingTransactionsByAddresses({ addresses: ['bad-address'] }),
).rejects.toThrow('getLatestPendingTransactionsByAddresses: invalid address');
});
it('should throw for a non-integer limit', async () => {
vi.spyOn(provider, 'validateAddress').mockReturnValue(AddressTypes.P2WPKH);
await expect(
provider.getLatestPendingTransactionsByAddresses({ addresses: ['addr1'], limit: 3.7 }),
).rejects.toThrow('limit must be a positive integer');
});
it('should throw for a zero limit', async () => {
vi.spyOn(provider, 'validateAddress').mockReturnValue(AddressTypes.P2WPKH);
await expect(
provider.getLatestPendingTransactionsByAddresses({ addresses: ['addr1'], limit: 0 }),
).rejects.toThrow('limit must be a positive integer');
});
it('should throw for a negative limit', async () => {
vi.spyOn(provider, 'validateAddress').mockReturnValue(AddressTypes.P2WPKH);
await expect(
provider.getLatestPendingTransactionsByAddresses({ addresses: ['addr1'], limit: -1 }),
).rejects.toThrow('limit must be a positive integer');
});
});
});
// ============================================================================
// Integration Tests - Real Regtest Network
// ============================================================================
describe('Mempool API - Integration Tests (testnet.opnet.org)', () => {
const TESTNET_URL = 'https://testnet.opnet.org';
let provider: JSONRpcProvider;
beforeEach(() => {
provider = new JSONRpcProvider({ url: TESTNET_URL, network: networks.opnetTestnet });
});
describe('getMempoolInfo', () => {
it('should fetch mempool info from regtest', async () => {
const info = await provider.getMempoolInfo();
expect(typeof info.count).toBe('number');
expect(typeof info.opnetCount).toBe('number');
expect(typeof info.size).toBe('number');
expect(info.count).toBeGreaterThanOrEqual(0);
expect(info.opnetCount).toBeGreaterThanOrEqual(0);
expect(info.size).toBeGreaterThanOrEqual(0);
expect(info.opnetCount).toBeLessThanOrEqual(info.count);
console.log(
`Mempool: ${info.count} txs (${info.opnetCount} OPNet), size: ${info.size}`,
);
}, 30000);
});
describe('getLatestPendingTransactions', () => {
it('should fetch latest pending transactions', async () => {
const txs = await provider.getLatestPendingTransactions();
expect(Array.isArray(txs)).toBe(true);
if (txs.length > 0) {
const tx = txs[0];
expect(typeof tx.id).toBe('string');
expect(tx.firstSeen).toBeDefined();
expect(tx.blockHeight).toBeDefined();
expect(typeof tx.transactionType).toBe('string');
expect(typeof tx.psbt).toBe('boolean');
expect(Array.isArray(tx.inputs)).toBe(true);
expect(Array.isArray(tx.outputs)).toBe(true);
expect(typeof tx.raw).toBe('string');
}
console.log(`Found ${txs.length} pending transactions`);
}, 30000);
it('should respect limit parameter', async () => {
const txs = await provider.getLatestPendingTransactions({ limit: 5 });
expect(Array.isArray(txs)).toBe(true);
expect(txs.length).toBeLessThanOrEqual(5);
console.log(`Fetched ${txs.length} pending transactions (limit 5)`);
}, 30000);
});
describe('getLatestPendingTransactionsByAddresses', () => {
it('should filter transactions by address', async () => {
const allTxs = await provider.getLatestPendingTransactions({ limit: 1 });
const addressableOutput = allTxs.flatMap((tx) => tx.outputs).find((o) => o.address !== null);
if (!addressableOutput?.address) {
console.log('No addressable outputs in pending transactions — skipping');
return;
}
const txs = await provider.getLatestPendingTransactionsByAddresses({
addresses: [addressableOutput.address],
});
expect(Array.isArray(txs)).toBe(true);
console.log(
`Found ${txs.length} transactions for address ${addressableOutput.address}`,
);
}, 30000);
});
describe('getPendingTransaction', () => {
it('should return null for non-existent transaction', async () => {
const fakeTxHash =
'0000000000000000000000000000000000000000000000000000000000000000';
const result = await provider.getPendingTransaction(fakeTxHash);
// Should be null since this tx doesn't exist
expect(result).toBeNull();
}, 30000);
it('should fetch a pending transaction if one exists', async () => {
// First get list of pending transactions
const txs = await provider.getLatestPendingTransactions({ limit: 1 });
if (txs.length === 0) {
console.log('No pending transactions to test getPendingTransaction');
return;
}
const txHash = txs[0].id;
const result = await provider.getPendingTransaction(txHash);
expect(result).not.toBeNull();
expect(result!.id).toBe(txHash);
expect(typeof result!.transactionType).toBe('string');
expect(Array.isArray(result!.inputs)).toBe(true);
expect(Array.isArray(result!.outputs)).toBe(true);
console.log(
`Fetched pending tx ${txHash.slice(0, 16)}... (type: ${result!.transactionType})`,
);
}, 30000);
});
});