mirror of
https://github.com/Permissionless-Software-Foundation/psf-bch-api.git
synced 2026-09-21 16:52:00 -07:00
feat(slp): Ported SLP endpoints from bch-api
This commit is contained in:
+4
-1
@@ -7,4 +7,7 @@ RPC_PASSWORD=password
|
||||
X402_ENABLED=false
|
||||
|
||||
# Fulcrum Indexer
|
||||
FULCRUM_API=http://192.168.2.127:3001
|
||||
FULCRUM_API=http://192.168.2.127:3001
|
||||
|
||||
# SLP Indexer
|
||||
SLP_INDEXER_API=http://192.168.2.127:5010
|
||||
Generated
+1190
-180
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
Vendored
+12
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user