From 5ab5547bf31402f9bcae5fb4f19faa30023df5c7 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Mon, 10 Nov 2025 06:01:07 -0800 Subject: [PATCH 01/13] Adding code comments --- bin/server.js | 3 ++- src/config/env/common.js | 27 +++------------------------ 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/bin/server.js b/bin/server.js index cbc25e1..323db79 100644 --- a/bin/server.js +++ b/bin/server.js @@ -1,5 +1,5 @@ /* - Express server for REST2NOSTR Proxy API. + Express server for psf-bch-api REST API. The architecture of the code follows the Clean Architecture pattern. */ @@ -72,6 +72,7 @@ class Server { allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'] })) + // Wrap all endpoints in x402 middleware. This handles payments for the API calls. if (x402Settings.enabled) { const routes = buildX402Routes(this.config.apiPrefix) const facilitatorOptions = x402Settings.facilitatorUrl diff --git a/src/config/env/common.js b/src/config/env/common.js index 55ddf24..137fdd4 100644 --- a/src/config/env/common.js +++ b/src/config/env/common.js @@ -16,6 +16,7 @@ const pkgInfo = JSON.parse(readFileSync(`${__dirname.toString()}/../../../packag const version = pkgInfo.version +// This function is used to convert the string input of an environment variable to a boolean value. const normalizeBoolean = (value, defaultValue) => { if (value === undefined || value === null || value === '') return defaultValue @@ -25,6 +26,8 @@ const normalizeBoolean = (value, defaultValue) => { return defaultValue } +// By default, the price per API call is 2000 satoshis. +// But the user can override this value by setting the X402_PRICE_SAT environment variable. const parsedPriceSat = Number(process.env.X402_PRICE_SAT) const priceSat = Number.isFinite(parsedPriceSat) && parsedPriceSat > 0 ? parsedPriceSat : 2000 @@ -48,30 +51,6 @@ export default { // Logging level logLevel: process.env.LOG_LEVEL || 'info', - // Nostr relay configuration (array of relay URLs) - nostrRelayUrls: (() => { - // Support NOSTR_RELAY_URLS (plural) as comma-separated string or JSON array - if (process.env.NOSTR_RELAY_URLS) { - try { - // Try parsing as JSON array first - const parsed = JSON.parse(process.env.NOSTR_RELAY_URLS) - if (Array.isArray(parsed)) { - return parsed.filter(url => url && typeof url === 'string') - } - } catch (e) { - // Not JSON, treat as comma-separated string - return process.env.NOSTR_RELAY_URLS.split(',').map(url => url.trim()).filter(url => url.length > 0) - } - } - // Backward compatibility: support NOSTR_RELAY_URL (singular) - if (process.env.NOSTR_RELAY_URL) { - return [process.env.NOSTR_RELAY_URL] - } - - // Default - return ['wss://nostr-relay.psfoundation.info', 'wss://relay.damus.io'] - })(), - // Full node RPC configuration fullNode: { rpcBaseUrl: process.env.RPC_BASEURL || 'http://127.0.0.1:8332', From f2e61fde35688969dd9b02b29ba7efef33d344be Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 14 Nov 2025 05:03:15 -0800 Subject: [PATCH 02/13] Removing integration tests from old repository --- src/controllers/timer-controller.js | 2 +- test/integration/api/event-integration.js | 250 ------------------ test/integration/api/req-integration.js | 173 ------------ .../api/subscription-integration.js | 198 -------------- .../manage-subscription-integration.js | 163 ------------ .../use-cases/publish-event-integration.js | 104 -------- .../use-cases/query-events-integration.js | 95 ------- 7 files changed, 1 insertion(+), 984 deletions(-) delete mode 100644 test/integration/api/event-integration.js delete mode 100644 test/integration/api/req-integration.js delete mode 100644 test/integration/api/subscription-integration.js delete mode 100644 test/integration/use-cases/manage-subscription-integration.js delete mode 100644 test/integration/use-cases/publish-event-integration.js delete mode 100644 test/integration/use-cases/query-events-integration.js diff --git a/src/controllers/timer-controller.js b/src/controllers/timer-controller.js index 1c524eb..fa13c79 100644 --- a/src/controllers/timer-controller.js +++ b/src/controllers/timer-controller.js @@ -23,7 +23,7 @@ class TimerController { } // Constants - this.SHUTDOWN_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes in milliseconds + this.SHUTDOWN_INTERVAL_MS = 10 * 60 * 60 * 1000 // 10 hours in milliseconds this.LIVENESS_CHECK_INTERVAL_MS = 1 * 60 * 1000 // 1 minute in milliseconds // Handlers diff --git a/test/integration/api/event-integration.js b/test/integration/api/event-integration.js deleted file mode 100644 index 5d35a73..0000000 --- a/test/integration/api/event-integration.js +++ /dev/null @@ -1,250 +0,0 @@ -/* - Integration tests for POST /event endpoint. - These tests require a running Nostr relay. -*/ - -// npm libraries -import { assert } from 'chai' - -// Unit under test -import Server from '../../../bin/server.js' -import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools/pure' -import { hexToBytes } from '@noble/hashes/utils.js' - -describe('#event-integration.js', () => { - let server - const baseUrl = 'http://localhost:3001' // Use different port for tests - - before(async () => { - // Start test server - server = new Server() - server.config.port = 3001 - await server.startServer() - - // Wait for server to be ready - await new Promise(resolve => setTimeout(resolve, 1000)) - }) - - after(async () => { - // Stop server - if (server && server.server) { - await new Promise((resolve) => { - server.server.close(() => { - resolve() - }) - }) - } - }) - - describe('POST /event', () => { - it('should publish kind 0 event (profile metadata) - covers example 01', async () => { - // Generate keys - const sk = generateSecretKey() - - // Create profile metadata event (kind 0) - const profileMetadata = { - name: 'Test User', - about: 'Integration test user', - picture: 'https://example.com/test.jpg' - } - - const eventTemplate = { - kind: 0, - created_at: Math.floor(Date.now() / 1000), - tags: [], - content: JSON.stringify(profileMetadata) - } - - // Sign the event - const signedEvent = finalizeEvent(eventTemplate, sk) - - // Publish to REST API - const response = await fetch(`${baseUrl}/event`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(signedEvent) - }) - - const result = await response.json() - - // Assert response - assert.equal(response.status, 200) - assert.property(result, 'accepted') - assert.property(result, 'eventId') - assert.equal(result.eventId, signedEvent.id) - }) - - it('should publish kind 1 event (text post) - covers example 03', async () => { - // Alice's private key from examples - const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0' - const alicePrivKeyBin = hexToBytes(alicePrivKeyHex) - const alicePubKey = getPublicKey(alicePrivKeyBin) - - // Generate a post - const eventTemplate = { - kind: 1, - created_at: Math.floor(Date.now() / 1000), - tags: [], - content: 'Integration test post' - } - - // Sign the post - const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin) - - // Publish to REST API - const response = await fetch(`${baseUrl}/event`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(signedEvent) - }) - - const result = await response.json() - - // Assert response - assert.equal(response.status, 200) - assert.property(result, 'accepted') - assert.property(result, 'eventId') - assert.equal(result.eventId, signedEvent.id) - assert.equal(signedEvent.pubkey, alicePubKey) - }) - - it('should publish kind 3 event (follow list) - covers example 06', async () => { - // Alice's private key - const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0' - const alicePrivKeyBin = hexToBytes(alicePrivKeyHex) - - // Bob's public key - const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f' - const bobPrivKeyBin = hexToBytes(bobPrivKeyHex) - const bobPubKey = getPublicKey(bobPrivKeyBin) - - const psf = 'wss://nostr-relay.psfoundation.info' - - const followList = [ - ['p', bobPubKey, psf, 'bob'] - ] - - // Generate a follow list event (kind 3) - const eventTemplate = { - kind: 3, - created_at: Math.floor(Date.now() / 1000), - tags: followList, - content: '' - } - - // Sign the event - const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin) - - // Publish to REST API - const response = await fetch(`${baseUrl}/event`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(signedEvent) - }) - - const result = await response.json() - - // Assert response - assert.equal(response.status, 200) - assert.property(result, 'accepted') - assert.property(result, 'eventId') - assert.equal(result.eventId, signedEvent.id) - }) - - it('should publish kind 7 event (reaction/like) - covers example 07', async () => { - // Bob's private key - const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f' - const bobPrivKeyBin = hexToBytes(bobPrivKeyHex) - const bobPubKey = getPublicKey(bobPrivKeyBin) - - const psf = 'wss://nostr-relay.psfoundation.info' - - // Use a test event ID - const evIdToLike = 'd09b4c5da59be3cd2768aa53fa78b77bf4859084c94f3bf26d401f004a9c8167' - const evIdAuthorPubKey = '2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e92' - - // Generate like event (kind 7) - const likeEventTemplate = { - kind: 7, - created_at: Math.floor(Date.now() / 1000), - pubkey: bobPubKey, - tags: [ - ['e', evIdToLike, psf], - ['p', evIdAuthorPubKey, psf] - ], - content: '+' - } - - // Sign the event - const signedEvent = finalizeEvent(likeEventTemplate, bobPrivKeyBin) - - // Publish to REST API - const response = await fetch(`${baseUrl}/event`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(signedEvent) - }) - - const result = await response.json() - - // Assert response - assert.equal(response.status, 200) - assert.property(result, 'accepted') - assert.property(result, 'eventId') - assert.equal(result.eventId, signedEvent.id) - }) - - it('should reject invalid event', async () => { - const invalidEvent = { - id: 'invalid', - pubkey: 'invalid', - created_at: Math.floor(Date.now() / 1000), - kind: 1, - tags: [], - content: 'Test', - sig: 'invalid' - } - - const response = await fetch(`${baseUrl}/event`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(invalidEvent) - }) - - const result = await response.json() - - // Should reject invalid event - error response format - assert.equal(response.status, 400) - assert.property(result, 'error') - assert.include(result.error, 'Invalid event structure') - }) - - it('should return 400 when event data is missing', async () => { - // Send empty body - Express will parse as undefined, controller should handle it - const response = await fetch(`${baseUrl}/event`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: '' - }) - - // Empty body should be parsed as undefined by Express - const result = await response.json() - - assert.equal(response.status, 400) - assert.property(result, 'error') - assert.include(result.error, 'Event data is required') - }) - }) -}) diff --git a/test/integration/api/req-integration.js b/test/integration/api/req-integration.js deleted file mode 100644 index 5b53bbc..0000000 --- a/test/integration/api/req-integration.js +++ /dev/null @@ -1,173 +0,0 @@ -/* - Integration tests for GET /req/:subId endpoint. - These tests require a running Nostr relay. -*/ - -// npm libraries -import { assert } from 'chai' - -// Unit under test -import Server from '../../../bin/server.js' -import { getPublicKey } from 'nostr-tools/pure' -import { hexToBytes } from '@noble/hashes/utils.js' - -describe('#req-integration.js', () => { - let server - const baseUrl = 'http://localhost:3002' // Use different port for tests - - before(async () => { - // Start test server - server = new Server() - server.config.port = 3002 - await server.startServer() - - // Wait for server to be ready - await new Promise(resolve => setTimeout(resolve, 1000)) - }) - - after(async () => { - // Stop server - if (server && server.server) { - await new Promise((resolve) => { - server.server.close(() => { - resolve() - }) - }) - } - }) - - describe('GET /req/:subId', () => { - it('should query kind 1 events (posts) - covers examples 02, 04', async () => { - // JB55's public key from example 02 - const jb55 = '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' - - // Create subscription ID - const subId = 'read-posts-' + Date.now() - - // Create filters - read posts from JB55 - const filters = { - limit: 2, - kinds: [1], - authors: [jb55] - } - - // Query events using GET /req/:subId - const filtersJson = encodeURIComponent(JSON.stringify([filters])) - const url = `${baseUrl}/req/${subId}?filters=${filtersJson}` - - const response = await fetch(url) - const events = await response.json() - - // Assert response - assert.equal(response.status, 200) - assert.isArray(events) - // May be empty if no events exist, but structure should be correct - if (events.length > 0) { - assert.property(events[0], 'id') - assert.property(events[0], 'pubkey') - assert.property(events[0], 'created_at') - assert.property(events[0], 'kind') - assert.property(events[0], 'content') - assert.equal(events[0].kind, 1) - } - }) - - it('should query Alice posts - covers example 04', async () => { - // Alice's public key - const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0' - const alicePrivKeyBin = hexToBytes(alicePrivKeyHex) - const alicePubKey = getPublicKey(alicePrivKeyBin) - - // Create subscription ID - const subId = 'read-alice-posts-' + Date.now() - - // Create filters - read posts from Alice - const filters = { - limit: 2, - kinds: [1], - authors: [alicePubKey] - } - - // Query events using GET /req/:subId - const filtersJson = encodeURIComponent(JSON.stringify([filters])) - const url = `${baseUrl}/req/${subId}?filters=${filtersJson}` - - const response = await fetch(url) - const events = await response.json() - - // Assert response - assert.equal(response.status, 200) - assert.isArray(events) - if (events.length > 0) { - assert.equal(events[0].pubkey, alicePubKey) - assert.equal(events[0].kind, 1) - } - }) - - it('should query kind 3 events (follow list) - covers example 05', async () => { - // Alice's public key - const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0' - const alicePrivKeyBin = hexToBytes(alicePrivKeyHex) - const alicePubKey = getPublicKey(alicePrivKeyBin) - - // Create subscription ID - const subId = 'get-follow-list-' + Date.now() - - // Create filters - get follow list (kind 3) from Alice - const filters = { - limit: 5, - kinds: [3], - authors: [alicePubKey] - } - - // Query events using GET /req/:subId - const filtersJson = encodeURIComponent(JSON.stringify([filters])) - const url = `${baseUrl}/req/${subId}?filters=${filtersJson}` - - const response = await fetch(url) - const events = await response.json() - - // Assert response - assert.equal(response.status, 200) - assert.isArray(events) - if (events.length > 0) { - assert.equal(events[0].kind, 3) - assert.equal(events[0].pubkey, alicePubKey) - assert.isArray(events[0].tags) - } - }) - - it('should handle filters as individual query params', async () => { - const subId = 'test-sub-' + Date.now() - const url = `${baseUrl}/req/${subId}?kinds=[1]&limit=10` - - const response = await fetch(url) - const events = await response.json() - - assert.equal(response.status, 200) - assert.isArray(events) - }) - - it('should return 400 when subscription ID is missing', async () => { - const url = `${baseUrl}/req/?filters=${encodeURIComponent(JSON.stringify([{ kinds: [1] }]))}` - - const response = await fetch(url) - await response.json() - - // Should return 404 or 400 - assert.isAtLeast(response.status, 400) - }) - - it('should return 400 when filters JSON is invalid', async () => { - const subId = 'test-sub-' + Date.now() - const url = `${baseUrl}/req/${subId}?filters=invalid-json{` - - const response = await fetch(url) - const result = await response.json() - - assert.equal(response.status, 400) - assert.property(result, 'error') - assert.include(result.error, 'Invalid filters JSON') - }) - }) -}) diff --git a/test/integration/api/subscription-integration.js b/test/integration/api/subscription-integration.js deleted file mode 100644 index 3c1e743..0000000 --- a/test/integration/api/subscription-integration.js +++ /dev/null @@ -1,198 +0,0 @@ -/* - Integration tests for POST /req/:subId SSE subscription and DELETE /req/:subId. - These tests require a running Nostr relay. -*/ - -// npm libraries -import { assert } from 'chai' - -// Unit under test -import Server from '../../../bin/server.js' - -describe('#subscription-integration.js', () => { - let server - const baseUrl = 'http://localhost:3003' // Use different port for tests - - before(async () => { - // Start test server - server = new Server() - server.config.port = 3003 - await server.startServer() - - // Wait for server to be ready - await new Promise(resolve => setTimeout(resolve, 1000)) - }) - - after(async function () { - this.timeout(10000) // Increase timeout for cleanup - // Stop server - if (server && server.server) { - // Close all connections forcefully if available - if (server.server.closeAllConnections) { - server.server.closeAllConnections() - } - - await new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve() // Force resolve after 2 seconds - }, 2000) - - server.server.close(() => { - clearTimeout(timeout) - resolve() - }) - }) - } - }) - - describe('POST /req/:subId', () => { - it('should create SSE subscription', async () => { - const subId = 'test-sub-' + Date.now() - const filters = { kinds: [1], limit: 10 } - - const response = await fetch(`${baseUrl}/req/${subId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(filters) - }) - - // Assert SSE headers - assert.equal(response.headers.get('content-type'), 'text/event-stream') - assert.equal(response.headers.get('cache-control'), 'no-cache') - assert.equal(response.headers.get('connection'), 'keep-alive') - - // Read initial connection message - const reader = response.body.getReader() - const decoder = new TextDecoder() - - try { - const { value } = await reader.read() - const text = decoder.decode(value) - assert.include(text, 'connected') - assert.include(text, subId) - } finally { - reader.releaseLock() - } - }) - - it('should return 400 when subscription ID is missing', async () => { - const filters = { kinds: [1] } - - const response = await fetch(`${baseUrl}/req/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(filters) - }) - - // Should return 404 or 400 - assert.isAtLeast(response.status, 400) - }) - - it('should return 400 when filters are missing', async () => { - const subId = 'test-sub-' + Date.now() - - const response = await fetch(`${baseUrl}/req/${subId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({}) - }) - - const result = await response.json() - - assert.equal(response.status, 400) - assert.property(result, 'error') - assert.include(result.error, 'Filters are required') - }) - }) - - describe('DELETE /req/:subId', () => { - it('should close a subscription', async () => { - const subId = 'test-sub-' + Date.now() - - const response = await fetch(`${baseUrl}/req/${subId}`, { - method: 'DELETE' - }) - - // May return 200 if subscription exists, or 500 if it doesn't - // The important thing is it doesn't crash - assert.isAtMost(response.status, 500) - }) - - it('should return 400 when subscription ID is missing', async () => { - const response = await fetch(`${baseUrl}/req/`, { - method: 'DELETE' - }) - - // Should return 404 or 400 - assert.isAtLeast(response.status, 400) - }) - }) - - describe('PUT /req/:subId', () => { - it('should create SSE subscription (alternative method)', async function () { - this.timeout(10000) // Increase timeout for this test - - const subId = 'test-sub-' + Date.now() - const filters = { kinds: [1], limit: 10 } - - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 8000) - - let response - try { - response = await fetch(`${baseUrl}/req/${subId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(filters), - signal: controller.signal - }) - - // Assert SSE headers - assert.equal(response.headers.get('content-type'), 'text/event-stream') - - clearTimeout(timeoutId) - - // Consume the stream to prevent hanging - const reader = response.body.getReader() - const decoder = new TextDecoder() - - try { - // Read initial connection message with timeout - const readPromise = reader.read() - const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve({ value: null, done: true }), 2000)) - const { value, done } = await Promise.race([readPromise, timeoutPromise]) - - if (value && !done) { - const text = decoder.decode(value) - assert.include(text, 'connected') - } - } finally { - reader.releaseLock() - } - } catch (err) { - // AbortController aborted - this is expected - if (err.name !== 'AbortError') { - throw err - } - } finally { - clearTimeout(timeoutId) - // Always close the subscription - try { - await fetch(`${baseUrl}/req/${subId}`, { - method: 'DELETE' - }) - } catch (err) { - // Ignore errors when closing - } - } - }) - }) -}) diff --git a/test/integration/use-cases/manage-subscription-integration.js b/test/integration/use-cases/manage-subscription-integration.js deleted file mode 100644 index b2a883e..0000000 --- a/test/integration/use-cases/manage-subscription-integration.js +++ /dev/null @@ -1,163 +0,0 @@ -/* - Integration tests for ManageSubscriptionUseCase with real adapter. - These tests require a running Nostr relay. -*/ - -// npm libraries -import { assert } from 'chai' - -// Unit under test -import Adapters from '../../../src/adapters/index.js' -import ManageSubscriptionUseCase from '../../../src/use-cases/manage-subscription.js' - -describe('#manage-subscription-integration.js', () => { - let adapters - let uut - - before(async () => { - // Initialize adapters (will connect to real relay) - adapters = new Adapters() - await adapters.start() - - uut = new ManageSubscriptionUseCase({ adapters }) - }) - - after(async () => { - // Clean up all subscriptions and disconnect from all relays - // Note: This is a simplified cleanup - in production you'd track all subscriptions - if (adapters && adapters.nostrRelays) { - await Promise.allSettled( - adapters.nostrRelays.map(relay => relay.disconnect()) - ) - } - }) - - describe('#createSubscription()', () => { - it('should successfully create a subscription', async () => { - const subscriptionId = 'test-sub-' + Date.now() - const filters = [{ kinds: [1], limit: 5 }] - - let eventReceived = false - let eoseReceived = false - - const onEvent = (event) => { - eventReceived = true - assert.property(event, 'id') - assert.property(event, 'kind') - } - - const onEose = () => { - eoseReceived = true - } - - const onClosed = () => { - // Handler for closed events - } - - await uut.createSubscription(subscriptionId, filters, onEvent, onEose, onClosed) - - // Assert subscription exists - assert.isTrue(uut.hasSubscription(subscriptionId)) - - // Wait a bit for events/EOSE - await new Promise(resolve => setTimeout(resolve, 2000)) - - // EOSE should be received (or events) - // Note: May not receive events if none exist, but EOSE should come - assert.isTrue(eoseReceived || eventReceived) - - // Clean up - if (uut.hasSubscription(subscriptionId)) { - await uut.closeSubscription(subscriptionId) - } - }) - - it('should prevent duplicate subscriptions', async () => { - const subscriptionId = 'test-dup-' + Date.now() - const filters = [{ kinds: [1] }] - - await uut.createSubscription(subscriptionId, filters) - - try { - await uut.createSubscription(subscriptionId, filters) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'already exists') - } - - // Clean up - if (uut.hasSubscription(subscriptionId)) { - await uut.closeSubscription(subscriptionId) - } - }) - - it('should handle subscription with no events', async () => { - const subscriptionId = 'test-empty-' + Date.now() - const filters = [{ kinds: [99999], limit: 1 }] // Unlikely to have events - - let eoseReceived = false - - const onEose = () => { - eoseReceived = true - } - - await uut.createSubscription(subscriptionId, filters, null, onEose, null) - - // Wait for EOSE - await new Promise(resolve => setTimeout(resolve, 2000)) - - // Should receive EOSE even with no events - assert.isTrue(eoseReceived) - - // Clean up - if (uut.hasSubscription(subscriptionId)) { - await uut.closeSubscription(subscriptionId) - } - }) - }) - - describe('#closeSubscription()', () => { - it('should successfully close a subscription', async () => { - const subscriptionId = 'test-close-' + Date.now() - const filters = [{ kinds: [1] }] - - await uut.createSubscription(subscriptionId, filters) - assert.isTrue(uut.hasSubscription(subscriptionId)) - - await uut.closeSubscription(subscriptionId) - - // Assert subscription is removed - assert.isFalse(uut.hasSubscription(subscriptionId)) - }) - - it('should return successfully when closing non-existent subscription (idempotent)', async () => { - const subscriptionId = 'non-existent-sub' - - // Should not throw - idempotent operation - await uut.closeSubscription(subscriptionId) - - // Should return successfully without error - assert.isTrue(true, 'closeSubscription should succeed for non-existent subscription') - }) - }) - - describe('#hasSubscription()', () => { - it('should return false for non-existent subscription', () => { - assert.isFalse(uut.hasSubscription('non-existent')) - }) - - it('should return true for existing subscription', async () => { - const subscriptionId = 'test-has-' + Date.now() - const filters = [{ kinds: [1] }] - - assert.isFalse(uut.hasSubscription(subscriptionId)) - await uut.createSubscription(subscriptionId, filters) - assert.isTrue(uut.hasSubscription(subscriptionId)) - - // Clean up - if (uut.hasSubscription(subscriptionId)) { - await uut.closeSubscription(subscriptionId) - } - }) - }) -}) diff --git a/test/integration/use-cases/publish-event-integration.js b/test/integration/use-cases/publish-event-integration.js deleted file mode 100644 index 228ad68..0000000 --- a/test/integration/use-cases/publish-event-integration.js +++ /dev/null @@ -1,104 +0,0 @@ -/* - Integration tests for PublishEventUseCase with real adapter. - These tests require a running Nostr relay. -*/ - -// npm libraries -import { assert } from 'chai' - -// Unit under test -import Adapters from '../../../src/adapters/index.js' -import PublishEventUseCase from '../../../src/use-cases/publish-event.js' -import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure' - -describe('#publish-event-integration.js', () => { - let adapters - let uut - - before(async () => { - // Initialize adapters (will connect to real relay) - adapters = new Adapters() - await adapters.start() - - uut = new PublishEventUseCase({ adapters }) - }) - - after(async () => { - // Clean up adapters - disconnect from all relays - if (adapters && adapters.nostrRelays) { - await Promise.allSettled( - adapters.nostrRelays.map(relay => relay.disconnect()) - ) - } - }) - - describe('#execute()', () => { - it('should successfully publish a valid event', async () => { - // Generate keys - const sk = generateSecretKey() - - // Create event template - const eventTemplate = { - kind: 1, - created_at: Math.floor(Date.now() / 1000), - tags: [], - content: 'Integration test post from use case' - } - - // Sign the event - const signedEvent = finalizeEvent(eventTemplate, sk) - - // Execute use case - const result = await uut.execute(signedEvent) - - // Assert result - assert.property(result, 'accepted') - assert.property(result, 'message') - assert.property(result, 'eventId') - assert.equal(result.eventId, signedEvent.id) - }) - - it('should reject invalid event structure', async () => { - const invalidEvent = { - id: 'invalid', - pubkey: 'invalid', - created_at: Math.floor(Date.now() / 1000), - kind: 1, - tags: [], - content: 'Test', - sig: 'invalid' - } - - try { - await uut.execute(invalidEvent) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'Invalid event structure') - } - }) - - it('should handle relay rejection', async () => { - // Generate keys - const sk = generateSecretKey() - - // Create a duplicate event (if we send same event twice) - const eventTemplate = { - kind: 1, - created_at: Math.floor(Date.now() / 1000), - tags: [], - content: 'Duplicate test post' - } - - const signedEvent = finalizeEvent(eventTemplate, sk) - - // Publish first time - const result1 = await uut.execute(signedEvent) - assert.property(result1, 'accepted') - - // Try to publish again (may be rejected as duplicate) - const result2 = await uut.execute(signedEvent) - assert.property(result2, 'accepted') - // Result may be accepted or rejected depending on relay - }) - }) -}) diff --git a/test/integration/use-cases/query-events-integration.js b/test/integration/use-cases/query-events-integration.js deleted file mode 100644 index 07702ca..0000000 --- a/test/integration/use-cases/query-events-integration.js +++ /dev/null @@ -1,95 +0,0 @@ -/* - Integration tests for QueryEventsUseCase with real adapter. - These tests require a running Nostr relay. -*/ - -// npm libraries -import { assert } from 'chai' - -// Unit under test -import Adapters from '../../../src/adapters/index.js' -import QueryEventsUseCase from '../../../src/use-cases/query-events.js' - -describe('#query-events-integration.js', () => { - let adapters - let uut - - before(async () => { - // Initialize adapters (will connect to real relay) - adapters = new Adapters() - await adapters.start() - - uut = new QueryEventsUseCase({ adapters }) - }) - - after(async () => { - // Clean up adapters - disconnect from all relays - if (adapters && adapters.nostrRelays) { - await Promise.allSettled( - adapters.nostrRelays.map(relay => relay.disconnect()) - ) - } - }) - - describe('#execute()', () => { - it('should successfully query events', async () => { - const filters = [{ kinds: [1], limit: 5 }] - const subscriptionId = 'test-query-' + Date.now() - - const events = await uut.execute(filters, subscriptionId) - - // Assert result is an array - assert.isArray(events) - - // If events are returned, verify structure - if (events.length > 0) { - assert.property(events[0], 'id') - assert.property(events[0], 'pubkey') - assert.property(events[0], 'created_at') - assert.property(events[0], 'kind') - assert.property(events[0], 'content') - assert.equal(events[0].kind, 1) - } - }) - - it('should handle empty results', async () => { - // Query for events that likely don't exist - const filters = [{ kinds: [99999], limit: 1 }] - const subscriptionId = 'test-empty-' + Date.now() - - const events = await uut.execute(filters, subscriptionId) - - // Should return empty array, not throw - assert.isArray(events) - assert.equal(events.length, 0) - }) - - it('should handle multiple filters', async function () { - // Increase timeout for this test - needs to be longer than use case timeout (30s) - this.timeout(35000) - - const filters = [ - { kinds: [1], limit: 2 }, - { kinds: [3], limit: 2 } - ] - const subscriptionId = 'test-multi-' + Date.now() - - const events = await uut.execute(filters, subscriptionId) - - // Should return array (may be empty) - assert.isArray(events) - }) - - it('should timeout if EOSE not received', async () => { - // This test may take up to 30 seconds - // Use a filter that might not return EOSE quickly - const filters = [{ kinds: [1] }] // No limit, might timeout - const subscriptionId = 'test-timeout-' + Date.now() - - // Should eventually return (even if empty) - const events = await uut.execute(filters, subscriptionId) - - assert.isArray(events) - }) - }) -}) From 4f893357689e768ad4285fec3a50905810856ec7 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 14 Nov 2025 05:22:59 -0800 Subject: [PATCH 03/13] feat(mining): Ported mining endpoints from bch-api --- .../rest-api/full-node/mining/controller.js | 99 +++++++++++++ .../rest-api/full-node/mining/index.js | 52 +++++++ src/controllers/rest-api/index.js | 4 + src/use-cases/full-node-mining-use-cases.js | 28 ++++ src/use-cases/index.js | 2 + .../controllers/mining-controller-unit.js | 139 ++++++++++++++++++ test/unit/controllers/rest-api-index-unit.js | 8 + .../full-node-mining-use-cases-unit.js | 84 +++++++++++ 8 files changed, 416 insertions(+) create mode 100644 src/controllers/rest-api/full-node/mining/controller.js create mode 100644 src/controllers/rest-api/full-node/mining/index.js create mode 100644 src/use-cases/full-node-mining-use-cases.js create mode 100644 test/unit/controllers/mining-controller-unit.js create mode 100644 test/unit/use-cases/full-node-mining-use-cases-unit.js diff --git a/src/controllers/rest-api/full-node/mining/controller.js b/src/controllers/rest-api/full-node/mining/controller.js new file mode 100644 index 0000000..bc98baa --- /dev/null +++ b/src/controllers/rest-api/full-node/mining/controller.js @@ -0,0 +1,99 @@ +/* + REST API Controller for the /full-node/mining routes. +*/ + +import wlogger from '../../../../adapters/wlogger.js' + +class MiningRESTController { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Mining REST Controller.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases || !this.useCases.mining) { + throw new Error( + 'Instance of Mining use cases required when instantiating Mining REST Controller.' + ) + } + + this.miningUseCases = this.useCases.mining + + // Bind functions + this.root = this.root.bind(this) + this.getMiningInfo = this.getMiningInfo.bind(this) + this.getNetworkHashPS = this.getNetworkHashPS.bind(this) + this.handleError = this.handleError.bind(this) + } + + /** + * @api {get} /v6/full-node/mining/ Service status + * @apiName MiningRoot + * @apiGroup Mining + * + * @apiDescription Returns the status of the mining service. + * + * @apiSuccess {String} status Service identifier + */ + async root (req, res) { + return res.status(200).json({ status: 'mining' }) + } + + /** + * @api {get} /v6/full-node/mining/getMiningInfo Get Mining Info + * @apiName GetMiningInfo + * @apiGroup Mining + * @apiDescription Returns a json object containing mining-related information. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v6/full-node/mining/getMiningInfo" -H "accept: application/json" + */ + async getMiningInfo (req, res) { + try { + const result = await this.miningUseCases.getMiningInfo() + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/mining/getNetworkHashPS Get Estimated network hashes per second + * @apiName GetNetworkHashPS + * @apiGroup Mining + * @apiDescription Returns the estimated network hashes per second based on the last n blocks. Pass in [nblocks] to override # of blocks, -1 specifies since last difficulty change. Pass in [height] to estimate the network speed at the time when a certain block was found. + * + * @apiParam {Number} nblocks Number of blocks to use for estimation (default: 120) + * @apiParam {Number} height Block height to estimate at (default: -1) + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v6/full-node/mining/getNetworkHashPS?nblocks=120&height=-1" -H "accept: application/json" + */ + async getNetworkHashPS (req, res) { + try { + let nblocks = 120 // Default + let height = -1 // Default + if (req.query.nblocks) nblocks = parseInt(req.query.nblocks) + if (req.query.height) height = parseInt(req.query.height) + + const result = await this.miningUseCases.getNetworkHashPS({ nblocks, height }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + handleError (err, res) { + wlogger.error('Error in MiningRESTController:', err) + + const status = err.status || 500 + const message = err.message || 'Internal server error' + + return res.status(status).json({ error: message }) + } +} + +export default MiningRESTController diff --git a/src/controllers/rest-api/full-node/mining/index.js b/src/controllers/rest-api/full-node/mining/index.js new file mode 100644 index 0000000..a71aead --- /dev/null +++ b/src/controllers/rest-api/full-node/mining/index.js @@ -0,0 +1,52 @@ +/* + REST API router for /full-node/mining routes. +*/ + +import express from 'express' +import MiningRESTController from './controller.js' + +class MiningRouter { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Mining REST Router.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases) { + throw new Error( + 'Instance of Use Cases library required when instantiating Mining REST Router.' + ) + } + + const dependencies = { + adapters: this.adapters, + useCases: this.useCases + } + + this.miningController = new MiningRESTController(dependencies) + + this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '') + this.baseUrl = `${this.apiPrefix}/full-node/mining` + if (!this.baseUrl.startsWith('/')) { + this.baseUrl = `/${this.baseUrl}` + } + this.router = express.Router() + } + + attach (app) { + if (!app) { + throw new Error('Must pass app object when attaching REST API controllers.') + } + + this.router.get('/', this.miningController.root) + this.router.get('/getMiningInfo', this.miningController.getMiningInfo) + this.router.get('/getNetworkHashPS', this.miningController.getNetworkHashPS) + + app.use(this.baseUrl, this.router) + } +} + +export default MiningRouter diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index a6d4397..67239d7 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -10,6 +10,7 @@ import BlockchainRouter from './full-node/blockchain/index.js' import ControlRouter from './full-node/control/index.js' import DSProofRouter from './full-node/dsproof/index.js' +import MiningRouter from './full-node/mining/index.js' import config from '../../config/index.js' class RESTControllers { @@ -64,6 +65,9 @@ class RESTControllers { const dsproofRouter = new DSProofRouter(dependencies) dsproofRouter.attach(app) + + const miningRouter = new MiningRouter(dependencies) + miningRouter.attach(app) } } diff --git a/src/use-cases/full-node-mining-use-cases.js b/src/use-cases/full-node-mining-use-cases.js new file mode 100644 index 0000000..f2deba8 --- /dev/null +++ b/src/use-cases/full-node-mining-use-cases.js @@ -0,0 +1,28 @@ +/* + Use cases for interacting with the BCH full node mining RPC interface. +*/ + +class MiningUseCases { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + + if (!this.adapters) { + throw new Error('Adapters instance required when instantiating Mining use cases.') + } + + this.fullNode = this.adapters.fullNode + if (!this.fullNode) { + throw new Error('Full node adapter required when instantiating Mining use cases.') + } + } + + async getMiningInfo () { + return this.fullNode.call('getmininginfo') + } + + async getNetworkHashPS ({ nblocks, height }) { + return this.fullNode.call('getnetworkhashps', [nblocks, height]) + } +} + +export default MiningUseCases diff --git a/src/use-cases/index.js b/src/use-cases/index.js index d3441a9..229a783 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -8,6 +8,7 @@ import BlockchainUseCases from './full-node-blockchain-use-cases.js' import ControlUseCases from './full-node-control-use-cases.js' import DSProofUseCases from './full-node-dsproof-use-cases.js' +import MiningUseCases from './full-node-mining-use-cases.js' class UseCases { constructor (localConfig = {}) { @@ -21,6 +22,7 @@ class UseCases { this.blockchain = new BlockchainUseCases({ adapters: this.adapters }) this.control = new ControlUseCases({ adapters: this.adapters }) this.dsproof = new DSProofUseCases({ adapters: this.adapters }) + this.mining = new MiningUseCases({ adapters: this.adapters }) } // Run any startup Use Cases at the start of the app. diff --git a/test/unit/controllers/mining-controller-unit.js b/test/unit/controllers/mining-controller-unit.js new file mode 100644 index 0000000..bdb4df3 --- /dev/null +++ b/test/unit/controllers/mining-controller-unit.js @@ -0,0 +1,139 @@ +/* + Unit tests for MiningRESTController. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' + +import MiningRESTController from '../../../src/controllers/rest-api/full-node/mining/controller.js' +import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js' + +describe('#mining-controller.js', () => { + let sandbox + let mockAdapters + let mockUseCases + let uut + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = {} + mockUseCases = { + mining: { + getMiningInfo: sandbox.stub().resolves({ blocks: 100, difficulty: 1.5 }), + getNetworkHashPS: sandbox.stub().resolves(1234567890) + } + } + + uut = new MiningRESTController({ + adapters: mockAdapters, + useCases: mockUseCases + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new MiningRESTController({ useCases: mockUseCases }) + }, /Adapters library required/) + }) + + it('should require mining use cases', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new MiningRESTController({ adapters: mockAdapters, useCases: {} }) + }, /Mining use cases required/) + }) + }) + + describe('#root()', () => { + it('should return mining status', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.root(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { status: 'mining' }) + }) + }) + + describe('#getMiningInfo()', () => { + it('should return mining info on success', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.getMiningInfo(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { blocks: 100, difficulty: 1.5 }) + assert.isTrue(mockUseCases.mining.getMiningInfo.calledOnce) + }) + + it('should handle errors via handleError', async () => { + const error = new Error('failure') + error.status = 503 + mockUseCases.mining.getMiningInfo.rejects(error) + const req = createMockRequest() + const res = createMockResponse() + + await uut.getMiningInfo(req, res) + + assert.equal(res.statusValue, 503) + assert.deepEqual(res.jsonData, { error: 'failure' }) + }) + }) + + describe('#getNetworkHashPS()', () => { + it('should return network hash PS with default params', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.getNetworkHashPS(req, res) + + assert.equal(res.statusValue, 200) + assert.equal(res.jsonData, 1234567890) + assert.isTrue(mockUseCases.mining.getNetworkHashPS.calledOnce) + assert.deepEqual(mockUseCases.mining.getNetworkHashPS.firstCall.args[0], { + nblocks: 120, + height: -1 + }) + }) + + it('should parse query params for nblocks and height', async () => { + const req = createMockRequest({ + query: { + nblocks: '240', + height: '1000' + } + }) + const res = createMockResponse() + + await uut.getNetworkHashPS(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.mining.getNetworkHashPS.calledOnce) + assert.deepEqual(mockUseCases.mining.getNetworkHashPS.firstCall.args[0], { + nblocks: 240, + height: 1000 + }) + }) + + it('should handle errors via handleError', async () => { + const error = new Error('RPC error') + error.status = 500 + mockUseCases.mining.getNetworkHashPS.rejects(error) + const req = createMockRequest() + const res = createMockResponse() + + await uut.getNetworkHashPS(req, res) + + assert.equal(res.statusValue, 500) + assert.deepEqual(res.jsonData, { error: 'RPC error' }) + }) + }) +}) diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js index 4b4a7f6..00dc9d9 100644 --- a/test/unit/controllers/rest-api-index-unit.js +++ b/test/unit/controllers/rest-api-index-unit.js @@ -9,6 +9,7 @@ import RESTControllers from '../../../src/controllers/rest-api/index.js' import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockchain/index.js' import ControlRouter from '../../../src/controllers/rest-api/full-node/control/index.js' import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/index.js' +import MiningRouter from '../../../src/controllers/rest-api/full-node/mining/index.js' describe('#controllers/rest-api/index.js', () => { let sandbox @@ -51,6 +52,10 @@ describe('#controllers/rest-api/index.js', () => { }, dsproof: { getDSProof: () => {} + }, + mining: { + getMiningInfo: () => {}, + getNetworkHashPS: () => {} } } }) @@ -80,6 +85,7 @@ describe('#controllers/rest-api/index.js', () => { const blockchainAttachStub = sandbox.stub(BlockchainRouter.prototype, 'attach') const controlAttachStub = sandbox.stub(ControlRouter.prototype, 'attach') const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach') + const miningAttachStub = sandbox.stub(MiningRouter.prototype, 'attach') const restControllers = new RESTControllers({ adapters: mockAdapters, useCases: mockUseCases @@ -94,6 +100,8 @@ describe('#controllers/rest-api/index.js', () => { assert.equal(controlAttachStub.getCall(0).args[0], app) assert.isTrue(dsproofAttachStub.calledOnce) assert.equal(dsproofAttachStub.getCall(0).args[0], app) + assert.isTrue(miningAttachStub.calledOnce) + assert.equal(miningAttachStub.getCall(0).args[0], app) }) }) }) diff --git a/test/unit/use-cases/full-node-mining-use-cases-unit.js b/test/unit/use-cases/full-node-mining-use-cases-unit.js new file mode 100644 index 0000000..815d946 --- /dev/null +++ b/test/unit/use-cases/full-node-mining-use-cases-unit.js @@ -0,0 +1,84 @@ +/* + Unit tests for MiningUseCases. +*/ + +import { assert } from 'chai' + +import MiningUseCases from '../../../src/use-cases/full-node-mining-use-cases.js' + +describe('#full-node-mining-use-cases.js', () => { + let mockAdapters + let uut + + beforeEach(() => { + mockAdapters = { + fullNode: { + call: async () => ({}) + } + } + + uut = new MiningUseCases({ adapters: mockAdapters }) + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new MiningUseCases() + }, /Adapters instance required/) + }) + + it('should require full node adapter', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new MiningUseCases({ adapters: {} }) + }, /Full node adapter required/) + }) + }) + + describe('#getMiningInfo()', () => { + it('should call full node adapter with correct method', async () => { + let capturedMethod = '' + mockAdapters.fullNode.call = async method => { + capturedMethod = method + return { blocks: 100, difficulty: 1.5 } + } + + const result = await uut.getMiningInfo() + + assert.equal(capturedMethod, 'getmininginfo') + assert.deepEqual(result, { blocks: 100, difficulty: 1.5 }) + }) + }) + + describe('#getNetworkHashPS()', () => { + it('should call full node adapter with correct method and default params', async () => { + let capturedMethod = '' + let capturedParams = [] + mockAdapters.fullNode.call = async (method, params) => { + capturedMethod = method + capturedParams = params + return 1234567890 + } + + const result = await uut.getNetworkHashPS({ nblocks: 120, height: -1 }) + + assert.equal(capturedMethod, 'getnetworkhashps') + assert.deepEqual(capturedParams, [120, -1]) + assert.equal(result, 1234567890) + }) + + it('should call full node adapter with custom params', async () => { + let capturedParams = [] + mockAdapters.fullNode.call = async (method, params) => { + capturedParams = params + return 9876543210 + } + + const result = await uut.getNetworkHashPS({ nblocks: 240, height: 1000 }) + + assert.deepEqual(capturedParams, [240, 1000]) + assert.equal(result, 9876543210) + }) + }) +}) From e5c8fa9321bc88ff0efe8b9ef3688ef6e23bda50 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 14 Nov 2025 06:28:15 -0800 Subject: [PATCH 04/13] feat(rawtransactions): Adding full node raw transaction endpoints --- src/adapters/full-node-rpc.js | 8 +- .../full-node/blockchain/controller.js | 8 +- .../full-node/rawtransactions/controller.js | 333 +++++++++++++++ .../full-node/rawtransactions/index.js | 58 +++ src/controllers/rest-api/index.js | 4 + .../full-node-rawtransactions-use-cases.js | 121 ++++++ src/use-cases/index.js | 2 + .../controllers/blockchain-controller-unit.js | 5 +- .../rawtransactions-controller-unit.js | 388 ++++++++++++++++++ test/unit/controllers/rest-api-index-unit.js | 15 + ...ull-node-rawtransactions-use-cases-unit.js | 267 ++++++++++++ 11 files changed, 1196 insertions(+), 13 deletions(-) create mode 100644 src/controllers/rest-api/full-node/rawtransactions/controller.js create mode 100644 src/controllers/rest-api/full-node/rawtransactions/index.js create mode 100644 src/use-cases/full-node-rawtransactions-use-cases.js create mode 100644 test/unit/controllers/rawtransactions-controller-unit.js create mode 100644 test/unit/use-cases/full-node-rawtransactions-use-cases-unit.js diff --git a/src/adapters/full-node-rpc.js b/src/adapters/full-node-rpc.js index 89709fd..3704f8e 100644 --- a/src/adapters/full-node-rpc.js +++ b/src/adapters/full-node-rpc.js @@ -113,12 +113,8 @@ class FullNodeRPCAdapter { } } - validateArraySize (length, options = {}) { - const { isProUser = false } = options - const freemiumLimit = Number(this.config.fullNode?.freemiumArrayLimit || 20) - const proLimit = Number(this.config.fullNode?.proArrayLimit || freemiumLimit) - - const limit = isProUser ? proLimit : freemiumLimit + validateArraySize (length) { + const limit = 20 return length <= limit } diff --git a/src/controllers/rest-api/full-node/blockchain/controller.js b/src/controllers/rest-api/full-node/blockchain/controller.js index 90c8e99..78080d5 100644 --- a/src/controllers/rest-api/full-node/blockchain/controller.js +++ b/src/controllers/rest-api/full-node/blockchain/controller.js @@ -155,7 +155,7 @@ class BlockchainRESTController { }) } - if (!this.adapters.fullNode.validateArraySize(hashes.length, { isProUser: Boolean(req.locals?.proLimit) })) { + if (!this.adapters.fullNode.validateArraySize(hashes.length)) { return res.status(400).json({ error: 'Array too large.' }) } @@ -238,7 +238,7 @@ class BlockchainRESTController { }) } - if (!this.adapters.fullNode.validateArraySize(txids.length, { isProUser: Boolean(req.locals?.proLimit) })) { + if (!this.adapters.fullNode.validateArraySize(txids.length)) { return res.status(400).json({ error: 'Array too large.' }) } @@ -415,7 +415,7 @@ class BlockchainRESTController { }) } - if (!this.adapters.fullNode.validateArraySize(txids.length, { isProUser: Boolean(req.locals?.proLimit) })) { + if (!this.adapters.fullNode.validateArraySize(txids.length)) { return res.status(400).json({ error: 'Array too large.' }) } @@ -470,7 +470,7 @@ class BlockchainRESTController { }) } - if (!this.adapters.fullNode.validateArraySize(proofs.length, { isProUser: Boolean(req.locals?.proLimit) })) { + if (!this.adapters.fullNode.validateArraySize(proofs.length)) { return res.status(400).json({ error: 'Array too large.' }) } diff --git a/src/controllers/rest-api/full-node/rawtransactions/controller.js b/src/controllers/rest-api/full-node/rawtransactions/controller.js new file mode 100644 index 0000000..26b59bc --- /dev/null +++ b/src/controllers/rest-api/full-node/rawtransactions/controller.js @@ -0,0 +1,333 @@ +/* + REST API Controller for the /full-node/rawtransactions routes. +*/ + +import wlogger from '../../../../adapters/wlogger.js' + +class RawTransactionsRESTController { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating RawTransactions REST Controller.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases || !this.useCases.rawtransactions) { + throw new Error( + 'Instance of RawTransactions use cases required when instantiating RawTransactions REST Controller.' + ) + } + + this.rawtransactionsUseCases = this.useCases.rawtransactions + + // Bind functions + this.root = this.root.bind(this) + this.decodeRawTransactionSingle = this.decodeRawTransactionSingle.bind(this) + this.decodeRawTransactionBulk = this.decodeRawTransactionBulk.bind(this) + this.decodeScriptSingle = this.decodeScriptSingle.bind(this) + this.decodeScriptBulk = this.decodeScriptBulk.bind(this) + this.getRawTransactionSingle = this.getRawTransactionSingle.bind(this) + this.getRawTransactionBulk = this.getRawTransactionBulk.bind(this) + this.sendRawTransactionSingle = this.sendRawTransactionSingle.bind(this) + this.sendRawTransactionBulk = this.sendRawTransactionBulk.bind(this) + this.handleError = this.handleError.bind(this) + } + + /** + * @api {get} /v6/full-node/rawtransactions/ Service status + * @apiName RawTransactionsRoot + * @apiGroup RawTransactions + * + * @apiDescription Returns the status of the rawtransactions service. + * + * @apiSuccess {String} status Service identifier + */ + async root (req, res) { + return res.status(200).json({ status: 'rawtransactions' }) + } + + /** + * @api {get} /v6/full-node/rawtransactions/decodeRawTransaction/:hex Decode Single Raw Transaction + * @apiName DecodeSingleRawTransaction + * @apiGroup RawTransactions + * @apiDescription Return a JSON object representing the serialized, hex-encoded transaction. + * + * @apiParam {String} hex Hex-encoded transaction + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v6/full-node/rawtransactions/decodeRawTransaction/02000000010e991f7ccec410f27d333f737f149b5d3be6728687da81072e638aed0063a176010000006b483045022100cd20443b0af090053450bc4ab00d563d4ac5955bb36e0135b00b8a96a19f233302205047f2c70a08c6ef4b76f2d198b33a31d17edfaa7e1e9e865894da0d396009354121024d4e7f522f67105b7bf5f9dbe557e7b2244613fdfcd6fe09304f93877328f6beffffffff02a0860100000000001976a9140ee020c07f39526ac5505c54fa1ab98490979b8388acb5f0f70b000000001976a9143a9b2b0c12fe722fcf653b6ef5dcc38732d6ff5188ac00000000" -H "accept: application/json" + */ + async decodeRawTransactionSingle (req, res) { + try { + const hex = req.params.hex + + if (!hex || hex === '') { + return res.status(400).json({ error: 'hex can not be empty' }) + } + + const result = await this.rawtransactionsUseCases.decodeRawTransaction({ hex }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/rawtransactions/decodeRawTransaction Decode Bulk Raw Transactions + * @apiName DecodeBulkRawTransactions + * @apiGroup RawTransactions + * @apiDescription Return bulk hex encoded transaction. + * + * @apiParam {String[]} hexes Array of hex-encoded transactions + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v6/full-node/rawtransactions/decodeRawTransaction" -H "accept: application/json" -H "Content-Type: application/json" -d '{"hexes":["01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000"]}' + */ + async decodeRawTransactionBulk (req, res) { + try { + const hexes = req.body.hexes + + if (!Array.isArray(hexes)) { + return res.status(400).json({ error: 'hexes must be an array' }) + } + + if (!this.adapters.fullNode.validateArraySize(hexes.length)) { + return res.status(400).json({ error: 'Array too large.' }) + } + + // Validate each element in the array + for (const hex of hexes) { + if (!hex || hex === '') { + return res.status(400).json({ error: 'Encountered empty hex' }) + } + } + + const result = await this.rawtransactionsUseCases.decodeRawTransactions({ hexes }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/rawtransactions/decodeScript/:hex Decode Single Script + * @apiName DecodeSingleScript + * @apiGroup RawTransactions + * @apiDescription Decode a hex-encoded script. + * + * @apiParam {String} hex Hex-encoded script + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v6/full-node/rawtransactions/decodeScript/4830450221009a51e00ec3524a7389592bc27bea4af5104a59510f5f0cfafa64bbd5c164ca2e02206c2a8bbb47eabdeed52f17d7df668d521600286406930426e3a9415fe10ed592012102e6e1423f7abde8b70bca3e78a7d030e5efabd3eb35c19302542b5fe7879c1a16" -H "accept: application/json" + */ + async decodeScriptSingle (req, res) { + try { + const hex = req.params.hex + + if (!hex || hex === '') { + return res.status(400).json({ error: 'hex can not be empty' }) + } + + const result = await this.rawtransactionsUseCases.decodeScript({ hex }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/rawtransactions/decodeScript Bulk Decode Script + * @apiName DecodeBulkScript + * @apiGroup RawTransactions + * @apiDescription Decode multiple hex-encoded scripts. + * + * @apiParam {String[]} hexes Array of hex-encoded scripts + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v6/full-node/rawtransactions/decodeScript" -H "accept: application/json" -H "Content-Type: application/json" -d '{"hexes":["01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000"]}' + */ + async decodeScriptBulk (req, res) { + try { + const hexes = req.body.hexes + + if (!Array.isArray(hexes)) { + return res.status(400).json({ error: 'hexes must be an array' }) + } + + if (!this.adapters.fullNode.validateArraySize(hexes.length)) { + return res.status(400).json({ error: 'Array too large.' }) + } + + // Validate each hex in the array + for (const hex of hexes) { + if (!hex || hex === '') { + return res.status(400).json({ error: 'Encountered empty hex' }) + } + } + + const result = await this.rawtransactionsUseCases.decodeScripts({ hexes }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/rawtransactions/getRawTransaction/:txid Get Raw Transaction + * @apiName GetRawTransaction + * @apiGroup RawTransactions + * @apiDescription Return the raw transaction data. If verbose is 'true', returns an Object with information about 'txid'. If verbose is 'false' or omitted, returns a string that is serialized, hex-encoded data for 'txid'. + * + * @apiParam {String} txid Transaction ID + * @apiParam {Boolean} verbose Return verbose data (default false) + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v6/full-node/rawtransactions/getRawTransaction/fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33?verbose=true" -H "accept: application/json" + */ + async getRawTransactionSingle (req, res) { + try { + const txid = req.params.txid + const verbose = req.query.verbose === 'true' + + if (!txid || txid === '') { + return res.status(400).json({ error: 'txid can not be empty' }) + } + + if (txid.length !== 64) { + return res.status(400).json({ + error: `parameter 1 must be of length 64 (not ${txid.length})` + }) + } + + const result = await this.rawtransactionsUseCases.getRawTransactionWithHeight({ txid, verbose }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/rawtransactions/getRawTransaction Get Bulk Raw Transactions + * @apiName GetBulkRawTransactions + * @apiGroup RawTransactions + * @apiDescription Return the raw transaction data for multiple transactions. If verbose is 'true', returns an Object with information about 'txid'. If verbose is 'false' or omitted, returns a string that is serialized, hex-encoded data for 'txid'. + * + * @apiParam {String[]} txids Array of transaction IDs + * @apiParam {Boolean} verbose Return verbose data (default false) + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v6/full-node/rawtransactions/getRawTransaction" -H "accept: application/json" -H "Content-Type: application/json" -d '{"txids":["a5f972572ee1753e2fd2457dd61ce5f40fa2f8a30173d417e49feef7542c96a1","5165dc531aad05d1149bb0f0d9b7bda99c73e2f05e314bcfb5b4bb9ca5e1af5e"],"verbose":true}' + */ + async getRawTransactionBulk (req, res) { + try { + const txids = req.body.txids + const verbose = !!req.body.verbose + + if (!Array.isArray(txids)) { + return res.status(400).json({ error: 'txids must be an array' }) + } + + if (!this.adapters.fullNode.validateArraySize(txids.length)) { + return res.status(400).json({ error: 'Array too large.' }) + } + + // Validate each txid in the array + for (const txid of txids) { + if (!txid || txid === '') { + return res.status(400).json({ error: 'Encountered empty TXID' }) + } + + if (txid.length !== 64) { + return res.status(400).json({ + error: `parameter 1 must be of length 64 (not ${txid.length})` + }) + } + } + + const result = await this.rawtransactionsUseCases.getRawTransactions({ txids, verbose }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/rawtransactions/sendRawTransaction/:hex Send Single Raw Transaction + * @apiName SendSingleRawTransaction + * @apiGroup RawTransactions + * @apiDescription Submits single raw transaction (serialized, hex-encoded) to local node and network. + * + * @apiParam {String} hex Hex-encoded transaction + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v6/full-node/rawtransactions/sendRawTransaction/01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000" -H "accept: application/json" + */ + async sendRawTransactionSingle (req, res) { + try { + const hex = req.params.hex + + if (typeof hex !== 'string') { + return res.status(400).json({ error: 'hex must be a string' }) + } + + if (hex === '') { + return res.status(400).json({ error: 'Encountered empty hex' }) + } + + const result = await this.rawtransactionsUseCases.sendRawTransaction({ hex }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/rawtransactions/sendRawTransaction Send Bulk Raw Transactions + * @apiName SendBulkRawTransactions + * @apiGroup RawTransactions + * @apiDescription Submits multiple raw transaction (serialized, hex-encoded) to local node and network. + * + * @apiParam {String[]} hexes Array of hex-encoded transactions + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v6/full-node/rawtransactions/sendRawTransaction" -H "accept: application/json" -H "Content-Type: application/json" -d '{"hexes":["01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000"]}' + */ + async sendRawTransactionBulk (req, res) { + try { + const hexes = req.body.hexes + + if (!Array.isArray(hexes)) { + return res.status(400).json({ error: 'hex must be an array' }) + } + + if (!this.adapters.fullNode.validateArraySize(hexes.length)) { + return res.status(400).json({ error: 'Array too large.' }) + } + + // Validate each element + for (const hex of hexes) { + if (hex === '') { + return res.status(400).json({ error: 'Encountered empty hex' }) + } + } + + const result = await this.rawtransactionsUseCases.sendRawTransactions({ hexes }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + handleError (err, res) { + wlogger.error('Error in RawTransactionsRESTController:', err) + + const status = err.status || 500 + const message = err.message || 'Internal server error' + + return res.status(status).json({ error: message }) + } +} + +export default RawTransactionsRESTController diff --git a/src/controllers/rest-api/full-node/rawtransactions/index.js b/src/controllers/rest-api/full-node/rawtransactions/index.js new file mode 100644 index 0000000..a06ecd8 --- /dev/null +++ b/src/controllers/rest-api/full-node/rawtransactions/index.js @@ -0,0 +1,58 @@ +/* + REST API router for /full-node/rawtransactions routes. +*/ + +import express from 'express' +import RawTransactionsRESTController from './controller.js' + +class RawTransactionsRouter { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating RawTransactions REST Router.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases) { + throw new Error( + 'Instance of Use Cases library required when instantiating RawTransactions REST Router.' + ) + } + + const dependencies = { + adapters: this.adapters, + useCases: this.useCases + } + + this.rawtransactionsController = new RawTransactionsRESTController(dependencies) + + this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '') + this.baseUrl = `${this.apiPrefix}/full-node/rawtransactions` + if (!this.baseUrl.startsWith('/')) { + this.baseUrl = `/${this.baseUrl}` + } + this.router = express.Router() + } + + attach (app) { + if (!app) { + throw new Error('Must pass app object when attaching REST API controllers.') + } + + this.router.get('/', this.rawtransactionsController.root) + this.router.get('/decodeRawTransaction/:hex', this.rawtransactionsController.decodeRawTransactionSingle) + this.router.post('/decodeRawTransaction', this.rawtransactionsController.decodeRawTransactionBulk) + this.router.get('/decodeScript/:hex', this.rawtransactionsController.decodeScriptSingle) + this.router.post('/decodeScript', this.rawtransactionsController.decodeScriptBulk) + this.router.get('/getRawTransaction/:txid', this.rawtransactionsController.getRawTransactionSingle) + this.router.post('/getRawTransaction', this.rawtransactionsController.getRawTransactionBulk) + this.router.get('/sendRawTransaction/:hex', this.rawtransactionsController.sendRawTransactionSingle) + this.router.post('/sendRawTransaction', this.rawtransactionsController.sendRawTransactionBulk) + + app.use(this.baseUrl, this.router) + } +} + +export default RawTransactionsRouter diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index 67239d7..fafe8db 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -11,6 +11,7 @@ import BlockchainRouter from './full-node/blockchain/index.js' import ControlRouter from './full-node/control/index.js' import DSProofRouter from './full-node/dsproof/index.js' import MiningRouter from './full-node/mining/index.js' +import RawTransactionsRouter from './full-node/rawtransactions/index.js' import config from '../../config/index.js' class RESTControllers { @@ -68,6 +69,9 @@ class RESTControllers { const miningRouter = new MiningRouter(dependencies) miningRouter.attach(app) + + const rawtransactionsRouter = new RawTransactionsRouter(dependencies) + rawtransactionsRouter.attach(app) } } diff --git a/src/use-cases/full-node-rawtransactions-use-cases.js b/src/use-cases/full-node-rawtransactions-use-cases.js new file mode 100644 index 0000000..6d4b304 --- /dev/null +++ b/src/use-cases/full-node-rawtransactions-use-cases.js @@ -0,0 +1,121 @@ +/* + Use cases for interacting with the BCH full node raw transactions RPC interface. +*/ + +import wlogger from '../adapters/wlogger.js' + +class RawTransactionsUseCases { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + + if (!this.adapters) { + throw new Error('Adapters instance required when instantiating RawTransactions use cases.') + } + + this.fullNode = this.adapters.fullNode + if (!this.fullNode) { + throw new Error('Full node adapter required when instantiating RawTransactions use cases.') + } + } + + async decodeRawTransaction ({ hex }) { + return this.fullNode.call('decoderawtransaction', [hex]) + } + + async decodeRawTransactions ({ hexes }) { + try { + const promises = hexes.map(hex => + this.fullNode.call('decoderawtransaction', [hex], `decoderawtransaction-${hex.slice(0, 16)}`) + ) + + return await Promise.all(promises) + } catch (err) { + wlogger.error('Error in RawTransactionsUseCases.decodeRawTransactions()', err) + throw err + } + } + + async decodeScript ({ hex }) { + return this.fullNode.call('decodescript', [hex]) + } + + async decodeScripts ({ hexes }) { + try { + const promises = hexes.map(hex => + this.fullNode.call('decodescript', [hex], `decodescript-${hex.slice(0, 16)}`) + ) + + return await Promise.all(promises) + } catch (err) { + wlogger.error('Error in RawTransactionsUseCases.decodeScripts()', err) + throw err + } + } + + async getRawTransaction ({ txid, verbose = false }) { + const verboseInt = verbose ? 1 : 0 + return this.fullNode.call('getrawtransaction', [txid, verboseInt]) + } + + async getRawTransactions ({ txids, verbose = false }) { + try { + const verboseInt = verbose ? 1 : 0 + const promises = txids.map(txid => + this.fullNode.call('getrawtransaction', [txid, verboseInt], `getrawtransaction-${txid}`) + ) + + return await Promise.all(promises) + } catch (err) { + wlogger.error('Error in RawTransactionsUseCases.getRawTransactions()', err) + throw err + } + } + + async getRawTransactionWithHeight ({ txid, verbose = false }) { + const verboseInt = verbose ? 1 : 0 + const data = await this.fullNode.call('getrawtransaction', [txid, verboseInt]) + + if (verbose && data && data.blockhash) { + data.height = null + try { + // Look up the block height and append it to the TX response. + const blockHeader = await this.fullNode.call('getblockheader', [data.blockhash, true]) + data.height = blockHeader.height + } catch (err) { + // Exit quietly if block header lookup fails + wlogger.debug('Could not fetch block header for height lookup', err) + } + } + + return data + } + + async getBlockHeader ({ blockHash, verbose = false }) { + return this.fullNode.call('getblockheader', [blockHash, verbose]) + } + + async sendRawTransaction ({ hex }) { + return this.fullNode.call('sendrawtransaction', [hex]) + } + + async sendRawTransactions ({ hexes }) { + // Dev Note: Sending the 'sendrawtransaction' RPC call to a full node in parallel will + // not work. Testing showed that the full node will return the same TXID for + // different TX hexes. I believe this is by design, to prevent double spends. + // In parallel, we are essentially asking the node to broadcast a new TX before + // it's finished broadcasting the previous one. Serial execution is required. + try { + const result = [] + for (const hex of hexes) { + const txid = await this.fullNode.call('sendrawtransaction', [hex], `sendrawtransaction-${hex.slice(0, 16)}`) + result.push(txid) + } + return result + } catch (err) { + wlogger.error('Error in RawTransactionsUseCases.sendRawTransactions()', err) + throw err + } + } +} + +export default RawTransactionsUseCases diff --git a/src/use-cases/index.js b/src/use-cases/index.js index 229a783..8d740f1 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -9,6 +9,7 @@ import BlockchainUseCases from './full-node-blockchain-use-cases.js' import ControlUseCases from './full-node-control-use-cases.js' import DSProofUseCases from './full-node-dsproof-use-cases.js' import MiningUseCases from './full-node-mining-use-cases.js' +import RawTransactionsUseCases from './full-node-rawtransactions-use-cases.js' class UseCases { constructor (localConfig = {}) { @@ -23,6 +24,7 @@ class UseCases { this.control = new ControlUseCases({ adapters: this.adapters }) this.dsproof = new DSProofUseCases({ adapters: this.adapters }) this.mining = new MiningUseCases({ adapters: this.adapters }) + this.rawtransactions = new RawTransactionsUseCases({ adapters: this.adapters }) } // Run any startup Use Cases at the start of the app. diff --git a/test/unit/controllers/blockchain-controller-unit.js b/test/unit/controllers/blockchain-controller-unit.js index 2c150de..af25c65 100644 --- a/test/unit/controllers/blockchain-controller-unit.js +++ b/test/unit/controllers/blockchain-controller-unit.js @@ -161,8 +161,7 @@ describe('#blockchain-controller.js', () => { it('should validate array size and call use case', async () => { const hash = 'a'.repeat(64) const req = createMockRequest({ - body: { hashes: [hash], verbose: true }, - locals: { proLimit: false } + body: { hashes: [hash], verbose: true } }) const res = createMockResponse() mockUseCases.blockchain.getBlockHeaders.resolves(['result']) @@ -172,7 +171,7 @@ describe('#blockchain-controller.js', () => { assert.equal(res.statusValue, 200) assert.deepEqual(res.jsonData, ['result']) assert.isTrue( - mockAdapters.fullNode.validateArraySize.calledOnceWithExactly(1, { isProUser: false }) + mockAdapters.fullNode.validateArraySize.calledOnceWithExactly(1) ) assert.isTrue( mockUseCases.blockchain.getBlockHeaders.calledOnceWithExactly({ diff --git a/test/unit/controllers/rawtransactions-controller-unit.js b/test/unit/controllers/rawtransactions-controller-unit.js new file mode 100644 index 0000000..fcd9100 --- /dev/null +++ b/test/unit/controllers/rawtransactions-controller-unit.js @@ -0,0 +1,388 @@ +/* + Unit tests for RawTransactionsRESTController. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' + +import RawTransactionsRESTController from '../../../src/controllers/rest-api/full-node/rawtransactions/controller.js' +import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js' + +describe('#rawtransactions-controller.js', () => { + let sandbox + let mockAdapters + let mockUseCases + let uut + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = { + fullNode: { + validateArraySize: sandbox.stub().returns(true) + } + } + mockUseCases = { + rawtransactions: { + decodeRawTransaction: sandbox.stub().resolves({ txid: 'abc123' }), + decodeRawTransactions: sandbox.stub().resolves([{ txid: 'abc123' }]), + decodeScript: sandbox.stub().resolves({ asm: 'OP_DUP' }), + decodeScripts: sandbox.stub().resolves([{ asm: 'OP_DUP' }]), + getRawTransaction: sandbox.stub().resolves({ txid: 'abc123' }), + getRawTransactionWithHeight: sandbox.stub().resolves({ txid: 'abc123', height: 100 }), + getRawTransactions: sandbox.stub().resolves([{ txid: 'abc123' }]), + sendRawTransaction: sandbox.stub().resolves('txid123'), + sendRawTransactions: sandbox.stub().resolves(['txid1', 'txid2']) + } + } + + uut = new RawTransactionsRESTController({ + adapters: mockAdapters, + useCases: mockUseCases + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new RawTransactionsRESTController({ useCases: mockUseCases }) + }, /Adapters library required/) + }) + + it('should require rawtransactions use cases', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new RawTransactionsRESTController({ adapters: mockAdapters, useCases: {} }) + }, /RawTransactions use cases required/) + }) + }) + + describe('#root()', () => { + it('should return rawtransactions status', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.root(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { status: 'rawtransactions' }) + }) + }) + + describe('#decodeRawTransactionSingle()', () => { + it('should return decoded transaction on success', async () => { + const req = createMockRequest({ params: { hex: '01000000' } }) + const res = createMockResponse() + + await uut.decodeRawTransactionSingle(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { txid: 'abc123' }) + assert.isTrue(mockUseCases.rawtransactions.decodeRawTransaction.calledOnce) + assert.deepEqual(mockUseCases.rawtransactions.decodeRawTransaction.firstCall.args[0], { hex: '01000000' }) + }) + + it('should return 400 if hex is empty', async () => { + const req = createMockRequest({ params: { hex: '' } }) + const res = createMockResponse() + + await uut.decodeRawTransactionSingle(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'hex can not be empty' }) + }) + + it('should handle errors via handleError', async () => { + const error = new Error('RPC error') + error.status = 500 + mockUseCases.rawtransactions.decodeRawTransaction.rejects(error) + const req = createMockRequest({ params: { hex: '01000000' } }) + const res = createMockResponse() + + await uut.decodeRawTransactionSingle(req, res) + + assert.equal(res.statusValue, 500) + assert.deepEqual(res.jsonData, { error: 'RPC error' }) + }) + }) + + describe('#decodeRawTransactionBulk()', () => { + it('should return decoded transactions on success', async () => { + const req = createMockRequest({ body: { hexes: ['hex1', 'hex2'] } }) + const res = createMockResponse() + + await uut.decodeRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, [{ txid: 'abc123' }]) + assert.isTrue(mockUseCases.rawtransactions.decodeRawTransactions.calledOnce) + }) + + it('should return 400 if hexes is not an array', async () => { + const req = createMockRequest({ body: { hexes: 'not-array' } }) + const res = createMockResponse() + + await uut.decodeRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'hexes must be an array' }) + }) + + it('should return 400 if array is too large', async () => { + mockAdapters.fullNode.validateArraySize.returns(false) + const req = createMockRequest({ body: { hexes: new Array(25).fill('hex') } }) + const res = createMockResponse() + + await uut.decodeRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'Array too large.' }) + }) + + it('should return 400 if empty hex encountered', async () => { + const req = createMockRequest({ body: { hexes: ['hex1', '', 'hex2'] } }) + const res = createMockResponse() + + await uut.decodeRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'Encountered empty hex' }) + }) + }) + + describe('#decodeScriptSingle()', () => { + it('should return decoded script on success', async () => { + const req = createMockRequest({ params: { hex: '76a914' } }) + const res = createMockResponse() + + await uut.decodeScriptSingle(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { asm: 'OP_DUP' }) + assert.isTrue(mockUseCases.rawtransactions.decodeScript.calledOnce) + }) + + it('should return 400 if hex is empty', async () => { + const req = createMockRequest({ params: { hex: '' } }) + const res = createMockResponse() + + await uut.decodeScriptSingle(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'hex can not be empty' }) + }) + }) + + describe('#decodeScriptBulk()', () => { + it('should return decoded scripts on success', async () => { + const req = createMockRequest({ body: { hexes: ['script1', 'script2'] } }) + const res = createMockResponse() + + await uut.decodeScriptBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, [{ asm: 'OP_DUP' }]) + }) + + it('should return 400 if array is too large', async () => { + mockAdapters.fullNode.validateArraySize.returns(false) + const req = createMockRequest({ body: { hexes: new Array(25).fill('script') } }) + const res = createMockResponse() + + await uut.decodeScriptBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'Array too large.' }) + }) + }) + + describe('#getRawTransactionSingle()', () => { + it('should return raw transaction on success', async () => { + const req = createMockRequest({ params: { txid: 'a'.repeat(64) }, query: {} }) + const res = createMockResponse() + + await uut.getRawTransactionSingle(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { txid: 'abc123', height: 100 }) + assert.isTrue(mockUseCases.rawtransactions.getRawTransactionWithHeight.calledOnce) + }) + + it('should pass verbose=true when query param is set', async () => { + const req = createMockRequest({ params: { txid: 'a'.repeat(64) }, query: { verbose: 'true' } }) + const res = createMockResponse() + + await uut.getRawTransactionSingle(req, res) + + assert.isTrue(mockUseCases.rawtransactions.getRawTransactionWithHeight.calledOnce) + assert.deepEqual(mockUseCases.rawtransactions.getRawTransactionWithHeight.firstCall.args[0], { + txid: 'a'.repeat(64), + verbose: true + }) + }) + + it('should return 400 if txid is empty', async () => { + const req = createMockRequest({ params: { txid: '' } }) + const res = createMockResponse() + + await uut.getRawTransactionSingle(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'txid can not be empty' }) + }) + + it('should return 400 if txid length is not 64', async () => { + const req = createMockRequest({ params: { txid: 'short' } }) + const res = createMockResponse() + + await uut.getRawTransactionSingle(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'parameter 1 must be of length 64 (not 5)' }) + }) + }) + + describe('#getRawTransactionBulk()', () => { + it('should return raw transactions on success', async () => { + const req = createMockRequest({ + body: { + txids: ['a'.repeat(64), 'b'.repeat(64)], + verbose: true + } + }) + const res = createMockResponse() + + await uut.getRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, [{ txid: 'abc123' }]) + assert.isTrue(mockUseCases.rawtransactions.getRawTransactions.calledOnce) + assert.deepEqual(mockUseCases.rawtransactions.getRawTransactions.firstCall.args[0], { + txids: ['a'.repeat(64), 'b'.repeat(64)], + verbose: true + }) + }) + + it('should return 400 if txids is not an array', async () => { + const req = createMockRequest({ body: { txids: 'not-array' } }) + const res = createMockResponse() + + await uut.getRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'txids must be an array' }) + }) + + it('should return 400 if array is too large', async () => { + mockAdapters.fullNode.validateArraySize.returns(false) + const req = createMockRequest({ body: { txids: new Array(25).fill('a'.repeat(64)) } }) + const res = createMockResponse() + + await uut.getRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'Array too large.' }) + }) + + it('should return 400 if empty txid encountered', async () => { + const req = createMockRequest({ body: { txids: ['a'.repeat(64), ''] } }) + const res = createMockResponse() + + await uut.getRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'Encountered empty TXID' }) + }) + + it('should return 400 if txid length is not 64', async () => { + const req = createMockRequest({ body: { txids: ['short'] } }) + const res = createMockResponse() + + await uut.getRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'parameter 1 must be of length 64 (not 5)' }) + }) + }) + + describe('#sendRawTransactionSingle()', () => { + it('should return txid on success', async () => { + const req = createMockRequest({ params: { hex: '01000000' } }) + const res = createMockResponse() + + await uut.sendRawTransactionSingle(req, res) + + assert.equal(res.statusValue, 200) + assert.equal(res.jsonData, 'txid123') + assert.isTrue(mockUseCases.rawtransactions.sendRawTransaction.calledOnce) + }) + + it('should return 400 if hex is empty', async () => { + const req = createMockRequest({ params: { hex: '' } }) + const res = createMockResponse() + + await uut.sendRawTransactionSingle(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'Encountered empty hex' }) + }) + + it('should return 400 if hex is not a string', async () => { + const req = createMockRequest({ params: { hex: 123 } }) + const res = createMockResponse() + + await uut.sendRawTransactionSingle(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'hex must be a string' }) + }) + }) + + describe('#sendRawTransactionBulk()', () => { + it('should return txids on success', async () => { + const req = createMockRequest({ body: { hexes: ['hex1', 'hex2'] } }) + const res = createMockResponse() + + await uut.sendRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, ['txid1', 'txid2']) + assert.isTrue(mockUseCases.rawtransactions.sendRawTransactions.calledOnce) + }) + + it('should return 400 if hexes is not an array', async () => { + const req = createMockRequest({ body: { hexes: 'not-array' } }) + const res = createMockResponse() + + await uut.sendRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'hex must be an array' }) + }) + + it('should return 400 if array is too large', async () => { + mockAdapters.fullNode.validateArraySize.returns(false) + const req = createMockRequest({ body: { hexes: new Array(25).fill('hex') } }) + const res = createMockResponse() + + await uut.sendRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'Array too large.' }) + }) + + it('should return 400 if empty hex encountered', async () => { + const req = createMockRequest({ body: { hexes: ['hex1', '', 'hex2'] } }) + const res = createMockResponse() + + await uut.sendRawTransactionBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'Encountered empty hex' }) + }) + }) +}) diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js index 00dc9d9..96685d2 100644 --- a/test/unit/controllers/rest-api-index-unit.js +++ b/test/unit/controllers/rest-api-index-unit.js @@ -10,6 +10,7 @@ import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockc import ControlRouter from '../../../src/controllers/rest-api/full-node/control/index.js' import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/index.js' import MiningRouter from '../../../src/controllers/rest-api/full-node/mining/index.js' +import RawTransactionsRouter from '../../../src/controllers/rest-api/full-node/rawtransactions/index.js' describe('#controllers/rest-api/index.js', () => { let sandbox @@ -56,6 +57,17 @@ describe('#controllers/rest-api/index.js', () => { mining: { getMiningInfo: () => {}, getNetworkHashPS: () => {} + }, + rawtransactions: { + decodeRawTransaction: () => {}, + decodeRawTransactions: () => {}, + decodeScript: () => {}, + decodeScripts: () => {}, + getRawTransaction: () => {}, + getRawTransactionWithHeight: () => {}, + getRawTransactions: () => {}, + sendRawTransaction: () => {}, + sendRawTransactions: () => {} } } }) @@ -86,6 +98,7 @@ describe('#controllers/rest-api/index.js', () => { const controlAttachStub = sandbox.stub(ControlRouter.prototype, 'attach') const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach') const miningAttachStub = sandbox.stub(MiningRouter.prototype, 'attach') + const rawtransactionsAttachStub = sandbox.stub(RawTransactionsRouter.prototype, 'attach') const restControllers = new RESTControllers({ adapters: mockAdapters, useCases: mockUseCases @@ -102,6 +115,8 @@ describe('#controllers/rest-api/index.js', () => { assert.equal(dsproofAttachStub.getCall(0).args[0], app) assert.isTrue(miningAttachStub.calledOnce) assert.equal(miningAttachStub.getCall(0).args[0], app) + assert.isTrue(rawtransactionsAttachStub.calledOnce) + assert.equal(rawtransactionsAttachStub.getCall(0).args[0], app) }) }) }) diff --git a/test/unit/use-cases/full-node-rawtransactions-use-cases-unit.js b/test/unit/use-cases/full-node-rawtransactions-use-cases-unit.js new file mode 100644 index 0000000..95dae8d --- /dev/null +++ b/test/unit/use-cases/full-node-rawtransactions-use-cases-unit.js @@ -0,0 +1,267 @@ +/* + Unit tests for RawTransactionsUseCases. +*/ + +import { assert } from 'chai' + +import RawTransactionsUseCases from '../../../src/use-cases/full-node-rawtransactions-use-cases.js' + +describe('#full-node-rawtransactions-use-cases.js', () => { + let mockAdapters + let uut + + beforeEach(() => { + mockAdapters = { + fullNode: { + call: async () => ({}) + } + } + + uut = new RawTransactionsUseCases({ adapters: mockAdapters }) + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new RawTransactionsUseCases() + }, /Adapters instance required/) + }) + + it('should require full node adapter', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new RawTransactionsUseCases({ adapters: {} }) + }, /Full node adapter required/) + }) + }) + + describe('#decodeRawTransaction()', () => { + it('should call full node adapter with correct method', async () => { + let capturedMethod = '' + let capturedParams = [] + mockAdapters.fullNode.call = async (method, params) => { + capturedMethod = method + capturedParams = params + return { txid: 'abc123', version: 2 } + } + + const result = await uut.decodeRawTransaction({ hex: '01000000' }) + + assert.equal(capturedMethod, 'decoderawtransaction') + assert.deepEqual(capturedParams, ['01000000']) + assert.deepEqual(result, { txid: 'abc123', version: 2 }) + }) + }) + + describe('#decodeRawTransactions()', () => { + it('should call full node adapter for each hex in parallel', async () => { + const callCount = { count: 0 } + mockAdapters.fullNode.call = async (method, params) => { + callCount.count++ + return { txid: `tx${callCount.count}`, hex: params[0] } + } + + const hexes = ['hex1', 'hex2', 'hex3'] + const result = await uut.decodeRawTransactions({ hexes }) + + assert.equal(callCount.count, 3) + assert.equal(result.length, 3) + assert.equal(result[0].txid, 'tx1') + assert.equal(result[1].txid, 'tx2') + assert.equal(result[2].txid, 'tx3') + }) + }) + + describe('#decodeScript()', () => { + it('should call full node adapter with correct method', async () => { + let capturedMethod = '' + let capturedParams = [] + mockAdapters.fullNode.call = async (method, params) => { + capturedMethod = method + capturedParams = params + return { asm: 'OP_DUP OP_HASH160', type: 'pubkeyhash' } + } + + const result = await uut.decodeScript({ hex: '76a914' }) + + assert.equal(capturedMethod, 'decodescript') + assert.deepEqual(capturedParams, ['76a914']) + assert.deepEqual(result, { asm: 'OP_DUP OP_HASH160', type: 'pubkeyhash' }) + }) + }) + + describe('#decodeScripts()', () => { + it('should call full node adapter for each hex in parallel', async () => { + const callCount = { count: 0 } + mockAdapters.fullNode.call = async (method, params) => { + callCount.count++ + return { asm: `script${callCount.count}`, hex: params[0] } + } + + const hexes = ['script1', 'script2'] + const result = await uut.decodeScripts({ hexes }) + + assert.equal(callCount.count, 2) + assert.equal(result.length, 2) + }) + }) + + describe('#getRawTransaction()', () => { + it('should call full node adapter with verbose=false by default', async () => { + let capturedParams = [] + mockAdapters.fullNode.call = async (method, params) => { + capturedParams = params + return '01000000' + } + + await uut.getRawTransaction({ txid: 'abc123' }) + + assert.deepEqual(capturedParams, ['abc123', 0]) + }) + + it('should call full node adapter with verbose=true when specified', async () => { + let capturedParams = [] + mockAdapters.fullNode.call = async (method, params) => { + capturedParams = params + return { txid: 'abc123', version: 2 } + } + + await uut.getRawTransaction({ txid: 'abc123', verbose: true }) + + assert.deepEqual(capturedParams, ['abc123', 1]) + }) + }) + + describe('#getRawTransactions()', () => { + it('should call full node adapter for each txid in parallel', async () => { + const callCount = { count: 0 } + mockAdapters.fullNode.call = async (method, params) => { + callCount.count++ + return { txid: params[0], version: 2 } + } + + const txids = ['tx1', 'tx2'] + const result = await uut.getRawTransactions({ txids, verbose: true }) + + assert.equal(callCount.count, 2) + assert.equal(result.length, 2) + }) + }) + + describe('#getRawTransactionWithHeight()', () => { + it('should return transaction without height when verbose=false', async () => { + mockAdapters.fullNode.call = async (method, params) => { + if (method === 'getrawtransaction') { + return '01000000' + } + return {} + } + + const result = await uut.getRawTransactionWithHeight({ txid: 'abc123', verbose: false }) + + assert.equal(result, '01000000') + }) + + it('should fetch and append height when verbose=true and blockhash exists', async () => { + let callCount = 0 + mockAdapters.fullNode.call = async (method, params) => { + callCount++ + if (method === 'getrawtransaction') { + return { txid: 'abc123', blockhash: 'block123' } + } + if (method === 'getblockheader') { + return { height: 100, hash: 'block123' } + } + return {} + } + + const result = await uut.getRawTransactionWithHeight({ txid: 'abc123', verbose: true }) + + assert.equal(callCount, 2) + assert.equal(result.height, 100) + assert.equal(result.txid, 'abc123') + }) + + it('should handle block header lookup failure gracefully', async () => { + let callCount = 0 + mockAdapters.fullNode.call = async (method, params) => { + callCount++ + if (method === 'getrawtransaction') { + return { txid: 'abc123', blockhash: 'block123' } + } + if (method === 'getblockheader') { + throw new Error('Block not found') + } + return {} + } + + const result = await uut.getRawTransactionWithHeight({ txid: 'abc123', verbose: true }) + + assert.equal(callCount, 2) + assert.isNull(result.height) + assert.equal(result.txid, 'abc123') + }) + }) + + describe('#getBlockHeader()', () => { + it('should call full node adapter with correct method', async () => { + let capturedMethod = '' + let capturedParams = [] + mockAdapters.fullNode.call = async (method, params) => { + capturedMethod = method + capturedParams = params + return { height: 100, hash: 'block123' } + } + + const result = await uut.getBlockHeader({ blockHash: 'block123', verbose: true }) + + assert.equal(capturedMethod, 'getblockheader') + assert.deepEqual(capturedParams, ['block123', true]) + assert.deepEqual(result, { height: 100, hash: 'block123' }) + }) + }) + + describe('#sendRawTransaction()', () => { + it('should call full node adapter with correct method', async () => { + let capturedMethod = '' + let capturedParams = [] + mockAdapters.fullNode.call = async (method, params) => { + capturedMethod = method + capturedParams = params + return 'txid123' + } + + const result = await uut.sendRawTransaction({ hex: '01000000' }) + + assert.equal(capturedMethod, 'sendrawtransaction') + assert.deepEqual(capturedParams, ['01000000']) + assert.equal(result, 'txid123') + }) + }) + + describe('#sendRawTransactions()', () => { + it('should send transactions serially, not in parallel', async () => { + const callOrder = [] + mockAdapters.fullNode.call = async (method, params) => { + callOrder.push(params[0]) + // Simulate some async work + await new Promise(resolve => setTimeout(resolve, 10)) + return `txid-${params[0]}` + } + + const hexes = ['hex1', 'hex2', 'hex3'] + const startTime = Date.now() + const result = await uut.sendRawTransactions({ hexes }) + const endTime = Date.now() + + // Should take at least 30ms if serial (3 * 10ms) + assert.isAtLeast(endTime - startTime, 25) + assert.deepEqual(callOrder, ['hex1', 'hex2', 'hex3']) + assert.equal(result.length, 3) + assert.equal(result[0], 'txid-hex1') + assert.equal(result[1], 'txid-hex2') + assert.equal(result[2], 'txid-hex3') + }) + }) +}) From 2af4b0a943872397417ffe704ed1df91b7c8fcb1 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 14 Nov 2025 06:56:59 -0800 Subject: [PATCH 05/13] Converted router index.js files to router.js --- .../full-node/blockchain/{index.js => router.js} | 0 .../rest-api/full-node/control/{index.js => router.js} | 0 .../rest-api/full-node/dsproof/{index.js => router.js} | 0 .../rest-api/full-node/mining/{index.js => router.js} | 0 .../full-node/rawtransactions/{index.js => router.js} | 0 src/controllers/rest-api/index.js | 10 +++++----- test/unit/controllers/rest-api-index-unit.js | 10 +++++----- 7 files changed, 10 insertions(+), 10 deletions(-) rename src/controllers/rest-api/full-node/blockchain/{index.js => router.js} (100%) rename src/controllers/rest-api/full-node/control/{index.js => router.js} (100%) rename src/controllers/rest-api/full-node/dsproof/{index.js => router.js} (100%) rename src/controllers/rest-api/full-node/mining/{index.js => router.js} (100%) rename src/controllers/rest-api/full-node/rawtransactions/{index.js => router.js} (100%) diff --git a/src/controllers/rest-api/full-node/blockchain/index.js b/src/controllers/rest-api/full-node/blockchain/router.js similarity index 100% rename from src/controllers/rest-api/full-node/blockchain/index.js rename to src/controllers/rest-api/full-node/blockchain/router.js diff --git a/src/controllers/rest-api/full-node/control/index.js b/src/controllers/rest-api/full-node/control/router.js similarity index 100% rename from src/controllers/rest-api/full-node/control/index.js rename to src/controllers/rest-api/full-node/control/router.js diff --git a/src/controllers/rest-api/full-node/dsproof/index.js b/src/controllers/rest-api/full-node/dsproof/router.js similarity index 100% rename from src/controllers/rest-api/full-node/dsproof/index.js rename to src/controllers/rest-api/full-node/dsproof/router.js diff --git a/src/controllers/rest-api/full-node/mining/index.js b/src/controllers/rest-api/full-node/mining/router.js similarity index 100% rename from src/controllers/rest-api/full-node/mining/index.js rename to src/controllers/rest-api/full-node/mining/router.js diff --git a/src/controllers/rest-api/full-node/rawtransactions/index.js b/src/controllers/rest-api/full-node/rawtransactions/router.js similarity index 100% rename from src/controllers/rest-api/full-node/rawtransactions/index.js rename to src/controllers/rest-api/full-node/rawtransactions/router.js diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index fafe8db..02961bd 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -7,11 +7,11 @@ // Local libraries // import EventRouter from './event/index.js' // import ReqRouter from './req/index.js' -import BlockchainRouter from './full-node/blockchain/index.js' -import ControlRouter from './full-node/control/index.js' -import DSProofRouter from './full-node/dsproof/index.js' -import MiningRouter from './full-node/mining/index.js' -import RawTransactionsRouter from './full-node/rawtransactions/index.js' +import BlockchainRouter from './full-node/blockchain/router.js' +import ControlRouter from './full-node/control/router.js' +import DSProofRouter from './full-node/dsproof/router.js' +import MiningRouter from './full-node/mining/router.js' +import RawTransactionsRouter from './full-node/rawtransactions/router.js' import config from '../../config/index.js' class RESTControllers { diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js index 96685d2..1ecccc1 100644 --- a/test/unit/controllers/rest-api-index-unit.js +++ b/test/unit/controllers/rest-api-index-unit.js @@ -6,11 +6,11 @@ import { assert } from 'chai' import sinon from 'sinon' import RESTControllers from '../../../src/controllers/rest-api/index.js' -import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockchain/index.js' -import ControlRouter from '../../../src/controllers/rest-api/full-node/control/index.js' -import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/index.js' -import MiningRouter from '../../../src/controllers/rest-api/full-node/mining/index.js' -import RawTransactionsRouter from '../../../src/controllers/rest-api/full-node/rawtransactions/index.js' +import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockchain/router.js' +import ControlRouter from '../../../src/controllers/rest-api/full-node/control/router.js' +import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/router.js' +import MiningRouter from '../../../src/controllers/rest-api/full-node/mining/router.js' +import RawTransactionsRouter from '../../../src/controllers/rest-api/full-node/rawtransactions/router.js' describe('#controllers/rest-api/index.js', () => { let sandbox From ca25e33517a8da6b6e408ce5b3e4f70daac3d87b Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 14 Nov 2025 07:25:23 -0800 Subject: [PATCH 06/13] feat(fulcrum): Ported fulcrum endpoints from bch-api --- package-lock.json | 1 + package.json | 1 + src/adapters/fulcrum-api.js | 124 ++++ src/adapters/index.js | 2 + src/config/env/common.js | 6 + .../rest-api/full-node/fulcrum/controller.js | 563 ++++++++++++++++++ .../rest-api/full-node/fulcrum/router.js | 64 ++ src/controllers/rest-api/index.js | 4 + src/use-cases/full-node-fulcrum-use-cases.js | 155 +++++ src/use-cases/index.js | 2 + .../controllers/fulcrum-controller-unit.js | 481 +++++++++++++++ test/unit/controllers/rest-api-index-unit.js | 19 + .../full-node-fulcrum-use-cases-unit.js | 297 +++++++++ 13 files changed, 1719 insertions(+) create mode 100644 src/adapters/fulcrum-api.js create mode 100644 src/controllers/rest-api/full-node/fulcrum/controller.js create mode 100644 src/controllers/rest-api/full-node/fulcrum/router.js create mode 100644 src/use-cases/full-node-fulcrum-use-cases.js create mode 100644 test/unit/controllers/fulcrum-controller-unit.js create mode 100644 test/unit/use-cases/full-node-fulcrum-use-cases-unit.js diff --git a/package-lock.json b/package-lock.json index 30839cd..cb3c2f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@psf/bch-js": "6.8.3", "axios": "1.7.7", "cors": "2.8.5", "dotenv": "16.3.1", diff --git a/package.json b/package.json index c1d6292..5386d53 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "license": "MIT", "description": "REST API proxy to Bitcoin Cash infrastructure", "dependencies": { + "@psf/bch-js": "6.8.3", "axios": "1.7.7", "cors": "2.8.5", "dotenv": "16.3.1", diff --git a/src/adapters/fulcrum-api.js b/src/adapters/fulcrum-api.js new file mode 100644 index 0000000..d39a7f7 --- /dev/null +++ b/src/adapters/fulcrum-api.js @@ -0,0 +1,124 @@ +/* + Adapter library for interacting with Fulcrum API service over HTTP. +*/ + +import axios from 'axios' +import wlogger from './wlogger.js' +import config from '../config/index.js' + +class FulcrumAPIAdapter { + constructor (localConfig = {}) { + this.config = localConfig.config || config + + // Allow missing config for testing environments + if (!this.config.fulcrumApi || !this.config.fulcrumApi.baseUrl) { + if (process.env.NODE_ENV === 'test' || process.env.TEST) { + // In test environment, create a mock baseURL + this.config.fulcrumApi = { + baseUrl: 'http://localhost:50001', + timeoutMs: 15000 + } + } else { + throw new Error('FULCRUM_API env var not set. Can not connect to Fulcrum indexer.') + } + } + + const { + baseUrl, + timeoutMs = 15000 + } = this.config.fulcrumApi + + this.http = axios.create({ + baseURL: baseUrl, + timeout: timeoutMs + }) + } + + async get (path) { + try { + const response = await this.http.get(path) + return response.data + } catch (err) { + throw this._handleError(err) + } + } + + async post (path, data) { + try { + const response = await this.http.post(path, data) + return response.data + } catch (err) { + throw this._handleError(err) + } + } + + _handleError (err) { + const { status, message } = this.decodeError(err) + const error = new Error(message) + error.status = status + error.originalError = err + return error + } + + decodeError (err) { + try { + // Attempt to extract error message from response data + if (err.response && err.response.data) { + const data = err.response.data + // Handle structured error responses + if (data.error) { + return this._formatError(data.error, err.response.status || 400) + } + // Handle string error messages + if (typeof data === 'string') { + return this._formatError(data, err.response.status || 400) + } + // Handle object responses that might contain error info + if (typeof data === 'object' && data.message) { + return this._formatError(data.message, err.response.status || 400) + } + // Fallback to returning the status + return this._formatError('Fulcrum API error', err.response.status || 500) + } + + // Network errors + if (err.message) { + if (err.message.includes('ENOTFOUND') || err.message.includes('ENETUNREACH') || err.message.includes('EAI_AGAIN')) { + return this._formatError( + 'Network error: Could not communicate with Fulcrum API service.', + 503 + ) + } + } + + if (err.code && (err.code === 'ECONNABORTED' || err.code === 'ECONNREFUSED')) { + return this._formatError( + 'Network error: Could not communicate with Fulcrum API service.', + 503 + ) + } + + if (err.error && typeof err.error === 'string' && err.error.includes('429')) { + return this._formatError('429 Too Many Requests', 429) + } + + if (err.message) { + return this._formatError(err.message, err.status || 422) + } + + return this._formatError('Unhandled Fulcrum API error', 500) + } catch (decodeError) { + wlogger.error('Unhandled error in FulcrumAPIAdapter.decodeError()', decodeError) + return this._formatError('Internal server error', 500) + } + } + + _formatError (message, status = 500) { + return { + message: message || 'Internal server error', + status: status || 500 + } + } +} + +export default FulcrumAPIAdapter diff --git a/src/adapters/index.js b/src/adapters/index.js index ffabb9d..f9b5c96 100644 --- a/src/adapters/index.js +++ b/src/adapters/index.js @@ -7,6 +7,7 @@ // Load individual adapter libraries. // import NostrRelayAdapter from './nostr-relay.js' import FullNodeRPCAdapter from './full-node-rpc.js' +import FulcrumAPIAdapter from './fulcrum-api.js' import config from '../config/index.js' class Adapters { @@ -33,6 +34,7 @@ class Adapters { // this.nostrRelay = this.nostrRelays[0] this.fullNode = new FullNodeRPCAdapter({ config: this.config }) + this.fulcrum = new FulcrumAPIAdapter({ config: this.config }) } async start () { diff --git a/src/config/env/common.js b/src/config/env/common.js index 137fdd4..698fc6f 100644 --- a/src/config/env/common.js +++ b/src/config/env/common.js @@ -60,6 +60,12 @@ export default { rpcRequestIdPrefix: process.env.RPC_REQUEST_ID_PREFIX || 'psf-bch-api' }, + // Fulcrum API configuration + fulcrumApi: { + baseUrl: process.env.FULCRUM_API || '', + timeoutMs: Number(process.env.FULCRUM_TIMEOUT_MS || 15000) + }, + x402: x402Defaults, // Version diff --git a/src/controllers/rest-api/full-node/fulcrum/controller.js b/src/controllers/rest-api/full-node/fulcrum/controller.js new file mode 100644 index 0000000..5c172b0 --- /dev/null +++ b/src/controllers/rest-api/full-node/fulcrum/controller.js @@ -0,0 +1,563 @@ +/* + REST API Controller for the /full-node/fulcrum routes. +*/ + +import wlogger from '../../../../adapters/wlogger.js' +import BCHJS from '@psf/bch-js' + +const bchjs = new BCHJS() + +class FulcrumRESTController { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Fulcrum REST Controller.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases || !this.useCases.fulcrum) { + throw new Error( + 'Instance of Fulcrum use cases required when instantiating Fulcrum REST Controller.' + ) + } + + this.fulcrumUseCases = this.useCases.fulcrum + + // Bind functions + this.root = this.root.bind(this) + this.getBalance = this.getBalance.bind(this) + this.balanceBulk = this.balanceBulk.bind(this) + this.getUtxos = this.getUtxos.bind(this) + this.utxosBulk = this.utxosBulk.bind(this) + this.getTransactionDetails = this.getTransactionDetails.bind(this) + this.transactionDetailsBulk = this.transactionDetailsBulk.bind(this) + this.broadcastTransaction = this.broadcastTransaction.bind(this) + this.getBlockHeaders = this.getBlockHeaders.bind(this) + this.blockHeadersBulk = this.blockHeadersBulk.bind(this) + this.getTransactions = this.getTransactions.bind(this) + this.transactionsBulk = this.transactionsBulk.bind(this) + this.getMempool = this.getMempool.bind(this) + this.mempoolBulk = this.mempoolBulk.bind(this) + this.handleError = this.handleError.bind(this) + } + + /** + * @api {get} /v6/full-node/fulcrum/ Service status + * @apiName FulcrumRoot + * @apiGroup Fulcrum + * + * @apiDescription Returns the status of the fulcrum service. + * + * @apiSuccess {String} status Service identifier + */ + async root (req, res) { + return res.status(200).json({ status: 'fulcrum' }) + } + + /** + * Validates and converts an address to cash address format + * @param {string} address - Address to validate and convert + * @returns {string} Cash address + * @throws {Error} If address is invalid or not mainnet + */ + _validateAndConvertAddress (address) { + if (!address) { + throw new Error('address is empty') + } + + // Convert legacy to cash address + const cashAddr = bchjs.Address.toCashAddress(address) + + // Ensure it's a valid BCH address + try { + bchjs.Address.toLegacyAddress(cashAddr) + } catch (err) { + throw new Error(`Invalid BCH address. Double check your address is valid: ${address}`) + } + + // Ensure it's mainnet (no testnet support) + const isMainnet = bchjs.Address.isMainnetAddress(cashAddr) + if (!isMainnet) { + throw new Error('Invalid network. Only mainnet addresses are supported.') + } + + return cashAddr + } + + /** + * @api {get} /v6/full-node/fulcrum/balance/:address Get balance for a single address + * @apiName GetBalance + * @apiGroup Fulcrum + * @apiDescription Returns an object with confirmed and unconfirmed balance associated with an address. + */ + async getBalance (req, res) { + try { + const address = req.params.address + + if (Array.isArray(address)) { + return res.status(400).json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + const cashAddr = this._validateAndConvertAddress(address) + + const result = await this.fulcrumUseCases.getBalance({ address: cashAddr }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/balance Get balances for an array of addresses + * @apiName GetBalances + * @apiGroup Fulcrum + * @apiDescription Returns an array of balances associated with an array of addresses. Limited to 20 items per request. + */ + async balanceBulk (req, res) { + try { + const addresses = req.body.addresses + + if (!Array.isArray(addresses)) { + return res.status(400).json({ + success: false, + error: 'addresses needs to be an array. Use GET for single address.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(addresses.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + // Validate and convert all addresses + const validatedAddresses = [] + for (let i = 0; i < addresses.length; i++) { + try { + const cashAddr = this._validateAndConvertAddress(addresses[i]) + validatedAddresses.push(cashAddr) + } catch (err) { + return res.status(400).json({ + success: false, + error: err.message + }) + } + } + + const result = await this.fulcrumUseCases.getBalances({ addresses: validatedAddresses }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/fulcrum/utxos/:address Get utxos for a single address + * @apiName GetUtxos + * @apiGroup Fulcrum + * @apiDescription Returns an object with UTXOs associated with an address. + */ + async getUtxos (req, res) { + try { + const address = req.params.address + + if (Array.isArray(address)) { + return res.status(400).json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + const cashAddr = this._validateAndConvertAddress(address) + + const result = await this.fulcrumUseCases.getUtxos({ address: cashAddr }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/utxos Get utxos for an array of addresses + * @apiName GetUtxosBulk + * @apiGroup Fulcrum + * @apiDescription Returns an array of objects with UTXOs associated with an address. Limited to 20 items per request. + */ + async utxosBulk (req, res) { + try { + const addresses = req.body.addresses + + if (!Array.isArray(addresses)) { + return res.status(400).json({ + success: false, + error: 'addresses needs to be an array. Use GET for single address.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(addresses.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + // Validate and convert all addresses + const validatedAddresses = [] + for (let i = 0; i < addresses.length; i++) { + try { + const cashAddr = this._validateAndConvertAddress(addresses[i]) + validatedAddresses.push(cashAddr) + } catch (err) { + return res.status(400).json({ + success: false, + error: err.message + }) + } + } + + const result = await this.fulcrumUseCases.getUtxosBulk({ addresses: validatedAddresses }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/fulcrum/tx/data/:txid Get transaction details for a TXID + * @apiName GetTransactionDetails + * @apiGroup Fulcrum + * @apiDescription Returns an object with transaction details of the TXID + */ + async getTransactionDetails (req, res) { + try { + const txid = req.params.txid + + if (typeof txid !== 'string') { + return res.status(400).json({ + success: false, + error: 'txid must be a string' + }) + } + + const result = await this.fulcrumUseCases.getTransactionDetails({ txid }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/tx/data Get transaction details for an array of TXIDs + * @apiName GetTransactionDetailsBulk + * @apiGroup Fulcrum + * @apiDescription Returns an array of objects with transaction details of an array of TXIDs. Limited to 20 items per request. + */ + async transactionDetailsBulk (req, res) { + try { + const txids = req.body.txids + const verbose = req.body.verbose !== undefined ? req.body.verbose : true + + if (!Array.isArray(txids)) { + return res.status(400).json({ + success: false, + error: 'txids needs to be an array. Use GET for single txid.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(txids.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + const result = await this.fulcrumUseCases.getTransactionDetailsBulk({ txids, verbose }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/tx/broadcast Broadcast a raw transaction + * @apiName BroadcastTransaction + * @apiGroup Fulcrum + * @apiDescription Broadcast a raw transaction and return the transaction ID on success or error on failure. + */ + async broadcastTransaction (req, res) { + try { + const txHex = req.body.txHex + + if (typeof txHex !== 'string') { + return res.status(400).json({ + success: false, + error: 'txHex must be a string' + }) + } + + const result = await this.fulcrumUseCases.broadcastTransaction({ txHex }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/fulcrum/block/headers/:height Get block headers + * @apiName GetBlockHeaders + * @apiGroup Fulcrum + * @apiDescription Returns an array with block headers starting at the block height + * + * @apiParam {Number} height Block height + * @apiParam {Number} count Number of block headers to return (query parameter, default: 1) + */ + async getBlockHeaders (req, res) { + try { + const heightRaw = req.params.height + const countRaw = req.query.count + + const height = Number(heightRaw) + const count = countRaw === undefined ? 1 : Number(countRaw) + + if (Number.isNaN(height) || height < 0) { + return res.status(400).json({ + success: false, + error: 'height must be a positive number' + }) + } + + if (Number.isNaN(count) || count < 0) { + return res.status(400).json({ + success: false, + error: 'count must be a positive number' + }) + } + + const result = await this.fulcrumUseCases.getBlockHeaders({ height, count }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/block/headers Get block headers for an array of height + count pairs + * @apiName GetBlockHeadersBulk + * @apiGroup Fulcrum + * @apiDescription Returns an array of objects with block headers. Limited to 20 items per request. + */ + async blockHeadersBulk (req, res) { + try { + const heights = req.body.heights + + if (!Array.isArray(heights)) { + return res.status(400).json({ + success: false, + error: 'heights needs to be an array. Use GET for single height.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(heights.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + // Validate each height object + for (const item of heights) { + if (!item || typeof item.height !== 'number' || typeof item.count !== 'number') { + return res.status(400).json({ + success: false, + error: 'Each height object must have numeric height and count properties' + }) + } + if (item.height < 0 || item.count < 0) { + return res.status(400).json({ + success: false, + error: 'height and count must be positive numbers' + }) + } + } + + const result = await this.fulcrumUseCases.getBlockHeadersBulk({ heights }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/fulcrum/transactions/:address Get transaction history for a single address + * @apiName GetTransactions + * @apiGroup Fulcrum + * @apiDescription Returns an array of historical transactions associated with an address. Results are returned in descending order (most recent TX first). Passing allTxs=true will return the entire transaction history, otherwise, only the last 100 TXIDs will be returned. + * + * @apiParam {String} address Address + * @apiParam {Boolean} allTxs Optional: return all transactions (default: false, limited to 100) + */ + async getTransactions (req, res) { + try { + const address = req.params.address + let allTxs = false + + // Check if allTxs is in params or query + if (req.params.allTxs) { + allTxs = req.params.allTxs === 'true' + } else if (req.query.allTxs) { + allTxs = req.query.allTxs === 'true' + } + + if (Array.isArray(address)) { + return res.status(400).json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + const cashAddr = this._validateAndConvertAddress(address) + + const result = await this.fulcrumUseCases.getTransactions({ address: cashAddr, allTxs }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/transactions Get the transaction history for an array of addresses + * @apiName GetTransactionsBulk + * @apiGroup Fulcrum + * @apiDescription Returns an array of transactions associated with an array of addresses. Limited to 20 items per request. Passing allTxs=true will return the entire transaction history, otherwise, only the last 100 TXIDs will be returned. + */ + async transactionsBulk (req, res) { + try { + const addresses = req.body.addresses + const allTxs = req.body.allTxs === true + + if (!Array.isArray(addresses)) { + return res.status(400).json({ + success: false, + error: 'addresses needs to be an array. Use GET for single address.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(addresses.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + // Validate and convert all addresses + const validatedAddresses = [] + for (let i = 0; i < addresses.length; i++) { + try { + const cashAddr = this._validateAndConvertAddress(addresses[i]) + validatedAddresses.push(cashAddr) + } catch (err) { + return res.status(400).json({ + success: false, + error: err.message + }) + } + } + + const result = await this.fulcrumUseCases.getTransactionsBulk({ + addresses: validatedAddresses, + allTxs + }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/fulcrum/unconfirmed/:address Get unconfirmed utxos for a single address + * @apiName GetMempool + * @apiGroup Fulcrum + * @apiDescription Returns an object with unconfirmed UTXOs associated with an address. + */ + async getMempool (req, res) { + try { + const address = req.params.address + + if (Array.isArray(address)) { + return res.status(400).json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + const cashAddr = this._validateAndConvertAddress(address) + + const result = await this.fulcrumUseCases.getMempool({ address: cashAddr }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/unconfirmed Get unconfirmed utxos for an array of addresses + * @apiName GetMempoolBulk + * @apiGroup Fulcrum + * @apiDescription Returns an array of objects with unconfirmed UTXOs associated with an address. Limited to 20 items per request. + */ + async mempoolBulk (req, res) { + try { + const addresses = req.body.addresses + + if (!Array.isArray(addresses)) { + return res.status(400).json({ + success: false, + error: 'addresses needs to be an array. Use GET for single address.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(addresses.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + // Validate and convert all addresses + const validatedAddresses = [] + for (let i = 0; i < addresses.length; i++) { + try { + const cashAddr = this._validateAndConvertAddress(addresses[i]) + validatedAddresses.push(cashAddr) + } catch (err) { + return res.status(400).json({ + success: false, + error: err.message + }) + } + } + + const result = await this.fulcrumUseCases.getMempoolBulk({ addresses: validatedAddresses }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + handleError (err, res) { + wlogger.error('Error in FulcrumRESTController:', err) + + const status = err.status || 500 + const message = err.message || 'Internal server error' + + return res.status(status).json({ error: message }) + } +} + +export default FulcrumRESTController diff --git a/src/controllers/rest-api/full-node/fulcrum/router.js b/src/controllers/rest-api/full-node/fulcrum/router.js new file mode 100644 index 0000000..4f75073 --- /dev/null +++ b/src/controllers/rest-api/full-node/fulcrum/router.js @@ -0,0 +1,64 @@ +/* + REST API router for /full-node/fulcrum routes. +*/ + +import express from 'express' +import FulcrumRESTController from './controller.js' + +class FulcrumRouter { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Fulcrum REST Router.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases) { + throw new Error( + 'Instance of Use Cases library required when instantiating Fulcrum REST Router.' + ) + } + + const dependencies = { + adapters: this.adapters, + useCases: this.useCases + } + + this.fulcrumController = new FulcrumRESTController(dependencies) + + this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '') + this.baseUrl = `${this.apiPrefix}/full-node/fulcrum` + if (!this.baseUrl.startsWith('/')) { + this.baseUrl = `/${this.baseUrl}` + } + this.router = express.Router() + } + + attach (app) { + if (!app) { + throw new Error('Must pass app object when attaching REST API controllers.') + } + + this.router.get('/', this.fulcrumController.root) + this.router.get('/balance/:address', this.fulcrumController.getBalance) + this.router.post('/balance', this.fulcrumController.balanceBulk) + this.router.get('/utxos/:address', this.fulcrumController.getUtxos) + this.router.post('/utxos', this.fulcrumController.utxosBulk) + this.router.get('/tx/data/:txid', this.fulcrumController.getTransactionDetails) + this.router.post('/tx/data', this.fulcrumController.transactionDetailsBulk) + this.router.post('/tx/broadcast', this.fulcrumController.broadcastTransaction) + this.router.get('/block/headers/:height', this.fulcrumController.getBlockHeaders) + this.router.post('/block/headers', this.fulcrumController.blockHeadersBulk) + this.router.get('/transactions/:address', this.fulcrumController.getTransactions) + this.router.get('/transactions/:address/:allTxs', this.fulcrumController.getTransactions) + this.router.post('/transactions', this.fulcrumController.transactionsBulk) + this.router.get('/unconfirmed/:address', this.fulcrumController.getMempool) + this.router.post('/unconfirmed', this.fulcrumController.mempoolBulk) + + app.use(this.baseUrl, this.router) + } +} + +export default FulcrumRouter diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index 02961bd..1abd795 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -10,6 +10,7 @@ import BlockchainRouter from './full-node/blockchain/router.js' import ControlRouter from './full-node/control/router.js' import DSProofRouter from './full-node/dsproof/router.js' +import FulcrumRouter from './full-node/fulcrum/router.js' import MiningRouter from './full-node/mining/router.js' import RawTransactionsRouter from './full-node/rawtransactions/router.js' import config from '../../config/index.js' @@ -67,6 +68,9 @@ class RESTControllers { const dsproofRouter = new DSProofRouter(dependencies) dsproofRouter.attach(app) + const fulcrumRouter = new FulcrumRouter(dependencies) + fulcrumRouter.attach(app) + const miningRouter = new MiningRouter(dependencies) miningRouter.attach(app) diff --git a/src/use-cases/full-node-fulcrum-use-cases.js b/src/use-cases/full-node-fulcrum-use-cases.js new file mode 100644 index 0000000..75fa266 --- /dev/null +++ b/src/use-cases/full-node-fulcrum-use-cases.js @@ -0,0 +1,155 @@ +/* + Use cases for interacting with the Fulcrum API service. +*/ + +import wlogger from '../adapters/wlogger.js' +import BCHJS from '@psf/bch-js' + +const bchjs = new BCHJS() + +class FulcrumUseCases { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + + if (!this.adapters) { + throw new Error('Adapters instance required when instantiating Fulcrum use cases.') + } + + this.fulcrum = this.adapters.fulcrum + if (!this.fulcrum) { + throw new Error('Fulcrum adapter required when instantiating Fulcrum use cases.') + } + + // Allow bchjs to be injected for testing + this.bchjs = localConfig.bchjs || bchjs + } + + async getBalance ({ address }) { + return this.fulcrum.get(`electrumx/balance/${address}`) + } + + async getBalances ({ addresses }) { + try { + const response = await this.fulcrum.post('electrumx/balance/', { addresses }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getBalances()', err) + throw err + } + } + + async getUtxos ({ address }) { + return this.fulcrum.get(`electrumx/utxos/${address}`) + } + + async getUtxosBulk ({ addresses }) { + try { + const response = await this.fulcrum.post('electrumx/utxos/', { addresses }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getUtxosBulk()', err) + throw err + } + } + + async getTransactionDetails ({ txid }) { + return this.fulcrum.get(`electrumx/tx/data/${txid}`) + } + + async getTransactionDetailsBulk ({ txids, verbose }) { + try { + const response = await this.fulcrum.post('electrumx/tx/data', { txids, verbose }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getTransactionDetailsBulk()', err) + throw err + } + } + + async broadcastTransaction ({ txHex }) { + try { + const response = await this.fulcrum.post('electrumx/tx/broadcast', { txHex }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.broadcastTransaction()', err) + throw err + } + } + + async getBlockHeaders ({ height, count }) { + return this.fulcrum.get(`electrumx/block/headers/${height}?count=${count}`) + } + + async getBlockHeadersBulk ({ heights }) { + try { + const response = await this.fulcrum.post('electrumx/block/headers', { heights }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getBlockHeadersBulk()', err) + throw err + } + } + + async getTransactions ({ address, allTxs }) { + try { + const response = await this.fulcrum.get(`electrumx/transactions/${address}`) + + // Sort transactions in descending order, so that newest transactions are first. + if (response.transactions && Array.isArray(response.transactions)) { + response.transactions = await this.bchjs.Electrumx.sortAllTxs(response.transactions, 'DESCENDING') + + if (!allTxs) { + // Return only the first 100 transactions of the history. + response.transactions = response.transactions.slice(0, 100) + } + } + + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getTransactions()', err) + throw err + } + } + + async getTransactionsBulk ({ addresses, allTxs }) { + try { + const response = await this.fulcrum.post('electrumx/transactions/', { addresses }) + + // Sort transactions in descending order for each address entry. + if (response.transactions && Array.isArray(response.transactions)) { + for (let i = 0; i < response.transactions.length; i++) { + const thisEntry = response.transactions[i] + if (thisEntry.transactions && Array.isArray(thisEntry.transactions)) { + thisEntry.transactions = await this.bchjs.Electrumx.sortAllTxs(thisEntry.transactions, 'DESCENDING') + + if (!allTxs && thisEntry.transactions.length > 100) { + // Extract only the first 100 transactions. + thisEntry.transactions = thisEntry.transactions.slice(0, 100) + } + } + } + } + + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getTransactionsBulk()', err) + throw err + } + } + + async getMempool ({ address }) { + return this.fulcrum.get(`electrumx/unconfirmed/${address}`) + } + + async getMempoolBulk ({ addresses }) { + try { + const response = await this.fulcrum.post('electrumx/unconfirmed/', { addresses }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getMempoolBulk()', err) + throw err + } + } +} + +export default FulcrumUseCases diff --git a/src/use-cases/index.js b/src/use-cases/index.js index 8d740f1..a769b79 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -8,6 +8,7 @@ import BlockchainUseCases from './full-node-blockchain-use-cases.js' import ControlUseCases from './full-node-control-use-cases.js' import DSProofUseCases from './full-node-dsproof-use-cases.js' +import FulcrumUseCases from './full-node-fulcrum-use-cases.js' import MiningUseCases from './full-node-mining-use-cases.js' import RawTransactionsUseCases from './full-node-rawtransactions-use-cases.js' @@ -23,6 +24,7 @@ class UseCases { this.blockchain = new BlockchainUseCases({ adapters: this.adapters }) this.control = new ControlUseCases({ adapters: this.adapters }) this.dsproof = new DSProofUseCases({ adapters: this.adapters }) + this.fulcrum = new FulcrumUseCases({ adapters: this.adapters }) this.mining = new MiningUseCases({ adapters: this.adapters }) this.rawtransactions = new RawTransactionsUseCases({ adapters: this.adapters }) } diff --git a/test/unit/controllers/fulcrum-controller-unit.js b/test/unit/controllers/fulcrum-controller-unit.js new file mode 100644 index 0000000..6213fee --- /dev/null +++ b/test/unit/controllers/fulcrum-controller-unit.js @@ -0,0 +1,481 @@ +/* + Unit tests for FulcrumRESTController. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' + +import FulcrumRESTController from '../../../src/controllers/rest-api/full-node/fulcrum/controller.js' +import { + createMockRequest, + createMockResponse +} from '../mocks/controller-mocks.js' + +// Valid mainnet cash address for testing +const VALID_MAINNET_ADDRESS = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + +describe('#fulcrum-controller.js', () => { + let sandbox + let mockUseCases + let mockAdapters + let uut + + const createFulcrumUseCaseStubs = () => ({ + getBalance: sandbox.stub().resolves({ balance: 1000 }), + getBalances: sandbox.stub().resolves({ balances: [] }), + getUtxos: sandbox.stub().resolves({ utxos: [] }), + getUtxosBulk: sandbox.stub().resolves({ utxos: [] }), + getTransactionDetails: sandbox.stub().resolves({ txid: 'abc' }), + getTransactionDetailsBulk: sandbox.stub().resolves({ transactions: [] }), + broadcastTransaction: sandbox.stub().resolves({ txid: 'abc' }), + getBlockHeaders: sandbox.stub().resolves({ headers: [] }), + getBlockHeadersBulk: sandbox.stub().resolves({ headers: [] }), + getTransactions: sandbox.stub().resolves({ transactions: [] }), + getTransactionsBulk: sandbox.stub().resolves({ transactions: [] }), + getMempool: sandbox.stub().resolves({ mempool: [] }), + getMempoolBulk: sandbox.stub().resolves({ mempool: [] }) + }) + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = { + fullNode: { + validateArraySize: sandbox.stub().returns(true) + } + } + mockUseCases = { + fulcrum: createFulcrumUseCaseStubs() + } + + uut = new FulcrumRESTController({ + adapters: mockAdapters, + useCases: mockUseCases + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new FulcrumRESTController({ useCases: mockUseCases }) + }, /Adapters library required/) + }) + + it('should require fulcrum use cases', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new FulcrumRESTController({ adapters: mockAdapters, useCases: {} }) + }, /Fulcrum use cases required/) + }) + }) + + describe('#root()', () => { + it('should return service status', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.root(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { status: 'fulcrum' }) + }) + }) + + describe('#getBalance()', () => { + it('should return balance on success', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getBalance(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { balance: 1000 }) + assert.isTrue(mockUseCases.fulcrum.getBalance.calledOnce) + }) + + it('should return error if address is array', async () => { + const req = createMockRequest({ + params: { address: [] } + }) + const res = createMockResponse() + + await uut.getBalance(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + + it('should handle errors via handleError', async () => { + const error = new Error('failure') + error.status = 503 + mockUseCases.fulcrum.getBalance.rejects(error) + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getBalance(req, res) + + assert.equal(res.statusValue, 503) + assert.deepEqual(res.jsonData, { error: 'failure' }) + }) + }) + + describe('#balanceBulk()', () => { + it('should return error if addresses is not array', async () => { + const req = createMockRequest({ + body: { addresses: 'not-an-array' } + }) + const res = createMockResponse() + + await uut.balanceBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + + it('should validate array size and call use case', async () => { + const req = createMockRequest({ + body: { addresses: [VALID_MAINNET_ADDRESS] } + }) + const res = createMockResponse() + + await uut.balanceBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockAdapters.fullNode.validateArraySize.calledOnce) + assert.isTrue(mockUseCases.fulcrum.getBalances.calledOnce) + }) + + it('should return error if array size invalid', async () => { + mockAdapters.fullNode.validateArraySize.returns(false) + const req = createMockRequest({ + body: { addresses: [VALID_MAINNET_ADDRESS] } + }) + const res = createMockResponse() + + await uut.balanceBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.equal(res.jsonData.error, 'Array too large.') + }) + }) + + describe('#getUtxos()', () => { + it('should return utxos on success', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getUtxos(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { utxos: [] }) + assert.isTrue(mockUseCases.fulcrum.getUtxos.calledOnce) + }) + }) + + describe('#utxosBulk()', () => { + it('should validate array and call use case', async () => { + const req = createMockRequest({ + body: { addresses: [VALID_MAINNET_ADDRESS] } + }) + const res = createMockResponse() + + await uut.utxosBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getUtxosBulk.calledOnce) + }) + }) + + describe('#getTransactionDetails()', () => { + it('should return transaction details on success', async () => { + const txid = 'a'.repeat(64) + const req = createMockRequest({ + params: { txid } + }) + const res = createMockResponse() + + await uut.getTransactionDetails(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { txid: 'abc' }) + assert.isTrue(mockUseCases.fulcrum.getTransactionDetails.calledOnce) + }) + + it('should return error if txid is not string', async () => { + const req = createMockRequest({ + params: { txid: 123 } + }) + const res = createMockResponse() + + await uut.getTransactionDetails(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + }) + + describe('#transactionDetailsBulk()', () => { + it('should validate array and call use case', async () => { + const req = createMockRequest({ + body: { txids: ['a'.repeat(64)], verbose: true } + }) + const res = createMockResponse() + + await uut.transactionDetailsBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getTransactionDetailsBulk.calledOnce) + }) + + it('should default verbose to true', async () => { + const req = createMockRequest({ + body: { txids: ['a'.repeat(64)] } + }) + const res = createMockResponse() + + await uut.transactionDetailsBulk(req, res) + + assert.isTrue( + mockUseCases.fulcrum.getTransactionDetailsBulk.calledWithMatch({ + txids: ['a'.repeat(64)], + verbose: true + }) + ) + }) + }) + + describe('#broadcastTransaction()', () => { + it('should broadcast transaction on success', async () => { + const req = createMockRequest({ + body: { txHex: '010203' } + }) + const res = createMockResponse() + + await uut.broadcastTransaction(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { txid: 'abc' }) + assert.isTrue(mockUseCases.fulcrum.broadcastTransaction.calledOnce) + }) + + it('should return error if txHex is not string', async () => { + const req = createMockRequest({ + body: { txHex: 123 } + }) + const res = createMockResponse() + + await uut.broadcastTransaction(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + }) + + describe('#getBlockHeaders()', () => { + it('should return block headers on success', async () => { + const req = createMockRequest({ + params: { height: '100' }, + query: { count: '2' } + }) + const res = createMockResponse() + + await uut.getBlockHeaders(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue( + mockUseCases.fulcrum.getBlockHeaders.calledWithMatch({ + height: 100, + count: 2 + }) + ) + }) + + it('should default count to 1', async () => { + const req = createMockRequest({ + params: { height: '100' } + }) + const res = createMockResponse() + + await uut.getBlockHeaders(req, res) + + assert.isTrue( + mockUseCases.fulcrum.getBlockHeaders.calledWithMatch({ + height: 100, + count: 1 + }) + ) + }) + + it('should return error if height is invalid', async () => { + const req = createMockRequest({ + params: { height: 'invalid' } + }) + const res = createMockResponse() + + await uut.getBlockHeaders(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + }) + + describe('#blockHeadersBulk()', () => { + it('should validate heights array and call use case', async () => { + const req = createMockRequest({ + body: { heights: [{ height: 100, count: 2 }] } + }) + const res = createMockResponse() + + await uut.blockHeadersBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getBlockHeadersBulk.calledOnce) + }) + + it('should return error if heights is not array', async () => { + const req = createMockRequest({ + body: { heights: 'not-an-array' } + }) + const res = createMockResponse() + + await uut.blockHeadersBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + + it('should validate height objects', async () => { + const req = createMockRequest({ + body: { heights: [{ height: 'invalid', count: 2 }] } + }) + const res = createMockResponse() + + await uut.blockHeadersBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + }) + + describe('#getTransactions()', () => { + it('should return transactions on success', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getTransactions(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getTransactions.calledOnce) + }) + + it('should handle allTxs from params', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS, allTxs: 'true' } + }) + const res = createMockResponse() + + await uut.getTransactions(req, res) + + assert.isTrue( + mockUseCases.fulcrum.getTransactions.calledWithMatch({ + address: VALID_MAINNET_ADDRESS, + allTxs: true + }) + ) + }) + + it('should handle allTxs from query', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS }, + query: { allTxs: 'true' } + }) + const res = createMockResponse() + + await uut.getTransactions(req, res) + + assert.isTrue( + mockUseCases.fulcrum.getTransactions.calledWithMatch({ + allTxs: true + }) + ) + }) + }) + + describe('#transactionsBulk()', () => { + it('should validate addresses and call use case', async () => { + const req = createMockRequest({ + body: { addresses: [VALID_MAINNET_ADDRESS], allTxs: true } + }) + const res = createMockResponse() + + await uut.transactionsBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getTransactionsBulk.calledOnce) + }) + }) + + describe('#getMempool()', () => { + it('should return mempool on success', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getMempool(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { mempool: [] }) + assert.isTrue(mockUseCases.fulcrum.getMempool.calledOnce) + }) + }) + + describe('#mempoolBulk()', () => { + it('should validate addresses and call use case', async () => { + const req = createMockRequest({ + body: { addresses: [VALID_MAINNET_ADDRESS] } + }) + const res = createMockResponse() + + await uut.mempoolBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getMempoolBulk.calledOnce) + }) + }) + + describe('#handleError()', () => { + it('should handle errors with status', async () => { + const error = new Error('test error') + error.status = 400 + const res = createMockResponse() + + uut.handleError(error, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'test error' }) + }) + + it('should default status to 500', async () => { + const error = new Error('test error') + const res = createMockResponse() + + uut.handleError(error, res) + + assert.equal(res.statusValue, 500) + assert.deepEqual(res.jsonData, { error: 'test error' }) + }) + }) +}) diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js index 1ecccc1..1814509 100644 --- a/test/unit/controllers/rest-api-index-unit.js +++ b/test/unit/controllers/rest-api-index-unit.js @@ -9,6 +9,7 @@ import RESTControllers from '../../../src/controllers/rest-api/index.js' import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockchain/router.js' import ControlRouter from '../../../src/controllers/rest-api/full-node/control/router.js' import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/router.js' +import FulcrumRouter from '../../../src/controllers/rest-api/full-node/fulcrum/router.js' import MiningRouter from '../../../src/controllers/rest-api/full-node/mining/router.js' import RawTransactionsRouter from '../../../src/controllers/rest-api/full-node/rawtransactions/router.js' @@ -54,6 +55,21 @@ describe('#controllers/rest-api/index.js', () => { dsproof: { getDSProof: () => {} }, + fulcrum: { + getBalance: () => {}, + getBalances: () => {}, + getUtxos: () => {}, + getUtxosBulk: () => {}, + getTransactionDetails: () => {}, + getTransactionDetailsBulk: () => {}, + broadcastTransaction: () => {}, + getBlockHeaders: () => {}, + getBlockHeadersBulk: () => {}, + getTransactions: () => {}, + getTransactionsBulk: () => {}, + getMempool: () => {}, + getMempoolBulk: () => {} + }, mining: { getMiningInfo: () => {}, getNetworkHashPS: () => {} @@ -97,6 +113,7 @@ describe('#controllers/rest-api/index.js', () => { const blockchainAttachStub = sandbox.stub(BlockchainRouter.prototype, 'attach') const controlAttachStub = sandbox.stub(ControlRouter.prototype, 'attach') const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach') + const fulcrumAttachStub = sandbox.stub(FulcrumRouter.prototype, 'attach') const miningAttachStub = sandbox.stub(MiningRouter.prototype, 'attach') const rawtransactionsAttachStub = sandbox.stub(RawTransactionsRouter.prototype, 'attach') const restControllers = new RESTControllers({ @@ -113,6 +130,8 @@ describe('#controllers/rest-api/index.js', () => { assert.equal(controlAttachStub.getCall(0).args[0], app) assert.isTrue(dsproofAttachStub.calledOnce) assert.equal(dsproofAttachStub.getCall(0).args[0], app) + assert.isTrue(fulcrumAttachStub.calledOnce) + assert.equal(fulcrumAttachStub.getCall(0).args[0], app) assert.isTrue(miningAttachStub.calledOnce) assert.equal(miningAttachStub.getCall(0).args[0], app) assert.isTrue(rawtransactionsAttachStub.calledOnce) diff --git a/test/unit/use-cases/full-node-fulcrum-use-cases-unit.js b/test/unit/use-cases/full-node-fulcrum-use-cases-unit.js new file mode 100644 index 0000000..266bc95 --- /dev/null +++ b/test/unit/use-cases/full-node-fulcrum-use-cases-unit.js @@ -0,0 +1,297 @@ +/* + Unit tests for FulcrumUseCases. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' +import BCHJS from '@psf/bch-js' + +import FulcrumUseCases from '../../../src/use-cases/full-node-fulcrum-use-cases.js' + +describe('#full-node-fulcrum-use-cases.js', () => { + let sandbox + let mockAdapters + let uut + let sortAllTxsStub + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = { + fulcrum: { + get: sandbox.stub().resolves({}), + post: sandbox.stub().resolves({}) + } + } + + // Create a mock BCHJS instance with stubbed sortAllTxs method + const mockBchjs = new BCHJS() + if (!mockBchjs.Electrumx) { + mockBchjs.Electrumx = {} + } + + // Create a stub that sorts transactions + sortAllTxsStub = sandbox.stub(mockBchjs.Electrumx, 'sortAllTxs') + sortAllTxsStub.callsFake(async (txs, order) => { + const sorted = [...txs].sort((a, b) => { + if (order === 'DESCENDING') { + return (b.height || 0) - (a.height || 0) + } + return (a.height || 0) - (b.height || 0) + }) + return sorted + }) + + // Inject the mocked bchjs instance into the use cases + uut = new FulcrumUseCases({ adapters: mockAdapters, bchjs: mockBchjs }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new FulcrumUseCases() + }, /Adapters instance required/) + }) + + it('should require fulcrum adapter', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new FulcrumUseCases({ adapters: {} }) + }, /Fulcrum adapter required/) + }) + }) + + describe('#getBalance()', () => { + it('should call fulcrum adapter get method', async () => { + const address = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + mockAdapters.fulcrum.get.resolves({ balance: 1000 }) + + const result = await uut.getBalance({ address }) + + assert.isTrue(mockAdapters.fulcrum.get.calledOnceWith(`electrumx/balance/${address}`)) + assert.deepEqual(result, { balance: 1000 }) + }) + }) + + describe('#getBalances()', () => { + it('should call fulcrum adapter post method', async () => { + const addresses = ['bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf'] + mockAdapters.fulcrum.post.resolves({ balances: [] }) + + const result = await uut.getBalances({ addresses }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/balance/', { addresses }) + ) + assert.deepEqual(result, { balances: [] }) + }) + }) + + describe('#getUtxos()', () => { + it('should call fulcrum adapter get method', async () => { + const address = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + mockAdapters.fulcrum.get.resolves({ utxos: [] }) + + const result = await uut.getUtxos({ address }) + + assert.isTrue(mockAdapters.fulcrum.get.calledOnceWith(`electrumx/utxos/${address}`)) + assert.deepEqual(result, { utxos: [] }) + }) + }) + + describe('#getUtxosBulk()', () => { + it('should call fulcrum adapter post method', async () => { + const addresses = ['bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf'] + mockAdapters.fulcrum.post.resolves({ utxos: [] }) + + const result = await uut.getUtxosBulk({ addresses }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/utxos/', { addresses }) + ) + assert.deepEqual(result, { utxos: [] }) + }) + }) + + describe('#getTransactionDetails()', () => { + it('should call fulcrum adapter get method', async () => { + const txid = 'a'.repeat(64) + mockAdapters.fulcrum.get.resolves({ txid }) + + const result = await uut.getTransactionDetails({ txid }) + + assert.isTrue(mockAdapters.fulcrum.get.calledOnceWith(`electrumx/tx/data/${txid}`)) + assert.deepEqual(result, { txid }) + }) + }) + + describe('#getTransactionDetailsBulk()', () => { + it('should call fulcrum adapter post method with verbose', async () => { + const txids = ['a'.repeat(64)] + const verbose = true + mockAdapters.fulcrum.post.resolves({ transactions: [] }) + + const result = await uut.getTransactionDetailsBulk({ txids, verbose }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/tx/data', { txids, verbose }) + ) + assert.deepEqual(result, { transactions: [] }) + }) + }) + + describe('#broadcastTransaction()', () => { + it('should call fulcrum adapter post method', async () => { + const txHex = '010203' + mockAdapters.fulcrum.post.resolves({ txid: 'abc' }) + + const result = await uut.broadcastTransaction({ txHex }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/tx/broadcast', { txHex }) + ) + assert.deepEqual(result, { txid: 'abc' }) + }) + }) + + describe('#getBlockHeaders()', () => { + it('should call fulcrum adapter get method with height and count', async () => { + const height = 100 + const count = 2 + mockAdapters.fulcrum.get.resolves({ headers: [] }) + + const result = await uut.getBlockHeaders({ height, count }) + + assert.isTrue( + mockAdapters.fulcrum.get.calledOnceWith(`electrumx/block/headers/${height}?count=${count}`) + ) + assert.deepEqual(result, { headers: [] }) + }) + }) + + describe('#getBlockHeadersBulk()', () => { + it('should call fulcrum adapter post method', async () => { + const heights = [{ height: 100, count: 2 }] + mockAdapters.fulcrum.post.resolves({ headers: [] }) + + const result = await uut.getBlockHeadersBulk({ heights }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/block/headers', { heights }) + ) + assert.deepEqual(result, { headers: [] }) + }) + }) + + describe('#getTransactions()', () => { + it('should call fulcrum adapter and sort transactions when allTxs is false', async () => { + const address = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + const allTxs = false + const mockTransactions = [ + { tx_hash: 'aaa', height: 100 }, + { tx_hash: 'bbb', height: 200 }, + { tx_hash: 'ccc', height: 150 } + ] + mockAdapters.fulcrum.get.resolves({ + transactions: mockTransactions + }) + + const result = await uut.getTransactions({ address, allTxs }) + + assert.isTrue(mockAdapters.fulcrum.get.calledOnceWith(`electrumx/transactions/${address}`)) + assert.property(result, 'transactions') + // Transactions should be sorted and limited to 100 + if (result.transactions && result.transactions.length > 100) { + assert.isAtMost(result.transactions.length, 100) + } + }) + + it('should return all transactions when allTxs is true', async () => { + const address = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + const allTxs = true + const mockTransactions = Array(150).fill({ tx_hash: 'aaa', height: 100 }) + mockAdapters.fulcrum.get.resolves({ + transactions: mockTransactions + }) + + const result = await uut.getTransactions({ address, allTxs }) + + assert.property(result, 'transactions') + // All transactions should be returned when allTxs is true + assert.equal(result.transactions.length, 150) + }) + }) + + describe('#getTransactionsBulk()', () => { + it('should call fulcrum adapter and sort transactions for each address', async () => { + const addresses = ['bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf'] + const allTxs = false + const mockResponse = { + transactions: [ + { + transactions: [ + { tx_hash: 'aaa', height: 100 }, + { tx_hash: 'bbb', height: 200 } + ] + } + ] + } + mockAdapters.fulcrum.post.resolves(mockResponse) + + const result = await uut.getTransactionsBulk({ addresses, allTxs }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/transactions/', { addresses }) + ) + assert.property(result, 'transactions') + }) + + it('should limit to 100 transactions when allTxs is false', async () => { + const addresses = ['bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf'] + const allTxs = false + const mockTransactions = Array(150).fill({ tx_hash: 'aaa', height: 100 }) + const mockResponse = { + transactions: [ + { + transactions: mockTransactions + } + ] + } + mockAdapters.fulcrum.post.resolves(mockResponse) + + const result = await uut.getTransactionsBulk({ addresses, allTxs }) + + assert.isAtMost(result.transactions[0].transactions.length, 100) + }) + }) + + describe('#getMempool()', () => { + it('should call fulcrum adapter get method', async () => { + const address = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + mockAdapters.fulcrum.get.resolves({ mempool: [] }) + + const result = await uut.getMempool({ address }) + + assert.isTrue(mockAdapters.fulcrum.get.calledOnceWith(`electrumx/unconfirmed/${address}`)) + assert.deepEqual(result, { mempool: [] }) + }) + }) + + describe('#getMempoolBulk()', () => { + it('should call fulcrum adapter post method', async () => { + const addresses = ['bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf'] + mockAdapters.fulcrum.post.resolves({ mempool: [] }) + + const result = await uut.getMempoolBulk({ addresses }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/unconfirmed/', { addresses }) + ) + assert.deepEqual(result, { mempool: [] }) + }) + }) +}) From d104405f171ee872ea625646b487d70cbdf3cc63 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 14 Nov 2025 08:46:34 -0800 Subject: [PATCH 07/13] Moving fulcrum rest api libs --- src/controllers/rest-api/{full-node => }/fulcrum/controller.js | 2 +- src/controllers/rest-api/{full-node => }/fulcrum/router.js | 0 src/controllers/rest-api/index.js | 2 +- test/unit/controllers/fulcrum-controller-unit.js | 2 +- test/unit/controllers/rest-api-index-unit.js | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename src/controllers/rest-api/{full-node => }/fulcrum/controller.js (99%) rename src/controllers/rest-api/{full-node => }/fulcrum/router.js (100%) diff --git a/src/controllers/rest-api/full-node/fulcrum/controller.js b/src/controllers/rest-api/fulcrum/controller.js similarity index 99% rename from src/controllers/rest-api/full-node/fulcrum/controller.js rename to src/controllers/rest-api/fulcrum/controller.js index 5c172b0..2da0cab 100644 --- a/src/controllers/rest-api/full-node/fulcrum/controller.js +++ b/src/controllers/rest-api/fulcrum/controller.js @@ -2,7 +2,7 @@ REST API Controller for the /full-node/fulcrum routes. */ -import wlogger from '../../../../adapters/wlogger.js' +import wlogger from '../../../adapters/wlogger.js' import BCHJS from '@psf/bch-js' const bchjs = new BCHJS() diff --git a/src/controllers/rest-api/full-node/fulcrum/router.js b/src/controllers/rest-api/fulcrum/router.js similarity index 100% rename from src/controllers/rest-api/full-node/fulcrum/router.js rename to src/controllers/rest-api/fulcrum/router.js diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index 1abd795..2cfab3a 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -10,7 +10,7 @@ import BlockchainRouter from './full-node/blockchain/router.js' import ControlRouter from './full-node/control/router.js' import DSProofRouter from './full-node/dsproof/router.js' -import FulcrumRouter from './full-node/fulcrum/router.js' +import FulcrumRouter from './fulcrum/router.js' import MiningRouter from './full-node/mining/router.js' import RawTransactionsRouter from './full-node/rawtransactions/router.js' import config from '../../config/index.js' diff --git a/test/unit/controllers/fulcrum-controller-unit.js b/test/unit/controllers/fulcrum-controller-unit.js index 6213fee..d39b1d1 100644 --- a/test/unit/controllers/fulcrum-controller-unit.js +++ b/test/unit/controllers/fulcrum-controller-unit.js @@ -5,7 +5,7 @@ import { assert } from 'chai' import sinon from 'sinon' -import FulcrumRESTController from '../../../src/controllers/rest-api/full-node/fulcrum/controller.js' +import FulcrumRESTController from '../../../src/controllers/rest-api/fulcrum/controller.js' import { createMockRequest, createMockResponse diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js index 1814509..dcfa79d 100644 --- a/test/unit/controllers/rest-api-index-unit.js +++ b/test/unit/controllers/rest-api-index-unit.js @@ -9,9 +9,9 @@ import RESTControllers from '../../../src/controllers/rest-api/index.js' import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockchain/router.js' import ControlRouter from '../../../src/controllers/rest-api/full-node/control/router.js' import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/router.js' -import FulcrumRouter from '../../../src/controllers/rest-api/full-node/fulcrum/router.js' import MiningRouter from '../../../src/controllers/rest-api/full-node/mining/router.js' import RawTransactionsRouter from '../../../src/controllers/rest-api/full-node/rawtransactions/router.js' +import FulcrumRouter from '../../../src/controllers/rest-api/fulcrum/router.js' describe('#controllers/rest-api/index.js', () => { let sandbox From 81a1241b5d9bfedee049516eee6bfe8f7d8955b0 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 15 Nov 2025 07:03:13 -0800 Subject: [PATCH 08/13] Renaming fulcrum libraries --- .env-local | 6 ++++++ ...{full-node-fulcrum-use-cases.js => fulcrum-use-cases.js} | 0 src/use-cases/index.js | 2 +- ...-fulcrum-use-cases-unit.js => fulcrum-use-cases-unit.js} | 4 ++-- 4 files changed, 9 insertions(+), 3 deletions(-) rename src/use-cases/{full-node-fulcrum-use-cases.js => fulcrum-use-cases.js} (100%) rename test/unit/use-cases/{full-node-fulcrum-use-cases-unit.js => fulcrum-use-cases-unit.js} (98%) diff --git a/.env-local b/.env-local index 36974a9..66a0217 100644 --- a/.env-local +++ b/.env-local @@ -2,3 +2,9 @@ RPC_BASEURL=http://172.17.0.1:8332 RPC_USERNAME=bitcoin RPC_PASSWORD=password + +# x402 payments required to access this API? +X402_ENABLED=false + +# Fulcrum Indexer +FULCRUM_API=http://192.168.2.127:3001 \ No newline at end of file diff --git a/src/use-cases/full-node-fulcrum-use-cases.js b/src/use-cases/fulcrum-use-cases.js similarity index 100% rename from src/use-cases/full-node-fulcrum-use-cases.js rename to src/use-cases/fulcrum-use-cases.js diff --git a/src/use-cases/index.js b/src/use-cases/index.js index a769b79..bd4d60b 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -8,7 +8,7 @@ import BlockchainUseCases from './full-node-blockchain-use-cases.js' import ControlUseCases from './full-node-control-use-cases.js' import DSProofUseCases from './full-node-dsproof-use-cases.js' -import FulcrumUseCases from './full-node-fulcrum-use-cases.js' +import FulcrumUseCases from './fulcrum-use-cases.js' import MiningUseCases from './full-node-mining-use-cases.js' import RawTransactionsUseCases from './full-node-rawtransactions-use-cases.js' diff --git a/test/unit/use-cases/full-node-fulcrum-use-cases-unit.js b/test/unit/use-cases/fulcrum-use-cases-unit.js similarity index 98% rename from test/unit/use-cases/full-node-fulcrum-use-cases-unit.js rename to test/unit/use-cases/fulcrum-use-cases-unit.js index 266bc95..8e860af 100644 --- a/test/unit/use-cases/full-node-fulcrum-use-cases-unit.js +++ b/test/unit/use-cases/fulcrum-use-cases-unit.js @@ -6,9 +6,9 @@ import { assert } from 'chai' import sinon from 'sinon' import BCHJS from '@psf/bch-js' -import FulcrumUseCases from '../../../src/use-cases/full-node-fulcrum-use-cases.js' +import FulcrumUseCases from '../../../src/use-cases/fulcrum-use-cases.js' -describe('#full-node-fulcrum-use-cases.js', () => { +describe('#fulcrum-use-cases.js', () => { let sandbox let mockAdapters let uut From a37d088b52030c53929e89654a47f48b22e66309 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 15 Nov 2025 07:07:00 -0800 Subject: [PATCH 09/13] Removing entities placeholder --- src/entities/event.js | 71 ---------------- test/unit/entities/event-unit.js | 139 ------------------------------- 2 files changed, 210 deletions(-) delete mode 100644 src/entities/event.js delete mode 100644 test/unit/entities/event-unit.js diff --git a/src/entities/event.js b/src/entities/event.js deleted file mode 100644 index 558d5be..0000000 --- a/src/entities/event.js +++ /dev/null @@ -1,71 +0,0 @@ -/* - Event entity - represents a Nostr event. - This is a domain model following Clean Architecture principles. -*/ - -class Event { - constructor (data) { - this.id = data.id - this.pubkey = data.pubkey - this.created_at = data.created_at - this.kind = data.kind - this.tags = data.tags || [] - this.content = data.content - this.sig = data.sig - } - - /** - * Validates the event structure - * @returns {boolean} True if valid - */ - isValid () { - if (!this.id || !this.pubkey || !this.created_at || this.kind === undefined || !this.sig) { - return false - } - - // Basic type checks - if (typeof this.id !== 'string' || this.id.length !== 64) { - return false - } - - if (typeof this.pubkey !== 'string' || this.pubkey.length !== 64) { - return false - } - - if (typeof this.created_at !== 'number') { - return false - } - - if (typeof this.kind !== 'number' || this.kind < 0 || this.kind > 65535) { - return false - } - - if (typeof this.sig !== 'string' || this.sig.length !== 128) { - return false - } - - if (!Array.isArray(this.tags)) { - return false - } - - return true - } - - /** - * Convert to plain object - * @returns {Object} Plain event object - */ - toJSON () { - return { - id: this.id, - pubkey: this.pubkey, - created_at: this.created_at, - kind: this.kind, - tags: this.tags, - content: this.content, - sig: this.sig - } - } -} - -export default Event diff --git a/test/unit/entities/event-unit.js b/test/unit/entities/event-unit.js deleted file mode 100644 index 5bf3422..0000000 --- a/test/unit/entities/event-unit.js +++ /dev/null @@ -1,139 +0,0 @@ -/* - Unit tests for the Event entity. -*/ - -// npm libraries -import { assert } from 'chai' - -// Mocking data libraries -import { - mockKind0Event, - mockKind1Event, - mockKind3Event, - mockKind7Event, - mockInvalidEventMissingId, - mockInvalidEventWrongIdLength, - mockInvalidEventMissingPubkey, - mockInvalidEventWrongPubkeyLength, - mockInvalidEventMissingCreatedAt, - mockInvalidEventWrongCreatedAtType, - mockInvalidEventMissingKind, - mockInvalidEventKindOutOfRange, - mockInvalidEventMissingSig, - mockInvalidEventWrongSigLength, - mockInvalidEventTagsNotArray -} from '../mocks/event-mocks.js' - -// Unit under test -import Event from '../../../src/entities/event.js' - -describe('#event.js', () => { - describe('#isValid()', () => { - it('should return true for valid kind 0 event', () => { - const event = new Event(mockKind0Event) - assert.isTrue(event.isValid()) - }) - - it('should return true for valid kind 1 event', () => { - const event = new Event(mockKind1Event) - assert.isTrue(event.isValid()) - }) - - it('should return true for valid kind 3 event', () => { - const event = new Event(mockKind3Event) - assert.isTrue(event.isValid()) - }) - - it('should return true for valid kind 7 event', () => { - const event = new Event(mockKind7Event) - assert.isTrue(event.isValid()) - }) - - it('should return false for event missing id', () => { - const event = new Event(mockInvalidEventMissingId) - assert.isFalse(event.isValid()) - }) - - it('should return false for event with wrong id length', () => { - const event = new Event(mockInvalidEventWrongIdLength) - assert.isFalse(event.isValid()) - }) - - it('should return false for event missing pubkey', () => { - const event = new Event(mockInvalidEventMissingPubkey) - assert.isFalse(event.isValid()) - }) - - it('should return false for event with wrong pubkey length', () => { - const event = new Event(mockInvalidEventWrongPubkeyLength) - assert.isFalse(event.isValid()) - }) - - it('should return false for event missing created_at', () => { - const event = new Event(mockInvalidEventMissingCreatedAt) - assert.isFalse(event.isValid()) - }) - - it('should return false for event with wrong created_at type', () => { - const event = new Event(mockInvalidEventWrongCreatedAtType) - assert.isFalse(event.isValid()) - }) - - it('should return false for event missing kind', () => { - const event = new Event(mockInvalidEventMissingKind) - assert.isFalse(event.isValid()) - }) - - it('should return false for event with kind out of range', () => { - const event = new Event(mockInvalidEventKindOutOfRange) - assert.isFalse(event.isValid()) - }) - - it('should return false for event missing sig', () => { - const event = new Event(mockInvalidEventMissingSig) - assert.isFalse(event.isValid()) - }) - - it('should return false for event with wrong sig length', () => { - const event = new Event(mockInvalidEventWrongSigLength) - assert.isFalse(event.isValid()) - }) - - it('should return false for event with tags not an array', () => { - const event = new Event(mockInvalidEventTagsNotArray) - assert.isFalse(event.isValid()) - }) - }) - - describe('#toJSON()', () => { - it('should serialize event to JSON correctly', () => { - const event = new Event(mockKind1Event) - const json = event.toJSON() - - assert.property(json, 'id') - assert.property(json, 'pubkey') - assert.property(json, 'created_at') - assert.property(json, 'kind') - assert.property(json, 'tags') - assert.property(json, 'content') - assert.property(json, 'sig') - - assert.equal(json.id, mockKind1Event.id) - assert.equal(json.pubkey, mockKind1Event.pubkey) - assert.equal(json.created_at, mockKind1Event.created_at) - assert.equal(json.kind, mockKind1Event.kind) - assert.deepEqual(json.tags, mockKind1Event.tags) - assert.equal(json.content, mockKind1Event.content) - assert.equal(json.sig, mockKind1Event.sig) - }) - - it('should serialize event with tags correctly', () => { - const event = new Event(mockKind3Event) - const json = event.toJSON() - - assert.isArray(json.tags) - assert.equal(json.tags.length, 1) - assert.deepEqual(json.tags, mockKind3Event.tags) - }) - }) -}) From 5a5119716a4c2a8524a3877734786f4a0706690d Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 15 Nov 2025 17:03:02 -0800 Subject: [PATCH 10/13] feat(slp): Ported SLP endpoints from bch-api --- .env-local | 5 +- package-lock.json | 1370 +++++++++++++++--- package.json | 2 + src/adapters/index.js | 2 + src/adapters/slp-indexer-api.js | 124 ++ src/config/env/common.js | 12 + src/controllers/rest-api/index.js | 4 + src/controllers/rest-api/slp/controller.js | 245 ++++ src/controllers/rest-api/slp/router.js | 56 + src/use-cases/index.js | 2 + src/use-cases/slp-use-cases.js | 333 +++++ test/unit/controllers/rest-api-index-unit.js | 15 + test/unit/controllers/slp-controller-unit.js | 369 +++++ test/unit/use-cases/slp-use-cases-unit.js | 311 ++++ 14 files changed, 2669 insertions(+), 181 deletions(-) create mode 100644 src/adapters/slp-indexer-api.js create mode 100644 src/controllers/rest-api/slp/controller.js create mode 100644 src/controllers/rest-api/slp/router.js create mode 100644 src/use-cases/slp-use-cases.js create mode 100644 test/unit/controllers/slp-controller-unit.js create mode 100644 test/unit/use-cases/slp-use-cases-unit.js diff --git a/.env-local b/.env-local index 66a0217..92b69cf 100644 --- a/.env-local +++ b/.env-local @@ -7,4 +7,7 @@ RPC_PASSWORD=password X402_ENABLED=false # Fulcrum Indexer -FULCRUM_API=http://192.168.2.127:3001 \ No newline at end of file +FULCRUM_API=http://192.168.2.127:3001 + +# SLP Indexer +SLP_INDEXER_API=http://192.168.2.127:5010 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cb3c2f2..1c9eb0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "cors": "2.8.5", "dotenv": "16.3.1", "express": "5.1.0", + "minimal-slp-wallet": "5.13.3", + "slp-token-media": "1.2.10", "winston": "3.11.0", "winston-daily-rotate-file": "4.7.1", "x402-bch-express": "1.1.1" @@ -50,6 +52,16 @@ "node": ">=10.15.1" } }, + "node_modules/@chris.troutner/retry-queue-commonjs": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@chris.troutner/retry-queue-commonjs/-/retry-queue-commonjs-1.0.8.tgz", + "integrity": "sha512-jEHmCKffjIXTm0d/YcRQzCDPbzW2NFTEO72b1vmETnmATO57MmO39F37gEaq7J7lnF5JMxuM3Oq+FWa1L7HXxA==", + "license": "MIT", + "dependencies": { + "p-queue": "4.0.0", + "p-retry": "4.6.2" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -74,7 +86,6 @@ "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -87,7 +98,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -104,7 +114,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -121,7 +130,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -138,7 +146,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -155,7 +162,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -172,7 +178,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -189,7 +194,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -206,7 +210,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -223,7 +226,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -240,7 +242,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -257,7 +258,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -274,7 +274,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -291,7 +290,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -308,7 +306,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -325,7 +322,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -342,7 +338,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -359,7 +354,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -376,7 +370,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -393,7 +386,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -410,7 +402,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -427,7 +418,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -444,7 +434,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -672,7 +661,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -683,7 +671,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -693,7 +680,6 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -704,14 +690,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -971,7 +955,6 @@ "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "*", @@ -982,7 +965,6 @@ "version": "3.7.7", "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, "license": "MIT", "dependencies": { "@types/eslint": "*", @@ -993,7 +975,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -1007,7 +988,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -1021,12 +1001,17 @@ "version": "24.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -1044,7 +1029,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", @@ -1055,28 +1039,24 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", @@ -1088,14 +1068,12 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -1108,7 +1086,6 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, "license": "MIT", "dependencies": { "@xtuc/ieee754": "^1.2.0" @@ -1118,7 +1095,6 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@xtuc/long": "4.2.2" @@ -1128,14 +1104,12 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -1152,7 +1126,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -1166,7 +1139,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -1179,7 +1151,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -1194,7 +1165,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -1205,7 +1175,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", - "dev": true, "license": "MIT", "peerDependencies": { "webpack": "4.x.x || 5.x.x", @@ -1216,7 +1185,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", - "dev": true, "license": "MIT", "dependencies": { "envinfo": "^7.7.3" @@ -1229,7 +1197,6 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", - "dev": true, "license": "MIT", "peerDependencies": { "webpack-cli": "4.x.x" @@ -1244,14 +1211,12 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, "license": "Apache-2.0" }, "node_modules/accepts": { @@ -1271,7 +1236,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1284,7 +1248,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.13.0" @@ -1324,7 +1287,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -1342,7 +1304,6 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1359,7 +1320,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/ansi-regex": { @@ -1392,7 +1352,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -1448,7 +1407,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { @@ -1694,7 +1652,6 @@ "version": "2.8.25", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", "integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", - "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -1709,6 +1666,539 @@ "node": ">=4.5.0" } }, + "node_modules/bch-consumer": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/bch-consumer/-/bch-consumer-1.6.2.tgz", + "integrity": "sha512-PclXVzmWtwqxe2ba6Mo9QbqxJ+5rUog8c+Ro5PV/W3IxkKNotVRa71bLei99AIELDO3A3ljfXkoCNsSM7tR37A==", + "license": "MIT", + "dependencies": { + "@psf/bch-js": "6.7.3", + "apidoc": "0.51.0", + "axios": "0.25.0" + } + }, + "node_modules/bch-consumer/node_modules/@psf/bch-js": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/@psf/bch-js/-/bch-js-6.7.3.tgz", + "integrity": "sha512-z6oJvPAXxSObPhqUAxCkCuPL2bp3Bs1oyhBZpQ5SR6cbJzcOMhuI4bHho2srH5agSCU+bbjehBMRGvo18eGW0A==", + "license": "MIT", + "dependencies": { + "@chris.troutner/bip32-utils": "1.0.5", + "@psf/bip21": "2.0.1", + "@psf/bitcoincash-ops": "2.0.0", + "@psf/bitcoincashjs-lib": "4.0.3", + "@psf/coininfo": "4.0.0", + "axios": "0.26.1", + "bc-bip68": "1.0.5", + "bchaddrjs-slp": "0.2.5", + "bigi": "1.4.2", + "bignumber.js": "9.0.0", + "bip-schnorr": "0.3.0", + "bip38": "2.0.2", + "bip39": "3.0.2", + "bip66": "1.1.5", + "bitcoinjs-message": "2.0.0", + "bs58": "4.0.1", + "ecashaddrjs": "1.0.7", + "ini": "1.3.8", + "randombytes": "2.0.6", + "safe-buffer": "5.1.2", + "satoshi-bitcoin": "1.0.4", + "slp-mdm": "0.0.6", + "slp-parser": "0.0.4", + "wif": "2.0.6" + } + }, + "node_modules/bch-consumer/node_modules/@psf/bch-js/node_modules/axios": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", + "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.8" + } + }, + "node_modules/bch-consumer/node_modules/apidoc": { + "version": "0.51.0", + "resolved": "https://registry.npmjs.org/apidoc/-/apidoc-0.51.0.tgz", + "integrity": "sha512-3P4srhm6NA+kE/YRM4qL5jESElpQQJL+Z8n7hyr4uC+1DSwGzmE6adXPVxuT/hpDyShrqmRlHk8hf40RSqEsNw==", + "license": "MIT", + "os": [ + "darwin", + "freebsd", + "linux", + "openbsd", + "win32" + ], + "dependencies": { + "bootstrap": "3.4.1", + "commander": "^8.3.0", + "diff-match-patch": "^1.0.5", + "esbuild-loader": "^2.16.0", + "expose-loader": "^3.1.0", + "fs-extra": "^10.0.0", + "glob": "^7.2.0", + "handlebars": "^4.7.7", + "iconv-lite": "^0.6.3", + "jquery": "^3.6.0", + "klaw-sync": "^6.0.0", + "lodash": "^4.17.21", + "markdown-it": "^12.2.0", + "nodemon": "^2.0.15", + "path-to-regexp": "^6.2.0", + "prismjs": "^1.25.0", + "semver": "^7.3.5", + "style-loader": "^3.3.1", + "url-parse": "^1.5.3", + "webpack": "^5.64.2", + "webpack-cli": "^4.9.1", + "winston": "^3.3.3" + }, + "bin": { + "apidoc": "bin/apidoc" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/bch-consumer/node_modules/axios": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", + "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.7" + } + }, + "node_modules/bch-consumer/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/bch-consumer/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/bch-consumer/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/bch-consumer/node_modules/expose-loader": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-3.1.0.tgz", + "integrity": "sha512-2RExSo0yJiqP+xiUue13jQa2IHE8kLDzTI7b6kn+vUlBVvlzNSiLDzo4e5Pp5J039usvTUnxZ8sUOhv0Kg15NA==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/bch-consumer/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/bch-consumer/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bch-consumer/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/bch-consumer/node_modules/nodemon": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", + "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/bch-consumer/node_modules/nodemon/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/bch-consumer/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/bch-consumer/node_modules/randombytes": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.6.tgz", + "integrity": "sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/bch-consumer/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/bch-consumer/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bch-consumer/node_modules/simple-update-notifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", + "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "license": "MIT", + "dependencies": { + "semver": "~7.0.0" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/bch-consumer/node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/bch-consumer/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/bch-donation": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/bch-donation/-/bch-donation-1.1.2.tgz", + "integrity": "sha512-V7xQ23M6Ocavb1Nj4/pATwaLX/uF/9s2nWir3f+F3aetD3kMmsRHFRuXJwGQG3dSC5fJtRR/M5Z2zynY3t91Xg==", + "license": "MIT", + "dependencies": { + "apidoc": "0.51.0" + } + }, + "node_modules/bch-donation/node_modules/apidoc": { + "version": "0.51.0", + "resolved": "https://registry.npmjs.org/apidoc/-/apidoc-0.51.0.tgz", + "integrity": "sha512-3P4srhm6NA+kE/YRM4qL5jESElpQQJL+Z8n7hyr4uC+1DSwGzmE6adXPVxuT/hpDyShrqmRlHk8hf40RSqEsNw==", + "license": "MIT", + "os": [ + "darwin", + "freebsd", + "linux", + "openbsd", + "win32" + ], + "dependencies": { + "bootstrap": "3.4.1", + "commander": "^8.3.0", + "diff-match-patch": "^1.0.5", + "esbuild-loader": "^2.16.0", + "expose-loader": "^3.1.0", + "fs-extra": "^10.0.0", + "glob": "^7.2.0", + "handlebars": "^4.7.7", + "iconv-lite": "^0.6.3", + "jquery": "^3.6.0", + "klaw-sync": "^6.0.0", + "lodash": "^4.17.21", + "markdown-it": "^12.2.0", + "nodemon": "^2.0.15", + "path-to-regexp": "^6.2.0", + "prismjs": "^1.25.0", + "semver": "^7.3.5", + "style-loader": "^3.3.1", + "url-parse": "^1.5.3", + "webpack": "^5.64.2", + "webpack-cli": "^4.9.1", + "winston": "^3.3.3" + }, + "bin": { + "apidoc": "bin/apidoc" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/bch-donation/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/bch-donation/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/bch-donation/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/bch-donation/node_modules/expose-loader": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-3.1.0.tgz", + "integrity": "sha512-2RExSo0yJiqP+xiUue13jQa2IHE8kLDzTI7b6kn+vUlBVvlzNSiLDzo4e5Pp5J039usvTUnxZ8sUOhv0Kg15NA==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/bch-donation/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/bch-donation/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bch-donation/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/bch-donation/node_modules/nodemon": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", + "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/bch-donation/node_modules/nodemon/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/bch-donation/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/bch-donation/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/bch-donation/node_modules/simple-update-notifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", + "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "license": "MIT", + "dependencies": { + "semver": "~7.0.0" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/bch-donation/node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/bch-donation/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/bchaddrjs-slp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/bchaddrjs-slp/-/bchaddrjs-slp-0.2.5.tgz", @@ -1741,7 +2231,6 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -1765,7 +2254,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1886,7 +2374,6 @@ "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.4.1.tgz", "integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==", "deprecated": "This version of Bootstrap is no longer supported. Please upgrade to the latest version.", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1906,7 +2393,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -1946,7 +2432,6 @@ "version": "4.27.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2009,7 +2494,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/buffer-xor": { @@ -2145,7 +2629,6 @@ "version": "1.0.30001754", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2218,7 +2701,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0" @@ -2257,7 +2739,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, "license": "MIT", "dependencies": { "is-plain-object": "^2.0.4", @@ -2347,7 +2828,6 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2474,7 +2954,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2485,6 +2964,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz", + "integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg==", + "license": "MIT" + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2680,7 +3165,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/doctrine": { @@ -2793,7 +3277,6 @@ "version": "1.5.249", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.249.tgz", "integrity": "sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg==", - "dev": true, "license": "ISC" }, "node_modules/elliptic": { @@ -2822,7 +3305,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -2847,7 +3329,6 @@ "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -2861,7 +3342,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", - "dev": true, "license": "BSD-2-Clause", "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -2871,7 +3351,6 @@ "version": "7.20.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.20.0.tgz", "integrity": "sha512-+zUomDcLXsVkQ37vUqWBvQwLaLlj8eZPSi61llaEFAVBY5mhcXdaSw1pSJVl4yTYD5g/gEfpNl28YYk4IPvrrg==", - "dev": true, "license": "MIT", "bin": { "envinfo": "dist/cli.js" @@ -3028,7 +3507,6 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -3092,7 +3570,6 @@ "version": "0.16.17", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.17.tgz", "integrity": "sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -3130,7 +3607,6 @@ "version": "2.21.0", "resolved": "https://registry.npmjs.org/esbuild-loader/-/esbuild-loader-2.21.0.tgz", "integrity": "sha512-k7ijTkCT43YBSZ6+fBCW1Gin7s46RrJ0VQaM8qA7lq7W+OLsGgtLyFV8470FzYi/4TeDexniTBTPTwZUnXXR5g==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.16.17", @@ -3151,7 +3627,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3661,7 +4136,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -3674,7 +4148,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -3699,11 +4172,16 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -3782,7 +4260,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -3803,7 +4280,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, "funding": [ { "type": "github", @@ -3820,7 +4296,6 @@ "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4.9.1" @@ -3874,7 +4349,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -3921,7 +4395,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, "license": "BSD-3-Clause", "bin": { "flat": "cli.js" @@ -4087,7 +4560,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4269,7 +4741,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/globals": { @@ -4320,7 +4791,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -4334,7 +4804,6 @@ "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.5", @@ -4385,7 +4854,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4603,7 +5071,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, "license": "ISC" }, "node_modules/import-fresh": { @@ -4627,7 +5094,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, "license": "MIT", "dependencies": { "pkg-dir": "^4.2.0", @@ -4694,7 +5160,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -4787,7 +5252,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -4876,7 +5340,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4930,7 +5393,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4967,7 +5429,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5013,7 +5474,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, "license": "MIT", "dependencies": { "isobject": "^3.0.1" @@ -5199,14 +5659,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5289,7 +5747,6 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -5304,7 +5761,6 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -5320,7 +5776,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5330,7 +5785,6 @@ "version": "3.7.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", - "dev": true, "license": "MIT" }, "node_modules/js-sha256": { @@ -5377,7 +5831,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -5398,7 +5851,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -5411,7 +5863,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -5465,7 +5916,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5475,7 +5925,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.1.11" @@ -5505,7 +5954,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", - "dev": true, "license": "MIT", "dependencies": { "uc.micro": "^1.0.1" @@ -5542,7 +5990,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" @@ -5556,7 +6003,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, "license": "MIT", "dependencies": { "big.js": "^5.2.2", @@ -5587,7 +6033,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -5671,7 +6116,6 @@ "version": "12.3.2", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1", @@ -5708,7 +6152,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", - "dev": true, "license": "MIT" }, "node_modules/media-typer": { @@ -5736,7 +6179,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, "license": "MIT" }, "node_modules/merkle-lib": { @@ -5766,6 +6208,300 @@ "node": ">= 0.6" } }, + "node_modules/minimal-slp-wallet": { + "version": "5.13.3", + "resolved": "https://registry.npmjs.org/minimal-slp-wallet/-/minimal-slp-wallet-5.13.3.tgz", + "integrity": "sha512-CwDipejJ64EGvCn0VohMXD8+4zX6lDE8KvYd7CHgmk+cT5HwQxO0WfGGL0m7HgWPpbDw5Eus/b6NXFQtRJIlxA==", + "license": "MIT", + "dependencies": { + "@chris.troutner/retry-queue-commonjs": "1.0.8", + "@psf/bch-js": "6.8.1", + "apidoc": "0.51.0", + "bch-consumer": "1.6.2", + "bch-donation": "1.1.2", + "crypto-js": "4.0.0" + } + }, + "node_modules/minimal-slp-wallet/node_modules/@psf/bch-js": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@psf/bch-js/-/bch-js-6.8.1.tgz", + "integrity": "sha512-xu5YT9L3OhdILwJUmkV7Pg/WZ3r2aA6jD7fr5WMvo0OJSzzBBbXGShIbQNZp9+Vv9BoeVLCMqpZoW1Fp7OLCbg==", + "license": "MIT", + "dependencies": { + "@chris.troutner/bip32-utils": "1.0.5", + "@psf/bip21": "2.0.1", + "@psf/bitcoincash-ops": "2.0.0", + "@psf/bitcoincashjs-lib": "4.0.3", + "@psf/coininfo": "4.0.0", + "axios": "0.26.1", + "bc-bip68": "1.0.5", + "bchaddrjs-slp": "0.2.5", + "bigi": "1.4.2", + "bignumber.js": "9.0.0", + "bip-schnorr": "0.3.0", + "bip38": "2.0.2", + "bip39": "3.0.2", + "bip66": "1.1.5", + "bitcoinjs-message": "2.0.0", + "bs58": "4.0.1", + "ecashaddrjs": "1.0.7", + "ini": "1.3.8", + "randombytes": "2.0.6", + "safe-buffer": "5.1.2", + "satoshi-bitcoin": "1.0.4", + "slp-mdm": "0.0.6", + "slp-parser": "0.0.4", + "wif": "2.0.6" + } + }, + "node_modules/minimal-slp-wallet/node_modules/apidoc": { + "version": "0.51.0", + "resolved": "https://registry.npmjs.org/apidoc/-/apidoc-0.51.0.tgz", + "integrity": "sha512-3P4srhm6NA+kE/YRM4qL5jESElpQQJL+Z8n7hyr4uC+1DSwGzmE6adXPVxuT/hpDyShrqmRlHk8hf40RSqEsNw==", + "license": "MIT", + "os": [ + "darwin", + "freebsd", + "linux", + "openbsd", + "win32" + ], + "dependencies": { + "bootstrap": "3.4.1", + "commander": "^8.3.0", + "diff-match-patch": "^1.0.5", + "esbuild-loader": "^2.16.0", + "expose-loader": "^3.1.0", + "fs-extra": "^10.0.0", + "glob": "^7.2.0", + "handlebars": "^4.7.7", + "iconv-lite": "^0.6.3", + "jquery": "^3.6.0", + "klaw-sync": "^6.0.0", + "lodash": "^4.17.21", + "markdown-it": "^12.2.0", + "nodemon": "^2.0.15", + "path-to-regexp": "^6.2.0", + "prismjs": "^1.25.0", + "semver": "^7.3.5", + "style-loader": "^3.3.1", + "url-parse": "^1.5.3", + "webpack": "^5.64.2", + "webpack-cli": "^4.9.1", + "winston": "^3.3.3" + }, + "bin": { + "apidoc": "bin/apidoc" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/minimal-slp-wallet/node_modules/axios": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", + "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.8" + } + }, + "node_modules/minimal-slp-wallet/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/minimal-slp-wallet/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/minimal-slp-wallet/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/minimal-slp-wallet/node_modules/expose-loader": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-3.1.0.tgz", + "integrity": "sha512-2RExSo0yJiqP+xiUue13jQa2IHE8kLDzTI7b6kn+vUlBVvlzNSiLDzo4e5Pp5J039usvTUnxZ8sUOhv0Kg15NA==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimal-slp-wallet/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/minimal-slp-wallet/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/minimal-slp-wallet/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimal-slp-wallet/node_modules/nodemon": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", + "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/minimal-slp-wallet/node_modules/nodemon/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/minimal-slp-wallet/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/minimal-slp-wallet/node_modules/randombytes": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.6.tgz", + "integrity": "sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/minimal-slp-wallet/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/minimal-slp-wallet/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/minimal-slp-wallet/node_modules/simple-update-notifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", + "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "license": "MIT", + "dependencies": { + "semver": "~7.0.0" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/minimal-slp-wallet/node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/minimal-slp-wallet/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -5971,7 +6707,6 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, "license": "MIT" }, "node_modules/node-addon-api": { @@ -5995,7 +6730,6 @@ "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, "license": "MIT" }, "node_modules/nodemon": { @@ -6105,7 +6839,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6352,16 +7085,276 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-4.0.0.tgz", + "integrity": "sha512-3cRXXn3/O0o3+eVmUroJPSj/esxoEFIm0ZOno/T+NzG/VZgPOqQ8WKmlNqubSEpZmCIngEy34unkHGg83ZIBmg==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^3.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/p2wdb": { + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/p2wdb/-/p2wdb-2.2.10.tgz", + "integrity": "sha512-6X/SgkO2FSBlL5i0GWqEeuORQ2APji7w8ufkH2xR4YFJ9GtChcaV8Jdk6dNwVaJa+9bT3vPGwpFx93rttDxV8g==", + "license": "MIT", + "dependencies": { + "@chris.troutner/retry-queue-commonjs": "1.0.8", + "apidoc": "0.52.0", + "axios": "0.24.0" + } + }, + "node_modules/p2wdb/node_modules/apidoc": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/apidoc/-/apidoc-0.52.0.tgz", + "integrity": "sha512-k0gaMI7LWxaqKt2D+twC6XpI8X5SHkJalfo8TmF72d2TSNABquqB8LyIrVYzLcadcHuLMRFDaDibOK271gD67w==", + "license": "MIT", + "os": [ + "darwin", + "freebsd", + "linux", + "openbsd", + "win32" + ], + "dependencies": { + "bootstrap": "3.4.1", + "commander": "^8.3.0", + "diff-match-patch": "^1.0.5", + "esbuild-loader": "^2.16.0", + "expose-loader": "^3.1.0", + "fs-extra": "^10.0.0", + "glob": "^7.2.0", + "handlebars": "^4.7.7", + "iconv-lite": "^0.6.3", + "jquery": "^3.6.0", + "klaw-sync": "^6.0.0", + "lodash": "^4.17.21", + "markdown-it": "^12.2.0", + "nodemon": "^2.0.15", + "prismjs": "^1.25.0", + "semver": "^7.3.5", + "style-loader": "^3.3.1", + "webpack": "^5.64.2", + "webpack-cli": "^4.9.1", + "winston": "^3.3.3" + }, + "bin": { + "apidoc": "bin/apidoc" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/p2wdb/node_modules/axios": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.4" + } + }, + "node_modules/p2wdb/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/p2wdb/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/p2wdb/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/p2wdb/node_modules/expose-loader": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-3.1.0.tgz", + "integrity": "sha512-2RExSo0yJiqP+xiUue13jQa2IHE8kLDzTI7b6kn+vUlBVvlzNSiLDzo4e5Pp5J039usvTUnxZ8sUOhv0Kg15NA==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/p2wdb/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/p2wdb/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/p2wdb/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p2wdb/node_modules/nodemon": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", + "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/p2wdb/node_modules/nodemon/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/p2wdb/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/p2wdb/node_modules/simple-update-notifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", + "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "license": "MIT", + "dependencies": { + "semver": "~7.0.0" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/p2wdb/node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/p2wdb/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -6409,7 +7402,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6428,7 +7420,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6488,14 +7479,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -6598,7 +7587,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, "license": "MIT", "dependencies": { "find-up": "^4.0.0" @@ -6611,7 +7599,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -6625,7 +7612,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -6638,7 +7624,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -6654,7 +7639,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -6686,7 +7670,6 @@ "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6733,7 +7716,6 @@ "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -6761,6 +7743,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6879,7 +7867,6 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", - "dev": true, "license": "MIT", "dependencies": { "resolve": "^1.9.0" @@ -6957,17 +7944,21 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -6988,7 +7979,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, "license": "MIT", "dependencies": { "resolve-from": "^5.0.0" @@ -7001,7 +7991,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7017,6 +8006,15 @@ "node": ">=4" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -7207,7 +8205,6 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.9", @@ -7227,7 +8224,6 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -7244,7 +8240,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3" @@ -7257,7 +8252,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/scryptsy": { @@ -7290,7 +8284,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7325,7 +8318,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" @@ -7422,7 +8414,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, "license": "MIT", "dependencies": { "kind-of": "^6.0.2" @@ -7435,7 +8426,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7448,7 +8438,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7579,6 +8568,26 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/slp-mutable-data": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/slp-mutable-data/-/slp-mutable-data-2.3.10.tgz", + "integrity": "sha512-lyqY1gSjHQqLfJq1dDZUS7dTmYUTTk3j6Z4HXDAQ4X6vDsm7yYvXNKgHdiWueP0Jb0f1z1SJwJ/hZP6todch5g==", + "license": "GPL-2.0", + "dependencies": { + "axios": "0.27.2", + "p2wdb": "2.2.10" + } + }, + "node_modules/slp-mutable-data/node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, "node_modules/slp-parser": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/slp-parser/-/slp-parser-0.0.4.tgz", @@ -7588,18 +8597,37 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/slp-token-media": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/slp-token-media/-/slp-token-media-1.2.10.tgz", + "integrity": "sha512-+/QPMuLax9fN1aUYT+ftyx47P224OTQgJLXxzifB9ZC+m3rO5ItcOFC36naZDMTMzHYMld25FyU7N/1W3qfpPQ==", + "license": "MIT", + "dependencies": { + "@chris.troutner/retry-queue-commonjs": "1.0.8", + "axios": "0.27.2", + "slp-mutable-data": "2.3.10" + } + }, + "node_modules/slp-token-media/node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", - "dev": true, "license": "MIT" }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7609,7 +8637,6 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -7904,7 +8931,6 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", "integrity": "sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12.13.0" @@ -7946,7 +8972,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8013,7 +9038,6 @@ "version": "5.44.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -8032,7 +9056,6 @@ "version": "5.3.14", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", @@ -8067,7 +9090,6 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, "license": "MIT" }, "node_modules/test-exclude": { @@ -8163,7 +9185,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -8185,7 +9206,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, "license": "ISC", "bin": { "nodetouch": "bin/nodetouch.js" @@ -8360,14 +9380,12 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", - "dev": true, "license": "MIT" }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, "license": "BSD-2-Clause", "optional": true, "bin": { @@ -8399,21 +9417,18 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, "license": "MIT" }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -8432,7 +9447,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -8469,6 +9483,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8522,7 +9546,6 @@ "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", - "dev": true, "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", @@ -8536,7 +9559,6 @@ "version": "5.102.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", @@ -8585,7 +9607,6 @@ "version": "4.10.0", "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", - "dev": true, "license": "MIT", "dependencies": { "@discoveryjs/json-ext": "^0.5.0", @@ -8633,7 +9654,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10" @@ -8643,7 +9663,6 @@ "version": "5.10.0", "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", - "dev": true, "license": "MIT", "dependencies": { "clone-deep": "^4.0.1", @@ -8658,7 +9677,6 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", - "dev": true, "license": "MIT", "dependencies": { "source-list-map": "^2.0.0", @@ -8669,7 +9687,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -8683,7 +9700,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -8693,7 +9709,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -8703,7 +9718,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -8716,7 +9730,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.13.0" @@ -8726,7 +9739,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8836,7 +9848,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", - "dev": true, "license": "MIT" }, "node_modules/winston": { @@ -8907,7 +9918,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, "license": "MIT" }, "node_modules/workerpool": { diff --git a/package.json b/package.json index 5386d53..28fe642 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "cors": "2.8.5", "dotenv": "16.3.1", "express": "5.1.0", + "minimal-slp-wallet": "5.13.3", + "slp-token-media": "1.2.10", "winston": "3.11.0", "winston-daily-rotate-file": "4.7.1", "x402-bch-express": "1.1.1" diff --git a/src/adapters/index.js b/src/adapters/index.js index f9b5c96..7d0d02c 100644 --- a/src/adapters/index.js +++ b/src/adapters/index.js @@ -8,6 +8,7 @@ // import NostrRelayAdapter from './nostr-relay.js' import FullNodeRPCAdapter from './full-node-rpc.js' import FulcrumAPIAdapter from './fulcrum-api.js' +import SlpIndexerAPIAdapter from './slp-indexer-api.js' import config from '../config/index.js' class Adapters { @@ -35,6 +36,7 @@ class Adapters { this.fullNode = new FullNodeRPCAdapter({ config: this.config }) this.fulcrum = new FulcrumAPIAdapter({ config: this.config }) + this.slpIndexer = new SlpIndexerAPIAdapter({ config: this.config }) } async start () { diff --git a/src/adapters/slp-indexer-api.js b/src/adapters/slp-indexer-api.js new file mode 100644 index 0000000..37fb721 --- /dev/null +++ b/src/adapters/slp-indexer-api.js @@ -0,0 +1,124 @@ +/* + Adapter library for interacting with SLP Indexer API service over HTTP. +*/ + +import axios from 'axios' +import wlogger from './wlogger.js' +import config from '../config/index.js' + +class SlpIndexerAPIAdapter { + constructor (localConfig = {}) { + this.config = localConfig.config || config + + // Allow missing config for testing environments + if (!this.config.slpIndexerApi || !this.config.slpIndexerApi.baseUrl) { + if (process.env.NODE_ENV === 'test' || process.env.TEST) { + // In test environment, create a mock baseURL + this.config.slpIndexerApi = { + baseUrl: 'http://localhost:5021', + timeoutMs: 15000 + } + } else { + throw new Error('SLP_INDEXER_API env var not set. Can not connect to PSF SLP indexer.') + } + } + + const { + baseUrl, + timeoutMs = 15000 + } = this.config.slpIndexerApi + + this.http = axios.create({ + baseURL: baseUrl, + timeout: timeoutMs + }) + } + + async get (path) { + try { + const response = await this.http.get(path) + return response.data + } catch (err) { + throw this._handleError(err) + } + } + + async post (path, data) { + try { + const response = await this.http.post(path, data) + return response.data + } catch (err) { + throw this._handleError(err) + } + } + + _handleError (err) { + const { status, message } = this.decodeError(err) + const error = new Error(message) + error.status = status + error.originalError = err + return error + } + + decodeError (err) { + try { + // Attempt to extract error message from response data + if (err.response && err.response.data) { + const data = err.response.data + // Handle structured error responses + if (data.error) { + return this._formatError(data.error, err.response.status || 400) + } + // Handle string error messages + if (typeof data === 'string') { + return this._formatError(data, err.response.status || 400) + } + // Handle object responses that might contain error info + if (typeof data === 'object' && data.message) { + return this._formatError(data.message, err.response.status || 400) + } + // Fallback to returning the status + return this._formatError('SLP Indexer API error', err.response.status || 500) + } + + // Network errors + if (err.message) { + if (err.message.includes('ENOTFOUND') || err.message.includes('ENETUNREACH') || err.message.includes('EAI_AGAIN')) { + return this._formatError( + 'Network error: Could not communicate with SLP Indexer API service.', + 503 + ) + } + } + + if (err.code && (err.code === 'ECONNABORTED' || err.code === 'ECONNREFUSED')) { + return this._formatError( + 'Network error: Could not communicate with SLP Indexer API service.', + 503 + ) + } + + if (err.error && typeof err.error === 'string' && err.error.includes('429')) { + return this._formatError('429 Too Many Requests', 429) + } + + if (err.message) { + return this._formatError(err.message, err.status || 422) + } + + return this._formatError('Unhandled SLP Indexer API error', 500) + } catch (decodeError) { + wlogger.error('Unhandled error in SlpIndexerAPIAdapter.decodeError()', decodeError) + return this._formatError('Internal server error', 500) + } + } + + _formatError (message, status = 500) { + return { + message: message || 'Internal server error', + status: status || 500 + } + } +} + +export default SlpIndexerAPIAdapter diff --git a/src/config/env/common.js b/src/config/env/common.js index 698fc6f..818e4b9 100644 --- a/src/config/env/common.js +++ b/src/config/env/common.js @@ -66,6 +66,18 @@ export default { timeoutMs: Number(process.env.FULCRUM_TIMEOUT_MS || 15000) }, + // SLP Indexer API configuration + slpIndexerApi: { + baseUrl: process.env.SLP_INDEXER_API || '', + timeoutMs: Number(process.env.SLP_INDEXER_TIMEOUT_MS || 15000) + }, + + // REST API URL for wallet operations + restURL: process.env.REST_URL || process.env.LOCAL_RESTURL || 'http://127.0.0.1:3000/v5/', + + // IPFS Gateway URL + ipfsGateway: process.env.IPFS_GATEWAY || 'p2wdb-gateway-678.fullstack.cash', + x402: x402Defaults, // Version diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index 2cfab3a..6c8127f 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -13,6 +13,7 @@ import DSProofRouter from './full-node/dsproof/router.js' import FulcrumRouter from './fulcrum/router.js' import MiningRouter from './full-node/mining/router.js' import RawTransactionsRouter from './full-node/rawtransactions/router.js' +import SlpRouter from './slp/router.js' import config from '../../config/index.js' class RESTControllers { @@ -76,6 +77,9 @@ class RESTControllers { const rawtransactionsRouter = new RawTransactionsRouter(dependencies) rawtransactionsRouter.attach(app) + + const slpRouter = new SlpRouter(dependencies) + slpRouter.attach(app) } } diff --git a/src/controllers/rest-api/slp/controller.js b/src/controllers/rest-api/slp/controller.js new file mode 100644 index 0000000..c77e1ef --- /dev/null +++ b/src/controllers/rest-api/slp/controller.js @@ -0,0 +1,245 @@ +/* + REST API Controller for the /slp routes. +*/ + +import wlogger from '../../../adapters/wlogger.js' +import BCHJS from '@psf/bch-js' + +const bchjs = new BCHJS() + +class SlpRESTController { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating SLP REST Controller.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases || !this.useCases.slp) { + throw new Error( + 'Instance of SLP use cases required when instantiating SLP REST Controller.' + ) + } + + this.slpUseCases = this.useCases.slp + + // Bind functions + this.root = this.root.bind(this) + this.getStatus = this.getStatus.bind(this) + this.getAddress = this.getAddress.bind(this) + this.getTxid = this.getTxid.bind(this) + this.getTokenStats = this.getTokenStats.bind(this) + this.getTokenData = this.getTokenData.bind(this) + this.getTokenData2 = this.getTokenData2.bind(this) + this.handleError = this.handleError.bind(this) + } + + /** + * @api {get} /v6/slp/ Service status + * @apiName SlpRoot + * @apiGroup SLP + * + * @apiDescription Returns the status of the SLP service. + * + * @apiSuccess {String} status Service identifier + */ + async root (req, res) { + return res.status(200).json({ status: 'psf-slp-indexer' }) + } + + /** + * Validates and converts an address to cash address format + * @param {string} address - Address to validate and convert + * @returns {string} Cash address + * @throws {Error} If address is invalid or not mainnet + */ + _validateAndConvertAddress (address) { + if (!address) { + throw new Error('address is empty') + } + + // Convert legacy to cash address + const cashAddr = bchjs.SLP.Address.toCashAddress(address) + + // Ensure it's a valid BCH address + try { + bchjs.SLP.Address.toLegacyAddress(cashAddr) + } catch (err) { + throw new Error(`Invalid BCH address. Double check your address is valid: ${address}`) + } + + // Ensure it's mainnet (no testnet support) + const isMainnet = bchjs.Address.isMainnetAddress(cashAddr) + if (!isMainnet) { + throw new Error('Invalid network. Only mainnet addresses are supported.') + } + + return cashAddr + } + + /** + * @api {get} /v6/slp/status Get indexer status + * @apiName GetStatus + * @apiGroup SLP + * @apiDescription Returns the status of the SLP indexer. + */ + async getStatus (req, res) { + try { + const result = await this.slpUseCases.getStatus() + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/slp/address Get SLP balance for address + * @apiName GetAddress + * @apiGroup SLP + * @apiDescription Returns SLP balance for an address. + */ + async getAddress (req, res) { + try { + const address = req.body.address + + if (!address || address === '') { + return res.status(400).json({ + success: false, + error: 'address can not be empty' + }) + } + + // Validate and convert address + const cashAddr = this._validateAndConvertAddress(address) + + const result = await this.slpUseCases.getAddress({ address: cashAddr }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/slp/txid Get SLP transaction data + * @apiName GetTxid + * @apiGroup SLP + * @apiDescription Returns SLP transaction data for a TXID. + */ + async getTxid (req, res) { + try { + const txid = req.body.txid + + if (!txid || txid === '') { + return res.status(400).json({ + success: false, + error: 'txid can not be empty' + }) + } + + if (txid.length !== 64) { + return res.status(400).json({ + success: false, + error: 'This is not a txid' + }) + } + + const result = await this.slpUseCases.getTxid({ txid }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/slp/token Get token statistics + * @apiName GetTokenStats + * @apiGroup SLP + * @apiDescription Returns statistics for a single SLP token. + */ + async getTokenStats (req, res) { + try { + const tokenId = req.body.tokenId + + if (!tokenId || tokenId === '') { + return res.status(400).json({ + success: false, + error: 'tokenId can not be empty' + }) + } + + // Flag to toggle tx history of the token + const withTxHistory = req.body.withTxHistory === true + + const result = await this.slpUseCases.getTokenStats({ tokenId, withTxHistory }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/slp/token/data Get token data + * @apiName GetTokenData + * @apiGroup SLP + * @apiDescription Get mutable and immutable data if the token contains them. + */ + async getTokenData (req, res) { + try { + const tokenId = req.body.tokenId + + if (!tokenId || tokenId === '') { + return res.status(400).json({ + success: false, + error: 'tokenId can not be empty' + }) + } + + // Flag to toggle tx history of the token + const withTxHistory = req.body.withTxHistory === true + + const result = await this.slpUseCases.getTokenData({ tokenId, withTxHistory }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/slp/token/data2 Get expanded token data + * @apiName GetTokenData2 + * @apiGroup SLP + * @apiDescription Get expanded data for the token, including icons. + */ + async getTokenData2 (req, res) { + try { + const tokenId = req.body.tokenId + + if (!tokenId || tokenId === '') { + return res.status(400).json({ + success: false, + error: 'tokenId can not be empty' + }) + } + + const updateCache = req.body.updateCache + + const result = await this.slpUseCases.getTokenData2({ tokenId, updateCache }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + handleError (err, res) { + wlogger.error('Error in SlpRESTController:', err) + + const status = err.status || 500 + const message = err.message || 'Internal server error' + + return res.status(status).json({ error: message }) + } +} + +export default SlpRESTController diff --git a/src/controllers/rest-api/slp/router.js b/src/controllers/rest-api/slp/router.js new file mode 100644 index 0000000..9375812 --- /dev/null +++ b/src/controllers/rest-api/slp/router.js @@ -0,0 +1,56 @@ +/* + REST API router for /slp routes. +*/ + +import express from 'express' +import SlpRESTController from './controller.js' + +class SlpRouter { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating SLP REST Router.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases) { + throw new Error( + 'Instance of Use Cases library required when instantiating SLP REST Router.' + ) + } + + const dependencies = { + adapters: this.adapters, + useCases: this.useCases + } + + this.slpController = new SlpRESTController(dependencies) + + this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '') + this.baseUrl = `${this.apiPrefix}/slp` + if (!this.baseUrl.startsWith('/')) { + this.baseUrl = `/${this.baseUrl}` + } + this.router = express.Router() + } + + attach (app) { + if (!app) { + throw new Error('Must pass app object when attaching REST API controllers.') + } + + this.router.get('/', this.slpController.root) + this.router.get('/status', this.slpController.getStatus) + this.router.post('/address', this.slpController.getAddress) + this.router.post('/txid', this.slpController.getTxid) + this.router.post('/token', this.slpController.getTokenStats) + this.router.post('/token/data', this.slpController.getTokenData) + this.router.post('/token/data2', this.slpController.getTokenData2) + + app.use(this.baseUrl, this.router) + } +} + +export default SlpRouter diff --git a/src/use-cases/index.js b/src/use-cases/index.js index bd4d60b..030a66a 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -11,6 +11,7 @@ import DSProofUseCases from './full-node-dsproof-use-cases.js' import FulcrumUseCases from './fulcrum-use-cases.js' import MiningUseCases from './full-node-mining-use-cases.js' import RawTransactionsUseCases from './full-node-rawtransactions-use-cases.js' +import SlpUseCases from './slp-use-cases.js' class UseCases { constructor (localConfig = {}) { @@ -27,6 +28,7 @@ class UseCases { this.fulcrum = new FulcrumUseCases({ adapters: this.adapters }) this.mining = new MiningUseCases({ adapters: this.adapters }) this.rawtransactions = new RawTransactionsUseCases({ adapters: this.adapters }) + this.slp = new SlpUseCases({ adapters: this.adapters }) } // Run any startup Use Cases at the start of the app. diff --git a/src/use-cases/slp-use-cases.js b/src/use-cases/slp-use-cases.js new file mode 100644 index 0000000..4a4555b --- /dev/null +++ b/src/use-cases/slp-use-cases.js @@ -0,0 +1,333 @@ +/* + Use cases for interacting with the SLP Indexer API service. +*/ + +import wlogger from '../adapters/wlogger.js' +import BCHJS from '@psf/bch-js' +import SlpWallet from 'minimal-slp-wallet' +import SlpTokenMedia from 'slp-token-media' +import axios from 'axios' +import config from '../config/index.js' + +const bchjs = new BCHJS() + +class SlpUseCases { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + + if (!this.adapters) { + throw new Error('Adapters instance required when instantiating SLP use cases.') + } + + this.slpIndexer = this.adapters.slpIndexer + if (!this.slpIndexer) { + throw new Error('SLP Indexer adapter required when instantiating SLP use cases.') + } + + // Allow bchjs to be injected for testing + this.bchjs = localConfig.bchjs || bchjs + + // Get config + this.config = localConfig.config || config + + // Initialize wallet (lazy initialization) + this.wallet = null + this.slpTokenMedia = null + this.walletInitialized = false + this.initializationPromise = null + } + + // Initialize wallet and SlpTokenMedia asynchronously + async _ensureInitialized () { + if (this.walletInitialized) { + return + } + + if (this.initializationPromise) { + return this.initializationPromise + } + + this.initializationPromise = this._initialize() + return this.initializationPromise + } + + async _initialize () { + try { + // Initialize wallet + this.wallet = new SlpWallet(undefined, { + restURL: this.config.restURL, + interface: 'rest-api' + }) + + // Wait for wallet to initialize + await this.wallet.walletInfoPromise + + // Initialize SlpTokenMedia + this.slpTokenMedia = new SlpTokenMedia({ + wallet: this.wallet, + ipfsGatewayUrl: this.config.ipfsGateway + }) + + this.walletInitialized = true + wlogger.info('SLP wallet and token media initialized') + } catch (err) { + wlogger.error('Error initializing SLP wallet:', err) + throw err + } + } + + async getStatus () { + try { + return await this.slpIndexer.get('slp/status/') + } catch (err) { + wlogger.error('Error in SlpUseCases.getStatus()', err) + throw err + } + } + + async getAddress ({ address }) { + try { + return await this.slpIndexer.post('slp/address/', { address }) + } catch (err) { + wlogger.error('Error in SlpUseCases.getAddress()', err) + throw err + } + } + + async getTxid ({ txid }) { + try { + return await this.slpIndexer.post('slp/tx/', { txid }) + } catch (err) { + wlogger.error('Error in SlpUseCases.getTxid()', err) + throw err + } + } + + async getTokenStats ({ tokenId, withTxHistory = false }) { + try { + return await this.slpIndexer.post('slp/token/', { tokenId, withTxHistory }) + } catch (err) { + wlogger.error('Error in SlpUseCases.getTokenStats()', err) + throw err + } + } + + async getTokenData ({ tokenId, withTxHistory = false }) { + try { + const tokenData = {} + + // Get token stats from the Genesis TX of the token + const response = await this.slpIndexer.post('slp/token/', { tokenId, withTxHistory }) + const tokenStats = response.tokenData + + tokenData.genesisData = tokenStats + + // Try to get immutable data + try { + const immutableData = tokenStats.documentUri + tokenData.immutableData = immutableData || '' + } catch (error) { + tokenData.immutableData = '' + } + + // Try to get mutable data + try { + const mutableData = await this.getMutableCid({ tokenStats }) + tokenData.mutableData = mutableData || '' + } catch (error) { + wlogger.warn('Error getting mutable data:', error) + tokenData.mutableData = '' + } + + return tokenData + } catch (err) { + wlogger.error('Error in SlpUseCases.getTokenData()', err) + throw err + } + } + + async getTokenData2 ({ tokenId, updateCache }) { + try { + await this._ensureInitialized() + + const tokenData = await this.slpTokenMedia.getIcon({ tokenId, updateCache }) + return tokenData + } catch (err) { + wlogger.error('Error in SlpUseCases.getTokenData2()', err) + throw err + } + } + + async getMutableCid ({ tokenStats }) { + // Validate input - this should throw, not be caught + if (!tokenStats || !tokenStats.documentHash) { + throw new Error('No documentHash property found in tokenStats') + } + + try { + await this._ensureInitialized() + + // Get the OP_RETURN data and decode it + const mutableData = await this.decodeOpReturn({ txid: tokenStats.documentHash }) + const jsonData = JSON.parse(mutableData) + + // mda = mutable data address + const mda = jsonData.mda + + // Get the mda transaction history + const transactions = await this.wallet.getTransactions(mda) + wlogger.info(`MDA has ${transactions.length} transactions in its history.`) + + const mdaTxs = transactions + + let data = false + + // These are used to filter blockchain data to find the most recent + // update to the MDA + let largestBlock = 700000 + let largestTimestamp = 1666107111271 + let bestEntry + + // Used to track the number of transactions before the best candidate is found + let txCnt = 0 + + // Map each transaction of the mda + // If it finds an OP_RETURN, decode it and exit the loop + for (let i = 0; i < mdaTxs.length; i++) { + const tx = mdaTxs[i] + const txid = tx.tx_hash + txCnt++ + + data = await this.decodeOpReturn({ txid }) + + // Try parse the OP_RETURN data to a JSON object + if (data) { + try { + // Convert the OP_RETURN data to a JSON object + const obj = JSON.parse(data) + + // Keep searching if this TX does not have a cid value + if (!obj.cid) continue + + // Ensure data was generated by the MDA + const txData = await this.wallet.getTxData([txid]) + const vinAddress = txData[0].vin[0].address + + // Skip entry if it was not made by the MDA private key + if (mda !== vinAddress) { + continue + } + + // First best entry found + if (!bestEntry) { + bestEntry = data + largestBlock = tx.height + + if (obj.ts) { + largestTimestamp = obj.ts + } + } else { + // One candidate already found. Looking for potentially better entry + + if (tx.height < largestBlock) { + // Exit loop if next candidate has an older block height + break + } + + if (obj.ts && obj.ts < largestTimestamp) { + // Continue looping through entries if the current entry in + // the same block has a smaller timestamp + continue + } + + bestEntry = data + largestBlock = tx.height + if (obj.ts) { + largestTimestamp = obj.ts + } + } + } catch (error) { + continue + } + } + } + + wlogger.info(`${txCnt} transactions reviewed to find mutable data.`) + + if (!bestEntry) { + return false + } + + // Get the CID + const obj = JSON.parse(bestEntry) + const cid = obj.cid + + if (!cid) { + return false + } + + // Assuming that CID starts with ipfs://. Cutting out that prefix + const mutableCid = cid.substring(7) + + return mutableCid + } catch (err) { + wlogger.error('Error in SlpUseCases.getMutableCid()', err) + return false + } + } + + async decodeOpReturn ({ txid }) { + try { + if (!txid || typeof txid !== 'string') { + throw new Error('txid must be a string.') + } + + // Get transaction data + const txData = await this.bchjs.Electrumx.txData(txid) + let data = false + + // Map the vout of the transaction in search of an OP_RETURN + for (let i = 0; i < txData.details.vout.length; i++) { + const vout = txData.details.vout[i] + + const script = this.bchjs.Script.toASM( + Buffer.from(vout.scriptPubKey.hex, 'hex') + ).split(' ') + + // Exit on the first OP_RETURN found + if (script[0] === 'OP_RETURN') { + data = Buffer.from(script[1], 'hex').toString('ascii') + break + } + } + + return data + } catch (error) { + wlogger.error('Error in SlpUseCases.decodeOpReturn()', error) + throw error + } + } + + async getCIDData ({ cid }) { + try { + if (!cid || typeof cid !== 'string') { + throw new Error('cid must be a string.') + } + + // Assuming that CID starts with ipfs://. Cutting out that prefix + const cidWithoutPrefix = cid.substring(7) + + const dataUrl = `https://${cidWithoutPrefix}.ipfs.dweb.link/data.json` + wlogger.info(`Fetching IPFS data from: ${dataUrl}`) + + const response = await axios.get(dataUrl) + + return response.data + } catch (error) { + wlogger.error('Error in SlpUseCases.getCIDData()', error) + throw error + } + } +} + +export default SlpUseCases diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js index dcfa79d..6df2479 100644 --- a/test/unit/controllers/rest-api-index-unit.js +++ b/test/unit/controllers/rest-api-index-unit.js @@ -12,6 +12,7 @@ import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/r import MiningRouter from '../../../src/controllers/rest-api/full-node/mining/router.js' import RawTransactionsRouter from '../../../src/controllers/rest-api/full-node/rawtransactions/router.js' import FulcrumRouter from '../../../src/controllers/rest-api/fulcrum/router.js' +import SlpRouter from '../../../src/controllers/rest-api/slp/router.js' describe('#controllers/rest-api/index.js', () => { let sandbox @@ -84,6 +85,17 @@ describe('#controllers/rest-api/index.js', () => { getRawTransactions: () => {}, sendRawTransaction: () => {}, sendRawTransactions: () => {} + }, + slp: { + getStatus: () => {}, + getAddress: () => {}, + getTxid: () => {}, + getTokenStats: () => {}, + getTokenData: () => {}, + getTokenData2: () => {}, + getMutableCid: () => {}, + decodeOpReturn: () => {}, + getCIDData: () => {} } } }) @@ -116,6 +128,7 @@ describe('#controllers/rest-api/index.js', () => { const fulcrumAttachStub = sandbox.stub(FulcrumRouter.prototype, 'attach') const miningAttachStub = sandbox.stub(MiningRouter.prototype, 'attach') const rawtransactionsAttachStub = sandbox.stub(RawTransactionsRouter.prototype, 'attach') + const slpAttachStub = sandbox.stub(SlpRouter.prototype, 'attach') const restControllers = new RESTControllers({ adapters: mockAdapters, useCases: mockUseCases @@ -136,6 +149,8 @@ describe('#controllers/rest-api/index.js', () => { assert.equal(miningAttachStub.getCall(0).args[0], app) assert.isTrue(rawtransactionsAttachStub.calledOnce) assert.equal(rawtransactionsAttachStub.getCall(0).args[0], app) + assert.isTrue(slpAttachStub.calledOnce) + assert.equal(slpAttachStub.getCall(0).args[0], app) }) }) }) diff --git a/test/unit/controllers/slp-controller-unit.js b/test/unit/controllers/slp-controller-unit.js new file mode 100644 index 0000000..b86c345 --- /dev/null +++ b/test/unit/controllers/slp-controller-unit.js @@ -0,0 +1,369 @@ +/* + Unit tests for SlpRESTController. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' + +import SlpRESTController from '../../../src/controllers/rest-api/slp/controller.js' +import { + createMockRequest, + createMockResponse +} from '../mocks/controller-mocks.js' + +// Valid mainnet cash address for testing +const VALID_MAINNET_ADDRESS = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + +describe('#slp-controller.js', () => { + let sandbox + let mockUseCases + let mockAdapters + let uut + + const createSlpUseCaseStubs = () => ({ + getStatus: sandbox.stub().resolves({ status: 'ok' }), + getAddress: sandbox.stub().resolves({ balance: 1000 }), + getTxid: sandbox.stub().resolves({ txid: 'abc' }), + getTokenStats: sandbox.stub().resolves({ tokenData: {} }), + getTokenData: sandbox.stub().resolves({ genesisData: {}, immutableData: '', mutableData: '' }), + getTokenData2: sandbox.stub().resolves({ tokenIcon: 'test-icon.png' }) + }) + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = {} + mockUseCases = { + slp: createSlpUseCaseStubs() + } + + uut = new SlpRESTController({ + adapters: mockAdapters, + useCases: mockUseCases + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new SlpRESTController({ useCases: mockUseCases }) + }, /Adapters library required/) + }) + + it('should require slp use cases', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new SlpRESTController({ adapters: mockAdapters, useCases: {} }) + }, /SLP use cases required/) + }) + }) + + describe('#root()', () => { + it('should return service status', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.root(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { status: 'psf-slp-indexer' }) + }) + }) + + describe('#getStatus()', () => { + it('should return status on success', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.getStatus(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { status: 'ok' }) + assert.isTrue(mockUseCases.slp.getStatus.calledOnce) + }) + + it('should handle errors via handleError', async () => { + const error = new Error('failure') + error.status = 503 + mockUseCases.slp.getStatus.rejects(error) + const req = createMockRequest() + const res = createMockResponse() + + await uut.getStatus(req, res) + + assert.equal(res.statusValue, 503) + assert.deepEqual(res.jsonData, { error: 'failure' }) + }) + }) + + describe('#getAddress()', () => { + it('should return address balance on success', async () => { + const req = createMockRequest({ + body: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getAddress(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { balance: 1000 }) + assert.isTrue(mockUseCases.slp.getAddress.calledOnce) + }) + + it('should return error if address is empty', async () => { + const req = createMockRequest({ + body: { address: '' } + }) + const res = createMockResponse() + + await uut.getAddress(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + assert.include(res.jsonData.error, 'can not be empty') + }) + + it('should return error if address is missing', async () => { + const req = createMockRequest({ + body: {} + }) + const res = createMockResponse() + + await uut.getAddress(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + + it('should handle errors via handleError', async () => { + const error = new Error('Invalid address') + error.status = 400 + mockUseCases.slp.getAddress.rejects(error) + const req = createMockRequest({ + body: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getAddress(req, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'Invalid address' }) + }) + }) + + describe('#getTxid()', () => { + it('should return transaction data on success', async () => { + const req = createMockRequest({ + body: { txid: 'a'.repeat(64) } + }) + const res = createMockResponse() + + await uut.getTxid(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { txid: 'abc' }) + assert.isTrue(mockUseCases.slp.getTxid.calledOnce) + }) + + it('should return error if txid is empty', async () => { + const req = createMockRequest({ + body: { txid: '' } + }) + const res = createMockResponse() + + await uut.getTxid(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + assert.include(res.jsonData.error, 'can not be empty') + }) + + it('should return error if txid is not 64 characters', async () => { + const req = createMockRequest({ + body: { txid: 'abc' } + }) + const res = createMockResponse() + + await uut.getTxid(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + assert.include(res.jsonData.error, 'not a txid') + }) + + it('should handle errors via handleError', async () => { + const error = new Error('Transaction not found') + error.status = 404 + mockUseCases.slp.getTxid.rejects(error) + const req = createMockRequest({ + body: { txid: 'a'.repeat(64) } + }) + const res = createMockResponse() + + await uut.getTxid(req, res) + + assert.equal(res.statusValue, 404) + assert.deepEqual(res.jsonData, { error: 'Transaction not found' }) + }) + }) + + describe('#getTokenStats()', () => { + it('should return token stats on success', async () => { + const req = createMockRequest({ + body: { tokenId: 'a'.repeat(64) } + }) + const res = createMockResponse() + + await uut.getTokenStats(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { tokenData: {} }) + assert.isTrue(mockUseCases.slp.getTokenStats.calledOnce) + }) + + it('should pass withTxHistory flag', async () => { + const req = createMockRequest({ + body: { tokenId: 'a'.repeat(64), withTxHistory: true } + }) + const res = createMockResponse() + + await uut.getTokenStats(req, res) + + assert.isTrue(mockUseCases.slp.getTokenStats.calledWith({ + tokenId: 'a'.repeat(64), + withTxHistory: true + })) + }) + + it('should return error if tokenId is empty', async () => { + const req = createMockRequest({ + body: { tokenId: '' } + }) + const res = createMockResponse() + + await uut.getTokenStats(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + + it('should handle errors via handleError', async () => { + const error = new Error('Token not found') + error.status = 404 + mockUseCases.slp.getTokenStats.rejects(error) + const req = createMockRequest({ + body: { tokenId: 'a'.repeat(64) } + }) + const res = createMockResponse() + + await uut.getTokenStats(req, res) + + assert.equal(res.statusValue, 404) + assert.deepEqual(res.jsonData, { error: 'Token not found' }) + }) + }) + + describe('#getTokenData()', () => { + it('should return token data on success', async () => { + const req = createMockRequest({ + body: { tokenId: 'a'.repeat(64) } + }) + const res = createMockResponse() + + await uut.getTokenData(req, res) + + assert.equal(res.statusValue, 200) + assert.property(res.jsonData, 'genesisData') + assert.property(res.jsonData, 'immutableData') + assert.property(res.jsonData, 'mutableData') + assert.isTrue(mockUseCases.slp.getTokenData.calledOnce) + }) + + it('should return error if tokenId is empty', async () => { + const req = createMockRequest({ + body: { tokenId: '' } + }) + const res = createMockResponse() + + await uut.getTokenData(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + + it('should handle errors via handleError', async () => { + const error = new Error('Token data not found') + error.status = 404 + mockUseCases.slp.getTokenData.rejects(error) + const req = createMockRequest({ + body: { tokenId: 'a'.repeat(64) } + }) + const res = createMockResponse() + + await uut.getTokenData(req, res) + + assert.equal(res.statusValue, 404) + assert.deepEqual(res.jsonData, { error: 'Token data not found' }) + }) + }) + + describe('#getTokenData2()', () => { + it('should return expanded token data on success', async () => { + const req = createMockRequest({ + body: { tokenId: 'a'.repeat(64) } + }) + const res = createMockResponse() + + await uut.getTokenData2(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { tokenIcon: 'test-icon.png' }) + assert.isTrue(mockUseCases.slp.getTokenData2.calledOnce) + }) + + it('should pass updateCache flag', async () => { + const req = createMockRequest({ + body: { tokenId: 'a'.repeat(64), updateCache: true } + }) + const res = createMockResponse() + + await uut.getTokenData2(req, res) + + assert.isTrue(mockUseCases.slp.getTokenData2.calledWith({ + tokenId: 'a'.repeat(64), + updateCache: true + })) + }) + + it('should return error if tokenId is empty', async () => { + const req = createMockRequest({ + body: { tokenId: '' } + }) + const res = createMockResponse() + + await uut.getTokenData2(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + + it('should handle errors via handleError', async () => { + const error = new Error('Token icon not found') + error.status = 404 + mockUseCases.slp.getTokenData2.rejects(error) + const req = createMockRequest({ + body: { tokenId: 'a'.repeat(64) } + }) + const res = createMockResponse() + + await uut.getTokenData2(req, res) + + assert.equal(res.statusValue, 404) + assert.deepEqual(res.jsonData, { error: 'Token icon not found' }) + }) + }) +}) diff --git a/test/unit/use-cases/slp-use-cases-unit.js b/test/unit/use-cases/slp-use-cases-unit.js new file mode 100644 index 0000000..9ab7a14 --- /dev/null +++ b/test/unit/use-cases/slp-use-cases-unit.js @@ -0,0 +1,311 @@ +/* + Unit tests for SlpUseCases. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' +import BCHJS from '@psf/bch-js' + +import SlpUseCases from '../../../src/use-cases/slp-use-cases.js' + +describe('#slp-use-cases.js', () => { + let sandbox + let mockAdapters + let mockConfig + let uut + let mockBchjs + let mockWallet + let mockSlpTokenMedia + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockConfig = { + restURL: 'http://localhost:3000/v5/', + ipfsGateway: 'p2wdb-gateway-678.fullstack.cash' + } + + mockAdapters = { + slpIndexer: { + get: sandbox.stub().resolves({}), + post: sandbox.stub().resolves({}) + } + } + + // Create mock BCHJS + mockBchjs = new BCHJS() + mockBchjs.Electrumx = { + txData: sandbox.stub().resolves({ + details: { + vout: [ + { + scriptPubKey: { + hex: '6a0c48656c6c6f20576f726c6421' + } + } + ] + } + }) + } + mockBchjs.Script = { + toASM: sandbox.stub().returns('OP_RETURN 48656c6c6f20576f726c6421') + } + + // Create mock wallet + mockWallet = { + walletInfoPromise: Promise.resolve(), + getTransactions: sandbox.stub().resolves([]), + getTxData: sandbox.stub().resolves([{ + vin: [{ + address: 'bitcoincash:test123' + }] + }]) + } + + // Create mock SlpTokenMedia + mockSlpTokenMedia = { + getIcon: sandbox.stub().resolves({ tokenIcon: 'test-icon.png' }) + } + + // Mock the imports + uut = new SlpUseCases({ + adapters: mockAdapters, + bchjs: mockBchjs, + config: mockConfig + }) + + // Replace the wallet initialization with our mocks + uut.wallet = mockWallet + uut.slpTokenMedia = mockSlpTokenMedia + uut.walletInitialized = true + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new SlpUseCases() + }, /Adapters instance required/) + }) + + it('should require slpIndexer adapter', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new SlpUseCases({ adapters: {} }) + }, /SLP Indexer adapter required/) + }) + }) + + describe('#getStatus()', () => { + it('should call slpIndexer adapter get method', async () => { + mockAdapters.slpIndexer.get.resolves({ status: 'ok' }) + + const result = await uut.getStatus() + + assert.isTrue(mockAdapters.slpIndexer.get.calledOnceWith('slp/status/')) + assert.deepEqual(result, { status: 'ok' }) + }) + }) + + describe('#getAddress()', () => { + it('should call slpIndexer adapter post method', async () => { + const address = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + mockAdapters.slpIndexer.post.resolves({ balance: 1000 }) + + const result = await uut.getAddress({ address }) + + assert.isTrue( + mockAdapters.slpIndexer.post.calledOnceWith('slp/address/', { address }) + ) + assert.deepEqual(result, { balance: 1000 }) + }) + }) + + describe('#getTxid()', () => { + it('should call slpIndexer adapter post method', async () => { + const txid = 'a'.repeat(64) + mockAdapters.slpIndexer.post.resolves({ txid }) + + const result = await uut.getTxid({ txid }) + + assert.isTrue( + mockAdapters.slpIndexer.post.calledOnceWith('slp/tx/', { txid }) + ) + assert.deepEqual(result, { txid }) + }) + }) + + describe('#getTokenStats()', () => { + it('should call slpIndexer adapter post method', async () => { + const tokenId = 'a'.repeat(64) + const withTxHistory = false + mockAdapters.slpIndexer.post.resolves({ tokenData: {} }) + + const result = await uut.getTokenStats({ tokenId, withTxHistory }) + + assert.isTrue( + mockAdapters.slpIndexer.post.calledOnceWith('slp/token/', { tokenId, withTxHistory }) + ) + assert.deepEqual(result, { tokenData: {} }) + }) + }) + + describe('#getTokenData()', () => { + it('should get token data with mutable and immutable data', async () => { + const tokenId = 'a'.repeat(64) + const tokenStats = { + tokenData: { + documentUri: 'ipfs://test123', + documentHash: 'b'.repeat(64) + } + } + mockAdapters.slpIndexer.post.resolves(tokenStats) + + // Mock decodeOpReturn to return JSON with mda + sandbox.stub(uut, 'decodeOpReturn').resolves(JSON.stringify({ mda: 'bitcoincash:test123' })) + sandbox.stub(uut, 'getMutableCid').resolves('mutable-cid-123') + + const result = await uut.getTokenData({ tokenId }) + + assert.property(result, 'genesisData') + assert.property(result, 'immutableData') + assert.property(result, 'mutableData') + }) + + it('should handle errors when getting mutable data', async () => { + const tokenId = 'a'.repeat(64) + const tokenStats = { + tokenData: { + documentUri: 'ipfs://test123', + documentHash: 'b'.repeat(64) + } + } + mockAdapters.slpIndexer.post.resolves(tokenStats) + + sandbox.stub(uut, 'getMutableCid').rejects(new Error('Test error')) + + const result = await uut.getTokenData({ tokenId }) + + assert.property(result, 'genesisData') + assert.property(result, 'immutableData') + assert.equal(result.mutableData, '') + }) + }) + + describe('#getTokenData2()', () => { + it('should call slpTokenMedia getIcon method', async () => { + const tokenId = 'a'.repeat(64) + const updateCache = false + mockSlpTokenMedia.getIcon.resolves({ tokenIcon: 'test-icon.png' }) + + const result = await uut.getTokenData2({ tokenId, updateCache }) + + assert.isTrue( + mockSlpTokenMedia.getIcon.calledOnceWith({ tokenId, updateCache }) + ) + assert.deepEqual(result, { tokenIcon: 'test-icon.png' }) + }) + }) + + describe('#decodeOpReturn()', () => { + it('should decode OP_RETURN data from transaction', async () => { + const txid = 'a'.repeat(64) + const mockTxData = { + details: { + vout: [ + { + scriptPubKey: { + hex: '6a0c48656c6c6f20576f726c6421' + } + } + ] + } + } + mockBchjs.Electrumx.txData.resolves(mockTxData) + mockBchjs.Script.toASM.returns('OP_RETURN 48656c6c6f20576f726c6421') + + const result = await uut.decodeOpReturn({ txid }) + + assert.isTrue(mockBchjs.Electrumx.txData.calledOnceWith(txid)) + assert.isString(result) + }) + + it('should throw error if txid is not a string', async () => { + try { + await uut.decodeOpReturn({ txid: null }) + assert.fail('Should have thrown an error') + } catch (err) { + assert.include(err.message, 'txid must be a string') + } + }) + }) + + describe('#getCIDData()', () => { + it('should fetch IPFS data from CID', async () => { + const cid = 'ipfs://test123' + const mockData = { name: 'Test Token' } + + // Mock axios + const axios = await import('axios') + sandbox.stub(axios.default, 'get').resolves({ data: mockData }) + + const result = await uut.getCIDData({ cid }) + + assert.deepEqual(result, mockData) + }) + + it('should throw error if cid is not a string', async () => { + try { + await uut.getCIDData({ cid: null }) + assert.fail('Should have thrown an error') + } catch (err) { + assert.include(err.message, 'cid must be a string') + } + }) + }) + + describe('#getMutableCid()', () => { + it('should extract mutable CID from token stats', async () => { + const tokenStats = { + documentHash: 'a'.repeat(64) + } + + const mockOpReturn = JSON.stringify({ mda: 'bitcoincash:test123' }) + sandbox.stub(uut, 'decodeOpReturn').resolves(mockOpReturn) + + mockWallet.getTransactions.resolves([ + { + tx_hash: 'b'.repeat(64), + height: 100 + } + ]) + + mockWallet.getTxData.resolves([{ + vin: [{ + address: 'bitcoincash:test123' + }] + }]) + + // Mock decodeOpReturn for the transaction + uut.decodeOpReturn.onSecondCall().resolves(JSON.stringify({ cid: 'ipfs://mutable-cid-123', ts: 1234567890 })) + + const result = await uut.getMutableCid({ tokenStats }) + + assert.isString(result) + }) + + it('should return false if no documentHash in tokenStats', async () => { + const tokenStats = {} + + try { + await uut.getMutableCid({ tokenStats }) + assert.fail('Should have thrown an error') + } catch (err) { + assert.include(err.message, 'No documentHash property found') + } + }) + }) +}) From 1914e6f98513b7fed71efffbb60267b961dbd64d Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 15 Nov 2025 17:15:46 -0800 Subject: [PATCH 11/13] fixing route name for fulcrum --- src/controllers/rest-api/fulcrum/router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/rest-api/fulcrum/router.js b/src/controllers/rest-api/fulcrum/router.js index 4f75073..970576b 100644 --- a/src/controllers/rest-api/fulcrum/router.js +++ b/src/controllers/rest-api/fulcrum/router.js @@ -29,7 +29,7 @@ class FulcrumRouter { this.fulcrumController = new FulcrumRESTController(dependencies) this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '') - this.baseUrl = `${this.apiPrefix}/full-node/fulcrum` + this.baseUrl = `${this.apiPrefix}/fulcrum` if (!this.baseUrl.startsWith('/')) { this.baseUrl = `/${this.baseUrl}` } From f59ca87f1e556702f5c41fcf82775539579eedd1 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 15 Nov 2025 17:55:56 -0800 Subject: [PATCH 12/13] Converting /full-node/fulcrum endpoints to just /fulcrum --- .../rest-api/fulcrum/controller.js | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/controllers/rest-api/fulcrum/controller.js b/src/controllers/rest-api/fulcrum/controller.js index 2da0cab..14270f9 100644 --- a/src/controllers/rest-api/fulcrum/controller.js +++ b/src/controllers/rest-api/fulcrum/controller.js @@ -1,5 +1,5 @@ /* - REST API Controller for the /full-node/fulcrum routes. + REST API Controller for the /fulcrum routes. */ import wlogger from '../../../adapters/wlogger.js' @@ -44,7 +44,7 @@ class FulcrumRESTController { } /** - * @api {get} /v6/full-node/fulcrum/ Service status + * @api {get} /v6/fulcrum/ Service status * @apiName FulcrumRoot * @apiGroup Fulcrum * @@ -87,7 +87,7 @@ class FulcrumRESTController { } /** - * @api {get} /v6/full-node/fulcrum/balance/:address Get balance for a single address + * @api {get} /v6/fulcrum/balance/:address Get balance for a single address * @apiName GetBalance * @apiGroup Fulcrum * @apiDescription Returns an object with confirmed and unconfirmed balance associated with an address. @@ -113,7 +113,7 @@ class FulcrumRESTController { } /** - * @api {post} /v6/full-node/fulcrum/balance Get balances for an array of addresses + * @api {post} /v6/fulcrum/balance Get balances for an array of addresses * @apiName GetBalances * @apiGroup Fulcrum * @apiDescription Returns an array of balances associated with an array of addresses. Limited to 20 items per request. @@ -158,7 +158,7 @@ class FulcrumRESTController { } /** - * @api {get} /v6/full-node/fulcrum/utxos/:address Get utxos for a single address + * @api {get} /v6/fulcrum/utxos/:address Get utxos for a single address * @apiName GetUtxos * @apiGroup Fulcrum * @apiDescription Returns an object with UTXOs associated with an address. @@ -184,7 +184,7 @@ class FulcrumRESTController { } /** - * @api {post} /v6/full-node/fulcrum/utxos Get utxos for an array of addresses + * @api {post} /v6/fulcrum/utxos Get utxos for an array of addresses * @apiName GetUtxosBulk * @apiGroup Fulcrum * @apiDescription Returns an array of objects with UTXOs associated with an address. Limited to 20 items per request. @@ -229,7 +229,7 @@ class FulcrumRESTController { } /** - * @api {get} /v6/full-node/fulcrum/tx/data/:txid Get transaction details for a TXID + * @api {get} /v6/fulcrum/tx/data/:txid Get transaction details for a TXID * @apiName GetTransactionDetails * @apiGroup Fulcrum * @apiDescription Returns an object with transaction details of the TXID @@ -253,7 +253,7 @@ class FulcrumRESTController { } /** - * @api {post} /v6/full-node/fulcrum/tx/data Get transaction details for an array of TXIDs + * @api {post} /v6/fulcrum/tx/data Get transaction details for an array of TXIDs * @apiName GetTransactionDetailsBulk * @apiGroup Fulcrum * @apiDescription Returns an array of objects with transaction details of an array of TXIDs. Limited to 20 items per request. @@ -285,7 +285,7 @@ class FulcrumRESTController { } /** - * @api {post} /v6/full-node/fulcrum/tx/broadcast Broadcast a raw transaction + * @api {post} /v6/fulcrum/tx/broadcast Broadcast a raw transaction * @apiName BroadcastTransaction * @apiGroup Fulcrum * @apiDescription Broadcast a raw transaction and return the transaction ID on success or error on failure. @@ -309,7 +309,7 @@ class FulcrumRESTController { } /** - * @api {get} /v6/full-node/fulcrum/block/headers/:height Get block headers + * @api {get} /v6/fulcrum/block/headers/:height Get block headers * @apiName GetBlockHeaders * @apiGroup Fulcrum * @apiDescription Returns an array with block headers starting at the block height @@ -347,7 +347,7 @@ class FulcrumRESTController { } /** - * @api {post} /v6/full-node/fulcrum/block/headers Get block headers for an array of height + count pairs + * @api {post} /v6/fulcrum/block/headers Get block headers for an array of height + count pairs * @apiName GetBlockHeadersBulk * @apiGroup Fulcrum * @apiDescription Returns an array of objects with block headers. Limited to 20 items per request. @@ -394,7 +394,7 @@ class FulcrumRESTController { } /** - * @api {get} /v6/full-node/fulcrum/transactions/:address Get transaction history for a single address + * @api {get} /v6/fulcrum/transactions/:address Get transaction history for a single address * @apiName GetTransactions * @apiGroup Fulcrum * @apiDescription Returns an array of historical transactions associated with an address. Results are returned in descending order (most recent TX first). Passing allTxs=true will return the entire transaction history, otherwise, only the last 100 TXIDs will be returned. @@ -431,7 +431,7 @@ class FulcrumRESTController { } /** - * @api {post} /v6/full-node/fulcrum/transactions Get the transaction history for an array of addresses + * @api {post} /v6/fulcrum/transactions Get the transaction history for an array of addresses * @apiName GetTransactionsBulk * @apiGroup Fulcrum * @apiDescription Returns an array of transactions associated with an array of addresses. Limited to 20 items per request. Passing allTxs=true will return the entire transaction history, otherwise, only the last 100 TXIDs will be returned. @@ -480,7 +480,7 @@ class FulcrumRESTController { } /** - * @api {get} /v6/full-node/fulcrum/unconfirmed/:address Get unconfirmed utxos for a single address + * @api {get} /v6/fulcrum/unconfirmed/:address Get unconfirmed utxos for a single address * @apiName GetMempool * @apiGroup Fulcrum * @apiDescription Returns an object with unconfirmed UTXOs associated with an address. @@ -506,7 +506,7 @@ class FulcrumRESTController { } /** - * @api {post} /v6/full-node/fulcrum/unconfirmed Get unconfirmed utxos for an array of addresses + * @api {post} /v6/fulcrum/unconfirmed Get unconfirmed utxos for an array of addresses * @apiName GetMempoolBulk * @apiGroup Fulcrum * @apiDescription Returns an array of objects with unconfirmed UTXOs associated with an address. Limited to 20 items per request. From c338fd38eaaf8dfff6c80b1b91228fe9fdef2ba2 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sun, 16 Nov 2025 07:38:35 -0800 Subject: [PATCH 13/13] fix(slp): Removing the data2 path as it never worked well --- src/controllers/rest-api/slp/controller.js | 27 --------- src/controllers/rest-api/slp/router.js | 1 - src/use-cases/slp-use-cases.js | 12 ---- test/unit/controllers/rest-api-index-unit.js | 1 - test/unit/controllers/slp-controller-unit.js | 59 +------------------- test/unit/use-cases/slp-use-cases-unit.js | 15 ----- 6 files changed, 1 insertion(+), 114 deletions(-) diff --git a/src/controllers/rest-api/slp/controller.js b/src/controllers/rest-api/slp/controller.js index c77e1ef..0a66c3a 100644 --- a/src/controllers/rest-api/slp/controller.js +++ b/src/controllers/rest-api/slp/controller.js @@ -32,7 +32,6 @@ class SlpRESTController { this.getTxid = this.getTxid.bind(this) this.getTokenStats = this.getTokenStats.bind(this) this.getTokenData = this.getTokenData.bind(this) - this.getTokenData2 = this.getTokenData2.bind(this) this.handleError = this.handleError.bind(this) } @@ -206,32 +205,6 @@ class SlpRESTController { } } - /** - * @api {post} /v6/slp/token/data2 Get expanded token data - * @apiName GetTokenData2 - * @apiGroup SLP - * @apiDescription Get expanded data for the token, including icons. - */ - async getTokenData2 (req, res) { - try { - const tokenId = req.body.tokenId - - if (!tokenId || tokenId === '') { - return res.status(400).json({ - success: false, - error: 'tokenId can not be empty' - }) - } - - const updateCache = req.body.updateCache - - const result = await this.slpUseCases.getTokenData2({ tokenId, updateCache }) - return res.status(200).json(result) - } catch (err) { - return this.handleError(err, res) - } - } - handleError (err, res) { wlogger.error('Error in SlpRESTController:', err) diff --git a/src/controllers/rest-api/slp/router.js b/src/controllers/rest-api/slp/router.js index 9375812..32c7f97 100644 --- a/src/controllers/rest-api/slp/router.js +++ b/src/controllers/rest-api/slp/router.js @@ -47,7 +47,6 @@ class SlpRouter { this.router.post('/txid', this.slpController.getTxid) this.router.post('/token', this.slpController.getTokenStats) this.router.post('/token/data', this.slpController.getTokenData) - this.router.post('/token/data2', this.slpController.getTokenData2) app.use(this.baseUrl, this.router) } diff --git a/src/use-cases/slp-use-cases.js b/src/use-cases/slp-use-cases.js index 4a4555b..2e64650 100644 --- a/src/use-cases/slp-use-cases.js +++ b/src/use-cases/slp-use-cases.js @@ -146,18 +146,6 @@ class SlpUseCases { } } - async getTokenData2 ({ tokenId, updateCache }) { - try { - await this._ensureInitialized() - - const tokenData = await this.slpTokenMedia.getIcon({ tokenId, updateCache }) - return tokenData - } catch (err) { - wlogger.error('Error in SlpUseCases.getTokenData2()', err) - throw err - } - } - async getMutableCid ({ tokenStats }) { // Validate input - this should throw, not be caught if (!tokenStats || !tokenStats.documentHash) { diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js index 6df2479..b54830d 100644 --- a/test/unit/controllers/rest-api-index-unit.js +++ b/test/unit/controllers/rest-api-index-unit.js @@ -92,7 +92,6 @@ describe('#controllers/rest-api/index.js', () => { getTxid: () => {}, getTokenStats: () => {}, getTokenData: () => {}, - getTokenData2: () => {}, getMutableCid: () => {}, decodeOpReturn: () => {}, getCIDData: () => {} diff --git a/test/unit/controllers/slp-controller-unit.js b/test/unit/controllers/slp-controller-unit.js index b86c345..650f791 100644 --- a/test/unit/controllers/slp-controller-unit.js +++ b/test/unit/controllers/slp-controller-unit.js @@ -25,8 +25,7 @@ describe('#slp-controller.js', () => { getAddress: sandbox.stub().resolves({ balance: 1000 }), getTxid: sandbox.stub().resolves({ txid: 'abc' }), getTokenStats: sandbox.stub().resolves({ tokenData: {} }), - getTokenData: sandbox.stub().resolves({ genesisData: {}, immutableData: '', mutableData: '' }), - getTokenData2: sandbox.stub().resolves({ tokenIcon: 'test-icon.png' }) + getTokenData: sandbox.stub().resolves({ genesisData: {}, immutableData: '', mutableData: '' }) }) beforeEach(() => { @@ -310,60 +309,4 @@ describe('#slp-controller.js', () => { assert.deepEqual(res.jsonData, { error: 'Token data not found' }) }) }) - - describe('#getTokenData2()', () => { - it('should return expanded token data on success', async () => { - const req = createMockRequest({ - body: { tokenId: 'a'.repeat(64) } - }) - const res = createMockResponse() - - await uut.getTokenData2(req, res) - - assert.equal(res.statusValue, 200) - assert.deepEqual(res.jsonData, { tokenIcon: 'test-icon.png' }) - assert.isTrue(mockUseCases.slp.getTokenData2.calledOnce) - }) - - it('should pass updateCache flag', async () => { - const req = createMockRequest({ - body: { tokenId: 'a'.repeat(64), updateCache: true } - }) - const res = createMockResponse() - - await uut.getTokenData2(req, res) - - assert.isTrue(mockUseCases.slp.getTokenData2.calledWith({ - tokenId: 'a'.repeat(64), - updateCache: true - })) - }) - - it('should return error if tokenId is empty', async () => { - const req = createMockRequest({ - body: { tokenId: '' } - }) - const res = createMockResponse() - - await uut.getTokenData2(req, res) - - assert.equal(res.statusValue, 400) - assert.property(res.jsonData, 'error') - }) - - it('should handle errors via handleError', async () => { - const error = new Error('Token icon not found') - error.status = 404 - mockUseCases.slp.getTokenData2.rejects(error) - const req = createMockRequest({ - body: { tokenId: 'a'.repeat(64) } - }) - const res = createMockResponse() - - await uut.getTokenData2(req, res) - - assert.equal(res.statusValue, 404) - assert.deepEqual(res.jsonData, { error: 'Token icon not found' }) - }) - }) }) diff --git a/test/unit/use-cases/slp-use-cases-unit.js b/test/unit/use-cases/slp-use-cases-unit.js index 9ab7a14..3796656 100644 --- a/test/unit/use-cases/slp-use-cases-unit.js +++ b/test/unit/use-cases/slp-use-cases-unit.js @@ -195,21 +195,6 @@ describe('#slp-use-cases.js', () => { }) }) - describe('#getTokenData2()', () => { - it('should call slpTokenMedia getIcon method', async () => { - const tokenId = 'a'.repeat(64) - const updateCache = false - mockSlpTokenMedia.getIcon.resolves({ tokenIcon: 'test-icon.png' }) - - const result = await uut.getTokenData2({ tokenId, updateCache }) - - assert.isTrue( - mockSlpTokenMedia.getIcon.calledOnceWith({ tokenId, updateCache }) - ) - assert.deepEqual(result, { tokenIcon: 'test-icon.png' }) - }) - }) - describe('#decodeOpReturn()', () => { it('should decode OP_RETURN data from transaction', async () => { const txid = 'a'.repeat(64)