Added x402 support

This commit is contained in:
Chris Troutner
2025-11-25 10:59:30 -08:00
parent f24d36ccee
commit 246da25f91
14 changed files with 1384 additions and 269 deletions
+1107 -209
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -51,7 +51,8 @@
"satoshi-bitcoin": "1.0.5",
"slp-mdm": "0.0.7",
"slp-parser": "0.0.4",
"wif": "2.0.6"
"wif": "2.0.6",
"x402-bch-axios": "1.1.1"
},
"devDependencies": {
"apidoc": "1.2.0",
+43
View File
@@ -9,6 +9,15 @@
// bch-api mainnet.
// const DEFAULT_REST_API = "http://localhost:3000/v5/"
// Global npm libraries
import axios from 'axios'
import {
createSigner,
withPaymentInterceptor,
createPaymentHeader,
selectPaymentRequirements
} from 'x402-bch-axios'
// local deps
import BitcoinCash from './bitcoincash.js'
import Crypto from './crypto.js'
@@ -61,11 +70,37 @@ class BCHJS {
this.authToken = `Bearer ${this.bearerToken}`
}
// x402 payment configuration
// If a WIF private key is provided, enable x402 automatic payment handling
this.wif = (config && config.wif) || ''
this.paymentAmountSats = (config && config.paymentAmountSats) || 2000
this.bchServerURL = (config && config.bchServerURL) || 'http://free-bch.fullstack.cash'
const libConfig = {
restURL: this.restURL,
authToken: this.authToken
}
// If WIF is provided, create an axios instance with x402 payment interceptor
// Otherwise, let sub-modules use their own axios import for backwards compatibility
if (this.wif) {
let axiosInstance = axios.create({
baseURL: this.restURL,
headers: {
authorization: this.authToken
}
})
const signer = createSigner(this.wif, this.paymentAmountSats)
axiosInstance = withPaymentInterceptor(
axiosInstance,
signer,
{ bchServerURL: this.bchServerURL }
)
libConfig.axios = axiosInstance
}
// ElectrumX indexer
this.Electrumx = new Electrumx(libConfig)
@@ -102,6 +137,14 @@ class BCHJS {
this.eCash = new Ecash()
this.PsfSlpIndexer = new PsfSlpIndexer(libConfig)
// Expose x402 helper functions for advanced use cases
this.x402 = {
createSigner,
withPaymentInterceptor,
createPaymentHeader,
selectPaymentRequirements
}
}
}
+26 -24
View File
@@ -11,6 +11,8 @@ class Blockchain {
constructor (config) {
this.restURL = config.restURL
this.authToken = config.authToken
// Use the shared axios instance if provided, otherwise fall back to axios
this.axios = config.axios || axios
this.axiosOptions = {
headers: {
@@ -39,7 +41,7 @@ class Blockchain {
*/
async getBestBlockHash () {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getBestBlockHash`,
this.axiosOptions
)
@@ -93,7 +95,7 @@ class Blockchain {
if (!blockhash || typeof blockhash !== 'string') {
throw new Error('blockhash must be a string')
}
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}full-node/blockchain/getblock`,
{
blockhash,
@@ -147,7 +149,7 @@ class Blockchain {
*/
async getBlockchainInfo () {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getBlockchainInfo`,
this.axiosOptions
)
@@ -178,7 +180,7 @@ class Blockchain {
*/
async getBlockCount () {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getBlockCount`,
this.axiosOptions
)
@@ -214,7 +216,7 @@ class Blockchain {
if (typeof height !== 'string') height = JSON.stringify(height)
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getBlockHash/${height}`,
this.axiosOptions
)
@@ -261,7 +263,7 @@ class Blockchain {
try {
// Handle single hash.
if (typeof hash === 'string') {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getBlockHeader/${hash}?verbose=${verbose}`,
this.axiosOptions
)
@@ -271,7 +273,7 @@ class Blockchain {
// Handle array of hashes.
} else if (Array.isArray(hash)) {
// Dev note: must use axios.post for unit test stubbing.
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}full-node/blockchain/getBlockHeader`,
{
hashes: hash,
@@ -322,7 +324,7 @@ class Blockchain {
*/
async getChainTips () {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getChainTips`,
this.axiosOptions
)
@@ -354,7 +356,7 @@ class Blockchain {
*/
async getDifficulty () {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getDifficulty`,
this.axiosOptions
)
@@ -370,7 +372,7 @@ class Blockchain {
if (typeof txid !== 'string') txid = JSON.stringify(txid)
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getMempoolAncestors/${txid}?verbose=${verbose}`,
this.axiosOptions
)
@@ -385,7 +387,7 @@ class Blockchain {
if (typeof txid !== 'string') txid = JSON.stringify(txid)
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getMempoolDescendants/${txid}?verbose=${verbose}`,
this.axiosOptions
)
@@ -482,7 +484,7 @@ class Blockchain {
try {
if (typeof txid === 'string') {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getMempoolEntry/${txid}`,
this.axiosOptions
)
@@ -490,7 +492,7 @@ class Blockchain {
return response.data
} else if (Array.isArray(txid)) {
// Dev note: must use axios.post for unit test stubbing.
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}full-node/blockchain/getMempoolEntry`,
{
txids: txid
@@ -533,7 +535,7 @@ class Blockchain {
*/
async getMempoolInfo () {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getMempoolInfo`,
this.axiosOptions
)
@@ -580,7 +582,7 @@ class Blockchain {
*/
async getRawMempool (verbose = false) {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/getRawMempool?vebose=${verbose}`,
this.axiosOptions
)
@@ -624,11 +626,11 @@ class Blockchain {
}
// Send the request to the REST API.
// const response = await axios.get(
// const response = await this.axios.get(
// `${this.restURL}blockchain/getTxOut/${txid}/${n}?includeMempool=${includeMempool}`,
// this.axiosOptions
// )
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}full-node/blockchain/getTxOut`,
{
txid,
@@ -688,13 +690,13 @@ class Blockchain {
const path = `${this.restURL}full-node/blockchain/getTxOutProof/${txids}`
// if (blockhash) path = `${path}?blockhash=${blockhash}`
const response = await axios.get(path, this.axiosOptions)
const response = await this.axios.get(path, this.axiosOptions)
return response.data
// Array of txids.
} else if (Array.isArray(txids)) {
// Dev note: must use axios.post for unit test stubbing.
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}full-node/blockchain/getTxOutProof`,
{
txids
@@ -714,7 +716,7 @@ class Blockchain {
async preciousBlock (blockhash) {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/preciousBlock/${blockhash}`,
this.axiosOptions
)
@@ -727,7 +729,7 @@ class Blockchain {
async pruneBlockchain (height) {
try {
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}full-node/blockchain/pruneBlockchain/${height}`,
this.axiosOptions
)
@@ -740,7 +742,7 @@ class Blockchain {
async verifyChain (checklevel = 3, nblocks = 6) {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/verifyChain?checklevel=${checklevel}&nblocks=${nblocks}`,
this.axiosOptions
)
@@ -792,7 +794,7 @@ class Blockchain {
try {
// Single block
if (typeof proof === 'string') {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/blockchain/verifyTxOutProof/${proof}`,
this.axiosOptions
)
@@ -801,7 +803,7 @@ class Blockchain {
// Array of hashes.
} else if (Array.isArray(proof)) {
// Dev note: must use axios.post for unit test stubbing.
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}full-node/blockchain/verifyTxOutProof`,
{
proofs: proof
+4 -2
View File
@@ -10,6 +10,8 @@ class Control {
constructor (config) {
this.restURL = config.restURL
this.authToken = config.authToken
// Use the shared axios instance if provided, otherwise fall back to axios
this.axios = config.axios || axios
this.axiosOptions = {
headers: {
@@ -66,7 +68,7 @@ class Control {
*/
async getNetworkInfo () {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/control/getNetworkInfo`,
this.axiosOptions
)
@@ -79,7 +81,7 @@ class Control {
async getMemoryInfo () {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/control/getMemoryInfo`,
this.axiosOptions
)
+14 -12
View File
@@ -15,6 +15,8 @@ class ElectrumX {
constructor (config) {
this.restURL = config.restURL
this.authToken = config.authToken
// Use the shared axios instance if provided, otherwise fall back to axios
this.axios = config.axios || axios
this.axiosOptions = {
headers: {
@@ -97,7 +99,7 @@ class ElectrumX {
// Handle single address.
if (typeof address === 'string') {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}fulcrum/utxos/${address}`,
this.axiosOptions
)
@@ -105,7 +107,7 @@ class ElectrumX {
// Handle array of addresses.
} else if (Array.isArray(address)) {
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}fulcrum/utxos`,
{
addresses: address
@@ -181,7 +183,7 @@ class ElectrumX {
try {
// Handle single address.
if (typeof address === 'string') {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}fulcrum/balance/${address}`,
this.axiosOptions
)
@@ -189,7 +191,7 @@ class ElectrumX {
// Handle array of addresses.
} else if (Array.isArray(address)) {
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}fulcrum/balance`,
{
addresses: address
@@ -279,7 +281,7 @@ class ElectrumX {
try {
// Handle single address.
if (typeof address === 'string') {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}fulcrum/transactions/${address}/${allTxs}`,
this.axiosOptions
)
@@ -287,7 +289,7 @@ class ElectrumX {
// Handle array of addresses.
} else if (Array.isArray(address)) {
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}fulcrum/transactions`,
{
addresses: address,
@@ -374,7 +376,7 @@ class ElectrumX {
try {
// Handle single address.
if (typeof address === 'string') {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}fulcrum/unconfirmed/${address}`,
this.axiosOptions
)
@@ -382,7 +384,7 @@ class ElectrumX {
// Handle array of addresses.
} else if (Array.isArray(address)) {
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}fulcrum/unconfirmed`,
{
addresses: address
@@ -443,7 +445,7 @@ class ElectrumX {
*/
async blockHeader (height, count = 1) {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}fulcrum/block/headers/${height}?count=${count}`,
this.axiosOptions
)
@@ -534,13 +536,13 @@ class ElectrumX {
try {
// Handle single transaction.
if (typeof txid === 'string') {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}fulcrum/tx/data/${txid}`,
this.axiosOptions
)
return response.data
} else if (Array.isArray(txid)) {
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}fulcrum/tx/data`,
{
txids: txid
@@ -582,7 +584,7 @@ class ElectrumX {
async broadcast (txHex) {
try {
if (typeof txHex === 'string') {
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}fulcrum/tx/broadcast`,
{ txHex },
this.axiosOptions
+3 -1
View File
@@ -9,7 +9,6 @@ let _this
class Encryption {
constructor (config) {
this.restURL = config.restURL
this.axios = axios
this.authToken = config.authToken
this.axiosOptions = {
@@ -18,6 +17,9 @@ class Encryption {
}
}
// Use the shared axios instance if provided, otherwise fall back to axios
this.axios = config.axios || axios
_this = this
}
+3 -1
View File
@@ -6,6 +6,8 @@ class Generating {
constructor (config) {
this.restURL = config.restURL
this.authToken = config.authToken
// Use the shared axios instance if provided, otherwise fall back to axios
this.axios = config.axios || axios
this.axiosOptions = {
headers: {
@@ -16,7 +18,7 @@ class Generating {
async generateToAddress (blocks, address, maxtries = 1000000) {
try {
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}generating/generateToAddress/${blocks}/${address}?maxtries=${maxtries}`,
this.axiosOptions
)
+6 -4
View File
@@ -6,6 +6,8 @@ class Mining {
constructor (config) {
this.restURL = config.restURL
this.authToken = config.authToken
// Use the shared axios instance if provided, otherwise fall back to axios
this.axios = config.axios || axios
this.axiosOptions = {
headers: {
@@ -16,7 +18,7 @@ class Mining {
async getBlockTemplate (templateRequest) {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}mining/getBlockTemplate/${templateRequest}`,
this.axiosOptions
)
@@ -29,7 +31,7 @@ class Mining {
async getMiningInfo () {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}mining/getMiningInfo`,
this.axiosOptions
)
@@ -42,7 +44,7 @@ class Mining {
async getNetworkHashps (nblocks = 120, height = 1) {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}mining/getNetworkHashps?nblocks=${nblocks}&height=${height}`,
this.axiosOptions
)
@@ -58,7 +60,7 @@ class Mining {
if (parameters) path = `${path}?parameters=${parameters}`
try {
const response = await axios.post(path, this.axiosOptions)
const response = await this.axios.post(path, this.axiosOptions)
return response.data
} catch (error) {
if (error.response && error.response.data) throw error.response.data
+2 -1
View File
@@ -13,7 +13,8 @@ class Price {
}
}
this.axios = axios
// Use the shared axios instance if provided, otherwise fall back to axios
this.axios = config.axios || axios
}
/**
+8 -6
View File
@@ -19,6 +19,8 @@ class PsfSlpIndexer {
constructor (config = {}) {
this.restURL = config.restURL
this.authToken = config.authToken
// Use the shared axios instance if provided, otherwise fall back to axios
this.axios = config.axios || axios
this.axiosOptions = {
headers: {
@@ -58,7 +60,7 @@ class PsfSlpIndexer {
*/
async status () {
try {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}slp/status`,
this.axiosOptions
)
@@ -123,7 +125,7 @@ class PsfSlpIndexer {
// Handle single address.
if (typeof address === 'string') {
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}slp/address`,
{ address },
this.axiosOptions
@@ -188,7 +190,7 @@ class PsfSlpIndexer {
try {
// Handle single address.
if (typeof tokenId === 'string') {
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}slp/token`,
{ tokenId, withTxHistory },
this.axiosOptions
@@ -280,7 +282,7 @@ class PsfSlpIndexer {
// Handle single address.
if (typeof txid === 'string') {
const response = await axios.post(
const response = await this.axios.post(
`${this.restURL}slp/txid`,
{ txid },
this.axiosOptions
@@ -415,7 +417,7 @@ class PsfSlpIndexer {
// Handle single address.
if (typeof tokenId === 'string') {
const response = await axios.post(
const response = await this.axios.post(
// 'https://bchn.fullstack.cash/v5/psf/slp/token/data/',
url,
{ tokenId, withTxHistory },
@@ -508,7 +510,7 @@ class PsfSlpIndexer {
// Handle single address.
if (typeof tokenId === 'string') {
const response = await axios.post(
const response = await this.axios.post(
// 'https://bchn.fullstack.cash/v5/psf/slp/token/data/',
url,
{ tokenId, updateCache },
+8 -7
View File
@@ -13,7 +13,8 @@ class RawTransactions {
}
}
this.axios = axios
// Use the shared axios instance if provided, otherwise fall back to axios
this.axios = config.axios || axios
}
/**
@@ -82,7 +83,7 @@ class RawTransactions {
try {
// Single hex
if (typeof hex === 'string') {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/rawtransactions/decodeRawTransaction/${hex}`,
this.axiosOptions
)
@@ -99,7 +100,7 @@ class RawTransactions {
},
headers: this.axiosOptions.headers
}
const response = await axios(options)
const response = await this.axios(options)
return response.data
}
@@ -150,7 +151,7 @@ class RawTransactions {
try {
if (typeof script === 'string') {
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/rawtransactions/decodeScript/${script}`,
this.axiosOptions
)
@@ -165,7 +166,7 @@ class RawTransactions {
},
headers: this.axiosOptions.headers
}
const response = await axios(options)
const response = await this.axios(options)
return response.data
}
@@ -249,7 +250,7 @@ class RawTransactions {
// 'getRawTransaction() this.axiosOptions: ',
// this.axiosOptions
// )
const response = await axios.get(
const response = await this.axios.get(
`${this.restURL}full-node/rawtransactions/getRawTransaction/${txid}?verbose=${verbose}`,
this.axiosOptions
)
@@ -266,7 +267,7 @@ class RawTransactions {
},
headers: this.axiosOptions.headers
}
const response = await axios(options)
const response = await this.axios(options)
return response.data
}
+2 -1
View File
@@ -14,7 +14,8 @@ class Utils {
this.restURL = config.restURL
this.slpParser = slpParser
this.authToken = config.authToken
this.axios = axios
// Use the shared axios instance if provided, otherwise fall back to axios
this.axios = config.axios || axios
this.axiosOptions = {
headers: {
+156
View File
@@ -0,0 +1,156 @@
import assert from 'assert'
import sinon from 'sinon'
import BCHJS from '../../src/bch-js.js'
describe('#X402 Integration', () => {
let sandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
})
afterEach(() => {
sandbox.restore()
})
describe('#Constructor Configuration', () => {
it('should initialize without x402 by default', () => {
const bchjs = new BCHJS()
assert.strictEqual(bchjs.wif, '')
assert.strictEqual(bchjs.paymentAmountSats, 2000)
assert.strictEqual(bchjs.bchServerURL, 'http://free-bch.fullstack.cash')
})
it('should accept wif in config', () => {
const testWif = 'L1eYaneXDDXy8VDig4Arwe8wYHbhtsA5wuQvwsKwhaYeneoZuKG4'
const bchjs = new BCHJS({
wif: testWif
})
assert.strictEqual(bchjs.wif, testWif)
})
it('should accept paymentAmountSats in config', () => {
const bchjs = new BCHJS({
paymentAmountSats: 5000
})
assert.strictEqual(bchjs.paymentAmountSats, 5000)
})
it('should accept bchServerURL in config', () => {
const customUrl = 'http://localhost:5000'
const bchjs = new BCHJS({
bchServerURL: customUrl
})
assert.strictEqual(bchjs.bchServerURL, customUrl)
})
it('should use default values when config options are not provided', () => {
const bchjs = new BCHJS({
restURL: 'http://localhost:3000/v6/'
})
assert.strictEqual(bchjs.wif, '')
assert.strictEqual(bchjs.paymentAmountSats, 2000)
assert.strictEqual(bchjs.bchServerURL, 'http://free-bch.fullstack.cash')
})
})
describe('#x402 Helper Functions', () => {
it('should expose createSigner function', () => {
const bchjs = new BCHJS()
assert.strictEqual(typeof bchjs.x402.createSigner, 'function')
})
it('should expose withPaymentInterceptor function', () => {
const bchjs = new BCHJS()
assert.strictEqual(typeof bchjs.x402.withPaymentInterceptor, 'function')
})
it('should expose createPaymentHeader function', () => {
const bchjs = new BCHJS()
assert.strictEqual(typeof bchjs.x402.createPaymentHeader, 'function')
})
it('should expose selectPaymentRequirements function', () => {
const bchjs = new BCHJS()
assert.strictEqual(typeof bchjs.x402.selectPaymentRequirements, 'function')
})
})
describe('#Axios Instance', () => {
it('should have axios available in sub-modules', () => {
const bchjs = new BCHJS()
// Check that sub-modules have axios available (from their own import or config)
assert.ok(bchjs.Control.axios)
assert.ok(bchjs.Blockchain.axios)
assert.ok(bchjs.Mining.axios)
assert.ok(bchjs.Electrumx.axios)
assert.ok(bchjs.RawTransactions.axios)
assert.ok(bchjs.Price.axios)
})
it('should pass x402-wrapped axios instance when WIF is provided', () => {
const testWif = 'L1eYaneXDDXy8VDig4Arwe8wYHbhtsA5wuQvwsKwhaYeneoZuKG4'
const bchjs = new BCHJS({
wif: testWif
})
// All modules should have the same wrapped axios instance
assert.ok(bchjs.Control.axios)
assert.ok(bchjs.Blockchain.axios)
// When WIF is provided, the axios instances should be the same wrapped instance
assert.strictEqual(bchjs.Control.axios, bchjs.Blockchain.axios)
})
})
describe('#selectPaymentRequirements', () => {
it('should select BCH utxo payment requirements from accepts array', () => {
const bchjs = new BCHJS()
const accepts = [
{
scheme: 'exact',
network: 'base-sepolia',
maxAmountRequired: '10000'
},
{
scheme: 'utxo',
network: 'bch',
minAmountRequired: '1000',
payTo: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
}
]
const result = bchjs.x402.selectPaymentRequirements(accepts)
assert.strictEqual(result.scheme, 'utxo')
assert.strictEqual(result.network, 'bch')
assert.strictEqual(result.minAmountRequired, '1000')
})
it('should throw an error when no BCH requirements found', () => {
const bchjs = new BCHJS()
const accepts = [
{
scheme: 'exact',
network: 'base-sepolia',
maxAmountRequired: '10000'
}
]
assert.throws(() => {
bchjs.x402.selectPaymentRequirements(accepts)
}, /No BCH payment requirements found/)
})
})
})