diff --git a/.github/workflows/build_docker.yaml b/.github/workflows/build_docker.yaml
index 44fb9a8..a54de89 100644
--- a/.github/workflows/build_docker.yaml
+++ b/.github/workflows/build_docker.yaml
@@ -1,4 +1,4 @@
-# Build the three simplepool container images and push them to the GitHub
+# Build the four simplepool container images and push them to the GitHub
# Container Registry (ghcr.io) — the same registry coinshift-rs publishes to.
#
# This job runs only in the canonical LayerTwo-Labs repo (see the `if:` guard
@@ -47,6 +47,8 @@ jobs:
dockerfile: deploy/docker/Dockerfile.dashboard
- image: simplepool-payout
dockerfile: deploy/docker/Dockerfile.payout
+ - image: simplepool-slipstream
+ dockerfile: deploy/docker/Dockerfile.slipstream
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -88,6 +90,6 @@ jobs:
github.event.pull_request.head.repo.full_name == github.repository }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- # Per-image GitHub Actions cache so the three legs don't collide.
+ # Per-image GitHub Actions cache so the legs don't collide.
cache-from: type=gha,scope=${{ matrix.image }}
cache-to: type=gha,mode=max,scope=${{ matrix.image }}
diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml
index b6fab09..e02c352 100644
--- a/.github/workflows/integration_tests.yaml
+++ b/.github/workflows/integration_tests.yaml
@@ -71,6 +71,13 @@ jobs:
- name: Run coinbase-direct PPLNS end-to-end regtest test
run: bash tests/test_pplns_coinbase_regtest.sh
+ # Slipstream: a tx submitted to the service is checked and broadcast to
+ # the node, reaches the enforcer's template through its mempool mirror,
+ # and is followed to confirmed. No proxy; the template is mined with
+ # generateblock.
+ - name: Run slipstream end-to-end regtest test
+ run: bash tests/test_slipstream_regtest.sh
+
- name: Upload logs
if: failure()
uses: actions/upload-artifact@v4
@@ -81,6 +88,8 @@ jobs:
.regtest-e2e/logs/
.regtest-pplns/logs/
.regtest-cbwin/logs/
+ .regtest/slipstream-e2e/logs/
+ .regtest/slipstream-e2e/slipstream.log
/tmp/simplepool-e2e.log
/tmp/simplepool-e2e.conf
/tmp/simplepool-int.log
@@ -126,6 +135,14 @@ jobs:
working-directory: payout
run: npm test
+ - name: Install slipstream dependencies
+ working-directory: slipstream
+ run: npm ci || npm install
+
+ - name: Run slipstream service tests
+ working-directory: slipstream
+ run: npm test
+
# The payout path the coinbase e2e skips: wallet-enabled enforcer +
# thunder, a real deposit, and one payout tick broadcasting a Thunder
# transaction (tests/test_payout_regtest.sh). Separate job purely for
diff --git a/README.md b/README.md
index 4ba0f82..76ea9e0 100644
--- a/README.md
+++ b/README.md
@@ -579,7 +579,7 @@ you can commit + push from here), use
### Docker
An alternative to the bare-metal script: containerized builds of the
-three services (stratum proxy, dashboard, payout worker) under
+four services (stratum proxy, dashboard, payout worker, slipstream) under
[`deploy/docker/`](deploy/docker/). One `docker compose up -d --build`
gets the whole app stack running against a Thunder daemon and Bitcoin
Core that live on the host (or wherever you point them). Shared bind
@@ -592,6 +592,23 @@ Note: the drivechain infrastructure (`bitcoind`, Thunder, the
those daemons have their own lifecycles and typically run bare-metal on
the same host.
+### Slipstream
+
+[`slipstream/`](slipstream/) is an optional service that takes a raw tx from
+anyone and gets it into the pool's blocks, including txs the network will not
+relay: BIP300/301 txs, or any other consensus-valid tx. It checks each one
+against the pool's own bitcoind (`testmempoolaccept`) and the fee rule, then
+broadcasts it there; the enforcer's template mempool mirrors that node's, so it
+reaches the templates the proxy mines with no enforcer change. Non-standard txs
+need the node to run `-acceptnonstdtxn` (which Core allows only off mainnet).
+The fee rule is Slipstream's: the higher of a 1 sat/vB floor and the current
+mineable rate. Every submission is kept, and each accepted tx is followed from
+template to block. It also serves the pool's `info.json` for pool directories.
+It is not set up by `install.sh` yet: the systemd unit and nginx vhost are
+templates in [`deploy/`](deploy/), and [`slipstream/README.md`](slipstream/README.md)
+walks through installing them. [`docs/simplepool.html`](docs/simplepool.html)
+explains it end to end.
+
## Config keys
```
@@ -748,6 +765,8 @@ scripts/
dashboard/ # Node/Express read-only stats UI
payout/ # payout worker: Thunder rail (pps-classic, pplns-thunder)
# and L1 rail via the enforcer wallet (pplns-btc)
+slipstream/ # slipstream service: takes txs from anyone and gets them
+ # into the pool's blocks via its bitcoind; serves info.json
docs/simplepool.html # single-file explainer: every mode, end to end
```
diff --git a/deploy/docker/Dockerfile.slipstream b/deploy/docker/Dockerfile.slipstream
new file mode 100644
index 0000000..3a48757
--- /dev/null
+++ b/deploy/docker/Dockerfile.slipstream
@@ -0,0 +1,21 @@
+# -----------------------------------------------------------------------------
+# simplepool slipstream service — private tx submission (Node.js)
+# -----------------------------------------------------------------------------
+
+FROM node:20-bookworm-slim AS deps
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ python3 make g++ ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+WORKDIR /app
+COPY slipstream/package.json slipstream/package-lock.json ./
+RUN npm ci --omit=dev
+
+# -----------------------------------------------------------------------------
+FROM node:20-bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends tini \
+ && rm -rf /var/lib/apt/lists/*
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY slipstream/ ./
+USER node
+ENTRYPOINT ["/usr/bin/tini", "--", "node", "index.js"]
diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml
index ae8285a..7c2fb47 100644
--- a/deploy/docker/docker-compose.yml
+++ b/deploy/docker/docker-compose.yml
@@ -1,7 +1,7 @@
# -----------------------------------------------------------------------------
# simplepool — docker-compose orchestration
# -----------------------------------------------------------------------------
-# Runs the three simplepool services against a Thunder daemon that lives on
+# Runs the four simplepool services against a Thunder daemon that lives on
# the host (or wherever THUNDER_RPC_URL points). Bitcoin Core is likewise
# expected to be reachable from the simplepool container.
#
@@ -102,3 +102,45 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
stop_grace_period: 30s
+
+ slipstream:
+ build:
+ context: ../..
+ dockerfile: deploy/docker/Dockerfile.slipstream
+ image: simplepool-slipstream:latest
+ container_name: simplepool-slipstream
+ restart: unless-stopped
+ environment:
+ # The pool's bitcoind -- the node the enforcer mirrors its mempool
+ # from. Txs are checked and broadcast here. Run it with
+ # -acceptnonstdtxn to take non-standard txs (not allowed on mainnet).
+ BITCOIND_RPC_URL: ${BITCOIND_RPC_URL:-http://host.docker.internal:8332}
+ BITCOIND_RPC_USER: ${BITCOIND_RPC_USER:-}
+ BITCOIND_RPC_PASS: ${BITCOIND_RPC_PASS:-}
+ # The enforcer's block template server -- the proxy's bitcoind_url.
+ # Read only: the template is where the mineable rate comes from.
+ ENFORCER_GBT_URL: ${ENFORCER_GBT_URL:-http://host.docker.internal:8122}
+ SLIPSTREAM_DB_PATH: /data/slipstream.db
+ PROXY_DB_PATH: /data/shares.db
+ SLIPSTREAM_BIND: 0.0.0.0
+ SLIPSTREAM_PORT: "8124"
+ SLIPSTREAM_TRUST_PROXY: ${SLIPSTREAM_TRUST_PROXY:-0}
+ SLIPSTREAM_MIN_FEE_RATE: ${SLIPSTREAM_MIN_FEE_RATE:-1}
+ POOL_NAME: ${POOL_NAME:-}
+ POOL_OPERATOR: ${POOL_OPERATOR:-}
+ POOL_CHAIN: ${POOL_CHAIN:-}
+ POOL_LOGO: ${POOL_LOGO:-}
+ POOL_CONTACT: ${POOL_CONTACT:-}
+ PUBLIC_STRATUM_URL: ${PUBLIC_STRATUM_URL:-}
+ PUBLIC_DASHBOARD_URL: ${PUBLIC_DASHBOARD_URL:-}
+ PUBLIC_SLIPSTREAM_URL: ${PUBLIC_SLIPSTREAM_URL:-}
+ ports:
+ # Loopback on the host: publish through nginx, which is also what
+ # SLIPSTREAM_TRUST_PROXY=1 assumes sets X-Forwarded-For.
+ - "127.0.0.1:${SLIPSTREAM_PORT:-8124}:8124"
+ volumes:
+ # Writes slipstream.db; reads shares.db.
+ - ../../data:/data
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ stop_grace_period: 10s
diff --git a/deploy/nginx/slipstream.conf b/deploy/nginx/slipstream.conf
new file mode 100644
index 0000000..e13dd5b
--- /dev/null
+++ b/deploy/nginx/slipstream.conf
@@ -0,0 +1,44 @@
+# slipstream.example — reverse proxy for the simplepool slipstream service.
+#
+# The service is bound to 127.0.0.1:8124 (see
+# deploy/systemd/simplepool-slipstream.service) and keys its per-client
+# submission limit on the X-Forwarded-For hop set here. Replace
+# slipstream.example with your hostname, then:
+#
+# sudo cp deploy/nginx/slipstream.conf /etc/nginx/sites-available/
+# sudo ln -s /etc/nginx/sites-available/ /etc/nginx/sites-enabled/
+# sudo nginx -t && sudo systemctl reload nginx
+# sudo certbot --nginx -d # TLS, edits this file in place
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name slipstream.example;
+
+ # Reuses the dashboard's zone (deploy/nginx/pool-ratelimit.conf) for
+ # reads. Submissions have their own, stricter limit inside the service.
+ limit_req zone=pool_dash burst=20 nodelay;
+
+ # A raw tx arrives as hex: up to 1MB of tx is 2MB of body.
+ client_max_body_size 3m;
+
+ access_log /var/log/nginx/slipstream.example.access.log;
+ error_log /var/log/nginx/slipstream.example.error.log warn;
+
+ location / {
+ proxy_pass http://127.0.0.1:8124;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ # Overwritten, not appended: the service trusts this hop, so the
+ # client must not be able to put its own in front of it.
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 30s;
+ proxy_connect_timeout 5s;
+ }
+
+ location = /healthz {
+ proxy_pass http://127.0.0.1:8124/healthz;
+ access_log off;
+ }
+}
diff --git a/deploy/systemd/simplepool-slipstream.service b/deploy/systemd/simplepool-slipstream.service
new file mode 100644
index 0000000..38e5124
--- /dev/null
+++ b/deploy/systemd/simplepool-slipstream.service
@@ -0,0 +1,55 @@
+[Unit]
+Description=simplepool slipstream (tx submission)
+Documentation=https://github.com/LayerTwo-Labs/simplepool/blob/main/slipstream/README.md
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=@USER@
+Group=@USER@
+WorkingDirectory=@ROOT@/slipstream
+# The pool's bitcoind: the node the enforcer mirrors its mempool from. Txs
+# are checked (testmempoolaccept) and broadcast (sendrawtransaction) here.
+# For non-standard txs it must run -acceptnonstdtxn, which Core allows only
+# off mainnet. Credentials: user/pass, or BITCOIND_RPC_COOKIE_FILE instead.
+Environment=BITCOIND_RPC_URL=http://127.0.0.1:8332
+# Environment=BITCOIND_RPC_USER=
+# Environment=BITCOIND_RPC_PASS=
+# Environment=BITCOIND_RPC_COOKIE_FILE=/var/lib/bitcoind/.cookie
+# The enforcer's block template server -- the same URL as bitcoind_url in
+# proxy.conf. Read only: the template it serves is where the mineable rate
+# comes from and how a tx is seen to be in play.
+Environment=ENFORCER_GBT_URL=http://127.0.0.1:8122
+Environment=SLIPSTREAM_DB_PATH=@ROOT@/data/slipstream.db
+Environment=PROXY_DB_PATH=@ROOT@/data/shares.db
+# Loopback only; nginx publishes it (deploy/nginx/slipstream.conf), and
+# sets the X-Forwarded-For hop the rate limit is keyed on.
+Environment=SLIPSTREAM_BIND=127.0.0.1
+Environment=SLIPSTREAM_PORT=8124
+Environment=SLIPSTREAM_TRUST_PROXY=1
+Environment=SLIPSTREAM_MIN_FEE_RATE=1
+# info.json presentation. Facts about the pool (mode, fee, coinbase tag,
+# addresses) are read from pool_meta and are NOT set here.
+# Environment=PUBLIC_SLIPSTREAM_URL=https://slipstream.example
+# Environment=PUBLIC_STRATUM_URL=stratum+tcp://stratum.example:3334
+# Environment=PUBLIC_DASHBOARD_URL=https://pool.example
+# Environment=POOL_NAME=
+# Environment=POOL_OPERATOR=
+# Environment=POOL_CHAIN=
+# Environment=POOL_LOGO=
+# Environment=POOL_CONTACT=
+ExecStart=/usr/bin/node @ROOT@/slipstream/index.js
+Restart=on-failure
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=full
+ProtectHome=read-only
+ReadWritePaths=@ROOT@/data
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/sequence-diagrams.py b/docs/sequence-diagrams.py
index 943deb2..bc4a3a6 100755
--- a/docs/sequence-diagrams.py
+++ b/docs/sequence-diagrams.py
@@ -274,6 +274,35 @@ def build(title, desc, actors, steps, accent="pplns", min_width=0):
+# ---------------------------------------------------------- slipstream -----
+DIAGRAMS['slipstream'] = build(
+ 'slipstream: a tx from anyone, into the pool\'s blocks',
+ 'A submitter posts a raw tx. The slipstream service checks it against the '
+ 'pool\'s bitcoind and the fee rule without broadcasting, then broadcasts it. '
+ 'The enforcer mirrors that node\'s mempool, so the tx reaches the template '
+ 'the pool mines; the service follows it to a block.',
+ [('sub', 'Submitter', 'any wallet'),
+ ('slip', 'slipstream', ':8124'),
+ ('node', 'bitcoind', '-acceptnonstdtxn'),
+ ('enf', 'enforcer', 'template server'),
+ POOL],
+ [('msg', 'sub', 'slip', 'POST /api/tx '),
+ ('msg', 'slip', 'node', 'testmempoolaccept'),
+ ('msg', 'node', 'slip', 'allowed? fee, vsize', 'dashed'),
+ ('self', 'slip', 'fee rule: max(floor, mineable)'),
+ ('note', 'Checked BEFORE it is sent: nothing can be taken back out of a mempool, so refusing afterwards would be too late.', 'warn'),
+ ('msg', 'slip', 'node', 'sendrawtransaction'),
+ ('msg', 'node', 'enf', 'mempool mirror (ZMQ)'),
+ ('note', 'From here it is an ordinary mempool tx: relayed to peers, and minable by any pool whose node took it.'),
+ ('msg', 'pool', 'enf', 'getblocktemplate'),
+ ('msg', 'enf', 'pool', 'a template carrying the tx', 'dashed'),
+ ('msg', 'slip', 'enf', 'poll: in the template?'),
+ ('msg', 'slip', 'node', 'poll: mined? how deep?'),
+ ('note', 'Every submission is kept, refusals included. Each accepted tx is followed to confirmed, or to dropped with the node\'s own reason.', 'win'),
+ ], min_width=600)
+
+
+
# ---- splicing -------------------------------------------------------------
#
# Each figure sits between HTML comment markers so a regeneration replaces
diff --git a/docs/simplepool.html b/docs/simplepool.html
index fb29ed2..0dd9cae 100644
--- a/docs/simplepool.html
+++ b/docs/simplepool.html
@@ -308,6 +308,8 @@ simplepool
Auditing every number
The data model
Connect a miner
+ The dashboard
+ Slipstream
Configuration
Running one
What it can't do
@@ -710,6 +712,20 @@ The stack
pool:tip, pool:credits). SQLite stays authoritative;
the publish is fire-and-forget and a Redis outage cannot cost you a share.
+
+
+ The proxy takes templates from the enforcer's template server, never from
+ bitcoind directly: only the enforcer's carry the BIP300/301 commitments a
+ sidechain needs. It long-polls (BIP22), so a new tip reaches miners as soon
+ as the enforcer has it rather than on the next poll.
+
+
+
+ Optionally, the slipstream service sits beside the
+ proxy and takes transactions from anyone. It hands them to the pool's
+ bitcoind; the enforcer's template mempool mirrors that node's, so they reach
+ the pool's templates without any change to the enforcer.
+
@@ -2067,6 +2083,27 @@ The data model
it is stated here rather than enforced by a trigger that would hide it.
+
+ The slipstream service keeps its own
+
+ data/slipstream.db, written only by the
+ slipstream service , which opens
+ shares.db read-only for pool_meta and
+ blocks_found and never writes to it.
+
+
+
+ Table What it holds
+
+ slipstream_submissions
+ every POST exactly as it arrived, accepted or refused, with the reason — the record of what was asked of the pool
+ slipstream_txs
+ one row per accepted tx: fee and the rate it was held to, where it stands now, when it was first and last in a template, the block that mined it and whether that block was the pool's, and the raw tx, so it can be sent again
+ slipstream_events
+ every status change, append-only, so a tx's history can be read back rather than inferred
+
+
+
@@ -2190,7 +2227,181 @@ What will not disconnect you
-
+
+
+ The dashboard
+
+ Everything the proxy writes down, readable by anyone. The dashboard reads
+ shares.db and never keeps a second copy of the pool's config:
+ the mode, the fee, the rate and the ports it shows are the ones the proxy
+ wrote into pool_meta, so the page cannot disagree with the
+ process that did the work.
+
+
+ Public pages
+
+
+ Path What it shows
+
+ /hashrate, the leaderboard (per worker and per address), recent blocks, how to connect with each port's difficulty policy, and a card explaining every number on the page for this pool's mode
+ /worker/:nameone worker: shares, hashrate, credits and payouts
+ /blocksevery block the pool found, paginated, with its status and what it paid
+ /templatesthe work being handed out: height, block value, fees, the sidechain commitments it carries, and the template history
+ /slipstreamonly when slipstream runs: its fees, how to submit, and each recent submission with where it stands
+ /healththe hard-failure checks — ledger arithmetic, rate, duplicates — with a 503 while any is failing, so an uptime monitor can watch it
+
+
+
+
+ JSON, for monitors and other tools
+
+
+ Path What it returns
+
+ /api/statuseverything at once: the pool's identity and totals, the node, the health checks, and which commit of each component is running. Always 200 — read health.ok
+ /api/overview, /api/leaderboard, /api/worker/:name, /api/blocks, /api/templates, /api/nodethe data behind each page
+ /api/versionsbuild provenance of simplepool, the enforcer, Thunder and bitcoind
+ /healthzliveness only
+
+
+
+
+ Admin
+
+ /admin, behind basic auth, is where the operator acts: balances
+ and what is owed, per-worker audits, deposits into the Thunder reserve,
+ payouts and a "pay now" trigger, and a tools page for the stuck cases. Every
+ write action is CSRF-gated, and every broadcast attempt is logged in
+ tx_attempts, successful or not.
+
+
+
+
+
+ Slipstream
+
+ An optional service that takes a raw transaction from anyone and gets it
+ into the pool's blocks — including the ones the network will not relay:
+ BIP300/301 deposits, withdrawal bundles and BMM requests, or anything else
+ that is consensus-valid. It borrows the fee rule of Marathon's Slipstream.
+
+
+ How a transaction gets in
+
+ The service checks the transaction against the pool's own bitcoind with
+ testmempoolaccept, which reports the fee and size and
+ broadcasts nothing, applies the fee rule, and only then sends it with
+ sendrawtransaction. The enforcer's template mempool mirrors
+ that node's mempool, so the transaction reaches the template the proxy is
+ mining with no enforcer change — and with the enforcer's own BIP300 rules
+ still applied on the way in.
+
+
+
+
+
+
+ slipstream: a tx from anyone, into the pool's blocks
+ A submitter posts a raw tx. The slipstream service checks it against the pool's bitcoind and the fee rule without broadcasting, then broadcasts it. The enforcer mirrors that node's mempool, so the tx reaches the template the pool mines; the service follows it to a block.
+
+
+
+
+
+
+ Submitter any wallet slipstream :8124 bitcoind -acceptnonstdtxn enforcer template server simplepool :3334
+
+ POST /api/tx <raw hex> testmempoolaccept allowed? fee, vsize fee rule: max(floor, mineable) Checked BEFORE it is sent: nothing can be taken back out of a mempool, so refusing afterwards would be too late. sendrawtransaction mempool mirror (ZMQ) From here it is an ordinary mempool tx: relayed to peers, and minable by any pool whose node took it. getblocktemplate a template carrying the tx poll: in the template? poll: mined? how deep? Every submission is kept, refusals included. Each accepted tx is followed to confirmed, or to dropped with the node's own reason.
+
+
+
+
+
+ The fee rule
+
+ A transaction must pay the higher of the minimum submission rate
+ and the current mineable rate . The minimum is
+ SLIPSTREAM_MIN_FEE_RATE, 1 sat/vB by default. The mineable
+ rate is read off the template being mined: while it has room, anything over
+ the minimum gets in, so the two are equal; once it is full, it is the rate
+ of the cheapest transaction it carries. The rule is checked once, at
+ submission; after that a transaction competes by fee rate like any other.
+
+
+
Why the order matters
+
+ Nothing can be taken back out of a node's mempool. A transaction that
+ paid too little and was refused after it was sent would be
+ relayed and mined anyway, for less than was asked. So the rule runs
+ between the check and the broadcast, never after.
+
+
+
+ Following it to a block
+
+
+ Status Means
+
+ pendingin the node's mempool, not in the latest template
+ in_templatein the latest template: the pool's miners are working on it now
+ minedin a block, recorded as the pool's or another pool's from blocks_found
+ confirmedSLIPSTREAM_CONFIRMATIONS deep, 6 by default
+ droppedleft the mempool unmined, and the node refused it when it was sent again; the reason is the node's own, e.g. a replacement or a spent input
+
+
+
+
+ A transaction whose block is orphaned goes back to pending when
+ the node restores it to its mempool, which it does by itself. Every
+ submission is kept, refusals included, and every status change is logged.
+
+
+ info.json
+
+ The service also answers GET /info.json, the listing a pool
+ directory reads. Its facts — mode (the exact
+ pool_mode), fee_bps, coinbase_tag,
+ operator_address, pool_btc_address — come from
+ pool_meta, never from the service's config. Name, chain, logo,
+ contact and the public URLs, slipstream_url among them, come
+ from its environment.
+
+
+ What it needs
+
+
+ A node that takes non-standard transactions:
+ acceptnonstdtxn=1. Core refuses that setting on a chain that
+ reports itself as mainnet — drivechain-patched builds included — so a
+ mainnet-fork chain needs a Core patch that lifts the check before it can
+ take them. BIP300 deposits are standard on a drivechain-patched node
+ already and need no setting at all.
+
+
+ Acceptance of relay. An accepted transaction is broadcast
+ like any other, so another pool may mine it first. That is the price of
+ needing nothing from the enforcer, and the status table records whose
+ block it was.
+
+
+
+
+
Configuration
@@ -2244,7 +2455,7 @@
Configuration
bitcoind_user / bitcoind_pass—
Optional — omit both for an unauthenticated backend and the call goes out with no auth header. Cookie auth is not supported.
bitcoind_poll_interval_ms30000
- Template refresh. On a drivechain pool this is also the worst-case delay before a sidechain's BMM request can reach a job — lower it to 5000–10000 if sidechains need to merge-mine reliably.
+ Template refresh when the backend does not long-poll. The enforcer does (BIP22), and then the proxy wakes on each new tip instead and this only paces retries. Against a backend that does not, it is also the worst-case delay before a sidechain's BMM request can reach a job — lower it to 5000–10000 if sidechains need to merge-mine reliably.
coinbase_tag/simplepool/
Short string baked into the coinbase scriptSig.
db_path./data/shares.db
@@ -2266,11 +2477,16 @@ Configuration
PAYOUT_SETTLE_INTERVAL_MS, PAYOUT_MIN_SATS,
THUNDER_FROM_ADDRESS and friends. The Thunder reserve address is
deliberately not a proxy key: the coinbase never touches Thunder, so
- only the dashboard and the payout worker have any business knowing it.
+ only the dashboard and the payout worker have any business knowing it. The
+ slipstream service is configured the same way —
+ BITCOIND_RPC_URL, ENFORCER_GBT_URL,
+ SLIPSTREAM_MIN_FEE_RATE and the POOL_* /
+ PUBLIC_* presentation fields; slipstream/lib/config.js
+ has the full list.
-
+
Running one
@@ -2303,13 +2519,22 @@
What runs
Unit Mode Job
- simplepool.serviceboth the stratum proxy — the only thing that is strictly required
- simplepool-dashboard.serviceboth read-only public stats on :8081, plus /admin behind basic auth
- simplepool-payout.servicepps-classic the daily Thunder payout batch
+ simplepool.serviceall the stratum proxy — the only thing that is strictly required
+ simplepool-dashboard.serviceall read-only public stats on :8081, plus /admin behind basic auth
+ simplepool-payout.servicepps-classic, pplns-thunder, pplns-btc the daily payout batch, over Thunder or on L1 through the enforcer's wallet
+ simplepool-slipstream.serviceany, optional the slipstream service on :8124, published through nginx
+
+ Slipstream is not set up by the installer yet. Its unit and nginx vhost are
+ templates in the repository — deploy/systemd/simplepool-slipstream.service
+ and deploy/nginx/slipstream.conf — and
+ slipstream/README.md walks through installing them. The
+ docker-compose stack runs it as a fourth container.
+
+
Which commit is running
The build commit is compiled into the binary, so simplepool
@@ -2322,7 +2547,7 @@ Which commit is running
-
+
What it can't do
@@ -2376,6 +2601,14 @@
What it can't do
dashboard says so plainly, rather than letting either half be found out
when an order is cancelled.
+
+ Slipstream does not keep transactions private. It
+ broadcasts through the pool's bitcoind, so another pool can mine a
+ submitted transaction first, and it cannot withdraw one once sent. On a
+ chain that reports itself as mainnet the node cannot run
+ acceptnonstdtxn without a Core patch, so there it takes only
+ standard transactions — which includes BIP300 deposits.
+
The submit ceiling bounds a flood, it does not end one. A
mismatched connection is refused cheaply instead of validated, but it is
@@ -2392,8 +2625,8 @@ What it can't do
- simplepool — a solo and PPS Bitcoin mining pool in C11, by
- Roberto Santacroce. MIT licensed.
+ simplepool — a Bitcoin and drivechain mining pool in C11,
+ with five payout modes, by Roberto Santacroce. MIT licensed.
This page is a single self-contained HTML file with no scripts and no
@@ -2404,8 +2637,10 @@
What it can't do
NONCE_AND_SHARES.md (share and payout math),
CLASSIC_PAYOUTS.md (the pps-classic design and the
coinbase-deposit finding),
-
OPERATOR_GUIDE.md (day-to-day operations) and
-
VERIFY.md (the verification checklist).
+
OPERATOR_GUIDE.md (day-to-day operations),
+
VERIFY.md (the verification checklist),
+
dashboard/README.md (every page and endpoint) and
+
slipstream/README.md (the slipstream service).
Where this page and those disagree, they are right.
diff --git a/slipstream/.gitignore b/slipstream/.gitignore
new file mode 100644
index 0000000..c2658d7
--- /dev/null
+++ b/slipstream/.gitignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/slipstream/README.md b/slipstream/README.md
new file mode 100644
index 0000000..419622d
--- /dev/null
+++ b/slipstream/README.md
@@ -0,0 +1,171 @@
+# simplepool-slipstream
+
+Takes a raw transaction from anyone and gets it into the pool's blocks. That
+includes transactions the network will not relay: BIP300/301 transactions
+(deposits, withdrawal bundles, BMM requests) or any other consensus-valid
+transaction.
+
+```
+submitter ──POST /api/tx──▶ slipstream ──testmempoolaccept, sendrawtransaction──▶ bitcoind
+ │ │ ZMQ mempool mirror
+ │ ▼
+ └── reads the template ◀──────────────── enforcer :8122 ◀── simplepool proxy
+```
+
+Each transaction goes to the pool's own bitcoind, the node the enforcer syncs
+its template mempool from. Once the node accepts it, it reaches the templates
+the proxy mines with no further step and **no enforcer change**. The
+enforcer's own BIP300 rules still apply to it on the way in.
+
+## The node
+
+- **Non-standard transactions** need the node to run **`-acceptnonstdtxn=1`**.
+ Core refuses that flag on mainnet with `acceptnonstdtxn is not currently
+ supported for main chain`. This was checked against the drivechain-patched,
+ ecash betanet and stock builds. It works on signet and regtest.
+ - drynet3 reports itself as `main`, so non-standard transactions there need a
+ patched Core that lifts the check.
+- **BIP300 deposits** are standard on a drivechain-patched node already, so
+ they need no flag.
+- **Transactions are relayed.** The node broadcasts an accepted transaction
+ like any other, so another pool can mine it. `mined_by_pool` records whose
+ block it was.
+- **Nothing can be withdrawn.** Core has no RPC to remove a transaction from
+ its mempool, which is why the fee rule is applied *before* broadcast.
+
+## The fee rule
+
+It follows Slipstream's rule: a transaction must pay **the higher of the
+minimum submission rate** (`SLIPSTREAM_MIN_FEE_RATE`, default 1 sat/vB) **and
+the current mineable rate**.
+
+- **Mineable rate.** This is read from the template the proxy is mining now.
+ While the template has room, it equals the floor. Once the template is full,
+ it is the fee rate of the cheapest transaction in it.
+- **Checked before anything is sent.** `testmempoolaccept` reports the fee and
+ size without broadcasting, and only a transaction that passes both the node
+ and the rule reaches `sendrawtransaction`.
+
+## API
+
+Every endpoint is readable cross-origin.
+
+| | |
+|---|---|
+| `GET /info.json` (also `/api/info`) | The pool, for pool directories. |
+| `GET /api/fees` | `{min_submission_rate, mineable_rate, required_rate, template_height, template_weight, updated_at}` |
+| `POST /api/tx` (also `/tx`) | The raw transaction as hex, either as a text body or as JSON `{"hex": "..."}`. Returns `{accepted: true, txid, status, ...}` or `{accepted: false, reject_reason}`. Reject reasons are the node's (`missing-inputs`, `bad-txns-...`, `mandatory-script-verify-flag-failed ...`, `scriptpubkey` when the node does not run `-acceptnonstdtxn`) or this service's (`fee-rate-too-low`, `invalid-hex`, `tx-size`, `rate-limited`). |
+| `GET /api/tx/:txid` | `{tx, events}`: where it stands now, and every status change it went through. |
+| `GET /api/txs?status=&limit=` | Recent transactions, newest first. |
+| `GET /healthz` | `503` while the enforcer is unreachable. |
+
+```
+curl -s -X POST --data-binary "$RAW_TX_HEX" https://slipstream.example/api/tx
+```
+
+## Statuses
+
+| status | |
+|---|---|
+| `pending` | In the node's mempool, but not in the latest template. |
+| `in_template` | In the latest template, so the pool's miners are working on it now. |
+| `mined` | In a block, fewer than `SLIPSTREAM_CONFIRMATIONS` (default 6) deep. `mined_by_pool` says whose block it was (from `blocks_found`). |
+| `confirmed` | That deep. |
+| `dropped` | Left the mempool unmined, and the node refused it when it was sent again. `status_reason` is the node's reason, e.g. `insufficient fee, rejecting replacement ...` or `bad-txns-inputs-missingorspent`. |
+
+- **Leaving the mempool.** A transaction can leave the mempool without being
+ mined: evicted, expired, replaced, or its input spent by a block. The
+ service then sends it again, and the node's answer decides between `pending`
+ and `dropped`.
+- **Orphaned blocks.** When a transaction's block is orphaned, the node puts
+ it back in its mempool itself, and the transaction becomes `pending` again.
+
+## Storage
+
+The service keeps its own database, `SLIPSTREAM_DB_PATH` (default
+`../data/slipstream.db`). It **never writes to `shares.db`**, which it reads
+only for `pool_meta` and `blocks_found`.
+
+- `slipstream_submissions`: every POST exactly as it arrived, accepted or not.
+- `slipstream_txs`: one row per accepted transaction, including the raw
+ transaction so it can be sent again.
+- `slipstream_events`: every status change, append-only.
+
+## info.json
+
+Facts about the pool are read from `pool_meta`, which the proxy writes: `mode`
+(the exact `pool_mode`), `fee_bps`, `coinbase_tag`, `operator_address` and
+`pool_btc_address`. They are never configured here, so they cannot disagree
+with what the coinbase actually does. Presentation fields come from
+environment variables:
+- `POOL_NAME`, `POOL_OPERATOR`, `POOL_LOGO`, `POOL_CONTACT`
+- `POOL_CHAIN`, for a name like `betanet` that `pool_meta.network` cannot express
+- `POOL_PAYOUT_TEXT`, which overrides the default sentence for each mode
+- `PUBLIC_STRATUM_URL`, `PUBLIC_DASHBOARD_URL`, `PUBLIC_SLIPSTREAM_URL`
+
+`status_url` is the dashboard's `/api/status`.
+
+## Install (systemd + nginx)
+
+The installer does not set this up yet. The unit and the vhost are
+templates in the repository; from the pool checkout (`ROOT`), as root:
+
+```
+cd ROOT/slipstream && sudo -u npm ci --omit=dev
+
+sed -e "s|@USER@||g" -e "s|@ROOT@|ROOT|g" \
+ ROOT/deploy/systemd/simplepool-slipstream.service \
+ > /etc/systemd/system/simplepool-slipstream.service
+$EDITOR /etc/systemd/system/simplepool-slipstream.service # RPC credentials, POOL_*, PUBLIC_*
+systemctl daemon-reload && systemctl enable --now simplepool-slipstream
+
+sed "s/slipstream\.example//g" ROOT/deploy/nginx/slipstream.conf \
+ > /etc/nginx/sites-available/
+ln -s /etc/nginx/sites-available/ /etc/nginx/sites-enabled/
+nginx -t && systemctl reload nginx
+certbot --nginx -d
+```
+
+The vhost overwrites `X-Forwarded-For` with the connecting address, and the
+service (with `SLIPSTREAM_TRUST_PROXY=1`) keys its rate limit on the last hop
+of that header, so a client cannot pick its own key. It reuses the
+dashboard's `pool_dash` rate-limit zone from `deploy/nginx/pool-ratelimit.conf`,
+which the installer already puts in `conf.d/`.
+
+To show the Slipstream page on the dashboard, add to
+`simplepool-dashboard.service` (or its `local.conf` drop-in) and restart it:
+
+```
+Environment=SLIPSTREAM_API_URL=http://127.0.0.1:8124
+Environment=PUBLIC_SLIPSTREAM_URL=https://
+```
+
+Check it:
+
+```
+curl -s https:///healthz
+curl -s https:///api/fees
+curl -s https:///info.json
+journalctl -u simplepool-slipstream -f
+```
+
+## Run by hand
+
+```
+cd slipstream && npm ci
+BITCOIND_RPC_URL=http://127.0.0.1:8332 BITCOIND_RPC_COOKIE_FILE=/var/lib/bitcoind/.cookie \
+ENFORCER_GBT_URL=http://127.0.0.1:8122 \
+PROXY_DB_PATH=../data/shares.db \
+PUBLIC_SLIPSTREAM_URL=https://slipstream.example \
+node index.js
+```
+
+The full list of variables is in [`lib/config.js`](lib/config.js). The service
+listens on `127.0.0.1:8124`.
+
+## Tests
+
+- `npm test`: unit tests. These run against a fake bitcoind and enforcer, and
+ cover every status transition.
+- `tests/test_slipstream_regtest.sh`: end to end, against a real node and the
+ stock enforcer. It runs in CI.
diff --git a/slipstream/index.js b/slipstream/index.js
new file mode 100644
index 0000000..9ca4ab0
--- /dev/null
+++ b/slipstream/index.js
@@ -0,0 +1,66 @@
+/* simplepool-slipstream — take txs from anyone, mine them in the pool's blocks.
+ *
+ * Submissions are checked against the pool's own bitcoind and the fee rule,
+ * then broadcast to it. The enforcer's template mempool mirrors that node's,
+ * so an accepted tx reaches the templates the proxy mines. This service
+ * records every submission and follows each accepted tx from template to
+ * block.
+ *
+ * Config is environment-only — see lib/config.js for the full list.
+ *
+ * Run:
+ * BITCOIND_RPC_URL=http://127.0.0.1:8332 BITCOIND_RPC_COOKIE_FILE=... \
+ * ENFORCER_GBT_URL=http://127.0.0.1:8122 \
+ * PROXY_DB_PATH=../data/shares.db \
+ * node index.js
+ */
+
+import { readFileSync } from 'node:fs';
+
+import { loadConfig } from './lib/config.js';
+import { openStore } from './lib/store.js';
+import { openPoolDb } from './lib/pool.js';
+import { EnforcerClient, BitcoindClient } from './lib/rpc.js';
+import { Slipstream } from './lib/slipstream.js';
+import { startHttp } from './lib/http.js';
+
+const cfg = loadConfig();
+const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
+
+const log = {
+ debug: (m) => process.env.SLIPSTREAM_DEBUG === '1' && console.log(`[debug] ${m}`),
+ info: (m) => console.log(`[info] ${m}`),
+ warn: (m) => console.warn(`[warn] ${m}`),
+ error: (m) => console.error(`[error] ${m}`),
+};
+
+log.info(`simplepool-slipstream ${version} starting ` +
+ `(node=${cfg.bitcoind.url} enforcer=${cfg.enforcerUrl} db=${cfg.dbPath})`);
+log.info(` fee floor ${cfg.minFeeRate} sat/vB, confirmed at ${cfg.confirmations}, ` +
+ `poll ${cfg.pollMs}ms`);
+
+const slipstream = new Slipstream({
+ store: openStore(cfg.dbPath),
+ bitcoind: new BitcoindClient(cfg.bitcoind),
+ enforcer: new EnforcerClient({ url: cfg.enforcerUrl }),
+ pool: openPoolDb(cfg.proxyDbPath),
+ cfg,
+ log,
+});
+
+let lastTickError = null;
+async function loop() {
+ try {
+ await slipstream.tick();
+ if (lastTickError) log.info('enforcer reachable again');
+ lastTickError = null;
+ } catch (e) {
+ // Logged once per distinct failure, not once per poll
+ if (e.message !== lastTickError) log.warn(`tick failed: ${e.message}`);
+ lastTickError = e.message;
+ }
+ setTimeout(loop, cfg.pollMs);
+}
+
+startHttp({ slipstream, pool: slipstream.pool, cfg, version, log });
+loop();
diff --git a/slipstream/lib/config.js b/slipstream/lib/config.js
new file mode 100644
index 0000000..26b7606
--- /dev/null
+++ b/slipstream/lib/config.js
@@ -0,0 +1,110 @@
+/* Slipstream service config, loaded from environment variables.
+ *
+ * Required:
+ * BITCOIND_RPC_URL the pool's own bitcoind, the node the enforcer
+ * syncs its mempool from. Txs are checked
+ * (testmempoolaccept) and broadcast
+ * (sendrawtransaction) here, and followed with
+ * getmempoolentry, getrawtransaction and
+ * getblockheader. For non-standard txs it must run
+ * -acceptnonstdtxn, which Core allows only off
+ * mainnet; BIP300 deposits are standard on a
+ * drivechain-patched node without it.
+ * BITCOIND_RPC_USER / BITCOIND_RPC_PASS, or BITCOIND_RPC_COOKIE_FILE
+ * ENFORCER_GBT_URL the enforcer's block template server, the same
+ * endpoint the proxy mines from (bitcoind_url in
+ * proxy.conf), e.g. http://127.0.0.1:8122. Read
+ * only: the template it serves is what the pool is
+ * mining, so it is where the mineable rate comes
+ * from and how a tx is seen to be in play.
+ *
+ * Optional:
+ * SLIPSTREAM_DB_PATH this service's own SQLite file (default
+ * ../data/slipstream.db). Every submission and
+ * every status change is kept here. Not shares.db:
+ * this service never writes to the proxy's
+ * database, whose writer is the proxy's share
+ * path, and nothing here is worth contending
+ * with it for.
+ * PROXY_DB_PATH shares.db, opened read-only (default
+ * ../data/shares.db). Pool identity for info.json
+ * comes from pool_meta, and blocks_found says
+ * whether the block that mined a tx was ours.
+ * Without it both are reported as unknown.
+ * SLIPSTREAM_PORT HTTP port (default 8124)
+ * SLIPSTREAM_BIND bind address (default 127.0.0.1; put nginx in
+ * front to publish it)
+ * SLIPSTREAM_TRUST_PROXY '1' = take the client address from
+ * X-Forwarded-For, for rate limiting. Only behind a
+ * proxy that sets it, or anyone can pick theirs.
+ * SLIPSTREAM_MIN_FEE_RATE floor in sat/vB (default 1). The required rate is
+ * the higher of this and the current mineable rate,
+ * as Slipstream states it.
+ * SLIPSTREAM_CONFIRMATIONS depth at which a mined tx counts as confirmed
+ * (default 6)
+ * SLIPSTREAM_POLL_MS how often to read the template and follow every
+ * open tx (default 5000)
+ * SLIPSTREAM_RATE_LIMIT_PER_MIN
+ * submissions per client address per minute
+ * (default 30)
+ *
+ * info.json presentation fields — facts about the pool (mode, fee, coinbase
+ * tag, addresses) are never configured here, they are read from pool_meta,
+ * which the proxy writes:
+ * POOL_NAME, POOL_OPERATOR, POOL_LOGO, POOL_CONTACT
+ * POOL_CHAIN chain name as the pool advertises it, e.g.
+ * 'betanet'. pool_meta.network can only say main,
+ * test, signet or regtest. Defaults to that.
+ * POOL_PAYOUT_TEXT overrides the per-mode default payout sentence
+ * PUBLIC_STRATUM_URL, PUBLIC_DASHBOARD_URL, PUBLIC_SLIPSTREAM_URL
+ */
+
+function num(name, dflt, { min = -Infinity } = {}) {
+ const raw = process.env[name];
+ if (raw === undefined || raw === '') return dflt;
+ const v = Number(raw);
+ if (!Number.isFinite(v) || v < min) {
+ throw new Error(`${name}=${raw}: expected a number >= ${min}`);
+ }
+ return v;
+}
+
+function str(name) {
+ const v = process.env[name];
+ return v === undefined || v === '' ? null : v;
+}
+
+export function loadConfig() {
+ for (const name of ['BITCOIND_RPC_URL', 'ENFORCER_GBT_URL']) {
+ if (!str(name)) throw new Error(`${name} is required`);
+ }
+ return {
+ enforcerUrl: str('ENFORCER_GBT_URL'),
+ dbPath: str('SLIPSTREAM_DB_PATH') || '../data/slipstream.db',
+ proxyDbPath: str('PROXY_DB_PATH') || '../data/shares.db',
+ bitcoind: {
+ url: str('BITCOIND_RPC_URL'),
+ user: str('BITCOIND_RPC_USER'),
+ pass: str('BITCOIND_RPC_PASS'),
+ cookieFile: str('BITCOIND_RPC_COOKIE_FILE'),
+ },
+ port: num('SLIPSTREAM_PORT', 8124, { min: 1 }),
+ bind: str('SLIPSTREAM_BIND') || '127.0.0.1',
+ trustProxy: process.env.SLIPSTREAM_TRUST_PROXY === '1',
+ minFeeRate: num('SLIPSTREAM_MIN_FEE_RATE', 1, { min: 0 }),
+ confirmations: num('SLIPSTREAM_CONFIRMATIONS', 6, { min: 1 }),
+ pollMs: num('SLIPSTREAM_POLL_MS', 5000, { min: 100 }),
+ rateLimitPerMin: num('SLIPSTREAM_RATE_LIMIT_PER_MIN', 30, { min: 1 }),
+ presentation: {
+ name: str('POOL_NAME'),
+ operator: str('POOL_OPERATOR'),
+ logo: str('POOL_LOGO'),
+ contact: str('POOL_CONTACT'),
+ chain: str('POOL_CHAIN'),
+ payoutText: str('POOL_PAYOUT_TEXT'),
+ stratumUrl: str('PUBLIC_STRATUM_URL'),
+ dashboardUrl: str('PUBLIC_DASHBOARD_URL'),
+ slipstreamUrl: str('PUBLIC_SLIPSTREAM_URL'),
+ },
+ };
+}
diff --git a/slipstream/lib/fees.js b/slipstream/lib/fees.js
new file mode 100644
index 0000000..c6edd80
--- /dev/null
+++ b/slipstream/lib/fees.js
@@ -0,0 +1,46 @@
+/* The fee rule, as Slipstream states it: a tx must pay the higher of the
+ * minimum submission rate and the current mineable rate.
+ *
+ * The mineable rate is read off the template the proxy is mining now. While
+ * the template has room, anything over the floor gets in, so the floor is the
+ * mineable rate. Once it is full, the cheapest tx it carries is what a new tx
+ * has to beat. Per tx, not per package: a cheap parent carried by its child
+ * reads lower than it was really priced at, which only ever errs towards
+ * accepting.
+ */
+
+/* Weight a template can carry: 4M less what the header and a coinbase take.
+ * The coinbase is the one piece that varies, and a pplns-coinbase one paying
+ * a window of miners is the heaviest, so reserve for that. */
+export const TEMPLATE_WEIGHT_CAPACITY = 4_000_000 - 8_000;
+
+/* A template this close to capacity is full: nothing typical fits after it. */
+export const FULL_MARGIN_WEIGHT = 4_000;
+
+export const vsizeOf = (weight) => Math.ceil(weight / 4);
+
+/* Rate in sat/vB, rounded to 3 places so a stored rate reads back as it was
+ * shown. */
+export const feeRate = (feeSats, vsize) => Math.round((feeSats / vsize) * 1000) / 1000;
+
+export function mineableRate(template, floor) {
+ const txs = template?.transactions ?? [];
+ const used = txs.reduce((w, tx) => w + (tx.weight ?? 0), 0);
+ if (txs.length === 0 || used < TEMPLATE_WEIGHT_CAPACITY - FULL_MARGIN_WEIGHT) {
+ return floor;
+ }
+ const cheapest = Math.min(...txs.map(tx => feeRate(tx.fee ?? 0, vsizeOf(tx.weight))));
+ return Math.max(floor, cheapest);
+}
+
+/* What /api/fees reports, and what a submission is held to. */
+export function feeSnapshot(template, floor) {
+ const mineable = mineableRate(template, floor);
+ return {
+ min_submission_rate: floor,
+ mineable_rate: mineable,
+ required_rate: Math.max(floor, mineable),
+ template_height: template?.height ?? null,
+ template_weight: (template?.transactions ?? []).reduce((w, tx) => w + (tx.weight ?? 0), 0),
+ };
+}
diff --git a/slipstream/lib/http.js b/slipstream/lib/http.js
new file mode 100644
index 0000000..4a9910b
--- /dev/null
+++ b/slipstream/lib/http.js
@@ -0,0 +1,177 @@
+/* The public HTTP surface.
+ *
+ * GET /info.json the pool, for pool directories (also /api/info)
+ * GET /api/fees minimum submission rate and current mineable rate
+ * POST /api/tx submit a raw tx, as hex: a text/plain body, or
+ * JSON {"hex": "..."} (also POST /tx)
+ * GET /api/tx/:txid where a tx stands, with its full history
+ * GET /api/txs recent txs; ?status= and ?limit= (max 500)
+ * GET /healthz
+ *
+ * Plain node:http, like the payout worker's admin surface: a handful of
+ * routes does not need a framework. Everything is readable cross-origin,
+ * since pool directories fetch info.json from their own pages.
+ */
+
+import { createServer } from 'node:http';
+
+import { buildInfo } from './info.js';
+import { summary } from './slipstream.js';
+
+/* Hex doubles the size, and JSON wraps it. */
+const MAX_BODY_BYTES = 2 * 1_000_000 + 1024;
+
+const TXID_RE = /^[0-9a-f]{64}$/;
+const STATUSES = ['pending', 'in_template', 'mined', 'confirmed', 'dropped'];
+
+/* Fixed-window per-address counter. Crude, and enough: a submission costs
+ * the enforcer a node round trip, so the point is only to bound that. */
+export function rateLimiter(perMinute) {
+ const windows = new Map();
+ return (key) => {
+ const minute = Math.floor(Date.now() / 60_000);
+ const w = windows.get(key);
+ if (!w || w.minute !== minute) {
+ windows.set(key, { minute, count: 1 });
+ if (windows.size > 10_000) {
+ for (const [k, v] of windows) if (v.minute !== minute) windows.delete(k);
+ }
+ return true;
+ }
+ w.count += 1;
+ return w.count <= perMinute;
+ };
+}
+
+/* Behind a proxy, the LAST X-Forwarded-For entry: the one the proxy itself
+ * added. Anything before it came from the client, which can write whatever
+ * it likes there -- taking the first entry would let a client pick its own
+ * rate-limit key. */
+export function clientAddress(req, trustProxy) {
+ if (trustProxy) {
+ const fwd = req.headers['x-forwarded-for'];
+ if (typeof fwd === 'string' && fwd.length > 0) {
+ const hops = fwd.split(',').map(h => h.trim()).filter(Boolean);
+ if (hops.length > 0) return hops[hops.length - 1];
+ }
+ }
+ return req.socket.remoteAddress ?? 'unknown';
+}
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ let size = 0;
+ const chunks = [];
+ req.on('data', (chunk) => {
+ size += chunk.length;
+ if (size > MAX_BODY_BYTES) {
+ reject(Object.assign(new Error('body too large'), { tooLarge: true }));
+ req.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
+ req.on('error', reject);
+ });
+}
+
+function txHexFrom(body, contentType) {
+ if ((contentType ?? '').includes('application/json')) {
+ const parsed = JSON.parse(body);
+ return typeof parsed === 'string' ? parsed : (parsed?.hex ?? parsed?.tx ?? '');
+ }
+ return body;
+}
+
+export function createHandler({ slipstream, pool, cfg, version, log }) {
+ const allow = rateLimiter(cfg.rateLimitPerMin);
+ return async (req, res) => {
+ const send = (status, body) => {
+ res.statusCode = status;
+ res.setHeader('content-type', 'application/json');
+ res.setHeader('access-control-allow-origin', '*');
+ res.setHeader('cache-control', 'no-store');
+ res.end(JSON.stringify(body));
+ };
+ const url = new URL(req.url, 'http://localhost');
+ const path = url.pathname.replace(/\/+$/, '') || '/';
+ try {
+ if (req.method === 'OPTIONS') {
+ res.statusCode = 204;
+ res.setHeader('access-control-allow-origin', '*');
+ res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS');
+ res.setHeader('access-control-allow-headers', 'content-type');
+ return res.end();
+ }
+ if (req.method === 'GET' && (path === '/info.json' || path === '/api/info')) {
+ res.setHeader('cache-control', 'public, max-age=60');
+ const body = buildInfo(pool.meta(), cfg.presentation, { version });
+ res.statusCode = 200;
+ res.setHeader('content-type', 'application/json');
+ res.setHeader('access-control-allow-origin', '*');
+ return res.end(JSON.stringify(body, null, 2));
+ }
+ if (req.method === 'GET' && path === '/api/fees') {
+ return send(200, slipstream.fees());
+ }
+ if (req.method === 'POST' && (path === '/api/tx' || path === '/tx')) {
+ const who = clientAddress(req, cfg.trustProxy);
+ if (!allow(who)) return send(429, { accepted: false, reject_reason: 'rate-limited' });
+ let hex;
+ try {
+ hex = txHexFrom(await readBody(req), req.headers['content-type']);
+ } catch (e) {
+ if (e.tooLarge) return send(413, { accepted: false, reject_reason: 'tx-size' });
+ return send(400, { accepted: false, reject_reason: 'invalid-body' });
+ }
+ const { httpStatus, body } = await slipstream.submit(hex, who);
+ return send(httpStatus, body);
+ }
+ const txMatch = path.match(/^\/(?:api\/)?tx\/([0-9a-fA-F]+)$/);
+ if (req.method === 'GET' && txMatch) {
+ const txid = txMatch[1].toLowerCase();
+ if (!TXID_RE.test(txid)) return send(400, { error: 'invalid txid' });
+ const row = slipstream.store.get(txid);
+ if (!row) return send(404, { error: 'unknown txid' });
+ return send(200, { tx: summary(row), events: slipstream.store.events(txid) });
+ }
+ if (req.method === 'GET' && path === '/api/txs') {
+ const status = url.searchParams.get('status');
+ if (status && !STATUSES.includes(status)) {
+ return send(400, { error: `status must be one of ${STATUSES.join(', ')}` });
+ }
+ const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 50, 1), 500);
+ return send(200, { txs: slipstream.store.recent({ status, limit }) });
+ }
+ if (req.method === 'GET' && path === '/healthz') {
+ const age = slipstream.templateAt === null
+ ? null : Math.floor(Date.now() / 1000) - slipstream.templateAt;
+ const ok = slipstream.enforcerError === null && age !== null;
+ return send(ok ? 200 : 503, {
+ ok, template_age_s: age, enforcer_error: slipstream.enforcerError,
+ });
+ }
+ if (req.method === 'GET' && path === '/') {
+ return send(200, {
+ service: 'simplepool slipstream',
+ endpoints: ['GET /info.json', 'GET /api/fees', 'POST /api/tx',
+ 'GET /api/tx/:txid', 'GET /api/txs', 'GET /healthz'],
+ });
+ }
+ return send(404, { error: 'not found' });
+ } catch (e) {
+ log.error(`${req.method} ${path}: ${e.stack ?? e.message}`);
+ return send(500, { error: 'internal error' });
+ }
+ };
+}
+
+export function startHttp(opts) {
+ const { cfg, log } = opts;
+ const server = createServer(createHandler(opts));
+ server.listen(cfg.port, cfg.bind, () => {
+ log.info(`slipstream http listening on ${cfg.bind}:${cfg.port}`);
+ });
+ return server;
+}
diff --git a/slipstream/lib/info.js b/slipstream/lib/info.js
new file mode 100644
index 0000000..f418483
--- /dev/null
+++ b/slipstream/lib/info.js
@@ -0,0 +1,54 @@
+/* info.json: what a pool directory needs to list this pool.
+ *
+ * Facts about the pool are read from pool_meta, which the proxy writes from
+ * its own config, and never configured here: a second copy of the fee or the
+ * mode is a copy that can silently disagree with what the coinbase actually
+ * does. Only presentation (name, logo, URLs, contact) comes from env.
+ *
+ * `mode` is the exact pool_mode. Three modes are PPLNS and they differ in
+ * what a miner has to trust the pool with, so "pplns" alone would hide the
+ * one thing a miner choosing a pool most needs to know.
+ */
+
+/* One sentence per mode on how a miner gets paid. */
+export const PAYOUT_TEXT = {
+ 'solo':
+ 'The stratum username is your BTC address; each block\'s coinbase pays the miner who found it.',
+ 'pps-classic':
+ 'The stratum username is your Thunder address; every accepted share is credited at a rate '
+ + 'derived from the current block template, and balances are paid over Thunder in a daily batch.',
+ 'pplns-thunder':
+ 'The stratum username is your Thunder address; each block is split across the last window of '
+ + 'shares once it is 100 blocks deep, and balances are paid over Thunder.',
+ 'pplns-btc':
+ 'The stratum username is your BTC address; each block is split across the last window of '
+ + 'shares once it is 100 blocks deep, and balances are paid on Bitcoin.',
+ 'pplns-coinbase':
+ 'The stratum username is your BTC address; the coinbase pays every miner in the PPLNS window '
+ + 'directly, so the pool never holds funds.',
+};
+
+const trimSlash = (url) => url.replace(/\/+$/, '');
+
+export function buildInfo(meta, p, { version = null } = {}) {
+ const mode = meta?.pool_mode ?? null;
+ return {
+ name: p.name,
+ operator: p.operator,
+ chain: p.chain ?? meta?.network ?? null,
+ mode,
+ fee_bps: meta?.fee_bps ?? null,
+ coinbase_tag: meta?.coinbase_tag ?? null,
+ stratum_url: p.stratumUrl,
+ dashboard_url: p.dashboardUrl,
+ status_url: p.dashboardUrl ? `${trimSlash(p.dashboardUrl)}/api/status` : null,
+ slipstream_url: p.slipstreamUrl,
+ operator_address: meta?.operator_address ?? null,
+ pool_btc_address: meta?.pool_btc_address ?? null,
+ payout: p.payoutText ?? PAYOUT_TEXT[mode] ?? null,
+ software: 'simplepool',
+ version,
+ logo: p.logo,
+ contact: p.contact,
+ };
+}
diff --git a/slipstream/lib/pool.js b/slipstream/lib/pool.js
new file mode 100644
index 0000000..e7e43a6
--- /dev/null
+++ b/slipstream/lib/pool.js
@@ -0,0 +1,61 @@
+/* Read-only view of the proxy's shares.db.
+ *
+ * Opened lazily and re-tried, like the dashboard's: the proxy may not have
+ * created it yet. Every read degrades to "unknown" rather than failing, so
+ * slipstream keeps accepting txs through a proxy restart.
+ */
+
+import Database from 'better-sqlite3';
+import fs from 'node:fs';
+
+export function openPoolDb(path) {
+ let db = null;
+ let lastTryMs = 0;
+
+ function get() {
+ if (db) return db;
+ const nowMs = Date.now();
+ if (!path || nowMs - lastTryMs < 1000) return null;
+ lastTryMs = nowMs;
+ if (!fs.existsSync(path)) return null;
+ try {
+ db = new Database(path, { readonly: true, fileMustExist: true });
+ db.pragma('busy_timeout = 2000');
+ } catch (err) {
+ console.error(`[warn] pool db open failed: ${err.message}`);
+ db = null;
+ }
+ return db;
+ }
+
+ return {
+ /* pool_meta's identity columns, or null if there is no pool_meta
+ * yet. Read per call: a proxy restarted onto another mode or network
+ * shows up on the next request. */
+ meta() {
+ const d = get();
+ if (!d) return null;
+ try {
+ return d.prepare(`
+ SELECT pool_mode, fee_bps, network, coinbase_tag,
+ operator_address, pool_btc_address
+ FROM pool_meta WHERE id = 1
+ `).get() ?? null;
+ } catch {
+ return null; // a DB predating the identity columns
+ }
+ },
+
+ /* Whether the pool found `blockHash`: true, false, or null when
+ * shares.db cannot say. */
+ foundBlock(blockHash) {
+ const d = get();
+ if (!d) return null;
+ try {
+ return d.prepare('SELECT 1 FROM blocks_found WHERE hash = ?').get(blockHash) !== undefined;
+ } catch {
+ return null;
+ }
+ },
+ };
+}
diff --git a/slipstream/lib/rpc.js b/slipstream/lib/rpc.js
new file mode 100644
index 0000000..c79c141
--- /dev/null
+++ b/slipstream/lib/rpc.js
@@ -0,0 +1,137 @@
+/* JSON-RPC clients for bitcoind and the enforcer's block template server.
+ *
+ * Both speak the same wire format; they differ only in auth. The template
+ * server has none, bitcoind takes basic auth from user/pass or its cookie
+ * file. A cookie is re-read on every call, because bitcoind rewrites it on
+ * each restart and a stale one fails every request until this restarts too.
+ */
+
+import fs from 'node:fs';
+
+export class RpcError extends Error {
+ constructor(method, code, message) {
+ super(`${method}: ${code} ${message}`);
+ this.method = method;
+ this.code = code;
+ this.rpcMessage = message;
+ }
+}
+
+export class RpcClient {
+ constructor({ url, user = null, pass = null, cookieFile = null, timeoutMs = 30000 }) {
+ this.url = url;
+ this.user = user;
+ this.pass = pass;
+ this.cookieFile = cookieFile;
+ this.timeoutMs = timeoutMs;
+ this._id = 0;
+ }
+
+ _auth() {
+ if (this.cookieFile) {
+ const cookie = fs.readFileSync(this.cookieFile, 'utf8').trim();
+ return 'Basic ' + Buffer.from(cookie).toString('base64');
+ }
+ if (this.user && this.pass) {
+ return 'Basic ' + Buffer.from(`${this.user}:${this.pass}`).toString('base64');
+ }
+ return null;
+ }
+
+ async call(method, params = []) {
+ const headers = { 'Content-Type': 'application/json' };
+ const auth = this._auth();
+ if (auth) headers.Authorization = auth;
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), this.timeoutMs);
+ let res;
+ try {
+ res = await fetch(this.url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ jsonrpc: '2.0', id: ++this._id, method, params }),
+ signal: ctrl.signal,
+ });
+ } finally {
+ clearTimeout(t);
+ }
+ // bitcoind answers RPC errors with HTTP 500 and a JSON body, so read
+ // the body before deciding the status means transport failure.
+ const text = await res.text();
+ let body;
+ try {
+ body = JSON.parse(text);
+ } catch {
+ throw new Error(`${method}: HTTP ${res.status} ${res.statusText}`);
+ }
+ if (body.error) throw new RpcError(method, body.error.code, body.error.message);
+ return body.result;
+ }
+}
+
+/* RPC_INVALID_ADDRESS_OR_KEY: what bitcoind answers for an unknown txid or
+ * block hash. */
+const NOT_FOUND = -5;
+
+const orNull = async (promise) => {
+ try {
+ return await promise;
+ } catch (e) {
+ if (e instanceof RpcError && e.code === NOT_FOUND) return null;
+ throw e;
+ }
+};
+
+/* The pool's own bitcoind: where a slipstream tx is checked, broadcast and
+ * followed. The enforcer's template mempool mirrors this node's mempool, so
+ * a tx accepted here reaches the templates the proxy mines. */
+export class BitcoindClient {
+ constructor(opts) {
+ this.rpc = new RpcClient(opts);
+ }
+
+ /* testmempoolaccept for one tx: { txid, wtxid, allowed, vsize, fees,
+ * 'reject-reason' }. Checks everything sendrawtransaction would, and
+ * broadcasts nothing. */
+ async testAccept(txHex) {
+ const [result] = await this.rpc.call('testmempoolaccept', [[txHex]]);
+ return result;
+ }
+
+ send(txHex) { return this.rpc.call('sendrawtransaction', [txHex]); }
+
+ /* { vsize, weight, fees: { base }, ... }, or null outside the mempool */
+ mempoolEntry(txid) { return orNull(this.rpc.call('getmempoolentry', [txid])); }
+
+ /* The block a tx is confirmed in, if it is confirmed at all. Needs
+ * txindex, which the enforcer already requires of this node. */
+ async txBlock(txid) {
+ const tx = await orNull(this.rpc.call('getrawtransaction', [txid, true]));
+ return tx?.blockhash && tx.confirmations > 0 ? tx.blockhash : null;
+ }
+
+ /* { confirmations, height } of a block, confirmations -1 once it has left
+ * the main chain; null if the node does not know it. */
+ async blockConfirmations(blockHash) {
+ const header = await orNull(this.rpc.call('getblockheader', [blockHash, true]));
+ return header ? { confirmations: header.confirmations, height: header.height } : null;
+ }
+}
+
+/* The enforcer's block template server: read only for the template the
+ * proxy is mining, which is where the mineable rate comes from and how a tx
+ * is seen to be in play. */
+export class EnforcerClient {
+ constructor(opts) {
+ this.rpc = new RpcClient(opts);
+ }
+
+ /* The enforcer serves only coinbasetxn templates, and says so rather
+ * than falling back. */
+ getBlockTemplate() {
+ return this.rpc.call('getblocktemplate', [{
+ rules: ['segwit'],
+ capabilities: ['coinbasetxn'],
+ }]);
+ }
+}
diff --git a/slipstream/lib/slipstream.js b/slipstream/lib/slipstream.js
new file mode 100644
index 0000000..61c12de
--- /dev/null
+++ b/slipstream/lib/slipstream.js
@@ -0,0 +1,277 @@
+/* Submission and tracking.
+ *
+ * A submission goes to the pool's own bitcoind. The enforcer's template
+ * mempool mirrors that node's mempool, so a tx the node accepts reaches the
+ * templates the proxy mines with no further step. The node needs
+ * `-acceptnonstdtxn` for it to take non-standard txs; BIP300 deposits are
+ * standard on a drivechain-patched node already.
+ *
+ * Nothing can be taken back out of a node's mempool, so the fee rule is
+ * applied BEFORE anything is broadcast: `testmempoolaccept` reports the fee
+ * and size without broadcasting, and only a tx that passes both the node and
+ * the rule is sent. Once sent it is relayed like any other.
+ *
+ * After that, every poll reads the template the proxy is mining and asks the
+ * node where each open tx stands, and moves the row accordingly.
+ */
+
+import { RpcError } from './rpc.js';
+import { OPEN_STATUSES } from './store.js';
+import { feeRate, feeSnapshot } from './fees.js';
+
+const now = () => Math.floor(Date.now() / 1000);
+const btcToSats = (btc) => Math.round(btc * 1e8);
+
+/* A serialized tx is at most a block, 4M wu, i.e. 4MB; nothing near that is
+ * useful, and a POST body has to be bounded somewhere. 1MB of tx. */
+export const MAX_TX_HEX_LEN = 2 * 1_000_000;
+
+/* testmempoolaccept's reasons for a tx the node already has */
+const ALREADY_KNOWN = ['txn-already-in-mempool', 'txn-already-known'];
+
+export class Slipstream {
+ constructor({ store, bitcoind, enforcer, pool, cfg, log }) {
+ this.store = store;
+ this.bitcoind = bitcoind;
+ this.enforcer = enforcer;
+ this.pool = pool;
+ this.cfg = cfg;
+ this.log = log;
+ this.template = null;
+ this.templateAt = null;
+ this.enforcerError = null;
+ }
+
+ fees() {
+ return {
+ ...feeSnapshot(this.template, this.cfg.minFeeRate),
+ updated_at: this.templateAt,
+ };
+ }
+
+ async refreshTemplate() {
+ try {
+ this.template = await this.enforcer.getBlockTemplate();
+ this.templateAt = now();
+ this.enforcerError = null;
+ } catch (e) {
+ this.enforcerError = e.message;
+ throw e;
+ }
+ return this.template;
+ }
+
+ /* Check one tx, and broadcast it if it passes. Returns
+ * `{ httpStatus, body }`. Every call is logged as a submission, whatever
+ * becomes of it. */
+ async submit(rawHex, submitter) {
+ const hex = typeof rawHex === 'string' ? rawHex.trim() : '';
+ const record = (fields) => this.store.logSubmission({ submitter, rawHex: hex, ...fields });
+ const refuse = (httpStatus, reason, extra = {}) => {
+ record({ txid: extra.txid ?? null, accepted: false, rejectReason: reason });
+ return { httpStatus, body: { accepted: false, reject_reason: reason, ...extra } };
+ };
+
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) {
+ return refuse(400, 'invalid-hex');
+ }
+ if (hex.length > MAX_TX_HEX_LEN) return refuse(400, 'tx-size');
+
+ let check;
+ try {
+ check = await this.bitcoind.testAccept(hex);
+ } catch (e) {
+ if (e instanceof RpcError && e.code === -22) return refuse(400, 'tx-decode-failed');
+ this.log.error(`testmempoolaccept failed: ${e.message}`);
+ return refuse(502, 'node-unavailable');
+ }
+ const { txid } = check;
+
+ const existing = this.store.get(txid);
+ if (existing && !['dropped'].includes(existing.status)) {
+ record({ txid, accepted: true });
+ return { httpStatus: 200, body: { accepted: true, already_tracked: true, ...summary(existing) } };
+ }
+
+ // Fee and size: from the check, or from the mempool for a tx the node
+ // already has, which is then taken on and followed like any other.
+ let feeSats;
+ let vsize;
+ let alreadyInMempool = false;
+ if (check.allowed) {
+ feeSats = btcToSats(check.fees.base);
+ vsize = check.vsize;
+ } else if (ALREADY_KNOWN.includes(check['reject-reason'])) {
+ const entry = await this.bitcoind.mempoolEntry(txid);
+ if (!entry) return refuse(200, 'already-confirmed', { txid });
+ feeSats = btcToSats(entry.fees.base);
+ vsize = entry.vsize;
+ alreadyInMempool = true;
+ } else {
+ return refuse(200, check['reject-reason'] ?? 'rejected', { txid });
+ }
+
+ if (!this.template) {
+ try { await this.refreshTemplate(); } catch { /* the floor still applies */ }
+ }
+ const fees = this.fees();
+ const rate = feeRate(feeSats, vsize);
+ if (rate < fees.required_rate) {
+ return refuse(200, 'fee-rate-too-low', {
+ txid, fee_rate: rate, required_fee_rate: fees.required_rate,
+ });
+ }
+
+ if (!alreadyInMempool) {
+ try {
+ await this.bitcoind.send(hex);
+ } catch (e) {
+ // Lost a race with something the check did not see, e.g. a
+ // conflicting tx arriving in between.
+ if (e instanceof RpcError) return refuse(200, e.rpcMessage, { txid });
+ this.log.error(`sendrawtransaction ${txid} failed: ${e.message}`);
+ return refuse(502, 'node-unavailable', { txid });
+ }
+ }
+ const entry = await this.bitcoind.mempoolEntry(txid);
+ const row = {
+ txid,
+ wtxid: check.wtxid,
+ raw_hex: hex,
+ vsize,
+ weight: entry?.weight ?? vsize * 4,
+ fee_sats: feeSats,
+ fee_rate: rate,
+ required_fee_rate: fees.required_rate,
+ submitted_height: this.template?.height ?? null,
+ submitter: submitter ?? null,
+ };
+ if (existing) {
+ // Dropped before, and valid again now
+ this.store.touch(txid, {
+ raw_hex: hex, fee_sats: feeSats, fee_rate: rate,
+ required_fee_rate: row.required_fee_rate, submitted_at: now(),
+ submitted_height: row.submitted_height,
+ });
+ this.store.setStatus(txid, 'pending', { detail: { resubmitted_by: 'submitter' } });
+ } else {
+ this.store.insertAccepted(row);
+ }
+ record({ txid, accepted: true });
+ this.log.info(`accepted ${txid} at ${rate} sat/vB (required ${fees.required_rate})`
+ + (alreadyInMempool ? ', already in the mempool' : ''));
+ return { httpStatus: 200, body: { accepted: true, ...summary(this.store.get(txid)) } };
+ }
+
+ /* One pass over every tx still in play. An unreachable enforcer ends the
+ * pass before anything moves; the next one picks up where it stood. */
+ async tick() {
+ const template = await this.refreshTemplate();
+ const inTemplate = new Set((template.transactions ?? []).map(tx => tx.txid));
+ for (const row of this.store.byStatus(OPEN_STATUSES)) {
+ await this._followOpen(row, inTemplate);
+ }
+ for (const row of this.store.byStatus(['mined'])) {
+ await this._followMined(row);
+ }
+ }
+
+ async _followOpen(row, inTemplate) {
+ if (await this.bitcoind.mempoolEntry(row.txid)) {
+ const ts = now();
+ if (inTemplate.has(row.txid)) {
+ this.store.setStatus(row.txid, 'in_template', {
+ fields: row.first_in_template_at === null ? { first_in_template_at: ts } : {},
+ });
+ this.store.touch(row.txid, { last_in_template_at: ts });
+ } else {
+ this.store.setStatus(row.txid, 'pending');
+ }
+ return;
+ }
+ const block = await this.bitcoind.txBlock(row.txid);
+ if (block) return this._markMined(row, block);
+ // Out of the mempool and not mined: evicted, expired, replaced, or its
+ // input spent by a block. Sending it again tells which.
+ return this._rebroadcast(row, 'left-mempool');
+ }
+
+ async _markMined(row, blockHash) {
+ const header = await this.bitcoind.blockConfirmations(blockHash);
+ this.store.setStatus(row.txid, 'mined', {
+ fields: {
+ mined_block_hash: blockHash,
+ mined_height: header?.height ?? null,
+ mined_at: now(),
+ mined_by_pool: boolOrNull(this.pool.foundBlock(blockHash)),
+ confirmations: header?.confirmations ?? null,
+ },
+ detail: { block_hash: blockHash, height: header?.height ?? null },
+ });
+ this.log.info(`mined ${row.txid} in ${blockHash}`);
+ }
+
+ async _followMined(row) {
+ const header = await this.bitcoind.blockConfirmations(row.mined_block_hash);
+ if (!header || header.confirmations < 0) {
+ // Its block left the main chain. The node puts a disconnected
+ // block's txs back in its mempool when it can, so it may already
+ // be waiting again, or mined in the block that replaced it.
+ const block = await this.bitcoind.txBlock(row.txid);
+ if (block && block !== row.mined_block_hash) return this._markMined(row, block);
+ if (await this.bitcoind.mempoolEntry(row.txid)) {
+ return this._backToPending(row, 'orphaned');
+ }
+ return this._rebroadcast(row, 'orphaned');
+ }
+ if (header.confirmations >= this.cfg.confirmations) {
+ this.store.setStatus(row.txid, 'confirmed', {
+ fields: { confirmations: header.confirmations, confirmed_at: now() },
+ });
+ this.log.info(`confirmed ${row.txid} (${header.confirmations} deep)`);
+ } else {
+ this.store.touch(row.txid, { confirmations: header.confirmations });
+ }
+ }
+
+ _backToPending(row, why) {
+ this.store.setStatus(row.txid, 'pending', {
+ fields: { mined_block_hash: null, mined_height: null, mined_at: null,
+ mined_by_pool: null, confirmations: null },
+ detail: { after: why },
+ });
+ this.log.info(`${row.txid} back in the mempool after ${why}`);
+ }
+
+ async _rebroadcast(row, why) {
+ try {
+ await this.bitcoind.send(row.raw_hex);
+ } catch (e) {
+ if (!(e instanceof RpcError)) {
+ this.log.warn(`rebroadcasting ${row.txid} (${why}) failed: ${e.message}`);
+ return;
+ }
+ // -27: "Transaction already in block chain" -- mined, and the
+ // lookup above raced the block. The next poll finds its block.
+ if (e.code === -27) return;
+ this.store.setStatus(row.txid, 'dropped', {
+ reason: e.rpcMessage,
+ detail: { after: why },
+ });
+ this.log.info(`dropped ${row.txid}: the node refused it again after ${why} (${e.rpcMessage})`);
+ return;
+ }
+ this.store.touch(row.txid, { resubmissions: row.resubmissions + 1 });
+ this.store.event(row.txid, 'rebroadcast', { after: why });
+ this._backToPending(row, why);
+ }
+}
+
+const boolOrNull = (v) => (v === null || v === undefined ? null : v ? 1 : 0);
+
+/* A row as the API shows it: everything but the raw tx. */
+export function summary(row) {
+ if (!row) return null;
+ const { raw_hex: _raw, ...rest } = row;
+ return rest;
+}
diff --git a/slipstream/lib/store.js b/slipstream/lib/store.js
new file mode 100644
index 0000000..0c9e230
--- /dev/null
+++ b/slipstream/lib/store.js
@@ -0,0 +1,189 @@
+/* The slipstream service's own database.
+ *
+ * Three tables, each with one job:
+ *
+ * slipstream_submissions every POST, accepted or not, exactly as it
+ * arrived. The record of what was asked of the
+ * pool, kept even when the tx was refused.
+ * slipstream_txs one row per accepted tx, holding where it stands
+ * now: pending, in_template, mined, confirmed or
+ * dropped.
+ * slipstream_events every status change, append-only, so a row's
+ * history can be read back rather than inferred.
+ *
+ * Status meanings:
+ * pending in the node's mempool, not in the latest template
+ * in_template in the latest template, i.e. being mined on now
+ * mined in a block, fewer than the configured confirmations deep
+ * confirmed that deep
+ * dropped left the mempool unmined, and the node refused it when it
+ * was sent again: status_reason is the node's reason
+ *
+ * A tx is relayed once the node accepts it, so another pool may mine it:
+ * mined_by_pool says whose block it was.
+ */
+
+import Database from 'better-sqlite3';
+
+export const OPEN_STATUSES = ['pending', 'in_template'];
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS slipstream_submissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts INTEGER NOT NULL,
+ submitter TEXT,
+ txid TEXT,
+ raw_hex TEXT NOT NULL,
+ accepted INTEGER NOT NULL,
+ reject_reason TEXT
+);
+CREATE INDEX IF NOT EXISTS slipstream_submissions_ts_idx ON slipstream_submissions(ts);
+CREATE INDEX IF NOT EXISTS slipstream_submissions_txid_idx ON slipstream_submissions(txid);
+
+CREATE TABLE IF NOT EXISTS slipstream_txs (
+ txid TEXT PRIMARY KEY,
+ wtxid TEXT,
+ raw_hex TEXT NOT NULL,
+ vsize INTEGER NOT NULL,
+ weight INTEGER NOT NULL,
+ fee_sats INTEGER NOT NULL,
+ fee_rate REAL NOT NULL, -- sat/vB
+ required_fee_rate REAL NOT NULL, -- what was asked of it, sat/vB
+ submitted_at INTEGER NOT NULL,
+ submitted_height INTEGER, -- template height at submission
+ submitter TEXT,
+ status TEXT NOT NULL,
+ status_reason TEXT,
+ status_at INTEGER NOT NULL,
+ first_in_template_at INTEGER,
+ last_in_template_at INTEGER,
+ mined_block_hash TEXT,
+ mined_height INTEGER,
+ mined_at INTEGER,
+ mined_by_pool INTEGER, -- 1 ours, 0 not, NULL unknown
+ confirmations INTEGER,
+ confirmed_at INTEGER,
+ resubmissions INTEGER NOT NULL DEFAULT 0 -- rebroadcasts after leaving the mempool
+);
+CREATE INDEX IF NOT EXISTS slipstream_txs_status_idx ON slipstream_txs(status);
+CREATE INDEX IF NOT EXISTS slipstream_txs_submitted_idx ON slipstream_txs(submitted_at);
+
+CREATE TABLE IF NOT EXISTS slipstream_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ txid TEXT NOT NULL,
+ ts INTEGER NOT NULL,
+ event TEXT NOT NULL,
+ detail TEXT
+);
+CREATE INDEX IF NOT EXISTS slipstream_events_txid_idx ON slipstream_events(txid, id);
+`;
+
+const now = () => Math.floor(Date.now() / 1000);
+
+export function openStore(path) {
+ const db = new Database(path);
+ db.pragma('journal_mode = WAL');
+ db.pragma('synchronous = NORMAL');
+ db.pragma('busy_timeout = 5000');
+ db.exec(SCHEMA);
+ return new Store(db);
+}
+
+export class Store {
+ constructor(db) {
+ this.db = db;
+ this._event = db.prepare(
+ 'INSERT INTO slipstream_events (txid, ts, event, detail) VALUES (?, ?, ?, ?)');
+ }
+
+ logSubmission({ submitter, txid, rawHex, accepted, rejectReason }) {
+ this.db.prepare(`
+ INSERT INTO slipstream_submissions (ts, submitter, txid, raw_hex, accepted, reject_reason)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(now(), submitter ?? null, txid ?? null, rawHex, accepted ? 1 : 0, rejectReason ?? null);
+ }
+
+ event(txid, event, detail = null) {
+ this._event.run(txid, now(), event,
+ detail === null || typeof detail === 'string' ? detail : JSON.stringify(detail));
+ }
+
+ get(txid) {
+ return this.db.prepare('SELECT * FROM slipstream_txs WHERE txid = ?').get(txid) ?? null;
+ }
+
+ events(txid) {
+ return this.db.prepare(
+ 'SELECT ts, event, detail FROM slipstream_events WHERE txid = ? ORDER BY id').all(txid);
+ }
+
+ insertAccepted(row) {
+ const ts = now();
+ this.db.transaction(() => {
+ this.db.prepare(`
+ INSERT INTO slipstream_txs
+ (txid, wtxid, raw_hex, vsize, weight, fee_sats, fee_rate, required_fee_rate,
+ submitted_at, submitted_height, submitter, status, status_at)
+ VALUES (@txid, @wtxid, @raw_hex, @vsize, @weight, @fee_sats, @fee_rate,
+ @required_fee_rate, @ts, @submitted_height, @submitter, 'pending', @ts)
+ `).run({ ...row, ts });
+ this._event.run(row.txid, ts, 'accepted', JSON.stringify({
+ fee_sats: row.fee_sats, fee_rate: row.fee_rate,
+ required_fee_rate: row.required_fee_rate,
+ }));
+ })();
+ }
+
+ /* Move a row to `status`, recording the change as an event. `fields`
+ * are further columns to set alongside. A no-op when nothing changes, so
+ * a poll that finds a tx where it already was writes nothing. */
+ setStatus(txid, status, { reason = null, fields = {}, detail = null } = {}) {
+ const row = this.get(txid);
+ if (!row) return false;
+ const changed = row.status !== status || (row.status_reason ?? null) !== reason;
+ const extra = Object.entries(fields).filter(([k, v]) => row[k] !== v);
+ if (!changed && extra.length === 0) return false;
+ const ts = now();
+ this.db.transaction(() => {
+ const sets = ['status = @status', 'status_reason = @reason'];
+ if (changed) sets.push('status_at = @ts');
+ for (const [k] of extra) sets.push(`${k} = @${k}`);
+ this.db.prepare(`UPDATE slipstream_txs SET ${sets.join(', ')} WHERE txid = @txid`)
+ .run({ txid, status, reason, ts, ...Object.fromEntries(extra) });
+ if (changed) {
+ this._event.run(txid, ts, status,
+ detail === null ? reason : JSON.stringify({ reason, ...detail }));
+ }
+ })();
+ return true;
+ }
+
+ /* Columns that change with every poll and would drown the event log:
+ * set without an event. */
+ touch(txid, fields) {
+ const keys = Object.keys(fields);
+ if (keys.length === 0) return;
+ this.db.prepare(
+ `UPDATE slipstream_txs SET ${keys.map(k => `${k} = @${k}`).join(', ')} WHERE txid = @txid`,
+ ).run({ txid, ...fields });
+ }
+
+ byStatus(statuses) {
+ const marks = statuses.map(() => '?').join(', ');
+ return this.db.prepare(
+ `SELECT * FROM slipstream_txs WHERE status IN (${marks}) ORDER BY submitted_at`,
+ ).all(...statuses);
+ }
+
+ recent({ status = null, limit = 50 } = {}) {
+ const cols = `txid, wtxid, vsize, weight, fee_sats, fee_rate, required_fee_rate,
+ submitted_at, status, status_reason, status_at, first_in_template_at,
+ last_in_template_at, mined_block_hash, mined_height, mined_at,
+ mined_by_pool, confirmations, confirmed_at, resubmissions`;
+ return status
+ ? this.db.prepare(`SELECT ${cols} FROM slipstream_txs WHERE status = ?
+ ORDER BY submitted_at DESC LIMIT ?`).all(status, limit)
+ : this.db.prepare(`SELECT ${cols} FROM slipstream_txs
+ ORDER BY submitted_at DESC LIMIT ?`).all(limit);
+ }
+}
diff --git a/slipstream/package-lock.json b/slipstream/package-lock.json
new file mode 100644
index 0000000..b8991b3
--- /dev/null
+++ b/slipstream/package-lock.json
@@ -0,0 +1,467 @@
+{
+ "name": "simplepool-slipstream",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "simplepool-slipstream",
+ "version": "0.1.0",
+ "dependencies": {
+ "better-sqlite3": "^11.5.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/better-sqlite3": {
+ "version": "11.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/node-abi": {
+ "version": "3.96.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
+ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
diff --git a/slipstream/package.json b/slipstream/package.json
new file mode 100644
index 0000000..5c1ffe4
--- /dev/null
+++ b/slipstream/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "simplepool-slipstream",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "node index.js",
+ "dev": "node --watch index.js",
+ "test": "node --test test/*.test.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "dependencies": {
+ "better-sqlite3": "^11.5.0"
+ }
+}
diff --git a/slipstream/test/fees.test.js b/slipstream/test/fees.test.js
new file mode 100644
index 0000000..63bc437
--- /dev/null
+++ b/slipstream/test/fees.test.js
@@ -0,0 +1,28 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+import { feeSnapshot, mineableRate } from '../lib/fees.js';
+import { fullTemplate } from './helpers.js';
+
+test('a template with room is mineable at the floor', () => {
+ const template = fullTemplate({ feeRate: 50, count: 2 });
+ assert.equal(mineableRate(template, 1), 1);
+ assert.equal(mineableRate({ transactions: [] }, 1), 1);
+ assert.equal(mineableRate(null, 1), 1);
+});
+
+test('a full template is mineable at its cheapest tx', () => {
+ const template = fullTemplate({ feeRate: 3 });
+ template.transactions[4].fee = 5 * 100_000; // 5 sat/vB, dearer than the rest
+ assert.equal(mineableRate(template, 1), 3);
+});
+
+test('the required rate is the higher of floor and mineable', () => {
+ assert.equal(feeSnapshot(fullTemplate({ feeRate: 3 }), 1).required_rate, 3);
+ assert.equal(feeSnapshot(fullTemplate({ feeRate: 3 }), 10).required_rate, 10);
+ const snap = feeSnapshot(fullTemplate({ feeRate: 50, count: 1 }), 1);
+ assert.deepEqual(
+ [snap.min_submission_rate, snap.mineable_rate, snap.required_rate],
+ [1, 1, 1],
+ );
+});
diff --git a/slipstream/test/helpers.js b/slipstream/test/helpers.js
new file mode 100644
index 0000000..b2c7a40
--- /dev/null
+++ b/slipstream/test/helpers.js
@@ -0,0 +1,115 @@
+/* A fake bitcoind and enforcer, scripted per test, around a real in-memory
+ * store: the store is what the tests are about, so it is not faked. */
+
+import { openStore } from '../lib/store.js';
+import { Slipstream } from '../lib/slipstream.js';
+import { RpcError } from '../lib/rpc.js';
+
+export const TXID_A = 'a'.repeat(64);
+export const TXID_B = 'b'.repeat(64);
+export const BLOCK_1 = '1'.repeat(64);
+export const BLOCK_2 = '2'.repeat(64);
+export const TIP = 'f'.repeat(64);
+
+export const quietLog = { debug() {}, info() {}, warn() {}, error() {} };
+
+export function baseCfg(overrides = {}) {
+ return {
+ minFeeRate: 1,
+ confirmations: 6,
+ rateLimitPerMin: 30,
+ trustProxy: false,
+ presentation: {},
+ ...overrides,
+ };
+}
+
+/* A node with a mempool (txid -> entry) and a chain (txid -> block hash,
+ * block hash -> header). */
+export class FakeBitcoind {
+ constructor() {
+ this.mempool = new Map();
+ this.txBlocks = new Map();
+ this.headers = new Map();
+ this.sent = [];
+ this.nextTest = null;
+ this.sendError = null;
+ }
+
+ /* By default any tx checks out as TXID_A, 100 vB paying 200 sat. */
+ async testAccept(_hex) {
+ const next = this.nextTest ?? { txid: TXID_A, allowed: true, vsize: 100, fees: { base: 200e-8 } };
+ this.nextTest = null;
+ if (next instanceof RpcError) throw next;
+ this._lastTest = { wtxid: next.txid, ...next };
+ return this._lastTest;
+ }
+
+ async send(hex) {
+ this.sent.push(hex);
+ if (this.sendError) {
+ const e = this.sendError;
+ this.sendError = null;
+ throw e;
+ }
+ const { txid, vsize = 100, fees = { base: 200e-8 } } = this._lastTest ?? { txid: TXID_A };
+ this.mempool.set(txid, { vsize, weight: vsize * 4, fees });
+ return txid;
+ }
+
+ async mempoolEntry(txid) { return this.mempool.get(txid) ?? null; }
+ async txBlock(txid) { return this.txBlocks.get(txid) ?? null; }
+ async blockConfirmations(hash) { return this.headers.get(hash) ?? null; }
+
+ /* Move a tx from the mempool into `block`, `confirmations` deep. */
+ mine(txid, block, { height = 100, confirmations = 1 } = {}) {
+ this.mempool.delete(txid);
+ this.txBlocks.set(txid, block);
+ this.headers.set(block, { height, confirmations });
+ }
+}
+
+export class FakeEnforcer {
+ constructor() {
+ this.template = { height: 100, previousblockhash: TIP, transactions: [] };
+ }
+
+ async getBlockTemplate() {
+ if (this.down) throw new Error('connect ECONNREFUSED');
+ return this.template;
+ }
+}
+
+export function fakePool({ meta = null, ours = [] } = {}) {
+ return {
+ meta: () => meta,
+ foundBlock: (hash) => ours.includes(hash),
+ };
+}
+
+export function makeSlipstream({ cfg = {}, pool = fakePool() } = {}) {
+ const bitcoind = new FakeBitcoind();
+ const enforcer = new FakeEnforcer();
+ const slipstream = new Slipstream({
+ store: openStore(':memory:'),
+ bitcoind,
+ enforcer,
+ pool,
+ cfg: baseCfg(cfg),
+ log: quietLog,
+ });
+ return { slipstream, bitcoind, enforcer, store: slipstream.store };
+}
+
+/* A template carrying `count` txs of `weight` wu each, paying `feeRate`. */
+export function fullTemplate({ feeRate, weight = 400_000, count = 10 }) {
+ return {
+ height: 100,
+ previousblockhash: TIP,
+ transactions: Array.from({ length: count }, (_, i) => ({
+ txid: String(i).padStart(64, '0'),
+ weight,
+ fee: Math.round(feeRate * (weight / 4)),
+ })),
+ };
+}
diff --git a/slipstream/test/http.test.js b/slipstream/test/http.test.js
new file mode 100644
index 0000000..db9f65c
--- /dev/null
+++ b/slipstream/test/http.test.js
@@ -0,0 +1,87 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { createServer } from 'node:http';
+
+import { clientAddress, createHandler } from '../lib/http.js';
+import { TXID_A, fakePool, makeSlipstream, quietLog } from './helpers.js';
+
+const HEX = '0200000001' + '00'.repeat(60);
+
+async function serve(cfgOverrides = {}, meta = { pool_mode: 'solo', fee_bps: 0 }) {
+ const ctx = makeSlipstream({ cfg: cfgOverrides });
+ const pool = fakePool({ meta });
+ const server = createServer(createHandler({
+ slipstream: ctx.slipstream, pool, cfg: ctx.slipstream.cfg, version: '0.1.0', log: quietLog,
+ }));
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ return { ...ctx, base, close: () => new Promise(resolve => server.close(resolve)) };
+}
+
+test('info.json is served cross-origin', async () => {
+ const { base, close } = await serve({ presentation: { slipstreamUrl: 'https://s.example' } });
+ try {
+ const res = await fetch(`${base}/info.json`);
+ assert.equal(res.status, 200);
+ assert.equal(res.headers.get('access-control-allow-origin'), '*');
+ const info = await res.json();
+ assert.equal(info.mode, 'solo');
+ assert.equal(info.slipstream_url, 'https://s.example');
+ assert.equal(info.version, '0.1.0');
+ } finally {
+ await close();
+ }
+});
+
+test('submit as text or JSON, then read the tx back with its history', async () => {
+ const { base, close } = await serve();
+ try {
+ let res = await fetch(`${base}/api/tx`, { method: 'POST', body: HEX });
+ assert.equal(res.status, 200);
+ assert.equal((await res.json()).accepted, true);
+
+ res = await fetch(`${base}/tx`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ hex: HEX }),
+ });
+ assert.equal((await res.json()).already_tracked, true);
+
+ res = await fetch(`${base}/api/tx/${TXID_A}`);
+ const { tx, events } = await res.json();
+ assert.equal(tx.status, 'pending');
+ assert.equal(tx.raw_hex, undefined);
+ assert.deepEqual(events.map(e => e.event), ['accepted']);
+
+ res = await fetch(`${base}/api/txs?status=pending`);
+ assert.equal((await res.json()).txs.length, 1);
+ assert.equal((await fetch(`${base}/api/txs?status=bogus`)).status, 400);
+ assert.equal((await fetch(`${base}/api/tx/${'c'.repeat(64)}`)).status, 404);
+ } finally {
+ await close();
+ }
+});
+
+test('fees are reported, and submissions are rate limited per address', async () => {
+ const { base, close } = await serve({ rateLimitPerMin: 2 });
+ try {
+ const fees = await (await fetch(`${base}/api/fees`)).json();
+ assert.equal(fees.min_submission_rate, 1);
+ assert.equal(fees.required_rate, 1);
+ const statuses = [];
+ for (let i = 0; i < 3; i++) {
+ statuses.push((await fetch(`${base}/api/tx`, { method: 'POST', body: HEX })).status);
+ }
+ assert.deepEqual(statuses, [200, 200, 429]);
+ } finally {
+ await close();
+ }
+});
+
+test('behind a proxy, the client address is the hop the proxy added, not one the client wrote', () => {
+ const req = (fwd) => ({ headers: { 'x-forwarded-for': fwd }, socket: { remoteAddress: '127.0.0.1' } });
+ assert.equal(clientAddress(req('1.2.3.4, 203.0.113.9'), true), '203.0.113.9');
+ assert.equal(clientAddress(req('203.0.113.9'), true), '203.0.113.9');
+ assert.equal(clientAddress(req('1.2.3.4'), false), '127.0.0.1');
+ assert.equal(clientAddress({ headers: {}, socket: { remoteAddress: '::1' } }, true), '::1');
+});
diff --git a/slipstream/test/info.test.js b/slipstream/test/info.test.js
new file mode 100644
index 0000000..3021a46
--- /dev/null
+++ b/slipstream/test/info.test.js
@@ -0,0 +1,73 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+import { buildInfo, PAYOUT_TEXT } from '../lib/info.js';
+
+const META = {
+ pool_mode: 'pplns-coinbase',
+ fee_bps: 100,
+ network: 'main',
+ coinbase_tag: '/bip300xyz/',
+ operator_address: 'bc1qljvzxk0tp6qtrunt590z5rtdhs8jhkkn4ny4rm',
+ pool_btc_address: null,
+};
+
+const PRESENTATION = {
+ name: 'bip300.xyz',
+ operator: 'bip300.xyz',
+ chain: 'betanet',
+ logo: 'mining-pool-logos/bip300xyz.svg',
+ contact: null,
+ payoutText: null,
+ stratumUrl: 'stratum+tcp://stratum.beta.bip300.xyz:3334',
+ dashboardUrl: 'https://pool.beta.bip300.xyz/',
+ slipstreamUrl: 'https://slipstream.beta.bip300.xyz',
+};
+
+test('matches the shape pool directories expect', () => {
+ const info = buildInfo(META, PRESENTATION);
+ assert.deepEqual(info, {
+ name: 'bip300.xyz',
+ operator: 'bip300.xyz',
+ chain: 'betanet',
+ mode: 'pplns-coinbase',
+ fee_bps: 100,
+ coinbase_tag: '/bip300xyz/',
+ stratum_url: 'stratum+tcp://stratum.beta.bip300.xyz:3334',
+ dashboard_url: 'https://pool.beta.bip300.xyz/',
+ status_url: 'https://pool.beta.bip300.xyz/api/status',
+ slipstream_url: 'https://slipstream.beta.bip300.xyz',
+ operator_address: 'bc1qljvzxk0tp6qtrunt590z5rtdhs8jhkkn4ny4rm',
+ pool_btc_address: null,
+ payout: PAYOUT_TEXT['pplns-coinbase'],
+ software: 'simplepool',
+ version: null,
+ logo: 'mining-pool-logos/bip300xyz.svg',
+ contact: null,
+ });
+});
+
+test('facts come from pool_meta, the exact mode included', () => {
+ const info = buildInfo({ ...META, pool_mode: 'pplns-btc', fee_bps: 250 }, PRESENTATION);
+ assert.equal(info.mode, 'pplns-btc');
+ assert.equal(info.fee_bps, 250);
+ assert.equal(info.payout, PAYOUT_TEXT['pplns-btc']);
+});
+
+test('every mode has a payout sentence, and it can be overridden', () => {
+ for (const mode of ['solo', 'pps-classic', 'pplns-thunder', 'pplns-btc', 'pplns-coinbase']) {
+ assert.ok(buildInfo({ ...META, pool_mode: mode }, PRESENTATION).payout, mode);
+ }
+ const info = buildInfo(META, { ...PRESENTATION, payoutText: 'custom' });
+ assert.equal(info.payout, 'custom');
+});
+
+test('chain falls back to the network, and missing pool_meta reads as unknown', () => {
+ assert.equal(buildInfo(META, { ...PRESENTATION, chain: null }).chain, 'main');
+ const info = buildInfo(null, { ...PRESENTATION, chain: null, dashboardUrl: null });
+ assert.equal(info.mode, null);
+ assert.equal(info.fee_bps, null);
+ assert.equal(info.chain, null);
+ assert.equal(info.status_url, null);
+ assert.equal(info.payout, null);
+});
diff --git a/slipstream/test/submit.test.js b/slipstream/test/submit.test.js
new file mode 100644
index 0000000..517e674
--- /dev/null
+++ b/slipstream/test/submit.test.js
@@ -0,0 +1,83 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+import { RpcError } from '../lib/rpc.js';
+import { TXID_A, fullTemplate, makeSlipstream } from './helpers.js';
+
+const HEX = '0200000001' + '00'.repeat(60);
+
+const submissions = (store) =>
+ store.db.prepare('SELECT txid, accepted, reject_reason FROM slipstream_submissions ORDER BY id').all();
+
+test('an accepted tx is broadcast, tracked as pending, and the submission logged', async () => {
+ const { slipstream, bitcoind, store } = makeSlipstream();
+ const { httpStatus, body } = await slipstream.submit(HEX, '203.0.113.5');
+ assert.equal(httpStatus, 200);
+ assert.equal(body.accepted, true);
+ assert.equal(body.status, 'pending');
+ assert.equal(body.fee_sats, 200);
+ assert.equal(body.fee_rate, 2);
+ assert.equal(body.raw_hex, undefined, 'the raw tx is not echoed');
+ assert.deepEqual(bitcoind.sent, [HEX]);
+ const row = store.get(TXID_A);
+ assert.equal(row.raw_hex, HEX);
+ assert.equal(row.weight, 400);
+ assert.equal(row.submitter, '203.0.113.5');
+ assert.equal(row.submitted_height, 100);
+ assert.deepEqual(submissions(store), [{ txid: TXID_A, accepted: 1, reject_reason: null }]);
+ assert.deepEqual(store.events(TXID_A).map(e => e.event), ['accepted']);
+});
+
+test('under the mineable rate: refused before anything is broadcast', async () => {
+ const { slipstream, bitcoind, enforcer, store } = makeSlipstream();
+ enforcer.template = fullTemplate({ feeRate: 3 });
+ const { body } = await slipstream.submit(HEX, 'x');
+ assert.equal(body.accepted, false);
+ assert.equal(body.reject_reason, 'fee-rate-too-low');
+ assert.equal(body.required_fee_rate, 3);
+ assert.deepEqual(bitcoind.sent, [], 'nothing can be taken back once sent, so nothing is sent');
+ assert.equal(store.get(TXID_A), null);
+ assert.deepEqual(submissions(store), [{ txid: TXID_A, accepted: 0, reject_reason: 'fee-rate-too-low' }]);
+});
+
+test("the node's refusals are passed on, and logged", async () => {
+ const { slipstream, bitcoind, store } = makeSlipstream();
+
+ assert.equal((await slipstream.submit('zz', 'x')).httpStatus, 400);
+
+ bitcoind.nextTest = { txid: TXID_A, allowed: false, 'reject-reason': 'missing-inputs' };
+ const refused = await slipstream.submit(HEX, 'x');
+ assert.equal(refused.httpStatus, 200);
+ assert.equal(refused.body.reject_reason, 'missing-inputs');
+
+ bitcoind.nextTest = new RpcError('testmempoolaccept', -22, 'TX decode failed');
+ assert.equal((await slipstream.submit(HEX, 'x')).httpStatus, 400);
+
+ // Passed the check, lost a race before the broadcast
+ bitcoind.sendError = new RpcError('sendrawtransaction', -26, 'txn-mempool-conflict');
+ assert.equal((await slipstream.submit(HEX, 'x')).body.reject_reason, 'txn-mempool-conflict');
+
+ assert.deepEqual(submissions(store).map(s => s.reject_reason),
+ ['invalid-hex', 'missing-inputs', 'tx-decode-failed', 'txn-mempool-conflict']);
+ assert.equal(store.get(TXID_A), null);
+});
+
+test('a tx the node already has is taken on without sending it again', async () => {
+ const { slipstream, bitcoind, store } = makeSlipstream();
+ bitcoind.mempool.set(TXID_A, { vsize: 100, weight: 400, fees: { base: 500e-8 } });
+ bitcoind.nextTest = { txid: TXID_A, allowed: false, 'reject-reason': 'txn-already-in-mempool' };
+ const { body } = await slipstream.submit(HEX, 'x');
+ assert.equal(body.accepted, true);
+ assert.equal(body.fee_sats, 500);
+ assert.deepEqual(bitcoind.sent, []);
+ assert.equal(store.get(TXID_A).status, 'pending');
+});
+
+test('resubmitting a tracked tx returns where it stands', async () => {
+ const { slipstream } = makeSlipstream();
+ await slipstream.submit(HEX, 'x');
+ const { body } = await slipstream.submit(HEX, 'y');
+ assert.equal(body.accepted, true);
+ assert.equal(body.already_tracked, true);
+ assert.equal(body.submitter, 'x');
+});
diff --git a/slipstream/test/tracker.test.js b/slipstream/test/tracker.test.js
new file mode 100644
index 0000000..8f6f056
--- /dev/null
+++ b/slipstream/test/tracker.test.js
@@ -0,0 +1,117 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+import { RpcError } from '../lib/rpc.js';
+import { BLOCK_1, BLOCK_2, TXID_A, fakePool, makeSlipstream } from './helpers.js';
+
+const HEX = '0200000001' + '00'.repeat(60);
+
+async function submitted(opts = {}) {
+ const ctx = makeSlipstream(opts);
+ await ctx.slipstream.submit(HEX, 'x');
+ return ctx;
+}
+
+const eventsOf = (store) => store.events(TXID_A).map(e => e.event);
+
+test('in and out of the template, without an event per poll', async () => {
+ const { slipstream, enforcer, store } = await submitted();
+ enforcer.template.transactions = [{ txid: TXID_A, weight: 400, fee: 200 }];
+ await slipstream.tick();
+ await slipstream.tick();
+ const row = store.get(TXID_A);
+ assert.equal(row.status, 'in_template');
+ assert.ok(row.first_in_template_at !== null && row.last_in_template_at !== null);
+
+ enforcer.template.transactions = [];
+ await slipstream.tick();
+ assert.equal(store.get(TXID_A).status, 'pending');
+ assert.deepEqual(eventsOf(store), ['accepted', 'in_template', 'pending']);
+});
+
+test('mined by the pool, then confirmed', async () => {
+ const { slipstream, bitcoind, store } = await submitted({ pool: fakePool({ ours: [BLOCK_1] }) });
+ bitcoind.mine(TXID_A, BLOCK_1, { height: 100, confirmations: 1 });
+ await slipstream.tick();
+ let row = store.get(TXID_A);
+ assert.equal(row.status, 'mined');
+ assert.equal(row.mined_block_hash, BLOCK_1);
+ assert.equal(row.mined_height, 100);
+ assert.equal(row.mined_by_pool, 1);
+
+ bitcoind.headers.set(BLOCK_1, { height: 100, confirmations: 6 });
+ await slipstream.tick();
+ row = store.get(TXID_A);
+ assert.equal(row.status, 'confirmed');
+ assert.equal(row.confirmations, 6);
+ assert.deepEqual(eventsOf(store), ['accepted', 'mined', 'confirmed']);
+});
+
+test('mined by another pool: it was relayed, so that is recorded, not assumed away', async () => {
+ const { slipstream, bitcoind, store } = await submitted({ pool: fakePool({ ours: [] }) });
+ bitcoind.mine(TXID_A, BLOCK_1);
+ await slipstream.tick();
+ assert.equal(store.get(TXID_A).mined_by_pool, 0);
+});
+
+test('an orphaned block the node put the tx back from: pending again', async () => {
+ const { slipstream, bitcoind, store } = await submitted();
+ bitcoind.mine(TXID_A, BLOCK_1);
+ await slipstream.tick();
+
+ bitcoind.headers.set(BLOCK_1, { height: 100, confirmations: -1 });
+ bitcoind.txBlocks.delete(TXID_A);
+ bitcoind.mempool.set(TXID_A, { vsize: 100, weight: 400, fees: { base: 200e-8 } });
+ await slipstream.tick();
+ const row = store.get(TXID_A);
+ assert.equal(row.status, 'pending');
+ assert.equal(row.mined_block_hash, null);
+ assert.equal(row.resubmissions, 0, 'the node put it back, nothing was sent');
+});
+
+test('an orphaned tx mined again elsewhere follows its new block', async () => {
+ const { slipstream, bitcoind, store } = await submitted();
+ bitcoind.mine(TXID_A, BLOCK_1);
+ await slipstream.tick();
+
+ bitcoind.headers.set(BLOCK_1, { height: 100, confirmations: -1 });
+ bitcoind.mine(TXID_A, BLOCK_2);
+ await slipstream.tick();
+ assert.equal(store.get(TXID_A).status, 'mined');
+ assert.equal(store.get(TXID_A).mined_block_hash, BLOCK_2);
+});
+
+test('out of the mempool, not mined: sent again, and pending if the node takes it', async () => {
+ const { slipstream, bitcoind, store } = await submitted();
+ bitcoind.mempool.delete(TXID_A); // evicted, or a node restart without mempool.dat
+ await slipstream.tick();
+ assert.equal(store.get(TXID_A).status, 'pending');
+ assert.equal(store.get(TXID_A).resubmissions, 1);
+ assert.equal(bitcoind.sent.length, 2);
+ assert.ok(eventsOf(store).includes('rebroadcast'));
+});
+
+test("out of the mempool and refused again: dropped, with the node's reason", async () => {
+ const { slipstream, bitcoind, store } = await submitted();
+ bitcoind.mempool.delete(TXID_A); // replaced, or its input spent by a block
+ bitcoind.sendError = new RpcError('sendrawtransaction', -25, 'bad-txns-inputs-missingorspent');
+ await slipstream.tick();
+ assert.equal(store.get(TXID_A).status, 'dropped');
+ assert.equal(store.get(TXID_A).status_reason, 'bad-txns-inputs-missingorspent');
+});
+
+test('"already in block chain" on a rebroadcast waits for the block, rather than dropping', async () => {
+ const { slipstream, bitcoind, store } = await submitted();
+ bitcoind.mempool.delete(TXID_A);
+ bitcoind.sendError = new RpcError('sendrawtransaction', -27, 'Transaction already in block chain');
+ await slipstream.tick();
+ assert.equal(store.get(TXID_A).status, 'pending');
+});
+
+test('an unreachable enforcer fails the tick and changes nothing', async () => {
+ const { slipstream, enforcer, store } = await submitted();
+ enforcer.down = true;
+ await assert.rejects(slipstream.tick());
+ assert.equal(store.get(TXID_A).status, 'pending');
+ assert.ok(slipstream.enforcerError);
+});
diff --git a/tests/README.md b/tests/README.md
index caa5df5..6588acc 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -96,6 +96,16 @@ the version last validated against.
batched transaction through the enforcer's wallet, with a shared address
summed. Own `.regtest-btcpay/` dir.
+- `test_slipstream_regtest.sh` — the slipstream service against a real
+ node and the stock enforcer: a non-standard tx is accepted by a node
+ running `-acceptnonstdtxn`, reaches the enforcer's template through its
+ mempool mirror, and is followed through mined to confirmed; a tx under the
+ fee floor is refused before it is broadcast; a tx replaced in the node's
+ mempool is recorded as dropped with the node's reason. Own
+ `.regtest/slipstream-e2e/` dir.
+
+ bash tests/test_slipstream_regtest.sh
+
- `test_pplns_coinbase_regtest.sh` — `pool_mode=pplns-coinbase`, which has no
ledger step at all: the payment IS the block. Asserts the coinbase pays the
window on chain, that no output pays anything the pool controls beyond its
diff --git a/tests/test_slipstream_regtest.sh b/tests/test_slipstream_regtest.sh
new file mode 100755
index 0000000..c13706c
--- /dev/null
+++ b/tests/test_slipstream_regtest.sh
@@ -0,0 +1,260 @@
+#!/usr/bin/env bash
+# End-to-end proof of the slipstream service, against a real chain.
+#
+# bitcoind-patched (-acceptnonstdtxn) <-> bip300301_enforcer <-> (proxy)
+# ^
+# +-- slipstream/ (testmempoolaccept, sendrawtransaction)
+#
+# The enforcer is the stock release: it mirrors the node's mempool over ZMQ,
+# so whatever the node accepts reaches its templates with no enforcer change.
+#
+# What only a chain can prove, and what this asserts:
+#
+# 1. a non-standard tx (a dust output) is accepted through slipstream by a
+# node running -acceptnonstdtxn, and lands in the node's mempool.
+# 2. it reaches the template the enforcer serves -- the one the proxy
+# mines -- and the service sees it there (in_template).
+# 3. mining that template confirms it, and the service follows it through
+# mined to confirmed.
+# 4. the fee rule holds, and holds BEFORE broadcast: a tx under the floor
+# is refused and never reaches the node's mempool. Nothing can be taken
+# back out of a mempool, so refusing after sending would be too late.
+# 5. a tx replaced in the node's mempool (RBF) leaves it, is refused when
+# sent again, and is recorded as dropped with the node's reason.
+# 6. every submission is kept, refused ones included.
+# 7. info.json names the slipstream URL and the software.
+#
+# The proxy is not run: the service talks only to the node and the enforcer,
+# and the template is mined directly with generateblock, which accepts a
+# block only if the node finds the whole of it valid.
+#
+# Env:
+# REGTEST_DIR data dir, WIPED each run (default: /.regtest/slipstream-e2e)
+# REGTEST_BIN_DIR binary cache, kept across runs (default: /.regtest/bin)
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+ROOT="$(cd "$HERE/.." && pwd)"
+export REGTEST_DIR="${REGTEST_DIR:-$ROOT/.regtest/slipstream-e2e}"
+export REGTEST_BIN_DIR="${REGTEST_BIN_DIR:-$ROOT/.regtest/bin}"
+export REGTEST_SKIP_THUNDER=1
+export REGTEST_WALLETLESS=1
+
+SVC_LOG="$REGTEST_DIR/slipstream.log"
+SVC_DB="$REGTEST_DIR/slipstream.db"
+SVC_PID=""
+BIN="$REGTEST_BIN_DIR"
+
+cli() { "$BIN/bitcoin-cli" -datadir="$REGTEST_DIR/data/bitcoind" -regtest \
+ -rpcuser=user -rpcpassword=password "$@"; }
+wcli() { cli -rpcwallet=miner "$@"; }
+stage() { echo; echo "=== slipstream-e2e: $1"; }
+fail() { echo "FAIL: $*" >&2; exit 1; }
+api() { curl -sf "http://127.0.0.1:$SVC_PORT$1"; }
+post() { curl -s -X POST --data-binary "$1" "http://127.0.0.1:$SVC_PORT/api/tx"; }
+
+dump_logs() {
+ echo "!!! slipstream-e2e FAILED — recent logs:" >&2
+ for f in "$REGTEST_DIR"/logs/*.log "$SVC_LOG"; do
+ [ -f "$f" ] || continue
+ echo "--- tail $f" >&2
+ tail -40 "$f" >&2
+ done
+}
+
+cleanup() {
+ [ -n "$SVC_PID" ] && kill "$SVC_PID" 2>/dev/null || true
+ "$ROOT/scripts/regtest/stop.sh" || true
+ rm -rf "$LOCK"
+}
+
+mkdir -p "$(dirname "$REGTEST_DIR")"
+LOCK="$REGTEST_DIR.lock"
+if ! mkdir "$LOCK" 2>/dev/null; then
+ echo "FAIL: $LOCK exists — another run of this suite is active." >&2
+ echo " REGTEST_DIR=$REGTEST_DIR scripts/regtest/stop.sh && rm -rf $LOCK" >&2
+ exit 1
+fi
+trap 'code=$?; [ "$code" -ne 0 ] && dump_logs; cleanup; exit $code' EXIT
+trap 'exit 130' INT TERM
+
+for dep in sqlite3 jq node nc curl python3; do
+ command -v "$dep" >/dev/null 2>&1 || { echo "$dep not installed" >&2; exit 1; }
+done
+
+PICKED=""
+pick_port() {
+ local p
+ while :; do
+ p=$(( (RANDOM % 20000) + 20001 ))
+ [[ " $PICKED " == *" $p "* ]] && continue
+ nc -z 127.0.0.1 "$p" 2>/dev/null && continue
+ PICKED="$PICKED $p"
+ printf -v "$1" '%s' "$p"
+ return
+ done
+}
+
+stage "allocate stack ports"
+pick_port REGTEST_BITCOIND_RPC_PORT
+pick_port REGTEST_BITCOIND_ZMQ_PORT
+pick_port REGTEST_ENFORCER_RPC_PORT
+pick_port REGTEST_ENFORCER_GRPC_PORT
+pick_port SVC_PORT
+export REGTEST_BITCOIND_RPC_PORT REGTEST_BITCOIND_ZMQ_PORT \
+ REGTEST_ENFORCER_RPC_PORT REGTEST_ENFORCER_GRPC_PORT
+
+stage "wipe data dir (fresh chain every run)"
+rm -rf "$REGTEST_DIR/data" "$REGTEST_DIR/logs" "$REGTEST_DIR/run"
+rm -f "$SVC_DB" "$SVC_DB-wal" "$SVC_DB-shm" "$SVC_LOG"
+
+stage "download prebuilt binaries"
+"$ROOT/scripts/regtest/setup.sh" >/dev/null
+
+stage "start bitcoind-patched (-acceptnonstdtxn) + walletless enforcer"
+# The one node setting slipstream needs, for non-standard txs. Core allows it
+# only off mainnet; on regtest it is simply a config line.
+echo 'acceptnonstdtxn=1' >> "$REGTEST_DIR/data/bitcoind/bitcoin.conf"
+"$ROOT/scripts/regtest/start.sh" >/dev/null
+
+stage "mature coins for the node wallet"
+MINER_ADDR=$(wcli getnewaddress)
+cli generatetoaddress 110 "$MINER_ADDR" >/dev/null
+
+gbt() {
+ curl -s --data-binary \
+ '{"jsonrpc":"2.0","id":"t","method":"getblocktemplate","params":[{"rules":["segwit"],"capabilities":["coinbasetxn"]}]}' \
+ -H 'content-type: application/json' "http://127.0.0.1:$REGTEST_ENFORCER_RPC_PORT"
+}
+wait_template_on_tip() {
+ local tip
+ tip=$(cli getbestblockhash)
+ for _ in $(seq 1 60); do
+ [ "$(gbt | jq -r '.result.previousblockhash // empty')" = "$tip" ] && return
+ sleep 1
+ done
+ fail "the enforcer's template never reached tip $tip"
+}
+wait_template_on_tip
+
+stage "start the slipstream service"
+( cd "$ROOT/slipstream" && [ -d node_modules ] || npm ci --silent --no-audit --no-fund )
+(
+ cd "$ROOT/slipstream"
+ BITCOIND_RPC_URL="http://127.0.0.1:$REGTEST_BITCOIND_RPC_PORT" \
+ BITCOIND_RPC_USER=user BITCOIND_RPC_PASS=password \
+ ENFORCER_GBT_URL="http://127.0.0.1:$REGTEST_ENFORCER_RPC_PORT" \
+ SLIPSTREAM_DB_PATH="$SVC_DB" \
+ PROXY_DB_PATH="$REGTEST_DIR/no-proxy.db" \
+ SLIPSTREAM_PORT="$SVC_PORT" \
+ SLIPSTREAM_POLL_MS=500 \
+ SLIPSTREAM_CONFIRMATIONS=3 \
+ PUBLIC_SLIPSTREAM_URL="http://127.0.0.1:$SVC_PORT" \
+ exec node index.js
+) > "$SVC_LOG" 2>&1 &
+SVC_PID=$!
+for _ in $(seq 1 30); do api /healthz >/dev/null 2>&1 && break; sleep 1; done
+api /healthz >/dev/null || fail "the service never became healthy"
+
+# A spend of one wallet UTXO paying `fee_sats`, with `extra` as a second
+# output, signalling RBF. A 1-sat `extra` is dust: non-standard, so only a
+# node running -acceptnonstdtxn takes it.
+build_tx() {
+ local fee_sats="$1" extra_btc="$2"
+ local utxo txid vout amount pay dust raw
+ utxo=$(wcli listunspent 100 | jq -c '[.[] | select(.amount >= 1)][0]')
+ txid=$(jq -r .txid <<<"$utxo"); vout=$(jq -r .vout <<<"$utxo")
+ amount=$(jq -r .amount <<<"$utxo")
+ wcli lockunspent false "[{\"txid\":\"$txid\",\"vout\":$vout}]" >/dev/null
+ pay=$(wcli getnewaddress); dust=$(wcli getnewaddress)
+ local send
+ send=$(python3 -c "print(f'{$amount - $fee_sats/1e8 - $extra_btc:.8f}')")
+ raw=$(wcli createrawtransaction "[{\"txid\":\"$txid\",\"vout\":$vout}]" \
+ "[{\"$pay\":$send},{\"$dust\":$extra_btc}]" 0 true)
+ wcli signrawtransactionwithwallet "$raw" | jq -r .hex
+}
+
+stage "1. a non-standard tx is accepted, and broadcast to the node"
+# ~141 vB for a P2WPKH one-in, two-out: 1_000 sat is ~7 sat/vB
+TX_HEX=$(build_tx 1000 0.00000001)
+RESP=$(post "$TX_HEX")
+echo " $RESP"
+[ "$(jq -r .accepted <<<"$RESP")" = "true" ] || fail "slipstream refused the tx: $RESP"
+TXID=$(jq -r .txid <<<"$RESP")
+cli getrawmempool | jq -e --arg t "$TXID" 'index($t) != null' >/dev/null \
+ || fail "the accepted tx is not in the node's mempool"
+cli getmempoolentry "$TXID" >/dev/null
+
+stage "2. it reaches the template"
+for _ in $(seq 1 30); do
+ [ "$(api "/api/tx/$TXID" | jq -r .tx.status)" = "in_template" ] && break
+ sleep 1
+done
+[ "$(api "/api/tx/$TXID" | jq -r .tx.status)" = "in_template" ] \
+ || fail "never seen in the template: $(api "/api/tx/$TXID")"
+gbt | jq -e --arg t "$TXID" '[.result.transactions[].txid] | index($t) != null' >/dev/null \
+ || fail "not in the enforcer's template"
+
+stage "3. mining the template confirms it"
+TEMPLATE_TXS=$(gbt | jq -c '[.result.transactions[].data]')
+BLOCK=$(cli generateblock "$MINER_ADDR" "$TEMPLATE_TXS" | jq -r .hash)
+echo " mined $BLOCK"
+for _ in $(seq 1 30); do
+ [ "$(api "/api/tx/$TXID" | jq -r .tx.status)" = "mined" ] && break
+ sleep 1
+done
+TX_JSON=$(api "/api/tx/$TXID")
+[ "$(jq -r .tx.status <<<"$TX_JSON")" = "mined" ] || fail "never seen mined: $TX_JSON"
+[ "$(jq -r .tx.mined_block_hash <<<"$TX_JSON")" = "$BLOCK" ] || fail "wrong block: $TX_JSON"
+cli generatetoaddress 2 "$MINER_ADDR" >/dev/null
+for _ in $(seq 1 30); do
+ [ "$(api "/api/tx/$TXID" | jq -r .tx.status)" = "confirmed" ] && break
+ sleep 1
+done
+TX_JSON=$(api "/api/tx/$TXID")
+[ "$(jq -r .tx.status <<<"$TX_JSON")" = "confirmed" ] || fail "never confirmed: $TX_JSON"
+jq -r '.events[].event' <<<"$TX_JSON" | tr '\n' ' '; echo
+# A poll can land between the block connecting and the enforcer reporting
+# it, and see the tx out of the template but not yet mined: a `pending` in
+# between is correct, so it is not part of what is asserted.
+[ "$(jq -r '[.events[].event | select(. != "pending")] | join(",")' <<<"$TX_JSON")" \
+ = "accepted,in_template,mined,confirmed" ] || fail "unexpected history: $TX_JSON"
+
+stage "4. under the fee floor: refused before broadcast"
+wait_template_on_tip
+# 20 sat for ~141 vB is ~0.14 sat/vB: over the node's relay floor, under ours
+LOW_HEX=$(build_tx 20 0.00001000)
+RESP=$(post "$LOW_HEX")
+echo " $RESP"
+[ "$(jq -r .reject_reason <<<"$RESP")" = "fee-rate-too-low" ] || fail "low fee not refused: $RESP"
+LOW_TXID=$(jq -r .txid <<<"$RESP")
+cli getrawmempool | jq -e --arg t "$LOW_TXID" 'index($t) == null' >/dev/null \
+ || fail "the refused tx was broadcast anyway"
+
+stage "5. replaced in the node's mempool: dropped, with the node's reason"
+RBF_HEX=$(build_tx 2000 0.00001000)
+RESP=$(post "$RBF_HEX")
+[ "$(jq -r .accepted <<<"$RESP")" = "true" ] || fail "slipstream refused the tx: $RESP"
+RBF_TXID=$(jq -r .txid <<<"$RESP")
+wcli bumpfee "$RBF_TXID" >/dev/null
+for _ in $(seq 1 30); do
+ [ "$(api "/api/tx/$RBF_TXID" | jq -r .tx.status)" = "dropped" ] && break
+ sleep 1
+done
+RBF_JSON=$(api "/api/tx/$RBF_TXID")
+echo " $(jq -c '{status: .tx.status, reason: .tx.status_reason}' <<<"$RBF_JSON")"
+[ "$(jq -r .tx.status <<<"$RBF_JSON")" = "dropped" ] || fail "replacement not noticed: $RBF_JSON"
+
+stage "6. every submission is kept"
+SUBS=$(sqlite3 "$SVC_DB" "SELECT accepted || ':' || COALESCE(reject_reason, '') FROM slipstream_submissions ORDER BY id" | tr '\n' ' ')
+echo " $SUBS"
+[ "$SUBS" = "1: 0:fee-rate-too-low 1: " ] || fail "unexpected submission log: $SUBS"
+
+stage "7. info.json"
+INFO=$(api /info.json)
+[ "$(jq -r .software <<<"$INFO")" = "simplepool" ] || fail "info.json: $INFO"
+[ "$(jq -r .slipstream_url <<<"$INFO")" = "http://127.0.0.1:$SVC_PORT" ] || fail "info.json: $INFO"
+api /api/fees | jq -c .
+
+echo
+echo "=== slipstream-e2e: PASS"