Merge pull request #3 from Permissionless-Software-Foundation/ct-unstable

Updating README and examples
This commit is contained in:
Chris Troutner
2026-03-23 10:57:19 -07:00
committed by GitHub
25 changed files with 1166 additions and 1037 deletions
+183 -25
View File
@@ -1,30 +1,188 @@
# psf-bch-api
# psf-bch-api-base
This is a REST API for communicating with Bitcoin Cash infrastructure. It replaces [bch-api](https://github.com/Permissionless-Software-Foundation/bch-api), and it implements [x402-bch protocol](https://github.com/x402-bch/x402-bch) to handle payments to access the API.
[![License](https://img.shields.io/npm/l/@psf/bch-js)](https://github.com/Permissionless-Software-Foundation/psf-bch-api/blob/master/LICENSE.md)
[![js-standard-style](https://img.shields.io/badge/javascript-standard%20code%20style-green.svg?style=flat-square)](https://github.com/feross/standard)
This project is a fork of `psf-bch-api` with one major change: paid access now uses the `x402` protocol on the Base ecosystem, with pricing in USDC, instead of `x402-bch` on BCH.
The API surface remains focused on BCH infrastructure (BCH full node, Fulcrum, and SLP indexer), but monetization and payment verification are handled through x402-compatible middleware and facilitator endpoints.
## What Changed In This Fork
- Switched from `x402-bch` middleware to `x402-express`.
- Added Base/EVM payment support (`@x402/evm`) and `x402-axios` client example.
- Pricing is configured in `X402_PRICE_USDC` (USDC amount), not BCH satoshis.
- x402 network is configured using CAIP-2 format (for example `eip155:8453` for Base mainnet and `eip155:84532` for Base Sepolia).
- Facilitator auth headers are generated using Coinbase CDP JWT auth (`FACILITATOR_KEY_ID` and `FACILITATOR_SECRET_KEY`) when using CDP endpoints.
## Architecture
The server is a Node.js + Express REST API following a Clean Architecture style, and it still depends on:
- BCH full node JSON-RPC
- Fulcrum API
- SLP indexer API
The access-control layer now supports:
- open access (no auth, no payment)
- bearer-token auth
- x402 paid access on Base + USDC
- optional bearer bypass for trusted clients when x402 is enabled
## Quick Start
1. Install dependencies:
```bash
npm install
```
2. Create a local env file:
```bash
cp .env-example .env
```
3. Edit `.env` for your infrastructure and access-control mode.
4. Start the server:
```bash
npm start
```
By default, the API runs on `http://localhost:5942` and controllers are mounted under `/v6`.
## Environment Variables
All configuration is loaded from environment variables (typically from `.env`).
### Core Server and BCH Infrastructure
- `PORT` (default: `5942`)
- `NODE_ENV` (default: `development`)
- `API_PREFIX` (default: `/v6`)
- `LOG_LEVEL` (default: `info`)
- `RPC_BASEURL` (default: `http://127.0.0.1:8332`)
- `RPC_USERNAME`
- `RPC_PASSWORD`
- `RPC_TIMEOUT_MS` (default: `15000`)
- `RPC_REQUEST_ID_PREFIX` (default: `psf-bch-api`)
- `FULCRUM_API`
- `FULCRUM_TIMEOUT_MS` (default: `15000`)
- `SLP_INDEXER_API`
- `SLP_INDEXER_TIMEOUT_MS` (default: `15000`)
- `REST_URL` or `LOCAL_RESTURL` (default fallback: `http://127.0.0.1:5942/v6/`)
- `IPFS_GATEWAY` (default: `p2wdb-gateway-678.fullstack.cash`)
- `SERVER_KEEPALIVE_TIMEOUT_MS` (default: `3000`)
- `SERVER_HEADERS_TIMEOUT_MS` (default: `65000`)
- `SERVER_REQUEST_TIMEOUT_MS` (default: `120000`)
### x402 + Base + USDC Settings
- `X402_ENABLED` (default: `true`)
- `SERVER_BASE_ADDRESS` (EVM address receiving x402 settlements)
- `X402_PRICE_USDC` (USDC charged per request)
- `x402_NETWORK` (CAIP-2 chain ID; e.g. `eip155:8453` or `eip155:84532`)
- `x402_FACILITATOR_URL` (default: `https://api.cdp.coinbase.com/platform/v2/x402`)
- `FACILITATOR_KEY_ID` (required for CDP facilitator auth)
- `FACILITATOR_SECRET_KEY` (required for CDP facilitator auth)
### Optional Bearer Auth
- `USE_BASIC_AUTH` (default: `false`)
- `BASIC_AUTH_TOKEN`
## Access Control Modes
Behavior is controlled by `X402_ENABLED` and `USE_BASIC_AUTH`.
### 1) Open Access
```bash
X402_ENABLED=false
USE_BASIC_AUTH=false
```
No payment and no auth checks.
### 2) Bearer Auth Only
```bash
X402_ENABLED=false
USE_BASIC_AUTH=true
BASIC_AUTH_TOKEN=my-secret-token
```
Requests (except `/` and `/health`) must send:
```text
Authorization: Bearer my-secret-token
```
### 3) x402 Payments on Base (USDC)
```bash
X402_ENABLED=true
USE_BASIC_AUTH=false
SERVER_BASE_ADDRESS=0xYourBaseAddress
X402_PRICE_USDC=0.1
x402_NETWORK=eip155:8453
x402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402
FACILITATOR_KEY_ID=your_key_id
FACILITATOR_SECRET_KEY=your_secret
```
Protected endpoints return `402 Payment Required` when no valid payment is attached. x402-capable clients can pay and retry automatically.
### 4) x402 + Bearer Bypass
```bash
X402_ENABLED=true
USE_BASIC_AUTH=true
BASIC_AUTH_TOKEN=my-secret-token
```
Bearer-authenticated requests bypass payment; all others must pay via x402.
## Client Example: Axios + x402
Use `examples/01-x402-axios-client.js` to test the payment flow end-to-end.
What it does:
1. Calls a protected endpoint without payment and expects `402`.
2. Repeats the call using `x402-axios` + an EVM wallet and expects success.
Run it with an EVM private key:
```bash
PRIVATE_KEY=0x... node examples/01-x402-axios-client.js
```
The script is configured for Base mainnet by default (`viem/chains` `base`). You can switch it to Base Sepolia by using the commented testnet import in the file.
## Coinbase CDP Credentials
When using Coinbase's hosted facilitator (`https://api.cdp.coinbase.com/platform/v2/x402`), you must provide:
- `FACILITATOR_KEY_ID`
- `FACILITATOR_SECRET_KEY`
These are CDP Secret API Key credentials from the Coinbase Developer Platform API Keys dashboard and are used to generate JWT auth headers for `/verify` and `/settle`.
## Development
Common commands:
```bash
npm start
npm test
npm run test:integration
npm run docs
```
## License
[MIT](./LICENSE.md)
## x402-bch Payments
All REST endpoints exposed under the `/v6` prefix are protected by the [`x402-bch-express`](https://www.npmjs.com/package/x402-bch-express) middleware. Each API call requires a BCH payment authorization for **200 satoshis**. The middleware advertises payment requirements via HTTP 402 responses and validates incoming `X-PAYMENT` headers with a configured Facilitator.
### Configuration
Environment variables control the payment flow:
- `X402_ENABLED` — set to `false` (case-insensitive) to disable the middleware. Defaults to enabled.
- `SERVER_BCH_ADDRESS` — BCH cash address that receives funding transactions. Defaults to `bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d`.
- `FACILITATOR_URL` — Root URL of the facilitator service (e.g., `http://localhost:4345/facilitator`).
- `X402_PRICE_SAT` — Optional; override the satoshi price per call (defaults to `200`).
When `X402_ENABLED=false`, the server continues to operate without payment headers for local development or trusted deployments.
### Manual Verification
1. Start or point to an `x402-bch` facilitator service (the example facilitator listens at `http://localhost:4345/facilitator`).
2. Run the API server with the default configuration: `npm start`.
3. Call a protected endpoint without an `X-PAYMENT` header, e.g. `curl -i http://localhost:5942/v6/full-node/control/getNetworkInfo`. The server will respond with HTTP `402` and include payment requirements.
4. Restart the server with `X402_ENABLED=false npm start` to confirm that the same request now bypasses the middleware (useful for local development without payments).
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+10 -1
View File
@@ -60,6 +60,7 @@ class Server {
try {
// Create an Express instance.
const app = express()
app.set('trust proxy', true)
const x402Settings = getX402Settings()
const basicAuthSettings = getBasicAuthSettings()
@@ -195,7 +196,10 @@ class Server {
// Request logging middleware
app.use((req, res, next) => {
wlogger.info(`${req.method} ${req.path}`)
wlogger.info(`${req.method} ${req.path}`, {
client_ip: req.ip,
remote_address: req.socket?.remoteAddress || null
})
next()
})
@@ -258,6 +262,11 @@ class Server {
wlogger.info(`Server started on port ${this.config.port}`)
})
// Explicit timeout settings reduce stale keep-alive socket reuse races.
this.server.keepAliveTimeout = this.config.serverKeepAliveTimeoutMs
this.server.headersTimeout = this.config.serverHeadersTimeoutMs
this.server.requestTimeout = this.config.serverRequestTimeoutMs
this.server.on('error', (err) => {
console.error('Server error:', err)
wlogger.error('Server error:', err)
+21
View File
@@ -0,0 +1,21 @@
# 2026-03-16 Update Log
## Summary
Reduced noisy error logging for common SLP transaction misses in the `/v6/slp/txid` path.
## Changes Made
- Updated `src/use-cases/slp-use-cases.js` in `getTxid()`:
- Added a guard for expected missing-record errors (`404` + `Key not found in database`).
- Skips `wlogger.error()` for that specific, common case.
- Still rethrows the error so API response behavior is unchanged.
- Updated `src/controllers/rest-api/slp/controller.js` in `handleError()`:
- Added the same guard to suppress duplicate error-level logs for the same expected case.
- Keeps normal error logging for all other errors.
## Outcome
- The common "Key not found in database" case no longer pollutes error logs.
- Unexpected failures continue to be logged at error level.
- Client-facing status and error message behavior remains unchanged.
+76
View File
@@ -0,0 +1,76 @@
# 2026-03-17 Update Log
## Summary
Enhanced REST request logging to capture client network identity in Winston logs, enabled proxy-aware IP resolution, and reduced Fulcrum error-log noise for expected missing-transaction requests.
## Changes Made
- Updated `bin/server.js`:
- Set Express proxy handling with `app.set('trust proxy', true)`.
- Kept the current Winston request message format (`"${req.method} ${req.path}"`).
- Added structured Winston metadata fields to request logs:
- `client_ip` from `req.ip`
- `remote_address` from `req.socket.remoteAddress`
- Updated `src/adapters/fulcrum-api.js`:
- Added parsing helpers to normalize Fulcrum error messages from multiple response shapes.
- Mapped common daemon missing-TX error (`No such mempool or blockchain transaction`) to:
- status `404`
- message `Transaction not found`
- Updated `src/use-cases/fulcrum-use-cases.js`:
- Removed duplicate error logging in `getTransactionDetails()` and now rethrows adapter errors without a second error-level log.
- Updated `src/controllers/rest-api/fulcrum/controller.js`:
- Added TXID validation (`64`-character hex) for `GET /v6/fulcrum/tx/data/:txid`.
- Updated `handleError()` logging policy:
- `Transaction not found` (`404`) logs at `info`
- other `4xx` logs at `warn`
- `5xx` logs at `error`
## Useful Fields Available for REST Request Logging
- Routing and request basics:
- `method` (`req.method`)
- `path` (`req.path`)
- `original_url` (`req.originalUrl`)
- `query` (`req.query`)
- Client network identity:
- `client_ip` (`req.ip`)
- `forwarded_ips` (`req.ips`, when behind one or more proxies)
- `remote_address` (`req.socket.remoteAddress`)
- HTTP and transport:
- `protocol` (`req.protocol`)
- `secure` (`req.secure`)
- `http_version` (`req.httpVersion`)
- `host` (`req.get('host')`)
- `origin` (`req.get('origin')`)
- `referer` (`req.get('referer')`)
- `user_agent` (`req.get('user-agent')`)
- Request/response performance and size:
- `status_code` (`res.statusCode`, from `res.on('finish')`)
- `duration_ms` (elapsed time between request start and response finish)
- `request_size_bytes` (`req.get('content-length')`)
- `response_size_bytes` (`res.getHeader('content-length')`)
- App-specific request context in this codebase:
- `basic_auth_valid` (`req.locals.basicAuthValid`)
- x402 decision/bypass status (derived from middleware path and config)
## Already Logging
- In Winston request logs:
- `message` with method + path (for example `GET /v6/full-node/blockchain/getBlockCount`)
- `client_ip`
- `remote_address`
- `timestamp` (from Winston timestamp formatter)
- `level`
- In console endpoint logs:
- Request line with method, path, and `req.ip`
- Response line with method, path, and final `res.statusCode`
## Outcome
- Request logs now preserve existing behavior while adding IP attribution fields.
- `trust proxy` ensures `req.ip` is proxy-aware when the server is deployed behind a reverse proxy.
- The project now has a documented list of high-value request fields for future logging expansion.
- Fulcrum missing-transaction lookups now return cleaner API semantics (`404 Transaction not found`).
- Duplicate error logs for a single missing TX lookup were removed.
- Invalid TXIDs are rejected early with a `400` validation error.
-67
View File
@@ -1,67 +0,0 @@
/*
Example script creating a key-pair for a Nostr account and publishing profile metadata.
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'
import * as nip19 from 'nostr-tools/nip19'
import { bytesToHex } from '@noble/hashes/utils.js'
const API_URL = process.env.API_URL || 'http://localhost:5942'
// Generate keys
const sk = generateSecretKey() // `sk` is a Uint8Array
const nsec = nip19.nsecEncode(sk)
const skHex = bytesToHex(sk)
const pk = getPublicKey(sk) // `pk` is a hex string
const npub = nip19.npubEncode(pk)
console.log('private key:', skHex)
console.log('encoded private key:', nsec)
console.log()
console.log('public key:', pk)
console.log('encoded public key:', npub)
console.log()
// Create profile metadata event (kind 0)
const profileMetadata = {
name: 'Alice',
about: 'Hello, I am Alice!',
picture: 'https://example.com/alice.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)
console.log('Signed event:', JSON.stringify(signedEvent, null, 2))
// Publish to REST API
try {
const response = await fetch(`${API_URL}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
console.log('Publish result:', result)
if (result.accepted) {
console.log('Profile metadata published successfully!')
} else {
console.error('Failed to publish:', result.message)
}
} catch (err) {
console.error('Error publishing event:', err)
}
-44
View File
@@ -1,44 +0,0 @@
/*
Example script for reading posts (kind 1 events) from a relay.
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
const API_URL = process.env.API_URL || 'http://localhost:5942'
// JB55's public key
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]
}
try {
// Query events using GET /req/:subId
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
const url = `${API_URL}/req/${subId}?filters=${filtersJson}`
console.log(`Querying events from ${API_URL}`)
console.log('Filters:', JSON.stringify(filters, null, 2))
const response = await fetch(url)
const events = await response.json()
console.log(`\nReceived ${events.length} events:`)
events.forEach((ev, index) => {
console.log(`\nEvent ${index + 1}:`)
console.log(' ID:', ev.id)
console.log(' Author:', ev.pubkey)
console.log(' Created:', new Date(ev.created_at * 1000).toISOString())
console.log(' Content:', ev.content)
})
} catch (err) {
console.error('Error reading posts:', err)
}
-55
View File
@@ -1,55 +0,0 @@
/*
Example script for writing a post to a relay.
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils.js'
const API_URL = process.env.API_URL || 'http://localhost:5942'
// Alice is our user making the post.
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
const alicePubKey = getPublicKey(alicePrivKeyBin)
console.log(`Alice Public Key: ${alicePubKey}`)
const now = new Date()
// Generate a post.
const eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: `This is a test message posted at ${now.toLocaleString()}`
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
console.log('signedEvent:', JSON.stringify(signedEvent, null, 2))
// Publish to REST API
try {
const response = await fetch(`${API_URL}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
console.log('result:', result)
if (result.accepted) {
console.log('Post published successfully!')
console.log('Event ID:', result.eventId)
} else {
console.error('Failed to publish:', result.message)
}
} catch (err) {
console.error('Error publishing post:', err)
}
-49
View File
@@ -1,49 +0,0 @@
/*
Example script for reading posts from user Alice.
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
import { getPublicKey } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils.js'
const API_URL = process.env.API_URL || 'http://localhost:5942'
// Alice is our user.
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
const alicePubKey = getPublicKey(alicePrivKeyBin)
console.log(`Alice Public Key: ${alicePubKey}`)
// Create subscription ID
const subId = 'read-alice-posts-' + Date.now()
// Create filters - read posts from Alice
const filters = {
limit: 2,
kinds: [1],
authors: [alicePubKey]
}
try {
// Query events using GET /req/:subId
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
const url = `${API_URL}/req/${subId}?filters=${filtersJson}`
console.log(`Querying events from ${API_URL}`)
console.log('Filters:', JSON.stringify(filters, null, 2))
const response = await fetch(url)
const events = await response.json()
console.log(`\nReceived ${events.length} events from Alice:`)
events.forEach((ev, index) => {
console.log(`\nEvent ${index + 1}:`)
console.log(' ID:', ev.id)
console.log(' Created:', new Date(ev.created_at * 1000).toISOString())
console.log(' Content:', ev.content)
})
} catch (err) {
console.error('Error reading Alice posts:', err)
}
-53
View File
@@ -1,53 +0,0 @@
/*
Example script for getting a follow list (kind 3 events).
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
import { getPublicKey } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils.js'
const API_URL = process.env.API_URL || 'http://localhost:5942'
// Alice is our user to get the follow list.
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
const alicePubKey = getPublicKey(alicePrivKeyBin)
console.log(`Alice Public Key: ${alicePubKey}`)
// 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]
}
try {
// Query events using GET /req/:subId
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
const url = `${API_URL}/req/${subId}?filters=${filtersJson}`
console.log(`Querying follow list from ${API_URL}`)
console.log('Filters:', JSON.stringify(filters, null, 2))
const response = await fetch(url)
const events = await response.json()
if (events.length > 0) {
// Get the most recent follow list (kind 3 events are replaceable)
const followListEvent = events[0]
const aliceFollowList = followListEvent.tags.filter(tag => tag[0] === 'p')
console.log(`\nAlice Follow list (${aliceFollowList.length} followed users):`)
aliceFollowList.forEach((tag, index) => {
console.log(` ${index + 1}. ${tag[1]}${tag[3] ? ` (${tag[3]})` : ''}`)
})
} else {
console.log('\nNo follow list found for Alice')
}
} catch (err) {
console.error('Error getting follow list:', err)
}
-63
View File
@@ -1,63 +0,0 @@
/*
Example to update the follow list with a new list of people to follow.
Refactored to use REST API instead of WebSocket.
Run the server with `npm start` in the main directory, before running this example.
*/
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils.js'
const API_URL = process.env.API_URL || 'http://localhost:5942'
// Alice wants to update her follow list
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
const alicePubKey = getPublicKey(alicePrivKeyBin)
console.log(`Alice Public Key: ${alicePubKey}`)
// Bob is the person to be added to the new follow list
const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
const bobPubKey = getPublicKey(bobPrivKeyBin)
console.log(`Bob Public Key: ${bobPubKey}`)
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: ''
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the event
const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
// Publish to REST API
try {
const response = await fetch(`${API_URL}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
console.log('Publish result:', result)
if (result.accepted) {
console.log('Follow list updated successfully!')
} else {
console.error('Failed to update follow list:', result.message)
}
} catch (err) {
console.error('Error updating follow list:', err)
}
-59
View File
@@ -1,59 +0,0 @@
/*
Example script for adding a reaction (like) to an event.
Refactored to use REST API instead of WebSocket.
https://github.com/nostr-protocol/nips/blob/master/25.md
Run the server with `npm start` in the main directory, before running this example.
*/
import { hexToBytes } from '@noble/hashes/utils.js'
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'
const API_URL = process.env.API_URL || 'http://localhost:5942'
const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
const bobPubKey = getPublicKey(bobPrivKeyBin)
const psf = 'wss://nostr-relay.psfoundation.info'
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], // "e" tag includes event id, relay reference
['p', evIdAuthorPubKey, psf] // "p" tag includes author pubkey, relay reference
],
content: '+'
}
// Sign the event
const signedEvent = finalizeEvent(likeEventTemplate, bobPrivKeyBin)
console.log('signedEvent:', JSON.stringify(signedEvent, null, 2))
// Publish to REST API
try {
const response = await fetch(`${API_URL}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
const result = await response.json()
console.log('result:', result)
if (result.accepted) {
console.log('Like published successfully!')
} else {
console.error('Failed to publish like:', result.message)
}
} catch (err) {
console.error('Error publishing like:', err)
}
+4 -87
View File
@@ -1,90 +1,7 @@
# REST2NOSTR Examples
# x402 Examples
This directory contains examples refactored from the `nostr-sandbox/` directory to use the REST API instead of WebSocket connections.
This example uses the Base blockchain to talk to the psf-bch-api server at https://x402.fullstack.cash.
## Prerequisites
1. Install dependencies:
```bash
npm install nostr-tools @noble/hashes
```
2. Start the REST2NOSTR proxy server:
```bash
npm start
```
3. Set the API_URL environment variable if the server is not running on localhost:3000:
```bash
export API_URL=http://localhost:3000
```
## Examples
### 01-create-account.js
Creates a new Nostr account keypair and publishes profile metadata (kind 0 event).
```bash
node examples/01-create-account.js
```
### 02-read-posts.js
Reads posts (kind 1 events) from a specific author using GET /req/:subId.
```bash
node examples/02-read-posts.js
```
### 03-write-post.js
Publishes a text post (kind 1 event) using POST /event.
```bash
node examples/03-write-post.js
```
### 04-read-alice-posts.js
Reads posts from Alice's account using GET /req/:subId with author filter.
```bash
node examples/04-read-alice-posts.js
```
### 14-get-follow-list.js
Retrieves a user's follow list (kind 3 event) using GET /req/:subId.
```bash
node examples/05-get-follow-list.js
```
### 15-update-follow-list.js
Updates a user's follow list (kind 3 event) using POST /event.
```bash
node examples/06-update-follow-list.js
```
### 17-liking-event.js
Adds a reaction/like to an event (kind 7 event) using POST /event.
```bash
node examples/07-liking-event.js
```
## API Endpoints Used
- **POST /event**: Publish events to the relay
- **GET /req/:subId**: Stateless query for events (returns immediately)
## Differences from WebSocket Examples
1. **No WebSocket connections**: All communication is via HTTP REST API
2. **Stateless queries**: GET /req/:subId returns events immediately rather than streaming
3. **Event publishing**: POST /event returns immediately with acceptance status
4. **No subscription management**: For stateless queries, subscriptions are automatically closed after EOSE
## Notes
- These examples use the same private keys as the original sandbox examples for consistency
- The REST API handles WebSocket connections to relays internally
- For real-time streaming, use POST /req/:subId which supports Server-Sent Events (SSE)
Get a private key for an Base address that contains some USDC. Then execute the example like this:
- `PRIVATE_KEY=0x8bff4962a50d43fe93ac2d7df4da12e6bd701bd1bf12b66d391365ae03058d4f node 08-x402-axios-client.js`
+725 -506
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -16,14 +16,14 @@
"description": "REST API proxy to Bitcoin Cash infrastructure",
"dependencies": {
"@coinbase/cdp-sdk": "1.45.0",
"@psf/bch-js": "7.1.11",
"@psf/bch-js": "7.1.14",
"@x402/core": "2.6.0",
"@x402/evm": "2.6.0",
"axios": "1.7.7",
"cors": "2.8.5",
"dotenv": "16.3.1",
"express": "5.1.0",
"minimal-slp-wallet": "7.1.4",
"minimal-slp-wallet": "7.1.5",
"psffpp": "1.2.1",
"slp-token-media": "1.2.10",
"winston": "3.11.0",
+1
View File
@@ -0,0 +1 @@
+1 -1
View File
@@ -9,7 +9,7 @@ RPC_PASSWORD=password
FULCRUM_API=http://172.17.0.1:3001/v1
# SLP Indexer
SLP_INDEXER_API=http://172.17.0.1:5010
SLP_INDEXER_API=http://172.17.0.1:5020
# REST API URL for wallet operations
LOCAL_RESTURL=http://172.17.0.1:5942/v6
+2
View File
@@ -17,4 +17,6 @@ services:
volumes:
#- ./start-rest2nostr.sh:/home/safeuser/REST2NOSTR/start-rest2nostr.sh
- ./.env:/home/safeuser/.env
- ../data:/home/safeuser/psf-bch-api/production/data
- ../data/logs:/home/safeuser/psf-bch-api/logs
restart: always
+33 -3
View File
@@ -65,17 +65,28 @@ class FulcrumAPIAdapter {
// Attempt to extract error message from response data
if (err.response && err.response.data) {
const data = err.response.data
const status = err.response.status || 400
const message = this._extractErrorMessage(data)
if (this._isCommonMissingTxError(message)) {
return this._formatError('Transaction not found', 404)
}
if (message) {
return this._formatError(message, status)
}
// Handle structured error responses
if (data.error) {
return this._formatError(data.error, err.response.status || 400)
return this._formatError(data.error, status)
}
// Handle string error messages
if (typeof data === 'string') {
return this._formatError(data, err.response.status || 400)
return this._formatError(data, status)
}
// Handle object responses that might contain error info
if (typeof data === 'object' && data.message) {
return this._formatError(data.message, err.response.status || 400)
return this._formatError(data.message, status)
}
// Fallback to returning the status
return this._formatError('Fulcrum API error', err.response.status || 500)
@@ -119,6 +130,25 @@ class FulcrumAPIAdapter {
status: status || 500
}
}
_extractErrorMessage (data) {
if (!data) return ''
if (typeof data === 'string') return data
if (typeof data === 'object') {
if (typeof data.error === 'string') return data.error
if (data.error && typeof data.error === 'object' && data.error.message) return data.error.message
if (data.message) return data.message
}
return ''
}
_isCommonMissingTxError (message = '') {
return typeof message === 'string' &&
message.includes('No such mempool or blockchain transaction')
}
}
export default FulcrumAPIAdapter
+5
View File
@@ -57,6 +57,11 @@ export default {
// Server port
port: parseInt(process.env.PORT, 10) || 5942,
// HTTP server connection lifecycle configuration.
serverKeepAliveTimeoutMs: Number(process.env.SERVER_KEEPALIVE_TIMEOUT_MS || 3000),
serverHeadersTimeoutMs: Number(process.env.SERVER_HEADERS_TIMEOUT_MS || 65000),
serverRequestTimeoutMs: Number(process.env.SERVER_REQUEST_TIMEOUT_MS || 120000),
// Environment
env: process.env.NODE_ENV || 'development',
+47 -6
View File
@@ -87,6 +87,16 @@ class FulcrumRESTController {
return cashAddr
}
_isValidTxid (txid) {
return typeof txid === 'string' && /^[a-fA-F0-9]{64}$/.test(txid)
}
_isCommonMissingTxError (err) {
return err?.status === 404 &&
typeof err?.message === 'string' &&
err.message.includes('Transaction not found')
}
/**
* @api {get} /v6/fulcrum/balance/:address Get balance for a single address
* @apiName GetBalance
@@ -239,10 +249,10 @@ class FulcrumRESTController {
try {
const txid = req.params.txid
if (typeof txid !== 'string') {
if (!this._isValidTxid(txid)) {
return res.status(400).json({
success: false,
error: 'txid must be a string'
error: 'txid must be a 64-character hex string'
})
}
@@ -424,7 +434,20 @@ class FulcrumRESTController {
const cashAddr = this._validateAndConvertAddress(address)
const result = await this.fulcrumUseCases.getTransactions({ address: cashAddr, allTxs })
// Extract bearer token from request header if present
let bearerToken = null
if (req.headers && req.headers.authorization) {
const parts = req.headers.authorization.split(' ')
if (parts.length === 2 && parts[0] === 'Bearer') {
bearerToken = parts[1]
}
}
const result = await this.fulcrumUseCases.getTransactions({
address: cashAddr,
allTxs,
bearerToken
})
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
@@ -470,9 +493,19 @@ class FulcrumRESTController {
}
}
// Extract bearer token from request header if present
let bearerToken = null
if (req.headers && req.headers.authorization) {
const parts = req.headers.authorization.split(' ')
if (parts.length === 2 && parts[0] === 'Bearer') {
bearerToken = parts[1]
}
}
const result = await this.fulcrumUseCases.getTransactionsBulk({
addresses: validatedAddresses,
allTxs
allTxs,
bearerToken
})
return res.status(200).json(result)
} catch (err) {
@@ -552,9 +585,17 @@ class FulcrumRESTController {
}
handleError (err, res) {
wlogger.error('Error in FulcrumRESTController:', err)
const status = err.status || 500
const isCommonMissingTxError = this._isCommonMissingTxError(err)
if (isCommonMissingTxError) {
wlogger.info(`Fulcrum transaction not found: ${err.message}`)
} else if (status >= 500) {
wlogger.error('Error in FulcrumRESTController:', err)
} else {
wlogger.warn(`Fulcrum client error (${status}): ${err.message}`)
}
const message = err.message || 'Internal server error'
return res.status(status).json({ error: message })
+8 -1
View File
@@ -208,7 +208,14 @@ class SlpRESTController {
}
handleError (err, res) {
wlogger.error('Error in SlpRESTController:', err)
const isCommonMissingTxError =
err?.status === 404 &&
typeof err?.message === 'string' &&
err.message.includes('Key not found in database')
if (!isCommonMissingTxError) {
wlogger.error('Error in SlpRESTController:', err)
}
const status = err.status || 500
const message = err.message || 'Internal server error'
+40 -14
View File
@@ -6,9 +6,14 @@ import wlogger from '../adapters/wlogger.js'
import BCHJS from '@psf/bch-js'
import config from '../config/index.js'
// Use RESTURL (from test) or REST_URL (from psf-bch-api config) or fallback to config
const restURL = process.env.RESTURL || process.env.REST_URL || process.env.LOCAL_RESTURL || config.restURL
// Use BCHJSBEARERTOKEN (from test) or BASIC_AUTH_TOKEN (from psf-bch-api config) or fallback to config
const bearerToken = process.env.BCHJSBEARERTOKEN || process.env.BASIC_AUTH_TOKEN || config.basicAuth.token
const bchjs = new BCHJS({
restURL: config.restURL,
bearerToken: config.basicAuth.token
restURL,
bearerToken
})
class FulcrumUseCases {
@@ -57,14 +62,9 @@ class FulcrumUseCases {
}
async getTransactionDetails ({ txid }) {
try {
const response = await this.fulcrum.get(`electrumx/tx/data/${txid}`)
// console.log(`getTransactionDetails() TXID ${txid}: ${JSON.stringify(response, null, 2)}`)
return response
} catch (err) {
wlogger.error('Error in FulcrumUseCases.getTransactionDetails()', err)
throw err
}
const response = await this.fulcrum.get(`electrumx/tx/data/${txid}`)
// console.log(`getTransactionDetails() TXID ${txid}: ${JSON.stringify(response, null, 2)}`)
return response
}
async getTransactionDetailsBulk ({ txids, verbose }) {
@@ -101,13 +101,24 @@ class FulcrumUseCases {
}
}
async getTransactions ({ address, allTxs }) {
async getTransactions ({ address, allTxs, bearerToken = null }) {
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')
// Use bearer token from request if provided, otherwise use the default bchjs instance
let bchjsInstance = this.bchjs
if (bearerToken) {
// Create a temporary bchjs instance with the bearer token from the request
const restURL = process.env.RESTURL || process.env.REST_URL || process.env.LOCAL_RESTURL || config.restURL
bchjsInstance = new BCHJS({
restURL,
bearerToken
})
}
response.transactions = await bchjsInstance.Electrumx.sortAllTxs(response.transactions, 'DESCENDING')
if (!allTxs) {
// Return only the first 100 transactions of the history.
@@ -122,16 +133,31 @@ class FulcrumUseCases {
}
}
async getTransactionsBulk ({ addresses, allTxs }) {
async getTransactionsBulk ({ addresses, allTxs, bearerToken = null }) {
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)) {
// Use bearer token from request if provided, otherwise use the default bchjs instance
let bchjsInstance = this.bchjs
// console.log('getTransactionsBulk() bearerToken: ', bearerToken)
if (bearerToken) {
// Create a temporary bchjs instance with the bearer token from the request
const restURL = config.restURL
// console.log('getTransactionsBulk() restURL: ', restURL)
bchjsInstance = new BCHJS({
restURL,
bearerToken
})
}
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')
thisEntry.transactions = await bchjsInstance.Electrumx.sortAllTxs(thisEntry.transactions, 'DESCENDING')
if (!allTxs && thisEntry.transactions.length > 100) {
// Extract only the first 100 transactions.
+8 -1
View File
@@ -98,7 +98,14 @@ class SlpUseCases {
try {
return await this.slpIndexer.post('slp/tx/', { txid })
} catch (err) {
wlogger.error('Error in SlpUseCases.getTxid()', err)
const isCommonMissingTxError =
err?.status === 404 &&
typeof err?.message === 'string' &&
err.message.includes('Key not found in database')
if (!isCommonMissingTxError) {
wlogger.error('Error in SlpUseCases.getTxid()', err)
}
throw err
}
}