Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.16.0] - 2026-09-08

### Added
- The rate limiter logs one counter line per window when it refuses requests, naming the count and the knob to raise, so an operator can see a 429 happening.

## [0.15.4] - 2026-09-08

### Fixed
Expand Down
21 changes: 10 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
# XChain Platform Encoder

<p align="center">
<img src="https://img.shields.io/badge/version-0.15.4-blue" alt="Version">
<img src="https://img.shields.io/badge/tests-1%2C604%2B%20passing-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/version-0.16.0-blue" alt="Version">
<img src="https://img.shields.io/badge/tests-1%2C663%2B%20passing-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/node-%3E%3D22-green" alt="Node">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-blue" alt="License">
</p>
Expand Down Expand Up @@ -147,9 +147,8 @@ parity check CI runs across the vendored copies.
These four names configure the shim itself. The fleet deploy path carries them
into the container: `xchain-node` forwards any of them set in the module config
store or in the deploy host's environment (`ModuleService.resolveObservabilityEnv`),
and the validator compose files under `claude/deploy/testnet-validators/` name
them outright. Nothing is fabricated when neither source sets one, so these
defaults hold on an unconfigured box:
and the validator compose files name them outright. Nothing is fabricated when
neither source sets one, so these defaults hold on an unconfigured box:

| Variable | Default | Effect |
|---|---|---|
Expand All @@ -166,20 +165,20 @@ defaults hold on an unconfigured box:
| `npm run build` | Production browser bundle (minified) -> `dist/xchain_encoder.min.js` |
| `npm run build:dev` | Development browser bundle (unminified) |
| `npm run smoke-test` | Smoke tests (~52 tests, <1s) |
| `npm run test:unit` | Unit tests (759 tests) |
| `npm run test:integration` | Integration tests (112 tests) |
| `npm run test:unit` | Unit tests (798 tests) |
| `npm run test:integration` | Integration tests (115 tests) |
| `npm run test:boundary` | Boundary condition tests (~101 tests) |
| `npm run test:security` | Security tests (57 tests) |
| `npm run test:security` | Security tests (63 tests) |
| `npm run test:fuzz` | Property-based fuzz tests (6 suites, 6 tests) |
| `npm run test:chaos` | Chaos engineering tests (63 tests) |
| `npm run test:chaos` | Chaos engineering tests (65 tests) |
| `npm run test:e2e` | End-to-end tests (~158 tests) |
| `npm run test:regression` | Regression tests (290 tests) |
| `npm run test:regression` | Regression tests (299 tests) |
| `npm run mutate` | Full mutation testing via StrykerJS |
| `npm run mutate:quick` | Quick mutation check (XChainEncoder.js only) |
| `npm run bench` | Performance benchmarks |
| `npm run bench:full` | Extended benchmarks with JSON output |
| `npm run bench:soak` | Soak test (sustained load) |
| `npm test` | Unit tests (hermetic, no external services, 759 tests) |
| `npm test` | Unit tests (hermetic, no external services, 798 tests) |
| `npm run test:regtest` | Regtest integration tests (requires local bitcoind) |

## Test Suite
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "xchain-encoder",
"description": "xchain-encoder encodes XChain Platform ACTION commands into blockchain transactions.",
"version": "0.15.4",
"version": "0.16.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
Expand Down
18 changes: 15 additions & 3 deletions src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const helmet = require('helmet');
const cors = require('cors');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const { limitedHandler } = require('./rateLimitLog.js')
const XChainEncoder = require('./XChainEncoder');
const jsonRouter = require('express-json-rpc-router')
const concurrencyGate = require('./concurrencyGate.js')
Expand Down Expand Up @@ -180,12 +181,23 @@ if (API_KEY) {
})
}

const ENCODER_RATE_LIMIT_WINDOW_MS = 60 * 1000
const ENCODER_RATE_LIMIT_RPM = parseInt(process.env.ENCODER_RATE_LIMIT_RPM, 10) || 60
const limiter = rateLimit({
windowMs: 60 * 1000,
limit: parseInt(process.env.ENCODER_RATE_LIMIT_RPM, 10) || 60,
windowMs: ENCODER_RATE_LIMIT_WINDOW_MS,
limit: ENCODER_RATE_LIMIT_RPM,
standardHeaders: true,
legacyHeaders: false,
message: { jsonrpc: '2.0', id: null, error: { code: -32029, message: 'Too many requests' } }
// Counts refusals instead of logging one line per request; see
// src/rateLimitLog.js.
handler: limitedHandler({
service: 'Encoder',
name: 'app-wide',
envVar: 'ENCODER_RATE_LIMIT_RPM',
limit: ENCODER_RATE_LIMIT_RPM,
windowMs: ENCODER_RATE_LIMIT_WINDOW_MS,
message: { jsonrpc: '2.0', id: null, error: { code: -32029, message: 'Too many requests' } }
})
})
app.use(limiter)

