A template for building a CUSF ("Core Untouched Soft Fork") enforcer: a daemon that runs beside an unmodified Bitcoin Core or eCash node and enforces additional consensus rules on its behalf.
The template ships the complete enforcer machinery with an empty rule set, so as built it accepts every block and every transaction. You add your rules at a handful of marked points, and the result is a deployable enforcer for your soft fork.
The node stays stock. The enforcer does four things around it:
- Follows the chain. It reads headers over REST, or JSON-RPC when REST is
off, blocks over JSON-RPC or straight from
blk*.dat, and new tips over ZMQsequence. Every block runs through your rules as it connects. A block that breaks them isinvalidateblocked on the node, which then follows the best valid chain. - Filters the mempool. It keeps its own mempool, admitting only transactions
your rules accept. A rejected transaction is deprioritised on the node with
prioritisetransactionand a fee delta of −21 million BTC, so the node's own templates never pick it up. - Serves block templates. Miners call its
getblocktemplateserver, which builds templates from the filtered mempool. Blocks mined that way follow your rules. - Survives reorgs. State your rules derive from blocks is recorded per block and undone when the block disconnects.
A miner that mines from the node directly can still produce blocks your rules reject. The enforcer invalidates those on every node that runs it.
Optional parts switch on with flags:
| Flag | Adds |
|---|---|
--enable-mempool |
the filtered mempool |
--enable-block-template-server |
getblocktemplate on --serve-rpc-addr (default 127.0.0.1:8122); needs the mempool |
--enable-wallet |
a BDK wallet with an encrypted seed, synced over Esplora or Electrum |
The gRPC API on --serve-grpc-addr (default 127.0.0.1:50051) serves
ValidatorService, WalletService, MiningService and CryptoService from
proto/cusf/, plus health and reflection.
The enforcer supports the eCash node ecash-v1.0.0-core31.1 from
releases.ecash.com. The setup recipe downloads a
release, verifies its build provenance and installs it under
.integration-deps/bitcoin-ecash-<channel>/:
just setup-ecash # eCash mainnet
just setup-ecash alphanet
just setup-ecash betanetbitcoind -server -rest -txindex \
-zmqpubsequence=tcp://127.0.0.1:29000 \
-rpcuser=user -rpcpassword=password- The node must not be pruned. The enforcer checks at startup.
-txindexis needed only with--enable-wallet.- Without
-restthe enforcer falls back to JSON-RPC for headers, which is slower. - The enforcer finds the ZMQ endpoint through
getzmqnotifications, or from--node-zmq-addr-sequence.
cusf_enforcer \
--node-rpc-addr=127.0.0.1:8332 \
--node-rpc-user=user --node-rpc-pass=password \
--enable-mempool --enable-block-template-server \
--coinbase-recipient=<address>--node-rpc-addr defaults to the regtest port, so set it on every other chain.
The enforcer works out which fork the node runs:
- eCash mainnet is detected from the node's user agent, which starts with
/eCash. - Alphanet and betanet report the same user agent as Bitcoin Core. Pass
--fork=ecash-alphanetor--fork=ecash-betanet. Without it the enforcer runs with Bitcoin's parameters, including no replay protection. - A
--forkthat contradicts the node's user agent is refused at startup.
Data lives under <data-dir>/<fork>/<chain>, for example ecash/mainnet, with
logs in its logs directory.
Each fork's parameters live in NetworkParams::new in
lib/types.rs:
| Fork | --fork |
Mainnet activation height | Mainnet magic | Replay nLockTime |
|---|---|---|---|---|
| Bitcoin | bitcoin |
0 | stock | none |
| eCash | ecash |
973,728 | eca5dcb7 |
499,999,998 |
| eCash alphanet | ecash-alphanet |
963,648 | eca5a104 |
499,999,999 |
| eCash betanet | ecash-betanet |
967,680 | eca5b104 |
499,999,999 |
- Activation height. Blocks below it are recorded but not run through your rules. Test chains activate at 0. If your soft fork activates later than the fork itself, change the height there.
- Magic. eCash test chains use
eca5 <fork byte> <chain byte>: fork bytea1on alphanet andb1on eCash and betanet, chain byte14testnet,24testnet4,34regtest. eCash signets hash anecashtag ahead of the challenge.
eCash nodes treat a transaction whose nLockTime is the fork's replay value as
final. Bitcoin treats the same value as a lock until a block height thousands of
years away, so it never relays or mines that transaction. Bitcoin ignores
nLockTime when every input's nSequence is 0xffffffff, so at least one input
must be non-final for the protection to hold.
eCash also changes OP_CHECKLOCKTIMEVERIFY: it fails, whatever its argument, in
a transaction carrying the replay value. That value is larger than any real
block height, so it would otherwise satisfy every height lock. A spend of a
CLTV-locked output therefore cannot carry replay protection through nLockTime.
What this means for your enforcer:
- Transactions it builds take
NetworkParams::spend_lock_time()as nLockTime andtypes::input_sequence()as each input's nSequence. The wallet's sends already do, inWallet::apply_replay_protection. - Script your rules execute must fail
OP_CHECKLOCKTIMEVERIFYwhenNetworkParams::cltv_rejects_lock_time()holds. This matters most for script the node itself never runs, where the enforcer is the only check.
On Bitcoin all three helpers are no-ops.
eCash nodes refuse template requests that do not acknowledge the rule
bip300301, unless started with -deprecatedrpc=getblocktemplate. They also
mark their templates !bip300301, which tells mining software that does not
know the rule to refuse them.
The enforcer acknowledges the rule in its own requests and strips !bip300301
from the templates it serves, so stock mining software accepts them. Bitcoin
Core ignores rules it does not know. The string is
rpc_client::ECASH_TEMPLATE_RULE.
This step is optional. The recipe renames the crates, binary, environment
variables, Docker image and CI artifacts. It leaves the cusf_enforcer_mempool
dependency, its cusf_enforcer module and the CusfEnforcer trait alone.
new=my_enforcer
dash="${new//_/-}"
upper="$(printf '%s' "$new" | tr '[:lower:]' '[:upper:]')"
git grep -lz -E 'cusf_enforcer|cusf-enforcer|CUSF_ENFORCER' -- . ':!Cargo.lock' ':!lib/proto/generated' |
xargs -0 perl -pi -e "
s/(?<![\w-])cusf_enforcer(?=_lib|_integration_tests)/$new/g;
s/code\(cusf_enforcer::/code($new::/g;
s/(?<![\w-])cusf_enforcer(?![\w-]|::)/$new/g;
s/CUSF_ENFORCER/$upper/g;
s/cusf-enforcer(?!-mempool)/$dash/g;
"
cargo update --workspace
just fmt
just verifyChange the GitHub organisation in the Docker image name in
.github/workflows/build_docker.yaml
by hand.
Put them in your own module tree under lib/validator/. Rules written as
functions of the block or transaction, the height, NetworkParams and your own
state are easy to call from every hook below.
Each hook is marked with a Fork point comment.
| Hook | File | Runs | To reject |
|---|---|---|---|
BlockHandler::connect_block_pass_through |
lib/validator/task/mod.rs |
every block, during initial sync and live | return a non-fatal error::ConnectBlock |
BlockHandler::validate_tx |
lib/validator/task/mod.rs |
every transaction offered to the mempool | return Ok(false) |
CusfEnforcer::validate_block |
lib/validator/enforcer.rs |
getblocktemplate proposals, read-only |
return Ok(Some(reason)) |
CusfBlockProducer::{initial,finalize}_block_template |
lib/block_producer/mod.rs |
building each served template | not a check: injects your own transactions |
- Gate on activation. Skip your rules while
height < self.params.activation_height. - Call your rules from
validate_blocktoo. As shipped it runs nothing, so a proposal would pass even when connecting the same block would fail. Validate against heighttip_height + 1, and fail closed on anything it cannot resolve locally. - Inject transactions the node will not relay by reserving their weight in
initial_block_templatethroughtemplate.suffix_txs, then appending them infinalize_block_template.
error::ConnectBlock in
lib/validator/task/error.rs marks every
variant with #[fatal(...)]:
#[fatal(false)]means the block is invalid. The enforcer has the nodeinvalidateblockit.#[fatal(true)]means the enforcer could not decide, for example on a database or I/O error. Sync stops, and the block is never invalidated.
#[error("transaction `{txid}` breaks my rule")]
#[fatal(false)]
MyRuleViolated { txid: bitcoin::Txid },Never mark a failure to fetch or read data as non-fatal. The node has already accepted the block, so the enforcer would invalidate a block its peers accept.
- Add your tables to
Dbsinlib/validator/dbs/mod.rs, and raiseNUM_DBSto match. - Record every change a block makes as fields of
diff::Blockinlib/validator/dbs/diff.rs.applywrites them on connect andundoreverses them on disconnect. - Raise
DB_VERSIONwhenever tables or encodings change. A datadir from another version refuses to open and has to be resynced, since all state is derived from the chain.
The hooks see the block or transaction and your own state. They do not see the
outputs being spent. If your rules need input amounts or scripts, either index
those outputs yourself as reorg-safe state, or fetch them from the node before
connecting the block. getblock <hash> 3 returns every input's prevout without
-txindex. A failed fetch is fatal, as in step 4.
Trials drive real bitcoind, electrs and enforcer processes.
- Write
pub async fn test_my_rule(post_setup: PostSetup) -> anyhow::Result<()>inintegration_tests/test_my_rule.rs, and declare the module inintegration_tests/lib.rs. - Register it in
tests()inintegration_tests/integration_test.rs. - Add its name to the
it-alllist in theJustfile.
The hidden --activation-height flag overrides the activation height, so a
trial can mine below and above it on a fresh chain.
Run every trial against both nodes before shipping:
just setup-core
just it-all
just setup-ecash
export ELECTRS=/path/to/electrs
CUSF_ENFORCER_INTEGRATION_TEST_ENV=integrationtests.ecash-mainnet.env just it-allRun one enforcer per soft fork against the same node. Each one invalidates blocks that break its own rules.
A template server filters only by its own enforcer's rules. Miners mining from one fork's server can include transactions that another fork rejects, and that fork's enforcer will then invalidate the block. To mine safely under several soft forks, run all their rule sets in one enforcer.
just build # debug build
just verify # fmt check, unit tests, clippy, integration-harness build
just test # fmt check, clippy, all unit tests through cargo-nextest
just setup-core # download stock Bitcoin Core; write integrationtests.env
just setup-ecash # download the eCash node; write integrationtests.ecash-mainnet.env
just it-all # every integration trial
just it <trial> # one integration trial by name
just generate # regenerate lib/proto/generated/ from proto/The tools: a Rust toolchain plus nightly rustfmt, cargo-nextest for
just test, buf for just generate, and an electrs binary for the wallet
trials, set through ELECTRS or integrationtests.env. See
integration_tests/README.md.
- Node: Bitcoin Core 29, 30 or 31, or eCash
ecash-v1.0.0-core31.1, which reports Core 31. The supported set lives inlib/version.rs, and--helpdocuments the override flags. - ZMQ: the
pubsequencenotifier enabled. - REST:
-reston, or the slower JSON-RPC fallback accepted. - Indexes:
-txindexwhen running with--enable-wallet. - Pruning: none. The enforcer syncs full blocks from genesis.
- Wallet sync: an Esplora or Electrum server for the same chain, on any chain other than regtest.