diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf45761..1be559e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 6b0c29e..9baf3a2 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,8 @@
# XChain Platform Encoder
-
-
+
+
@@ -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 |
|---|---|---|
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 9d32a05..acfecf4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "xchain-encoder",
- "version": "0.15.4",
+ "version": "0.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xchain-encoder",
- "version": "0.15.4",
+ "version": "0.16.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^1.18.1",
diff --git a/package.json b/package.json
index f7d24b0..044c046 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/api.js b/src/api.js
index 27f11b9..e2c870a 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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')
@@ -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)
diff --git a/src/observability/logShipper.js b/src/observability/logShipper.js
index dae9542..a36c827 100644
--- a/src/observability/logShipper.js
+++ b/src/observability/logShipper.js
@@ -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') {
diff --git a/src/rateLimitLog.js b/src/rateLimitLog.js
new file mode 100644
index 0000000..a214a68
--- /dev/null
+++ b/src/rateLimitLog.js
@@ -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 }
diff --git a/test/security/rate-limit-log.test.js b/test/security/rate-limit-log.test.js
new file mode 100644
index 0000000..17d507c
--- /dev/null
+++ b/test/security/rate-limit-log.test.js
@@ -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(...)')
+ })
+ })
+})