Expand Down
2 changes: 1 addition & 1 deletion src/observability/logShipper.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const ENVELOPE_KEYS = new Set(['ts', 'level', 'service', 'msg', 'version']);
// A bare token only where it cannot be confused with the next pair: anything
// carrying whitespace, `=` or a quote is JSON-quoted so a reader can split the
// tail on unquoted spaces. This is the half of the text format the watch
// collector's parser is written against (claude/scripts/xchain-watch.js).
// collector's parser is written against.
function formatFieldValue(value) {
if (value === null) return 'null';
if (typeof value === 'string') {
Expand Down
49 changes: 49 additions & 0 deletions src/rateLimitLog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*********************************************************************
*
* Copyright © 2025–2026 Dankest, LLC
* Based on XChain Platform by Dankest, LLC – https://dankest.llc
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of XChain Platform. Licensed under the GNU Affero
* General Public License v3.0 or later; see LICENSE.md. A commercial
* license (without AGPL source-disclosure terms) is available -
* contact legal@dankest.llc.
*
**********************************************************************
* Security: rate-limit refusal counter/logger
*
* express-rate-limit sets the draft-6 RateLimit-* and Retry-After headers
* and picks the status/body before calling a custom `handler`, so `message`
* moves here and the handler sends it itself. A line per refusal would
* flood the log under real abuse, so this logs once at the first refusal
* of a window and again at the first refusal after a full windowMs has
* elapsed since that line, carrying how many refusals happened since. The
* count only advances on an actual refusal (no timer/interval), so an idle
* limiter never holds the process open.
*
********************************************************************/

'use strict'

function limitedHandler({ service, name, envVar, limit, windowMs, message, log = console.warn, now = Date.now }) {
const windowSeconds = Math.round(windowMs / 1000)
let count = 0
let lastLogAt = null

return function (req, res, next, options) {
res.status(429).json(message)

count++
const ts = now()
const dueForLog = lastLogAt === null || (ts - lastLogAt >= windowMs)
if (dueForLog) {
const n = count
log(`${service} rate limit [${name}]: ${n} request${n === 1 ? '' : 's'} refused in the last ${windowSeconds} s (limit ${limit}/${windowSeconds} s); raise ${envVar} if this is legitimate traffic`)
count = 0
lastLogAt = ts
}
}
}

module.exports = { limitedHandler }
199 changes: 199 additions & 0 deletions test/security/rate-limit-log.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/*********************************************************************
*
* Copyright © 2025–2026 Dankest, LLC
* Based on XChain Platform by Dankest, LLC – https://dankest.llc
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of XChain Platform. Licensed under the GNU Affero
* General Public License v3.0 or later; see LICENSE.md. A commercial
* license (without AGPL source-disclosure terms) is available -
* contact legal@dankest.llc.
*
**********************************************************************
* Security: rate-limit refusal counter/logger
*
* Covers src/rateLimitLog.js directly (the 429 body, the once-per-window
* log line, the silence in between, and the accumulated-count reset) with
* an injectable clock/logger so nothing here needs a real timer, plus one
* end-to-end pass through express-rate-limit to prove the draft-6 headers
* a watchdog checks still land on the 429 this handler sends. A closing
* source check pins src/api.js's limiter to this handler rather than a
* bare `message:` option.
*
* Run: mocha test/security/rate-limit-log.test.js --timeout 30000
*/

'use strict'

const assert = require('assert')
const fs = require('fs')
const path = require('path')
const express = require('express')
const rateLimit = require('express-rate-limit')
const http = require('http')
const { limitedHandler } = require('../../src/rateLimitLog.js')

const MESSAGE = { jsonrpc: '2.0', id: null, error: { code: -32029, message: 'Too many requests' } }

function makeRes() {
return {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this },
json(body) { this.body = body; return this }
}
}

// Extracts the parenthesised call starting at `openIdx` (str[openIdx] === '(')
// by counting balanced parens, so the source check below does not depend on
// formatting/indentation to find where one call ends and the next begins.
function extractBalanced(str, openIdx) {
let depth = 0
for (let i = openIdx; i < str.length; i++) {
if (str[i] === '(') depth++
else if (str[i] === ')') {
depth--
if (depth === 0) return str.slice(openIdx, i + 1)
}
}
throw new Error('unbalanced parens from index ' + openIdx)
}

describe('Security: rate-limit refusal counter/logger', () => {

describe('limitedHandler', () => {
it('sends 429 with the exact JSON-RPC body the message option carried', () => {
const handler = limitedHandler({
service: 'Encoder', name: 'app-wide', envVar: 'ENCODER_RATE_LIMIT_RPM',
limit: 60, windowMs: 60000, message: MESSAGE, log: () => {}, now: () => 0
})
const res = makeRes()
handler({}, res, () => {}, {})
assert.strictEqual(res.statusCode, 429)
assert.deepStrictEqual(res.body, MESSAGE)
})

it('logs one line at the first refusal of a window, with count 1', () => {
const lines = []
const handler = limitedHandler({
service: 'Encoder', name: 'app-wide', envVar: 'ENCODER_RATE_LIMIT_RPM',
limit: 60, windowMs: 60000, message: MESSAGE, log: (l) => lines.push(l), now: () => 1000
})
handler({}, makeRes(), () => {}, {})
assert.strictEqual(lines.length, 1)
assert.strictEqual(
lines[0],
'Encoder rate limit [app-wide]: 1 request refused in the last 60 s (limit 60/60 s); raise ENCODER_RATE_LIMIT_RPM if this is legitimate traffic'
)
})

it('logs nothing more for refusals inside the same window', () => {
const lines = []
let t = 1000
const handler = limitedHandler({
service: 'Encoder', name: 'app-wide', envVar: 'ENCODER_RATE_LIMIT_RPM',
limit: 60, windowMs: 60000, message: MESSAGE, log: (l) => lines.push(l), now: () => t
})
handler({}, makeRes(), () => {}, {}) // first refusal: logs
t = 1000 + 30000 // 30s later, still inside the 60s window
handler({}, makeRes(), () => {}, {})
handler({}, makeRes(), () => {}, {})
handler({}, makeRes(), () => {}, {})
assert.strictEqual(lines.length, 1)
})

it('logs the count accumulated since the last line at the first refusal after a full window, then resets', () => {
const lines = []
let t = 0
const handler = limitedHandler({
service: 'Encoder', name: 'app-wide', envVar: 'ENCODER_RATE_LIMIT_RPM',
limit: 60, windowMs: 60000, message: MESSAGE, log: (l) => lines.push(l), now: () => t
})
handler({}, makeRes(), () => {}, {}) // t=0: first ever, logs count 1

t = 10000
for (let i = 0; i < 4; i++) handler({}, makeRes(), () => {}, {}) // 4 silent refusals

t = 60000 // exactly windowMs after the last logged line
handler({}, makeRes(), () => {}, {}) // 5th silent refusal is the trigger: logs 5, resets

assert.strictEqual(lines.length, 2)
assert.strictEqual(
lines[1],
'Encoder rate limit [app-wide]: 5 requests refused in the last 60 s (limit 60/60 s); raise ENCODER_RATE_LIMIT_RPM if this is legitimate traffic'
)

// Reset confirmed: a refusal just after the log point does not log again.
t = 60001
handler({}, makeRes(), () => {}, {})
assert.strictEqual(lines.length, 2)
})
})

describe('wired into a real express-rate-limit instance', () => {
let server

afterEach(() => {
if (server) {
if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
server.close()
server = null
}
})

it('carries the draft-6 RateLimit-Policy/Limit and Retry-After headers on the 429 this handler sends', async () => {
const app = express()
app.use(rateLimit({
windowMs: 60000,
limit: 2,
standardHeaders: true,
legacyHeaders: false,
handler: limitedHandler({
service: 'Encoder', name: 'app-wide', envVar: 'ENCODER_RATE_LIMIT_RPM',
limit: 2, windowMs: 60000, message: MESSAGE, log: () => {}
})
}))
app.get('/ping', (req, res) => res.json({ ok: true }))

server = http.createServer(app)
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = 'http://127.0.0.1:' + server.address().port

await fetch(base + '/ping')
await fetch(base + '/ping')
const third = await fetch(base + '/ping')

assert.strictEqual(third.status, 429)
assert.ok(third.headers.get('retry-after'), 'expected a Retry-After header')
assert.ok(third.headers.get('ratelimit-policy'), 'expected a RateLimit-Policy header')
assert.strictEqual(third.headers.get('ratelimit-limit'), '2')
const body = await third.json()
assert.deepStrictEqual(body, MESSAGE)
})
})

describe('src/api.js wiring', () => {
it('routes the limiter through limitedHandler and carries no bare message: option', () => {
const src = fs.readFileSync(path.join(__dirname, '../../src/api.js'), 'utf8')

const callStart = src.indexOf('const limiter = rateLimit(')
assert.notStrictEqual(callStart, -1, 'expected `const limiter = rateLimit(...)` in src/api.js')
const openIdx = src.indexOf('(', callStart + 'const limiter = rateLimit'.length)
const rateLimitCall = extractBalanced(src, openIdx)

assert.match(rateLimitCall, /handler:\s*limitedHandler\(/)

// Strip the nested limitedHandler(...) call (which legitimately carries
// its own `message:` field) before checking the outer options object
// for a bare `message:` - the shape rateLimit() would answer with
// itself, unlogged, if the handler wiring ever regressed.
const handlerCallStart = rateLimitCall.indexOf('limitedHandler(')
const handlerOpenIdx = handlerCallStart + 'limitedHandler'.length
const handlerCall = extractBalanced(rateLimitCall, handlerOpenIdx)
const outerOnly = rateLimitCall.slice(0, handlerCallStart) + rateLimitCall.slice(handlerCallStart + handlerCall.length)

assert.doesNotMatch(outerOnly, /message\s*:/, 'rateLimit({...}) must not carry a bare message: option outside limitedHandler(...)')
})
})
})
Loading